From 5ea0ef9ec031b9410c2f53048d576dc227c2c853 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Tue, 3 Mar 2026 19:52:21 +0200 Subject: [PATCH] feat: add uipath-core and platform --- .github/labeler.yml | 14 +- .github/scripts/detect_changed_packages.py | 162 + .github/workflows/cd.yml | 73 +- .github/workflows/ci.yml | 11 +- .github/workflows/integration_tests.yml | 6 +- .github/workflows/lint-packages.yml | 215 + .github/workflows/lint.yml | 39 - .github/workflows/publish-dev.yml | 161 +- .github/workflows/publish-docs.yml | 19 +- .github/workflows/slack.yml | 20 - .github/workflows/test-packages.yml | 199 + .github/workflows/test-uipath-langchain.yml | 82 +- .github/workflows/test-uipath-llamaindex.yml | 81 +- .github/workflows/test-uipath-runtime.yml | 76 + .github/workflows/test.yml | 38 - .../uipath-core/.python-version | 0 packages/uipath-core/CONTRIBUTING.md | 68 + packages/uipath-core/README.md | 103 + packages/uipath-core/pyproject.toml | 112 + .../uipath-core/src/uipath/core/__init__.py | 11 + .../src/uipath/core/chat/__init__.py | 192 + .../src/uipath/core/chat/async_stream.py | 60 + .../src/uipath/core/chat/citation.py | 86 + .../src/uipath/core/chat/content.py | 116 + .../uipath-core/src/uipath/core/chat/error.py | 34 + .../uipath-core/src/uipath/core/chat/event.py | 93 + .../src/uipath/core/chat/exchange.py | 83 + .../src/uipath/core/chat/interrupt.py | 112 + .../src/uipath/core/chat/message.py | 91 + .../uipath-core/src/uipath/core/chat/meta.py | 11 + .../src/uipath/core/chat/session.py | 57 + .../uipath-core/src/uipath/core/chat/tool.py | 76 + .../src/uipath/core/errors/__init__.py | 12 + .../src/uipath/core/errors/errors.py | 33 + .../src/uipath/core/events/__init__.py | 5 + .../src/uipath/core/events/_event_bus.py | 157 + .../src/uipath/core/feature_flags/__init__.py | 11 + .../core/feature_flags/feature_flags.py | 118 + .../src/uipath/core/guardrails/__init__.py | 48 + .../_deterministic_guardrails_service.py | 161 + .../src/uipath/core/guardrails/_evaluators.py | 424 ++ .../src/uipath/core/guardrails/guardrails.py | 241 ++ .../uipath-core/src/uipath/core/py.typed | 0 .../src/uipath/core/serialization/__init__.py | 5 + .../src/uipath/core/serialization/json.py | 196 + .../src/uipath/core/tracing/__init__.py | 17 + .../src/uipath/core/tracing/_utils.py | 142 + .../src/uipath/core/tracing/decorators.py | 350 ++ .../src/uipath/core/tracing/exporters.py | 45 + .../src/uipath/core/tracing/processors.py | 76 + .../src/uipath/core/tracing/span_utils.py | 334 ++ .../src/uipath/core/tracing/trace_manager.py | 108 + .../src/uipath/core/tracing/types.py | 21 + .../src/uipath/core/triggers/__init__.py | 15 + .../src/uipath/core/triggers/trigger.py | 72 + packages/uipath-core/tests/__init__.py | 4 + packages/uipath-core/tests/conftest.py | 50 + .../uipath-core/tests/errors}/__init__.py | 0 .../tests/errors/test_trigger_errors.py | 65 + .../tests/feature_flags}/__init__.py | 0 .../tests/feature_flags/test_feature_flags.py | 167 + .../test_deterministic_guardrails_service.py | 1468 +++++++ .../guardrails/test_guardrails_models.py | 53 + .../tests/serialization/test_json.py | 638 +++ .../tracing/test_external_integration.py | 89 + .../tests/tracing/test_serialization.py | 171 + .../tests/tracing/test_span_filtering.py | 248 ++ .../tests/tracing/test_span_nesting.py | 650 +++ .../tests/tracing/test_span_registry.py | 290 ++ .../tests/tracing/test_trace_manager.py | 33 + .../uipath-core/tests/tracing/test_traced.py | 1096 +++++ .../tests/tracing/test_tracing_utils.py | 90 + packages/uipath-core/uv.lock | 1135 +++++ packages/uipath-platform/.python-version | 1 + packages/uipath-platform/CONTRIBUTING.md | 115 + packages/uipath-platform/README.md | 3 + packages/uipath-platform/pyproject.toml | 117 + .../src/uipath/platform/__init__.py | 40 + .../src/uipath/platform/_uipath.py | 166 + .../uipath/platform/action_center/__init__.py | 14 + .../platform/action_center/_tasks_service.py | 688 ++++ .../platform/action_center/task_schema.py | 30 + .../uipath/platform/action_center/tasks.py | 106 + .../src/uipath/platform/agenthub/__init__.py | 8 + .../platform/agenthub/_agenthub_service.py | 205 + .../src/uipath/platform/agenthub/agenthub.py | 18 + .../uipath/platform/attachments/__init__.py | 12 + .../platform/attachments/attachments.py | 44 + .../platform/automation_tracker/__init__.py | 21 + .../_automation_tracker_service.py | 332 ++ .../automation_tracker/automation_tracker.py | 64 + .../src/uipath/platform/chat/__init__.py | 67 + .../platform/chat/_conversations_service.py | 52 + .../platform/chat/_llm_gateway_service.py | 689 ++++ .../src/uipath/platform/chat/llm_gateway.py | 128 + .../src/uipath/platform/chat/llm_throttle.py | 49 + .../src/uipath/platform/common/__init__.py | 104 + .../src/uipath/platform/common/_api_client.py | 59 + .../uipath/platform/common/_base_service.py | 204 + .../src/uipath/platform/common/_bindings.py | 268 ++ .../src/uipath/platform/common/_config.py | 142 + .../platform/common/_endpoints_manager.py | 202 + .../platform/common/_execution_context.py | 78 + .../common/_external_application_service.py | 140 + .../uipath/platform/common/_folder_context.py | 68 + .../src/uipath/platform/common/_models.py | 100 + .../platform/common/_service_url_overrides.py | 64 + .../src/uipath/platform/common/_span_utils.py | 401 ++ .../uipath/platform/common/_ssl_context.py | 53 + .../src/uipath/platform/common}/_url.py | 0 .../uipath/platform/common}/_user_agent.py | 0 .../src/uipath/platform/common/auth.py | 37 + .../src/uipath/platform/common/constants.py | 79 + .../uipath/platform/common/dynamic_schema.py | 128 + .../platform/common/interrupt_models.py | 237 ++ .../src/uipath/platform/common/paging.py | 63 + .../src/uipath/platform/common/retry.py | 93 + .../src/uipath/platform/common}/validation.py | 0 .../uipath/platform/connections/__init__.py | 26 + .../connections/_connections_service.py | 823 ++++ .../platform/connections/connections.py | 108 + .../platform/context_grounding/__init__.py | 69 + .../_context_grounding_service.py | 1890 +++++++++ .../context_grounding/context_grounding.py | 209 + .../context_grounding_index.py | 94 + .../context_grounding_payloads.py | 238 ++ .../src/uipath/platform/documents/__init__.py | 50 + .../platform/documents/_documents_service.py | 2556 ++++++++++++ .../uipath/platform/documents/documents.py | 298 ++ .../src/uipath/platform/entities/__init__.py | 36 + .../platform/entities/_entities_service.py | 904 ++++ .../src/uipath/platform/entities/entities.py | 325 ++ .../src/uipath/platform/errors/__init__.py | 37 + .../errors/_base_url_missing_error.py | 13 + ..._batch_transform_not_complete_exception.py | 13 + .../platform/errors/_enriched_exception.py | 38 + .../errors/_folder_not_found_exception.py | 13 + .../_ingestion_in_progress_exception.py | 17 + .../errors/_operation_failed_exception.py | 19 + .../_operation_not_complete_exception.py | 14 + .../platform/errors/_secret_missing_error.py | 13 + .../_unsupported_data_source_exception.py | 17 + .../uipath/platform/guardrails/__init__.py | 36 + .../guardrails/_guardrails_service.py | 135 + .../uipath/platform/guardrails/guardrails.py | 62 + .../uipath/platform/orchestrator/__init__.py | 54 + .../platform/orchestrator/_assets_service.py | 559 +++ .../orchestrator/_attachments_service.py | 1023 +++++ .../platform/orchestrator/_buckets_service.py | 1803 ++++++++ .../platform/orchestrator/_folder_service.py | 222 + .../platform/orchestrator/_jobs_service.py | 1490 +++++++ .../platform/orchestrator/_mcp_service.py | 230 ++ .../orchestrator/_processes_service.py | 337 ++ .../platform/orchestrator/_queues_service.py | 355 ++ .../uipath/platform/orchestrator/assets.py | 73 + .../platform/orchestrator/attachment.py | 36 + .../uipath/platform/orchestrator/buckets.py | 80 + .../uipath/platform/orchestrator/folder.py | 15 + .../src/uipath/platform/orchestrator/job.py | 83 + .../src/uipath/platform/orchestrator/mcp.py | 56 + .../uipath/platform/orchestrator/processes.py | 49 + .../uipath/platform/orchestrator/queues.py | 204 + .../src/uipath/platform}/py.typed | 0 .../platform/resource_catalog/__init__.py | 15 + .../_resource_catalog_service.py | 634 +++ .../resource_catalog/resource_catalog.py | 124 + .../platform/resume_triggers/__init__.py | 17 + .../uipath/platform/resume_triggers/_enums.py | 55 + .../platform/resume_triggers/_protocol.py | 917 +++++ .../tests/services/conftest.py | 70 + .../tests/services/test_actions_service.py | 179 + .../tests/services/test_api_client.py | 92 + .../tests/services/test_assets_service.py | 675 +++ .../services/test_attachments_service.py | 1195 ++++++ .../tests/services/test_base_service.py | 383 ++ .../tests/services/test_buckets_service.py | 1939 +++++++++ .../services/test_connections_service.py | 2071 ++++++++++ .../test_context_grounding_service.py | 2430 +++++++++++ .../services/test_conversations_service.py | 166 + .../tests/services/test_documents_service.py | 3644 +++++++++++++++++ .../tests/services/test_entities_service.py | 262 ++ .../test_external_application_service.py | 114 + .../tests/services/test_folder_service.py | 501 +++ .../tests/services/test_guardrails_service.py | 300 ++ .../tests/services/test_hitl.py | 1431 +++++++ .../tests/services/test_jobs_service.py | 1392 +++++++ .../test_jobs_service_bulk_operations.py | 213 + .../services/test_jobs_service_pagination.py | 221 + .../tests/services/test_llm_integration.py | 120 + .../tests/services/test_llm_schema_cleanup.py | 229 ++ .../tests/services/test_llm_service.py | 545 +++ .../tests/services/test_llm_throttle.py | 429 ++ .../tests/services/test_mcp_service.py | 541 +++ .../tests/services/test_processes_service.py | 473 +++ .../tests/services/test_queues_service.py | 808 ++++ .../services/test_resource_catalog_service.py | 861 ++++ .../tests/services/test_retry.py | 253 ++ .../services/test_service_url_overrides.py | 90 + .../tests/services/test_span_utils.py | 429 ++ .../services/test_uipath_llm_integration.py | 510 +++ .../classification_response.json | 23 + ..._validation_action_response_completed.json | 1277 ++++++ ...validation_action_response_unassigned.json | 29 + .../ixp_extraction_response.json | 1078 +++++ .../modern_extraction_response.json | 192 + packages/uipath-platform/uv.lock | 1193 ++++++ packages/uipath/.python-version | 1 + .../uipath/CONTRIBUTING.md | 0 README.md => packages/uipath/README.md | 0 .../uipath/docs}/AutomationSuite.md | 0 .../uipath/docs}/CONTRIBUTING.md | 0 {docs => packages/uipath/docs}/FAQ.md | 0 .../assets/env-preparation-failed-dark.png | Bin .../assets/env-preparation-failed-light.png | Bin .../uipath/docs}/assets/favicon.png | Bin .../uipath/docs}/assets/llms.txt | 0 .../uipath/docs}/assets/logo-dark.svg | 0 .../uipath/docs}/assets/logo-light.svg | 0 {docs => packages/uipath/docs}/cli/index.md | 0 {docs => packages/uipath/docs}/core/assets.md | 0 .../docs}/core/assets/cloud_env_var_dark.gif | Bin .../docs}/core/assets/cloud_env_var_light.gif | Bin .../core/assets/cloud_env_var_secret_dark.png | Bin .../assets/cloud_env_var_secret_light.png | Bin .../docs}/core/assets/copy_path_dark.png | Bin .../docs}/core/assets/copy_path_light.png | Bin .../uipath/docs}/core/attachments.md | 0 .../uipath/docs}/core/buckets.md | 0 .../uipath/docs}/core/connections.md | 0 .../uipath/docs}/core/context_grounding.md | 0 .../uipath/docs}/core/documents.md | 0 .../uipath/docs}/core/documents_models.md | 0 .../uipath/docs}/core/entities.md | 0 .../docs}/core/environment_variables.md | 0 .../uipath/docs}/core/getting_started.md | 0 .../uipath/docs}/core/guardrails.md | 0 {docs => packages/uipath/docs}/core/jobs.md | 0 .../uipath/docs}/core/llm_gateway.md | 0 .../uipath/docs}/core/processes.md | 0 {docs => packages/uipath/docs}/core/queues.md | 0 .../uipath/docs}/core/release_notes.md | 0 .../uipath/docs}/core/resource_catalog.md | 0 {docs => packages/uipath/docs}/core/tasks.md | 0 {docs => packages/uipath/docs}/core/traced.md | 0 .../uipath/docs}/eval/contains.md | 0 .../uipath/docs}/eval/custom_evaluators.md | 0 .../uipath/docs}/eval/exact_match.md | 0 {docs => packages/uipath/docs}/eval/index.md | 0 .../uipath/docs}/eval/json_similarity.md | 0 .../uipath/docs}/eval/llm_judge_output.md | 0 .../uipath/docs}/eval/llm_judge_trajectory.md | 0 .../uipath/docs}/eval/tool_call_args.md | 0 .../uipath/docs}/eval/tool_call_count.md | 0 .../uipath/docs}/eval/tool_call_order.md | 0 .../uipath/docs}/eval/tool_call_output.md | 0 {docs => packages/uipath/docs}/hooks.py | 0 {docs => packages/uipath/docs}/index.md | 0 .../uipath/docs}/javascripts/extra.js | 0 .../uipath/docs}/overrides/main.html | 0 .../docs}/overrides/partials/actions.html | 0 .../uipath/docs}/overrides/partials/logo.html | 0 .../uipath/docs}/release_policy.md | 0 .../google-ADK-agent/agent-output.png | Bin .../package-requirements.png | Bin .../uipath/docs}/stylesheets/extra.css | 0 justfile => packages/uipath/justfile | 0 mkdocs.yml => packages/uipath/mkdocs.yml | 0 .../__init__.py => packages/uipath/py.typed | 0 .../uipath/pyproject.toml | 4 + .../asset-modifier-agent/.env.example | 0 .../samples}/asset-modifier-agent/README.md | 0 .../asset-modifier-agent/bindings.json | 0 .../samples}/asset-modifier-agent/input.json | 0 .../samples}/asset-modifier-agent/main.py | 0 .../asset-modifier-agent/pyproject.toml | 0 .../samples}/asset-modifier-agent/uipath.json | 0 .../uipath/samples}/calculator/README.md | 0 .../uipath/samples}/calculator/bindings.json | 0 .../context-precision-multi-model.json | 0 .../eval-sets/crash-scenarios.json | 0 .../evaluations/eval-sets/default.json | 0 .../eval-level-expected-output-llm-judge.json | 0 .../eval-sets/eval-level-expected-output.json | 0 .../eval-sets/faithfulness-multi-model.json | 0 .../evaluations/eval-sets/legacy.json | 0 .../evaluations/eval-sets/multi-model.json | 0 .../eval-sets/trajectory-multi-model.json | 0 .../evaluations/evaluators/contains.json | 0 .../evaluators/context-precision-claude.json | 0 .../evaluators/context-precision-gemini.json | 0 .../correct-operator-evaluator.json | 0 .../evaluators/custom/correct_operator.py | 0 .../correct-operator-evaluator-types.json | 0 .../evaluations/evaluators/exact-match.json | 0 .../evaluators/faithfulness-claude.json | 0 .../evaluators/faithfulness-gemini.json | 0 .../evaluators/json-similarity.json | 0 .../legacy-equality-with-target-key.json | 0 .../evaluators/legacy-equality.json | 0 .../evaluators/legacy-json-similarity.json | 0 .../legacy-llm-as-a-judge-haiku-4.5.json | 0 .../legacy-llm-as-a-judge-sonnet-4.5.json | 0 .../evaluators/legacy-llm-as-a-judge.json | 0 .../evaluators/legacy-trajectory.json | 0 .../evaluators/llm-judge-claude.json | 0 .../evaluators/llm-judge-gemini.json | 0 .../evaluators/llm-judge-haiku-4.5.json | 0 .../llm-judge-semantic-similarity.json | 0 .../evaluators/llm-judge-sonnet-4.5.json | 0 .../llm-judge-strict-json-similarity.json | 0 .../evaluators/trajectory-claude.json | 0 .../evaluators/trajectory-gemini.json | 0 .../evaluations/evaluators/trajectory.json | 0 .../uipath/samples}/calculator/main.py | 0 .../uipath/samples}/calculator/output.json | 0 .../uipath/samples}/calculator/pyproject.toml | 0 .../uipath/samples}/calculator/uipath.json | 0 .../csv-processor/.claude/commands}/eval.md | 0 .../.claude/commands}/new-agent.md | 0 .../samples}/csv-processor/bindings.json | 0 .../eval-sets/file-input-tests-local.json | 0 .../attachment_created_evaluator.json | 0 .../evaluators/csv_columns_evaluator.json | 0 .../evaluators/csv_shape_evaluator.json | 0 .../custom/attachment_created_evaluator.py | 0 .../custom/csv_columns_evaluator.py | 0 .../evaluators/custom/csv_shape_evaluator.py | 0 .../uipath/samples}/csv-processor/main.py | 0 .../samples}/csv-processor/pyproject.toml | 0 .../csv-processor/test-data/large_dataset.csv | 0 .../csv-processor/test-data/minimal.csv | 0 .../csv-processor/test-data/sales_data.csv | 0 .../uipath/samples}/csv-processor/uipath.json | 0 .../document-understanding-agent/README.md | 0 .../bindings.json | 0 .../document-understanding-agent/main.py | 0 .../pyproject.toml | 0 .../src/du_modern.py | 0 .../document-understanding-agent/src/ixp.py | 0 .../src/pretrained.py | 0 .../document-understanding-agent/test.pdf | Bin .../document-understanding-agent/uipath.json | 0 .../samples}/event-trigger/.python-version | 0 .../uipath/samples}/event-trigger/README.md | 0 .../samples}/event-trigger/bindings.json | 0 .../uipath/samples}/event-trigger/main.py | 0 .../samples}/event-trigger/pyproject.toml | 0 .../uipath/samples}/event-trigger/uipath.json | 0 .../uipath/samples}/hello-world/README.md | 0 .../uipath/samples}/hello-world/bindings.json | 0 .../samples}/hello-world/entry-points.json | 0 .../uipath/samples}/hello-world/main.py | 0 .../samples}/hello-world/pyproject.toml | 0 .../uipath/samples}/hello-world/uipath.json | 0 .../samples}/list-mcp-agent/bindings.json | 0 .../uipath/samples}/list-mcp-agent/input.json | 0 .../uipath/samples}/list-mcp-agent/main.py | 0 .../samples}/list-mcp-agent/pyproject.toml | 0 .../samples}/list-mcp-agent/uipath.json | 0 .../uipath/samples}/llm_chat_agent/README.md | 0 .../uipath/samples}/llm_chat_agent/agent.py | 0 .../samples}/llm_chat_agent/bindings.json | 0 .../samples}/llm_chat_agent/pyproject.toml | 0 .../samples}/llm_chat_agent/uipath.json | 0 .../samples}/multi-output-agent/bindings.json | 0 .../eval-sets/target-output-key.json | 0 .../legacy-equality-array-index.json | 0 .../evaluators/legacy-equality-array-tag.json | 0 .../legacy-equality-deep-nested.json | 0 .../evaluators/legacy-equality-flat-key.json | 0 .../legacy-equality-full-output.json | 0 .../legacy-equality-nested-path.json | 0 .../legacy-json-similarity-customer.json | 0 .../legacy-json-similarity-nested.json | 0 .../samples}/multi-output-agent/main.py | 0 .../multi-output-agent/pyproject.toml | 0 .../samples}/multi-output-agent/uipath.json | 0 .../samples}/pizza-order-function/README.md | 0 .../pizza-order-function/bindings.json | 0 .../pizza-order-function/main.mermaid | 0 .../samples}/pizza-order-function/main.py | 0 .../pizza-order-function/pyproject.toml | 0 .../samples}/pizza-order-function/uipath.json | 0 .../samples}/queue-interactions/README.md | 0 .../samples}/queue-interactions/bindings.json | 0 .../samples}/queue-interactions/input.json | 0 .../samples}/queue-interactions/main.py | 0 .../queue-interactions/pyproject.toml | 0 .../samples}/queue-interactions/uipath.json | 0 .../.claude/commands/eval.md | 287 ++ .../.claude/commands/new-agent.md | 103 + .../resource-overrides/.vscode/launch.json | 15 + .../samples}/resource-overrides/README.md | 0 .../samples}/resource-overrides/bindings.json | 0 .../samples}/resource-overrides/main.py | 0 .../resource-overrides/pyproject.toml | 0 .../samples}/resource-overrides/uipath.json | 0 .../tree-recursive-function/README.md | 0 .../tree-recursive-function/bindings.json | 0 .../tree-recursive-function/main.mermaid | 0 .../samples}/tree-recursive-function/main.py | 0 .../tree-recursive-function/pyproject.toml | 0 .../tree-recursive-function/uipath.json | 0 .../uipath/samples}/weather_tools/README.md | 0 .../samples}/weather_tools/TOOL_EVALUATORS.md | 0 .../samples}/weather_tools/bindings.json | 0 .../evaluations/eval-sets/default.json | 0 .../evaluators/tool-call-args.json | 0 .../evaluators/tool-call-count.json | 0 .../evaluators/tool-call-order.json | 0 .../evaluators/tool-call-output.json | 0 .../evaluations/evaluators/trajectory.json | 0 .../uipath/samples}/weather_tools/main.py | 0 .../samples}/weather_tools/pyproject.toml | 0 .../uipath/samples}/weather_tools/uipath.json | 0 .../uipath/scripts}/debug_test.py | 0 .../uipath/scripts}/lint_httpx_client.py | 0 .../uipath/scripts}/update_agents_md.py | 0 {specs => packages/uipath/specs}/README.md | 0 .../uipath/specs}/bindings.schema.json | 0 .../uipath/specs}/bindings.spec.md | 0 .../uipath/specs}/entry-points.schema.json | 0 .../uipath/specs}/entry-points.spec.md | 0 .../uipath/specs}/uipath.schema.json | 0 .../uipath/specs}/uipath.spec.md | 0 .../uipath/src}/uipath/_cli/README.md | 0 .../uipath/src}/uipath/_cli/__init__.py | 0 .../src}/uipath/_cli/_auth/_auth_server.py | 0 .../src}/uipath/_cli/_auth/_auth_service.py | 0 .../uipath/src}/uipath/_cli/_auth/_models.py | 0 .../src}/uipath/_cli/_auth/_oidc_utils.py | 0 .../src}/uipath/_cli/_auth/_portal_service.py | 0 .../src}/uipath/_cli/_auth/_url_utils.py | 0 .../uipath/src}/uipath/_cli/_auth/_utils.py | 0 .../uipath/_cli/_auth/auth_config_25_10.json | 0 .../uipath/_cli/_auth/auth_config_cloud.json | 0 .../uipath/src}/uipath/_cli/_auth/index.html | 0 .../src}/uipath/_cli/_auth/localhost.crt | 0 .../src}/uipath/_cli/_auth/localhost.key | 0 .../uipath/src}/uipath/_cli/_chat/_bridge.py | 0 .../uipath/src}/uipath/_cli/_debug/_bridge.py | 0 .../src/uipath/_cli/_evals}/__init__.py | 0 .../_cli/_evals/_console_progress_reporter.py | 0 .../uipath/_cli/_evals/_progress_reporter.py | 0 .../src}/uipath/_cli/_evals/_telemetry.py | 0 .../uipath/src}/uipath/_cli/_push/models.py | 0 .../src}/uipath/_cli/_push/sw_file_handler.py | 0 .../uipath/src}/uipath/_cli/_telemetry.py | 0 .../uipath/_cli/_templates/.psmdcp.template | 0 .../uipath/_cli/_templates/.rels.template | 0 .../_templates/[Content_Types].xml.template | 0 .../_templates/custom_evaluator.py.template | 0 .../uipath/_cli/_templates/main.py.template | 0 .../_cli/_templates/package.nuspec.template | 0 .../uipath/src}/uipath/_cli/_utils/_common.py | 0 .../src}/uipath/_cli/_utils/_console.py | 0 .../src}/uipath/_cli/_utils/_constants.py | 0 .../src}/uipath/_cli/_utils/_context.py | 0 .../uipath/src}/uipath/_cli/_utils/_debug.py | 0 .../src}/uipath/_cli/_utils/_folders.py | 0 .../src}/uipath/_cli/_utils/_formatters.py | 0 .../src}/uipath/_cli/_utils/_help_json.py | 0 .../uipath/_cli/_utils/_parallelization.py | 0 .../src}/uipath/_cli/_utils/_processes.py | 0 .../src}/uipath/_cli/_utils/_project_files.py | 0 .../src}/uipath/_cli/_utils/_resources.py | 0 .../src}/uipath/_cli/_utils/_service_base.py | 0 .../_cli/_utils/_service_cli_generator.py | 0 .../uipath/_cli/_utils/_service_metadata.py | 0 .../uipath/_cli/_utils/_service_protocol.py | 0 .../uipath/_cli/_utils/_studio_project.py | 0 .../src}/uipath/_cli/_utils/_tracing.py | 0 .../src}/uipath/_cli/_utils/_type_registry.py | 0 .../src}/uipath/_cli/_utils/_uv_helpers.py | 0 .../src}/uipath/_cli/_utils/_validators.py | 0 .../uipath/src}/uipath/_cli/cli_add.py | 0 .../uipath/src}/uipath/_cli/cli_auth.py | 0 .../uipath/src}/uipath/_cli/cli_debug.py | 0 .../uipath/src}/uipath/_cli/cli_deploy.py | 0 .../uipath/src}/uipath/_cli/cli_dev.py | 0 .../uipath/src}/uipath/_cli/cli_eval.py | 0 .../uipath/src}/uipath/_cli/cli_init.py | 0 .../uipath/src}/uipath/_cli/cli_invoke.py | 0 .../uipath/src}/uipath/_cli/cli_new.py | 0 .../uipath/src}/uipath/_cli/cli_pack.py | 0 .../uipath/src}/uipath/_cli/cli_publish.py | 0 .../uipath/src}/uipath/_cli/cli_pull.py | 0 .../uipath/src}/uipath/_cli/cli_push.py | 0 .../uipath/src}/uipath/_cli/cli_register.py | 0 .../uipath/src}/uipath/_cli/cli_run.py | 0 .../uipath/src}/uipath/_cli/cli_server.py | 0 .../uipath/src}/uipath/_cli/middlewares.py | 0 .../src/uipath/_cli/models}/__init__.py | 0 .../src}/uipath/_cli/models/runtime_schema.py | 0 .../uipath/_cli/models/uipath_json_schema.py | 0 .../uipath/src}/uipath/_cli/runtimes.py | 0 .../src}/uipath/_cli/services/__init__.py | 0 .../uipath/_cli/services/_buckets_metadata.py | 0 .../src}/uipath/_cli/services/cli_assets.py | 0 .../src}/uipath/_cli/services/cli_buckets.py | 0 .../uipath/src}/uipath/_cli/spinner.py | 0 .../uipath/src}/uipath/_resources/AGENTS.md | 0 .../uipath/src}/uipath/_resources/CLAUDE.md | 0 .../src}/uipath/_resources/CLI_REFERENCE.md | 0 .../uipath/_resources/REQUIRED_STRUCTURE.md | 0 .../src}/uipath/_resources/SDK_REFERENCE.md | 0 packages/uipath/src/uipath/_resources/eval.md | 287 ++ .../uipath/src/uipath/_resources/new-agent.md | 103 + .../uipath/src}/uipath/_utils/__init__.py | 0 .../uipath/src}/uipath/_utils/_auth.py | 0 .../uipath/src}/uipath/_utils/_endpoint.py | 0 .../uipath/src}/uipath/_utils/_logs.py | 0 .../src}/uipath/_utils/_request_override.py | 0 .../src}/uipath/_utils/_request_spec.py | 0 .../uipath/src}/uipath/_utils/_ssl_context.py | 0 packages/uipath/src/uipath/_utils/_url.py | 102 + .../uipath/src/uipath/_utils/_user_agent.py | 19 + .../uipath/src}/uipath/_utils/constants.py | 0 .../uipath/src/uipath/_utils/validation.py | 43 + .../uipath/src}/uipath/agent/__init__.py | 0 .../src}/uipath/agent/models/_legacy.py | 0 .../uipath/src}/uipath/agent/models/agent.py | 0 .../uipath/src}/uipath/agent/models/evals.py | 0 .../src}/uipath/agent/react/__init__.py | 0 .../agent/react/conversational_prompts.py | 0 .../uipath/src}/uipath/agent/react/prompts.py | 0 .../uipath/src}/uipath/agent/react/tools.py | 0 .../src}/uipath/agent/utils/__init__.py | 0 .../uipath/src}/uipath/agent/utils/_utils.py | 0 .../src}/uipath/agent/utils/text_tokens.py | 0 .../src}/uipath/eval/_execution_context.py | 0 .../src}/uipath/eval/_helpers/__init__.py | 0 .../eval/_helpers/evaluators_helpers.py | 0 .../src}/uipath/eval/_helpers/helpers.py | 0 .../src}/uipath/eval/_helpers/output_path.py | 0 .../uipath/src}/uipath/eval/constants.py | 0 .../src}/uipath/eval/evaluators/__init__.py | 0 .../uipath/eval/evaluators/base_evaluator.py | 0 .../eval/evaluators/base_legacy_evaluator.py | 0 .../binary_classification_evaluator.py | 0 .../eval/evaluators/contains_evaluator.py | 0 .../src}/uipath/eval/evaluators/evaluator.py | 0 .../eval/evaluators/evaluator_factory.py | 0 .../eval/evaluators/exact_match_evaluator.py | 0 .../evaluators/json_similarity_evaluator.py | 0 .../legacy_context_precision_evaluator.py | 0 .../legacy_deterministic_evaluator_base.py | 0 .../eval/evaluators/legacy_evaluator_utils.py | 0 .../legacy_exact_match_evaluator.py | 0 .../legacy_faithfulness_evaluator.py | 0 .../legacy_json_similarity_evaluator.py | 0 .../legacy_llm_as_judge_evaluator.py | 0 .../eval/evaluators/legacy_llm_helpers.py | 0 .../evaluators/legacy_trajectory_evaluator.py | 0 .../eval/evaluators/llm_as_judge_evaluator.py | 0 .../evaluators/llm_judge_output_evaluator.py | 0 .../llm_judge_trajectory_evaluator.py | 0 .../multiclass_classification_evaluator.py | 0 .../eval/evaluators/output_evaluator.py | 0 .../uipath/eval/evaluators/registration.py | 0 .../evaluators/tool_call_args_evaluator.py | 0 .../evaluators/tool_call_count_evaluator.py | 0 .../evaluators/tool_call_order_evaluator.py | 0 .../evaluators/tool_call_output_evaluator.py | 0 .../evaluators_types/ContainsEvaluator.json | 0 .../evaluators_types/ExactMatchEvaluator.json | 0 .../JsonSimilarityEvaluator.json | 0 .../LLMJudgeOutputEvaluator.json | 0 ...geStrictJSONSimilarityOutputEvaluator.json | 0 .../LLMJudgeTrajectoryEvaluator.json | 0 ...LLMJudgeTrajectorySimulationEvaluator.json | 0 .../ToolCallArgsEvaluator.json | 0 .../ToolCallCountEvaluator.json | 0 .../ToolCallOrderEvaluator.json | 0 .../ToolCallOutputEvaluator.json | 0 .../eval/evaluators_types/generate_types.py | 0 .../uipath/src}/uipath/eval/helpers.py | 0 .../uipath/src}/uipath/eval/mocks/__init__.py | 0 .../src}/uipath/eval/mocks/_cache_manager.py | 0 .../src}/uipath/eval/mocks/_input_mocker.py | 0 .../src}/uipath/eval/mocks/_llm_mocker.py | 0 .../src}/uipath/eval/mocks/_mock_context.py | 0 .../src}/uipath/eval/mocks/_mock_runtime.py | 0 .../uipath/src}/uipath/eval/mocks/_mocker.py | 0 .../src}/uipath/eval/mocks/_mocker_factory.py | 0 .../src}/uipath/eval/mocks/_mockito_mocker.py | 0 .../uipath/src}/uipath/eval/mocks/_types.py | 0 .../uipath/src}/uipath/eval/mocks/mockable.py | 0 .../src}/uipath/eval/models/__init__.py | 0 .../eval/models/_conversational_utils.py | 0 .../src}/uipath/eval/models/evaluation_set.py | 0 .../uipath/eval/models/llm_judge_types.py | 0 .../uipath/src}/uipath/eval/models/models.py | 0 .../src}/uipath/eval/runtime/__init__.py | 0 .../src}/uipath/eval/runtime/_evaluate.py | 0 .../src}/uipath/eval/runtime/_exporters.py | 0 .../uipath/eval/runtime/_parallelization.py | 0 .../uipath/src}/uipath/eval/runtime/_spans.py | 0 .../uipath/src}/uipath/eval/runtime/_types.py | 0 .../uipath/src}/uipath/eval/runtime/_utils.py | 0 .../src}/uipath/eval/runtime/context.py | 0 .../uipath/src}/uipath/eval/runtime/events.py | 0 .../src}/uipath/eval/runtime/runtime.py | 0 .../uipath/src}/uipath/functions/__init__.py | 0 .../uipath/src}/uipath/functions/debug.py | 0 .../uipath/src}/uipath/functions/factory.py | 0 .../src}/uipath/functions/graph_builder.py | 0 .../uipath/src}/uipath/functions/runtime.py | 0 .../src}/uipath/functions/schema_gen.py | 0 .../src}/uipath/functions/type_conversion.py | 0 packages/uipath/src/uipath/py.typed | 0 .../uipath/src}/uipath/telemetry/__init__.py | 0 .../src}/uipath/telemetry/_constants.py | 0 .../uipath/src}/uipath/telemetry/_track.py | 0 .../uipath/src}/uipath/tracing/__init__.py | 0 .../tracing/_live_tracking_processor.py | 0 .../src}/uipath/tracing/_otel_exporters.py | 0 .../uipath/src}/uipath/tracing/_utils.py | 0 .../uipath/src}/uipath/utils/__init__.py | 0 .../testcases}/apicalls-testcase/main.py | 0 .../apicalls-testcase/pyproject.toml | 0 .../testcases}/apicalls-testcase/run.sh | 0 .../apicalls-testcase/src/assert.py | 0 .../testcases}/apicalls-testcase/uipath.json | 0 .../uipath/testcases}/basic-testcase/main.py | 0 .../testcases}/basic-testcase/pyproject.toml | 0 .../uipath/testcases}/basic-testcase/run.sh | 0 .../testcases}/basic-testcase/src/assert.py | 0 .../testcases}/basic-testcase/uipath.json | 0 .../calculator-crash-evals/pyproject.toml | 0 .../testcases}/calculator-crash-evals/run.sh | 0 .../calculator-crash-evals/src/assert.py | 0 .../calculator-crash-evals/uipath.json | 0 .../calculator-evals/pyproject.toml | 0 .../uipath/testcases}/calculator-evals/run.sh | 0 .../testcases}/calculator-evals/src/assert.py | 0 .../testcases}/calculator-evals/uipath.json | 0 .../uipath/testcases}/common/trace_assert.py | 0 .../testcases}/common/validate_output.sh | 0 .../csv-processor-evals/pyproject.toml | 0 .../testcases}/csv-processor-evals/run.sh | 0 .../csv-processor-evals/src/assert.py | 0 .../csv-processor-evals/uipath.json | 0 .../eval-input-overrides/entry-points.json | 0 .../eval-input-overrides/pyproject.toml | 0 .../testcases}/eval-input-overrides/run.sh | 0 .../eval-input-overrides/src/assert.py | 0 .../src/input-overrides-eval-set.json | 0 .../src/input-overrides.json | 0 .../eval-input-overrides/uipath.json | 0 .../eval-spans-testcase/pyproject.toml | 0 .../testcases}/eval-spans-testcase/run.sh | 0 .../eval-spans-testcase/src/assert.py | 0 .../eval-spans-testcase/uipath.json | 0 .../eval-telemetry-testcase/pyproject.toml | 0 .../testcases}/eval-telemetry-testcase/run.sh | 0 .../eval-telemetry-testcase/src/assert.py | 0 .../eval-telemetry-testcase/uipath.json | 0 .../testcases}/langchain-cross/agent.mermaid | 0 .../langchain-cross/expected_traces.json | 0 .../testcases}/langchain-cross/langgraph.json | 0 .../testcases}/langchain-cross/pyproject.toml | 0 .../uipath/testcases}/langchain-cross/run.sh | 0 .../testcases}/langchain-cross/src/assert.py | 0 .../langchain-cross/src/assert.py.orig | 0 .../testcases}/langchain-cross/src/main.py | 0 .../testcases}/llamaindexcross/agent.mermaid | 0 .../llamaindexcross/llama_index.json | 0 .../testcases}/llamaindexcross/pyproject.toml | 0 .../uipath/testcases}/llamaindexcross/run.sh | 0 .../testcases}/llamaindexcross/src/assert.py | 0 .../testcases}/llamaindexcross/src/main.py | 0 .../target-output-key-evals/pyproject.toml | 0 .../testcases}/target-output-key-evals/run.sh | 0 .../target-output-key-evals/src/assert.py | 0 .../target-output-key-evals/uipath.json | 0 .../testcases}/tools-evals/pyproject.toml | 0 .../uipath/testcases}/tools-evals/run.sh | 0 .../testcases}/tools-evals/src/assert.py | 0 .../uipath/testcases}/tools-evals/uipath.json | 0 {tests => packages/uipath/tests}/__init__.py | 0 .../uipath/tests}/agent/models/test_agent.py | 0 .../uipath/tests}/agent/models/test_evals.py | 0 .../uipath/tests}/agent/models/test_legacy.py | 0 packages/uipath/tests/agent/react/__init__.py | 0 .../react/test_conversational_prompts.py | 0 .../agent/utils/test_load_agent_definition.py | 0 .../tests}/agent/utils/test_text_tokens.py | 0 packages/uipath/tests/cli/chat/__init__.py | 0 .../uipath/tests}/cli/chat/test_bridge.py | 0 .../uipath/tests}/cli/conftest.py | 0 .../cli/contract/test_sdk_cli_alignment.py | 0 .../cli/eval/evals/eval-sets/default.json | 0 .../eval/evals/eval-sets/multiple-evals.json | 0 .../eval/evals/evaluators/exact-match.json | 0 .../cli/eval/mocks/test_cache_manager.py | 0 .../cli/eval/mocks/test_input_mocker.py | 0 .../mocks/test_mockable_mocked_annotation.py | 0 .../tests}/cli/eval/mocks/test_mocks.py | 0 .../cli/eval/test_apply_file_overrides.py | 0 .../cli/eval/test_conversational_utils.py | 0 .../tests}/cli/eval/test_eval_discovery.py | 0 .../tests}/cli/eval/test_eval_resume_flow.py | 0 .../cli/eval/test_eval_runtime_metadata.py | 0 .../cli/eval/test_eval_runtime_spans.py | 0 .../eval/test_eval_runtime_suspend_resume.py | 0 .../uipath/tests}/cli/eval/test_eval_set.py | 0 .../tests}/cli/eval/test_eval_span_utils.py | 0 .../tests}/cli/eval/test_eval_telemetry.py | 0 .../cli/eval/test_eval_tracing_integration.py | 0 .../uipath/tests}/cli/eval/test_eval_util.py | 0 .../uipath/tests}/cli/eval/test_evaluate.py | 0 .../cli/eval/test_input_overrides_e2e.py | 0 .../eval/test_live_tracking_span_processor.py | 0 .../tests}/cli/eval/test_progress_reporter.py | 0 .../tests}/cli/eval/test_span_persistence.py | 0 .../test_json_similarity_evaluator.py | 0 ...test_legacy_context_precision_evaluator.py | 0 .../test_legacy_exact_match_evaluator.py | 0 .../test_legacy_faithfulness_evaluator.py | 0 ...acy_llm_as_judge_placeholder_validation.py | 0 .../cli/integration/test_assets_commands.py | 0 .../cli/integration/test_buckets_commands.py | 0 .../uipath/tests}/cli/mocks/pyproject.toml | 0 .../uipath/tests}/cli/mocks/simple_script.py | 0 .../tests}/cli/mocks/uipath-mock-legacy.json | 0 .../cli/mocks/uipath-simple-script-mock.json | 0 .../tests}/cli/models/test_runtime_schema.py | 0 .../uipath/tests}/cli/test_auth.py | 0 .../uipath/tests}/cli/test_cli_telemetry.py | 0 .../tests}/cli/test_debug_simulation.py | 0 .../uipath/tests}/cli/test_init.py | 0 .../uipath/tests}/cli/test_init_agents_md.py | 0 .../uipath/tests}/cli/test_input_args.py | 0 .../uipath/tests}/cli/test_invoke.py | 0 .../uipath/tests}/cli/test_new.py | 0 .../uipath/tests}/cli/test_oidc_utils.py | 0 .../uipath/tests}/cli/test_pack.py | 0 .../test_portal_service_ensure_valid_token.py | 0 .../cli/test_portal_service_refresh_token.py | 0 .../uipath/tests}/cli/test_publish.py | 0 .../uipath/tests}/cli/test_pull.py | 0 .../uipath/tests}/cli/test_push.py | 0 .../uipath/tests}/cli/test_run.py | 0 .../uipath/tests}/cli/test_server.py | 0 .../uipath/tests}/cli/test_utils.py | 0 .../tests}/cli/unit/test_buckets_migration.py | 0 .../uipath/tests}/cli/unit/test_context.py | 0 .../uipath/tests}/cli/unit/test_formatters.py | 0 .../cli/unit/test_service_cli_generator.py | 0 .../tests}/cli/unit/test_service_metadata.py | 0 .../tests}/cli/unit/test_service_protocol.py | 0 .../tests}/cli/unit/test_type_registry.py | 0 .../uipath/tests}/cli/unit/test_validators.py | 0 .../uipath/tests}/cli/utils/common.py | 0 .../tests}/cli/utils/project_details.py | 0 {tests => packages/uipath/tests}/conftest.py | 0 .../uipath/tests}/evaluators/__init__.py | 0 .../evaluators/test_documentation_examples.py | 0 .../test_eval_level_expected_output.py | 0 .../evaluators/test_evaluator_aggregation.py | 0 .../evaluators/test_evaluator_factory.py | 0 .../evaluators/test_evaluator_helpers.py | 0 .../evaluators/test_evaluator_methods.py | 0 .../evaluators/test_evaluator_schemas.py | 0 .../uipath/tests}/evaluators/test_helpers.py | 0 .../test_legacy_target_output_key_paths.py | 0 .../test_llm_judge_placeholder_validation.py | 0 .../tests}/evaluators/test_output_path.py | 0 packages/uipath/tests/functions/__init__.py | 0 .../functions/test_debug_breakpoints.py | 0 .../tests}/functions/test_graph_builder.py | 0 .../tests}/functions/test_unwrap_decorated.py | 0 .../tests}/resource_overrides/__init__.py | 0 .../tests}/resource_overrides/overwrites.json | 0 .../test_resource_overrides.py | 0 .../uipath/tests}/sdk/test_bindings.py | 0 .../uipath/tests}/sdk/test_config.py | 0 .../uipath/tests}/sdk/test_uipath_caching.py | 0 .../tests}/sdk/test_utils_validation.py | 0 .../uipath/tests}/telemetry/__init__.py | 0 .../uipath/tests}/telemetry/test_track.py | 0 .../tests}/tracing/test_otel_exporters.py | 0 uv.lock => packages/uipath/uv.lock | 12 +- 784 files changed, 60414 insertions(+), 232 deletions(-) create mode 100644 .github/scripts/detect_changed_packages.py create mode 100644 .github/workflows/lint-packages.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/slack.yml create mode 100644 .github/workflows/test-packages.yml create mode 100644 .github/workflows/test-uipath-runtime.yml delete mode 100644 .github/workflows/test.yml rename .python-version => packages/uipath-core/.python-version (100%) create mode 100644 packages/uipath-core/CONTRIBUTING.md create mode 100644 packages/uipath-core/README.md create mode 100644 packages/uipath-core/pyproject.toml create mode 100644 packages/uipath-core/src/uipath/core/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/chat/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/chat/async_stream.py create mode 100644 packages/uipath-core/src/uipath/core/chat/citation.py create mode 100644 packages/uipath-core/src/uipath/core/chat/content.py create mode 100644 packages/uipath-core/src/uipath/core/chat/error.py create mode 100644 packages/uipath-core/src/uipath/core/chat/event.py create mode 100644 packages/uipath-core/src/uipath/core/chat/exchange.py create mode 100644 packages/uipath-core/src/uipath/core/chat/interrupt.py create mode 100644 packages/uipath-core/src/uipath/core/chat/message.py create mode 100644 packages/uipath-core/src/uipath/core/chat/meta.py create mode 100644 packages/uipath-core/src/uipath/core/chat/session.py create mode 100644 packages/uipath-core/src/uipath/core/chat/tool.py create mode 100644 packages/uipath-core/src/uipath/core/errors/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/errors/errors.py create mode 100644 packages/uipath-core/src/uipath/core/events/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/events/_event_bus.py create mode 100644 packages/uipath-core/src/uipath/core/feature_flags/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/feature_flags/feature_flags.py create mode 100644 packages/uipath-core/src/uipath/core/guardrails/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/guardrails/_deterministic_guardrails_service.py create mode 100644 packages/uipath-core/src/uipath/core/guardrails/_evaluators.py create mode 100644 packages/uipath-core/src/uipath/core/guardrails/guardrails.py rename py.typed => packages/uipath-core/src/uipath/core/py.typed (100%) create mode 100644 packages/uipath-core/src/uipath/core/serialization/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/serialization/json.py create mode 100644 packages/uipath-core/src/uipath/core/tracing/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/tracing/_utils.py create mode 100644 packages/uipath-core/src/uipath/core/tracing/decorators.py create mode 100644 packages/uipath-core/src/uipath/core/tracing/exporters.py create mode 100644 packages/uipath-core/src/uipath/core/tracing/processors.py create mode 100644 packages/uipath-core/src/uipath/core/tracing/span_utils.py create mode 100644 packages/uipath-core/src/uipath/core/tracing/trace_manager.py create mode 100644 packages/uipath-core/src/uipath/core/tracing/types.py create mode 100644 packages/uipath-core/src/uipath/core/triggers/__init__.py create mode 100644 packages/uipath-core/src/uipath/core/triggers/trigger.py create mode 100644 packages/uipath-core/tests/__init__.py create mode 100644 packages/uipath-core/tests/conftest.py rename {src/uipath/_cli/_evals => packages/uipath-core/tests/errors}/__init__.py (100%) create mode 100644 packages/uipath-core/tests/errors/test_trigger_errors.py rename {src/uipath/_cli/models => packages/uipath-core/tests/feature_flags}/__init__.py (100%) create mode 100644 packages/uipath-core/tests/feature_flags/test_feature_flags.py create mode 100644 packages/uipath-core/tests/guardrails/test_deterministic_guardrails_service.py create mode 100644 packages/uipath-core/tests/guardrails/test_guardrails_models.py create mode 100644 packages/uipath-core/tests/serialization/test_json.py create mode 100644 packages/uipath-core/tests/tracing/test_external_integration.py create mode 100644 packages/uipath-core/tests/tracing/test_serialization.py create mode 100644 packages/uipath-core/tests/tracing/test_span_filtering.py create mode 100644 packages/uipath-core/tests/tracing/test_span_nesting.py create mode 100644 packages/uipath-core/tests/tracing/test_span_registry.py create mode 100644 packages/uipath-core/tests/tracing/test_trace_manager.py create mode 100644 packages/uipath-core/tests/tracing/test_traced.py create mode 100644 packages/uipath-core/tests/tracing/test_tracing_utils.py create mode 100644 packages/uipath-core/uv.lock create mode 100644 packages/uipath-platform/.python-version create mode 100644 packages/uipath-platform/CONTRIBUTING.md create mode 100644 packages/uipath-platform/README.md create mode 100644 packages/uipath-platform/pyproject.toml create mode 100644 packages/uipath-platform/src/uipath/platform/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/_uipath.py create mode 100644 packages/uipath-platform/src/uipath/platform/action_center/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/action_center/task_schema.py create mode 100644 packages/uipath-platform/src/uipath/platform/action_center/tasks.py create mode 100644 packages/uipath-platform/src/uipath/platform/agenthub/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/agenthub/_agenthub_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/agenthub/agenthub.py create mode 100644 packages/uipath-platform/src/uipath/platform/attachments/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/attachments/attachments.py create mode 100644 packages/uipath-platform/src/uipath/platform/automation_tracker/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/automation_tracker/_automation_tracker_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/automation_tracker/automation_tracker.py create mode 100644 packages/uipath-platform/src/uipath/platform/chat/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/chat/_conversations_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/chat/llm_gateway.py create mode 100644 packages/uipath-platform/src/uipath/platform/chat/llm_throttle.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/_api_client.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/_base_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/_bindings.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/_config.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/_endpoints_manager.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/_execution_context.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/_external_application_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/_folder_context.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/_models.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/_service_url_overrides.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/_span_utils.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/_ssl_context.py rename {src/uipath/_utils => packages/uipath-platform/src/uipath/platform/common}/_url.py (100%) rename {src/uipath/_utils => packages/uipath-platform/src/uipath/platform/common}/_user_agent.py (100%) create mode 100644 packages/uipath-platform/src/uipath/platform/common/auth.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/constants.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/dynamic_schema.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/interrupt_models.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/paging.py create mode 100644 packages/uipath-platform/src/uipath/platform/common/retry.py rename {src/uipath/_utils => packages/uipath-platform/src/uipath/platform/common}/validation.py (100%) create mode 100644 packages/uipath-platform/src/uipath/platform/connections/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/connections/_connections_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/connections/connections.py create mode 100644 packages/uipath-platform/src/uipath/platform/context_grounding/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py create mode 100644 packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_index.py create mode 100644 packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py create mode 100644 packages/uipath-platform/src/uipath/platform/documents/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/documents/_documents_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/documents/documents.py create mode 100644 packages/uipath-platform/src/uipath/platform/entities/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/entities/_entities_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/entities/entities.py create mode 100644 packages/uipath-platform/src/uipath/platform/errors/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_base_url_missing_error.py create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_batch_transform_not_complete_exception.py create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_enriched_exception.py create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_folder_not_found_exception.py create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_ingestion_in_progress_exception.py create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_operation_failed_exception.py create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_operation_not_complete_exception.py create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_secret_missing_error.py create mode 100644 packages/uipath-platform/src/uipath/platform/errors/_unsupported_data_source_exception.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/_folder_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/assets.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/attachment.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/buckets.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/folder.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/job.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/processes.py create mode 100644 packages/uipath-platform/src/uipath/platform/orchestrator/queues.py rename {src/uipath => packages/uipath-platform/src/uipath/platform}/py.typed (100%) create mode 100644 packages/uipath-platform/src/uipath/platform/resource_catalog/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py create mode 100644 packages/uipath-platform/src/uipath/platform/resource_catalog/resource_catalog.py create mode 100644 packages/uipath-platform/src/uipath/platform/resume_triggers/__init__.py create mode 100644 packages/uipath-platform/src/uipath/platform/resume_triggers/_enums.py create mode 100644 packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py create mode 100644 packages/uipath-platform/tests/services/conftest.py create mode 100644 packages/uipath-platform/tests/services/test_actions_service.py create mode 100644 packages/uipath-platform/tests/services/test_api_client.py create mode 100644 packages/uipath-platform/tests/services/test_assets_service.py create mode 100644 packages/uipath-platform/tests/services/test_attachments_service.py create mode 100644 packages/uipath-platform/tests/services/test_base_service.py create mode 100644 packages/uipath-platform/tests/services/test_buckets_service.py create mode 100644 packages/uipath-platform/tests/services/test_connections_service.py create mode 100644 packages/uipath-platform/tests/services/test_context_grounding_service.py create mode 100644 packages/uipath-platform/tests/services/test_conversations_service.py create mode 100644 packages/uipath-platform/tests/services/test_documents_service.py create mode 100644 packages/uipath-platform/tests/services/test_entities_service.py create mode 100644 packages/uipath-platform/tests/services/test_external_application_service.py create mode 100644 packages/uipath-platform/tests/services/test_folder_service.py create mode 100644 packages/uipath-platform/tests/services/test_guardrails_service.py create mode 100644 packages/uipath-platform/tests/services/test_hitl.py create mode 100644 packages/uipath-platform/tests/services/test_jobs_service.py create mode 100644 packages/uipath-platform/tests/services/test_jobs_service_bulk_operations.py create mode 100644 packages/uipath-platform/tests/services/test_jobs_service_pagination.py create mode 100644 packages/uipath-platform/tests/services/test_llm_integration.py create mode 100644 packages/uipath-platform/tests/services/test_llm_schema_cleanup.py create mode 100644 packages/uipath-platform/tests/services/test_llm_service.py create mode 100644 packages/uipath-platform/tests/services/test_llm_throttle.py create mode 100644 packages/uipath-platform/tests/services/test_mcp_service.py create mode 100644 packages/uipath-platform/tests/services/test_processes_service.py create mode 100644 packages/uipath-platform/tests/services/test_queues_service.py create mode 100644 packages/uipath-platform/tests/services/test_resource_catalog_service.py create mode 100644 packages/uipath-platform/tests/services/test_retry.py create mode 100644 packages/uipath-platform/tests/services/test_service_url_overrides.py create mode 100644 packages/uipath-platform/tests/services/test_span_utils.py create mode 100644 packages/uipath-platform/tests/services/test_uipath_llm_integration.py create mode 100644 packages/uipath-platform/tests/services/tests_data/documents_service/classification_response.json create mode 100644 packages/uipath-platform/tests/services/tests_data/documents_service/extraction_validation_action_response_completed.json create mode 100644 packages/uipath-platform/tests/services/tests_data/documents_service/extraction_validation_action_response_unassigned.json create mode 100644 packages/uipath-platform/tests/services/tests_data/documents_service/ixp_extraction_response.json create mode 100644 packages/uipath-platform/tests/services/tests_data/documents_service/modern_extraction_response.json create mode 100644 packages/uipath-platform/uv.lock create mode 100644 packages/uipath/.python-version rename CONTRIBUTING.md => packages/uipath/CONTRIBUTING.md (100%) rename README.md => packages/uipath/README.md (100%) rename {docs => packages/uipath/docs}/AutomationSuite.md (100%) rename {docs => packages/uipath/docs}/CONTRIBUTING.md (100%) rename {docs => packages/uipath/docs}/FAQ.md (100%) rename {docs => packages/uipath/docs}/assets/env-preparation-failed-dark.png (100%) rename {docs => packages/uipath/docs}/assets/env-preparation-failed-light.png (100%) rename {docs => packages/uipath/docs}/assets/favicon.png (100%) rename {docs => packages/uipath/docs}/assets/llms.txt (100%) rename {docs => packages/uipath/docs}/assets/logo-dark.svg (100%) rename {docs => packages/uipath/docs}/assets/logo-light.svg (100%) rename {docs => packages/uipath/docs}/cli/index.md (100%) rename {docs => packages/uipath/docs}/core/assets.md (100%) rename {docs => packages/uipath/docs}/core/assets/cloud_env_var_dark.gif (100%) rename {docs => packages/uipath/docs}/core/assets/cloud_env_var_light.gif (100%) rename {docs => packages/uipath/docs}/core/assets/cloud_env_var_secret_dark.png (100%) rename {docs => packages/uipath/docs}/core/assets/cloud_env_var_secret_light.png (100%) rename {docs => packages/uipath/docs}/core/assets/copy_path_dark.png (100%) rename {docs => packages/uipath/docs}/core/assets/copy_path_light.png (100%) rename {docs => packages/uipath/docs}/core/attachments.md (100%) rename {docs => packages/uipath/docs}/core/buckets.md (100%) rename {docs => packages/uipath/docs}/core/connections.md (100%) rename {docs => packages/uipath/docs}/core/context_grounding.md (100%) rename {docs => packages/uipath/docs}/core/documents.md (100%) rename {docs => packages/uipath/docs}/core/documents_models.md (100%) rename {docs => packages/uipath/docs}/core/entities.md (100%) rename {docs => packages/uipath/docs}/core/environment_variables.md (100%) rename {docs => packages/uipath/docs}/core/getting_started.md (100%) rename {docs => packages/uipath/docs}/core/guardrails.md (100%) rename {docs => packages/uipath/docs}/core/jobs.md (100%) rename {docs => packages/uipath/docs}/core/llm_gateway.md (100%) rename {docs => packages/uipath/docs}/core/processes.md (100%) rename {docs => packages/uipath/docs}/core/queues.md (100%) rename {docs => packages/uipath/docs}/core/release_notes.md (100%) rename {docs => packages/uipath/docs}/core/resource_catalog.md (100%) rename {docs => packages/uipath/docs}/core/tasks.md (100%) rename {docs => packages/uipath/docs}/core/traced.md (100%) rename {docs => packages/uipath/docs}/eval/contains.md (100%) rename {docs => packages/uipath/docs}/eval/custom_evaluators.md (100%) rename {docs => packages/uipath/docs}/eval/exact_match.md (100%) rename {docs => packages/uipath/docs}/eval/index.md (100%) rename {docs => packages/uipath/docs}/eval/json_similarity.md (100%) rename {docs => packages/uipath/docs}/eval/llm_judge_output.md (100%) rename {docs => packages/uipath/docs}/eval/llm_judge_trajectory.md (100%) rename {docs => packages/uipath/docs}/eval/tool_call_args.md (100%) rename {docs => packages/uipath/docs}/eval/tool_call_count.md (100%) rename {docs => packages/uipath/docs}/eval/tool_call_order.md (100%) rename {docs => packages/uipath/docs}/eval/tool_call_output.md (100%) rename {docs => packages/uipath/docs}/hooks.py (100%) rename {docs => packages/uipath/docs}/index.md (100%) rename {docs => packages/uipath/docs}/javascripts/extra.js (100%) rename {docs => packages/uipath/docs}/overrides/main.html (100%) rename {docs => packages/uipath/docs}/overrides/partials/actions.html (100%) rename {docs => packages/uipath/docs}/overrides/partials/logo.html (100%) rename {docs => packages/uipath/docs}/release_policy.md (100%) rename {docs => packages/uipath/docs}/sample_images/google-ADK-agent/agent-output.png (100%) rename {docs => packages/uipath/docs}/sample_images/resource-overrides/package-requirements.png (100%) rename {docs => packages/uipath/docs}/stylesheets/extra.css (100%) rename justfile => packages/uipath/justfile (100%) rename mkdocs.yml => packages/uipath/mkdocs.yml (100%) rename tests/agent/react/__init__.py => packages/uipath/py.typed (100%) rename pyproject.toml => packages/uipath/pyproject.toml (96%) rename {samples => packages/uipath/samples}/asset-modifier-agent/.env.example (100%) rename {samples => packages/uipath/samples}/asset-modifier-agent/README.md (100%) rename {samples => packages/uipath/samples}/asset-modifier-agent/bindings.json (100%) rename {samples => packages/uipath/samples}/asset-modifier-agent/input.json (100%) rename {samples => packages/uipath/samples}/asset-modifier-agent/main.py (100%) rename {samples => packages/uipath/samples}/asset-modifier-agent/pyproject.toml (100%) rename {samples => packages/uipath/samples}/asset-modifier-agent/uipath.json (100%) rename {samples => packages/uipath/samples}/calculator/README.md (100%) rename {samples => packages/uipath/samples}/calculator/bindings.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/eval-sets/context-precision-multi-model.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/eval-sets/crash-scenarios.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/eval-sets/default.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/eval-sets/eval-level-expected-output-llm-judge.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/eval-sets/eval-level-expected-output.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/eval-sets/faithfulness-multi-model.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/eval-sets/legacy.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/eval-sets/multi-model.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/eval-sets/trajectory-multi-model.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/contains.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/context-precision-claude.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/context-precision-gemini.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/correct-operator-evaluator.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/custom/correct_operator.py (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/custom/types/correct-operator-evaluator-types.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/exact-match.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/faithfulness-claude.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/faithfulness-gemini.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/json-similarity.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/legacy-equality-with-target-key.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/legacy-equality.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/legacy-json-similarity.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/legacy-llm-as-a-judge-haiku-4.5.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/legacy-llm-as-a-judge-sonnet-4.5.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/legacy-llm-as-a-judge.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/legacy-trajectory.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/llm-judge-claude.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/llm-judge-gemini.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/llm-judge-haiku-4.5.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/llm-judge-semantic-similarity.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/llm-judge-sonnet-4.5.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/llm-judge-strict-json-similarity.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/trajectory-claude.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/trajectory-gemini.json (100%) rename {samples => packages/uipath/samples}/calculator/evaluations/evaluators/trajectory.json (100%) rename {samples => packages/uipath/samples}/calculator/main.py (100%) rename {samples => packages/uipath/samples}/calculator/output.json (100%) rename {samples => packages/uipath/samples}/calculator/pyproject.toml (100%) rename {samples => packages/uipath/samples}/calculator/uipath.json (100%) rename {src/uipath/_resources => packages/uipath/samples/csv-processor/.claude/commands}/eval.md (100%) rename {src/uipath/_resources => packages/uipath/samples/csv-processor/.claude/commands}/new-agent.md (100%) rename {samples => packages/uipath/samples}/csv-processor/bindings.json (100%) rename {samples => packages/uipath/samples}/csv-processor/evaluations/eval-sets/file-input-tests-local.json (100%) rename {samples => packages/uipath/samples}/csv-processor/evaluations/evaluators/attachment_created_evaluator.json (100%) rename {samples => packages/uipath/samples}/csv-processor/evaluations/evaluators/csv_columns_evaluator.json (100%) rename {samples => packages/uipath/samples}/csv-processor/evaluations/evaluators/csv_shape_evaluator.json (100%) rename {samples => packages/uipath/samples}/csv-processor/evaluations/evaluators/custom/attachment_created_evaluator.py (100%) rename {samples => packages/uipath/samples}/csv-processor/evaluations/evaluators/custom/csv_columns_evaluator.py (100%) rename {samples => packages/uipath/samples}/csv-processor/evaluations/evaluators/custom/csv_shape_evaluator.py (100%) rename {samples => packages/uipath/samples}/csv-processor/main.py (100%) rename {samples => packages/uipath/samples}/csv-processor/pyproject.toml (100%) rename {samples => packages/uipath/samples}/csv-processor/test-data/large_dataset.csv (100%) rename {samples => packages/uipath/samples}/csv-processor/test-data/minimal.csv (100%) rename {samples => packages/uipath/samples}/csv-processor/test-data/sales_data.csv (100%) rename {samples => packages/uipath/samples}/csv-processor/uipath.json (100%) rename {samples => packages/uipath/samples}/document-understanding-agent/README.md (100%) rename {samples => packages/uipath/samples}/document-understanding-agent/bindings.json (100%) rename {samples => packages/uipath/samples}/document-understanding-agent/main.py (100%) rename {samples => packages/uipath/samples}/document-understanding-agent/pyproject.toml (100%) rename {samples => packages/uipath/samples}/document-understanding-agent/src/du_modern.py (100%) rename {samples => packages/uipath/samples}/document-understanding-agent/src/ixp.py (100%) rename {samples => packages/uipath/samples}/document-understanding-agent/src/pretrained.py (100%) rename {samples => packages/uipath/samples}/document-understanding-agent/test.pdf (100%) rename {samples => packages/uipath/samples}/document-understanding-agent/uipath.json (100%) rename {samples => packages/uipath/samples}/event-trigger/.python-version (100%) rename {samples => packages/uipath/samples}/event-trigger/README.md (100%) rename {samples => packages/uipath/samples}/event-trigger/bindings.json (100%) rename {samples => packages/uipath/samples}/event-trigger/main.py (100%) rename {samples => packages/uipath/samples}/event-trigger/pyproject.toml (100%) rename {samples => packages/uipath/samples}/event-trigger/uipath.json (100%) rename {samples => packages/uipath/samples}/hello-world/README.md (100%) rename {samples => packages/uipath/samples}/hello-world/bindings.json (100%) rename {samples => packages/uipath/samples}/hello-world/entry-points.json (100%) rename {samples => packages/uipath/samples}/hello-world/main.py (100%) rename {samples => packages/uipath/samples}/hello-world/pyproject.toml (100%) rename {samples => packages/uipath/samples}/hello-world/uipath.json (100%) rename {samples => packages/uipath/samples}/list-mcp-agent/bindings.json (100%) rename {samples => packages/uipath/samples}/list-mcp-agent/input.json (100%) rename {samples => packages/uipath/samples}/list-mcp-agent/main.py (100%) rename {samples => packages/uipath/samples}/list-mcp-agent/pyproject.toml (100%) rename {samples => packages/uipath/samples}/list-mcp-agent/uipath.json (100%) rename {samples => packages/uipath/samples}/llm_chat_agent/README.md (100%) rename {samples => packages/uipath/samples}/llm_chat_agent/agent.py (100%) rename {samples => packages/uipath/samples}/llm_chat_agent/bindings.json (100%) rename {samples => packages/uipath/samples}/llm_chat_agent/pyproject.toml (100%) rename {samples => packages/uipath/samples}/llm_chat_agent/uipath.json (100%) rename {samples => packages/uipath/samples}/multi-output-agent/bindings.json (100%) rename {samples => packages/uipath/samples}/multi-output-agent/evaluations/eval-sets/target-output-key.json (100%) rename {samples => packages/uipath/samples}/multi-output-agent/evaluations/evaluators/legacy-equality-array-index.json (100%) rename {samples => packages/uipath/samples}/multi-output-agent/evaluations/evaluators/legacy-equality-array-tag.json (100%) rename {samples => packages/uipath/samples}/multi-output-agent/evaluations/evaluators/legacy-equality-deep-nested.json (100%) rename {samples => packages/uipath/samples}/multi-output-agent/evaluations/evaluators/legacy-equality-flat-key.json (100%) rename {samples => packages/uipath/samples}/multi-output-agent/evaluations/evaluators/legacy-equality-full-output.json (100%) rename {samples => packages/uipath/samples}/multi-output-agent/evaluations/evaluators/legacy-equality-nested-path.json (100%) rename {samples => packages/uipath/samples}/multi-output-agent/evaluations/evaluators/legacy-json-similarity-customer.json (100%) rename {samples => packages/uipath/samples}/multi-output-agent/evaluations/evaluators/legacy-json-similarity-nested.json (100%) rename {samples => packages/uipath/samples}/multi-output-agent/main.py (100%) rename {samples => packages/uipath/samples}/multi-output-agent/pyproject.toml (100%) rename {samples => packages/uipath/samples}/multi-output-agent/uipath.json (100%) rename {samples => packages/uipath/samples}/pizza-order-function/README.md (100%) rename {samples => packages/uipath/samples}/pizza-order-function/bindings.json (100%) rename {samples => packages/uipath/samples}/pizza-order-function/main.mermaid (100%) rename {samples => packages/uipath/samples}/pizza-order-function/main.py (100%) rename {samples => packages/uipath/samples}/pizza-order-function/pyproject.toml (100%) rename {samples => packages/uipath/samples}/pizza-order-function/uipath.json (100%) rename {samples => packages/uipath/samples}/queue-interactions/README.md (100%) rename {samples => packages/uipath/samples}/queue-interactions/bindings.json (100%) rename {samples => packages/uipath/samples}/queue-interactions/input.json (100%) rename {samples => packages/uipath/samples}/queue-interactions/main.py (100%) rename {samples => packages/uipath/samples}/queue-interactions/pyproject.toml (100%) rename {samples => packages/uipath/samples}/queue-interactions/uipath.json (100%) create mode 100644 packages/uipath/samples/resource-overrides/.claude/commands/eval.md create mode 100644 packages/uipath/samples/resource-overrides/.claude/commands/new-agent.md create mode 100644 packages/uipath/samples/resource-overrides/.vscode/launch.json rename {samples => packages/uipath/samples}/resource-overrides/README.md (100%) rename {samples => packages/uipath/samples}/resource-overrides/bindings.json (100%) rename {samples => packages/uipath/samples}/resource-overrides/main.py (100%) rename {samples => packages/uipath/samples}/resource-overrides/pyproject.toml (100%) rename {samples => packages/uipath/samples}/resource-overrides/uipath.json (100%) rename {samples => packages/uipath/samples}/tree-recursive-function/README.md (100%) rename {samples => packages/uipath/samples}/tree-recursive-function/bindings.json (100%) rename {samples => packages/uipath/samples}/tree-recursive-function/main.mermaid (100%) rename {samples => packages/uipath/samples}/tree-recursive-function/main.py (100%) rename {samples => packages/uipath/samples}/tree-recursive-function/pyproject.toml (100%) rename {samples => packages/uipath/samples}/tree-recursive-function/uipath.json (100%) rename {samples => packages/uipath/samples}/weather_tools/README.md (100%) rename {samples => packages/uipath/samples}/weather_tools/TOOL_EVALUATORS.md (100%) rename {samples => packages/uipath/samples}/weather_tools/bindings.json (100%) rename {samples => packages/uipath/samples}/weather_tools/evaluations/eval-sets/default.json (100%) rename {samples => packages/uipath/samples}/weather_tools/evaluations/evaluators/tool-call-args.json (100%) rename {samples => packages/uipath/samples}/weather_tools/evaluations/evaluators/tool-call-count.json (100%) rename {samples => packages/uipath/samples}/weather_tools/evaluations/evaluators/tool-call-order.json (100%) rename {samples => packages/uipath/samples}/weather_tools/evaluations/evaluators/tool-call-output.json (100%) rename {samples => packages/uipath/samples}/weather_tools/evaluations/evaluators/trajectory.json (100%) rename {samples => packages/uipath/samples}/weather_tools/main.py (100%) rename {samples => packages/uipath/samples}/weather_tools/pyproject.toml (100%) rename {samples => packages/uipath/samples}/weather_tools/uipath.json (100%) rename {scripts => packages/uipath/scripts}/debug_test.py (100%) rename {scripts => packages/uipath/scripts}/lint_httpx_client.py (100%) rename {scripts => packages/uipath/scripts}/update_agents_md.py (100%) rename {specs => packages/uipath/specs}/README.md (100%) rename {specs => packages/uipath/specs}/bindings.schema.json (100%) rename {specs => packages/uipath/specs}/bindings.spec.md (100%) rename {specs => packages/uipath/specs}/entry-points.schema.json (100%) rename {specs => packages/uipath/specs}/entry-points.spec.md (100%) rename {specs => packages/uipath/specs}/uipath.schema.json (100%) rename {specs => packages/uipath/specs}/uipath.spec.md (100%) rename {src => packages/uipath/src}/uipath/_cli/README.md (100%) rename {src => packages/uipath/src}/uipath/_cli/__init__.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_auth/_auth_server.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_auth/_auth_service.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_auth/_models.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_auth/_oidc_utils.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_auth/_portal_service.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_auth/_url_utils.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_auth/_utils.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_auth/auth_config_25_10.json (100%) rename {src => packages/uipath/src}/uipath/_cli/_auth/auth_config_cloud.json (100%) rename {src => packages/uipath/src}/uipath/_cli/_auth/index.html (100%) rename {src => packages/uipath/src}/uipath/_cli/_auth/localhost.crt (100%) rename {src => packages/uipath/src}/uipath/_cli/_auth/localhost.key (100%) rename {src => packages/uipath/src}/uipath/_cli/_chat/_bridge.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_debug/_bridge.py (100%) rename {tests/cli/chat => packages/uipath/src/uipath/_cli/_evals}/__init__.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_evals/_console_progress_reporter.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_evals/_progress_reporter.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_evals/_telemetry.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_push/models.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_push/sw_file_handler.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_telemetry.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_templates/.psmdcp.template (100%) rename {src => packages/uipath/src}/uipath/_cli/_templates/.rels.template (100%) rename {src => packages/uipath/src}/uipath/_cli/_templates/[Content_Types].xml.template (100%) rename {src => packages/uipath/src}/uipath/_cli/_templates/custom_evaluator.py.template (100%) rename {src => packages/uipath/src}/uipath/_cli/_templates/main.py.template (100%) rename {src => packages/uipath/src}/uipath/_cli/_templates/package.nuspec.template (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_common.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_console.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_constants.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_context.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_debug.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_folders.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_formatters.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_help_json.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_parallelization.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_processes.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_project_files.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_resources.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_service_base.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_service_cli_generator.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_service_metadata.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_service_protocol.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_studio_project.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_tracing.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_type_registry.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_uv_helpers.py (100%) rename {src => packages/uipath/src}/uipath/_cli/_utils/_validators.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_add.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_auth.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_debug.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_deploy.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_dev.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_eval.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_init.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_invoke.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_new.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_pack.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_publish.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_pull.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_push.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_register.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_run.py (100%) rename {src => packages/uipath/src}/uipath/_cli/cli_server.py (100%) rename {src => packages/uipath/src}/uipath/_cli/middlewares.py (100%) rename {tests/functions => packages/uipath/src/uipath/_cli/models}/__init__.py (100%) rename {src => packages/uipath/src}/uipath/_cli/models/runtime_schema.py (100%) rename {src => packages/uipath/src}/uipath/_cli/models/uipath_json_schema.py (100%) rename {src => packages/uipath/src}/uipath/_cli/runtimes.py (100%) rename {src => packages/uipath/src}/uipath/_cli/services/__init__.py (100%) rename {src => packages/uipath/src}/uipath/_cli/services/_buckets_metadata.py (100%) rename {src => packages/uipath/src}/uipath/_cli/services/cli_assets.py (100%) rename {src => packages/uipath/src}/uipath/_cli/services/cli_buckets.py (100%) rename {src => packages/uipath/src}/uipath/_cli/spinner.py (100%) rename {src => packages/uipath/src}/uipath/_resources/AGENTS.md (100%) rename {src => packages/uipath/src}/uipath/_resources/CLAUDE.md (100%) rename {src => packages/uipath/src}/uipath/_resources/CLI_REFERENCE.md (100%) rename {src => packages/uipath/src}/uipath/_resources/REQUIRED_STRUCTURE.md (100%) rename {src => packages/uipath/src}/uipath/_resources/SDK_REFERENCE.md (100%) create mode 100644 packages/uipath/src/uipath/_resources/eval.md create mode 100644 packages/uipath/src/uipath/_resources/new-agent.md rename {src => packages/uipath/src}/uipath/_utils/__init__.py (100%) rename {src => packages/uipath/src}/uipath/_utils/_auth.py (100%) rename {src => packages/uipath/src}/uipath/_utils/_endpoint.py (100%) rename {src => packages/uipath/src}/uipath/_utils/_logs.py (100%) rename {src => packages/uipath/src}/uipath/_utils/_request_override.py (100%) rename {src => packages/uipath/src}/uipath/_utils/_request_spec.py (100%) rename {src => packages/uipath/src}/uipath/_utils/_ssl_context.py (100%) create mode 100644 packages/uipath/src/uipath/_utils/_url.py create mode 100644 packages/uipath/src/uipath/_utils/_user_agent.py rename {src => packages/uipath/src}/uipath/_utils/constants.py (100%) create mode 100644 packages/uipath/src/uipath/_utils/validation.py rename {src => packages/uipath/src}/uipath/agent/__init__.py (100%) rename {src => packages/uipath/src}/uipath/agent/models/_legacy.py (100%) rename {src => packages/uipath/src}/uipath/agent/models/agent.py (100%) rename {src => packages/uipath/src}/uipath/agent/models/evals.py (100%) rename {src => packages/uipath/src}/uipath/agent/react/__init__.py (100%) rename {src => packages/uipath/src}/uipath/agent/react/conversational_prompts.py (100%) rename {src => packages/uipath/src}/uipath/agent/react/prompts.py (100%) rename {src => packages/uipath/src}/uipath/agent/react/tools.py (100%) rename {src => packages/uipath/src}/uipath/agent/utils/__init__.py (100%) rename {src => packages/uipath/src}/uipath/agent/utils/_utils.py (100%) rename {src => packages/uipath/src}/uipath/agent/utils/text_tokens.py (100%) rename {src => packages/uipath/src}/uipath/eval/_execution_context.py (100%) rename {src => packages/uipath/src}/uipath/eval/_helpers/__init__.py (100%) rename {src => packages/uipath/src}/uipath/eval/_helpers/evaluators_helpers.py (100%) rename {src => packages/uipath/src}/uipath/eval/_helpers/helpers.py (100%) rename {src => packages/uipath/src}/uipath/eval/_helpers/output_path.py (100%) rename {src => packages/uipath/src}/uipath/eval/constants.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/__init__.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/base_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/base_legacy_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/binary_classification_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/contains_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/evaluator_factory.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/exact_match_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/json_similarity_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/legacy_context_precision_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/legacy_deterministic_evaluator_base.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/legacy_evaluator_utils.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/legacy_exact_match_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/legacy_faithfulness_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/legacy_json_similarity_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/legacy_llm_helpers.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/legacy_trajectory_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/llm_as_judge_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/llm_judge_output_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/llm_judge_trajectory_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/multiclass_classification_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/output_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/registration.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/tool_call_args_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/tool_call_count_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/tool_call_order_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators/tool_call_output_evaluator.py (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators_types/ContainsEvaluator.json (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators_types/ExactMatchEvaluator.json (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators_types/JsonSimilarityEvaluator.json (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators_types/LLMJudgeOutputEvaluator.json (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators_types/LLMJudgeStrictJSONSimilarityOutputEvaluator.json (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators_types/LLMJudgeTrajectoryEvaluator.json (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators_types/LLMJudgeTrajectorySimulationEvaluator.json (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators_types/ToolCallArgsEvaluator.json (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators_types/ToolCallCountEvaluator.json (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators_types/ToolCallOrderEvaluator.json (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators_types/ToolCallOutputEvaluator.json (100%) rename {src => packages/uipath/src}/uipath/eval/evaluators_types/generate_types.py (100%) rename {src => packages/uipath/src}/uipath/eval/helpers.py (100%) rename {src => packages/uipath/src}/uipath/eval/mocks/__init__.py (100%) rename {src => packages/uipath/src}/uipath/eval/mocks/_cache_manager.py (100%) rename {src => packages/uipath/src}/uipath/eval/mocks/_input_mocker.py (100%) rename {src => packages/uipath/src}/uipath/eval/mocks/_llm_mocker.py (100%) rename {src => packages/uipath/src}/uipath/eval/mocks/_mock_context.py (100%) rename {src => packages/uipath/src}/uipath/eval/mocks/_mock_runtime.py (100%) rename {src => packages/uipath/src}/uipath/eval/mocks/_mocker.py (100%) rename {src => packages/uipath/src}/uipath/eval/mocks/_mocker_factory.py (100%) rename {src => packages/uipath/src}/uipath/eval/mocks/_mockito_mocker.py (100%) rename {src => packages/uipath/src}/uipath/eval/mocks/_types.py (100%) rename {src => packages/uipath/src}/uipath/eval/mocks/mockable.py (100%) rename {src => packages/uipath/src}/uipath/eval/models/__init__.py (100%) rename {src => packages/uipath/src}/uipath/eval/models/_conversational_utils.py (100%) rename {src => packages/uipath/src}/uipath/eval/models/evaluation_set.py (100%) rename {src => packages/uipath/src}/uipath/eval/models/llm_judge_types.py (100%) rename {src => packages/uipath/src}/uipath/eval/models/models.py (100%) rename {src => packages/uipath/src}/uipath/eval/runtime/__init__.py (100%) rename {src => packages/uipath/src}/uipath/eval/runtime/_evaluate.py (100%) rename {src => packages/uipath/src}/uipath/eval/runtime/_exporters.py (100%) rename {src => packages/uipath/src}/uipath/eval/runtime/_parallelization.py (100%) rename {src => packages/uipath/src}/uipath/eval/runtime/_spans.py (100%) rename {src => packages/uipath/src}/uipath/eval/runtime/_types.py (100%) rename {src => packages/uipath/src}/uipath/eval/runtime/_utils.py (100%) rename {src => packages/uipath/src}/uipath/eval/runtime/context.py (100%) rename {src => packages/uipath/src}/uipath/eval/runtime/events.py (100%) rename {src => packages/uipath/src}/uipath/eval/runtime/runtime.py (100%) rename {src => packages/uipath/src}/uipath/functions/__init__.py (100%) rename {src => packages/uipath/src}/uipath/functions/debug.py (100%) rename {src => packages/uipath/src}/uipath/functions/factory.py (100%) rename {src => packages/uipath/src}/uipath/functions/graph_builder.py (100%) rename {src => packages/uipath/src}/uipath/functions/runtime.py (100%) rename {src => packages/uipath/src}/uipath/functions/schema_gen.py (100%) rename {src => packages/uipath/src}/uipath/functions/type_conversion.py (100%) create mode 100644 packages/uipath/src/uipath/py.typed rename {src => packages/uipath/src}/uipath/telemetry/__init__.py (100%) rename {src => packages/uipath/src}/uipath/telemetry/_constants.py (100%) rename {src => packages/uipath/src}/uipath/telemetry/_track.py (100%) rename {src => packages/uipath/src}/uipath/tracing/__init__.py (100%) rename {src => packages/uipath/src}/uipath/tracing/_live_tracking_processor.py (100%) rename {src => packages/uipath/src}/uipath/tracing/_otel_exporters.py (100%) rename {src => packages/uipath/src}/uipath/tracing/_utils.py (100%) rename {src => packages/uipath/src}/uipath/utils/__init__.py (100%) rename {testcases => packages/uipath/testcases}/apicalls-testcase/main.py (100%) rename {testcases => packages/uipath/testcases}/apicalls-testcase/pyproject.toml (100%) rename {testcases => packages/uipath/testcases}/apicalls-testcase/run.sh (100%) rename {testcases => packages/uipath/testcases}/apicalls-testcase/src/assert.py (100%) rename {testcases => packages/uipath/testcases}/apicalls-testcase/uipath.json (100%) rename {testcases => packages/uipath/testcases}/basic-testcase/main.py (100%) rename {testcases => packages/uipath/testcases}/basic-testcase/pyproject.toml (100%) rename {testcases => packages/uipath/testcases}/basic-testcase/run.sh (100%) rename {testcases => packages/uipath/testcases}/basic-testcase/src/assert.py (100%) rename {testcases => packages/uipath/testcases}/basic-testcase/uipath.json (100%) rename {testcases => packages/uipath/testcases}/calculator-crash-evals/pyproject.toml (100%) rename {testcases => packages/uipath/testcases}/calculator-crash-evals/run.sh (100%) rename {testcases => packages/uipath/testcases}/calculator-crash-evals/src/assert.py (100%) rename {testcases => packages/uipath/testcases}/calculator-crash-evals/uipath.json (100%) rename {testcases => packages/uipath/testcases}/calculator-evals/pyproject.toml (100%) rename {testcases => packages/uipath/testcases}/calculator-evals/run.sh (100%) rename {testcases => packages/uipath/testcases}/calculator-evals/src/assert.py (100%) rename {testcases => packages/uipath/testcases}/calculator-evals/uipath.json (100%) rename {testcases => packages/uipath/testcases}/common/trace_assert.py (100%) rename {testcases => packages/uipath/testcases}/common/validate_output.sh (100%) rename {testcases => packages/uipath/testcases}/csv-processor-evals/pyproject.toml (100%) rename {testcases => packages/uipath/testcases}/csv-processor-evals/run.sh (100%) rename {testcases => packages/uipath/testcases}/csv-processor-evals/src/assert.py (100%) rename {testcases => packages/uipath/testcases}/csv-processor-evals/uipath.json (100%) rename {testcases => packages/uipath/testcases}/eval-input-overrides/entry-points.json (100%) rename {testcases => packages/uipath/testcases}/eval-input-overrides/pyproject.toml (100%) rename {testcases => packages/uipath/testcases}/eval-input-overrides/run.sh (100%) rename {testcases => packages/uipath/testcases}/eval-input-overrides/src/assert.py (100%) rename {testcases => packages/uipath/testcases}/eval-input-overrides/src/input-overrides-eval-set.json (100%) rename {testcases => packages/uipath/testcases}/eval-input-overrides/src/input-overrides.json (100%) rename {testcases => packages/uipath/testcases}/eval-input-overrides/uipath.json (100%) rename {testcases => packages/uipath/testcases}/eval-spans-testcase/pyproject.toml (100%) rename {testcases => packages/uipath/testcases}/eval-spans-testcase/run.sh (100%) rename {testcases => packages/uipath/testcases}/eval-spans-testcase/src/assert.py (100%) rename {testcases => packages/uipath/testcases}/eval-spans-testcase/uipath.json (100%) rename {testcases => packages/uipath/testcases}/eval-telemetry-testcase/pyproject.toml (100%) rename {testcases => packages/uipath/testcases}/eval-telemetry-testcase/run.sh (100%) rename {testcases => packages/uipath/testcases}/eval-telemetry-testcase/src/assert.py (100%) rename {testcases => packages/uipath/testcases}/eval-telemetry-testcase/uipath.json (100%) rename {testcases => packages/uipath/testcases}/langchain-cross/agent.mermaid (100%) rename {testcases => packages/uipath/testcases}/langchain-cross/expected_traces.json (100%) rename {testcases => packages/uipath/testcases}/langchain-cross/langgraph.json (100%) rename {testcases => packages/uipath/testcases}/langchain-cross/pyproject.toml (100%) rename {testcases => packages/uipath/testcases}/langchain-cross/run.sh (100%) rename {testcases => packages/uipath/testcases}/langchain-cross/src/assert.py (100%) rename {testcases => packages/uipath/testcases}/langchain-cross/src/assert.py.orig (100%) rename {testcases => packages/uipath/testcases}/langchain-cross/src/main.py (100%) rename {testcases => packages/uipath/testcases}/llamaindexcross/agent.mermaid (100%) rename {testcases => packages/uipath/testcases}/llamaindexcross/llama_index.json (100%) rename {testcases => packages/uipath/testcases}/llamaindexcross/pyproject.toml (100%) rename {testcases => packages/uipath/testcases}/llamaindexcross/run.sh (100%) rename {testcases => packages/uipath/testcases}/llamaindexcross/src/assert.py (100%) rename {testcases => packages/uipath/testcases}/llamaindexcross/src/main.py (100%) rename {testcases => packages/uipath/testcases}/target-output-key-evals/pyproject.toml (100%) rename {testcases => packages/uipath/testcases}/target-output-key-evals/run.sh (100%) rename {testcases => packages/uipath/testcases}/target-output-key-evals/src/assert.py (100%) rename {testcases => packages/uipath/testcases}/target-output-key-evals/uipath.json (100%) rename {testcases => packages/uipath/testcases}/tools-evals/pyproject.toml (100%) rename {testcases => packages/uipath/testcases}/tools-evals/run.sh (100%) rename {testcases => packages/uipath/testcases}/tools-evals/src/assert.py (100%) rename {testcases => packages/uipath/testcases}/tools-evals/uipath.json (100%) rename {tests => packages/uipath/tests}/__init__.py (100%) rename {tests => packages/uipath/tests}/agent/models/test_agent.py (100%) rename {tests => packages/uipath/tests}/agent/models/test_evals.py (100%) rename {tests => packages/uipath/tests}/agent/models/test_legacy.py (100%) create mode 100644 packages/uipath/tests/agent/react/__init__.py rename {tests => packages/uipath/tests}/agent/react/test_conversational_prompts.py (100%) rename {tests => packages/uipath/tests}/agent/utils/test_load_agent_definition.py (100%) rename {tests => packages/uipath/tests}/agent/utils/test_text_tokens.py (100%) create mode 100644 packages/uipath/tests/cli/chat/__init__.py rename {tests => packages/uipath/tests}/cli/chat/test_bridge.py (100%) rename {tests => packages/uipath/tests}/cli/conftest.py (100%) rename {tests => packages/uipath/tests}/cli/contract/test_sdk_cli_alignment.py (100%) rename {tests => packages/uipath/tests}/cli/eval/evals/eval-sets/default.json (100%) rename {tests => packages/uipath/tests}/cli/eval/evals/eval-sets/multiple-evals.json (100%) rename {tests => packages/uipath/tests}/cli/eval/evals/evaluators/exact-match.json (100%) rename {tests => packages/uipath/tests}/cli/eval/mocks/test_cache_manager.py (100%) rename {tests => packages/uipath/tests}/cli/eval/mocks/test_input_mocker.py (100%) rename {tests => packages/uipath/tests}/cli/eval/mocks/test_mockable_mocked_annotation.py (100%) rename {tests => packages/uipath/tests}/cli/eval/mocks/test_mocks.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_apply_file_overrides.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_conversational_utils.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_eval_discovery.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_eval_resume_flow.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_eval_runtime_metadata.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_eval_runtime_spans.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_eval_runtime_suspend_resume.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_eval_set.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_eval_span_utils.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_eval_telemetry.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_eval_tracing_integration.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_eval_util.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_evaluate.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_input_overrides_e2e.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_live_tracking_span_processor.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_progress_reporter.py (100%) rename {tests => packages/uipath/tests}/cli/eval/test_span_persistence.py (100%) rename {tests => packages/uipath/tests}/cli/evaluators/test_json_similarity_evaluator.py (100%) rename {tests => packages/uipath/tests}/cli/evaluators/test_legacy_context_precision_evaluator.py (100%) rename {tests => packages/uipath/tests}/cli/evaluators/test_legacy_exact_match_evaluator.py (100%) rename {tests => packages/uipath/tests}/cli/evaluators/test_legacy_faithfulness_evaluator.py (100%) rename {tests => packages/uipath/tests}/cli/evaluators/test_legacy_llm_as_judge_placeholder_validation.py (100%) rename {tests => packages/uipath/tests}/cli/integration/test_assets_commands.py (100%) rename {tests => packages/uipath/tests}/cli/integration/test_buckets_commands.py (100%) rename {tests => packages/uipath/tests}/cli/mocks/pyproject.toml (100%) rename {tests => packages/uipath/tests}/cli/mocks/simple_script.py (100%) rename {tests => packages/uipath/tests}/cli/mocks/uipath-mock-legacy.json (100%) rename {tests => packages/uipath/tests}/cli/mocks/uipath-simple-script-mock.json (100%) rename {tests => packages/uipath/tests}/cli/models/test_runtime_schema.py (100%) rename {tests => packages/uipath/tests}/cli/test_auth.py (100%) rename {tests => packages/uipath/tests}/cli/test_cli_telemetry.py (100%) rename {tests => packages/uipath/tests}/cli/test_debug_simulation.py (100%) rename {tests => packages/uipath/tests}/cli/test_init.py (100%) rename {tests => packages/uipath/tests}/cli/test_init_agents_md.py (100%) rename {tests => packages/uipath/tests}/cli/test_input_args.py (100%) rename {tests => packages/uipath/tests}/cli/test_invoke.py (100%) rename {tests => packages/uipath/tests}/cli/test_new.py (100%) rename {tests => packages/uipath/tests}/cli/test_oidc_utils.py (100%) rename {tests => packages/uipath/tests}/cli/test_pack.py (100%) rename {tests => packages/uipath/tests}/cli/test_portal_service_ensure_valid_token.py (100%) rename {tests => packages/uipath/tests}/cli/test_portal_service_refresh_token.py (100%) rename {tests => packages/uipath/tests}/cli/test_publish.py (100%) rename {tests => packages/uipath/tests}/cli/test_pull.py (100%) rename {tests => packages/uipath/tests}/cli/test_push.py (100%) rename {tests => packages/uipath/tests}/cli/test_run.py (100%) rename {tests => packages/uipath/tests}/cli/test_server.py (100%) rename {tests => packages/uipath/tests}/cli/test_utils.py (100%) rename {tests => packages/uipath/tests}/cli/unit/test_buckets_migration.py (100%) rename {tests => packages/uipath/tests}/cli/unit/test_context.py (100%) rename {tests => packages/uipath/tests}/cli/unit/test_formatters.py (100%) rename {tests => packages/uipath/tests}/cli/unit/test_service_cli_generator.py (100%) rename {tests => packages/uipath/tests}/cli/unit/test_service_metadata.py (100%) rename {tests => packages/uipath/tests}/cli/unit/test_service_protocol.py (100%) rename {tests => packages/uipath/tests}/cli/unit/test_type_registry.py (100%) rename {tests => packages/uipath/tests}/cli/unit/test_validators.py (100%) rename {tests => packages/uipath/tests}/cli/utils/common.py (100%) rename {tests => packages/uipath/tests}/cli/utils/project_details.py (100%) rename {tests => packages/uipath/tests}/conftest.py (100%) rename {tests => packages/uipath/tests}/evaluators/__init__.py (100%) rename {tests => packages/uipath/tests}/evaluators/test_documentation_examples.py (100%) rename {tests => packages/uipath/tests}/evaluators/test_eval_level_expected_output.py (100%) rename {tests => packages/uipath/tests}/evaluators/test_evaluator_aggregation.py (100%) rename {tests => packages/uipath/tests}/evaluators/test_evaluator_factory.py (100%) rename {tests => packages/uipath/tests}/evaluators/test_evaluator_helpers.py (100%) rename {tests => packages/uipath/tests}/evaluators/test_evaluator_methods.py (100%) rename {tests => packages/uipath/tests}/evaluators/test_evaluator_schemas.py (100%) rename {tests => packages/uipath/tests}/evaluators/test_helpers.py (100%) rename {tests => packages/uipath/tests}/evaluators/test_legacy_target_output_key_paths.py (100%) rename {tests => packages/uipath/tests}/evaluators/test_llm_judge_placeholder_validation.py (100%) rename {tests => packages/uipath/tests}/evaluators/test_output_path.py (100%) create mode 100644 packages/uipath/tests/functions/__init__.py rename {tests => packages/uipath/tests}/functions/test_debug_breakpoints.py (100%) rename {tests => packages/uipath/tests}/functions/test_graph_builder.py (100%) rename {tests => packages/uipath/tests}/functions/test_unwrap_decorated.py (100%) rename {tests => packages/uipath/tests}/resource_overrides/__init__.py (100%) rename {tests => packages/uipath/tests}/resource_overrides/overwrites.json (100%) rename {tests => packages/uipath/tests}/resource_overrides/test_resource_overrides.py (100%) rename {tests => packages/uipath/tests}/sdk/test_bindings.py (100%) rename {tests => packages/uipath/tests}/sdk/test_config.py (100%) rename {tests => packages/uipath/tests}/sdk/test_uipath_caching.py (100%) rename {tests => packages/uipath/tests}/sdk/test_utils_validation.py (100%) rename {tests => packages/uipath/tests}/telemetry/__init__.py (100%) rename {tests => packages/uipath/tests}/telemetry/test_track.py (100%) rename {tests => packages/uipath/tests}/tracing/test_otel_exporters.py (100%) rename uv.lock => packages/uipath/uv.lock (99%) diff --git a/.github/labeler.yml b/.github/labeler.yml index 1650e436d..ee2f4de14 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,7 +1,15 @@ +test:uipath-langchain: + - changed-files: + - any-glob-to-any-file: ['packages/uipath/src/**/*.py'] + - changed-files: + - any-glob-to-any-file: ['packages/uipath-platform/src/**/*.py'] + - changed-files: + - any-glob-to-any-file: ['packages/uipath-core/src/**/*.py'] + test:uipath-llamaindex: - changed-files: - - any-glob-to-any-file: ['src/**/*.py'] + - any-glob-to-any-file: ['packages/uipath/src/**/*.py'] -test:uipath-langchain: +test:uipath-runtime: - changed-files: - - any-glob-to-any-file: ['src/**/*.py'] + - any-glob-to-any-file: ['packages/uipath-core/src/**/*.py'] diff --git a/.github/scripts/detect_changed_packages.py b/.github/scripts/detect_changed_packages.py new file mode 100644 index 000000000..2888ebc2e --- /dev/null +++ b/.github/scripts/detect_changed_packages.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Detect which packages have changed in a PR or push to main. + +Includes dependency-aware propagation: when a package changes, all +downstream dependents are also included in the test list. +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +# Internal dependency graph: package -> packages that depend on it. +# When a package changes, its dependents' tests also run. +# Add new entries here as packages are added to the monorepo. +# External dependents (uipath-langchain, uipath-runtime, etc.) are +# handled separately via labeler.yml auto-labels. +DEPENDENTS: dict[str, list[str]] = { + "uipath-core": ["uipath-platform", "uipath"], + "uipath-platform": ["uipath"], +} + + +def expand_with_dependents(changed: list[str], all_packages: list[str]) -> list[str]: + """Expand changed package list to include downstream dependents.""" + expanded = set(changed) + for pkg in changed: + for dep in DEPENDENTS.get(pkg, []): + if dep in all_packages: + expanded.add(dep) + return sorted(expanded) + + +def get_all_packages() -> list[str]: + """Get all packages in the monorepo.""" + packages_dir = Path("packages") + packages = [] + + for item in packages_dir.iterdir(): + if item.is_dir() and (item / "pyproject.toml").exists(): + packages.append(item.name) + + return sorted(packages) + + +def get_changed_packages(base_sha: str, head_sha: str) -> list[str]: + """Get packages that have changed between two commits.""" + try: + # Get changed files + result = subprocess.run( + ["git", "diff", "--name-only", f"{base_sha}...{head_sha}"], + capture_output=True, + text=True, + check=True, + ) + + changed_files = result.stdout.strip().split("\n") + + # Extract package names from paths like "packages/uipath-llamaindex/..." + changed_packages = set() + for file_path in changed_files: + if file_path.startswith("packages/"): + parts = file_path.split("/") + if len(parts) >= 2: + package_name = parts[1] + # Verify it's a real package + if (Path("packages") / package_name / "pyproject.toml").exists(): + changed_packages.add(package_name) + + return sorted(changed_packages) + + except subprocess.CalledProcessError as e: + print(f"Error running git diff: {e}", file=sys.stderr) + return [] + + +def get_changed_packages_auto() -> list[str]: + """Auto-detect changed packages using git.""" + try: + # Try to detect changes against origin/main + result = subprocess.run( + ["git", "diff", "--name-only", "origin/main...HEAD"], + capture_output=True, + text=True, + check=True, + ) + + changed_files = result.stdout.strip().split("\n") + + # Extract package names + changed_packages = set() + for file_path in changed_files: + if file_path.startswith("packages/"): + parts = file_path.split("/") + if len(parts) >= 2: + package_name = parts[1] + if (Path("packages") / package_name / "pyproject.toml").exists(): + changed_packages.add(package_name) + + return sorted(changed_packages) + + except (subprocess.CalledProcessError, Exception) as e: + print(f"Warning: Could not auto-detect changes: {e}", file=sys.stderr) + return [] + + +def main(): + """Main entry point.""" + event_name = os.getenv("GITHUB_EVENT_NAME", "") + base_sha = os.getenv("BASE_SHA", "") + head_sha = os.getenv("HEAD_SHA", "") + + all_packages = get_all_packages() + + # If we have explicit SHAs (from PR or push), detect changed packages + if base_sha and head_sha: + packages = get_changed_packages(base_sha, head_sha) + event_type = "pull request" if event_name == "pull_request" else "push" + print(f"{event_type.capitalize()} - detected {len(packages)} directly changed package(s):") + for pkg in packages: + print(f" - {pkg}") + + # workflow_call or missing context - try auto-detection + else: + print(f"Event: {event_name or 'workflow_call'} - attempting auto-detection") + packages = get_changed_packages_auto() + + if packages: + print(f"Auto-detected {len(packages)} directly changed package(s):") + for pkg in packages: + print(f" - {pkg}") + else: + # Fallback: test all packages + print("Could not detect changes - testing all packages") + packages = all_packages + for pkg in packages: + print(f" - {pkg}") + + # Expand with downstream dependents + expanded = expand_with_dependents(packages, all_packages) + added = sorted(set(expanded) - set(packages)) + if added: + print(f"\nAdded {len(added)} dependent package(s):") + for pkg in added: + print(f" - {pkg}") + packages = expanded + + # Output as JSON for GitHub Actions + packages_json = json.dumps(packages) + print(f"\nPackages JSON: {packages_json}") + + # Write to GitHub output + github_output = os.getenv("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a") as f: + f.write(f"packages={packages_json}\n") + f.write(f"count={len(packages)}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index ac0da64da..ae85b13fd 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -6,24 +6,51 @@ on: branches: - main paths: - - pyproject.toml + - 'packages/*/pyproject.toml' + - '!packages/*/samples/**/pyproject.toml' + - '!packages/*/testcases/**/pyproject.toml' + +permissions: + contents: read + pull-requests: read jobs: - lint: - uses: ./.github/workflows/lint.yml + detect-changed-packages: + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.detect.outputs.packages }} + count: ${{ steps.detect.outputs.count }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 - test: - uses: ./.github/workflows/test.yml + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Detect changed packages + id: detect + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.event.after }} + run: python .github/scripts/detect_changed_packages.py build: - name: Build + name: Build ${{ matrix.package }} + needs: detect-changed-packages + if: needs.detect-changed-packages.outputs.count > 0 && github.repository == 'UiPath/uipath-python' runs-on: ubuntu-latest - - needs: - - lint - - test - - if: ${{ github.repository == 'UiPath/uipath-python' }} + defaults: + run: + working-directory: packages/${{ matrix.package }} + strategy: + fail-fast: false + matrix: + package: ${{ fromJson(needs.detect-changed-packages.outputs.packages) }} permissions: contents: read actions: write @@ -40,15 +67,17 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version-file: ".python-version" + python-version-file: "packages/${{ matrix.package }}/.python-version" - name: Install dependencies run: uv sync --all-extras - name: Update AGENTS.md + if: matrix.package == 'uipath' run: uv run python scripts/update_agents_md.py - name: Replace connection string placeholder + if: matrix.package == 'uipath' run: | originalfile="src/uipath/telemetry/_constants.py" tmpfile=$(mktemp) @@ -60,21 +89,23 @@ jobs: CONNECTION_STRING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING }} - name: Build - run: uv build + run: uv build --no-sources --package ${{ matrix.package }} - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: release-dists - path: dist/ + name: release-dists-${{ matrix.package }} + path: packages/${{ matrix.package }}/dist/ pypi-publish: - name: Upload release to PyPI + name: Upload ${{ matrix.package }} to PyPI + needs: [detect-changed-packages, build] runs-on: ubuntu-latest environment: pypi - - needs: - - build + strategy: + fail-fast: false + matrix: + package: ${{ fromJson(needs.detect-changed-packages.outputs.packages) }} permissions: contents: read id-token: write @@ -83,7 +114,7 @@ jobs: - name: Retrieve release distributions uses: actions/download-artifact@v4 with: - name: release-dists + name: release-dists-${{ matrix.package }} path: dist/ - name: Publish package distributions to PyPI diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 383039232..6e61cd027 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,18 +5,23 @@ on: branches: - main paths-ignore: - - pyproject.toml + - packages/*/pyproject.toml + - '!packages/*/samples/**/pyproject.toml' + - '!packages/*/testcases/**/pyproject.toml' pull_request: branches: - main +permissions: + contents: read + jobs: commit-lint: if: ${{ github.event_name == 'pull_request' }} uses: ./.github/workflows/commitlint.yml lint: - uses: ./.github/workflows/lint.yml + uses: ./.github/workflows/lint-packages.yml test: - uses: ./.github/workflows/test.yml \ No newline at end of file + uses: ./.github/workflows/test-packages.yml diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index ba4bf69be..2e8cfc066 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -1,4 +1,4 @@ -name: Integration testing +name: uipath - Integration Tests on: push: @@ -22,6 +22,7 @@ jobs: - name: Discover testcases id: discover + working-directory: packages/uipath run: | # Find all testcase folders (excluding common folders like README, etc.) testcase_dirs=$(find testcases -maxdepth 1 -type d -name "*-*" | sed 's|testcases/||' | sort) @@ -54,6 +55,7 @@ jobs: uses: actions/checkout@v4 - name: Install dependencies + working-directory: packages/uipath run: uv sync - name: Run testcase @@ -68,7 +70,7 @@ jobs: TELEMETRY_CONNECTION_STRING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING }} APP_INSIGHTS_APP_ID: ${{ secrets.APP_INSIGHTS_APP_ID }} APP_INSIGHTS_API_KEY: ${{ secrets.APP_INSIGHTS_API_KEY }} - working-directory: testcases/${{ matrix.testcase }} + working-directory: packages/uipath/testcases/${{ matrix.testcase }} run: | # If any errors occur execution will stop with exit code set -e diff --git a/.github/workflows/lint-packages.yml b/.github/workflows/lint-packages.yml new file mode 100644 index 000000000..e7f275c54 --- /dev/null +++ b/.github/workflows/lint-packages.yml @@ -0,0 +1,215 @@ +name: Lint Packages + +on: + workflow_call: + +permissions: + contents: read + +jobs: + detect-changed-packages: + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.detect.outputs.packages }} + count: ${{ steps.detect.outputs.count }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Detect changed packages + id: detect + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python .github/scripts/detect_changed_packages.py + + lint-uipath-core: + name: Lint uipath-core + needs: detect-changed-packages + runs-on: ubuntu-latest + steps: + - name: Check if package changed + id: check + run: | + if echo '${{ needs.detect-changed-packages.outputs.packages }}' | jq -e 'index("uipath-core")' > /dev/null; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Skip + if: steps.check.outputs.skip == 'true' + run: echo "Skipping - no changes to uipath-core" + + - name: Checkout + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@v4 + + - name: Setup uv + if: steps.check.outputs.skip != 'true' + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Setup Python + if: steps.check.outputs.skip != 'true' + uses: actions/setup-python@v5 + with: + python-version-file: "packages/uipath-core/.python-version" + + - name: Install dependencies + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-core + run: uv sync --locked --no-sources --all-extras + + - name: Check static types + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-core + run: uv run mypy --config-file pyproject.toml . + + - name: Check linting + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-core + run: uv run ruff check . + + - name: Check formatting + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-core + run: uv run ruff format --check . + + lint-uipath-platform: + name: Lint uipath-platform + needs: detect-changed-packages + runs-on: ubuntu-latest + steps: + - name: Check if package changed + id: check + run: | + if echo '${{ needs.detect-changed-packages.outputs.packages }}' | jq -e 'index("uipath-platform")' > /dev/null; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Skip + if: steps.check.outputs.skip == 'true' + run: echo "Skipping - no changes to uipath-platform" + + - name: Checkout + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@v4 + + - name: Setup uv + if: steps.check.outputs.skip != 'true' + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Setup Python + if: steps.check.outputs.skip != 'true' + uses: actions/setup-python@v5 + with: + python-version-file: "packages/uipath-platform/.python-version" + + - name: Install dependencies + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-platform + run: uv sync --locked --no-sources --all-extras + + - name: Check static types + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-platform + run: uv run mypy --config-file pyproject.toml . + + - name: Check linting + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-platform + run: uv run ruff check . + + - name: Check formatting + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-platform + run: uv run ruff format --check . + + lint-uipath: + name: Lint uipath + needs: detect-changed-packages + runs-on: ubuntu-latest + steps: + - name: Check if package changed + id: check + run: | + if echo '${{ needs.detect-changed-packages.outputs.packages }}' | jq -e 'index("uipath")' > /dev/null; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Skip + if: steps.check.outputs.skip == 'true' + run: echo "Skipping - no changes to uipath" + + - name: Checkout + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@v4 + + - name: Setup uv + if: steps.check.outputs.skip != 'true' + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Setup Python + if: steps.check.outputs.skip != 'true' + uses: actions/setup-python@v5 + with: + python-version-file: "packages/uipath/.python-version" + + - name: Install dependencies + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath + run: uv sync --locked --no-sources --all-extras + + - name: Check static types + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath + run: uv run mypy --config-file pyproject.toml . + + - name: Check linting + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath + run: uv run ruff check . + + - name: Check formatting + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath + run: uv run ruff format --check . + + - name: Check httpx.Client() usage + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath + run: uv run python scripts/lint_httpx_client.py + + lint-gate: + name: Lint + needs: [lint-uipath-core, lint-uipath-platform, lint-uipath] + runs-on: ubuntu-latest + if: always() + steps: + - name: Check lint results + run: | + if [[ "${{ needs.lint-uipath-core.result }}" == "failure" || \ + "${{ needs.lint-uipath-platform.result }}" == "failure" || \ + "${{ needs.lint-uipath.result }}" == "failure" ]]; then + echo "Lint failed" + exit 1 + fi + echo "All lint checks passed" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index ff8f20cb2..000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Lint - -on: workflow_call - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version-file: ".python-version" - - - name: Install dependencies - run: uv sync --locked --all-extras - - - name: Check static types - run: uv run mypy --config-file pyproject.toml - - - name: Check linting - run: uv run ruff check . - - - name: Check formatting - run: uv run ruff format --check . - - - name: Check httpx.Client() usage - run: uv run python scripts/lint_httpx_client.py diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index d2fce5b27..3c438f75c 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -3,16 +3,53 @@ name: Publish Dev Build on: pull_request: types: [opened, synchronize, reopened, labeled] + paths: + - 'packages/**' + - '.github/workflows/publish-dev.yml' + - '.github/scripts/detect_changed_packages.py' + +permissions: + contents: read + pull-requests: write jobs: - publish-dev: + detect-changed-packages: + if: contains(github.event.pull_request.labels.*.name, 'build:dev') runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write + outputs: + packages: ${{ steps.detect.outputs.packages }} + count: ${{ steps.detect.outputs.count }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 - # Only run if PR has the build:dev label - if: contains(github.event.pull_request.labels.*.name, 'build:dev') + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Detect changed packages + id: detect + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python .github/scripts/detect_changed_packages.py + + publish-dev: + name: Publish Dev Build - ${{ matrix.package }} + needs: detect-changed-packages + if: contains(github.event.pull_request.labels.*.name, 'build:dev') && needs.detect-changed-packages.outputs.count > 0 + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/${{ matrix.package }} + strategy: + fail-fast: false + matrix: + package: ${{ fromJson(needs.detect-changed-packages.outputs.packages) }} steps: - name: Checkout @@ -26,12 +63,13 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version-file: ".python-version" + python-version-file: "packages/${{ matrix.package }}/.python-version" - name: Install dependencies run: uv sync --all-extras - name: Replace connection string placeholder + if: matrix.package == 'uipath' run: | originalfile="src/uipath/telemetry/_constants.py" tmpfile=$(mktemp) @@ -47,12 +85,12 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - $pyprojcontent = Get-Content pyproject.toml -Raw + $pyprojPath = "pyproject.toml" + $pyprojcontent = Get-Content $pyprojPath -Raw $PROJECT_NAME = ($pyprojcontent | Select-String -Pattern '(?m)^\[(project|tool\.poetry)\][^\[]*?name\s*=\s*"([^"]*)"' -AllMatches).Matches[0].Groups[2].Value $CURRENT_VERSION = ($pyprojcontent | Select-String -Pattern '(?m)^\[(project|tool\.poetry)\][^\[]*?version\s*=\s*"([^"]*)"' -AllMatches).Matches[0].Groups[2].Value - # Get PR number and run number with proper padding $PR_NUM = [int]"${{ github.event.pull_request.number }}" $PADDED_PR = "{0:D5}" -f [int]"${{ github.event.pull_request.number }}" @@ -67,19 +105,12 @@ jobs: $DEV_VERSION = "$CURRENT_VERSION.dev1$PADDED_PR$PADDED_RUN" # Update version in pyproject.toml - (Get-Content pyproject.toml) -replace "version = `"$CURRENT_VERSION`"", "version = `"$DEV_VERSION`"" | Set-Content pyproject.toml - - Write-Output "Package version set to $DEV_VERSION" + (Get-Content $pyprojPath) -replace "version = `"$CURRENT_VERSION`"", "version = `"$DEV_VERSION`"" | Set-Content $pyprojPath - $startMarker = "" - $endMarker = "" + Write-Output "Package $PROJECT_NAME version set to $DEV_VERSION" $dependencyMessage = @" - $startMarker - ## Development Package - - - Use ``uipath pack --nolock`` to get the latest dev build from this PR (requires version range). - - Add this package as a dependency in your pyproject.toml: + ### $PROJECT_NAME ``````toml [project] @@ -99,13 +130,7 @@ jobs: [tool.uv.sources] $PROJECT_NAME = { index = "testpypi" } - - [tool.uv] - override-dependencies = [ - "$PROJECT_NAME>=$MIN_VERSION,<$MAX_VERSION", - ] `````` - $endMarker "@ # Get the owner and repo from the GitHub repository @@ -123,30 +148,80 @@ jobs: $pr = Invoke-RestMethod -Uri $prUri -Method Get -Headers $headers $currentBody = $pr.body - # Check if markers already exist in the PR description - $markerPattern = "(?s)$([regex]::Escape($startMarker)).*?$([regex]::Escape($endMarker))" - if ($currentBody -match $markerPattern) { - # Replace everything between markers (including markers) - $newBody = $currentBody -replace $markerPattern, $dependencyMessage + # Define regex patterns for matching package sections + $devPackagesHeader = "## Development Packages" + $packageHeaderPattern = "### $PROJECT_NAME\s*\n" + + # Find if the package section exists using multiline regex + $packageSectionRegex = "(?ms)### $PROJECT_NAME\s*\n``````toml.*?``````" + + if ($currentBody -match $devPackagesHeader) { + # Development Packages section exists + if ($currentBody -match $packageSectionRegex) { + # Replace existing package section + Write-Output "Updating existing $PROJECT_NAME section" + $newBody = $currentBody -replace $packageSectionRegex, $dependencyMessage.Trim() + } else { + # Append new package section after the Development Packages header + Write-Output "Adding new $PROJECT_NAME section" + $insertPoint = $currentBody.IndexOf($devPackagesHeader) + $devPackagesHeader.Length + $newBody = $currentBody.Insert($insertPoint, "`n`n$dependencyMessage") + } } else { - # Append the dependency message to the end of the description - $newBody = if ($currentBody) { "$currentBody`n`n$dependencyMessage" } else { $dependencyMessage } - } - - # Update the PR description - $updateBody = @{ - body = $newBody - } | ConvertTo-Json + # Create the Development Packages section + Write-Output "Creating Development Packages section with $PROJECT_NAME" + $packageSection = @" + ## Development Packages - Invoke-RestMethod -Uri $prUri -Method Patch -Headers $headers -Body $updateBody -ContentType "application/json" + $dependencyMessage + "@ + $newBody = if ($currentBody) { "$currentBody`n`n$packageSection" } else { $packageSection } + } - Write-Output "Updated PR description with development package information" + # Update the PR description with retry logic + $maxRetries = 3 + $retryCount = 0 + $success = $false + + while (-not $success -and $retryCount -lt $maxRetries) { + try { + $updateBody = @{ + body = $newBody + } | ConvertTo-Json + + Invoke-RestMethod -Uri $prUri -Method Patch -Headers $headers -Body $updateBody -ContentType "application/json" | Out-Null + $success = $true + Write-Output "Successfully updated PR description with $PROJECT_NAME information" + } catch { + $retryCount++ + if ($retryCount -lt $maxRetries) { + Write-Output "Failed to update PR description, retrying ($retryCount/$maxRetries)..." + Start-Sleep -Seconds 2 + # Re-fetch PR body in case another job updated it + $pr = Invoke-RestMethod -Uri $prUri -Method Get -Headers $headers + $currentBody = $pr.body + + # Recompute newBody with fresh data + if ($currentBody -match $packageSectionRegex) { + $newBody = $currentBody -replace $packageSectionRegex, $dependencyMessage.Trim() + } elseif ($currentBody -match $devPackagesHeader) { + $insertPoint = $currentBody.IndexOf($devPackagesHeader) + $devPackagesHeader.Length + $newBody = $currentBody.Insert($insertPoint, "`n`n$dependencyMessage") + } else { + $packageSection = "$devPackagesHeader`n`n$dependencyMessage" + $newBody = if ($currentBody) { "$currentBody`n`n$packageSection" } else { $packageSection } + } + } else { + Write-Output "Failed to update PR description after $maxRetries attempts" + throw + } + } + } - name: Build package - run: uv build + run: uv build --no-sources --package ${{ matrix.package }} - name: Publish run: uv publish --index testpypi env: - UV_PUBLISH_TOKEN: ${{ secrets.TESTPYPI_TOKEN }} - + UV_PUBLISH_TOKEN: ${{ matrix.package == 'uipath' && secrets.TESTPYPI_TOKEN || matrix.package == 'uipath-platform' && secrets.TESTPYPI_TOKEN_PLATFORM || secrets.TESTPYPI_TOKEN_CORE }} diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 3daf38653..2b08458b7 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -6,9 +6,9 @@ on: branches: - main paths: - - "docs/**" - - "mkdocs.yml" - - "pyproject.toml" + - "packages/uipath/docs/**" + - "packages/uipath/mkdocs.yml" + - "packages/uipath/pyproject.toml" repository_dispatch: types: [publish-docs] @@ -31,9 +31,10 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version-file: ".python-version" + python-version-file: "packages/uipath/.python-version" - name: Install dependencies + working-directory: packages/uipath run: uv sync --all-extras - name: Clone plugins @@ -43,15 +44,17 @@ jobs: git clone https://x-access-token:${{ secrets.REPO_ACCESS }}@github.com/UiPath/uipath-mcp-python plugins/uipath-mcp-python - name: Symlink plugin docs + working-directory: packages/uipath run: | - ln -s ../plugins/uipath-langchain-python/docs docs/langchain - ln -s ../plugins/uipath-integrations-python/packages/uipath-llamaindex/docs docs/llamaindex - ln -s ../plugins/uipath-integrations-python/packages/uipath-openai-agents/docs docs/openai-agents - ln -s ../plugins/uipath-mcp-python/docs docs/mcp + ln -s ../../plugins/uipath-langchain-python/docs docs/langchain + ln -s ../../plugins/uipath-integrations-python/packages/uipath-llamaindex/docs docs/llamaindex + ln -s ../../plugins/uipath-integrations-python/packages/uipath-openai-agents/docs docs/openai-agents + ln -s ../../plugins/uipath-mcp-python/docs docs/mcp ls -a docs/langchain ls -a docs/llamaindex ls -a docs/openai-agents ls -a docs/mcp - name: Publish Docs + working-directory: packages/uipath run: uv run mkdocs gh-deploy --force diff --git a/.github/workflows/slack.yml b/.github/workflows/slack.yml deleted file mode 100644 index 493d43a28..000000000 --- a/.github/workflows/slack.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: GitHub Reviewer Slack Agent - -on: - pull_request: - types: [opened] - -jobs: - trigger-uipath-agent: - runs-on: ubuntu-latest - steps: - - name: Install Python dependencies - run: pip install requests - - - name: Trigger UiPath Agent - env: - PR_NUMBER: ${{ github.event.number }} - REPO_NAME: ${{ github.repository }} - OWNER_NAME: ${{ github.repository_owner }} - run: | - python -c "import requests; import json; import os; comment = {'messages': [{'role': 'user', 'content': f'You are reviewing PR #{os.environ['PR_NUMBER']}, repo: {os.environ['REPO_NAME']}, owner: {os.environ['OWNER_NAME']}'}]}; payload = json.dumps(comment); resp = requests.post('${{ secrets.UIPATH_SLACK_URL }}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs', headers={'Authorization': 'Bearer ${{ secrets.UIPATH_SLACK_PAT }}', 'Content-Type': 'application/json', 'X-UiPath-FolderPath': 'MCP Folder'}, json={'startInfo': {'releaseName': 'github-slack-agent', 'inputArguments': payload}}); print(f'Status code: {resp.status_code}'); print(f'Response: {resp.text}')" diff --git a/.github/workflows/test-packages.yml b/.github/workflows/test-packages.yml new file mode 100644 index 000000000..58e37a42a --- /dev/null +++ b/.github/workflows/test-packages.yml @@ -0,0 +1,199 @@ +name: Test Packages + +on: + workflow_call: + +permissions: + contents: read + +jobs: + detect-changed-packages: + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.detect.outputs.packages }} + count: ${{ steps.detect.outputs.count }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Detect changed packages + id: detect + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python .github/scripts/detect_changed_packages.py + + test-uipath-core: + name: Test (uipath-core, ${{ matrix.python-version }}, ${{ matrix.os }}) + needs: detect-changed-packages + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + os: [ubuntu-latest, windows-latest] + steps: + - name: Check if package changed + id: check + shell: bash + run: | + if echo '${{ needs.detect-changed-packages.outputs.packages }}' | jq -e 'index("uipath-core")' > /dev/null; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Skip + if: steps.check.outputs.skip == 'true' + shell: bash + run: echo "Skipping - no changes to uipath-core" + + - name: Checkout + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@v4 + + - name: Setup uv + if: steps.check.outputs.skip != 'true' + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + if: steps.check.outputs.skip != 'true' + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-core + run: uv sync --all-extras --python ${{ matrix.python-version }} + + - name: Run tests + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-core + run: uv run pytest + + test-uipath-platform: + name: Test (uipath-platform, ${{ matrix.python-version }}, ${{ matrix.os }}) + needs: detect-changed-packages + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + os: [ubuntu-latest, windows-latest] + steps: + - name: Check if package changed + id: check + shell: bash + run: | + if echo '${{ needs.detect-changed-packages.outputs.packages }}' | jq -e 'index("uipath-platform")' > /dev/null; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Skip + if: steps.check.outputs.skip == 'true' + shell: bash + run: echo "Skipping - no changes to uipath-platform" + + - name: Checkout + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@v4 + + - name: Setup uv + if: steps.check.outputs.skip != 'true' + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + if: steps.check.outputs.skip != 'true' + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-platform + run: uv sync --all-extras --python ${{ matrix.python-version }} + + - name: Run tests + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath-platform + run: uv run pytest + + test-uipath: + name: Test (uipath, ${{ matrix.python-version }}, ${{ matrix.os }}) + needs: detect-changed-packages + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + os: [ubuntu-latest, windows-latest] + steps: + - name: Check if package changed + id: check + shell: bash + run: | + if echo '${{ needs.detect-changed-packages.outputs.packages }}' | jq -e 'index("uipath")' > /dev/null; then + echo "skip=false" >> $GITHUB_OUTPUT + else + echo "skip=true" >> $GITHUB_OUTPUT + fi + + - name: Skip + if: steps.check.outputs.skip == 'true' + shell: bash + run: echo "Skipping - no changes to uipath" + + - name: Checkout + if: steps.check.outputs.skip != 'true' + uses: actions/checkout@v4 + + - name: Setup uv + if: steps.check.outputs.skip != 'true' + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + if: steps.check.outputs.skip != 'true' + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath + run: uv sync --all-extras --python ${{ matrix.python-version }} + + - name: Run tests + if: steps.check.outputs.skip != 'true' + working-directory: packages/uipath + run: uv run pytest + + continue-on-error: true + + test-gate: + name: Test + needs: [test-uipath-core, test-uipath-platform, test-uipath] + runs-on: ubuntu-latest + if: always() + steps: + - name: Check test results + run: | + if [[ "${{ needs.test-uipath-core.result }}" == "failure" || \ + "${{ needs.test-uipath-platform.result }}" == "failure" || \ + "${{ needs.test-uipath.result }}" == "failure" ]]; then + echo "Tests failed" + exit 1 + fi + echo "All tests passed" diff --git a/.github/workflows/test-uipath-langchain.yml b/.github/workflows/test-uipath-langchain.yml index e3040f695..f74135975 100644 --- a/.github/workflows/test-uipath-langchain.yml +++ b/.github/workflows/test-uipath-langchain.yml @@ -1,11 +1,47 @@ -name: Test UiPath Langchain +name: uipath - Test Langchain on: pull_request: types: [ opened, synchronize, reopened, labeled ] jobs: + build-wheels: + runs-on: ubuntu-latest + permissions: + contents: read + if: contains(github.event.pull_request.labels.*.name, 'test:uipath-langchain') + steps: + - name: Checkout uipath-python + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build uipath-core package + working-directory: packages/uipath-core + run: uv build + + - name: Build uipath-platform package + working-directory: packages/uipath-platform + run: uv build + + - name: Build uipath package + working-directory: packages/uipath + run: uv build + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: uipath-wheels + path: packages/*/dist/*.whl + test-uipath-langchain: + needs: [build-wheels] runs-on: ${{ matrix.os }} strategy: matrix: @@ -15,15 +51,7 @@ jobs: permissions: contents: read - # Only run if PR has the test:uipath-langchain label - if: contains(github.event.pull_request.labels.*.name, 'test:uipath-langchain') - steps: - - name: Checkout uipath-python - uses: actions/checkout@v4 - with: - path: 'uipath-python' - - name: Setup uv uses: astral-sh/setup-uv@v5 @@ -32,9 +60,11 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Build uipath-python package - working-directory: uipath-python - run: uv build + - name: Download wheels + uses: actions/download-artifact@v4 + with: + name: uipath-wheels + path: wheels - name: Checkout uipath-langchain-python uses: actions/checkout@v4 @@ -42,10 +72,13 @@ jobs: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' - - name: Update uipath-python version + - name: Update uipath packages shell: bash working-directory: uipath-langchain-python - run: uv add ../uipath-python/dist/*.whl --dev + run: | + uv add ../wheels/uipath-core/dist/*.whl --dev + uv add ../wheels/uipath-platform/dist/*.whl --dev + uv add ../wheels/uipath/dist/*.whl --dev - name: Run uipath-langchain tests working-directory: uipath-langchain-python @@ -83,7 +116,7 @@ jobs: run-uipath-langchain-integration-tests: runs-on: ubuntu-latest - needs: [discover-testcases] + needs: [build-wheels, discover-testcases] container: image: ghcr.io/astral-sh/uv:python3.12-bookworm env: @@ -101,14 +134,11 @@ jobs: name: "${{ matrix.testcase }} / ${{ matrix.environment }} / ${{ matrix.use_azure_chat && 'UiPathAzureChatOpenAI' || 'UiPathChat' }}" steps: - - name: Checkout uipath-python - uses: actions/checkout@v4 + - name: Download wheels + uses: actions/download-artifact@v4 with: - path: 'uipath-python' - - - name: Build uipath-python package - working-directory: uipath-python - run: uv build + name: uipath-wheels + path: wheels - name: Checkout uipath-langchain-python uses: actions/checkout@v4 @@ -116,10 +146,13 @@ jobs: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' - - name: Update uipath-python version + - name: Update uipath packages shell: bash working-directory: uipath-langchain-python - run: uv add ../uipath-python/dist/*.whl + run: | + uv add ../wheels/uipath-core/dist/*.whl + uv add ../wheels/uipath-platform/dist/*.whl + uv add ../wheels/uipath/dist/*.whl - name: Install dependencies working-directory: uipath-langchain-python @@ -142,4 +175,3 @@ jobs: # Execute the testcase run script directly bash run.sh bash ../common/validate_output.sh - diff --git a/.github/workflows/test-uipath-llamaindex.yml b/.github/workflows/test-uipath-llamaindex.yml index e02b6fd53..fcf8d0fb4 100644 --- a/.github/workflows/test-uipath-llamaindex.yml +++ b/.github/workflows/test-uipath-llamaindex.yml @@ -1,11 +1,47 @@ -name: Test UiPath LlamaIndex +name: uipath - Test LlamaIndex on: pull_request: types: [ opened, synchronize, reopened, labeled ] jobs: + build-wheels: + runs-on: ubuntu-latest + permissions: + contents: read + if: contains(github.event.pull_request.labels.*.name, 'test:uipath-llamaindex') + steps: + - name: Checkout uipath-python + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build uipath-core package + working-directory: packages/uipath-core + run: uv build + + - name: Build uipath-platform package + working-directory: packages/uipath-platform + run: uv build + + - name: Build uipath package + working-directory: packages/uipath + run: uv build + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: uipath-wheels + path: packages/*/dist/*.whl + test-uipath-llamaindex: + needs: [build-wheels] runs-on: ${{ matrix.os }} strategy: matrix: @@ -15,15 +51,7 @@ jobs: permissions: contents: read - # Only run if PR has the test:uipath-llamaindex label - if: contains(github.event.pull_request.labels.*.name, 'test:uipath-llamaindex') - steps: - - name: Checkout uipath-python - uses: actions/checkout@v4 - with: - path: 'uipath-python' - - name: Setup uv uses: astral-sh/setup-uv@v5 @@ -32,9 +60,11 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Build uipath-python package - working-directory: uipath-python - run: uv build + - name: Download wheels + uses: actions/download-artifact@v4 + with: + name: uipath-wheels + path: wheels - name: Checkout uipath-integrations-python uses: actions/checkout@v4 @@ -42,10 +72,13 @@ jobs: repository: 'UiPath/uipath-integrations-python' path: 'uipath-integrations-python' - - name: Update uipath-python version + - name: Update uipath packages shell: bash working-directory: uipath-integrations-python/packages/uipath-llamaindex - run: uv add ../../../uipath-python/dist/*.whl --dev + run: | + uv add ../../../wheels/uipath-core/dist/*.whl --dev + uv add ../../../wheels/uipath-platform/dist/*.whl --dev + uv add ../../../wheels/uipath/dist/*.whl --dev - name: Run uipath-llamaindex tests working-directory: uipath-integrations-python/packages/uipath-llamaindex @@ -83,7 +116,7 @@ jobs: run-uipath-llamaindex-integration-tests: runs-on: ubuntu-latest - needs: [discover-testcases] + needs: [build-wheels, discover-testcases] container: image: ghcr.io/astral-sh/uv:python3.12-bookworm env: @@ -100,14 +133,11 @@ jobs: name: "${{ matrix.testcase }} / ${{ matrix.environment }}" steps: - - name: Checkout uipath-python - uses: actions/checkout@v4 + - name: Download wheels + uses: actions/download-artifact@v4 with: - path: 'uipath-python' - - - name: Build uipath-python package - working-directory: uipath-python - run: uv build + name: uipath-wheels + path: wheels - name: Checkout uipath-integrations-python uses: actions/checkout@v4 @@ -115,10 +145,13 @@ jobs: repository: 'UiPath/uipath-integrations-python' path: 'uipath-integrations-python' - - name: Update uipath-python version + - name: Update uipath packages shell: bash working-directory: uipath-integrations-python/packages/uipath-llamaindex - run: uv add ../../../uipath-python/dist/*.whl + run: | + uv add ../../../wheels/uipath-core/dist/*.whl + uv add ../../../wheels/uipath-platform/dist/*.whl + uv add ../../../wheels/uipath/dist/*.whl - name: Install dependencies working-directory: uipath-integrations-python/packages/uipath-llamaindex diff --git a/.github/workflows/test-uipath-runtime.yml b/.github/workflows/test-uipath-runtime.yml new file mode 100644 index 000000000..13ad019ef --- /dev/null +++ b/.github/workflows/test-uipath-runtime.yml @@ -0,0 +1,76 @@ +name: uipath-core - Test Runtime + +on: + pull_request: + types: [ opened, synchronize, reopened, labeled ] + +jobs: + build-wheels: + runs-on: ubuntu-latest + permissions: + contents: read + if: contains(github.event.pull_request.labels.*.name, 'test:uipath-runtime') + steps: + - name: Checkout uipath-python + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build uipath-core package + working-directory: packages/uipath-core + run: uv build + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: uipath-core-wheel + path: packages/uipath-core/dist/*.whl + + test-uipath-runtime: + needs: [build-wheels] + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: [ "3.11", "3.12", "3.13" ] + os: [ ubuntu-latest, windows-latest ] + + permissions: + contents: read + + steps: + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Download wheels + uses: actions/download-artifact@v4 + with: + name: uipath-core-wheel + path: wheels + + - name: Checkout uipath-runtime-python + uses: actions/checkout@v4 + with: + repository: 'UiPath/uipath-runtime-python' + path: 'uipath-runtime-python' + + - name: Update uipath-core version + shell: bash + working-directory: uipath-runtime-python + run: uv add ../wheels/*.whl --dev + + - name: Run uipath-runtime tests + working-directory: uipath-runtime-python + run: | + uv sync --all-extras + uv run pytest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index e091b0f7f..000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Test - -on: - workflow_call - -jobs: - test: - name: Test - runs-on: ${{ matrix.os }} - timeout-minutes: 10 - strategy: - matrix: - python-version: ["3.11", "3.12", "3.13"] - os: [ubuntu-latest, windows-latest] - - permissions: - contents: read - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup uv - uses: astral-sh/setup-uv@v5 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: uv sync --all-extras - - - name: Run tests - run: uv run pytest - - continue-on-error: true - diff --git a/.python-version b/packages/uipath-core/.python-version similarity index 100% rename from .python-version rename to packages/uipath-core/.python-version diff --git a/packages/uipath-core/CONTRIBUTING.md b/packages/uipath-core/CONTRIBUTING.md new file mode 100644 index 000000000..ea263320d --- /dev/null +++ b/packages/uipath-core/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing to UiPath Runtime SDK + +## Local Development Setup + +### Prerequisites + +1. **Install Python ≥ 3.11**: + - Download and install Python 3.11 from the official [Python website](https://www.python.org/downloads/) + - Verify the installation by running: + ```sh + python3.11 --version + ``` + + Alternative: [mise](https://mise.jdx.dev/lang/python.html) + +2. **Install [uv](https://docs.astral.sh/uv/)**: + Follow the official installation instructions for your operating system. + +3. **Create a virtual environment in the current working directory**: + ```sh + uv venv + ``` + +4. **Activate the virtual environment**: + - Linux/Mac + ```sh + source .venv/bin/activate + ``` + - Windows Powershell + ```sh + .venv\Scripts\Activate.ps1 + ``` + - Windows Bash + ```sh + source .venv/Scripts/activate + ``` + +5. **Install dependencies**: + ```sh + uv sync --all-extras --no-cache + ``` + +For additional commands related to linting, formatting, and building, run `just --list`. + +### Using the SDK Locally + +1. Create a project directory: + ```sh + mkdir project + cd project + ``` + +2. Initialize the Python project: + ```sh + uv init . --python 3.11 + ``` + +3. Set the SDK path: + ```sh + PATH_TO_SDK=/Users/YOUR_USERNAME/uipath-core-python + ``` + +4. Install the SDK in editable mode: + ```sh + uv add --editable ${PATH_TO_SDK} + ``` + +> **Note:** Instead of cloning the project into `.venv/lib/python3.11/site-packages/uipath-core`, this mode creates a file named `_uipath-core.pth` inside `.venv/lib/python3.11/site-packages`. This file contains the value of `PATH_TO_SDK`, which is added to `sys.path`—the list of directories where Python searches for packages. To view the entries, run `python -c 'import sys; print(sys.path)'`. diff --git a/packages/uipath-core/README.md b/packages/uipath-core/README.md new file mode 100644 index 000000000..69d9d952e --- /dev/null +++ b/packages/uipath-core/README.md @@ -0,0 +1,103 @@ +# UiPath Core + +[![PyPI - Version](https://img.shields.io/pypi/v/uipath-core)](https://pypi.org/project/uipath-core/) +[![PyPI downloads](https://img.shields.io/pypi/dm/uipath-core.svg)](https://pypi.org/project/uipath-core/) +[![Python versions](https://img.shields.io/pypi/pyversions/uipath-core.svg)](https://pypi.org/project/uipath-core/) + +Core abstractions and contracts for the UiPath Python SDK. + +## Installation + +```bash +pip install uipath-core +``` + +## Modules + +### Errors + +Exception hierarchy for UiPath trigger errors with category-based classification. + +- **`ErrorCategory`**: Enum: `DEPLOYMENT`, `SYSTEM`, `UNKNOWN`, `USER` +- **`UiPathFaultedTriggerError`**: Base trigger error with category and detail +- **`UiPathPendingTriggerError`**: Pending trigger variant + +```python +from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError +``` + +### Serialization + +JSON serialization utilities for complex Python types. Handles Pydantic models (v1 & v2), dataclasses, enums, datetime/timezone objects, sets, tuples, and named tuples. + +- **`serialize_json(obj)`**: Serialize any object to a JSON string +- **`serialize_defaults(obj)`**: Custom `default` handler for `json.dumps()` + +```python +from uipath.core.serialization import serialize_json +``` + +### Tracing + +OpenTelemetry integration with UiPath execution tracking. Provides function instrumentation, span lifecycle management, custom exporters, and batch/simple span processors with automatic `execution.id` propagation. + +- **`@traced`**: Decorator for sync/async function instrumentation. Supports custom span names, run types, input/output processors, and non-recording spans +- **`UiPathTraceManager`**: Manages `TracerProvider`, span exporters, and processors. Provides `start_execution_span()` context manager and span retrieval by execution ID +- **`UiPathSpanUtils`**: Span registry and parent context management +- **`UiPathTraceSettings`**: Configuration model with optional span filtering + +```python +from uipath.core.tracing import traced, UiPathTraceManager + +@traced(name="my_operation", run_type="tool") +def do_work(input: str) -> str: + return process(input) +``` + +### Guardrails + +Deterministic rule-based validation for inputs and outputs. Rules are evaluated pre-execution (input-only) and post-execution (all rules), with flexible field selection using dot-notation paths and array access (`[*]`). + +**Rule types:** +- **`WordRule`**: String pattern matching +- **`NumberRule`**: Numeric constraint validation +- **`BooleanRule`**: Boolean assertions +- **`UniversalRule`**: Always-apply constraints + +**Field selection:** +- **`AllFieldsSelector`**: Apply to all fields of a given source (input/output) +- **`SpecificFieldsSelector`**: Target specific fields by path + +**Service:** +- **`DeterministicGuardrailsService`**: Evaluates guardrail rules against inputs/outputs, returning `GuardrailValidationResult` with pass/fail status and reason + +```python +from uipath.core.guardrails import DeterministicGuardrailsService, GuardrailValidationResultType +``` + +### Chat + +Pydantic models for the UiPath conversation event protocol. Defines the streaming event schema between clients and LLM/agent backends. + +**Hierarchy:** +``` +Conversation → Exchange → Message → Content Parts (with Citations) + → Tool Calls (with Results) + → Interrupts (human-in-the-loop) +``` + +Supports session capabilities negotiation, async input streams (audio/video), tool call confirmation interrupts, URL and media citations, and inline/external value references. + +```python +from uipath.core.chat import UiPathConversationEvent, UiPathSessionStartEvent +``` + +## Dependencies + +| Package | Version | +|---|---| +| `pydantic` | `>=2.12.5, <3` | +| `opentelemetry-sdk` | `>=1.39.0, <2` | +| `opentelemetry-instrumentation` | `>=0.60b0, <1` | + + diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml new file mode 100644 index 000000000..306e9d9c6 --- /dev/null +++ b/packages/uipath-core/pyproject.toml @@ -0,0 +1,112 @@ +[project] +name = "uipath-core" +version = "0.5.5" +description = "UiPath Core abstractions" +readme = { file = "README.md", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = [ + "opentelemetry-sdk>=1.39.0, <2.0.0", + "opentelemetry-instrumentation>=0.60b0, <1.0.0", + "pydantic>=2.12.5, <3.0.0", +] +classifiers = [ + "Intended Audience :: Developers", + "Topic :: Software Development :: Build Tools", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + +] +maintainers = [ + { name = "Marius Cosareanu", email = "marius.cosareanu@uipath.com" }, + { name = "Cristian Pufu", email = "cristian.pufu@uipath.com" }, +] + +[project.urls] +Homepage = "https://uipath.com" +Repository = "https://github.com/UiPath/uipath-python" +Documentation = "https://uipath.github.io/uipath-python/" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "bandit>=1.8.2", + "mypy>=1.14.1", + "ruff>=0.9.4", + "rust-just>=1.39.0", + "pytest>=7.4.0", + "pytest-asyncio>=1.0.0", + "pytest-httpx>=0.35.0", + "pytest-trio>=0.8.0", + "pytest-cov>=4.1.0", + "pytest-mock>=3.11.1", + "pre-commit>=4.1.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/uipath"] + +[tool.ruff] +line-length = 88 +indent-width = 4 +exclude = ["samples/**", "testcases/**"] + +[tool.ruff.lint] +select = ["E", "F", "B", "I", "D"] +ignore = ["D417"] + +[tool.ruff.lint.pydocstyle] +convention = "google" +ignore-decorators = [] + +[tool.ruff.lint.per-file-ignores] +"*" = ["E501"] +"tests/**" = ["D"] +"*_test.py" = ["D"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.mypy] +plugins = ["pydantic.mypy"] +mypy_path = "src" +explicit_package_bases = true +namespace_packages = true +follow_imports = "silent" +warn_redundant_casts = true +warn_unused_ignores = true +disallow_any_generics = true +check_untyped_defs = true +no_implicit_reexport = true + +disallow_untyped_defs = false + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +addopts = "-ra -q --cov=src/uipath --cov-report=term-missing" +asyncio_default_fixture_loop_scope = "function" +asyncio_mode = "auto" + +[tool.coverage.report] +show_missing = true + +[tool.coverage.run] +source = ["src"] + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true diff --git a/packages/uipath-core/src/uipath/core/__init__.py b/packages/uipath-core/src/uipath/core/__init__.py new file mode 100644 index 000000000..fd87f2efc --- /dev/null +++ b/packages/uipath-core/src/uipath/core/__init__.py @@ -0,0 +1,11 @@ +"""UiPath Core Package.""" + +from uipath.core.tracing.decorators import traced +from uipath.core.tracing.span_utils import UiPathSpanUtils +from uipath.core.tracing.trace_manager import UiPathTraceManager + +__all__ = [ + "traced", + "UiPathSpanUtils", + "UiPathTraceManager", +] diff --git a/packages/uipath-core/src/uipath/core/chat/__init__.py b/packages/uipath-core/src/uipath/core/chat/__init__.py new file mode 100644 index 000000000..476cb9352 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/__init__.py @@ -0,0 +1,192 @@ +"""UiPath Conversation Models. + +This module provides Pydantic models that represent the JSON event schema for conversations between a client (UI) and an LLM/agent. + +The event objects define a hierarchical conversation structure: + +* Conversation + * Exchange + * Message + * Content Parts + * Citations + * Tool Calls + * Tool Results + + A conversation may contain multiple exchanges, and an exchange may contain multiple messages. A message may contain + multiple content parts, each of which can be text or binary, including media input and output streams; and each + content part can include multiple citations. A message may also contain multiple tool calls, which may contain a tool + result. + + The protocol also supports a top level, "async", input media streams (audio and video), which can span multiple + exchanges. These are used for Gemini's automatic turn detection mode, where the LLM determines when the user has + stopped talking and starts producing output. The output forms one or more messages in an exchange with no explicit + input message. However, the LLM may produce an input transcript which can be used to construct the implicit input + message that started the exchange. + + In addition, the protocol also supports "async" tool calls that span multiple exchanges. This can be used with + Gemini's asynchronous function calling protocol, which allows function calls to produce results that interrupt the + conversation when ready, even after multiple exchanges. They also support generating multiple results from a single + tool call. By contrast most tool calls are scoped to a single message, which contains both the call and the single + result produced by that call. + + Not all features supported by the protocol will be supported by all clients and LLMs. The optional top level + `capabilities` property can be used to communicate information about supported features. This property should be set + on the first event written to a new websocket connection. This initial event may or may not contain additional + sub-events. +""" + +from .async_stream import ( + UiPathConversationAsyncInputStreamEndEvent, + UiPathConversationAsyncInputStreamEvent, + UiPathConversationAsyncInputStreamStartEvent, + UiPathConversationInputStreamChunkEvent, +) +from .citation import ( + UiPathConversationCitation, + UiPathConversationCitationData, + UiPathConversationCitationEndEvent, + UiPathConversationCitationEvent, + UiPathConversationCitationSource, + UiPathConversationCitationSourceBase, + UiPathConversationCitationSourceMedia, + UiPathConversationCitationSourceUrl, + UiPathConversationCitationStartEvent, +) +from .content import ( + InlineOrExternal, + UiPathContentPartInterrupted, + UiPathConversationContentPart, + UiPathConversationContentPartChunkEvent, + UiPathConversationContentPartData, + UiPathConversationContentPartEndEvent, + UiPathConversationContentPartEvent, + UiPathConversationContentPartStartEvent, + UiPathExternalValue, + UiPathInlineValue, +) +from .error import ( + UiPathConversationErrorEndEvent, + UiPathConversationErrorEvent, + UiPathConversationErrorStartEvent, +) +from .event import UiPathConversationEvent, UiPathConversationLabelUpdatedEvent +from .exchange import ( + UiPathConversationExchange, + UiPathConversationExchangeData, + UiPathConversationExchangeEndEvent, + UiPathConversationExchangeEvent, + UiPathConversationExchangeStartEvent, +) +from .interrupt import ( + InterruptTypeEnum, + UiPathConversationGenericInterruptEndEvent, + UiPathConversationGenericInterruptStartEvent, + UiPathConversationInterrupt, + UiPathConversationInterruptData, + UiPathConversationInterruptEndEvent, + UiPathConversationInterruptEvent, + UiPathConversationInterruptStartEvent, + UiPathConversationToolCallConfirmationEndValue, + UiPathConversationToolCallConfirmationInterruptEndEvent, + UiPathConversationToolCallConfirmationInterruptStartEvent, + UiPathConversationToolCallConfirmationValue, +) +from .message import ( + UiPathConversationMessage, + UiPathConversationMessageData, + UiPathConversationMessageEndEvent, + UiPathConversationMessageEvent, + UiPathConversationMessageStartEvent, +) +from .meta import UiPathConversationMetaEvent +from .session import ( + UiPathSessionCapabilities, + UiPathSessionEndEvent, + UiPathSessionEndingEvent, + UiPathSessionStartedEvent, + UiPathSessionStartEvent, +) +from .tool import ( + UiPathConversationToolCall, + UiPathConversationToolCallData, + UiPathConversationToolCallEndEvent, + UiPathConversationToolCallEvent, + UiPathConversationToolCallResult, + UiPathConversationToolCallStartEvent, +) + +__all__ = [ + # Root + "UiPathConversationEvent", + "UiPathConversationLabelUpdatedEvent", + # Error + "UiPathConversationErrorStartEvent", + "UiPathConversationErrorEndEvent", + "UiPathConversationErrorEvent", + # Session + "UiPathSessionCapabilities", + "UiPathSessionStartEvent", + "UiPathSessionStartedEvent", + "UiPathSessionEndingEvent", + "UiPathSessionEndEvent", + # Exchange + "UiPathConversationExchangeStartEvent", + "UiPathConversationExchangeEndEvent", + "UiPathConversationExchangeEvent", + "UiPathConversationExchangeData", + "UiPathConversationExchange", + # Message + "UiPathConversationMessageStartEvent", + "UiPathConversationMessageEndEvent", + "UiPathConversationMessageEvent", + "UiPathConversationMessageData", + "UiPathConversationMessage", + # Interrupt + "InterruptTypeEnum", + "UiPathConversationInterruptStartEvent", + "UiPathConversationInterruptEndEvent", + "UiPathConversationInterruptEvent", + "UiPathConversationToolCallConfirmationValue", + "UiPathConversationToolCallConfirmationEndValue", + "UiPathConversationToolCallConfirmationInterruptStartEvent", + "UiPathConversationToolCallConfirmationInterruptEndEvent", + "UiPathConversationGenericInterruptStartEvent", + "UiPathConversationGenericInterruptEndEvent", + "UiPathConversationInterruptData", + "UiPathConversationInterrupt", + # Content + "UiPathConversationContentPartChunkEvent", + "UiPathConversationContentPartStartEvent", + "UiPathContentPartInterrupted", + "UiPathConversationContentPartEndEvent", + "UiPathConversationContentPartEvent", + "UiPathConversationContentPartData", + "UiPathConversationContentPart", + "UiPathInlineValue", + "UiPathExternalValue", + "InlineOrExternal", + # Citation + "UiPathConversationCitationStartEvent", + "UiPathConversationCitationEndEvent", + "UiPathConversationCitationEvent", + "UiPathConversationCitationSourceBase", + "UiPathConversationCitationSourceUrl", + "UiPathConversationCitationSourceMedia", + "UiPathConversationCitationSource", + "UiPathConversationCitationData", + "UiPathConversationCitation", + # Tool + "UiPathConversationToolCallStartEvent", + "UiPathConversationToolCallEndEvent", + "UiPathConversationToolCallEvent", + "UiPathConversationToolCallResult", + "UiPathConversationToolCallData", + "UiPathConversationToolCall", + # Async Stream + "UiPathConversationInputStreamChunkEvent", + "UiPathConversationAsyncInputStreamStartEvent", + "UiPathConversationAsyncInputStreamEndEvent", + "UiPathConversationAsyncInputStreamEvent", + # Meta + "UiPathConversationMetaEvent", +] diff --git a/packages/uipath-core/src/uipath/core/chat/async_stream.py b/packages/uipath-core/src/uipath/core/chat/async_stream.py new file mode 100644 index 000000000..64bea1e01 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/async_stream.py @@ -0,0 +1,60 @@ +"""Async input stream events.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from .error import UiPathConversationErrorEvent + + +class UiPathConversationInputStreamChunkEvent(BaseModel): + """Represents a single chunk of input stream data.""" + + data: str + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationAsyncInputStreamStartEvent(BaseModel): + """Signals the start of an asynchronous input stream.""" + + mime_type: str = Field(..., alias="mimeType") + start_of_speech_sensitivity: str | None = Field( + None, alias="startOfSpeechSensitivity" + ) + end_of_speech_sensitivity: str | None = Field(None, alias="endOfSpeechSensitivity") + prefix_padding_ms: int | None = Field(None, alias="prefixPaddingMs") + silence_duration_ms: int | None = Field(None, alias="silenceDurationMs") + metadata: dict[str, Any] | None = Field(None, alias="metaData") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationAsyncInputStreamEndEvent(BaseModel): + """Signals the end of an asynchronous input stream.""" + + metadata: dict[str, Any] | None = Field(None, alias="metaData") + last_chunk_content_part_sequence: int | None = Field( + None, alias="lastChunkContentPartSequence" + ) + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationAsyncInputStreamEvent(BaseModel): + """Encapsulates sub-events related to an asynchronous input stream.""" + + stream_id: str = Field(..., alias="streamId") + start: UiPathConversationAsyncInputStreamStartEvent | None = Field( + None, alias="startAsyncInputStream" + ) + end: UiPathConversationAsyncInputStreamEndEvent | None = Field( + None, alias="endAsyncInputStream" + ) + chunk: UiPathConversationInputStreamChunkEvent | None = None + meta_event: dict[str, Any] | None = Field(None, alias="metaEvent") + error: UiPathConversationErrorEvent | None = Field( + None, alias="asyncInputStreamError" + ) + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/citation.py b/packages/uipath-core/src/uipath/core/chat/citation.py new file mode 100644 index 000000000..326c79c28 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/citation.py @@ -0,0 +1,86 @@ +"""Citation events for message content.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from .error import UiPathConversationErrorEvent + + +class UiPathConversationCitationStartEvent(BaseModel): + """Indicates the start of a citation target in a content part.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationCitationEndEvent(BaseModel): + """Indicates the end of a citation target in a content part.""" + + sources: list[UiPathConversationCitationSource] + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationCitationEvent(BaseModel): + """Encapsulates sub-events related to citations.""" + + citation_id: str = Field(..., alias="citationId") + start: UiPathConversationCitationStartEvent | None = Field( + None, alias="startCitation" + ) + end: UiPathConversationCitationEndEvent | None = Field(None, alias="endCitation") + error: UiPathConversationErrorEvent | None = Field(None, alias="citationError") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationCitationSourceBase(BaseModel): + """Represents a citation source with common base fields.""" + + title: str + number: int + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationCitationSourceUrl(UiPathConversationCitationSourceBase): + """Represents a citation source that can be rendered as a link (URL).""" + + url: str + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationCitationSourceMedia(UiPathConversationCitationSourceBase): + """Represents a citation source that references media, such as a PDF document.""" + + mime_type: str | None = Field(..., alias="mimeType") + download_url: str | None = Field(None, alias="downloadUrl") + page_number: str | None = Field(None, alias="pageNumber") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +UiPathConversationCitationSource = ( + UiPathConversationCitationSourceUrl | UiPathConversationCitationSourceMedia +) + + +class UiPathConversationCitationData(BaseModel): + """Represents the core data of a citation or reference inside a content part.""" + + offset: int + length: int + sources: list[UiPathConversationCitationSource] + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationCitation(UiPathConversationCitationData): + """Represents a citation or reference inside a content part.""" + + citation_id: str = Field(..., alias="citationId") + created_at: str = Field(..., alias="createdAt") + updated_at: str = Field(..., alias="updatedAt") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/content.py b/packages/uipath-core/src/uipath/core/chat/content.py new file mode 100644 index 000000000..cc6300490 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/content.py @@ -0,0 +1,116 @@ +"""Message content part events.""" + +from __future__ import annotations + +from typing import Any, Sequence + +from pydantic import BaseModel, ConfigDict, Field + +from .citation import ( + UiPathConversationCitation, + UiPathConversationCitationData, + UiPathConversationCitationEvent, +) +from .error import UiPathConversationErrorEvent + + +class UiPathConversationContentPartChunkEvent(BaseModel): + """Contains a chunk of a message content part.""" + + data: str | None = None + citation: UiPathConversationCitationEvent | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationContentPartStartEvent(BaseModel): + """Signals the start of a message content part.""" + + mime_type: str = Field(..., alias="mimeType") + metadata: dict[str, Any] | None = Field(None, alias="metaData") + external_value: UiPathExternalValue | None = Field(None, alias="externalValue") + name: str | None = None + timestamp: str | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathContentPartInterrupted(BaseModel): + """Indicates the interrupt of a content stream.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationContentPartEndEvent(BaseModel): + """Signals the end of a message content part.""" + + last_chunk_content_part_sequence: int | None = Field( + None, alias="lastChunkContentPartSequence" + ) + interrupted: UiPathContentPartInterrupted | None = None + metadata: dict[str, Any] | None = Field(None, alias="metaData") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationContentPartEvent(BaseModel): + """Encapsulates events related to message content parts.""" + + content_part_id: str = Field(..., alias="contentPartId") + start: UiPathConversationContentPartStartEvent | None = Field( + None, alias="startContentPart" + ) + end: UiPathConversationContentPartEndEvent | None = Field( + None, alias="endContentPart" + ) + chunk: UiPathConversationContentPartChunkEvent | None = None + meta_event: dict[str, Any] | None = Field(None, alias="metaEvent") + error: UiPathConversationErrorEvent | None = Field(None, alias="contentPartError") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathInlineValue(BaseModel): + """Used when a value is small enough to be returned inline.""" + + inline: Any + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathExternalValue(BaseModel): + """Used when a value is too large to be returned inline.""" + + uri: str + byte_count: int | None = Field(None, alias="byteCount") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +InlineOrExternal = UiPathInlineValue | UiPathExternalValue + + +class UiPathConversationContentPartData(BaseModel): + """Represents the core data of a single part of message content.""" + + mime_type: str = Field(..., alias="mimeType") + data: InlineOrExternal + citations: Sequence[UiPathConversationCitationData] + is_transcript: bool | None = Field(None, alias="isTranscript") + is_incomplete: bool | None = Field(None, alias="isIncomplete") + name: str | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationContentPart(UiPathConversationContentPartData): + """Represents a single part of message content.""" + + content_part_id: str = Field(..., alias="contentPartId") + created_at: str = Field(..., alias="createdAt") + updated_at: str = Field(..., alias="updatedAt") + + # Override to use full type + citations: Sequence[UiPathConversationCitation] + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/error.py b/packages/uipath-core/src/uipath/core/chat/error.py new file mode 100644 index 000000000..7fd691fe0 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/error.py @@ -0,0 +1,34 @@ +"""Common error event models used across all conversation event types.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class UiPathConversationErrorStartEvent(BaseModel): + """Represents the start of an error condition.""" + + message: str + details: Any | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationErrorEndEvent(BaseModel): + """Represents the end of an error condition.""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationErrorEvent(BaseModel): + """Encapsulates sub-events that represent the start and end of an error condition. + + This is a common error event model used across all event types (conversation, exchange, + message, content part, citation, tool call, async input stream). + """ + + error_id: str = Field(..., alias="errorId") + start: UiPathConversationErrorStartEvent | None = Field(None, alias="startError") + end: UiPathConversationErrorEndEvent | None = Field(None, alias="endError") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/event.py b/packages/uipath-core/src/uipath/core/chat/event.py new file mode 100644 index 000000000..492d45f19 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/event.py @@ -0,0 +1,93 @@ +"""The top-level event type representing an event in a conversation. + +This is the root container for all other event subtypes (conversation start, +exchanges, messages, content, citations, tool calls, and async streams). +""" + +from pydantic import BaseModel, ConfigDict, Field + +from .async_stream import UiPathConversationAsyncInputStreamEvent +from .error import UiPathConversationErrorEvent +from .exchange import UiPathConversationExchangeEvent +from .meta import UiPathConversationMetaEvent +from .session import ( + UiPathSessionEndEvent, + UiPathSessionEndingEvent, + UiPathSessionStartedEvent, + UiPathSessionStartEvent, +) +from .tool import UiPathConversationToolCallEvent + + +class UiPathConversationLabelUpdatedEvent(BaseModel): + """Indicates the update of the conversation label.""" + + label: str + autogenerated: bool + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationEvent(BaseModel): + """The top-level event type representing an event in a conversation. + + This is the root container for all other event subtypes (conversation start, + exchanges, messages, content, citations, tool calls, and async streams). + """ + + conversation_id: str = Field( + ..., + alias="conversationId", + description="A globally unique identifier for conversation to which the other sub-event and data properties apply.", + ) + start: UiPathSessionStartEvent | None = Field( + None, + alias="startSession", + description="Signals the start of session for a conversation.", + ) + started: UiPathSessionStartedEvent | None = Field( + None, + alias="sessionStarted", + description="Sent in response to a SessionStartEvent to signal the acceptance of the session.", + ) + ending: UiPathSessionEndingEvent | None = Field( + None, + alias="sessionEnding", + description="Sent by the service when the client needs to end the current session.", + ) + end: UiPathSessionEndEvent | None = Field( + None, + alias="endSession", + description="Signals the end of a session for a conversation.", + ) + exchange: UiPathConversationExchangeEvent | None = Field( + None, + description="Encapsulates sub-events related to an exchange within a conversation.", + ) + async_input_stream: UiPathConversationAsyncInputStreamEvent | None = Field( + None, + alias="asyncInputStream", + description="Encapsulates sub-events related to an asynchronous input stream.", + ) + async_tool_call: UiPathConversationToolCallEvent | None = Field( + None, + alias="asyncToolCall", + description="Optional async tool call sub-event. This feature is not supported by all LLMs. Most tool calls are scoped to a message, and use the toolCall and toolResult properties defined by the ConversationMessage type.", + ) + label_updated: "UiPathConversationLabelUpdatedEvent | None" = Field( + None, + alias="labelUpdated", + description="Indicates that the conversation's label has been updated. Useful for UIs to update the display name of live conversations.", + ) + meta_event: UiPathConversationMetaEvent | None = Field( + None, + alias="metaEvent", + description="Allows additional events to be sent in the context of the enclosing event stream.", + ) + error: UiPathConversationErrorEvent | None = Field( + None, + alias="conversationError", + description="Sent by the service to indicate the start and end of an error condition impacting a conversation. The client should not send any events for this conversation while the error condition is present.", + ) + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/exchange.py b/packages/uipath-core/src/uipath/core/chat/exchange.py new file mode 100644 index 000000000..788bbe560 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/exchange.py @@ -0,0 +1,83 @@ +"""Exchange-level events. + +Characteristics of an Exchange: +It groups together messages that belong to the same turn of conversation. + +Example: + User says something → one message inside the exchange. + LLM responds → one or more messages in the same exchange. + +Each exchange has: + A start event (signals the beginning of the turn). + An end event (signals the end of the turn). + Messages that happened in between. + +An exchange can include multiple messages (e.g. LLM streaming several outputs, or user message + assistant + tool outputs). +Exchanges are ordered within a conversation via conversation_sequence. +""" + +from typing import Any, Sequence + +from pydantic import BaseModel, ConfigDict, Field + +from .error import UiPathConversationErrorEvent +from .message import ( + UiPathConversationMessage, + UiPathConversationMessageData, + UiPathConversationMessageEvent, +) + + +class UiPathConversationExchangeStartEvent(BaseModel): + """Signals the start of an exchange of messages within a conversation.""" + + conversation_sequence: int | None = Field(None, alias="conversationSequence") + metadata: dict[str, Any] | None = Field(None, alias="metaData") + timestamp: str | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationExchangeEndEvent(BaseModel): + """Signals the end of an exchange of messages within a conversation.""" + + metadata: dict[str, Any] | None = Field(None, alias="metaData") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationExchangeEvent(BaseModel): + """Encapsulates a single exchange in the conversation.""" + + exchange_id: str = Field(..., alias="exchangeId") + start: UiPathConversationExchangeStartEvent | None = Field( + None, alias="startExchange" + ) + end: UiPathConversationExchangeEndEvent | None = Field(None, alias="endExchange") + message: UiPathConversationMessageEvent | None = None + meta_event: dict[str, Any] | None = Field(None, alias="metaEvent") + error: UiPathConversationErrorEvent | None = Field(None, alias="exchangeError") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationExchangeData(BaseModel): + """Represents the core data of a group of related messages (one turn of conversation).""" + + messages: Sequence[UiPathConversationMessageData] + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationExchange(UiPathConversationExchangeData): + """Represents a group of related messages (one turn of conversation).""" + + exchange_id: str = Field(..., alias="exchangeId") + created_at: str = Field(..., alias="createdAt") + updated_at: str = Field(..., alias="updatedAt") + span_id: str | None = Field(None, alias="spanId") + + # Override to use full type + messages: Sequence[UiPathConversationMessage] + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/interrupt.py b/packages/uipath-core/src/uipath/core/chat/interrupt.py new file mode 100644 index 000000000..a2ce3e13f --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/interrupt.py @@ -0,0 +1,112 @@ +"""Interrupt events for human-in-the-loop patterns.""" + +from enum import Enum +from typing import Any, Literal, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class InterruptTypeEnum(str, Enum): + """Enum of known interrupt types.""" + + TOOL_CALL_CONFIRMATION = "uipath_cas_tool_call_confirmation" + + +class UiPathConversationToolCallConfirmationValue(BaseModel): + """Schema for tool call confirmation interrupt value.""" + + tool_call_id: str = Field(..., alias="toolCallId") + tool_name: str = Field(..., alias="toolName") + input_schema: Any = Field(..., alias="inputSchema") + input_value: Any | None = Field(None, alias="inputValue") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallConfirmationInterruptStartEvent(BaseModel): + """Tool call confirmation interrupt start event with strong typing.""" + + type: Literal["uipath_cas_tool_call_confirmation"] + value: UiPathConversationToolCallConfirmationValue + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationGenericInterruptStartEvent(BaseModel): + """Generic interrupt start event for custom interrupt types.""" + + type: str + value: Any + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +UiPathConversationInterruptStartEvent = Union[ + UiPathConversationToolCallConfirmationInterruptStartEvent, + UiPathConversationGenericInterruptStartEvent, +] + + +class UiPathConversationToolCallConfirmationEndValue(BaseModel): + """Schema for tool call confirmation end value.""" + + approved: bool + input: Any | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallConfirmationInterruptEndEvent(BaseModel): + """Tool call confirmation interrupt end event with strong typing.""" + + type: Literal["uipath_cas_tool_call_confirmation"] + value: UiPathConversationToolCallConfirmationEndValue + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationGenericInterruptEndEvent(BaseModel): + """Generic interrupt end event for custom interrupt types.""" + + type: str + value: Any + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +UiPathConversationInterruptEndEvent = Union[ + UiPathConversationToolCallConfirmationInterruptEndEvent, + UiPathConversationGenericInterruptEndEvent, +] + + +class UiPathConversationInterruptEvent(BaseModel): + """Encapsulates interrupt-related events within a message.""" + + interrupt_id: str = Field(..., alias="interruptId") + start: UiPathConversationInterruptStartEvent | None = Field( + None, alias="startInterrupt" + ) + end: UiPathConversationInterruptEndEvent | None = Field(None, alias="endInterrupt") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationInterruptData(BaseModel): + """Represents the core data of an interrupt within a message - a pause point where the agent needs external input.""" + + type: str + interrupt_value: Any = Field(..., alias="interruptValue") + end_value: Any | None = Field(None, alias="endValue") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationInterrupt(UiPathConversationInterruptData): + """Represents an interrupt within a message - a pause point where the agent needs external input.""" + + interrupt_id: str = Field(..., alias="interruptId") + created_at: str = Field(..., alias="createdAt") + updated_at: str = Field(..., alias="updatedAt") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/message.py b/packages/uipath-core/src/uipath/core/chat/message.py new file mode 100644 index 000000000..48e79171f --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/message.py @@ -0,0 +1,91 @@ +"""Message-level events.""" + +from typing import Any, Sequence + +from pydantic import BaseModel, ConfigDict, Field + +from .content import ( + UiPathConversationContentPart, + UiPathConversationContentPartData, + UiPathConversationContentPartEvent, +) +from .error import UiPathConversationErrorEvent +from .interrupt import ( + UiPathConversationInterrupt, + UiPathConversationInterruptData, + UiPathConversationInterruptEvent, +) +from .tool import ( + UiPathConversationToolCall, + UiPathConversationToolCallData, + UiPathConversationToolCallEvent, +) + + +class UiPathConversationMessageStartEvent(BaseModel): + """Signals the start of a message within an exchange.""" + + exchange_sequence: int | None = Field(None, alias="exchangeSequence") + timestamp: str | None = None + role: str + metadata: dict[str, Any] | None = Field(None, alias="metaData") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationMessageEndEvent(BaseModel): + """Signals the end of a message.""" + + metadata: dict[str, Any] | None = Field(None, alias="metaData") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationMessageEvent(BaseModel): + """Encapsulates sub-events related to a message.""" + + message_id: str = Field(..., alias="messageId") + start: UiPathConversationMessageStartEvent | None = Field( + None, alias="startMessage" + ) + end: UiPathConversationMessageEndEvent | None = Field(None, alias="endMessage") + content_part: UiPathConversationContentPartEvent | None = Field( + None, alias="contentPart" + ) + tool_call: UiPathConversationToolCallEvent | None = Field(None, alias="toolCall") + interrupt: UiPathConversationInterruptEvent | None = None + meta_event: dict[str, Any] | None = Field(None, alias="metaEvent") + error: UiPathConversationErrorEvent | None = Field(None, alias="messageError") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationMessageData(BaseModel): + """Represents the core data of a single message within an exchange.""" + + role: str + content_parts: Sequence[UiPathConversationContentPartData] = Field( + ..., alias="contentParts" + ) + tool_calls: Sequence[UiPathConversationToolCallData] = Field(..., alias="toolCalls") + interrupts: Sequence[UiPathConversationInterruptData] + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationMessage(UiPathConversationMessageData): + """Represents a single message within an exchange.""" + + message_id: str = Field(..., alias="messageId") + created_at: str = Field(..., alias="createdAt") + updated_at: str = Field(..., alias="updatedAt") + span_id: str | None = Field(None, alias="spanId") + + # Overrides to use full types + content_parts: Sequence[UiPathConversationContentPart] = Field( + ..., alias="contentParts" + ) + tool_calls: Sequence[UiPathConversationToolCall] = Field(..., alias="toolCalls") + interrupts: Sequence[UiPathConversationInterrupt] + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/meta.py b/packages/uipath-core/src/uipath/core/chat/meta.py new file mode 100644 index 000000000..574737ef4 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/meta.py @@ -0,0 +1,11 @@ +"""Meta events allow additional extensible data.""" + +from pydantic import BaseModel, ConfigDict + + +class UiPathConversationMetaEvent(BaseModel): + """Arbitrary metadata events in the conversation schema.""" + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) diff --git a/packages/uipath-core/src/uipath/core/chat/session.py b/packages/uipath-core/src/uipath/core/chat/session.py new file mode 100644 index 000000000..d0156b196 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/session.py @@ -0,0 +1,57 @@ +"""Conversation-level events and capabilities.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class UiPathSessionCapabilities(BaseModel): + """Describes the capabilities of a conversation participant.""" + + async_input_stream_emitter: bool | None = Field( + None, alias="asyncInputStreamEmitter" + ) + async_input_stream_handler: bool | None = Field( + None, alias="asyncInputStreamHandler" + ) + async_tool_call_emitter: bool | None = Field(None, alias="asyncToolCallEmitter") + async_tool_call_handler: bool | None = Field(None, alias="asyncToolCallHandler") + mime_types_emitted: list[str] | None = Field(None, alias="mimeTypesEmitted") + mime_types_handled: list[str] | None = Field(None, alias="mimeTypesHandled") + + model_config = ConfigDict( + validate_by_name=True, validate_by_alias=True, extra="allow" + ) + + +class UiPathSessionStartEvent(BaseModel): + """Signals the start of session for a conversation.""" + + capabilities: UiPathSessionCapabilities | None = None + metadata: dict[str, Any] | None = Field(None, alias="metaData") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathSessionStartedEvent(BaseModel): + """Sent in response to a SessionStartEvent to signal the acceptance of the session.""" + + capabilities: UiPathSessionCapabilities | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathSessionEndingEvent(BaseModel): + """Sent by the service when the client needs to end the current session.""" + + time_to_live_ms: int = Field(..., alias="timeToLiveMS") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathSessionEndEvent(BaseModel): + """Signals the end of a session for a conversation.""" + + metadata: dict[str, Any] | None = Field(None, alias="metaData") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/chat/tool.py b/packages/uipath-core/src/uipath/core/chat/tool.py new file mode 100644 index 000000000..9c9e911bd --- /dev/null +++ b/packages/uipath-core/src/uipath/core/chat/tool.py @@ -0,0 +1,76 @@ +"""Tool call events.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from .error import UiPathConversationErrorEvent + + +class UiPathConversationToolCallResult(BaseModel): + """Represents the result of a tool call execution.""" + + timestamp: str | None = None + output: Any | None = None + is_error: bool | None = Field(None, alias="isError") + cancelled: bool | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallStartEvent(BaseModel): + """Signals the start of a tool call.""" + + tool_name: str = Field(..., alias="toolName") + timestamp: str | None = None + input: dict[str, Any] | None = None + metadata: dict[str, Any] | None = Field(None, alias="metaData") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallEndEvent(BaseModel): + """Signals the end of a tool call.""" + + timestamp: str | None = None + output: Any = None + is_error: bool | None = Field(None, alias="isError") + cancelled: bool | None = None + metadata: dict[str, Any] | None = Field(None, alias="metaData") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallEvent(BaseModel): + """Encapsulates the data related to a tool call event.""" + + tool_call_id: str = Field(..., alias="toolCallId") + start: UiPathConversationToolCallStartEvent | None = Field( + None, alias="startToolCall" + ) + end: UiPathConversationToolCallEndEvent | None = Field(None, alias="endToolCall") + meta_event: dict[str, Any] | None = Field(None, alias="metaEvent") + error: UiPathConversationErrorEvent | None = Field(None, alias="toolCallError") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCallData(BaseModel): + """Represents the core data of a call to an external tool or function within a message.""" + + name: str + input: dict[str, Any] | None = None + result: UiPathConversationToolCallResult | None = None + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) + + +class UiPathConversationToolCall(UiPathConversationToolCallData): + """Represents a call to an external tool or function within a message.""" + + tool_call_id: str = Field(..., alias="toolCallId") + timestamp: str | None = None + created_at: str = Field(..., alias="createdAt") + updated_at: str = Field(..., alias="updatedAt") + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True) diff --git a/packages/uipath-core/src/uipath/core/errors/__init__.py b/packages/uipath-core/src/uipath/core/errors/__init__.py new file mode 100644 index 000000000..25f83f949 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/errors/__init__.py @@ -0,0 +1,12 @@ +"""UiPath core exceptions module. + +This module exposes common UiPath exceptions. +""" + +from .errors import ( + ErrorCategory, + UiPathFaultedTriggerError, + UiPathPendingTriggerError, +) + +__all__ = ["UiPathFaultedTriggerError", "UiPathPendingTriggerError", "ErrorCategory"] diff --git a/packages/uipath-core/src/uipath/core/errors/errors.py b/packages/uipath-core/src/uipath/core/errors/errors.py new file mode 100644 index 000000000..8df841c2d --- /dev/null +++ b/packages/uipath-core/src/uipath/core/errors/errors.py @@ -0,0 +1,33 @@ +"""UiPath common exceptions.""" + +from enum import Enum + + +class ErrorCategory(str, Enum): + """Categories of UiPath errors.""" + + DEPLOYMENT = "Deployment" + SYSTEM = "System" + UNKNOWN = "Unknown" + USER = "User" + + +class UiPathFaultedTriggerError(Exception): + """UiPath resume trigger error.""" + + category: ErrorCategory + message: str + detail: str + + def __init__(self, category: ErrorCategory, message: str, detail: str = ""): + """Initialize the UiPathFaultedTriggerError.""" + self.category = category + self.message = message + self.detail = detail + super().__init__(f"{message}: {detail}" if detail else message) + + +class UiPathPendingTriggerError(UiPathFaultedTriggerError): + """Custom resume trigger error for pending triggers.""" + + pass diff --git a/packages/uipath-core/src/uipath/core/events/__init__.py b/packages/uipath-core/src/uipath/core/events/__init__.py new file mode 100644 index 000000000..5c387ffaa --- /dev/null +++ b/packages/uipath-core/src/uipath/core/events/__init__.py @@ -0,0 +1,5 @@ +"""This module contains the event bus implementation.""" + +from uipath.core.events._event_bus import EventBus + +__all__ = ["EventBus"] diff --git a/packages/uipath-core/src/uipath/core/events/_event_bus.py b/packages/uipath-core/src/uipath/core/events/_event_bus.py new file mode 100644 index 000000000..e99d6ffa8 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/events/_event_bus.py @@ -0,0 +1,157 @@ +"""Event bus implementation for runtime events.""" + +import asyncio +import logging +from typing import Any, Callable, TypeVar + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +class EventBus: + """Event bus for publishing and subscribing to events.""" + + def __init__(self) -> None: + """Initialize a new EventBus instance.""" + self._subscribers: dict[str, list[Callable[[Any], Any]]] = {} + self._running_tasks: set[asyncio.Task[Any]] = set() + + def subscribe(self, topic: str, handler: Callable[[Any], Any]) -> None: + """Subscribe a handler method/function to a topic. + + Args: + topic: The topic name to subscribe to. + handler: The async handler method/function that will handle events for this topic. + """ + if topic not in self._subscribers: + self._subscribers[topic] = [] + self._subscribers[topic].append(handler) + logger.debug(f"Handler registered for topic: {topic}") + + def unsubscribe(self, topic: str, handler: Callable[[Any], Any]) -> None: + """Unsubscribe a handler from a topic. + + Args: + topic: The topic name to unsubscribe from. + handler: The handler to remove. + """ + if topic in self._subscribers: + try: + self._subscribers[topic].remove(handler) + if not self._subscribers[topic]: + del self._subscribers[topic] + logger.debug(f"Handler unregistered from topic: {topic}") + except ValueError: + logger.warning(f"Handler not found for topic: {topic}") + + def _cleanup_completed_tasks(self) -> None: + completed_tasks = {task for task in self._running_tasks if task.done()} + self._running_tasks -= completed_tasks + + async def publish( + self, topic: str, payload: T, wait_for_completion: bool = True + ) -> None: + """Publish an event to all handlers of a topic. + + Args: + topic: The topic name to publish to. + payload: The event payload to publish. + wait_for_completion: Whether to wait for the event to be processed. + """ + if topic not in self._subscribers: + logger.debug(f"No handlers for topic: {topic}") + return + + self._cleanup_completed_tasks() + + tasks = [] + for subscriber in self._subscribers[topic]: + try: + task = asyncio.create_task(subscriber(payload)) + tasks.append(task) + self._running_tasks.add(task) + except Exception as e: + logger.error(f"Error creating task for subscriber {subscriber}: {e}") + + if tasks and wait_for_completion: + try: + await asyncio.gather(*tasks, return_exceptions=True) + except Exception as e: + logger.error(f"Error during event processing for topic {topic}: {e}") + finally: + # Clean up the tasks we just waited for + for task in tasks: + self._running_tasks.discard(task) + + def get_running_tasks_count(self) -> int: + """Get the number of currently running subscriber tasks. + + Returns: + Number of running tasks. + """ + self._cleanup_completed_tasks() + return len(self._running_tasks) + + async def wait_for_all(self, timeout: float | None = None) -> None: + """Wait for all currently running subscriber tasks to complete. + + Args: + timeout: Maximum time to wait in seconds. If None, waits indefinitely. + """ + self._cleanup_completed_tasks() + + if not self._running_tasks: + logger.debug("No running tasks to wait for") + return + + logger.debug( + f"Waiting for {len(self._running_tasks)} EventBus tasks to complete..." + ) + + try: + tasks_to_wait = list(self._running_tasks) + + if timeout: + await asyncio.wait_for( + asyncio.gather(*tasks_to_wait, return_exceptions=True), + timeout=timeout, + ) + else: + await asyncio.gather(*tasks_to_wait, return_exceptions=True) + + logger.debug("All EventBus tasks completed") + + except asyncio.TimeoutError: + logger.warning(f"Timeout waiting for EventBus tasks after {timeout}s") + for task in tasks_to_wait: + if not task.done(): + task.cancel() + except Exception as e: + logger.error(f"Error waiting for EventBus tasks: {e}") + finally: + self._cleanup_completed_tasks() + + def get_subscribers_count(self, topic: str) -> int: + """Get the number of subscribers for a topic. + + Args: + topic: The topic name. + + Returns: + Number of handlers for the topic. + """ + return len(self._subscribers.get(topic, [])) + + def clear_subscribers(self, topic: str | None = None) -> None: + """Clear subscribers for a topic or all topics. + + Args: + topic: The topic to clear. If None, clears all topics. + """ + if topic is None: + self._subscribers.clear() + logger.debug("All handlers cleared") + elif topic in self._subscribers: + del self._subscribers[topic] + logger.debug(f"Handlers cleared for topic: {topic}") diff --git a/packages/uipath-core/src/uipath/core/feature_flags/__init__.py b/packages/uipath-core/src/uipath/core/feature_flags/__init__.py new file mode 100644 index 000000000..c112d4f3b --- /dev/null +++ b/packages/uipath-core/src/uipath/core/feature_flags/__init__.py @@ -0,0 +1,11 @@ +"""UiPath Feature Flags. + +Local-only feature flag registry for the UiPath SDK. +""" + +from .feature_flags import FeatureFlags, FeatureFlagsManager + +__all__ = [ + "FeatureFlags", + "FeatureFlagsManager", +] diff --git a/packages/uipath-core/src/uipath/core/feature_flags/feature_flags.py b/packages/uipath-core/src/uipath/core/feature_flags/feature_flags.py new file mode 100644 index 000000000..53469d7cf --- /dev/null +++ b/packages/uipath-core/src/uipath/core/feature_flags/feature_flags.py @@ -0,0 +1,118 @@ +"""Feature flags configuration for UiPath SDK. + +A simple, local-only feature flag registry. Flags can be set +programmatically via :meth:`FeatureFlagsManager.configure_flags` or +supplied via environment variables named ``UIPATH_FEATURE_`` +when nothing has been configured programmatically. + +Programmatic values always take precedence over environment variables. + +Example usage:: + + from uipath.core.feature_flags import FeatureFlags + + # Programmatic configuration (e.g. from an upstream layer) + FeatureFlags.configure_flags({"NewSerialization": True, "ModelOverride": "gpt-4"}) + + # Check a boolean flag + if FeatureFlags.is_flag_enabled("NewSerialization"): + ... + + # Get an arbitrary value + model = FeatureFlags.get_flag("ModelOverride", default="default-model") + + # Local override via environment variable + # $ export UIPATH_FEATURE_NewSerialization=false +""" + +import json +import os +from typing import Any + + +def _parse_env_value(raw: str) -> Any: + """Convert an environment variable string to a Python value. + + Booleans are matched first (case-insensitive). For all other values + JSON decoding is attempted so that dicts, lists and numbers survive + the env-var round-trip. Plain strings that are not valid JSON are + returned as-is. + """ + lower = raw.lower() + if lower == "true": + return True + if lower == "false": + return False + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return raw + # Only promote structured types (dict/list); scalars stay as strings. + if isinstance(parsed, (dict, list)): + return parsed + return raw + + +class FeatureFlagsManager: + """Singleton registry for UiPath feature flags. + + Use the module-level :data:`FeatureFlags` instance rather than + instantiating this class directly. + """ + + _instance: "FeatureFlagsManager | None" = None + _flags: dict[str, Any] + + def __new__(cls) -> "FeatureFlagsManager": + """Return the singleton instance, creating it on first call.""" + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._flags = {} + return cls._instance + + def configure_flags(self, flags: dict[str, Any]) -> None: + """Merge feature flag values into the registry. + + Args: + flags: Mapping of flag names to their values. Existing flags + with the same name are overwritten. + """ + self._flags.update(flags) + + def reset_flags(self) -> None: + """Clear all configured flags.""" + self._flags.clear() + + def get_flag(self, name: str, *, default: Any = None) -> Any: + """Return a flag value. + + Resolution order: + + 1. Value set via :meth:`configure_flags` (highest priority) + 2. ``UIPATH_FEATURE_`` environment variable (fallback when nothing configured) + 3. *default* + + Args: + name: The feature flag name. + default: Fallback when the flag is not set anywhere. + """ + if name in self._flags: + return self._flags[name] + env_val = os.environ.get(f"UIPATH_FEATURE_{name}") + if env_val is not None: + return _parse_env_value(env_val) + return default + + def is_flag_enabled(self, name: str, *, default: bool = False) -> bool: + """Check whether a boolean flag is enabled. + + Uses the same resolution order as :meth:`get_flag`. + + Args: + name: The feature flag name. + default: Fallback when the flag is not set anywhere. + """ + return bool(self.get_flag(name, default=default)) + + +FeatureFlags = FeatureFlagsManager() diff --git a/packages/uipath-core/src/uipath/core/guardrails/__init__.py b/packages/uipath-core/src/uipath/core/guardrails/__init__.py new file mode 100644 index 000000000..4ade20740 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/guardrails/__init__.py @@ -0,0 +1,48 @@ +"""UiPath Guardrails Models. + +This module contains models related to UiPath Guardrails. +""" + +from ._deterministic_guardrails_service import DeterministicGuardrailsService +from .guardrails import ( + AllFieldsSelector, + ApplyTo, + BaseGuardrail, + BooleanRule, + DeterministicGuardrail, + FieldReference, + FieldSelector, + FieldSource, + GuardrailScope, + GuardrailSelector, + GuardrailValidationResult, + GuardrailValidationResultType, + NumberRule, + Rule, + SelectorType, + SpecificFieldsSelector, + UniversalRule, + WordRule, +) + +__all__ = [ + "DeterministicGuardrailsService", + "FieldSource", + "ApplyTo", + "FieldReference", + "SelectorType", + "AllFieldsSelector", + "SpecificFieldsSelector", + "FieldSelector", + "BaseGuardrail", + "DeterministicGuardrail", + "WordRule", + "NumberRule", + "BooleanRule", + "UniversalRule", + "Rule", + "GuardrailScope", + "GuardrailSelector", + "GuardrailValidationResult", + "GuardrailValidationResultType", +] diff --git a/packages/uipath-core/src/uipath/core/guardrails/_deterministic_guardrails_service.py b/packages/uipath-core/src/uipath/core/guardrails/_deterministic_guardrails_service.py new file mode 100644 index 000000000..27be4996b --- /dev/null +++ b/packages/uipath-core/src/uipath/core/guardrails/_deterministic_guardrails_service.py @@ -0,0 +1,161 @@ +from typing import Any + +from pydantic import BaseModel + +from ..tracing.decorators import traced +from ._evaluators import ( + evaluate_boolean_rule, + evaluate_number_rule, + evaluate_universal_rule, + evaluate_word_rule, +) +from .guardrails import ( + AllFieldsSelector, + ApplyTo, + BooleanRule, + DeterministicGuardrail, + FieldSource, + GuardrailValidationResult, + GuardrailValidationResultType, + NumberRule, + SpecificFieldsSelector, + UniversalRule, + WordRule, +) + + +class DeterministicGuardrailsService(BaseModel): + @traced("evaluate_pre_deterministic_guardrail", run_type="uipath") + def evaluate_pre_deterministic_guardrail( + self, + input_data: dict[str, Any], + guardrail: DeterministicGuardrail, + ) -> GuardrailValidationResult: + """Evaluate deterministic guardrail rules against input data (pre-execution).""" + # Check if guardrail contains any output-dependent rules + has_output_rule = self._has_output_dependent_rule(guardrail, [ApplyTo.OUTPUT]) + + # If guardrail has output-dependent rules, skip evaluation in pre-execution + # Output rules will be evaluated during post-execution + if has_output_rule: + return GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason="No rules to apply for input data.", + ) + return self._evaluate_deterministic_guardrail( + input_data=input_data, + output_data={}, + guardrail=guardrail, + ) + + @traced("evaluate_post_deterministic_guardrails", run_type="uipath") + def evaluate_post_deterministic_guardrail( + self, + input_data: dict[str, Any], + output_data: dict[str, Any], + guardrail: DeterministicGuardrail, + ) -> GuardrailValidationResult: + """Evaluate deterministic guardrail rules against input and output data.""" + # Check if guardrail contains any output-dependent rules + has_output_rule = self._has_output_dependent_rule( + guardrail, [ApplyTo.OUTPUT, ApplyTo.INPUT_AND_OUTPUT] + ) + + # If guardrail has no output-dependent rules, skip post-execution evaluation + # Only input rules exist and they should have been evaluated during pre-execution + if not has_output_rule: + return GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason="No rules to apply for output data.", + ) + + return self._evaluate_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + @staticmethod + def _has_output_dependent_rule( + guardrail: DeterministicGuardrail, + universal_rules_apply_to_values: list[ApplyTo], + ) -> bool: + """Check if at least one rule EXCLUSIVELY requires output data. + + Args: + guardrail: The guardrail to check + universal_rules_apply_to_values: List of ApplyTo values to consider as output-dependent for UniversalRules. + + Returns: + True if at least one rule exclusively depends on output data, False otherwise. + """ + for rule in guardrail.rules: + # UniversalRule: only return True if it applies to values in universal_rules_apply_to_values + if isinstance(rule, UniversalRule): + if rule.apply_to in universal_rules_apply_to_values: + return True + # Rules with field_selector + elif isinstance(rule, (WordRule, NumberRule, BooleanRule)): + field_selector = rule.field_selector + # AllFieldsSelector applies to both input and output, not exclusively output + # SpecificFieldsSelector: only return True if at least one field has OUTPUT source + if isinstance(field_selector, SpecificFieldsSelector): + if field_selector.fields and any( + field.source == FieldSource.OUTPUT + for field in field_selector.fields + ): + return True + elif isinstance(field_selector, AllFieldsSelector): + if FieldSource.OUTPUT in field_selector.sources: + return True + + return False + + @staticmethod + def _evaluate_deterministic_guardrail( + input_data: dict[str, Any], + output_data: dict[str, Any], + guardrail: DeterministicGuardrail, + ) -> GuardrailValidationResult: + """Evaluate deterministic guardrail rules against input and output data. + + Validation fails only if ALL guardrail rules are violated. + """ + validated_conditions: list[str] = [] + + for rule in guardrail.rules: + if isinstance(rule, WordRule): + passed, reason = evaluate_word_rule(rule, input_data, output_data) + elif isinstance(rule, NumberRule): + passed, reason = evaluate_number_rule(rule, input_data, output_data) + elif isinstance(rule, BooleanRule): + passed, reason = evaluate_boolean_rule(rule, input_data, output_data) + elif isinstance(rule, UniversalRule): + passed, reason = evaluate_universal_rule(rule, output_data) + else: + return GuardrailValidationResult( + result=GuardrailValidationResultType.VALIDATION_FAILED, + reason=f"Unknown rule type: {type(rule)}", + ) + validated_conditions.append(reason) + if passed: + return GuardrailValidationResult( + result=GuardrailValidationResultType.PASSED, + reason=reason, + ) + + has_always_rule = any( + condition == "Always rule enforced" for condition in validated_conditions + ) + + validated_conditions_str = ", ".join(validated_conditions) + final_reason = ( + "Always rule enforced" + if has_always_rule + else f"Data matched all guardrail conditions: [{validated_conditions_str}]" + ) + + return GuardrailValidationResult( + result=GuardrailValidationResultType.VALIDATION_FAILED, + reason=final_reason, + ) diff --git a/packages/uipath-core/src/uipath/core/guardrails/_evaluators.py b/packages/uipath-core/src/uipath/core/guardrails/_evaluators.py new file mode 100644 index 000000000..502ff7cde --- /dev/null +++ b/packages/uipath-core/src/uipath/core/guardrails/_evaluators.py @@ -0,0 +1,424 @@ +"""Guardrail rule evaluators. + +This module provides functions for evaluating different types of guardrail rules +against input and output data. +""" + +import inspect +from enum import IntEnum +from typing import Any, Callable + +from .guardrails import ( + AllFieldsSelector, + ApplyTo, + BooleanRule, + FieldReference, + FieldSource, + NumberRule, + SpecificFieldsSelector, + UniversalRule, + WordRule, +) + + +class ArrayDepth(IntEnum): + """Array depth enumeration for path parsing.""" + + NONE = 0 # Not an array + SINGLE = 1 # Single array [*] + MATRIX = 2 # Matrix [*][*] + + +def extract_field_value(path: str, data: dict[str, Any]) -> list[Any]: + """Extract field values from data using dot-notation path. + + Supports array notation with [*] and [*][*] for arrays and matrices. + If an array is encountered at any point in the path, all elements are checked. + """ + if not isinstance(data, dict): + return [] + + results: list[Any] = [] + + def _parse_path_segment(segment: str) -> tuple[str, ArrayDepth]: + """Parse a path segment to extract field name and array depth.""" + if "[*][*]" in segment: + field_name = segment.replace("[*][*]", "") + return field_name, ArrayDepth.MATRIX + elif "[*]" in segment: + field_name = segment.replace("[*]", "") + return field_name, ArrayDepth.SINGLE + else: + return segment, ArrayDepth.NONE + + def _traverse(current: Any, remaining_parts: list[str]) -> None: + """Recursively traverse the path, handling arrays and matrices.""" + if not remaining_parts: + # End of path, add current value + if current is not None: + if isinstance(current, list): + # If current is a list, add all elements + results.extend(current) + else: + results.append(current) + return + + part = remaining_parts[0] + next_parts = remaining_parts[1:] + field_name, array_depth = _parse_path_segment(part) + + if isinstance(current, dict): + if field_name not in current: + return + next_value = current.get(field_name) + + if array_depth == ArrayDepth.MATRIX: + # Matrix [*][*] - expect 2D array + if isinstance(next_value, list): + for row in next_value: + if isinstance(row, list): + for item in row: + _traverse(item, next_parts) + else: + # Not a 2D array, treat as 1D + _traverse(row, next_parts) + elif array_depth == ArrayDepth.SINGLE: + # Array [*] - expect 1D array + if isinstance(next_value, list): + for item in next_value: + _traverse(item, next_parts) + else: + # Not an array, but path expects one - continue traversal + _traverse(next_value, next_parts) + else: + # No array notation, continue traversal + if isinstance(next_value, list): + # Array encountered without notation - check all elements + for item in next_value: + _traverse(item, next_parts) + else: + _traverse(next_value, next_parts) + elif isinstance(current, list): + # Current is an array - check all elements + for item in current: + _traverse(item, remaining_parts) + else: + # Cannot traverse further + return + + path_parts = path.split(".") + _traverse(data, path_parts) + return results + + +def get_fields_from_selector( + field_selector: AllFieldsSelector | SpecificFieldsSelector, + input_data: dict[str, Any], + output_data: dict[str, Any], +) -> list[tuple[Any, FieldReference]]: + """Get field values and their references based on the field selector.""" + fields: list[tuple[Any, FieldReference]] = [] + + if isinstance(field_selector, AllFieldsSelector): + # For "all" selector, we need to collect all fields from the specified sources + # This is a simplified implementation - in practice, you might want to + # recursively collect all nested fields + if FieldSource.INPUT in field_selector.sources: + for key, value in input_data.items(): + fields.append( + ( + value, + FieldReference(path=key, source=FieldSource.INPUT), + ) + ) + if FieldSource.OUTPUT in field_selector.sources: + for key, value in output_data.items(): + fields.append( + ( + value, + FieldReference(path=key, source=FieldSource.OUTPUT), + ) + ) + elif isinstance(field_selector, SpecificFieldsSelector): + # For specific fields, extract values based on field references + for field_ref in field_selector.fields: + # Use FieldSource to determine whether to use input_data or output_data + if field_ref.source == FieldSource.INPUT: + data = input_data + elif field_ref.source == FieldSource.OUTPUT: + data = output_data + else: + # Unknown source, skip this field + continue + # Extract values (may return multiple if arrays are in the path) + values = extract_field_value(field_ref.path, data) + # Add each value as a separate field reference + for value in values: + fields.append((value, field_ref)) + + return fields + + +def format_guardrail_passed_validation_result_message( + field_ref: FieldReference, + operator: str | None, + rule_description: str | None, +) -> str: + """Format a guardrail validation result message following the standard pattern.""" + source = "Input" if field_ref.source == FieldSource.INPUT else "Output" + + if rule_description: + return ( + f"{source} data didn't match the guardrail condition for field " + f"[{field_ref.path}]: {rule_description}" + ) + + return ( + f"{source} data didn't match the guardrail condition: " + f"[{field_ref.path}] comparing function [{operator}]" + ) + + +def get_validated_conditions_description( + field_path: str, + operator: str | None, + rule_description: str | None, +) -> str: + if rule_description: + return rule_description + + return f"[{field_path}] comparing function [{operator}]" + + +def evaluate_word_rule( + rule: WordRule, input_data: dict[str, Any], output_data: dict[str, Any] +) -> tuple[bool, str]: + """Evaluate a word rule against input and output data.""" + fields = get_fields_from_selector(rule.field_selector, input_data, output_data) + operator = _humanize_guardrail_func(rule.detects_violation) or "violation check" + field_paths = ", ".join({field_ref.path for _, field_ref in fields}) + + for field_value, field_ref in fields: + if field_value is None: + continue + + # Word rules should only be applied to string values + # Skip non-string values (numbers, booleans, objects, arrays, etc.) + if not isinstance(field_value, str): + continue + + field_str = field_value + + # Use the custom function to evaluate the rule + # If detects_violation returns True, it means the rule was violated (validation fails) + try: + violation_detected = rule.detects_violation(field_str) + except Exception: + # If function raises an exception, treat as failure + violation_detected = True + + if not violation_detected: + reason = format_guardrail_passed_validation_result_message( + field_ref=field_ref, + operator=operator, + rule_description=rule.rule_description, + ) + return True, reason + + return False, get_validated_conditions_description( + field_path=field_paths, + operator=operator, + rule_description=rule.rule_description, + ) + + +def evaluate_number_rule( + rule: NumberRule, input_data: dict[str, Any], output_data: dict[str, Any] +) -> tuple[bool, str]: + """Evaluate a number rule against input and output data.""" + fields = get_fields_from_selector(rule.field_selector, input_data, output_data) + operator = _humanize_guardrail_func(rule.detects_violation) or "violation check" + field_paths = ", ".join({field_ref.path for _, field_ref in fields}) + for field_value, field_ref in fields: + if field_value is None: + continue + + # Number rules should only be applied to numeric values + # Skip non-numeric values (strings, booleans, objects, arrays, etc.) + # Note: bool is a subclass of int in Python, so we must check for bool first + if isinstance(field_value, bool) or not isinstance(field_value, (int, float)): + continue + + field_num = float(field_value) + + # Use the custom function to evaluate the rule + # If detects_violation returns True, it means the rule was violated (validation fails) + try: + violation_detected = rule.detects_violation(field_num) + except Exception: + # If function raises an exception, treat as failure + violation_detected = True + + if not violation_detected: + reason = format_guardrail_passed_validation_result_message( + field_ref=field_ref, + operator=operator, + rule_description=rule.rule_description, + ) + return True, reason + + return False, get_validated_conditions_description( + field_path=field_paths, + operator=operator, + rule_description=rule.rule_description, + ) + + +def evaluate_boolean_rule( + rule: BooleanRule, + input_data: dict[str, Any], + output_data: dict[str, Any], +) -> tuple[bool, str]: + """Evaluate a boolean rule against input and output data.""" + fields = get_fields_from_selector(rule.field_selector, input_data, output_data) + operator = _humanize_guardrail_func(rule.detects_violation) or "violation check" + field_paths = ", ".join({field_ref.path for _, field_ref in fields}) + for field_value, field_ref in fields: + if field_value is None: + continue + + # Boolean rules should only be applied to boolean values + # Skip non-boolean values (strings, numbers, objects, arrays, etc.) + if not isinstance(field_value, bool): + continue + + field_bool = field_value + + # Use the custom function to evaluate the rule + # If detects_violation returns True, it means the rule was violated (validation fails) + try: + violation_detected = rule.detects_violation(field_bool) + except Exception: + # If function raises an exception, treat as failure + violation_detected = True + + if not violation_detected: + reason = format_guardrail_passed_validation_result_message( + field_ref=field_ref, + operator=operator, + rule_description=rule.rule_description, + ) + return True, reason + + return False, get_validated_conditions_description( + field_path=field_paths, + operator=operator, + rule_description=rule.rule_description, + ) + + +def evaluate_universal_rule( + rule: UniversalRule, + output_data: dict[str, Any], +) -> tuple[bool, str]: + """Evaluate a universal rule against input and output data. + + Universal rules trigger based on the apply_to scope and execution phase: + - Pre-execution (empty output_data): + - INPUT: triggers (result = VALIDATION_FAILED) + - OUTPUT: does not trigger (result = PASSED) + - INPUT_AND_OUTPUT: triggers (result = VALIDATION_FAILED) + - Post-execution (output_data has data): + - INPUT: does not trigger (result = PASSED) + - OUTPUT: triggers (result = VALIDATION_FAILED) + - INPUT_AND_OUTPUT: triggers (result = VALIDATION_FAILED) + """ + # Determine if this is pre-execution (no output data) or post-execution + is_pre_execution = not output_data or len(output_data) == 0 + + if rule.apply_to == ApplyTo.INPUT: + # INPUT: triggers in pre-execution, does not trigger in post-execution + if is_pre_execution: + return False, "Always rule enforced" + else: + return True, "No rules to apply for output data" + elif rule.apply_to == ApplyTo.OUTPUT: + # OUTPUT: does not trigger in pre-execution, triggers in post-execution + if is_pre_execution: + return True, "No rules to apply for input data" + else: + return False, "Always rule enforced" + elif rule.apply_to == ApplyTo.INPUT_AND_OUTPUT: + # INPUT_AND_OUTPUT: triggers in both phases + return False, "Always rule enforced" + else: + return False, f"Unknown apply_to value: {rule.apply_to}" + + +def _humanize_guardrail_func(func: Callable[..., Any] | str | None) -> str | None: + """Build a user-friendly description of a guardrail predicate. + + Deterministic guardrails store Python callables (often lambdas) to evaluate + conditions. For diagnostics, it's useful to include a readable hint about the + predicate that failed. + + Args: + func: A Python callable used as a predicate, or a pre-rendered string + description (for example, ``"s:str -> bool: contains 'test'"``). + + Returns: + A human-readable description, or ``None`` if one cannot be produced. + """ + if func is None: + return None + + if isinstance(func, str): + rendered = func.strip() + return rendered or None + + name = getattr(func, "__name__", None) + if name and name != "": + return name + + # Best-effort extraction for lambdas / callables. + try: + sig = str(inspect.signature(func)) + except (TypeError, ValueError): + sig = "" + + try: + source_lines = inspect.getsourcelines(func) + source = "".join(source_lines[0]).strip() + # Collapse whitespace to keep the message compact. + source = " ".join(source.split()) + + # Remove "detects_violation=lambda" prefix if present + # Pattern: "detects_violation=lambda s: condition" -> "condition" + if "detects_violation=lambda" in source: + # Find the lambda part + lambda_start = source.find("detects_violation=lambda") + if lambda_start != -1: + # Get everything after "detects_violation=lambda" + lambda_part = source[ + lambda_start + len("detects_violation=lambda") : + ].strip() + # Find the colon that separates param from body + colon_idx = lambda_part.find(":") + if colon_idx != -1: + # Extract just the body (condition) + body = lambda_part[colon_idx + 1 :].strip() + # Remove trailing comma if present + body = body.rstrip(",").strip() + source = body + except (OSError, TypeError): + source = "" + + if source and sig: + return f"{sig}: {source}" + if source: + return source + if sig: + return sig + + rendered = repr(func).strip() + return rendered or None diff --git a/packages/uipath-core/src/uipath/core/guardrails/guardrails.py b/packages/uipath-core/src/uipath/core/guardrails/guardrails.py new file mode 100644 index 000000000..fe651c35b --- /dev/null +++ b/packages/uipath-core/src/uipath/core/guardrails/guardrails.py @@ -0,0 +1,241 @@ +"""Guardrails models for UiPath Platform.""" + +from enum import Enum +from typing import Annotated, Any, Callable, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +def _decapitalize_first_letter(s: str) -> str: + """Convert first letter to lowercase (e.g., 'SimpleText' -> 'simpleText').""" + if not s or len(s) == 0: + return s + if len(s) == 1: + return s.lower() + return s[0].lower() + s[1:] + + +class GuardrailValidationResultType(str, Enum): + """Guardrail validation result type enumeration.""" + + PASSED = "passed" + VALIDATION_FAILED = "validation_failed" + ENTITLEMENTS_MISSING = "entitlements_missing" + FEATURE_DISABLED = "feature_disabled" + + +class GuardrailValidationResult(BaseModel): + """Result returned from validating input with a given guardrail. + + Attributes: + result: The validation result type. + reason: Textual explanation describing why the validation passed or failed. + """ + + model_config = ConfigDict(populate_by_name=True) + + result: GuardrailValidationResultType = Field( + alias="result", description="Validation result." + ) + reason: str = Field( + alias="reason", description="Explanation for the validation result." + ) + + +class FieldSource(str, Enum): + """Field source enumeration.""" + + INPUT = "input" + OUTPUT = "output" + + +class ApplyTo(str, Enum): + """Apply to enumeration.""" + + INPUT = "input" + INPUT_AND_OUTPUT = "inputAndOutput" + OUTPUT = "output" + + +class FieldReference(BaseModel): + """Field reference model.""" + + path: str + source: FieldSource + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + @field_validator("source", mode="before") + @classmethod + def normalize_type(cls, v: Any) -> Any: + """Normalize type by decapitalizing first letter.""" + return _decapitalize_first_letter(v) if isinstance(v, str) else v + + +class SelectorType(str, Enum): + """Selector type enumeration.""" + + ALL = "all" + SPECIFIC = "specific" + + +class AllFieldsSelector(BaseModel): + """All fields selector.""" + + selector_type: Literal["all"] = Field(alias="$selectorType") + sources: list[FieldSource] + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + @field_validator("sources", mode="before") + @classmethod + def normalize_sources(cls, v: Any) -> Any: + """Normalize sources by decapitalizing first letter of each item.""" + if isinstance(v, list): + return [ + _decapitalize_first_letter(item) if isinstance(item, str) else item + for item in v + ] + return v + + +class SpecificFieldsSelector(BaseModel): + """Specific fields selector.""" + + selector_type: Literal["specific"] = Field(alias="$selectorType") + fields: list[FieldReference] + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +FieldSelector = Annotated[ + AllFieldsSelector | SpecificFieldsSelector, + Field(discriminator="selector_type"), +] + + +class RuleType(str, Enum): + """Rule type enumeration.""" + + BOOLEAN = "boolean" + NUMBER = "number" + UNIVERSAL = "always" + WORD = "word" + + +class WordRule(BaseModel): + """Word rule model.""" + + rule_type: Literal["word"] = Field(alias="$ruleType") + field_selector: FieldSelector = Field(alias="fieldSelector") + rule_description: str | None = Field( + default=None, + exclude=True, + description="Human-friendly description of the rule condition.", + ) + detects_violation: Callable[[str], bool] = Field( + exclude=True, + description="Function that returns True if the string violates the rule (validation should fail).", + ) + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class UniversalRule(BaseModel): + """Universal rule model.""" + + rule_type: Literal["always"] = Field(alias="$ruleType") + apply_to: ApplyTo = Field(alias="applyTo") + rule_description: str | None = Field( + default=None, + exclude=True, + description="Human-friendly description of the rule condition.", + ) + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + @field_validator("apply_to", mode="before") + @classmethod + def normalize_type(cls, v: Any) -> Any: + """Normalize type by decapitalizing first letter.""" + return _decapitalize_first_letter(v) if isinstance(v, str) else v + + +class NumberRule(BaseModel): + """Number rule model.""" + + rule_type: Literal["number"] = Field(alias="$ruleType") + field_selector: FieldSelector = Field(alias="fieldSelector") + rule_description: str | None = Field( + default=None, + exclude=True, + description="Human-friendly description of the rule condition.", + ) + detects_violation: Callable[[float], bool] = Field( + exclude=True, + description="Function that returns True if the number violates the rule (validation should fail).", + ) + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class BooleanRule(BaseModel): + """Boolean rule model.""" + + rule_type: Literal["boolean"] = Field(alias="$ruleType") + field_selector: FieldSelector = Field(alias="fieldSelector") + rule_description: str | None = Field( + default=None, + exclude=True, + description="Human-friendly description of the rule condition.", + ) + detects_violation: Callable[[bool], bool] = Field( + exclude=True, + description="Function that returns True if the boolean violates the rule (validation should fail).", + ) + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +Rule = Annotated[ + WordRule | NumberRule | BooleanRule | UniversalRule, + Field(discriminator="rule_type"), +] + + +class GuardrailScope(str, Enum): + """Guardrail scope enumeration.""" + + AGENT = "Agent" + LLM = "Llm" + TOOL = "Tool" + + +class GuardrailSelector(BaseModel): + """Guardrail selector model.""" + + scopes: list[GuardrailScope] = Field(default=[GuardrailScope.TOOL]) + match_names: list[str] | None = Field(None, alias="matchNames") + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class BaseGuardrail(BaseModel): + """Base guardrail model.""" + + id: str + name: str + description: str | None = None + enabled_for_evals: bool = Field(True, alias="enabledForEvals") + selector: GuardrailSelector + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class DeterministicGuardrail(BaseGuardrail): + """Deterministic guardrail model.""" + + guardrail_type: Literal["custom"] = Field(alias="$guardrailType") + rules: list[Rule] + + model_config = ConfigDict(populate_by_name=True, extra="allow") diff --git a/py.typed b/packages/uipath-core/src/uipath/core/py.typed similarity index 100% rename from py.typed rename to packages/uipath-core/src/uipath/core/py.typed diff --git a/packages/uipath-core/src/uipath/core/serialization/__init__.py b/packages/uipath-core/src/uipath/core/serialization/__init__.py new file mode 100644 index 000000000..9296b1074 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/serialization/__init__.py @@ -0,0 +1,5 @@ +"""Serialization utilities for converting Python objects to various formats.""" + +from .json import serialize_defaults, serialize_json, serialize_object + +__all__ = ["serialize_defaults", "serialize_json", "serialize_object"] diff --git a/packages/uipath-core/src/uipath/core/serialization/json.py b/packages/uipath-core/src/uipath/core/serialization/json.py new file mode 100644 index 000000000..a3eebc412 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/serialization/json.py @@ -0,0 +1,196 @@ +"""JSON serialization utilities for converting Python objects to JSON formats.""" + +import json +import uuid +from dataclasses import asdict, is_dataclass +from datetime import date, datetime, time, timezone +from enum import Enum +from typing import Any, cast +from zoneinfo import ZoneInfo + +from pydantic import BaseModel + + +def serialize_defaults( + obj: Any, +) -> dict[str, Any] | list[Any] | str | int | float | bool | None: + """Convert Python objects to JSON-serializable formats. + + Handles common Python types that are not natively JSON-serializable: + - Pydantic models (v1 and v2) + - Dataclasses + - Enums + - Datetime objects + - Timezone objects + - Named tuples + - Sets and tuples + + This function is designed to be used as the `default` parameter in json.dumps(): + ```python + import json + result = json.dumps(obj, default=serialize_defaults) + ``` + + Or use the convenience function `serialize_json()` which wraps this: + ```python + result = serialize_json(obj) + ``` + + Args: + obj: The object to serialize + + Returns: + A JSON-serializable representation of the object: + - Pydantic models: dict from model_dump() + - Dataclasses: dict from asdict() + - Enums: the enum value (recursively serialized) + - datetime: ISO format string + - timezone/ZoneInfo: timezone name + - sets/tuples: converted to lists + - named tuples: converted to dict + - Primitives (None, bool, int, float, str, list, dict): returned unchanged + - Other types: converted to string with str() + + Examples: + >>> from datetime import datetime + >>> from pydantic import BaseModel + >>> + >>> class User(BaseModel): + ... name: str + ... created_at: datetime + >>> + >>> user = User(name="Alice", created_at=datetime.now()) + >>> import json + >>> json.dumps(user, default=serialize_defaults) + '{"name": "Alice", "created_at": "2024-01-01T12:00:00"}' + >>> # Or use the convenience function + >>> serialize_json(user) + '{"name": "Alice", "created_at": "2024-01-01T12:00:00"}' + """ + # Handle Pydantic BaseModel instances + if hasattr(obj, "model_dump") and not isinstance(obj, type): + return obj.model_dump(exclude_none=True, mode="json") + + # Handle Pydantic model classes - convert to schema representation + if isinstance(obj, type) and issubclass(obj, BaseModel): + return { + "__class__": obj.__name__, + "__module__": obj.__module__, + "schema": obj.model_json_schema(), + } + + # Handle Pydantic v1 models + if hasattr(obj, "dict") and not isinstance(obj, type): + return obj.dict() + + # Handle objects with to_dict method + if hasattr(obj, "to_dict") and not isinstance(obj, type): + return obj.to_dict() + + # Handle objects with as_dict property (UiPathBaseRuntimeError) + if hasattr(obj, "as_dict") and not isinstance(obj, type): + return obj.as_dict + + # Handle dataclasses + if is_dataclass(obj) and not isinstance(obj, type): + return asdict(obj) + + # Handle enums - recursively serialize the value + if isinstance(obj, Enum): + return serialize_defaults(obj.value) + + # Handle sets and tuples + if isinstance(obj, (set, tuple)): + # Check if it's a named tuple (has _asdict method) + if hasattr(obj, "_asdict") and callable( + obj._asdict # pyright: ignore[reportAttributeAccessIssue] + ): + return cast( + dict[str, Any], + obj._asdict(), # pyright: ignore[reportAttributeAccessIssue] + ) + # Convert to list + return list(obj) + + # Handle exceptions + if isinstance(obj, Exception): + return str(obj) + + # Handle datetime objects + if isinstance(obj, datetime): + return obj.isoformat() + + # Handle timezone objects + if isinstance(obj, (timezone, ZoneInfo)): + return obj.tzname(None) + + # Allow JSON-serializable primitives to pass through unchanged + if obj is None or isinstance(obj, (bool, int, float, str, list, dict)): + return obj + + # Fallback: convert to string + return str(obj) + + +def serialize_json(obj: Any) -> str: + """Serialize Python object to JSON string. + + This is a convenience function that wraps json.dumps() with serialize_defaults() + as the default handler for non-JSON-serializable types. + + Args: + obj: The object to serialize to JSON + + Returns: + JSON string representation of the object + + Examples: + >>> from datetime import datetime + >>> from pydantic import BaseModel + >>> + >>> class Task(BaseModel): + ... name: str + ... created: datetime + >>> + >>> task = Task(name="Review PR", created=datetime(2024, 1, 15, 10, 30)) + >>> serialize_json(task) + '{"name": "Review PR", "created": "2024-01-15T10:30:00"}' + """ + return json.dumps(obj, default=serialize_defaults) + + +def serialize_object(obj): + """Recursively serializes an object and all its nested components.""" + # Handle Pydantic models + if hasattr(obj, "model_dump"): + return serialize_object(obj.model_dump(by_alias=True)) + elif hasattr(obj, "dict"): + return serialize_object(obj.dict()) + elif hasattr(obj, "to_dict"): + return serialize_object(obj.to_dict()) + # Special handling for UiPathBaseRuntimeErrors + elif hasattr(obj, "as_dict"): + return serialize_object(obj.as_dict) + elif isinstance(obj, (datetime, date, time)): + return obj.isoformat() + # Handle dictionaries + elif isinstance(obj, dict): + return {k: serialize_object(v) for k, v in obj.items()} + # Handle lists + elif isinstance(obj, list): + return [serialize_object(item) for item in obj] + # Handle exceptions + elif isinstance(obj, Exception): + return str(obj) + # Handle other iterable objects (convert to dict first) + elif hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes)): + try: + return serialize_object(dict(obj)) + except (TypeError, ValueError): + return obj + # UUIDs must be serialized explicitly + elif isinstance(obj, uuid.UUID): + return str(obj) + # Return primitive types as is + else: + return obj diff --git a/packages/uipath-core/src/uipath/core/tracing/__init__.py b/packages/uipath-core/src/uipath/core/tracing/__init__.py new file mode 100644 index 000000000..af6d3c2e1 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/tracing/__init__.py @@ -0,0 +1,17 @@ +"""OpenTelemetry tracing module with UiPath integration. + +This module provides decorators and utilities for instrumenting Python functions +with OpenTelemetry tracing, including custom processors for UiPath execution tracking. +""" + +from uipath.core.tracing.decorators import traced +from uipath.core.tracing.span_utils import UiPathSpanUtils +from uipath.core.tracing.trace_manager import UiPathTraceManager +from uipath.core.tracing.types import UiPathTraceSettings + +__all__ = [ + "traced", + "UiPathSpanUtils", + "UiPathTraceManager", + "UiPathTraceSettings", +] diff --git a/packages/uipath-core/src/uipath/core/tracing/_utils.py b/packages/uipath-core/src/uipath/core/tracing/_utils.py new file mode 100644 index 000000000..841aa67d8 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/tracing/_utils.py @@ -0,0 +1,142 @@ +"""Helper utilities for the tracing module.""" + +import inspect +import json +from collections.abc import Callable +from typing import Any, Mapping, Optional + +from opentelemetry.trace import Span + +from uipath.core.serialization import serialize_json + + +def get_supported_params( + tracer_impl: Callable[..., Any], + params: Mapping[str, Any], +) -> dict[str, Any]: + """Extract the parameters supported by the tracer implementation.""" + try: + sig = inspect.signature(tracer_impl) + except (TypeError, ValueError): + # If we can't inspect, pass all parameters and let the function handle it + return dict(params) + + supported: dict[str, Any] = {} + for name, value in params.items(): + if value is not None and name in sig.parameters: + supported[name] = value + return supported + + +def format_args_for_trace_json( + signature: inspect.Signature, *args: Any, **kwargs: Any +) -> str: + """Return a JSON string of inputs from the function signature.""" + result = format_args_for_trace(signature, *args, **kwargs) + return serialize_json(result) + + +def format_object_for_trace_json( + input_object: Any, +) -> str: + """Return a JSON string of inputs from the function signature.""" + return serialize_json(input_object) + + +def format_args_for_trace( + signature: inspect.Signature, *args: Any, **kwargs: Any +) -> dict[str, Any]: + try: + """Return a dictionary of inputs from the function signature.""" + # Create a parameter mapping by partially binding the arguments + + parameter_binding = signature.bind_partial(*args, **kwargs) + + # Fill in default values for any unspecified parameters + parameter_binding.apply_defaults() + + # Extract the input parameters, skipping special Python parameters + result = {} + for name, value in parameter_binding.arguments.items(): + # Skip class and instance references + if name in ("self", "cls"): + continue + + # Handle **kwargs parameters specially + param_info = signature.parameters.get(name) + if param_info and param_info.kind == inspect.Parameter.VAR_KEYWORD: + # Flatten nested kwargs directly into the result + if isinstance(value, dict): + result.update(value) + else: + # Regular parameter + result[name] = value + + return result + except Exception: + return {"args": args, "kwargs": kwargs} + + +def set_span_input_attributes( + span: Span, + trace_name: str, + wrapped_func: Callable[..., Any], + args: Any, + kwargs: Any, + span_type: str, + run_type: Optional[str], + input_processor: Optional[Callable[..., Any]], +) -> None: + """Set span attributes for metadata and inputs before function execution. + + This should be called BEFORE the wrapped function executes to ensure + input context is captured even if the function raises an exception. + + Args: + span: The OpenTelemetry span to set attributes on + trace_name: Name of the trace/span + wrapped_func: The function being traced + args: Positional arguments passed to the function + kwargs: Keyword arguments passed to the function + span_type: Span type categorization (set to "TOOL" for OpenInference tool calls) + run_type: Optional run type categorization + input_processor: Optional function to process inputs before recording + """ + is_tool = span_type and span_type.upper() == "TOOL" + if is_tool: + span.set_attribute("openinference.span.kind", "TOOL") + span.set_attribute("tool.name", trace_name) + span.set_attribute("span_type", "TOOL") + else: + span.set_attribute("span_type", span_type) + + if run_type is not None: + span.set_attribute("run_type", run_type) + + inputs = format_args_for_trace_json( + inspect.signature(wrapped_func), *args, **kwargs + ) + if input_processor: + processed_inputs = input_processor(json.loads(inputs)) + inputs = json.dumps(processed_inputs, default=str) + span.set_attribute("input.mime_type", "application/json") + span.set_attribute("input.value", inputs) + + +def set_span_output_attributes( + span: Span, + result: Any, + output_processor: Optional[Callable[..., Any]], +) -> None: + """Set span attributes for outputs after function execution. + + This should be called AFTER the wrapped function executes successfully. + + Args: + span: The OpenTelemetry span to set attributes on + result: The result from the function execution + output_processor: Optional function to process outputs before recording + """ + output = output_processor(result) if output_processor else result + span.set_attribute("output.value", format_object_for_trace_json(output)) + span.set_attribute("output.mime_type", "application/json") diff --git a/packages/uipath-core/src/uipath/core/tracing/decorators.py b/packages/uipath-core/src/uipath/core/tracing/decorators.py new file mode 100644 index 000000000..b7204271a --- /dev/null +++ b/packages/uipath-core/src/uipath/core/tracing/decorators.py @@ -0,0 +1,350 @@ +"""Tracing decorators for function instrumentation.""" + +import inspect +import logging +import random +from functools import wraps +from typing import Any, Callable, Optional, overload + +from opentelemetry import context as context_api +from opentelemetry import trace +from opentelemetry.context import _SUPPRESS_INSTRUMENTATION_KEY +from opentelemetry.trace import SpanContext, TraceFlags +from opentelemetry.trace.status import StatusCode + +from uipath.core.tracing._utils import ( + get_supported_params, + set_span_input_attributes, + set_span_output_attributes, +) +from uipath.core.tracing.span_utils import ( + ParentedNonRecordingSpan, + UiPathSpanUtils, + _span_registry, +) + +logger = logging.getLogger(__name__) + + +def _opentelemetry_traced( + name: Optional[str] = None, + run_type: Optional[str] = None, + span_type: Optional[str] = None, + input_processor: Optional[Callable[..., Any]] = None, + output_processor: Optional[Callable[..., Any]] = None, + recording: bool = True, +): + """Default tracer implementation using OpenTelemetry. + + Args: + name: Optional name for the span + run_type: Optional string to categorize the run type + span_type: Optional string to categorize the span type. If set to "tool" or "TOOL", + the function is treated as an OpenInference tool call with: + - openinference.span.kind = "TOOL" + - tool.name = function name + - span_type = "TOOL" + - input.value and output.value (already set by default) + input_processor: Optional function to process inputs before recording + output_processor: Optional function to process outputs before recording + recording: If False, span is not recorded + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + trace_name = name or func.__name__ + + def get_span(): + ctx = UiPathSpanUtils.get_parent_context() + if not recording: + parent_context = trace.get_current_span(ctx).get_span_context() + + # Create a valid but non-sampled trace context + # Generate a valid trace ID (not INVALID) + trace_id = random.getrandbits(128) + span_id = random.getrandbits(64) + + non_sampled_context = SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=False, + trace_flags=TraceFlags(0x00), # NOT sampled + ) + non_recording = ParentedNonRecordingSpan( + non_sampled_context, parent=parent_context + ) + + # Make it active so children see it + span_cm = trace.use_span(non_recording) + span_cm.__enter__() + + _span_registry.register_span(non_recording) + + return span_cm, non_recording + else: + # Normal recording span + span_cm = trace.get_tracer(__name__).start_as_current_span( + trace_name, context=ctx + ) + span = span_cm.__enter__() + return span_cm, span + + # --------- Sync wrapper --------- + @wraps(func) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return func(*args, **kwargs) + span_cm, span = get_span() + try: + # Set input attributes BEFORE execution + set_span_input_attributes( + span, + trace_name=trace_name, + wrapped_func=func, + args=args, + kwargs=kwargs, + run_type=run_type, + span_type=span_type or "function_call_sync", + input_processor=input_processor, + ) + + # Execute the function + result = func(*args, **kwargs) + + # Set output attributes AFTER execution + set_span_output_attributes( + span, + result=result, + output_processor=output_processor, + ) + return result + except Exception as e: + span.record_exception(e) + span.set_status(StatusCode.ERROR, str(e)) + raise + finally: + if span_cm: + span_cm.__exit__(None, None, None) + + # --------- Async wrapper --------- + @wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return await func(*args, **kwargs) + span_cm, span = get_span() + try: + # Set input attributes BEFORE execution + set_span_input_attributes( + span, + trace_name=trace_name, + wrapped_func=func, + args=args, + kwargs=kwargs, + run_type=run_type, + span_type=span_type or "function_call_async", + input_processor=input_processor, + ) + + # Execute the function + result = await func(*args, **kwargs) + + # Set output attributes AFTER execution + set_span_output_attributes( + span, + result=result, + output_processor=output_processor, + ) + return result + except Exception as e: + span.record_exception(e) + span.set_status(StatusCode.ERROR, str(e)) + raise + finally: + if span_cm: + span_cm.__exit__(None, None, None) + + # --------- Generator wrapper --------- + @wraps(func) + def generator_wrapper(*args: Any, **kwargs: Any) -> Any: + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + for item in func(*args, **kwargs): + yield item + return + span_cm, span = get_span() + try: + # Set input attributes BEFORE execution + set_span_input_attributes( + span, + trace_name=trace_name, + wrapped_func=func, + args=args, + kwargs=kwargs, + run_type=run_type, + span_type=span_type or "function_call_generator_sync", + input_processor=input_processor, + ) + + # Execute the generator and collect outputs + outputs = [] + for item in func(*args, **kwargs): + outputs.append(item) + span.add_event(f"Yielded: {item}") + yield item + + # Set output attributes AFTER execution + set_span_output_attributes( + span, + result=outputs, + output_processor=output_processor, + ) + except Exception as e: + span.record_exception(e) + span.set_status(StatusCode.ERROR, str(e)) + raise + finally: + if span_cm: + span_cm.__exit__(None, None, None) + + # --------- Async generator wrapper --------- + @wraps(func) + async def async_generator_wrapper(*args: Any, **kwargs: Any) -> Any: + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + async for item in func(*args, **kwargs): + yield item + return + span_cm, span = get_span() + try: + # Set input attributes BEFORE execution + set_span_input_attributes( + span, + trace_name=trace_name, + wrapped_func=func, + args=args, + kwargs=kwargs, + run_type=run_type, + span_type=span_type or "function_call_generator_async", + input_processor=input_processor, + ) + + # Execute the generator and collect outputs + outputs = [] + async for item in func(*args, **kwargs): + outputs.append(item) + span.add_event(f"Yielded: {item}") + yield item + + # Set output attributes AFTER execution + set_span_output_attributes( + span, + result=outputs, + output_processor=output_processor, + ) + except Exception as e: + span.record_exception(e) + span.set_status(StatusCode.ERROR, str(e)) + raise + finally: + if span_cm: + span_cm.__exit__(None, None, None) + + if inspect.iscoroutinefunction(func): + return async_wrapper + elif inspect.isgeneratorfunction(func): + return generator_wrapper + elif inspect.isasyncgenfunction(func): + return async_generator_wrapper + else: + return sync_wrapper + + return decorator + + +@overload +def traced(func: Callable[..., Any], /) -> Callable[..., Any]: ... + + +@overload +def traced( + name: Optional[str] = ..., + run_type: Optional[str] = ..., + span_type: Optional[str] = ..., + input_processor: Optional[Callable[..., Any]] = ..., + output_processor: Optional[Callable[..., Any]] = ..., + hide_input: bool = ..., + hide_output: bool = ..., + recording: bool = ..., +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ... + + +def traced( + name: Optional[str | Callable[..., Any]] = None, + run_type: Optional[str] = None, + span_type: Optional[str] = None, + input_processor: Optional[Callable[..., Any]] = None, + output_processor: Optional[Callable[..., Any]] = None, + hide_input: bool = False, + hide_output: bool = False, + recording: bool = True, +): + """Decorator that will trace function invocations. + + Args: + name: Optional custom name for the span + run_type: Optional string to categorize the run type + span_type: Optional string to categorize the span type + input_processor: Optional function to process function inputs before recording + Should accept a dictionary of inputs and return a processed dictionary + output_processor: Optional function to process function outputs before recording + Should accept the function output and return a processed value + hide_input: If True, don't log any input data + hide_output: If True, don't log any output data + recording: If False, current span and all child spans are not captured + """ + # Handle @traced without parentheses: the decorated function + # is passed as the first positional argument (``name``). + _func: Optional[Callable[..., Any]] = None + if callable(name): + _func = name + name = None + + # Apply default processors selectively based on hide flags + def _default_input_processor(inputs: Any) -> dict[str, str]: + """Default input processor that doesn't log any actual input data.""" + return {"redacted": "Input data not logged for privacy/security"} + + def _default_output_processor(outputs: Any) -> dict[str, str]: + """Default output processor that doesn't log any actual output data.""" + return {"redacted": "Output data not logged for privacy/security"} + + if hide_input: + input_processor = _default_input_processor + if hide_output: + output_processor = _default_output_processor + + # Store the parameters for later reapplication + params = { + "name": name, + "run_type": run_type, + "span_type": span_type, + "input_processor": input_processor, + "output_processor": output_processor, + "recording": recording, + } + + tracer_impl = _opentelemetry_traced + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + # Check which parameters are supported by the tracer_impl + supported_params = get_supported_params(tracer_impl, params) + + # Decorate the function with only supported parameters + decorated_func = tracer_impl(**supported_params)(func) + + return decorated_func + + if _func is not None: + return decorator(_func) + + return decorator + + +__all__ = ["traced"] diff --git a/packages/uipath-core/src/uipath/core/tracing/exporters.py b/packages/uipath-core/src/uipath/core/tracing/exporters.py new file mode 100644 index 000000000..a30723bd3 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/tracing/exporters.py @@ -0,0 +1,45 @@ +"""Custom OpenTelemetry Span Exporter for UiPath Runtime executions.""" + +from collections import defaultdict +from typing import Dict, Optional, Sequence + +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + +class UiPathRuntimeExecutionSpanExporter(SpanExporter): + """Custom exporter that stores spans grouped by execution ids.""" + + def __init__(self): + """Initialize the exporter.""" + self._spans: Dict[str, list[ReadableSpan]] = defaultdict(list) + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + """Export spans, grouping them by execution id.""" + for span in spans: + if span.attributes is not None: + exec_id = span.attributes.get("execution.id") + if exec_id is not None and isinstance(exec_id, str): + self._spans[exec_id].append(span) + + return SpanExportResult.SUCCESS + + def get_spans(self, execution_id: str) -> list[ReadableSpan]: + """Retrieve spans for a given execution id.""" + return self._spans.get(execution_id, []) + + def clear(self, execution_id: Optional[str] = None) -> None: + """Clear stored spans for one or all executions.""" + if execution_id: + self._spans.pop(execution_id, None) + else: + self._spans.clear() + + def shutdown(self) -> None: + """Shutdown the exporter and clear all stored spans.""" + self.clear() + + +__all__ = [ + "UiPathRuntimeExecutionSpanExporter", +] diff --git a/packages/uipath-core/src/uipath/core/tracing/processors.py b/packages/uipath-core/src/uipath/core/tracing/processors.py new file mode 100644 index 000000000..2fd10f639 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/tracing/processors.py @@ -0,0 +1,76 @@ +"""Custom span processors for UiPath execution tracing.""" + +from typing import cast + +from opentelemetry import context as context_api +from opentelemetry import trace +from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor +from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + SimpleSpanProcessor, + SpanExporter, +) + +from uipath.core.tracing.types import UiPathTraceSettings + + +class UiPathExecutionTraceProcessorMixin: + """Mixin that propagates execution.id and optionally filters spans.""" + + _settings: UiPathTraceSettings | None = None + + def on_start(self, span: Span, parent_context: context_api.Context | None = None): + """Called when a span is started.""" + parent_span: Span | None + if parent_context: + parent_span = cast(Span, trace.get_current_span(parent_context)) + else: + parent_span = cast(Span, trace.get_current_span()) + + if parent_span and parent_span.is_recording() and parent_span.attributes: + execution_id = parent_span.attributes.get("execution.id") + if execution_id: + span.set_attribute("execution.id", execution_id) + + def on_end(self, span: ReadableSpan): + """Called when a span ends. Filters before delegating to parent.""" + span_filter = self._settings.span_filter if self._settings else None + if span_filter is None or span_filter(span): + parent = cast(SpanProcessor, super()) + parent.on_end(span) + + +class UiPathExecutionBatchTraceProcessor( + UiPathExecutionTraceProcessorMixin, BatchSpanProcessor +): + """Batch span processor that propagates execution.id and optionally filters.""" + + def __init__( + self, + span_exporter: SpanExporter, + settings: UiPathTraceSettings | None = None, + ): + """Initialize the batch trace processor.""" + super().__init__(span_exporter) + self._settings = settings + + +class UiPathExecutionSimpleTraceProcessor( + UiPathExecutionTraceProcessorMixin, SimpleSpanProcessor +): + """Simple span processor that propagates execution.id and optionally filters.""" + + def __init__( + self, + span_exporter: SpanExporter, + settings: UiPathTraceSettings | None = None, + ): + """Initialize the simple trace processor.""" + super().__init__(span_exporter) + self._settings = settings + + +__all__ = [ + "UiPathExecutionBatchTraceProcessor", + "UiPathExecutionSimpleTraceProcessor", +] diff --git a/packages/uipath-core/src/uipath/core/tracing/span_utils.py b/packages/uipath-core/src/uipath/core/tracing/span_utils.py new file mode 100644 index 000000000..0e9e9fb22 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/tracing/span_utils.py @@ -0,0 +1,334 @@ +"""Utilities for managing UiPath spans.""" + +import logging +from typing import Callable, Optional + +from opentelemetry import context, trace +from opentelemetry.trace import NonRecordingSpan, Span, set_span_in_context + +logger = logging.getLogger(__name__) + + +class ParentedNonRecordingSpan(NonRecordingSpan): + """Non-recording span with explicit parent tracking. + + Extends NonRecordingSpan to include a parent attribute, allowing the SpanRegistry + to properly track parent-child relationships for non-recording spans. + This is necessary because NonRecordingSpan instances created directly don't have + their parent automatically set like normal recording spans do. + """ + + def __init__( + self, context: trace.SpanContext, parent: Optional[trace.SpanContext] = None + ): + """Initialize a parented non-recording span. + + Args: + context: The SpanContext for this span + parent: Optional parent SpanContext + """ + super().__init__(context) + self.parent = parent + + +class SpanRegistry: + """Registry to track all spans and their parent relationships.""" + + MAX_HIERARCHY_DEPTH = 1000 # Hard limit for hierarchy traversal + + def __init__(self): + self._spans: dict[int, Span] = {} # span_id -> span + self._parent_map: dict[int, Optional[int]] = {} # span_id -> parent_id + + def register_span(self, span: Span) -> None: + """Register a span and its parent relationship.""" + span_id = span.get_span_context().span_id + + parent_id: Optional[int] = None + + if hasattr(span, "parent") and span.parent is not None: + parent_id = getattr(span.parent, "span_id", None) + + self._spans[span_id] = span + self._parent_map[span_id] = parent_id + + parent_str = "{:016x}".format(parent_id) if parent_id is not None else "None" + + logger.debug( + "SpanRegistry: registered span: %s (id: %016x, parent: %s)", + getattr(span, "name", "unknown"), + span_id, + parent_str, + ) + + def get_span(self, span_id: int) -> Optional[Span]: + """Get a span by ID.""" + return self._spans.get(span_id) + + def get_parent_id(self, span_id: int) -> Optional[int]: + """Get the parent ID of a span.""" + return self._parent_map.get(span_id) + + def calculate_depth(self, span_id: int) -> int: + """Calculate the depth of a span in the hierarchy. + + Returns: + The depth of the span, capped at MAX_HIERARCHY_DEPTH. + """ + depth = 0 + current_id = span_id + visited = set() + + while current_id is not None and current_id not in visited: + visited.add(current_id) + parent_id = self._parent_map.get(current_id) + if parent_id is None: + break + depth += 1 + if depth >= self.MAX_HIERARCHY_DEPTH: + logger.warning( + "Hit MAX_HIERARCHY_DEPTH (%d) while calculating depth for span %016x", + self.MAX_HIERARCHY_DEPTH, + span_id, + ) + break + current_id = parent_id + + return depth + + def is_ancestor(self, ancestor_id: int, descendant_id: int) -> bool: + """Check if ancestor_id is an ancestor of descendant_id. + + Returns: + True if ancestor_id is an ancestor of descendant_id, False otherwise. + If MAX_HIERARCHY_DEPTH is reached, returns False. + """ + current_id: Optional[int] = descendant_id + visited = set() + steps = 0 + + while current_id is not None and current_id not in visited: + if current_id == ancestor_id: + return True + visited.add(current_id) + current_id = self._parent_map.get(current_id) + steps += 1 + if steps >= self.MAX_HIERARCHY_DEPTH: + logger.warning( + "Hit MAX_HIERARCHY_DEPTH (%d) while checking ancestry between %016x and %016x", + self.MAX_HIERARCHY_DEPTH, + ancestor_id, + descendant_id, + ) + return False + + return False + + def clear(self) -> None: + """Clear all registered spans.""" + self._spans.clear() + self._parent_map.clear() + + +# Global span registry instance +_span_registry = SpanRegistry() + + +class UiPathSpanUtils: + """Static utility class to manage tracing implementations and decorated functions.""" + + _current_span_provider: Optional[Callable[[], Optional[Span]]] = None + _current_span_ancestors_provider: Optional[Callable[[], list[Span]]] = None + + @staticmethod + def register_current_span_provider( + current_span_provider: Optional[Callable[[], Optional[Span]]], + ): + """Register a custom current span provider function. + + Args: + current_span_provider: A function that returns the current span from an external + tracing framework. If None, no custom span parenting will be used. + """ + UiPathSpanUtils._current_span_provider = current_span_provider + + @staticmethod + def get_parent_context() -> context.Context: + """Get the parent context for span creation. + + This method determines the correct parent context when creating a new traced span. + It handles scenarios where spans may exist in both OpenTelemetry's context (current_span) + and in an external tracing system (external_span), such as LangGraph. + + The algorithm follows this priority: + + 1. **No spans available**: Returns the current OpenTelemetry context (empty context) + + 2. **Only current_span exists**: Returns a context with current_span set as parent + - This is the standard OpenTelemetry behavior for nested traced functions + + 3. **Only external_span exists**: Returns a context with external_span set as parent + - This occurs when an external tracing system (like LangGraph) has an active span + but there's no OTel span in the current call stack + + 4. **Both spans exist**: Calls `_get_bottom_most_span()` to determine which is deeper + - Uses the SpanRegistry to build parent-child relationships + - Returns the span that is closer to the "bottom" (leaf) of the trace tree + - This ensures new spans are always attached to the deepest/most specific parent + + Context Sources: + - **current_span**: Retrieved from OpenTelemetry's `trace.get_current_span()` + - Represents the active OTel span in the current execution context + - Created by `@traced` decorators or manual span creation + + - **external_span**: Retrieved from the registered custom span provider + - Set via `register_current_span_provider()` + - Typically provided by external frameworks (LangGraph, LangChain, etc.) + - Allows integration with tracing systems outside of OpenTelemetry + + Returns: + context.Context: An OpenTelemetry context containing the appropriate parent span, + or the current empty context if no spans are available + + Example: + ```python + # Called by the @traced decorator when creating a new span: + ctx = UiPathTracingManager.get_parent_context() + with tracer.start_as_current_span("my_span", context=ctx) as span: + # New span will have the correct parent based on the logic above + pass + ``` + + See Also: + - `_get_bottom_most_span()`: Logic for choosing between two available spans + - `register_current_span_provider()`: Register external span provider + - `get_external_current_span()`: Retrieve span from external provider + """ + current_span = trace.get_current_span() + has_current_span = ( + current_span is not None and current_span.get_span_context().is_valid + ) + + external_span = UiPathSpanUtils.get_external_current_span() + + # Only one or no spans available + if not has_current_span: + return ( + set_span_in_context(external_span) + if external_span is not None + else context.get_current() + ) + if external_span is None: + return set_span_in_context(current_span) + + # Both spans exist - find the bottom-most one + bottom_span = UiPathSpanUtils._get_bottom_most_span(current_span, external_span) + return set_span_in_context(bottom_span) + + @staticmethod + def _get_bottom_most_span( + current_span: Span, + external_span: Span, + ) -> Span: + """Determine which span is deeper in the ancestor tree. + + Args: + current_span: The OTel current span + external_span: The external span from the provider + + Returns: + The span that is deeper (closer to the bottom) in the call hierarchy + """ + # Register both spans in the registry + _span_registry.register_span(current_span) + _span_registry.register_span(external_span) + + # Also register external ancestors + external_ancestors = UiPathSpanUtils.get_ancestor_spans() or [] + for ancestor in external_ancestors: + _span_registry.register_span(ancestor) + + current_span_id = current_span.get_span_context().span_id + external_span_id = external_span.get_span_context().span_id + + # Check if one span is an ancestor of the other + if _span_registry.is_ancestor(external_span_id, current_span_id): + logger.debug( + "Traced Context: current_span is a descendant of external_span -> returning current_span (deeper)" + ) + return current_span + elif _span_registry.is_ancestor(current_span_id, external_span_id): + logger.debug( + "Traced Context: external_span is a descendant of current_span -> returning external_span (deeper)" + ) + return external_span + + # Neither is an ancestor of the other - they're in different branches + # Use depth as tiebreaker + current_depth = _span_registry.calculate_depth(current_span_id) + external_depth = _span_registry.calculate_depth(external_span_id) + + if current_depth > external_depth: + logger.debug( + "Traced Context: Different branches, current_span is deeper (depth %d > %d) -> returning current_span", + current_depth, + external_depth, + ) + return current_span + elif external_depth > current_depth: + logger.debug( + "Traced Context: Different branches, external_span is deeper (depth %d > %d) -> returning external_span", + external_depth, + current_depth, + ) + return external_span + else: + # Same depth, different branches - default to external + logger.debug( + "Traced Context: Same depth (%d), different branches -> defaulting to external_span", + current_depth, + ) + return external_span + + @staticmethod + def get_external_current_span() -> Optional[Span]: + """Get the current span from the external provider, if any.""" + if UiPathSpanUtils._current_span_provider is not None: + try: + return UiPathSpanUtils._current_span_provider() + except Exception as e: + logger.warning("Error getting current span from provider: %s", e) + return None + + @staticmethod + def get_ancestor_spans() -> list[Span]: + """Get the ancestor spans from the registered provider, if any.""" + if UiPathSpanUtils._current_span_ancestors_provider is not None: + try: + return UiPathSpanUtils._current_span_ancestors_provider() + except Exception as e: + logger.warning("Error getting ancestor spans from provider: %s", e) + return [] + + @staticmethod + def register_current_span_ancestors_provider( + current_span_ancestors_provider: Optional[Callable[[], list[Span]]], + ): + """Register a custom current span ancestors provider function. + + Args: + current_span_ancestors_provider: A function that returns a list of ancestor spans + from an external tracing framework. If None, no custom + span ancestor information will be used. + """ + UiPathSpanUtils._current_span_ancestors_provider = ( + current_span_ancestors_provider + ) + + @staticmethod + def get_current_span_ancestors_provider(): + """Get the currently set custom span ancestors provider.""" + return UiPathSpanUtils._current_span_ancestors_provider + + +__all__ = ["ParentedNonRecordingSpan", "UiPathSpanUtils"] diff --git a/packages/uipath-core/src/uipath/core/tracing/trace_manager.py b/packages/uipath-core/src/uipath/core/tracing/trace_manager.py new file mode 100644 index 000000000..481953c5b --- /dev/null +++ b/packages/uipath-core/src/uipath/core/tracing/trace_manager.py @@ -0,0 +1,108 @@ +"""Tracing manager for handling tracer implementations and function registry.""" + +from __future__ import annotations + +import contextlib +from typing import Any, Generator, Optional + +from opentelemetry import trace +from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider +from opentelemetry.sdk.trace.export import SpanExporter +from opentelemetry.util._decorator import _AgnosticContextManager + +from uipath.core.tracing.exporters import UiPathRuntimeExecutionSpanExporter +from uipath.core.tracing.processors import ( + UiPathExecutionBatchTraceProcessor, + UiPathExecutionSimpleTraceProcessor, +) +from uipath.core.tracing.types import UiPathTraceSettings + + +class UiPathTraceManager: + """Trace manager. + + NOTE: Instantiate trace manager only once. + """ + + def __init__(self): + """Initialize a trace manager.""" + trace.set_tracer_provider(TracerProvider()) + # If a previous provider set, reuse it. + current_provider = trace.get_tracer_provider() + assert isinstance(current_provider, TracerProvider), ( + "An incompatible Otel TracerProvider was instantiated. Please check runtime configuration." + ) + self.tracer_provider: TracerProvider = current_provider + self.tracer_span_processors: list[SpanProcessor] = [] + self.execution_span_exporter = UiPathRuntimeExecutionSpanExporter() + self.add_span_exporter(self.execution_span_exporter) + + def add_span_exporter( + self, + span_exporter: SpanExporter, + batch: bool = True, + settings: UiPathTraceSettings | None = None, + ) -> UiPathTraceManager: + """Add a span exporter to the tracer provider. + + Args: + span_exporter: The exporter to add. + batch: Whether to use batch processing (default: True). + settings: Optional trace settings for filtering, etc. + """ + span_processor: SpanProcessor + if batch: + span_processor = UiPathExecutionBatchTraceProcessor(span_exporter, settings) + else: + span_processor = UiPathExecutionSimpleTraceProcessor( + span_exporter, settings + ) + self.tracer_span_processors.append(span_processor) + self.tracer_provider.add_span_processor(span_processor) + return self + + def add_span_processor( + self, + span_processor: SpanProcessor, + ) -> UiPathTraceManager: + """Add a span processor to the tracer provider.""" + self.tracer_span_processors.append(span_processor) + self.tracer_provider.add_span_processor(span_processor) + return self + + def get_execution_spans( + self, + execution_id: str, + ) -> list[ReadableSpan]: + """Retrieve spans for a given execution id.""" + return self.execution_span_exporter.get_spans(execution_id) + + @contextlib.contextmanager + def start_execution_span( + self, + root_span: str, + execution_id: str, + attributes: Optional[dict[str, str]] = None, + ) -> Generator[_AgnosticContextManager[Any] | Any, Any, None]: + """Start an execution span.""" + try: + tracer = trace.get_tracer("uipath-runtime") + span_attributes: dict[str, Any] = {} + if execution_id: + span_attributes["execution.id"] = execution_id + if attributes: + span_attributes.update(attributes) + with tracer.start_as_current_span( + root_span, attributes=span_attributes + ) as span: + yield span + finally: + self.flush_spans() + + def flush_spans(self) -> None: + """Flush all span processors.""" + for span_processor in self.tracer_span_processors: + span_processor.force_flush() + + +__all__ = ["UiPathTraceManager"] diff --git a/packages/uipath-core/src/uipath/core/tracing/types.py b/packages/uipath-core/src/uipath/core/tracing/types.py new file mode 100644 index 000000000..7130718f3 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/tracing/types.py @@ -0,0 +1,21 @@ +"""Tracing types for UiPath SDK.""" + +from typing import Callable + +from opentelemetry.sdk.trace import ReadableSpan +from pydantic import BaseModel, Field + + +class UiPathTraceSettings(BaseModel): + """Trace settings for UiPath SDK.""" + + model_config = {"arbitrary_types_allowed": True} # Needed for Callable + + span_filter: Callable[[ReadableSpan], bool] | None = Field( + default=None, + description=( + "Optional filter to decide whether a span should be exported. " + "Called when a span ends with a ReadableSpan argument. " + "Return True to export, False to skip." + ), + ) diff --git a/packages/uipath-core/src/uipath/core/triggers/__init__.py b/packages/uipath-core/src/uipath/core/triggers/__init__.py new file mode 100644 index 000000000..400462277 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/triggers/__init__.py @@ -0,0 +1,15 @@ +"""Module containing UiPath trigger definitions.""" + +__all__ = [ + "UiPathResumeTrigger", + "UiPathResumeTriggerType", + "UiPathApiTrigger", + "UiPathResumeTriggerName", +] + +from uipath.core.triggers.trigger import ( + UiPathApiTrigger, + UiPathResumeTrigger, + UiPathResumeTriggerName, + UiPathResumeTriggerType, +) diff --git a/packages/uipath-core/src/uipath/core/triggers/trigger.py b/packages/uipath-core/src/uipath/core/triggers/trigger.py new file mode 100644 index 000000000..424245079 --- /dev/null +++ b/packages/uipath-core/src/uipath/core/triggers/trigger.py @@ -0,0 +1,72 @@ +"""Module defining resume trigger types and data models.""" + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class UiPathResumeTriggerType(str, Enum): + """Constants representing different types of resume job triggers in the system.""" + + NONE = "None" + QUEUE_ITEM = "QueueItem" + JOB = "Job" + TASK = "Task" + TIMER = "Timer" + INBOX = "Inbox" + API = "Api" + DEEP_RAG = "DeepRag" + BATCH_RAG = "BatchRag" + INDEX_INGESTION = "IndexIngestion" + IXP_EXTRACTION = "IxpExtraction" + IXP_VS_ESCALATION = "IxpVsEscalation" + + +class UiPathResumeTriggerName(str, Enum): + """Constants representing specific names for resume job triggers in the system.""" + + UNKNOWN = "Unknown" + QUEUE_ITEM = "QueueItem" + JOB = "Job" + TASK = "Task" + ESCALATION = "Escalation" + TIMER = "Timer" + INBOX = "Inbox" + API = "Api" + DEEP_RAG = "DeepRag" + BATCH_RAG = "BatchRag" + INDEX_INGESTION = "IndexIngestion" + EXTRACTION = "Extraction" + IXP_VS_ESCALATION = "IxpVsEscalation" + JOB_RAW = "JobRaw" + INDEX_INGESTION_RAW = "IndexIngestionRaw" + DEEP_RAG_RAW = "DeepRagRaw" + + +class UiPathApiTrigger(BaseModel): + """API resume trigger request.""" + + inbox_id: str | None = Field(default=None, alias="inboxId") + request: Any = None + + model_config = ConfigDict(validate_by_name=True) + + +class UiPathResumeTrigger(BaseModel): + """Information needed to resume execution.""" + + interrupt_id: str | None = Field(default=None, alias="interruptId") + trigger_type: UiPathResumeTriggerType = Field( + default=UiPathResumeTriggerType.API, alias="triggerType" + ) + trigger_name: UiPathResumeTriggerName = Field( + default=UiPathResumeTriggerName.UNKNOWN, alias="triggerName", exclude=True + ) + item_key: str | None = Field(default=None, alias="itemKey") + api_resume: UiPathApiTrigger | None = Field(default=None, alias="apiResume") + folder_path: str | None = Field(default=None, alias="folderPath") + folder_key: str | None = Field(default=None, alias="folderKey") + payload: Any | None = Field(default=None, alias="interruptObject", exclude=True) + + model_config = ConfigDict(validate_by_name=True) diff --git a/packages/uipath-core/tests/__init__.py b/packages/uipath-core/tests/__init__.py new file mode 100644 index 000000000..6152bacfc --- /dev/null +++ b/packages/uipath-core/tests/__init__.py @@ -0,0 +1,4 @@ +""" +Test suite for UiPath Runtime SDK. +Contains test cases for all runtime abstractions. +""" diff --git a/packages/uipath-core/tests/conftest.py b/packages/uipath-core/tests/conftest.py new file mode 100644 index 000000000..ed5b894e0 --- /dev/null +++ b/packages/uipath-core/tests/conftest.py @@ -0,0 +1,50 @@ +"""Shared pytest fixtures for all tests.""" + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + +class SpanCapture: + """Helper to capture and analyze spans.""" + + def __init__(self): + self.exporter = InMemorySpanExporter() + self.provider = TracerProvider() + self.provider.add_span_processor(SimpleSpanProcessor(self.exporter)) + trace.set_tracer_provider(self.provider) + + def get_spans(self): + """Get all captured spans.""" + return self.exporter.get_finished_spans() + + def clear(self): + """Clear captured spans.""" + self.exporter.clear() + + def print_hierarchy(self): + """Print the span hierarchy for debugging.""" + spans = self.get_spans() + print("\n=== Span Hierarchy ===") + for span in spans: + parent_id = span.parent.span_id if span.parent else "ROOT" + print(f" {span.name}") + print(f" Span ID: {span.context.span_id}") + print(f" Parent ID: {parent_id}") + print(f" Trace ID: {span.context.trace_id}") + print("======================\n") + + +@pytest.fixture(scope="session") +def span_capture() -> SpanCapture: + """Fixture to capture spans - created once for entire test session.""" + return SpanCapture() + + +@pytest.fixture(autouse=True) +def clear_spans_between_tests(span_capture: SpanCapture): + """Clear captured spans before each test.""" + span_capture.clear() + yield diff --git a/src/uipath/_cli/_evals/__init__.py b/packages/uipath-core/tests/errors/__init__.py similarity index 100% rename from src/uipath/_cli/_evals/__init__.py rename to packages/uipath-core/tests/errors/__init__.py diff --git a/packages/uipath-core/tests/errors/test_trigger_errors.py b/packages/uipath-core/tests/errors/test_trigger_errors.py new file mode 100644 index 000000000..93dd72363 --- /dev/null +++ b/packages/uipath-core/tests/errors/test_trigger_errors.py @@ -0,0 +1,65 @@ +"""Tests for UiPath trigger error classes.""" + +from uipath.core.errors import ( + ErrorCategory, + UiPathFaultedTriggerError, + UiPathPendingTriggerError, +) + + +class TestUiPathFaultedTriggerError: + """Test UiPathFaultedTriggerError constructor and behavior.""" + + def test_init_with_detail(self) -> None: + """Test that category, message, and detail are stored and str includes both.""" + err = UiPathFaultedTriggerError( + category=ErrorCategory.USER, + message="Something failed", + detail="missing input", + ) + assert err.category == ErrorCategory.USER + assert err.message == "Something failed" + assert err.detail == "missing input" + assert str(err) == "Something failed: missing input" + + def test_init_without_detail(self) -> None: + """Test that detail defaults to empty string and str is just the message.""" + err = UiPathFaultedTriggerError( + category=ErrorCategory.SYSTEM, + message="Internal error", + ) + assert err.category == ErrorCategory.SYSTEM + assert err.message == "Internal error" + assert err.detail == "" + assert str(err) == "Internal error" + + def test_is_exception(self) -> None: + """Test that the error can be raised and caught as an Exception.""" + with __import__("pytest").raises(UiPathFaultedTriggerError, match="boom"): + raise UiPathFaultedTriggerError( + category=ErrorCategory.DEPLOYMENT, + message="boom", + ) + + +class TestUiPathPendingTriggerError: + """Test UiPathPendingTriggerError inherits correctly.""" + + def test_inherits_faulted_trigger_error(self) -> None: + """Test that PendingTriggerError is a subclass of FaultedTriggerError.""" + err = UiPathPendingTriggerError( + category=ErrorCategory.UNKNOWN, + message="Pending", + detail="waiting for response", + ) + assert isinstance(err, UiPathFaultedTriggerError) + assert err.category == ErrorCategory.UNKNOWN + assert str(err) == "Pending: waiting for response" + + def test_catchable_as_faulted(self) -> None: + """Test that PendingTriggerError can be caught as FaultedTriggerError.""" + with __import__("pytest").raises(UiPathFaultedTriggerError): + raise UiPathPendingTriggerError( + category=ErrorCategory.USER, + message="still pending", + ) diff --git a/src/uipath/_cli/models/__init__.py b/packages/uipath-core/tests/feature_flags/__init__.py similarity index 100% rename from src/uipath/_cli/models/__init__.py rename to packages/uipath-core/tests/feature_flags/__init__.py diff --git a/packages/uipath-core/tests/feature_flags/test_feature_flags.py b/packages/uipath-core/tests/feature_flags/test_feature_flags.py new file mode 100644 index 000000000..8193d74e5 --- /dev/null +++ b/packages/uipath-core/tests/feature_flags/test_feature_flags.py @@ -0,0 +1,167 @@ +"""Unit tests for the feature flags registry.""" + +from typing import TYPE_CHECKING + +from uipath.core.feature_flags import FeatureFlags +from uipath.core.feature_flags.feature_flags import _parse_env_value + +if TYPE_CHECKING: + from _pytest.monkeypatch import MonkeyPatch + + +class TestParseEnvValue: + """Tests for _parse_env_value.""" + + def test_true_string(self) -> None: + assert _parse_env_value("true") is True + + def test_true_uppercase(self) -> None: + assert _parse_env_value("TRUE") is True + + def test_true_mixed_case(self) -> None: + assert _parse_env_value("True") is True + + def test_false_string(self) -> None: + assert _parse_env_value("false") is False + + def test_false_uppercase(self) -> None: + assert _parse_env_value("FALSE") is False + + def test_string_passthrough(self) -> None: + assert _parse_env_value("gpt-4") == "gpt-4" + + def test_empty_string(self) -> None: + assert _parse_env_value("") == "" + + def test_numeric_string(self) -> None: + assert _parse_env_value("42") == "42" + + def test_json_dict(self) -> None: + result = _parse_env_value('{"model": "gpt-4", "enabled": true}') + assert result == {"model": "gpt-4", "enabled": True} + + def test_json_list(self) -> None: + result = _parse_env_value('["a", "b", "c"]') + assert result == ["a", "b", "c"] + + def test_json_nested_dict(self) -> None: + result = _parse_env_value('{"outer": {"inner": 1}}') + assert result == {"outer": {"inner": 1}} + + def test_float_string_stays_string(self) -> None: + assert _parse_env_value("3.14") == "3.14" + + def test_plain_string_not_json(self) -> None: + assert _parse_env_value("gpt-4") == "gpt-4" + + +class TestConfigureFlags: + """Tests for configure_flags / reset_flags.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + + def test_configure_sets_flags(self) -> None: + FeatureFlags.configure_flags({"FeatureA": True, "FeatureB": "value"}) + assert FeatureFlags.get_flag("FeatureA") is True + assert FeatureFlags.get_flag("FeatureB") == "value" + + def test_configure_merges(self) -> None: + FeatureFlags.configure_flags({"FeatureA": True}) + FeatureFlags.configure_flags({"FeatureB": False}) + assert FeatureFlags.get_flag("FeatureA") is True + assert FeatureFlags.get_flag("FeatureB") is False + + def test_configure_overwrites(self) -> None: + FeatureFlags.configure_flags({"FeatureA": True}) + FeatureFlags.configure_flags({"FeatureA": False}) + assert FeatureFlags.get_flag("FeatureA") is False + + def test_reset_clears_all(self) -> None: + FeatureFlags.configure_flags({"FeatureA": True}) + FeatureFlags.reset_flags() + assert FeatureFlags.get_flag("FeatureA") is None + + +class TestGetFlag: + """Tests for get_flag.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + + def test_returns_default_when_unset(self) -> None: + assert FeatureFlags.get_flag("Missing") is None + + def test_returns_custom_default(self) -> None: + assert FeatureFlags.get_flag("Missing", default="fallback") == "fallback" + + def test_returns_configured_value(self) -> None: + FeatureFlags.configure_flags({"FeatureA": "hello"}) + assert FeatureFlags.get_flag("FeatureA") == "hello" + + def test_configured_value_takes_precedence_over_env_var( + self, monkeypatch: "MonkeyPatch" + ) -> None: + FeatureFlags.configure_flags({"FeatureA": True}) + monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "false") + assert FeatureFlags.get_flag("FeatureA") is True + + def test_env_var_used_when_nothing_configured( + self, monkeypatch: "MonkeyPatch" + ) -> None: + monkeypatch.setenv("UIPATH_FEATURE_X", "custom") + assert FeatureFlags.get_flag("X", default="other") == "custom" + + def test_env_var_string_value(self, monkeypatch: "MonkeyPatch") -> None: + monkeypatch.setenv("UIPATH_FEATURE_Model", "gpt-4-turbo") + assert FeatureFlags.get_flag("Model") == "gpt-4-turbo" + + def test_env_var_json_dict(self, monkeypatch: "MonkeyPatch") -> None: + monkeypatch.setenv("UIPATH_FEATURE_Models", '{"gpt-4": true, "claude": false}') + assert FeatureFlags.get_flag("Models") == {"gpt-4": True, "claude": False} + + def test_env_var_json_list(self, monkeypatch: "MonkeyPatch") -> None: + monkeypatch.setenv("UIPATH_FEATURE_AllowedModels", '["gpt-4", "claude"]') + assert FeatureFlags.get_flag("AllowedModels") == ["gpt-4", "claude"] + + +class TestIsFlagEnabled: + """Tests for is_flag_enabled.""" + + def setup_method(self) -> None: + FeatureFlags.reset_flags() + + def test_enabled_flag(self) -> None: + FeatureFlags.configure_flags({"FeatureA": True}) + assert FeatureFlags.is_flag_enabled("FeatureA") is True + + def test_disabled_flag(self) -> None: + FeatureFlags.configure_flags({"FeatureA": False}) + assert FeatureFlags.is_flag_enabled("FeatureA") is False + + def test_missing_flag_defaults_false(self) -> None: + assert FeatureFlags.is_flag_enabled("Missing") is False + + def test_missing_flag_custom_default(self) -> None: + assert FeatureFlags.is_flag_enabled("Missing", default=True) is True + + def test_truthy_string_is_enabled(self) -> None: + FeatureFlags.configure_flags({"FeatureA": "some-value"}) + assert FeatureFlags.is_flag_enabled("FeatureA") is True + + def test_none_is_disabled(self) -> None: + FeatureFlags.configure_flags({"FeatureA": None}) + assert FeatureFlags.is_flag_enabled("FeatureA") is False + + def test_configured_value_takes_precedence_over_env_var( + self, monkeypatch: "MonkeyPatch" + ) -> None: + FeatureFlags.configure_flags({"FeatureA": True}) + monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "false") + assert FeatureFlags.is_flag_enabled("FeatureA") is True + + def test_env_var_used_when_nothing_configured( + self, monkeypatch: "MonkeyPatch" + ) -> None: + monkeypatch.setenv("UIPATH_FEATURE_FeatureA", "true") + assert FeatureFlags.is_flag_enabled("FeatureA") is True diff --git a/packages/uipath-core/tests/guardrails/test_deterministic_guardrails_service.py b/packages/uipath-core/tests/guardrails/test_deterministic_guardrails_service.py new file mode 100644 index 000000000..1db2da454 --- /dev/null +++ b/packages/uipath-core/tests/guardrails/test_deterministic_guardrails_service.py @@ -0,0 +1,1468 @@ +import re +from typing import Any + +import pytest + +from uipath.core.guardrails import ( + AllFieldsSelector, + ApplyTo, + BooleanRule, + DeterministicGuardrail, + DeterministicGuardrailsService, + FieldReference, + FieldSource, + GuardrailScope, + GuardrailSelector, + GuardrailValidationResultType, + NumberRule, + SpecificFieldsSelector, + UniversalRule, + WordRule, +) + + +@pytest.fixture +def service() -> DeterministicGuardrailsService: + return DeterministicGuardrailsService() + + +class TestDeterministicGuardrailsService: + """Test GuardrailsService functionality.""" + + def test_evaluate_post_deterministic_guardrail_validation_passed( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test deterministic guardrail validation with passing rules.""" + # Create a deterministic guardrail matching the C# example + deterministic_guardrail = DeterministicGuardrail( + id="test-deterministic-id", + name="Pre execution Guardrail", + description="Test pre-execution guardrail", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[FieldReference(path="age", source=FieldSource.INPUT)], + ), + detects_violation=lambda n: n < 21.0, + ), + BooleanRule( + rule_type="boolean", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="isActive", source=FieldSource.INPUT) + ], + ), + detects_violation=lambda b: b is not True, + ), + ], + ) + + # Input data matching the C# example + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data: dict[str, Any] = {} + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + assert result.reason == "No rules to apply for output data." + + def test_evaluate_post_deterministic_guardrail_validation_passes_when_input_data_dont_violates_all_the_rules( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test deterministic guardrail validation fails when age is too low.""" + deterministic_guardrail = DeterministicGuardrail( + id="test-deterministic-id", + name="Pre execution Guardrail", + description="Test pre-execution guardrail", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[FieldReference(path="age", source=FieldSource.INPUT)], + ), + detects_violation=lambda n: n < 21.0, + ), + BooleanRule( + rule_type="boolean", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="isActive", source=FieldSource.INPUT) + ], + ), + detects_violation=lambda b: b is not True, + ), + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="status", source=FieldSource.OUTPUT) + ], + ), + detects_violation=lambda n: n != 200.0, + ), + ], + ) + + # Input data with age < 21 + input_data = { + "userName": "John", + "age": 18, + "isActive": True, + } + output_data: dict[str, Any] = {} + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + assert ( + result.reason + == "Input data didn't match the guardrail condition: [isActive] comparing function [(b): b is not True]" + ) + + def test_evaluate_post_deterministic_guardrail_validation_passes_when_input_and_output_data_dont_violates_all_the_rules( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test deterministic guardrail validation fails when isActive is False.""" + deterministic_guardrail = DeterministicGuardrail( + id="test-deterministic-id", + name="Pre execution Guardrail", + description="Test pre-execution guardrail", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[FieldReference(path="age", source=FieldSource.INPUT)], + ), + detects_violation=lambda n: n < 21.0, + ), + BooleanRule( + rule_type="boolean", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="isActive", source=FieldSource.INPUT) + ], + ), + detects_violation=lambda b: b is not True, + ), + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="status", source=FieldSource.OUTPUT) + ], + ), + detects_violation=lambda n: n != 200.0, + ), + ], + ) + + # Input data with isActive = False + input_data = { + "userName": "John", + "age": 25, + "isActive": False, + } + output_data = { + "status": 200, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + assert ( + result.reason + == "Input data didn't match the guardrail condition: [age] comparing function [(n): n < 21.0]" + ) + + def test_evaluate_post_deterministic_guardrail_uses_rule_description( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Ensure rule_description is returned when a rule fails validation.""" + friendly_description = "Username must include 'te' and a digit" + deterministic_guardrail = DeterministicGuardrail( + id="test-rule-desc-id", + name="Regex Guardrail With Description", + description="Test regex guardrail with description", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + WordRule( + rule_type="word", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="userName", source=FieldSource.INPUT) + ], + ), + rule_description=friendly_description, + detects_violation=lambda s: not bool(re.search(".*te.*3.*", s)), + ), + ], + ) + + # Input data with userName that doesn't match the regex pattern + input_data = { + "userName": "test", + } + + result = service.evaluate_pre_deterministic_guardrail( + input_data=input_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + assert ( + result.reason + == "Data matched all guardrail conditions: [Username must include 'te' and a digit]" + ) + + def test_evaluate_post_deterministic_guardrail_passes_validation_when_no_output_rules( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test deterministic guardrail validation passes when regex matches.""" + deterministic_guardrail = DeterministicGuardrail( + id="test-deterministic-id", + name="Regex Guardrail", + description="Test regex guardrail", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + WordRule( + rule_type="word", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="userName", source=FieldSource.INPUT) + ], + ), + detects_violation=lambda s: not bool(re.search(".*te.*3.*", s)), + ), + ], + ) + + # Input data with userName that matches the regex pattern + input_data = { + "userName": "test123", + } + output_data: dict[str, Any] = {} + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + assert result.reason == "No rules to apply for output data." + + def test_evaluate_post_deterministic_guardrail_failes_validation_when_data_macthes_rules( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test deterministic guardrail validation fails when regex doesn't match.""" + deterministic_guardrail = DeterministicGuardrail( + id="test-deterministic-id", + name="Regex Guardrail", + description="Test regex guardrail", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + WordRule( + rule_type="word", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="userName", source=FieldSource.INPUT) + ], + ), + detects_violation=lambda s: not bool(re.search(".*te.*3.*", s)), + ), + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="status", source=FieldSource.OUTPUT) + ], + ), + detects_violation=lambda n: n != 200.0, + ), + ], + ) + + # Input data with userName that doesn't match the regex pattern + input_data = { + "userName": "test", + } + output_data = { + "status": 201, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=deterministic_guardrail, + ) + + expected_reason = ( + "Data matched all guardrail conditions: [[userName] comparing function " + '[(s): not bool(re.search(".*te.*3.*", s))], ' + "[status] comparing function [(n): n != 200.0]]" + ) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + assert result.reason == expected_reason + + def test_evaluate_post_deterministic_guardrail_word_contains_substring_detects_violation( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test deterministic guardrail validation fails when string contains forbidden substring.""" + deterministic_guardrail = DeterministicGuardrail( + id="test-deterministic-id", + name="Word Contains Guardrail", + description="Test word contains guardrail", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + WordRule( + rule_type="word", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="userName", source=FieldSource.INPUT) + ], + ), + detects_violation=lambda s: "dre" in s, + ), + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="status", source=FieldSource.OUTPUT) + ], + ), + detects_violation=lambda n: n != 200.0, + ), + ], + ) + + # Input data with userName that contains "dre" - should fail + input_data = { + "userName": "andrei", + } + output_data = { + "status": 201, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + assert ( + result.reason + == "Data matched all guardrail conditions: [[userName] comparing function " + '[(s): "dre" in s], [status] comparing function [(n): n != 200.0]]' + ) + + def test_evaluate_post_deterministic_guardrail_number_func_passes_when_no_input_rules( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test deterministic guardrail validation passes when number func returns True.""" + deterministic_guardrail = DeterministicGuardrail( + id="test-deterministic-id", + name="Number Func Guardrail", + description="Test number func guardrail", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[FieldReference(path="age", source=FieldSource.INPUT)], + ), + detects_violation=lambda n: n < 18 or n > 65, + ), + ], + ) + + # Input data with age that passes the function check + input_data = { + "age": 25, + } + output_data: dict[str, Any] = {} + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + assert result.reason == "No rules to apply for output data." + + def test_evaluate_post_deterministic_guardrail_number_func_negative( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test deterministic guardrail validation fails when number func returns False.""" + deterministic_guardrail = DeterministicGuardrail( + id="test-deterministic-id", + name="Number Func Guardrail", + description="Test number func guardrail", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[FieldReference(path="age", source=FieldSource.INPUT)], + ), + detects_violation=lambda n: n < 18 or n > 65, + ), + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="status", source=FieldSource.OUTPUT) + ], + ), + detects_violation=lambda n: n != 200.0, + ), + ], + ) + + # Input data with age that fails the function check + input_data = { + "age": 70, + } + output_data = { + "status": 201, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + + def test_evaluate_post_execution_pases_when_only_some_rules_not_met( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test post-execution guardrail passes when only some rules are not met.""" + guardrail = self._create_guardrail_for_pre_execution() + input_data = { + "userName": "John", + "age": 18, # Less than 21 + "isActive": True, + } + output_data = { + "status": 200, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + + def test_should_ignore_post_execution_guardrail_for_pre_execution_returns_false( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test that pre-execution guardrail ignores post-execution data.""" + guardrail = self._create_guardrail_for_post_execution() + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data: dict[str, Any] = {} + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + # Should fail because post-execution guardrail needs output data + assert result.result == GuardrailValidationResultType.PASSED + + def test_should_trigger_policy_post_execution_guardrail_for_pre_execution_returns_false( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test that pre-execution guardrail does not trigger in post-execution.""" + guardrail = self._create_guardrail_for_pre_execution() + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data = { + "result": "Success", + "status": 200, + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + # Pre-execution guardrail should still pass in post-execution + assert result.result == GuardrailValidationResultType.PASSED + + def test_should_trigger_policy_post_execution_with_output_fields_when_no_violation_then_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + guardrail = self._create_guardrail_for_post_execution() + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data = { + "result": "Success", + "status": 200, + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + + def test_post_execution_with_output_fields_when_only_input_conditions_violated_then_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + guardrail = self._create_guardrail_for_post_execution() + input_data = { + "userName": "John", + "age": 18, # Less than 21 + "isActive": True, + } + output_data = { + "result": "Success", + "status": 200, + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + + def test_post_execution_with_input_and_output_fields_output_when_only_output_conditions_violated_then_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + guardrail = self._create_guardrail_for_post_execution() + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data = { + "result": "Success", + "status": 400, # Not 200 + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + + def test_post_execution_multiple_rules_when_all_conditions_when_no_condition_is_violated_then_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + guardrail = self._create_guardrail_with_multiple_rules() + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data = { + "result": "Success", + "status": 200, + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + + def test_post_execution_rule_with_multiple_conditions_when_no_condition_is_violated_then_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test guardrail with rule having multiple conditions passes when all are met.""" + guardrail = self._create_guardrail_with_rule_having_multiple_conditions() + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data = { + "result": "Success", + "status": 200, + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + + def test_post_execution_rule_with_multiple_conditions_when_only_some_conditions_are_violated_then_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test guardrail with multiple conditions fails when one condition is not met.""" + guardrail = self._create_guardrail_with_rule_having_multiple_conditions() + input_data = { + "userName": "John", + "age": 25, + "isActive": False, # Not True + } + output_data = { + "result": "Success", + "status": 200, + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + + def test_post_execution_rule_with_multiple_conditions_when_all_condition_are_violated_then_returns_false( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test guardrail with multiple conditions fails when one condition is not met.""" + guardrail = self._create_guardrail_with_rule_having_multiple_conditions() + input_data = { + "userName": "John", + "age": 15, # < 18 + "isActive": False, # Not True + } + output_data = { + "result": "Success", + "status": 201, # Not 200 + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + + def test_post_execution_with_all_fields_selector_when_no_field_violates_condition_then_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test guardrail with AllFieldsSelector passes when output has matching fields.""" + guardrail = DeterministicGuardrail( + id="test-all-fields-id", + name="Guardrail With All Fields Selector", + description="Test all fields selector", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=AllFieldsSelector( + selector_type="all", sources=[FieldSource.OUTPUT] + ), + detects_violation=lambda n: n != 25.0, + ), + ], + ) + + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data = { + "result": "Success", + "status": 25, # Doesn't match the rule value + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + + def test_post_execution_with_all_fields_selector_when_all_fields_violate_condition_then_returns_false( + self, + service: DeterministicGuardrailsService, + ) -> None: + guardrail = DeterministicGuardrail( + id="test-all-fields-id", + name="Guardrail With All Fields Selector", + description="Test all fields selector", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=AllFieldsSelector( + selector_type="all", sources=[FieldSource.OUTPUT] + ), + detects_violation=lambda n: n != 25.0, + ), + ], + ) + + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data = { + "result": "Success", + "status": 20, # Matches the rule value + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + + def test_post_execution_with_all_fields_selector_when_empty_output_schema_then_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test guardrail with AllFieldsSelector fails when output is empty.""" + guardrail = DeterministicGuardrail( + id="test-all-fields-id", + name="Guardrail With All Fields Selector", + description="Test all fields selector", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=AllFieldsSelector( + selector_type="all", sources=[FieldSource.INPUT] + ), + detects_violation=lambda n: n != 200.0, + ), + ], + ) + + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data: dict[str, Any] = {} # Empty output + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + + def test_should_trigger_policy_pre_execution_always_rule_with_input_apply_to_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test UniversalRule with INPUT ApplyTo triggers in pre-execution.""" + guardrail = self._create_guardrail_with_always_rule(ApplyTo.INPUT) + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + + result = service.evaluate_pre_deterministic_guardrail( + input_data=input_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + assert result.reason == "Always rule enforced" + + def test_should_trigger_policy_pre_execution_always_rule_with_output_apply_to_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test UniversalRule with OUTPUT ApplyTo does not trigger in pre-execution.""" + guardrail = self._create_guardrail_with_always_rule(ApplyTo.OUTPUT) + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data: dict[str, Any] = {} + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + assert result.reason == "No rules to apply for input data" + + def test_should_trigger_policy_pre_execution_always_rule_with_input_and_output_apply_to_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test UniversalRule with INPUT_AND_OUTPUT ApplyTo triggers in pre-execution.""" + guardrail = self._create_guardrail_with_always_rule(ApplyTo.INPUT_AND_OUTPUT) + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data: dict[str, Any] = {} + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + assert result.reason == "Always rule enforced" + + def test_should_trigger_policy_post_execution_always_rule_with_input_apply_to_returns_false( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test UniversalRule with INPUT ApplyTo does not trigger in post-execution.""" + guardrail = self._create_guardrail_with_always_rule(ApplyTo.INPUT) + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data = { + "result": "Success", + "status": 200, + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + assert result.reason == "No rules to apply for output data." + + def test_should_trigger_policy_post_execution_always_rule_with_output_apply_to_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test UniversalRule with OUTPUT ApplyTo triggers in post-execution.""" + guardrail = self._create_guardrail_with_always_rule(ApplyTo.OUTPUT) + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data = { + "result": "Success", + "status": 200, + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + assert result.reason == "Always rule enforced" + + def test_should_trigger_policy_post_execution_always_rule_with_input_and_output_apply_to_returns_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test UniversalRule with INPUT_AND_OUTPUT ApplyTo triggers in post-execution.""" + guardrail = self._create_guardrail_with_always_rule(ApplyTo.INPUT_AND_OUTPUT) + input_data = { + "userName": "John", + "age": 25, + "isActive": True, + } + output_data = { + "result": "Success", + "status": 200, + "success": True, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=guardrail, + ) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + assert result.reason == "Always rule enforced" + + # Helper methods to create guardrails + + def _create_guardrail_for_pre_execution(self) -> DeterministicGuardrail: + """Create a guardrail for pre-execution testing.""" + return DeterministicGuardrail( + id="test-pre-exec-id", + name="Pre execution Guardrail", + description="Test pre-execution guardrail", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[FieldReference(path="age", source=FieldSource.INPUT)], + ), + detects_violation=lambda n: n < 21.0, + ), + BooleanRule( + rule_type="boolean", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="isActive", source=FieldSource.INPUT) + ], + ), + detects_violation=lambda b: b is not True, + ), + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="status", source=FieldSource.OUTPUT) + ], + ), + detects_violation=lambda n: n != 200.0, + ), + ], + ) + + def _create_guardrail_for_post_execution(self) -> DeterministicGuardrail: + """Create a guardrail for post-execution testing.""" + return DeterministicGuardrail( + id="test-post-exec-id", + name="Guardrail for Post execution", + description="Test post-execution guardrail", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[FieldReference(path="age", source=FieldSource.INPUT)], + ), + detects_violation=lambda n: n < 21.0, + ), + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="status", source=FieldSource.OUTPUT) + ], + ), + detects_violation=lambda n: n != 200.0, + ), + ], + ) + + def _create_guardrail_with_multiple_rules(self) -> DeterministicGuardrail: + """Create a guardrail with multiple rules.""" + return DeterministicGuardrail( + id="test-multiple-rules-id", + name="Guardrail With Multiple Rules", + description="Test guardrail with multiple rules", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[FieldReference(path="age", source=FieldSource.INPUT)], + ), + detects_violation=lambda n: n < 21.0, + ), + BooleanRule( + rule_type="boolean", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="isActive", source=FieldSource.INPUT) + ], + ), + detects_violation=lambda b: b is not True, + ), + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="status", source=FieldSource.OUTPUT) + ], + ), + detects_violation=lambda n: n != 200.0, + ), + ], + ) + + def _create_guardrail_with_rule_having_multiple_conditions( + self, + ) -> DeterministicGuardrail: + """Create a guardrail with rule having multiple conditions.""" + return DeterministicGuardrail( + id="test-multiple-conditions-id", + name="Guardrail With Rule Having Multiple Conditions", + description="Test guardrail with multiple conditions", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[FieldReference(path="age", source=FieldSource.INPUT)], + ), + detects_violation=lambda n: n < 21.0, + ), + BooleanRule( + rule_type="boolean", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="isActive", source=FieldSource.INPUT) + ], + ), + detects_violation=lambda b: b is not True, + ), + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="status", source=FieldSource.OUTPUT) + ], + ), + detects_violation=lambda n: n != 200.0, + ), + ], + ) + + def test_evaluate_post_deterministic_guardrail_word_contains_operator_passes( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test deterministic guardrail with word contains operator passes for pre-execution.""" + deterministic_guardrail = DeterministicGuardrail( + id="b4283bd4-5ce0-49de-a918-2604d830460c", + name="Before", + description="", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["ConverterToStringAgent"] + ), + rules=[ + WordRule( + rule_type="word", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference( + path="input_string", source=FieldSource.INPUT + ) + ], + ), + detects_violation=lambda s: "cti" in s, + ), + ], + ) + + # Input data without "cti" in input_string - should pass + input_data = { + "input_string": "test value", + } + output_data: dict[str, Any] = {} + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + assert result.reason == "No rules to apply for output data." + + def test_evaluate_post_deterministic_guardrail_only_output_rules_passes( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test post guardrail with only output rules passes when conditions are met.""" + deterministic_guardrail = DeterministicGuardrail( + id="test-only-output-id", + name="Output Only Guardrail", + description="Test guardrail with only output rules", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="status", source=FieldSource.OUTPUT) + ], + ), + detects_violation=lambda n: n != 200.0, + ), + WordRule( + rule_type="word", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="result", source=FieldSource.OUTPUT) + ], + ), + detects_violation=lambda s: s != "Success", + ), + ], + ) + + input_data = { + "userName": "John", + } + output_data = { + "status": 200, + "result": "Success", + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + assert ( + result.reason + == "Output data didn't match the guardrail condition: [status] comparing function [(n): n != 200.0]" + ) + + def test_evaluate_post_deterministic_guardrail_only_always_rule_fails( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test post guardrail with only UniversalRule always fails.""" + deterministic_guardrail = DeterministicGuardrail( + id="test-only-always-id", + name="Always Rule Only Guardrail", + description="Test guardrail with only always rule", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + UniversalRule( + rule_type="always", + apply_to=ApplyTo.OUTPUT, + ), + ], + ) + + input_data = { + "userName": "John", + } + output_data = { + "status": 200, + } + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + assert result.reason == "Always rule enforced" + + def test_evaluate_post_deterministic_guardrail_only_input_rules_passes( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test post guardrail passes when only input rules exist (no output data required).""" + deterministic_guardrail = DeterministicGuardrail( + id="test-only-input-id", + name="Input Only Guardrail", + description="Test guardrail with only input rules", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[FieldReference(path="age", source=FieldSource.INPUT)], + ), + detects_violation=lambda n: n < 18, + ), + WordRule( + rule_type="word", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[FieldReference(path="name", source=FieldSource.INPUT)], + ), + detects_violation=lambda s: len(s) < 2, + ), + ], + ) + + input_data = { + "age": 25, + "name": "John", + } + output_data: dict[str, Any] = {} + + result = service.evaluate_post_deterministic_guardrail( + input_data=input_data, + output_data=output_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + assert result.reason == "No rules to apply for output data." + + def test_evaluate_pre_deterministic_guardrail_with_input_and_output_rules_input_true( + self, + service: DeterministicGuardrailsService, + ) -> None: + """Test pre-execution guardrail with input rule and output rules, should pass because is ignored.""" + deterministic_guardrail = DeterministicGuardrail( + id="test-pre-mixed-rules-id", + name="Pre Execution Mixed Rules Guardrail", + description="Test pre-execution with both input and output rules", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[FieldReference(path="age", source=FieldSource.INPUT)], + ), + detects_violation=lambda n: n < 21.0, + ), + BooleanRule( + rule_type="boolean", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="isActive", source=FieldSource.INPUT) + ], + ), + detects_violation=lambda b: b is not True, + ), + # Output rule - should be ignored in pre-execution + NumberRule( + rule_type="number", + field_selector=SpecificFieldsSelector( + selector_type="specific", + fields=[ + FieldReference(path="status", source=FieldSource.OUTPUT) + ], + ), + detects_violation=lambda n: n != 200.0, + ), + ], + ) + + input_data = { + "userName": "John", + "age": 18, + "isActive": True, + } + + result = service.evaluate_pre_deterministic_guardrail( + input_data=input_data, + guardrail=deterministic_guardrail, + ) + + assert result.result == GuardrailValidationResultType.PASSED + + def _create_guardrail_with_always_rule( + self, apply_to: ApplyTo + ) -> DeterministicGuardrail: + """Create a guardrail with an AlwaysRule (UniversalRule).""" + return DeterministicGuardrail( + id="test-always-rule-id", + name="Guardrail With Always Rule", + description="Test guardrail with always rule", + enabled_for_evals=True, + guardrail_type="custom", + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["test"] + ), + rules=[ + UniversalRule( + rule_type="always", + apply_to=apply_to, + ), + ], + ) diff --git a/packages/uipath-core/tests/guardrails/test_guardrails_models.py b/packages/uipath-core/tests/guardrails/test_guardrails_models.py new file mode 100644 index 000000000..954cdd41c --- /dev/null +++ b/packages/uipath-core/tests/guardrails/test_guardrails_models.py @@ -0,0 +1,53 @@ +"""Tests for guardrails models normalization validators.""" + +from typing import TYPE_CHECKING + +from uipath.core.guardrails import ( + AllFieldsSelector, + FieldReference, + FieldSource, +) + +if TYPE_CHECKING: + pass + + +class TestGuardrailsModelsNormalization: + """Test guardrails models field normalization.""" + + def test_field_reference_normalizes_capitalized_source( + self, + ) -> None: + """Test that FieldReference normalizes capitalized source values to lowercase.""" + # Create FieldReference with capitalized "Input" - should normalize to FieldSource.INPUT + field_ref = FieldReference(path="testField", source="Input") # type: ignore[arg-type] + assert field_ref.source == FieldSource.INPUT + + # Create FieldReference with capitalized "Output" - should normalize to FieldSource.OUTPUT + field_ref = FieldReference(path="testField", source="Output") # type: ignore[arg-type] + assert field_ref.source == FieldSource.OUTPUT + + # Create FieldReference with lowercase "input" - should work as-is + field_ref = FieldReference(path="testField", source="input") # type: ignore[arg-type] + assert field_ref.source == FieldSource.INPUT + + def test_all_fields_selector_normalizes_capitalized_sources( + self, + ) -> None: + """Test that AllFieldsSelector normalizes capitalized source values in the list.""" + # Create AllFieldsSelector with capitalized "Input" and "Output" - should normalize + selector = AllFieldsSelector( + selector_type="all", + sources=["Input", "Output"], # type: ignore[list-item] + ) + assert FieldSource.INPUT in selector.sources + assert FieldSource.OUTPUT in selector.sources + assert len(selector.sources) == 2 + + # Create AllFieldsSelector with mixed case - should normalize all + selector = AllFieldsSelector( + selector_type="all", + sources=["Input", "output"], # type: ignore[list-item] + ) + assert FieldSource.INPUT in selector.sources + assert FieldSource.OUTPUT in selector.sources diff --git a/packages/uipath-core/tests/serialization/test_json.py b/packages/uipath-core/tests/serialization/test_json.py new file mode 100644 index 000000000..7f1b13433 --- /dev/null +++ b/packages/uipath-core/tests/serialization/test_json.py @@ -0,0 +1,638 @@ +"""Tests for serialization utilities.""" + +import json +from collections import namedtuple +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +from typing import Any +from zoneinfo import ZoneInfo + +import pytest +from pydantic import BaseModel + +from uipath.core.serialization import serialize_json + + +def _has_tzdata() -> bool: + """Check if timezone data is available.""" + try: + ZoneInfo("America/New_York") + return True + except Exception: + return False + + +class Color(Enum): + """Test enum.""" + + RED = "red" + GREEN = "green" + BLUE = 3 + + +class Priority(Enum): + """Test enum with int values.""" + + LOW = 1 + MEDIUM = 2 + HIGH = 3 + + +class SimpleModel(BaseModel): + """Simple Pydantic v2 model.""" + + name: str + value: int + + +class NestedModel(BaseModel): + """Pydantic model with nested model.""" + + id: str + inner: SimpleModel + items: list[SimpleModel] + + +@dataclass +class SimpleDataclass: + """Simple dataclass for testing.""" + + name: str + count: int + + +@dataclass +class NestedDataclass: + """Dataclass with nested dataclass.""" + + id: str + inner: SimpleDataclass + + +Point = namedtuple("Point", ["x", "y"]) + + +class TestSimpleSerializeDefaults: + """Tests for serialize_defaults and serialize_json functions.""" + + def test_serializes_none(self) -> None: + """Test None serialization via json.dumps.""" + data = {"value": None} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["value"] is None + + def test_serializes_primitives(self) -> None: + """Test primitive types pass through json.dumps unchanged.""" + data = { + "bool_true": True, + "bool_false": False, + "integer": 42, + "float": 3.14, + "string": "hello", + } + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["bool_true"] is True + assert parsed["bool_false"] is False + assert parsed["integer"] == 42 + assert parsed["float"] == 3.14 + assert parsed["string"] == "hello" + + def test_serializes_pydantic_model(self) -> None: + """Test Pydantic BaseModel serialization via json.dumps.""" + model = SimpleModel(name="test", value=42) + result = serialize_json(model) + parsed = json.loads(result) + assert isinstance(parsed, dict) + assert parsed["name"] == "test" + assert parsed["value"] == 42 + + def test_serializes_nested_pydantic_model(self) -> None: + """Test nested Pydantic models via json.dumps.""" + inner = SimpleModel(name="inner", value=10) + model = NestedModel( + id="123", + inner=inner, + items=[ + SimpleModel(name="item1", value=1), + SimpleModel(name="item2", value=2), + ], + ) + result = serialize_json(model) + parsed = json.loads(result) + assert isinstance(parsed, dict) + assert parsed["id"] == "123" + assert parsed["inner"]["name"] == "inner" + assert len(parsed["items"]) == 2 + assert parsed["items"][0]["name"] == "item1" + assert parsed["items"][1]["value"] == 2 + + def test_serializes_pydantic_model_excludes_none(self) -> None: + """Test Pydantic model with None values excluded via json.dumps.""" + + class OptionalModel(BaseModel): + required: str + optional: str | None = None + + model = OptionalModel(required="value") + result = serialize_json(model) + parsed = json.loads(result) + assert isinstance(parsed, dict) + assert parsed["required"] == "value" + # exclude_none=True should exclude the None field + assert "optional" not in parsed + + def test_serializes_pydantic_model_class(self) -> None: + """Test Pydantic model class (not instance) serialization via json.dumps.""" + data = {"model_class": SimpleModel} + result = serialize_json(data) + parsed = json.loads(result) + assert isinstance(parsed["model_class"], dict) + assert parsed["model_class"]["__class__"] == "SimpleModel" + assert parsed["model_class"]["__module__"] == "test_json" + assert "schema" in parsed["model_class"] + assert isinstance(parsed["model_class"]["schema"], dict) + + def test_serializes_dataclass(self) -> None: + """Test dataclass serialization via json.dumps.""" + obj = SimpleDataclass(name="test", count=5) + result = serialize_json(obj) + parsed = json.loads(result) + assert isinstance(parsed, dict) + assert parsed["name"] == "test" + assert parsed["count"] == 5 + + def test_serializes_nested_dataclass(self) -> None: + """Test nested dataclass serialization via json.dumps.""" + inner = SimpleDataclass(name="inner", count=10) + obj = NestedDataclass(id="123", inner=inner) + result = serialize_json(obj) + parsed = json.loads(result) + assert isinstance(parsed, dict) + assert parsed["id"] == "123" + assert parsed["inner"]["name"] == "inner" + assert parsed["inner"]["count"] == 10 + + def test_serializes_enum_string_value(self) -> None: + """Test enum with string value via json.dumps.""" + data = {"color": Color.RED} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["color"] == "red" + + def test_serializes_enum_int_value(self) -> None: + """Test enum with int value via json.dumps.""" + data = {"priority": Priority.HIGH} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["priority"] == 3 + + def test_serializes_enum_mixed_value(self) -> None: + """Test enum with mixed types via json.dumps.""" + data = {"color1": Color.GREEN, "color2": Color.BLUE} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["color1"] == "green" + assert parsed["color2"] == 3 + + def test_serializes_datetime(self) -> None: + """Test datetime serialization via json.dumps.""" + dt = datetime(2024, 1, 15, 10, 30, 45) + data = {"timestamp": dt} + result = serialize_json(data) + parsed = json.loads(result) + assert isinstance(parsed["timestamp"], str) + assert "2024-01-15" in parsed["timestamp"] + assert "10:30:45" in parsed["timestamp"] + + def test_serializes_datetime_with_timezone(self) -> None: + """Test datetime with timezone via json.dumps.""" + dt = datetime(2024, 1, 15, 10, 30, 45, tzinfo=timezone.utc) + data = {"timestamp": dt} + result = serialize_json(data) + parsed = json.loads(result) + assert isinstance(parsed["timestamp"], str) + assert parsed["timestamp"] == "2024-01-15T10:30:45+00:00" + + def test_serializes_timezone(self) -> None: + """Test timezone object serialization via json.dumps.""" + tz = timezone.utc + data = {"timezone": tz} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["timezone"] == "UTC" + + @pytest.mark.skipif(not _has_tzdata(), reason="Timezone data not available") + def test_serializes_zoneinfo(self) -> None: + """Test ZoneInfo serialization via json.dumps.""" + tz = ZoneInfo("America/New_York") + data = {"timezone": tz} + result = serialize_json(data) + parsed = json.loads(result) + # ZoneInfo returns timezone name or None + assert isinstance(parsed["timezone"], (str, type(None))) + + def test_serializes_set(self) -> None: + """Test set serialization via json.dumps.""" + obj = {1, 2, 3} + data = {"numbers": obj} + result = serialize_json(data) + parsed = json.loads(result) + assert isinstance(parsed["numbers"], list) + assert set(parsed["numbers"]) == {1, 2, 3} + + def test_serializes_tuple(self) -> None: + """Test tuple serialization via json.dumps.""" + obj = (1, 2, 3) + data = {"numbers": obj} + result = serialize_json(data) + parsed = json.loads(result) + assert isinstance(parsed["numbers"], list) + assert parsed["numbers"] == [1, 2, 3] + + def test_serializes_named_tuple(self) -> None: + """Test named tuple serialization via json.dumps. + + Note: Python's json encoder treats namedtuples as regular tuples, + so they serialize as lists [x, y] rather than dicts {"x": x, "y": y}. + The serialize_defaults function is not called for namedtuples + because they're natively JSON-serializable as tuples. + """ + point = Point(x=10, y=20) + data = {"point": point} + result = serialize_json(data) + parsed = json.loads(result) + # Namedtuples serialize as lists through json.dumps + assert isinstance(parsed["point"], list) + assert parsed["point"] == [10, 20] + + def test_serializes_object_with_as_dict(self) -> None: + """Test object with as_dict property via json.dumps.""" + + class RuntimeLike: + @property + def as_dict(self) -> dict[str, Any]: + return {"host": "localhost", "port": 8080} + + obj = RuntimeLike() + data = {"runtime": obj} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["runtime"] == {"host": "localhost", "port": 8080} + + def test_serializes_object_with_to_dict(self) -> None: + """Test object with to_dict method via json.dumps.""" + + class CustomObject: + def to_dict(self) -> dict[str, Any]: + return {"custom": "value"} + + obj = CustomObject() + data = {"obj": obj} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["obj"] == {"custom": "value"} + + def test_serializes_unknown_object_to_str(self) -> None: + """Test unknown object falls back to str() via json.dumps.""" + + class CustomClass: + def __str__(self) -> str: + return "custom_string" + + obj = CustomClass() + data = {"obj": obj} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["obj"] == "custom_string" + + def test_serializes_exception(self) -> None: + """Test Exception serialization via json.dumps.""" + err = ValueError("something went wrong") + data = {"error": err} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["error"] == "something went wrong" + + def test_with_json_dumps(self) -> None: + """Test integration with json.dumps().""" + + class ComplexObject(BaseModel): + name: str + created_at: datetime + priority: Priority + + obj = ComplexObject( + name="task", + created_at=datetime(2024, 1, 15, 10, 30), + priority=Priority.HIGH, + ) + + # Should not raise TypeError + result = serialize_json(obj) + parsed = json.loads(result) + + assert parsed["name"] == "task" + assert "2024-01-15" in parsed["created_at"] + assert parsed["priority"] == 3 + + def test_with_json_dumps_complex_nested(self) -> None: + """Test with complex nested structure.""" + data = { + "model": SimpleModel(name="test", value=42), + "dataclass": SimpleDataclass(name="dc", count=5), + "enum": Color.RED, + "datetime": datetime(2024, 1, 1), + "set": {1, 2, 3}, + "tuple": (4, 5, 6), + "error": ValueError("something failed"), + } + + result = serialize_json(data) + parsed = json.loads(result) + + assert parsed["model"]["name"] == "test" + assert parsed["dataclass"]["name"] == "dc" + assert parsed["enum"] == "red" + assert "2024-01-01" in parsed["datetime"] + assert set(parsed["set"]) == {1, 2, 3} + assert parsed["tuple"] == [4, 5, 6] + assert parsed["error"] == "something failed" + + def test_with_list_of_pydantic_models(self) -> None: + """Test with list of Pydantic models (common MCP scenario).""" + models = [ + SimpleModel(name="first", value=1), + SimpleModel(name="second", value=2), + SimpleModel(name="third", value=3), + ] + + # This should not raise TypeError + result = serialize_json(models) + parsed = json.loads(result) + + assert len(parsed) == 3 + assert parsed[0]["name"] == "first" + assert parsed[1]["value"] == 2 + + def test_recursive_enum_serialization(self) -> None: + """Test that enum values are recursively serialized via json.dumps.""" + + class NestedEnum(Enum): + """Enum with complex value.""" + + COMPLEX = {"key": "value"} + + data = {"enum": NestedEnum.COMPLEX} + result = serialize_json(data) + parsed = json.loads(result) + # The enum value itself (a dict) should be returned as-is + assert parsed["enum"] == {"key": "value"} + assert isinstance(parsed["enum"], dict) + + def test_dataclass_class_returns_string(self) -> None: + """Test that dataclass class (not instance) falls back to str via json.dumps.""" + data = {"dataclass_class": SimpleDataclass} + result = serialize_json(data) + parsed = json.loads(result) + assert isinstance(parsed["dataclass_class"], str) + assert "SimpleDataclass" in parsed["dataclass_class"] + + def test_empty_collections(self) -> None: + """Test empty collections via json.dumps.""" + data: dict[str, Any] = {"empty_set": set(), "empty_tuple": (), "empty_list": []} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["empty_set"] == [] + assert parsed["empty_tuple"] == [] + assert parsed["empty_list"] == [] + + def test_with_dict_method(self) -> None: + """Test object with dict() method (Pydantic v1 compatibility) via json.dumps.""" + + class OldStyleModel: + def dict(self) -> dict[str, Any]: + return {"old": "style"} + + obj = OldStyleModel() + data = {"obj": obj} + result = serialize_json(data) + parsed = json.loads(result) + assert parsed["obj"] == {"old": "style"} + + def test_dict_of_pydantic_models(self) -> None: + """Test dictionary containing Pydantic models as values.""" + data = { + "user1": SimpleModel(name="Alice", value=100), + "user2": SimpleModel(name="Bob", value=200), + "user3": SimpleModel(name="Charlie", value=300), + } + + result = serialize_json(data) + parsed = json.loads(result) + + assert isinstance(parsed, dict) + assert parsed["user1"]["name"] == "Alice" + assert parsed["user1"]["value"] == 100 + assert parsed["user2"]["name"] == "Bob" + assert parsed["user2"]["value"] == 200 + assert parsed["user3"]["name"] == "Charlie" + assert parsed["user3"]["value"] == 300 + + def test_dict_of_dataclass_models(self) -> None: + """Test dictionary containing dataclass instances as values.""" + data = { + "item1": SimpleDataclass(name="First", count=1), + "item2": SimpleDataclass(name="Second", count=2), + "item3": SimpleDataclass(name="Third", count=3), + } + + result = serialize_json(data) + parsed = json.loads(result) + + assert isinstance(parsed, dict) + assert parsed["item1"]["name"] == "First" + assert parsed["item1"]["count"] == 1 + assert parsed["item2"]["name"] == "Second" + assert parsed["item2"]["count"] == 2 + assert parsed["item3"]["name"] == "Third" + assert parsed["item3"]["count"] == 3 + + def test_normal_class_fallback_to_str(self) -> None: + """Test normal class (not Pydantic, dataclass, or enum) falls back to str().""" + + class RegularClass: + def __init__(self, value: str) -> None: + self.value = value + + def __str__(self) -> str: + return f"RegularClass({self.value})" + + obj = RegularClass("test_value") + data = {"object": obj, "name": "test"} + json_result = serialize_json(data) + parsed = json.loads(json_result) + assert parsed["object"] == "RegularClass(test_value)" + assert parsed["name"] == "test" + + def test_list_of_dataclass(self) -> None: + """Test list containing dataclass instances.""" + data = [ + SimpleDataclass(name="First", count=1), + SimpleDataclass(name="Second", count=2), + SimpleDataclass(name="Third", count=3), + ] + + result = serialize_json(data) + parsed = json.loads(result) + + assert isinstance(parsed, list) + assert len(parsed) == 3 + assert parsed[0]["name"] == "First" + assert parsed[0]["count"] == 1 + assert parsed[1]["name"] == "Second" + assert parsed[2]["count"] == 3 + + def test_list_of_pydantic_models(self) -> None: + """Test list containing Pydantic model instances.""" + data = [ + SimpleModel(name="Alice", value=100), + SimpleModel(name="Bob", value=200), + SimpleModel(name="Charlie", value=300), + ] + + result = serialize_json(data) + parsed = json.loads(result) + + assert isinstance(parsed, list) + assert len(parsed) == 3 + assert parsed[0]["name"] == "Alice" + assert parsed[0]["value"] == 100 + assert parsed[1]["name"] == "Bob" + assert parsed[2]["value"] == 300 + + def test_list_of_normal_classes(self) -> None: + """Test list containing normal class instances (fallback to str).""" + + class Item: + def __init__(self, id: int, label: str) -> None: + self.id = id + self.label = label + + def __str__(self) -> str: + return f"Item(id={self.id}, label={self.label})" + + data = [ + Item(1, "First"), + Item(2, "Second"), + Item(3, "Third"), + ] + + result = serialize_json(data) + parsed = json.loads(result) + + assert isinstance(parsed, list) + assert len(parsed) == 3 + assert parsed[0] == "Item(id=1, label=First)" + assert parsed[1] == "Item(id=2, label=Second)" + assert parsed[2] == "Item(id=3, label=Third)" + + def test_list_of_mixed_types(self) -> None: + """Test list containing mixed types: Pydantic, dataclass, normal class, primitives.""" + + class CustomItem: + def __init__(self, name: str) -> None: + self.name = name + + def __str__(self) -> str: + return f"CustomItem({self.name})" + + data = [ + SimpleModel(name="pydantic", value=1), + SimpleDataclass(name="dataclass", count=2), + CustomItem("custom"), + "plain_string", + 42, + True, + None, + Color.RED, + ] + + result = serialize_json(data) + parsed = json.loads(result) + + assert isinstance(parsed, list) + assert len(parsed) == 8 + # Pydantic model + assert parsed[0]["name"] == "pydantic" + assert parsed[0]["value"] == 1 + # Dataclass + assert parsed[1]["name"] == "dataclass" + assert parsed[1]["count"] == 2 + # Normal class (str fallback) + assert parsed[2] == "CustomItem(custom)" + # Primitives + assert parsed[3] == "plain_string" + assert parsed[4] == 42 + assert parsed[5] is True + assert parsed[6] is None + # Enum + assert parsed[7] == "red" + + def test_list_of_lists_mixed(self) -> None: + """Test nested lists containing mixed types.""" + + class Widget: + def __init__(self, id: int) -> None: + self.id = id + + def __str__(self) -> str: + return f"Widget({self.id})" + + data = [ + [SimpleModel(name="model1", value=1), SimpleModel(name="model2", value=2)], + [ + SimpleDataclass(name="dc1", count=10), + SimpleDataclass(name="dc2", count=20), + ], + [Widget(1), Widget(2), Widget(3)], + ["string1", "string2"], + [1, 2, 3, 4], + [Color.RED, Color.GREEN, Priority.HIGH], + [True, False, None], + ] + + result = serialize_json(data) + parsed = json.loads(result) + + assert isinstance(parsed, list) + assert len(parsed) == 7 + + # First sublist: Pydantic models + assert len(parsed[0]) == 2 + assert parsed[0][0]["name"] == "model1" + assert parsed[0][1]["value"] == 2 + + # Second sublist: Dataclasses + assert len(parsed[1]) == 2 + assert parsed[1][0]["name"] == "dc1" + assert parsed[1][1]["count"] == 20 + + # Third sublist: Normal classes + assert len(parsed[2]) == 3 + assert parsed[2][0] == "Widget(1)" + assert parsed[2][2] == "Widget(3)" + + # Fourth sublist: Strings + assert parsed[3] == ["string1", "string2"] + + # Fifth sublist: Integers + assert parsed[4] == [1, 2, 3, 4] + + # Sixth sublist: Enums + assert parsed[5] == ["red", "green", 3] + + # Seventh sublist: Booleans and None + assert parsed[6] == [True, False, None] diff --git a/packages/uipath-core/tests/tracing/test_external_integration.py b/packages/uipath-core/tests/tracing/test_external_integration.py new file mode 100644 index 000000000..2c27cf28d --- /dev/null +++ b/packages/uipath-core/tests/tracing/test_external_integration.py @@ -0,0 +1,89 @@ +"""Test span nesting behavior for traced decorators.""" + +from opentelemetry import trace + +from tests.conftest import SpanCapture + + +def test_external_span_provider_integration(span_capture: SpanCapture): + """Test integration with external span provider.""" + from uipath.core.tracing.decorators import traced + from uipath.core.tracing.span_utils import UiPathSpanUtils + + # Create a mock external span + external_tracer = trace.get_tracer("external") + + with external_tracer.start_as_current_span("external_span"): + # Register a provider that returns the external span + UiPathSpanUtils.register_current_span_provider(lambda: trace.get_current_span()) + + @traced(name="internal_span") + def internal_function(): + return "result" + + result = internal_function() + + assert result == "result" + + # Clean up + UiPathSpanUtils.register_current_span_provider(None) + + spans = span_capture.get_spans() + + # Should have both external and internal spans + internal_span = next((s for s in spans if s.name == "internal_span"), None) + external_span_recorded = next((s for s in spans if s.name == "external_span"), None) + + assert internal_span is not None + assert external_span_recorded is not None + + # Internal span should be child of external span + assert internal_span.parent.span_id == external_span_recorded.context.span_id + + span_capture.print_hierarchy() + + +def test_external_span_provider_returns_none(span_capture: SpanCapture): + """Test that None from external span provider is handled.""" + from uipath.core.tracing.decorators import traced + from uipath.core.tracing.span_utils import UiPathSpanUtils + + # Register a provider that returns None + UiPathSpanUtils.register_current_span_provider(lambda: None) + + @traced(name="test_span") + def test_function(): + return "result" + + result = test_function() + assert result == "result" + + # Clean up + UiPathSpanUtils.register_current_span_provider(None) + + spans = span_capture.get_spans() + assert len(spans) == 1 + + +def test_external_span_provider_raises_exception(span_capture: SpanCapture): + """Test that exceptions from external span provider are caught.""" + from uipath.core.tracing.decorators import traced + from uipath.core.tracing.span_utils import UiPathSpanUtils + + def failing_provider(): + raise RuntimeError("Provider failed!") + + UiPathSpanUtils.register_current_span_provider(failing_provider) + + @traced(name="test_span") + def test_function(): + return "result" + + result = test_function() + assert result == "result" + + # Clean up + UiPathSpanUtils.register_current_span_provider(None) + + spans = span_capture.get_spans() + assert len(spans) == 1 diff --git a/packages/uipath-core/tests/tracing/test_serialization.py b/packages/uipath-core/tests/tracing/test_serialization.py new file mode 100644 index 000000000..7f04daf6f --- /dev/null +++ b/packages/uipath-core/tests/tracing/test_serialization.py @@ -0,0 +1,171 @@ +import json + +from tests.conftest import SpanCapture + + +def test_traced_with_pydantic_model_input(span_capture: SpanCapture): + """Test tracing with Pydantic model as input.""" + from pydantic import BaseModel + + from uipath.core.tracing.decorators import traced + + class UserModel(BaseModel): + name: str + age: int + + @traced(name="process_user") + def process_user(user: UserModel): + return f"{user.name} is {user.age}" + + user = UserModel(name="Alice", age=30) + result = process_user(user) + + assert result == "Alice is 30" + + spans = span_capture.get_spans() + assert len(spans) == 1 + + span = spans[0] + # Verify input was serialized + input_value = span.attributes.get("input.value") + assert input_value is not None + input_data = json.loads(input_value) + assert input_data["user"]["name"] == "Alice" + assert input_data["user"]["age"] == 30 + + +def test_traced_with_dataclass_input(span_capture: SpanCapture): + """Test tracing with dataclass as input.""" + from dataclasses import dataclass + + from uipath.core.tracing.decorators import traced + + @dataclass + class Product: + name: str + price: float + + @traced(name="calculate_total") + def calculate_total(product: Product, quantity: int): + return product.price * quantity + + product = Product(name="Widget", price=9.99) + result = calculate_total(product, 5) + + assert result == 49.95 + + spans = span_capture.get_spans() + assert len(spans) == 1 + + span = spans[0] + input_value = span.attributes.get("input.value") + assert input_value is not None + input_data = json.loads(input_value) + assert input_data["product"]["name"] == "Widget" + assert input_data["product"]["price"] == 9.99 + + +def test_traced_with_enum_input(span_capture: SpanCapture): + """Test tracing with enum as input.""" + from enum import Enum + + from uipath.core.tracing.decorators import traced + + class Status(Enum): + PENDING = "pending" + COMPLETED = "completed" + + @traced(name="update_status") + def update_status(status: Status): + return f"Status is {status.value}" + + result = update_status(Status.COMPLETED) + + assert result == "Status is completed" + + spans = span_capture.get_spans() + assert len(spans) == 1 + + span = spans[0] + input_value = span.attributes.get("input.value") + assert input_value is not None + input_data = json.loads(input_value) + assert input_data["status"] == "completed" + + +def test_traced_with_datetime_input(span_capture: SpanCapture): + """Test tracing with datetime as input.""" + from datetime import datetime + + from uipath.core.tracing.decorators import traced + + @traced(name="process_timestamp") + def process_timestamp(timestamp: datetime): + return timestamp.isoformat() + + dt = datetime(2024, 1, 15, 10, 30, 0) + process_timestamp(dt) + + spans = span_capture.get_spans() + assert len(spans) == 1 + + span = spans[0] + input_value = span.attributes.get("input.value") + assert input_value is not None + input_data = json.loads(input_value) + assert "2024-01-15" in input_data["timestamp"] + + +def test_traced_with_complex_return_value(span_capture: SpanCapture): + """Test tracing with complex return value.""" + from typing import Any + + from pydantic import BaseModel + + from uipath.core.tracing.decorators import traced + + class Result(BaseModel): + success: bool + data: dict[str, Any] + + @traced(name="get_result") + def get_result(): + return Result(success=True, data={"key": "value"}) + + get_result() + + spans = span_capture.get_spans() + assert len(spans) == 1 + + span = spans[0] + output_value = span.attributes.get("output.value") + assert output_value is not None + output_data = json.loads(output_value) + assert output_data["success"] is True + assert output_data["data"]["key"] == "value" + + +def test_traced_with_set_and_tuple(span_capture: SpanCapture): + """Test tracing with set and tuple inputs.""" + from typing import Set, Tuple + + from uipath.core.tracing.decorators import traced + + @traced(name="process_collections") + def process_collections(items: Set[int], pair: Tuple[int, ...]) -> int: + return len(items) + len(pair) + + result = process_collections({1, 2, 3}, (4, 5)) + + assert result == 5 + + spans = span_capture.get_spans() + assert len(spans) == 1 + + span = spans[0] + input_value = span.attributes.get("input.value") + assert input_value is not None + input_data = json.loads(input_value) + # Sets and tuples should be converted to lists + assert isinstance(input_data["items"], list) + assert isinstance(input_data["pair"], list) diff --git a/packages/uipath-core/tests/tracing/test_span_filtering.py b/packages/uipath-core/tests/tracing/test_span_filtering.py new file mode 100644 index 000000000..1c6efabf2 --- /dev/null +++ b/packages/uipath-core/tests/tracing/test_span_filtering.py @@ -0,0 +1,248 @@ +"""Tests for span filtering in trace manager and processors.""" + +from opentelemetry import trace + +from uipath.core.tracing.trace_manager import UiPathTraceManager +from uipath.core.tracing.types import UiPathTraceSettings + + +class TestSpanFiltering: + """Tests for span filtering functionality.""" + + def test_no_filter_exports_all_spans(self): + """Test that without a filter, all spans are exported.""" + trace_manager = UiPathTraceManager() + + tracer = trace.get_tracer("test") + with trace_manager.start_execution_span("root", "exec-1"): + with tracer.start_as_current_span("child-1"): + pass + with tracer.start_as_current_span("child-2"): + pass + + spans = trace_manager.get_execution_spans("exec-1") + assert len(spans) == 3 + span_names = {s.name for s in spans} + assert span_names == {"root", "child-1", "child-2"} + + def test_filter_drops_non_matching_spans(self): + """Test that filter drops spans that don't match the predicate.""" + from unittest.mock import MagicMock + + from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + mock_exporter = MagicMock(spec=SpanExporter) + mock_exporter.export.return_value = SpanExportResult.SUCCESS + + settings = UiPathTraceSettings( + span_filter=lambda span: ( + span.attributes is not None and span.attributes.get("keep") is True + ) + ) + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings) + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("kept", attributes={"keep": True}): + pass + with tracer.start_as_current_span("dropped", attributes={"keep": False}): + pass + with tracer.start_as_current_span("also-dropped"): + pass + + trace_manager.flush_spans() + + exported_spans = [] + for call in mock_exporter.export.call_args_list: + exported_spans.extend(call[0][0]) + + exported_names = {s.name for s in exported_spans} + assert "kept" in exported_names + assert "dropped" not in exported_names + assert "also-dropped" not in exported_names + + def test_filter_by_span_name(self): + """Test filtering spans by name pattern.""" + from unittest.mock import MagicMock + + from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + mock_exporter = MagicMock(spec=SpanExporter) + mock_exporter.export.return_value = SpanExportResult.SUCCESS + + settings = UiPathTraceSettings( + span_filter=lambda span: span.name.startswith("uipath.") + ) + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings) + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("uipath.action"): + pass + with tracer.start_as_current_span("uipath.tool"): + pass + with tracer.start_as_current_span("http.request"): + pass + + trace_manager.flush_spans() + + exported_spans = [] + for call in mock_exporter.export.call_args_list: + exported_spans.extend(call[0][0]) + + exported_names = {s.name for s in exported_spans} + assert "uipath.action" in exported_names + assert "uipath.tool" in exported_names + assert "http.request" not in exported_names + + def test_filter_custom_instrumentation_attribute(self): + """Test filtering by custom instrumentation attribute (low-code scenario).""" + from unittest.mock import MagicMock + + from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + mock_exporter = MagicMock(spec=SpanExporter) + mock_exporter.export.return_value = SpanExportResult.SUCCESS + + settings = UiPathTraceSettings( + span_filter=lambda span: bool( + span.attributes and span.attributes.get("uipath.custom_instrumentation") + ) + ) + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings) + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span( + "custom-span", + attributes={"uipath.custom_instrumentation": True}, + ): + pass + with tracer.start_as_current_span( + "auto-instrumented", + attributes={"http.method": "GET"}, + ): + pass + + trace_manager.flush_spans() + + exported_spans = [] + for call in mock_exporter.export.call_args_list: + exported_spans.extend(call[0][0]) + + exported_names = {s.name for s in exported_spans} + assert "custom-span" in exported_names + assert "auto-instrumented" not in exported_names + + def test_none_filter_same_as_no_filter(self): + """Test that explicit None filter behaves same as no filter.""" + from unittest.mock import MagicMock + + from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + mock_exporter = MagicMock(spec=SpanExporter) + mock_exporter.export.return_value = SpanExportResult.SUCCESS + + settings = UiPathTraceSettings(span_filter=None) + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings) + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("span-1"): + pass + with tracer.start_as_current_span("span-2"): + pass + + trace_manager.flush_spans() + + exported_spans = [] + for call in mock_exporter.export.call_args_list: + exported_spans.extend(call[0][0]) + + assert len(exported_spans) == 2 + + def test_filter_with_empty_attributes(self): + """Test that filter handles spans with no attributes gracefully.""" + from unittest.mock import MagicMock + + from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + mock_exporter = MagicMock(spec=SpanExporter) + mock_exporter.export.return_value = SpanExportResult.SUCCESS + + settings = UiPathTraceSettings( + span_filter=lambda span: ( + span.attributes is not None and span.attributes.get("required") is True + ) + ) + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings) + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("no-attrs"): + pass + with tracer.start_as_current_span( + "has-required", attributes={"required": True} + ): + pass + + trace_manager.flush_spans() + + exported_spans = [] + for call in mock_exporter.export.call_args_list: + exported_spans.extend(call[0][0]) + + exported_names = {s.name for s in exported_spans} + assert "has-required" in exported_names + assert "no-attrs" not in exported_names + + def test_different_filters_per_exporter(self): + """Test that different exporters can have different filters.""" + from unittest.mock import MagicMock + + from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + mock_exporter_a = MagicMock(spec=SpanExporter) + mock_exporter_a.export.return_value = SpanExportResult.SUCCESS + + mock_exporter_b = MagicMock(spec=SpanExporter) + mock_exporter_b.export.return_value = SpanExportResult.SUCCESS + + settings_a = UiPathTraceSettings( + span_filter=lambda span: ( + span.attributes is not None and span.attributes.get("dest") == "a" + ) + ) + settings_b = UiPathTraceSettings( + span_filter=lambda span: ( + span.attributes is not None and span.attributes.get("dest") == "b" + ) + ) + + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter( + mock_exporter_a, batch=False, settings=settings_a + ) + trace_manager.add_span_exporter( + mock_exporter_b, batch=False, settings=settings_b + ) + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("to-a", attributes={"dest": "a"}): + pass + with tracer.start_as_current_span("to-b", attributes={"dest": "b"}): + pass + with tracer.start_as_current_span("to-neither", attributes={"dest": "c"}): + pass + + trace_manager.flush_spans() + + exported_a = [] + for call in mock_exporter_a.export.call_args_list: + exported_a.extend(call[0][0]) + assert {s.name for s in exported_a} == {"to-a"} + + exported_b = [] + for call in mock_exporter_b.export.call_args_list: + exported_b.extend(call[0][0]) + assert {s.name for s in exported_b} == {"to-b"} diff --git a/packages/uipath-core/tests/tracing/test_span_nesting.py b/packages/uipath-core/tests/tracing/test_span_nesting.py new file mode 100644 index 000000000..7245f648a --- /dev/null +++ b/packages/uipath-core/tests/tracing/test_span_nesting.py @@ -0,0 +1,650 @@ +"""Test span nesting behavior for traced decorators.""" + +import pytest + +from tests.conftest import SpanCapture + + +def test_simple_sync_nesting(span_capture: SpanCapture): + """Test that nested sync functions create proper parent-child relationships.""" + from uipath.core.tracing.decorators import traced + + @traced(name="outer") + def outer_function(): + return inner_function() + + @traced(name="inner") + def inner_function(): + return "result" + + result = outer_function() + + assert result == "result" + + spans = span_capture.get_spans() + assert len(spans) == 2, f"Expected 2 spans, got {len(spans)}" + + # Find spans by name + inner_span = next(s for s in spans if s.name == "inner") + outer_span = next(s for s in spans if s.name == "outer") + + # Verify parent-child relationship + assert inner_span.parent is not None, "Inner span should have a parent" + assert inner_span.parent.span_id == outer_span.context.span_id, ( + "Inner span's parent should be outer span" + ) + + # Verify they're in the same trace + assert inner_span.context.trace_id == outer_span.context.trace_id, ( + "Spans should be in the same trace" + ) + + span_capture.print_hierarchy() + + +def test_deep_sync_nesting(span_capture: SpanCapture): + """Test deeply nested sync functions.""" + from uipath.core.tracing.decorators import traced + + @traced(name="level1") + def level1(): + return level2() + + @traced(name="level2") + def level2(): + return level3() + + @traced(name="level3") + def level3(): + return "deep_result" + + result = level1() + + assert result == "deep_result" + + spans = span_capture.get_spans() + assert len(spans) == 3, f"Expected 3 spans, got {len(spans)}" + + # Find spans + level1_span = next(s for s in spans if s.name == "level1") + level2_span = next(s for s in spans if s.name == "level2") + level3_span = next(s for s in spans if s.name == "level3") + + # Verify chain: level1 -> level2 -> level3 + assert level1_span.parent is None, "Level1 should be root" + assert level2_span.parent.span_id == level1_span.context.span_id + assert level3_span.parent.span_id == level2_span.context.span_id + + span_capture.print_hierarchy() + + +@pytest.mark.asyncio +async def test_async_nesting(span_capture: SpanCapture): + """Test that nested async functions create proper parent-child relationships.""" + from uipath.core.tracing.decorators import traced + + @traced(name="async_outer") + async def async_outer(): + return await async_inner() + + @traced(name="async_inner") + async def async_inner(): + return "async_result" + + result = await async_outer() + + assert result == "async_result" + + spans = span_capture.get_spans() + assert len(spans) == 2, f"Expected 2 spans, got {len(spans)}" + + inner_span = next(s for s in spans if s.name == "async_inner") + outer_span = next(s for s in spans if s.name == "async_outer") + + assert inner_span.parent is not None + assert inner_span.parent.span_id == outer_span.context.span_id + + span_capture.print_hierarchy() + + +@pytest.mark.asyncio +async def test_mixed_sync_async_nesting(span_capture: SpanCapture): + """Test mixing sync and async traced functions.""" + from uipath.core.tracing.decorators import traced + + @traced(name="async_root") + async def async_root(): + return await async_child() + + @traced(name="async_child") + async def async_child(): + return sync_child() + + @traced(name="sync_child") + def sync_child(): + return "mixed_result" + + result = await async_root() + + assert result == "mixed_result" + + spans = span_capture.get_spans() + assert len(spans) == 3 + + # Verify hierarchy + async_root_span = next(s for s in spans if s.name == "async_root") + async_child_span = next(s for s in spans if s.name == "async_child") + sync_child_span = next(s for s in spans if s.name == "sync_child") + + assert async_child_span.parent.span_id == async_root_span.context.span_id + assert sync_child_span.parent.span_id == async_child_span.context.span_id + + span_capture.print_hierarchy() + + +def test_multiple_calls_same_function(span_capture: SpanCapture): + """Test that multiple calls to the same function create separate spans.""" + from uipath.core.tracing.decorators import traced + + @traced(name="called_multiple_times") + def reusable_function(value): + return value * 2 + + @traced(name="caller") + def caller(): + result1 = reusable_function(5) + result2 = reusable_function(10) + return result1 + result2 + + result = caller() + + assert result == 30 + + spans = span_capture.get_spans() + assert len(spans) == 3, f"Expected 3 spans (1 caller + 2 calls), got {len(spans)}" + + # Find the caller span + caller_span = next(s for s in spans if s.name == "caller") + + # Both reusable_function calls should be children of caller + reusable_spans = [s for s in spans if s.name == "called_multiple_times"] + assert len(reusable_spans) == 2 + + for reusable_span in reusable_spans: + assert reusable_span.parent.span_id == caller_span.context.span_id + + span_capture.print_hierarchy() + + +def test_sibling_functions(span_capture: SpanCapture): + """Test that sibling function calls are handled correctly.""" + from uipath.core.tracing.decorators import traced + + @traced(name="sibling1") + def sibling1(): + return "s1" + + @traced(name="sibling2") + def sibling2(): + return "s2" + + @traced(name="parent") + def parent(): + r1 = sibling1() + r2 = sibling2() + return r1 + r2 + + result = parent() + + assert result == "s1s2" + + spans = span_capture.get_spans() + assert len(spans) == 3 + + parent_span = next(s for s in spans if s.name == "parent") + sibling1_span = next(s for s in spans if s.name == "sibling1") + sibling2_span = next(s for s in spans if s.name == "sibling2") + + # Both siblings should have the same parent + assert sibling1_span.parent.span_id == parent_span.context.span_id + assert sibling2_span.parent.span_id == parent_span.context.span_id + + span_capture.print_hierarchy() + + +def test_generator_nesting(span_capture: SpanCapture): + """Test that generator functions maintain proper span nesting.""" + from uipath.core.tracing.decorators import traced + + @traced(name="generator_parent") + def generator_parent(): + results = list(generator_child()) + return sum(results) + + @traced(name="generator_child") + def generator_child(): + for i in range(3): + yield i * 2 + + result = generator_parent() + + assert result == 6 # 0 + 2 + 4 + + spans = span_capture.get_spans() + assert len(spans) == 2 + + parent_span = next(s for s in spans if s.name == "generator_parent") + child_span = next(s for s in spans if s.name == "generator_child") + + assert child_span.parent.span_id == parent_span.context.span_id + + span_capture.print_hierarchy() + + +@pytest.mark.asyncio +async def test_async_generator_nesting(span_capture: SpanCapture): + """Test async generator nesting.""" + from uipath.core.tracing.decorators import traced + + @traced(name="async_gen_parent") + async def async_gen_parent(): + results = [] + async for item in async_gen_child(): + results.append(item) + return sum(results) + + @traced(name="async_gen_child") + async def async_gen_child(): + for i in range(3): + yield i * 3 + + result = await async_gen_parent() + + assert result == 9 # 0 + 3 + 6 + + spans = span_capture.get_spans() + assert len(spans) == 2 + + parent_span = next(s for s in spans if s.name == "async_gen_parent") + child_span = next(s for s in spans if s.name == "async_gen_child") + + assert child_span.parent.span_id == parent_span.context.span_id + + span_capture.print_hierarchy() + + +def test_non_recording_blocks_children(span_capture: SpanCapture): + """Test that recording=False on parent prevents children from being recorded.""" + from uipath.core.tracing.decorators import traced + + @traced(name="non_recording_parent", recording=False) + def non_recording_parent(): + return recording_child() + + @traced(name="recording_child") + def recording_child(): + return "result" + + result = non_recording_parent() + + assert result == "result" + + spans = span_capture.get_spans() + # When parent has recording=False, children are also not recorded due to ParentBased sampler + assert len(spans) == 0, ( + f"Expected 0 spans, but got {len(spans)}: {[s.name for s in spans]}" + ) + + span_capture.print_hierarchy() + + +def test_nested_non_recording_parents_with_external_span(span_capture: SpanCapture): + """Test nested non-recording parents maintain hierarchy with external span provider. + + Scenario: + 1. External system (like LangGraph) creates a span manually + 2. Inside, @traced(recording=False) creates non-recording parent 1 + 3. Inside that, @traced(recording=False) creates non-recording parent 2 + 4. Inside that, @traced(recording=True) creates recording child + + Validates that the hierarchy is preserved: + - non-recording parent 1 should have external_span as parent + - non-recording parent 2 should have non-recording parent 1 as parent + - Recording child should recognize non-recording parent 2 as the deeper parent + """ + from opentelemetry import trace + + from uipath.core.tracing.decorators import traced + from uipath.core.tracing.span_utils import UiPathSpanUtils, _span_registry + + # Simulate external system (like LangGraph) creating a span manually + external_tracer = trace.get_tracer("external_system") + with external_tracer.start_as_current_span("external_span") as external_span: + stored_external_span = external_span + + # Register this as the external span provider + UiPathSpanUtils.register_current_span_provider(lambda: stored_external_span) + + try: + + @traced(name="non_recording_parent_1", recording=False) + def non_recording_parent_1(): + return non_recording_parent_2() + + @traced(name="non_recording_parent_2", recording=False) + def non_recording_parent_2(): + return recording_child() + + @traced(name="recording_child", recording=True) + def recording_child(): + return "child_result" + + result = non_recording_parent_1() + assert result == "child_result" + + external_span_id = stored_external_span.get_span_context().span_id + finally: + UiPathSpanUtils.register_current_span_provider(None) + + spans = span_capture.get_spans() + + # Should have: external_span only (non-recording spans aren't recorded) + external_span_recorded = next((s for s in spans if s.name == "external_span"), None) + assert external_span_recorded is not None, "external_span should be recorded" + + # Find non-recording parents in SpanRegistry + non_recording_parent_1_id = None + non_recording_parent_2_id = None + + for span_id, parent_id in _span_registry._parent_map.items(): + stored_span = _span_registry.get_span(span_id) + if stored_span is not None: + # Find parent 1 (direct child of external_span) + if parent_id == external_span_id: + non_recording_parent_1_id = span_id + # Find parent 2 (child of parent 1) + elif ( + parent_id == non_recording_parent_1_id + and non_recording_parent_1_id is not None + ): + non_recording_parent_2_id = span_id + + assert non_recording_parent_1_id is not None, ( + "BUG: non_recording_parent_1 should be registered in SpanRegistry " + "with external_span as its parent" + ) + assert non_recording_parent_2_id is not None, ( + "BUG: non_recording_parent_2 should be registered in SpanRegistry " + "with non_recording_parent_1 as its parent" + ) + + # Verify the hierarchy and depths + parent_1_parent = _span_registry.get_parent_id(non_recording_parent_1_id) + parent_2_parent = _span_registry.get_parent_id(non_recording_parent_2_id) + + assert parent_1_parent == external_span_id, ( + f"non_recording_parent_1 should have external_span as parent, " + f"got {parent_1_parent}" + ) + assert parent_2_parent == non_recording_parent_1_id, ( + f"non_recording_parent_2 should have non_recording_parent_1 as parent, " + f"got {parent_2_parent}" + ) + + # Verify depths: external=0, parent_1=1, parent_2=2 + external_depth = _span_registry.calculate_depth(external_span_id) + parent_1_depth = _span_registry.calculate_depth(non_recording_parent_1_id) + parent_2_depth = _span_registry.calculate_depth(non_recording_parent_2_id) + + assert external_depth == 0, ( + f"External span should have depth 0, got {external_depth}" + ) + assert parent_1_depth == 1, ( + f"non_recording_parent_1 should have depth 1 (parent=external), got {parent_1_depth}" + ) + assert parent_2_depth == 2, ( + f"non_recording_parent_2 should have depth 2 (parent=parent_1), got {parent_2_depth}" + ) + + # Verify the fix: _get_bottom_most_span should recognize parent 2 as deepest + # This simulates what child calls get_parent_context() + current_span_mock = _span_registry.get_span(non_recording_parent_2_id) + external_span_mock = external_span_recorded + + if current_span_mock is not None: + bottom_span = UiPathSpanUtils._get_bottom_most_span( + current_span_mock, external_span_mock + ) + assert bottom_span.get_span_context().span_id == non_recording_parent_2_id, ( + "BUG: _get_bottom_most_span should return non_recording_parent_2 (deepest), " + f"but returned {bottom_span.get_span_context().span_id}" + ) + + _span_registry.clear() + span_capture.print_hierarchy() + + +def test_non_recording_parent_picks_external_when_outside_context( + span_capture: SpanCapture, +): + """Test that non-recording parent correctly picks external span when called outside OTel context. + + Scenario: + 1. External span provider is registered and stored + 2. Exit the OTel context (so trace.get_current_span() returns default/invalid) + 3. @traced(recording=False) is called OUTSIDE the context + + The test: Non-recording parent should have the external span as parent. + + This test fails with: parent_context = trace.get_current_span().get_span_context() + Because outside the context, trace.get_current_span() returns a default NonRecordingSpan, + not the external span we want. + + This test passes with: parent_context from get_parent_context() logic + Because get_parent_context() checks both current (invalid) and external (valid), + and picks the external one. + """ + from opentelemetry import trace + + from uipath.core.tracing.decorators import traced + from uipath.core.tracing.span_utils import UiPathSpanUtils, _span_registry + + # Create external span INSIDE a context + external_tracer = trace.get_tracer("external_system") + external_span_cm = external_tracer.start_as_current_span("external_span") + external_span = external_span_cm.__enter__() + external_span_id = external_span.get_span_context().span_id + + # Store and register the external span + stored_external_span = external_span + UiPathSpanUtils.register_current_span_provider(lambda: stored_external_span) + + # Exit the OTel context manager + external_span_cm.__exit__(None, None, None) + + # NOW we're OUTSIDE the context - trace.get_current_span() returns default/invalid + # But the external provider is still active + + try: + + @traced(name="non_recording_parent", recording=False) + def non_recording_parent(): + return "result" + + result = non_recording_parent() + assert result == "result" + + # The non-recording parent should have the external span as parent + # NOT the invalid span from trace.get_current_span() + non_recording_parent_id = None + for span_id, parent_id in _span_registry._parent_map.items(): + if parent_id == external_span_id: + stored_span = _span_registry.get_span(span_id) + if stored_span is not None: + non_recording_parent_id = span_id + break + + assert non_recording_parent_id is not None, ( + "Non-recording parent should be parented to external_span. " + "With trace.get_current_span() directly, it would pick an invalid/default span instead." + ) + + # Verify hierarchy + non_recording_depth = _span_registry.calculate_depth(non_recording_parent_id) + external_depth = _span_registry.calculate_depth(external_span_id) + + assert external_depth == 0, ( + f"External span should have depth 0, got {external_depth}" + ) + assert non_recording_depth == 1, ( + f"Non-recording parent should have depth 1 (parent=external), got {non_recording_depth}" + ) + + _span_registry.clear() + finally: + UiPathSpanUtils.register_current_span_provider(None) + + +def test_ctx_parameter_required_when_external_deeper_than_current( + span_capture: SpanCapture, +): + """Test that trace.get_current_span(ctx) is required when external span is deeper. + + Scenario: + 1. Create an external span (depth 0) + 2. Create a deeper nested external span (depth 1) - this becomes current external + 3. Create an OTel span INSIDE the deepest external context + 4. Register the deepest external span as the external provider + 5. Create a non-recording span + + Expected behavior with trace.get_current_span(ctx): + - get_parent_context() compares: current OTel span vs external span + - External span is deeper (depth 1), so it's chosen + - trace.get_current_span(ctx) gets the external span from ctx + - Non-recording span is parented to external + + Bug with trace.get_current_span() without ctx: + - trace.get_current_span() (no args) returns the OTel span (from thread-local) + - Non-recording span gets parented to OTel span (wrong!) + + This test PASSES with the fix (ctx parameter) and FAILS without it. + """ + from opentelemetry import trace + + from uipath.core.tracing.decorators import traced + from uipath.core.tracing.span_utils import UiPathSpanUtils, _span_registry + + # Step 1: Create external span hierarchy + external_tracer = trace.get_tracer("external_system") + + # Create external_root (depth 0) + external_root_cm = external_tracer.start_as_current_span("external_root") + external_root = external_root_cm.__enter__() + external_root_id = external_root.get_span_context().span_id + + # Create external_deep (depth 1) - child of external_root + external_deep_cm = external_tracer.start_as_current_span("external_deep") + external_deep = external_deep_cm.__enter__() + external_deep_id = external_deep.get_span_context().span_id + + # Register external_deep as the external provider BEFORE exiting context + UiPathSpanUtils.register_current_span_provider(lambda: external_deep) + + # Exit the external context to create a separate branch + external_deep_cm.__exit__(None, None, None) + external_root_cm.__exit__(None, None, None) + + # NOW create OTel span in a SEPARATE context (not inside external_deep) + otel_tracer = trace.get_tracer(__name__) + otel_span_cm = otel_tracer.start_as_current_span("otel_span") + otel_span = otel_span_cm.__enter__() + otel_span_id = otel_span.get_span_context().span_id + + try: + from uipath.core.tracing.span_utils import ParentedNonRecordingSpan + + # Register external spans with parent relationship + _span_registry.register_span(external_root) + _span_registry.register_span(external_deep) + + # Register OTel span as ParentedNonRecordingSpan with NO parent + # (This simulates OTel span in a separate branch) + otel_span_context = otel_span.get_span_context() + otel_span_tracked = ParentedNonRecordingSpan(otel_span_context, parent=None) + _span_registry.register_span(otel_span_tracked) + + # Verify depths + external_root_depth = _span_registry.calculate_depth(external_root_id) + external_deep_depth = _span_registry.calculate_depth(external_deep_id) + otel_depth = _span_registry.calculate_depth(otel_span_id) + + assert external_root_depth == 0, ( + f"External root depth should be 0, got {external_root_depth}" + ) + assert external_deep_depth == 1, ( + f"External deep depth should be 1, got {external_deep_depth}" + ) + assert otel_depth == 0, ( + f"OTel span depth should be 0 (no parent), got {otel_depth}" + ) + + # Verify external_deep is deeper than otel_span + assert external_deep_depth > otel_depth, ( + "External deep should be deeper than otel span" + ) + + # Create non-recording span INSIDE the otel_span context + @traced(name="non_recording_inside_otel", recording=False) + def non_recording_func(): + # Add a recording child inside the non-recording parent + return recording_child_func() + + @traced(name="recording_child_of_non_recording", recording=True) + def recording_child_func(): + return "child_result" + + result = non_recording_func() + assert result == "child_result" + + # Get all spans captured + captured_spans = span_capture.get_spans() + + # Verify that neither non-recording nor its recording child are in the captured spans + # (because the parent was non-recording, children should not be recorded per ParentBased sampler) + captured_span_names = [s.name for s in captured_spans] + assert "non_recording_inside_otel" not in captured_span_names, ( + "Non-recording span should not be captured in span_capture" + ) + assert "recording_child_of_non_recording" not in captured_span_names, ( + "Recording child of non-recording parent should not be captured due to ParentBased sampler" + ) + + # Step 2: Verify the non-recording span was parented to external_deep (deeper) + # NOT to otel_span (which is current in thread-local context) + non_recording_id = None + for span_id, parent_id in _span_registry._parent_map.items(): + stored_span = _span_registry.get_span(span_id) + if stored_span is not None and parent_id == external_deep_id: + non_recording_id = span_id + break + + assert non_recording_id is not None, ( + "CRITICAL: Non-recording span should be parented to external_deep (deeper). " + "This requires using trace.get_current_span(ctx) NOT trace.get_current_span(). " + "With trace.get_current_span() alone, it would pick the OTel span " + "(which is current in thread-local context), not the external span." + ) + + # Verify it's NOT parented to the OTel span + for _span_id, parent_id in _span_registry._parent_map.items(): + if parent_id == otel_span_id: + raise AssertionError( + "Non-recording span should NOT be parented to otel_span. " + "This indicates trace.get_current_span() was used instead of trace.get_current_span(ctx)." + ) + + _span_registry.clear() + + finally: + otel_span_cm.__exit__(None, None, None) + UiPathSpanUtils.register_current_span_provider(None) diff --git a/packages/uipath-core/tests/tracing/test_span_registry.py b/packages/uipath-core/tests/tracing/test_span_registry.py new file mode 100644 index 000000000..544c89303 --- /dev/null +++ b/packages/uipath-core/tests/tracing/test_span_registry.py @@ -0,0 +1,290 @@ +"""Test span registry and parent context resolution.""" + +from opentelemetry.trace import Span + +from tests.conftest import SpanCapture +from uipath.core.tracing.decorators import traced +from uipath.core.tracing.span_utils import UiPathSpanUtils, _span_registry + + +def test_nested_traced_functions_with_external_context(span_capture: SpanCapture): + """Test nested traced functions when external span context is provided. + + Simulates the scenario where: + - An external tracing system (like LangGraph) has active spans + - Our traced decorators create OTel spans + - The registry determines which span is deeper + """ + + external_span_stack: list[Span] = [] + + def mock_external_span_provider(): + return external_span_stack[-1] if external_span_stack else None + + def mock_ancestors_provider(): + return list(external_span_stack) + + UiPathSpanUtils.register_current_span_provider(mock_external_span_provider) + UiPathSpanUtils.register_current_span_ancestors_provider(mock_ancestors_provider) + + @traced(name="root") + def root(): + spans = span_capture.get_spans() + if spans: + external_span_stack.append(spans[-1]) + + result = level1() + + if external_span_stack: + external_span_stack.pop() + + return result + + @traced(name="level1") + def level1(): + return level2() + + @traced(name="level2") + def level2(): + return level3() + + @traced(name="level3") + def level3(): + return level4() + + @traced(name="level4") + def level4(): + return level5() + + @traced(name="level5") + def level5(): + return "done" + + result = root() + + assert result == "done" + + spans = span_capture.get_spans() + assert len(spans) == 6, f"Expected 6 spans, got {len(spans)}" + + root_span = next(s for s in spans if s.name == "root") + level1_span = next(s for s in spans if s.name == "level1") + level2_span = next(s for s in spans if s.name == "level2") + level3_span = next(s for s in spans if s.name == "level3") + level4_span = next(s for s in spans if s.name == "level4") + level5_span = next(s for s in spans if s.name == "level5") + + # Verify hierarchy + assert root_span.parent is None, "Root should have no parent" + assert level1_span.parent is not None + assert level1_span.parent.span_id == root_span.context.span_id + assert level2_span.parent is not None + assert level2_span.parent.span_id == level1_span.context.span_id + assert level3_span.parent is not None + assert level3_span.parent.span_id == level2_span.context.span_id + assert level4_span.parent is not None + assert level4_span.parent.span_id == level3_span.context.span_id + assert level5_span.parent is not None + assert level5_span.parent.span_id == level4_span.context.span_id + + span_capture.print_hierarchy() + + # Cleanup + UiPathSpanUtils.register_current_span_provider(None) + UiPathSpanUtils.register_current_span_ancestors_provider(None) + + +def test_span_registry_depth_calculation(span_capture: SpanCapture): + """Test that the span registry correctly calculates span depths.""" + from uipath.core.tracing.decorators import traced + from uipath.core.tracing.span_utils import _span_registry + + @traced(name="depth0") + def depth0(): + return depth1() + + @traced(name="depth1") + def depth1(): + return depth2() + + @traced(name="depth2") + def depth2(): + return depth3() + + @traced(name="depth3") + def depth3(): + return "max_depth" + + result = depth0() + + assert result == "max_depth" + + spans = span_capture.get_spans() + assert len(spans) == 4 + + # Register all spans in the registry + for span in spans: + _span_registry.register_span(span) + + # Get span IDs + depth0_span = next(s for s in spans if s.name == "depth0") + depth1_span = next(s for s in spans if s.name == "depth1") + depth2_span = next(s for s in spans if s.name == "depth2") + depth3_span = next(s for s in spans if s.name == "depth3") + + # Verify depths + assert _span_registry.calculate_depth(depth0_span.context.span_id) == 0 + assert _span_registry.calculate_depth(depth1_span.context.span_id) == 1 + assert _span_registry.calculate_depth(depth2_span.context.span_id) == 2 + assert _span_registry.calculate_depth(depth3_span.context.span_id) == 3 + + span_capture.print_hierarchy() + + +def test_span_registry_is_ancestor(span_capture: SpanCapture): + """Test that the span registry correctly identifies ancestor relationships.""" + + @traced(name="grandparent") + def grandparent(): + return parent() + + @traced(name="parent") + def parent(): + return child() + + @traced(name="child") + def child(): + return "leaf" + + result = grandparent() + + assert result == "leaf" + + spans = span_capture.get_spans() + assert len(spans) == 3 + + # Register all spans + for span in spans: + _span_registry.register_span(span) + + grandparent_span = next(s for s in spans if s.name == "grandparent") + parent_span = next(s for s in spans if s.name == "parent") + child_span = next(s for s in spans if s.name == "child") + + grandparent_id = grandparent_span.context.span_id + parent_id = parent_span.context.span_id + child_id = child_span.context.span_id + + # Test ancestor relationships + assert _span_registry.is_ancestor(grandparent_id, child_id), ( + "Grandparent should be ancestor of child" + ) + assert _span_registry.is_ancestor(parent_id, child_id), ( + "Parent should be ancestor of child" + ) + assert _span_registry.is_ancestor(grandparent_id, parent_id), ( + "Grandparent should be ancestor of parent" + ) + + # Test negative cases + assert not _span_registry.is_ancestor(child_id, grandparent_id), ( + "Child should not be ancestor of grandparent" + ) + assert not _span_registry.is_ancestor(parent_id, grandparent_id), ( + "Parent should not be ancestor of grandparent" + ) + + span_capture.print_hierarchy() + + +def test_mixed_otel_and_external_spans(span_capture: SpanCapture): + """Test scenario where OTel spans and external spans are interleaved. + + This mimics the real-world scenario from the screenshots where: + - LangGraph creates spans + - generate_report is traced + - Custom traced functions are called + - All should maintain proper hierarchy + """ + + external_spans: list[Span] = [] + + def mock_external_span_provider(): + return external_spans[-1] if external_spans else None + + def mock_ancestors_provider(): + return external_spans[:-1] if len(external_spans) > 1 else [] + + UiPathSpanUtils.register_current_span_provider(mock_external_span_provider) + UiPathSpanUtils.register_current_span_ancestors_provider(mock_ancestors_provider) + + @traced(name="langraph_simulation") + def langraph_simulation(): + spans = span_capture.get_spans() + if spans: + external_spans.append(spans[-1]) + + result = generate_report() + + if external_spans: + external_spans.pop() + + return result + + @traced(name="generate_report") + def generate_report(): + spans = span_capture.get_spans() + if spans: + # Add generate_report to external spans + for s in spans: + if s.name == "generate_report" and s not in external_spans: + external_spans.append(s) + break + + result = custom_function() + + # Remove generate_report from external spans + if ( + external_spans + and getattr(external_spans[-1], "name", None) == "generate_report" + ): + external_spans.pop() + + return result + + @traced(name="custom_function") + def custom_function(): + return nested_function() + + @traced(name="nested_function") + def nested_function(): + return "result" + + result = langraph_simulation() + + assert result == "result" + + spans = span_capture.get_spans() + assert len(spans) >= 4, f"Expected at least 4 spans, got {len(spans)}" + + # Verify hierarchy + langraph_span = next(s for s in spans if s.name == "langraph_simulation") + report_span = next(s for s in spans if s.name == "generate_report") + custom_span = next(s for s in spans if s.name == "custom_function") + nested_span = next(s for s in spans if s.name == "nested_function") + + # Check parent-child relationships + assert report_span.parent is not None + assert report_span.parent.span_id == langraph_span.context.span_id + + assert custom_span.parent is not None + assert custom_span.parent.span_id == report_span.context.span_id + + assert nested_span.parent is not None + assert nested_span.parent.span_id == custom_span.context.span_id + + span_capture.print_hierarchy() + + # Cleanup + UiPathSpanUtils.register_current_span_provider(None) + UiPathSpanUtils.register_current_span_ancestors_provider(None) diff --git a/packages/uipath-core/tests/tracing/test_trace_manager.py b/packages/uipath-core/tests/tracing/test_trace_manager.py new file mode 100644 index 000000000..d3d7db13b --- /dev/null +++ b/packages/uipath-core/tests/tracing/test_trace_manager.py @@ -0,0 +1,33 @@ +"""Simple test for runtime factory and executor span capture.""" + +import pytest +from opentelemetry import trace + +from uipath.core.tracing.trace_manager import UiPathTraceManager + + +@pytest.mark.asyncio +async def test_multiple_factories_same_executor(): + """Test two factories using same executor, verify spans are captured correctly.""" + trace_manager = UiPathTraceManager() + + # Create span + tracer = trace.get_tracer("uipath-runtime") + with trace_manager.start_execution_span("root-span", "test"): + with tracer.start_as_current_span( + "custom-child-span", attributes={"operation": "child", "step": "1"} + ): + pass + + spans = trace_manager.get_execution_spans("test") + assert len(spans) == 2 + + assert spans[0].name == "custom-child-span" + assert spans[0].attributes == { + "operation": "child", + "step": "1", + "execution.id": "test", + } + + assert spans[1].name == "root-span" + assert spans[1].attributes == {"execution.id": "test"} diff --git a/packages/uipath-core/tests/tracing/test_traced.py b/packages/uipath-core/tests/tracing/test_traced.py new file mode 100644 index 000000000..ca2b2de6a --- /dev/null +++ b/packages/uipath-core/tests/tracing/test_traced.py @@ -0,0 +1,1096 @@ +import json +from asyncio import sleep +from dataclasses import asdict, dataclass +from enum import Enum +from typing import Any, Sequence + +import pytest +from opentelemetry import trace +from opentelemetry.sdk.trace import ReadableSpan, TracerProvider +from opentelemetry.sdk.trace.export import ( + SimpleSpanProcessor, + SpanExporter, + SpanExportResult, +) + +from uipath.core.tracing import traced + + +class InMemorySpanExporter(SpanExporter): + """An OpenTelemetry span exporter that stores spans in memory for testing.""" + + def __init__(self): + self.spans = [] + self.is_shutdown = False + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + if self.is_shutdown: + return SpanExportResult.FAILURE + + self.spans.extend(spans) + return SpanExportResult.SUCCESS + + def get_exported_spans(self) -> list[ReadableSpan]: + return self.spans + + def clear_exported_spans(self) -> None: + self.spans = [] + + def force_flush(self, timeout_millis: int = 30000) -> bool: + return not self.is_shutdown + + def shutdown(self) -> None: + self.is_shutdown = True + + +@pytest.fixture +def setup_tracer(): + # Setup InMemorySpanExporter and TracerProvider + exporter = InMemorySpanExporter() + provider = TracerProvider() + trace.set_tracer_provider(provider) + trace.get_tracer_provider().add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore + + yield exporter, provider + + +def test_traced_sync_function(setup_tracer): + exporter, provider = setup_tracer + + @traced() + def sample_function(x, y): + return x + y + + result = sample_function(2, 3) + assert result == 5 + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + assert span.name == "sample_function" + assert span.attributes["span_type"] == "function_call_sync" + assert "input.value" in span.attributes + assert "output.value" in span.attributes + assert span.attributes["output.value"] == "5" + + +def test_traced_bare_sync_function(setup_tracer): + """Test @traced without parentheses on a sync function.""" + exporter, provider = setup_tracer + + @traced + def sample_function(x, y): + return x + y + + result = sample_function(2, 3) + assert result == 5 + + provider.shutdown() + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + assert span.name == "sample_function" + assert span.attributes["span_type"] == "function_call_sync" + assert "input.value" in span.attributes + assert "output.value" in span.attributes + assert span.attributes["output.value"] == "5" + + +@pytest.mark.asyncio +async def test_traced_async_function(setup_tracer): + exporter, provider = setup_tracer + + @traced() + async def sample_async_function(x, y): + return x * y + + result = await sample_async_function(2, 3) + assert result == 6 + + provider.shutdown() # Ensure spans are flushed + + await sleep(1) + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + assert span.name == "sample_async_function" + assert span.attributes["span_type"] == "function_call_async" + assert "input.value" in span.attributes + assert "output.value" in span.attributes + assert span.attributes["output.value"] == "6" + + +@pytest.mark.asyncio +async def test_traced_bare_async_function(setup_tracer): + """Test @traced without parentheses on an async function.""" + exporter, provider = setup_tracer + + @traced + async def sample_async_function(x, y): + return x * y + + result = await sample_async_function(2, 3) + assert result == 6 + + provider.shutdown() + + await sleep(1) + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + assert span.name == "sample_async_function" + assert span.attributes["span_type"] == "function_call_async" + assert "input.value" in span.attributes + assert "output.value" in span.attributes + assert span.attributes["output.value"] == "6" + + +def test_traced_generator_function(setup_tracer): + exporter, provider = setup_tracer + + @traced() + def sample_generator_function(n): + for i in range(n): + yield i + + results = list(sample_generator_function(3)) + assert results == [0, 1, 2] + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + assert span.name == "sample_generator_function" + assert span.attributes["span_type"] == "function_call_generator_sync" + assert "input.value" in span.attributes + assert "output.value" in span.attributes + assert span.attributes["output.value"] == "[0, 1, 2]" + + +@pytest.mark.asyncio +async def test_traced_async_generator_function(setup_tracer): + exporter, provider = setup_tracer + + @traced() + async def sample_async_generator_function(n): + for i in range(n): + yield i + + results = [item async for item in sample_async_generator_function(3)] + assert results == [0, 1, 2] + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + assert span.name == "sample_async_generator_function" + assert span.attributes["span_type"] == "function_call_generator_async" + assert "input.value" in span.attributes + assert "output.value" in span.attributes + assert span.attributes["output.value"] == "[0, 1, 2]" + + +def test_traced_bare_generator_function(setup_tracer): + """Test @traced without parentheses on a generator function.""" + exporter, provider = setup_tracer + + @traced + def sample_generator_function(n): + for i in range(n): + yield i + + results = list(sample_generator_function(3)) + assert results == [0, 1, 2] + + provider.shutdown() + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + assert span.name == "sample_generator_function" + assert span.attributes["span_type"] == "function_call_generator_sync" + assert "input.value" in span.attributes + assert "output.value" in span.attributes + assert span.attributes["output.value"] == "[0, 1, 2]" + + +@pytest.mark.asyncio +async def test_traced_bare_async_generator_function(setup_tracer): + """Test @traced without parentheses on an async generator function.""" + exporter, provider = setup_tracer + + @traced + async def sample_async_generator_function(n): + for i in range(n): + yield i + + results = [item async for item in sample_async_generator_function(3)] + assert results == [0, 1, 2] + + provider.shutdown() + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + assert span.name == "sample_async_generator_function" + assert span.attributes["span_type"] == "function_call_generator_async" + assert "input.value" in span.attributes + assert "output.value" in span.attributes + assert span.attributes["output.value"] == "[0, 1, 2]" + + +def test_traced_bare_preserves_type_hints(setup_tracer): + """Test that @traced without parentheses preserves function type hints and signature.""" + exporter, provider = setup_tracer + + @traced + def typed_function(x: int, y: str = "hello") -> bool: + return True + + import inspect + from typing import get_type_hints + + hints = get_type_hints(typed_function) + assert hints == {"x": int, "y": str, "return": bool} + + sig = inspect.signature(typed_function) + assert list(sig.parameters.keys()) == ["x", "y"] + assert sig.parameters["x"].annotation is int + assert sig.parameters["y"].default == "hello" + + provider.shutdown() + + +def test_traced_with_basic_processors(setup_tracer): + """Test traced decorator with basic input and output processors.""" + exporter, provider = setup_tracer + + def double_input(inputs): + """Double numeric inputs.""" + result = inputs.copy() + for key, value in result.items(): + if isinstance(value, (int, float)): + result[key] = value * 2 + return result + + def format_output(output): + """Format the output as a string.""" + return {"result": str(output)} + + @traced(input_processor=double_input, output_processor=format_output) + def multiply(x, y): + return x * y + + # Original function behavior should be unchanged + result = multiply(3, 4) + assert result == 12 + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + + # Check that input processor was applied (doubles the inputs) + inputs_json = span.attributes["input.value"] + inputs = json.loads(inputs_json) + assert inputs["x"] == 6 # 3 doubled to 6 + assert inputs["y"] == 8 # 4 doubled to 8 + + # Check that output processor was applied (formatted as string in dict) + output_json = span.attributes["output.value"] + output = json.loads(output_json) + assert output == {"result": "12"} # Result wrapped in dict with string conversion + + +@pytest.mark.asyncio +async def test_traced_async_with_basic_processors(setup_tracer): + """Test traced decorator with basic processors for async functions.""" + exporter, provider = setup_tracer + + def add_context(inputs): + """Add context to inputs.""" + result = inputs.copy() + result["context"] = "test" + return result + + def add_timestamp(output): + """Add a timestamp to output.""" + if isinstance(output, dict): + result = output.copy() + result["processed"] = True + return result + return {"value": output, "processed": True} + + @traced(input_processor=add_context, output_processor=add_timestamp) + async def async_operation(message): + await sleep(0.1) + return {"status": "completed", "message": message} + + # Original function behavior should be unchanged + result = await async_operation("hello") + assert result == {"status": "completed", "message": "hello"} + + provider.shutdown() # Ensure spans are flushed + await sleep(0.1) # Give time for spans to be processed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + + # Check that input processor was applied + inputs_json = span.attributes["input.value"] + inputs = json.loads(inputs_json) + assert inputs["message"] == "hello" + assert inputs["context"] == "test" # Added by processor + + # Check that output processor was applied + output_json = span.attributes["output.value"] + output = json.loads(output_json) + assert output["status"] == "completed" + assert output["message"] == "hello" + assert output["processed"] is True # Added by processor + + +def mask_credit_card(inputs: dict[str, Any]) -> dict[str, Any]: + """Process inputs to mask credit card information.""" + processed = inputs.copy() + if "card_number" in processed: + if len(processed["card_number"]) >= 4: + # Keep only the last 4 digits + processed["card_number"] = "**** **** **** " + processed["card_number"][-4:] + else: + processed["card_number"] = "****" + return processed + + +def anonymize_single_user_data(output_dict: dict[str, Any]) -> dict[str, Any]: + """Process a single dictionary to anonymize user information.""" + processed = output_dict.copy() + if "user_info" in processed and isinstance(processed["user_info"], dict): + user_info = processed["user_info"].copy() + if "name" in user_info: + user_info["name"] = "Anonymous User" + if "email" in user_info: + user_info["email"] = "anonymous@example.com" + processed["user_info"] = user_info + return processed + + +def anonymize_user_data(output: Any) -> Any: + """Process outputs to anonymize user information.""" + # Handle list of outputs (from generators) + if isinstance(output, list): + processed_outputs = [] + for item in output: + if isinstance(item, dict): + processed_outputs.append(anonymize_single_user_data(item)) + else: + processed_outputs.append(item) + return processed_outputs + + # Handle single dictionary output + if not isinstance(output, dict): + return output + + return anonymize_single_user_data(output) + + +def dataclass_to_dict(obj): + """Convert a dataclass instance or list of dataclass instances to dict(s).""" + if isinstance(obj, list): + return [dataclass_to_dict(item) for item in obj] + + # Check if object is a dataclass (has __dataclass_fields__ attribute) + if hasattr(obj, "__dataclass_fields__"): + return asdict(obj) + + return obj + + +# Example of a processor that handles dataclasses +def process_dataclass_output(output): + """Process output that might be a dataclass or contain dataclasses.""" + # First convert to dict if it's a dataclass + data = dataclass_to_dict(output) + + # Now apply regular dict processing + if isinstance(data, dict): + # Apply your masking/anonymization logic + if "sensitive_field" in data: + data["sensitive_field"] = "***MASKED***" + + # Check for direct email field (for UserProfile dataclass) + if "email" in data: + data["email"] = "anonymous@example.com" + + # Process nested user_info if present + if "user_info" in data and isinstance(data["user_info"], dict): + if "email" in data["user_info"]: + data["user_info"]["email"] = "anonymous@example.com" + + return data + + +# Example of using with a dataclass +@dataclass +class UserProfile: + user_id: int + name: str + email: str + access_level: str + + +def test_traced_with_input_processor(setup_tracer): + exporter, provider = setup_tracer + + @traced(input_processor=mask_credit_card) + def process_payment(name, card_number, amount): + return {"transaction_id": "tx_12345", "amount": amount, "status": "approved"} + + result = process_payment("John Doe", "4111111111111111", 99.99) + assert result["transaction_id"] == "tx_12345" + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + assert span.name == "process_payment" + + # Verify inputs were processed + inputs_json = span.attributes["input.value"] + inputs = json.loads(inputs_json) + assert "card_number" in inputs + assert inputs["card_number"] == "**** **** **** 1111" # Should be masked + assert inputs["name"] == "John Doe" # Should be unchanged + + # Verify original function output is returned + assert result["transaction_id"] == "tx_12345" + assert result["amount"] == 99.99 + + +def test_traced_with_output_processor(setup_tracer): + exporter, provider = setup_tracer + + @traced(output_processor=anonymize_user_data) + def get_user_profile(user_id): + # Return sensitive user data + return { + "user_id": user_id, + "user_info": {"name": "Jane Smith", "email": "jane@example.com", "age": 32}, + "subscription": "premium", + } + + result = get_user_profile(12345) + + # Original function should return unmodified data + assert result["user_info"]["name"] == "Jane Smith" + assert result["user_info"]["email"] == "jane@example.com" + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + + # Verify output was processed for tracing + output_json = span.attributes["output.value"] + output = json.loads(output_json) + assert output["user_info"]["name"] == "Anonymous User" + assert output["user_info"]["email"] == "anonymous@example.com" + assert output["user_info"]["age"] == 32 # Non-sensitive data preserved + assert output["subscription"] == "premium" # Non-sensitive data preserved + + +def test_traced_with_dataclass_output(setup_tracer): + exporter, provider = setup_tracer + + @traced(output_processor=process_dataclass_output) + def get_user_profile(user_id): + # Return a dataclass with sensitive data + return UserProfile( + user_id=user_id, + name="John Doe", + email="john.doe@example.com", + access_level="admin", + ) + + result = get_user_profile(12345) + + # Verify original result is unchanged + assert result.name == "John Doe" + assert result.email == "john.doe@example.com" + + provider.shutdown() + spans = exporter.get_exported_spans() + + # Verify the output was processed for tracing + output_json = spans[0].attributes["output.value"] + output = json.loads(output_json) + assert "email" in output + assert output["email"] == "anonymous@example.com" # Masked in the trace + + +@pytest.mark.asyncio +async def test_traced_async_with_processors(setup_tracer): + exporter, provider = setup_tracer + + @traced(input_processor=mask_credit_card, output_processor=anonymize_user_data) + async def async_payment_with_user_data(name, card_number, amount): + await sleep(0.1) # Simulate async operation + return { + "transaction_id": "tx_async_12345", + "amount": amount, + "status": "approved", + "user_info": { + "name": name, + "email": f"{name.lower().replace(' ', '.')}@example.com", + }, + } + + result = await async_payment_with_user_data("John Doe", "5555555555554444", 199.99) + + # Original function should return unmodified data + assert result["transaction_id"] == "tx_async_12345" + assert result["user_info"]["name"] == "John Doe" + assert result["user_info"]["email"] == "john.doe@example.com" + + provider.shutdown() # Ensure spans are flushed + await sleep(0.1) # Give time for spans to be processed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + + # Verify inputs were processed + inputs_json = span.attributes["input.value"] + inputs = json.loads(inputs_json) + assert inputs["card_number"] == "**** **** **** 4444" + + # Verify outputs were processed + output_json = span.attributes["output.value"] + output = json.loads(output_json) + assert output["user_info"]["name"] == "Anonymous User" + assert output["user_info"]["email"] == "anonymous@example.com" + assert output["amount"] == 199.99 # Non-sensitive data preserved + + +def test_traced_generator_with_processors(setup_tracer): + exporter, provider = setup_tracer + + @traced(input_processor=mask_credit_card, output_processor=anonymize_user_data) + def generate_user_transactions(card_number, count): + for i in range(count): + yield { + "transaction_id": f"tx_{i}", + "amount": 10.0 * (i + 1), + "user_info": {"name": "Jane Smith", "email": "jane@example.com"}, + } + + results = list(generate_user_transactions("4111111111111111", 3)) + + # Original function should return unmodified data + assert len(results) == 3 + assert results[0]["user_info"]["name"] == "Jane Smith" + assert results[0]["user_info"]["email"] == "jane@example.com" + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + + # Verify inputs were processed + inputs_json = span.attributes["input.value"] + inputs = json.loads(inputs_json) + assert inputs["card_number"] == "**** **** **** 1111" + + # Verify outputs were processed + output_json = span.attributes["output.value"] + output = json.loads(output_json) + assert len(output) == 3 + for transaction in output: + assert transaction["user_info"]["name"] == "Anonymous User" + assert transaction["user_info"]["email"] == "anonymous@example.com" + + +@pytest.mark.asyncio +async def test_traced_async_generator_with_processors(setup_tracer): + exporter, provider = setup_tracer + + @traced(input_processor=mask_credit_card, output_processor=anonymize_user_data) + async def generate_async_transactions(card_number, count): + for i in range(count): + await sleep(0.05) # Simulate async operation + yield { + "transaction_id": f"tx_async_{i}", + "amount": 20.0 * (i + 1), + "user_info": {"name": "Bob Johnson", "email": "bob@example.com"}, + } + + results = [ + item async for item in generate_async_transactions("5555555555554444", 2) + ] + + # Original function should return unmodified data + assert len(results) == 2 + assert results[0]["user_info"]["name"] == "Bob Johnson" + assert results[0]["user_info"]["email"] == "bob@example.com" + + provider.shutdown() # Ensure spans are flushed + await sleep(0.1) # Give time for spans to be processed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + + # Verify inputs were processed + inputs_json = span.attributes["input.value"] + inputs = json.loads(inputs_json) + assert inputs["card_number"] == "**** **** **** 4444" + + # Verify outputs were processed + output_json = span.attributes["output.value"] + output = json.loads(output_json) + assert len(output) == 2 + for transaction in output: + assert transaction["user_info"]["name"] == "Anonymous User" + assert transaction["user_info"]["email"] == "anonymous@example.com" + + +def test_traced_with_hide_input_outputs(setup_tracer): + """Test that hide_input=True and hide_output=True redacts all data.""" + exporter, provider = setup_tracer + + @traced(hide_input=True, hide_output=True) + def fully_private_function(sensitive_input): + return {"sensitive_output": f"Processed {sensitive_input}"} + + result = fully_private_function("confidential_data") + + # Original function should return unmodified data + assert result["sensitive_output"] == "Processed confidential_data" + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + + # Verify both inputs and outputs were redacted + inputs_json = span.attributes["input.value"] + inputs = json.loads(inputs_json) + assert inputs == {"redacted": "Input data not logged for privacy/security"} + + output_json = span.attributes["output.value"] + output = json.loads(output_json) + assert output == {"redacted": "Output data not logged for privacy/security"} + + +class Operator(Enum): + ADD = "+" + SUBTRACT = "-" + MULTIPLY = "*" + DIVIDE = "/" + + +@dataclass +class CalculatorInput: + a: float = 0.0 + b: float = 0.0 + operator: Operator = Operator.ADD + + +@dataclass +class CalculatorOutput: + result: float = 0.0 + operator: Operator = Operator.ADD + + +def test_traced_complex_input_serialization(setup_tracer): + """Test that traced decorator properly serializes complex inputs like dataclasses with enums.""" + exporter, provider = setup_tracer + + @traced() + def test_complex_input(input: CalculatorInput) -> CalculatorOutput: + assert isinstance(input.a, float) + assert isinstance(input.b, float) + assert isinstance(input.operator, Operator) + return CalculatorOutput(result=(input.a * input.b), operator=Operator.MULTIPLY) + + # Create a complex input with dataclass and enum + calculator_input = CalculatorInput(a=10.5, b=5.2, operator=Operator.MULTIPLY) + test_complex_input(calculator_input) + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + assert span.name == "test_complex_input" + assert span.attributes["span_type"] == "function_call_sync" + + # Verify that inputs are properly serialized as JSON + assert "input.value" in span.attributes + inputs_json = span.attributes["input.value"] + inputs = json.loads(inputs_json) + + # Debug: Print the actual inputs structure + print(f"Inputs JSON: {inputs_json}") + print(f"Parsed inputs: {inputs}") + + # Check that the dataclass is properly serialized + assert "input" in inputs + input_data = inputs["input"] + + # Verify the dataclass fields are properly serialized + assert input_data["a"] == 10.5 + assert input_data["b"] == 5.2 + # Verify the enum is serialized as its value + assert input_data["operator"] == "*" + + # Verify that outputs are properly serialized as JSON + assert "output.value" in span.attributes + output_json = span.attributes["output.value"] + output = json.loads(output_json) + + # Debug: Print the actual output structure + print(f"Output JSON: {output_json}") + print(f"Parsed output: {output}") + + # Verify the output dataclass fields are properly serialized + assert output["result"] == 54.6 # 10.5 * 5.2 = 54.6 + # Verify the enum is serialized as its value + assert output["operator"] == "*" + + +@pytest.mark.asyncio +async def test_traced_with_pydantic_basemodel_class(setup_tracer): + """Test that Pydantic BaseModel classes can be serialized in tracing. + + This tests the fix for the issue where passing a Pydantic BaseModel class + as a parameter (like response_format=OutputFormat) would cause JSON + serialization errors in tracing. + """ + from pydantic import BaseModel + + exporter, provider = setup_tracer + + class OutputFormat(BaseModel): + result: str + confidence: float = 0.95 + + @traced() + async def llm_chat_completions(messages: list[Any], response_format=None): + """Simulate LLM function with BaseModel class as response_format.""" + if response_format: + mock_content = '{"result": "hi!", "confidence": 0.95}' + return {"choices": [{"message": {"content": mock_content}}]} + return {"choices": [{"message": {"content": "hi!"}}]} + + # Test with tuple message format and BaseModel class as parameter + messages = [("human", "repeat this: hi!")] + result = await llm_chat_completions(messages, response_format=OutputFormat) + + assert result is not None + assert "choices" in result + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 1 + span = spans[0] + assert span.name == "llm_chat_completions" + assert span.attributes["span_type"] == "function_call_async" + + # Verify inputs are properly serialized as JSON, including BaseModel class + assert "input.value" in span.attributes + inputs_json = span.attributes["input.value"] + inputs = json.loads(inputs_json) + + # Check BaseModel class is properly serialized with schema representation + assert "response_format" in inputs + response_format_data = inputs["response_format"] + + # Verify the BaseModel class is serialized as a schema representation + assert "__class__" in response_format_data + assert "__module__" in response_format_data + assert "schema" in response_format_data + assert response_format_data["__class__"] == "OutputFormat" + + # Verify the schema contains expected structure + schema = response_format_data["schema"] + assert "properties" in schema + assert "result" in schema["properties"] + assert "confidence" in schema["properties"] + assert schema["properties"]["result"]["type"] == "string" + assert schema["properties"]["confidence"]["type"] == "number" + + # Verify that tuple messages are also properly serialized + assert "messages" in inputs + messages_data = inputs["messages"] + assert isinstance(messages_data, list) + assert len(messages_data) == 1 + assert messages_data[0] == ["human", "repeat this: hi!"] + + # Verify that outputs are properly serialized as JSON + assert "output.value" in span.attributes + output_json = span.attributes["output.value"] + output = json.loads(output_json) + + assert "choices" in output + assert len(output["choices"]) == 1 + + +@pytest.mark.asyncio +async def test_non_recording_traced_async_function(setup_tracer): + exporter, provider = setup_tracer + + @traced(recording=True) + async def child_sample_async_function(x, y): + return x * y + + @traced(recording=False) + async def sample_async_function(x, y): + return await child_sample_async_function(x, y) + + result = await sample_async_function(2, 3) + assert result == 6 + + provider.shutdown() # Ensure spans are flushed + + await sleep(1) + spans = exporter.get_exported_spans() + + assert len(spans) == 0 + + +def test_non_recording_traced_sync_function(setup_tracer): + exporter, provider = setup_tracer + + @traced(recording=True) + def child_sample_sync_function(x, y): + return x * y + + @traced(recording=False) + def sample_sync_function(x, y): + return child_sample_sync_function(x, y) + + result = sample_sync_function(2, 3) + assert result == 6 + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 0 + + +def test_non_recording_traced_generator_function(setup_tracer): + exporter, provider = setup_tracer + + @traced() + def sample_child_generator_function(n): + for i in range(n): + yield i + + @traced(recording=False) + def sample_generator_function(n): + for i in sample_child_generator_function(n): + yield i + + results = list(sample_generator_function(3)) + assert results == [0, 1, 2] + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 0 + + +@pytest.mark.asyncio +async def test_non_recording_traced_async_generator_function(setup_tracer): + exporter, provider = setup_tracer + + @traced() + async def sample_child_async_generator_function(n): + for i in range(n): + yield i + + @traced(recording=False) + async def sample_async_generator_function(n): + async for i in sample_child_async_generator_function(n): + yield i + + results = [item async for item in sample_async_generator_function(3)] + assert results == [0, 1, 2] + + provider.shutdown() # Ensure spans are flushed + spans = exporter.get_exported_spans() + + assert len(spans) == 0 + + +# --------- Suppress Instrumentation Tests --------- + + +def test_suppress_instrumentation_sync_function(setup_tracer): + """Test that suppress_instrumentation prevents spans from being created for sync functions.""" + from opentelemetry.instrumentation.utils import suppress_instrumentation + + exporter, provider = setup_tracer + + @traced() + def sample_function(x, y): + return x + y + + # Call without suppression - should create span + result = sample_function(2, 3) + assert result == 5 + + spans = exporter.get_exported_spans() + assert len(spans) == 1 + + exporter.clear_exported_spans() + + # Call with suppression - should NOT create span + with suppress_instrumentation(): + result = sample_function(4, 5) + assert result == 9 + + provider.shutdown() + spans = exporter.get_exported_spans() + assert len(spans) == 0 + + +@pytest.mark.asyncio +async def test_suppress_instrumentation_async_function(setup_tracer): + """Test that suppress_instrumentation prevents spans from being created for async functions.""" + from opentelemetry.instrumentation.utils import suppress_instrumentation + + exporter, provider = setup_tracer + + @traced() + async def sample_async_function(x, y): + return x * y + + # Call without suppression - should create span + result = await sample_async_function(2, 3) + assert result == 6 + + await sleep(0.1) + spans = exporter.get_exported_spans() + assert len(spans) == 1 + + exporter.clear_exported_spans() + + # Call with suppression - should NOT create span + with suppress_instrumentation(): + result = await sample_async_function(4, 5) + assert result == 20 + + provider.shutdown() + await sleep(0.1) + spans = exporter.get_exported_spans() + assert len(spans) == 0 + + +def test_suppress_instrumentation_generator_function(setup_tracer): + """Test that suppress_instrumentation prevents spans from being created for generator functions.""" + from opentelemetry.instrumentation.utils import suppress_instrumentation + + exporter, provider = setup_tracer + + @traced() + def sample_generator_function(n): + for i in range(n): + yield i + + # Call without suppression - should create span + results = list(sample_generator_function(3)) + assert results == [0, 1, 2] + + spans = exporter.get_exported_spans() + assert len(spans) == 1 + + exporter.clear_exported_spans() + + # Call with suppression - should NOT create span + with suppress_instrumentation(): + results = list(sample_generator_function(4)) + assert results == [0, 1, 2, 3] + + provider.shutdown() + spans = exporter.get_exported_spans() + assert len(spans) == 0 + + +@pytest.mark.asyncio +async def test_suppress_instrumentation_async_generator_function(setup_tracer): + """Test that suppress_instrumentation prevents spans from being created for async generator functions.""" + from opentelemetry.instrumentation.utils import suppress_instrumentation + + exporter, provider = setup_tracer + + @traced() + async def sample_async_generator_function(n): + for i in range(n): + yield i + + # Call without suppression - should create span + results = [item async for item in sample_async_generator_function(3)] + assert results == [0, 1, 2] + + spans = exporter.get_exported_spans() + assert len(spans) == 1 + + exporter.clear_exported_spans() + + # Call with suppression - should NOT create span + with suppress_instrumentation(): + results = [item async for item in sample_async_generator_function(4)] + assert results == [0, 1, 2, 3] + + provider.shutdown() + spans = exporter.get_exported_spans() + assert len(spans) == 0 + + +def test_suppress_instrumentation_nested_functions(setup_tracer): + """Test that suppress_instrumentation prevents spans for nested traced function calls.""" + from opentelemetry.instrumentation.utils import suppress_instrumentation + + exporter, provider = setup_tracer + + @traced() + def inner_function(x): + return x * 2 + + @traced() + def outer_function(x): + return inner_function(x) + 1 + + # Call without suppression - should create 2 spans + result = outer_function(5) + assert result == 11 + + spans = exporter.get_exported_spans() + assert len(spans) == 2 + + exporter.clear_exported_spans() + + # Call with suppression - should NOT create any spans + with suppress_instrumentation(): + result = outer_function(10) + assert result == 21 + + provider.shutdown() + spans = exporter.get_exported_spans() + assert len(spans) == 0 diff --git a/packages/uipath-core/tests/tracing/test_tracing_utils.py b/packages/uipath-core/tests/tracing/test_tracing_utils.py new file mode 100644 index 000000000..803f2b152 --- /dev/null +++ b/packages/uipath-core/tests/tracing/test_tracing_utils.py @@ -0,0 +1,90 @@ +import inspect +import json + +from uipath.core.tracing._utils import format_args_for_trace, format_args_for_trace_json + + +class TestSpanUtils: + def test_format_args_for_trace(self): + # Simple function signature + def func1(a, b, c=3): + pass + + sig = inspect.signature(func1) + result = format_args_for_trace(sig, 1, 2) + assert result == {"a": 1, "b": 2, "c": 3} + + # Test with kwargs + result = format_args_for_trace(sig, 1, c=4, b=5) + assert result == {"a": 1, "b": 5, "c": 4} + + # Function with self parameter + class TestClass: + def method(self, x, y): + pass + + sig = inspect.signature(TestClass.method) + result = format_args_for_trace(sig, TestClass(), 10, 20) + assert result == {"x": 10, "y": 20} + + # Function with **kwargs + def func2(a, **kwargs): + pass + + sig = inspect.signature(func2) + result = format_args_for_trace(sig, 1, b=2, c=3) + assert result == {"a": 1, "b": 2, "c": 3} + + def test_format_args_for_trace_json(self): + def sample_func(a, b=None): + pass + + sig = inspect.signature(sample_func) + + # Test with simple args + json_result = format_args_for_trace_json(sig, 1, b="test") + parsed = json.loads(json_result) + assert parsed == {"a": 1, "b": "test"} + + # Test with non-serializable object + class NonSerializable: + pass + + json_result = format_args_for_trace_json(sig, 1, b=NonSerializable()) + # Should not raise exception + parsed = json.loads(json_result) + assert parsed["a"] == 1 + assert "b" in parsed # The value will be a string representation + + def test_format_args_for_trace_json_with_class_type(self): + """Test format_args_for_trace_json with a function that takes a class type as parameter.""" + from pydantic import BaseModel + + # Define a mock OutputFormat class (similar to the example) + class OutputFormat(BaseModel): + format_type: str = "json" + strict: bool = True + + # Define a function that takes a class type parameter + def chat_completions(messages, response_format=None): + pass + + sig = inspect.signature(chat_completions) + + # Test with class type as parameter (not instance) + json_result = format_args_for_trace_json( + sig, [("human", "repeat this: hi!")], response_format=OutputFormat + ) + + # Should not raise exception and should serialize the class + parsed = json.loads(json_result) + # Note: tuples are serialized as lists in JSON + assert parsed["messages"] == [["human", "repeat this: hi!"]] + assert "response_format" in parsed + + # When a class type is passed, it should be serialized with class info + response_format_data = parsed["response_format"] + assert "__class__" in response_format_data + assert response_format_data["__class__"] == "OutputFormat" + assert "__module__" in response_format_data + assert "schema" in response_format_data diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock new file mode 100644 index 000000000..ca9bac5fb --- /dev/null +++ b/packages/uipath-core/uv.lock @@ -0,0 +1,1135 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/76/a7f3e639b78601118aaa4a394db2c66ae2597fbd8c39644c32874ed11e0c/bandit-1.9.3.tar.gz", hash = "sha256:ade4b9b7786f89ef6fc7344a52b34558caec5da74cb90373aed01de88472f774", size = 4242154, upload-time = "2026-01-19T04:05:22.802Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/0b/8bdc52111c83e2dc2f97403dc87c0830b8989d9ae45732b34b686326fb2c/bandit-1.9.3-py3-none-any.whl", hash = "sha256:4745917c88d2246def79748bde5e08b9d5e9b92f877863d43fab70cd8814ce6a", size = 134451, upload-time = "2026-01-19T04:05:20.938Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-httpx" +version = "0.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/5574834da9499066fa1a5ea9c336f94dba2eae02298d36dab192fcf95c86/pytest_httpx-0.36.0.tar.gz", hash = "sha256:9edb66a5fd4388ce3c343189bc67e7e1cb50b07c2e3fc83b97d511975e8a831b", size = 56793, upload-time = "2025-12-02T16:34:57.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-trio" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "outcome" }, + { name = "pytest" }, + { name = "trio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/08/056279526554c6c6e6ad6d4a479a338d14dc785ac30be8bdc6ca0153c1be/pytest-trio-0.8.0.tar.gz", hash = "sha256:8363db6336a79e6c53375a2123a41ddbeccc4aa93f93788651641789a56fb52e", size = 46525, upload-time = "2022-11-01T17:24:29.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/22/71953f47e0da5852c899f58cd7a31e6100f37c632b7b9ee52d067613a844/pytest_trio-0.8.0-py3-none-any.whl", hash = "sha256:e6a7e7351ae3e8ec3f4564d30ee77d1ec66e1df611226e5618dbb32f9545c841", size = 27221, upload-time = "2022-11-01T17:24:27.501Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +] + +[[package]] +name = "rust-just" +version = "1.46.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/50/3828817f76e19977a4048c2c8b39a7f48babc21dd9dbed4af2f3c18d4570/rust_just-1.46.0.tar.gz", hash = "sha256:84437481c814577529835132e2cc5fcc35a981c1712e4877cb20fc2f5ec5b2d6", size = 1447346, upload-time = "2026-01-03T02:03:17.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/0b/a5bf2707b02a484d91f8275efa39f76fe19304f5bfba82293fa4b18608d2/rust_just-1.46.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7d6d4c67a443f1acb1f78f9ba4b3349fa04f17e8be2d4448b771cdc93a382812", size = 1739556, upload-time = "2026-01-03T02:02:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ae/40bcd996ccb2fcb0152b5bfde7beaf3840877a8837611421c495b45c82da/rust_just-1.46.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0caf9b77d30455558d017c9e625ce94c373f88d81656477127727604fa5d36ab", size = 1620974, upload-time = "2026-01-03T02:02:45.341Z" }, + { url = "https://files.pythonhosted.org/packages/62/36/7067e0eaf674ed7c98b35ed50d713c0c885f2d2b57847a627e11502da1b8/rust_just-1.46.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b63521acd91c65164c202ded3ae730130c7fb4377f59cd2f9847b45161c94fd", size = 1703423, upload-time = "2026-01-03T02:02:47.681Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/3e98182f5e03c48880d647651385863552a3e24cfec5c51d116c06e6f180/rust_just-1.46.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46a35110c7acf27bdded79cf3bbfea9eb80a53f6f81f374248fe3340584c92e5", size = 1666645, upload-time = "2026-01-03T02:02:50.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/5e/b9badf6e6982e5744f076d12ab911e5ac8b4b03a0674bab4f498ed9d0b4c/rust_just-1.46.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a2407cefc2e5ed4527297747bd5bcb61a885776021cb2438c3a7b118b2cabc2", size = 1847430, upload-time = "2026-01-03T02:02:52.716Z" }, + { url = "https://files.pythonhosted.org/packages/98/10/6916d7c862b99de600a1fd3739d13353c220dfbc0229a0b2c5012c2f801d/rust_just-1.46.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0dab319619f600561b993242312a344953b1ea44637b30257af905a70ce6f568", size = 1926224, upload-time = "2026-01-03T02:02:55.194Z" }, + { url = "https://files.pythonhosted.org/packages/24/93/18bc615e68a80f43105d5e7cc3571e85776aec829ac40faae4de5d5dc2f3/rust_just-1.46.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3b4c26dd86e5d96047fc0935967f22cb9f49c687767d78b7d3fe511eba39ffa", size = 1902165, upload-time = "2026-01-03T02:02:57.636Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/e3c19a24ff64e78a04df0bdf4c61e15c28dcac8b7b5c3a5505eb5749d40a/rust_just-1.46.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acdb496ca26efc508be0e625309b74b1f6316b4f7295d13247c3b791dfa77eb1", size = 1835209, upload-time = "2026-01-03T02:03:00.256Z" }, + { url = "https://files.pythonhosted.org/packages/26/75/0850c38e41025794826165329a097f657152902a785c0579f213b7d61ae6/rust_just-1.46.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3cf1c32a258f8ee44af877ec271e2eea257923a3303a6d2610b0b5f1523daaab", size = 1719519, upload-time = "2026-01-03T02:03:02.774Z" }, + { url = "https://files.pythonhosted.org/packages/48/85/53c6ee2b9cdbbe1bd43cd0f8096036c29e9e6ba2d3d6344206c490e2ce18/rust_just-1.46.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:657ab85882c124b0fbcd75763035d0dbd20b06c582cc6d4f55017d7b517d5a89", size = 1685664, upload-time = "2026-01-03T02:03:05.514Z" }, + { url = "https://files.pythonhosted.org/packages/33/04/1ad3a66bef0d0f554f0f9971b048bbaf7b3955458f3fda47b48fbf8ff009/rust_just-1.46.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:644b71bfe68863b71ee2618a88dbfd446ea70e2dcfa7b0e5eaec7b6dc4faceca", size = 1838231, upload-time = "2026-01-03T02:03:07.618Z" }, + { url = "https://files.pythonhosted.org/packages/ab/75/33c2e887a68e57b356cda74d325d6ebe406bb72ad8c4e2d067d4fa9b697b/rust_just-1.46.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e9d8a879fb86eb1c7f7f83953999ae4ce53ea4e5c0ca531cf6ff09e1e9335ff7", size = 1900319, upload-time = "2026-01-03T02:03:09.873Z" }, + { url = "https://files.pythonhosted.org/packages/44/30/6b1677aa64a4f69f3ec174b5e2a9a49e0ffd06946d4b4dc8295366fbd9dd/rust_just-1.46.0-py3-none-win32.whl", hash = "sha256:100701de91bded3f6f2bf564d09c2f8e483b8dfb490d1c74008ce3c01ff0ff67", size = 1623463, upload-time = "2026-01-03T02:03:12.343Z" }, + { url = "https://files.pythonhosted.org/packages/64/61/97ad7a1ea67b9485404b18150c258015842cf116a1ce626421863fd8f0e1/rust_just-1.46.0-py3-none-win_amd64.whl", hash = "sha256:ccaf8e473f64f5c815b0039e883a1feaf5634b9cdffd1dbff9e5fde77b5926f4", size = 1801103, upload-time = "2026-01-03T02:03:15.256Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "stevedore" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/5b/496f8abebd10c3301129abba7ddafd46c71d799a70c44ab080323987c4c9/stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945", size = 516074, upload-time = "2025-11-20T10:06:07.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/40/8561ce06dc46fd17242c7724ab25b257a2ac1b35f4ebf551b40ce6105cfa/stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", size = 54428, upload-time = "2025-11-20T10:06:05.946Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "trio" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uipath-core" +version = "0.5.5" +source = { editable = "." } +dependencies = [ + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-httpx" }, + { name = "pytest-mock" }, + { name = "pytest-trio" }, + { name = "ruff" }, + { name = "rust-just" }, +] + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-instrumentation", specifier = ">=0.60b0,<1.0.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2.0.0" }, + { name = "pydantic", specifier = ">=2.12.5,<3.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.8.2" }, + { name = "mypy", specifier = ">=1.14.1" }, + { name = "pre-commit", specifier = ">=4.1.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-httpx", specifier = ">=0.35.0" }, + { name = "pytest-mock", specifier = ">=3.11.1" }, + { name = "pytest-trio", specifier = ">=0.8.0" }, + { name = "ruff", specifier = ">=0.9.4" }, + { name = "rust-just", specifier = ">=1.39.0" }, +] + +[[package]] +name = "virtualenv" +version = "20.36.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] diff --git a/packages/uipath-platform/.python-version b/packages/uipath-platform/.python-version new file mode 100644 index 000000000..2c0733315 --- /dev/null +++ b/packages/uipath-platform/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/packages/uipath-platform/CONTRIBUTING.md b/packages/uipath-platform/CONTRIBUTING.md new file mode 100644 index 000000000..d47717b3d --- /dev/null +++ b/packages/uipath-platform/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# Contributing to UiPath Runtime SDK + +## Local Development Setup + +### Prerequisites + +1. **Install Python ≥ 3.11**: + - Download and install Python 3.11 from the official [Python website](https://www.python.org/downloads/) + - Verify the installation by running: + ```sh + python3.11 --version + ``` + + Alternative: [mise](https://mise.jdx.dev/lang/python.html) + +2. **Install [uv](https://docs.astral.sh/uv/)**: + Follow the official installation instructions for your operating system. + +3. **Create a virtual environment in the current working directory**: + ```sh + uv venv + ``` + +4. **Activate the virtual environment**: + - Linux/Mac + ```sh + source .venv/bin/activate + ``` + - Windows Powershell + ```sh + .venv\Scripts\Activate.ps1 + ``` + - Windows Bash + ```sh + source .venv/Scripts/activate + ``` + +5. **Install dependencies**: + ```sh + uv sync --all-extras --no-cache + ``` + +For additional commands related to linting, formatting, and building, run `just --list`. + +### Using the SDK Locally + +1. Create a project directory: + ```sh + mkdir project + cd project + ``` + +2. Initialize the Python project: + ```sh + uv init . --python 3.11 + ``` + +3. Set the SDK path: + ```sh + PATH_TO_SDK=/Users/YOUR_USERNAME/uipath-platform-python + ``` + +4. Install the SDK in editable mode: + ```sh + uv add --editable ${PATH_TO_SDK} + ``` + +> **Note:** Instead of cloning the project into `.venv/lib/python3.11/site-packages/uipath-platform`, this mode creates a file named `_uipath-platform.pth` inside `.venv/lib/python3.11/site-packages`. This file contains the value of `PATH_TO_SDK`, which is added to `sys.path`—the list of directories where Python searches for packages. To view the entries, run `python -c 'import sys; print(sys.path)'`. + +## Service URL Overrides + +When developing a UiPath service locally, you can redirect SDK requests to your local server using environment variables: + +```bash +UIPATH_SERVICE_URL_=http://localhost: +``` + +The service name maps from the endpoint prefix — strip the trailing underscore and uppercase it: + +| Prefix | Env var | +|---------------------|--------------------------------------| +| `agenthub_/` | `UIPATH_SERVICE_URL_AGENTHUB` | +| `orchestrator_/` | `UIPATH_SERVICE_URL_ORCHESTRATOR` | +| `agentsruntime_/` | `UIPATH_SERVICE_URL_AGENTSRUNTIME` | +| `du_/` | `UIPATH_SERVICE_URL_DU` | +| `ecs_/` | `UIPATH_SERVICE_URL_ECS` | +| `connections_/` | `UIPATH_SERVICE_URL_CONNECTIONS` | +| `identity_/` | `UIPATH_SERVICE_URL_IDENTITY` | +| `apps_/` | `UIPATH_SERVICE_URL_APPS` | +| `datafabric_/` | `UIPATH_SERVICE_URL_DATAFABRIC` | +| `resourcecatalog_/` | `UIPATH_SERVICE_URL_RESOURCECATALOG` | + +### What happens + +1. The org/tenant prefix and service prefix are stripped — your local server receives only the API path: + + ``` + agenthub_/llm/api/chat/completions → http://localhost:5200/llm/api/chat/completions + orchestrator_/odata/Processes → http://localhost:5300/odata/Processes + ``` + +2. Routing headers (`X-UiPath-Internal-TenantId`, `X-UiPath-Internal-AccountId`) are injected automatically since the platform routing layer is bypassed. + +### Example `.env` + +```bash +UIPATH_URL=https://alpha.uipath.com/org/tenant +UIPATH_ACCESS_TOKEN=your-token +UIPATH_ORGANIZATION_ID=your-org-id +UIPATH_TENANT_ID=your-tenant-id + +# Override agenthub and orchestrator to local servers +UIPATH_SERVICE_URL_AGENTHUB=http://localhost:5200 +UIPATH_SERVICE_URL_ORCHESTRATOR=http://localhost:5300 +``` diff --git a/packages/uipath-platform/README.md b/packages/uipath-platform/README.md new file mode 100644 index 000000000..a910e3f54 --- /dev/null +++ b/packages/uipath-platform/README.md @@ -0,0 +1,3 @@ +# UiPath Platform + +Python SDK for interacting programmatically with UiPath services. diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml new file mode 100644 index 000000000..3c80a9455 --- /dev/null +++ b/packages/uipath-platform/pyproject.toml @@ -0,0 +1,117 @@ +[project] +name = "uipath-platform" +version = "0.0.11" +description = "HTTP client library for programmatic access to UiPath Platform" +readme = { file = "README.md", content-type = "text/markdown" } +requires-python = ">=3.11" +dependencies = [ + "httpx>=0.28.1", + "tenacity>=9.0.0", + "truststore>=0.10.1", + "uipath-core>=0.5.3, <0.6.0", + "pydantic-function-models>=0.1.11", +] +classifiers = [ + "Intended Audience :: Developers", + "Topic :: Software Development :: Build Tools", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + +] +maintainers = [ + { name = "Marius Cosareanu", email = "marius.cosareanu@uipath.com" }, + { name = "Cristian Pufu", email = "cristian.pufu@uipath.com" }, +] + +[project.urls] +Homepage = "https://uipath.com" +Repository = "https://github.com/UiPath/uipath-python" +Documentation = "https://uipath.github.io/uipath-python/" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = [ + "bandit>=1.8.2", + "mypy>=1.19.0", + "ruff>=0.9.4", + "rust-just>=1.39.0", + "pytest>=7.4.0", + "pytest-asyncio>=1.0.0", + "pytest-httpx>=0.35.0", + "pytest-trio>=0.8.0", + "pytest-cov>=4.1.0", + "pytest-mock>=3.11.1", + "pre-commit>=4.1.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/uipath"] + +[tool.ruff] +line-length = 88 +indent-width = 4 +exclude = ["samples/**", "testcases/**"] + +[tool.ruff.lint] +select = ["E", "F", "B", "I", "D"] +ignore = ["D417"] + +[tool.ruff.lint.pydocstyle] +convention = "google" +ignore-decorators = [] + +[tool.ruff.lint.per-file-ignores] +"*" = ["E501"] +"tests/**" = ["D"] +"*_test.py" = ["D"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.mypy] +plugins = ["pydantic.mypy"] +mypy_path = "src" +explicit_package_bases = true +namespace_packages = true +follow_imports = "silent" +warn_redundant_casts = true +warn_unused_ignores = true +disallow_any_generics = true +check_untyped_defs = true +no_implicit_reexport = true + +disallow_untyped_defs = false + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +addopts = "-ra -q --cov=src/uipath --cov-report=term-missing" +asyncio_default_fixture_loop_scope = "function" +asyncio_mode = "auto" + +[tool.coverage.report] +show_missing = true + +[tool.coverage.run] +source = ["src"] + +[tool.uv.sources] +uipath-core = { path = "../uipath-core", editable = true } + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true diff --git a/packages/uipath-platform/src/uipath/platform/__init__.py b/packages/uipath-platform/src/uipath/platform/__init__.py new file mode 100644 index 000000000..61abb103c --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/__init__.py @@ -0,0 +1,40 @@ +"""UiPath SDK for Python. + +This package provides a Python interface to interact with UiPath's automation platform. + + +The main entry point is the UiPath class, which provides access to all SDK functionality. + +Example: +```python + # First set these environment variables: + # export UIPATH_URL="https://cloud.uipath.com/organization-name/default-tenant" + # export UIPATH_ACCESS_TOKEN="your_**_token" + # export UIPATH_FOLDER_PATH="your/folder/path" + + from uipath.platform import UiPath + sdk = UiPath() + # Invoke a process by name + sdk.processes.invoke("MyProcess") +``` + +## Error Handling + +Exception classes are available in the `errors` module and should be imported explicitly: + +```python + from uipath.platform.errors import ( + BaseUrlMissingError, + SecretMissingError, + EnrichedException, + IngestionInProgressException, + FolderNotFoundException, + UnsupportedDataSourceException, + ) +``` +""" + +from ._uipath import UiPath +from .common import UiPathApiConfig, UiPathExecutionContext + +__all__ = ["UiPathApiConfig", "UiPath", "UiPathExecutionContext"] diff --git a/packages/uipath-platform/src/uipath/platform/_uipath.py b/packages/uipath-platform/src/uipath/platform/_uipath.py new file mode 100644 index 000000000..2143e5dd6 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/_uipath.py @@ -0,0 +1,166 @@ +from functools import cached_property +from typing import Optional + +from pydantic import ValidationError + +from uipath.platform.automation_tracker import AutomationTrackerService + +from .action_center import TasksService +from .agenthub._agenthub_service import AgentHubService +from .chat import ConversationsService, UiPathLlmChatService, UiPathOpenAIService +from .common import ( + ApiClient, + ExternalApplicationService, + UiPathApiConfig, + UiPathExecutionContext, +) +from .common.auth import resolve_config_from_env +from .connections import ConnectionsService +from .context_grounding import ContextGroundingService +from .documents import DocumentsService +from .entities import EntitiesService +from .errors import BaseUrlMissingError, SecretMissingError +from .guardrails import GuardrailsService +from .orchestrator import ( + AssetsService, + AttachmentsService, + BucketsService, + FolderService, + JobsService, + McpService, + ProcessesService, + QueuesService, +) +from .resource_catalog import ResourceCatalogService + + +def _has_valid_client_credentials( + client_id: Optional[str], client_secret: Optional[str] +) -> bool: + if bool(client_id) != bool(client_secret): + raise ValueError("Both client_id and client_secret must be provided together.") + return bool(client_id and client_secret) + + +class UiPath: + def __init__( + self, + *, + base_url: Optional[str] = None, + secret: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + scope: Optional[str] = None, + debug: bool = False, + ) -> None: + try: + if _has_valid_client_credentials(client_id, client_secret): + assert client_id and client_secret + service = ExternalApplicationService(base_url) + token_data = service.get_token_data(client_id, client_secret, scope) + base_url, secret = service._base_url, token_data.access_token + else: + base_url, secret = resolve_config_from_env(base_url, secret) + + self._config = UiPathApiConfig( + base_url=base_url, + secret=secret, + ) + except ValidationError as e: + for error in e.errors(): + if error["loc"][0] == "base_url": + raise BaseUrlMissingError() from e + elif error["loc"][0] == "secret": + raise SecretMissingError() from e + self._execution_context = UiPathExecutionContext() + + @property + def api_client(self) -> ApiClient: + return ApiClient(self._config, self._execution_context) + + @property + def assets(self) -> AssetsService: + return AssetsService(self._config, self._execution_context) + + @cached_property + def attachments(self) -> AttachmentsService: + return AttachmentsService(self._config, self._execution_context) + + @property + def processes(self) -> ProcessesService: + return ProcessesService(self._config, self._execution_context, self.attachments) + + @property + def tasks(self) -> TasksService: + return TasksService(self._config, self._execution_context) + + @cached_property + def buckets(self) -> BucketsService: + return BucketsService(self._config, self._execution_context) + + @cached_property + def connections(self) -> ConnectionsService: + return ConnectionsService(self._config, self._execution_context, self.folders) + + @property + def context_grounding(self) -> ContextGroundingService: + return ContextGroundingService( + self._config, + self._execution_context, + self.folders, + self.buckets, + ) + + @property + def documents(self) -> DocumentsService: + return DocumentsService(self._config, self._execution_context) + + @property + def queues(self) -> QueuesService: + return QueuesService(self._config, self._execution_context) + + @property + def jobs(self) -> JobsService: + return JobsService(self._config, self._execution_context) + + @cached_property + def folders(self) -> FolderService: + return FolderService(self._config, self._execution_context) + + @property + def llm_openai(self) -> UiPathOpenAIService: + return UiPathOpenAIService(self._config, self._execution_context) + + @property + def llm(self) -> UiPathLlmChatService: + return UiPathLlmChatService(self._config, self._execution_context) + + @property + def entities(self) -> EntitiesService: + return EntitiesService(self._config, self._execution_context) + + @cached_property + def resource_catalog(self) -> ResourceCatalogService: + return ResourceCatalogService( + self._config, self._execution_context, self.folders + ) + + @property + def conversational(self) -> ConversationsService: + return ConversationsService(self._config, self._execution_context) + + @property + def mcp(self) -> McpService: + return McpService(self._config, self._execution_context, self.folders) + + @property + def guardrails(self) -> GuardrailsService: + return GuardrailsService(self._config, self._execution_context) + + @property + def agenthub(self) -> AgentHubService: + return AgentHubService(self._config, self._execution_context, self.folders) + + @property + def automation_tracker(self) -> AutomationTrackerService: + return AutomationTrackerService(self._config, self._execution_context) diff --git a/packages/uipath-platform/src/uipath/platform/action_center/__init__.py b/packages/uipath-platform/src/uipath/platform/action_center/__init__.py new file mode 100644 index 000000000..675f50eb6 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/action_center/__init__.py @@ -0,0 +1,14 @@ +"""UiPath Action Center Models. + +This module contains models related to UiPath Action Center service. +""" + +from ._tasks_service import TasksService +from .task_schema import TaskSchema +from .tasks import Task + +__all__ = [ + "TasksService", + "Task", + "TaskSchema", +] diff --git a/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py new file mode 100644 index 000000000..95791098b --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/action_center/_tasks_service.py @@ -0,0 +1,688 @@ +import asyncio +import os +import uuid +from typing import Any, Dict, List, Optional, Tuple + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig, UiPathConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext +from ..common._models import Endpoint, RequestSpec +from ..common.constants import ( + ENV_TENANT_ID, + HEADER_FOLDER_KEY, + HEADER_FOLDER_PATH, + HEADER_TENANT_ID, +) +from .task_schema import TaskSchema +from .tasks import Task, TaskRecipient, TaskRecipientType + + +def _ensure_string_value(value: Any) -> str: + """Convert any value to a string for use in field Value.""" + if isinstance(value, str): + return value + return str(value) if value else "" + + +def _create_spec( + data: Optional[Dict[str, Any]], + action_schema: Optional[TaskSchema], + title: str, + app_key: Optional[str] = None, + app_folder_key: Optional[str] = None, + app_folder_path: Optional[str] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + source_name: str = "Agent", +) -> RequestSpec: + field_list = [] + outcome_list = [] + if action_schema: + if action_schema.inputs: + for input_field in action_schema.inputs: + field_name = input_field.name + field_value = data.get(field_name, "") if data is not None else "" + field_list.append( + { + "Id": input_field.key, + "Name": field_name, + "Title": field_name, + "Type": "Fact", + "Value": _ensure_string_value(field_value), + } + ) + if action_schema.outputs: + for output_field in action_schema.outputs: + field_name = output_field.name + field_list.append( + { + "Id": output_field.key, + "Name": field_name, + "Title": field_name, + "Type": "Fact", + "Value": "", + } + ) + if action_schema.in_outs: + for inout_field in action_schema.in_outs: + field_name = inout_field.name + field_value = data.get(field_name, "") if data is not None else "" + field_list.append( + { + "Id": inout_field.key, + "Name": field_name, + "Title": field_name, + "Type": "Fact", + "Value": _ensure_string_value(field_value), + } + ) + if action_schema.outcomes: + for outcome in action_schema.outcomes: + outcome_list.append( + { + "Id": action_schema.key, + "Name": outcome.name, + "Title": outcome.name, + "Type": "Action.Http", + "IsPrimary": True, + } + ) + + json_payload: Dict[str, Any] = { + "appId": app_key, + "title": title, + "data": data if data is not None else {}, + "actionableMessageMetaData": actionable_message_metadata + if actionable_message_metadata is not None + else ( + { + "fieldSet": { + "id": str(uuid.uuid4()), + "fields": field_list, + } + if len(field_list) != 0 + else {}, + "actionSet": { + "id": str(uuid.uuid4()), + "actions": outcome_list, + } + if len(outcome_list) != 0 + else {}, + } + if action_schema is not None + else {} + ), + } + + if priority and (normalized_priority := _normalize_priority(priority)): + json_payload["priority"] = normalized_priority + if labels is not None: + json_payload["tags"] = [ + { + "name": label, + "displayName": label, + "value": label, + "displayValue": label, + } + for label in labels + ] + if is_actionable_message_enabled is not None: + json_payload["isActionableMessageEnabled"] = is_actionable_message_enabled + + project_id = UiPathConfig.project_id + trace_id = UiPathConfig.trace_id + + if project_id and trace_id: + folder_key = UiPathConfig.folder_key + job_key = UiPathConfig.job_key + process_key = UiPathConfig.process_uuid + + task_source_metadata: Dict[str, Any] = { + "InstanceId": trace_id, + "FolderKey": folder_key, + "JobKey": job_key, + "ProcessKey": process_key, + } + + task_source = { + "sourceName": source_name, + "sourceId": project_id, + "taskSourceMetadata": task_source_metadata, + } + + json_payload["taskSource"] = task_source + + return RequestSpec( + method="POST", + endpoint=Endpoint("/orchestrator_/tasks/AppTasks/CreateAppTask"), + json=json_payload, + headers=folder_headers(app_folder_key, app_folder_path), + ) + + +def _normalize_priority(priority: str | None) -> str | None: + """Normalize priority string to match API expectations. + + Converts case-insensitive priority strings to the proper capitalized format + expected by the Orchestrator API. + + Args: + priority: Priority string (case-insensitive: "low", "HIGH", "MeDiUm", etc.) + + Returns: + Normalized priority string ("Low", "Medium", "High", "Critical") or None + """ + if priority is None or not priority.strip(): + return None + + priority_map = { + "low": "Low", + "medium": "Medium", + "high": "High", + "critical": "Critical", + } + + normalized = priority_map.get(priority.lower()) + if normalized is None: + raise ValueError( + f"Invalid priority value: '{priority}'. " + f"Must be one of: Low, Medium, High, Critical (case-insensitive)" + ) + + return normalized + + +def _retrieve_action_spec( + action_key: str, app_folder_key: str, app_folder_path: str +) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/tasks/GenericTasks/GetTaskDataByKey"), + params={"taskKey": action_key}, + headers=folder_headers(app_folder_key, app_folder_path), + ) + + +async def _assign_task_spec( + self, task_key: str, assignee: str | None, task_recipient: TaskRecipient | None +) -> RequestSpec: + request_spec = RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Tasks/UiPath.Server.Configuration.OData.AssignTasks" + ), + ) + if task_recipient: + recipient_value = await _resolve_recipient(self, task_recipient) + if ( + task_recipient.type == TaskRecipientType.USER_ID + or task_recipient.type == TaskRecipientType.EMAIL + ): + request_spec.json = { + "taskAssignments": [ + { + "taskId": task_key, + "assignmentCriteria": "SingleUser", + "userNameOrEmail": recipient_value, + } + ] + } + else: + request_spec.json = { + "taskAssignments": [ + { + "taskId": task_key, + "assignmentCriteria": "AllUsers", + "assigneeNamesOrEmails": [recipient_value], + } + ] + } + elif assignee: + request_spec.json = { + "taskAssignments": [{"taskId": task_key, "UserNameOrEmail": assignee}] + } + return request_spec + + +async def _resolve_recipient(self, task_recipient: TaskRecipient) -> str: + recipient_value = task_recipient.value + + if task_recipient.type == TaskRecipientType.USER_ID: + spec = _resolve_user(task_recipient.value) + response = await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + content=spec.content, + headers=spec.headers, + scoped="org", + ) + recipient_value = response.json().get("email") + task_recipient.display_name = recipient_value + + if task_recipient.type == TaskRecipientType.GROUP_ID: + spec = _resolve_group(task_recipient.value) + response = await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + content=spec.content, + headers=spec.headers, + scoped="org", + ) + recipient_value = response.json().get("displayName") + task_recipient.display_name = recipient_value + + return recipient_value + + +def _resolve_user(entity_id: str) -> RequestSpec: + org_id = UiPathConfig.organization_id + return RequestSpec( + method="POST", + endpoint=Endpoint( + "/identity_/api/Directory/Resolve/{org_id}".format(org_id=org_id) + ), + json={"entityId": entity_id, "entityType": "User"}, + ) + + +def _resolve_group(entity_id: str) -> RequestSpec: + org_id = UiPathConfig.organization_id + return RequestSpec( + method="GET", + endpoint=Endpoint( + "/identity_/api/Group/{org_id}/{entity_id}".format( + org_id=org_id, entity_id=entity_id + ) + ), + ) + + +def _retrieve_app_key_spec(app_name: str) -> RequestSpec: + tenant_id = os.getenv(ENV_TENANT_ID, None) + if not tenant_id: + raise Exception(f"{ENV_TENANT_ID} env var is not set") + return RequestSpec( + method="GET", + endpoint=Endpoint("/apps_/default/api/v1/default/deployed-action-apps-schemas"), + params={"search": app_name, "filterByDeploymentTitle": "true"}, + headers={HEADER_TENANT_ID: tenant_id}, + ) + + +def folder_headers( + app_folder_key: Optional[str], app_folder_path: Optional[str] +) -> Dict[str, str]: + headers = {} + if app_folder_key: + headers[HEADER_FOLDER_KEY] = app_folder_key + elif app_folder_path: + headers[HEADER_FOLDER_PATH] = app_folder_path + return headers + + +class TasksService(FolderContext, BaseService): + """Service for managing UiPath Action Center tasks. + + Tasks are task-based automation components that can be integrated into + applications and processes. They represent discrete units of work that can + be triggered and monitored through the UiPath API. + + This service provides methods to create and retrieve tasks, supporting + both app-specific and generic tasks. It inherits folder context management + capabilities from FolderContext. + + Reference: https://docs.uipath.com/automation-cloud/docs/actions + """ + + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + @resource_override( + resource_type="app", + resource_identifier="app_name", + folder_identifier="app_folder_path", + ) + @traced(name="tasks_create", run_type="uipath") + async def create_async( + self, + title: str, + data: Optional[Dict[str, Any]] = None, + *, + app_name: Optional[str] = None, + app_key: Optional[str] = None, + app_folder_path: Optional[str] = None, + app_folder_key: Optional[str] = None, + assignee: Optional[str] = None, + recipient: Optional[TaskRecipient] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + source_name: str = "Agent", + ) -> Task: + """Creates a new action asynchronously. + + This method creates a new action task in UiPath Orchestrator. The action can be + either app-specific (using app_name or app_key) or a generic action. + + Args: + title: The title of the action + data: Optional dictionary containing input data for the action + app_name: The name of the application (if creating an app-specific action) + app_key: The key of the application (if creating an app-specific action) + app_folder_path: Optional folder path for the action + app_folder_key: Optional folder key for the action + assignee: Optional username or email to assign the task to + priority: Optional priority of the task + labels: Optional list of labels for the task + is_actionable_message_enabled: Optional boolean indicating whether actionable notifications are enabled for this task + actionable_message_metadata: Optional metadata for the action + source_name: The name of the source that created the task. Defaults to 'Agent'. + + Returns: + Action: The created action object + + Raises: + Exception: If neither app_name nor app_key is provided for app-specific actions + """ + app_folder_path = app_folder_path if app_folder_path else self._folder_path + + (key, action_schema) = ( + (app_key, None) + if app_key + else await self._get_app_key_and_schema_async(app_name, app_folder_path) + ) + spec = _create_spec( + title=title, + data=data, + app_key=key, + action_schema=action_schema, + app_folder_key=app_folder_key, + app_folder_path=app_folder_path, + priority=priority, + labels=labels, + is_actionable_message_enabled=is_actionable_message_enabled, + actionable_message_metadata=actionable_message_metadata, + source_name=source_name, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + json_response = response.json() + if assignee or recipient: + spec = await _assign_task_spec( + self, json_response["id"], assignee, recipient + ) + await self.request_async( + spec.method, spec.endpoint, json=spec.json, content=spec.content + ) + return Task.model_validate(json_response) + + @resource_override( + resource_type="app", + resource_identifier="app_name", + folder_identifier="app_folder_path", + ) + @traced(name="tasks_create", run_type="uipath") + def create( + self, + title: str, + data: Optional[Dict[str, Any]] = None, + *, + app_name: Optional[str] = None, + app_key: Optional[str] = None, + app_folder_path: Optional[str] = None, + app_folder_key: Optional[str] = None, + assignee: Optional[str] = None, + recipient: Optional[TaskRecipient] = None, + priority: Optional[str] = None, + labels: Optional[List[str]] = None, + is_actionable_message_enabled: Optional[bool] = None, + actionable_message_metadata: Optional[Dict[str, Any]] = None, + source_name: str = "Agent", + ) -> Task: + """Creates a new task synchronously. + + This method creates a new action task in UiPath Orchestrator. The action can be + either app-specific (using app_name or app_key) or a generic action. + + Args: + title: The title of the action + data: Optional dictionary containing input data for the action + app_name: The name of the application (if creating an app-specific action) + app_key: The key of the application (if creating an app-specific action) + app_folder_path: Optional folder path for the action + app_folder_key: Optional folder key for the action + assignee: Optional username or email to assign the task to + priority: Optional priority of the task + labels: Optional list of labels for the task + is_actionable_message_enabled: Optional boolean indicating whether actionable notifications are enabled for this task + actionable_message_metadata: Optional metadata for the action + source_name: The name of the source that created the task. Defaults to 'Agent'. + + Returns: + Action: The created action object + + Raises: + Exception: If neither app_name nor app_key is provided for app-specific actions + """ + app_folder_path = app_folder_path if app_folder_path else self._folder_path + + (key, action_schema) = ( + (app_key, None) + if app_key + else self._get_app_key_and_schema(app_name, app_folder_path) + ) + spec = _create_spec( + title=title, + data=data, + app_key=key, + action_schema=action_schema, + app_folder_key=app_folder_key, + app_folder_path=app_folder_path, + priority=priority, + labels=labels, + is_actionable_message_enabled=is_actionable_message_enabled, + actionable_message_metadata=actionable_message_metadata, + source_name=source_name, + ) + + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + json_response = response.json() + if assignee or recipient: + spec = asyncio.run( + _assign_task_spec(self, json_response["id"], assignee, recipient) + ) + self.request( + spec.method, spec.endpoint, json=spec.json, content=spec.content + ) + return Task.model_validate(json_response) + + @resource_override( + resource_type="app", + resource_identifier="app_name", + folder_identifier="app_folder_path", + ) + @traced(name="tasks_retrieve", run_type="uipath") + def retrieve( + self, + action_key: str, + app_folder_path: str = "", + app_folder_key: str = "", + app_name: str | None = None, + ) -> Task: + """Retrieves a task by its key synchronously. + + Args: + action_key: The unique identifier of the task to retrieve + app_folder_path: Optional folder path for the task + app_folder_key: Optional folder key for the task + app_name: app name hint for resource override + Returns: + Task: The retrieved task object + """ + spec = _retrieve_action_spec( + action_key=action_key, + app_folder_key=app_folder_key, + app_folder_path=app_folder_path, + ) + response = self.request( + spec.method, spec.endpoint, params=spec.params, headers=spec.headers + ) + + return Task.model_validate(response.json()) + + @resource_override( + resource_type="app", + resource_identifier="app_name", + folder_identifier="app_folder_path", + ) + @traced(name="tasks_retrieve", run_type="uipath") + async def retrieve_async( + self, + action_key: str, + app_folder_path: str = "", + app_folder_key: str = "", + app_name: str | None = None, + ) -> Task: + """Retrieves a task by its key asynchronously. + + Args: + action_key: The unique identifier of the task to retrieve + app_folder_path: Optional folder path for the task + app_folder_key: Optional folder key for the task + app_name: app name hint for resource override + Returns: + Task: The retrieved task object + """ + spec = _retrieve_action_spec( + action_key=action_key, + app_folder_key=app_folder_key, + app_folder_path=app_folder_path, + ) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params, headers=spec.headers + ) + + return Task.model_validate(response.json()) + + async def _get_app_key_and_schema_async( + self, app_name: Optional[str], app_folder_path: Optional[str] + ) -> Tuple[str, Optional[TaskSchema]]: + if not app_name: + raise Exception("appName or appKey is required") + spec = _retrieve_app_key_spec(app_name=app_name) + + response = await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + scoped="org", + ) + try: + deployed_app = self._extract_deployed_app( + response.json()["deployed"], app_folder_path + ) + action_schema = deployed_app["actionSchema"] + deployed_app_key = deployed_app["systemName"] + except (KeyError, IndexError): + raise Exception("Action app not found") from None + try: + return ( + deployed_app_key, + TaskSchema( + key=action_schema["key"], + in_outs=action_schema["inOuts"], + inputs=action_schema["inputs"], + outputs=action_schema["outputs"], + outcomes=action_schema["outcomes"], + ), + ) + except KeyError: + raise Exception("Failed to deserialize action schema") from KeyError + + def _get_app_key_and_schema( + self, app_name: Optional[str], app_folder_path: Optional[str] + ) -> Tuple[str, Optional[TaskSchema]]: + if not app_name: + raise Exception("appName or appKey is required") + + spec = _retrieve_app_key_spec(app_name=app_name) + + response = self.request( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + scoped="org", + ) + + try: + deployed_app = self._extract_deployed_app( + response.json()["deployed"], app_folder_path + ) + action_schema = deployed_app["actionSchema"] + deployed_app_key = deployed_app["systemName"] + except (KeyError, IndexError): + raise Exception("Action app not found") from None + try: + return ( + deployed_app_key, + TaskSchema( + key=action_schema["key"], + in_outs=action_schema["inOuts"], + inputs=action_schema["inputs"], + outputs=action_schema["outputs"], + outcomes=action_schema["outcomes"], + ), + ) + except KeyError: + raise Exception("Failed to deserialize action schema") from KeyError + + # should be removed after folder filtering support is added on apps API + def _extract_deployed_app( + self, deployed_apps: List[Dict[str, Any]], app_folder_path: Optional[str] + ) -> Dict[str, Any]: + if len(deployed_apps) > 1 and not app_folder_path: + raise Exception("Multiple app schemas found") + try: + if app_folder_path: + return next( + app + for app in deployed_apps + if app["deploymentFolder"]["fullyQualifiedName"] == app_folder_path + ) + else: + return next( + app + for app in deployed_apps + if app["deploymentFolder"]["key"] == self._folder_key + ) + except StopIteration: + raise KeyError from StopIteration + + @property + def custom_headers(self) -> Dict[str, str]: + return self.folder_headers diff --git a/packages/uipath-platform/src/uipath/platform/action_center/task_schema.py b/packages/uipath-platform/src/uipath/platform/action_center/task_schema.py new file mode 100644 index 000000000..2fb707549 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/action_center/task_schema.py @@ -0,0 +1,30 @@ +"""Module defining the ActionSchema model for UiPath platform actions.""" + +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class FieldDetails(BaseModel): + """Model representing details of a field in an action schema.""" + + name: str + key: str + + +class TaskSchema(BaseModel): + """Model representing the schema of an action in the UiPath platform.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + key: str + in_outs: Optional[List[FieldDetails]] = Field(default=None, alias="inOuts") + inputs: Optional[List[FieldDetails]] = None + outputs: Optional[List[FieldDetails]] = None + outcomes: Optional[List[FieldDetails]] = None diff --git a/packages/uipath-platform/src/uipath/platform/action_center/tasks.py b/packages/uipath-platform/src/uipath/platform/action_center/tasks.py new file mode 100644 index 000000000..f882cf40f --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/action_center/tasks.py @@ -0,0 +1,106 @@ +"""Data model for an Action in the UiPath Platform.""" + +import enum +from datetime import datetime +from typing import Any, Dict, List, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, field_serializer + + +class TaskStatus(enum.IntEnum): + """Enum representing possible Task status.""" + + UNASSIGNED = 0 + PENDING = 1 + COMPLETED = 2 + + +class TaskRecipientType(str, enum.Enum): + """Task recipient type enumeration.""" + + USER_ID = "UserId" + GROUP_ID = "GroupId" + EMAIL = "UserEmail" + GROUP_NAME = "GroupName" + + +class TaskRecipient(BaseModel): + """Model representing a task recipient.""" + + type: Literal[ + TaskRecipientType.USER_ID, + TaskRecipientType.GROUP_ID, + TaskRecipientType.EMAIL, + TaskRecipientType.GROUP_NAME, + ] = Field(..., alias="type") + value: str = Field(..., alias="value") + display_name: Optional[str] = Field(default=None, alias="displayName") + + +class Task(BaseModel): + """Model representing a Task in the UiPath Platform.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + @field_serializer("*", when_used="json") + def serialize_datetime(self, value): + """Serialize datetime fields to ISO 8601 format for JSON output.""" + if isinstance(value, datetime): + return value.isoformat() if value else None + return value + + task_definition_properties_id: Optional[int] = Field( + default=None, alias="taskDefinitionPropertiesId" + ) + app_tasks_metadata: Optional[Any] = Field(default=None, alias="appTasksMetadata") + action_label: Optional[str] = Field(default=None, alias="actionLabel") + # 2.3.0 change to TaskStatus enum + status: Optional[Union[str, int]] = None + data: Optional[Dict[str, Any]] = None + action: Optional[str] = None + wait_job_state: Optional[str] = Field(default=None, alias="waitJobState") + organization_unit_fully_qualified_name: Optional[str] = Field( + default=None, alias="organizationUnitFullyQualifiedName" + ) + tags: Optional[List[Any]] = None + assigned_to_user: Optional[Any] = Field(default=None, alias="assignedToUser") + task_sla_details: Optional[List[Any]] = Field(default=None, alias="taskSlaDetails") + completed_by_user: Optional[Any] = Field(default=None, alias="completedByUser") + task_assignment_criteria: Optional[str] = Field( + default=None, alias="taskAssignmentCriteria" + ) + task_assignees: Optional[List[Any]] = Field(default=None, alias="taskAssignees") + title: Optional[str] = None + type: Optional[str] = None + priority: Optional[str] = None + assigned_to_user_id: Optional[int] = Field(default=None, alias="assignedToUserId") + organization_unit_id: Optional[int] = Field( + default=None, alias="organizationUnitId" + ) + external_tag: Optional[str] = Field(default=None, alias="externalTag") + creator_job_key: Optional[str] = Field(default=None, alias="creatorJobKey") + wait_job_key: Optional[str] = Field(default=None, alias="waitJobKey") + last_assigned_time: Optional[datetime] = Field( + default=None, alias="lastAssignedTime" + ) + completion_time: Optional[datetime] = Field(default=None, alias="completionTime") + parent_operation_id: Optional[str] = Field(default=None, alias="parentOperationId") + key: Optional[str] = None + is_deleted: bool = Field(default=False, alias="isDeleted") + deleter_user_id: Optional[int] = Field(default=None, alias="deleterUserId") + deletion_time: Optional[datetime] = Field(default=None, alias="deletionTime") + last_modification_time: Optional[datetime] = Field( + default=None, alias="lastModificationTime" + ) + last_modifier_user_id: Optional[int] = Field( + default=None, alias="lastModifierUserId" + ) + creation_time: Optional[datetime] = Field(default=None, alias="creationTime") + creator_user_id: Optional[int] = Field(default=None, alias="creatorUserId") + id: Optional[int] = None diff --git a/packages/uipath-platform/src/uipath/platform/agenthub/__init__.py b/packages/uipath-platform/src/uipath/platform/agenthub/__init__.py new file mode 100644 index 000000000..2012f3953 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/agenthub/__init__.py @@ -0,0 +1,8 @@ +"""UiPath AgentHub Models. + +This module contains models related to UiPath AgentHub service. +""" + +from uipath.platform.agenthub.agenthub import LlmModel + +__all__ = ["LlmModel"] diff --git a/packages/uipath-platform/src/uipath/platform/agenthub/_agenthub_service.py b/packages/uipath-platform/src/uipath/platform/agenthub/_agenthub_service.py new file mode 100644 index 000000000..4119a433f --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/agenthub/_agenthub_service.py @@ -0,0 +1,205 @@ +from typing import Any + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext, header_folder +from ..common._models import Endpoint, RequestSpec +from ..orchestrator import FolderService +from .agenthub import LlmModel + + +class AgentHubService(FolderContext, BaseService): + """Service class for interacting with AgentHub platform service.""" + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folder_service: FolderService, + ) -> None: + self._folder_service = folder_service + super().__init__(config=config, execution_context=execution_context) + + def get_available_llm_models( + self, headers: dict[str, Any] | None = None + ) -> list[LlmModel]: + """Fetch available models from LLM Gateway discovery endpoint. + + Returns: + List of available models and their configurations. + """ + spec = self._available_models_spec(headers=headers) + + response = self.request( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + return [ + LlmModel.model_validate(available_model) + for available_model in response.json() + ] + + async def get_available_llm_models_async( + self, headers: dict[str, Any] | None = None + ) -> list[LlmModel]: + """Asynchronously fetch available models from LLM Gateway discovery endpoint. + + Returns: + List of available models and their configurations. + """ + spec = self._available_models_spec(headers=headers) + + response = await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + return [ + LlmModel.model_validate(available_model) + for available_model in response.json() + ] + + def invoke_system_agent( + self, + *, + agent_name: str, + entrypoint: str, + input_arguments: dict[str, Any] | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + headers: dict[str, Any] | None = None, + ) -> str: + """Start a system agent job. + + Args: + agent_name: The name of the system agent to invoke. + entrypoint: The entry point to execute. + input_arguments: Optional input arguments to pass to the agent. + folder_key: Optional folder key to override the default folder context. + folder_path: Optional folder path to override the default folder context. + + Returns: + str: The started job's key. + """ + folder_key = self._resolve_folder_key(folder_key, folder_path) + + spec = self._start_spec( + agent_name=agent_name, + entrypoint=entrypoint, + input_arguments=input_arguments, + folder_key=folder_key, + headers=headers, + ) + + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + response_data = response.json() + + return response_data["key"] + + async def invoke_system_agent_async( + self, + *, + agent_name: str, + entrypoint: str, + input_arguments: dict[str, Any] | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + headers: dict[str, Any] | None = None, + ) -> str: + """Asynchronously start a system agent and return the job. + + Args: + agent_name: The name of the system agent to invoke. + entrypoint: The entry point to execute. + input_arguments: Optional input arguments to pass to the agent. + folder_key: Optional folder key to override the default folder context. + folder_path: Optional folder path to override the default folder context. + + Returns: + str: The started job's key. + + """ + folder_key = self._resolve_folder_key(folder_key, folder_path) + + spec = self._start_spec( + agent_name=agent_name, + entrypoint=entrypoint, + input_arguments=input_arguments, + folder_key=folder_key, + headers=headers, + ) + + response = await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + response_data = response.json() + + return response_data["key"] + + def _start_spec( + self, + agent_name: str, + entrypoint: str, + input_arguments: dict[str, Any] | None, + folder_key: str, + headers: dict[str, Any] | None, + ) -> RequestSpec: + """Build the request specification for starting a system agent. + + Args: + agent_name: The name of the system agent. + entrypoint: The entry point to execute. + input_arguments: Input arguments for the agent. + folder_key: Folder key for scoping. + + Returns: + RequestSpec: The request specification with endpoint, method, headers, and body. + """ + return RequestSpec( + method="POST", + endpoint=Endpoint(f"agenthub_/api/systemagents/{agent_name}/start"), + headers=header_folder(folder_key, None) | (headers or {}), + json={ + "EntryPoint": entrypoint, + "InputArguments": input_arguments or {}, + }, + ) + + def _resolve_folder_key( + self, folder_key: str | None, folder_path: str | None + ) -> str: + if folder_key is None and folder_path is not None: + folder_key = self._folder_service.retrieve_key(folder_path=folder_path) + + if folder_key is None and folder_path is None: + folder_key = self._folder_key or ( + self._folder_service.retrieve_key(folder_path=self._folder_path) + if self._folder_path + else None + ) + + if folder_key is None: + raise ValueError("AgentHubClient: Failed to resolve folder key") + + return folder_key + + def _available_models_spec(self, headers: dict[str, Any] | None) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint("/agenthub_/llm/api/discovery"), + headers=headers or {}, + ) diff --git a/packages/uipath-platform/src/uipath/platform/agenthub/agenthub.py b/packages/uipath-platform/src/uipath/platform/agenthub/agenthub.py new file mode 100644 index 000000000..6ccfea047 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/agenthub/agenthub.py @@ -0,0 +1,18 @@ +"""AgentHub response payload models.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class LlmModel(BaseModel): + """Model representing an available LLM model.""" + + model_name: str = Field(..., alias="modelName") + vendor: str | None = Field(default=None) + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) diff --git a/packages/uipath-platform/src/uipath/platform/attachments/__init__.py b/packages/uipath-platform/src/uipath/platform/attachments/__init__.py new file mode 100644 index 000000000..302de848b --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/attachments/__init__.py @@ -0,0 +1,12 @@ +"""UiPath Attachment Models. + +This module contains models related to UiPath Attachments service. +""" + +from .attachments import Attachment, AttachmentMode, BlobFileAccessInfo + +__all__ = [ + "Attachment", + "AttachmentMode", + "BlobFileAccessInfo", +] diff --git a/packages/uipath-platform/src/uipath/platform/attachments/attachments.py b/packages/uipath-platform/src/uipath/platform/attachments/attachments.py new file mode 100644 index 000000000..e91c7eff4 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/attachments/attachments.py @@ -0,0 +1,44 @@ +"""Module defining the attachment model for attachments.""" + +import uuid +from dataclasses import dataclass +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class AttachmentMode(str, Enum): + """Mode of attachment open.""" + + READ = "read" + WRITE = "write" + + +class Attachment(BaseModel): + """Model representing an attachment. Id 'None' is used for uploads.""" + + id: uuid.UUID = Field(..., alias="ID") + full_name: str = Field(..., alias="FullName") + mime_type: str = Field(..., alias="MimeType") + metadata: Optional[dict[str, Any]] = Field(None, alias="Metadata") + model_config = { + "title": "UiPathAttachment", + "validate_by_name": True, + "validate_by_alias": True, + } + + +@dataclass +class BlobFileAccessInfo: + """Information about blob file access for an attachment. + + Attributes: + id: The unique identifier (UUID) of the attachment. + uri: The blob storage URI for accessing the file. + name: The name of the attachment file. + """ + + id: uuid.UUID + uri: str + name: str diff --git a/packages/uipath-platform/src/uipath/platform/automation_tracker/__init__.py b/packages/uipath-platform/src/uipath/platform/automation_tracker/__init__.py new file mode 100644 index 000000000..a9bfbdb72 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/automation_tracker/__init__.py @@ -0,0 +1,21 @@ +"""UiPath Automation Tracker (BTS) Models. + +This module contains models and service for tracking business transactions +and operations via the Business Transaction Service (BTS). +""" + +from ._automation_tracker_service import AutomationTrackerService +from .automation_tracker import ( + OperationPayload, + OperationStatus, + TransactionPayload, + TransactionStatus, +) + +__all__ = [ + "AutomationTrackerService", + "OperationPayload", + "OperationStatus", + "TransactionPayload", + "TransactionStatus", +] diff --git a/packages/uipath-platform/src/uipath/platform/automation_tracker/_automation_tracker_service.py b/packages/uipath-platform/src/uipath/platform/automation_tracker/_automation_tracker_service.py new file mode 100644 index 000000000..9a0537582 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/automation_tracker/_automation_tracker_service.py @@ -0,0 +1,332 @@ +"""Automation Tracker (BTS) service for UiPath Platform. + +Provides HTTP client methods for tracking business transactions +and operations via the Business Transaction Service, used for Process Mining. +All errors are logged but never raised, ensuring BTS failures +cannot break agent execution. +""" + +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from uipath.core import traced + +from ..common import BaseService, UiPathApiConfig, UiPathExecutionContext +from ..common._config import UiPathConfig +from ..common._models import Endpoint, RequestSpec +from .automation_tracker import ( + OperationPayload, + OperationStatus, + TransactionPayload, + TransactionStatus, +) + + +class AutomationTrackerService(BaseService): + """Service for tracking business transactions and operations via BTS. + + This service provides methods to start/end transactions and operations + for Process Mining tracking. All errors are logged but never raised, + ensuring BTS failures cannot break agent execution. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._organization_id = UiPathConfig.organization_id or "" + self._tenant_id = UiPathConfig.tenant_id or "" + + def _send(self, endpoint: str, payload_dict: Dict[str, Any]) -> None: + """Send a POST request to BTS, logging but never raising errors.""" + spec = RequestSpec( + method="POST", + endpoint=Endpoint(f"/automationtracker_/{endpoint}"), + json=payload_dict, + ) + try: + self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + ) + except Exception: + self._logger.error( + "Failed to send request to BTS endpoint %s", + endpoint, + exc_info=True, + ) + + async def _send_async(self, endpoint: str, payload_dict: Dict[str, Any]) -> None: + """Send an async POST request to BTS, logging but never raising errors.""" + spec = RequestSpec( + method="POST", + endpoint=Endpoint(f"/automationtracker_/{endpoint}"), + json=payload_dict, + ) + try: + await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + ) + except Exception: + self._logger.error( + "Failed to send request to BTS endpoint %s", + endpoint, + exc_info=True, + ) + + # ── Transaction methods ────────────────────────────────────────── + + @traced(name="automation_tracker_start_transaction", run_type="uipath") + def start_transaction( + self, + *, + transaction_id: str, + name: str, + reference: str, + fingerprint: str, + status: TransactionStatus = TransactionStatus.UNKNOWN, + result: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + timestamp: Optional[datetime] = None, + ) -> None: + """Start tracking a business transaction.""" + payload = TransactionPayload( + organization_id=self._organization_id, + tenant_id=self._tenant_id, + transaction_id=transaction_id, + name=name, + reference=reference, + timestamp=timestamp or datetime.now(timezone.utc), + fingerprint=fingerprint, + result=result, + status=status.value, + attributes=attributes or {}, + ) + self._send( + "track/transaction/start", payload.model_dump(by_alias=True, mode="json") + ) + + @traced(name="automation_tracker_start_transaction", run_type="uipath") + async def start_transaction_async( + self, + *, + transaction_id: str, + name: str, + reference: str, + fingerprint: str, + status: TransactionStatus = TransactionStatus.UNKNOWN, + result: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + timestamp: Optional[datetime] = None, + ) -> None: + """Start tracking a business transaction (async).""" + payload = TransactionPayload( + organization_id=self._organization_id, + tenant_id=self._tenant_id, + transaction_id=transaction_id, + name=name, + reference=reference, + timestamp=timestamp or datetime.now(timezone.utc), + fingerprint=fingerprint, + result=result, + status=status.value, + attributes=attributes or {}, + ) + await self._send_async( + "track/transaction/start", payload.model_dump(by_alias=True, mode="json") + ) + + @traced(name="automation_tracker_end_transaction", run_type="uipath") + def end_transaction( + self, + *, + transaction_id: str, + name: str, + reference: str, + fingerprint: str, + status: TransactionStatus = TransactionStatus.UNKNOWN, + result: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + timestamp: Optional[datetime] = None, + ) -> None: + """End tracking a business transaction.""" + payload = TransactionPayload( + organization_id=self._organization_id, + tenant_id=self._tenant_id, + transaction_id=transaction_id, + name=name, + reference=reference, + timestamp=timestamp or datetime.now(timezone.utc), + fingerprint=fingerprint, + result=result, + status=status.value, + attributes=attributes or {}, + ) + self._send( + "track/transaction/end", payload.model_dump(by_alias=True, mode="json") + ) + + @traced(name="automation_tracker_end_transaction", run_type="uipath") + async def end_transaction_async( + self, + *, + transaction_id: str, + name: str, + reference: str, + fingerprint: str, + status: TransactionStatus = TransactionStatus.UNKNOWN, + result: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + timestamp: Optional[datetime] = None, + ) -> None: + """End tracking a business transaction (async).""" + payload = TransactionPayload( + organization_id=self._organization_id, + tenant_id=self._tenant_id, + transaction_id=transaction_id, + name=name, + reference=reference, + timestamp=timestamp or datetime.now(timezone.utc), + fingerprint=fingerprint, + result=result, + status=status.value, + attributes=attributes or {}, + ) + await self._send_async( + "track/transaction/end", payload.model_dump(by_alias=True, mode="json") + ) + + # ── Operation methods ──────────────────────────────────────────── + + @traced(name="automation_tracker_start_operation", run_type="uipath") + def start_operation( + self, + *, + transaction_id: str, + operation_id: str, + name: str, + fingerprint: str, + parent_operation: Optional[str] = None, + status: OperationStatus = OperationStatus.UNKNOWN, + result: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + timestamp: Optional[datetime] = None, + ) -> None: + """Start tracking an operation within a transaction.""" + payload = OperationPayload( + organization_id=self._organization_id, + tenant_id=self._tenant_id, + transaction_id=transaction_id, + operation_id=operation_id, + parent_operation=parent_operation, + name=name, + timestamp=timestamp or datetime.now(timezone.utc), + status=status.value, + attributes=attributes or {}, + result=result, + fingerprint=fingerprint, + ) + self._send( + "track/operation/start", payload.model_dump(by_alias=True, mode="json") + ) + + @traced(name="automation_tracker_start_operation", run_type="uipath") + async def start_operation_async( + self, + *, + transaction_id: str, + operation_id: str, + name: str, + fingerprint: str, + parent_operation: Optional[str] = None, + status: OperationStatus = OperationStatus.UNKNOWN, + result: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + timestamp: Optional[datetime] = None, + ) -> None: + """Start tracking an operation within a transaction (async).""" + payload = OperationPayload( + organization_id=self._organization_id, + tenant_id=self._tenant_id, + transaction_id=transaction_id, + operation_id=operation_id, + parent_operation=parent_operation, + name=name, + timestamp=timestamp or datetime.now(timezone.utc), + status=status.value, + attributes=attributes or {}, + result=result, + fingerprint=fingerprint, + ) + await self._send_async( + "track/operation/start", payload.model_dump(by_alias=True, mode="json") + ) + + @traced(name="automation_tracker_end_operation", run_type="uipath") + def end_operation( + self, + *, + transaction_id: str, + operation_id: str, + name: str, + fingerprint: str, + parent_operation: Optional[str] = None, + status: OperationStatus = OperationStatus.UNKNOWN, + result: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + timestamp: Optional[datetime] = None, + ) -> None: + """End tracking an operation within a transaction.""" + payload = OperationPayload( + organization_id=self._organization_id, + tenant_id=self._tenant_id, + transaction_id=transaction_id, + operation_id=operation_id, + parent_operation=parent_operation, + name=name, + timestamp=timestamp or datetime.now(timezone.utc), + status=status.value, + attributes=attributes or {}, + result=result, + fingerprint=fingerprint, + ) + self._send( + "track/operation/end", payload.model_dump(by_alias=True, mode="json") + ) + + @traced(name="automation_tracker_end_operation", run_type="uipath") + async def end_operation_async( + self, + *, + transaction_id: str, + operation_id: str, + name: str, + fingerprint: str, + parent_operation: Optional[str] = None, + status: OperationStatus = OperationStatus.UNKNOWN, + result: Optional[str] = None, + attributes: Optional[Dict[str, str]] = None, + timestamp: Optional[datetime] = None, + ) -> None: + """End tracking an operation within a transaction (async).""" + payload = OperationPayload( + organization_id=self._organization_id, + tenant_id=self._tenant_id, + transaction_id=transaction_id, + operation_id=operation_id, + parent_operation=parent_operation, + name=name, + timestamp=timestamp or datetime.now(timezone.utc), + status=status.value, + attributes=attributes or {}, + result=result, + fingerprint=fingerprint, + ) + await self._send_async( + "track/operation/end", payload.model_dump(by_alias=True, mode="json") + ) diff --git a/packages/uipath-platform/src/uipath/platform/automation_tracker/automation_tracker.py b/packages/uipath-platform/src/uipath/platform/automation_tracker/automation_tracker.py new file mode 100644 index 000000000..918fa3393 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/automation_tracker/automation_tracker.py @@ -0,0 +1,64 @@ +"""Automation Tracker (BTS) models for UiPath Platform. + +Models for tracking business transactions and operations +via the Business Transaction Service, used for Process Mining. +""" + +from datetime import datetime +from enum import Enum +from typing import Dict, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class TransactionStatus(str, Enum): + """Status of a BTS transaction.""" + + UNKNOWN = "Unknown" + SUCCESSFUL = "Successful" + FAILED = "Failed" + + +class OperationStatus(str, Enum): + """Status of a BTS operation.""" + + UNKNOWN = "Unknown" + SUCCESSFUL = "Successful" + FAILED = "Failed" + CANCELLED = "Cancelled" + ABANDONED = "Abandoned" + + +class TransactionPayload(BaseModel): + """Wire-format payload for BTS transaction start/end endpoints.""" + + model_config = ConfigDict(populate_by_name=True) + + organization_id: str = Field(alias="organizationId") + tenant_id: str = Field(alias="tenantId") + transaction_id: str = Field(alias="transactionId") + name: str + reference: str + timestamp: datetime + fingerprint: str + result: Optional[str] = None + status: str + attributes: Dict[str, str] = Field(default_factory=dict) + + +class OperationPayload(BaseModel): + """Wire-format payload for BTS operation start/end endpoints.""" + + model_config = ConfigDict(populate_by_name=True) + + organization_id: str = Field(alias="organizationId") + tenant_id: str = Field(alias="tenantId") + transaction_id: str = Field(alias="transactionId") + operation_id: str = Field(alias="operationId") + parent_operation: Optional[str] = Field(default=None, alias="parentOperation") + name: str + timestamp: datetime + status: str + attributes: Dict[str, str] = Field(default_factory=dict) + result: Optional[str] = None + fingerprint: str diff --git a/packages/uipath-platform/src/uipath/platform/chat/__init__.py b/packages/uipath-platform/src/uipath/platform/chat/__init__.py new file mode 100644 index 000000000..e192b047e --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/chat/__init__.py @@ -0,0 +1,67 @@ +"""UiPath Chat Services. + +This module provides services for chat-related functionality including: +- LLM Gateway services for chat completions and embeddings +- Conversations service for autopilot conversations +""" + +from ._conversations_service import ConversationsService +from ._llm_gateway_service import ( + DEFAULT_REQUESTING_FEATURE, + DEFAULT_REQUESTING_PRODUCT, + ChatModels, + EmbeddingModels, + UiPathLlmChatService, + UiPathOpenAIService, +) +from .llm_gateway import ( + AutoToolChoice, + ChatCompletion, + ChatCompletionChoice, + ChatCompletionUsage, + ChatMessage, + EmbeddingItem, + EmbeddingUsage, + RequiredToolChoice, + SpecificToolChoice, + TextEmbedding, + ToolCall, + ToolChoice, + ToolDefinition, + ToolFunctionDefinition, + ToolParametersDefinition, + ToolPropertyDefinition, +) +from .llm_throttle import get_llm_semaphore, set_llm_concurrency + +__all__ = [ + # Conversations Service + "ConversationsService", + # LLM Gateway Services + "ChatModels", + "EmbeddingModels", + "DEFAULT_REQUESTING_PRODUCT", + "DEFAULT_REQUESTING_FEATURE", + "UiPathLlmChatService", + "UiPathOpenAIService", + # LLM Throttling + "get_llm_semaphore", + "set_llm_concurrency", + # LLM Gateway Models + "ToolPropertyDefinition", + "ToolParametersDefinition", + "ToolFunctionDefinition", + "ToolDefinition", + "AutoToolChoice", + "RequiredToolChoice", + "SpecificToolChoice", + "ChatMessage", + "ChatCompletionChoice", + "ChatCompletionUsage", + "ChatCompletion", + "EmbeddingItem", + "EmbeddingUsage", + "TextEmbedding", + "ToolChoice", + "ToolCall", +] diff --git a/packages/uipath-platform/src/uipath/platform/chat/_conversations_service.py b/packages/uipath-platform/src/uipath/platform/chat/_conversations_service.py new file mode 100644 index 000000000..dcf501739 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/chat/_conversations_service.py @@ -0,0 +1,52 @@ +from uipath.core.chat import UiPathConversationMessage +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec + + +class ConversationsService(BaseService): + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + @traced(name="retrieve_message", run_type="uipath") + def retrieve_message( + self, conversation_id: str, exchange_id: str, message_id: str + ) -> UiPathConversationMessage: + retrieve_message_spec = self._retrieve_message_spec( + conversation_id, exchange_id, message_id + ) + + response = self.request( + retrieve_message_spec.method, retrieve_message_spec.endpoint + ) + + return UiPathConversationMessage.model_validate(response.json()) + + @traced(name="retrieve_message", run_type="uipath") + async def retrieve_message_async( + self, conversation_id: str, exchange_id: str, message_id: str + ) -> UiPathConversationMessage: + retrieve_message_spec = self._retrieve_message_spec( + conversation_id, exchange_id, message_id + ) + + response = await self.request_async( + retrieve_message_spec.method, retrieve_message_spec.endpoint + ) + + return UiPathConversationMessage.model_validate(response.json()) + + def _retrieve_message_spec( + self, conversation_id: str, exchange_id: str, message_id: str + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"/autopilotforeveryone_/api/v1/conversation/{conversation_id}/exchange/{exchange_id}/message/{message_id}" + ), + ) diff --git a/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py new file mode 100644 index 000000000..d7c093d0d --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/chat/_llm_gateway_service.py @@ -0,0 +1,689 @@ +"""UiPath LLM Gateway Services. + +This module provides services for interacting with UiPath's LLM (Large Language Model) Gateway, +offering both OpenAI-compatible and normalized API interfaces for chat completions and embeddings. + +The module includes: +- UiPathOpenAIService: OpenAI-compatible API for chat completions and embeddings +- UiPathLlmChatService: UiPath's normalized API with advanced features like tool calling +- ChatModels: Constants for available chat models +- EmbeddingModels: Constants for available embedding models + +Classes: + ChatModels: Container for supported chat model identifiers + EmbeddingModels: Container for supported embedding model identifiers + UiPathOpenAIService: Service using OpenAI-compatible API format + UiPathLlmChatService: Service using UiPath's normalized API format +""" + +from typing import Any, Optional + +from opentelemetry import trace +from pydantic import BaseModel +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._endpoints_manager import EndpointManager +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint +from .llm_gateway import ( + ChatCompletion, + SpecificToolChoice, + TextEmbedding, + ToolChoice, + ToolDefinition, +) +from .llm_throttle import get_llm_semaphore + +# Common constants +API_VERSION = "2024-10-21" # Standard API version for OpenAI-compatible endpoints +NORMALIZED_API_VERSION = ( + "2024-08-01-preview" # API version for UiPath's normalized endpoints +) + +DEFAULT_REQUESTING_PRODUCT = "uipath-python-sdk" +DEFAULT_REQUESTING_FEATURE = "llm-call" + + +def _build_llm_headers( + requesting_product: str = DEFAULT_REQUESTING_PRODUCT, + requesting_feature: str = DEFAULT_REQUESTING_FEATURE, + agenthub_config: Optional[str] = None, + action_id: Optional[str] = None, +) -> dict[str, str]: + headers: dict[str, str] = { + "X-UIPATH-STREAMING-ENABLED": "false", + "X-UiPath-LlmGateway-RequestingProduct": requesting_product, + "X-UiPath-LlmGateway-RequestingFeature": requesting_feature, + } + if agenthub_config: + headers["X-UiPath-AgentHub-Config"] = agenthub_config + if action_id: + headers["X-UiPath-LlmGateway-ActionId"] = action_id + return headers + + +class ChatModels(object): + """Available chat models for LLM Gateway services. + + This class provides constants for the supported chat models that can be used + with both UiPathOpenAIService and UiPathLlmChatService. + """ + + gpt_4 = "gpt-4" + gpt_4_1106_Preview = "gpt-4-1106-Preview" + gpt_4_32k = "gpt-4-32k" + gpt_4_turbo_2024_04_09 = "gpt-4-turbo-2024-04-09" + gpt_4_vision_preview = "gpt-4-vision-preview" + gpt_4o_2024_05_13 = "gpt-4o-2024-05-13" + gpt_4o_2024_08_06 = "gpt-4o-2024-08-06" + gpt_4o_mini_2024_07_18 = "gpt-4o-mini-2024-07-18" + gpt_4_1_mini_2025_04_14 = "gpt-4.1-mini-2025-04-14" + o3_mini = "o3-mini-2025-01-31" + + +class EmbeddingModels(object): + """Available embedding models for LLM Gateway services. + + This class provides constants for the supported embedding models that can be used + with the embeddings functionality. + """ + + text_embedding_3_large = "text-embedding-3-large" + text_embedding_ada_002 = "text-embedding-ada-002" + + +def _cleanup_schema(schema: dict[str, Any]) -> dict[str, Any]: + """Clean up a JSON schema for use with LLM Gateway. + + This function converts a JSON schema to a format that's + compatible with the LLM Gateway's JSON schema requirements by removing + titles and other metadata that might cause validation issues. + + Args: + schema (dict[str, Any]): an input JSON schema. + + Returns: + dict: A cleaned JSON schema dictionary suitable for LLM Gateway response_format. + + Examples: + ```python + from pydantic import BaseModel + + class Country(BaseModel): + name: str + capital: str + languages: list[str] + + schema = _cleanup_schema(Country.model_json_schema()) + # Returns a clean schema without titles and unnecessary metadata + ``` + """ + + def clean_type(type_def): + """Clean property definitions by removing titles and cleaning nested items. Additionally, `additionalProperties` is ensured on all objects.""" + cleaned_type = {} + for key, value in type_def.items(): + if key == "title" or key == "properties": + continue + else: + cleaned_type[key] = value + if type_def.get("type") == "object" and "additionalProperties" not in type_def: + cleaned_type["additionalProperties"] = False + + if "properties" in type_def: + properties = type_def.get("properties", {}) + for key, value in properties.items(): + properties[key] = clean_type(value) + cleaned_type["properties"] = properties + + if type_def.get("type") == "object": + cleaned_type["required"] = list(cleaned_type.get("properties", {}).keys()) + + if "$defs" in type_def: + cleaned_defs = {} + for key, value in type_def["$defs"].items(): + cleaned_defs[key] = clean_type(value) + cleaned_type["$defs"] = cleaned_defs + return cleaned_type + + # Create clean schema + clean_schema = clean_type(schema) + return clean_schema + + +class UiPathOpenAIService(BaseService): + """Service for calling UiPath's LLM Gateway using OpenAI-compatible API. + + This service provides access to Large Language Model capabilities through UiPath's + LLM Gateway, including chat completions and text embeddings. It uses the OpenAI-compatible + API format and is suitable for applications that need direct OpenAI API compatibility. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + *, + requesting_product: str = DEFAULT_REQUESTING_PRODUCT, + requesting_feature: str = DEFAULT_REQUESTING_FEATURE, + agenthub_config: Optional[str] = None, + action_id: Optional[str] = None, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._llm_headers = _build_llm_headers( + requesting_product, requesting_feature, agenthub_config, action_id + ) + + @traced(name="llm_embeddings", run_type="uipath") + async def embeddings( + self, + input: str, + embedding_model: str = EmbeddingModels.text_embedding_ada_002, + openai_api_version: str = API_VERSION, + ): + """Generate text embeddings using UiPath's LLM Gateway service. + + This method converts input text into dense vector representations that can be used + for semantic search, similarity calculations, and other NLP tasks. + + Args: + input (str): The input text to embed. Can be a single sentence, paragraph, + or document that you want to convert to embeddings. + embedding_model (str, optional): The embedding model to use. + Defaults to EmbeddingModels.text_embedding_ada_002. + Available models are defined in the EmbeddingModels class. + openai_api_version (str, optional): The OpenAI API version to use. + Defaults to API_VERSION. + + Returns: + TextEmbedding: The embedding response containing the vector representation + of the input text along with metadata. + + Examples: + ```python + # Basic embedding + embedding = await service.embeddings("Hello, world!") + + # Using a specific model + embedding = await service.embeddings( + "This is a longer text to embed", + embedding_model=EmbeddingModels.text_embedding_3_large + ) + ``` + """ + endpoint = EndpointManager.get_embeddings_endpoint().format( + model=embedding_model, api_version=openai_api_version + ) + endpoint = Endpoint("/" + endpoint) + + async with get_llm_semaphore(): + response = await self.request_async( + "POST", + endpoint, + json={"input": input}, + params={"api-version": API_VERSION}, + headers=self._llm_headers, + ) + + return TextEmbedding.model_validate(response.json()) + + @traced(name="LLM call", run_type="uipath") + async def chat_completions( + self, + messages: list[dict[str, str]], + model: str = ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens: int = 4096, + temperature: float = 0, + response_format: dict[str, Any] | type[BaseModel] | None = None, + api_version: str = API_VERSION, + ): + """Generate chat completions using UiPath's LLM Gateway service. + + This method provides conversational AI capabilities by sending a series of messages + to a language model and receiving a generated response. It supports multi-turn + conversations and various OpenAI-compatible models. + + Args: + messages (List[Dict[str, str]]): List of message dictionaries with 'role' and 'content' keys. + The supported roles are 'system', 'user', and 'assistant'. System messages set + the behavior/context, user messages are from the human, and assistant messages + are from the AI. + model (str, optional): The model to use for chat completion. + Defaults to ChatModels.gpt_4_1_mini_2025_04_14. + Available models are defined in the ChatModels class. + max_tokens (int, optional): Maximum number of tokens to generate in the response. + Defaults to 4096. Higher values allow longer responses. + temperature (float, optional): Temperature for sampling, between 0 and 1. + Lower values (closer to 0) make output more deterministic and focused, + higher values make it more creative and random. Defaults to 0. + response_format (Optional[Union[Dict[str, Any], type[BaseModel]]], optional): + An object specifying the format that the model must output. Can be either: + - A dictionary with response format configuration (traditional format) + - A Pydantic BaseModel class (automatically converted to JSON schema) + Used to enable JSON mode or other structured outputs. Defaults to None. + api_version (str, optional): The API version to use. Defaults to API_VERSION. + + Returns: + ChatCompletion: The chat completion response containing the generated message, + usage statistics, and other metadata. + + Examples: + ```python + # Simple conversation + messages = [ + {"role": "system", "content": "You are a helpful Python programming assistant."}, + {"role": "user", "content": "How do I read a file in Python?"} + ] + response = await service.chat_completions(messages) + + # Multi-turn conversation with more tokens + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is machine learning?"}, + {"role": "assistant", "content": "Machine learning is a subset of AI..."}, + {"role": "user", "content": "Can you give me a practical example?"} + ] + response = await service.chat_completions( + messages, + max_tokens=200, + temperature=0.3 + ) + + # Using Pydantic model for structured response + from pydantic import BaseModel + + class Country(BaseModel): + name: str + capital: str + languages: list[str] + + response = await service.chat_completions( + messages=[ + {"role": "system", "content": "You are a helpful assistant. Respond with structured JSON."}, + {"role": "user", "content": "Tell me about Canada."} + ], + response_format=Country, # Pass BaseModel directly + max_tokens=1000 + ) + ``` + + Note: + The conversation history can be included to provide context to the model. + Each message should have both 'role' and 'content' keys. + When using a Pydantic BaseModel as response_format, it will be automatically + converted to the appropriate JSON schema format for the LLM Gateway. + """ + span = trace.get_current_span() + span.set_attribute("model", model) + span.set_attribute("uipath.custom_instrumentation", True) + + endpoint = EndpointManager.get_passthrough_endpoint().format( + model=model, api_version=api_version + ) + endpoint = Endpoint("/" + endpoint) + + request_body = { + "messages": messages, + "max_tokens": max_tokens, + "temperature": temperature, + } + + # Handle response_format - convert BaseModel to schema if needed + if response_format: + if isinstance(response_format, type) and issubclass( + response_format, BaseModel + ): + # Convert Pydantic model to JSON schema format + cleaned_schema = _cleanup_schema(response_format.model_json_schema()) + request_body["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": response_format.__name__.lower(), + "strict": True, + "schema": cleaned_schema, + }, + } + else: + # Use provided dictionary format directly + request_body["response_format"] = response_format + + async with get_llm_semaphore(): + response = await self.request_async( + "POST", + endpoint, + json=request_body, + params={"api-version": API_VERSION}, + headers=self._llm_headers, + ) + + return ChatCompletion.model_validate(response.json()) + + +class UiPathLlmChatService(BaseService): + """Service for calling UiPath's normalized LLM Gateway API. + + This service provides access to Large Language Model capabilities through UiPath's + normalized LLM Gateway API. Unlike the OpenAI-compatible service, this service uses + UiPath's standardized API format and supports advanced features like tool calling, + function calling, and more sophisticated conversation control. + + The normalized API provides a consistent interface across different underlying model + providers and includes enhanced features for enterprise use cases. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + *, + requesting_product: str = DEFAULT_REQUESTING_PRODUCT, + requesting_feature: str = DEFAULT_REQUESTING_FEATURE, + agenthub_config: Optional[str] = None, + action_id: Optional[str] = None, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._llm_headers = _build_llm_headers( + requesting_product, requesting_feature, agenthub_config, action_id + ) + + @traced(name="LLM call", run_type="uipath") + async def chat_completions( + self, + messages: list[dict[str, str]] | list[tuple[str, str]], + model: str = ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens: int = 4096, + temperature: float = 0, + n: int = 1, + frequency_penalty: float = 0, + presence_penalty: float = 0, + top_p: float | None = 1, + top_k: int | None = None, + tools: list[ToolDefinition] | None = None, + tool_choice: ToolChoice | None = None, + response_format: dict[str, Any] | type[BaseModel] | None = None, + api_version: str = NORMALIZED_API_VERSION, + ): + """Generate chat completions using UiPath's normalized LLM Gateway API. + + This method provides advanced conversational AI capabilities with support for + tool calling, function calling, and sophisticated conversation control parameters. + It uses UiPath's normalized API format for consistent behavior across different + model providers. + + Args: + messages (List[Dict[str, str]]): List of message dictionaries with 'role' and 'content' keys. + The supported roles are 'system', 'user', and 'assistant'. System messages set + the behavior/context, user messages are from the human, and assistant messages + are from the AI. + model (str, optional): The model to use for chat completion. + Defaults to ChatModels.gpt_4_1_mini_2025_04_14. + Available models are defined in the ChatModels class. + max_tokens (int, optional): Maximum number of tokens to generate in the response. + Defaults to 4096. Higher values allow longer responses. + temperature (float, optional): Temperature for sampling, between 0 and 1. + Lower values (closer to 0) make output more deterministic and focused, + higher values make it more creative and random. Defaults to 0. + n (int, optional): Number of chat completion choices to generate for each input. + Defaults to 1. Higher values generate multiple alternative responses. + frequency_penalty (float, optional): Penalty for token frequency between -2.0 and 2.0. + Positive values reduce repetition of frequent tokens. Defaults to 0. + presence_penalty (float, optional): Penalty for token presence between -2.0 and 2.0. + Positive values encourage discussion of new topics. Defaults to 0. + top_p (float, optional): Nucleus sampling parameter between 0 and 1. + Controls diversity by considering only the top p probability mass. Defaults to 1. + top_k (int, optional): Nucleus sampling parameter. + Controls diversity by considering only the top k most probable tokens. Defaults to None. + tools (Optional[List[ToolDefinition]], optional): List of tool definitions that the + model can call. Tools enable the model to perform actions or retrieve information + beyond text generation. Defaults to None. + tool_choice (Optional[ToolChoice], optional): Controls which tools the model can call. + Can be "auto" (model decides), "none" (no tools), or a specific tool choice. + Defaults to None. + response_format (Optional[Union[Dict[str, Any], type[BaseModel]]], optional): + An object specifying the format that the model must output. Can be either: + - A dictionary with response format configuration (traditional format) + - A Pydantic BaseModel class (automatically converted to JSON schema) + Used to enable JSON mode or other structured outputs. Defaults to None. + api_version (str, optional): The normalized API version to use. + Defaults to NORMALIZED_API_VERSION. + + Returns: + ChatCompletion: The chat completion response containing the generated message(s), + tool calls (if any), usage statistics, and other metadata. + + Examples: + ```python + # Basic conversation + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is the weather like today?"} + ] + response = await service.chat_completions(messages) + + # Conversation with tool calling + tools = [ + ToolDefinition( + function=FunctionDefinition( + name="get_weather", + description="Get current weather for a location", + parameters=ParametersDefinition( + type="object", + properties={ + "location": PropertyDefinition( + type="string", + description="City name" + ) + }, + required=["location"] + ) + ) + ) + ] + response = await service.chat_completions( + messages, + tools=tools, + tool_choice="auto", + max_tokens=500 + ) + + # Advanced parameters for creative writing + response = await service.chat_completions( + messages, + temperature=0.8, + top_p=0.9, + frequency_penalty=0.3, + presence_penalty=0.2, + n=3 # Generate 3 alternative responses + ) + + # Using Pydantic model for structured response + from pydantic import BaseModel + + class Country(BaseModel): + name: str + capital: str + languages: list[str] + + response = await service.chat_completions( + messages=[ + {"role": "system", "content": "You are a helpful assistant. Respond with structured JSON."}, + {"role": "user", "content": "Tell me about Canada."} + ], + response_format=Country, # Pass BaseModel directly + max_tokens=1000 + ) + ) + ``` + + Note: + This service uses UiPath's normalized API format which provides consistent + behavior across different underlying model providers and enhanced enterprise features. + """ + span = trace.get_current_span() + span.set_attribute("model", model) + span.set_attribute("uipath.custom_instrumentation", True) + + converted_messages = [] + + for message in messages: + if isinstance(message, tuple) and len(message) == 2: + role, content = message + converted_messages.append({"role": role, "content": content}) + elif isinstance(message, dict): + converted_messages.append(message) + else: + raise ValueError( + f"Invalid message format: {message}. Expected tuple (role, content) or dict with 'role' and 'content' keys." + ) + + endpoint = EndpointManager.get_normalized_endpoint().format( + model=model, api_version=api_version + ) + endpoint = Endpoint("/" + endpoint) + + # Build request body - Claude models don't support some OpenAI-specific parameters + is_claude_model = "claude" in model.lower() + + request_body = { + "messages": converted_messages, + "max_tokens": max_tokens, + "temperature": temperature, + } + + # Only add OpenAI-specific parameters for non-Claude models + if not is_claude_model: + request_body["n"] = n + request_body["frequency_penalty"] = frequency_penalty + request_body["presence_penalty"] = presence_penalty + if top_p is not None: + request_body["top_p"] = top_p + + if top_k is not None: + request_body["top_k"] = top_k + + # Handle response_format - convert BaseModel to schema if needed + if response_format: + if isinstance(response_format, type) and issubclass( + response_format, BaseModel + ): + # Convert Pydantic model to JSON schema format + cleaned_schema = _cleanup_schema(response_format.model_json_schema()) + request_body["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": response_format.__name__.lower(), + "strict": True, + "schema": cleaned_schema, + }, + } + else: + # Use provided dictionary format directly + request_body["response_format"] = response_format + + # Add tools if provided - convert to UiPath format + if tools: + request_body["tools"] = [ + self._convert_tool_to_uipath_format(tool) for tool in tools + ] + + # Handle tool_choice + if tool_choice: + if isinstance(tool_choice, str): + request_body["tool_choice"] = tool_choice + elif isinstance(tool_choice, SpecificToolChoice): + request_body["tool_choice"] = {"type": "tool", "name": tool_choice.name} + else: + request_body["tool_choice"] = tool_choice.model_dump() + + headers = { + **self._llm_headers, + "X-UiPath-LlmGateway-NormalizedApi-ModelName": model, + "X-UiPath-LLMGateway-AllowFull4xxResponse": "true", + } + + # Log the complete request for debugging + import json as json_module + import logging + + logger = logging.getLogger(__name__) + + logger.info("=" * 80) + logger.info("📤 LLM Gateway Normalized API Request") + logger.info("=" * 80) + logger.info(f"Model: {model}") + logger.info(f"Endpoint: {endpoint}") + logger.info(f"API Version: {NORMALIZED_API_VERSION}") + logger.info(f"Is Claude Model: {is_claude_model}") + logger.info("-" * 80) + logger.info("Headers:") + for key, value in headers.items(): + logger.info(f" {key}: {value}") + logger.info("-" * 80) + logger.info("Request Body:") + # Create a copy for logging with tools truncated for readability + log_body: dict[str, Any] = request_body.copy() + tools_list = log_body.get("tools") + if tools_list and isinstance(tools_list, list): + log_body["tools"] = f"[{len(tools_list)} tool(s)]" + messages_list = log_body.get("messages") + if messages_list and isinstance(messages_list, list): + log_body["messages"] = [ + { + **msg, + "content": msg["content"][:100] + "..." + if len(msg.get("content", "")) > 100 + else msg["content"], + } + for msg in messages_list + ] + logger.info(json_module.dumps(log_body, indent=2)) + logger.info("=" * 80) + + async with get_llm_semaphore(): + response = await self.request_async( + "POST", + endpoint, + json=request_body, + params={"api-version": NORMALIZED_API_VERSION}, + headers=headers, + ) + + logger.info(f"✅ Response received with status: {response.status_code}") + return ChatCompletion.model_validate(response.json()) + + def _convert_tool_to_uipath_format(self, tool: ToolDefinition) -> dict[str, Any]: + """Convert an OpenAI-style tool definition to UiPath API format. + + This internal method transforms tool definitions from the standard OpenAI format + to the format expected by UiPath's normalized LLM Gateway API. + + Args: + tool (ToolDefinition): The tool definition in OpenAI format containing + function name, description, and parameter schema. + + Returns: + Dict[str, Any]: The tool definition converted to UiPath API format + with the appropriate structure and field mappings. + """ + parameters = { + "type": tool.function.parameters.type, + "properties": { + name: { + "type": prop.type, + **({"description": prop.description} if prop.description else {}), + **({"enum": prop.enum} if prop.enum else {}), + } + for name, prop in tool.function.parameters.properties.items() + }, + } + + if tool.function.parameters.required: + parameters["required"] = tool.function.parameters.required + + return { + "name": tool.function.name, + "description": tool.function.description, + "parameters": parameters, + } diff --git a/packages/uipath-platform/src/uipath/platform/chat/llm_gateway.py b/packages/uipath-platform/src/uipath/platform/chat/llm_gateway.py new file mode 100644 index 000000000..0223bd4d3 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/chat/llm_gateway.py @@ -0,0 +1,128 @@ +"""Models for LLM Gateway interactions in the UiPath platform.""" + +from typing import Any, Dict, List, Literal, Optional, Union + +from pydantic import BaseModel + + +class EmbeddingItem(BaseModel): + """Model representing an individual embedding item.""" + + embedding: List[float] + index: int + object: str + + +class EmbeddingUsage(BaseModel): + """Model representing usage statistics for embeddings.""" + + prompt_tokens: int + total_tokens: int + + +class TextEmbedding(BaseModel): + """Model representing a text embedding response.""" + + data: List[EmbeddingItem] + model: str + object: str + usage: EmbeddingUsage + + +class ToolCall(BaseModel): + """Model representing a tool call.""" + + id: str + name: str + arguments: Dict[str, Any] + + +class ToolPropertyDefinition(BaseModel): + """Model representing a tool property definition.""" + + type: str + description: Optional[str] = None + enum: Optional[List[str]] = None + + +class ToolParametersDefinition(BaseModel): + """Model representing tool parameters definition.""" + + type: str = "object" + properties: Dict[str, ToolPropertyDefinition] + required: Optional[List[str]] = None + + +class ToolFunctionDefinition(BaseModel): + """Model representing a tool function definition.""" + + name: str + description: Optional[str] = None + parameters: ToolParametersDefinition + + +class ToolDefinition(BaseModel): + """Model representing a tool definition.""" + + type: Literal["function"] = "function" + function: ToolFunctionDefinition + + +class AutoToolChoice(BaseModel): + """Model representing an automatic tool choice.""" + + type: Literal["auto"] = "auto" + + +class RequiredToolChoice(BaseModel): + """Model representing a required tool choice.""" + + type: Literal["required"] = "required" + + +class SpecificToolChoice(BaseModel): + """Model representing a specific tool choice.""" + + type: Literal["tool"] = "tool" + name: str + + +ToolChoice = Union[ + AutoToolChoice, RequiredToolChoice, SpecificToolChoice, Literal["auto", "none"] +] + + +class ChatMessage(BaseModel): + """Model representing a chat message.""" + + role: str + content: Optional[str] = None + tool_calls: Optional[List[ToolCall]] = None + + +class ChatCompletionChoice(BaseModel): + """Model representing a chat completion choice.""" + + index: int + message: ChatMessage + finish_reason: str + + +class ChatCompletionUsage(BaseModel): + """Model representing usage statistics for chat completions.""" + + prompt_tokens: int + completion_tokens: int + total_tokens: int + cache_read_input_tokens: Optional[int] = None + + +class ChatCompletion(BaseModel): + """Model representing a chat completion response.""" + + id: str + object: str + created: int + model: str + choices: List[ChatCompletionChoice] + usage: ChatCompletionUsage diff --git a/packages/uipath-platform/src/uipath/platform/chat/llm_throttle.py b/packages/uipath-platform/src/uipath/platform/chat/llm_throttle.py new file mode 100644 index 000000000..338ce1b9c --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/chat/llm_throttle.py @@ -0,0 +1,49 @@ +"""LLM request throttling utilities. + +This module provides concurrency control for LLM API requests to prevent +overwhelming the system with simultaneous calls. +""" + +import asyncio + +DEFAULT_LLM_CONCURRENCY = 20 +_llm_concurrency_limit: int = DEFAULT_LLM_CONCURRENCY +_llm_semaphore: asyncio.Semaphore | None = None +_llm_semaphore_loop: asyncio.AbstractEventLoop | None = None + + +def get_llm_semaphore() -> asyncio.Semaphore: + """Get the LLM semaphore, creating with configured limit if not set. + + The semaphore is recreated if called from a different event loop than + the one it was originally created in. This prevents "bound to a different + event loop" errors when using multiple asyncio.run() calls. + """ + global _llm_semaphore, _llm_semaphore_loop + + loop = asyncio.get_running_loop() + + # Recreate semaphore if it doesn't exist or if the event loop changed + if _llm_semaphore is None or _llm_semaphore_loop is not loop: + _llm_semaphore = asyncio.Semaphore(_llm_concurrency_limit) + _llm_semaphore_loop = loop + + return _llm_semaphore + + +def set_llm_concurrency(limit: int) -> None: + """Set the max concurrent LLM requests. Call before making any LLM calls. + + Args: + limit: Maximum number of concurrent LLM requests allowed (must be > 0). + + Raises: + ValueError: If limit is less than 1. + """ + if limit < 1: + raise ValueError("LLM concurrency limit must be at least 1") + + global _llm_concurrency_limit, _llm_semaphore, _llm_semaphore_loop + _llm_concurrency_limit = limit + _llm_semaphore = None + _llm_semaphore_loop = None diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py new file mode 100644 index 000000000..c0d7398ad --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -0,0 +1,104 @@ +"""UiPath Common Models. + +This module contains common models used across multiple services. +""" + +from ._api_client import ApiClient +from ._base_service import BaseService +from ._bindings import ( + ConnectionResourceOverwrite, + GenericResourceOverwrite, + ResourceOverwrite, + ResourceOverwriteParser, + ResourceOverwritesContext, + resource_override, +) +from ._config import UiPathApiConfig, UiPathConfig +from ._endpoints_manager import EndpointManager +from ._execution_context import UiPathExecutionContext +from ._external_application_service import ExternalApplicationService +from ._folder_context import FolderContext, header_folder +from ._models import Endpoint, RequestSpec +from ._service_url_overrides import inject_routing_headers, resolve_service_url +from ._span_utils import UiPathSpan, _SpanUtils +from ._ssl_context import get_httpx_client_kwargs +from ._url import UiPathUrl +from ._user_agent import user_agent_value +from .auth import TokenData +from .dynamic_schema import jsonschema_to_pydantic +from .interrupt_models import ( + CreateBatchTransform, + CreateDeepRag, + CreateEphemeralIndex, + CreateEscalation, + CreateTask, + DocumentExtraction, + DocumentExtractionValidation, + InvokeProcess, + InvokeProcessRaw, + InvokeSystemAgent, + WaitBatchTransform, + WaitDeepRag, + WaitDocumentExtraction, + WaitDocumentExtractionValidation, + WaitEphemeralIndex, + WaitEscalation, + WaitJob, + WaitJobRaw, + WaitSystemAgent, + WaitTask, +) +from .paging import PagedResult + +__all__ = [ + "ApiClient", + "BaseService", + "UiPathApiConfig", + "UiPathExecutionContext", + "ExternalApplicationService", + "FolderContext", + "TokenData", + "UiPathConfig", + "CreateTask", + "CreateEscalation", + "WaitEscalation", + "InvokeProcess", + "InvokeProcessRaw", + "WaitTask", + "WaitJob", + "WaitJobRaw", + "PagedResult", + "CreateDeepRag", + "WaitDeepRag", + "CreateBatchTransform", + "WaitBatchTransform", + "DocumentExtraction", + "WaitDocumentExtraction", + "InvokeSystemAgent", + "WaitSystemAgent", + "CreateEphemeralIndex", + "WaitEphemeralIndex", + "DocumentExtractionValidation", + "WaitDocumentExtractionValidation", + "RequestSpec", + "Endpoint", + "UiPathUrl", + "user_agent_value", + "get_httpx_client_kwargs", + "resource_override", + "header_folder", + "validate_pagination_params", + "EndpointManager", + "jsonschema_to_pydantic", + "ConnectionResourceOverwrite", + "GenericResourceOverwrite", + "ResourceOverwrite", + "ResourceOverwriteParser", + "ResourceOverwritesContext", + "UiPathSpan", + "_SpanUtils", + "resolve_service_url", + "inject_routing_headers", +] + +from .validation import validate_pagination_params diff --git a/packages/uipath-platform/src/uipath/platform/common/_api_client.py b/packages/uipath-platform/src/uipath/platform/common/_api_client.py new file mode 100644 index 000000000..da9b91f4a --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_api_client.py @@ -0,0 +1,59 @@ +from typing import Any, Literal, Union + +from httpx import URL, Response + +from ._base_service import BaseService +from ._config import UiPathApiConfig +from ._execution_context import UiPathExecutionContext +from ._folder_context import FolderContext + + +class ApiClient(FolderContext, BaseService): + """Low-level client for making direct HTTP requests to the UiPath API. + + This class provides a flexible way to interact with the UiPath API when the + higher-level service classes don't provide the needed functionality. It inherits + from both FolderContext and BaseService to provide folder-aware request capabilities + with automatic authentication and retry logic. + """ + + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + def request( + self, + method: str, + url: Union[URL, str], + scoped: Literal["org", "tenant"] = "tenant", + **kwargs: Any, + ) -> Response: + if kwargs.get("include_folder_headers", False): + kwargs["headers"] = { + **kwargs.get("headers", self._client.headers), + **self.folder_headers, + } + + if "include_folder_headers" in kwargs: + del kwargs["include_folder_headers"] + + return super().request(method, url, scoped=scoped, **kwargs) + + async def request_async( + self, + method: str, + url: Union[URL, str], + scoped: Literal["org", "tenant"] = "tenant", + **kwargs: Any, + ) -> Response: + if kwargs.get("include_folder_headers", False): + kwargs["headers"] = { + **kwargs.get("headers", self._client_async.headers), + **self.folder_headers, + } + + if "include_folder_headers" in kwargs: + del kwargs["include_folder_headers"] + + return await super().request_async(method, url, scoped=scoped, **kwargs) diff --git a/packages/uipath-platform/src/uipath/platform/common/_base_service.py b/packages/uipath-platform/src/uipath/platform/common/_base_service.py new file mode 100644 index 000000000..a8db5aee0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_base_service.py @@ -0,0 +1,204 @@ +import inspect +from logging import getLogger +from typing import Any, Literal, Union + +from httpx import ( + URL, + AsyncClient, + Client, + Headers, + HTTPStatusError, + Response, +) +from tenacity import ( + retry, + retry_if_exception, + retry_if_result, + stop_after_attempt, +) + +from ..errors import EnrichedException +from ._config import UiPathApiConfig +from ._execution_context import UiPathExecutionContext +from ._service_url_overrides import inject_routing_headers, resolve_service_url +from ._ssl_context import get_httpx_client_kwargs +from ._url import UiPathUrl +from ._user_agent import user_agent_value +from .constants import HEADER_USER_AGENT +from .retry import ( + MAX_RETRY_ATTEMPTS, + is_retryable_platform_exception, + is_retryable_response, + platform_wait_strategy, +) + + +class BaseService: + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + self._logger = getLogger("uipath") + self._config = config + self._execution_context = execution_context + + self._url = UiPathUrl(self._config.base_url) + + default_client_kwargs = get_httpx_client_kwargs() + + client_kwargs = { + **default_client_kwargs, # SSL, proxy, timeout, redirects + "base_url": self._url.base_url, + "headers": Headers(self.default_headers), + } + + self._client = Client(**client_kwargs) + self._client_async = AsyncClient(**client_kwargs) + + self._logger.debug(f"HEADERS: {self.default_headers}") + + super().__init__() + + @retry( + retry=( + retry_if_exception(is_retryable_platform_exception) + | retry_if_result(is_retryable_response) + ), + wait=platform_wait_strategy, + stop=stop_after_attempt(MAX_RETRY_ATTEMPTS), + reraise=True, + ) + def request( + self, + method: str, + url: Union[URL, str], + *, + scoped: Literal["org", "tenant"] = "tenant", + **kwargs: Any, + ) -> Response: + self._logger.debug(f"Request: {method} {url}") + self._logger.debug(f"HEADERS: {kwargs.get('headers', self._client.headers)}") + + try: + stack = inspect.stack() + + # use the third frame because of the retry decorator + caller_frame = stack[3].frame + function_name = caller_frame.f_code.co_name + + if "self" in caller_frame.f_locals: + module_name = type(caller_frame.f_locals["self"]).__name__ + elif "cls" in caller_frame.f_locals: + module_name = caller_frame.f_locals["cls"].__name__ + else: + module_name = "" + except Exception: + function_name = "" + module_name = "" + + specific_component = ( + f"{module_name}.{function_name}" if module_name and function_name else "" + ) + + kwargs.setdefault("headers", {}) + kwargs["headers"][HEADER_USER_AGENT] = user_agent_value(specific_component) + + override = resolve_service_url(str(url)) + if override: + scoped_url = override + inject_routing_headers(kwargs["headers"]) + else: + scoped_url = self._url.scope_url(str(url), scoped) + + response = self._client.request(method, scoped_url, **kwargs) + + try: + response.raise_for_status() + except HTTPStatusError as e: + # include the http response in the error message + raise EnrichedException(e) from e + + return response + + @retry( + retry=( + retry_if_exception(is_retryable_platform_exception) + | retry_if_result(is_retryable_response) + ), + wait=platform_wait_strategy, + stop=stop_after_attempt(MAX_RETRY_ATTEMPTS), + reraise=True, + ) + async def request_async( + self, + method: str, + url: Union[URL, str], + *, + scoped: Literal["org", "tenant"] = "tenant", + **kwargs: Any, + ) -> Response: + self._logger.debug(f"Request: {method} {url}") + self._logger.debug( + f"HEADERS: {kwargs.get('headers', self._client_async.headers)}" + ) + + kwargs.setdefault("headers", {}) + kwargs["headers"][HEADER_USER_AGENT] = user_agent_value( + self._specific_component + ) + + override = resolve_service_url(str(url)) + if override: + scoped_url = override + inject_routing_headers(kwargs["headers"]) + else: + scoped_url = self._url.scope_url(str(url), scoped) + + response = await self._client_async.request(method, scoped_url, **kwargs) + + try: + response.raise_for_status() + except HTTPStatusError as e: + # include the http response in the error message + raise EnrichedException(e) from e + return response + + @property + def default_headers(self) -> dict[str, str]: + return { + "Accept": "application/json", + **self.auth_headers, + **self.custom_headers, + } + + @property + def auth_headers(self) -> dict[str, str]: + header = f"Bearer {self._config.secret}" + return {"Authorization": header} + + @property + def custom_headers(self) -> dict[str, str]: + return {} + + @property + def _specific_component(self) -> str: + try: + stack = inspect.stack() + + caller_frame = stack[4].frame + function_name = caller_frame.f_code.co_name + + if "self" in caller_frame.f_locals: + module_name = type(caller_frame.f_locals["self"]).__name__ + elif "cls" in caller_frame.f_locals: + module_name = caller_frame.f_locals["cls"].__name__ + else: + module_name = "" + except Exception: + function_name = "" + module_name = "" + + specific_component = ( + f"{module_name}.{function_name}" if module_name and function_name else "" + ) + + return specific_component diff --git a/packages/uipath-platform/src/uipath/platform/common/_bindings.py b/packages/uipath-platform/src/uipath/platform/common/_bindings.py new file mode 100644 index 000000000..01ff732a4 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_bindings.py @@ -0,0 +1,268 @@ +import functools +import inspect +import logging +from abc import ABC, abstractmethod +from contextvars import ContextVar, Token +from typing import ( + Annotated, + Any, + Callable, + Coroutine, + Literal, + Optional, + TypeVar, + Union, +) + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, TypeAdapter + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +class ResourceOverwrite(BaseModel, ABC): + """Abstract base class for resource overwrites. + + Subclasses must implement properties to provide resource and folder identifiers + appropriate for their resource type. + """ + + model_config = ConfigDict(populate_by_name=True) + + @property + @abstractmethod + def resource_identifier(self) -> str: + """The identifier used to reference this resource.""" + pass + + @property + @abstractmethod + def folder_identifier(self) -> str: + """The folder location identifier for this resource.""" + pass + + +class GenericResourceOverwrite(ResourceOverwrite): + resource_type: Literal["process", "index", "app", "asset", "bucket", "mcpServer"] + name: str = Field(alias="name") + folder_path: str = Field(alias="folderPath") + + @property + def resource_identifier(self) -> str: + return self.name + + @property + def folder_identifier(self) -> str: + return self.folder_path + + +class ConnectionResourceOverwrite(ResourceOverwrite): + resource_type: Literal["connection"] + # In eval context, studio web provides "ConnectionId". + connection_id: str = Field( + alias="connectionId", + validation_alias=AliasChoices("connectionId", "ConnectionId"), + ) + folder_key: str = Field(alias="folderKey") + + model_config = ConfigDict( + populate_by_name=True, + extra="ignore", + ) + + @property + def resource_identifier(self) -> str: + return self.connection_id + + @property + def folder_identifier(self) -> str: + return self.folder_key + + +ResourceOverwriteUnion = Annotated[ + Union[GenericResourceOverwrite, ConnectionResourceOverwrite], + Field(discriminator="resource_type"), +] + + +class ResourceOverwriteParser: + """Parser for resource overwrite configurations. + + Handles parsing of resource overwrites from key-value pairs where the key + contains the resource type prefix (e.g., "process.name", "connection.key"). + """ + + _adapter: TypeAdapter[ResourceOverwriteUnion] = TypeAdapter(ResourceOverwriteUnion) + + @classmethod + def parse(cls, key: str, value: dict[str, Any]) -> ResourceOverwrite: + """Parse a resource overwrite from a key-value pair. + + Extracts the resource type from the key prefix and injects it into the value + for discriminated union validation. + + Args: + key: The resource key (e.g., "process.MyProcess", "connection.abc-123") + value: The resource data dictionary + + Returns: + The appropriate ResourceOverwrite subclass instance + """ + resource_type = key.split(".")[0] + value_with_type = {"resource_type": resource_type, **value} + return cls._adapter.validate_python(value_with_type) + + +_resource_overwrites: ContextVar[Optional[dict[str, ResourceOverwrite]]] = ContextVar( + "resource_overwrites", default=None +) + + +class ResourceOverwritesContext: + def __init__( + self, + get_overwrites_callable: Callable[ + [], Coroutine[Any, Any, dict[str, ResourceOverwrite]] + ], + ): + self.get_overwrites_callable = get_overwrites_callable + self._token: Optional[Token[Optional[dict[str, ResourceOverwrite]]]] = None + self.overwrites_count = 0 + + async def __aenter__(self) -> "ResourceOverwritesContext": + existing = _resource_overwrites.get() + if existing is not None: + logger.warning( + "Entering ResourceOverwritesContext while another context is already active (%d existing overwrite(s))", + len(existing), + ) + overwrites = await self.get_overwrites_callable() + self._token = _resource_overwrites.set(overwrites) + self.overwrites_count = len(overwrites) + if overwrites: + logger.info( + "Resource overwrites context entered: %d overwrite(s) loaded, keys=%s", + len(overwrites), + list(overwrites.keys()), + ) + else: + logger.debug("Resource overwrites context entered: no overwrites loaded") + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self._token: + logger.debug( + "Resource overwrites context exited: %d overwrite(s) cleared", + self.overwrites_count, + ) + _resource_overwrites.reset(self._token) + + +def resource_override( + resource_type: str, + resource_identifier: str = "name", + folder_identifier: str = "folder_path", +) -> Callable[..., Any]: + """Decorator for applying resource overrides for an overridable resource. + + It checks the current ContextVar to identify the requested overrides and, if any key matches, it invokes the decorated function + with the extracted resource and folder identifiers. + + Args: + resource_type: Type of resource to check for overrides (e.g., "asset", "bucket") + resource_identifier: Key name for the resource ID in override data (default: "name") + folder_identifier: Key name for the folder path in override data (default: "folder_path") + + Returns: + Decorated function that receives overridden resource identifiers when applicable + + Note: + Must be applied BEFORE the @traced decorator to ensure proper execution order. + """ + + def decorator(func: Callable[..., Any]): + sig = inspect.signature(func) + + def process_args(args, kwargs) -> dict[str, Any]: + """Process arguments and apply resource overrides if applicable.""" + # convert both args and kwargs to single dict + bound = sig.bind_partial(*args, **kwargs) + bound.apply_defaults() + all_args = dict(bound.arguments) + if ( + "kwargs" in sig.parameters + and sig.parameters["kwargs"].kind == inspect.Parameter.VAR_KEYWORD + ): + extra_kwargs = all_args.pop("kwargs", {}) + all_args.update(extra_kwargs) + + # Get overwrites from context variable + context_overwrites = _resource_overwrites.get() + + if context_overwrites is not None: + resource_identifier_value = all_args.get(resource_identifier) + folder_identifier_value = all_args.get(folder_identifier) + + key = f"{resource_type}.{resource_identifier_value}" + # try to apply folder path, fallback to resource_type.resource_name + if folder_identifier_value: + key = ( + f"{key}.{folder_identifier_value}" + if f"{key}.{folder_identifier_value}" in context_overwrites + else key + ) + + matched_overwrite = context_overwrites.get(key) + + # Apply the matched overwrite + if matched_overwrite is not None: + old_resource = all_args.get(resource_identifier) + old_folder = all_args.get(folder_identifier) + if resource_identifier in sig.parameters: + all_args[resource_identifier] = ( + matched_overwrite.resource_identifier + ) + if folder_identifier in sig.parameters: + all_args[folder_identifier] = ( + matched_overwrite.folder_identifier + ) + logger.debug( + "Resource overwrite applied for %s on %s: %s='%s' -> '%s', %s='%s' -> '%s'", + resource_type, + func.__name__, + resource_identifier, + old_resource, + matched_overwrite.resource_identifier, + folder_identifier, + old_folder, + matched_overwrite.folder_identifier, + ) + else: + logger.debug( + "No resource overwrite matched for %s key='%s' on %s", + resource_type, + key, + func.__name__, + ) + + return all_args + + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args, **kwargs): + all_args = process_args(args, kwargs) + return await func(**all_args) + + return async_wrapper + else: + + @functools.wraps(func) + def wrapper(*args, **kwargs): + all_args = process_args(args, kwargs) + return func(**all_args) + + return wrapper + + return decorator diff --git a/packages/uipath-platform/src/uipath/platform/common/_config.py b/packages/uipath-platform/src/uipath/platform/common/_config.py new file mode 100644 index 000000000..e5684c773 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_config.py @@ -0,0 +1,142 @@ +import os +from pathlib import Path + +from pydantic import BaseModel + + +class UiPathApiConfig(BaseModel): + base_url: str + secret: str + + +class ConfigurationManager: + _instance = None + studio_solution_id: str | None = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @property + def bindings_file_path(self) -> Path: + from uipath.platform.common.constants import UIPATH_BINDINGS_FILE + + return Path(UIPATH_BINDINGS_FILE) + + @property + def config_file_path(self) -> Path: + from uipath.platform.common.constants import UIPATH_CONFIG_FILE + + return Path(UIPATH_CONFIG_FILE) + + @property + def config_file_name(self) -> str: + from uipath.platform.common.constants import UIPATH_CONFIG_FILE + + return UIPATH_CONFIG_FILE + + @property + def project_id(self) -> str | None: + from uipath.platform.common.constants import ENV_UIPATH_PROJECT_ID + + return os.getenv(ENV_UIPATH_PROJECT_ID, None) + + @property + def project_key(self) -> str | None: + from uipath.platform.common.constants import ENV_PROJECT_KEY + + return os.getenv(ENV_PROJECT_KEY, None) + + @property + def tenant_name(self) -> str | None: + from uipath.platform.common.constants import ENV_TENANT_NAME + + return os.getenv(ENV_TENANT_NAME, None) + + @property + def tenant_id(self) -> str | None: + from uipath.platform.common.constants import ENV_TENANT_ID + + return os.getenv(ENV_TENANT_ID, None) + + @property + def organization_id(self) -> str | None: + from uipath.platform.common.constants import ENV_ORGANIZATION_ID + + return os.getenv(ENV_ORGANIZATION_ID, None) + + @property + def base_url(self) -> str | None: + from uipath.platform.common.constants import ENV_BASE_URL + + return os.getenv(ENV_BASE_URL, None) + + @property + def folder_key(self) -> str | None: + from uipath.platform.common.constants import ENV_FOLDER_KEY + + return os.getenv(ENV_FOLDER_KEY, None) + + @property + def process_uuid(self) -> str | None: + from uipath.platform.common.constants import ENV_UIPATH_PROCESS_UUID + + return os.getenv(ENV_UIPATH_PROCESS_UUID, None) + + @property + def trace_id(self) -> str | None: + from uipath.platform.common.constants import ENV_UIPATH_TRACE_ID + + return os.getenv(ENV_UIPATH_TRACE_ID, None) + + @property + def process_version(self) -> str | None: + from uipath.platform.common.constants import ENV_UIPATH_PROCESS_VERSION + + return os.getenv(ENV_UIPATH_PROCESS_VERSION, None) + + @property + def is_studio_project(self) -> bool: + return self.project_id is not None + + @property + def job_key(self) -> str | None: + from uipath.platform.common.constants import ENV_JOB_KEY + + return os.getenv(ENV_JOB_KEY, None) + + @property + def has_legacy_eval_folder(self) -> bool: + from uipath.platform.common.constants import LEGACY_EVAL_FOLDER + + eval_path = Path(os.getcwd()) / LEGACY_EVAL_FOLDER + return eval_path.exists() and eval_path.is_dir() + + @property + def has_eval_folder(self) -> bool: + from uipath.platform.common.constants import EVALS_FOLDER + + coded_eval_path = Path(os.getcwd()) / EVALS_FOLDER + return coded_eval_path.exists() and coded_eval_path.is_dir() + + @property + def entry_points_file_path(self) -> Path: + from uipath.platform.common.constants import ENTRY_POINTS_FILE + + return Path(ENTRY_POINTS_FILE) + + @property + def studio_metadata_file_path(self) -> Path: + from uipath.platform.common.constants import STUDIO_METADATA_FILE + + return Path(".uipath", STUDIO_METADATA_FILE) + + @property + def is_tracing_enabled(self) -> bool: + from uipath.platform.common.constants import ENV_TRACING_ENABLED + + return os.getenv(ENV_TRACING_ENABLED, "true").lower() == "true" + + +UiPathConfig = ConfigurationManager() diff --git a/packages/uipath-platform/src/uipath/platform/common/_endpoints_manager.py b/packages/uipath-platform/src/uipath/platform/common/_endpoints_manager.py new file mode 100644 index 000000000..5585ef61b --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_endpoints_manager.py @@ -0,0 +1,202 @@ +import logging +import os +from enum import Enum + +import httpx + +from ._config import UiPathConfig +from ._service_url_overrides import resolve_service_url +from ._ssl_context import get_httpx_client_kwargs + +loggger = logging.getLogger(__name__) + + +class UiPathEndpoints(Enum): + AH_NORMALIZED_COMPLETION_ENDPOINT = "agenthub_/llm/api/chat/completions" + AH_PASSTHROUGH_COMPLETION_ENDPOINT = "agenthub_/llm/openai/deployments/{model}/chat/completions?api-version={api_version}" + AH_EMBEDDING_ENDPOINT = ( + "agenthub_/llm/openai/deployments/{model}/embeddings?api-version={api_version}" + ) + AH_VENDOR_COMPLETION_ENDPOINT = ( + "agenthub_/llm/raw/vendor/{vendor}/model/{model}/completions" + ) + AH_CAPABILITIES_ENDPOINT = "agenthub_/llm/api/capabilities" + + OR_NORMALIZED_COMPLETION_ENDPOINT = "orchestrator_/llm/api/chat/completions" + OR_PASSTHROUGH_COMPLETION_ENDPOINT = "orchestrator_/llm/openai/deployments/{model}/chat/completions?api-version={api_version}" + OR_EMBEDDING_ENDPOINT = "orchestrator_/llm/openai/deployments/{model}/embeddings?api-version={api_version}" + OR_VENDOR_COMPLETION_ENDPOINT = ( + "orchestrator_/llm/raw/vendor/{vendor}/model/{model}/completions" + ) + OR_CAPABILITIES_ENDPOINT = "orchestrator_/llm/api/capabilities" + + +class EndpointManager: + """Manages and caches the UiPath endpoints. + This class provides functionality to determine which UiPath endpoints to use based on + the availability of AgentHub and Orchestrator. It checks for capabilities and caches + the results to avoid repeated network calls. + + The endpoint selection follows a fallback order: + 1. AgentHub (if available) + 2. Orchestrator (if available) + + Environment Variable Override: + The fallback behavior can be bypassed using the UIPATH_LLM_SERVICE environment variable: + - 'agenthub' or 'ah': Force use of AgentHub endpoints (skips capability checks) + - 'orchestrator' or 'or': Force use of Orchestrator endpoints (skips capability checks) + + Class Attributes: + _base_url (str): The base URL for UiPath services, retrieved from the UIPATH_URL + environment variable. + _agenthub_available (Optional[bool]): Cached result of AgentHub availability check. + _orchestrator_available (Optional[bool]): Cached result of Orchestrator availability check. + + Methods: + is_agenthub_available(): Checks if AgentHub is available, caching the result. + is_orchestrator_available(): Checks if Orchestrator is available, caching the result. + get_passthrough_endpoint(): Returns the appropriate passthrough completion endpoint. + get_normalized_endpoint(): Returns the appropriate normalized completion endpoint. + get_embeddings_endpoint(): Returns the appropriate embeddings endpoint. + get_vendor_endpoint(): Returns the appropriate vendor completion endpoint. + All endpoint methods automatically select the best available endpoint using the fallback order, + unless overridden by the UIPATH_LLM_SERVICE environment variable. + """ # noqa: D205 + + _base_url = UiPathConfig.base_url or "" + _agenthub_available: bool | None = None + _orchestrator_available: bool | None = None + + @classmethod + def is_agenthub_available(cls) -> bool: + """Check if AgentHub is available and cache the result.""" + if cls._agenthub_available is None: + cls._agenthub_available = cls._check_agenthub() + return cls._agenthub_available + + @classmethod + def is_orchestrator_available(cls) -> bool: + """Check if Orchestrator is available and cache the result.""" + if cls._orchestrator_available is None: + cls._orchestrator_available = cls._check_orchestrator() + return cls._orchestrator_available + + @classmethod + def _check_capabilities(cls, endpoint: UiPathEndpoints, service_name: str) -> bool: + """Perform the actual check for service capabilities. + + Args: + endpoint: The capabilities endpoint to check + service_name: Human-readable service name for logging + + Returns: + bool: True if the service is available and has valid capabilities + """ + try: + with httpx.Client(**get_httpx_client_kwargs()) as http_client: + capabilities_url = resolve_service_url(endpoint.value) + if not capabilities_url: + base_url = UiPathConfig.base_url or "" + capabilities_url = f"{base_url.rstrip('/')}/{endpoint.value}" + loggger.debug( + f"Checking {service_name} capabilities at {capabilities_url}" + ) + response = http_client.get(capabilities_url) + + if response.status_code != 200: + return False + + capabilities = response.json() + + # Validate structure and required fields + if not isinstance(capabilities, dict) or "version" not in capabilities: + return False + + return True + + except Exception as e: + loggger.error( + f"Error checking {service_name} capabilities: {e}", exc_info=True + ) + return False + + @classmethod + def _check_agenthub(cls) -> bool: + """Perform the actual check for AgentHub capabilities.""" + return cls._check_capabilities( + UiPathEndpoints.AH_CAPABILITIES_ENDPOINT, "AgentHub" + ) + + @classmethod + def _check_orchestrator(cls) -> bool: + """Perform the actual check for Orchestrator capabilities.""" + return cls._check_capabilities( + UiPathEndpoints.OR_CAPABILITIES_ENDPOINT, "Orchestrator" + ) + + @classmethod + def _select_endpoint(cls, ah: UiPathEndpoints, orc: UiPathEndpoints) -> str: + """Select an endpoint based on UIPATH_LLM_SERVICE override or capability checks.""" + service_override = os.getenv("UIPATH_LLM_SERVICE", "").lower() + + if service_override in ("agenthub", "ah"): + return ah.value + if service_override in ("orchestrator", "or"): + return orc.value + + # Determine fallback order based on environment hints + hdens_env = os.getenv("HDENS_ENV", "").lower() + + # Default order: AgentHub -> Orchestrator + check_order = [ + ("ah", ah, cls.is_agenthub_available), + ("orc", orc, cls.is_orchestrator_available), + ] + + # Prioritize Orchestrator if HDENS_ENV is 'sf' + # Note: The default order already prioritizes AgentHub + if hdens_env == "sf": + check_order.reverse() + + # Execute fallback checks in the determined order + for _, endpoint, is_available in check_order: + if is_available(): + return endpoint.value + + url = UiPathConfig.base_url or "" + if ".uipath.com" in url: + return ah.value + else: + return orc.value + + @classmethod + def get_passthrough_endpoint(cls) -> str: + """Get the passthrough completion endpoint.""" + return cls._select_endpoint( + UiPathEndpoints.AH_PASSTHROUGH_COMPLETION_ENDPOINT, + UiPathEndpoints.OR_PASSTHROUGH_COMPLETION_ENDPOINT, + ) + + @classmethod + def get_normalized_endpoint(cls) -> str: + """Get the normalized completion endpoint.""" + return cls._select_endpoint( + UiPathEndpoints.AH_NORMALIZED_COMPLETION_ENDPOINT, + UiPathEndpoints.OR_NORMALIZED_COMPLETION_ENDPOINT, + ) + + @classmethod + def get_embeddings_endpoint(cls) -> str: + """Get the embeddings endpoint.""" + return cls._select_endpoint( + UiPathEndpoints.AH_EMBEDDING_ENDPOINT, + UiPathEndpoints.OR_EMBEDDING_ENDPOINT, + ) + + @classmethod + def get_vendor_endpoint(cls) -> str: + """Get the vendor completion endpoint.""" + return cls._select_endpoint( + UiPathEndpoints.AH_VENDOR_COMPLETION_ENDPOINT, + UiPathEndpoints.OR_VENDOR_COMPLETION_ENDPOINT, + ) diff --git a/packages/uipath-platform/src/uipath/platform/common/_execution_context.py b/packages/uipath-platform/src/uipath/platform/common/_execution_context.py new file mode 100644 index 000000000..de54c0c99 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_execution_context.py @@ -0,0 +1,78 @@ +from os import environ as env + +from uipath.platform.common.constants import ENV_JOB_ID, ENV_JOB_KEY, ENV_ROBOT_KEY + + +class UiPathExecutionContext: + """Manages the execution context for UiPath automation processes. + + The UiPathExecutionContext class handles information about the current execution environment, + including the job instance ID and robot key. This information is essential for + tracking and managing automation jobs in UiPath Automation Cloud. + """ + + def __init__(self) -> None: + try: + self._instance_key: str | None = env[ENV_JOB_KEY] + except KeyError: + self._instance_key = None + + try: + self._instance_id: str | None = env[ENV_JOB_ID] + except KeyError: + self._instance_id = None + + try: + self._robot_key: str | None = env[ENV_ROBOT_KEY] + except KeyError: + self._robot_key = None + + super().__init__() + + @property + def instance_id(self) -> str | None: + """Get the current job instance ID. + + The instance ID uniquely identifies the current automation job execution + in UiPath Automation Cloud. + + Returns: + Optional[str]: The job instance ID. + + Raises: + ValueError: If the instance ID is not set in the environment. + """ + if self._instance_id is None: + raise ValueError(f"Instance ID is not set ({ENV_JOB_ID})") + + return self._instance_id + + @property + def instance_key(self) -> str | None: + """Get the current job instance key. + + The instance key uniquely identifies the current automation job execution + in UiPath Automation Cloud. + """ + if self._instance_key is None: + raise ValueError(f"Instance key is not set ({ENV_JOB_KEY})") + + return self._instance_key + + @property + def robot_key(self) -> str | None: + """Get the current robot key. + + The robot key identifies the UiPath Robot that is executing the current + automation job. + + Returns: + Optional[str]: The robot key. + + Raises: + ValueError: If the robot key is not set in the environment. + """ + if self._robot_key is None: + raise ValueError(f"Robot key is not set ({ENV_ROBOT_KEY})") + + return self._robot_key diff --git a/packages/uipath-platform/src/uipath/platform/common/_external_application_service.py b/packages/uipath-platform/src/uipath/platform/common/_external_application_service.py new file mode 100644 index 000000000..4603c8361 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_external_application_service.py @@ -0,0 +1,140 @@ +from os import environ as env +from typing import Optional +from urllib.parse import urlparse + +import httpx +from httpx import HTTPStatusError, Request + +from ..errors import EnrichedException +from ._ssl_context import get_httpx_client_kwargs +from .auth import TokenData +from .constants import ENV_BASE_URL + + +class ExternalApplicationService: + """Service for client credentials authentication flow.""" + + def __init__(self, base_url: Optional[str]): + if not (resolved_base_url := (base_url or env.get(ENV_BASE_URL))): + raise ValueError( + "Base URL must be set either via constructor or the BASE_URL environment variable." + ) + self._base_url = resolved_base_url + self._domain = self._extract_environment_from_base_url(self._base_url) + + def get_token_url(self) -> str: + """Get the token URL for the specified domain.""" + match self._domain: + case "alpha": + return "https://alpha.uipath.com/identity_/connect/token" + case "staging": + return "https://staging.uipath.com/identity_/connect/token" + case _: # cloud (default) + return "https://cloud.uipath.com/identity_/connect/token" + + def _is_valid_domain_or_subdomain(self, hostname: str, domain: str) -> bool: + """Check if hostname is either an exact match or a valid subdomain of the domain. + + Args: + hostname: The hostname to check + domain: The domain to validate against + + Returns: + True if hostname is valid domain or subdomain, False otherwise + """ + return hostname == domain or hostname.endswith(f".{domain}") + + def _extract_environment_from_base_url(self, base_url: str) -> str: + """Extract domain from base URL. + + Args: + base_url: The base URL to extract domain from + + Returns: + The domain (alpha, staging, or cloud) + """ + try: + parsed = urlparse(base_url) + hostname = parsed.hostname + + if hostname: + match hostname: + case h if self._is_valid_domain_or_subdomain(h, "alpha.uipath.com"): + return "alpha" + case h if self._is_valid_domain_or_subdomain( + h, "staging.uipath.com" + ): + return "staging" + case h if self._is_valid_domain_or_subdomain(h, "cloud.uipath.com"): + return "cloud" + + # Default to cloud if we can't determine + return "cloud" + except Exception: + # Default to cloud if parsing fails + return "cloud" + + def get_token_data( + self, client_id: str, client_secret: str, scope: Optional[str] = "OR.Execution" + ) -> TokenData: + """Authenticate using client credentials flow. + + Args: + client_id: The client ID for authentication + client_secret: The client secret for authentication + scope: The scope for the token (default: OR.Execution) + + Returns: + Token data if successful + """ + token_url = self.get_token_url() + + data = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": scope, + } + + try: + with httpx.Client(**get_httpx_client_kwargs()) as client: + response = client.post(token_url, data=data) + match response.status_code: + case 200: + return TokenData.model_validate(response.json()) + case 400: + raise EnrichedException( + HTTPStatusError( + message="Invalid client credentials or request parameters.", + request=Request( + data=data, url=token_url, method="post" + ), + response=response, + ) + ) + case 401: + raise EnrichedException( + HTTPStatusError( + message="Unauthorized: Invalid client credentials.", + request=Request( + data=data, url=token_url, method="post" + ), + response=response, + ) + ) + case _: + raise EnrichedException( + HTTPStatusError( + message=f"Authentication failed with unexpected status: {response.status_code}", + request=Request( + data=data, url=token_url, method="post" + ), + response=response, + ) + ) + except EnrichedException: + raise + except httpx.RequestError as e: + raise Exception(f"Network error during authentication: {e}") from e + except Exception as e: + raise Exception(f"Unexpected error during authentication: {e}") from e diff --git a/packages/uipath-platform/src/uipath/platform/common/_folder_context.py b/packages/uipath-platform/src/uipath/platform/common/_folder_context.py new file mode 100644 index 000000000..a3007a85f --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_folder_context.py @@ -0,0 +1,68 @@ +from os import environ as env +from typing import Any, Optional + +from uipath.platform.common.constants import ( + ENV_FOLDER_KEY, + ENV_FOLDER_PATH, + HEADER_FOLDER_KEY, + HEADER_FOLDER_PATH, +) + + +def header_folder( + folder_key: Optional[str], folder_path: Optional[str] +) -> dict[str, str]: + if folder_key is not None and folder_path is not None: + raise ValueError("Only one of folder_key or folder_path can be provided") + + headers = {} + if folder_key is not None and folder_key != "": + headers[HEADER_FOLDER_KEY] = folder_key + if folder_path is not None and folder_path != "": + headers[HEADER_FOLDER_PATH] = folder_path + + return headers + + +class FolderContext: + """Manages the folder context for UiPath automation resources. + + The FolderContext class handles information about the current folder in which + automation resources (like processes, assets, etc.) are being accessed or modified. + This is essential for organizing and managing resources in the UiPath Automation Cloud + folder structure. + """ + + def __init__(self, **kwargs: Any) -> None: + try: + self._folder_key: str | None = env[ENV_FOLDER_KEY] + except KeyError: + self._folder_key = None + + try: + self._folder_path: str | None = env[ENV_FOLDER_PATH] + except KeyError: + self._folder_path = None + + super().__init__(**kwargs) + + @property + def folder_headers(self) -> dict[str, str]: + """Get the HTTP headers for folder-based API requests. + + Returns headers containing either the folder key or folder path, + which are used to specify the target folder for API operations. + The folder context is essential for operations that need to be + performed within a specific folder in UiPath Automation Cloud. + + Returns: + dict[str, str]: A dictionary containing the appropriate folder + header (either folder key or folder path). If no folder header is + set as environment variable, the function returns an empty dictionary. + """ + if self._folder_key is not None: + return {HEADER_FOLDER_KEY: self._folder_key} + elif self._folder_path is not None: + return {HEADER_FOLDER_PATH: self._folder_path} + else: + return {} diff --git a/packages/uipath-platform/src/uipath/platform/common/_models.py b/packages/uipath-platform/src/uipath/platform/common/_models.py new file mode 100644 index 000000000..60a79b8c6 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_models.py @@ -0,0 +1,100 @@ +from dataclasses import dataclass, field +from typing import Any, Optional, Union + + +class Endpoint(str): + """A string subclass representing a normalized API endpoint path. + + This class ensures consistent endpoint formatting by: + - Adding a leading slash if missing + - Removing trailing slashes (except for root '/') + - Stripping query parameters + + The class supports string formatting for dynamic path parameters. + + Examples: + >>> endpoint = Endpoint("/api/v1/users/{id}") + >>> endpoint.format(id=123) + '/api/v1/users/123' + + >>> endpoint = Endpoint("projects") + >>> str(endpoint) + '/projects' + + Args: + endpoint (str): The endpoint path to normalize. May include format placeholders + for dynamic values (e.g. "/users/{id}"). + + Raises: + ValueError: If format() is called with None or empty string arguments. + """ + + def __new__(cls, endpoint: str) -> "Endpoint": + if not endpoint.startswith("/"): + endpoint = f"/{endpoint}" + + if endpoint != "/" and endpoint.endswith("/"): + endpoint = endpoint[:-1] + + endpoint = endpoint.split("?")[0] + + return super().__new__(cls, endpoint) + + def format(self, *args: Any, **kwargs: Any) -> str: + """Formats the endpoint with the given arguments.""" + for index, arg in enumerate(args): + if not self._is_valid_value(arg): + raise ValueError(f"Positional argument `{index}` is `{arg}`.") + + for key, value in kwargs.items(): + if not self._is_valid_value(value): + raise ValueError(f"Keyword argument `{key}` is `{value}`.") + + return super().format(*args, **kwargs) + + def __repr__(self) -> str: + return f"Endpoint({super().__str__()!r})" + + def _is_valid_value(self, value: Any) -> bool: + return value is not None and value != "" + + @property + def service(self) -> str: + """Extracts and returns the service name from the endpoint path. + + The service name is expected to be the first path segment after the leading slash, + with any underscores removed. + + Examples: + >>> endpoint = Endpoint("/cloud_/projects") + >>> endpoint.service + 'cloud' + + >>> endpoint = Endpoint("/automation_hub_/assets") + >>> endpoint.service + 'automationhub' + + Returns: + str: The service name with underscores removed. + """ + return self.split("/")[1].replace("_", "") + + +@dataclass +class RequestSpec: + """Encapsulates the configuration for making an HTTP request. + + This class contains all necessary parameters to construct and send an HTTP request, + including the HTTP method, endpoint, query parameters, headers, and various forms + of request body data (content, JSON, form data). + """ + + method: str + endpoint: Endpoint + params: dict[str, Any] = field(default_factory=dict) + headers: dict[str, Any] = field(default_factory=dict) + content: Optional[Any] = None + json: Optional[Any] = None + data: Optional[Any] = None + files: Optional[dict[str, Any]] = None + timeout: Optional[Union[int, float]] = None diff --git a/packages/uipath-platform/src/uipath/platform/common/_service_url_overrides.py b/packages/uipath-platform/src/uipath/platform/common/_service_url_overrides.py new file mode 100644 index 000000000..f0a56318d --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_service_url_overrides.py @@ -0,0 +1,64 @@ +"""Per-service URL overrides for local development. + +Set UIPATH_SERVICE_URL_=http://localhost: to redirect +requests for a specific service to a local server. The service name +is derived from the endpoint prefix (e.g., agenthub_ -> AGENTHUB). + +The local server receives only the API path — org/tenant prefix and +service prefix are stripped. + +When an override is active, routing headers (X-UiPath-Internal-TenantId, +X-UiPath-Internal-AccountId) are injected since the platform routing +layer is bypassed. +""" + +import os + +from ._config import UiPathConfig + + +def resolve_service_url(endpoint_path: str) -> str | None: + """Resolve a service URL override for the given endpoint path. + + Args: + endpoint_path: Endpoint path with service prefix, + e.g. "agenthub_/llm/api/chat/completions" or + "/orchestrator_/odata/Buckets". + + Returns: + Override URL with the API path appended, or None if no override is set. + """ + path = endpoint_path.lstrip("/") + if not path: + return None + + first_segment = path.split("/")[0] + if "_" not in first_segment: + return None + + service_name = first_segment.replace("_", "").upper() + override_base = os.getenv(f"UIPATH_SERVICE_URL_{service_name}") + if not override_base: + return None + + remaining = path[len(first_segment) :] + return f"{override_base.rstrip('/')}{remaining}" + + +def inject_routing_headers(headers: dict[str, str]) -> None: + """Add routing headers bypassed when using a local service override. + + The platform routing layer normally injects tenant and account + identifiers. When going direct to a local service, these must + be sent explicitly. + + Args: + headers: Mutable headers dict to update in place. + """ + tenant_id = UiPathConfig.tenant_id + if tenant_id: + headers["X-UiPath-Internal-TenantId"] = tenant_id + + organization_id = UiPathConfig.organization_id + if organization_id: + headers["X-UiPath-Internal-AccountId"] = organization_id diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py new file mode 100644 index 000000000..448648067 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -0,0 +1,401 @@ +import inspect +import json +import logging +import os +from dataclasses import dataclass, field +from datetime import datetime +from enum import IntEnum +from os import environ as env +from typing import Any, Dict, List, Optional + +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.trace import StatusCode +from pydantic import BaseModel, ConfigDict, Field +from uipath.core.serialization import serialize_json + +logger = logging.getLogger(__name__) + +# SourceEnum.Robots = 4 (default for Python SDK / coded agents) +DEFAULT_SOURCE = 4 + + +class AttachmentProvider(IntEnum): + ORCHESTRATOR = 0 + + +class AttachmentDirection(IntEnum): + NONE = 0 + IN = 1 + OUT = 2 + + +class SpanAttachment(BaseModel): + """Represents an attachment in the UiPath tracing system.""" + + model_config = ConfigDict(populate_by_name=True, use_enum_values=True) + + id: str = Field(..., alias="id") + file_name: str = Field(..., alias="fileName") + mime_type: str = Field(..., alias="mimeType") + provider: AttachmentProvider = Field( + default=AttachmentProvider.ORCHESTRATOR, alias="provider" + ) + direction: AttachmentDirection = Field( + default=AttachmentDirection.NONE, alias="direction" + ) + + +@dataclass +class UiPathSpan: + """Represents a span in the UiPath tracing system. + + Note: attributes can be either a JSON string (backwards compatible) or a dict (optimized). + IDs are stored as OTEL hex strings (32 chars for trace_id, 16 chars for span_id/parent_id). + """ + + id: str # 16-char hex (OTEL span ID format) + trace_id: str # 32-char hex (OTEL trace ID format) + name: str + attributes: str | Dict[str, Any] # Support both str (legacy) and dict (optimized) + parent_id: Optional[str] = None # 16-char hex (OTEL span ID format) + start_time: str = field(default_factory=lambda: datetime.now().isoformat()) + end_time: str = field(default_factory=lambda: datetime.now().isoformat()) + status: int = 1 + created_at: str = field(default_factory=lambda: datetime.now().isoformat() + "Z") + updated_at: str = field(default_factory=lambda: datetime.now().isoformat() + "Z") + organization_id: Optional[str] = field( + default_factory=lambda: env.get("UIPATH_ORGANIZATION_ID", "") + ) + tenant_id: Optional[str] = field( + default_factory=lambda: env.get("UIPATH_TENANT_ID", "") + ) + expiry_time_utc: Optional[str] = None + folder_key: Optional[str] = field( + default_factory=lambda: env.get("UIPATH_FOLDER_KEY", "") + ) + source: int = DEFAULT_SOURCE + span_type: str = "Coded Agents" + process_key: Optional[str] = field( + default_factory=lambda: env.get("UIPATH_PROCESS_UUID") + ) + reference_id: Optional[str] = field( + default_factory=lambda: env.get("TRACE_REFERENCE_ID") + ) + + job_key: Optional[str] = field(default_factory=lambda: env.get("UIPATH_JOB_KEY")) + + # Top-level fields for internal tracing schema + execution_type: Optional[int] = None + agent_version: Optional[str] = None + attachments: Optional[List[SpanAttachment]] = None + + def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: + """Convert the Span to a dictionary suitable for JSON serialization. + + Args: + serialize_attributes: If True and attributes is a dict, serialize to JSON string. + If False, keep attributes as-is (dict or str). + Default True for backwards compatibility. + """ + attributes_out = self.attributes + if serialize_attributes and isinstance(self.attributes, dict): + attributes_out = json.dumps(self.attributes) + + attachments_out = None + if self.attachments is not None: + attachments_out = [ + { + "Id": att.id, + "FileName": att.file_name, + "MimeType": att.mime_type, + "Provider": int(att.provider), + "Direction": int(att.direction), + } + for att in self.attachments + ] + + return { + "Id": self.id, + "TraceId": self.trace_id, + "ParentId": self.parent_id, + "Name": self.name, + "StartTime": self.start_time, + "EndTime": self.end_time, + "Attributes": attributes_out, + "Status": self.status, + "CreatedAt": self.created_at, + "UpdatedAt": self.updated_at, + "OrganizationId": self.organization_id, + "TenantId": self.tenant_id, + "ExpiryTimeUtc": self.expiry_time_utc, + "FolderKey": self.folder_key, + "Source": self.source, + "SpanType": self.span_type, + "ProcessKey": self.process_key, + "JobKey": self.job_key, + "ReferenceId": self.reference_id, + "ExecutionType": self.execution_type, + "AgentVersion": self.agent_version, + "Attachments": attachments_out, + } + + +class _SpanUtils: + @staticmethod + def normalize_trace_id(value: str) -> str: + """Normalize trace ID to 32-char OTEL hex format. + + Accepts both UUID format (with dashes) and OTEL hex format (32 chars). + Returns lowercase 32-char hex string. + """ + # Remove dashes if UUID format + normalized = value.replace("-", "").lower() + if len(normalized) != 32: + raise ValueError(f"Invalid trace ID format: {value}") + return normalized + + @staticmethod + def normalize_span_id(value: str) -> str: + """Normalize span ID to 16-char OTEL hex format. + + Accepts both UUID format (with dashes, uses last 16 hex chars) and OTEL hex format (16 chars). + Returns lowercase 16-char hex string. + """ + # Remove dashes if UUID format + normalized = value.replace("-", "").lower() + if len(normalized) == 32: + # UUID format - take last 16 chars (span ID portion) + return normalized[16:] + elif len(normalized) == 16: + return normalized + else: + raise ValueError(f"Invalid span ID format: {value}") + + @staticmethod + def otel_span_to_uipath_span( + otel_span: ReadableSpan, + custom_trace_id: Optional[str] = None, + serialize_attributes: bool = True, + ) -> UiPathSpan: + """Convert an OpenTelemetry span to a UiPathSpan. + + Args: + otel_span: The OpenTelemetry span to convert + custom_trace_id: Optional custom trace ID to use (UUID or OTEL hex format) + serialize_attributes: If True, serialize attributes to JSON string (backwards compatible). + If False, keep as dict for optimized processing. Default True. + """ + # Extract the context information from the OTel span + span_context = otel_span.get_span_context() + + # Convert to OTEL hex format (32 chars for trace_id, 16 chars for span_id) + trace_id = format(span_context.trace_id, "032x") + span_id = format(span_context.span_id, "016x") + + # Override trace_id if custom or env var provided (supports both UUID and hex format) + trace_id_override = custom_trace_id or os.environ.get("UIPATH_TRACE_ID") + if trace_id_override: + trace_id = _SpanUtils.normalize_trace_id(trace_id_override) + + # Get parent span ID if it exists + parent_id: Optional[str] = None + if otel_span.parent is not None: + parent_id = format(otel_span.parent.span_id, "016x") + else: + # Only set UIPATH_PARENT_SPAN_ID for root spans (spans without a parent) + parent_span_id_str = env.get("UIPATH_PARENT_SPAN_ID") + if parent_span_id_str: + parent_id = _SpanUtils.normalize_span_id(parent_span_id_str) + + # Build attributes dict efficiently + # Use the otel attributes as base - we only add new keys, don't modify existing + otel_attrs = otel_span.attributes if otel_span.attributes else {} + # Only copy if we need to modify - we'll build attributes_dict lazily + attributes_dict: dict[str, Any] = dict(otel_attrs) if otel_attrs else {} + + # Map status + status = 1 # Default to OK + if otel_span.status.status_code == StatusCode.ERROR: + status = 2 # Error + attributes_dict["error"] = otel_span.status.description + + # Process inputs - avoid redundant parsing if already parsed + original_inputs = otel_attrs.get("input", None) + if original_inputs: + if isinstance(original_inputs, str): + try: + attributes_dict["input.value"] = json.loads(original_inputs) + attributes_dict["input.mime_type"] = "application/json" + except Exception: + attributes_dict["input.value"] = original_inputs + else: + attributes_dict["input.value"] = original_inputs + + # Process outputs - avoid redundant parsing if already parsed + original_outputs = otel_attrs.get("output", None) + if original_outputs: + if isinstance(original_outputs, str): + try: + attributes_dict["output.value"] = json.loads(original_outputs) + attributes_dict["output.mime_type"] = "application/json" + except Exception: + attributes_dict["output.value"] = original_outputs + else: + attributes_dict["output.value"] = original_outputs + + # Add events as additional attributes if they exist + if otel_span.events: + events_list = [ + { + "name": event.name, + "timestamp": event.timestamp, + "attributes": dict(event.attributes) if event.attributes else {}, + } + for event in otel_span.events + ] + attributes_dict["events"] = events_list + + # Add links as additional attributes if they exist + if hasattr(otel_span, "links") and otel_span.links: + links_list = [ + { + "trace_id": link.context.trace_id, + "span_id": link.context.span_id, + "attributes": dict(link.attributes) if link.attributes else {}, + } + for link in otel_span.links + ] + attributes_dict["links"] = links_list + + span_type_value = attributes_dict.get("span_type", "OpenTelemetry") + span_type = str(span_type_value) + + # Top-level fields for internal tracing schema + execution_type = attributes_dict.get("executionType") + agent_version = attributes_dict.get("agentVersion") + reference_id = attributes_dict.get("referenceId") + + # Source: override via uipath.source attribute, else DEFAULT_SOURCE + uipath_source = attributes_dict.get("uipath.source") + source = uipath_source if isinstance(uipath_source, int) else DEFAULT_SOURCE + + attachments = None + attachments_data = attributes_dict.get("attachments") + if attachments_data: + try: + attachments_list = json.loads(attachments_data) + attachments = [ + SpanAttachment( + id=att.get("id"), + file_name=att.get("fileName", ""), + mime_type=att.get("mimeType", ""), + provider=att.get("provider", 0), + direction=att.get("direction", 0), + ) + for att in attachments_list + ] + except Exception as e: + logger.warning(f"Error processing attachments: {e}") + + # Create UiPathSpan from OpenTelemetry span + start_time = datetime.fromtimestamp( + (otel_span.start_time or 0) / 1e9 + ).isoformat() + + end_time_str = None + if otel_span.end_time is not None: + end_time_str = datetime.fromtimestamp( + (otel_span.end_time or 0) / 1e9 + ).isoformat() + else: + end_time_str = datetime.now().isoformat() + + return UiPathSpan( + id=span_id, + trace_id=trace_id, + parent_id=parent_id, + name=otel_span.name, + attributes=json.dumps(attributes_dict) + if serialize_attributes + else attributes_dict, + start_time=start_time, + end_time=end_time_str, + status=status, + span_type=span_type, + execution_type=execution_type, + agent_version=agent_version, + reference_id=reference_id, + source=source, + attachments=attachments, + ) + + @staticmethod + def format_object_for_trace_json( + input_object: Any, + ) -> str: + """Return a JSON string of inputs from the function signature.""" + return serialize_json(input_object) + + @staticmethod + def format_args_for_trace( + signature: inspect.Signature, *args: Any, **kwargs: Any + ) -> Dict[str, Any]: + try: + """Return a dictionary of inputs from the function signature.""" + # Create a parameter mapping by partially binding the arguments + + parameter_binding = signature.bind_partial(*args, **kwargs) + + # Fill in default values for any unspecified parameters + parameter_binding.apply_defaults() + + # Extract the input parameters, skipping special Python parameters + result = {} + for name, value in parameter_binding.arguments.items(): + # Skip class and instance references + if name in ("self", "cls"): + continue + + # Handle **kwargs parameters specially + param_info = signature.parameters.get(name) + if param_info and param_info.kind == inspect.Parameter.VAR_KEYWORD: + # Flatten nested kwargs directly into the result + if isinstance(value, dict): + result.update(value) + else: + # Regular parameter + result[name] = value + + return result + except Exception as e: + logger.warning( + f"Error formatting arguments for trace: {e}. Using args and kwargs directly." + ) + return {"args": args, "kwargs": kwargs} + + @staticmethod + def spans_to_llm_context(spans: list[ReadableSpan]) -> str: + """Convert spans to a formatted conversation history string suitable for LLM context. + + Includes function calls (including LLM calls) with their inputs and outputs. + """ + history = [] + for span in spans: + attributes = dict(span.attributes) if span.attributes else {} + + input_value = attributes.get("input.value") + output_value = attributes.get("output.value") + telemetry_filter = attributes.get("telemetry.filter") + + if not input_value or not output_value or telemetry_filter == "drop": + continue + + history.append(f"Function: {span.name}") + history.append(f"Input: {input_value}") + history.append(f"Output: {output_value}") + history.append("") + + if not history: + return "(empty)" + + return "\n".join(history) diff --git a/packages/uipath-platform/src/uipath/platform/common/_ssl_context.py b/packages/uipath-platform/src/uipath/platform/common/_ssl_context.py new file mode 100644 index 000000000..16bef6849 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_ssl_context.py @@ -0,0 +1,53 @@ +import os +import ssl +from typing import Any, Dict + + +def expand_path(path): + """Expand environment variables and user home directory in path.""" + if not path: + return path + # Expand environment variables like $HOME + path = os.path.expandvars(path) + # Expand user home directory ~ + path = os.path.expanduser(path) + return path + + +def create_ssl_context(): + # Try truststore first (system certificates) + try: + import truststore + + return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + except ImportError: + # Fallback to manual certificate configuration + import certifi + + ssl_cert_file = expand_path(os.environ.get("SSL_CERT_FILE")) + requests_ca_bundle = expand_path(os.environ.get("REQUESTS_CA_BUNDLE")) + ssl_cert_dir = expand_path(os.environ.get("SSL_CERT_DIR")) + + return ssl.create_default_context( + cafile=ssl_cert_file or requests_ca_bundle or certifi.where(), + capath=ssl_cert_dir, + ) + + +def get_httpx_client_kwargs() -> Dict[str, Any]: + """Get standardized httpx client configuration.""" + client_kwargs: Dict[str, Any] = {"follow_redirects": True, "timeout": 30.0} + # Check environment variable to disable SSL verification + disable_ssl_env = os.environ.get("UIPATH_DISABLE_SSL_VERIFY", "").lower() + disable_ssl_from_env = disable_ssl_env in ("1", "true", "yes", "on") + + if disable_ssl_from_env: + client_kwargs["verify"] = False + else: + # Use system certificates with truststore fallback + client_kwargs["verify"] = create_ssl_context() + + # Auto-detect proxy from environment variables (httpx handles this automatically) + # HTTP_PROXY, HTTPS_PROXY, NO_PROXY are read by httpx by default + + return client_kwargs diff --git a/src/uipath/_utils/_url.py b/packages/uipath-platform/src/uipath/platform/common/_url.py similarity index 100% rename from src/uipath/_utils/_url.py rename to packages/uipath-platform/src/uipath/platform/common/_url.py diff --git a/src/uipath/_utils/_user_agent.py b/packages/uipath-platform/src/uipath/platform/common/_user_agent.py similarity index 100% rename from src/uipath/_utils/_user_agent.py rename to packages/uipath-platform/src/uipath/platform/common/_user_agent.py diff --git a/packages/uipath-platform/src/uipath/platform/common/auth.py b/packages/uipath-platform/src/uipath/platform/common/auth.py new file mode 100644 index 000000000..885a0ef1b --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/auth.py @@ -0,0 +1,37 @@ +"""Module defining the TokenData model for authentication tokens.""" + +from os import environ as env +from typing import Optional + +from pydantic import BaseModel + +from uipath.platform.common.constants import ( + ENV_BASE_URL, + ENV_UIPATH_ACCESS_TOKEN, + ENV_UNATTENDED_USER_ACCESS_TOKEN, +) + + +class TokenData(BaseModel): + """Pydantic model for token data structure.""" + + access_token: str + refresh_token: Optional[str] = None + expires_in: Optional[int] = None + token_type: Optional[str] = None + scope: Optional[str] = None + id_token: Optional[str] = None + + +def resolve_config_from_env( + base_url: Optional[str], + secret: Optional[str], +): + """Simple config resolution from environment variables.""" + base_url_value = base_url or env.get(ENV_BASE_URL) + secret_value = ( + secret + or env.get(ENV_UNATTENDED_USER_ACCESS_TOKEN) + or env.get(ENV_UIPATH_ACCESS_TOKEN) + ) + return base_url_value, secret_value diff --git a/packages/uipath-platform/src/uipath/platform/common/constants.py b/packages/uipath-platform/src/uipath/platform/common/constants.py new file mode 100644 index 000000000..7dd0a8544 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/constants.py @@ -0,0 +1,79 @@ +"""Constants.""" + +# Environment variables +DOTENV_FILE = ".env" +ENV_BASE_URL = "UIPATH_URL" +ENV_EVAL_BACKEND_URL = "UIPATH_EVAL_BACKEND_URL" +ENV_UNATTENDED_USER_ACCESS_TOKEN = "UNATTENDED_USER_ACCESS_TOKEN" +ENV_UIPATH_ACCESS_TOKEN = "UIPATH_ACCESS_TOKEN" +ENV_FOLDER_KEY = "UIPATH_FOLDER_KEY" +ENV_FOLDER_PATH = "UIPATH_FOLDER_PATH" +ENV_JOB_KEY = "UIPATH_JOB_KEY" +ENV_JOB_ID = "UIPATH_JOB_ID" +ENV_ROBOT_KEY = "UIPATH_ROBOT_KEY" +ENV_TENANT_ID = "UIPATH_TENANT_ID" +ENV_TENANT_NAME = "UIPATH_TENANT_NAME" +ENV_ORGANIZATION_ID = "UIPATH_ORGANIZATION_ID" +ENV_TELEMETRY_ENABLED = "UIPATH_TELEMETRY_ENABLED" +ENV_TRACING_ENABLED = "UIPATH_TRACING_ENABLED" +ENV_UIPATH_PROJECT_ID = "UIPATH_PROJECT_ID" +ENV_PROJECT_KEY = "PROJECT_KEY" +ENV_UIPATH_PROCESS_UUID = "UIPATH_PROCESS_UUID" +ENV_UIPATH_TRACE_ID = "UIPATH_TRACE_ID" +ENV_UIPATH_PROCESS_VERSION = "UIPATH_PROCESS_VERSION" + +# Headers +HEADER_FOLDER_KEY = "x-uipath-folderkey" +HEADER_FOLDER_PATH = "x-uipath-folderpath" +HEADER_USER_AGENT = "x-uipath-user-agent" +HEADER_TENANT_ID = "x-uipath-tenantid" +HEADER_INTERNAL_TENANT_ID = "x-uipath-internal-tenantid" +HEADER_JOB_KEY = "x-uipath-jobkey" +HEADER_SW_LOCK_KEY = "x-uipath-sw-lockkey" + +# Data sources (request types) +ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE_REQUEST = ( + "#UiPath.Vdbs.Domain.Api.V20Models.StorageBucketDataSourceRequest" +) +CONFLUENCE_DATA_SOURCE_REQUEST = ( + "#UiPath.Vdbs.Domain.Api.V20Models.ConfluenceDataSourceRequest" +) +DROPBOX_DATA_SOURCE_REQUEST = ( + "#UiPath.Vdbs.Domain.Api.V20Models.DropboxDataSourceRequest" +) +GOOGLE_DRIVE_DATA_SOURCE_REQUEST = ( + "#UiPath.Vdbs.Domain.Api.V20Models.GoogleDriveDataSourceRequest" +) +ONEDRIVE_DATA_SOURCE_REQUEST = ( + "#UiPath.Vdbs.Domain.Api.V20Models.OneDriveDataSourceRequest" +) + +# Data sources +ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE = ( + "#UiPath.Vdbs.Domain.Api.V20Models.StorageBucketDataSource" +) +CONFLUENCE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.ConfluenceDataSource" +DROPBOX_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.DropboxDataSource" +GOOGLE_DRIVE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.GoogleDriveDataSource" +ONEDRIVE_DATA_SOURCE = "#UiPath.Vdbs.Domain.Api.V20Models.OneDriveDataSource" + + +# Local storage +TEMP_ATTACHMENTS_FOLDER = "uipath_attachments" + +# LLM models +COMMUNITY_agents_SUFFIX = "-community-agents" + +# File names +PYTHON_CONFIGURATION_FILE = "pyproject.toml" +UIPATH_CONFIG_FILE = "uipath.json" +UIPATH_BINDINGS_FILE = "bindings.json" +ENTRY_POINTS_FILE = "entry-points.json" +STUDIO_METADATA_FILE = "studio_metadata.json" + + +# Folder names +LEGACY_EVAL_FOLDER = "evals" +EVALS_FOLDER = "evaluations" +# Evaluators +CUSTOM_EVALUATOR_PREFIX = "file://" diff --git a/packages/uipath-platform/src/uipath/platform/common/dynamic_schema.py b/packages/uipath-platform/src/uipath/platform/common/dynamic_schema.py new file mode 100644 index 000000000..c07f94198 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/dynamic_schema.py @@ -0,0 +1,128 @@ +"""Json schema to dynamic pydantic model.""" + +from enum import Enum +from typing import Any, Type, Union + +from pydantic import BaseModel, Field, create_model + + +def jsonschema_to_pydantic( + schema: dict[str, Any], +) -> Type[BaseModel]: + """Convert a schema dict to a pydantic model. + + Modified version of https://github.com/kreneskyp/jsonschema-pydantic to account for three unresolved issues. + 1. Support for title + 2. Better representation of optionals. + 3. Support for optional + + Args: + schema: JSON schema. + definitions: Definitions dict. Defaults to `$def`. + + Returns: Pydantic model. + """ + dynamic_type_counter = 0 + combined_model_counter = 0 + + def convert_type(prop: dict[str, Any]) -> Any: + nonlocal dynamic_type_counter, combined_model_counter + if "$ref" in prop: + # This is the full path. It will be updated in update_forward_refs. + return prop["$ref"].split("/")[-1].capitalize() + + if "type" in prop: + type_mapping = { + "string": str, + "number": float, + "integer": int, + "boolean": bool, + "array": list, + "object": dict, + "null": None, + } + + type_ = prop["type"] + + if "enum" in prop: + dynamic_members = { + f"KEY_{i}": value for i, value in enumerate(prop["enum"]) + } + + base_type: Any = type_mapping.get(type_, Any) + + class DynamicEnum(base_type, Enum): + pass + + type_ = DynamicEnum(prop.get("title", "DynamicEnum"), dynamic_members) # type: ignore[call-arg] # explicit ignore + return type_ + elif type_ == "array": + item_type: Any = convert_type(prop.get("items", {})) + return list[item_type] # noqa F821 + elif type_ == "object": + if "properties" in prop: + if "title" in prop and prop["title"]: + title = prop["title"] + else: + title = f"DynamicType_{dynamic_type_counter}" + dynamic_type_counter += 1 + + fields: dict[str, Any] = {} + required_fields = prop.get("required", []) + + for name, property in prop.get("properties", {}).items(): + pydantic_type = convert_type(property) + field_kwargs = {} + if "default" in property: + field_kwargs["default"] = property["default"] + if name not in required_fields: + # Note that we do not make this optional. This is due to a limitation in Pydantic/Python. + # If we convert the Optional type back to json schema, it is represented as type | None. + # pydantic_type = Optional[pydantic_type] + + if "default" not in field_kwargs: + field_kwargs["default"] = None + if "description" in property: + field_kwargs["description"] = property["description"] + if "title" in property: + field_kwargs["title"] = property["title"] + + fields[name] = (pydantic_type, Field(**field_kwargs)) + + object_model = create_model(title, **fields) + if "description" in prop: + object_model.__doc__ = prop["description"] + return object_model + else: + return dict[str, Any] + else: + return type_mapping.get(type_, Any) + + elif "allOf" in prop: + combined_fields = {} + for sub_schema in prop["allOf"]: + model = convert_type(sub_schema) + combined_fields.update(model.__annotations__) + combined_model = create_model( + f"CombinedModel_{combined_model_counter}", **combined_fields + ) + combined_model_counter += 1 + return combined_model + + elif "anyOf" in prop: + unioned_types = tuple( + convert_type(sub_schema) for sub_schema in prop["anyOf"] + ) + return Union[unioned_types] + elif prop == {} or "type" not in prop: + return Any + else: + raise ValueError(f"Unsupported schema: {prop}") + + namespace: dict[str, Any] = {} + for name, definition in schema.get("$defs", schema.get("definitions", {})).items(): + model = convert_type(definition) + namespace[name.capitalize()] = model + model = convert_type(schema) + model.model_rebuild(force=True, _types_namespace=namespace) + return model diff --git a/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py b/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py new file mode 100644 index 000000000..d406f3c38 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/interrupt_models.py @@ -0,0 +1,237 @@ +"""Models for interrupt operations in UiPath platform.""" + +from typing import Annotated, Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from uipath.platform.context_grounding.context_grounding_index import ( + ContextGroundingIndex, +) + +from ..action_center.tasks import Task, TaskRecipient +from ..attachments import Attachment +from ..context_grounding import ( + BatchTransformCreationResponse, + BatchTransformOutputColumn, + CitationMode, + DeepRagCreationResponse, + EphemeralIndexUsage, +) +from ..documents import ( + ActionPriority, + ExtractionResponseIXP, + FileContent, + StartExtractionResponse, +) +from ..documents.documents import StartExtractionValidationResponse +from ..orchestrator.job import Job + + +class InvokeProcess(BaseModel): + """Model representing a process invocation.""" + + name: str + process_folder_path: str | None = None + process_folder_key: str | None = None + input_arguments: dict[str, Any] | None + attachments: list[Attachment] | None = None + + +class WaitJob(BaseModel): + """Model representing a wait job operation.""" + + job: Job + process_folder_path: str | None = None + process_folder_key: str | None = None + + +class InvokeProcessRaw(InvokeProcess): + """Model representing a raw process invocation (returns job without state validation).""" + + pass + + +class WaitJobRaw(WaitJob): + """Model representing a raw wait job operation (returns job without state validation).""" + + pass + + +class CreateTask(BaseModel): + """Model representing an action creation.""" + + title: str + data: dict[str, Any] | None = None + assignee: str | None = "" + recipient: TaskRecipient | None = None + app_name: str | None = None + app_folder_path: str | None = None + app_folder_key: str | None = None + app_key: str | None = None + priority: str | None = None + labels: list[str] | None = None + is_actionable_message_enabled: bool | None = None + actionable_message_metadata: dict[str, Any] | None = None + source_name: str = "Agent" + + +class CreateEscalation(CreateTask): + """Model representing an escalation creation.""" + + pass + + +class WaitTask(BaseModel): + """Model representing a wait action operation.""" + + action: Task + app_folder_path: str | None = None + app_folder_key: str | None = None + app_name: str | None = None + recipient: TaskRecipient | None = None + + +class WaitEscalation(WaitTask): + """Model representing a wait escalation operation.""" + + pass + + +class CreateDeepRag(BaseModel): + """Model representing a Deep RAG task creation.""" + + name: str + index_name: Annotated[str, Field(max_length=512)] | None = None + index_id: Annotated[str, Field(max_length=512)] | None = None + prompt: Annotated[str, Field(max_length=250000)] + glob_pattern: Annotated[str, Field(max_length=512, default="*")] = "**" + citation_mode: CitationMode = CitationMode.SKIP + index_folder_key: str | None = None + index_folder_path: str | None = None + is_ephemeral_index: bool | None = None + + @model_validator(mode="after") + def validate_ephemeral_index_requires_index_id(self) -> "CreateDeepRag": + """Validate that if it is an ephemeral index that it is using index id.""" + if self.is_ephemeral_index is True and self.index_id is None: + raise ValueError("Index id must be provided for an ephemeral index") + return self + + +class WaitDeepRag(BaseModel): + """Model representing a wait Deep RAG task.""" + + deep_rag: DeepRagCreationResponse + index_folder_path: str | None = None + index_folder_key: str | None = None + + +class CreateEphemeralIndex(BaseModel): + """Model representing a Ephemeral Index task creation.""" + + usage: EphemeralIndexUsage + attachments: list[str] + + +class WaitEphemeralIndex(BaseModel): + """Model representing a wait Ephemeral Index task.""" + + index: ContextGroundingIndex + + +class CreateBatchTransform(BaseModel): + """Model representing a Batch Transform task creation.""" + + name: str + index_name: str | None = None + index_id: Annotated[str, Field(max_length=512)] | None = None + prompt: Annotated[str, Field(max_length=250000)] + output_columns: list[BatchTransformOutputColumn] + storage_bucket_folder_path_prefix: Annotated[str | None, Field(max_length=512)] = ( + None + ) + enable_web_search_grounding: bool = False + destination_path: str + index_folder_key: str | None = None + index_folder_path: str | None = None + is_ephemeral_index: bool | None = None + + @model_validator(mode="after") + def validate_ephemeral_index_requires_index_id(self) -> "CreateBatchTransform": + """Validate that if it is an ephemeral index that it is using index id.""" + if self.is_ephemeral_index is True and self.index_id is None: + raise ValueError("Index id must be provided for an ephemeral index") + return self + + +class WaitBatchTransform(BaseModel): + """Model representing a wait Batch Transform task.""" + + batch_transform: BatchTransformCreationResponse + index_folder_path: str | None = None + index_folder_key: str | None = None + + +class InvokeSystemAgent(BaseModel): + """Model representing a system agent job invocation.""" + + agent_name: str + entrypoint: str + input_arguments: dict[str, Any] | None = None + folder_path: str | None = None + folder_key: str | None = None + + +class WaitSystemAgent(BaseModel): + """Model representing a wait system agent job invocation.""" + + job_key: str + process_folder_path: str | None = None + process_folder_key: str | None = None + + +class DocumentExtraction(BaseModel): + """Model representing a document extraction task creation.""" + + project_name: str + tag: str + file: FileContent | None = None + file_path: str | None = None + + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) + + @model_validator(mode="after") + def validate_exactly_one_file_source(self) -> "DocumentExtraction": + """Validate that exactly one of file or file_path is provided.""" + if (self.file is None) == (self.file_path is None): + raise ValueError( + "Exactly one of 'file' or 'file_path' must be provided, not both or neither" + ) + return self + + +class WaitDocumentExtraction(BaseModel): + """Model representing a wait document extraction task creation.""" + + extraction: StartExtractionResponse + + +class DocumentExtractionValidation(BaseModel): + """Model representing a document extraction task creation.""" + + extraction_response: ExtractionResponseIXP + action_title: str + action_catalog: str | None = None + action_priority: ActionPriority | None = None + action_folder: str | None = None + storage_bucket_name: str | None = None + storage_bucket_directory_path: str | None = None + + +class WaitDocumentExtractionValidation(BaseModel): + """Model representing a wait document extraction task creation.""" + + extraction_validation: StartExtractionValidationResponse + task_url: str | None = None diff --git a/packages/uipath-platform/src/uipath/platform/common/paging.py b/packages/uipath-platform/src/uipath/platform/common/paging.py new file mode 100644 index 000000000..5fe41d0cc --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/paging.py @@ -0,0 +1,63 @@ +"""Pagination result types for UiPath SDK.""" + +from dataclasses import dataclass +from typing import Generic, Iterator, List, Optional, TypeVar + +__all__ = ["PagedResult"] + +T = TypeVar("T") + + +@dataclass(frozen=True) +class PagedResult(Generic[T]): + """Container for a single page of results from a paginated API. + + Attributes: + items: The list of items in this page + continuation_token: Token to fetch next page (REST APIs) + has_more: Whether more results likely exist (OData APIs) + skip: Number of items skipped (OData APIs) + top: Maximum items requested (OData APIs) + + Example: + # Offset-based pagination (OData) + skip = 0 + while True: + result = sdk.buckets.list(skip=skip, top=100) + for bucket in result.items: + process(bucket) + if not result.has_more: + break + skip += 100 + + # Cursor-based pagination (REST) + token = None + while True: + result = sdk.buckets.list_files( + name="my-storage", + continuation_token=token + ) + for file in result.items: + process(file) + if not result.continuation_token: + break + token = result.continuation_token + """ + + items: List[T] + continuation_token: Optional[str] = None + has_more: Optional[bool] = None + skip: Optional[int] = None + top: Optional[int] = None + + def __iter__(self) -> Iterator[T]: + """Allow iteration over items directly.""" + return iter(self.items) + + def __len__(self) -> int: + """Return the number of items in this page.""" + return len(self.items) + + def __bool__(self) -> bool: + """Return True if page contains items.""" + return bool(self.items) diff --git a/packages/uipath-platform/src/uipath/platform/common/retry.py b/packages/uipath-platform/src/uipath/platform/common/retry.py new file mode 100644 index 000000000..b4395c822 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/retry.py @@ -0,0 +1,93 @@ +"""Retry utilities for UiPath platform HTTP requests. + +Provides generic, reusable retry helpers (status codes, header parsing, backoff) +and platform-specific retry strategy for BaseService. +""" + +import random + +from httpx import ConnectTimeout, HTTPStatusError, Response, TimeoutException +from tenacity import RetryCallState + +from ..errors import EnrichedException + +RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({408, 429, 502, 503, 504}) + + +def parse_retry_after(header_value: str) -> float | None: + """Parse a numeric Retry-After header value. + + Only handles numeric durations (seconds). HTTP-date values return None. + Negative values return None. + """ + try: + seconds = float(header_value.strip()) + if seconds < 0: + return None + return seconds + except (ValueError, AttributeError): + return None + + +def exponential_backoff_with_jitter(attempt: int, initial: float) -> float: + """Calculate exponential backoff with jitter. + + Returns ``initial * 2^(attempt-1) + uniform(0, 1.0)``. + """ + exponent = attempt - 1 + exponential = initial * (2**exponent) + jitter = random.uniform(0, 1.0) + return exponential + jitter + + +def extract_retry_after_from_chain(exception: BaseException) -> float | None: + """Walk the exception __cause__ chain looking for a Retry-After header. + + Supports ``HTTPStatusError`` (has ``.response.headers``) and + ``EnrichedException`` whose ``__cause__`` is an ``HTTPStatusError``. + """ + current: BaseException | None = exception + while current is not None: + if isinstance(current, HTTPStatusError): + header = current.response.headers.get("retry-after") + if header: + parsed = parse_retry_after(header) + if parsed is not None: + return parsed + current = current.__cause__ + return None + + +MAX_RETRY_ATTEMPTS: int = 5 +_MAX_RETRY_AFTER_DELAY: float = 120.0 +_MAX_BACKOFF_DELAY: float = 10.0 +_INITIAL_BACKOFF: float = 1.0 + + +def is_retryable_platform_exception(exception: BaseException) -> bool: + """Return True if the exception is transient and should be retried.""" + if isinstance(exception, (ConnectTimeout, TimeoutException)): + return True + if isinstance(exception, EnrichedException): + return exception.status_code in RETRYABLE_STATUS_CODES + return False + + +def is_retryable_response(response: Response) -> bool: + """Return True if the response has a server error status code (5xx).""" + return 500 <= response.status_code < 600 + + +def platform_wait_strategy(retry_state: RetryCallState) -> float: + """Wait strategy that honors Retry-After, falling back to exponential backoff.""" + if retry_state.outcome is not None: + exception = retry_state.outcome.exception() + if exception is not None: + retry_after = extract_retry_after_from_chain(exception) + if retry_after is not None: + return min(retry_after, _MAX_RETRY_AFTER_DELAY) + + backoff = exponential_backoff_with_jitter( + retry_state.attempt_number, _INITIAL_BACKOFF + ) + return min(backoff, _MAX_BACKOFF_DELAY) diff --git a/src/uipath/_utils/validation.py b/packages/uipath-platform/src/uipath/platform/common/validation.py similarity index 100% rename from src/uipath/_utils/validation.py rename to packages/uipath-platform/src/uipath/platform/common/validation.py diff --git a/packages/uipath-platform/src/uipath/platform/connections/__init__.py b/packages/uipath-platform/src/uipath/platform/connections/__init__.py new file mode 100644 index 000000000..2a29b94ae --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/connections/__init__.py @@ -0,0 +1,26 @@ +"""UiPath Connections Models. + +This module contains models related to UiPath Connections service. +""" + +from ._connections_service import ConnectionsService +from .connections import ( + ActivityMetadata, + ActivityParameterLocationInfo, + Connection, + ConnectionMetadata, + ConnectionToken, + ConnectionTokenType, + EventArguments, +) + +__all__ = [ + "ConnectionsService", + "ActivityMetadata", + "ActivityParameterLocationInfo", + "Connection", + "ConnectionMetadata", + "ConnectionToken", + "ConnectionTokenType", + "EventArguments", +] diff --git a/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py b/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py new file mode 100644 index 000000000..56b52ac71 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/connections/_connections_service.py @@ -0,0 +1,823 @@ +import json +import logging +from typing import Any, Dict, List, Optional +from urllib.parse import parse_qsl, quote, urlsplit + +from httpx import Response +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import header_folder +from ..common._models import Endpoint, RequestSpec +from ..orchestrator._folder_service import FolderService +from .connections import ( + ActivityMetadata, + Connection, + ConnectionMetadata, + ConnectionToken, + ConnectionTokenType, + EventArguments, +) + +logger: logging.Logger = logging.getLogger("uipath") + + +class ConnectionsService(BaseService): + """Service for managing UiPath external service connections. + + This service provides methods to retrieve direct connection information retrieval + and secure token management. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + + @resource_override("connection", resource_identifier="key") + @traced( + name="connections_retrieve", + run_type="uipath", + hide_output=True, + ) + def retrieve(self, key: str) -> Connection: + """Retrieve connection details by its key. + + This method fetches the configuration and metadata for a connection, + which can be used to establish communication with an external service. + + Args: + key (str): The unique identifier of the connection to retrieve. + + Returns: + Connection: The connection details, including configuration parameters + and authentication information. + """ + spec = self._retrieve_spec(key) + response = self.request(spec.method, url=spec.endpoint) + return Connection.model_validate(response.json()) + + @traced( + name="connections_metadata", + run_type="uipath", + hide_output=True, + ) + def metadata( + self, + 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, + ) -> ConnectionMetadata: + """Synchronously retrieve connection API metadata. + + This method fetches the metadata for a connection. When parameters are provided, + it automatically fetches JIT (Just-In-Time) metadata for cascading fields in a loop, + following action URLs up to a maximum depth. + + Args: + element_instance_id (int): The element instance ID of the connection. + connector_key (str): The connector key (e.g., 'uipath-atlassian-jira', 'uipath-slack'). + tool_path (str): The tool path to retrieve metadata for. + parameters (Optional[Dict[str, str]]): Parameter values. When provided, triggers + automatic JIT fetching for cascading fields. + schema_mode (bool): Whether or not to represent the output schema in the response fields. + max_jit_depth (int): The maximum depth of the JIT resolution loop. + + Returns: + ConnectionMetadata: The connection metadata. + + Examples: + >>> metadata = sdk.connections.metadata( + ... element_instance_id=123, + ... connector_key="uipath-atlassian-jira", + ... tool_path="Issue", + ... parameters={"projectId": "PROJ-123"} # Optional + ... ) + """ + spec = self._metadata_spec( + element_instance_id, connector_key, tool_path, schema_mode + ) + response = self.request( + spec.method, url=spec.endpoint, params=spec.params, headers=spec.headers + ) + data = response.json() + metadata = ConnectionMetadata.model_validate(data) + + last_action_url = None + depth = 0 + + while ( + parameters + and (action_url := self._get_jit_action_url(metadata)) + and depth < max_jit_depth + ): + # Stop if we're about to call the same URL template again + if action_url == last_action_url: + break + + last_action_url = action_url + depth += 1 + + jit_spec = self._metadata_jit_spec( + element_instance_id, action_url, parameters, schema_mode + ) + jit_response = self.request( + jit_spec.method, + url=jit_spec.endpoint, + params=jit_spec.params, + headers=jit_spec.headers, + ) + data = jit_response.json() + metadata = ConnectionMetadata.model_validate(data) + + return metadata + + @traced(name="connections_list", run_type="uipath") + def list( + self, + *, + 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, + ) -> List[Connection]: + """Lists all connections with optional filtering. + + Args: + name: Optional connection name to filter (supports partial matching) + folder_path: Optional folder path for filtering connections + folder_key: Optional folder key (mutually exclusive with folder_path) + connector_key: Optional connector key to filter by specific connector type + skip: Number of records to skip (for pagination) + top: Maximum number of records to return + + Returns: + List[Connection]: List of connection instances + + Raises: + ValueError: If both folder_path and folder_key are provided together, or if + folder_path is provided but cannot be resolved to a folder_key + + Examples: + >>> # List all connections + >>> connections = sdk.connections.list() + + >>> # Find connections by name + >>> salesforce_conns = sdk.connections.list(name="Salesforce") + + >>> # List all Slack connections in Finance folder + >>> connections = sdk.connections.list( + ... folder_path="Finance", + ... connector_key="uipath-slack" + ... ) + """ + spec = self._list_spec( + name=name, + folder_key=self._folders_service.retrieve_folder_key(folder_path), + connector_key=connector_key, + skip=skip, + top=top, + ) + response = self.request( + spec.method, url=spec.endpoint, params=spec.params, headers=spec.headers + ) + + return self._parse_and_validate_list_response(response) + + @traced(name="connections_list", run_type="uipath") + async def list_async( + self, + *, + 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, + ) -> List[Connection]: + """Asynchronously lists all connections with optional filtering. + + Args: + name: Optional connection name to filter (supports partial matching) + folder_path: Optional folder path for filtering connections + folder_key: Optional folder key (mutually exclusive with folder_path) + connector_key: Optional connector key to filter by specific connector type + skip: Number of records to skip (for pagination) + top: Maximum number of records to return + + Returns: + List[Connection]: List of connection instances + + Raises: + ValueError: If both folder_path and folder_key are provided together, or if + folder_path is provided but cannot be resolved to a folder_key + + Examples: + >>> # List all connections + >>> connections = await sdk.connections.list_async() + + >>> # Find connections by name + >>> salesforce_conns = await sdk.connections.list_async(name="Salesforce") + + >>> # List all Slack connections in Finance folder + >>> connections = await sdk.connections.list_async( + ... folder_path="Finance", + ... connector_key="uipath-slack" + ... ) + """ + spec = self._list_spec( + name=name, + folder_key=await self._folders_service.retrieve_folder_key_async( + folder_path + ), + connector_key=connector_key, + skip=skip, + top=top, + ) + response = await self.request_async( + spec.method, url=spec.endpoint, params=spec.params, headers=spec.headers + ) + + return self._parse_and_validate_list_response(response) + + @resource_override("connection", resource_identifier="key") + @traced( + name="connections_retrieve", + run_type="uipath", + hide_output=True, + ) + async def retrieve_async(self, key: str) -> Connection: + """Asynchronously retrieve connection details by its key. + + This method fetches the configuration and metadata for a connection, + which can be used to establish communication with an external service. + + Args: + key (str): The unique identifier of the connection to retrieve. + + Returns: + Connection: The connection details, including configuration parameters + and authentication information. + """ + spec = self._retrieve_spec(key) + response = await self.request_async(spec.method, url=spec.endpoint) + return Connection.model_validate(response.json()) + + @traced( + name="connections_metadata", + run_type="uipath", + hide_output=True, + ) + async def metadata_async( + self, + 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, + ) -> ConnectionMetadata: + """Asynchronously retrieve connection API metadata. + + This method fetches the metadata for a connection. When parameters are provided, + it automatically fetches JIT (Just-In-Time) metadata for cascading fields in a loop, + following action URLs up to a maximum depth. + + Args: + element_instance_id (int): The element instance ID of the connection. + connector_key (str): The connector key (e.g., 'uipath-atlassian-jira', 'uipath-slack'). + tool_path (str): The tool path to retrieve metadata for. + parameters (Optional[Dict[str, str]]): Parameter values. When provided, triggers + automatic JIT fetching for cascading fields. + schema_mode (bool): Whether or not to represent the output schema in the response fields. + max_jit_depth (int): The maximum depth of the JIT resolution loop. + + Returns: + ConnectionMetadata: The connection metadata. + + Examples: + >>> metadata = await sdk.connections.metadata_async( + ... element_instance_id=123, + ... connector_key="uipath-atlassian-jira", + ... tool_path="Issue", + ... parameters={"projectId": "PROJ-123"} # Optional + ... ) + """ + spec = self._metadata_spec( + element_instance_id, connector_key, tool_path, schema_mode + ) + response = await self.request_async( + spec.method, url=spec.endpoint, params=spec.params, headers=spec.headers + ) + data = response.json() + metadata = ConnectionMetadata.model_validate(data) + + last_action_url = None + depth = 0 + + while ( + parameters + and (action_url := self._get_jit_action_url(metadata)) + and depth < max_jit_depth + ): + # Stop if we're about to call the same URL template again + if action_url == last_action_url: + break + + last_action_url = action_url + depth += 1 + + jit_spec = self._metadata_jit_spec( + element_instance_id, action_url, parameters, schema_mode + ) + jit_response = await self.request_async( + jit_spec.method, + url=jit_spec.endpoint, + params=jit_spec.params, + headers=jit_spec.headers, + ) + data = jit_response.json() + metadata = ConnectionMetadata.model_validate(data) + + return metadata + + @traced( + name="connections_retrieve_token", + run_type="uipath", + hide_output=True, + ) + def retrieve_token( + self, key: str, token_type: ConnectionTokenType = ConnectionTokenType.DIRECT + ) -> ConnectionToken: + """Retrieve an authentication token for a connection. + + This method obtains a fresh authentication token that can be used to + communicate with the external service. This is particularly useful for + services that use token-based authentication. + + Args: + key (str): The unique identifier of the connection. + token_type (ConnectionTokenType): The token type to use. + + Returns: + ConnectionToken: The authentication token details, including the token + value and any associated metadata. + """ + spec = self._retrieve_token_spec(key, token_type) + response = self.request(spec.method, url=spec.endpoint, params=spec.params) + return ConnectionToken.model_validate(response.json()) + + @traced( + name="connections_retrieve_token", + run_type="uipath", + hide_output=True, + ) + async def retrieve_token_async( + self, key: str, token_type: ConnectionTokenType = ConnectionTokenType.DIRECT + ) -> ConnectionToken: + """Asynchronously retrieve an authentication token for a connection. + + This method obtains a fresh authentication token that can be used to + communicate with the external service. This is particularly useful for + services that use token-based authentication. + + Args: + key (str): The unique identifier of the connection. + token_type (ConnectionTokenType): The token type to use. + + Returns: + ConnectionToken: The authentication token details, including the token + value and any associated metadata. + """ + spec = self._retrieve_token_spec(key, token_type) + response = await self.request_async( + spec.method, url=spec.endpoint, params=spec.params + ) + return ConnectionToken.model_validate(response.json()) + + @traced( + name="connections_retrieve_event_payload", + run_type="uipath", + ) + def retrieve_event_payload(self, event_args: EventArguments) -> Dict[str, Any]: + """Retrieve event payload from UiPath Integration Service. + + Args: + event_args (EventArguments): The event arguments. Should be passed along from the job's input. + + Returns: + Dict[str, Any]: The event payload data + """ + if not event_args.additional_event_data: + raise ValueError("additional_event_data is required") + + # Parse additional event data to get event id + event_data = json.loads(event_args.additional_event_data) + + event_id = None + if "processedEventId" in event_data: + event_id = event_data["processedEventId"] + elif "rawEventId" in event_data: + event_id = event_data["rawEventId"] + else: + raise ValueError("Event Id not found in additional event data") + + # Build request URL using connection token's API base URI + spec = self._retrieve_event_payload_spec("v1", event_id) + + response = self.request(spec.method, url=spec.endpoint) + + return response.json() + + @traced( + name="connections_retrieve_event_payload", + run_type="uipath", + ) + async def retrieve_event_payload_async( + self, event_args: EventArguments + ) -> Dict[str, Any]: + """Retrieve event payload from UiPath Integration Service. + + Args: + event_args (EventArguments): The event arguments. Should be passed along from the job's input. + + Returns: + Dict[str, Any]: The event payload data + """ + if not event_args.additional_event_data: + raise ValueError("additional_event_data is required") + + # Parse additional event data to get event id + event_data = json.loads(event_args.additional_event_data) + + event_id = None + if "processedEventId" in event_data: + event_id = event_data["processedEventId"] + elif "rawEventId" in event_data: + event_id = event_data["rawEventId"] + else: + raise ValueError("Event Id not found in additional event data") + + # Build request URL using connection token's API base URI + spec = self._retrieve_event_payload_spec("v1", event_id) + + response = await self.request_async(spec.method, url=spec.endpoint) + + return response.json() + + def _retrieve_event_payload_spec(self, version: str, event_id: str) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/elements_/{version}/events/{event_id}"), + ) + + def _retrieve_spec(self, key: str) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/connections_/api/v1/Connections/{key}"), + ) + + def _metadata_spec( + self, + element_instance_id: int, + connector_key: str, + tool_path: str, + schema_mode: bool, + ) -> RequestSpec: + metadata_endpoint_url = f"/elements_/v3/element/instances/{element_instance_id}/elements/{connector_key}/objects/{tool_path}/metadata" + return RequestSpec( + method="GET", + endpoint=Endpoint(metadata_endpoint_url), + headers={ + "accept": "application/schema+json" + if schema_mode + else "application/json" + }, + ) + + def _metadata_jit_spec( + self, + element_instance_id: int, + dynamic_path: str, + parameters: Dict[str, str], + schema_mode: bool, + ) -> RequestSpec: + """Build request spec for JIT metadata with dynamic path parameter substitution. + + For example, if the dynamic path is "elements/jira/projects/{projectId}/issues", and the parameters + are {"projectId": "PROJ-123"}, the resolved path will be "elements/jira/projects/PROJ-123/issues". + """ + for key, value in parameters.items(): + dynamic_path = dynamic_path.replace( + f"{{{key}}}", quote(str(value), safe="") + ) + split = urlsplit(dynamic_path.lstrip("/")) + query_params = dict(parse_qsl(split.query)) + + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"/elements_/v3/element/instances/{element_instance_id}/{split.path}" + ), + params=query_params, + headers={ + "accept": "application/schema+json" + if schema_mode + else "application/json" + }, + ) + + def _get_jit_action_url( + self, connection_metadata: ConnectionMetadata + ) -> Optional[str]: + """Return the URL of the JIT action that should be triggered dynamically.""" + if "method" not in connection_metadata.metadata: + return None + + methods = connection_metadata.metadata["method"] + actions = [ + action + for method_data in methods.values() + for action in method_data.get("design", {}).get("actions", []) + if action.get("actionType") == "api" + ] + return actions[0].get("apiConfiguration", {}).get("url") if actions else None + + def _retrieve_token_spec( + self, key: str, token_type: ConnectionTokenType = ConnectionTokenType.DIRECT + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/connections_/api/v1/Connections/{key}/token"), + params={"tokenType": token_type.value}, + ) + + def _parse_and_validate_list_response(self, response: Response) -> List[Connection]: + """Parse and validate the list response from the API. + + Handles both OData response format (with 'value' field) and raw list responses. + + Args: + response: The HTTP response from the API + + Returns: + List of validated Connection instances + """ + data = response.json() + + # Handle both OData responses (dict with 'value') and raw list responses + if isinstance(data, dict): + connections_data = data.get("value", []) + elif isinstance(data, list): + connections_data = data + else: + connections_data = [] + + return [Connection.model_validate(conn) for conn in connections_data] + + def _list_spec( + self, + name: Optional[str] = None, + folder_key: Optional[str] = None, + connector_key: Optional[str] = None, + skip: Optional[int] = None, + top: Optional[int] = None, + ) -> RequestSpec: + """Build the request specification for listing connections. + + Args: + name: Optional connection name to filter (supports partial matching) + folder_key: Optional folder key + connector_key: Optional connector key to filter by specific connector type + skip: Number of records to skip (for pagination) + top: Maximum number of records to return + + Returns: + RequestSpec with endpoint, params, and headers configured + + Raises: + ValueError: If both folder_path and folder_key are provided together, or if + folder_path is provided but cannot be resolved to a folder_key + """ + # Build OData filters + filters = [] + if name: + # Escape single quotes in name for OData + escaped_name = name.replace("'", "''") + filters.append(f"contains(Name, '{escaped_name}')") + if connector_key: + filters.append(f"connector/key eq '{connector_key}'") + + params = {} + if filters: + params["$filter"] = " and ".join(filters) + if skip is not None: + params["$skip"] = str(skip) + if top is not None: + params["$top"] = str(top) + + # Always expand connector and folder for complete information + params["$expand"] = "connector,folder" + + # Use header_folder which handles validation + headers = header_folder(folder_key, None) + + return RequestSpec( + method="GET", + endpoint=Endpoint("/connections_/api/v1/Connections"), + params=params, + headers=headers, + ) + + @traced( + name="activity_invoke", + run_type="uipath", + ) + def invoke_activity( + self, + activity_metadata: ActivityMetadata, + connection_id: str, + activity_input: Dict[str, Any], + ) -> Any: + """Invoke an activity synchronously. + + Args: + activity_metadata: Metadata describing the activity to invoke + connection_id: The ID of the connection + activity_input: Input parameters for the activity + + Returns: + The response from the activity + + Raises: + ValueError: If required parameters are missing or invalid + RuntimeError: If the HTTP request fails or returns an error status + """ + connection = self.retrieve(connection_id) + folder_key = connection.folder.get("key") if connection.folder else None + + spec = self._build_activity_request_spec( + activity_metadata, connection.id, activity_input, folder_key + ) + + response = self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + params=spec.params, + json=spec.json, + files=spec.files, + ) + + return response.json() + + @traced( + name="activity_invoke", + run_type="uipath", + ) + async def invoke_activity_async( + self, + activity_metadata: ActivityMetadata, + connection_id: str, + activity_input: Dict[str, Any], + ) -> Any: + """Invoke an activity asynchronously. + + Args: + activity_metadata: Metadata describing the activity to invoke + connection_id: The ID of the connection + activity_input: Input parameters for the activity + + Returns: + The response from the activity + + Raises: + ValueError: If required parameters are missing or invalid + RuntimeError: If the HTTP request fails or returns an error status + """ + connection = await self.retrieve_async(connection_id) + folder_key = connection.folder.get("key") if connection.folder else None + + spec = self._build_activity_request_spec( + activity_metadata, connection.id, activity_input, folder_key + ) + + response = await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + params=spec.params, + json=spec.json, + files=spec.files, + ) + + return response.json() + + def _build_activity_request_spec( + self, + activity_metadata: ActivityMetadata, + connection_id: str, + activity_input: Dict[str, Any], + folder_key: Optional[str] = None, + ) -> RequestSpec: + """Build the request specification for invoking an activity.""" + url = f"/elements_/v3/element/instances/{connection_id}{activity_metadata.object_path}" + + query_params: Dict[str, str] = {} + path_params: Dict[str, str] = {} + header_params: Dict[str, str] = {} + multipart_params: Dict[str, Any] = {} + body_fields: Dict[str, Any] = {} + + # iterating through input items instead of parameters because input will usually not contain all parameters + # and we don't want to add unused optional parameters to the request + for param_name, value in activity_input.items(): + if value is None: + continue + + value_str = str(value) if not isinstance(value, str) else value + + if param_name in activity_metadata.parameter_location_info.query_params: + query_params[param_name] = value_str + elif param_name in activity_metadata.parameter_location_info.path_params: + path_params[param_name] = value_str + elif param_name in activity_metadata.parameter_location_info.header_params: + header_params[param_name] = value_str + elif ( + param_name in activity_metadata.parameter_location_info.multipart_params + ): + multipart_params[param_name] = value + elif param_name in activity_metadata.parameter_location_info.body_fields: + body_fields[param_name] = value + else: + raise ValueError( + f"Parameter {param_name} does not exist in activity metadata." + ) + + # path parameter handling + for key, value in path_params.items(): + url = url.replace(f"{{{key}}}", value) + + # header parameter handling + headers = { + "x-uipath-originator": "uipath-python", + "x-uipath-source": "uipath-python", + **header_folder(folder_key, None), + **header_params, + } + + # body and files handling + json_data: Dict[str, Any] | None = None + files: Dict[str, Any] | None = None + + # multipart/form-data for file uploads + json_section = activity_metadata.json_body_section or "body" + if "multipart" in activity_metadata.content_type.lower(): + files = {} + + for key, val in multipart_params.items(): + # json body itself appears as a multipart param as well + # instead of making assumptions on whether or not it's present, we'll handle it defensively + if key == json_section: + continue + # files not supported yet supported so this will likely not work + files[key] = ( + key, + val, + None, + ) # probably needs to extract content type from val since IS metadata doesn't provide it + + files[json_section] = ( + "", + json.dumps(body_fields), + "application/json", + ) + + # application/json for regular requests + elif "json" in activity_metadata.content_type.lower(): + json_data = body_fields + else: + raise ValueError( + f"Unsupported content type: {activity_metadata.content_type}" + ) + + return RequestSpec( + method=activity_metadata.method_name, + endpoint=Endpoint(url), + headers=headers, + params=query_params, + json=json_data, + files=files, + ) diff --git a/packages/uipath-platform/src/uipath/platform/connections/connections.py b/packages/uipath-platform/src/uipath/platform/connections/connections.py new file mode 100644 index 000000000..394861708 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/connections/connections.py @@ -0,0 +1,108 @@ +"""Models for connections in the UiPath platform.""" + +from enum import Enum +from typing import Any, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ConnectionMetadata(BaseModel): + """Metadata about a connection.""" + + fields: dict[str, Any] = Field(default_factory=dict, alias="fields") + metadata: dict[str, Any] = Field(default_factory=dict, alias="metadata") + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class Connection(BaseModel): + """Model representing a connection in the UiPath platform.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + id: Optional[str] = None + name: Optional[str] = None + owner: Optional[str] = None + create_time: Optional[str] = Field(default=None, alias="createTime") + update_time: Optional[str] = Field(default=None, alias="updateTime") + state: Optional[str] = None + api_base_uri: Optional[str] = Field(default=None, alias="apiBaseUri") + element_instance_id: int = Field(alias="elementInstanceId") + connector: Optional[Any] = None + is_default: Optional[bool] = Field(default=None, alias="isDefault") + last_used_time: Optional[str] = Field(default=None, alias="lastUsedTime") + connection_identity: Optional[str] = Field(default=None, alias="connectionIdentity") + polling_interval_in_minutes: Optional[int] = Field( + default=None, alias="pollingIntervalInMinutes" + ) + folder: Optional[Any] = None + element_version: Optional[str] = Field(default=None, alias="elementVersion") + + +class ConnectionTokenType(str, Enum): + """Enum representing types of connection tokens.""" + + DIRECT = "direct" + BEARER = "bearer" + + +class ConnectionToken(BaseModel): + """Model representing a connection token in the UiPath platform.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + access_token: str = Field(alias="accessToken") + token_type: Optional[str] = Field(default=None, alias="tokenType") + scope: Optional[str] = None + expires_in: Optional[int] = Field(default=None, alias="expiresIn") + api_base_uri: Optional[str] = Field(default=None, alias="apiBaseUri") + element_instance_id: Optional[int] = Field(default=None, alias="elementInstanceId") + + +class EventArguments(BaseModel): + """Model representing event arguments for a connection.""" + + event_connector: Optional[str] = Field(default=None, alias="UiPathEventConnector") + event: Optional[str] = Field(default=None, alias="UiPathEvent") + event_object_type: Optional[str] = Field( + default=None, alias="UiPathEventObjectType" + ) + event_object_id: Optional[str] = Field(default=None, alias="UiPathEventObjectId") + additional_event_data: Optional[str] = Field( + default=None, alias="UiPathAdditionalEventData" + ) + + model_config = ConfigDict( + populate_by_name=True, + extra="allow", + ) + + +class ActivityParameterLocationInfo(BaseModel): + """Information about parameter location in an activity.""" + + query_params: List[str] = [] + header_params: List[str] = [] + path_params: List[str] = [] + multipart_params: List[str] = [] + body_fields: List[str] = [] + + +class ActivityMetadata(BaseModel): + """Metadata for an activity.""" + + object_path: str + method_name: str + content_type: str + parameter_location_info: ActivityParameterLocationInfo + json_body_section: Optional[str] = None diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/__init__.py b/packages/uipath-platform/src/uipath/platform/context_grounding/__init__.py new file mode 100644 index 000000000..c43d64c68 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/__init__.py @@ -0,0 +1,69 @@ +"""Init file for context grounding module.""" + +from ._context_grounding_service import ContextGroundingService +from .context_grounding import ( + BatchTransformCreationResponse, + BatchTransformOutputColumn, + BatchTransformResponse, + BatchTransformStatus, + Citation, + CitationMode, + ContextGroundingQueryResponse, + DeepRagContent, + DeepRagCreationResponse, + DeepRagResponse, + DeepRagStatus, + EphemeralIndexUsage, + IndexStatus, +) +from .context_grounding_index import ContextGroundingIndex +from .context_grounding_payloads import ( + BaseSourceConfig, + BucketDataSource, + BucketSourceConfig, + ConfluenceDataSource, + ConfluenceSourceConfig, + ConnectionSourceConfig, + CreateIndexPayload, + DropboxDataSource, + DropboxSourceConfig, + GoogleDriveDataSource, + GoogleDriveSourceConfig, + Indexer, + OneDriveDataSource, + OneDriveSourceConfig, + SourceConfig, +) + +__all__ = [ + "BatchTransformCreationResponse", + "BatchTransformOutputColumn", + "BatchTransformResponse", + "BatchTransformStatus", + "BaseSourceConfig", + "BucketDataSource", + "BucketSourceConfig", + "CitationMode", + "ConfluenceDataSource", + "ConfluenceSourceConfig", + "ConnectionSourceConfig", + "ContextGroundingIndex", + "ContextGroundingQueryResponse", + "ContextGroundingService", + "CreateIndexPayload", + "DeepRagCreationResponse", + "DeepRagResponse", + "DeepRagContent", + "DeepRagStatus", + "IndexStatus", + "DropboxDataSource", + "DropboxSourceConfig", + "EphemeralIndexUsage", + "GoogleDriveDataSource", + "GoogleDriveSourceConfig", + "Indexer", + "OneDriveDataSource", + "OneDriveSourceConfig", + "SourceConfig", + "Citation", +] diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py new file mode 100644 index 000000000..0087c5138 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/_context_grounding_service.py @@ -0,0 +1,1890 @@ +from pathlib import Path +from typing import Annotated, Any, Dict, List, Optional, Tuple, Union + +import httpx +from pydantic import Field, TypeAdapter +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext, header_folder +from ..common._models import Endpoint, RequestSpec +from ..common._ssl_context import get_httpx_client_kwargs +from ..common.constants import ( + ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE, +) +from ..errors import ( + BatchTransformNotCompleteException, + IngestionInProgressException, + UnsupportedDataSourceException, +) +from ..orchestrator._buckets_service import BucketsService +from ..orchestrator._folder_service import FolderService +from .context_grounding import ( + BatchTransformCreationResponse, + BatchTransformOutputColumn, + BatchTransformReadUriResponse, + BatchTransformResponse, + BatchTransformStatus, + CitationMode, + ContextGroundingQueryResponse, + DeepRagCreationResponse, + DeepRagResponse, + EphemeralIndexUsage, +) +from .context_grounding_index import ContextGroundingIndex +from .context_grounding_payloads import ( + AttachmentsDataSource, + BucketDataSource, + BucketSourceConfig, + ConfluenceDataSource, + ConfluenceSourceConfig, + CreateEphemeralIndexPayload, + CreateIndexPayload, + DropboxDataSource, + DropboxSourceConfig, + GoogleDriveDataSource, + GoogleDriveSourceConfig, + OneDriveDataSource, + OneDriveSourceConfig, + SourceConfig, +) + + +class ContextGroundingService(FolderContext, BaseService): + """Service for managing semantic automation contexts in UiPath. + + Context Grounding is a feature that helps in understanding and managing the + semantic context in which automation processes operate. It provides capabilities + for indexing, retrieving, and searching through contextual information that + can be used to enhance AI-enabled automation. + + This service requires a valid folder key to be set in the environment, as + context grounding operations are always performed within a specific folder + context. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService, + buckets_service: BucketsService, + ) -> None: + self._folders_service = folders_service + self._buckets_service = buckets_service + super().__init__(config=config, execution_context=execution_context) + + # 2.3.0 prefix trace name with contextgrounding + @resource_override(resource_type="index") + @traced(name="add_to_index", run_type="uipath") + def add_to_index( + self, + name: str, + blob_file_path: str, + content_type: Optional[str] = None, + content: Optional[Union[str, bytes]] = None, + source_path: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ingest_data: bool = True, + ) -> None: + """Add content to the index. + + Args: + name (str): The name of the index to add content to. + content_type (Optional[str]): The MIME type of the file. For file inputs this is computed dynamically. Default is "application/octet-stream". + blob_file_path (str): The path where the blob will be stored in the storage bucket. + content (Optional[Union[str, bytes]]): The content to be added, either as a string or bytes. + source_path (Optional[str]): The source path of the content if it is being uploaded from a file. + folder_key (Optional[str]): The key of the folder where the index resides. + folder_path (Optional[str]): The path of the folder where the index resides. + ingest_data (bool): Whether to ingest data in the index after content is uploaded. Defaults to True. + + Raises: + ValueError: If neither content nor source_path is provided, or if both are provided. + """ + if not (content or source_path): + raise ValueError("Content or source_path is required") + if content and source_path: + raise ValueError("Content and source_path are mutually exclusive") + + index = self.retrieve(name=name, folder_key=folder_key, folder_path=folder_path) + bucket_name, bucket_folder_path = self._extract_bucket_info(index) + if source_path: + self._buckets_service.upload( + name=bucket_name, + blob_file_path=blob_file_path, + source_path=source_path, + folder_path=bucket_folder_path, + content_type=content_type, + ) + else: + self._buckets_service.upload( + name=bucket_name, + content=content, + blob_file_path=blob_file_path, + folder_path=bucket_folder_path, + content_type=content_type, + ) + + if ingest_data: + self.ingest_data(index, folder_key=folder_key, folder_path=folder_path) + + # 2.3.0 prefix trace name with contextgrounding + @resource_override(resource_type="index") + @traced(name="add_to_index", run_type="uipath") + async def add_to_index_async( + self, + name: str, + blob_file_path: str, + content_type: Optional[str] = None, + content: Optional[Union[str, bytes]] = None, + source_path: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ingest_data: bool = True, + ) -> None: + """Asynchronously add content to the index. + + Args: + name (str): The name of the index to add content to. + content_type (Optional[str]): The MIME type of the file. For file inputs this is computed dynamically. Default is "application/octet-stream". + blob_file_path (str): The path where the blob will be stored in the storage bucket. + content (Optional[Union[str, bytes]]): The content to be added, either as a string or bytes. + source_path (Optional[str]): The source path of the content if it is being uploaded from a file. + folder_key (Optional[str]): The key of the folder where the index resides. + folder_path (Optional[str]): The path of the folder where the index resides. + ingest_data (bool): Whether to ingest data in the index after content is uploaded. Defaults to True. + + Raises: + ValueError: If neither content nor source_path is provided, or if both are provided. + """ + if not (content or source_path): + raise ValueError("Content or source_path is required") + if content and source_path: + raise ValueError("Content and source_path are mutually exclusive") + + index = await self.retrieve_async( + name=name, folder_key=folder_key, folder_path=folder_path + ) + bucket_name, bucket_folder_path = self._extract_bucket_info(index) + if source_path: + await self._buckets_service.upload_async( + name=bucket_name, + blob_file_path=blob_file_path, + source_path=source_path, + folder_path=bucket_folder_path, + content_type=content_type, + ) + else: + await self._buckets_service.upload_async( + name=bucket_name, + content=content, + blob_file_path=blob_file_path, + folder_path=bucket_folder_path, + content_type=content_type, + ) + + if ingest_data: + await self.ingest_data_async( + index, folder_key=folder_key, folder_path=folder_path + ) + + @resource_override(resource_type="index") + @traced(name="contextgrounding_retrieve", run_type="uipath") + def retrieve( + self, + name: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> ContextGroundingIndex: + """Retrieve context grounding index information by its name. + + Args: + name (str): The name of the context index to retrieve. + folder_key (Optional[str]): The key of the folder where the index resides. + folder_path (Optional[str]): The path of the folder where the index resides. + + Returns: + ContextGroundingIndex: The index information, including its configuration and metadata if found. + + Raises: + Exception: If no index with the given name is found. + """ + spec = self._retrieve_spec( + name, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = self.request( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + try: + return next( + ContextGroundingIndex.model_validate(item) + for item in response["value"] + if item["name"] == name + ) + except StopIteration as e: + raise Exception("ContextGroundingIndex not found") from e + + @resource_override(resource_type="index") + @traced(name="contextgrounding_retrieve", run_type="uipath") + async def retrieve_async( + self, + name: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> ContextGroundingIndex: + """Asynchronously retrieve context grounding index information by its name. + + Args: + name (str): The name of the context index to retrieve. + folder_key (Optional[str]): The key of the folder where the index resides. + folder_path (Optional[str]): The path of the folder where the index resides. + + Returns: + ContextGroundingIndex: The index information, including its configuration and metadata if found. + + Raises: + Exception: If no index with the given name is found. + """ + spec = self._retrieve_spec( + name, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = ( + await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + try: + return next( + ContextGroundingIndex.model_validate(item) + for item in response["value"] + if item["name"] == name + ) + except StopIteration as e: + raise Exception("ContextGroundingIndex not found") from e + + @traced(name="contextgrounding_retrieve_by_id", run_type="uipath") + def retrieve_by_id( + self, + id: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Any: + """Retrieve context grounding index information by its ID. + + This method provides direct access to a context index using its unique + identifier, which can be more efficient than searching by name. + + Args: + id (str): The unique identifier of the context index. + folder_key (Optional[str]): The key of the folder where the index resides. + folder_path (Optional[str]): The path of the folder where the index resides. + + Returns: + Any: The index information, including its configuration and metadata. + """ + spec = self._retrieve_by_id_spec( + id, + folder_key=folder_key, + folder_path=folder_path, + ) + + return self.request( + spec.method, + spec.endpoint, + params=spec.params, + ).json() + + @traced(name="contextgrounding_retrieve_by_id", run_type="uipath") + async def retrieve_by_id_async( + self, + id: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Any: + """Retrieve asynchronously context grounding index information by its ID. + + This method provides direct access to a context index using its unique + identifier, which can be more efficient than searching by name. + + Args: + id (str): The unique identifier of the context index. + folder_key (Optional[str]): The key of the folder where the index resides. + folder_path (Optional[str]): The path of the folder where the index resides. + + Returns: + Any: The index information, including its configuration and metadata. + """ + spec = self._retrieve_by_id_spec( + id, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + ) + + return response.json() + + @resource_override(resource_type="index") + @traced(name="contextgrounding_create_index", run_type="uipath") + def create_index( + self, + name: str, + source: SourceConfig, + description: Optional[str] = None, + extraction_strategy: Optional[str] = None, + embeddings_enabled: Optional[bool] = None, + is_encrypted: Optional[bool] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> ContextGroundingIndex: + """Create a new context grounding index. + + Args: + name (str): The name of the index to create. + source (SourceConfig): Source configuration using one of: + - BucketSourceConfig: For storage buckets + - GoogleDriveSourceConfig: For Google Drive + - DropboxSourceConfig: For Dropbox + - OneDriveSourceConfig: For OneDrive + - ConfluenceSourceConfig: For Confluence + + The source can include an optional indexer field for scheduled indexing: + source.indexer = Indexer(cron_expression="0 0 18 ? * 2", time_zone_id="UTC") + description (Optional[str]): Description of the index. + extraction_strategy (Optional[str]): Extraction method - "NativeV1" or "LLMV4". Defaults to NativeV1. + embeddings_enabled (Optional[bool]): Whether to generate embeddings. Defaults to true. + is_encrypted (Optional[bool]): Whether to encrypt the index. Defaults to false. + folder_key (Optional[str]): The key of the folder where the index will be created. + folder_path (Optional[str]): The path of the folder where the index will be created. + + Returns: + ContextGroundingIndex: The created index information. + """ + spec = self._create_spec( + name=name, + description=description, + source=source, + extraction_strategy=extraction_strategy, + embeddings_enabled=embeddings_enabled, + is_encrypted=is_encrypted, + folder_path=folder_path, + folder_key=folder_key, + ) + + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + return ContextGroundingIndex.model_validate(response.json()) + + @resource_override(resource_type="index") + @traced(name="contextgrounding_create_index", run_type="uipath") + async def create_index_async( + self, + name: str, + source: SourceConfig, + description: Optional[str] = None, + extraction_strategy: Optional[str] = None, + embeddings_enabled: Optional[bool] = None, + is_encrypted: Optional[bool] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> ContextGroundingIndex: + """Create a new context grounding index. + + Args: + name (str): The name of the index to create. + source (SourceConfig): Source configuration using one of: + - BucketSourceConfig: For storage buckets + - GoogleDriveSourceConfig: For Google Drive + - DropboxSourceConfig: For Dropbox + - OneDriveSourceConfig: For OneDrive + - ConfluenceSourceConfig: For Confluence + + The source can include an optional indexer field for scheduled indexing: + source.indexer = Indexer(cron_expression="0 0 18 ? * 2", time_zone_id="UTC") + description (Optional[str]): Description of the index. + extraction_strategy (Optional[str]): Extraction method - "NativeV1" or "LLMV4". Defaults to NativeV1. + embeddings_enabled (Optional[bool]): Whether to generate embeddings. Defaults to true. + is_encrypted (Optional[bool]): Whether to encrypt the index. Defaults to false. + folder_key (Optional[str]): The key of the folder where the index will be created. + folder_path (Optional[str]): The path of the folder where the index will be created. + + Returns: + ContextGroundingIndex: The created index information. + """ + spec = self._create_spec( + name=name, + description=description, + source=source, + extraction_strategy=extraction_strategy, + embeddings_enabled=embeddings_enabled, + is_encrypted=is_encrypted, + folder_path=folder_path, + folder_key=folder_key, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + return ContextGroundingIndex.model_validate(response.json()) + + @resource_override(resource_type="index") + @traced(name="contextgrounding_create_ephemeral_index", run_type="uipath") + def create_ephemeral_index( + self, usage: EphemeralIndexUsage, attachments: list[str] + ) -> ContextGroundingIndex: + """Create a new ephemeral context grounding index. + + Args: + usage (EphemeralIndexUsage): The task type for the ephemeral index (DeepRAG or BatchRAG) + attachments (list[str]): The list of attachments ids from which the ephemeral index will be created + + Returns: + ContextGroundingIndex: The created index information. + """ + spec = self._create_ephemeral_spec( + usage, + attachments, + ) + + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + return ContextGroundingIndex.model_validate(response.json()) + + @resource_override(resource_type="index") + @traced(name="contextgrounding_create_ephemeral_index", run_type="uipath") + async def create_ephemeral_index_async( + self, usage: EphemeralIndexUsage, attachments: list[str] + ) -> ContextGroundingIndex: + """Create a new ephemeral context grounding index. + + Args: + usage (EphemeralIndexUsage): The task type for the ephemeral index (DeepRAG or BatchRAG) + attachments (list[str]): The list of attachments ids from which the ephemeral index will be created + + Returns: + ContextGroundingIndex: The created index information. + """ + spec = self._create_ephemeral_spec( + usage, + attachments, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + return ContextGroundingIndex.model_validate(response.json()) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_retrieve_deep_rag", run_type="uipath") + def retrieve_deep_rag( + self, + id: str, + *, + index_name: str | None = None, + ) -> DeepRagResponse: + """Retrieves a Deep RAG task. + + Args: + id (str): The id of the Deep RAG task. + index_name (Optional[str]): Index name hint for resource override. + + Returns: + DeepRagResponse: The Deep RAG task response. + """ + spec = self._deep_rag_retrieve_spec( + id=id, + ) + response = self.request( + spec.method, + spec.endpoint, + params=spec.params, + json=spec.json, + headers=spec.headers, + ) + return DeepRagResponse.model_validate(response.json()) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_retrieve_deep_rag_async", run_type="uipath") + async def retrieve_deep_rag_async( + self, + id: str, + *, + index_name: str | None = None, + ) -> DeepRagResponse: + """Asynchronously retrieves a Deep RAG task. + + Args: + id (str): The id of the Deep RAG task. + index_name (Optional[str]): Index name hint for resource override. + + Returns: + DeepRagResponse: The Deep RAG task response. + """ + spec = self._deep_rag_retrieve_spec( + id=id, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + json=spec.json, + headers=spec.headers, + ) + + return DeepRagResponse.model_validate(response.json()) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_start_batch_transform", run_type="uipath") + def start_batch_transform( + self, + name: str, + prompt: Annotated[str, Field(max_length=250000)], + output_columns: list[BatchTransformOutputColumn], + storage_bucket_folder_path_prefix: Annotated[ + str | None, Field(max_length=512) + ] = None, + target_file_name: Annotated[str | None, Field(max_length=512)] = None, + enable_web_search_grounding: bool = False, + index_name: str | None = None, + index_id: Annotated[str, Field(max_length=512)] | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> BatchTransformCreationResponse: + """Starts a Batch Transform, task on the targeted index. + + Batch Transform tasks are processing and transforming csv files from the index. + Only one file can be processed per batch transform job. + + Args: + name (str): The name of the Deep RAG task. + index_name (str): The name of the context index to search in. + prompt (str): Describe the task: what to research, what to synthesize. + output_columns (list[BatchTransformOutputColumn]): The output columns to add into the csv. + storage_bucket_folder_path_prefix (str): The prefix pattern for filtering files in the storage bucket. + Can be combined with target_file_name. Defaults to None. + target_file_name (str, optional): Specific file name to target. + If both target_file_name and storage_bucket_folder_path_prefix are provided, they will be combined (e.g., "data/file.csv"). + If only target_file_name is provided, it will be used directly. + Only one file can be processed per batch transform job. + enable_web_search_grounding (Optional[bool]): Whether to enable web search. Defaults to False. + index_id (str): The id of the context index to search in, used in place of name if present + folder_key (str, optional): The folder key where the index resides. Defaults to None. + folder_path (str, optional): The folder path where the index resides. Defaults to None. + + Returns: + BatchTransformCreationResponse: The batch transform task creation response. + """ + if not (index_name or index_id): + raise ValueError("Index name or id is required") + if index_name and index_id: + raise ValueError("Index name or id are mutually exclusive") + + if not index_id: + index = self.retrieve( + index_name, folder_key=folder_key, folder_path=folder_path + ) + if index and index.in_progress_ingestion(): + raise IngestionInProgressException(index_name=index_name) + index_id = index.id + + spec = self._batch_transform_creation_spec( + index_id=index_id, + name=name, + storage_bucket_folder_path_prefix=storage_bucket_folder_path_prefix, + target_file_name=target_file_name, + prompt=prompt, + output_columns=output_columns, + enable_web_search_grounding=enable_web_search_grounding, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + params=spec.params, + headers=spec.headers, + ) + return BatchTransformCreationResponse.model_validate(response.json()) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_start_batch_transform_async", run_type="uipath") + async def start_batch_transform_async( + self, + name: str, + prompt: Annotated[str, Field(max_length=250000)], + output_columns: list[BatchTransformOutputColumn], + storage_bucket_folder_path_prefix: Annotated[ + str | None, Field(max_length=512) + ] = None, + target_file_name: Annotated[str | None, Field(max_length=512)] = None, + enable_web_search_grounding: bool = False, + index_name: str | None = None, + index_id: Annotated[str, Field(max_length=512)] | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> BatchTransformCreationResponse: + """Asynchronously starts a Batch Transform, task on the targeted index. + + Batch Transform tasks are processing and transforming csv files from the index. + Only one file can be processed per batch transform job. + + Args: + name (str): The name of the Deep RAG task. + index_name (str): The name of the context index to search in. + prompt (str): Describe the task: what to research, what to synthesize. + output_columns (list[BatchTransformOutputColumn]): The output columns to add into the csv. + storage_bucket_folder_path_prefix (str): The prefix pattern for filtering files in the storage bucket. + Can be combined with target_file_name. Defaults to None. + target_file_name (str, optional): Specific file name to target. + If both target_file_name and storage_bucket_folder_path_prefix are provided, they will be combined (e.g., "data/file.csv"). + If only target_file_name is provided, it will be used directly. + Only one file can be processed per batch transform job. + enable_web_search_grounding (Optional[bool]): Whether to enable web search. Defaults to False. + index_id (str): The id of the context index to search in, used in place of name if present + folder_key (str, optional): The folder key where the index resides. Defaults to None. + folder_path (str, optional): The folder path where the index resides. Defaults to None. + + Returns: + BatchTransformCreationResponse: The batch transform task creation response. + """ + if not (index_name or index_id): + raise ValueError("Index name or id is required") + if index_name and index_id: + raise ValueError("Index name or id are mutually exclusive") + + if not index_id: + index = await self.retrieve_async( + index_name, folder_key=folder_key, folder_path=folder_path + ) + if index and index.in_progress_ingestion(): + raise IngestionInProgressException(index_name=index_name) + index_id = index.id + + spec = self._batch_transform_creation_spec( + index_id=index_id, + name=name, + storage_bucket_folder_path_prefix=storage_bucket_folder_path_prefix, + target_file_name=target_file_name, + prompt=prompt, + output_columns=output_columns, + enable_web_search_grounding=enable_web_search_grounding, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + params=spec.params, + headers=spec.headers, + ) + return BatchTransformCreationResponse.model_validate(response.json()) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_start_batch_transform", run_type="uipath") + async def start_batch_transform_ephemeral( + self, + name: str, + prompt: Annotated[str, Field(max_length=250000)], + output_columns: list[BatchTransformOutputColumn], + storage_bucket_folder_path_prefix: Annotated[ + str | None, Field(max_length=512) + ] = None, + enable_web_search_grounding: bool = False, + index_id: Annotated[str, Field(max_length=512)] | None = None, + ) -> BatchTransformCreationResponse: + """Asynchronously starts a Batch Transform, task on the targeted index. + + Batch Transform tasks are processing and transforming csv files from the index. + + Args: + name (str): The name of the Deep RAG task. + prompt (str): Describe the task: what to research, what to synthesize. + output_columns (list[BatchTransformOutputColumn]): The output columns to add into the csv. + storage_bucket_folder_path_prefix (str): The prefix pattern for filtering files in the storage bucket. Use "*" to include all files. Defaults to "*". + enable_web_search_grounding (Optional[bool]): Whether to enable web search. Defaults to False. + index_id (str): The id of the context index to search in, used in place of name if present + + Returns: + BatchTransformCreationResponse: The batch transform task creation response. + """ + spec = self._batch_transform_ephemeral_creation_spec( + index_id=index_id, + name=name, + storage_bucket_folder_path_prefix=storage_bucket_folder_path_prefix, + prompt=prompt, + output_columns=output_columns, + enable_web_search_grounding=enable_web_search_grounding, + ) + + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + params=spec.params, + headers=spec.headers, + ) + return BatchTransformCreationResponse.model_validate(response.json()) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_start_batch_transform_async", run_type="uipath") + async def start_batch_transform_ephemeral_async( + self, + name: str, + prompt: Annotated[str, Field(max_length=250000)], + output_columns: list[BatchTransformOutputColumn], + storage_bucket_folder_path_prefix: Annotated[ + str | None, Field(max_length=512) + ] = None, + enable_web_search_grounding: bool = False, + index_id: Annotated[str, Field(max_length=512)] | None = None, + ) -> BatchTransformCreationResponse: + """Asynchronously starts a Batch Transform, task on the targeted index. + + Batch Transform tasks are processing and transforming csv files from the index. + + Args: + name (str): The name of the Deep RAG task. + prompt (str): Describe the task: what to research, what to synthesize. + output_columns (list[BatchTransformOutputColumn]): The output columns to add into the csv. + storage_bucket_folder_path_prefix (str): The prefix pattern for filtering files in the storage bucket. Use "*" to include all files. Defaults to "*". + enable_web_search_grounding (Optional[bool]): Whether to enable web search. Defaults to False. + index_id (str): The id of the context index to search in, used in place of name if present + + Returns: + BatchTransformCreationResponse: The batch transform task creation response. + """ + spec = self._batch_transform_ephemeral_creation_spec( + index_id=index_id, + name=name, + storage_bucket_folder_path_prefix=storage_bucket_folder_path_prefix, + prompt=prompt, + output_columns=output_columns, + enable_web_search_grounding=enable_web_search_grounding, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + params=spec.params, + headers=spec.headers, + ) + return BatchTransformCreationResponse.model_validate(response.json()) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_retrieve_batch_transform", run_type="uipath") + def retrieve_batch_transform( + self, + id: str, + *, + index_name: str | None = None, + ) -> BatchTransformResponse: + """Retrieves a Batch Transform task status. + + Args: + id (str): The id of the Batch Transform task. + index_name (Optional[str]): Index name hint for resource override. + + Returns: + BatchTransformResponse: The Batch Transform task response. + """ + spec = self._batch_transform_retrieve_spec(id=id) + response = self.request( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + return BatchTransformResponse.model_validate(response.json()) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_retrieve_batch_transform_async", run_type="uipath") + async def retrieve_batch_transform_async( + self, + id: str, + *, + index_name: str | None = None, + ) -> BatchTransformResponse: + """Asynchronously retrieves a Batch Transform task status. + + Args: + id (str): The id of the Batch Transform task. + index_name (Optional[str]): Index name hint for resource override. + + Returns: + BatchTransformResponse: The Batch Transform task response. + """ + spec = self._batch_transform_retrieve_spec(id=id) + response = await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + return BatchTransformResponse.model_validate(response.json()) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_download_batch_transform_result", run_type="uipath") + def download_batch_transform_result( + self, + id: str, + destination_path: str, + *, + validate_status: bool = True, + index_name: str | None = None, + ) -> None: + """Downloads the Batch Transform result file to the specified path. + + Args: + id (str): The id of the Batch Transform task. + destination_path (str): The local file path where the result file will be saved. + validate_status (bool): Whether to validate the batch transform status before downloading. Defaults to True. + index_name (Optional[str]): Index name hint for resource override. + + Raises: + BatchTransformNotCompleteException: If validate_status is True and the batch transform is not complete. + """ + if validate_status: + batch_transform = self.retrieve_batch_transform( + id=id, index_name=index_name + ) + if batch_transform.last_batch_rag_status != BatchTransformStatus.SUCCESSFUL: + raise BatchTransformNotCompleteException( + batch_transform_id=id, + status=batch_transform.last_batch_rag_status, + ) + + spec = self._batch_transform_get_read_uri_spec(id=id) + response = self.request( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + uri_response = BatchTransformReadUriResponse.model_validate(response.json()) + + Path(destination_path).parent.mkdir(parents=True, exist_ok=True) + + # SAS uris can be downloaded without authentication + # encrypted artifacts require authenticated DownloadBlob endpoint + with open(destination_path, "wb") as file: + if uri_response.is_encrypted: + download_spec = self._batch_transform_download_blob_spec(id=id) + download_response = self.request( + download_spec.method, + download_spec.endpoint, + headers=download_spec.headers, + ) + file_content = download_response.content + else: + with httpx.Client(**get_httpx_client_kwargs()) as client: + file_content = client.get(uri_response.uri).content + file.write(file_content) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced( + name="contextgrounding_download_batch_transform_result_async", run_type="uipath" + ) + async def download_batch_transform_result_async( + self, + id: str, + destination_path: str, + *, + validate_status: bool = True, + index_name: str | None = None, + ) -> None: + """Asynchronously downloads the Batch Transform result file to the specified path. + + Args: + id (str): The id of the Batch Transform task. + destination_path (str): The local file path where the result file will be saved. + validate_status (bool): Whether to validate the batch transform status before downloading. Defaults to True. + index_name (Optional[str]): Index name hint for resource override. + + Raises: + BatchTransformNotCompleteException: If validate_status is True and the batch transform is not complete. + """ + if validate_status: + batch_transform = await self.retrieve_batch_transform_async( + id=id, index_name=index_name + ) + if batch_transform.last_batch_rag_status != BatchTransformStatus.SUCCESSFUL: + raise BatchTransformNotCompleteException( + batch_transform_id=id, + status=batch_transform.last_batch_rag_status, + ) + + spec = self._batch_transform_get_read_uri_spec(id=id) + response = await self.request_async( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + uri_response = BatchTransformReadUriResponse.model_validate(response.json()) + + # SAS uris can be downloaded without authentication + # encrypted artifacts require authenticated DownloadBlob endpoint + if uri_response.is_encrypted: + download_spec = self._batch_transform_download_blob_spec(id=id) + download_response = await self.request_async( + download_spec.method, + download_spec.endpoint, + headers=download_spec.headers, + ) + file_content = download_response.content + else: + async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client: + download_response = await client.get(uri_response.uri) + file_content = download_response.content + + Path(destination_path).parent.mkdir(parents=True, exist_ok=True) + + with open(destination_path, "wb") as file: + file.write(file_content) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_start_deep_rag", run_type="uipath") + def start_deep_rag( + self, + name: str, + prompt: Annotated[str, Field(max_length=250000)], + glob_pattern: Annotated[str, Field(max_length=512, default="*")] = "**", + citation_mode: CitationMode = CitationMode.SKIP, + index_name: Annotated[str, Field(max_length=512)] | None = None, + index_id: Annotated[str, Field(max_length=512)] | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> DeepRagCreationResponse: + """Starts a Deep RAG task on the targeted index. + + Args: + name (str): The name of the Deep RAG task. + index_name (str): The name of the context index to search in. + prompt (str): Describe the task: what to research across documents, what to synthesize and how to cite sources. + glob_pattern (str): The glob pattern to search in the index. Defaults to "**". + citation_mode (CitationMode): The citation mode to use. Defaults to SKIP. + folder_key (str, optional): The folder key where the index resides. Defaults to None. + folder_path (str, optional): The folder path where the index resides. Defaults to None. + index_id (str): The id of the context index to search in, used in place of name if present + + Returns: + DeepRagCreationResponse: The Deep RAG task creation response. + """ + if not (index_name or index_id): + raise ValueError("Index name or id is required") + if index_name and index_id: + raise ValueError("Index name or id are mutually exclusive") + + if not index_id: + index = self.retrieve( + index_name, folder_key=folder_key, folder_path=folder_path + ) + if index and index.in_progress_ingestion(): + raise IngestionInProgressException(index_name=index_name) + index_id = index.id + + spec = self._deep_rag_creation_spec( + index_id=index_id, + name=name, + glob_pattern=glob_pattern, + prompt=prompt, + citation_mode=citation_mode, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + params=spec.params, + headers=spec.headers, + ) + + return DeepRagCreationResponse.model_validate(response.json()) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_start_deep_rag_async", run_type="uipath") + async def start_deep_rag_async( + self, + name: str, + prompt: Annotated[str, Field(max_length=250000)], + glob_pattern: Annotated[str, Field(max_length=512, default="*")] = "**", + citation_mode: CitationMode = CitationMode.SKIP, + index_name: Annotated[str, Field(max_length=512)] | None = None, + index_id: Annotated[str, Field(max_length=512)] | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> DeepRagCreationResponse: + """Asynchronously starts a Deep RAG task on the targeted index. + + Args: + name (str): The name of the Deep RAG task. + index_name (str): The name of the context index to search in. + name (str): The name of the Deep RAG task. + prompt (str): Describe the task: what to research across documents, what to synthesize and how to cite sources. + glob_pattern (str): The glob pattern to search in the index. Defaults to "**". + citation_mode (CitationMode): The citation mode to use. Defaults to SKIP. + folder_key (str, optional): The folder key where the index resides. Defaults to None. + folder_path (str, optional): The folder path where the index resides. Defaults to None. + index_id (str): The id of the context index to search in, used in place of name if present + + Returns: + DeepRagCreationResponse: The Deep RAG task creation response. + """ + if not (index_name or index_id): + raise ValueError("Index name or id is required") + if index_name and index_id: + raise ValueError("Index name or id are mutually exclusive") + + if not index_id: + index = await self.retrieve_async( + index_name, folder_key=folder_key, folder_path=folder_path + ) + if index and index.in_progress_ingestion(): + raise IngestionInProgressException(index_name=index_name) + index_id = index.id + + spec = self._deep_rag_creation_spec( + index_id=index_id, + name=name, + glob_pattern=glob_pattern, + prompt=prompt, + citation_mode=citation_mode, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + json=spec.json, + headers=spec.headers, + ) + + return DeepRagCreationResponse.model_validate(response.json()) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_start_deep_rag", run_type="uipath") + async def start_deep_rag_ephemeral( + self, + name: str, + prompt: Annotated[str, Field(max_length=250000)], + glob_pattern: Annotated[str, Field(max_length=512, default="*")] = "**", + citation_mode: CitationMode = CitationMode.SKIP, + index_id: Annotated[str, Field(max_length=512)] | None = None, + ) -> DeepRagCreationResponse: + """Asynchronously starts a Deep RAG task on the targeted index. + + Args: + name (str): The name of the Deep RAG task. + name (str): The name of the Deep RAG task. + prompt (str): Describe the task: what to research across documents, what to synthesize and how to cite sources. + glob_pattern (str): The glob pattern to search in the index. Defaults to "**". + citation_mode (CitationMode): The citation mode to use. Defaults to SKIP. + index_id (str): The id of the context index to search in, used in place of name if present + + Returns: + DeepRagCreationResponse: The Deep RAG task creation response. + """ + spec = self._deep_rag_ephemeral_creation_spec( + index_id=index_id, + name=name, + glob_pattern=glob_pattern, + prompt=prompt, + citation_mode=citation_mode, + ) + + response = self.request( + spec.method, + spec.endpoint, + params=spec.params, + json=spec.json, + headers=spec.headers, + ) + + return DeepRagCreationResponse.model_validate(response.json()) + + @resource_override(resource_type="index", resource_identifier="index_name") + @traced(name="contextgrounding_start_deep_rag_async", run_type="uipath") + async def start_deep_rag_ephemeral_async( + self, + name: str, + prompt: Annotated[str, Field(max_length=250000)], + glob_pattern: Annotated[str, Field(max_length=512, default="*")] = "**", + citation_mode: CitationMode = CitationMode.SKIP, + index_id: Annotated[str, Field(max_length=512)] | None = None, + ) -> DeepRagCreationResponse: + """Asynchronously starts a Deep RAG task on the targeted index. + + Args: + name (str): The name of the Deep RAG task. + name (str): The name of the Deep RAG task. + prompt (str): Describe the task: what to research across documents, what to synthesize and how to cite sources. + glob_pattern (str): The glob pattern to search in the index. Defaults to "**". + citation_mode (CitationMode): The citation mode to use. Defaults to SKIP. + index_id (str): The id of the context index to search in, used in place of name if present + + Returns: + DeepRagCreationResponse: The Deep RAG task creation response. + """ + spec = self._deep_rag_ephemeral_creation_spec( + index_id=index_id, + name=name, + glob_pattern=glob_pattern, + prompt=prompt, + citation_mode=citation_mode, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + params=spec.params, + json=spec.json, + headers=spec.headers, + ) + + return DeepRagCreationResponse.model_validate(response.json()) + + @resource_override(resource_type="index") + @traced(name="contextgrounding_search", run_type="uipath") + def search( + self, + name: str, + query: str, + number_of_results: int = 10, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> List[ContextGroundingQueryResponse]: + """Search for contextual information within a specific index. + + This method performs a semantic search against the specified context index, + helping to find relevant information that can be used in automation processes. + The search is powered by AI and understands natural language queries. + + Args: + name (str): The name of the context index to search in. + query (str): The search query in natural language. + number_of_results (int, optional): Maximum number of results to return. + Defaults to 10. + + Returns: + List[ContextGroundingQueryResponse]: A list of search results, each containing + relevant contextual information and metadata. + """ + index = self.retrieve(name, folder_key=folder_key, folder_path=folder_path) + if index and index.in_progress_ingestion(): + raise IngestionInProgressException(index_name=name) + + spec = self._search_spec( + name, + query, + number_of_results, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = self.request( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + return TypeAdapter(List[ContextGroundingQueryResponse]).validate_python( + response.json() + ) + + @resource_override(resource_type="index") + @traced(name="contextgrounding_search", run_type="uipath") + async def search_async( + self, + name: str, + query: str, + number_of_results: int = 10, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> List[ContextGroundingQueryResponse]: + """Search asynchronously for contextual information within a specific index. + + This method performs a semantic search against the specified context index, + helping to find relevant information that can be used in automation processes. + The search is powered by AI and understands natural language queries. + + Args: + name (str): The name of the context index to search in. + query (str): The search query in natural language. + number_of_results (int, optional): Maximum number of results to return. + Defaults to 10. + + Returns: + List[ContextGroundingQueryResponse]: A list of search results, each containing + relevant contextual information and metadata. + """ + index = self.retrieve( + name, + folder_key=folder_key, + folder_path=folder_path, + ) + if index and index.in_progress_ingestion(): + raise IngestionInProgressException(index_name=name) + spec = self._search_spec( + name, + query, + number_of_results, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = await self.request_async( + spec.method, + spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + return TypeAdapter(List[ContextGroundingQueryResponse]).validate_python( + response.json() + ) + + @traced(name="contextgrounding_ingest_data", run_type="uipath") + def ingest_data( + self, + index: ContextGroundingIndex, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Ingest data into the context grounding index. + + Args: + index (ContextGroundingIndex): The context grounding index to perform data ingestion. + folder_key (Optional[str]): The key of the folder where the index resides. + folder_path (Optional[str]): The path of the folder where the index resides. + """ + if not index.id: + return + spec = self._ingest_spec( + index.id, + folder_key=folder_key, + folder_path=folder_path, + ) + try: + self.request( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + except httpx.HTTPStatusError as e: + if e.response.status_code != 409: + raise e + raise IngestionInProgressException( + index_name=index.name, search_operation=False + ) from e + + @traced(name="contextgrounding_ingest_data", run_type="uipath") + async def ingest_data_async( + self, + index: ContextGroundingIndex, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Asynchronously ingest data into the context grounding index. + + Args: + index (ContextGroundingIndex): The context grounding index to perform data ingestion. + folder_key (Optional[str]): The key of the folder where the index resides. + folder_path (Optional[str]): The path of the folder where the index resides. + """ + if not index.id: + return + spec = self._ingest_spec( + index.id, + folder_key=folder_key, + folder_path=folder_path, + ) + try: + await self.request_async( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + except httpx.HTTPStatusError as e: + if e.response.status_code != 409: + raise e + raise IngestionInProgressException( + index_name=index.name, search_operation=False + ) from e + + @traced(name="contextgrounding_delete_index", run_type="uipath") + def delete_index( + self, + index: ContextGroundingIndex, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Delete a context grounding index. + + This method removes the specified context grounding index from Orchestrator. + + Args: + index (ContextGroundingIndex): The context grounding index to delete. + folder_key (Optional[str]): The key of the folder where the index resides. + folder_path (Optional[str]): The path of the folder where the index resides. + """ + if not index.id: + return + spec = self._delete_by_id_spec( + index.id, + folder_key=folder_key, + folder_path=folder_path, + ) + self.request( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + + @traced(name="contextgrounding_delete_index", run_type="uipath") + async def delete_index_async( + self, + index: ContextGroundingIndex, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Asynchronously delete a context grounding index. + + This method removes the specified context grounding index from Orchestrator. + + Args: + index (ContextGroundingIndex): The context grounding index to delete. + folder_key (Optional[str]): The key of the folder where the index resides. + folder_path (Optional[str]): The path of the folder where the index resides. + """ + if not index.id: + return + spec = self._delete_by_id_spec( + index.id, + folder_key=folder_key, + folder_path=folder_path, + ) + await self.request_async( + spec.method, + spec.endpoint, + headers=spec.headers, + ) + + def _ingest_spec( + self, + key: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder_key(folder_key, folder_path) + + return RequestSpec( + method="POST", + endpoint=Endpoint(f"/ecs_/v2/indexes/{key}/ingest"), + headers={ + **header_folder(folder_key, None), + }, + ) + + def _retrieve_spec( + self, + name: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder_key(folder_key, folder_path) + + return RequestSpec( + method="GET", + endpoint=Endpoint("/ecs_/v2/indexes"), + params={ + "$filter": f"Name eq '{name}'", + "$expand": "dataSource", + }, + headers={ + **header_folder(folder_key, None), + }, + ) + + def _create_spec( + self, + name: str, + description: Optional[str], + source: SourceConfig, + extraction_strategy: Optional[str] = None, + embeddings_enabled: Optional[bool] = None, + is_encrypted: Optional[bool] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + """Create request spec for index creation. + + Args: + name: Index name + description: Index description + source: Source configuration (typed model) with optional indexer + extraction_strategy: Extraction method - "NativeV1" or "LLMV4" + embeddings_enabled: Whether to generate embeddings + is_encrypted: Whether to encrypt the index + folder_key: Optional folder key + folder_path: Optional folder path + + Returns: + RequestSpec for the create index request + """ + folder_key = self._resolve_folder_key(folder_key, folder_path) + + data_source_dict = self._build_data_source(source) + + # Add indexer from source config if present + if source.indexer: + data_source_dict["indexer"] = source.indexer.model_dump(by_alias=True) + + payload = CreateIndexPayload( + name=name, + description=description or "", + data_source=data_source_dict, + extraction_strategy=extraction_strategy, + embeddings_enabled=embeddings_enabled, + is_encrypted=is_encrypted, + ) + + return RequestSpec( + method="POST", + endpoint=Endpoint("/ecs_/v2/indexes/create"), + json=payload.model_dump(by_alias=True, exclude_none=True), + headers={ + **header_folder(folder_key, None), + }, + ) + + def _create_ephemeral_spec( + self, + usage: str, + attachments: list[str], + ) -> RequestSpec: + """Create request spec for ephemeral index creation. + + Args: + usage (str): The task in which the ephemeral index will be used for + attachments (list[str]): The list of attachments ids from which the ephemeral index will be created + + Returns: + RequestSpec for the create index request + """ + data_source_dict = self._build_ephemeral_data_source(attachments) + + payload = CreateEphemeralIndexPayload( + usage=usage, + data_source=data_source_dict, + ) + + return RequestSpec( + method="POST", + endpoint=Endpoint("/ecs_/v2/indexes/createephemeral"), + json=payload.model_dump(by_alias=True, exclude_none=True), + headers={}, + ) + + def _build_data_source(self, source: SourceConfig) -> Dict[str, Any]: + """Build data source configuration from typed source config. + + Args: + source: Typed source configuration model + + Returns: + Dictionary with data source configuration for API + """ + file_name_glob = f"**/*.{source.file_type}" if source.file_type else "**/*" + + data_source: Union[ + BucketDataSource, + GoogleDriveDataSource, + DropboxDataSource, + OneDriveDataSource, + ConfluenceDataSource, + ] + + if isinstance(source, BucketSourceConfig): + data_source = BucketDataSource( + folder=source.folder_path, + bucketName=source.bucket_name, + fileNameGlob=file_name_glob, + directoryPath=source.directory_path, + ) + elif isinstance(source, GoogleDriveSourceConfig): + data_source = GoogleDriveDataSource( + folder=source.folder_path, + connectionId=source.connection_id, + connectionName=source.connection_name, + leafFolderId=source.leaf_folder_id, + directoryPath=source.directory_path, + fileNameGlob=file_name_glob, + ) + elif isinstance(source, DropboxSourceConfig): + data_source = DropboxDataSource( + folder=source.folder_path, + connectionId=source.connection_id, + connectionName=source.connection_name, + directoryPath=source.directory_path, + fileNameGlob=file_name_glob, + ) + elif isinstance(source, OneDriveSourceConfig): + data_source = OneDriveDataSource( + folder=source.folder_path, + connectionId=source.connection_id, + connectionName=source.connection_name, + leafFolderId=source.leaf_folder_id, + directoryPath=source.directory_path, + fileNameGlob=file_name_glob, + ) + elif isinstance(source, ConfluenceSourceConfig): + data_source = ConfluenceDataSource( + folder=source.folder_path, + connectionId=source.connection_id, + connectionName=source.connection_name, + directoryPath=source.directory_path, + fileNameGlob=file_name_glob, + spaceId=source.space_id, + ) + else: + raise ValueError( + f"Unsupported source configuration type: {type(source).__name__}" + ) + + return data_source.model_dump(by_alias=True, exclude_none=True) + + def _build_ephemeral_data_source(self, attachments: list[str]) -> Dict[str, Any]: + """Build data source configuration from typed source config. + + Args: + attachments (list[str]): The list of attachments ids from which the ephemeral index will be created + + Returns: + Dictionary with data source configuration for API + """ + data_source = AttachmentsDataSource(attachments=attachments) + return data_source.model_dump( + by_alias=True, + exclude_none=True, + mode="json", + ) + + def _retrieve_by_id_spec( + self, + id: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder_key(folder_key, folder_path) + + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/ecs_/v2/indexes/{id}"), + headers={ + **header_folder(folder_key, None), + }, + ) + + def _delete_by_id_spec( + self, + id: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder_key(folder_key, folder_path) + + return RequestSpec( + method="DELETE", + endpoint=Endpoint(f"/ecs_/v2/indexes/{id}"), + headers={ + **header_folder(folder_key, None), + }, + ) + + def _search_spec( + self, + name: str, + query: str, + number_of_results: int = 10, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + folder_key = self._resolve_folder_key(folder_key, folder_path) + + return RequestSpec( + method="POST", + endpoint=Endpoint("/ecs_/v1/search"), + json={ + "query": {"query": query, "numberOfResults": number_of_results}, + "schema": {"name": name}, + }, + headers={ + **header_folder(folder_key, None), + }, + ) + + def _deep_rag_creation_spec( + self, + index_id: str, + name: str, + glob_pattern: str, + prompt: str, + citation_mode: CitationMode, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> RequestSpec: + folder_key = self._resolve_folder_key(folder_key, folder_path) + + return RequestSpec( + method="POST", + endpoint=Endpoint(f"/ecs_/v2/indexes/{index_id}/createDeepRag"), + json={ + "name": name, + "prompt": prompt, + "globPattern": glob_pattern, + "citationMode": citation_mode.value, + }, + params={ + "$select": "id,lastDeepRagStatus,createdDate", + }, + headers={ + **header_folder(folder_key, None), + }, + ) + + def _deep_rag_ephemeral_creation_spec( + self, + index_id: str | None, + name: str, + glob_pattern: str, + prompt: str, + citation_mode: CitationMode, + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint(f"/ecs_/v2/indexes/{index_id}/createDeepRag"), + json={ + "name": name, + "prompt": prompt, + "globPattern": glob_pattern, + "citationMode": citation_mode.value, + }, + params={ + "$select": "id,lastDeepRagStatus,createdDate", + }, + headers={}, + ) + + def _batch_transform_creation_spec( + self, + index_id: str, + name: str, + enable_web_search_grounding: bool, + output_columns: list[BatchTransformOutputColumn], + storage_bucket_folder_path_prefix: str | None, + target_file_name: str | None, + prompt: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> RequestSpec: + folder_key = self._resolve_folder_key(folder_key, folder_path) + + # determine targetFileGlobPattern based on the provided parameters: + # 1. if both target_file_name and storage_bucket_folder_path_prefix are provided, combine them + # 2. if only target_file_name is provided, use it directly + # 3. if only storage_bucket_folder_path_prefix is provided, use it with wildcard + # 4. default to "**" if neither is provided + if target_file_name and storage_bucket_folder_path_prefix: + target_file_glob_pattern = ( + f"{storage_bucket_folder_path_prefix}/{target_file_name}" + ) + elif target_file_name: + target_file_glob_pattern = target_file_name + elif storage_bucket_folder_path_prefix: + if storage_bucket_folder_path_prefix.endswith("/*"): + target_file_glob_pattern = storage_bucket_folder_path_prefix + else: + target_file_glob_pattern = f"{storage_bucket_folder_path_prefix}/*" + else: + target_file_glob_pattern = "**" + + return RequestSpec( + method="POST", + endpoint=Endpoint(f"/ecs_/v2/indexes/{index_id}/createBatchRag"), + json={ + "name": name, + "prompt": prompt, + "targetFileGlobPattern": target_file_glob_pattern, + "useWebSearchGrounding": enable_web_search_grounding, + "outputColumns": [ + column.model_dump(by_alias=True) for column in output_columns + ], + }, + headers={ + **header_folder(folder_key, None), + }, + ) + + def _batch_transform_ephemeral_creation_spec( + self, + index_id: str | None, + name: str, + enable_web_search_grounding: bool, + output_columns: list[BatchTransformOutputColumn], + storage_bucket_folder_path_prefix: str | None, + prompt: str, + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint(f"/ecs_/v2/indexes/{index_id}/createBatchRag"), + json={ + "name": name, + "prompt": prompt, + "targetFileGlobPattern": f"{storage_bucket_folder_path_prefix}/*" + if storage_bucket_folder_path_prefix + else "**", + "useWebSearchGrounding": enable_web_search_grounding, + "outputColumns": [ + column.model_dump(by_alias=True) for column in output_columns + ], + }, + headers={}, + ) + + def _deep_rag_retrieve_spec( + self, + id: str, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/ecs_/v2/deeprag/{id}"), + params={ + "$expand": "content", + "$select": "content,name,createdDate,lastDeepRagStatus", + }, + ) + + def _batch_transform_retrieve_spec( + self, + id: str, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/ecs_/v2/batchRag/{id}"), + ) + + def _batch_transform_get_read_uri_spec( + self, + id: str, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/ecs_/v2/batchRag/{id}/GetReadUri"), + ) + + def _batch_transform_download_blob_spec( + self, + id: str, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/ecs_/v2/batchRag/{id}/DownloadBlob"), + ) + + def _resolve_folder_key(self, folder_key, folder_path): + if folder_key is None and folder_path is not None: + folder_key = self._folders_service.retrieve_key(folder_path=folder_path) + + if folder_key is None and folder_path is None: + folder_key = self._folder_key or ( + self._folders_service.retrieve_key(folder_path=self._folder_path) + if self._folder_path + else None + ) + + return folder_key + + def _extract_bucket_info(self, index: ContextGroundingIndex) -> Tuple[str, str]: + """Extract bucket information from the index, validating it's a storage bucket data source. + + Args: + index: The context grounding index + + Returns: + Tuple of (bucket_name, folder_path) + + Raises: + UnsupportedDataSourceException: If the data source is not an Orchestrator Storage Bucket + """ + if not index.data_source: + raise UnsupportedDataSourceException("add_to_index") + + # Check if the data source has the @odata.type field indicating it's a storage bucket + data_source_dict = ( + index.data_source.model_dump(by_alias=True) + if hasattr(index.data_source, "model_dump") + else index.data_source.__dict__ + ) + odata_type = data_source_dict.get("@odata.type") or data_source_dict.get( + "odata.type" + ) + + if odata_type and odata_type != ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE: + raise UnsupportedDataSourceException("add_to_index", odata_type) + + # Try to extract bucket information + bucket_name = getattr(index.data_source, "bucketName", None) + folder = getattr(index.data_source, "folder", None) + + if not bucket_name or not folder: + raise UnsupportedDataSourceException("add_to_index") + + return bucket_name, folder diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py new file mode 100644 index 000000000..851e2a81a --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding.py @@ -0,0 +1,209 @@ +"""Context Grounding response payload models.""" + +from enum import Enum +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class BatchTransformOutputColumn(BaseModel): + """Model representing a batch transform output column.""" + + name: str = Field( + min_length=1, + max_length=500, + pattern=r"^[\w\s\.,!?-]+$", + ) + description: str = Field(..., min_length=1, max_length=20000) + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + +class CitationMode(str, Enum): + """Enum representing possible citation modes.""" + + SKIP = "Skip" + INLINE = "Inline" + + +class EphemeralIndexUsage(str, Enum): + """Enum representing possible ephemeral index usage types.""" + + DEEP_RAG = "DeepRAG" + BATCH_RAG = "BatchRAG" + + +class DeepRagStatus(str, Enum): + """Enum representing possible deep RAG tasks status.""" + + QUEUED = "Queued" + IN_PROGRESS = "InProgress" + SUCCESSFUL = "Successful" + FAILED = "Failed" + + +class IndexStatus(str, Enum): + """Enum representing possible index tasks status.""" + + QUEUED = "Queued" + IN_PROGRESS = "InProgress" + SUCCESSFUL = "Successful" + FAILED = "Failed" + + +class Citation(BaseModel): + """Model representing a deep RAG citation.""" + + ordinal: int + page_number: int = Field(alias="pageNumber") + source: str + reference: str + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + +class DeepRagContent(BaseModel): + """Model representing a deep RAG task content.""" + + text: str + citations: list[Citation] + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + +class DeepRagResponse(BaseModel): + """Model representing a deep RAG task response.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + name: str + created_date: str = Field(alias="createdDate") + last_deep_rag_status: DeepRagStatus = Field(alias="lastDeepRagStatus") + content: DeepRagContent | None = Field(alias="content") + + +class BatchTransformStatus(str, Enum): + """Enum representing possible batch transform status values.""" + + IN_PROGRESS = "InProgress" + SUCCESSFUL = "Successful" + QUEUED = "Queued" + FAILED = "Failed" + + +class BatchTransformCreationResponse(BaseModel): + """Model representing a batch transform task creation response.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + ) + id: str + last_batch_rag_status: DeepRagStatus = Field(alias="lastBatchRagStatus") + error_message: str | None = Field(alias="errorMessage", default=None) + + +class BatchTransformResponse(BaseModel): + """Model representing a batch transform task response.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + ) + id: str + name: str + last_batch_rag_status: BatchTransformStatus = Field(alias="lastBatchRagStatus") + prompt: str + target_file_glob_pattern: str = Field(alias="targetFileGlobPattern") + use_web_search_grounding: bool = Field(alias="useWebSearchGrounding") + output_columns: list[BatchTransformOutputColumn] = Field(alias="outputColumns") + created_date: str = Field(alias="createdDate") + + +class BatchTransformReadUriResponse(BaseModel): + """Model representing a batch transform result file download URI response.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + ) + uri: str + is_encrypted: bool = Field(alias="isEncrypted", default=False) + + +class DeepRagCreationResponse(BaseModel): + """Model representing a deep RAG task creation response.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + id: str + last_deep_rag_status: DeepRagStatus = Field(alias="lastDeepRagStatus") + created_date: str = Field(alias="createdDate") + + +class ContextGroundingMetadata(BaseModel): + """Model representing metadata for a Context Grounding query response.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + operation_id: str = Field(alias="operation_id") + strategy: str = Field(alias="strategy") + + +class ContextGroundingQueryResponse(BaseModel): + """Model representing a Context Grounding query response item.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + source: str = Field(alias="source") + page_number: str = Field(alias="page_number") + content: str = Field(alias="content") + metadata: ContextGroundingMetadata = Field(alias="metadata") + source_document_id: Optional[str] = Field(default=None, alias="source_document_id") + caption: Optional[str] = Field(default=None, alias="caption") + score: Optional[float] = Field(default=None, alias="score") + reference: Optional[str] = Field(default=None, alias="reference") diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_index.py b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_index.py new file mode 100644 index 000000000..a37f2e457 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_index.py @@ -0,0 +1,94 @@ +"""Models for Context Grounding Index in the UiPath platform.""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, field_serializer + + +class ContextGroundingDataSource(BaseModel): + """Model representing a data source in a Context Grounding Index.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + id: Optional[str] = Field(default=None, alias="id") + folder: Optional[str] = Field(default=None, alias="folder") + bucketName: Optional[str] = Field(default=None, alias="bucketName") + + +class ContextGroundingIndexHealth(BaseModel): + """Model representing health metrics for a Context Grounding Index.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + schema_id: Optional[str] = Field(default=None, alias="schemaId") + ingestion_reliability_score: Optional[float] = Field( + default=None, alias="ingestionReliabilityScore" + ) + utilization_score: Optional[float] = Field(default=None, alias="utilizationScore") + overall_health_score: Optional[float] = Field( + default=None, alias="overallHealthScore" + ) + health_status: Optional[str] = Field(default=None, alias="healthStatus") + + +class ContextGroundingIndex(BaseModel): + """Model representing a Context Grounding Index in the UiPath platform.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + @field_serializer("last_ingested", "last_queried") + def serialize_datetime(self, value): + """Serialize datetime fields to ISO 8601 format.""" + if isinstance(value, datetime): + return value.isoformat() if value else None + return value + + id: Optional[str] = Field(default=None, alias="id") + name: Optional[str] = Field(default=None, alias="name") + description: Optional[str] = Field(default=None, alias="description") + is_encrypted: Optional[bool] = Field(default=None, alias="isEncrypted") + folder_fully_qualified_name: Optional[str] = Field( + default=None, alias="folderFullyQualifiedName" + ) + memory_usage: Optional[int] = Field(default=None, alias="memoryUsage") + disk_usage: Optional[int] = Field(default=None, alias="diskUsage") + extraction_strategy: Optional[str] = Field(default=None, alias="extractionStrategy") + embeddings_enabled: Optional[bool] = Field(default=None, alias="embeddingsEnabled") + data_source: Optional[ContextGroundingDataSource] = Field( + default=None, alias="dataSource" + ) + last_ingestion_status: Optional[str] = Field( + default=None, alias="lastIngestionStatus" + ) + last_ingestion_failure_reason: Optional[str] = Field( + default=None, alias="lastIngestionFailureReason" + ) + last_ingested: Optional[datetime] = Field(default=None, alias="lastIngested") + last_queried: Optional[datetime] = Field(default=None, alias="lastQueried") + folder_key: Optional[str] = Field(default=None, alias="folderKey") + index_health: Optional[ContextGroundingIndexHealth] = Field( + default=None, alias="indexHealth" + ) + + def in_progress_ingestion(self): + """Check if the last ingestion is in progress.""" + return ( + self.last_ingestion_status == "Queued" + or self.last_ingestion_status == "InProgress" + ) diff --git a/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py new file mode 100644 index 000000000..a28903462 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/context_grounding/context_grounding_payloads.py @@ -0,0 +1,238 @@ +"""Payload models for context grounding index creation and configuration.""" + +import re +from typing import Any, Dict, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic.alias_generators import to_camel + +from uipath.platform.common.constants import ( + CONFLUENCE_DATA_SOURCE_REQUEST, + DROPBOX_DATA_SOURCE_REQUEST, + GOOGLE_DRIVE_DATA_SOURCE_REQUEST, + ONEDRIVE_DATA_SOURCE_REQUEST, + ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE_REQUEST, +) + + +class BaseDataSource(BaseModel): + """Base model for data source configurations.""" + + folder: str = Field(alias="folder", description="Folder path") + file_name_glob: str = Field( + alias="fileNameGlob", description="File name glob pattern" + ) + directory_path: str = Field(alias="directoryPath", description="Directory path") + + +class BucketDataSource(BaseDataSource): + """Data source configuration for storage buckets.""" + + odata_type: str = Field( + alias="@odata.type", + default=ORCHESTRATOR_STORAGE_BUCKET_DATA_SOURCE_REQUEST, + ) + bucket_name: str = Field(alias="bucketName", description="Storage bucket name") + + +class GoogleDriveDataSource(BaseDataSource): + """Data source configuration for Google Drive.""" + + odata_type: str = Field( + alias="@odata.type", + default=GOOGLE_DRIVE_DATA_SOURCE_REQUEST, + ) + connection_id: str = Field(alias="connectionId", description="Connection ID") + connection_name: str = Field(alias="connectionName", description="Connection name") + leaf_folder_id: str = Field(alias="leafFolderId", description="Leaf folder ID") + + +class DropboxDataSource(BaseDataSource): + """Data source configuration for Dropbox.""" + + odata_type: str = Field( + alias="@odata.type", + default=DROPBOX_DATA_SOURCE_REQUEST, + ) + connection_id: str = Field(alias="connectionId", description="Connection ID") + connection_name: str = Field(alias="connectionName", description="Connection name") + + +class OneDriveDataSource(BaseDataSource): + """Data source configuration for OneDrive.""" + + odata_type: str = Field( + alias="@odata.type", + default=ONEDRIVE_DATA_SOURCE_REQUEST, + ) + connection_id: str = Field(alias="connectionId", description="Connection ID") + connection_name: str = Field(alias="connectionName", description="Connection name") + leaf_folder_id: str = Field(alias="leafFolderId", description="Leaf folder ID") + + +class ConfluenceDataSource(BaseDataSource): + """Data source configuration for Confluence.""" + + odata_type: str = Field( + alias="@odata.type", + default=CONFLUENCE_DATA_SOURCE_REQUEST, + ) + connection_id: str = Field(alias="connectionId", description="Connection ID") + connection_name: str = Field(alias="connectionName", description="Connection name") + space_id: str = Field(alias="spaceId", description="Space ID") + + +class AttachmentsDataSource(BaseModel): + """Data source configuration for Attachments.""" + + attachments: list[str] = Field(description="List of attachment ids") + + +class Indexer(BaseModel): + """Configuration for periodic indexing of data sources.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + cron_expression: str = Field(description="Cron expression for scheduling") + time_zone_id: str = Field(default="UTC", description="Time zone ID") + + @model_validator(mode="before") + @classmethod + def validate_cron(cls, values: Dict[str, Any]) -> Dict[str, Any]: + """Validate cron expression format.""" + cron_expr = values.get("cron_expression") or values.get("cronExpression") + if not cron_expr: + return values + + # Supports @aliases, @every syntax and standard cron expressions with 5-7 fields + cron_pattern = r"^(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)|((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,7})$" + + if not re.match(cron_pattern, cron_expr.strip(), re.IGNORECASE): + raise ValueError(f"Invalid cron expression format: '{cron_expr}'") + + return values + + +class CreateIndexPayload(BaseModel): + """Payload for creating a context grounding index. + + Note: data_source is Dict[str, Any] because it may contain additional + fields like 'indexer' that are added dynamically based on configuration. + The data source is still validated through the _build_data_source method + which uses typed models internally. + """ + + name: str = Field(description="Index name") + description: str = Field(default="", description="Index description") + data_source: Dict[str, Any] = Field( + alias="dataSource", description="Data source configuration" + ) + extraction_strategy: Optional[str] = Field( + default=None, + alias="extractionStrategy", + description="Extraction method: 'NativeV1' or 'LLMV4'. Defaults to NativeV1", + ) + embeddings_enabled: Optional[bool] = Field( + default=None, + alias="embeddingsEnabled", + description="Whether to generate embeddings. Defaults to true", + ) + is_encrypted: Optional[bool] = Field( + default=None, + alias="isEncrypted", + description="Whether to encrypt the index. Defaults to false", + ) + + model_config = ConfigDict(populate_by_name=True) + + +class CreateEphemeralIndexPayload(BaseModel): + """Payload for creating an ephemeral context grounding index. + + Note: data_source is Dict[str, Any] because it may contain additional + fields like 'indexer' that are added dynamically based on configuration. + The data source is still validated through the _build_data_source method + which uses typed models internally. + """ + + usage: str = Field(description="Index usage") + data_source: Dict[str, Any] = Field( + alias="dataSource", description="Data source configuration" + ) + + model_config = ConfigDict(populate_by_name=True) + + +# user-facing source configuration models +class BaseSourceConfig(BaseModel): + """Base configuration for all source types.""" + + folder_path: str = Field(description="Folder path in orchestrator") + directory_path: str = Field(description="Directory path") + file_type: Optional[str] = Field( + default=None, description="File type filter (e.g., 'pdf', 'txt')" + ) + indexer: Optional[Indexer] = Field( + default=None, description="Optional indexer configuration for periodic updates" + ) + + +class ConnectionSourceConfig(BaseSourceConfig): + """Base configuration for sources that use connections.""" + + connection_id: str = Field(description="Connection ID") + connection_name: str = Field(description="Connection name") + + +class BucketSourceConfig(BaseSourceConfig): + """Data source configuration for storage buckets.""" + + type: Literal["bucket"] = Field( + default="bucket", description="Source type identifier" + ) + bucket_name: str = Field(description="Storage bucket name") + directory_path: str = Field(default="/", description="Directory path in bucket") + + +class GoogleDriveSourceConfig(ConnectionSourceConfig): + """Data source configuration for Google Drive.""" + + type: Literal["google_drive"] = Field( + default="google_drive", description="Source type identifier" + ) + leaf_folder_id: str = Field(description="Leaf folder ID in Google Drive") + + +class DropboxSourceConfig(ConnectionSourceConfig): + """Data source configuration for Dropbox.""" + + type: Literal["dropbox"] = Field( + default="dropbox", description="Source type identifier" + ) + + +class OneDriveSourceConfig(ConnectionSourceConfig): + """Data source configuration for OneDrive.""" + + type: Literal["onedrive"] = Field( + default="onedrive", description="Source type identifier" + ) + leaf_folder_id: str = Field(description="Leaf folder ID in OneDrive") + + +class ConfluenceSourceConfig(ConnectionSourceConfig): + """Data source configuration for Confluence.""" + + type: Literal["confluence"] = Field( + default="confluence", description="Source type identifier" + ) + space_id: str = Field(description="Confluence space ID") + + +SourceConfig = Union[ + BucketSourceConfig, + GoogleDriveSourceConfig, + DropboxSourceConfig, + OneDriveSourceConfig, + ConfluenceSourceConfig, +] diff --git a/packages/uipath-platform/src/uipath/platform/documents/__init__.py b/packages/uipath-platform/src/uipath/platform/documents/__init__.py new file mode 100644 index 000000000..78f4b9156 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/documents/__init__.py @@ -0,0 +1,50 @@ +"""UiPath Documents Models. + +This module contains models related to UiPath Document Understanding service. +""" + +from ._documents_service import DocumentsService # type: ignore[attr-defined] +from .documents import ( + ActionPriority, + ClassificationResponse, + ClassificationResult, + DocumentBounds, + ExtractionResponse, + ExtractionResponseIXP, + ExtractionResult, + FieldGroupValueProjection, + FieldType, + FieldValueProjection, + FileContent, + ProjectType, + Reference, + StartExtractionResponse, + StartExtractionValidationResponse, + StartOperationResponse, + ValidateClassificationAction, + ValidateExtractionAction, + ValidationAction, +) + +__all__ = [ + "DocumentsService", + "FieldType", + "ActionPriority", + "ProjectType", + "FieldValueProjection", + "FieldGroupValueProjection", + "ExtractionResult", + "ExtractionResponse", + "ExtractionResponseIXP", + "ValidationAction", + "ValidateClassificationAction", + "ValidateExtractionAction", + "Reference", + "DocumentBounds", + "ClassificationResult", + "ClassificationResponse", + "FileContent", + "StartExtractionResponse", + "StartOperationResponse", + "StartExtractionValidationResponse", +] diff --git a/packages/uipath-platform/src/uipath/platform/documents/_documents_service.py b/packages/uipath-platform/src/uipath/platform/documents/_documents_service.py new file mode 100644 index 000000000..687e7fd15 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/documents/_documents_service.py @@ -0,0 +1,2556 @@ +# type: ignore # this is riddled with typing issues -- fix this later. +import asyncio +import time +from contextlib import nullcontext +from pathlib import Path +from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple, Union +from uuid import UUID + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext +from ..common._models import Endpoint +from ..errors import OperationFailedException, OperationNotCompleteException +from .documents import ( + ActionPriority, + ClassificationResponse, + ClassificationResult, + ExtractionResponse, + ExtractionResponseIXP, + FileContent, + ProjectType, + StartExtractionResponse, + StartExtractionValidationResponse, + ValidateClassificationAction, + ValidateExtractionAction, +) + +POLLING_INTERVAL = 2 # seconds +POLLING_TIMEOUT = 300 # seconds + + +def _must_not_be_provided(**kwargs: Any) -> None: + for name, value in kwargs.items(): + if value is not None: + raise ValueError(f"`{name}` must not be provided") + + +def _must_be_provided(**kwargs: Any) -> None: + for name, value in kwargs.items(): + if value is None: + raise ValueError(f"`{name}` must be provided") + + +def _exactly_one_must_be_provided(**kwargs: Any) -> None: + provided = [name for name, value in kwargs.items() if value is not None] + if len(provided) != 1: + raise ValueError( + f"Exactly one of `{', '.join(kwargs.keys())}` must be provided" + ) + + +def _validate_classify_params( + project_type: ProjectType, + tag: Optional[str], + version: Optional[int], + project_name: Optional[str], + file: Optional[FileContent], + file_path: Optional[str], +) -> None: + _exactly_one_must_be_provided(file=file, file_path=file_path) + if project_type == ProjectType.PRETRAINED: + _must_not_be_provided( + project_name=project_name, + tag=tag, + version=version, + ) + else: + _must_be_provided( + project_name=project_name, + ) + _exactly_one_must_be_provided(tag=tag, version=version) + + +def _validate_extract_params_and_get_project_type( + tag: Optional[str], + version: Optional[int], + project_name: Optional[str], + file: Optional[FileContent], + file_path: Optional[str], + classification_result: Optional[ClassificationResult], + project_type: Optional[ProjectType], + document_type_name: Optional[str], +) -> ProjectType: + if file or file_path: + _exactly_one_must_be_provided(file=file, file_path=file_path) + _must_be_provided(project_type=project_type) + _must_not_be_provided( + classification_result=classification_result, + ) + + if project_type == ProjectType.PRETRAINED: + _must_not_be_provided(tag=tag, project_name=project_name, version=version) + _must_be_provided(document_type_name=document_type_name) + elif project_type == ProjectType.MODERN: + _must_be_provided( + project_name=project_name, + document_type_name=document_type_name, + ) + _exactly_one_must_be_provided(version=version, tag=tag) + else: + _must_be_provided(project_name=project_name) + _exactly_one_must_be_provided(version=version, tag=tag) + _must_not_be_provided(document_type_name=document_type_name) + else: + _must_be_provided(classification_result=classification_result) + _must_not_be_provided( + tag=tag, + version=version, + project_name=project_name, + project_type=project_type, + file=file, + file_path=file_path, + document_type_name=document_type_name, + ) + project_type = classification_result.project_type + + return project_type + + +class DocumentsService(FolderContext, BaseService): + """Service for managing UiPath DocumentUnderstanding Document Operations. + + This service provides methods to extract data from documents using UiPath's Document Understanding capabilities. + + !!! warning "Preview Feature" + This function is currently experimental. + Behavior and parameters are subject to change in future versions. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + polling_interval: float = POLLING_INTERVAL, + polling_timeout: float = POLLING_TIMEOUT, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self.polling_interval = polling_interval + self.polling_timeout = polling_timeout + + def _get_common_headers(self) -> Dict[str, str]: + return { + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + } + + def _get_classifier_id( + self, project_type: ProjectType, project_id: str, version: Optional[int] + ) -> Optional[str]: + if project_type == ProjectType.PRETRAINED: + return "ml-classification" + + if version is None: + return None + + response = self.request( + "GET", + url=Endpoint(f"/du_/api/framework/projects/{project_id}/classifiers"), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + + try: + return next( + classifier["id"] + for classifier in response.json().get("classifiers", []) + if classifier["projectVersion"] == version + ) + except StopIteration: + raise ValueError(f"Classifier for version '{version}' not found.") from None + + async def _get_classifier_id_async( + self, project_type: ProjectType, project_id: str, version: Optional[int] + ) -> Optional[str]: + if project_type == ProjectType.PRETRAINED: + return "ml-classification" + + if version is None: + return None + + response = await self.request_async( + "GET", + url=Endpoint(f"/du_/api/framework/projects/{project_id}/classifiers"), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + + try: + return next( + classifier["id"] + for classifier in response.json().get("classifiers", []) + if classifier["projectVersion"] == version + ) + except StopIteration: + raise ValueError(f"Classifier for version '{version}' not found.") from None + + def _get_extractor_id( + self, + project_id: str, + version: Optional[int], + document_type_id: str, + project_type: ProjectType, + ) -> str: + if project_type == ProjectType.PRETRAINED: + return document_type_id + + if version is None: + return None + + response = self.request( + "GET", + url=Endpoint(f"/du_/api/framework/projects/{project_id}/extractors"), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + + try: + return next( + extractor["id"] + for extractor in response.json().get("extractors", []) + if extractor["projectVersion"] == version + and extractor["documentTypeId"] == document_type_id + ) + except StopIteration: + raise ValueError( + f"Extractor for version '{version}' and document type id '{document_type_id}' not found." + ) from None + + async def _get_extractor_id_async( + self, + project_id: str, + version: Optional[int], + document_type_id: str, + project_type: ProjectType, + ) -> str: + if project_type == ProjectType.PRETRAINED: + return document_type_id + + if version is None: + return None + + response = await self.request_async( + "GET", + url=Endpoint(f"/du_/api/framework/projects/{project_id}/extractors"), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + + try: + return next( + extractor["id"] + for extractor in response.json().get("extractors", []) + if extractor["projectVersion"] == version + and extractor["documentTypeId"] == document_type_id + ) + except StopIteration: + raise ValueError( + f"Extractor for version '{version}' and document type id '{document_type_id}' not found." + ) from None + + def _get_project_id( + self, + project_type: ProjectType, + project_name: Optional[str], + classification_result: Optional[ClassificationResult], + ) -> str: + if project_type == ProjectType.PRETRAINED: + return str(UUID(int=0)) + + if classification_result is not None: + return classification_result.project_id + + response = self.request( + "GET", + url=Endpoint("/du_/api/framework/projects"), + params={"api-version": 1.1, "type": project_type.value}, + headers=self._get_common_headers(), + ) + + try: + return next( + project["id"] + for project in response.json()["projects"] + if project["name"] == project_name + ) + except StopIteration: + raise ValueError(f"Project '{project_name}' not found.") from None + + async def _get_project_id_async( + self, + project_type: ProjectType, + project_name: Optional[str], + classification_result: Optional[ClassificationResult], + ) -> str: + if project_type == ProjectType.PRETRAINED: + return str(UUID(int=0)) + + if classification_result is not None: + return classification_result.project_id + + response = await self.request_async( + "GET", + url=Endpoint("/du_/api/framework/projects"), + params={"api-version": 1.1, "type": project_type.value}, + headers=self._get_common_headers(), + ) + + try: + return next( + project["id"] + for project in response.json()["projects"] + if project["name"] == project_name + ) + except StopIteration: + raise ValueError(f"Project '{project_name}' not found.") from None + + def _get_project_tags(self, project_id: str) -> Set[str]: + response = self.request( + "GET", + url=Endpoint(f"/du_/api/framework/projects/{project_id}/tags"), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + return {tag["name"] for tag in response.json().get("tags", [])} + + async def _get_project_tags_async(self, project_id: str) -> Set[str]: + response = await self.request_async( + "GET", + url=Endpoint(f"/du_/api/framework/projects/{project_id}/tags"), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + return {tag["name"] for tag in response.json().get("tags", [])} + + def _get_document_id( + self, + project_id: Optional[str], + file: Optional[FileContent], + file_path: Optional[str], + classification_result: Optional[ClassificationResult], + ) -> str: + if classification_result is not None: + return classification_result.document_id + + document_id = self._start_digitization( + project_id=project_id, + file=file, + file_path=file_path, + ) + self._wait_for_digitization( + project_id=project_id, + document_id=document_id, + ) + + return document_id + + async def _get_document_id_async( + self, + project_id: Optional[str], + file: Optional[FileContent], + file_path: Optional[str], + classification_result: Optional[ClassificationResult], + ) -> str: + if classification_result is not None: + return classification_result.document_id + + document_id = await self._start_digitization_async( + project_id=project_id, + file=file, + file_path=file_path, + ) + await self._wait_for_digitization_async( + project_id=project_id, + document_id=document_id, + ) + + return document_id + + def _get_version( + self, + version: Optional[int], + project_type: ProjectType, + classification_result: Optional[ClassificationResult], + ) -> Optional[int]: + if project_type == ProjectType.PRETRAINED: + return None + + if version is not None: + return version + + if classification_result is None or classification_result.classifier_id is None: + return None + + return self.request( + "GET", + url=Endpoint( + f"/du_/api/framework/projects/{classification_result.project_id}/classifiers/{classification_result.classifier_id}" + ), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ).json()["projectVersion"] + + async def _get_version_async( + self, + version: Optional[int], + project_type: ProjectType, + classification_result: Optional[ClassificationResult], + ) -> Optional[int]: + if project_type == ProjectType.PRETRAINED: + return None + + if version is not None: + return version + + if classification_result is None or classification_result.classifier_id is None: + return None + + return ( + await self.request_async( + "GET", + url=Endpoint( + f"/du_/api/framework/projects/{classification_result.project_id}/classifiers/{classification_result.classifier_id}" + ), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + ).json()["projectVersion"] + + def _get_tag( + self, + project_type: ProjectType, + project_id: str, + tag: Optional[str], + version: Optional[int], + project_name: Optional[str], + classification_result: Optional[ClassificationResult], + ) -> Optional[str]: + if project_type == ProjectType.PRETRAINED: + return None + + if version is not None: + return None + + if classification_result is not None: + return classification_result.tag + + tags = self._get_project_tags(project_id) + if tag not in tags: + raise ValueError( + f"Tag '{tag}' not found in project '{project_name}'. Available tags: {tags}" + ) + + return tag + + async def _get_tag_async( + self, + project_type: ProjectType, + project_id: str, + tag: Optional[str], + version: Optional[int], + project_name: Optional[str], + classification_result: Optional[ClassificationResult], + ) -> Optional[str]: + if project_type == ProjectType.PRETRAINED: + return None + + if version is not None: + return None + + if classification_result is not None: + return classification_result.tag + + tags = await self._get_project_tags_async(project_id) + if tag not in tags: + raise ValueError( + f"Tag '{tag}' not found in project '{project_name}'. Available tags: {tags}" + ) + + return tag + + def _start_digitization( + self, + project_id: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> str: + with open(Path(file_path), "rb") if file_path else nullcontext(file) as handle: + return self.request( + "POST", + url=Endpoint( + f"/du_/api/framework/projects/{project_id}/digitization/start" + ), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + files={"File": handle}, + ).json()["documentId"] + + async def _start_digitization_async( + self, + project_id: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> str: + with open(Path(file_path), "rb") if file_path else nullcontext(file) as handle: + return ( + await self.request_async( + "POST", + url=Endpoint( + f"/du_/api/framework/projects/{project_id}/digitization/start" + ), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + files={"File": handle}, + ) + ).json()["documentId"] + + def _wait_for_digitization(self, project_id: str, document_id: str) -> None: + def result_getter() -> Tuple[str, Optional[str], Optional[str]]: + result = self.request( + method="GET", + url=Endpoint( + f"/du_/api/framework/projects/{project_id}/digitization/result/{document_id}" + ), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ).json() + return ( + result["status"], + result.get("error", None), + result.get("result", None), + ) + + self._wait_for_operation( + result_getter=result_getter, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + + async def _wait_for_digitization_async( + self, project_id: str, document_id: str + ) -> None: + async def result_getter() -> Tuple[str, Optional[str], Optional[str]]: + result = ( + await self.request_async( + method="GET", + url=Endpoint( + f"/du_/api/framework/projects/{project_id}/digitization/result/{document_id}" + ), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + ).json() + return ( + result["status"], + result.get("error", None), + result.get("result", None), + ) + + await self._wait_for_operation_async( + result_getter=result_getter, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + + def _get_document_type_id( + self, + project_id: str, + document_type_name: Optional[str], + project_type: ProjectType, + classification_result: Optional[ClassificationResult], + ) -> str: + if project_type == ProjectType.IXP: + return str(UUID(int=0)) + + if classification_result is not None: + return classification_result.document_type_id + + response = self.request( + "GET", + url=Endpoint(f"/du_/api/framework/projects/{project_id}/document-types"), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + + try: + return next( + extractor["id"] + for extractor in response.json().get("documentTypes", []) + if extractor["name"].lower() == document_type_name.lower() + ) + except StopIteration: + raise ValueError( + f"Document type '{document_type_name}' not found." + ) from None + + async def _get_document_type_id_async( + self, + project_id: str, + document_type_name: Optional[str], + project_type: ProjectType, + classification_result: Optional[ClassificationResult], + ) -> str: + if project_type == ProjectType.IXP: + return str(UUID(int=0)) + + if classification_result is not None: + return classification_result.document_type_id + + response = await self.request_async( + "GET", + url=Endpoint(f"/du_/api/framework/projects/{project_id}/document-types"), + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + + try: + return next( + extractor["id"] + for extractor in response.json().get("documentTypes", []) + if extractor["name"].lower() == document_type_name.lower() + ) + except StopIteration: + raise ValueError( + f"Document type '{document_type_name}' not found." + ) from None + + def _start_extraction( + self, + project_id: str, + extractor_id: str, + tag: Optional[str], + document_type_id: str, + document_id: str, + ) -> StartExtractionResponse: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/start" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/extraction/start" + ) + + operation_id = self.request( + "POST", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + json={"documentId": document_id}, + ).json()["operationId"] + + return StartExtractionResponse( + operation_id=operation_id, + document_id=document_id, + project_id=project_id, + tag=tag, + ) + + async def _start_extraction_async( + self, + project_id: str, + extractor_id: str, + tag: Optional[str], + document_type_id: str, + document_id: str, + ) -> StartExtractionResponse: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/start" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/extraction/start" + ) + + operation_id = ( + await self.request_async( + "POST", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + json={"documentId": document_id}, + ) + ).json()["operationId"] + + return StartExtractionResponse( + operation_id=operation_id, + document_id=document_id, + project_id=project_id, + tag=tag, + ) + + def _wait_for_operation( + self, + result_getter: Callable[[], Tuple[Any, Optional[Any], Optional[Any]]], + wait_statuses: List[str], + success_status: str, + ) -> Any: + start_time = time.monotonic() + status = wait_statuses[0] + result = None + + while ( + status in wait_statuses + and (time.monotonic() - start_time) < self.polling_timeout + ): + status, error, result = result_getter() + time.sleep(self.polling_interval) + + if status != success_status: + if time.monotonic() - start_time >= self.polling_timeout: + raise TimeoutError("Operation timed out.") + raise RuntimeError( + f"Operation failed with status: {status}, error: {error}" + ) + + return result + + async def _wait_for_operation_async( + self, + result_getter: Callable[ + [], Awaitable[Tuple[Any, Optional[Any], Optional[Any]]] + ], + wait_statuses: List[str], + success_status: str, + ) -> Any: + start_time = time.monotonic() + status = wait_statuses[0] + result = None + + while ( + status in wait_statuses + and (time.monotonic() - start_time) < self.polling_timeout + ): + status, error, result = await result_getter() + await asyncio.sleep(self.polling_interval) + + if status != success_status: + if time.monotonic() - start_time >= self.polling_timeout: + raise TimeoutError("Operation timed out.") + raise RuntimeError( + f"Operation failed with status: {status}, error: {error}" + ) + + return result + + def _wait_for_extraction( + self, + project_id: str, + extractor_id: Optional[str], + tag: Optional[str], + document_type_id: str, + operation_id: str, + project_type: ProjectType, + ) -> Union[ExtractionResponse, ExtractionResponseIXP]: + def result_getter() -> Tuple[str, str, Any]: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/result/{operation_id}" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/extraction/result/{operation_id}" + ) + + result = self.request( + method="GET", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ).json() + return ( + result["status"], + result.get("error", None), + result.get("result", None), + ) + + extraction_response = self._wait_for_operation( + result_getter=result_getter, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + + extraction_response["projectId"] = project_id + extraction_response["extractorId"] = extractor_id + extraction_response["tag"] = tag + extraction_response["documentTypeId"] = document_type_id + extraction_response["projectType"] = project_type + + if project_type == ProjectType.IXP: + return ExtractionResponseIXP.model_validate(extraction_response) + + return ExtractionResponse.model_validate(extraction_response) + + async def _wait_for_extraction_async( + self, + project_id: str, + extractor_id: Optional[str], + tag: Optional[str], + document_type_id: str, + operation_id: str, + project_type: ProjectType, + ) -> Union[ExtractionResponse, ExtractionResponseIXP]: + async def result_getter() -> Tuple[str, str, Any]: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/result/{operation_id}" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/extraction/result/{operation_id}" + ) + + result = ( + await self.request_async( + method="GET", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + ).json() + return ( + result["status"], + result.get("error", None), + result.get("result", None), + ) + + extraction_response = await self._wait_for_operation_async( + result_getter=result_getter, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + + extraction_response["projectId"] = project_id + extraction_response["extractorId"] = extractor_id + extraction_response["tag"] = tag + extraction_response["documentTypeId"] = document_type_id + extraction_response["projectType"] = project_type + + if project_type == ProjectType.IXP: + return ExtractionResponseIXP.model_validate(extraction_response) + + return ExtractionResponse.model_validate(extraction_response) + + def _start_classification( + self, + project_id: str, + tag: Optional[str], + classifier_id: Optional[str], + document_id: str, + ) -> str: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/classification/start" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/classification/start" + ) + + return self.request( + "POST", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + json={"documentId": document_id}, + ).json()["operationId"] + + async def _start_classification_async( + self, + project_id: str, + tag: Optional[str], + classifier_id: Optional[str], + document_id: str, + ) -> str: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/classification/start" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/classification/start" + ) + + return ( + await self.request_async( + "POST", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + json={"documentId": document_id}, + ) + ).json()["operationId"] + + def _wait_for_classification( + self, + project_id: str, + project_type: ProjectType, + classifier_id: Optional[str], + tag: Optional[str], + operation_id: str, + ) -> List[ClassificationResult]: + def result_getter() -> Tuple[str, Optional[str], Optional[str]]: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/classification/result/{operation_id}" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/classification/result/{operation_id}" + ) + + result = self.request( + method="GET", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ).json() + return ( + result["status"], + result.get("error", None), + result.get("result", None), + ) + + classification_response = self._wait_for_operation( + result_getter=result_getter, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + for classification_result in classification_response["classificationResults"]: + classification_result["ProjectId"] = project_id + classification_result["ProjectType"] = project_type + classification_result["ClassifierId"] = classifier_id + classification_result["Tag"] = tag + + return ClassificationResponse.model_validate( + classification_response + ).classification_results + + async def _wait_for_classification_async( + self, + project_id: str, + project_type: ProjectType, + classifier_id: Optional[str], + tag: Optional[str], + operation_id: str, + ) -> List[ClassificationResult]: + async def result_getter() -> Tuple[str, Optional[str], Optional[str]]: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/classification/result/{operation_id}" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/classification/result/{operation_id}" + ) + + result = ( + await self.request_async( + method="GET", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + ).json() + return ( + result["status"], + result.get("error", None), + result.get("result", None), + ) + + classification_response = await self._wait_for_operation_async( + result_getter=result_getter, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + for classification_result in classification_response["classificationResults"]: + classification_result["ProjectId"] = project_id + classification_result["ProjectType"] = project_type + classification_result["ClassifierId"] = classifier_id + classification_result["Tag"] = tag + + return ClassificationResponse.model_validate( + classification_response + ).classification_results + + @traced(name="documents_classify", run_type="uipath") + def classify( + self, + project_type: ProjectType, + tag: Optional[str] = None, + version: Optional[int] = None, + project_name: Optional[str] = None, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> List[ClassificationResult]: + """Classify a document using a DU Modern project. + + Args: + project_type (ProjectType): Type of the project. + project_name (str, optional): Name of the [DU Modern](https://docs.uipath.com/document-understanding/automation-cloud/latest/user-guide/about-document-understanding) project. Must be provided if `project_type` is not `ProjectType.PRETRAINED`. + tag (str, optional): Tag of the published project version. Must be provided if `project_type` is not `ProjectType.PRETRAINED`. + version (int, optional): Version of the published project. It can be used instead of `tag`. + file (FileContent, optional): The document file to be classified. + file_path (str, optional): Path to the document file to be classified. + + Note: + Either `file` or `file_path` must be provided, but not both. + + Returns: + List[ClassificationResult]: A list of classification results. + + Examples: + ```python + Modern DU project: + with open("path/to/document.pdf", "rb") as file: + classification_results = service.classify( + project_name="MyModernProjectName", + tag="Production", + file=file, + ) + + Pretrained project: + with open("path/to/document.pdf", "rb") as file: + classification_results = service.classify( + project_type=ProjectType.PRETRAINED, + file=file, + ) + ``` + """ + _validate_classify_params( + project_type=project_type, + tag=tag, + version=version, + project_name=project_name, + file=file, + file_path=file_path, + ) + + project_id = self._get_project_id( + project_name=project_name, + project_type=project_type, + classification_result=None, + ) + + document_id = self._get_document_id( + project_id=project_id, + file=file, + file_path=file_path, + classification_result=None, + ) + + classifier_id = self._get_classifier_id( + project_type=project_type, project_id=project_id, version=version + ) + + tag = self._get_tag( + project_type=project_type, + project_id=project_id, + tag=tag, + version=version, + project_name=project_name, + classification_result=None, + ) + + operation_id = self._start_classification( + project_id=project_id, + tag=tag, + classifier_id=classifier_id, + document_id=document_id, + ) + return self._wait_for_classification( + project_id=project_id, + project_type=project_type, + classifier_id=classifier_id, + tag=tag, + operation_id=operation_id, + ) + + @traced(name="documents_classify_async", run_type="uipath") + async def classify_async( + self, + project_type: ProjectType, + tag: Optional[str] = None, + version: Optional[int] = None, + project_name: Optional[str] = None, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> List[ClassificationResult]: + """Asynchronously version of the [`classify`][uipath.platform.documents._documents_service.DocumentsService.classify] method.""" + _validate_classify_params( + project_type=project_type, + tag=tag, + version=version, + project_name=project_name, + file=file, + file_path=file_path, + ) + + project_id = await self._get_project_id_async( + project_name=project_name, + project_type=project_type, + classification_result=None, + ) + + document_id = await self._get_document_id_async( + project_id=project_id, + file=file, + file_path=file_path, + classification_result=None, + ) + + classifier_id = await self._get_classifier_id_async( + project_type=project_type, project_id=project_id, version=version + ) + + tag = await self._get_tag_async( + project_type=project_type, + project_id=project_id, + tag=tag, + version=version, + project_name=project_name, + classification_result=None, + ) + + operation_id = await self._start_classification_async( + project_id=project_id, + tag=tag, + classifier_id=classifier_id, + document_id=document_id, + ) + return await self._wait_for_classification_async( + project_id=project_id, + project_type=project_type, + classifier_id=classifier_id, + tag=tag, + operation_id=operation_id, + ) + + @traced(name="documents_start_ixp_extraction", run_type="uipath") + def start_ixp_extraction( + self, + project_name: str, + tag: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> StartExtractionResponse: + """Start an IXP extraction process without waiting for results (non-blocking). + + This method uploads the file as an attachment and starts the extraction process, + returning immediately without waiting for the extraction to complete. + Use this for async workflows where you want to receive results via callback/webhook. + + Args: + project_name (str): Name of the IXP project. + tag (str): Tag of the published project version (e.g., "staging"). + file (FileContent, optional): The document file to be processed. + file_path (str, optional): Path to the document file to be processed. + + Note: + Either `file` or `file_path` must be provided, but not both. + + Returns: + ExtractionStartResponse: Contains the operation_id, document_id, project_id, and tag + + Examples: + ```python + start_response = uipath.documents.start_ixp_extraction( + project_name="MyIXPProjectName", + tag="staging", + file_path="path/to/document.pdf", + ) + # start_response.operation_id can be used to poll for results later + ``` + """ + _exactly_one_must_be_provided(file=file, file_path=file_path) + + project_id = self._get_project_id( + project_type=ProjectType.IXP, + project_name=project_name, + classification_result=None, + ) + + document_id = self._start_digitization( + project_id=project_id, + file=file, + file_path=file_path, + ) + + return self._start_extraction( + project_id=project_id, + extractor_id=None, + tag=tag, + document_type_id=str(UUID(int=0)), + document_id=document_id, + ) + + @traced(name="documents_start_ixp_extraction_async", run_type="uipath") + async def start_ixp_extraction_async( + self, + project_name: str, + tag: str, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + ) -> StartExtractionResponse: + """Asynchronous version of the [`start_ixp_extraction`][uipath.platform.documents._documents_service.DocumentsService.start_ixp_extraction] method.""" + _exactly_one_must_be_provided(file=file, file_path=file_path) + + project_id = await self._get_project_id_async( + project_type=ProjectType.IXP, + project_name=project_name, + classification_result=None, + ) + + document_id = await self._start_digitization_async( + project_id=project_id, + file=file, + file_path=file_path, + ) + + return await self._start_extraction_async( + project_id=project_id, + extractor_id=None, + tag=tag, + document_type_id=str(UUID(int=0)), + document_id=document_id, + ) + + def _retrieve_operation_result( + self, + url: Endpoint, + operation_id: str, + operation_name: str, + ) -> Dict: + response = self.request( + method="GET", + url=url, + params={"api-version": "1.1"}, + headers=self._get_common_headers(), + ).json() + + status = response.get("status") + if status in ["NotStarted", "Running"]: + raise OperationNotCompleteException( + operation_id=operation_id, + status=response.get("status"), + operation_name=operation_name, + ) + + if status != "Succeeded": + raise OperationFailedException( + operation_id=operation_id, + status=status, + error=response.get("error"), + operation_name=operation_name, + ) + return response.get("result") + + async def _retrieve_operation_result_async( + self, + url: Endpoint, + operation_id: str, + operation_name: str, + ) -> Dict: + response = ( + await self.request_async( + method="GET", + url=url, + params={"api-version": "1.1"}, + headers=self._get_common_headers(), + ) + ).json() + + status = response.get("status") + if status in ["NotStarted", "Running"]: + raise OperationNotCompleteException( + operation_id=operation_id, + status=response.get("status"), + operation_name=operation_name, + ) + + if status != "Succeeded": + raise OperationFailedException( + operation_id=operation_id, + status=status, + error=response.get("error"), + operation_name=operation_name, + ) + return response.get("result") + + @traced(name="documents_retrieve_ixp_extraction_result", run_type="uipath") + def retrieve_ixp_extraction_result( + self, + project_id: str, + tag: str, + operation_id: str, + ) -> ExtractionResponseIXP: + """Retrieve the result of an IXP extraction operation (single-shot, non-blocking). + + This method retrieves the result of an IXP extraction that was previously started + with `start_ixp_extraction`. It does not poll - it makes a single request and + returns the result if available, or raises an exception if not complete. + + Args: + project_id (str): The ID of the IXP project. + tag (str): The tag of the published project version. + operation_id (str): The operation ID returned from `start_ixp_extraction`. + + Returns: + ExtractionResponseIXP: The extraction response containing the extracted data. + + Raises: + OperationNotCompleteException: If the extraction is not yet complete. + OperationFailedException: If the extraction operation failed. + + Examples: + ```python + # After receiving a callback/webhook that extraction is complete: + result = service.retrieve_ixp_extraction_result( + project_id=start_response.project_id, + tag=start_response.tag, + operation_id=start_response.operation_id, + ) + ``` + """ + document_type_id = str(UUID(int=0)) + + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/extraction/result/{operation_id}" + ) + + extraction_response = self._retrieve_operation_result( + url=url, + operation_id=operation_id, + operation_name="IXP extraction", + ) + + extraction_response["projectId"] = project_id + extraction_response["extractorId"] = None + extraction_response["tag"] = tag + extraction_response["documentTypeId"] = document_type_id + extraction_response["projectType"] = ProjectType.IXP + + return ExtractionResponseIXP.model_validate(extraction_response) + + @traced(name="documents_retrieve_ixp_extraction_result_async", run_type="uipath") + async def retrieve_ixp_extraction_result_async( + self, + project_id: str, + tag: str, + operation_id: str, + ) -> ExtractionResponseIXP: + """Asynchronous version of the [`retrieve_ixp_extraction_result`][uipath.platform.documents._documents_service.DocumentsService.retrieve_ixp_extraction_result] method.""" + document_type_id = str(UUID(int=0)) + + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/extraction/result/{operation_id}" + ) + + extraction_response = await self._retrieve_operation_result_async( + url=url, + operation_id=operation_id, + operation_name="IXP extraction", + ) + + extraction_response["projectId"] = project_id + extraction_response["extractorId"] = None + extraction_response["tag"] = tag + extraction_response["documentTypeId"] = document_type_id + extraction_response["projectType"] = ProjectType.IXP + + return ExtractionResponseIXP.model_validate(extraction_response) + + @traced(name="documents_extract", run_type="uipath") + def extract( + self, + tag: Optional[str] = None, + version: Optional[int] = None, + project_name: Optional[str] = None, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + classification_result: Optional[ClassificationResult] = None, + project_type: Optional[ProjectType] = None, + document_type_name: Optional[str] = None, + ) -> Union[ExtractionResponse, ExtractionResponseIXP]: + """Extract predicted data from a document using an DU Modern/IXP project. + + Args: + project_name (str, optional): Name of the [IXP](https://docs.uipath.com/ixp/automation-cloud/latest/overview/managing-projects#creating-a-new-project)/[DU Modern](https://docs.uipath.com/document-understanding/automation-cloud/latest/user-guide/about-document-understanding) project. Must be provided if `classification_result` is not provided. + tag (str): Tag of the published project version. Must be provided if `classification_result` is not provided and `project_type` is not `ProjectType.PRETRAINED`. + version (int, optional): Version of the published project. It can be used instead of `tag`. + file (FileContent, optional): The document file to be processed. Must be provided if `classification_result` is not provided. + file_path (str, optional): Path to the document file to be processed. Must be provided if `classification_result` is not provided. + project_type (ProjectType, optional): Type of the project. Must be provided if `project_name` is provided. + document_type_name (str, optional): Document type name associated with the extractor to be used for extraction. Required if `project_type` is `ProjectType.MODERN` and `project_name` is provided. + classification_result (ClassificationResult, optional): The classification result obtained from a previous classification step. If provided, `project_name`, `project_type`, `file`, `file_path`, and `document_type_name` must not be provided. + + Note: + Either `file` or `file_path` must be provided, but not both. + + Returns: + Union[ExtractionResponse, ExtractionResponseIXP]: The extraction response containing the extracted data. + + Examples: + IXP projects: + ```python + with open("path/to/document.pdf", "rb") as file: + extraction_response = service.extract( + project_name="MyIXPProjectName", + tag="live", + file=file, + ) + ``` + + DU Modern projects (providing document type name): + ```python + with open("path/to/document.pdf", "rb") as file: + extraction_response = service.extract( + project_name="MyModernProjectName", + tag="Production", + file=file, + project_type=ProjectType.MODERN, + document_type_name="Receipts", + ) + ``` + + DU Modern projects (using existing classification result): + ```python + with open("path/to/document.pdf", "rb") as file: + classification_results = uipath.documents.classify( + tag="Production", + project_name="MyModernProjectName", + file=file, + ) + + extraction_result = uipath.documents.extract( + classification_result=max(classification_results, key=lambda result: result.confidence), + ) + ``` + """ + project_type = _validate_extract_params_and_get_project_type( + tag=tag, + version=version, + project_name=project_name, + file=file, + file_path=file_path, + classification_result=classification_result, + project_type=project_type, + document_type_name=document_type_name, + ) + + project_id = self._get_project_id( + project_name=project_name, + project_type=project_type, + classification_result=classification_result, + ) + + version = self._get_version( + version=version, + project_type=project_type, + classification_result=classification_result, + ) + + tag = self._get_tag( + project_type=project_type, + project_id=project_id, + tag=tag, + version=version, + project_name=project_name, + classification_result=classification_result, + ) + + document_id = self._get_document_id( + project_id=project_id, + file=file, + file_path=file_path, + classification_result=classification_result, + ) + + document_type_id = self._get_document_type_id( + project_id=project_id, + document_type_name=document_type_name, + project_type=project_type, + classification_result=classification_result, + ) + + extractor_id = self._get_extractor_id( + project_id=project_id, + version=version, + document_type_id=document_type_id, + project_type=project_type, + ) + + operation_id = self._start_extraction( + project_id=project_id, + extractor_id=extractor_id, + tag=tag, + document_type_id=document_type_id, + document_id=document_id, + ).operation_id + + return self._wait_for_extraction( + project_id=project_id, + extractor_id=extractor_id, + tag=tag, + document_type_id=document_type_id, + operation_id=operation_id, + project_type=project_type, + ) + + @traced(name="documents_extract_async", run_type="uipath") + async def extract_async( + self, + tag: Optional[str] = None, + version: Optional[int] = None, + project_name: Optional[str] = None, + file: Optional[FileContent] = None, + file_path: Optional[str] = None, + classification_result: Optional[ClassificationResult] = None, + project_type: Optional[ProjectType] = None, + document_type_name: Optional[str] = None, + ) -> Union[ExtractionResponse, ExtractionResponseIXP]: + """Asynchronously version of the [`extract`][uipath.platform.documents._documents_service.DocumentsService.extract] method.""" + project_type = _validate_extract_params_and_get_project_type( + tag=tag, + version=version, + project_name=project_name, + file=file, + file_path=file_path, + classification_result=classification_result, + project_type=project_type, + document_type_name=document_type_name, + ) + + project_id = await self._get_project_id_async( + project_name=project_name, + project_type=project_type, + classification_result=classification_result, + ) + + version = await self._get_version_async( + version=version, + project_type=project_type, + classification_result=classification_result, + ) + + tag = await self._get_tag_async( + project_type=project_type, + project_id=project_id, + tag=tag, + version=version, + project_name=project_name, + classification_result=classification_result, + ) + + document_id = await self._get_document_id_async( + project_id=project_id, + file=file, + file_path=file_path, + classification_result=classification_result, + ) + + document_type_id = await self._get_document_type_id_async( + project_id=project_id, + document_type_name=document_type_name, + project_type=project_type, + classification_result=classification_result, + ) + + extractor_id = await self._get_extractor_id_async( + project_id=project_id, + version=version, + document_type_id=document_type_id, + project_type=project_type, + ) + + operation_id = ( + await self._start_extraction_async( + project_id=project_id, + extractor_id=extractor_id, + tag=tag, + document_type_id=document_type_id, + document_id=document_id, + ) + ).operation_id + + return await self._wait_for_extraction_async( + project_id=project_id, + extractor_id=extractor_id, + tag=tag, + document_type_id=document_type_id, + operation_id=operation_id, + project_type=project_type, + ) + + def _start_classification_validation( + self, + project_id: str, + classifier_id: Optional[str], + tag: Optional[str], + classification_results: List[ClassificationResult], + action_title: str, + action_priority: Optional[ActionPriority] = None, + action_catalog: Optional[str] = None, + action_folder: Optional[str] = None, + storage_bucket_name: Optional[str] = None, + storage_bucket_directory_path: Optional[str] = None, + ) -> str: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/start" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/classifiers/validation/start" + ) + + return self.request( + "POST", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + json={ + "classificationResults": [ + cr.model_dump() for cr in classification_results + ], + "documentId": classification_results[0].document_id, + "actionTitle": action_title, + "actionPriority": action_priority, + "actionCatalog": action_catalog, + "actionFolder": action_folder, + "storageBucketName": storage_bucket_name, + "storageBucketDirectoryPath": storage_bucket_directory_path, + }, + ).json()["operationId"] + + async def _start_classification_validation_async( + self, + project_id: str, + classifier_id: Optional[str], + tag: Optional[str], + classification_results: List[ClassificationResult], + action_title: str, + action_priority: Optional[ActionPriority] = None, + action_catalog: Optional[str] = None, + action_folder: Optional[str] = None, + storage_bucket_name: Optional[str] = None, + storage_bucket_directory_path: Optional[str] = None, + ) -> str: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/start" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/classifiers/validation/start" + ) + + return ( + await self.request_async( + "POST", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + json={ + "classificationResults": [ + cr.model_dump() for cr in classification_results + ], + "documentId": classification_results[0].document_id, + "actionTitle": action_title, + "actionPriority": action_priority, + "actionCatalog": action_catalog, + "actionFolder": action_folder, + "storageBucketName": storage_bucket_name, + "storageBucketDirectoryPath": storage_bucket_directory_path, + }, + ) + ).json()["operationId"] + + def _start_extraction_validation( + self, + project_id: str, + extractor_id: Optional[str], + tag: Optional[str], + document_type_id: str, + action_title: str, + action_priority: Optional[ActionPriority], + action_catalog: Optional[str], + action_folder: Optional[str], + storage_bucket_name: Optional[str], + storage_bucket_directory_path: Optional[str], + extraction_response: ExtractionResponse, + ) -> StartExtractionValidationResponse: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/start" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/start" + ) + + operation_id = self.request( + "POST", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + json={ + "extractionResult": extraction_response.extraction_result.model_dump(), + "documentId": extraction_response.extraction_result.document_id, + "actionTitle": action_title, + "actionPriority": action_priority, + "actionCatalog": action_catalog, + "actionFolder": action_folder, + "storageBucketName": storage_bucket_name, + "allowChangeOfDocumentType": True, + "storageBucketDirectoryPath": storage_bucket_directory_path, + }, + ).json()["operationId"] + + return StartExtractionValidationResponse( + operation_id=operation_id, + document_id=extraction_response.extraction_result.document_id, + project_id=project_id, + tag=tag, + ) + + async def _start_extraction_validation_async( + self, + project_id: str, + extractor_id: Optional[str], + tag: Optional[str], + document_type_id: str, + action_title: str, + action_priority: Optional[ActionPriority], + action_catalog: Optional[str], + action_folder: Optional[str], + storage_bucket_name: Optional[str], + storage_bucket_directory_path: Optional[str], + extraction_response: ExtractionResponse, + ) -> StartExtractionValidationResponse: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/start" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/start" + ) + + operation_id = ( + await self.request_async( + "POST", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + json={ + "extractionResult": extraction_response.extraction_result.model_dump(), + "documentId": extraction_response.extraction_result.document_id, + "actionTitle": action_title, + "actionPriority": action_priority, + "actionCatalog": action_catalog, + "actionFolder": action_folder, + "storageBucketName": storage_bucket_name, + "allowChangeOfDocumentType": True, + "storageBucketDirectoryPath": storage_bucket_directory_path, + }, + ) + ).json()["operationId"] + + return StartExtractionValidationResponse( + operation_id=operation_id, + document_id=extraction_response.extraction_result.document_id, + project_id=project_id, + tag=tag, + ) + + @traced(name="documents_start_ixp_extraction_validation", run_type="uipath") + def start_ixp_extraction_validation( + self, + extraction_response: ExtractionResponseIXP, + action_title: str, + action_catalog: Optional[str] = None, + action_priority: Optional[ActionPriority] = None, + action_folder: Optional[str] = None, + storage_bucket_name: Optional[str] = None, + storage_bucket_directory_path: Optional[str] = None, + ) -> StartExtractionValidationResponse: + """Start an IXP extraction validation action without waiting for results (non-blocking). + + Args: + extraction_response (ExtractionResponseIXP): The extraction response from the IXP extraction process. + action_title (str): The title of the validation action. + action_catalog (str, optional): The catalog of the validation action. + action_priority (ActionPriority, optional): The priority of the validation action. + action_folder (str, optional): The folder of the validation action. + storage_bucket_name (str, optional): The name of the storage bucket where validation data will be stored. + storage_bucket_directory_path (str, optional): The directory path within the storage bucket. + + Returns: + StartExtractionValidationResponse: Contains the operation_id, document_id, project_id, and tag. + + Examples: + ```python + start_operation_response = service.start_ixp_extraction_validation( + action_title="Validate IXP Extraction", + action_priority=ActionPriority.HIGH, + action_catalog="DefaultCatalog", + action_folder="Validations", + storage_bucket_name="my-storage-bucket", + storage_bucket_directory_path="validations/ixp", + extraction_response=extraction_response, + ) + # start_operation_response can be used to poll for validation results later + ``` + """ + return self._start_extraction_validation( + project_id=extraction_response.project_id, + extractor_id=None, + tag=extraction_response.tag, + document_type_id=str(UUID(int=0)), + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + extraction_response=extraction_response, + ) + + @resource_override( + resource_type="bucket", + resource_identifier="storage_bucket_name", + folder_identifier="action_folder", + ) + @traced( + name="documents_start_ixp_extraction_validation_async", + run_type="uipath", + ) + async def start_ixp_extraction_validation_async( + self, + extraction_response: ExtractionResponseIXP, + action_title: str, + action_catalog: Optional[str] = None, + action_priority: Optional[ActionPriority] = None, + action_folder: Optional[str] = None, + storage_bucket_name: Optional[str] = None, + storage_bucket_directory_path: Optional[str] = None, + ) -> StartExtractionValidationResponse: + """Asynchronous version of the [`start_ixp_extraction_validation`][uipath.platform.documents._documents_service.DocumentsService.start_ixp_extraction_validation] method.""" + return await self._start_extraction_validation_async( + project_id=extraction_response.project_id, + extractor_id=None, + tag=extraction_response.tag, + document_type_id=str(UUID(int=0)), + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + extraction_response=extraction_response, + ) + + @resource_override( + resource_type="bucket", + resource_identifier="storage_bucket_name", + folder_identifier="action_folder", + ) + @traced( + name="documents_retrieve_ixp_extraction_validation_result", + run_type="uipath", + ) + def retrieve_ixp_extraction_validation_result( + self, + project_id: str, + tag: str, + operation_id: str, + ) -> ValidateExtractionAction: + """Retrieve the result of an IXP create validate extraction action operation (single-shot, non-blocking). + + This method retrieves the result of an IXP create validate extraction action that was previously started + with `start_ixp_extraction_validation`. It does not poll - it makes a single request and + returns the result if available, or raises an exception if not complete. + + Args: + operation_id (str): The operation ID returned from `start_ixp_extraction_validation`. + project_id (str): The ID of the IXP project. + tag (str): The tag of the published project version. + + Returns: + ValidateExtractionAction: The validation action + + Raises: + OperationNotCompleteException: If the validation action is not yet complete. + OperationFailedException: If the validation action has failed. + + Examples: + ```python + # After receiving a callback/webhook that validation is complete: + validation_result = service.retrieve_ixp_extraction_validation_result( + operation_id=start_operation_response.operation_id, + project_id=start_operation_response.project_id, + tag=start_operation_response.tag, + ) + ``` + """ + document_type_id = str(UUID(int=0)) + + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/result/{operation_id}" + ) + + result = self._retrieve_operation_result( + url=url, + operation_id=operation_id, + operation_name="IXP Create Validate Extraction Action", + ) + + result["projectId"] = project_id + result["projectType"] = ProjectType.IXP + result["extractorId"] = None + result["tag"] = tag + result["documentTypeId"] = str(UUID(int=0)) + result["operationId"] = operation_id + + return ValidateExtractionAction.model_validate(result) + + @traced( + name="documents_retrieve_ixp_extraction_validation_result_async", + run_type="uipath", + ) + async def retrieve_ixp_extraction_validation_result_async( + self, + project_id: str, + tag: str, + operation_id: str, + ) -> ValidateExtractionAction: + """Asynchronous version of the [`retrieve_ixp_extraction_validation_result`][uipath.platform.documents._documents_service.DocumentsService.retrieve_ixp_extraction_validation_result] method.""" + document_type_id = str(UUID(int=0)) + + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/result/{operation_id}" + ) + + result = await self._retrieve_operation_result_async( + url=url, + operation_id=operation_id, + operation_name="IXP Create Validate Extraction Action", + ) + + result["projectId"] = project_id + result["projectType"] = ProjectType.IXP + result["extractorId"] = None + result["tag"] = tag + result["documentTypeId"] = str(UUID(int=0)) + result["operationId"] = operation_id + + return ValidateExtractionAction.model_validate(result) + + def _get_classification_validation_result( + self, + project_id: str, + classifier_id: Optional[str], + tag: Optional[str], + operation_id: str, + ) -> Dict: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/result/{operation_id}" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/classifiers/validation/result/{operation_id}" + ) + + return self.request( + method="GET", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ).json() + + async def _get_classification_validation_result_async( + self, + project_id: str, + classifier_id: Optional[str], + tag: Optional[str], + operation_id: str, + ) -> Dict: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/result/{operation_id}" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/classifiers/validation/result/{operation_id}" + ) + + return ( + await self.request_async( + method="GET", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + ).json() + + def _get_extraction_validation_result( + self, + project_id: str, + extractor_id: Optional[str], + tag: Optional[str], + document_type_id: str, + operation_id: str, + ) -> Dict: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/result/{operation_id}" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/result/{operation_id}" + ) + + return self.request( + method="GET", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ).json() + + async def _get_extraction_validation_result_async( + self, + project_id: str, + extractor_id: Optional[str], + tag: Optional[str], + document_type_id: str, + operation_id: str, + ) -> Dict: + if tag is None: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/result/{operation_id}" + ) + else: + url = Endpoint( + f"/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/result/{operation_id}" + ) + + return ( + await self.request_async( + method="GET", + url=url, + params={"api-version": 1.1}, + headers=self._get_common_headers(), + ) + ).json() + + def _wait_for_create_validate_classification_action( + self, + project_id: str, + project_type: ProjectType, + classifier_id: Optional[str], + tag: Optional[str], + operation_id: str, + ) -> ValidateClassificationAction: + def result_getter() -> Tuple[Any, Optional[Any], Optional[Any]]: + result = self._get_classification_validation_result( + project_id=project_id, + classifier_id=classifier_id, + tag=tag, + operation_id=operation_id, + ) + return ( + result["status"], + result.get("error", None), + result.get("result", None), + ) + + response = self._wait_for_operation( + result_getter=result_getter, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + + response["projectId"] = project_id + response["projectType"] = project_type + response["classifierId"] = classifier_id + response["tag"] = tag + response["operationId"] = operation_id + return ValidateClassificationAction.model_validate(response) + + async def _wait_for_create_validate_classification_action_async( + self, + project_id: str, + project_type: ProjectType, + classifier_id: Optional[str], + tag: Optional[str], + operation_id: str, + ) -> ValidateClassificationAction: + async def result_getter() -> Tuple[Any, Optional[Any], Optional[Any]]: + result = await self._get_classification_validation_result_async( + project_id=project_id, + classifier_id=classifier_id, + tag=tag, + operation_id=operation_id, + ) + return ( + result["status"], + result.get("error", None), + result.get("result", None), + ) + + response = await self._wait_for_operation_async( + result_getter=result_getter, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + + response["projectId"] = project_id + response["projectType"] = project_type + response["classifierId"] = classifier_id + response["tag"] = tag + response["operationId"] = operation_id + return ValidateClassificationAction.model_validate(response) + + def _wait_for_create_validate_extraction_action( + self, + project_id: str, + project_type: ProjectType, + extractor_id: Optional[str], + tag: Optional[str], + document_type_id: str, + operation_id: str, + ) -> ValidateExtractionAction: + def result_getter() -> Tuple[Any, Optional[Any], Optional[Any]]: + result = self._get_extraction_validation_result( + project_id=project_id, + extractor_id=extractor_id, + tag=tag, + document_type_id=document_type_id, + operation_id=operation_id, + ) + return ( + result["status"], + result.get("error", None), + result.get("result", None), + ) + + response = self._wait_for_operation( + result_getter=result_getter, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + + response["projectId"] = project_id + response["projectType"] = project_type + response["extractorId"] = extractor_id + response["tag"] = tag + response["documentTypeId"] = document_type_id + response["operationId"] = operation_id + return ValidateExtractionAction.model_validate(response) + + async def _wait_for_create_validate_extraction_action_async( + self, + project_id: str, + project_type: ProjectType, + extractor_id: Optional[str], + tag: Optional[str], + document_type_id: str, + operation_id: str, + ) -> ValidateExtractionAction: + async def result_getter_async() -> Tuple[Any, Optional[Any], Optional[Any]]: + result = await self._get_extraction_validation_result_async( + project_id=project_id, + extractor_id=extractor_id, + tag=tag, + document_type_id=document_type_id, + operation_id=operation_id, + ) + return ( + result["status"], + result.get("error", None), + result.get("result", None), + ) + + response = await self._wait_for_operation_async( + result_getter=result_getter_async, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + + response["projectId"] = project_id + response["projectType"] = project_type + response["extractorId"] = extractor_id + response["tag"] = tag + response["documentTypeId"] = document_type_id + response["operationId"] = operation_id + return ValidateExtractionAction.model_validate(response) + + @traced(name="documents_create_validate_classification_action", run_type="uipath") + def create_validate_classification_action( + self, + classification_results: List[ClassificationResult], + action_title: str, + action_priority: Optional[ActionPriority] = None, + action_catalog: Optional[str] = None, + action_folder: Optional[str] = None, + storage_bucket_name: Optional[str] = None, + storage_bucket_directory_path: Optional[str] = None, + ) -> ValidateClassificationAction: + """Create a validate classification action for a document based on the classification results. More details about validation actions can be found in the [official documentation](https://docs.uipath.com/ixp/automation-cloud/latest/user-guide/validating-classifications). + + Args: + classification_results (List[ClassificationResult]): The classification results to be validated, typically obtained from the [`classify`][uipath.platform.documents._documents_service.DocumentsService.classify] method. + action_title (str): Title of the action. + action_priority (ActionPriority, optional): Priority of the action. + action_catalog (str, optional): Catalog of the action. + action_folder (str, optional): Folder of the action. + storage_bucket_name (str, optional): Name of the storage bucket. + storage_bucket_directory_path (str, optional): Directory path in the storage bucket. + + Returns: + ValidateClassificationAction: The created validate classification action. + + Examples: + ```python + validation_action = service.create_validate_classification_action( + action_title="Test Validation Action", + action_priority=ActionPriority.MEDIUM, + action_catalog="default_du_actions", + action_folder="Shared", + storage_bucket_name="du_storage_bucket", + storage_bucket_directory_path="TestDirectory", + classification_results=classification_results, + ) + ``` + """ + if not classification_results: + raise ValueError("`classification_results` must not be empty") + + operation_id = self._start_classification_validation( + project_id=classification_results[0].project_id, + classifier_id=classification_results[0].classifier_id, + tag=classification_results[0].tag, + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + classification_results=classification_results, + ) + + return self._wait_for_create_validate_classification_action( + project_id=classification_results[0].project_id, + project_type=classification_results[0].project_type, + classifier_id=classification_results[0].classifier_id, + tag=classification_results[0].tag, + operation_id=operation_id, + ) + + @traced(name="documents_create_validate_classification_action", run_type="uipath") + async def create_validate_classification_action_async( + self, + classification_results: List[ClassificationResult], + action_title: str, + action_priority: Optional[ActionPriority] = None, + action_catalog: Optional[str] = None, + action_folder: Optional[str] = None, + storage_bucket_name: Optional[str] = None, + storage_bucket_directory_path: Optional[str] = None, + ) -> ValidateClassificationAction: + """Asynchronous version of the [`create_validation_action`][uipath.platform.documents._documents_service.DocumentsService.create_validate_classification_action] method.""" + if not classification_results: + raise ValueError("`classification_results` must not be empty") + + operation_id = await self._start_classification_validation_async( + project_id=classification_results[0].project_id, + classifier_id=classification_results[0].classifier_id, + tag=classification_results[0].tag, + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + classification_results=classification_results, + ) + + return await self._wait_for_create_validate_classification_action_async( + project_id=classification_results[0].project_id, + project_type=classification_results[0].project_type, + classifier_id=classification_results[0].classifier_id, + tag=classification_results[0].tag, + operation_id=operation_id, + ) + + @traced(name="documents_create_validate_extraction_action", run_type="uipath") + def create_validate_extraction_action( + self, + extraction_response: ExtractionResponse, + action_title: str, + action_priority: Optional[ActionPriority] = None, + action_catalog: Optional[str] = None, + action_folder: Optional[str] = None, + storage_bucket_name: Optional[str] = None, + storage_bucket_directory_path: Optional[str] = None, + ) -> ValidateExtractionAction: + """Create a validate extraction action for a document based on the extraction response. More details about validation actions can be found in the [official documentation](https://docs.uipath.com/ixp/automation-cloud/latest/user-guide/validating-extractions). + + Args: + extraction_response (ExtractionResponse): The extraction result to be validated, typically obtained from the [`extract`][uipath.platform.documents._documents_service.DocumentsService.extract] method. + action_title (str): Title of the action. + action_priority (ActionPriority, optional): Priority of the action. + action_catalog (str, optional): Catalog of the action. + action_folder (str, optional): Folder of the action. + storage_bucket_name (str, optional): Name of the storage bucket. + storage_bucket_directory_path (str, optional): Directory path in the storage bucket. + + Returns: + ValidateClassificationAction: The created validation action. + + Examples: + ```python + validation_action = service.create_validate_extraction_action( + action_title="Test Validation Action", + action_priority=ActionPriority.MEDIUM, + action_catalog="default_du_actions", + action_folder="Shared", + storage_bucket_name="du_storage_bucket", + storage_bucket_directory_path="TestDirectory", + extraction_response=extraction_response, + ) + ``` + """ + operation_id = self._start_extraction_validation( + project_id=extraction_response.project_id, + extractor_id=extraction_response.extractor_id, + tag=extraction_response.tag, + document_type_id=extraction_response.document_type_id, + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + extraction_response=extraction_response, + ).operation_id + + return self._wait_for_create_validate_extraction_action( + project_id=extraction_response.project_id, + project_type=extraction_response.project_type, + extractor_id=extraction_response.extractor_id, + tag=extraction_response.tag, + document_type_id=extraction_response.document_type_id, + operation_id=operation_id, + ) + + @traced(name="documents_create_validate_extraction_action_async", run_type="uipath") + async def create_validate_extraction_action_async( + self, + extraction_response: ExtractionResponse, + action_title: str, + action_priority: Optional[ActionPriority] = None, + action_catalog: Optional[str] = None, + action_folder: Optional[str] = None, + storage_bucket_name: Optional[str] = None, + storage_bucket_directory_path: Optional[str] = None, + ) -> ValidateExtractionAction: + """Asynchronous version of the [`create_validation_action`][uipath.platform.documents._documents_service.DocumentsService.create_validate_extraction_action] method.""" + operation_id = ( + await self._start_extraction_validation_async( + project_id=extraction_response.project_id, + extractor_id=extraction_response.extractor_id, + tag=extraction_response.tag, + document_type_id=extraction_response.document_type_id, + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + extraction_response=extraction_response, + ) + ).operation_id + + return await self._wait_for_create_validate_extraction_action_async( + project_id=extraction_response.project_id, + project_type=extraction_response.project_type, + extractor_id=extraction_response.extractor_id, + tag=extraction_response.tag, + document_type_id=extraction_response.document_type_id, + operation_id=operation_id, + ) + + @traced(name="documents_get_validate_classification_result", run_type="uipath") + def get_validate_classification_result( + self, validation_action: ValidateClassificationAction + ) -> List[ClassificationResult]: + """Get the result of a validate classification action. + + Note: + This method will block until the validation action is completed, meaning the user has completed the validation in UiPath Action Center. + + Args: + validation_action (ValidateClassificationAction): The validation action to get the result for, typically obtained from the [`create_validate_classification_action`][uipath.platform.documents._documents_service.DocumentsService.create_validate_classification_action] method. + + Returns: + List[ClassificationResult]: The validated classification results. + + Examples: + ```python + validated_results = service.get_validate_classification_result(validate_classification_action) + ``` + """ + + def result_getter() -> Tuple[str, None, Any]: + result = self._get_classification_validation_result( + project_id=validation_action.project_id, + classifier_id=validation_action.classifier_id, + tag=validation_action.tag, + operation_id=validation_action.operation_id, + ) + return (result["result"]["actionStatus"], None, result["result"]) + + response = self._wait_for_operation( + result_getter=result_getter, + wait_statuses=["Unassigned", "Pending"], + success_status="Completed", + ) + classification_results = [] + for cr in response["validatedClassificationResults"]: + cr["ProjectId"] = validation_action.project_id + cr["ProjectType"] = validation_action.project_type + cr["ClassifierId"] = validation_action.classifier_id + cr["Tag"] = validation_action.tag + classification_results.append(ClassificationResult.model_validate(cr)) + + return classification_results + + @traced( + name="documents_get_validate_classification_result_async", run_type="uipath" + ) + async def get_validate_classification_result_async( + self, validation_action: ValidateClassificationAction + ) -> List[ClassificationResult]: + """Asynchronous version of the [`get_validation_result`][uipath.platform.documents._documents_service.DocumentsService.get_validate_classification_result] method.""" + + async def result_getter() -> Tuple[str, None, Any]: + result = await self._get_classification_validation_result_async( + project_id=validation_action.project_id, + classifier_id=validation_action.classifier_id, + tag=validation_action.tag, + operation_id=validation_action.operation_id, + ) + return (result["result"]["actionStatus"], None, result["result"]) + + response = await self._wait_for_operation_async( + result_getter=result_getter, + wait_statuses=["Unassigned", "Pending"], + success_status="Completed", + ) + classification_results = [] + for cr in response["validatedClassificationResults"]: + cr["ProjectId"] = validation_action.project_id + cr["ProjectType"] = validation_action.project_type + cr["ClassifierId"] = validation_action.classifier_id + cr["Tag"] = validation_action.tag + classification_results.append(ClassificationResult.model_validate(cr)) + + return classification_results + + @traced(name="documents_get_validate_extraction_result", run_type="uipath") + def get_validate_extraction_result( + self, validation_action: ValidateExtractionAction + ) -> Union[ExtractionResponse, ExtractionResponseIXP]: + """Get the result of a validate extraction action. + + Note: + This method will block until the validation action is completed, meaning the user has completed the validation in UiPath Action Center. + + Args: + validation_action (ValidateClassificationAction): The validation action to get the result for, typically obtained from the [`create_validate_extraction_action`][uipath.platform.documents._documents_service.DocumentsService.create_validate_extraction_action] method. + + Returns: + Union[ExtractionResponse, ExtractionResponseIXP]: The validated extraction response. + + Examples: + ```python + validated_result = service.get_validate_extraction_result(validate_extraction_action) + ``` + """ + + def result_getter() -> Tuple[str, None, Any]: + result = self._get_extraction_validation_result( + project_id=validation_action.project_id, + extractor_id=validation_action.extractor_id, + tag=validation_action.tag, + document_type_id=validation_action.document_type_id, + operation_id=validation_action.operation_id, + ) + return (result["result"]["actionStatus"], None, result["result"]) + + response = self._wait_for_operation( + result_getter=result_getter, + wait_statuses=["Unassigned", "Pending"], + success_status="Completed", + ) + response["extractionResult"] = response.pop("validatedExtractionResults") + response["projectId"] = validation_action.project_id + response["extractorId"] = validation_action.extractor_id + response["tag"] = validation_action.tag + response["documentTypeId"] = validation_action.document_type_id + response["projectType"] = validation_action.project_type + + if validation_action.project_type == ProjectType.IXP: + return ExtractionResponseIXP.model_validate(response) + + return ExtractionResponse.model_validate(response) + + @traced(name="documents_get_validate_extraction_result_async", run_type="uipath") + async def get_validate_extraction_result_async( + self, validation_action: ValidateExtractionAction + ) -> Union[ExtractionResponse, ExtractionResponseIXP]: + """Asynchronous version of the [`get_validation_result`][uipath.platform.documents._documents_service.DocumentsService.get_validate_extraction_result] method.""" + + async def result_getter() -> Tuple[str, None, Any]: + result = await self._get_extraction_validation_result_async( + project_id=validation_action.project_id, + extractor_id=validation_action.extractor_id, + tag=validation_action.tag, + document_type_id=validation_action.document_type_id, + operation_id=validation_action.operation_id, + ) + return (result["result"]["actionStatus"], None, result["result"]) + + response = await self._wait_for_operation_async( + result_getter=result_getter, + wait_statuses=["Unassigned", "Pending"], + success_status="Completed", + ) + response["extractionResult"] = response.pop("validatedExtractionResults") + response["projectId"] = validation_action.project_id + response["extractorId"] = validation_action.extractor_id + response["tag"] = validation_action.tag + response["documentTypeId"] = validation_action.document_type_id + response["projectType"] = validation_action.project_type + + if validation_action.project_type == ProjectType.IXP: + return ExtractionResponseIXP.model_validate(response) + + return ExtractionResponse.model_validate(response) diff --git a/packages/uipath-platform/src/uipath/platform/documents/documents.py b/packages/uipath-platform/src/uipath/platform/documents/documents.py new file mode 100644 index 000000000..7870635f9 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/documents/documents.py @@ -0,0 +1,298 @@ +"""Document service payload models.""" + +from __future__ import annotations + +from enum import Enum +from typing import IO, Any, List, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + +FileContent = Union[IO[bytes], bytes, str] + + +class FieldType(str, Enum): + """Field types supported by Document Understanding service.""" + + TEXT = "Text" + NUMBER = "Number" + DATE = "Date" + NAME = "Name" + ADDRESS = "Address" + KEYWORD = "Keyword" + SET = "Set" + BOOLEAN = "Boolean" + TABLE = "Table" + INTERNAL = "Internal" + + +class ActionPriority(str, Enum): + """Priority levels for validation actions. More details can be found in the [official documentation](https://docs.uipath.com/action-center/automation-cloud/latest/user-guide/create-document-validation-action#configuration).""" + + LOW = "Low" + """Low priority""" + MEDIUM = "Medium" + """Medium priority""" + HIGH = "High" + """High priority""" + CRITICAL = "Critical" + """Critical priority""" + + @classmethod + def from_str(cls, value: str | None) -> ActionPriority: + """Creates an ActionPriority from a string.""" + if not value: + return cls.MEDIUM + try: + return cls[value.upper()] + except (KeyError, AttributeError): + return cls.MEDIUM + + +class ProjectType(str, Enum): + """Project types available and supported by Documents Service.""" + + IXP = "IXP" + """Represents an [IXP](https://docs.uipath.com/ixp/automation-cloud/latest/overview/managing-projects#creating-a-new-project) project type.""" + MODERN = "Modern" + """Represents a [DU Modern](https://docs.uipath.com/document-understanding/automation-cloud/latest/user-guide/about-document-understanding) project type.""" + PRETRAINED = "Pretrained" + """Represents a [Pretrained](https://docs.uipath.com/document-understanding/automation-cloud/latest/user-guide/out-of-the-box-pre-trained-ml-packages) project type.""" + + +class FieldValueProjection(BaseModel): + """A model representing a projection of a field value in a document extraction result.""" + + model_config = ConfigDict( + serialize_by_alias=True, + validate_by_alias=True, + validate_by_name=True, + ) + + id: str + name: str + value: Optional[str] + unformatted_value: Optional[str] = Field(alias="unformattedValue") + confidence: Optional[float] + ocr_confidence: Optional[float] = Field(alias="ocrConfidence") + type: FieldType + + +class FieldGroupValueProjection(BaseModel): + """A model representing a projection of a field group value in a document extraction result.""" + + model_config = ConfigDict( + serialize_by_alias=True, + validate_by_alias=True, + validate_by_name=True, + ) + + field_group_name: str = Field(alias="fieldGroupName") + field_values: List[FieldValueProjection] = Field(alias="fieldValues") + + +class ExtractionResult(BaseModel): + """A model representing the result of a document extraction process.""" + + model_config = ConfigDict( + serialize_by_alias=True, + validate_by_alias=True, + validate_by_name=True, + ) + + document_id: str = Field(alias="DocumentId") + results_version: int = Field(alias="ResultsVersion") + results_document: dict[str, Any] = Field(alias="ResultsDocument") + extractor_payloads: Optional[List[dict[str, Any]]] = Field( + default=None, alias="ExtractorPayloads" + ) + business_rules_results: Optional[List[dict[str, Any]]] = Field( + default=None, alias="BusinessRulesResults" + ) + + +class ExtractionResponse(BaseModel): + """A model representing the response from a document extraction process. + + Attributes: + extraction_result (ExtractionResult): The result of the extraction process. + project_id (str): The ID of the project associated with the extraction. + tag (str): The tag associated with the published model version. + document_type_id (str): The ID of the document type associated with the extraction. + """ + + model_config = ConfigDict( + serialize_by_alias=True, + validate_by_alias=True, + validate_by_name=True, + ) + + extraction_result: ExtractionResult = Field(alias="extractionResult") + project_id: str = Field(alias="projectId") + project_type: ProjectType = Field(alias="projectType") + extractor_id: Optional[str] = Field(alias="extractorId", default=None) + tag: Optional[str] + document_type_id: str = Field(alias="documentTypeId") + + +class ExtractionResponseIXP(ExtractionResponse): + """A model representing the response from a document extraction process for IXP projects. + + Attributes: + data_projection (List[FieldGroupValueProjection]): A simplified projection of the extracted data. + """ + + data_projection: Optional[List[FieldGroupValueProjection]] = Field( + alias="dataProjection", + default=None, + ) + + +class ValidationAction(BaseModel): + """A model representing a validation action for a document. + + Attributes: + action_data (dict): The data associated with the validation action. + action_status (str): The status of the validation action. Possible values can be found in the [official documentation](https://docs.uipath.com/action-center/automation-cloud/latest/user-guide/about-actions#action-statuses). + project_id (str): The ID of the project associated with the validation action. + tag (str): The tag associated with the published model version. + operation_id (str): The operation ID associated with the validation action. + """ + + model_config = ConfigDict( + serialize_by_alias=True, + validate_by_alias=True, + validate_by_name=True, + ) + + action_data: dict[str, Any] = Field(alias="actionData") + action_status: str = Field(alias="actionStatus") + project_id: str = Field(alias="projectId") + project_type: ProjectType = Field(alias="projectType") + tag: Optional[str] + operation_id: str = Field(alias="operationId") + + +class ValidateClassificationAction(ValidationAction): + """A model representing a validation action for document classification.""" + + classifier_id: Optional[str] = Field(alias="classifierId") + + +class ValidateExtractionAction(ValidationAction): + """A model representing a validation action for document extraction.""" + + extractor_id: Optional[str] = Field(alias="extractorId") + document_type_id: str = Field(alias="documentTypeId") + validated_extraction_result: Optional[ExtractionResult] = Field( + alias="validatedExtractionResults", default=None + ) + data_projection: Optional[List[FieldGroupValueProjection]] = Field( + alias="dataProjection", default=None + ) + + +class Reference(BaseModel): + """A model representing a reference within a document.""" + + model_config = ConfigDict( + serialize_by_alias=True, + validate_by_alias=True, + validate_by_name=True, + ) + + text_start_index: int = Field(alias="TextStartIndex") + text_length: int = Field(alias="TextLength") + tokens: List[str] = Field(alias="Tokens") + + +class DocumentBounds(BaseModel): + """A model representing the bounds of a document in terms of pages and text.""" + + model_config = ConfigDict( + serialize_by_alias=True, + validate_by_alias=True, + validate_by_name=True, + ) + + start_page: int = Field(alias="StartPage") + page_count: int = Field(alias="PageCount") + text_start_index: int = Field(alias="TextStartIndex") + text_length: int = Field(alias="TextLength") + page_range: str = Field(alias="PageRange") + + +class ClassificationResult(BaseModel): + """A model representing the result of a document classification. + + Attributes: + document_id (str): The ID of the classified document. + document_type_id (str): The ID of the predicted document type. + confidence (float): The confidence score of the classification. + ocr_confidence (float): The OCR confidence score of the document. + reference (Reference): The reference information for the classified document. + document_bounds (DocumentBounds): The bounds of the document in terms of pages and text. + classifier_name (str): The name of the classifier used. + project_id (str): The ID of the project associated with the classification. + """ + + model_config = ConfigDict( + serialize_by_alias=True, + validate_by_alias=True, + validate_by_name=True, + ) + + document_id: str = Field(alias="DocumentId") + document_type_id: str = Field(alias="DocumentTypeId") + confidence: float = Field(alias="Confidence") + ocr_confidence: float = Field(alias="OcrConfidence") + reference: Reference = Field(alias="Reference") + document_bounds: DocumentBounds = Field(alias="DocumentBounds") + classifier_name: str = Field(alias="ClassifierName") + project_id: str = Field(alias="ProjectId") + project_type: ProjectType = Field(alias="ProjectType") + classifier_id: Optional[str] = Field(alias="ClassifierId") + tag: Optional[str] = Field(alias="Tag") + + +class ClassificationResponse(BaseModel): + """A model representing the response from a document classification process.""" + + model_config = ConfigDict( + serialize_by_alias=True, + validate_by_alias=True, + validate_by_name=True, + ) + + classification_results: List[ClassificationResult] = Field( + alias="classificationResults" + ) + + +class StartOperationResponse(BaseModel): + """A model representing the response from starting an operation. + + Attributes: + operation_id (str): The ID of the extraction operation, used to poll for results. + document_id (str): The ID of the digitized document. + project_id (str): The ID of the project. + tag (str): The tag of the published project version. + """ + + model_config = ConfigDict( + serialize_by_alias=True, + validate_by_alias=True, + validate_by_name=True, + ) + + operation_id: str = Field(alias="operationId") + document_id: str = Field(alias="documentId") + project_id: str = Field(alias="projectId") + tag: str | None = Field(default=None) + + +class StartExtractionResponse(StartOperationResponse): + """A model representing the response from starting an extraction operation.""" + + +class StartExtractionValidationResponse(StartOperationResponse): + """A model representing the response from starting an extraction validation operation.""" diff --git a/packages/uipath-platform/src/uipath/platform/entities/__init__.py b/packages/uipath-platform/src/uipath/platform/entities/__init__.py new file mode 100644 index 000000000..64caf396e --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/__init__.py @@ -0,0 +1,36 @@ +"""UiPath Entities Models. + +This module contains models related to UiPath Entities service. +""" + +from ._entities_service import EntitiesService +from .entities import ( + Entity, + EntityField, + EntityFieldMetadata, + EntityRecord, + EntityRecordsBatchResponse, + ExternalField, + ExternalObject, + ExternalSourceFields, + FieldDataType, + FieldMetadata, + ReferenceType, + SourceJoinCriteria, +) + +__all__ = [ + "EntitiesService", + "Entity", + "EntityField", + "EntityRecord", + "EntityFieldMetadata", + "FieldDataType", + "FieldMetadata", + "EntityRecordsBatchResponse", + "ExternalField", + "ExternalObject", + "ExternalSourceFields", + "ReferenceType", + "SourceJoinCriteria", +] diff --git a/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py new file mode 100644 index 000000000..90a6ca1cb --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/_entities_service.py @@ -0,0 +1,904 @@ +from typing import Any, List, Optional, Type + +from httpx import Response +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from .entities import ( + Entity, + EntityRecord, + EntityRecordsBatchResponse, +) + + +class EntitiesService(BaseService): + """Service for managing UiPath Data Service entities. + + Entities are database tables in UiPath Data Service that can store + structured data for automation processes. + + See Also: + https://docs.uipath.com/data-service/automation-cloud/latest/user-guide/introduction + """ + + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + @traced(name="entity_retrieve", run_type="uipath") + def retrieve(self, entity_key: str) -> Entity: + """Retrieve an entity by its key. + + Args: + entity_key (str): The unique key/identifier of the entity. + + Returns: + Entity: The entity with all its metadata and field definitions, including: + - name: Entity name + - display_name: Human-readable display name + - fields: List of field metadata (field names, types, constraints) + - record_count: Number of records in the entity + - storage_size_in_mb: Storage size used by the entity + + Examples: + Basic usage:: + + # Retrieve entity metadata + entity = entities_service.retrieve("a1b2c3d4-e5f6-7890-abcd-ef1234567890") + print(f"Entity: {entity.display_name}") + print(f"Records: {entity.record_count}") + + Inspecting entity fields:: + + entity = entities_service.retrieve("a1b2c3d4-e5f6-7890-abcd-ef1234567890") + + # List all fields and their types + for field in entity.fields: + print(f"{field.name} ({field.sql_type.name})") + print(f" Required: {field.is_required}") + print(f" Primary Key: {field.is_primary_key}") + """ + spec = self._retrieve_spec(entity_key) + response = self.request(spec.method, spec.endpoint) + + return Entity.model_validate(response.json()) + + @traced(name="entity_retrieve", run_type="uipath") + async def retrieve_async(self, entity_key: str) -> Entity: + """Asynchronously retrieve an entity by its key. + + Args: + entity_key (str): The unique key/identifier of the entity. + + Returns: + Entity: The entity with all its metadata and field definitions, including: + - name: Entity name + - display_name: Human-readable display name + - fields: List of field metadata (field names, types, constraints) + - record_count: Number of records in the entity + - storage_size_in_mb: Storage size used by the entity + + Examples: + Basic usage:: + + # Retrieve entity metadata + entity = await entities_service.retrieve_async("a1b2c3d4-e5f6-7890-abcd-ef1234567890") + print(f"Entity: {entity.display_name}") + print(f"Records: {entity.record_count}") + + Inspecting entity fields:: + + entity = await entities_service.retrieve_async("a1b2c3d4-e5f6-7890-abcd-ef1234567890") + + # List all fields and their types + for field in entity.fields: + print(f"{field.name} ({field.sql_type.name})") + print(f" Required: {field.is_required}") + print(f" Primary Key: {field.is_primary_key}") + """ + spec = self._retrieve_spec(entity_key) + + response = await self.request_async(spec.method, spec.endpoint) + + return Entity.model_validate(response.json()) + + @traced(name="list_entities", run_type="uipath") + def list_entities(self) -> List[Entity]: + """List all entities in Data Service. + + Returns: + List[Entity]: A list of all entities with their metadata and field definitions. + Each entity includes name, display name, fields, record count, and storage information. + + Examples: + List all entities:: + + # Get all entities in the Data Service + entities = entities_service.list_entities() + for entity in entities: + print(f"{entity.display_name} ({entity.name})") + + Find entities with RBAC enabled:: + + entities = entities_service.list_entities() + + # Filter to entities with row-based access control + rbac_entities = [ + e for e in entities + if e.is_rbac_enabled + ] + + Summary report:: + + entities = entities_service.list_entities() + + total_records = sum(e.record_count or 0 for e in entities) + total_storage = sum(e.storage_size_in_mb or 0 for e in entities) + + print(f"Total entities: {len(entities)}") + print(f"Total records: {total_records}") + print(f"Total storage: {total_storage:.2f} MB") + """ + spec = self._list_entities_spec() + response = self.request(spec.method, spec.endpoint) + + entities_data = response.json() + return [Entity.model_validate(entity) for entity in entities_data] + + @traced(name="list_entities", run_type="uipath") + async def list_entities_async(self) -> List[Entity]: + """Asynchronously list all entities in the Data Service. + + Returns: + List[Entity]: A list of all entities with their metadata and field definitions. + Each entity includes name, display name, fields, record count, and storage information. + + Examples: + List all entities:: + + # Get all entities in the Data Service + entities = await entities_service.list_entities_async() + for entity in entities: + print(f"{entity.display_name} ({entity.name})") + + Find entities with RBAC enabled:: + + entities = await entities_service.list_entities_async() + + # Filter to entities with row-based access control + rbac_entities = [ + e for e in entities + if e.is_rbac_enabled + ] + + Summary report:: + + entities = await entities_service.list_entities_async() + + total_records = sum(e.record_count or 0 for e in entities) + total_storage = sum(e.storage_size_in_mb or 0 for e in entities) + + print(f"Total entities: {len(entities)}") + print(f"Total records: {total_records}") + print(f"Total storage: {total_storage:.2f} MB") + """ + spec = self._list_entities_spec() + response = await self.request_async(spec.method, spec.endpoint) + + entities_data = response.json() + return [Entity.model_validate(entity) for entity in entities_data] + + @traced(name="entity_list_records", run_type="uipath") + def list_records( + self, + entity_key: str, + schema: Optional[Type[Any]] = None, # Optional schema + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> List[EntityRecord]: + """List records from an entity with optional pagination and schema validation. + + The schema parameter enables type-safe access to entity records by validating the + data against a user-defined class with type annotations. When provided, each record + is validated against the schema's field definitions before being returned. + + Args: + entity_key (str): The unique key/identifier of the entity. + schema (Optional[Type[Any]]): Optional schema class for validation. This should be + a Python class with type-annotated fields that match the entity's structure. + + Field Validation Rules: + - Required fields: Use standard type annotations (e.g., `name: str`) + - Optional fields: Use `Optional` or union with None (e.g., `age: Optional[int]` or `age: int | None`) + - Field names must match the entity's field names (case-sensitive) + - The 'Id' field is automatically validated and does not need to be included + + Example schema class:: + + class CustomerRecord: + name: str # Required field + email: str # Required field + age: Optional[int] # Optional field + phone: str | None # Optional field (Python 3.10+ syntax) + + Benefits of using schema: + - Type safety: Ensures records match expected structure + - Early validation: Catches data issues before processing + - Documentation: Schema serves as clear contract for record structure + - IDE support: Enables better autocomplete and type checking + + When schema validation fails, a `ValueError` is raised with details about + the validation error (e.g., missing required fields, type mismatches). + + start (Optional[int]): Starting index for pagination (0-based). + limit (Optional[int]): Maximum number of records to return. + + Returns: + List[EntityRecord]: A list of entity records. Each record contains an 'id' field + and all other fields from the entity. Fields can be accessed as attributes + or dictionary keys on the EntityRecord object. + + Raises: + ValueError: If schema validation fails for any record, including cases where + required fields are missing or field types don't match the schema. + + Examples: + Basic usage without schema:: + + # Retrieve all records from an entity + records = entities_service.list_records("Customers") + for record in records: + print(record.id) + + With pagination:: + + # Get first 50 records + records = entities_service.list_records("Customers", start=0, limit=50) + + With schema validation:: + + class CustomerRecord: + name: str + email: str + age: Optional[int] + is_active: bool + + # Records are validated against CustomerRecord schema + records = entities_service.list_records( + "Customers", + schema=CustomerRecord + ) + + # Safe to access fields knowing they match the schema + for record in records: + print(f"{record.name}: {record.email}") + """ + # Example method to generate the API request specification (mocked here) + spec = self._list_records_spec(entity_key, start, limit) + + # Make the HTTP request (assumes self.request exists) + response = self.request(spec.method, spec.endpoint, params=spec.params) + + # Parse the response JSON and extract the "value" field + records_data = response.json().get("value", []) + + # Validate and wrap records + return [ + EntityRecord.from_data(data=record, model=schema) for record in records_data + ] + + @traced(name="entity_list_records", run_type="uipath") + async def list_records_async( + self, + entity_key: str, + schema: Optional[Type[Any]] = None, # Optional schema + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> List[EntityRecord]: + """Asynchronously list records from an entity with optional pagination and schema validation. + + The schema parameter enables type-safe access to entity records by validating the + data against a user-defined class with type annotations. When provided, each record + is validated against the schema's field definitions before being returned. + + Args: + entity_key (str): The unique key/identifier of the entity. + schema (Optional[Type[Any]]): Optional schema class for validation. This should be + a Python class with type-annotated fields that match the entity's structure. + + Field Validation Rules: + - Required fields: Use standard type annotations (e.g., `name: str`) + - Optional fields: Use `Optional` or union with None (e.g., `age: Optional[int]` or `age: int | None`) + - Field names must match the entity's field names (case-sensitive) + - The 'Id' field is automatically validated and does not need to be included + + Example schema class:: + + class CustomerRecord: + name: str # Required field + email: str # Required field + age: Optional[int] # Optional field + phone: str | None # Optional field (Python 3.10+ syntax) + + Benefits of using schema: + - Type safety: Ensures records match expected structure + - Early validation: Catches data issues before processing + - Documentation: Schema serves as clear contract for record structure + - IDE support: Enables better autocomplete and type checking + + When schema validation fails, a `ValueError` is raised with details about + the validation error (e.g., missing required fields, type mismatches). + + start (Optional[int]): Starting index for pagination (0-based). + limit (Optional[int]): Maximum number of records to return. + + Returns: + List[EntityRecord]: A list of entity records. Each record contains an 'id' field + and all other fields from the entity. Fields can be accessed as attributes + or dictionary keys on the EntityRecord object. + + Raises: + ValueError: If schema validation fails for any record, including cases where + required fields are missing or field types don't match the schema. + + Examples: + Basic usage without schema:: + + # Retrieve all records from an entity + records = await entities_service.list_records_async("Customers") + for record in records: + print(record.id) + + With pagination:: + + # Get first 50 records + records = await entities_service.list_records_async("Customers", start=0, limit=50) + + With schema validation:: + + class CustomerRecord: + name: str + email: str + age: Optional[int] + is_active: bool + + # Records are validated against CustomerRecord schema + records = await entities_service.list_records_async( + "Customers", + schema=CustomerRecord + ) + + # Safe to access fields knowing they match the schema + for record in records: + print(f"{record.name}: {record.email}") + """ + spec = self._list_records_spec(entity_key, start, limit) + + # Make the HTTP request (assumes self.request exists) + response = await self.request_async( + spec.method, spec.endpoint, params=spec.params + ) + + # Parse the response JSON and extract the "value" field + records_data = response.json().get("value", []) + + # Validate and wrap records + return [ + EntityRecord.from_data(data=record, model=schema) for record in records_data + ] + + @traced(name="entity_record_insert_batch", run_type="uipath") + def insert_records( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + ) -> EntityRecordsBatchResponse: + """Insert multiple records into an entity in a single batch operation. + + Args: + entity_key (str): The unique key/identifier of the entity. + records (List[Any]): List of records to insert. Each record should be an object + with attributes matching the entity's field names. + schema (Optional[Type[Any]]): Optional schema class for validation. When provided, + validates that each record in the response matches the schema structure. + + Returns: + EntityRecordsBatchResponse: Response containing successful and failed record operations. + - success_records: List of successfully inserted EntityRecord objects + - failure_records: List of EntityRecord objects that failed to insert + + Examples: + Insert records without schema:: + + class Customer: + def __init__(self, name, email, age): + self.name = name + self.email = email + self.age = age + + customers = [ + Customer("John Doe", "john@example.com", 30), + Customer("Jane Smith", "jane@example.com", 25), + ] + + response = entities_service.insert_records( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + customers + ) + + print(f"Inserted: {len(response.success_records)}") + print(f"Failed: {len(response.failure_records)}") + + Insert with schema validation:: + + class CustomerSchema: + name: str + email: str + age: int + + class Customer: + def __init__(self, name, email, age): + self.name = name + self.email = email + self.age = age + + customers = [Customer("Alice Brown", "alice@example.com", 28)] + + response = entities_service.insert_records( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + customers, + schema=CustomerSchema + ) + + # Access inserted records with validated structure + for record in response.success_records: + print(f"Inserted: {record.name} (ID: {record.id})") + """ + spec = self._insert_batch_spec(entity_key, records) + response = self.request(spec.method, spec.endpoint, json=spec.json) + + return self.validate_entity_batch(response, schema) + + @traced(name="entity_record_insert_batch", run_type="uipath") + async def insert_records_async( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + ) -> EntityRecordsBatchResponse: + """Asynchronously insert multiple records into an entity in a single batch operation. + + Args: + entity_key (str): The unique key/identifier of the entity. + records (List[Any]): List of records to insert. Each record should be an object + with attributes matching the entity's field names. + schema (Optional[Type[Any]]): Optional schema class for validation. When provided, + validates that each record in the response matches the schema structure. + + Returns: + EntityRecordsBatchResponse: Response containing successful and failed record operations. + - success_records: List of successfully inserted EntityRecord objects + - failure_records: List of EntityRecord objects that failed to insert + + Examples: + Insert records without schema:: + + class Customer: + def __init__(self, name, email, age): + self.name = name + self.email = email + self.age = age + + customers = [ + Customer("John Doe", "john@example.com", 30), + Customer("Jane Smith", "jane@example.com", 25), + ] + + response = await entities_service.insert_records_async( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + customers + ) + + print(f"Inserted: {len(response.success_records)}") + print(f"Failed: {len(response.failure_records)}") + + Insert with schema validation:: + + class CustomerSchema: + name: str + email: str + age: int + + class Customer: + def __init__(self, name, email, age): + self.name = name + self.email = email + self.age = age + + customers = [Customer("Alice Brown", "alice@example.com", 28)] + + response = await entities_service.insert_records_async( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + customers, + schema=CustomerSchema + ) + + # Access inserted records with validated structure + for record in response.success_records: + print(f"Inserted: {record.name} (ID: {record.id})") + """ + spec = self._insert_batch_spec(entity_key, records) + response = await self.request_async(spec.method, spec.endpoint, json=spec.json) + + return self.validate_entity_batch(response, schema) + + @traced(name="entity_record_update_batch", run_type="uipath") + def update_records( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + ) -> EntityRecordsBatchResponse: + """Update multiple records in an entity in a single batch operation. + + Args: + entity_key (str): The unique key/identifier of the entity. + records (List[Any]): List of records to update. Each record must have an 'Id' field + and should be a Pydantic model with `model_dump()` method or similar object. + schema (Optional[Type[Any]]): Optional schema class for validation. When provided, + validates that each record in the request and response matches the schema structure. + + Returns: + EntityRecordsBatchResponse: Response containing successful and failed record operations. + - success_records: List of successfully updated EntityRecord objects + - failure_records: List of EntityRecord objects that failed to update + + Examples: + Update records:: + + # First, retrieve records to update + records = entities_service.list_records("a1b2c3d4-e5f6-7890-abcd-ef1234567890") + + # Modify the records + for record in records: + if record.name == "John Doe": + record.age = 31 + + # Update the modified records + response = entities_service.update_records( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + records + ) + + print(f"Updated: {len(response.success_records)}") + print(f"Failed: {len(response.failure_records)}") + + Update with schema validation:: + + class CustomerSchema: + name: str + email: str + age: int + + # Retrieve and update + records = entities_service.list_records( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + schema=CustomerSchema + ) + + # Modify specific records + for record in records: + if record.age < 30: + record.is_active = True + + response = entities_service.update_records( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + records, + schema=CustomerSchema + ) + + for record in response.success_records: + print(f"Updated: {record.name}") + """ + valid_records = [ + EntityRecord.from_data(data=record.model_dump(by_alias=True), model=schema) + for record in records + ] + + spec = self._update_batch_spec(entity_key, valid_records) + response = self.request(spec.method, spec.endpoint, json=spec.json) + + return self.validate_entity_batch(response, schema) + + @traced(name="entity_record_update_batch", run_type="uipath") + async def update_records_async( + self, + entity_key: str, + records: List[Any], + schema: Optional[Type[Any]] = None, + ) -> EntityRecordsBatchResponse: + """Asynchronously update multiple records in an entity in a single batch operation. + + Args: + entity_key (str): The unique key/identifier of the entity. + records (List[Any]): List of records to update. Each record must have an 'Id' field + and should be a Pydantic model with `model_dump()` method or similar object. + schema (Optional[Type[Any]]): Optional schema class for validation. When provided, + validates that each record in the request and response matches the schema structure. + + Returns: + EntityRecordsBatchResponse: Response containing successful and failed record operations. + - success_records: List of successfully updated EntityRecord objects + - failure_records: List of EntityRecord objects that failed to update + + Examples: + Update records:: + + # First, retrieve records to update + records = await entities_service.list_records_async("a1b2c3d4-e5f6-7890-abcd-ef1234567890") + + # Modify the records + for record in records: + if record.name == "John Doe": + record.age = 31 + + # Update the modified records + response = await entities_service.update_records_async( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + records + ) + + print(f"Updated: {len(response.success_records)}") + print(f"Failed: {len(response.failure_records)}") + + Update with schema validation:: + + class CustomerSchema: + name: str + email: str + age: int + + # Retrieve and update + records = await entities_service.list_records_async( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + schema=CustomerSchema + ) + + # Modify specific records + for record in records: + if record.age < 30: + record.is_active = True + + response = await entities_service.update_records_async( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + records, + schema=CustomerSchema + ) + + for record in response.success_records: + print(f"Updated: {record.name}") + """ + valid_records = [ + EntityRecord.from_data(data=record.model_dump(by_alias=True), model=schema) + for record in records + ] + + spec = self._update_batch_spec(entity_key, valid_records) + response = await self.request_async(spec.method, spec.endpoint, json=spec.json) + + return self.validate_entity_batch(response, schema) + + @traced(name="entity_record_delete_batch", run_type="uipath") + def delete_records( + self, + entity_key: str, + record_ids: List[str], + ) -> EntityRecordsBatchResponse: + """Delete multiple records from an entity in a single batch operation. + + Args: + entity_key (str): The unique key/identifier of the entity. + record_ids (List[str]): List of record IDs (GUIDs) to delete. + + Returns: + EntityRecordsBatchResponse: Response containing successful and failed record operations. + - success_records: List of successfully deleted EntityRecord objects + - failure_records: List of EntityRecord objects that failed to delete + + Examples: + Delete specific records by ID:: + + # Delete records by their IDs + record_ids = [ + "12345678-1234-1234-1234-123456789012", + "87654321-4321-4321-4321-210987654321", + ] + + response = entities_service.delete_records( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + record_ids + ) + + print(f"Deleted: {len(response.success_records)}") + print(f"Failed: {len(response.failure_records)}") + + Delete records matching a condition:: + + # Get all records + records = entities_service.list_records("a1b2c3d4-e5f6-7890-abcd-ef1234567890") + + # Filter records to delete (e.g., inactive customers) + ids_to_delete = [ + record.id for record in records + if not getattr(record, 'is_active', True) + ] + + if ids_to_delete: + response = entities_service.delete_records( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ids_to_delete + ) + print(f"Deleted {len(response.success_records)} inactive records") + """ + spec = self._delete_batch_spec(entity_key, record_ids) + response = self.request(spec.method, spec.endpoint, json=spec.json) + + delete_records_response = EntityRecordsBatchResponse.model_validate( + response.json() + ) + + return delete_records_response + + @traced(name="entity_record_delete_batch", run_type="uipath") + async def delete_records_async( + self, + entity_key: str, + record_ids: List[str], + ) -> EntityRecordsBatchResponse: + """Asynchronously delete multiple records from an entity in a single batch operation. + + Args: + entity_key (str): The unique key/identifier of the entity. + record_ids (List[str]): List of record IDs (GUIDs) to delete. + + Returns: + EntityRecordsBatchResponse: Response containing successful and failed record operations. + - success_records: List of successfully deleted EntityRecord objects + - failure_records: List of EntityRecord objects that failed to delete + + Examples: + Delete specific records by ID:: + + # Delete records by their IDs + record_ids = [ + "12345678-1234-1234-1234-123456789012", + "87654321-4321-4321-4321-210987654321", + ] + + response = await entities_service.delete_records_async( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + record_ids + ) + + print(f"Deleted: {len(response.success_records)}") + print(f"Failed: {len(response.failure_records)}") + + Delete records matching a condition:: + + # Get all records + records = await entities_service.list_records_async("a1b2c3d4-e5f6-7890-abcd-ef1234567890") + + # Filter records to delete (e.g., inactive customers) + ids_to_delete = [ + record.id for record in records + if not getattr(record, 'is_active', True) + ] + + if ids_to_delete: + response = await entities_service.delete_records_async( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + ids_to_delete + ) + print(f"Deleted {len(response.success_records)} inactive records") + """ + spec = self._delete_batch_spec(entity_key, record_ids) + response = await self.request_async(spec.method, spec.endpoint, json=spec.json) + + delete_records_response = EntityRecordsBatchResponse.model_validate( + response.json() + ) + + return delete_records_response + + def validate_entity_batch( + self, + batch_response: Response, + schema: Optional[Type[Any]] = None, + ) -> EntityRecordsBatchResponse: + # Validate the response format + insert_records_response = EntityRecordsBatchResponse.model_validate( + batch_response.json() + ) + + # Validate individual records + validated_successful_records = [ + EntityRecord.from_data( + data=successful_record.model_dump(by_alias=True), model=schema + ) + for successful_record in insert_records_response.success_records + ] + + validated_failed_records = [ + EntityRecord.from_data( + data=failed_record.model_dump(by_alias=True), model=schema + ) + for failed_record in insert_records_response.failure_records + ] + + return EntityRecordsBatchResponse( + success_records=validated_successful_records, + failure_records=validated_failed_records, + ) + + def _retrieve_spec( + self, + entity_key: str, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint(f"datafabric_/api/Entity/{entity_key}"), + ) + + def _list_entities_spec(self) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint("datafabric_/api/Entity"), + ) + + def _list_records_spec( + self, + entity_key: str, + start: Optional[int] = None, + limit: Optional[int] = None, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/read" + ), + params=({"start": start, "limit": limit}), + ) + + def _insert_batch_spec(self, entity_key: str, records: List[Any]) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/insert-batch" + ), + json=[record.__dict__ for record in records], + ) + + def _update_batch_spec( + self, entity_key: str, records: List[EntityRecord] + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/update-batch" + ), + json=[record.model_dump(by_alias=True) for record in records], + ) + + def _delete_batch_spec(self, entity_key: str, record_ids: List[str]) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"datafabric_/api/EntityService/entity/{entity_key}/delete-batch" + ), + json=record_ids, + ) diff --git a/packages/uipath-platform/src/uipath/platform/entities/entities.py b/packages/uipath-platform/src/uipath/platform/entities/entities.py new file mode 100644 index 000000000..ce334bf86 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/entities/entities.py @@ -0,0 +1,325 @@ +"""Entities models for UiPath Platform API interactions.""" + +from enum import Enum +from types import EllipsisType +from typing import Any, Dict, List, Optional, Type, Union, get_args, get_origin + +from pydantic import BaseModel, ConfigDict, Field, create_model + + +class ReferenceType(Enum): + """Enum representing types of references between entities.""" + + ManyToOne = "ManyToOne" + + +class FieldDisplayType(Enum): + """Enum representing display types of fields in entities.""" + + Basic = "Basic" + Relationship = "Relationship" + File = "File" + ChoiceSetSingle = "ChoiceSetSingle" + ChoiceSetMultiple = "ChoiceSetMultiple" + AutoNumber = "AutoNumber" + + +class DataDirectionType(Enum): + """Enum representing data direction types for fields in entities.""" + + ReadOnly = "ReadOnly" + ReadAndWrite = "ReadAndWrite" + + +class JoinType(Enum): + """Enum representing types of joins between entities.""" + + LeftJoin = "LeftJoin" + + +class EntityType(Enum): + """Enum representing types of entities.""" + + Entity = "Entity" + ChoiceSet = "ChoiceSet" + InternalEntity = "InternalEntity" + SystemEntity = "SystemEntity" + + +class EntityFieldMetadata(BaseModel): + """Model representing metadata for an entity field.""" + + model_config = ConfigDict( + validate_by_name=True, + ) + type: str + required: bool + name: str + + +class ExternalConnection(BaseModel): + """Model representing an external connection.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + id: str + connection_id: str = Field(alias="connectionId") + element_instance_id: str = Field(alias="elementInstanceId") + folder_id: str = Field(alias="folderKey") # named folderKey in TS SDK + connector_id: str = Field(alias="connectorKey") # named connectorKey in TS SDK + connector_name: str = Field(alias="connectorName") + connection_name: str = Field(alias="connectionName") + + +class ExternalFieldMapping(BaseModel): + """Model representing an external field mapping.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + id: str + external_field_name: str = Field(alias="externalFieldName") + external_field_display_name: str = Field(alias="externalFieldDisplayName") + external_object_id: str = Field(alias="externalObjectId") + external_field_type: str = Field(alias="externalFieldType") + internal_field_id: str = Field(alias="internalFieldId") + direction_type: DataDirectionType = Field(alias="directionType") + + +class FieldDataType(BaseModel): + """Model representing data type information for a field.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + name: str + length_limit: Optional[int] = Field(default=None, alias="LengthLimit") + max_value: Optional[int] = Field(default=None, alias="MaxValue") + min_value: Optional[int] = Field(default=None, alias="MinValue") + decimal_precision: Optional[int] = Field(default=None, alias="DecimalPrecision") + + +class FieldMetadata(BaseModel): + """Model representing metadata for an entity field.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + id: Optional[str] = Field(default=None, alias="id") + name: str + is_primary_key: bool = Field(alias="isPrimaryKey") + is_foreign_key: bool = Field(alias="isForeignKey") + is_external_field: bool = Field(alias="isExternalField") + is_hidden_field: bool = Field(alias="isHiddenField") + is_unique: bool = Field(alias="isUnique") + reference_name: Optional[str] = Field(default=None, alias="referenceName") + reference_entity: Optional["Entity"] = Field(default=None, alias="referenceEntity") + reference_choiceset: Optional["Entity"] = Field( + default=None, alias="referenceChoiceset" + ) + reference_field: Optional["EntityField"] = Field( + default=None, alias="referenceField" + ) + reference_type: ReferenceType = Field(alias="referenceType") + sql_type: "FieldDataType" = Field(alias="sqlType") + is_required: bool = Field(alias="isRequired") + display_name: str = Field(alias="displayName") + description: Optional[str] = Field(default=None, alias="description") + is_system_field: bool = Field(alias="isSystemField") + field_display_type: Optional[str] = Field( + default=None, alias="fieldDisplayType" + ) # Should be FieldDisplayType enum + choiceset_id: Optional[str] = Field(default=None, alias="choicesetId") + default_value: Optional[str] = Field(default=None, alias="defaultValue") + is_attachment: bool = Field(alias="isAttachment") + is_rbac_enabled: bool = Field(alias="isRbacEnabled") + + +class ExternalField(BaseModel): + """Model representing an external field.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + field_metadata: FieldMetadata = Field(alias="fieldMetadata") + external_field_mapping_detail: ExternalFieldMapping = Field( + alias="externalFieldMappingDetail" + ) + + +class EntityField(BaseModel): + """Model representing a field within an entity.""" + + model_config = ConfigDict( + validate_by_name=True, + ) + id: Optional[str] = Field(default=None, alias="id") + definition: Optional[FieldMetadata] = Field(default=None, alias="definition") + + +class ExternalObject(BaseModel): + """Model representing an external object.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + id: str + external_object_name: str = Field(alias="externalObjectName") + external_object_display_name: str = Field(alias="externalObjectDisplayName") + primary_key: str = Field(alias="primaryKey") + external_connection_id: str = Field(alias="externalConnectionId") + entity_id: str = Field(alias="entityId") + is_primary_source: bool = Field(alias="isPrimarySource") + + +class ExternalSourceFields(BaseModel): + """Model representing external source fields.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + fields: List[ExternalField] + external_object_detail: ExternalObject = Field(alias="externalObject") + external_connection_detail: ExternalConnection = Field(alias="externalConnection") + + +class SourceJoinCriteria(BaseModel): + """Model representing source join criteria.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + id: str + entity_id: str = Field(alias="entityId") + join_field_name: str = Field(alias="joinFieldName") + join_type: str = Field(alias="joinType") + related_source_object_id: str = Field(alias="relatedSourceObjectId") + related_source_object_field_name: str = Field(alias="relatedSourceObjectFieldName") + related_source_field_name: str = Field(alias="relatedSourceFieldName") + + +class EntityRecord(BaseModel): + """Model representing a record within an entity.""" + + model_config = { + "validate_by_name": True, + "validate_by_alias": True, + "extra": "allow", + } + + id: str = Field(alias="Id") # Mandatory field validated by Pydantic + + @classmethod + def from_data( + cls, data: Dict[str, Any], model: Optional[Any] = None + ) -> "EntityRecord": + """Create an EntityRecord instance by validating raw data and optionally instantiating a custom model. + + :param data: Raw data dictionary for the entity. + :param model: Optional user-defined class for validation. + :return: EntityRecord instance + """ + # Validate the "Id" field is mandatory and must be a string + id_value = data.get("Id", None) + if id_value is None or not isinstance(id_value, str): + raise ValueError("Field 'Id' is mandatory and must be a string.") + + if model: + # Check if the model is a plain Python class or Pydantic model + cls._validate_against_user_model(data, model) + + return cls(**data) + + @staticmethod + def _validate_against_user_model( + data: Dict[str, Any], user_class: Type[Any] + ) -> None: + user_class_annotations = getattr(user_class, "__annotations__", None) + if user_class_annotations is None: + raise ValueError( + f"User-provided class '{user_class.__name__}' is missing type annotations." + ) + + # Dynamically define a Pydantic model based on the user's class annotations + # Fields must be valid type annotations directly + pydantic_fields: dict[str, tuple[Any, EllipsisType | None]] = {} + + for name, annotation in user_class_annotations.items(): + is_optional = False + + origin = get_origin(annotation) + args = get_args(annotation) + + # Handle Optional[...] or X | None + if origin is Union and type(None) in args: + is_optional = True + + # Check for optional fields + if is_optional: + pydantic_fields[name] = (annotation, None) # Not required + else: + pydantic_fields[name] = (annotation, ...) + + # Dynamically create the Pydantic model class + dynamic_model = create_model( + f"Dynamic_{user_class.__name__}", + **pydantic_fields, # type: ignore[call-overload] # __base__ causes an issue. type checker cannot know that the key does not contain "__base__" + ) + + # Validate input data + dynamic_model.model_validate(data) + + +class Entity(BaseModel): + """Model representing an entity in the UiPath platform.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + + name: str + display_name: str = Field(alias="displayName") + entity_type: str = Field(alias="entityType") + description: Optional[str] = Field(default=None, alias="description") + fields: Optional[List[FieldMetadata]] = Field(default=None, alias="fields") + external_fields: Optional[List[ExternalSourceFields]] = Field( + default=None, alias="externalFields" + ) + source_join_criteria: Optional[List[SourceJoinCriteria]] = Field( + default=None, alias="sourceJoinCriteria" + ) + record_count: Optional[int] = Field(default=None, alias="recordCount") + storage_size_in_mb: Optional[float] = Field(default=None, alias="storageSizeInMB") + used_storage_size_in_mb: Optional[float] = Field( + default=None, alias="usedStorageSizeInMB" + ) + attachment_size_in_byte: Optional[int] = Field( + default=None, alias="attachmentSizeInBytes" + ) + is_rbac_enabled: bool = Field(alias="isRbacEnabled") + id: str + + +class EntityRecordsBatchResponse(BaseModel): + """Model representing a batch response of entity records.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + + success_records: List[EntityRecord] = Field(alias="successRecords") + failure_records: List[EntityRecord] = Field(alias="failureRecords") + + +Entity.model_rebuild() diff --git a/packages/uipath-platform/src/uipath/platform/errors/__init__.py b/packages/uipath-platform/src/uipath/platform/errors/__init__.py new file mode 100644 index 000000000..582341129 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/__init__.py @@ -0,0 +1,37 @@ +"""UiPath Platform Errors. + +This module contains all exception classes used by the UiPath Platform SDK. + +Available exceptions: +- BaseUrlMissingError: Raised when base URL is not configured +- SecretMissingError: Raised when access token is not configured +- FolderNotFoundException: Raised when a folder cannot be found +- UnsupportedDataSourceException: Raised when an operation is attempted on an unsupported data source type +- IngestionInProgressException: Raised when a search is attempted on an index during ingestion +- BatchTransformNotCompleteException: Raised when attempting to get results from an incomplete batch transform +- OperationNotCompleteException: Raised when attempting to get results from an incomplete operation +- OperationFailedException: Raised when an operation has failed +- EnrichedException: Enriched HTTP error with detailed request/response information +""" + +from ._base_url_missing_error import BaseUrlMissingError +from ._batch_transform_not_complete_exception import BatchTransformNotCompleteException +from ._enriched_exception import EnrichedException +from ._folder_not_found_exception import FolderNotFoundException +from ._ingestion_in_progress_exception import IngestionInProgressException +from ._operation_failed_exception import OperationFailedException +from ._operation_not_complete_exception import OperationNotCompleteException +from ._secret_missing_error import SecretMissingError +from ._unsupported_data_source_exception import UnsupportedDataSourceException + +__all__ = [ + "BaseUrlMissingError", + "BatchTransformNotCompleteException", + "EnrichedException", + "FolderNotFoundException", + "IngestionInProgressException", + "SecretMissingError", + "OperationNotCompleteException", + "OperationFailedException", + "UnsupportedDataSourceException", +] diff --git a/packages/uipath-platform/src/uipath/platform/errors/_base_url_missing_error.py b/packages/uipath-platform/src/uipath/platform/errors/_base_url_missing_error.py new file mode 100644 index 000000000..db4c8efbd --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_base_url_missing_error.py @@ -0,0 +1,13 @@ +class BaseUrlMissingError(Exception): + """Raised when base URL is not configured. + + This exception is raised when attempting to use the SDK without setting + the base URL via the UIPATH_URL environment variable or through authentication. + """ + + def __init__( + self, + message="Authentication required. Please run \033[1muipath auth\033[22m or set the base URL via the UIPATH_URL environment variable.", + ): + self.message = message + super().__init__(self.message) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_batch_transform_not_complete_exception.py b/packages/uipath-platform/src/uipath/platform/errors/_batch_transform_not_complete_exception.py new file mode 100644 index 000000000..8b8956923 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_batch_transform_not_complete_exception.py @@ -0,0 +1,13 @@ +class BatchTransformNotCompleteException(Exception): + """Raised when attempting to get results from an incomplete batch transform. + + This exception is raised when attempting to download results from a batch + transform task that has not yet completed successfully. + """ + + def __init__(self, batch_transform_id: str, status: str): + self.message = ( + f"Batch transform '{batch_transform_id}' is not complete. " + f"Current status: {status}" + ) + super().__init__(self.message) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_enriched_exception.py b/packages/uipath-platform/src/uipath/platform/errors/_enriched_exception.py new file mode 100644 index 000000000..dbf64ebd3 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_enriched_exception.py @@ -0,0 +1,38 @@ +from httpx import HTTPStatusError + + +class EnrichedException(Exception): + """Enriched HTTP error with detailed request/response information. + + This exception wraps HTTPStatusError and provides additional context about + the failed HTTP request, including URL, method, status code, and response content. + """ + + def __init__(self, error: HTTPStatusError) -> None: + # Extract the relevant details from the HTTPStatusError + self.status_code = error.response.status_code if error.response else "Unknown" + self.url = str(error.request.url) if error.request else "Unknown" + self.http_method = ( + error.request.method + if error.request and error.request.method + else "Unknown" + ) + max_content_length = 200 + if error.response and error.response.content: + content = error.response.content.decode("utf-8") + if len(content) > max_content_length: + self.response_content = content[:max_content_length] + "... (truncated)" + else: + self.response_content = content + else: + self.response_content = "No content" + + enriched_message = ( + f"\nRequest URL: {self.url}" + f"\nHTTP Method: {self.http_method}" + f"\nStatus Code: {self.status_code}" + f"\nResponse Content: {self.response_content}" + ) + + # Initialize the parent Exception class with the formatted message + super().__init__(enriched_message) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_folder_not_found_exception.py b/packages/uipath-platform/src/uipath/platform/errors/_folder_not_found_exception.py new file mode 100644 index 000000000..b5daa8005 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_folder_not_found_exception.py @@ -0,0 +1,13 @@ +class FolderNotFoundException(Exception): + """Raised when a folder cannot be found. + + This exception is raised when attempting to access a folder that does not exist + in the UiPath Orchestrator. + """ + + def __init__( + self, + folder_name, + ): + self.message = f"Folder {folder_name} not found." + super().__init__(self.message) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_ingestion_in_progress_exception.py b/packages/uipath-platform/src/uipath/platform/errors/_ingestion_in_progress_exception.py new file mode 100644 index 000000000..d0a093719 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_ingestion_in_progress_exception.py @@ -0,0 +1,17 @@ +from typing import Optional + + +class IngestionInProgressException(Exception): + """Raised when a search is attempted on an index during ingestion. + + This exception is raised when attempting to search an index that is currently + undergoing ingestion and is not yet available for queries. + """ + + def __init__(self, index_name: Optional[str], search_operation: bool = True): + index_name = index_name or "Unknown index name" + if search_operation: + self.message = f"index '{index_name}' cannot be searched during ingestion" + else: + self.message = f"index '{index_name}' is currently queued for ingestion" + super().__init__(self.message) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_operation_failed_exception.py b/packages/uipath-platform/src/uipath/platform/errors/_operation_failed_exception.py new file mode 100644 index 000000000..216ef7d24 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_operation_failed_exception.py @@ -0,0 +1,19 @@ +class OperationFailedException(Exception): + """Raised when attempting to get results from a failed operation. + + This exception is raised when attempting to retrieve results from operation + that failed to complete successfully. + """ + + def __init__( + self, + operation_id: str, + status: str, + error: str, + operation_name: str = "Operation", + ): + self.operation_id = operation_id + self.status = status + self.error = error + self.message = f"{operation_name} '{operation_id}' failed with status: {status} error: {error}" + super().__init__(self.message) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_operation_not_complete_exception.py b/packages/uipath-platform/src/uipath/platform/errors/_operation_not_complete_exception.py new file mode 100644 index 000000000..50941353c --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_operation_not_complete_exception.py @@ -0,0 +1,14 @@ +class OperationNotCompleteException(Exception): + """Raised when attempting to get results from an incomplete operation. + + This exception is raised when attempting to retrieve results from operation + that has not yet completed successfully. + """ + + def __init__( + self, operation_id: str, status: str, operation_name: str = "Operation" + ): + self.operation_id = operation_id + self.status = status + self.message = f"{operation_name} '{operation_id}' is not complete. Current status: {status}" + super().__init__(self.message) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_secret_missing_error.py b/packages/uipath-platform/src/uipath/platform/errors/_secret_missing_error.py new file mode 100644 index 000000000..e03ed3c51 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_secret_missing_error.py @@ -0,0 +1,13 @@ +class SecretMissingError(Exception): + """Raised when access token is not configured. + + This exception is raised when attempting to use the SDK without setting + the access token via the UIPATH_ACCESS_TOKEN environment variable or through authentication. + """ + + def __init__( + self, + message="Authentication required. Please run \033[1muipath auth\033[22m or set the UIPATH_ACCESS_TOKEN environment variable to a valid access token.", + ): + self.message = message + super().__init__(self.message) diff --git a/packages/uipath-platform/src/uipath/platform/errors/_unsupported_data_source_exception.py b/packages/uipath-platform/src/uipath/platform/errors/_unsupported_data_source_exception.py new file mode 100644 index 000000000..0f9c30fe9 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/errors/_unsupported_data_source_exception.py @@ -0,0 +1,17 @@ +from typing import Optional + + +class UnsupportedDataSourceException(Exception): + """Raised when an operation is attempted on an unsupported data source type. + + This exception is raised when attempting to use an operation that only supports + specific data source types (e.g., Orchestrator Storage Bucket) with an incompatible + data source. + """ + + def __init__(self, operation: str, data_source_type: Optional[str] = None): + if data_source_type: + message = f"Operation '{operation}' is not supported for data source type: {data_source_type}. Only Orchestrator Storage Bucket data sources are supported." + else: + message = f"Operation '{operation}' requires an Orchestrator Storage Bucket data source." + super().__init__(message) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py new file mode 100644 index 000000000..ffab74581 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/__init__.py @@ -0,0 +1,36 @@ +"""UiPath Guardrails Models. + +This module contains models related to UiPath Guardrails service. +""" + +# 2.3.0 remove +from uipath.core.guardrails import ( + BaseGuardrail, + DeterministicGuardrail, + DeterministicGuardrailsService, + GuardrailScope, + GuardrailValidationResult, + GuardrailValidationResultType, +) + +from ._guardrails_service import GuardrailsService +from .guardrails import ( + BuiltInValidatorGuardrail, + EnumListParameterValue, + GuardrailType, + MapEnumParameterValue, +) + +__all__ = [ + "GuardrailsService", + "BuiltInValidatorGuardrail", + "GuardrailType", + "GuardrailValidationResultType", + "BaseGuardrail", + "GuardrailScope", + "DeterministicGuardrail", + "DeterministicGuardrailsService", + "GuardrailValidationResult", + "EnumListParameterValue", + "MapEnumParameterValue", +] diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py new file mode 100644 index 000000000..ebfbaf33d --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/_guardrails_service.py @@ -0,0 +1,135 @@ +from typing import Any + +from httpx import HTTPStatusError +from uipath.core.guardrails import ( + GuardrailValidationResult, + GuardrailValidationResultType, +) +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from ..errors import EnrichedException +from .guardrails import BuiltInValidatorGuardrail + + +class GuardrailsService(BaseService): + """Service for validating text against UiPath Guardrails. + + This service provides an interface for evaluating built-in guardrails such as: + + - PII detection + - Prompt injection detection + + Deterministic and custom guardrails are not yet supported. + + !!! info "Version Availability" + This service is available starting from **uipath** version **2.2.12**. + """ + + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + @staticmethod + def _parse_result(result_str: str) -> GuardrailValidationResultType: + """Parse result string from API response to GuardrailValidationResultType. + + Args: + result_str: The result string from the API response (e.g., "VALIDATION_FAILED"). + + Returns: + GuardrailValidationResultType: The parsed validation result type. + """ + if not result_str: + return GuardrailValidationResultType.VALIDATION_FAILED + + # Convert uppercase enum name to enum value + # API: "VALIDATION_FAILED" -> enum: "validation_failed" + result_value = result_str.lower() + try: + return GuardrailValidationResultType(result_value) + except ValueError: + # If direct conversion fails, try by enum name + try: + return GuardrailValidationResultType[result_str] + except KeyError: + # Fallback to validation_failed if unknown + return GuardrailValidationResultType.VALIDATION_FAILED + + @traced("evaluate_guardrail", run_type="uipath") + def evaluate_guardrail( + self, + input_data: str | dict[str, Any], + guardrail: BuiltInValidatorGuardrail, + ) -> GuardrailValidationResult: + """Validate input text using the provided guardrail. + + Args: + input_data: The text or structured data to validate. Dictionaries will be converted to a string before validation. + guardrail: A guardrail instance used for validation. + + Returns: + GuardrailValidationResult: The outcome of the guardrail evaluation. + """ + parameters = [ + param.model_dump(by_alias=True) for param in guardrail.validator_parameters + ] + payload = { + "validator": guardrail.validator_type, + "input": input_data if isinstance(input_data, str) else str(input_data), + "parameters": parameters, + } + spec = RequestSpec( + method="POST", + endpoint=Endpoint("/agentsruntime_/api/execution/guardrails/validate"), + json=payload, + ) + try: + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + response_data = response.json() + except EnrichedException as e: + # Handle 403 responses: API returns 403 with valid JSON body for + # ENTITLEMENTS_MISSING or FEATURE_DISABLED cases + if e.status_code == 403: + # Access the original HTTPStatusError to get the full response + original_error = e.__cause__ + if ( + isinstance(original_error, HTTPStatusError) + and original_error.response + ): + try: + response_data = original_error.response.json() + except Exception: + # If JSON parsing fails, re-raise the original exception + raise + else: + # Try to parse from response_content if available + try: + import json + + response_data = json.loads(e.response_content) + except Exception: + raise + else: + raise + + result = self._parse_result(response_data.get("result", "")) + + reason = response_data.get("details", "") + + # Prepare model data + model_data = { + "result": result.value, + "reason": reason, + } + + return GuardrailValidationResult.model_validate(model_data) diff --git a/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py new file mode 100644 index 000000000..cfc1e295f --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/guardrails/guardrails.py @@ -0,0 +1,62 @@ +"""Guardrails models for UiPath Platform.""" + +from enum import Enum +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field +from uipath.core.guardrails import BaseGuardrail + + +class EnumListParameterValue(BaseModel): + """Enum list parameter value.""" + + parameter_type: Literal["enum-list"] = Field(alias="$parameterType") + id: str + value: list[str] + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class MapEnumParameterValue(BaseModel): + """Map enum parameter value.""" + + parameter_type: Literal["map-enum"] = Field(alias="$parameterType") + id: str + value: dict[str, float] + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class NumberParameterValue(BaseModel): + """Number parameter value.""" + + parameter_type: Literal["number"] = Field(alias="$parameterType") + id: str + value: float + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +ValidatorParameter = Annotated[ + EnumListParameterValue | MapEnumParameterValue | NumberParameterValue, + Field(discriminator="parameter_type"), +] + + +class BuiltInValidatorGuardrail(BaseGuardrail): + """Built-in validator guardrail model.""" + + guardrail_type: Literal["builtInValidator"] = Field(alias="$guardrailType") + validator_type: str = Field(alias="validatorType") + validator_parameters: list[ValidatorParameter] = Field( + default_factory=list, alias="validatorParameters" + ) + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class GuardrailType(str, Enum): + """Guardrail type enumeration.""" + + BUILT_IN_VALIDATOR = "builtInValidator" + CUSTOM = "custom" diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/__init__.py b/packages/uipath-platform/src/uipath/platform/orchestrator/__init__.py new file mode 100644 index 000000000..337154eee --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/__init__.py @@ -0,0 +1,54 @@ +"""UiPath Orchestrator Models. + +This module contains models related to UiPath Orchestrator services. +""" + +from ._assets_service import AssetsService +from ._attachments_service import AttachmentsService +from ._buckets_service import BucketsService +from ._folder_service import FolderService +from ._jobs_service import JobsService +from ._mcp_service import McpService +from ._processes_service import ProcessesService +from ._queues_service import QueuesService +from .assets import Asset, UserAsset +from .attachment import Attachment +from .buckets import Bucket, BucketFile +from .job import Job, JobErrorInfo, JobState +from .mcp import McpServer, McpServerStatus, McpServerType +from .processes import Process +from .queues import ( + CommitType, + QueueItem, + QueueItemPriority, + TransactionItem, + TransactionItemResult, +) + +__all__ = [ + "AssetsService", + "AttachmentsService", + "BucketsService", + "FolderService", + "JobsService", + "McpService", + "ProcessesService", + "QueuesService", + "Asset", + "UserAsset", + "Attachment", + "Bucket", + "BucketFile", + "Job", + "JobErrorInfo", + "JobState", + "Process", + "CommitType", + "QueueItem", + "QueueItemPriority", + "TransactionItem", + "TransactionItemResult", + "McpServer", + "McpServerStatus", + "McpServerType", +] diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py new file mode 100644 index 000000000..7561dd4ea --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_assets_service.py @@ -0,0 +1,559 @@ +from typing import Any, Dict, Optional + +from httpx import Response +from uipath.core import traced + +from ..common._base_service import BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext, header_folder +from ..common._models import Endpoint, RequestSpec +from ..common.paging import PagedResult +from ..common.validation import validate_pagination_params +from .assets import Asset, UserAsset + + +class AssetsService(FolderContext, BaseService): + """Service for managing UiPath assets. + + Assets are key-value pairs that can be used to store configuration data, + credentials, and other settings used by automation processes. + """ + + # Pagination limits + MAX_PAGE_SIZE = 1000 # Maximum items per page + MAX_SKIP_OFFSET = 10000 # Maximum skip offset + + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._base_url = "assets" + + @traced(name="assets_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, + skip: int = 0, + top: int = 100, + ) -> PagedResult[Asset]: + """List assets using OData API with offset-based pagination. + + Returns a single page of results with pagination metadata. + + Args: + folder_path: Folder path to filter assets. + folder_key: Folder key (mutually exclusive with folder_path). + filter: OData $filter expression (e.g., "ValueType eq 'Text'"). + orderby: OData $orderby expression (e.g., "Name asc"). + skip: Number of items to skip (default 0, max 10000). + top: Maximum items per page (default 100, max 1000). + + Returns: + PagedResult[Asset]: Page of assets with pagination metadata. + + Raises: + ValueError: If skip or top parameters are invalid. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + + # List all assets in the default folder + result = client.assets.list(top=100) + for asset in result.items: + print(asset.name, asset.value_type) + + # List with filter + result = client.assets.list(filter="ValueType eq 'Text'") + + # Paginate through all assets + skip = 0 + while True: + result = client.assets.list(skip=skip, top=100) + for asset in result.items: + print(asset.name) + if not result.has_more: + break + skip += 100 + ``` + """ + validate_pagination_params( + skip=skip, + top=top, + max_skip=self.MAX_SKIP_OFFSET, + max_top=self.MAX_PAGE_SIZE, + ) + + spec = self._list_spec( + folder_path=folder_path, + folder_key=folder_key, + filter=filter, + orderby=orderby, + skip=skip, + top=top, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("value", []) + assets = [Asset.model_validate(item) for item in items] + + return PagedResult( + items=assets, + has_more=len(items) == top, + skip=skip, + top=top, + ) + + @traced(name="assets_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, + skip: int = 0, + top: int = 100, + ) -> PagedResult[Asset]: + """Asynchronously list assets using OData API with offset-based pagination. + + Returns a single page of results with pagination metadata. + + Args: + folder_path: Folder path to filter assets. + folder_key: Folder key (mutually exclusive with folder_path). + filter: OData $filter expression (e.g., "ValueType eq 'Text'"). + orderby: OData $orderby expression (e.g., "Name asc"). + skip: Number of items to skip (default 0, max 10000). + top: Maximum items per page (default 100, max 1000). + + Returns: + PagedResult[Asset]: Page of assets with pagination metadata. + + Raises: + ValueError: If skip or top parameters are invalid. + """ + validate_pagination_params( + skip=skip, + top=top, + max_skip=self.MAX_SKIP_OFFSET, + max_top=self.MAX_PAGE_SIZE, + ) + + spec = self._list_spec( + folder_path=folder_path, + folder_key=folder_key, + filter=filter, + orderby=orderby, + skip=skip, + top=top, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("value", []) + assets = [Asset.model_validate(item) for item in items] + + return PagedResult( + items=assets, + has_more=len(items) == top, + skip=skip, + top=top, + ) + + @resource_override(resource_type="asset") + @traced( + name="assets_retrieve", run_type="uipath", hide_input=True, hide_output=True + ) + def retrieve( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> UserAsset | Asset: + """Retrieve an asset by its name. + + Related Activity: [Get Asset](https://docs.uipath.com/activities/other/latest/workflow/get-robot-asset) + + Args: + name (str): The name of the asset. + folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. + + Returns: + UserAsset: The asset data. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + + client.assets.retrieve(name="MyAsset") + ``` + """ + try: + is_user = self._execution_context.robot_key is not None + except ValueError: + is_user = False + + spec = self._retrieve_spec( + name, + folder_key=folder_key, + folder_path=folder_path, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + content=spec.content, + headers=spec.headers, + json=spec.json, + ) + + if is_user: + return UserAsset.model_validate(response.json()) + else: + return Asset.model_validate(response.json()["value"][0]) + + @resource_override(resource_type="asset") + @traced( + name="assets_retrieve", run_type="uipath", hide_input=True, hide_output=True + ) + async def retrieve_async( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> UserAsset | Asset: + """Asynchronously retrieve an asset by its name. + + Related Activity: [Get Asset](https://docs.uipath.com/activities/other/latest/workflow/get-robot-asset) + + Args: + name (str): The name of the asset. + folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. + + Returns: + UserAsset: The asset data. + """ + try: + is_user = self._execution_context.robot_key is not None + except ValueError: + is_user = False + + spec = self._retrieve_spec( + name, + folder_key=folder_key, + folder_path=folder_path, + ) + response = await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + content=spec.content, + headers=spec.headers, + json=spec.json, + ) + + if is_user: + return UserAsset.model_validate(response.json()) + else: + return Asset.model_validate(response.json()["value"][0]) + + @resource_override(resource_type="asset") + @traced( + name="assets_credential", run_type="uipath", hide_input=True, hide_output=True + ) + def retrieve_credential( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Gets a specified Orchestrator credential. + + The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable) + + Related Activity: [Get Credential](https://docs.uipath.com/activities/other/latest/workflow/get-robot-credential) + + Args: + name (str): The name of the credential asset. + folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. + + Returns: + Optional[str]: The decrypted credential password. + + Raises: + ValueError: If the method is called for a user asset. + """ + try: + is_user = self._execution_context.robot_key is not None + except ValueError: + is_user = False + + if not is_user: + raise ValueError("This method can only be used for robot assets.") + + spec = self._retrieve_spec( + name, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + + user_asset = UserAsset.model_validate(response.json()) + + return user_asset.credential_password + + @resource_override(resource_type="asset") + @traced( + name="assets_credential", run_type="uipath", hide_input=True, hide_output=True + ) + async def retrieve_credential_async( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Optional[str]: + """Asynchronously gets a specified Orchestrator credential. + + The robot id is retrieved from the execution context (`UIPATH_ROBOT_KEY` environment variable) + + Related Activity: [Get Credential](https://docs.uipath.com/activities/other/latest/workflow/get-robot-credential) + + Args: + name (str): The name of the credential asset. + folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. + + Returns: + Optional[str]: The decrypted credential password. + + Raises: + ValueError: If the method is called for a user asset. + """ + try: + is_user = self._execution_context.robot_key is not None + except ValueError: + is_user = False + + if not is_user: + raise ValueError("This method can only be used for robot assets.") + + spec = self._retrieve_spec( + name, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + + user_asset = UserAsset.model_validate(response.json()) + + return user_asset.credential_password + + @traced(name="assets_update", run_type="uipath", hide_input=True, hide_output=True) + def update( + self, + robot_asset: UserAsset, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Response: + """Update an asset's value. + + Related Activity: [Set Asset](https://docs.uipath.com/activities/other/latest/workflow/set-asset) + + Args: + robot_asset (UserAsset): The asset object containing the updated values. + + Returns: + Response: The HTTP response confirming the update. + + Raises: + ValueError: If the method is called for a user asset. + """ + try: + is_user = self._execution_context.robot_key is not None + except ValueError: + is_user = False + + if not is_user: + raise ValueError("This method can only be used for robot assets.") + + spec = self._update_spec( + robot_asset, folder_key=folder_key, folder_path=folder_path + ) + + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + + return response.json() + + @traced(name="assets_update", run_type="uipath", hide_input=True, hide_output=True) + async def update_async( + self, + robot_asset: UserAsset, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Response: + """Asynchronously update an asset's value. + + Related Activity: [Set Asset](https://docs.uipath.com/activities/other/latest/workflow/set-asset) + + Args: + robot_asset (UserAsset): The asset object containing the updated values. + + Returns: + Response: The HTTP response confirming the update. + """ + spec = self._update_spec( + robot_asset, folder_key=folder_key, folder_path=folder_path + ) + + response = await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + + return response.json() + + @property + def custom_headers(self) -> Dict[str, str]: + return self.folder_headers + + def _retrieve_spec( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + try: + robot_key = self._execution_context.robot_key + except ValueError: + robot_key = None + + if robot_key is None: + return RequestSpec( + method="GET", + endpoint=Endpoint( + "/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered", + ), + params={"$filter": f"Name eq '{name}'", "$top": 1}, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + return RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey" + ), + json={ + "assetName": name, + "robotKey": robot_key, + "supportsCredentialsProxyDisconnected": True, + }, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _update_spec( + self, + robot_asset: UserAsset, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.SetRobotAssetByRobotKey" + ), + json={ + "robotKey": self._execution_context.robot_key, + "robotAsset": robot_asset.model_dump(by_alias=True, exclude_none=True), + }, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _list_spec( + self, + folder_path: Optional[str], + folder_key: Optional[str], + filter: Optional[str], + orderby: Optional[str], + skip: int, + top: int, + ) -> RequestSpec: + 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/Assets/UiPath.Server.Configuration.OData.GetFiltered" + ), + params=params, + headers={**header_folder(folder_key, folder_path)}, + ) diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py new file mode 100644 index 000000000..2a39e0dac --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_attachments_service.py @@ -0,0 +1,1023 @@ +import copy +import os +import shutil +import tempfile +import uuid +from contextlib import asynccontextmanager, contextmanager +from pathlib import Path +from typing import Any, AsyncIterator, Iterator, Tuple, overload + +import httpx +from httpx import Response +from httpx._types import RequestContent +from uipath.core.tracing import traced + +from ..attachments import Attachment, AttachmentMode, BlobFileAccessInfo +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext, header_folder +from ..common._models import Endpoint, RequestSpec +from ..common._ssl_context import get_httpx_client_kwargs +from ..common.constants import TEMP_ATTACHMENTS_FOLDER + + +def _upload_attachment_input_processor(inputs: dict[str, Any]) -> dict[str, Any]: + """Process attachment upload inputs to avoid logging large content.""" + processed_inputs = inputs.copy() + if "source_path" in processed_inputs: + processed_inputs["source_path"] = f"" + if "content" in processed_inputs: + if isinstance(processed_inputs["content"], str): + processed_inputs["content"] = "" + else: + processed_inputs["content"] = "" + return processed_inputs + + +class AttachmentsService(FolderContext, BaseService): + """Service for managing UiPath attachments. + + Attachments allow you to upload and download files to be used within UiPath + processes, actions, and other UiPath services. + + Reference: https://docs.uipath.com/orchestrator/reference/api-attachments + """ + + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._temp_dir = os.path.join(tempfile.gettempdir(), TEMP_ATTACHMENTS_FOLDER) + + @traced(name="attachments_open", run_type="uipath") + @contextmanager + def open( + self, + *, + attachment: Attachment, + mode: AttachmentMode = AttachmentMode.READ, + content: RequestContent | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> Iterator[Tuple[Attachment, Response]]: + """Open an attachment. + + Args: + attachment (Attachment): The attachment to open. + mode (AttachmentMode): The mode to use. + content (RequestContent | None): An optional request content to upload. + folder_key (str | None): The key of the folder. Override the default one set in the SDK config. + folder_path (str | None): The path of the folder. Override the default one set in the SDK config. + + Returns: + str: The name of the downloaded attachment. + + Raises: + Exception: If the download fails and no local file is found. + """ + try: + if mode == AttachmentMode.READ: + assert attachment.id, "Attachment ID is required to open an attachment." + spec = self._retrieve_download_uri_spec( + key=attachment.id, + folder_key=folder_key, + folder_path=folder_path, + ) + else: + spec = self._create_attachment_and_retrieve_upload_uri_spec( + name=attachment.full_name, + folder_key=folder_key, + folder_path=folder_path, + ) + + result = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + json=spec.json, + ).json() + resource: Attachment = copy.deepcopy(attachment) + resource.id = uuid.UUID(result["Id"]) + + resource_uri = result["BlobFileAccess"]["Uri"] + headers = { + key: value + for key, value in zip( + result["BlobFileAccess"]["Headers"]["Keys"], + result["BlobFileAccess"]["Headers"]["Values"], + strict=False, + ) + } + + if result["BlobFileAccess"]["RequiresAuth"]: + raise Exception( + "Attachment access not supported via UiPath Coded Agents." + ) + else: + http_verb = "GET" if mode == AttachmentMode.READ else "PUT" + with httpx.Client(**get_httpx_client_kwargs()) as client: + with client.stream( + http_verb, + resource_uri, + headers=headers, + content=content, + ) as response: + yield resource, response + except Exception as e: + # Re-raise the original exception if we can't find it locally + raise Exception(f"Attachment access failed with error: {e}") from e + + @traced(name="attachments_open", run_type="uipath") + @asynccontextmanager + async def open_async( + self, + *, + attachment: Attachment, + mode: AttachmentMode = AttachmentMode.READ, + content: RequestContent | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> AsyncIterator[Tuple[Attachment, Response]]: + """Open an attachment asynchronously. + + Args: + attachment (Attachment): The attachment to open. + mode (AttachmentMode): The mode to use. + content (RequestContent): An optional request content to upload. + folder_key (str | None): The key of the folder. Override the default one set in the SDK config. + folder_path (str | None): The path of the folder. Override the default one set in the SDK config. + + Returns: + str: The name of the downloaded attachment. + + Raises: + Exception: If the download fails and no local file is found. + """ + try: + if mode == AttachmentMode.READ: + assert attachment.id, "Attachment ID is required to open an attachment." + spec = self._retrieve_download_uri_spec( + key=attachment.id, + folder_key=folder_key, + folder_path=folder_path, + ) + else: + spec = self._create_attachment_and_retrieve_upload_uri_spec( + name=attachment.full_name, + folder_key=folder_key, + folder_path=folder_path, + ) + + result = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + json=spec.json, + ) + ).json() + resource: Attachment = copy.deepcopy(attachment) + resource.id = uuid.UUID(result["Id"]) + + resource_uri = result["BlobFileAccess"]["Uri"] + headers = { + key: value + for key, value in zip( + result["BlobFileAccess"]["Headers"]["Keys"], + result["BlobFileAccess"]["Headers"]["Values"], + strict=False, + ) + } + + if result["BlobFileAccess"]["RequiresAuth"]: + raise Exception( + "Attachment access not supported via UiPath Coded Agents." + ) + else: + http_verb = "GET" if mode == AttachmentMode.READ else "PUT" + async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client: + async with client.stream( + http_verb, + resource_uri, + headers=headers, + content=content, + ) as response: + yield resource, response + except Exception as e: + # Re-raise the original exception if we can't find it locally + raise Exception(f"Attachment access failed with error: {e}") from e + + @traced(name="attachments_download", run_type="uipath") + def download( + self, + *, + key: uuid.UUID, + destination_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> str: + """Download an attachment. + + This method downloads an attachment from UiPath to a local file. + If the attachment is not found in UiPath (404 error), it will check + for a local file in the temporary directory that matches the UUID. + + Note: + The local file fallback functionality is intended for local development + and debugging purposes only. + + Args: + key (uuid.UUID): The key of the attachment to download. + destination_path (str): The local path where the attachment will be saved. + folder_key (str | None): The key of the folder. Override the default one set in the SDK config. + folder_path (str | None): The path of the folder. Override the default one set in the SDK config. + + Returns: + str: The name of the downloaded attachment. + + Raises: + Exception: If the download fails and no local file is found. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + + attachment_name = client.attachments.download( + key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000"), + destination_path="path/to/save/document.pdf" + ) + print(f"Downloaded attachment: {attachment_name}") + ``` + """ + try: + spec = self._retrieve_download_uri_spec( + key=key, + folder_key=folder_key, + folder_path=folder_path, + ) + + result = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + # Get the attachment name + attachment_name = result["Name"] + + download_uri = result["BlobFileAccess"]["Uri"] + headers = { + key: value + for key, value in zip( + result["BlobFileAccess"]["Headers"]["Keys"], + result["BlobFileAccess"]["Headers"]["Values"], + strict=False, + ) + } + + with open(destination_path, "wb") as file: + if result["BlobFileAccess"]["RequiresAuth"]: + response = self.request( + "GET", download_uri, headers=headers, stream=True + ) + for chunk in response.iter_bytes(chunk_size=8192): + file.write(chunk) + else: + with httpx.Client(**get_httpx_client_kwargs()) as client: + with client.stream( + "GET", download_uri, headers=headers + ) as response: + for chunk in response.iter_bytes(chunk_size=8192): + file.write(chunk) + + return attachment_name + except Exception as e: + # If not found in UiPath, check local storage + if "404" in str(e): + # Check if file exists in temp directory + if os.path.exists(self._temp_dir): + # Look for any file starting with our UUID + pattern = f"{key}_*" + matching_files = list(Path(self._temp_dir).glob(pattern)) + + if matching_files: + # Get the full filename + local_file = matching_files[0] + + # Extract the original name from the filename (part after UUID_) + file_name = os.path.basename(local_file) + original_name = file_name[len(f"{key}_") :] + + # Copy the file to the destination + shutil.copy2(local_file, destination_path) + + return original_name + + # Re-raise the original exception if we can't find it locally + raise Exception( + f"Attachment with key {key} not found in UiPath or local storage" + ) from e + + @traced(name="attachments_download", run_type="uipath") + async def download_async( + self, + *, + key: uuid.UUID, + destination_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> str: + """Download an attachment asynchronously. + + This method asynchronously downloads an attachment from UiPath to a local file. + If the attachment is not found in UiPath (404 error), it will check + for a local file in the temporary directory that matches the UUID. + + Note: + The local file fallback functionality is intended for local development + and debugging purposes only. + + Args: + key (uuid.UUID): The key of the attachment to download. + destination_path (str): The local path where the attachment will be saved. + folder_key (str | None): The key of the folder. Override the default one set in the SDK config. + folder_path (str | None): The path of the folder. Override the default one set in the SDK config. + + Returns: + str: The name of the downloaded attachment. + + Raises: + Exception: If the download fails and no local file is found. + + Examples: + ```python + import asyncio + from uipath.platform import UiPath + + client = UiPath() + + async def main(): + attachment_name = await client.attachments.download_async( + key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000"), + destination_path="path/to/save/document.pdf" + ) + print(f"Downloaded attachment: {attachment_name}") + ``` + """ + try: + spec = self._retrieve_download_uri_spec( + key=key, + folder_key=folder_key, + folder_path=folder_path, + ) + + result = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + # Get the attachment name + attachment_name = result["Name"] + + download_uri = result["BlobFileAccess"]["Uri"] + headers = { + key: value + for key, value in zip( + result["BlobFileAccess"]["Headers"]["Keys"], + result["BlobFileAccess"]["Headers"]["Values"], + strict=False, + ) + } + + with open(destination_path, "wb") as file: + if result["BlobFileAccess"]["RequiresAuth"]: + response = await self.request_async( + "GET", download_uri, headers=headers, stream=True + ) + async for chunk in response.aiter_bytes(chunk_size=8192): + file.write(chunk) + else: + async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client: + async with client.stream( + "GET", download_uri, headers=headers + ) as response: + async for chunk in response.aiter_bytes(chunk_size=8192): + file.write(chunk) + + return attachment_name + except Exception as e: + # If not found in UiPath, check local storage + if "404" in str(e): + # Check if file exists in temp directory + if os.path.exists(self._temp_dir): + # Look for any file starting with our UUID + pattern = f"{key}_*" + matching_files = list(Path(self._temp_dir).glob(pattern)) + + if matching_files: + # Get the full filename + local_file = matching_files[0] + + # Extract the original name from the filename (part after UUID_) + file_name = os.path.basename(local_file) + original_name = file_name[len(f"{key}_") :] + + # Copy the file to the destination + shutil.copy2(local_file, destination_path) + + return original_name + + # Re-raise the original exception if we can't find it locally + raise Exception( + f"Attachment with key {key} not found in UiPath or local storage" + ) from e + + @overload + def upload( + self, + *, + name: str, + content: str | bytes, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> uuid.UUID: ... + + @overload + def upload( + self, + *, + name: str, + source_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> uuid.UUID: ... + + @traced( + name="attachments_upload", + run_type="uipath", + input_processor=_upload_attachment_input_processor, + ) + def upload( + self, + *, + name: str, + content: str | bytes | None = None, + source_path: str | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> uuid.UUID: + """Upload a file or content to UiPath as an attachment. + + This method uploads content to UiPath and makes it available as an attachment. + You can either provide a file path or content in memory. + + Args: + name (str): The name of the attachment file. + content (str | bytes | None): The content to upload (string or bytes). + source_path (str | None): The local path of the file to upload. + folder_key (str | None): The key of the folder. Override the default one set in the SDK config. + folder_path (str | None): The path of the folder. Override the default one set in the SDK config. + + Returns: + uuid.UUID: The UUID of the created attachment. + + Raises: + ValueError: If neither content nor source_path is provided, or if both are provided. + Exception: If the upload fails. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + + # Upload a file from disk + attachment_key = client.attachments.upload( + name="my-document.pdf", + source_path="path/to/local/document.pdf", + ) + print(f"Uploaded attachment with key: {attachment_key}") + + # Upload content from memory + attachment_key = client.attachments.upload( + name="notes.txt", + content="This is a text file content", + ) + print(f"Uploaded attachment with key: {attachment_key}") + ``` + """ + # Validate input parameters + if not (content or source_path): + raise ValueError("Content or source_path is required") + if content and source_path: + raise ValueError("Content and source_path are mutually exclusive") + + spec = self._create_attachment_and_retrieve_upload_uri_spec( + name=name, + folder_key=folder_key, + folder_path=folder_path, + ) + + result = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + json=spec.json, + ).json() + + # Get the ID from the response and convert to UUID + attachment_key = uuid.UUID(result["Id"]) + + upload_uri = result["BlobFileAccess"]["Uri"] + headers = { + key: value + for key, value in zip( + result["BlobFileAccess"]["Headers"]["Keys"], + result["BlobFileAccess"]["Headers"]["Values"], + strict=False, + ) + } + + if source_path: + # Upload from file + with open(source_path, "rb") as file: + file_content = file.read() + if result["BlobFileAccess"]["RequiresAuth"]: + self.request( + "PUT", upload_uri, headers=headers, content=file_content + ) + else: + with httpx.Client(**get_httpx_client_kwargs()) as client: + client.put(upload_uri, headers=headers, content=file_content) + else: + # Upload from memory + # Convert string to bytes if needed + if isinstance(content, str): + content = content.encode("utf-8") + + if result["BlobFileAccess"]["RequiresAuth"]: + self.request("PUT", upload_uri, headers=headers, content=content) + else: + with httpx.Client(**get_httpx_client_kwargs()) as client: + client.put(upload_uri, headers=headers, content=content) + + return attachment_key + + @overload + async def upload_async( + self, + *, + name: str, + content: str | bytes, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> uuid.UUID: ... + + @overload + async def upload_async( + self, + *, + name: str, + source_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> uuid.UUID: ... + + @traced( + name="attachments_upload", + run_type="uipath", + input_processor=_upload_attachment_input_processor, + ) + async def upload_async( + self, + *, + name: str, + content: str | bytes | None = None, + source_path: str | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> uuid.UUID: + """Upload a file or content to UiPath as an attachment asynchronously. + + This method asynchronously uploads content to UiPath and makes it available as an attachment. + You can either provide a file path or content in memory. + + Args: + name (str): The name of the attachment file. + content (str | bytes | None): The content to upload (string or bytes). + source_path (str | None): The local path of the file to upload. + folder_key (str | None): The key of the folder. Override the default one set in the SDK config. + folder_path (str | None): The path of the folder. Override the default one set in the SDK config. + + Returns: + uuid.UUID: The UUID of the created attachment. + + Raises: + ValueError: If neither content nor source_path is provided, or if both are provided. + Exception: If the upload fails. + + Examples: + ```python + import asyncio + from uipath.platform import UiPath + + client = UiPath() + + async def main(): + # Upload a file from disk + attachment_key = await client.attachments.upload_async( + name="my-document.pdf", + source_path="path/to/local/document.pdf", + ) + print(f"Uploaded attachment with key: {attachment_key}") + + # Upload content from memory + attachment_key = await client.attachments.upload_async( + name="notes.txt", + content="This is a text file content", + ) + print(f"Uploaded attachment with key: {attachment_key}") + ``` + """ + # Validate input parameters + if not (content or source_path): + raise ValueError("Content or source_path is required") + if content and source_path: + raise ValueError("Content and source_path are mutually exclusive") + + spec = self._create_attachment_and_retrieve_upload_uri_spec( + name=name, + folder_key=folder_key, + folder_path=folder_path, + ) + + result = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + json=spec.json, + ) + ).json() + + # Get the ID from the response and convert to UUID + attachment_key = uuid.UUID(result["Id"]) + + upload_uri = result["BlobFileAccess"]["Uri"] + headers = { + key: value + for key, value in zip( + result["BlobFileAccess"]["Headers"]["Keys"], + result["BlobFileAccess"]["Headers"]["Values"], + strict=False, + ) + } + + if source_path: + # Upload from file + with open(source_path, "rb") as file: + file_content = file.read() + if result["BlobFileAccess"]["RequiresAuth"]: + await self.request_async( + "PUT", upload_uri, headers=headers, content=file_content + ) + else: + with httpx.Client(**get_httpx_client_kwargs()) as client: + client.put(upload_uri, headers=headers, content=file_content) + else: + # Upload from memory + # Convert string to bytes if needed + if isinstance(content, str): + content = content.encode("utf-8") + + if result["BlobFileAccess"]["RequiresAuth"]: + await self.request_async( + "PUT", upload_uri, headers=headers, content=content + ) + else: + with httpx.Client(**get_httpx_client_kwargs()) as client: + client.put(upload_uri, headers=headers, content=content) + + return attachment_key + + @traced(name="attachments_get_blob_uri", run_type="uipath") + def get_blob_file_access_uri( + self, + *, + key: uuid.UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> BlobFileAccessInfo: + """Get the BlobFileAccess information for an attachment. + + This method retrieves the blob storage URI and filename for downloading + an attachment without actually downloading the file. + + Args: + key (uuid.UUID): The key of the attachment. + folder_key (str | None): The key of the folder. Override the default one set in the SDK config. + folder_path (str | None): The path of the folder. Override the default one set in the SDK config. + + Returns: + BlobFileAccessInfo: Object containing the blob storage URI and attachment name. + + Raises: + Exception: If the attachment is not found or the request fails. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + + info = client.attachments.get_blob_file_access_uri( + key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000") + ) + print(f"Attachment ID: {info.id}") + print(f"Blob URI: {info.uri}") + print(f"File name: {info.name}") + ``` + """ + spec = self._retrieve_download_uri_spec( + key=key, + folder_key=folder_key, + folder_path=folder_path, + ) + + result = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + return BlobFileAccessInfo( + id=key, + uri=result["BlobFileAccess"]["Uri"], + name=result["Name"], + ) + + @traced(name="attachments_get_blob_uri", run_type="uipath") + async def get_blob_file_access_uri_async( + self, + *, + key: uuid.UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> BlobFileAccessInfo: + """Get the BlobFileAccess information for an attachment asynchronously. + + This method asynchronously retrieves the blob storage URI and filename + for downloading an attachment without actually downloading the file. + + Args: + key (uuid.UUID): The key of the attachment. + folder_key (str | None): The key of the folder. Override the default one set in the SDK config. + folder_path (str | None): The path of the folder. Override the default one set in the SDK config. + + Returns: + BlobFileAccessInfo: Object containing the blob storage URI and attachment name. + + Raises: + Exception: If the attachment is not found or the request fails. + + Examples: + ```python + import asyncio + from uipath.platform import UiPath + + client = UiPath() + + async def main(): + info = await client.attachments.get_blob_file_access_uri_async( + key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000") + ) + print(f"Attachment ID: {info.id}") + print(f"Blob URI: {info.uri}") + print(f"File name: {info.name}") + ``` + """ + spec = self._retrieve_download_uri_spec( + key=key, + folder_key=folder_key, + folder_path=folder_path, + ) + + result = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + return BlobFileAccessInfo( + id=key, + uri=result["BlobFileAccess"]["Uri"], + name=result["Name"], + ) + + @traced(name="attachments_delete", run_type="uipath") + def delete( + self, + *, + key: uuid.UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> None: + """Delete an attachment. + + This method deletes an attachment from UiPath. + If the attachment is not found in UiPath (404 error), it will check + for a local file in the temporary directory that matches the UUID. + + Note: + The local file fallback functionality is intended for local development + and debugging purposes only. + + Args: + key (uuid.UUID): The key of the attachment to delete. + folder_key (str | None): The key of the folder. Override the default one set in the SDK config. + folder_path (str | None): The path of the folder. Override the default one set in the SDK config. + + Raises: + Exception: If the deletion fails and no local file is found. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + + client.attachments.delete( + key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000") + ) + print("Attachment deleted successfully") + ``` + """ + try: + spec = self._delete_attachment_spec( + key=key, + folder_key=folder_key, + folder_path=folder_path, + ) + + self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + except Exception as e: + # If not found in UiPath, check local storage + if "404" in str(e): + # Check if file exists in temp directory + if os.path.exists(self._temp_dir): + # Look for any file starting with our UUID + pattern = f"{key}_*" + matching_files = list(Path(self._temp_dir).glob(pattern)) + + if matching_files: + # Delete all matching files + for file_path in matching_files: + os.remove(file_path) + return + + # Re-raise the original exception if we can't find it locally + raise Exception( + f"Attachment with key {key} not found in UiPath or local storage" + ) from e + + @traced(name="attachments_delete", run_type="uipath") + async def delete_async( + self, + *, + key: uuid.UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> None: + """Delete an attachment asynchronously. + + This method asynchronously deletes an attachment from UiPath. + If the attachment is not found in UiPath (404 error), it will check + for a local file in the temporary directory that matches the UUID. + + Note: + The local file fallback functionality is intended for local development + and debugging purposes only. + + Args: + key (uuid.UUID): The key of the attachment to delete. + folder_key (str | None): The key of the folder. Override the default one set in the SDK config. + folder_path (str | None): The path of the folder. Override the default one set in the SDK config. + + Raises: + Exception: If the deletion fails and no local file is found. + + Examples: + ```python + import asyncio + from uipath.platform import UiPath + + client = UiPath() + + async def main(): + await client.attachments.delete_async( + key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000") + ) + print("Attachment deleted successfully") + ``` + """ + try: + spec = self._delete_attachment_spec( + key=key, + folder_key=folder_key, + folder_path=folder_path, + ) + + await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + except Exception as e: + # If not found in UiPath, check local storage + if "404" in str(e): + # Check if file exists in temp directory + if os.path.exists(self._temp_dir): + # Look for any file starting with our UUID + pattern = f"{key}_*" + matching_files = list(Path(self._temp_dir).glob(pattern)) + + if matching_files: + # Delete all matching files + for file_path in matching_files: + os.remove(file_path) + return + + # Re-raise the original exception if we can't find it locally + raise Exception( + f"Attachment with key {key} not found in UiPath or local storage" + ) from e + + @property + def custom_headers(self) -> dict[str, str]: + """Return custom headers for API requests.""" + return self.folder_headers + + def _create_attachment_and_retrieve_upload_uri_spec( + self, + name: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint("/orchestrator_/odata/Attachments"), + json={ + "Name": name, + }, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _retrieve_download_uri_spec( + self, + key: uuid.UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/orchestrator_/odata/Attachments({key})"), + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _delete_attachment_spec( + self, + key: uuid.UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> RequestSpec: + return RequestSpec( + method="DELETE", + endpoint=Endpoint(f"/orchestrator_/odata/Attachments({key})"), + headers={ + **header_folder(folder_key, folder_path), + }, + ) diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py new file mode 100644 index 000000000..d5e32aec6 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_buckets_service.py @@ -0,0 +1,1803 @@ +import asyncio +import mimetypes +import uuid +from pathlib import Path +from typing import Any, Dict, Optional, Union + +import httpx +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext, header_folder +from ..common._models import Endpoint, RequestSpec +from ..common._ssl_context import get_httpx_client_kwargs +from ..common.paging import PagedResult +from ..common.validation import validate_pagination_params +from .buckets import Bucket, BucketFile + +# Pagination limits +MAX_PAGE_SIZE = 1000 # Maximum items per page (top parameter) +MAX_SKIP_OFFSET = 10000 # Maximum skip offset for offset-based pagination + + +class BucketsService(FolderContext, BaseService): + """Service for managing UiPath storage buckets. + + Buckets are cloud storage containers that can be used to store and manage files + used by automation processes. + """ + + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self.custom_client = httpx.Client(**get_httpx_client_kwargs()) + self.custom_client_async = httpx.AsyncClient(**get_httpx_client_kwargs()) + + @traced(name="buckets_list", run_type="uipath") + def list( + self, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + name: Optional[str] = None, + skip: int = 0, + top: int = 100, + ) -> PagedResult[Bucket]: + """List buckets using OData API with offset-based pagination. + + Returns a single page of results with pagination metadata. + + Args: + folder_path: Folder path to filter buckets + folder_key: Folder key (mutually exclusive with folder_path) + name: Filter by bucket name (contains match) + skip: Number of buckets to skip (default 0, max 10000) + top: Maximum number of buckets to return (default 100, max 1000) + + Returns: + PagedResult[Bucket]: Page containing buckets and pagination metadata + + Raises: + ValueError: If skip < 0, skip > 10000, top < 1, or top > 1000 + + Examples: + >>> # Get first page + >>> result = sdk.buckets.list(top=100) + >>> for bucket in result.items: + ... print(bucket.name) + >>> + >>> # Check pagination metadata + >>> if result.has_more: + ... print(f"More results available. Current: skip={result.skip}, top={result.top}") + >>> + >>> # Manual pagination to get all buckets + >>> skip = 0 + >>> top = 100 + >>> all_buckets = [] + >>> while True: + ... result = sdk.buckets.list(skip=skip, top=top, name="invoice") + ... all_buckets.extend(result.items) + ... if not result.has_more: + ... break + ... skip += top + >>> + >>> # Helper function for complete iteration + >>> def iter_all_buckets(sdk, top=100, **filters): + ... skip = 0 + ... while True: + ... result = sdk.buckets.list(skip=skip, top=top, **filters) + ... yield from result.items + ... if not result.has_more: + ... break + ... skip += top + >>> + >>> # Usage + >>> for bucket in iter_all_buckets(sdk, name="invoice"): + ... process_bucket(bucket) + """ + # Validate parameters using shared utility + validate_pagination_params( + skip=skip, + top=top, + max_skip=MAX_SKIP_OFFSET, + max_top=MAX_PAGE_SIZE, + ) + + spec = self._list_spec( + folder_path=folder_path, + folder_key=folder_key, + name=name, + skip=skip, + top=top, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("value", []) + buckets = [Bucket.model_validate(item) for item in items] + + return PagedResult( + items=buckets, + has_more=len(items) == top, + skip=skip, + top=top, + ) + + @traced(name="buckets_list", run_type="uipath") + async def list_async( + self, + *, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + name: Optional[str] = None, + skip: int = 0, + top: int = 100, + ) -> PagedResult[Bucket]: + """Async version of list() with offset-based pagination. + + Returns a single page of results with pagination metadata. + + Args: + folder_path: Folder path to filter buckets + folder_key: Folder key (mutually exclusive with folder_path) + name: Filter by bucket name (contains match) + skip: Number of buckets to skip (default 0, max 10000) + top: Maximum number of buckets to return (default 100, max 1000) + + Returns: + PagedResult[Bucket]: Page containing buckets and pagination metadata + + Raises: + ValueError: If skip < 0, skip > 10000, top < 1, or top > 1000 + + Examples: + >>> # Get first page + >>> result = await sdk.buckets.list_async(top=100) + >>> for bucket in result.items: + ... print(bucket.name) + >>> + >>> # Manual pagination + >>> skip = 0 + >>> top = 100 + >>> all_buckets = [] + >>> while True: + ... result = await sdk.buckets.list_async(skip=skip, top=top) + ... all_buckets.extend(result.items) + ... if not result.has_more: + ... break + ... skip += top + """ + # Validate parameters using shared utility + validate_pagination_params( + skip=skip, + top=top, + max_skip=MAX_SKIP_OFFSET, + max_top=MAX_PAGE_SIZE, + ) + + spec = self._list_spec( + folder_path=folder_path, + folder_key=folder_key, + name=name, + skip=skip, + top=top, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("value", []) + buckets = [Bucket.model_validate(item) for item in items] + + return PagedResult( + items=buckets, + has_more=len(items) == top, + skip=skip, + top=top, + ) + + @traced(name="buckets_exists", run_type="uipath") + def exists( + self, + name: str, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> bool: + """Check if bucket exists. + + Args: + name: Bucket name + folder_key: Folder key + folder_path: Folder path + + Returns: + bool: True if bucket exists + + Examples: + >>> if sdk.buckets.exists("my-storage"): + ... print("Bucket found") + """ + try: + self.retrieve(name=name, folder_key=folder_key, folder_path=folder_path) + return True + except LookupError: + return False + + @traced(name="buckets_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="buckets_create", run_type="uipath") + def create( + self, + name: str, + *, + description: Optional[str] = None, + identifier: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> Bucket: + """Create a new bucket. + + Args: + name: Bucket name (must be unique within folder) + description: Optional description + identifier: UUID identifier (auto-generated if not provided) + folder_path: Folder to create bucket in + folder_key: Folder key + + Returns: + Bucket: Newly created bucket resource + + Raises: + Exception: If bucket creation fails + + Examples: + >>> bucket = sdk.buckets.create("my-storage") + >>> bucket = sdk.buckets.create( + ... "data-storage", + ... description="Production data" + ... ) + """ + spec = self._create_spec( + name=name, + description=description, + identifier=identifier or str(uuid.uuid4()), + folder_path=folder_path, + folder_key=folder_key, + ) + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + ).json() + + bucket = Bucket.model_validate(response) + return bucket + + @traced(name="buckets_create", run_type="uipath") + async def create_async( + self, + name: str, + *, + description: Optional[str] = None, + identifier: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> Bucket: + """Async version of create().""" + spec = self._create_spec( + name=name, + description=description, + identifier=identifier or str(uuid.uuid4()), + 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() + + bucket = Bucket.model_validate(response) + return bucket + + @resource_override(resource_type="bucket") + @traced(name="buckets_delete", run_type="uipath") + def delete( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> None: + """Delete a bucket. + + Args: + name: Bucket name + key: Bucket identifier (UUID) + folder_path: Folder path + folder_key: Folder key + + Raises: + LookupError: If bucket is not found + + Examples: + >>> sdk.buckets.delete(name="old-storage") + >>> sdk.buckets.delete(key="abc-123-def") + """ + bucket = self.retrieve( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + + self.request( + "DELETE", + url=f"/orchestrator_/odata/Buckets({bucket.id})", + headers={**self.folder_headers}, + ) + + @resource_override(resource_type="bucket") + @traced(name="buckets_delete", run_type="uipath") + 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().""" + bucket = await self.retrieve_async( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + + await self.request_async( + "DELETE", + url=f"/orchestrator_/odata/Buckets({bucket.id})", + headers={**self.folder_headers}, + ) + + @resource_override(resource_type="bucket") + @traced(name="buckets_download", run_type="uipath") + def download( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + blob_file_path: str, + destination_path: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Download a file from a bucket. + + Args: + key (Optional[str]): The key of the bucket. + name (Optional[str]): The name of the bucket. + blob_file_path (str): The path to the file in the bucket. + destination_path (str): The local path where the file will be saved. + folder_key (Optional[str]): The key of the folder where the bucket resides. + folder_path (Optional[str]): The path of the folder where the bucket resides. + + Raises: + ValueError: If neither key nor name is provided. + Exception: If the bucket with the specified key is not found. + """ + bucket = self.retrieve( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + spec = self._retrieve_readUri_spec( + bucket.id, blob_file_path, folder_key=folder_key, folder_path=folder_path + ) + result = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + read_uri = result["Uri"] + + headers = { + key: value + for key, value in zip( + result["Headers"]["Keys"], result["Headers"]["Values"], strict=False + ) + } + + with open(destination_path, "wb") as file: + if result["RequiresAuth"]: + file_content = self.request("GET", read_uri, headers=headers).content + else: + file_content = self.custom_client.get(read_uri, headers=headers).content + file.write(file_content) + + @resource_override(resource_type="bucket") + @traced(name="buckets_download", run_type="uipath") + async def download_async( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + blob_file_path: str, + destination_path: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Download a file from a bucket asynchronously. + + Args: + key (Optional[str]): The key of the bucket. + name (Optional[str]): The name of the bucket. + blob_file_path (str): The path to the file in the bucket. + destination_path (str): The local path where the file will be saved. + folder_key (Optional[str]): The key of the folder where the bucket resides. + folder_path (Optional[str]): The path of the folder where the bucket resides. + + Raises: + ValueError: If neither key nor name is provided. + Exception: If the bucket with the specified key is not found. + """ + bucket = await self.retrieve_async( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + spec = self._retrieve_readUri_spec( + bucket.id, blob_file_path, folder_key=folder_key, folder_path=folder_path + ) + result = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + read_uri = result["Uri"] + + headers = { + key: value + for key, value in zip( + result["Headers"]["Keys"], result["Headers"]["Values"], strict=False + ) + } + + if result["RequiresAuth"]: + file_content = ( + await self.request_async("GET", read_uri, headers=headers) + ).content + else: + file_content = ( + await self.custom_client_async.get(read_uri, headers=headers) + ).content + + await asyncio.to_thread(Path(destination_path).write_bytes, file_content) + + @resource_override(resource_type="bucket") + @traced(name="buckets_upload", run_type="uipath") + def upload( + self, + *, + key: Optional[str] = None, + name: Optional[str] = None, + blob_file_path: str, + content_type: Optional[str] = None, + source_path: Optional[str] = None, + content: Optional[Union[str, bytes]] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Upload a file to a bucket. + + Args: + key (Optional[str]): The key of the bucket. + name (Optional[str]): The name of the bucket. + blob_file_path (str): The path where the file will be stored in the bucket. + content_type (Optional[str]): The MIME type of the file. For file inputs this is computed dynamically. Default is "application/octet-stream". + source_path (Optional[str]): The local path of the file to upload. + content (Optional[Union[str, bytes]]): The content to upload (string or bytes). + folder_key (Optional[str]): The key of the folder where the bucket resides. + folder_path (Optional[str]): The path of the folder where the bucket resides. + + Raises: + ValueError: If neither key nor name is provided. + Exception: If the bucket with the specified key or name is not found. + """ + if content is not None and source_path is not None: + raise ValueError("Content and source_path are mutually exclusive") + if content is None and source_path is None: + raise ValueError("Either content or source_path must be provided") + + bucket = self.retrieve( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + + if source_path: + _content_type, _ = mimetypes.guess_type(source_path) + else: + _content_type = content_type + _content_type = _content_type or "application/octet-stream" + + spec = self._retrieve_writeri_spec( + bucket.id, + _content_type, + blob_file_path, + folder_key=folder_key, + folder_path=folder_path, + ) + + result = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + write_uri = result["Uri"] + + headers = { + key: value + for key, value in zip( + result["Headers"]["Keys"], result["Headers"]["Values"], strict=False + ) + } + + headers["Content-Type"] = _content_type + + if content is not None: + if isinstance(content, str): + content = content.encode("utf-8") + + if result["RequiresAuth"]: + self.request("PUT", write_uri, headers=headers, content=content) + else: + self.custom_client.put(write_uri, headers=headers, content=content) + + if source_path is not None: + with open(source_path, "rb") as file: + file_content = file.read() + if result["RequiresAuth"]: + self.request( + "PUT", write_uri, headers=headers, content=file_content + ) + else: + self.custom_client.put( + write_uri, headers=headers, content=file_content + ) + + @resource_override(resource_type="bucket") + @traced(name="buckets_upload", run_type="uipath") + async def upload_async( + self, + *, + key: Optional[str] = None, + name: Optional[str] = None, + blob_file_path: str, + content_type: Optional[str] = None, + source_path: Optional[str] = None, + content: Optional[Union[str, bytes]] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Upload a file to a bucket asynchronously. + + Args: + key (Optional[str]): The key of the bucket. + name (Optional[str]): The name of the bucket. + blob_file_path (str): The path where the file will be stored in the bucket. + content_type (Optional[str]): The MIME type of the file. For file inputs this is computed dynamically. Default is "application/octet-stream". + source_path (Optional[str]): The local path of the file to upload. + content (Optional[Union[str, bytes]]): The content to upload (string or bytes). + folder_key (Optional[str]): The key of the folder where the bucket resides. + folder_path (Optional[str]): The path of the folder where the bucket resides. + + Raises: + ValueError: If neither key nor name is provided. + Exception: If the bucket with the specified key or name is not found. + """ + if content is not None and source_path is not None: + raise ValueError("Content and source_path are mutually exclusive") + if content is None and source_path is None: + raise ValueError("Either content or source_path must be provided") + + bucket = await self.retrieve_async( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + + if source_path: + _content_type, _ = mimetypes.guess_type(source_path) + else: + _content_type = content_type + _content_type = _content_type or "application/octet-stream" + + spec = self._retrieve_writeri_spec( + bucket.id, + _content_type, + blob_file_path, + folder_key=folder_key, + folder_path=folder_path, + ) + + result = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + write_uri = result["Uri"] + + headers = { + key: value + for key, value in zip( + result["Headers"]["Keys"], result["Headers"]["Values"], strict=False + ) + } + + headers["Content-Type"] = _content_type + + if content is not None: + if isinstance(content, str): + content = content.encode("utf-8") + + if result["RequiresAuth"]: + await self.request_async( + "PUT", write_uri, headers=headers, content=content + ) + else: + await self.custom_client_async.put( + write_uri, headers=headers, content=content + ) + + if source_path is not None: + file_content = await asyncio.to_thread(Path(source_path).read_bytes) + if result["RequiresAuth"]: + await self.request_async( + "PUT", write_uri, headers=headers, content=file_content + ) + else: + await self.custom_client_async.put( + write_uri, headers=headers, content=file_content + ) + + @resource_override(resource_type="bucket") + @traced(name="buckets_retrieve", run_type="uipath") + def retrieve( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Bucket: + """Retrieve bucket information by its name. + + Args: + name (Optional[str]): The name of the bucket to retrieve. + key (Optional[str]): The key of the bucket. + folder_key (Optional[str]): The key of the folder where the bucket resides. + folder_path (Optional[str]): The path of the folder where the bucket resides. + + Returns: + Bucket: The bucket resource instance. + + Raises: + ValueError: If neither bucket key nor bucket name is provided. + Exception: If the bucket with the specified name is not found. + + Examples: + >>> bucket = sdk.buckets.retrieve(name="my-storage") + >>> print(bucket.name, bucket.identifier) + """ + if key: + spec = self._retrieve_by_key_spec( + key, folder_key=folder_key, folder_path=folder_path + ) + try: + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + if "value" in response: + items = response.get("value", []) + if not items: + raise LookupError(f"Bucket with key '{key}' not found") + bucket_data = items[0] + else: + bucket_data = response + except (KeyError, IndexError) as e: + raise LookupError(f"Bucket with key '{key}' not found") from e + else: + if not name: + raise ValueError("Must specify a bucket name or bucket key") + spec = self._retrieve_spec( + name, + folder_key=folder_key, + folder_path=folder_path, + ) + try: + 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"Bucket with name '{name}' not found") + bucket_data = items[0] + except (KeyError, IndexError) as e: + raise LookupError(f"Bucket with name '{name}' not found") from e + + bucket = Bucket.model_validate(bucket_data) + return bucket + + @resource_override(resource_type="bucket") + @traced(name="buckets_retrieve", run_type="uipath") + async def retrieve_async( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Bucket: + """Asynchronously retrieve bucket information by its name. + + Args: + name (Optional[str]): The name of the bucket to retrieve. + key (Optional[str]): The key of the bucket. + folder_key (Optional[str]): The key of the folder where the bucket resides. + folder_path (Optional[str]): The path of the folder where the bucket resides. + + Returns: + Bucket: The bucket resource instance. + + Raises: + ValueError: If neither bucket key nor bucket name is provided. + Exception: If the bucket with the specified name is not found. + + Examples: + >>> bucket = await sdk.buckets.retrieve_async(name="my-storage") + >>> print(bucket.name, bucket.identifier) + """ + if key: + spec = self._retrieve_by_key_spec( + key, folder_key=folder_key, folder_path=folder_path + ) + try: + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + if "value" in response: + items = response.get("value", []) + if not items: + raise LookupError(f"Bucket with key '{key}' not found") + bucket_data = items[0] + else: + bucket_data = response + except (KeyError, IndexError) as e: + raise LookupError(f"Bucket with key '{key}' not found") from e + else: + if not name: + raise ValueError("Must specify a bucket name or bucket key") + spec = self._retrieve_spec( + name, + folder_key=folder_key, + folder_path=folder_path, + ) + try: + 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"Bucket with name '{name}' not found") + bucket_data = items[0] + except (KeyError, IndexError) as e: + raise LookupError(f"Bucket with name '{name}' not found") from e + + bucket = Bucket.model_validate(bucket_data) + return bucket + + @resource_override(resource_type="bucket") + @traced(name="buckets_list_files", run_type="uipath") + def list_files( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + prefix: str = "", + take_hint: int = 500, + continuation_token: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> PagedResult[BucketFile]: + """List files in a bucket using cursor-based pagination. + + Returns a single page of results with continuation token for manual pagination. + This method uses the REST API with continuation tokens for efficient pagination + of large file sets. Recommended for sequential iteration over millions of files. + + Args: + name: Bucket name + key: Bucket identifier + prefix: Filter files by prefix + take_hint: Minimum number of files to return (default 500, max 1000). + The API may return up to 2x this value in some cases. + continuation_token: Token from previous response. Pass None for first page. + folder_key: Folder key + folder_path: Folder path + + Returns: + PagedResult[BucketFile]: Page containing files and continuation token metadata + + Raises: + ValueError: If take_hint is not between 1 and 1000 + + Examples: + >>> # Get first page + >>> result = sdk.buckets.list_files(name="my-storage") + >>> print(f"Got {len(result.items)} files") + >>> + >>> # Manual pagination to get all files + >>> all_files = [] + >>> token = None + >>> while True: + ... result = sdk.buckets.list_files( + ... name="my-storage", + ... prefix="reports/2024/", + ... continuation_token=token + ... ) + ... all_files.extend(result.items) + ... if not result.continuation_token: + ... break + ... token = result.continuation_token + >>> + >>> # Helper function for iteration + >>> def iter_all_files(sdk, bucket_name, prefix=""): + ... token = None + ... while True: + ... result = sdk.buckets.list_files( + ... name=bucket_name, + ... prefix=prefix, + ... continuation_token=token + ... ) + ... yield from result.items + ... if not result.continuation_token: + ... break + ... token = result.continuation_token + >>> + >>> # Usage + >>> for file in iter_all_files(sdk, "my-storage", "reports/"): + ... print(file.path) + + Performance: + Cursor-based pagination scales efficiently to millions of files. + Each page requires one API call regardless of dataset size. + + For sequential processing, this is the most efficient method. + For filtered queries, consider get_files() with OData filters. + """ + # Validate parameters + if take_hint < 1 or take_hint > 1000: + raise ValueError("take_hint must be between 1 and 1000") + + bucket = self.retrieve( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + + spec = self._list_files_spec( + bucket.id, + prefix, + continuation_token=continuation_token, + take_hint=take_hint, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("items", []) + files = [BucketFile.model_validate(item) for item in items] + next_token = response.get("continuationToken") + + return PagedResult( + items=files, + continuation_token=next_token, + has_more=next_token is not None, + ) + + @resource_override(resource_type="bucket") + @traced(name="buckets_list_files", run_type="uipath") + async def list_files_async( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + prefix: str = "", + take_hint: int = 500, + continuation_token: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> PagedResult[BucketFile]: + """Async version of list_files() with cursor-based pagination. + + Returns a single page of results with continuation token for manual pagination. + + Args: + name: Bucket name + key: Bucket identifier + prefix: Filter files by prefix + take_hint: Minimum number of files to return (default 500, max 1000). + The API may return up to 2x this value in some cases. + continuation_token: Token from previous response. Pass None for first page. + folder_key: Folder key + folder_path: Folder path + + Returns: + PagedResult[BucketFile]: Page containing files and continuation token metadata + + Raises: + ValueError: If take_hint is not between 1 and 1000 + + Examples: + >>> # Get first page + >>> result = await sdk.buckets.list_files_async(name="my-storage") + >>> print(f"Got {len(result.items)} files") + >>> + >>> # Manual pagination + >>> all_files = [] + >>> token = None + >>> while True: + ... result = await sdk.buckets.list_files_async( + ... name="my-storage", + ... continuation_token=token + ... ) + ... all_files.extend(result.items) + ... if not result.continuation_token: + ... break + ... token = result.continuation_token + """ + # Validate parameters + if take_hint < 1 or take_hint > 1000: + raise ValueError("take_hint must be between 1 and 1000") + + bucket = await self.retrieve_async( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + + spec = self._list_files_spec( + bucket.id, + prefix, + continuation_token=continuation_token, + take_hint=take_hint, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("items", []) + files = [BucketFile.model_validate(item) for item in items] + next_token = response.get("continuationToken") + + return PagedResult( + items=files, + continuation_token=next_token, + has_more=next_token is not None, + ) + + @resource_override(resource_type="bucket") + @traced(name="buckets_exists_file", run_type="uipath") + def exists_file( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + blob_file_path: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> bool: + """Check if a file exists in a bucket. + + Args: + name: Bucket name + key: Bucket identifier + blob_file_path: Path to the file in the bucket (cannot be empty) + folder_key: Folder key + folder_path: Folder path + + Returns: + bool: True if file exists, False otherwise + + Note: + This method uses short-circuit iteration to stop at the first match, + making it memory-efficient even for large buckets. It will raise + LookupError if the bucket itself doesn't exist. + + Raises: + ValueError: If blob_file_path is empty or whitespace-only + LookupError: If bucket is not found + + Examples: + >>> if sdk.buckets.exists_file(name="my-storage", blob_file_path="data/file.csv"): + ... print("File exists") + >>> # Check in specific folder + >>> exists = sdk.buckets.exists_file( + ... name="my-storage", + ... blob_file_path="reports/2024/summary.pdf", + ... folder_path="Production" + ... ) + """ + if not blob_file_path or not blob_file_path.strip(): + raise ValueError("blob_file_path cannot be empty or whitespace-only") + + normalized_target = ( + blob_file_path if blob_file_path.startswith("/") else f"/{blob_file_path}" + ) + + bucket = self.retrieve( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + + token = None + while True: + spec = self._list_files_spec( + bucket.id, + normalized_target, # Use normalized path for prefix + continuation_token=token, + take_hint=1, # Performance optimization: only need first match + folder_key=folder_key, + folder_path=folder_path, + ) + + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("items", []) + for item in items: + file = BucketFile.model_validate(item) + if file.path == normalized_target: + return True + + token = response.get("continuationToken") + if not token: + break + + return False + + @resource_override(resource_type="bucket") + @traced(name="buckets_exists_file", run_type="uipath") + async def exists_file_async( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + blob_file_path: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> bool: + """Async version of exists_file(). + + Args: + name: Bucket name + key: Bucket identifier + blob_file_path: Path to the file in the bucket (cannot be empty) + folder_key: Folder key + folder_path: Folder path + + Returns: + bool: True if file exists, False otherwise + + Raises: + ValueError: If blob_file_path is empty or whitespace-only + LookupError: If bucket is not found + + Examples: + >>> if await sdk.buckets.exists_file_async(name="my-storage", blob_file_path="data/file.csv"): + ... print("File exists") + """ + if not blob_file_path or not blob_file_path.strip(): + raise ValueError("blob_file_path cannot be empty or whitespace-only") + + normalized_target = ( + blob_file_path if blob_file_path.startswith("/") else f"/{blob_file_path}" + ) + + bucket = await self.retrieve_async( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + + token = None + while True: + spec = self._list_files_spec( + bucket.id, + normalized_target, # Use normalized path for prefix + continuation_token=token, + take_hint=1, # Performance optimization: only need first match + folder_key=folder_key, + folder_path=folder_path, + ) + + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("items", []) + for item in items: + file = BucketFile.model_validate(item) + if file.path == normalized_target: + return True + + token = response.get("continuationToken") + if not token: + break + + return False + + @resource_override(resource_type="bucket") + @traced(name="buckets_delete_file", run_type="uipath") + def delete_file( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + blob_file_path: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Delete a file from a bucket. + + Args: + name: Bucket name + key: Bucket identifier + blob_file_path: Path to the file in the bucket + folder_key: Folder key + folder_path: Folder path + + Examples: + >>> sdk.buckets.delete_file(name="my-storage", blob_file_path="data/file.txt") + """ + bucket = self.retrieve( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + spec = self._delete_file_spec( + bucket.id, blob_file_path, folder_key=folder_key, folder_path=folder_path + ) + self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + + @resource_override(resource_type="bucket") + @traced(name="buckets_delete_file", run_type="uipath") + async def delete_file_async( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + blob_file_path: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> None: + """Delete a file from a bucket asynchronously. + + Args: + name: Bucket name + key: Bucket identifier + blob_file_path: Path to the file in the bucket + folder_key: Folder key + folder_path: Folder path + + Examples: + >>> await sdk.buckets.delete_file_async(name="my-storage", blob_file_path="data/file.txt") + """ + bucket = await self.retrieve_async( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + spec = self._delete_file_spec( + bucket.id, blob_file_path, folder_key=folder_key, folder_path=folder_path + ) + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + + @resource_override(resource_type="bucket") + @traced(name="buckets_get_files", run_type="uipath") + def get_files( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + prefix: str = "", + recursive: bool = False, + file_name_glob: Optional[str] = None, + skip: int = 0, + top: int = 500, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> PagedResult[BucketFile]: + """Get files using OData GetFiles API with offset-based pagination. + + This method uses the OData API with $skip/$top for pagination. + Supports recursive traversal, glob filtering, and OData features. + Automatically excludes directories from results. + + Note: Offset-based pagination can degrade performance with very + large skip values (e.g., skip > 10000). For sequential iteration + over large datasets, consider list_files() instead. + + Args: + name: Bucket name + key: Bucket identifier + prefix: Directory path to filter files (default: root) + recursive: Recurse subdirectories for flat view (default: False) + file_name_glob: File filter pattern (e.g., "*.pdf", "data_*.csv") + skip: Number of files to skip (default 0, max 10000). Used for pagination. + top: Maximum number of files to return (default 500, max 1000). + folder_key: Folder key + folder_path: Folder path + + Returns: + PagedResult[BucketFile]: Page containing files (directories excluded) and pagination metadata + + Raises: + ValueError: If skip < 0, skip > 10000, top < 1, top > 1000, neither name nor key is provided, or file_name_glob is empty + LookupError: If bucket not found + + Examples: + >>> # Get first page + >>> result = sdk.buckets.get_files(name="my-storage") + >>> for file in result.items: + ... print(file.name) + >>> + >>> # Filter with glob pattern + >>> result = sdk.buckets.get_files( + ... name="my-storage", + ... recursive=True, + ... file_name_glob="*.pdf" + ... ) + >>> + >>> # Manual offset-based pagination + >>> skip = 0 + >>> top = 500 + >>> all_files = [] + >>> while True: + ... result = sdk.buckets.get_files( + ... name="my-storage", + ... prefix="reports/", + ... skip=skip, + ... top=top + ... ) + ... all_files.extend(result.items) + ... if not result.has_more: + ... break + ... skip += top + >>> + >>> # Helper function + >>> def iter_all_files_odata(sdk, bucket_name, **filters): + ... skip = 0 + ... top = 500 + ... while True: + ... result = sdk.buckets.get_files( + ... name=bucket_name, + ... skip=skip, + ... top=top, + ... **filters + ... ) + ... yield from result.items + ... if not result.has_more: + ... break + ... skip += top + >>> + >>> # Usage with filters + >>> for file in iter_all_files_odata( + ... sdk, + ... "my-storage", + ... recursive=True, + ... file_name_glob="*.pdf" + ... ): + ... process_file(file) + + Performance: + Best for: Filtered queries, random access, sorted results. + Consider list_files() for: Sequential iteration over large datasets. + + Performance degrades with large skip values due to database offset costs. + """ + if skip < 0: + raise ValueError("skip must be >= 0") + if skip > MAX_SKIP_OFFSET: + raise ValueError( + f"skip must be <= {MAX_SKIP_OFFSET} (requested: {skip}). " + f"For large datasets, use list_files() with continuation tokens instead of offset-based pagination." + ) + if top < 1: + raise ValueError("top must be >= 1") + if top > MAX_PAGE_SIZE: + raise ValueError( + f"top must be <= {MAX_PAGE_SIZE} (requested: {top}). " + f"Use pagination with skip and top parameters to retrieve larger datasets." + ) + + if not (name or key): + raise ValueError("Must specify either bucket name or key") + + if file_name_glob is not None and not file_name_glob.strip(): + raise ValueError("file_name_glob cannot be empty") + + bucket = self.retrieve( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + + spec = self._get_files_spec( + bucket.id, + prefix=prefix, + recursive=recursive, + file_name_glob=file_name_glob, + skip=skip, + top=top, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("value", []) + + files = [] + for item in items: + if not item.get("IsDirectory", False): + try: + files.append(BucketFile.model_validate(item)) + except Exception as e: + raise ValueError( + f"Failed to parse file entry: {e}. Item: {item}" + ) from e + + return PagedResult( + items=files, + has_more=len(items) == top, # Raw count, not len(files) + skip=skip, + top=top, + ) + + @resource_override(resource_type="bucket") + @traced(name="buckets_get_files", run_type="uipath") + async def get_files_async( + self, + *, + name: Optional[str] = None, + key: Optional[str] = None, + prefix: str = "", + recursive: bool = False, + file_name_glob: Optional[str] = None, + skip: int = 0, + top: int = 500, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> PagedResult[BucketFile]: + """Async version of get_files() with offset-based pagination. + + Returns a single page of results with pagination metadata. + Automatically excludes directories from results. + + Args: + name: Bucket name + key: Bucket identifier + prefix: Directory path to filter files + recursive: Recurse subdirectories for flat view + file_name_glob: File filter pattern (e.g., "*.pdf") + skip: Number of files to skip (default 0, max 10000) + top: Maximum number of files to return (default 500, max 1000) + folder_key: Folder key + folder_path: Folder path + + Returns: + PagedResult[BucketFile]: Page containing files (directories excluded) and pagination metadata + + Raises: + ValueError: If skip < 0, skip > 10000, top < 1, top > 1000, neither name nor key is provided, or file_name_glob is empty + LookupError: If bucket not found + + Examples: + >>> # Get first page + >>> result = await sdk.buckets.get_files_async( + ... name="my-storage", + ... recursive=True, + ... file_name_glob="*.pdf" + ... ) + >>> for file in result.items: + ... print(file.name) + >>> + >>> # Manual pagination + >>> skip = 0 + >>> top = 500 + >>> all_files = [] + >>> while True: + ... result = await sdk.buckets.get_files_async( + ... name="my-storage", + ... skip=skip, + ... top=top + ... ) + ... all_files.extend(result.items) + ... if not result.has_more: + ... break + ... skip += top + """ + if skip < 0: + raise ValueError("skip must be >= 0") + if skip > MAX_SKIP_OFFSET: + raise ValueError( + f"skip must be <= {MAX_SKIP_OFFSET} (requested: {skip}). " + f"For large datasets, use list_files() with continuation tokens instead of offset-based pagination." + ) + if top < 1: + raise ValueError("top must be >= 1") + if top > MAX_PAGE_SIZE: + raise ValueError( + f"top must be <= {MAX_PAGE_SIZE} (requested: {top}). " + f"Use pagination with skip and top parameters to retrieve larger datasets." + ) + + if not (name or key): + raise ValueError("Must specify either bucket name or key") + + if file_name_glob is not None and not file_name_glob.strip(): + raise ValueError("file_name_glob cannot be empty") + + bucket = await self.retrieve_async( + name=name, key=key, folder_key=folder_key, folder_path=folder_path + ) + + spec = self._get_files_spec( + bucket.id, + prefix=prefix, + recursive=recursive, + file_name_glob=file_name_glob, + skip=skip, + top=top, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("value", []) + + files = [] + for item in items: + if not item.get("IsDirectory", False): + try: + files.append(BucketFile.model_validate(item)) + except Exception as e: + raise ValueError( + f"Failed to parse file entry: {e}. Item: {item}" + ) from e + + return PagedResult( + items=files, + has_more=len(items) == top, # Raw count, not len(files) + skip=skip, + top=top, + ) + + @property + def custom_headers(self) -> Dict[str, str]: + return self.folder_headers + + def _list_spec( + self, + folder_path: Optional[str], + folder_key: Optional[str], + name: Optional[str], + skip: int, + top: int, + ) -> RequestSpec: + """Build OData request for listing buckets.""" + filters = [] + if name: + escaped_name = name.replace("'", "''") + filters.append(f"contains(tolower(Name), tolower('{escaped_name}'))") + + filter_str = " and ".join(filters) if filters else None + + params: Dict[str, Any] = {"$skip": skip, "$top": top} + if filter_str: + params["$filter"] = filter_str + + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/Buckets"), + params=params, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _create_spec( + self, + name: str, + description: Optional[str], + identifier: str, + folder_path: Optional[str], + folder_key: Optional[str], + ) -> RequestSpec: + """Build request for creating bucket.""" + body = { + "Name": name, + "Identifier": identifier, + } + if description: + body["Description"] = description + + return RequestSpec( + method="POST", + endpoint=Endpoint("/orchestrator_/odata/Buckets"), + json=body, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _retrieve_spec( + self, + name: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + escaped_name = name.replace("'", "''") + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/Buckets"), + params={"$filter": f"Name eq '{escaped_name}'", "$top": 1}, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _retrieve_readUri_spec( + self, + bucket_id: int, + blob_file_path: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"/orchestrator_/odata/Buckets({bucket_id})/UiPath.Server.Configuration.OData.GetReadUri" + ), + params={"path": blob_file_path}, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _retrieve_writeri_spec( + self, + bucket_id: int, + content_type: str, + blob_file_path: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"/orchestrator_/odata/Buckets({bucket_id})/UiPath.Server.Configuration.OData.GetWriteUri" + ), + params={"path": blob_file_path, "contentType": content_type}, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _retrieve_by_key_spec( + self, + key: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + escaped_key = key.replace("'", "''") + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='{escaped_key}')" + ), + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _list_files_spec( + self, + bucket_id: int, + prefix: str, + continuation_token: Optional[str] = None, + take_hint: int = 500, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + """Build REST API request for listing files in a bucket. + + Uses the /api/Buckets/{id}/ListFiles endpoint which supports cursor-based pagination. + + Args: + bucket_id: The bucket ID + prefix: Path prefix for filtering + continuation_token: Token for pagination + take_hint: Minimum number of files to return (default 500, max 1000) + folder_key: Folder key + folder_path: Folder path + """ + params: Dict[str, Any] = {} + if prefix: + params["prefix"] = prefix + if continuation_token is not None: + params["continuationToken"] = continuation_token + params["takeHint"] = take_hint + + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/api/Buckets/{bucket_id}/ListFiles"), + params=params, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _delete_file_spec( + self, + bucket_id: int, + blob_file_path: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + """Build request for deleting a file from a bucket.""" + return RequestSpec( + method="DELETE", + endpoint=Endpoint( + f"/orchestrator_/odata/Buckets({bucket_id})/UiPath.Server.Configuration.OData.DeleteFile" + ), + params={"path": blob_file_path}, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _get_files_spec( + self, + bucket_id: int, + prefix: str = "", + recursive: bool = False, + file_name_glob: Optional[str] = None, + skip: int = 0, + top: int = 500, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + """Build OData request for GetFiles endpoint. + + Args: + bucket_id: Bucket ID + prefix: Directory path prefix + recursive: Recurse subdirectories + file_name_glob: File name filter pattern + skip: Number of items to skip (pagination) + top: Number of items to return (pagination) + folder_key: Folder key + folder_path: Folder path + + Returns: + RequestSpec: OData request specification + """ + params: Dict[str, Any] = {} + + params["directory"] = "/" if not prefix else prefix + + if recursive: + params["recursive"] = "true" + + if file_name_glob: + params["fileNameGlob"] = file_name_glob + + if skip > 0: + params["$skip"] = skip + params["$top"] = top + + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"/orchestrator_/odata/Buckets({bucket_id})/UiPath.Server.Configuration.OData.GetFiles" + ), + params=params, + headers={ + **header_folder(folder_key, folder_path), + }, + ) diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_folder_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_folder_service.py new file mode 100644 index 000000000..84c5abdb3 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_folder_service.py @@ -0,0 +1,222 @@ +from typing import Optional + +from typing_extensions import deprecated +from uipath.core import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._models import Endpoint, RequestSpec +from ..errors import FolderNotFoundException +from .folder import PersonalWorkspace + + +class FolderService(BaseService): + """Service for managing UiPath Folders. + + A folder represents a single area for data organization + and access control - it is created when you need to categorize, manage, and enforce authorization rules for a group + of UiPath resources (i.e. processes, assets, connections, storage buckets etc.) or other folders + """ + + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + super().__init__(config=config, execution_context=execution_context) + + def retrieve_folder_key(self, folder_path: str | None) -> str | None: + """Resolve a folder path to its corresponding folder key. + + Args: + folder_path: Folder path to resolve to a key + + Returns: + The resolved folder key + + Raises: + ValueError: If folder_path is None or if folder_path is not found + """ + if folder_path is None: + raise ValueError("Cannot obtain folder_key without providing folder_path") + + resolved_folder_key = self.retrieve_key(folder_path=folder_path) + if not resolved_folder_key: + raise FolderNotFoundException(folder_path) + return resolved_folder_key + + async def retrieve_folder_key_async(self, folder_path: str | None) -> str | None: + """Asynchronously resolve a folder path to its corresponding folder key. + + Args: + folder_path: Folder path to resolve to a key + + Returns: + The resolved folder key + + Raises: + ValueError: If folder_path is None or if folder_path is not found + """ + if folder_path is None: + raise ValueError("Cannot obtain folder_key without providing folder_path") + + resolved_folder_key = await self.retrieve_key_async(folder_path=folder_path) + if not resolved_folder_key: + raise FolderNotFoundException(folder_path) + return resolved_folder_key + + @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]: + return self.retrieve_key(folder_path=folder_path) + + @traced(name="folder_retrieve_key", run_type="uipath") + def retrieve_key(self, *, folder_path: str) -> Optional[str]: + """Retrieve the folder key by folder path with pagination support. + + Args: + folder_path: The fully qualified folder path to search for. + + Returns: + The folder key if found, None otherwise. + """ + skip = 0 + take = 20 + + while True: + spec = self._retrieve_spec(folder_path, skip=skip, take=take) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + ).json() + + # Search for the folder in current page + folder_key = next( + ( + item["Key"] + for item in response["PageItems"] + if item["FullyQualifiedName"] == folder_path + ), + None, + ) + + if folder_key is not None: + return folder_key + + page_items = response["PageItems"] + if len(page_items) < take: + break + + skip += take + + return None + + @traced(name="folder_retrieve_key", run_type="uipath") + async def retrieve_key_async(self, *, folder_path: str) -> Optional[str]: + """Retrieve the folder key by folder path with pagination support. + + Args: + folder_path: The fully qualified folder path to search for. + + Returns: + The folder key if found, None otherwise. + """ + skip = 0 + take = 20 + + while True: + spec = self._retrieve_spec(folder_path, skip=skip, take=take) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + ) + ).json() + + # Search for the folder in current page + folder_key = next( + ( + item["Key"] + for item in response["PageItems"] + if item["FullyQualifiedName"] == folder_path + ), + None, + ) + + if folder_key is not None: + return folder_key + + page_items = response["PageItems"] + if len(page_items) < take: + break + + skip += take + + return None + + def _retrieve_spec( + self, folder_path: str, *, skip: int = 0, take: int = 20 + ) -> RequestSpec: + folder_name = folder_path.split("/")[-1] + return RequestSpec( + method="GET", + endpoint=Endpoint( + "orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser" + ), + params={ + "searchText": folder_name, + "skip": skip, + "take": take, + }, + ) + + @traced(name="folder_get_personal_workspace", run_type="uipath") + def get_personal_workspace(self) -> PersonalWorkspace: + """Retrieve the personal workspace folder for the current user. + + Returns: + PersonalWorkspace: The personal workspace information. + + Raises: + ValueError: If the user does not have a personal workspace. + """ + response = self.request( + "GET", + url=Endpoint( + "orchestrator_/odata/Users/UiPath.Server.Configuration.OData.GetCurrentUserExtended" + ), + params={"$select": "PersonalWorkspace", "$expand": "PersonalWorkspace"}, + ).json() + + personal_workspace = response.get("PersonalWorkspace") + if personal_workspace is None: + raise ValueError("Failed to fetch personal workspace") + + return PersonalWorkspace.model_validate(personal_workspace) + + @traced(name="folder_get_personal_workspace_async", run_type="uipath") + async def get_personal_workspace_async(self) -> PersonalWorkspace: + """Asynchronously retrieve the personal workspace folder for the current user. + + Returns: + PersonalWorkspace: The personal workspace information. + + Raises: + ValueError: If the personal workspace cannot be fetched. + """ + response = ( + await self.request_async( + "GET", + url=Endpoint( + "orchestrator_/odata/Users/UiPath.Server.Configuration.OData.GetCurrentUserExtended" + ), + params={"$select": "PersonalWorkspace", "$expand": "PersonalWorkspace"}, + ) + ).json() + + personal_workspace = response.get("PersonalWorkspace") + if personal_workspace is None: + raise ValueError("Failed to fetch personal workspace") + + return PersonalWorkspace.model_validate(personal_workspace) diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py new file mode 100644 index 000000000..f9433d221 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_jobs_service.py @@ -0,0 +1,1490 @@ +import os +import shutil +import tempfile +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional, Union, cast, overload + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext, header_folder +from ..common._models import Endpoint, RequestSpec +from ..common.constants import TEMP_ATTACHMENTS_FOLDER +from ..common.paging import PagedResult +from ..common.validation import validate_pagination_params +from ..errors import EnrichedException +from ._attachments_service import AttachmentsService +from .job import Job + + +class JobsService(FolderContext, BaseService): + """Service for managing API payloads and job inbox interactions. + + A job represents a single execution of an automation - it is created when you start + a process and contains information about that specific run, including its status, + start time, and any input/output data. + """ + + # Pagination limits + MAX_PAGE_SIZE = 1000 # Maximum items per page + MAX_SKIP_OFFSET = 10000 # Maximum skip offset + + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._attachments_service = AttachmentsService(config, execution_context) + # Define the temp directory path for local attachments + self._temp_dir = os.path.join(tempfile.gettempdir(), TEMP_ATTACHMENTS_FOLDER) + os.makedirs(self._temp_dir, exist_ok=True) + + @overload + def resume(self, *, inbox_id: str, payload: Any) -> None: ... + + @overload + def resume(self, *, job_id: str, payload: Any) -> None: ... + + @traced(name="jobs_resume", run_type="uipath") + def resume( + self, + *, + inbox_id: Optional[str] = None, + job_id: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + payload: Any, + ) -> None: + """Sends a payload to resume a paused job waiting for input, identified by its inbox ID. + + Args: + inbox_id (Optional[str]): The inbox ID of the job. + job_id (Optional[str]): The job ID of the job. + folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. + payload (Any): The payload to deliver. + """ + if job_id is None and inbox_id is None: + raise ValueError("Either job_id or inbox_id must be provided") + + # for type checking + job_id = str(job_id) + inbox_id = ( + inbox_id + if inbox_id + else self._retrieve_inbox_id( + job_id=job_id, + folder_key=folder_key, + folder_path=folder_path, + ) + ) + spec = self._resume_spec( + inbox_id=inbox_id, + payload=payload, + folder_key=folder_key, + folder_path=folder_path, + ) + self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + json=spec.json, + ) + + async def resume_async( + self, + *, + inbox_id: Optional[str] = None, + job_id: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + payload: Any, + ) -> None: + """Asynchronously sends a payload to resume a paused job waiting for input, identified by its inbox ID. + + Args: + inbox_id (Optional[str]): The inbox ID of the job. If not provided, the execution context will be used to retrieve the inbox ID. + job_id (Optional[str]): The job ID of the job. + folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. + payload (Any): The payload to deliver. + + Examples: + ```python + import asyncio + + from uipath.platform import UiPath + + sdk = UiPath() + + + async def main(): # noqa: D103 + payload = await sdk.jobs.resume_async(job_id="38073051", payload="The response") + + asyncio.run(main()) + ``` + """ + if job_id is None and inbox_id is None: + raise ValueError("Either job_id or inbox_id must be provided") + + # for type checking + job_id = str(job_id) + inbox_id = ( + inbox_id + if inbox_id + else self._retrieve_inbox_id( + job_id=job_id, + folder_key=folder_key, + folder_path=folder_path, + ) + ) + + spec = self._resume_spec( + inbox_id=inbox_id, + payload=payload, + folder_key=folder_key, + folder_path=folder_path, + ) + await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + json=spec.json, + ) + + @property + def custom_headers(self) -> Dict[str, str]: + return self.folder_headers + + @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, + skip: int = 0, + top: int = 100, + ) -> PagedResult[Job]: + """List jobs using OData API with offset-based pagination. + + Returns a single page of results with pagination metadata. + + Args: + folder_path: Folder path to filter jobs + folder_key: Folder key (mutually exclusive with folder_path) + filter: OData $filter expression (e.g., "State eq 'Successful'") + orderby: OData $orderby expression (e.g., "CreationTime desc") + skip: Number of items to skip (default 0, max 10000) + top: Maximum items per page (default 100, max 1000) + + Returns: + PagedResult[Job]: Page of jobs with pagination metadata + + Raises: + ValueError: If skip or top parameters are invalid + + Examples: + >>> result = sdk.jobs.list(top=100) + >>> for job in result.items: + ... print(job.key, job.state) + >>> print(f"Has more: {result.has_more}") + """ + validate_pagination_params( + skip=skip, + top=top, + max_skip=self.MAX_SKIP_OFFSET, + max_top=self.MAX_PAGE_SIZE, + ) + + spec = self._list_spec( + folder_path=folder_path, + folder_key=folder_key, + filter=filter, + orderby=orderby, + skip=skip, + top=top, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("value", []) + jobs = [Job.model_validate(item) for item in items] + + return PagedResult( + items=jobs, + has_more=len(items) == top, + skip=skip, + top=top, + ) + + @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, + skip: int = 0, + top: int = 100, + ) -> PagedResult[Job]: + """Async version of list() with offset-based pagination.""" + validate_pagination_params( + skip=skip, + top=top, + max_skip=self.MAX_SKIP_OFFSET, + max_top=self.MAX_PAGE_SIZE, + ) + + spec = self._list_spec( + folder_path=folder_path, + folder_key=folder_key, + filter=filter, + orderby=orderby, + skip=skip, + top=top, + ) + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("value", []) + jobs = [Job.model_validate(item) for item in items] + + return PagedResult( + items=jobs, + has_more=len(items) == top, + skip=skip, + top=top, + ) + + @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. + + This method uses bulk resolution to efficiently stop multiple jobs, + preventing N+1 query issues. Requests are automatically chunked for + large job lists to avoid URL length constraints. + + 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 + + Raises: + ValueError: If any job key is not a valid UUID format + LookupError: If any job keys are not found + + Examples: + >>> sdk.jobs.stop(job_keys=["ee9327fd-237d-419e-86ef-9946b34461e3"]) + >>> sdk.jobs.stop(job_keys=["key1", "key2"], strategy="Kill") + + Note: + Large batches are automatically chunked (50 keys per request) to + avoid URL length limits. The method supports stopping hundreds of + jobs efficiently. + """ + job_ids = self._resolve_job_identifiers( + job_keys=job_keys, + folder_key=folder_key, + folder_path=folder_path, + ) + + spec = self._stop_spec( + job_ids=job_ids, + 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().""" + job_ids = await self._resolve_job_identifiers_async( + job_keys=job_keys, + folder_key=folder_key, + folder_path=folder_path, + ) + + spec = self._stop_spec( + job_ids=job_ids, + 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_restart", run_type="uipath") + def restart( + self, + *, + job_key: str, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> Job: + """Restart a completed or failed job. + + Args: + job_key: Job UUID key to restart + folder_path: Folder path + folder_key: Folder key + + Returns: + Job: The restarted job + + Examples: + >>> restarted_job = sdk.jobs.restart(job_key="ee9327fd-237d-419e-86ef-9946b34461e3") + >>> print(restarted_job.state) + """ + job_ids = self._resolve_job_identifiers( + job_keys=[job_key], + folder_key=folder_key, + folder_path=folder_path, + ) + + spec = self._restart_spec( + job_id=job_ids[0], + folder_key=folder_key, + folder_path=folder_path, + ) + + response = self.request( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + return Job.model_validate(response.json()) + + @traced(name="jobs_restart", run_type="uipath") + async def restart_async( + self, + *, + job_key: str, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + ) -> Job: + """Async version of restart().""" + job_ids = await self._resolve_job_identifiers_async( + job_keys=[job_key], + folder_key=folder_key, + folder_path=folder_path, + ) + + spec = self._restart_spec( + job_id=job_ids[0], + folder_key=folder_key, + folder_path=folder_path, + ) + + response = await self.request_async( + spec.method, + url=spec.endpoint, + json=spec.json, + headers=spec.headers, + ) + + return Job.model_validate(response.json()) + + @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 UUID key + folder_key: Folder key + folder_path: Folder path + + Returns: + bool: True if job exists, False otherwise + + 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 + + @resource_override(resource_type="process", resource_identifier="process_name") + @traced(name="jobs_retrieve", run_type="uipath") + def retrieve( + self, + job_key: str, + *, + folder_key: str | None = None, + folder_path: str | None = None, + process_name: str | None = None, + ) -> Job: + """Retrieve a job identified by its 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. + process_name: process name hint for resource override + + Returns: + Job: The retrieved job. + + Raises: + LookupError: If the job with the specified key is not found. + + Examples: + ```python + from uipath.platform import UiPath + + sdk = UiPath() + job = sdk.jobs.retrieve(job_key="ee9327fd-237d-419e-86ef-9946b34461e3", folder_path="Shared") + ``` + """ + spec = self._retrieve_spec( + job_key=job_key, folder_key=folder_key, folder_path=folder_path + ) + 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 + + @resource_override(resource_type="process", resource_identifier="process_name") + @traced(name="jobs_retrieve_async", run_type="uipath") + async def retrieve_async( + self, + job_key: str, + *, + folder_key: str | None = None, + folder_path: str | None = None, + process_name: str | None = None, + ) -> Job: + """Asynchronously retrieve a job identified by its 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. + process_name: process name hint for resource override + + Returns: + Job: The retrieved job. + + Raises: + LookupError: If the job with the specified key is not found. + + Examples: + ```python + import asyncio + + from uipath.platform 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()) + ``` + """ + spec = self._retrieve_spec( + job_key=job_key, folder_key=folder_key, folder_path=folder_path + ) + 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, + *, + job_id: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> str: + spec = self._retrieve_inbox_id_spec( + job_id=job_id, + folder_key=folder_key, + folder_path=folder_path, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + + response = response.json() + return self._extract_first_inbox_id(response) + + async def _retrieve_inbox_id_async( + self, + *, + job_id: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> str: + spec = self._retrieve_inbox_id_spec( + job_id=job_id, + folder_key=folder_key, + folder_path=folder_path, + ) + response = await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + + response = response.json() + return self._extract_first_inbox_id(response) + + def retrieve_api_payload(self, inbox_id: str) -> Any: + """Fetch payload data for API triggers. + + Args: + inbox_id: The Id of the inbox to fetch the payload for. + + Returns: + The value field from the API response payload. + """ + spec = self._retrieve_api_payload_spec(inbox_id=inbox_id) + + response = self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + + data = response.json() + return data.get("payload") + + async def retrieve_api_payload_async(self, inbox_id: str) -> Any: + """Asynchronously fetch payload data for API triggers. + + Args: + inbox_id: The Id of the inbox to fetch the payload for. + + Returns: + The value field from the API response payload. + """ + spec = self._retrieve_api_payload_spec(inbox_id=inbox_id) + + response = await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + ) + + data = response.json() + return data.get("payload") + + def _retrieve_api_payload_spec( + self, + *, + inbox_id: str, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}"), + headers={ + **self.folder_headers, + }, + ) + + def _extract_first_inbox_id(self, response: Any) -> str: + if len(response["value"]) > 0: + return response["value"][0]["ItemKey"] + else: + raise LookupError("No inbox found") + + def _retrieve_inbox_id_spec( + self, + *, + job_id: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/JobTriggers"), + params={ + "$filter": f"JobId eq {job_id}", + "$top": 1, + "$select": "ItemKey", + }, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def extract_output(self, job: Job) -> Optional[str]: + """Get the actual output data, downloading from attachment if necessary. + + Args: + job: The job instance to fetch output data from. + + Returns: + Parsed output arguments as dictionary, or None if no output + """ + if job.output_file: + # Large output stored as attachment + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) / f"output_{job.output_file}" + self._attachments_service.download( + key=uuid.UUID(job.output_file), destination_path=temp_path + ) + with open(temp_path, "r", encoding="utf-8") as f: + return f.read() + elif job.output_arguments: + # Small output stored inline + return job.output_arguments + else: + return None + + async def extract_output_async(self, job: Job) -> Optional[str]: + """Asynchronously fetch the actual output data, downloading from attachment if necessary. + + Args: + job: The job instance to fetch output data from. + + Returns: + Parsed output arguments as dictionary, or None if no output + """ + if job.output_file: + # Large output stored as attachment + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) / f"output_{job.output_file}" + await self._attachments_service.download_async( + key=uuid.UUID(job.output_file), destination_path=temp_path + ) + with open(temp_path, "r", encoding="utf-8") as f: + return f.read() + elif job.output_arguments: + # Small output stored inline + return job.output_arguments + else: + return None + + def _resume_spec( + self, + *, + inbox_id: str, + payload: Any = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}" + ), + json={"payload": payload}, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _retrieve_spec( + self, + *, + job_key: str, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint( + f"/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})" + ), + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + @traced(name="jobs_list_attachments", run_type="uipath") + def list_attachments( + self, + *, + job_key: uuid.UUID, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> List[str]: + """List attachments associated with a specific job. + + This method retrieves all attachments linked to a job by its key. + + Args: + job_key (uuid.UUID): The key of the job to retrieve attachments for. + folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. + + Returns: + List[str]: A list of attachment IDs associated with the job. + + Raises: + Exception: If the retrieval fails. + """ + spec = self._list_job_attachments_spec( + job_key=job_key, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + return [item.get("attachmentId") for item in response] + + @traced(name="jobs_list_attachments", run_type="uipath") + async def list_attachments_async( + self, + *, + job_key: uuid.UUID, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> List[str]: + """List attachments associated with a specific job asynchronously. + + This method asynchronously retrieves all attachments linked to a job by its key. + + Args: + job_key (uuid.UUID): The key of the job to retrieve attachments for. + folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. + + Returns: + List[str]: A list of attachment IDs associated with the job. + + Raises: + Exception: If the retrieval fails. + + Examples: + ```python + import asyncio + from uipath.platform import UiPath + + client = UiPath() + + async def main(): + attachments = await client.jobs.list_attachments_async( + job_key=uuid.UUID("123e4567-e89b-12d3-a456-426614174000") + ) + for attachment_id in attachments: + print(f"Attachment ID: {attachment_id}") + ``` + """ + spec = self._list_job_attachments_spec( + job_key=job_key, + folder_key=folder_key, + folder_path=folder_path, + ) + + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + return [item.get("attachmentId") for item in response] + + @traced(name="jobs_link_attachment", run_type="uipath") + def link_attachment( + self, + *, + attachment_key: uuid.UUID, + job_key: uuid.UUID, + category: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ): + """Link an attachment to a job. + + This method links an existing attachment to a specific job. + + Args: + attachment_key (uuid.UUID): The key of the attachment to link. + job_key (uuid.UUID): The key of the job to link the attachment to. + category (Optional[str]): Optional category for the attachment in the context of this job. + folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. + + Raises: + Exception: If the link operation fails. + """ + spec = self._link_job_attachment_spec( + attachment_key=attachment_key, + job_key=job_key, + category=category, + folder_key=folder_key, + folder_path=folder_path, + ) + self.request( + spec.method, + url=spec.endpoint, + headers=spec.headers, + json=spec.json, + ) + + @traced(name="jobs_link_attachment", run_type="uipath") + async def link_attachment_async( + self, + *, + attachment_key: uuid.UUID, + job_key: uuid.UUID, + category: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ): + """Link an attachment to a job asynchronously. + + This method asynchronously links an existing attachment to a specific job. + + Args: + attachment_key (uuid.UUID): The key of the attachment to link. + job_key (uuid.UUID): The key of the job to link the attachment to. + category (Optional[str]): Optional category for the attachment in the context of this job. + folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. + + Raises: + Exception: If the link operation fails. + """ + spec = self._link_job_attachment_spec( + attachment_key=attachment_key, + job_key=job_key, + category=category, + folder_key=folder_key, + folder_path=folder_path, + ) + await self.request_async( + spec.method, + url=spec.endpoint, + headers=spec.headers, + json=spec.json, + ) + + def _list_job_attachments_spec( + self, + job_key: uuid.UUID, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/api/JobAttachments/GetByJobKey"), + params={ + "jobKey": job_key, + }, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + def _link_job_attachment_spec( + self, + attachment_key: uuid.UUID, + job_key: uuid.UUID, + category: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint("/orchestrator_/api/JobAttachments/Post"), + json={ + "attachmentId": str(attachment_key), + "jobKey": str(job_key), + "category": category, + }, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + + @traced(name="jobs_create_attachment", run_type="uipath") + def create_attachment( + self, + *, + name: str, + content: Optional[Union[str, bytes]] = None, + source_path: Optional[Union[str, Path]] = None, + job_key: Optional[Union[str, uuid.UUID]] = None, + category: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> uuid.UUID: + """Create and upload an attachment, optionally linking it to a job. + + This method handles creating an attachment from a file or memory data. + If a job key is provided or available in the execution context, the attachment + will be created in UiPath and linked to the job. If no job is available, + the file will be saved to a temporary storage folder. + + Note: + The local storage functionality (when no job is available) is intended for + local development and debugging purposes only. + + Args: + name (str): The name of the attachment file. + content (Optional[Union[str, bytes]]): The content to upload (string or bytes). + source_path (Optional[Union[str, Path]]): The local path of the file to upload. + job_key (Optional[Union[str, uuid.UUID]]): The key of the job to link the attachment to. + category (Optional[str]): Optional category for the attachment in the context of the job. + folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. + + Returns: + uuid.UUID: The unique identifier for the created attachment, regardless of whether it was + uploaded to UiPath or stored locally. + + Raises: + ValueError: If neither content nor source_path is provided, or if both are provided. + Exception: If the upload fails. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + + # Create attachment from file and link to job + attachment_id = client.jobs.create_attachment( + name="document.pdf", + source_path="path/to/local/document.pdf", + job_key="38073051" + ) + print(f"Created and linked attachment: {attachment_id}") + + # Create attachment from memory content (no job available - saves to temp storage) + attachment_id = client.jobs.create_attachment( + name="report.txt", + content="This is a text report" + ) + print(f"Created attachment: {attachment_id}") + ``` + """ + # Validate input parameters + if not (content or source_path): + raise ValueError("Content or source_path is required") + if content and source_path: + raise ValueError("Content and source_path are mutually exclusive") + + # Get job key from context if not explicitly provided + context_job_key = None + if job_key is None: + try: + context_job_key = self._execution_context.instance_key + except ValueError: + # Instance key is not set in environment + context_job_key = None + + # Check if a job is available + if job_key is not None or context_job_key is not None: + # Job is available - create attachment in UiPath and link to job + actual_job_key = job_key if job_key is not None else context_job_key + + # Create the attachment using the attachments service + if content is not None: + attachment_key = self._attachments_service.upload( + name=name, + content=content, + folder_key=folder_key, + folder_path=folder_path, + ) + else: + # source_path must be provided due to validation check above + attachment_key = self._attachments_service.upload( + name=name, + source_path=cast(str, source_path), + folder_key=folder_key, + folder_path=folder_path, + ) + + # Convert to UUID if string + if isinstance(actual_job_key, str): + actual_job_key = uuid.UUID(actual_job_key) + + # Link attachment to job + self.link_attachment( + attachment_key=attachment_key, + job_key=cast(uuid.UUID, actual_job_key), + category=category, + folder_key=folder_key, + folder_path=folder_path, + ) + + return attachment_key + else: + # No job available - save to temp folder + # Generate a UUID to use as identifier + attachment_id = uuid.uuid4() + + # Create destination file path + dest_path = os.path.join(self._temp_dir, f"{attachment_id}_{name}") + + # If we have source_path, copy the file + if source_path is not None: + source_path_str = ( + source_path if isinstance(source_path, str) else str(source_path) + ) + shutil.copy2(source_path_str, dest_path) + # If we have content, write it to a file + elif content is not None: + # Convert string to bytes if needed + if isinstance(content, str): + content = content.encode("utf-8") + + with open(dest_path, "wb") as f: + f.write(content) + + # Return only the UUID + return attachment_id + + @traced(name="jobs_create_attachment", run_type="uipath") + async def create_attachment_async( + self, + *, + name: str, + content: Optional[Union[str, bytes]] = None, + source_path: Optional[Union[str, Path]] = None, + job_key: Optional[Union[str, uuid.UUID]] = None, + category: Optional[str] = None, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> uuid.UUID: + """Create and upload an attachment asynchronously, optionally linking it to a job. + + This method asynchronously handles creating an attachment from a file or memory data. + If a job key is provided or available in the execution context, the attachment + will be created in UiPath and linked to the job. If no job is available, + the file will be saved to a temporary storage folder. + + Note: + The local storage functionality (when no job is available) is intended for + local development and debugging purposes only. + + Args: + name (str): The name of the attachment file. + content (Optional[Union[str, bytes]]): The content to upload (string or bytes). + source_path (Optional[Union[str, Path]]): The local path of the file to upload. + job_key (Optional[Union[str, uuid.UUID]]): The key of the job to link the attachment to. + category (Optional[str]): Optional category for the attachment in the context of the job. + folder_key (Optional[str]): The key of the folder. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder. Override the default one set in the SDK config. + + Returns: + uuid.UUID: The unique identifier for the created attachment, regardless of whether it was + uploaded to UiPath or stored locally. + + Raises: + ValueError: If neither content nor source_path is provided, or if both are provided. + Exception: If the upload fails. + + Examples: + ```python + import asyncio + from uipath.platform import UiPath + + client = UiPath() + + async def main(): + # Create attachment from file and link to job + attachment_id = await client.jobs.create_attachment_async( + name="document.pdf", + source_path="path/to/local/document.pdf", + job_key="38073051" + ) + print(f"Created and linked attachment: {attachment_id}") + + # Create attachment from memory content (no job available - saves to temp storage) + attachment_id = await client.jobs.create_attachment_async( + name="report.txt", + content="This is a text report" + ) + print(f"Created attachment: {attachment_id}") + ``` + """ + # Validate input parameters + if not (content or source_path): + raise ValueError("Content or source_path is required") + if content and source_path: + raise ValueError("Content and source_path are mutually exclusive") + + # Get job key from context if not explicitly provided + context_job_key = None + if job_key is None: + try: + context_job_key = self._execution_context.instance_key + except ValueError: + # Instance key is not set in environment + context_job_key = None + + # Check if a job is available + if job_key is not None or context_job_key is not None: + # Job is available - create attachment in UiPath and link to job + actual_job_key = job_key if job_key is not None else context_job_key + + # Create the attachment using the attachments service + if content is not None: + attachment_key = await self._attachments_service.upload_async( + name=name, + content=content, + folder_key=folder_key, + folder_path=folder_path, + ) + else: + # source_path must be provided due to validation check above + attachment_key = await self._attachments_service.upload_async( + name=name, + source_path=cast(str, source_path), + folder_key=folder_key, + folder_path=folder_path, + ) + + # Convert to UUID if string + if isinstance(actual_job_key, str): + actual_job_key = uuid.UUID(actual_job_key) + + # Link attachment to job + await self.link_attachment_async( + attachment_key=attachment_key, + job_key=cast(uuid.UUID, actual_job_key), + category=category, + folder_key=folder_key, + folder_path=folder_path, + ) + + return attachment_key + else: + # No job available - save to temp folder + # Generate a UUID to use as identifier + attachment_id = uuid.uuid4() + + # Create destination file path + dest_path = os.path.join(self._temp_dir, f"{attachment_id}_{name}") + + # If we have source_path, copy the file + if source_path is not None: + source_path_str = ( + source_path if isinstance(source_path, str) else str(source_path) + ) + shutil.copy2(source_path_str, dest_path) + # If we have content, write it to a file + elif content is not None: + # Convert string to bytes if needed + if isinstance(content, str): + content = content.encode("utf-8") + + with open(dest_path, "wb") as f: + f.write(content) + + # Return only the UUID + return attachment_id + + 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_ids: List[int], + strategy: str, + folder_key: Optional[str], + folder_path: Optional[str], + ) -> RequestSpec: + """Build request for stopping jobs with strategy. + + Pure function - no HTTP calls, no side effects. + + Args: + job_ids: List of job integer IDs (already resolved) + strategy: Stop strategy ("SoftStop" or "Kill") + folder_key: Folder key + folder_path: Folder path + + Returns: + RequestSpec: Request specification for StopJobs endpoint + """ + 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)}, + ) + + def _restart_spec( + self, + job_id: int, + folder_key: Optional[str], + folder_path: Optional[str], + ) -> RequestSpec: + """Build request for restarting a job.""" + return RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.RestartJob" + ), + json={"jobId": job_id}, + headers={**header_folder(folder_key, folder_path)}, + ) + + def _resolve_job_identifiers( + self, + job_keys: List[str], + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> List[int]: + """Resolve job keys to job IDs in chunked bulk queries. + + This method prevents N+1 query issues by fetching all job IDs using + OData $filter with 'in' operator. Requests are chunked to avoid URL + length limits (max 50 keys per request). + + Args: + job_keys: List of job UUID keys to resolve + folder_key: Folder key + folder_path: Folder path + + Returns: + List[int]: List of job integer IDs in same order as job_keys + + Raises: + ValueError: If any job key is not a valid UUID + LookupError: If any job key is not found + + Note: + Duplicate keys in input are allowed and will return corresponding IDs. + """ + if not job_keys: + return [] + + for key in job_keys: + try: + uuid.UUID(key) + except ValueError as e: + raise ValueError(f"Invalid job key format: {key}") from e + + unique_keys = [] + seen = set() + for key in job_keys: + if key not in seen: + unique_keys.append(key) + seen.add(key) + + CHUNK_SIZE = 50 + all_key_to_id: Dict[str, int] = {} + + for i in range(0, len(unique_keys), CHUNK_SIZE): + chunk = unique_keys[i : i + CHUNK_SIZE] + keys_formatted = "','".join(chunk) + filter_expr = f"Key in ('{keys_formatted}')" + + spec = RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/Jobs"), + params={ + "$filter": filter_expr, + "$select": "Id,Key", + "$top": len(chunk), + }, + headers={**header_folder(folder_key, folder_path)}, + ) + + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ).json() + + items = response.get("value", []) + + # Accumulate mappings from this chunk + for item in items: + all_key_to_id[item["Key"]] = item["Id"] + + # Verify all unique keys were found + if len(all_key_to_id) != len(unique_keys): + found_keys = set(all_key_to_id.keys()) + missing_keys = set(unique_keys) - found_keys + raise LookupError(f"Jobs not found for keys: {', '.join(missing_keys)}") + + # Build result preserving original order (including duplicates) + return [all_key_to_id[key] for key in job_keys] + + async def _resolve_job_identifiers_async( + self, + job_keys: List[str], + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> List[int]: + """Async version of _resolve_job_identifiers().""" + if not job_keys: + return [] + + for key in job_keys: + try: + uuid.UUID(key) + except ValueError as e: + raise ValueError(f"Invalid job key format: {key}") from e + + unique_keys = [] + seen = set() + for key in job_keys: + if key not in seen: + unique_keys.append(key) + seen.add(key) + + CHUNK_SIZE = 50 + all_key_to_id: Dict[str, int] = {} + + for i in range(0, len(unique_keys), CHUNK_SIZE): + chunk = unique_keys[i : i + CHUNK_SIZE] + keys_formatted = "','".join(chunk) + filter_expr = f"Key in ('{keys_formatted}')" + + spec = RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/Jobs"), + params={ + "$filter": filter_expr, + "$select": "Id,Key", + "$top": len(chunk), + }, + headers={**header_folder(folder_key, folder_path)}, + ) + + response = ( + await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + ).json() + + items = response.get("value", []) + + for item in items: + all_key_to_id[item["Key"]] = item["Id"] + + if len(all_key_to_id) != len(unique_keys): + found_keys = set(all_key_to_id.keys()) + missing_keys = set(unique_keys) - found_keys + raise LookupError(f"Jobs not found for keys: {', '.join(missing_keys)}") + + return [all_key_to_id[key] for key in job_keys] diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py new file mode 100644 index 000000000..a0f7b5e2a --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_mcp_service.py @@ -0,0 +1,230 @@ +from typing import List + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext, header_folder +from ..common._models import Endpoint, RequestSpec +from ._folder_service import FolderService +from .mcp import McpServer + + +class McpService(FolderContext, BaseService): + """Service for managing MCP (Model Context Protocol) servers in UiPath. + + MCP servers provide contextual information and capabilities that can be used + by AI agents and automation processes. + """ + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService, + ) -> None: + super().__init__(config=config, execution_context=execution_context) + self._folders_service = folders_service + + @traced(name="mcp_list", run_type="uipath") + def list( + self, + *, + folder_path: str | None = None, + ) -> List[McpServer]: + """List all MCP servers. + + Args: + folder_path (Optional[str]): The path of the folder to list servers from. + + Returns: + List[McpServer]: A list of MCP servers with their configuration. + + Examples: + ```python + from uipath import UiPath + + client = UiPath() + + servers = client.mcp.list(folder_path="MyFolder") + for server in servers: + print(f"{server.name} - {server.slug}") + ``` + """ + spec = self._list_spec( + folder_path=folder_path, + ) + + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + + return [McpServer.model_validate(server) for server in response.json()] + + @traced(name="mcp_list", run_type="uipath") + async def list_async( + self, + *, + folder_path: str | None = None, + ) -> List[McpServer]: + """Asynchronously list all MCP servers. + + Args: + folder_path (Optional[str]): The path of the folder to list servers from. + + Returns: + List[McpServer]: A list of MCP servers with their configuration. + + Examples: + ```python + import asyncio + + from uipath import UiPath + + sdk = UiPath() + + async def main(): + servers = await sdk.mcp.list_async(folder_path="MyFolder") + for server in servers: + print(f"{server.name} - {server.slug}") + + asyncio.run(main()) + ``` + """ + spec = self._list_spec( + folder_path=folder_path, + ) + + response = await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + + return [McpServer.model_validate(server) for server in response.json()] + + @resource_override(resource_type="mcpServer", resource_identifier="slug") + @traced(name="mcp_retrieve", run_type="uipath") + def retrieve( + self, + slug: str, + *, + folder_path: str | None = None, + ) -> McpServer: + """Retrieve a specific MCP server by its slug. + + Args: + slug (str): The unique slug identifier for the server. + folder_path (Optional[str]): The path of the folder where the server is located. + + Returns: + McpServer: The MCP server configuration. + + Examples: + ```python + from uipath import UiPath + + client = UiPath() + + server = client.mcp.retrieve(slug="my-server-slug", folder_path="MyFolder") + print(f"Server: {server.name}, URL: {server.mcp_url}") + ``` + """ + spec = self._retrieve_spec( + slug=slug, + folder_path=folder_path, + ) + + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + + return McpServer.model_validate(response.json()) + + @resource_override(resource_type="mcpServer", resource_identifier="slug") + @traced(name="mcp_retrieve", run_type="uipath") + async def retrieve_async( + self, + slug: str, + *, + folder_path: str | None = None, + ) -> McpServer: + """Asynchronously retrieve a specific MCP server by its slug. + + Args: + slug (str): The unique slug identifier for the server. + folder_path (Optional[str]): The path of the folder where the server is located. + + Returns: + McpServer: The MCP server configuration. + + Examples: + ```python + import asyncio + + from uipath import UiPath + + sdk = UiPath() + + async def main(): + server = await sdk.mcp.retrieve_async(slug="my-server-slug", folder_path="MyFolder") + print(f"Server: {server.name}, URL: {server.mcp_url}") + + asyncio.run(main()) + ``` + """ + spec = self._retrieve_spec( + slug=slug, + folder_path=folder_path, + ) + + response = await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + headers=spec.headers, + ) + + return McpServer.model_validate(response.json()) + + @property + def custom_headers(self) -> dict[str, str]: + return self.folder_headers + + def _list_spec( + self, + *, + folder_path: str | None, + ) -> RequestSpec: + folder_key = self._folders_service.retrieve_folder_key(folder_path) + return RequestSpec( + method="GET", + endpoint=Endpoint("/agenthub_/api/servers"), + headers={ + **header_folder(folder_key, None), + }, + ) + + def _retrieve_spec( + self, + slug: str, + *, + folder_path: str | None, + ) -> RequestSpec: + folder_key = self._folders_service.retrieve_folder_key(folder_path) + return RequestSpec( + method="GET", + endpoint=Endpoint(f"/agenthub_/api/servers/{slug}"), + headers={ + **header_folder(folder_key, None), + }, + ) diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py new file mode 100644 index 000000000..10b6010e2 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_processes_service.py @@ -0,0 +1,337 @@ +import json +import os +import uuid +from typing import Any, Dict, Optional, Union + +from opentelemetry import trace +from opentelemetry.trace import format_span_id +from uipath.core.tracing import traced + +from ..attachments import Attachment +from ..common._base_service import BaseService +from ..common._bindings import resource_override +from ..common._config import UiPathApiConfig, UiPathConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext, header_folder +from ..common._models import Endpoint, RequestSpec +from ..common._span_utils import _SpanUtils +from ..common.constants import ENV_JOB_KEY, HEADER_JOB_KEY +from ._attachments_service import AttachmentsService +from .job import Job + + +class ProcessesService(FolderContext, BaseService): + """Service for managing and executing UiPath automation processes. + + Processes (also known as automations or workflows) are the core units of + automation in UiPath, representing sequences of activities that perform + specific business tasks. + """ + + _INPUT_ARGUMENTS_SIZE_LIMIT = 10000 + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + attachment_service: AttachmentsService, + ) -> None: + self._attachments_service = attachment_service + super().__init__(config=config, execution_context=execution_context) + + @resource_override(resource_type="process") + @traced(name="processes_invoke", run_type="uipath") + def invoke( + self, + name: str, + input_arguments: Optional[Dict[str, Any]] = None, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + attachments: Optional[list[Attachment]] = None, + parent_operation_id: Optional[str] = None, + **kwargs: Any, + ) -> Job: + """Start execution of a process by its name. + + Related Activity: [Invoke Process](https://docs.uipath.com/activities/other/latest/workflow/invoke-process) + + Args: + name (str): The name of the process to execute. + input_arguments (Optional[Dict[str, Any]]): The input arguments to pass to the process. + attachments (Optional[list]): List of Attachment objects to pass to the process. + folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. + parent_operation_id (Optional[str]): The parent operation ID for BTS tracking correlation. + + Returns: + Job: The job execution details. + + Examples: + ```python + from uipath.platform import UiPath + + client = UiPath() + + client.processes.invoke(name="MyProcess") + ``` + + ```python + # if you want to execute the process in a specific folder + # another one than the one set in the SDK config + from uipath.platform import UiPath + + client = UiPath() + + client.processes.invoke(name="MyProcess", folder_path="my-folder-key") + ``` + """ + input_data = self._handle_input_arguments( + input_arguments=input_arguments, + attachments=attachments, + folder_key=folder_key, + folder_path=folder_path, + ) + + spec = self._invoke_spec( + name, + input_data=input_data, + folder_key=folder_key, + folder_path=folder_path, + parent_span_id=kwargs.get("parent_span_id"), + parent_operation_id=parent_operation_id, + ) + response = self.request( + spec.method, + url=spec.endpoint, + params=spec.params, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + + return Job.model_validate(response.json()["value"][0]) + + @resource_override(resource_type="process") + @traced(name="processes_invoke", run_type="uipath") + async def invoke_async( + self, + name: str, + input_arguments: Optional[Dict[str, Any]] = None, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + attachments: Optional[list[Attachment]] = None, + parent_operation_id: Optional[str] = None, + **kwargs: Any, + ) -> Job: + """Asynchronously start execution of a process by its name. + + Related Activity: [Invoke Process](https://docs.uipath.com/activities/other/latest/workflow/invoke-process) + + Args: + name (str): The name of the process to execute. + input_arguments (Optional[Dict[str, Any]]): The input arguments to pass to the process. + attachments (Optional[list]): List of Attachment objects to pass to the process. + folder_key (Optional[str]): The key of the folder to execute the process in. Override the default one set in the SDK config. + folder_path (Optional[str]): The path of the folder to execute the process in. Override the default one set in the SDK config. + parent_operation_id (Optional[str]): The parent operation ID for BTS tracking correlation. + + Returns: + Job: The job execution details. + + Examples: + ```python + import asyncio + + from uipath.platform import UiPath + + sdk = UiPath() + + async def main(): + job = await sdk.processes.invoke_async("testAppAction") + print(job) + + asyncio.run(main()) + ``` + """ + input_data = await self._handle_input_arguments_async( + input_arguments=input_arguments, + attachments=attachments, + folder_key=folder_key, + folder_path=folder_path, + ) + spec = self._invoke_spec( + name, + input_data=input_data, + folder_key=folder_key, + folder_path=folder_path, + parent_span_id=kwargs.get("parent_span_id"), + parent_operation_id=parent_operation_id, + ) + + response = await self.request_async( + spec.method, + url=spec.endpoint, + params=spec.params, + json=spec.json, + content=spec.content, + headers=spec.headers, + ) + + return Job.model_validate(response.json()["value"][0]) + + @property + def custom_headers(self) -> Dict[str, str]: + return self.folder_headers + + @staticmethod + def _prepare_link_attachments( + attachments: Optional[list[Attachment]], + ) -> Optional[list[Dict[str, str]]]: + """Format attachments for process invocation payload.""" + if not attachments: + return None + + link_attachments = [ + {"attachmentId": str(att.id)} for att in attachments if att.id is not None + ] + return link_attachments if link_attachments else None + + def _handle_input_arguments( + self, + input_arguments: Optional[Dict[str, Any]] = None, + attachments: Optional[list[Attachment]] = None, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Dict[str, Any]: + """Handle input arguments and attachments, storing as attachment if they exceed size limit. + + Args: + input_arguments: The input arguments to process + attachments: List of Attachment objects to pass to the process + folder_key: The folder key for attachment storage + folder_path: The folder path for attachment storage + + Returns: + Dict containing either "InputArguments" or "InputFile" key, and optionally "Attachments" + """ + result: Dict[str, Any] = {} + + # handle input arguments + if not input_arguments: + result["InputArguments"] = json.dumps({}) + else: + # If payload exceeds limit, store as attachment + payload_json = json.dumps(input_arguments) + if len(payload_json) > self._INPUT_ARGUMENTS_SIZE_LIMIT: + attachment_id = self._attachments_service.upload( + name=f"{uuid.uuid4()}.json", + content=payload_json, + folder_key=folder_key, + folder_path=folder_path, + ) + result["InputFile"] = str(attachment_id) + else: + result["InputArguments"] = payload_json + + link_attachments = self._prepare_link_attachments(attachments) + if link_attachments: + result["Attachments"] = link_attachments + + return result + + async def _handle_input_arguments_async( + self, + input_arguments: Optional[Dict[str, Any]] = None, + attachments: Optional[list[Attachment]] = None, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + ) -> Dict[str, Any]: + """Handle input arguments and attachments, storing as attachment if they exceed size limit. + + Args: + input_arguments: The input arguments to process + attachments: List of Attachment objects to pass to the process + folder_key: The folder key for attachment storage + folder_path: The folder path for attachment storage + + Returns: + Dict containing either "InputArguments" or "InputFile" key, and optionally "Attachments" + """ + result: Dict[str, Any] = {} + + if not input_arguments: + result["InputArguments"] = json.dumps({}) + else: + payload_json = json.dumps(input_arguments) + if len(payload_json) > self._INPUT_ARGUMENTS_SIZE_LIMIT: + attachment_id = await self._attachments_service.upload_async( + name=f"{uuid.uuid4()}.json", + content=payload_json, + folder_key=folder_key, + folder_path=folder_path, + ) + result["InputFile"] = str(attachment_id) + else: + result["InputArguments"] = payload_json + + formatted_attachments = self._prepare_link_attachments(attachments) + if formatted_attachments: + result["Attachments"] = formatted_attachments + + return result + + @staticmethod + def _add_tracing( + payload: Dict[str, Any], + trace_id: Optional[str] = None, + parent_span_id: Optional[Union[str, int]] = None, + ) -> None: + """Enrich payload with trace context for cross-process correlation.""" + if not trace_id: + return + + payload["TraceId"] = _SpanUtils.normalize_trace_id(trace_id) + if not parent_span_id: + span_context = trace.get_current_span().get_span_context() + if span_context.span_id: + parent_span_id = format_span_id(span_context.span_id) + if parent_span_id: + if isinstance(parent_span_id, int): + parent_span_id = format_span_id(parent_span_id) + payload["ParentSpanId"] = _SpanUtils.normalize_span_id(parent_span_id) + + def _invoke_spec( + self, + name: str, + input_data: Optional[Dict[str, Any]] = None, + *, + folder_key: Optional[str] = None, + folder_path: Optional[str] = None, + parent_span_id: Optional[str] = None, + parent_operation_id: Optional[str] = None, + ) -> RequestSpec: + payload: Dict[str, Any] = {"ReleaseName": name, **(input_data or {})} + self._add_tracing(payload, UiPathConfig.trace_id, parent_span_id) + + if parent_operation_id: + payload["ParentOperationId"] = parent_operation_id + + request_spec = RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" + ), + json={"startInfo": payload}, + headers={ + **header_folder(folder_key, folder_path), + }, + ) + job_key = os.environ.get(ENV_JOB_KEY, None) + if job_key: + request_spec.headers[HEADER_JOB_KEY] = job_key + + return request_spec diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py b/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py new file mode 100644 index 000000000..57d0a7ca0 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/_queues_service.py @@ -0,0 +1,355 @@ +from typing import Any, Dict, List, Union + +from httpx import Response +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext +from ..common._models import Endpoint, RequestSpec +from .queues import ( + CommitType, + QueueItem, + TransactionItem, + TransactionItemResult, +) + + +class QueuesService(FolderContext, BaseService): + """Service for managing UiPath queues and queue items. + + Queues are a fundamental component of UiPath automation that enable distributed + and scalable processing of work items. + """ + + def __init__( + self, config: UiPathApiConfig, execution_context: UiPathExecutionContext + ) -> 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. + + Returns: + Response: HTTP response containing the list of queue items. + """ + spec = self._list_items_spec() + response = self.request(spec.method, url=spec.endpoint) + + return response.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. + + Returns: + Response: HTTP response containing the list of queue items. + """ + spec = self._list_items_spec() + response = await self.request_async(spec.method, url=spec.endpoint) + return response.json() + + @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. + + Args: + item: Queue item data, either as a dictionary or QueueItem instance. + + Returns: + Response: HTTP response containing the created queue item details. + + 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() + + @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. + + Args: + item: Queue item data, either as a dictionary or QueueItem instance. + + Returns: + Response: HTTP response containing the created queue item details. + + Related Activity: [Add Queue Item](https://docs.uipath.com/ACTIVITIES/other/latest/workflow/add-queue-item) + """ + spec = self._create_item_spec(item) + response = await self.request_async( + spec.method, url=spec.endpoint, json=spec.json + ) + return response.json() + + @traced(name="queues_create_items", run_type="uipath") + def create_items( + self, + items: List[Union[Dict[str, Any], QueueItem]], + queue_name: str, + commit_type: CommitType, + ) -> Response: + """Creates multiple queue items in bulk. + + Args: + items: List of queue items to create, each either a dictionary or QueueItem instance. + queue_name: Name of the target queue. + commit_type: Type of commit operation to use for the bulk operation. + + Returns: + Response: HTTP response containing the bulk operation result. + """ + spec = self._create_items_spec(items, queue_name, commit_type) + response = self.request(spec.method, url=spec.endpoint, json=spec.json) + return response.json() + + @traced(name="queues_create_items", run_type="uipath") + async def create_items_async( + self, + items: List[Union[Dict[str, Any], QueueItem]], + queue_name: str, + commit_type: CommitType, + ) -> Response: + """Asynchronously creates multiple queue items in bulk. + + Args: + items: List of queue items to create, each either a dictionary or QueueItem instance. + queue_name: Name of the target queue. + commit_type: Type of commit operation to use for the bulk operation. + + Returns: + Response: HTTP response containing the bulk operation result. + """ + spec = self._create_items_spec(items, queue_name, commit_type) + response = await self.request_async( + spec.method, url=spec.endpoint, json=spec.json + ) + return response.json() + + @traced(name="queues_create_transaction_item", run_type="uipath") + def create_transaction_item( + self, item: Union[Dict[str, Any], TransactionItem], no_robot: bool = False + ) -> Response: + """Creates a new transaction item in a queue. + + Args: + item: Transaction item data, either as a dictionary or TransactionItem instance. + no_robot: If True, the transaction will not be associated with a robot. Defaults to False. + + Returns: + Response: HTTP response containing the transaction item details. + """ + spec = self._create_transaction_item_spec(item, no_robot) + response = self.request(spec.method, url=spec.endpoint, json=spec.json) + return response.json() + + @traced(name="queues_create_transaction_item", run_type="uipath") + async def create_transaction_item_async( + self, item: Union[Dict[str, Any], TransactionItem], no_robot: bool = False + ) -> Response: + """Asynchronously creates a new transaction item in a queue. + + Args: + item: Transaction item data, either as a dictionary or TransactionItem instance. + no_robot: If True, the transaction will not be associated with a robot. Defaults to False. + + Returns: + Response: HTTP response containing the transaction item details. + """ + spec = self._create_transaction_item_spec(item, no_robot) + response = await self.request_async( + spec.method, url=spec.endpoint, json=spec.json + ) + return response.json() + + @traced(name="queues_update_progress_of_transaction_item", run_type="uipath") + def update_progress_of_transaction_item( + self, transaction_key: str, progress: str + ) -> Response: + """Updates the progress of a transaction item. + + Args: + transaction_key: Unique identifier of the transaction. + progress: Progress message to set. + + Returns: + Response: HTTP response confirming the progress update. + + Related Activity: [Set Transaction Progress](https://docs.uipath.com/activities/other/latest/workflow/set-transaction-progress) + """ + spec = self._update_progress_of_transaction_item_spec(transaction_key, progress) + response = self.request(spec.method, url=spec.endpoint, json=spec.json) + return response.json() + + @traced(name="queues_update_progress_of_transaction_item", run_type="uipath") + async def update_progress_of_transaction_item_async( + self, transaction_key: str, progress: str + ) -> Response: + """Asynchronously updates the progress of a transaction item. + + Args: + transaction_key: Unique identifier of the transaction. + progress: Progress message to set. + + Returns: + Response: HTTP response confirming the progress update. + + Related Activity: [Set Transaction Progress](https://docs.uipath.com/activities/other/latest/workflow/set-transaction-progress) + """ + spec = self._update_progress_of_transaction_item_spec(transaction_key, progress) + response = await self.request_async( + spec.method, url=spec.endpoint, json=spec.json + ) + return response.json() + + @traced(name="queues_complete_transaction_item", run_type="uipath") + def complete_transaction_item( + self, transaction_key: str, result: Union[Dict[str, Any], TransactionItemResult] + ) -> Response: + """Completes a transaction item with the specified result. + + Args: + transaction_key: Unique identifier of the transaction to complete. + result: Result data for the transaction, either as a dictionary or TransactionItemResult instance. + + Returns: + Response: HTTP response confirming the transaction completion. + + Related Activity: [Set Transaction Status](https://docs.uipath.com/activities/other/latest/workflow/set-transaction-status) + """ + spec = self._complete_transaction_item_spec(transaction_key, result) + response = self.request(spec.method, url=spec.endpoint, json=spec.json) + return response.json() + + @traced(name="queues_complete_transaction_item", run_type="uipath") + async def complete_transaction_item_async( + self, transaction_key: str, result: Union[Dict[str, Any], TransactionItemResult] + ) -> Response: + """Asynchronously completes a transaction item with the specified result. + + Args: + transaction_key: Unique identifier of the transaction to complete. + result: Result data for the transaction, either as a dictionary or TransactionItemResult instance. + + Returns: + Response: HTTP response confirming the transaction completion. + + Related Activity: [Set Transaction Status](https://docs.uipath.com/activities/other/latest/workflow/set-transaction-status) + """ + spec = self._complete_transaction_item_spec(transaction_key, result) + response = await self.request_async( + spec.method, url=spec.endpoint, json=spec.json + ) + return response.json() + + @property + def custom_headers(self) -> Dict[str, str]: + return self.folder_headers + + def _list_items_spec(self) -> RequestSpec: + return RequestSpec( + method="GET", + endpoint=Endpoint("/orchestrator_/odata/QueueItems"), + ) + + 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) + } + + return RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem" + ), + json=json_payload, + ) + + def _create_items_spec( + self, + items: List[Union[Dict[str, Any], QueueItem]], + queue_name: str, + commit_type: CommitType, + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Queues/UiPathODataSvc.BulkAddQueueItems" + ), + json={ + "queueName": queue_name, + "commitType": commit_type.value, + "queueItems": [ + item.model_dump(exclude_unset=True, by_alias=True) + if isinstance(item, QueueItem) + else QueueItem(**item).model_dump(exclude_unset=True, by_alias=True) + for item in items + ], + }, + ) + + def _create_transaction_item_spec( + self, item: Union[Dict[str, Any], TransactionItem], no_robot: bool = False + ) -> RequestSpec: + if isinstance(item, dict): + transaction_item = TransactionItem(**item) + elif isinstance(item, TransactionItem): + transaction_item = item + + return RequestSpec( + method="POST", + endpoint=Endpoint( + "/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction" + ), + json={ + "transactionData": { + **transaction_item.model_dump(exclude_unset=True, by_alias=True), + **( + {"RobotIdentifier": self._execution_context.robot_key} + if not no_robot + else {} + ), + } + }, + ) + + def _update_progress_of_transaction_item_spec( + self, transaction_key: str, progress: str + ) -> RequestSpec: + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"/orchestrator_/odata/QueueItems({transaction_key})/UiPathODataSvc.SetTransactionProgress" + ), + json={"progress": progress}, + ) + + def _complete_transaction_item_spec( + self, transaction_key: str, result: Union[Dict[str, Any], TransactionItemResult] + ) -> RequestSpec: + if isinstance(result, dict): + transaction_result = TransactionItemResult(**result) + elif isinstance(result, TransactionItemResult): + transaction_result = result + + return RequestSpec( + method="POST", + endpoint=Endpoint( + f"/orchestrator_/odata/Queues({transaction_key})/UiPathODataSvc.SetTransactionResult" + ), + json={ + "transactionResult": transaction_result.model_dump( + exclude_unset=True, by_alias=True + ) + }, + ) diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py b/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py new file mode 100644 index 000000000..6ee89e806 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/assets.py @@ -0,0 +1,73 @@ +"""Models for UiPath Orchestrator Assets.""" + +from typing import Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class CredentialsConnectionData(BaseModel): + """Model representing connection data for credentials.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + url: str = Field(alias="Url") + body: str = Field(alias="Body") + bearer_token: str = Field(alias="BearerToken") + + +class UserAsset(BaseModel): + """Model representing a user asset.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + name: Optional[str] = Field(default=None, alias="Name") + value: Optional[str] = Field(default=None, alias="Value") + value_type: Optional[str] = Field(default=None, alias="ValueType") + string_value: Optional[str] = Field(default=None, alias="StringValue") + bool_value: Optional[bool] = Field(default=None, alias="BoolValue") + int_value: Optional[int] = Field(default=None, alias="IntValue") + credential_username: Optional[str] = Field(default=None, alias="CredentialUsername") + 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") + key_value_list: Optional[List[Dict[str, str]]] = Field( + default=None, alias="KeyValueList" + ) + connection_data: Optional[CredentialsConnectionData] = Field( + default=None, alias="ConnectionData" + ) + id: Optional[int] = Field(default=None, alias="Id") + + +class Asset(BaseModel): + """Model representing an orchestrator asset.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + key: Optional[str] = Field(default=None, alias="Key") + description: Optional[str] = Field(default=None, alias="Description") + name: Optional[str] = Field(default=None, alias="Name") + value: Optional[str] = Field(default=None, alias="Value") + value_type: Optional[str] = Field(default=None, alias="ValueType") + string_value: Optional[str] = Field(default=None, alias="StringValue") + bool_value: Optional[bool] = Field(default=None, alias="BoolValue") + int_value: Optional[int] = Field(default=None, alias="IntValue") + credential_username: Optional[str] = Field(default=None, alias="CredentialUsername") + 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") diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/attachment.py b/packages/uipath-platform/src/uipath/platform/orchestrator/attachment.py new file mode 100644 index 000000000..3091b72c6 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/attachment.py @@ -0,0 +1,36 @@ +"""Module defining the Attachment model for UiPath Orchestrator.""" + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field, field_serializer + + +class Attachment(BaseModel): + """Model representing an attachment in UiPath. + + Attachments can be associated with jobs in UiPath and contain binary files or documents. + """ + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + @field_serializer("creation_time", "last_modification_time", when_used="json") + def serialize_datetime(self, value): + """Serialize datetime fields to ISO 8601 format for JSON output.""" + if isinstance(value, datetime): + return value.isoformat() if value else None + return value + + name: str = Field(alias="Name") + creation_time: Optional[datetime] = Field(default=None, alias="CreationTime") + last_modification_time: Optional[datetime] = Field( + default=None, alias="LastModificationTime" + ) + key: uuid.UUID = Field(alias="Key") diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/buckets.py b/packages/uipath-platform/src/uipath/platform/orchestrator/buckets.py new file mode 100644 index 000000000..d5b552386 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/buckets.py @@ -0,0 +1,80 @@ +"""Models for Orchestrator Buckets API responses.""" + +from typing import Any, List, Optional + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + + +class BucketFile(BaseModel): + """Represents a file within a bucket. + + Supports both ListFiles API (lowercase fields) and GetFiles API (PascalCase fields). + """ + + model_config = ConfigDict( + populate_by_name=True, + validate_by_alias=True, + extra="allow", + ) + + full_path: str = Field( + validation_alias=AliasChoices("fullPath", "FullPath"), + description="Full path within bucket", + ) + content_type: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("contentType", "ContentType"), + description="MIME type", + ) + size: int = Field( + validation_alias=AliasChoices("size", "Size"), + description="File size in bytes", + ) + last_modified: Optional[str] = Field( + default=None, + validation_alias=AliasChoices("lastModified", "LastModified"), + description="Last modification timestamp (ISO format)", + ) + is_directory: bool = Field( + default=False, + validation_alias=AliasChoices("IsDirectory", "isDirectory"), + description="Whether this entry is a directory", + ) + + @property + def path(self) -> str: + """Alias for full_path for consistency.""" + return self.full_path + + @property + def name(self) -> str: + """Extract filename from full path.""" + return ( + self.full_path.split("/")[-1] if "/" in self.full_path else self.full_path + ) + + +class Bucket(BaseModel): + """Represents a bucket in Orchestrator.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + name: str = Field(alias="Name") + description: Optional[str] = Field(default=None, alias="Description") + identifier: str = Field(alias="Identifier") + storage_provider: Optional[str] = Field(default=None, alias="StorageProvider") + storage_parameters: Optional[str] = Field(default=None, alias="StorageParameters") + storage_container: Optional[str] = Field(default=None, alias="StorageContainer") + options: Optional[str] = Field(default=None, alias="Options") + credential_store_id: Optional[str] = Field(default=None, alias="CredentialStoreId") + external_name: Optional[str] = Field(default=None, alias="ExternalName") + password: Optional[str] = Field(default=None, alias="Password") + folders_count: Optional[int] = Field(default=None, alias="FoldersCount") + encrypted: Optional[bool] = Field(default=None, alias="Encrypted") + id: Optional[int] = Field(default=None, alias="Id") + tags: Optional[List[Any]] = Field(default=None, alias="Tags") diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/folder.py b/packages/uipath-platform/src/uipath/platform/orchestrator/folder.py new file mode 100644 index 000000000..8d9a91c9e --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/folder.py @@ -0,0 +1,15 @@ +"""Models for Orchestrator Folders API responses.""" + +from pydantic import BaseModel, ConfigDict, Field + + +class PersonalWorkspace(BaseModel): + """Represents a user's personal workspace folder.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + + fully_qualified_name: str = Field(alias="FullyQualifiedName") + key: str = Field(alias="Key") + id: int = Field(alias="Id") diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/job.py b/packages/uipath-platform/src/uipath/platform/orchestrator/job.py new file mode 100644 index 000000000..7ade631e5 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/job.py @@ -0,0 +1,83 @@ +"""Models for Orchestrator Jobs.""" + +from enum import Enum +from typing import Any, Dict, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class JobState(str, Enum): + """Job state enum.""" + + SUCCESSFUL = "successful" + FAULTED = "faulted" + SUSPENDED = "suspended" + RUNNING = "running" + PENDING = "pending" + RESUMED = "resumed" + + +class JobErrorInfo(BaseModel): + """Model representing job error information.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + code: Optional[str] = Field(default=None, alias="Code") + title: Optional[str] = Field(default=None, alias="Title") + detail: Optional[str] = Field(default=None, alias="Detail") + category: Optional[str] = Field(default=None, alias="Category") + status: Optional[int] = Field(default=None, alias="Status") + + +class Job(BaseModel): + """Model representing an orchestrator job.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + key: Optional[str] = Field(default=None, alias="Key") + start_time: Optional[str] = Field(default=None, alias="StartTime") + end_time: Optional[str] = Field(default=None, alias="EndTime") + # 2.3.0 change to JobState enum + state: Optional[str] = Field(default=None, alias="State") + job_priority: Optional[str] = Field(default=None, alias="JobPriority") + specific_priority_value: Optional[int] = Field( + default=None, alias="SpecificPriorityValue" + ) + robot: Optional[Dict[str, Any]] = Field(default=None, alias="Robot") + release: Optional[Dict[str, Any]] = Field(default=None, alias="Release") + resource_overwrites: Optional[str] = Field(default=None, alias="ResourceOverwrites") + source: Optional[str] = Field(default=None, alias="Source") + source_type: Optional[str] = Field(default=None, alias="SourceType") + batch_execution_key: Optional[str] = Field(default=None, alias="BatchExecutionKey") + info: Optional[str] = Field(default=None, alias="Info") + creation_time: Optional[str] = Field(default=None, alias="CreationTime") + creator_user_id: Optional[int] = Field(default=None, alias="CreatorUserId") + last_modification_time: Optional[str] = Field( + default=None, alias="LastModificationTime" + ) + last_modifier_user_id: Optional[int] = Field( + default=None, alias="LastModifierUserId" + ) + deletion_time: Optional[str] = Field(default=None, alias="DeletionTime") + deleter_user_id: Optional[int] = Field(default=None, alias="DeleterUserId") + is_deleted: Optional[bool] = Field(default=None, alias="IsDeleted") + input_arguments: Optional[str] = Field(default=None, alias="InputArguments") + input_file: Optional[str] = Field(default=None, alias="InputFile") + output_arguments: Optional[str] = Field(default=None, alias="OutputArguments") + output_file: Optional[str] = Field(default=None, alias="OutputFile") + host_machine_name: Optional[str] = Field(default=None, alias="HostMachineName") + has_errors: Optional[bool] = Field(default=None, alias="HasErrors") + has_warnings: Optional[bool] = Field(default=None, alias="HasWarnings") + job_error: Optional[JobErrorInfo] = Field(default=None, alias="JobError") + folder_key: str = Field(alias="FolderKey") + id: int = Field(alias="Id") diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py b/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py new file mode 100644 index 000000000..9a811d876 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/mcp.py @@ -0,0 +1,56 @@ +"""Models for MCP Servers in UiPath Orchestrator.""" + +from datetime import datetime +from enum import IntEnum +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class McpServerType(IntEnum): + """Enumeration of MCP server types.""" + + UiPath = 0 # Processes, Agents, Activities + Command = 1 # npx, uvx + Coded = 2 # PackageType.McpServer + SelfHosted = 3 # tunnel to (externally) self-hosted server + Remote = 4 # HTTP connection to remote MCP server + ProcessAssistant = 5 # Dynamic user process assistant + + +class McpServerStatus(IntEnum): + """Enumeration of MCP server statuses.""" + + Disconnected = 0 + Connected = 1 + + +class McpServer(BaseModel): + """Model representing an MCP server in UiPath Orchestrator.""" + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + id: Optional[str] = None + name: Optional[str] = None + slug: Optional[str] = None + description: Optional[str] = None + version: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + is_active: Optional[bool] = None + type: Optional[McpServerType] = None + status: Optional[McpServerStatus] = None + command: Optional[str] = None + arguments: Optional[str] = None + environment_variables: Optional[str] = None + process_key: Optional[str] = None + folder_key: Optional[str] = None + runtimes_count: Optional[int] = None + mcp_url: Optional[str] = None diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/processes.py b/packages/uipath-platform/src/uipath/platform/orchestrator/processes.py new file mode 100644 index 000000000..90c9d2799 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/processes.py @@ -0,0 +1,49 @@ +"""Models for Orchestrator Processes.""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class Process(BaseModel): + """Model representing an orchestrator process.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + key: str = Field(alias="Key") + process_key: str = Field(alias="ProcessKey") + process_version: str = Field(alias="ProcessVersion") + is_latest_version: bool = Field(alias="IsLatestVersion") + is_process_deleted: bool = Field(alias="IsProcessDeleted") + description: str = Field(alias="Description") + name: str = Field(alias="Name") + environment_variables: Optional[str] = Field( + default=None, alias="EnvironmentVariables" + ) + process_type: str = Field(alias="ProcessType") + requires_user_interaction: bool = Field(alias="RequiresUserInteraction") + is_attended: bool = Field(alias="IsAttended") + is_compiled: bool = Field(alias="IsCompiled") + feed_id: str = Field(alias="FeedId") + job_priority: str = Field(alias="JobPriority") + 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") + arguments: Optional[Dict[str, Optional[Any]]] = Field( + default=None, alias="Arguments" + ) + tags: List[str] = Field(alias="Tags") + environment: Optional[str] = Field(default=None, alias="Environment") + current_version: Optional[Dict[str, Any]] = Field( + default=None, alias="CurrentVersion" + ) + entry_point: Optional[Dict[str, Any]] = Field(default=None, alias="EntryPoint") diff --git a/packages/uipath-platform/src/uipath/platform/orchestrator/queues.py b/packages/uipath-platform/src/uipath/platform/orchestrator/queues.py new file mode 100644 index 000000000..3acd4a79b --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/orchestrator/queues.py @@ -0,0 +1,204 @@ +"""Models for Orchestrator Queues API.""" + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_serializer +from typing_extensions import Annotated + + +class QueueItemPriority(Enum): + """Enumeration for Queue Item Priority levels.""" + + LOW = "Low" + NORMAL = "Normal" + HIGH = "High" + + +class CommitType(Enum): + """Enumeration for Commit Types in batch processing.""" + + ALL_OR_NOTHING = "AllOrNothing" + STOP_ON_FIRST_FAILURE = "StopOnFirstFailure" + PROCESS_ALL_INDEPENDENTLY = "ProcessAllIndependently" + + +class QueueItem(BaseModel): + """Model representing an item in an Orchestrator queue.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + @field_serializer("defer_date", "due_date", "risk_sla_date", when_used="json") + def serialize_datetime(self, value): + """Serialize datetime fields to ISO 8601 format for JSON output.""" + if isinstance(value, datetime): + return value.isoformat() if value else None + return value + + name: str = Field( + description="The name of the queue into which the item will be added.", + alias="Name", + ) + priority: Optional[QueueItemPriority] = Field( + default=None, + description="Sets the processing importance for a given item.", + alias="Priority", + ) + specific_content: Optional[Dict[str, Any]] = Field( + default=None, + description="A collection of key value pairs containing custom data configured in the Add Queue Item activity, in UiPath Studio.", + alias="SpecificContent", + ) + defer_date: Optional[datetime] = Field( + default=None, + description="The earliest date and time at which the item is available for processing. If empty the item can be processed as soon as possible.", + alias="DeferDate", + ) + due_date: Optional[datetime] = Field( + default=None, + description="The latest date and time at which the item should be processed. If empty the item can be processed at any given time.", + alias="DueDate", + ) + risk_sla_date: Optional[datetime] = Field( + default=None, + description="The RiskSla date at time which is considered as risk zone for the item to be processed.", + alias="RiskSlaDate", + ) + progress: Optional[str] = Field( + default=None, + description="String field which is used to keep track of the business flow progress.", + alias="Progress", + ) + source: Optional[ + Annotated[str, Field(min_length=0, strict=True, max_length=20)] + ] = Field(default=None, description="The Source type of the item.", alias="Source") + parent_operation_id: Optional[ + Annotated[str, Field(min_length=0, strict=True, max_length=128)] + ] = Field( + default=None, + description="Operation id which started the job.", + alias="ParentOperationId", + ) + reference: Optional[ + Annotated[str, Field(min_length=0, strict=True, max_length=128)] + ] = Field( + default=None, + description="An optional, user-specified value for queue item identification.", + alias="Reference", + ) + + +class TransactionItem(BaseModel): + """Model representing a transaction item in an Orchestrator queue.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + @field_serializer("defer_date", "due_date", when_used="json") + def serialize_datetime(self, value): + """Serialize datetime fields to ISO 8601 format for JSON output.""" + if isinstance(value, datetime): + return value.isoformat() if value else None + return value + + name: str = Field( + description="The name of the queue in which to search for the next item or in which to insert the item before marking it as InProgress and sending it to the robot.", + alias="Name", + ) + robot_identifier: Optional[str] = Field( + default=None, + description="The unique key identifying the robot that sent the request.", + alias="RobotIdentifier", + ) + specific_content: Optional[Dict[str, Any]] = Field( + default=None, + description="If not null a new item will be added to the queue with this content before being moved to InProgress state and returned to the robot for processing. If null the next available item in the list will be moved to InProgress state and returned to the robot for processing.", + alias="SpecificContent", + ) + defer_date: Optional[datetime] = Field( + default=None, + description="The earliest date and time at which the item is available for processing. If empty the item can be processed as soon as possible.", + alias="DeferDate", + ) + due_date: Optional[datetime] = Field( + default=None, + description="The latest date and time at which the item should be processed. If empty the item can be processed at any given time.", + alias="DueDate", + ) + parent_operation_id: Optional[ + Annotated[str, Field(min_length=0, strict=True, max_length=128)] + ] = Field( + default=None, + description="Operation id which created the queue item.", + alias="ParentOperationId", + ) + + +class TransactionItemResult(BaseModel): + """Model representing the result of processing a transaction item in an Orchestrator queue.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + use_enum_values=True, + arbitrary_types_allowed=True, + extra="allow", + ) + + @field_serializer("defer_date", "due_date", when_used="json") + def serialize_datetime(self, value): + """Serialize datetime fields to ISO 8601 format for JSON output.""" + if isinstance(value, datetime): + return value.isoformat() if value else None + return value + + is_successful: Optional[bool] = Field( + default=None, + description="States if the processing was successful or not.", + alias="IsSuccessful", + ) + processing_exception: Optional[Any] = Field( + default=None, alias="ProcessingException" + ) + defer_date: Optional[datetime] = Field( + default=None, + description="The earliest date and time at which the item is available for processing. If empty the item can be processed as soon as possible.", + alias="DeferDate", + ) + due_date: Optional[datetime] = Field( + default=None, + description="The latest date and time at which the item should be processed. If empty the item can be processed at any given time.", + alias="DueDate", + ) + output: Optional[Dict[str, Any]] = Field( + default=None, + description="A collection of key value pairs containing custom data resulted after successful processing.", + alias="Output", + ) + analytics: Optional[Dict[str, Any]] = Field( + default=None, + description="A collection of key value pairs containing custom data for further analytics processing.", + alias="Analytics", + ) + progress: Optional[str] = Field( + default=None, + description="String field which is used to keep track of the business flow progress.", + alias="Progress", + ) + operation_id: Optional[Annotated[str, Field(strict=True, max_length=128)]] = Field( + default=None, + description="The operation id which finished the queue item. Will be saved only if queue item is in final state", + alias="OperationId", + ) diff --git a/src/uipath/py.typed b/packages/uipath-platform/src/uipath/platform/py.typed similarity index 100% rename from src/uipath/py.typed rename to packages/uipath-platform/src/uipath/platform/py.typed diff --git a/packages/uipath-platform/src/uipath/platform/resource_catalog/__init__.py b/packages/uipath-platform/src/uipath/platform/resource_catalog/__init__.py new file mode 100644 index 000000000..227db5439 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/resource_catalog/__init__.py @@ -0,0 +1,15 @@ +"""UiPath Resource Catalog Models. + +This module contains models related to UiPath Resource Catalog service. +""" + +from ._resource_catalog_service import ResourceCatalogService +from .resource_catalog import Folder, Resource, ResourceType, Tag + +__all__ = [ + "ResourceCatalogService", + "Folder", + "Resource", + "ResourceType", + "Tag", +] diff --git a/packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py b/packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py new file mode 100644 index 000000000..030d85d01 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/resource_catalog/_resource_catalog_service.py @@ -0,0 +1,634 @@ +from typing import Any, AsyncIterator, Dict, Iterator, List, Optional + +from uipath.core.tracing import traced + +from ..common._base_service import BaseService +from ..common._config import UiPathApiConfig +from ..common._execution_context import UiPathExecutionContext +from ..common._folder_context import FolderContext, header_folder +from ..common._models import Endpoint, RequestSpec +from ..orchestrator._folder_service import FolderService +from .resource_catalog import Resource, ResourceType + + +class ResourceCatalogService(FolderContext, BaseService): + """Service for searching and discovering UiPath resources across folders. + + The Resource Catalog Service provides a centralized way to search and retrieve + UiPath resources (assets, queues, processes, storage buckets, etc.) across + tenant and folder scopes. It enables programmatic discovery of resources with + flexible filtering by resource type, name, and folder location. + + See Also: + https://docs.uipath.com/orchestrator/standalone/2024.10/user-guide/about-resource-catalog-service + + !!! info "Version Availability" + This service is available starting from **uipath** version **2.1.168**. + """ + + _DEFAULT_PAGE_SIZE = 20 + + def __init__( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folder_service: FolderService, + ) -> None: + self.folder_service = folder_service + super().__init__(config=config, execution_context=execution_context) + + @traced(name="resource_catalog_search", run_type="uipath") + def search( + self, + *, + name: Optional[str] = None, + resource_types: Optional[List[ResourceType]] = None, + resource_sub_types: Optional[List[str]] = None, + page_size: int = _DEFAULT_PAGE_SIZE, + ) -> Iterator[Resource]: + """Search for tenant scoped resources and folder scoped resources (accessible to the user). + + This method automatically handles pagination and yields resources one by one. + + Args: + name: Optional name filter for resources + resource_types: Optional list of resource types to filter by + resource_sub_types: Optional list of resource subtypes to filter by + page_size: Number of resources to fetch per API call (default: 20, max: 100) + + Yields: + Resource: Each resource matching the search criteria + + Examples: + >>> # Search for all resources with "invoice" in the name + >>> for resource in uipath.resource_catalog.search(name="invoice"): + ... print(f"{resource.name}: {resource.resource_type}") + + >>> # Search for specific resource types + >>> for resource in uipath.resource_catalog.search( + ... resource_types=[ResourceType.ASSET] + ... ): + ... print(resource.name) + """ + skip = 0 + take = min(page_size, 100) + + while True: + spec = self._search_spec( + name=name, + resource_types=resource_types, + resource_sub_types=resource_sub_types, + skip=skip, + take=take, + ) + + 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 Resource.model_validate(item) + + if len(items) < take: + break + + skip += take + + @traced(name="resource_catalog_search", run_type="uipath") + async def search_async( + self, + *, + name: Optional[str] = None, + resource_types: Optional[List[ResourceType]] = None, + resource_sub_types: Optional[List[str]] = None, + page_size: int = _DEFAULT_PAGE_SIZE, + ) -> AsyncIterator[Resource]: + """Asynchronously search for tenant scoped resources and folder scoped resources (accessible to the user). + + This method automatically handles pagination and yields resources one by one. + + Args: + name: Optional name filter for resources + resource_types: Optional list of resource types to filter by + resource_sub_types: Optional list of resource subtypes to filter by + page_size: Number of resources to fetch per API call (default: 20, max: 100) + + Yields: + Resource: Each resource matching the search criteria + + Examples: + >>> # Search for all resources with "invoice" in the name + >>> async for resource in uipath.resource_catalog.search_async(name="invoice"): + ... print(f"{resource.name}: {resource.resource_type}") + + >>> # Search for specific resource types + >>> async for resource in uipath.resource_catalog.search_async( + ... resource_types=[ResourceType.ASSET] + ... ): + ... print(resource.name) + """ + skip = 0 + take = min(page_size, 100) + + while True: + spec = self._search_spec( + name=name, + resource_types=resource_types, + resource_sub_types=resource_sub_types, + skip=skip, + take=take, + ) + + 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: + yield Resource.model_validate(item) + + if len(items) < take: + break + + skip += take + + @traced(name="resource_catalog_list", run_type="uipath") + def list( + self, + *, + resource_types: Optional[List[ResourceType]] = None, + resource_sub_types: Optional[List[str]] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + page_size: int = _DEFAULT_PAGE_SIZE, + ) -> Iterator[Resource]: + """Get tenant scoped resources and folder scoped resources (accessible to the user). + + If no folder identifier is provided (path or key) only tenant resources will be retrieved. + This method automatically handles pagination and yields resources one by one. + + Args: + resource_types: Optional list of resource types to filter by + resource_sub_types: Optional list of resource subtypes to filter by + folder_path: Optional folder path to scope the results + folder_key: Optional folder key to scope the results + page_size: Number of resources to fetch per API call (default: 20, max: 100) + + Yields: + Resource: Each resource matching the criteria + + Examples: + >>> # Get all resources + >>> for resource in uipath.resource_catalog.list(): + ... print(f"{resource.name}: {resource.resource_type}") + + >>> # Get specific resource types + >>> assets = list(uipath.resource_catalog.list( + ... resource_types=[ResourceType.ASSET], + ... )) + + >>> # Get resources within a specific folder + >>> for resource in uipath.resource_catalog.list( + ... folder_path="/Shared/Finance", + ... resource_types=[ResourceType.ASSET], + ... resource_sub_types=["number"] + ... ): + ... print(resource.name) + """ + skip = 0 + take = min(page_size, 100) + + if take <= 0: + raise ValueError(f"page_size must be greater than 0. Got {page_size}") + + resolved_folder_key = self.folder_service.retrieve_folder_key(folder_path) + + while True: + spec = self._list_spec( + resource_types=resource_types, + resource_sub_types=resource_sub_types, + folder_key=resolved_folder_key, + skip=skip, + take=take, + ) + + 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 Resource.model_validate(item) + + if len(items) < take: + break + + skip += take + + @traced(name="resource_catalog_list", run_type="uipath") + async def list_async( + self, + *, + resource_types: Optional[List[ResourceType]] = None, + resource_sub_types: Optional[List[str]] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + page_size: int = _DEFAULT_PAGE_SIZE, + ) -> AsyncIterator[Resource]: + """Asynchronously get tenant scoped resources and folder scoped resources (accessible to the user). + + If no folder identifier is provided (path or key) only tenant resources will be retrieved. + This method automatically handles pagination and yields resources one by one. + + Args: + resource_types: Optional list of resource types to filter by + resource_sub_types: Optional list of resource subtypes to filter by + folder_path: Optional folder path to scope the results + folder_key: Optional folder key to scope the results + page_size: Number of resources to fetch per API call (default: 20, max: 100) + + Yields: + Resource: Each resource matching the criteria + + Examples: + >>> # Get all resources + >>> async for resource in uipath.resource_catalog.list_async(): + ... print(f"{resource.name}: {resource.resource_type}") + + >>> # Get specific resource types + >>> assets = [] + >>> async for resource in uipath.resource_catalog.list_async( + ... resource_types=[ResourceType.ASSET], + ... ): + ... assets.append(resource) + + >>> # Get resources within a specific folder + >>> async for resource in uipath.resource_catalog.list_async( + ... folder_path="/Shared/Finance", + ... resource_types=[ResourceType.ASSET], + ... resource_sub_types=["number"] + ... ): + ... print(resource.name) + """ + skip = 0 + take = min(page_size, 100) + + if take <= 0: + raise ValueError(f"page_size must be greater than 0. Got {page_size}") + + resolved_folder_key = await self.folder_service.retrieve_folder_key_async( + folder_path + ) + while True: + spec = self._list_spec( + resource_types=resource_types, + resource_sub_types=resource_sub_types, + folder_key=resolved_folder_key, + skip=skip, + take=take, + ) + + 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: + yield Resource.model_validate(item) + + if len(items) < take: + break + + skip += take + + @traced(name="list_by_type", run_type="uipath") + def list_by_type( + self, + *, + resource_type: ResourceType, + name: Optional[str] = None, + resource_sub_types: Optional[List[str]] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + page_size: int = _DEFAULT_PAGE_SIZE, + ) -> Iterator[Resource]: + """Get resources of a specific type (tenant scoped or folder scoped). + + If no folder identifier is provided (path or key) only tenant resources will be retrieved. + This method automatically handles pagination and yields resources one by one. + + Args: + resource_type: The specific resource type to filter by + name: Optional name filter for resources + resource_sub_types: Optional list of resource subtypes to filter by + folder_path: Optional folder path to scope the results + folder_key: Optional folder key to scope the results + page_size: Number of resources to fetch per API call (default: 20, max: 100) + + Yields: + Resource: Each resource matching the criteria + + Examples: + >>> # Get all assets + >>> for resource in uipath.resource_catalog.list_by_type(resource_type=ResourceType.ASSET): + ... print(f"{resource.name}: {resource.resource_sub_type}") + + >>> # Get assets with a specific name pattern + >>> assets = list(uipath.resource_catalog.list_by_type( + ... resource_type=ResourceType.ASSET, + ... name="config" + ... )) + + >>> # Get assets within a specific folder with subtype filter + >>> for resource in uipath.resource_catalog.list_by_type( + ... resource_type=ResourceType.ASSET, + ... folder_path="/Shared/Finance", + ... resource_sub_types=["number"] + ... ): + ... print(resource.name) + """ + skip = 0 + take = min(page_size, 100) + + if take <= 0: + raise ValueError(f"page_size must be greater than 0. Got {page_size}") + + resolved_folder_key = self.folder_service.retrieve_folder_key(folder_path) + + while True: + spec = self._list_by_type_spec( + resource_type=resource_type, + name=name, + resource_sub_types=resource_sub_types, + folder_key=resolved_folder_key, + skip=skip, + take=take, + ) + + 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 Resource.model_validate(item) + + if len(items) < take: + break + + skip += take + + @traced(name="list_by_type_async", run_type="uipath") + async def list_by_type_async( + self, + *, + resource_type: ResourceType, + name: Optional[str] = None, + resource_sub_types: Optional[List[str]] = None, + folder_path: Optional[str] = None, + folder_key: Optional[str] = None, + page_size: int = _DEFAULT_PAGE_SIZE, + ) -> AsyncIterator[Resource]: + """Asynchronously get resources of a specific type (tenant scoped or folder scoped). + + If no folder identifier is provided (path or key) only tenant resources will be retrieved. + This method automatically handles pagination and yields resources one by one. + + Args: + resource_type: The specific resource type to filter by + name: Optional name filter for resources + resource_sub_types: Optional list of resource subtypes to filter by + folder_path: Optional folder path to scope the results + folder_key: Optional folder key to scope the results + page_size: Number of resources to fetch per API call (default: 20, max: 100) + + Yields: + Resource: Each resource matching the criteria + + Examples: + >>> # Get all assets asynchronously + >>> async for resource in uipath.resource_catalog.list_by_type_async(resource_type=ResourceType.ASSET): + ... print(f"{resource.name}: {resource.resource_sub_type}") + + >>> # Get assets with a specific name pattern + >>> assets = [] + >>> async for resource in uipath.resource_catalog.list_by_type_async( + ... resource_type=ResourceType.ASSET, + ... name="config" + ... ): + ... assets.append(resource) + + >>> # Get assets within a specific folder with subtype filter + >>> async for resource in uipath.resource_catalog.list_by_type_async( + ... resource_type=ResourceType.ASSET, + ... folder_path="/Shared/Finance", + ... resource_sub_types=["number"] + ... ): + ... print(resource.name) + """ + skip = 0 + take = min(page_size, 100) + + if take <= 0: + raise ValueError(f"page_size must be greater than 0. Got {page_size}") + + resolved_folder_key = await self.folder_service.retrieve_folder_key_async( + folder_path + ) + + while True: + spec = self._list_by_type_spec( + resource_type=resource_type, + name=name, + resource_sub_types=resource_sub_types, + folder_key=resolved_folder_key, + skip=skip, + take=take, + ) + + 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: + yield Resource.model_validate(item) + + if len(items) < take: + break + + skip += take + + def _search_spec( + self, + name: Optional[str], + resource_types: Optional[List[ResourceType]], + resource_sub_types: Optional[List[str]], + skip: int, + take: int, + ) -> RequestSpec: + """Build the request specification for searching resources. + + Args: + name: Optional name filter + resource_types: Optional resource types filter + resource_sub_types: Optional resource subtypes filter + skip: Number of resources to skip (for pagination) + take: Number of resources to take + + Returns: + RequestSpec: The request specification for the API call + """ + params: Dict[str, Any] = { + "skip": skip, + "take": take, + } + + if name: + params["name"] = name + + if resource_types: + params["entityTypes"] = [x.value for x in resource_types] + + if resource_sub_types: + params["entitySubType"] = resource_sub_types + + return RequestSpec( + method="GET", + endpoint=Endpoint("resourcecatalog_/Entities/Search"), + params=params, + ) + + def _list_spec( + self, + resource_types: Optional[List[ResourceType]], + resource_sub_types: Optional[List[str]], + folder_key: Optional[str], + skip: int, + take: int, + ) -> RequestSpec: + """Build the request specification for getting resources. + + Args: + resource_types: Optional resource types filter + resource_sub_types: Optional resource subtypes filter + folder_key: Optional folder key to scope the results + skip: Number of resources to skip (for pagination) + take: Number of resources to take + + Returns: + RequestSpec: The request specification for the API call + """ + params: Dict[str, Any] = { + "skip": skip, + "take": take, + } + + if resource_types: + params["entityTypes"] = [x.value for x in resource_types] + + if resource_sub_types: + params["entitySubType"] = resource_sub_types + + headers = { + **header_folder(folder_key, None), + } + + return RequestSpec( + method="GET", + endpoint=Endpoint("resourcecatalog_/Entities"), + params=params, + headers=headers, + ) + + def _list_by_type_spec( + self, + resource_type: ResourceType, + name: Optional[str], + resource_sub_types: Optional[List[str]], + folder_key: Optional[str], + skip: int, + take: int, + ) -> RequestSpec: + """Build the request specification for getting resources. + + Args: + resource_type: Resource type + resource_sub_types: Optional resource subtypes filter + folder_key: Optional folder key to scope the results + skip: Number of resources to skip (for pagination) + take: Number of resources to take + + Returns: + RequestSpec: The request specification for the API call + """ + params: Dict[str, Any] = { + "skip": skip, + "take": take, + } + + if name: + params["name"] = name + + if resource_sub_types: + params["entitySubType"] = resource_sub_types + + headers = { + **header_folder(folder_key, None), + } + + return RequestSpec( + method="GET", + endpoint=Endpoint(f"resourcecatalog_/Entities/{resource_type.value}"), + params=params, + headers=headers, + ) diff --git a/packages/uipath-platform/src/uipath/platform/resource_catalog/resource_catalog.py b/packages/uipath-platform/src/uipath/platform/resource_catalog/resource_catalog.py new file mode 100644 index 000000000..bedf6525d --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/resource_catalog/resource_catalog.py @@ -0,0 +1,124 @@ +"""Models for Resource Catalog service.""" + +from enum import Enum +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ResourceType(str, Enum): + """Resource type.""" + + ASSET = "asset" + BUCKET = "bucket" + MACHINE = "machine" + TRIGGER = "trigger" + PROCESS = "process" + PACKAGE = "package" + LIBRARY = "library" + INDEX = "index" + APP = "app" + CONNECTION = "connection" + CONNECTOR = "connector" + MCP_SERVER = "mcpserver" + QUEUE = "queue" + + @classmethod + def from_string(cls, value: str) -> "ResourceType": + """Create a ResourceType instance from a string value. + + Args: + value: String value to convert to ResourceType + + Returns: + ResourceType: The matching ResourceType enum member + + Raises: + ValueError: If the value doesn't match any ResourceType + """ + lower_value = value.lower() + for member in cls: + if member.value == lower_value: + return member + + available = ", ".join([f"'{member.value}'" for member in cls]) + raise ValueError( + f"'{value}' is not a valid ResourceType. Available options: {available}" + ) + + +class Tag(BaseModel): + """Tag model for resources.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + + key: str + display_name: str = Field(alias="displayName") + name: str + display_value: str = Field(alias="displayValue") + value: str + type: str + account_key: str = Field(alias="accountKey") + tenant_key: Optional[str] = Field(None, alias="tenantKey") + user_key: Optional[str] = Field(None, alias="userKey") + + +class Folder(BaseModel): + """Folder model for resources.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + + id: int + key: str + display_name: str = Field(alias="displayName") + code: str + fully_qualified_name: str = Field(alias="fullyQualifiedName") + timestamp: str + tenant_key: str = Field(alias="tenantKey") + account_key: str = Field(alias="accountKey") + user_key: Optional[str] = Field(None, alias="userKey") + type: str + path: str + permissions: Optional[List[str]] = Field(default_factory=list) + + +class Resource(BaseModel): + """Resource model from Resource Catalog.""" + + model_config = ConfigDict( + validate_by_name=True, + validate_by_alias=True, + ) + + resource_key: str = Field(alias="entityKey") + name: str + description: Optional[str] = None + resource_type: str = Field(alias="entityType") + tags: Optional[List[Tag]] = Field(default_factory=list) + folders: List[Folder] = Field(default_factory=list) + linked_folders_count: int = Field(0, alias="linkedFoldersCount") + source: Optional[str] = None + scope: str + search_state: str = Field(alias="searchState") + timestamp: str + folder_key: Optional[str] = Field(None, alias="folderKey") + folder_keys: List[str] = Field(default_factory=list, alias="folderKeys") + tenant_key: Optional[str] = Field(None, alias="tenantKey") + account_key: str = Field(alias="accountKey") + user_key: Optional[str] = Field(None, alias="userKey") + dependencies: Optional[list[str]] = Field(default_factory=list) + custom_data: Optional[str] = Field(None, alias="customData") + resource_sub_type: Optional[str] = Field(None, alias="entitySubType") + + +class ResourceSearchResponse(BaseModel): + """Response model for resource search API.""" + + count: int + value: List[Resource] diff --git a/packages/uipath-platform/src/uipath/platform/resume_triggers/__init__.py b/packages/uipath-platform/src/uipath/platform/resume_triggers/__init__.py new file mode 100644 index 000000000..47c35f5b7 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/resume_triggers/__init__.py @@ -0,0 +1,17 @@ +"""Init file for resume triggers module.""" + +from ._enums import PropertyName, TriggerMarker, is_no_content_marker +from ._protocol import ( + UiPathResumeTriggerCreator, + UiPathResumeTriggerHandler, + UiPathResumeTriggerReader, +) + +__all__ = [ + "UiPathResumeTriggerReader", + "UiPathResumeTriggerCreator", + "UiPathResumeTriggerHandler", + "PropertyName", + "TriggerMarker", + "is_no_content_marker", +] diff --git a/packages/uipath-platform/src/uipath/platform/resume_triggers/_enums.py b/packages/uipath-platform/src/uipath/platform/resume_triggers/_enums.py new file mode 100644 index 000000000..f1a228497 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/resume_triggers/_enums.py @@ -0,0 +1,55 @@ +"""UiPath resume trigger enums.""" + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class PropertyName(str, Enum): + """UiPath trigger property names.""" + + INTERNAL = "__internal" + + +class TriggerMarker(str, Enum): + """UiPath trigger markers. + + These markers are used as properties of resume triggers objects for special handling at runtime. + """ + + NO_CONTENT = "NO_CONTENT" + + +def is_no_content_marker(value: Any) -> bool: + """Check if a value is a NO_CONTENT trigger marker (dict or string form).""" + if isinstance(value, dict): + return value.get(PropertyName.INTERNAL.value) == TriggerMarker.NO_CONTENT.value + if isinstance(value, str): + return ( + PropertyName.INTERNAL.value in value + and TriggerMarker.NO_CONTENT.value in value + ) + return False + + +class ExternalTriggerType(str, Enum): + """External trigger types.""" + + DEEP_RAG = "deepRag" + BATCH_RAG = "batchRag" + IXP_EXTRACTION = "ixpExtraction" + INDEX_INGESTION = "indexIngestion" + IXP_VS_ESCALATION = "IxpVsEscalation" + + +class ExternalTrigger(BaseModel): + """Model representing an external trigger entity.""" + + type: ExternalTriggerType + external_id: str = Field(alias="externalId") + + model_config = { + "validate_by_name": True, + "validate_by_alias": True, + } diff --git a/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py new file mode 100644 index 000000000..8a8544ccc --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/resume_triggers/_protocol.py @@ -0,0 +1,917 @@ +"""Implementation of UiPath resume trigger protocols.""" + +import json +import os +import uuid +from typing import Any + +from uipath.core.errors import ( + ErrorCategory, + UiPathFaultedTriggerError, + UiPathPendingTriggerError, +) +from uipath.core.serialization import serialize_object +from uipath.core.triggers import ( + UiPathApiTrigger, + UiPathResumeTrigger, + UiPathResumeTriggerName, + UiPathResumeTriggerType, +) + +from uipath.platform import UiPath +from uipath.platform.action_center import Task +from uipath.platform.action_center.tasks import TaskStatus +from uipath.platform.common._config import UiPathConfig +from uipath.platform.common.interrupt_models import ( + CreateBatchTransform, + CreateDeepRag, + CreateEphemeralIndex, + CreateEscalation, + CreateTask, + DocumentExtraction, + DocumentExtractionValidation, + InvokeProcess, + InvokeProcessRaw, + InvokeSystemAgent, + WaitBatchTransform, + WaitDeepRag, + WaitDocumentExtraction, + WaitDocumentExtractionValidation, + WaitEphemeralIndex, + WaitEscalation, + WaitJob, + WaitJobRaw, + WaitSystemAgent, + WaitTask, +) +from uipath.platform.context_grounding import DeepRagStatus, IndexStatus +from uipath.platform.context_grounding.context_grounding_index import ( + ContextGroundingIndex, +) +from uipath.platform.errors import ( + BatchTransformNotCompleteException, + OperationNotCompleteException, +) +from uipath.platform.orchestrator.job import JobState +from uipath.platform.resume_triggers._enums import ( + ExternalTrigger, + ExternalTriggerType, + PropertyName, + TriggerMarker, +) + + +def _try_convert_to_json_format(value: str | None) -> Any: + """Attempts to parse a string as JSON and returns the parsed object or original string. + + Args: + value: The string value to attempt JSON parsing on. + + Returns: + The parsed JSON object if successful, otherwise the original string value. + """ + try: + if not value: + return None + return json.loads(value) + except json.decoder.JSONDecodeError: + return value + + +class UiPathResumeTriggerReader: + """Handles reading and retrieving Human-In-The-Loop (HITL) data from UiPath services. + + Implements UiPathResumeTriggerReaderProtocol. + """ + + def _extract_field(self, field_name: str, payload: Any) -> str | None: + """Extracts a field from the payload and returns it if it exists.""" + if not payload: + return payload + + if isinstance(payload, dict): + return payload.get(field_name) + + # 2.3.0 remove + try: + payload_dict = json.loads(payload) + return payload_dict.get(field_name) + except json.decoder.JSONDecodeError: + return None + + async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: + """Read a resume trigger and convert it to runtime-compatible input. + + This method retrieves data from UiPath services (Actions, Jobs, API) + based on the trigger type and returns it in a format that the + runtime can use to resume execution. + + Args: + trigger: The resume trigger to read + + Returns: + The data retrieved from UiPath services, ready to be used + as resume input. Format depends on trigger type: + - TASK: Task data (possibly with escalation processing) + - JOB: Job output data + - API: API payload + Returns None if no data is available. + + Raises: + UiPathRuntimeError: If reading fails, job failed, API connection failed, + trigger type is unknown, or HITL feedback retrieval failed. + """ + uipath = UiPath() + + match trigger.trigger_type: + case UiPathResumeTriggerType.TASK: + if trigger.item_key: + task: Task = await uipath.tasks.retrieve_async( + trigger.item_key, + app_folder_key=trigger.folder_key, + app_folder_path=trigger.folder_path, + app_name=self._extract_field("app_name", trigger.payload), + ) + pending_status = TaskStatus.PENDING.value + unassigned_status = TaskStatus.UNASSIGNED.value + + if ( + task.status in (pending_status, unassigned_status) + and not task.is_deleted + ): + # 2.3.0 remove (task.status will already be the enum) + current_status = ( + TaskStatus(task.status).name + if isinstance(task.status, int) + else "Unknown" + ) + raise UiPathPendingTriggerError( + ErrorCategory.SYSTEM, + f"Task is not completed yet. Current status: {current_status}", + ) + + if trigger.trigger_name == UiPathResumeTriggerName.ESCALATION: + return task + + # if task is deleted, raise error only for Task models + # for escalations we return the task object to allow graceful handling down the stream + if task.is_deleted: + raise UiPathFaultedTriggerError( + ErrorCategory.USER, + f"The {task.title} task was deleted", + ) + + trigger_response = task.data + if not bool(trigger_response): + # 2.3.0 change to task.status.name + assert isinstance(task.status, int) + trigger_response = { + "status": TaskStatus(task.status).name.lower(), + PropertyName.INTERNAL.value: TriggerMarker.NO_CONTENT.value, + } + + return trigger_response + + case UiPathResumeTriggerType.JOB: + if trigger.item_key: + job = await uipath.jobs.retrieve_async( + trigger.item_key, + folder_key=trigger.folder_key, + folder_path=trigger.folder_path, + process_name=self._extract_field("name", trigger.payload), + ) + + job_state = (job.state or "").lower() + successful_state = JobState.SUCCESSFUL.value + faulted_state = JobState.FAULTED.value + running_state = JobState.RUNNING.value + pending_state = JobState.PENDING.value + resumed_state = JobState.RESUMED.value + suspended_state = JobState.SUSPENDED.value + + if job_state in ( + pending_state, + running_state, + suspended_state, + resumed_state, + ): + raise UiPathPendingTriggerError( + ErrorCategory.SYSTEM, + f"Job is not finished yet. Current state: {job_state}", + ) + + if trigger.trigger_name == UiPathResumeTriggerName.JOB_RAW: + return job + + if job_state != successful_state: + job_error = ( + _try_convert_to_json_format(str(job.job_error or job.info)) + or "Job error unavailable." + if job_state == faulted_state + else f"Job {job.key} is {job_state}." + ) + raise UiPathFaultedTriggerError( + ErrorCategory.USER, + f"Process did not finish successfully. Error: {job_error}", + ) + + output_data = await uipath.jobs.extract_output_async(job) + trigger_response = _try_convert_to_json_format(output_data) + + # if response is an empty dictionary, use job state as placeholder value + if isinstance(trigger_response, dict) and not bool( + trigger_response + ): + # 2.3.0 change to job_state.value + trigger_response = { + "state": job_state, + PropertyName.INTERNAL.value: TriggerMarker.NO_CONTENT.value, + } + + return trigger_response + case UiPathResumeTriggerType.DEEP_RAG: + if trigger.item_key: + deep_rag = await uipath.context_grounding.retrieve_deep_rag_async( + trigger.item_key, + index_name=self._extract_field("index_name", trigger.payload), + ) + deep_rag_status = deep_rag.last_deep_rag_status + + if deep_rag_status in ( + DeepRagStatus.QUEUED, + DeepRagStatus.IN_PROGRESS, + ): + raise UiPathPendingTriggerError( + ErrorCategory.SYSTEM, + f"DeepRag is not finished yet. Current status: {deep_rag_status}", + ) + + if deep_rag_status != DeepRagStatus.SUCCESSFUL: + raise UiPathFaultedTriggerError( + ErrorCategory.USER, + f"DeepRag '{deep_rag.name}' did not finish successfully.", + ) + + trigger_response = deep_rag.content + + # if response is an empty dictionary, use Deep Rag state as placeholder value + if not trigger_response: + trigger_response = { + "status": deep_rag_status, + PropertyName.INTERNAL.value: TriggerMarker.NO_CONTENT.value, + } + else: + trigger_response = trigger_response.model_dump() + trigger_response["deepRagId"] = trigger.item_key + + return trigger_response + + case UiPathResumeTriggerType.INDEX_INGESTION: + if trigger.item_key: + index = await uipath.context_grounding.retrieve_by_id_async( + trigger.item_key + ) + + ephemeral_index = ContextGroundingIndex(**index) + + ephemeral_index_status = ephemeral_index.last_ingestion_status + + if ephemeral_index_status in ( + IndexStatus.QUEUED, + IndexStatus.IN_PROGRESS, + ): + raise UiPathPendingTriggerError( + ErrorCategory.SYSTEM, + f"Index ingestion is not finished yet. Current status: {ephemeral_index_status}", + ) + + if ephemeral_index_status != IndexStatus.SUCCESSFUL: + raise UiPathFaultedTriggerError( + ErrorCategory.USER, + f"Index ingestion '{ephemeral_index.name}' did not finish successfully.", + ) + + trigger_response = ephemeral_index.model_dump() + + return trigger_response + + case UiPathResumeTriggerType.BATCH_RAG: + if trigger.item_key: + destination_path = self._extract_field( + "destination_path", trigger.payload + ) + assert destination_path is not None + try: + await uipath.context_grounding.download_batch_transform_result_async( + trigger.item_key, + destination_path, + validate_status=True, + index_name=self._extract_field( + "index_name", trigger.payload + ), + ) + except BatchTransformNotCompleteException as e: + raise UiPathPendingTriggerError( + ErrorCategory.SYSTEM, + f"{e.message}", + ) from e + + return f"Batch transform completed. Modified file available at {os.path.abspath(destination_path)}" + + case UiPathResumeTriggerType.IXP_EXTRACTION: + if trigger.item_key: + project_id = self._extract_field("project_id", trigger.payload) + tag = self._extract_field("tag", trigger.payload) + + assert project_id is not None + assert tag is not None + + try: + extraction_response = ( + await uipath.documents.retrieve_ixp_extraction_result_async( + project_id, tag, trigger.item_key + ) + ) + except OperationNotCompleteException as e: + raise UiPathPendingTriggerError( + ErrorCategory.SYSTEM, + f"{e.message}", + ) from e + + return extraction_response.model_dump() + + case UiPathResumeTriggerType.IXP_VS_ESCALATION: + if trigger.item_key: + project_id = self._extract_field("project_id", trigger.payload) + tag = self._extract_field("tag", trigger.payload) + + assert project_id is not None + assert tag is not None + try: + escalation_response = await uipath.documents.retrieve_ixp_extraction_validation_result_async( + project_id, tag, trigger.item_key + ) + except OperationNotCompleteException as e: + raise UiPathPendingTriggerError( + ErrorCategory.SYSTEM, + f"{e.message}", + ) from e + + pending_status_name = TaskStatus.PENDING.name.lower() + unassigned_status_name = TaskStatus.UNASSIGNED.name.lower() + + current_status = escalation_response.action_data["status"].lower() + if current_status in (pending_status_name, unassigned_status_name): + raise UiPathPendingTriggerError( + ErrorCategory.SYSTEM, + f"Document extraction escalation task is not completed yet. Current status: {current_status}", + ) + + return escalation_response.model_dump() + + case UiPathResumeTriggerType.API: + if trigger.api_resume and trigger.api_resume.inbox_id: + try: + return await uipath.jobs.retrieve_api_payload_async( + trigger.api_resume.inbox_id + ) + except Exception as e: + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + f"Failed to get trigger payload" + f"Error fetching API trigger payload for inbox {trigger.api_resume.inbox_id}: {str(e)}", + ) from e + + case _: + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + f"Unexpected trigger type received" + f"Trigger type :{type(trigger.trigger_type)} is invalid", + ) + + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, "Failed to receive payload from HITL action" + ) + + +class UiPathResumeTriggerCreator: + """Creates resume triggers from suspend values. + + Implements UiPathResumeTriggerCreatorProtocol. + """ + + async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: + """Create a resume trigger from a suspend value. + + This method processes the input value and creates an appropriate resume trigger + for HITL scenarios. It handles different input types: + - Tasks: Creates or references UiPath tasks with folder information + - Jobs: Invokes processes or references existing jobs with folder information + - API: Creates API triggers with generated inbox IDs + + Args: + suspend_value: The value that caused the suspension. + Can be UiPath models (CreateTask, InvokeProcess, etc.), + strings, or any other value that needs HITL processing. + + Returns: + UiPathResumeTrigger ready to be persisted + + Raises: + UiPathRuntimeError: If action/job creation fails, escalation fails, or an + unknown model type is encountered. + Exception: If any underlying UiPath service calls fail. + """ + try: + trigger_type = self._determine_trigger_type(suspend_value) + trigger_name = self._determine_trigger_name(suspend_value) + + resume_trigger = UiPathResumeTrigger( + trigger_type=trigger_type, + trigger_name=trigger_name, + payload=serialize_object(suspend_value), + ) + + match trigger_type: + case UiPathResumeTriggerType.TASK: + await self._handle_task_trigger(suspend_value, resume_trigger) + + case UiPathResumeTriggerType.JOB: + await self._handle_job_trigger(suspend_value, resume_trigger) + + case UiPathResumeTriggerType.API: + self._handle_api_trigger(suspend_value, resume_trigger) + + case UiPathResumeTriggerType.DEEP_RAG: + await self._handle_deep_rag_job_trigger( + suspend_value, resume_trigger + ) + case UiPathResumeTriggerType.INDEX_INGESTION: + await self._handle_ephemeral_index_job_trigger( + suspend_value, resume_trigger + ) + case UiPathResumeTriggerType.BATCH_RAG: + await self._handle_batch_rag_job_trigger( + suspend_value, resume_trigger + ) + case UiPathResumeTriggerType.IXP_EXTRACTION: + await self._handle_ixp_extraction_trigger( + suspend_value, resume_trigger + ) + case UiPathResumeTriggerType.IXP_VS_ESCALATION: + await self._handle_ixp_vs_escalation_trigger( + suspend_value, resume_trigger + ) + case _: + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + f"Unexpected model received" + f"{type(suspend_value)} is not a valid Human-In-The-Loop model", + ) + except Exception as e: + raise UiPathFaultedTriggerError( + ErrorCategory.SYSTEM, + "Failed to create HITL action", + f"{str(e)}", + ) from e + return resume_trigger + + async def _create_external_trigger(self, external_trigger: ExternalTrigger): + """Creates an external trigger in orchestrator.""" + # only create external trigger entities for non-debug runs + if not UiPathConfig.job_key: + return + + uipath = UiPath() + await uipath.api_client.request_async( + method="POST", + url="orchestrator_/api/JobTriggers/SaveExternalTrigger", + json=external_trigger.model_dump(by_alias=True), + ) + + def _determine_trigger_type(self, value: Any) -> UiPathResumeTriggerType: + """Determines the resume trigger type based on the input value. + + Args: + value: The suspend value to analyze + + Returns: + The appropriate UiPathResumeTriggerType based on the input value type. + """ + if isinstance(value, (CreateTask, WaitTask, CreateEscalation, WaitEscalation)): + return UiPathResumeTriggerType.TASK + if isinstance( + value, + ( + InvokeProcess, + InvokeProcessRaw, + WaitJob, + WaitJobRaw, + InvokeSystemAgent, + WaitSystemAgent, + ), + ): + return UiPathResumeTriggerType.JOB + if isinstance(value, (CreateDeepRag, WaitDeepRag)): + return UiPathResumeTriggerType.DEEP_RAG + if isinstance(value, (CreateEphemeralIndex, WaitEphemeralIndex)): + return UiPathResumeTriggerType.INDEX_INGESTION + if isinstance(value, (CreateBatchTransform, WaitBatchTransform)): + return UiPathResumeTriggerType.BATCH_RAG + if isinstance(value, (DocumentExtraction, WaitDocumentExtraction)): + return UiPathResumeTriggerType.IXP_EXTRACTION + if isinstance( + value, (DocumentExtractionValidation, WaitDocumentExtractionValidation) + ): + return UiPathResumeTriggerType.IXP_VS_ESCALATION + # default to API trigger + return UiPathResumeTriggerType.API + + def _determine_trigger_name(self, value: Any) -> UiPathResumeTriggerName: + """Determines the resume trigger name based on the input value. + + Args: + value: The suspend value to analyze + + Returns: + The appropriate UiPathResumeTriggerName based on the input value type. + """ + if isinstance(value, (CreateEscalation, WaitEscalation)): + return UiPathResumeTriggerName.ESCALATION + if isinstance(value, (CreateTask, WaitTask)): + return UiPathResumeTriggerName.TASK + if isinstance(value, (InvokeProcessRaw, WaitJobRaw)): + return UiPathResumeTriggerName.JOB_RAW + if isinstance( + value, (InvokeProcess, WaitJob, InvokeSystemAgent, WaitSystemAgent) + ): + return UiPathResumeTriggerName.JOB + if isinstance(value, (CreateDeepRag, WaitDeepRag)): + return UiPathResumeTriggerName.DEEP_RAG + if isinstance(value, (CreateEphemeralIndex, WaitEphemeralIndex)): + return UiPathResumeTriggerName.INDEX_INGESTION + if isinstance(value, (CreateBatchTransform, WaitBatchTransform)): + return UiPathResumeTriggerName.BATCH_RAG + if isinstance(value, (DocumentExtraction, WaitDocumentExtraction)): + return UiPathResumeTriggerName.EXTRACTION + # default to API trigger + return UiPathResumeTriggerName.API + + async def _handle_task_trigger( + self, value: Any, resume_trigger: UiPathResumeTrigger + ) -> None: + """Handle task-type resume triggers. + + Args: + value: The suspend value (CreateTask or WaitTask) + resume_trigger: The resume trigger to populate + """ + resume_trigger.folder_path = value.app_folder_path + resume_trigger.folder_key = value.app_folder_key + + if isinstance(value, (WaitTask, WaitEscalation)): + resume_trigger.item_key = value.action.key + elif isinstance(value, (CreateTask, CreateEscalation)): + uipath = UiPath() + action = await uipath.tasks.create_async( + title=value.title, + app_name=value.app_name if value.app_name else "", + app_folder_path=value.app_folder_path if value.app_folder_path else "", + app_folder_key=value.app_folder_key if value.app_folder_key else "", + app_key=value.app_key if value.app_key else "", + assignee=value.assignee if value.assignee else "", + recipient=value.recipient if value.recipient else "", + data=value.data, + priority=value.priority, + labels=value.labels, + is_actionable_message_enabled=value.is_actionable_message_enabled, + actionable_message_metadata=value.actionable_message_metadata, + source_name=value.source_name, + ) + if not action: + raise Exception("Failed to create action") + resume_trigger.item_key = action.key + + async def _handle_deep_rag_job_trigger( + self, value: Any, resume_trigger: UiPathResumeTrigger + ) -> None: + """Handle Deep RAG resume triggers. + + Args: + value: The suspend value (CreateDeepRag or WaitDeepRag) + resume_trigger: The resume trigger to populate + """ + resume_trigger.folder_path = value.index_folder_path + resume_trigger.folder_key = value.index_folder_key + if isinstance(value, WaitDeepRag): + resume_trigger.item_key = value.deep_rag.id + elif isinstance(value, CreateDeepRag): + uipath = UiPath() + if value.is_ephemeral_index: + deep_rag = ( + await uipath.context_grounding.start_deep_rag_ephemeral_async( + name=value.name, + index_id=value.index_id, + prompt=value.prompt, + glob_pattern=value.glob_pattern, + citation_mode=value.citation_mode, + ) + ) + + else: + deep_rag = await uipath.context_grounding.start_deep_rag_async( + name=value.name, + index_name=value.index_name, + index_id=value.index_id, + prompt=value.prompt, + glob_pattern=value.glob_pattern, + citation_mode=value.citation_mode, + folder_path=value.index_folder_path, + folder_key=value.index_folder_key, + ) + if not deep_rag: + raise Exception("Failed to start deep rag") + + resume_trigger.item_key = deep_rag.id + + assert resume_trigger.item_key + await self._create_external_trigger( + ExternalTrigger( + type=ExternalTriggerType.DEEP_RAG, external_id=resume_trigger.item_key + ) + ) + + async def _handle_ephemeral_index_job_trigger( + self, value: Any, resume_trigger: UiPathResumeTrigger + ) -> None: + """Handle ephemeral index. + + Args: + value: The suspend value (CreateEphemeralIndex or WaitEphemeralIndex) + resume_trigger: The resume trigger to populate + + """ + if isinstance(value, WaitEphemeralIndex): + resume_trigger.item_key = value.index.id + elif isinstance(value, CreateEphemeralIndex): + uipath = UiPath() + ephemeral_index = ( + await uipath.context_grounding.create_ephemeral_index_async( + usage=value.usage, + attachments=value.attachments, + ) + ) + if not ephemeral_index: + raise Exception("Failed to create ephemeral index") + resume_trigger.item_key = ephemeral_index.id + + assert resume_trigger.item_key + await self._create_external_trigger( + ExternalTrigger( + type=ExternalTriggerType.INDEX_INGESTION, + external_id=resume_trigger.item_key, + ) + ) + + async def _handle_batch_rag_job_trigger( + self, value: Any, resume_trigger: UiPathResumeTrigger + ) -> None: + """Handle batch transform resume triggers. + + Args: + value: The suspend value (CreateBatchTransform or WaitBatchTransform) + resume_trigger: The resume trigger to populate + """ + resume_trigger.folder_path = value.index_folder_path + resume_trigger.folder_key = value.index_folder_key + if isinstance(value, WaitBatchTransform): + resume_trigger.item_key = value.batch_transform.id + elif isinstance(value, CreateBatchTransform): + uipath = UiPath() + if value.is_ephemeral_index: + batch_transform = await uipath.context_grounding.start_batch_transform_ephemeral_async( + name=value.name, + index_id=value.index_id, + prompt=value.prompt, + output_columns=value.output_columns, + storage_bucket_folder_path_prefix=value.storage_bucket_folder_path_prefix, + enable_web_search_grounding=value.enable_web_search_grounding, + ) + else: + batch_transform = await uipath.context_grounding.start_batch_transform_async( + name=value.name, + index_name=value.index_name, + index_id=value.index_id, + prompt=value.prompt, + output_columns=value.output_columns, + storage_bucket_folder_path_prefix=value.storage_bucket_folder_path_prefix, + enable_web_search_grounding=value.enable_web_search_grounding, + folder_path=value.index_folder_path, + folder_key=value.index_folder_key, + ) + if not batch_transform: + raise Exception("Failed to start batch transform") + + resume_trigger.item_key = batch_transform.id + + assert resume_trigger.item_key + await self._create_external_trigger( + ExternalTrigger( + type=ExternalTriggerType.BATCH_RAG, + external_id=resume_trigger.item_key, + ) + ) + + async def _handle_ixp_extraction_trigger( + self, value: Any, resume_trigger: UiPathResumeTrigger + ) -> None: + """Handle IXP Extraction resume triggers. + + Args: + value: The suspend value (DocumentExtraction or WaitDocumentExtraction) + resume_trigger: The resume trigger to populate + """ + resume_trigger.folder_path = resume_trigger.folder_key = None + if isinstance(value, WaitDocumentExtraction): + resume_trigger.item_key = value.extraction.operation_id + # add project_id and tag to the payload dict (needed when reading the trigger) + assert isinstance(resume_trigger.payload, dict) + resume_trigger.payload.setdefault("project_id", value.extraction.project_id) + resume_trigger.payload.setdefault("tag", value.extraction.tag) + elif isinstance(value, DocumentExtraction): + uipath = UiPath() + document_extraction = await uipath.documents.start_ixp_extraction_async( + project_name=value.project_name, + tag=value.tag, + file=value.file, + file_path=value.file_path, + ) + if not document_extraction: + raise Exception("Failed to start document extraction") + + resume_trigger.item_key = document_extraction.operation_id + + # add project_id and tag to the payload dict (needed when reading the trigger) + assert isinstance(resume_trigger.payload, dict) + resume_trigger.payload.setdefault( + "project_id", document_extraction.project_id + ) + resume_trigger.payload.setdefault("tag", document_extraction.tag) + + assert resume_trigger.item_key + await self._create_external_trigger( + ExternalTrigger( + type=ExternalTriggerType.IXP_EXTRACTION, + external_id=resume_trigger.item_key, + ) + ) + + async def _handle_ixp_vs_escalation_trigger( + self, value: Any, resume_trigger: UiPathResumeTrigger + ) -> None: + """Handle IXP VS Escalation resume triggers. + + Args: + value: The suspend value (DocumentExtractionValidation or WaitDocumentExtractionValidation) + resume_trigger: The resume trigger to populate + """ + resume_trigger.folder_path = resume_trigger.folder_key = None + + if isinstance(value, WaitDocumentExtractionValidation): + resume_trigger.item_key = value.extraction_validation.operation_id + + # add project_id and tag to the payload dict (needed when reading the trigger) + assert isinstance(resume_trigger.payload, dict) + resume_trigger.payload.setdefault( + "project_id", value.extraction_validation.project_id + ) + resume_trigger.payload.setdefault("tag", value.extraction_validation.tag) + elif isinstance(value, DocumentExtractionValidation): + uipath = UiPath() + extraction_validation = ( + await uipath.documents.start_ixp_extraction_validation_async( + extraction_response=value.extraction_response, + action_title=value.action_title, + action_priority=value.action_priority, + action_folder=value.action_folder, + storage_bucket_name=value.storage_bucket_name, + storage_bucket_directory_path=value.storage_bucket_directory_path, + ) + ) + if not extraction_validation: + raise Exception("Failed to start extraction validation") + + resume_trigger.item_key = extraction_validation.operation_id + + # add project_id and tag to the payload dict (needed when reading the trigger) + assert isinstance(resume_trigger.payload, dict) + resume_trigger.payload.setdefault( + "project_id", extraction_validation.project_id + ) + resume_trigger.payload.setdefault("tag", extraction_validation.tag) + + assert resume_trigger.item_key + await self._create_external_trigger( + ExternalTrigger( + type=ExternalTriggerType.IXP_VS_ESCALATION, + external_id=resume_trigger.item_key, + ) + ) + + async def _handle_job_trigger( + self, value: Any, resume_trigger: UiPathResumeTrigger + ) -> None: + """Handle job-type resume triggers. + + Args: + value: The suspend value (InvokeProcess, WaitJob, InvokeSystemAgent, WaitSystemAgent) + resume_trigger: The resume trigger to populate + """ + if isinstance(value, InvokeSystemAgent): + resume_trigger.folder_path = value.folder_path + resume_trigger.folder_key = value.folder_key + else: + resume_trigger.folder_path = value.process_folder_path + resume_trigger.folder_key = value.process_folder_key + + if isinstance(value, WaitJob): + resume_trigger.item_key = value.job.key + elif isinstance(value, WaitSystemAgent): + resume_trigger.item_key = value.job_key + elif isinstance(value, InvokeProcess): + uipath = UiPath() + job = await uipath.processes.invoke_async( + name=value.name, + input_arguments=value.input_arguments, + attachments=value.attachments, + folder_path=value.process_folder_path, + folder_key=value.process_folder_key, + ) + if not job: + raise Exception("Failed to invoke process") + resume_trigger.item_key = job.key + elif isinstance(value, InvokeSystemAgent): + uipath = UiPath() + job_key = await uipath.agenthub.invoke_system_agent_async( + agent_name=value.agent_name, + entrypoint=value.entrypoint, + input_arguments=value.input_arguments, + folder_path=value.folder_path, + folder_key=value.folder_key, + ) + if not job_key: + raise Exception("Failed to invoke system agent") + resume_trigger.item_key = job_key + + def _handle_api_trigger( + self, value: Any, resume_trigger: UiPathResumeTrigger + ) -> None: + """Handle API-type resume triggers. + + Args: + value: The suspend value + resume_trigger: The resume trigger to populate + """ + resume_trigger.api_resume = UiPathApiTrigger( + inbox_id=str(uuid.uuid4()), request=serialize_object(value) + ) + + +class UiPathResumeTriggerHandler: + """Combined handler for creating and reading resume triggers. + + Implements UiPathResumeTriggerProtocol by composing the creator and reader. + """ + + def __init__(self): + """Initialize the handler with creator and reader instances.""" + self._creator = UiPathResumeTriggerCreator() + self._reader = UiPathResumeTriggerReader() + + async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: + """Create a resume trigger from a suspend value. + + Args: + suspend_value: The value that caused the suspension. + + Returns: + UiPathResumeTrigger ready to be persisted + + Raises: + UiPathRuntimeError: If trigger creation fails + """ + return await self._creator.create_trigger(suspend_value) + + async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: + """Read a resume trigger and convert it to runtime-compatible input. + + Args: + trigger: The resume trigger to read + + Returns: + The data retrieved from UiPath services, or None if no data is available. + + Raises: + UiPathRuntimeError: If reading fails or job failed + """ + return await self._reader.read_trigger(trigger) diff --git a/packages/uipath-platform/tests/services/conftest.py b/packages/uipath-platform/tests/services/conftest.py new file mode 100644 index 000000000..2d12e5556 --- /dev/null +++ b/packages/uipath-platform/tests/services/conftest.py @@ -0,0 +1,70 @@ +import importlib +from pathlib import Path + +import pytest + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext + + +@pytest.fixture +def base_url() -> str: + return "https://test.uipath.com" + + +@pytest.fixture +def org() -> str: + return "/org" + + +@pytest.fixture +def tenant() -> str: + return "/tenant" + + +@pytest.fixture +def secret() -> str: + return "secret" + + +@pytest.fixture +def config(base_url: str, org: str, tenant: str, secret: str) -> UiPathApiConfig: + return UiPathApiConfig(base_url=f"{base_url}{org}{tenant}", secret=secret) + + +@pytest.fixture +def version(monkeypatch: pytest.MonkeyPatch) -> str: + test_version = "1.0.0" + monkeypatch.setattr(importlib.metadata, "version", lambda _: test_version) + return test_version + + +@pytest.fixture +def execution_context(monkeypatch: pytest.MonkeyPatch) -> UiPathExecutionContext: + monkeypatch.setenv("UIPATH_ROBOT_KEY", "test-robot-key") + return UiPathExecutionContext() + + +@pytest.fixture +def tests_data_path() -> Path: + return Path(__file__).resolve().parent / "tests_data" + + +@pytest.fixture +def jobs_service(config, execution_context): + from uipath.platform.orchestrator import JobsService + + return JobsService(config, execution_context) + + +@pytest.fixture +def mock_env_vars(monkeypatch: pytest.MonkeyPatch) -> dict[str, str]: + """Fixture to provide mock environment variables and set them in os.environ.""" + env_vars = { + "UIPATH_URL": "https://cloud.uipath.com/organization/tenant", + "UIPATH_TENANT_ID": "e150b32b-8815-4560-8243-055ffc9b7523", + "UIPATH_ORGANIZATION_ID": "62d19041-d1aa-454d-958d-1375329845dc", + "UIPATH_ACCESS_TOKEN": "mock_token", + } + for key, value in env_vars.items(): + monkeypatch.setenv(key, value) + return env_vars diff --git a/packages/uipath-platform/tests/services/test_actions_service.py b/packages/uipath-platform/tests/services/test_actions_service.py new file mode 100644 index 000000000..d0eda5a73 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_actions_service.py @@ -0,0 +1,179 @@ +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.action_center import Task +from uipath.platform.action_center._tasks_service import TasksService +from uipath.platform.common.constants import HEADER_USER_AGENT + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> TasksService: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + + return TasksService(config=config, execution_context=execution_context) + + +class TestTasksService: + def test_retrieve( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/GetTaskDataByKey?taskKey=test-id", + status_code=200, + json={"id": 1, "title": "Test Action"}, + ) + + action = service.retrieve( + action_key="test-id", + app_folder_path="test-folder", + ) + + assert isinstance(action, Task) + assert action.id == 1 + assert action.title == "Test Action" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/GetTaskDataByKey?taskKey=test-id" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.TasksService.retrieve/{version}" + ) + + @pytest.mark.anyio + async def test_retrieve_async( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/GetTaskDataByKey?taskKey=test-id", + status_code=200, + json={"id": 1, "title": "Test Action"}, + ) + + action = await service.retrieve_async( + action_key="test-id", + app_folder_path="test-folder", + ) + + assert isinstance(action, Task) + assert action.id == 1 + assert action.title == "Test Action" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/tasks/GenericTasks/GetTaskDataByKey?taskKey=test-id" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.TasksService.retrieve_async/{version}" + ) + + def test_create_with_app_key( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask", + status_code=200, + json={"id": 1, "title": "Test Action"}, + ) + + action = service.create( + title="Test Action", + app_key="test-app-key", + data={"test": "data"}, + ) + + assert isinstance(action, Task) + assert action.id == 1 + assert action.title == "Test Action" + + def test_create_with_assignee( + self, + httpx_mock: HTTPXMock, + service: TasksService, + base_url: str, + org: str, + tenant: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "test-tenant-id") + + httpx_mock.add_response( + url=f"{base_url}{org}/apps_/default/api/v1/default/deployed-action-apps-schemas?search=test-app&filterByDeploymentTitle=true", + status_code=200, + json={ + "deployed": [ + { + "systemName": "test-app", + "actionSchema": { + "key": "test-key", + "inputs": [], + "outputs": [], + "inOuts": [], + "outcomes": [], + }, + "deploymentFolder": {"fullyQualifiedName": "test-folder-path"}, + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/tasks/AppTasks/CreateAppTask", + status_code=200, + json={"id": 1, "title": "Test Action"}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Tasks/UiPath.Server.Configuration.OData.AssignTasks", + status_code=200, + json={}, + ) + + action = service.create( + title="Test Action", + app_name="test-app", + data={"test": "data"}, + assignee="test@example.com", + ) + + assert isinstance(action, Task) + assert action.id == 1 + assert action.title == "Test Action" diff --git a/packages/uipath-platform/tests/services/test_api_client.py b/packages/uipath-platform/tests/services/test_api_client.py new file mode 100644 index 000000000..eac74fa3c --- /dev/null +++ b/packages/uipath-platform/tests/services/test_api_client.py @@ -0,0 +1,92 @@ +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common._api_client import ApiClient +from uipath.platform.common.constants import HEADER_USER_AGENT + + +@pytest.fixture +def service( + config: UiPathApiConfig, execution_context: UiPathExecutionContext +) -> ApiClient: + return ApiClient(config=config, execution_context=execution_context) + + +class TestApiClient: + def test_request( + self, + httpx_mock: HTTPXMock, + service: ApiClient, + base_url: str, + org: str, + tenant: str, + version: str, + secret: str, + ): + endpoint = "/endpoint" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{endpoint}", + status_code=200, + json={"test": "test"}, + ) + + response = service.request("GET", endpoint) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert sent_request.url == f"{base_url}{org}{tenant}{endpoint}" + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ApiClient.request/{version}" + ) + assert sent_request.headers["Authorization"] == f"Bearer {secret}" + + assert response is not None + assert response.status_code == 200 + assert response.json() == {"test": "test"} + + @pytest.mark.anyio + async def test_request_async( + self, + httpx_mock: HTTPXMock, + service: ApiClient, + base_url: str, + org: str, + tenant: str, + version: str, + secret: str, + ): + endpoint = "/endpoint" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{endpoint}", + status_code=200, + json={"test": "test"}, + ) + + response = await service.request_async("GET", endpoint) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert sent_request.url == f"{base_url}{org}{tenant}{endpoint}" + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ApiClient.request_async/{version}" + ) + assert sent_request.headers["Authorization"] == f"Bearer {secret}" + + assert response is not None + assert response.status_code == 200 + assert response.json() == {"test": "test"} diff --git a/packages/uipath-platform/tests/services/test_assets_service.py b/packages/uipath-platform/tests/services/test_assets_service.py new file mode 100644 index 000000000..6e83c3b9d --- /dev/null +++ b/packages/uipath-platform/tests/services/test_assets_service.py @@ -0,0 +1,675 @@ +from unittest.mock import Mock, patch + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.common.paging import PagedResult +from uipath.platform.orchestrator import Asset, UserAsset +from uipath.platform.orchestrator._assets_service import AssetsService + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> AssetsService: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + return AssetsService(config=config, execution_context=execution_context) + + +class TestAssetsService: + class TestRetrieveAsset: + def test_retrieve_robot_asset( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + import json + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={"id": 1, "name": "Test Asset", "value": "test-value"}, + ) + + asset = service.retrieve(name="Test Asset") + + assert isinstance(asset, UserAsset) + assert asset.id == 1 + assert asset.name == "Test Asset" + assert asset.value == "test-value" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey" + ) + + # Verify default behavior includes supportsCredentialsProxyDisconnected=True + request_body = json.loads(sent_request.content) + assert request_body["assetName"] == "Test Asset" + assert request_body["supportsCredentialsProxyDisconnected"] is True + assert "robotKey" in request_body + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve/{version}" + ) + + def test_retrieve_robot_asset_with_connection_data( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test retrieving a robot asset with external credential store connection data. + + This tests that the CredentialsConnectionData model correctly parses + API responses with uppercase field names (Url, Body, BearerToken). + """ + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "Id": 1, + "Name": "Test Credential", + "ValueType": "Credential", + "ConnectionData": { + "Url": "https://credentialstore.example.com/api/credentials", + "Body": '{"credentialId": "12345"}', + "BearerToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + }, + }, + ) + + asset = service.retrieve(name="Test Credential") + + assert isinstance(asset, UserAsset) + assert asset.id == 1 + assert asset.name == "Test Credential" + assert asset.value_type == "Credential" + + # Verify connection data is correctly parsed with uppercase aliases + assert asset.connection_data is not None + assert ( + asset.connection_data.url + == "https://credentialstore.example.com/api/credentials" + ) + assert asset.connection_data.body == '{"credentialId": "12345"}' + assert ( + asset.connection_data.bearer_token + == "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + ) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert HEADER_USER_AGENT in sent_request.headers + + def test_retrieve_asset( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + version: str, + config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$filter=Name eq 'Test Asset'&$top=1", + status_code=200, + json={ + "value": [ + { + "key": "asset-key", + "name": "Test Asset", + "value": "test-value", + } + ] + }, + ) + + asset = service.retrieve(name="Test Asset") + + assert isinstance(asset, Asset) + assert asset.key == "asset-key" + assert asset.name == "Test Asset" + assert asset.value == "test-value" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?%24filter=Name+eq+%27Test+Asset%27&%24top=1" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve/{version}" + ) + + class TestListAssets: + def test_list_assets( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + version: str, + config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$skip=0&$top=100", + status_code=200, + json={ + "value": [ + { + "Key": "asset-key-1", + "Name": "Asset 1", + "Value": "value-1", + "ValueType": "Text", + }, + { + "Key": "asset-key-2", + "Name": "Asset 2", + "Value": "value-2", + "ValueType": "Text", + }, + ] + }, + ) + + result = service.list() + + assert isinstance(result, PagedResult) + assert len(result.items) == 2 + assert all(isinstance(asset, Asset) for asset in result.items) + assert result.items[0].key == "asset-key-1" + assert result.items[0].name == "Asset 1" + assert result.items[1].key == "asset-key-2" + assert result.items[1].name == "Asset 2" + assert result.skip == 0 + assert result.top == 100 + assert result.has_more is False # 2 items < 100 top + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.list/{version}" + ) + + def test_list_assets_with_filter_and_orderby( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$skip=0&$top=100&$filter=ValueType eq 'Text'&$orderby=Name asc", + status_code=200, + json={ + "value": [ + { + "Key": "asset-key-1", + "Name": "Text Asset", + "ValueType": "Text", + }, + ] + }, + ) + + result = service.list(filter="ValueType eq 'Text'", orderby="Name asc") + + assert len(result.items) == 1 + assert result.items[0].name == "Text Asset" + + @pytest.mark.anyio + async def test_list_assets_async( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + version: str, + config: UiPathApiConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetFiltered?$skip=0&$top=100", + status_code=200, + json={ + "value": [ + { + "Key": "asset-key-1", + "Name": "Asset 1", + "Value": "value-1", + }, + ] + }, + ) + + result = await service.list_async() + + assert isinstance(result, PagedResult) + assert len(result.items) == 1 + assert result.items[0].key == "asset-key-1" + assert result.items[0].name == "Asset 1" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.list_async/{version}" + ) + + def test_retrieve_credential( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "id": 1, + "name": "Test Credential", + "credential_username": "test-user", + "credential_password": "test-password", + }, + ) + + credential = service.retrieve_credential(name="Test Credential") + + assert credential == "test-password" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve_credential/{version}" + ) + + def test_retrieve_credential_user_asset( + self, + service: AssetsService, + monkeypatch: pytest.MonkeyPatch, + config: UiPathApiConfig, + ) -> None: + with pytest.raises(ValueError): + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service = AssetsService( + config=config, + execution_context=UiPathExecutionContext(), + ) + service.retrieve_credential(name="Test Credential") + + async def test_retrieve_credential_async( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test asynchronously retrieving a credential asset.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey", + status_code=200, + json={ + "id": 1, + "name": "Test Credential", + "credential_username": "test-user", + "credential_password": "test-password", + }, + ) + + credential = await service.retrieve_credential_async(name="Test Credential") + + assert credential == "test-password" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.GetRobotAssetByNameForRobotKey" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.retrieve_credential_async/{version}" + ) + + def test_update( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.SetRobotAssetByRobotKey", + status_code=200, + json={"id": 1, "name": "Test Asset", "value": "updated-value"}, + ) + + asset = UserAsset(name="Test Asset", value="updated-value") + response = service.update(robot_asset=asset) + + assert response == {"id": 1, "name": "Test Asset", "value": "updated-value"} + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.SetRobotAssetByRobotKey" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.update/{version}" + ) + + @pytest.mark.anyio + async def test_update_async( + self, + httpx_mock: HTTPXMock, + service: AssetsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.SetRobotAssetByRobotKey", + status_code=200, + json={"id": 1, "name": "Test Asset", "value": "updated-value"}, + ) + + asset = UserAsset(name="Test Asset", value="updated-value") + response = await service.update_async(robot_asset=asset) + + assert response == {"id": 1, "name": "Test Asset", "value": "updated-value"} + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Assets/UiPath.Server.Configuration.OData.SetRobotAssetByRobotKey" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AssetsService.update_async/{version}" + ) + + class TestRequestKwargs: + """Test that all methods pass the correct kwargs to request/request_async.""" + + def test_retrieve_passes_all_kwargs( + self, service: AssetsService, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that retrieve passes all kwargs to request.""" + mock_response = Mock() + mock_response.json.return_value = { + "value": [{"key": "test-key", "name": "Test", "value": "test-value"}] + } + + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service._execution_context = UiPathExecutionContext() + + with patch.object( + service, "request", return_value=mock_response + ) as mock_request: + service.retrieve(name="Test") + + mock_request.assert_called_once() + call_kwargs = mock_request.call_args + + # Verify all expected kwargs are present + assert "url" in call_kwargs.kwargs + assert "params" in call_kwargs.kwargs + assert "json" in call_kwargs.kwargs + assert "content" in call_kwargs.kwargs + assert "headers" in call_kwargs.kwargs + + # Verify positional arg (method) + assert call_kwargs.args[0] == "GET" + + @pytest.mark.anyio + async def test_retrieve_async_passes_all_kwargs( + self, service: AssetsService, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that retrieve_async passes all kwargs to request_async.""" + mock_response = Mock() + mock_response.json.return_value = { + "value": [{"key": "test-key", "name": "Test", "value": "test-value"}] + } + + monkeypatch.delenv("UIPATH_ROBOT_KEY", raising=False) + service._execution_context = UiPathExecutionContext() + + with patch.object( + service, "request_async", return_value=mock_response + ) as mock_request: + await service.retrieve_async(name="Test") + + mock_request.assert_called_once() + call_kwargs = mock_request.call_args + + # Verify all expected kwargs are present + assert "url" in call_kwargs.kwargs + assert "params" in call_kwargs.kwargs + assert "json" in call_kwargs.kwargs + assert "content" in call_kwargs.kwargs + assert "headers" in call_kwargs.kwargs + + # Verify positional arg (method) + assert call_kwargs.args[0] == "GET" + + def test_retrieve_credential_passes_all_kwargs( + self, service: AssetsService + ) -> None: + """Test that retrieve_credential passes all kwargs to request.""" + mock_response = Mock() + mock_response.json.return_value = { + "id": 1, + "name": "Test", + "credential_password": "secret", + } + + with patch.object( + service, "request", return_value=mock_response + ) as mock_request: + service.retrieve_credential(name="Test") + + mock_request.assert_called_once() + call_kwargs = mock_request.call_args + + # Verify all expected kwargs are present + assert "url" in call_kwargs.kwargs + assert "params" in call_kwargs.kwargs + assert "json" in call_kwargs.kwargs + assert "content" in call_kwargs.kwargs + assert "headers" in call_kwargs.kwargs + + # Verify positional arg (method) + assert call_kwargs.args[0] == "POST" + + @pytest.mark.anyio + async def test_retrieve_credential_async_passes_all_kwargs( + self, service: AssetsService + ) -> None: + """Test that retrieve_credential_async passes all kwargs to request_async.""" + mock_response = Mock() + mock_response.json.return_value = { + "id": 1, + "name": "Test", + "credential_password": "secret", + } + + with patch.object( + service, "request_async", return_value=mock_response + ) as mock_request: + await service.retrieve_credential_async(name="Test") + + mock_request.assert_called_once() + call_kwargs = mock_request.call_args + + # Verify all expected kwargs are present + assert "url" in call_kwargs.kwargs + assert "params" in call_kwargs.kwargs + assert "json" in call_kwargs.kwargs + assert "content" in call_kwargs.kwargs + assert "headers" in call_kwargs.kwargs + + # Verify positional arg (method) + assert call_kwargs.args[0] == "POST" + + def test_update_passes_all_kwargs(self, service: AssetsService) -> None: + """Test that update passes all kwargs to request.""" + mock_response = Mock() + mock_response.json.return_value = { + "id": 1, + "name": "Test", + "value": "updated", + } + + asset = UserAsset(name="Test", value="updated") + + with patch.object( + service, "request", return_value=mock_response + ) as mock_request: + service.update(robot_asset=asset) + + mock_request.assert_called_once() + call_kwargs = mock_request.call_args + + # Verify all expected kwargs are present + assert "url" in call_kwargs.kwargs + assert "params" in call_kwargs.kwargs + assert "json" in call_kwargs.kwargs + assert "content" in call_kwargs.kwargs + assert "headers" in call_kwargs.kwargs + + # Verify positional arg (method) + assert call_kwargs.args[0] == "POST" + + @pytest.mark.anyio + async def test_update_async_passes_all_kwargs( + self, service: AssetsService + ) -> None: + """Test that update_async passes all kwargs to request_async.""" + mock_response = Mock() + mock_response.json.return_value = { + "id": 1, + "name": "Test", + "value": "updated", + } + + asset = UserAsset(name="Test", value="updated") + + with patch.object( + service, "request_async", return_value=mock_response + ) as mock_request: + await service.update_async(robot_asset=asset) + + mock_request.assert_called_once() + call_kwargs = mock_request.call_args + + # Verify all expected kwargs are present + assert "url" in call_kwargs.kwargs + assert "params" in call_kwargs.kwargs + assert "json" in call_kwargs.kwargs + assert "content" in call_kwargs.kwargs + assert "headers" in call_kwargs.kwargs + + # Verify positional arg (method) + assert call_kwargs.args[0] == "POST" diff --git a/packages/uipath-platform/tests/services/test_attachments_service.py b/packages/uipath-platform/tests/services/test_attachments_service.py new file mode 100644 index 000000000..dfde1a304 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_attachments_service.py @@ -0,0 +1,1195 @@ +import json +import os +import shutil +import uuid +from typing import TYPE_CHECKING, Any, Generator, Tuple + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.attachments import Attachment +from uipath.platform.attachments.attachments import AttachmentMode +from uipath.platform.common.constants import HEADER_USER_AGENT, TEMP_ATTACHMENTS_FOLDER +from uipath.platform.orchestrator._attachments_service import AttachmentsService + +if TYPE_CHECKING: + from _pytest.monkeypatch import MonkeyPatch + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: "MonkeyPatch", +) -> AttachmentsService: + """Fixture that provides a configured AttachmentsService instance for testing. + + Args: + config: The Config fixture with test configuration settings. + execution_context: The UiPathExecutionContext fixture with test execution context. + monkeypatch: PyTest MonkeyPatch fixture for environment modification. + + Returns: + AttachmentsService: A configured instance of AttachmentsService. + """ + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + return AttachmentsService(config=config, execution_context=execution_context) + + +@pytest.fixture +def temp_file(tmp_path: Any) -> Generator[Tuple[str, str, str], None, None]: + """Creates a temporary file for testing file uploads and downloads. + + Args: + tmp_path: PyTest fixture providing a temporary directory. + + Returns: + A tuple containing the file content, file name, and file path. + """ + content = "Test content" + name = f"test_file_{uuid.uuid4()}.txt" + path = os.path.join(tmp_path, name) + + with open(path, "w") as f: + f.write(content) + + yield content, name, path + + # Clean up the file after the test + if os.path.exists(path): + os.remove(path) + + +@pytest.fixture +def temp_attachments_dir(tmp_path: Any) -> Generator[str, None, None]: + """Create a temporary directory for attachments and clean it up after the test. + + Args: + tmp_path: Pytest's temporary directory fixture. + + Returns: + The path to the temporary directory. + """ + test_temp_dir = os.path.join(tmp_path, TEMP_ATTACHMENTS_FOLDER) + os.makedirs(test_temp_dir, exist_ok=True) + + yield test_temp_dir + + # Clean up the directory after the test + if os.path.exists(test_temp_dir): + shutil.rmtree(test_temp_dir) + + +@pytest.fixture +def local_attachment_file( + temp_attachments_dir: str, +) -> Generator[Tuple[uuid.UUID, str, str], None, None]: + """Creates a local attachment file in the temporary attachments directory. + + Args: + temp_attachments_dir: The temporary attachments directory. + + Returns: + A tuple containing the attachment ID, file name, and file content. + """ + attachment_id = uuid.uuid4() + file_name = "test_local_file.txt" + file_content = "Local test content" + + # Create the local file with the format {uuid}_{filename} + file_path = os.path.join(temp_attachments_dir, f"{attachment_id}_{file_name}") + with open(file_path, "w") as f: + f.write(file_content) + + yield attachment_id, file_name, file_content + + # Cleanup is handled by temp_attachments_dir fixture + + +@pytest.fixture +def blob_uri_response() -> dict[str, Any]: + """Provides a mock response for blob access requests. + + Returns: + Dict[str, Any]: A mock API response with blob storage access details. + """ + return { + "Id": "12345678-1234-1234-1234-123456789012", + "Name": "test_file.txt", + "BlobFileAccess": { + "Uri": "https://test-storage.com/test-container/test-blob", + "Headers": { + "Keys": ["x-ms-blob-type", "Content-Type"], + "Values": ["BlockBlob", "application/octet-stream"], + }, + "RequiresAuth": False, + }, + } + + +class TestAttachmentsService: + """Test suite for the AttachmentsService class.""" + + def test_upload_with_file_path( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + version: str, + temp_file: Tuple[str, str, str], + blob_uri_response: dict[str, Any], + ) -> None: + """Test uploading an attachment from a file path. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + version: Version fixture for the user agent header. + temp_file: Temporary file fixture tuple (content, name, path). + blob_uri_response: Mock response fixture for blob operations. + """ + # Arrange + content, file_name, file_path = temp_file + + # Mock the create attachment endpoint + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response, + ) + + # Mock the blob upload + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + # Act + attachment_key = service.upload( + name=file_name, + source_path=file_path, + ) + + # Assert + assert attachment_key == uuid.UUID(blob_uri_response["Id"]) + + # Verify the requests + requests = httpx_mock.get_requests() + assert requests is not None + assert len(requests) == 2 + + # Check the first request to create the attachment + create_request = requests[0] + assert create_request is not None + assert create_request.method == "POST" + assert ( + create_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments" + ) + assert json.loads(create_request.content) == {"Name": file_name} + assert HEADER_USER_AGENT in create_request.headers + assert create_request.headers[HEADER_USER_AGENT].startswith( + f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AttachmentsService.upload/{version}" + ) + + # Check the second request to upload the content + upload_request = requests[1] + assert upload_request is not None + assert upload_request.method == "PUT" + assert upload_request.url == blob_uri_response["BlobFileAccess"]["Uri"] + assert "x-ms-blob-type" in upload_request.headers + assert upload_request.headers["x-ms-blob-type"] == "BlockBlob" + + def test_upload_with_content( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + version: str, + blob_uri_response: dict[str, Any], + ) -> None: + """Test uploading an attachment with in-memory content. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + version: Version fixture for the user agent header. + blob_uri_response: Mock response fixture for blob operations. + """ + # Arrange + content = "Test content in memory" + file_name = "text_content.txt" + + # Mock the create attachment endpoint + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response, + ) + + # Mock the blob upload + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + # Act + attachment_key = service.upload( + name=file_name, + content=content, + ) + + # Assert + assert attachment_key == uuid.UUID(blob_uri_response["Id"]) + + # Verify the requests + requests = httpx_mock.get_requests() + assert requests is not None + assert len(requests) == 2 + + # Check the first request to create the attachment + create_request = requests[0] + assert create_request is not None + assert create_request.method == "POST" + assert ( + create_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments" + ) + assert json.loads(create_request.content) == {"Name": file_name} + assert HEADER_USER_AGENT in create_request.headers + + # Check the second request to upload the content + upload_request = requests[1] + assert upload_request is not None + assert upload_request.method == "PUT" + assert upload_request.url == blob_uri_response["BlobFileAccess"]["Uri"] + assert "x-ms-blob-type" in upload_request.headers + assert upload_request.headers["x-ms-blob-type"] == "BlockBlob" + assert upload_request.content == content.encode("utf-8") + + def test_upload_validation_errors( + self, + service: AttachmentsService, + ) -> None: + """Test validation errors when uploading attachments. + + Args: + service: AttachmentsService fixture. + """ + # Test missing both content and source_path + with pytest.raises(ValueError, match="Content or source_path is required"): + service.upload(name="test.txt") # type: ignore + + # Test providing both content and source_path + with pytest.raises( + ValueError, match="Content and source_path are mutually exclusive" + ): + service.upload( + name="test.txt", content="test content", source_path="/path/to/file.txt" + ) # type: ignore + + @pytest.mark.asyncio + async def test_upload_async_with_content( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + blob_uri_response: dict[str, Any], + ) -> None: + """Test asynchronously uploading an attachment with in-memory content. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + version: Version fixture for the user agent header. + blob_uri_response: Mock response fixture for blob operations. + """ + # Arrange + content = "Test content in memory" + file_name = "text_content.txt" + + # Mock the create attachment endpoint + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response, + ) + + # Mock the blob upload + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + # Act + attachment_key = await service.upload_async( + name=file_name, + content=content, + ) + + # Assert + assert attachment_key == uuid.UUID(blob_uri_response["Id"]) + + # Verify the requests + requests = httpx_mock.get_requests() + assert requests is not None + assert len(requests) == 2 + assert requests is not None + # Check the first request to create the attachment + create_request = requests[0] + assert create_request is not None + assert create_request.method == "POST" + assert create_request is not None + assert ( + create_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments" + ) + assert HEADER_USER_AGENT in create_request.headers + + def test_download( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + version: str, + tmp_path: Any, + blob_uri_response: dict[str, Any], + ) -> None: + """Test downloading an attachment. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + version: Version fixture for the user agent header. + tmp_path: Temporary directory fixture. + blob_uri_response: Mock response fixture for blob operations. + """ + # Arrange + attachment_key = uuid.UUID("12345678-1234-1234-1234-123456789012") + destination_path = os.path.join(tmp_path, "downloaded_file.txt") + file_content = b"Downloaded file content" + expected_name = blob_uri_response["Name"] + + # Mock the get attachment endpoint + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + method="GET", + status_code=200, + json=blob_uri_response, + ) + + # Mock the blob download + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="GET", + status_code=200, + content=file_content, + ) + + # Act + result = service.download( + key=attachment_key, + destination_path=destination_path, + ) + + # Assert + assert result == expected_name + assert os.path.exists(destination_path) + with open(destination_path, "rb") as f: + assert f.read() == file_content + + # Verify the requests + requests = httpx_mock.get_requests() + assert requests is not None + assert len(requests) == 2 + assert requests is not None + # Check the first request to get the attachment metadata + get_request = requests[0] + assert get_request is not None + assert get_request.method == "GET" + assert get_request is not None + assert ( + get_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" + ) + assert HEADER_USER_AGENT in get_request.headers + + # Check the second request to download the content + download_request = requests[1] + assert download_request is not None + assert download_request.method == "GET" + assert download_request is not None + + @pytest.mark.asyncio + async def test_download_async( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + version: str, + tmp_path: Any, + blob_uri_response: dict[str, Any], + ) -> None: + """Test asynchronously downloading an attachment. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + version: Version fixture for the user agent header. + tmp_path: Temporary directory fixture. + blob_uri_response: Mock response fixture for blob operations. + """ + # Arrange + attachment_key = uuid.UUID("12345678-1234-1234-1234-123456789012") + destination_path = os.path.join(tmp_path, "downloaded_file_async.txt") + file_content = b"Downloaded file content async" + expected_name = blob_uri_response["Name"] + + # Mock the get attachment endpoint + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + method="GET", + status_code=200, + json=blob_uri_response, + ) + + # Mock the blob download + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="GET", + status_code=200, + content=file_content, + ) + + # Act + result = await service.download_async( + key=attachment_key, + destination_path=destination_path, + ) + + # Assert + assert result == expected_name + assert os.path.exists(destination_path) + with open(destination_path, "rb") as f: + assert f.read() == file_content + + def test_delete( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test deleting an attachment. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + version: Version fixture for the user agent header. + """ + # Arrange + attachment_key = uuid.UUID("12345678-1234-1234-1234-123456789012") + + # Mock the delete attachment endpoint + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + method="DELETE", + status_code=204, + ) + + # Act + service.delete(key=attachment_key) + + # Verify the request + request = httpx_mock.get_request() + assert request is not None + assert request.method == "DELETE" + assert request is not None + assert ( + request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" + ) + assert HEADER_USER_AGENT in request.headers + assert request.headers[HEADER_USER_AGENT].startswith( + f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AttachmentsService.delete/{version}" + ) + + @pytest.mark.asyncio + async def test_delete_async( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test asynchronously deleting an attachment. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + version: Version fixture for the user agent header. + """ + # Arrange + attachment_key = uuid.UUID("12345678-1234-1234-1234-123456789012") + + # Mock the delete attachment endpoint + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + method="DELETE", + status_code=204, + ) + + # Act + await service.delete_async(key=attachment_key) + + # Verify the request + request = httpx_mock.get_request() + assert request is not None + assert request.method == "DELETE" + assert request is not None + assert ( + request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" + ) + assert HEADER_USER_AGENT in request.headers + assert request.headers[HEADER_USER_AGENT].startswith( + f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.AttachmentsService.delete_async/{version}" + ) + + def test_download_local_fallback( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Any, + temp_attachments_dir: str, + local_attachment_file: Tuple[uuid.UUID, str, str], + ) -> None: + """Test downloading an attachment with local fallback. + + This test verifies the fallback mechanism when an attachment is not found in UiPath + but exists in the local temporary storage. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + tmp_path: Temporary directory fixture. + temp_attachments_dir: Fixture for temporary attachments directory. + local_attachment_file: Fixture providing an attachment file in the temporary directory. + """ + # Arrange + attachment_id, file_name, file_content = local_attachment_file + destination_path = os.path.join(tmp_path, "downloaded_file.txt") + + # Replace the temp_dir in the service to use our test directory + service._temp_dir = temp_attachments_dir + + # Mock the 404 response for UiPath attachment + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="GET", + status_code=404, + json={"error": "Attachment not found"}, + ) + + # Act + result = service.download( + key=attachment_id, + destination_path=destination_path, + ) + + # Assert + assert result == file_name + assert os.path.exists(destination_path) + + with open(destination_path, "r") as f: + assert f.read() == file_content + + @pytest.mark.asyncio + async def test_download_async_local_fallback( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Any, + temp_attachments_dir: str, + local_attachment_file: Tuple[uuid.UUID, str, str], + ) -> None: + """Test asynchronously downloading an attachment with local fallback. + + This test verifies the fallback mechanism when an attachment is not found in UiPath + but exists in the local temporary storage, using the async method. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + tmp_path: Temporary directory fixture. + temp_attachments_dir: Fixture for temporary attachments directory. + local_attachment_file: Fixture providing an attachment file in the temporary directory. + """ + # Arrange + attachment_id, file_name, file_content = local_attachment_file + destination_path = os.path.join(tmp_path, "downloaded_file_async.txt") + + # Replace the temp_dir in the service to use our test directory + service._temp_dir = temp_attachments_dir + + # Mock the 404 response for UiPath attachment + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="GET", + status_code=404, + json={"error": "Attachment not found"}, + ) + + # Act + result = await service.download_async( + key=attachment_id, + destination_path=destination_path, + ) + + # Assert + assert result == file_name + assert os.path.exists(destination_path) + + with open(destination_path, "r") as f: + assert f.read() == file_content + + def test_delete_local_fallback( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + temp_attachments_dir: str, + local_attachment_file: Tuple[uuid.UUID, str, str], + ) -> None: + """Test deleting an attachment with local fallback. + + This test verifies the fallback mechanism when an attachment is not found in UiPath + but exists in the local temporary storage. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + temp_attachments_dir: Fixture for temporary attachments directory. + local_attachment_file: Fixture providing an attachment file in the temporary directory. + """ + # Arrange + attachment_id, file_name, _ = local_attachment_file + + # Replace the temp_dir in the service to use our test directory + service._temp_dir = temp_attachments_dir + + # Verify the file exists before deletion + expected_path = os.path.join( + temp_attachments_dir, f"{attachment_id}_{file_name}" + ) + assert os.path.exists(expected_path) + + # Mock the 404 response for UiPath attachment + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="DELETE", + status_code=404, + json={"error": "Attachment not found"}, + ) + + # Act + service.delete(key=attachment_id) + + # Assert - verify the file was deleted + assert not os.path.exists(expected_path) + + @pytest.mark.asyncio + async def test_delete_async_local_fallback( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + temp_attachments_dir: str, + local_attachment_file: Tuple[uuid.UUID, str, str], + ) -> None: + """Test asynchronously deleting an attachment with local fallback. + + This test verifies the fallback mechanism when an attachment is not found in UiPath + but exists in the local temporary storage, using the async method. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + temp_attachments_dir: Fixture for temporary attachments directory. + local_attachment_file: Fixture providing an attachment file in the temporary directory. + """ + # Arrange + attachment_id, file_name, _ = local_attachment_file + + # Replace the temp_dir in the service to use our test directory + service._temp_dir = temp_attachments_dir + + # Verify the file exists before deletion + expected_path = os.path.join( + temp_attachments_dir, f"{attachment_id}_{file_name}" + ) + assert os.path.exists(expected_path) + + # Mock the 404 response for UiPath attachment + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="DELETE", + status_code=404, + json={"error": "Attachment not found"}, + ) + + # Act + await service.delete_async(key=attachment_id) + + # Assert - verify the file was deleted + assert not os.path.exists(expected_path) + + def test_delete_not_found_throws_exception( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test that deleting a non-existent attachment throws an exception. + + This test verifies that when an attachment is not found in UiPath + and not found locally, an exception is raised. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + """ + # Arrange + attachment_id = uuid.uuid4() + + # Set a non-existent temp dir to ensure no local files are found + service._temp_dir = "non_existent_dir" + + # Mock the 404 response for UiPath attachment + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="DELETE", + status_code=404, + json={"error": "Attachment not found"}, + ) + + # Act & Assert + with pytest.raises( + Exception, + match=f"Attachment with key {attachment_id} not found in UiPath or local storage", + ): + service.delete(key=attachment_id) + + @pytest.mark.asyncio + async def test_delete_async_not_found_throws_exception( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test that asynchronously deleting a non-existent attachment throws an exception. + + This test verifies that when an attachment is not found in UiPath + and not found locally, an exception is raised when using the async method. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + """ + # Arrange + attachment_id = uuid.uuid4() + + # Set a non-existent temp dir to ensure no local files are found + service._temp_dir = "non_existent_dir" + + # Mock the 404 response for UiPath attachment + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="DELETE", + status_code=404, + json={"error": "Attachment not found"}, + ) + + # Act & Assert + with pytest.raises( + Exception, + match=f"Attachment with key {attachment_id} not found in UiPath or local storage", + ): + await service.delete_async(key=attachment_id) + + def test_open_read_mode( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + version: str, + blob_uri_response: dict[str, Any], + ) -> None: + """Test opening an attachment in READ mode. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + version: Version fixture for the user agent header. + blob_uri_response: Mock response fixture for blob operations. + """ + # Arrange + attachment_key = uuid.UUID("12345678-1234-1234-1234-123456789012") + file_content = b"Test file content for reading" + attachment = Attachment( # type: ignore[call-arg] + ID=attachment_key, + FullName="test_file.txt", + MimeType="text/plain", + ) + + # Mock the get attachment endpoint + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + method="GET", + status_code=200, + json=blob_uri_response, + ) + + # Mock the blob download + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="GET", + status_code=200, + content=file_content, + ) + + # Act & Assert + with service.open(attachment=attachment, mode=AttachmentMode.READ) as ( + resource, + response, + ): + assert resource.id == uuid.UUID(blob_uri_response["Id"]) + assert response.status_code == 200 + content = response.read() + assert content == file_content + + # Verify the requests + requests = httpx_mock.get_requests() + assert requests is not None + assert len(requests) == 2 + + # Check the first request to get the attachment metadata + get_request = requests[0] + assert get_request is not None + assert get_request.method == "GET" + assert ( + get_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" + ) + assert HEADER_USER_AGENT in get_request.headers + + # Check the second request to stream the content + stream_request = requests[1] + assert stream_request is not None + assert stream_request.method == "GET" + assert stream_request.url == blob_uri_response["BlobFileAccess"]["Uri"] + + def test_open_write_mode( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + version: str, + blob_uri_response: dict[str, Any], + ) -> None: + """Test opening an attachment in WRITE mode. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + version: Version fixture for the user agent header. + blob_uri_response: Mock response fixture for blob operations. + """ + # Arrange + file_name = "test_write_file.txt" + file_content = b"Content to write" + attachment = Attachment( # type: ignore[call-arg] + ID=uuid.uuid4(), + FullName=file_name, + MimeType="text/plain", + ) + + # Mock the create attachment endpoint + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response, + ) + + # Mock the blob upload + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + # Act & Assert + with service.open( + attachment=attachment, mode=AttachmentMode.WRITE, content=file_content + ) as (resource, response): + assert resource.id == uuid.UUID(blob_uri_response["Id"]) + assert response.status_code == 201 + + # Verify the requests + requests = httpx_mock.get_requests() + assert requests is not None + assert len(requests) == 2 + + # Check the first request to create the attachment + create_request = requests[0] + assert create_request is not None + assert create_request.method == "POST" + assert ( + create_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments" + ) + assert json.loads(create_request.content) == {"Name": file_name} + assert HEADER_USER_AGENT in create_request.headers + + # Check the second request to upload the content + upload_request = requests[1] + assert upload_request is not None + assert upload_request.method == "PUT" + assert upload_request.url == blob_uri_response["BlobFileAccess"]["Uri"] + + @pytest.mark.asyncio + async def test_open_async_read_mode( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + version: str, + blob_uri_response: dict[str, Any], + ) -> None: + """Test asynchronously opening an attachment in READ mode. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + version: Version fixture for the user agent header. + blob_uri_response: Mock response fixture for blob operations. + """ + # Arrange + attachment_key = uuid.UUID("12345678-1234-1234-1234-123456789012") + file_content = b"Test file content for async reading" + attachment = Attachment( # type: ignore[call-arg] + ID=attachment_key, + FullName="test_file_async.txt", + MimeType="text/plain", + ) + + # Mock the get attachment endpoint + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})", + method="GET", + status_code=200, + json=blob_uri_response, + ) + + # Mock the blob download + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="GET", + status_code=200, + content=file_content, + ) + + # Act & Assert + async with service.open_async( + attachment=attachment, mode=AttachmentMode.READ + ) as (resource, response): + assert resource.id == uuid.UUID(blob_uri_response["Id"]) + assert response.status_code == 200 + content = await response.aread() + assert content == file_content + + # Verify the requests + requests = httpx_mock.get_requests() + assert requests is not None + assert len(requests) == 2 + + # Check the first request to get the attachment metadata + get_request = requests[0] + assert get_request is not None + assert get_request.method == "GET" + assert ( + get_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_key})" + ) + assert HEADER_USER_AGENT in get_request.headers + + # Check the second request to stream the content + stream_request = requests[1] + assert stream_request is not None + assert stream_request.method == "GET" + assert stream_request.url == blob_uri_response["BlobFileAccess"]["Uri"] + + @pytest.mark.asyncio + async def test_open_async_write_mode( + self, + httpx_mock: HTTPXMock, + service: AttachmentsService, + base_url: str, + org: str, + tenant: str, + version: str, + blob_uri_response: dict[str, Any], + ) -> None: + """Test asynchronously opening an attachment in WRITE mode. + + Args: + httpx_mock: HTTPXMock fixture for mocking HTTP requests. + service: AttachmentsService fixture. + base_url: Base URL fixture for the API endpoint. + org: Organization fixture for the API path. + tenant: Tenant fixture for the API path. + version: Version fixture for the user agent header. + blob_uri_response: Mock response fixture for blob operations. + """ + # Arrange + file_name = "test_write_file_async.txt" + file_content = b"Content to write async" + attachment = Attachment( # type: ignore[call-arg] + ID=uuid.uuid4(), + FullName=file_name, + MimeType="text/plain", + ) + + # Mock the create attachment endpoint + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=200, + json=blob_uri_response, + ) + + # Mock the blob upload + httpx_mock.add_response( + url=blob_uri_response["BlobFileAccess"]["Uri"], + method="PUT", + status_code=201, + ) + + # Act & Assert + async with service.open_async( + attachment=attachment, mode=AttachmentMode.WRITE, content=file_content + ) as (resource, response): + assert resource.id == uuid.UUID(blob_uri_response["Id"]) + assert response.status_code == 201 + + # Verify the requests + requests = httpx_mock.get_requests() + assert requests is not None + assert len(requests) == 2 + + # Check the first request to create the attachment + create_request = requests[0] + assert create_request is not None + assert create_request.method == "POST" + assert ( + create_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments" + ) + assert json.loads(create_request.content) == {"Name": file_name} + assert HEADER_USER_AGENT in create_request.headers + + # Check the second request to upload the content + upload_request = requests[1] + assert upload_request is not None + assert upload_request.method == "PUT" + assert upload_request.url == blob_uri_response["BlobFileAccess"]["Uri"] diff --git a/packages/uipath-platform/tests/services/test_base_service.py b/packages/uipath-platform/tests/services/test_base_service.py new file mode 100644 index 000000000..bfe78fd18 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_base_service.py @@ -0,0 +1,383 @@ +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common._base_service import BaseService +from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.errors import EnrichedException + + +@pytest.fixture +def service( + config: UiPathApiConfig, execution_context: UiPathExecutionContext +) -> BaseService: + return BaseService(config=config, execution_context=execution_context) + + +class TestBaseService: + def test_init_base_service(self, service: BaseService): + assert service is not None + + def test_base_service_default_headers(self, service: BaseService, secret: str): + assert service.default_headers == { + "Accept": "application/json", + "Authorization": f"Bearer {secret}", + } + + class TestRequest: + def test_simple_request( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + version: str, + secret: str, + ): + endpoint = "/endpoint" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{endpoint}", + status_code=200, + json={"test": "test"}, + ) + + response = service.request("GET", endpoint) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert sent_request.url == f"{base_url}{org}{tenant}{endpoint}" + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.TestRequest.test_simple_request/{version}" + ) + assert sent_request.headers["Authorization"] == f"Bearer {secret}" + + assert response is not None + assert response.status_code == 200 + assert response.json() == {"test": "test"} + + class TestRequestAsync: + @pytest.mark.anyio + async def test_simple_request_async( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + version: str, + secret: str, + ): + endpoint = "/endpoint" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}{endpoint}", + status_code=200, + json={"test": "test"}, + ) + + response = await service.request_async("GET", endpoint) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert sent_request.url == f"{base_url}{org}{tenant}{endpoint}" + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.TestRequestAsync.test_simple_request_async/{version}" + ) + assert sent_request.headers["Authorization"] == f"Bearer {secret}" + + assert response is not None + assert response.status_code == 200 + assert response.json() == {"test": "test"} + + +class TestRetryBehavior: + """Integration tests for retry behavior in BaseService.""" + + def _url(self, base_url: str, org: str, tenant: str) -> str: + return f"{base_url}{org}{tenant}/endpoint" + + def test_429_with_retry_after_retried( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + ): + url = self._url(base_url, org, tenant) + httpx_mock.add_response(url=url, status_code=429, headers={"retry-after": "0"}) + httpx_mock.add_response(url=url, status_code=200, json={"ok": True}) + + response = service.request("GET", "/endpoint") + assert response.status_code == 200 + assert len(httpx_mock.get_requests()) == 2 + + def test_503_retried( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + ): + url = self._url(base_url, org, tenant) + httpx_mock.add_response(url=url, status_code=503) + httpx_mock.add_response(url=url, status_code=200, json={"ok": True}) + + response = service.request("GET", "/endpoint") + assert response.status_code == 200 + assert len(httpx_mock.get_requests()) == 2 + + def test_400_not_retried( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + ): + url = self._url(base_url, org, tenant) + httpx_mock.add_response(url=url, status_code=400) + + with pytest.raises(EnrichedException) as exc_info: + service.request("GET", "/endpoint") + assert exc_info.value.status_code == 400 + assert len(httpx_mock.get_requests()) == 1 + + def test_404_not_retried( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + ): + url = self._url(base_url, org, tenant) + httpx_mock.add_response(url=url, status_code=404) + + with pytest.raises(EnrichedException) as exc_info: + service.request("GET", "/endpoint") + assert exc_info.value.status_code == 404 + assert len(httpx_mock.get_requests()) == 1 + + def test_500_not_retried( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + ): + url = self._url(base_url, org, tenant) + httpx_mock.add_response(url=url, status_code=500) + + with pytest.raises(EnrichedException) as exc_info: + service.request("GET", "/endpoint") + assert exc_info.value.status_code == 500 + assert len(httpx_mock.get_requests()) == 1 + + def test_max_retries_exhausted( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + ): + url = self._url(base_url, org, tenant) + for _ in range(5): + httpx_mock.add_response( + url=url, status_code=429, headers={"retry-after": "0"} + ) + + with pytest.raises(EnrichedException) as exc_info: + service.request("GET", "/endpoint") + assert exc_info.value.status_code == 429 + assert len(httpx_mock.get_requests()) == 5 + + def test_502_retried( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + ): + url = self._url(base_url, org, tenant) + httpx_mock.add_response(url=url, status_code=502) + httpx_mock.add_response(url=url, status_code=200, json={"ok": True}) + + response = service.request("GET", "/endpoint") + assert response.status_code == 200 + assert len(httpx_mock.get_requests()) == 2 + + @pytest.mark.anyio + async def test_429_retried_async( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + ): + url = self._url(base_url, org, tenant) + httpx_mock.add_response(url=url, status_code=429, headers={"retry-after": "0"}) + httpx_mock.add_response(url=url, status_code=200, json={"ok": True}) + + response = await service.request_async("GET", "/endpoint") + assert response.status_code == 200 + assert len(httpx_mock.get_requests()) == 2 + + @pytest.mark.anyio + async def test_503_retried_async( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + ): + url = self._url(base_url, org, tenant) + httpx_mock.add_response(url=url, status_code=503) + httpx_mock.add_response(url=url, status_code=200, json={"ok": True}) + + response = await service.request_async("GET", "/endpoint") + assert response.status_code == 200 + assert len(httpx_mock.get_requests()) == 2 + + @pytest.mark.anyio + async def test_400_not_retried_async( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + ): + url = self._url(base_url, org, tenant) + httpx_mock.add_response(url=url, status_code=400) + + with pytest.raises(EnrichedException) as exc_info: + await service.request_async("GET", "/endpoint") + assert exc_info.value.status_code == 400 + assert len(httpx_mock.get_requests()) == 1 + + @pytest.mark.anyio + async def test_max_retries_exhausted_async( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + ): + url = self._url(base_url, org, tenant) + for _ in range(5): + httpx_mock.add_response( + url=url, status_code=429, headers={"retry-after": "0"} + ) + + with pytest.raises(EnrichedException) as exc_info: + await service.request_async("GET", "/endpoint") + assert exc_info.value.status_code == 429 + assert len(httpx_mock.get_requests()) == 5 + + +class TestServiceUrlOverride: + def test_request_uses_override_url( + self, + httpx_mock: HTTPXMock, + service: BaseService, + monkeypatch: pytest.MonkeyPatch, + version: str, + ) -> None: + monkeypatch.setenv("UIPATH_SERVICE_URL_AGENTHUB", "http://localhost:5200") + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-123") + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-456") + + httpx_mock.add_response( + url="http://localhost:5200/llm/api/chat/completions", + status_code=200, + json={"result": "ok"}, + ) + + response = service.request("POST", "/agenthub_/llm/api/chat/completions") + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert str(sent_request.url) == "http://localhost:5200/llm/api/chat/completions" + assert sent_request.headers["X-UiPath-Internal-TenantId"] == "tenant-123" + assert sent_request.headers["X-UiPath-Internal-AccountId"] == "org-456" + assert response.status_code == 200 + + def test_request_no_override_uses_normal_scoping( + self, + httpx_mock: HTTPXMock, + service: BaseService, + base_url: str, + org: str, + tenant: str, + version: str, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + monkeypatch.delenv("UIPATH_SERVICE_URL_ORCHESTRATOR", raising=False) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", + status_code=200, + json={"value": []}, + ) + + response = service.request("GET", "/orchestrator_/odata/Buckets") + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert ( + str(sent_request.url) + == f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets" + ) + assert response.status_code == 200 + + +class TestServiceUrlOverrideAsync: + @pytest.mark.anyio + async def test_request_async_uses_override_url( + self, + httpx_mock: HTTPXMock, + service: BaseService, + monkeypatch: pytest.MonkeyPatch, + version: str, + ) -> None: + monkeypatch.setenv("UIPATH_SERVICE_URL_AGENTHUB", "http://localhost:5200") + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-123") + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-456") + + httpx_mock.add_response( + url="http://localhost:5200/llm/api/chat/completions", + status_code=200, + json={"result": "ok"}, + ) + + response = await service.request_async( + "POST", "/agenthub_/llm/api/chat/completions" + ) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert str(sent_request.url) == "http://localhost:5200/llm/api/chat/completions" + assert sent_request.headers["X-UiPath-Internal-TenantId"] == "tenant-123" + assert sent_request.headers["X-UiPath-Internal-AccountId"] == "org-456" + assert response.status_code == 200 diff --git a/packages/uipath-platform/tests/services/test_buckets_service.py b/packages/uipath-platform/tests/services/test_buckets_service.py new file mode 100644 index 000000000..0fbb5f974 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_buckets_service.py @@ -0,0 +1,1939 @@ +import os +from pathlib import Path + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.orchestrator._buckets_service import BucketsService + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> BucketsService: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + return BucketsService(config=config, execution_context=execution_context) + + +@pytest.fixture +def temp_file(tmp_path): + """Create a temporary file for testing.""" + file_path = tmp_path / "test.txt" + file_path.write_text("test content") + return str(file_path) + + +class TestBucketsService: + class TestRetrieve: + def test_retrieve_by_key( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + bucket_key = "bucket-key" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='{bucket_key}')", + status_code=200, + json={ + "value": [ + {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} + ] + }, + ) + + bucket = service.retrieve(key=bucket_key) + assert bucket.id == 123 + assert bucket.name == "test-bucket" + assert bucket.identifier == "bucket-key" + + def test_retrieve_by_name( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + bucket_name = "test-bucket" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq '{bucket_name}'&$top=1", + status_code=200, + json={ + "value": [ + {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} + ] + }, + ) + + bucket = service.retrieve(name=bucket_name) + assert bucket.id == 123 + assert bucket.name == "test-bucket" + assert bucket.identifier == "bucket-key" + + @pytest.mark.asyncio + async def test_retrieve_by_key_async( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + bucket_key = "bucket-key" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='{bucket_key}')", + status_code=200, + json={ + "value": [ + {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} + ] + }, + ) + + bucket = await service.retrieve_async(key=bucket_key) + assert bucket.id == 123 + assert bucket.name == "test-bucket" + assert bucket.identifier == "bucket-key" + + @pytest.mark.asyncio + async def test_retrieve_by_name_async( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + bucket_name = "test-bucket" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq '{bucket_name}'&$top=1", + status_code=200, + json={ + "value": [ + {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} + ] + }, + ) + + bucket = await service.retrieve_async(name=bucket_name) + assert bucket.id == 123 + assert bucket.name == "test-bucket" + assert bucket.identifier == "bucket-key" + + class TestDownload: + def test_download( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + tmp_path: Path, + ): + bucket_key = "bucket-key" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='{bucket_key}')", + status_code=200, + json={ + "value": [ + {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetReadUri?path=test-file.txt", + status_code=200, + json={ + "Uri": "https://test-storage.com/test-file.txt", + "Headers": {"Keys": [], "Values": []}, + "RequiresAuth": False, + }, + ) + + httpx_mock.add_response( + url="https://test-storage.com/test-file.txt", + status_code=200, + content=b"test content", + ) + + destination_path = str(tmp_path / "downloaded.txt") + service.download( + key=bucket_key, + blob_file_path="test-file.txt", + destination_path=destination_path, + ) + + assert os.path.exists(destination_path) + with open(destination_path, "rb") as f: + assert f.read() == b"test content" + + class TestUpload: + def test_upload_from_path( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + temp_file: str, + ): + bucket_key = "bucket-key" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='{bucket_key}')", + status_code=200, + json={ + "value": [ + {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetWriteUri?path=test-file.txt&contentType=text/plain", + status_code=200, + json={ + "Uri": "https://test-storage.com/test-file.txt", + "Headers": {"Keys": [], "Values": []}, + "RequiresAuth": False, + }, + ) + + httpx_mock.add_response( + url="https://test-storage.com/test-file.txt", + status_code=200, + content=b"test content", + ) + + service.upload( + key=bucket_key, + blob_file_path="test-file.txt", + content_type="text/plain", + source_path=temp_file, + ) + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 3 + + assert sent_requests[2].method == "PUT" + assert sent_requests[2].url == "https://test-storage.com/test-file.txt" + + assert b"test content" in sent_requests[2].content + + def test_upload_from_memory( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + bucket_key = "bucket-key" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='{bucket_key}')", + status_code=200, + json={ + "value": [ + {"Id": 123, "Name": "test-bucket", "Identifier": "bucket-key"} + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetWriteUri?path=test-file.txt&contentType=text/plain", + status_code=200, + json={ + "Uri": "https://test-storage.com/test-file.txt", + "Headers": {"Keys": [], "Values": []}, + "RequiresAuth": False, + }, + ) + + httpx_mock.add_response( + url="https://test-storage.com/test-file.txt", + status_code=200, + content=b"test content", + ) + + service.upload( + key=bucket_key, + blob_file_path="test-file.txt", + content_type="text/plain", + content="test content", + ) + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 3 + + assert sent_requests[2].method == "PUT" + assert sent_requests[2].url == "https://test-storage.com/test-file.txt" + assert sent_requests[2].content == b"test content" + + +class TestList: + """Tests for list() method with auto-pagination.""" + + def test_list_all_buckets( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test listing buckets returns single page.""" + # Mock single page (100 items) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100", + status_code=200, + json={ + "value": [ + {"Id": i, "Name": f"bucket-{i}", "Identifier": f"id-{i}"} + for i in range(100) + ] + }, + ) + + # Single page - no auto-pagination + buckets = list(service.list()) + assert len(buckets) == 100 + assert buckets[0].id == 0 + assert buckets[99].id == 99 + + def test_list_with_name_filter( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test filtering by bucket name.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100&$filter=contains%28tolower%28Name%29%2C+tolower%28%27test%27%29%29", + status_code=200, + json={ + "value": [ + {"Id": 1, "Name": "test-bucket", "Identifier": "id-1"}, + {"Id": 2, "Name": "another-test", "Identifier": "id-2"}, + ] + }, + ) + + buckets = list(service.list(name="test")) + assert len(buckets) == 2 + assert buckets[0].name == "test-bucket" + + def test_list_with_folder_path( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test listing with folder context.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100", + status_code=200, + json={"value": [{"Id": 1, "Name": "bucket-1", "Identifier": "id-1"}]}, + match_headers={"x-uipath-folderpath": "Production"}, + ) + + buckets = list(service.list(folder_path="Production")) + assert len(buckets) == 1 + + def test_list_empty_results( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test list() with no buckets.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100", + status_code=200, + json={"value": []}, + ) + + buckets = list(service.list()) + assert len(buckets) == 0 + + def test_list_pagination_stops_on_partial_page( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test pagination stops when fewer items than page size.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100", + status_code=200, + json={ + "value": [ + {"Id": i, "Name": f"bucket-{i}", "Identifier": f"id-{i}"} + for i in range(30) + ] + }, + ) + + buckets = list(service.list()) + assert len(buckets) == 30 + # Verify only one request was made (no pagination) + assert len(httpx_mock.get_requests()) == 1 + + @pytest.mark.asyncio + async def test_list_async( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test async version of list().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100", + status_code=200, + json={ + "value": [ + {"Id": i, "Name": f"bucket-{i}", "Identifier": f"id-{i}"} + for i in range(10) + ] + }, + ) + + buckets = [] + for bucket in (await service.list_async()).items: + buckets.append(bucket) + + assert len(buckets) == 10 + + +class TestExists: + """Tests for exists() method.""" + + def test_exists_bucket_found( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test exists() returns True when bucket found.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 1, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + assert service.exists("test-bucket") is True + + def test_exists_bucket_not_found( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test exists() returns False for LookupError.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'nonexistent'&$top=1", + status_code=200, + json={"value": []}, + ) + + assert service.exists("nonexistent") is False + + def test_exists_propagates_network_errors( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test exists() propagates non-LookupError exceptions.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'error-bucket'&$top=1", + status_code=500, + ) + + # Should raise exception (not return False) + from uipath.platform.errors import EnrichedException + + with pytest.raises(EnrichedException): + service.exists("error-bucket") + + @pytest.mark.asyncio + async def test_exists_async( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test async version of exists().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'async-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 1, "Name": "async-bucket", "Identifier": "id-1"}]}, + ) + + result = await service.exists_async("async-bucket") + assert result is True + + +class TestCreate: + """Tests for create() method.""" + + def test_create_with_auto_uuid( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test create() auto-generates UUID if not provided.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", + status_code=201, + json={"Id": 1, "Name": "new-bucket", "Identifier": "auto-uuid-123"}, + match_content=None, # We'll check the request separately + ) + + bucket = service.create("new-bucket") + assert bucket.id == 1 + assert bucket.name == "new-bucket" + + # Verify UUID was in request + requests = httpx_mock.get_requests() + assert len(requests) == 1 + import json + + body = json.loads(requests[0].content) + assert "Identifier" in body + assert len(body["Identifier"]) > 0 # UUID generated + + def test_create_with_explicit_uuid( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test create() uses provided UUID.""" + custom_uuid = "custom-uuid-456" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", + status_code=201, + json={ + "Id": 1, + "Name": "new-bucket", + "Identifier": custom_uuid, + }, + ) + + bucket = service.create("new-bucket", identifier=custom_uuid) + assert bucket.identifier == custom_uuid + + # Verify exact UUID in request + requests = httpx_mock.get_requests() + import json + + body = json.loads(requests[0].content) + assert body["Identifier"] == custom_uuid + + def test_create_with_description( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test create() includes description.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", + status_code=201, + json={ + "Id": 1, + "Name": "new-bucket", + "Identifier": "id-1", + "Description": "Test description", + }, + ) + + service.create("new-bucket", description="Test description") + + # Verify Description field in request body + requests = httpx_mock.get_requests() + import json + + body = json.loads(requests[0].content) + assert body["Description"] == "Test description" + + def test_create_with_folder_context( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test create() with folder_path.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", + status_code=201, + json={"Id": 1, "Name": "new-bucket", "Identifier": "id-1"}, + match_headers={"x-uipath-folderpath": "Production"}, + ) + + bucket = service.create("new-bucket", folder_path="Production") + assert bucket.id == 1 + + def test_create_name_escaping( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test bucket names with special chars don't break creation.""" + bucket_name = "Test's Bucket" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", + status_code=201, + json={"Id": 1, "Name": bucket_name, "Identifier": "id-1"}, + ) + + bucket = service.create(bucket_name) + assert bucket.name == bucket_name + + @pytest.mark.asyncio + async def test_create_async( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test async version of create().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets", + status_code=201, + json={"Id": 1, "Name": "async-bucket", "Identifier": "id-1"}, + ) + + bucket = await service.create_async("async-bucket") + assert bucket.id == 1 + + +class TestEdgeCases: + """Tests for edge cases and error handling.""" + + def test_retrieve_with_quotes_in_name( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test bucket name with single quotes (OData escaping).""" + bucket_name = "Test's Bucket" + escaped_name = "Test''s Bucket" # OData escaping + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq '{escaped_name}'&$top=1", + status_code=200, + json={"value": [{"Id": 1, "Name": bucket_name, "Identifier": "id-1"}]}, + ) + + bucket = service.retrieve(name=bucket_name) + assert bucket.name == bucket_name + + def test_retrieve_key_not_found( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test retrieve by key raises LookupError.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets/UiPath.Server.Configuration.OData.GetByKey(identifier='nonexistent')", + status_code=200, + json={"value": []}, + ) + + with pytest.raises(LookupError, match="key 'nonexistent' not found"): + service.retrieve(key="nonexistent") + + def test_retrieve_name_not_found( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test retrieve by name raises LookupError.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'nonexistent'&$top=1", + status_code=200, + json={"value": []}, + ) + + with pytest.raises(LookupError, match="name 'nonexistent' not found"): + service.retrieve(name="nonexistent") + + def test_list_handles_odata_collection_wrapper( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test list() handles OData 'value' array correctly.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=100", + status_code=200, + json={ + "value": [{"Id": 1, "Name": "bucket-1", "Identifier": "id-1"}], + "@odata.context": "https://example.com/$metadata#Buckets", + }, + ) + + buckets = list(service.list()) + assert len(buckets) == 1 + assert buckets[0].id == 1 + + +class TestListFiles: + """Tests for list_files() method (REST ListFiles API).""" + + def test_list_files_basic( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test basic file listing with list_files().""" + # Mock bucket retrieve + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + # Mock ListFiles response + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?takeHint=500", + status_code=200, + json={ + "items": [ + { + "fullPath": "/data/file1.txt", + "contentType": "text/plain", + "size": 100, + "lastModified": "2024-01-01T00:00:00Z", + }, + { + "fullPath": "/data/file2.txt", + "contentType": "text/plain", + "size": 200, + "lastModified": "2024-01-02T00:00:00Z", + }, + ], + "continuationToken": None, + }, + ) + + result = service.list_files(name="test-bucket") + + files = result.items + token = result.continuation_token + assert token is None # No more pages + assert len(files) == 2 + assert files[0].path == "/data/file1.txt" + assert files[0].size == 100 + assert files[1].path == "/data/file2.txt" + assert files[1].size == 200 + + def test_list_files_with_prefix( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test list_files() with prefix filter.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=data&takeHint=500", + status_code=200, + json={ + "items": [ + { + "fullPath": "/data/file1.txt", + "contentType": "text/plain", + "size": 100, + "lastModified": "2024-01-01T00:00:00Z", + } + ], + "continuationToken": None, + }, + ) + + result = service.list_files(name="test-bucket", prefix="data") + + files = result.items + token = result.continuation_token + assert token is None + assert len(files) == 1 + assert files[0].path == "/data/file1.txt" + + def test_list_files_pagination( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test list_files() handles pagination with continuationToken.""" + # Mock bucket retrieval (called twice - once for each page) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + # First page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?takeHint=500", + status_code=200, + json={ + "items": [ + { + "fullPath": f"/file{i}.txt", + "contentType": "text/plain", + "size": 100, + "lastModified": "2024-01-01T00:00:00Z", + } + for i in range(500) + ], + "continuationToken": "page2token", + }, + ) + + # Second page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?continuationToken=page2token&takeHint=500", + status_code=200, + json={ + "items": [ + { + "fullPath": f"/file{i}.txt", + "contentType": "text/plain", + "size": 100, + "lastModified": "2024-01-01T00:00:00Z", + } + for i in range(500, 550) + ], + "continuationToken": None, + }, + ) + + # Manual pagination + all_files = [] + token = None + + # First page + result = service.list_files(name="test-bucket", continuation_token=token) + + files = result.items + token = result.continuation_token + assert len(files) == 500 + assert token == "page2token" + all_files.extend(files) + + # Second page + result = service.list_files(name="test-bucket", continuation_token=token) + + files = result.items + token = result.continuation_token + assert len(files) == 50 + assert token is None # No more pages + all_files.extend(files) + + assert len(all_files) == 550 + + def test_list_files_empty( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test list_files() with empty bucket.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'empty-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 456, "Name": "empty-bucket", "Identifier": "id-2"}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/456/ListFiles?takeHint=500", + status_code=200, + json={"items": [], "continuationToken": None}, + ) + + result = service.list_files(name="empty-bucket") + + files = result.items + token = result.continuation_token + assert token is None # No more pages + assert len(files) == 0 + + @pytest.mark.asyncio + async def test_list_files_async( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test async version of list_files().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?takeHint=500", + status_code=200, + json={ + "items": [ + { + "fullPath": "/async-file.txt", + "contentType": "text/plain", + "size": 100, + "lastModified": "2024-01-01T00:00:00Z", + } + ], + "continuationToken": None, + }, + ) + + result = await service.list_files_async(name="test-bucket") + files = result.items + token = result.continuation_token + assert token is None # No more pages + assert len(files) == 1 + assert files[0].path == "/async-file.txt" + + +class TestGetFiles: + """Tests for get_files() method (OData GetFiles API).""" + + def test_get_files_basic( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test basic file listing with get_files().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=500", + status_code=200, + json={ + "value": [ + { + "FullPath": "file1.txt", + "ContentType": "text/plain", + "Size": 100, + "IsDirectory": False, + }, + { + "FullPath": "file2.txt", + "ContentType": "text/plain", + "Size": 200, + "IsDirectory": False, + }, + ] + }, + ) + + files = list(service.get_files(name="test-bucket")) + assert len(files) == 2 + assert files[0].path == "file1.txt" + assert files[0].size == 100 + assert files[1].path == "file2.txt" + assert files[1].size == 200 + + def test_get_files_with_glob( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test get_files() with glob pattern.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&fileNameGlob=%2A.txt&%24top=500", + status_code=200, + json={ + "value": [ + { + "FullPath": "file1.txt", + "ContentType": "text/plain", + "Size": 100, + "IsDirectory": False, + } + ] + }, + ) + + files = list(service.get_files(name="test-bucket", file_name_glob="*.txt")) + assert len(files) == 1 + assert files[0].path == "file1.txt" + + def test_get_files_with_recursive( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test get_files() with recursive flag.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=docs&recursive=true&%24top=500", + status_code=200, + json={ + "value": [ + { + "FullPath": "docs/file1.txt", + "ContentType": "text/plain", + "Size": 100, + "IsDirectory": False, + }, + { + "FullPath": "docs/subdir/file2.txt", + "ContentType": "text/plain", + "Size": 200, + "IsDirectory": False, + }, + ] + }, + ) + + files = list( + service.get_files(name="test-bucket", prefix="docs", recursive=True) + ) + assert len(files) == 2 + assert files[1].path == "docs/subdir/file2.txt" + + def test_get_files_filters_directories( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test get_files() filters out directories.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=500", + status_code=200, + json={ + "value": [ + { + "FullPath": "file1.txt", + "ContentType": "text/plain", + "Size": 100, + "IsDirectory": False, + }, + { + "FullPath": "folder1", + "ContentType": None, + "Size": 0, + "IsDirectory": True, + }, + { + "FullPath": "file2.txt", + "ContentType": "text/plain", + "Size": 200, + "IsDirectory": False, + }, + ] + }, + ) + + files = list(service.get_files(name="test-bucket")) + # Should only get 2 files, directory should be filtered out + assert len(files) == 2 + assert all(not f.is_directory for f in files) + + def test_get_files_pagination( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test get_files() handles pagination with $skip and $top.""" + # Mock bucket retrieval (called twice - once for each page) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + # First page (full page of 500) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=500", + status_code=200, + json={ + "value": [ + { + "FullPath": f"file{i}.txt", + "ContentType": "text/plain", + "Size": 100, + "IsDirectory": False, + } + for i in range(500) + ] + }, + ) + + # Second page (partial page of 50) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24skip=500&%24top=500", + status_code=200, + json={ + "value": [ + { + "FullPath": f"file{i}.txt", + "ContentType": "text/plain", + "Size": 100, + "IsDirectory": False, + } + for i in range(500, 550) + ] + }, + ) + + # Manual pagination to get all files across both pages + all_files = [] + skip = 0 + while True: + result = service.get_files(name="test-bucket", skip=skip) + all_files.extend(result.items) + if not result.has_more: + break + skip += result.top + + assert len(all_files) == 550 + + def test_get_files_empty( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test get_files() with empty bucket.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'empty-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 456, "Name": "empty-bucket", "Identifier": "id-2"}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(456)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=500", + status_code=200, + json={"value": []}, + ) + + files = list(service.get_files(name="empty-bucket")) + assert len(files) == 0 + + def test_get_files_without_last_modified( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test get_files() handles missing lastModified field.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=500", + status_code=200, + json={ + "value": [ + { + "FullPath": "file1.txt", + "ContentType": "text/plain", + "Size": 100, + "IsDirectory": False, + # Note: No LastModified field (GetFiles doesn't provide it) + } + ] + }, + ) + + files = list(service.get_files(name="test-bucket")) + assert len(files) == 1 + assert files[0].last_modified is None # Should be None, not error + + @pytest.mark.asyncio + async def test_get_files_async( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test async version of get_files().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=500", + status_code=200, + json={ + "value": [ + { + "FullPath": "async-file.txt", + "ContentType": "text/plain", + "Size": 100, + "IsDirectory": False, + } + ] + }, + ) + + result = await service.get_files_async(name="test-bucket") + files = result.items + assert len(files) == 1 + assert files[0].path == "async-file.txt" + + +class TestExistsFile: + """Tests for exists_file() method.""" + + def test_exists_file_found( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test exists_file() returns True when file is found.""" + # Mock bucket retrieve + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + # Mock ListFiles response with matching file (take_hint=1 for performance) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Fdata%2Ffile.txt&takeHint=1", + status_code=200, + json={ + "items": [ + { + "fullPath": "/data/file.txt", + "contentType": "text/plain", + "size": 100, + "lastModified": "2024-01-01T00:00:00Z", + } + ], + "continuationToken": None, + }, + ) + + result = service.exists_file(name="test-bucket", blob_file_path="data/file.txt") + assert result is True + + def test_exists_file_not_found( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test exists_file() returns False when file is not found.""" + # Mock bucket retrieve + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + # Mock ListFiles response with no matching files + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Fnonexistent.txt&takeHint=1", + status_code=200, + json={"items": [], "continuationToken": None}, + ) + + result = service.exists_file( + name="test-bucket", blob_file_path="nonexistent.txt" + ) + assert result is False + + def test_exists_file_bucket_not_found( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test exists_file() raises LookupError when bucket doesn't exist.""" + # Mock bucket retrieve returning empty (bucket not found) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'nonexistent-bucket'&$top=1", + status_code=200, + json={"value": []}, + ) + + # Should raise LookupError, not return False + with pytest.raises(LookupError, match="Bucket.*not found"): + service.exists_file( + name="nonexistent-bucket", blob_file_path="some-file.txt" + ) + + def test_exists_file_short_circuit_on_match( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test exists_file() stops iteration on first match (short-circuit).""" + # Mock bucket retrieve + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + # Mock first page with matching file + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Ftarget.txt&takeHint=1", + status_code=200, + json={ + "items": [ + { + "fullPath": "/target.txt", + "contentType": "text/plain", + "size": 100, + "lastModified": "2024-01-01T00:00:00Z", + } + ], + "continuationToken": "next-page-token", # Has more pages + }, + ) + + # Should not request second page since file was found on first page + result = service.exists_file(name="test-bucket", blob_file_path="target.txt") + assert result is True + + # Verify only 2 requests were made (retrieve + first page) + # NOT 3 requests (which would include second page) + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + def test_exists_file_searches_across_pages( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test exists_file() searches across multiple pages if needed.""" + # Mock bucket retrieve + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + # First page - no match + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Ftarget.txt&takeHint=1", + status_code=200, + json={ + "items": [ + { + "fullPath": "/other-file.txt", + "contentType": "text/plain", + "size": 100, + "lastModified": "2024-01-01T00:00:00Z", + } + ], + "continuationToken": "page2", + }, + ) + + # Second page - found + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Ftarget.txt&continuationToken=page2&takeHint=1", + status_code=200, + json={ + "items": [ + { + "fullPath": "/target.txt", + "contentType": "text/plain", + "size": 200, + "lastModified": "2024-01-02T00:00:00Z", + } + ], + "continuationToken": None, + }, + ) + + result = service.exists_file(name="test-bucket", blob_file_path="target.txt") + assert result is True + + # Should have made 3 requests (retrieve + page1 + page2) + requests = httpx_mock.get_requests() + assert len(requests) == 3 + + def test_exists_file_with_folder_context( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test exists_file() with folder_path parameter.""" + # Mock bucket retrieve with folder path + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Ffile.txt&takeHint=1", + status_code=200, + json={ + "items": [ + { + "fullPath": "/file.txt", + "contentType": "text/plain", + "size": 100, + "lastModified": "2024-01-01T00:00:00Z", + } + ], + "continuationToken": None, + }, + ) + + result = service.exists_file( + name="test-bucket", blob_file_path="file.txt", folder_path="Production" + ) + assert result is True + + @pytest.mark.asyncio + async def test_exists_file_async( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test async version of exists_file().""" + # Mock bucket retrieve + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + # Mock ListFiles response + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Fasync-file.txt&takeHint=1", + status_code=200, + json={ + "items": [ + { + "fullPath": "/async-file.txt", + "contentType": "text/plain", + "size": 100, + "lastModified": "2024-01-01T00:00:00Z", + } + ], + "continuationToken": None, + }, + ) + + result = await service.exists_file_async( + name="test-bucket", blob_file_path="async-file.txt" + ) + assert result is True + + @pytest.mark.asyncio + async def test_exists_file_async_not_found( + self, + httpx_mock: HTTPXMock, + service: BucketsService, + base_url: str, + org: str, + tenant: str, + ): + """Test async version returns False when file not found.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + status_code=200, + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/api/Buckets/123/ListFiles?prefix=%2Fmissing.txt&takeHint=1", + status_code=200, + json={"items": [], "continuationToken": None}, + ) + + result = await service.exists_file_async( + name="test-bucket", blob_file_path="missing.txt" + ) + assert result is False + + def test_exists_file_empty_path_raises_error(self, service: BucketsService): + """Test exists_file() raises ValueError for empty blob_file_path.""" + with pytest.raises(ValueError, match="blob_file_path cannot be empty"): + service.exists_file(name="test-bucket", blob_file_path="") + + def test_exists_file_whitespace_path_raises_error(self, service: BucketsService): + """Test exists_file() raises ValueError for whitespace-only blob_file_path.""" + with pytest.raises(ValueError, match="blob_file_path cannot be empty"): + service.exists_file(name="test-bucket", blob_file_path=" ") + + @pytest.mark.asyncio + async def test_exists_file_async_empty_path_raises_error( + self, service: BucketsService + ): + """Test async version raises ValueError for empty blob_file_path.""" + with pytest.raises(ValueError, match="blob_file_path cannot be empty"): + await service.exists_file_async(name="test-bucket", blob_file_path="") + + @pytest.mark.asyncio + async def test_exists_file_async_whitespace_path_raises_error( + self, service: BucketsService + ): + """Test async version raises ValueError for whitespace-only blob_file_path.""" + with pytest.raises(ValueError, match="blob_file_path cannot be empty"): + await service.exists_file_async(name="test-bucket", blob_file_path=" ") + + +class TestTopParameterValidation: + """Test top parameter validation for methods using 'top' parameter.""" + + # -------------------- list() tests -------------------- + + def test_list_top_exceeds_maximum(self, service: BucketsService): + """Test that top > 1000 raises ValueError for list().""" + with pytest.raises(ValueError, match=r"top must be <= 1000.*requested: 1001"): + service.list(top=1001) + + def test_list_top_at_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that top = 1000 is allowed for list().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=1000", + json={"value": [], "@odata.count": 0}, + ) + result = service.list(top=1000) + assert result is not None + assert len(result.items) == 0 + + def test_list_top_below_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that top = 999 is allowed for list().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=999", + json={"value": [], "@odata.count": 0}, + ) + result = service.list(top=999) + assert result is not None + + # -------------------- list_async() tests -------------------- + + @pytest.mark.asyncio + async def test_list_async_top_exceeds_maximum(self, service: BucketsService): + """Test that top > 1000 raises ValueError for list_async().""" + with pytest.raises(ValueError, match=r"top must be <= 1000.*requested: 2000"): + await service.list_async(top=2000) + + @pytest.mark.asyncio + async def test_list_async_top_at_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that top = 1000 is allowed for list_async().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=1000", + json={"value": [], "@odata.count": 0}, + ) + result = await service.list_async(top=1000) + assert result is not None + + @pytest.mark.asyncio + async def test_list_async_top_below_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that top = 999 is allowed for list_async().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=0&$top=999", + json={"value": [], "@odata.count": 0}, + ) + result = await service.list_async(top=999) + assert result is not None + + # -------------------- get_files() tests -------------------- + + def test_get_files_top_exceeds_maximum(self, service: BucketsService): + """Test that top > 1000 raises ValueError for get_files().""" + with pytest.raises(ValueError, match=r"top must be <= 1000"): + service.get_files(name="test-bucket", top=1001) + + def test_get_files_top_at_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that top = 1000 is allowed for get_files().""" + # Mock bucket retrieval + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + # Mock file retrieval with GetFiles endpoint + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=1000", + json={"value": []}, + ) + result = service.get_files(name="test-bucket", top=1000) + assert result is not None + + def test_get_files_top_below_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that top = 999 is allowed for get_files().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=999", + json={"value": []}, + ) + result = service.get_files(name="test-bucket", top=999) + assert result is not None + + # -------------------- get_files_async() tests -------------------- + + @pytest.mark.asyncio + async def test_get_files_async_top_exceeds_maximum(self, service: BucketsService): + """Test that top > 1000 raises ValueError for get_files_async().""" + with pytest.raises(ValueError, match=r"top must be <= 1000"): + await service.get_files_async(name="test-bucket", top=1001) + + @pytest.mark.asyncio + async def test_get_files_async_top_at_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that top = 1000 is allowed for get_files_async().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=1000", + json={"value": []}, + ) + result = await service.get_files_async(name="test-bucket", top=1000) + assert result is not None + + @pytest.mark.asyncio + async def test_get_files_async_top_below_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that top = 999 is allowed for get_files_async().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24top=999", + json={"value": []}, + ) + result = await service.get_files_async(name="test-bucket", top=999) + assert result is not None + + # -------------------- skip parameter validation tests -------------------- + + def test_list_skip_exceeds_maximum(self, service: BucketsService): + """Test that skip > 10000 raises ValueError for list().""" + with pytest.raises( + ValueError, match=r"skip must be <= 10000.*requested: 10001" + ): + service.list(skip=10001) + + def test_list_skip_at_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that skip = 10000 is allowed for list().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=10000&$top=100", + json={"value": [], "@odata.count": 0}, + ) + result = service.list(skip=10000) + assert result is not None + + def test_list_skip_below_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that skip = 9999 is allowed for list().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=9999&$top=100", + json={"value": [], "@odata.count": 0}, + ) + result = service.list(skip=9999) + assert result is not None + + @pytest.mark.asyncio + async def test_list_async_skip_exceeds_maximum(self, service: BucketsService): + """Test that skip > 10000 raises ValueError for list_async().""" + with pytest.raises( + ValueError, match=r"skip must be <= 10000.*requested: 20000" + ): + await service.list_async(skip=20000) + + @pytest.mark.asyncio + async def test_list_async_skip_at_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that skip = 10000 is allowed for list_async().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=10000&$top=100", + json={"value": [], "@odata.count": 0}, + ) + result = await service.list_async(skip=10000) + assert result is not None + + def test_get_files_skip_exceeds_maximum(self, service: BucketsService): + """Test that skip > 10000 raises ValueError for get_files().""" + with pytest.raises(ValueError, match=r"skip must be <= 10000"): + service.get_files(name="test-bucket", skip=10001) + + def test_get_files_skip_at_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that skip = 10000 is allowed for get_files().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24skip=10000&%24top=500", + json={"value": []}, + ) + result = service.get_files(name="test-bucket", skip=10000) + assert result is not None + + @pytest.mark.asyncio + async def test_get_files_async_skip_exceeds_maximum(self, service: BucketsService): + """Test that skip > 10000 raises ValueError for get_files_async().""" + with pytest.raises(ValueError, match=r"skip must be <= 10000"): + await service.get_files_async(name="test-bucket", skip=10001) + + @pytest.mark.asyncio + async def test_get_files_async_skip_at_maximum( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that skip = 10000 is allowed for get_files_async().""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$filter=Name eq 'test-bucket'&$top=1", + json={"value": [{"Id": 123, "Name": "test-bucket", "Identifier": "id-1"}]}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets(123)/UiPath.Server.Configuration.OData.GetFiles?directory=%2F&%24skip=10000&%24top=500", + json={"value": []}, + ) + result = await service.get_files_async(name="test-bucket", skip=10000) + assert result is not None + + def test_combined_max_skip_and_top( + self, + service: BucketsService, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + ): + """Test that skip=10000 and top=1000 work together (combined boundary).""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Buckets?$skip=10000&$top=1000", + json={"value": [], "@odata.count": 0}, + ) + result = service.list(skip=10000, top=1000) + assert result is not None diff --git a/packages/uipath-platform/tests/services/test_connections_service.py b/packages/uipath-platform/tests/services/test_connections_service.py new file mode 100644 index 000000000..79f8e043d --- /dev/null +++ b/packages/uipath-platform/tests/services/test_connections_service.py @@ -0,0 +1,2071 @@ +import json +from unittest.mock import AsyncMock, MagicMock +from urllib.parse import unquote_plus + +import pytest +from pydantic import ValidationError +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common import jsonschema_to_pydantic +from uipath.platform.common.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT +from uipath.platform.connections import ( + ActivityMetadata, + ActivityParameterLocationInfo, + Connection, + ConnectionMetadata, + ConnectionToken, + EventArguments, +) +from uipath.platform.connections._connections_service import ConnectionsService +from uipath.platform.orchestrator._folder_service import FolderService + + +@pytest.fixture +def mock_folders_service() -> MagicMock: + """Mock FolderService for testing.""" + service = MagicMock(spec=FolderService) + service.retrieve_folder_key.return_value = "test-folder-key" + service.retrieve_folder_key_async = AsyncMock(return_value="test-folder-key") + return service + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + mock_folders_service: MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> ConnectionsService: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + return ConnectionsService( + config=config, + execution_context=execution_context, + folders_service=mock_folders_service, + ) + + +class TestConnectionsService: + def test_retrieve( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + connection_key = "test-connection" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}", + status_code=200, + json={ + "id": "test-id", + "name": "Test Connection", + "state": "active", + "elementInstanceId": 123, + }, + ) + + connection = service.retrieve(key=connection_key) + + assert isinstance(connection, Connection) + assert connection.id == "test-id" + assert connection.name == "Test Connection" + assert connection.state == "active" + assert connection.element_instance_id == 123 + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve/{version}" + ) + + def test_metadata( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + element_instance_id = 123 + connector_key = "test-connector" + tool_path = "test-tool" + valid_choice = { + "index": 0, + "finishReason": "done", + "message": {"content": "foo", "role": "user"}, + } + invalid_choice = { + "index": 0, + "finishReason": "done", + "message": {"content": 123, "role": "user"}, + } + valid_object = { + "choices": [valid_choice], + "usage": {"totalTokens": 100}, + "created": 1000, + } + invalid_object_1 = { + "choices": [valid_choice], + "usage": {"totalTokens": 100}, + "created": "string", + } + invalid_object_2 = { + "choices": [invalid_choice], + "usage": {"totalTokens": 100}, + "created": 1000, + } + json_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "choices": { + "title": "Choices", + "type": "array", + "items": {"$ref": "#/definitions/choices"}, + }, + "usage": {"title": "Usage", "$ref": "#/definitions/usage"}, + "created": { + "title": "Creation timestamp", + "type": "integer", + "format": "int64", + }, + }, + "definitions": { + "message": { + "type": "object", + "title": "Message", + "properties": { + "content": { + "title": "Translated message content", + "type": "string", + }, + "role": { + "title": "Role of the message sender", + "type": "string", + }, + }, + }, + "choices": { + "type": "object", + "title": "Choices", + "properties": { + "index": { + "title": "Choice index", + "type": "integer", + "format": "int64", + }, + "finish_reason": { + "title": "Completion reason", + "type": "string", + }, + "message": { + "title": "Message", + "$ref": "#/definitions/message", + }, + }, + }, + "usage": { + "type": "object", + "title": "Usage", + "properties": { + "total_tokens": { + "title": "Total tokens used", + "type": "integer", + "format": "int64", + } + }, + }, + }, + } + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/{connector_key}/objects/{tool_path}/metadata", + status_code=200, + json={ + "fields": json_schema, + }, + ) + + metadata = service.metadata(element_instance_id, connector_key, tool_path) + + assert isinstance(metadata, ConnectionMetadata) + dynamic_type = jsonschema_to_pydantic(metadata.fields) + + dynamic_type.model_validate(valid_object) + with pytest.raises(ValidationError): + assert dynamic_type.model_validate(invalid_object_1) + with pytest.raises(ValidationError): + assert dynamic_type.model_validate(invalid_object_2) + dynamic_type.model_json_schema() + + @pytest.mark.anyio + async def test_retrieve_async( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + connection_key = "test-connection" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}", + status_code=200, + json={ + "id": "test-id", + "name": "Test Connection", + "state": "active", + "elementInstanceId": 123, + }, + ) + + connection = await service.retrieve_async(key=connection_key) + + assert isinstance(connection, Connection) + assert connection.id == "test-id" + assert connection.name == "Test Connection" + assert connection.state == "active" + assert connection.element_instance_id == 123 + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve_async/{version}" + ) + + async def test_metadata_async( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + element_instance_id = 123 + connector_key = "test-connector" + tool_path = "test-tool" + valid_choice = { + "index": 0, + "finishReason": "done", + "message": {"content": "foo", "role": "user"}, + } + invalid_choice = { + "index": 0, + "finishReason": "done", + "message": {"content": 123, "role": "user"}, + } + valid_object = { + "choices": [valid_choice], + "usage": {"totalTokens": 100}, + "created": 1000, + } + invalid_object_1 = { + "choices": [valid_choice], + "usage": {"totalTokens": 100}, + "created": "string", + } + invalid_object_2 = { + "choices": [invalid_choice], + "usage": {"totalTokens": 100}, + "created": 1000, + } + json_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "choices": { + "title": "Choices", + "type": "array", + "items": {"$ref": "#/definitions/choices"}, + }, + "usage": {"title": "Usage", "$ref": "#/definitions/usage"}, + "created": { + "title": "Creation timestamp", + "type": "integer", + "format": "int64", + }, + }, + "definitions": { + "message": { + "type": "object", + "title": "Message", + "properties": { + "content": { + "title": "Translated message content", + "type": "string", + }, + "role": { + "title": "Role of the message sender", + "type": "string", + }, + }, + }, + "choices": { + "type": "object", + "title": "Choices", + "properties": { + "index": { + "title": "Choice index", + "type": "integer", + "format": "int64", + }, + "finish_reason": { + "title": "Completion reason", + "type": "string", + }, + "message": { + "title": "Message", + "$ref": "#/definitions/message", + }, + }, + }, + "usage": { + "type": "object", + "title": "Usage", + "properties": { + "total_tokens": { + "title": "Total tokens used", + "type": "integer", + "format": "int64", + } + }, + }, + }, + } + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/{connector_key}/objects/{tool_path}/metadata", + status_code=200, + json={ + "fields": json_schema, + }, + ) + + metadata = await service.metadata_async( + element_instance_id, connector_key, tool_path + ) + + assert isinstance(metadata, ConnectionMetadata) + dynamic_type = jsonschema_to_pydantic(metadata.fields) + + dynamic_type.model_validate(valid_object) + with pytest.raises(ValidationError): + assert dynamic_type.model_validate(invalid_object_1) + with pytest.raises(ValidationError): + assert dynamic_type.model_validate(invalid_object_2) + dynamic_type.model_json_schema() + + def test_retrieve_token( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + connection_key = "test-connection" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}/token?tokenType=direct", + status_code=200, + json={ + "accessToken": "test-token", + "tokenType": "Bearer", + "expiresIn": 3600, + }, + ) + + token = service.retrieve_token(key=connection_key) + + assert isinstance(token, ConnectionToken) + assert token.access_token == "test-token" + assert token.token_type == "Bearer" + assert token.expires_in == 3600 + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}/token?tokenType=direct" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve_token/{version}" + ) + + @pytest.mark.anyio + async def test_retrieve_token_async( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + connection_key = "test-connection" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}/token?tokenType=direct", + status_code=200, + json={ + "accessToken": "test-token", + "tokenType": "Bearer", + "expiresIn": 3600, + }, + ) + + token = await service.retrieve_token_async(key=connection_key) + + assert isinstance(token, ConnectionToken) + assert token.access_token == "test-token" + assert token.token_type == "Bearer" + assert token.expires_in == 3600 + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_key}/token?tokenType=direct" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve_token_async/{version}" + ) + + def test_list_no_filters( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test list method without any filters.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24expand=connector%2Cfolder", + status_code=200, + json={ + "value": [ + { + "id": "conn-1", + "name": "Slack Connection", + "state": "active", + "elementInstanceId": 101, + }, + { + "id": "conn-2", + "name": "Salesforce Connection", + "state": "active", + "elementInstanceId": 102, + }, + ] + }, + ) + + connections = service.list() + + assert isinstance(connections, list) + assert len(connections) == 2 + assert connections[0].id == "conn-1" + assert connections[0].name == "Slack Connection" + assert connections[1].id == "conn-2" + assert connections[1].name == "Salesforce Connection" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + # Check for URL-encoded version + assert "%24expand=connector%2Cfolder" in str(sent_request.url) + + def test_list_with_name_filter( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test list method with name filtering.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24filter=contains%28Name%2C%20%27Salesforce%27%29&%24expand=connector%2Cfolder", + status_code=200, + json={ + "value": [ + { + "id": "conn-2", + "name": "Salesforce Connection", + "state": "active", + "elementInstanceId": 102, + } + ] + }, + ) + + connections = service.list(name="Salesforce") + + assert len(connections) == 1 + assert connections[0].name == "Salesforce Connection" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + # Decode URL-encoded characters (including + as space) + url_str = unquote_plus(str(sent_request.url)) + assert "contains(Name, 'Salesforce')" in url_str + + def test_list_with_folder_path_resolution( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + mock_folders_service: MagicMock, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test list method with folder path resolution.""" + mock_folders_service.retrieve_folder_key.return_value = "folder-123" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24expand=connector%2Cfolder", + status_code=200, + json={"value": []}, + ) + + service.list(folder_path="Finance/Production") + + # Verify folder service was called + mock_folders_service.retrieve_folder_key.assert_called_once_with( + "Finance/Production" + ) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + # Verify the resolved key was used in headers + assert HEADER_FOLDER_KEY in sent_request.headers + assert sent_request.headers[HEADER_FOLDER_KEY] == "folder-123" + + def test_list_with_connector_filter( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test list method with connector key filtering.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24filter=connector%2Fkey%20eq%20%27uipath-slack%27&%24expand=connector%2Cfolder", + status_code=200, + json={ + "value": [ + { + "id": "conn-1", + "name": "Slack Connection", + "state": "active", + "elementInstanceId": 101, + } + ] + }, + ) + + connections = service.list(connector_key="uipath-slack") + + assert len(connections) == 1 + assert connections[0].name == "Slack Connection" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + # Decode URL-encoded characters (including + as space) + url_str = unquote_plus(str(sent_request.url)) + assert "connector/key eq 'uipath-slack'" in url_str + + def test_list_with_pagination( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test list method with pagination parameters.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24skip=10&%24top=5&%24expand=connector%2Cfolder", + status_code=200, + json={"value": []}, + ) + + service.list(skip=10, top=5) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert "%24skip=10" in str(sent_request.url) + assert "%24top=5" in str(sent_request.url) + + def test_list_with_combined_filters( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + mock_folders_service: MagicMock, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test list method with multiple filters combined.""" + mock_folders_service.retrieve_folder_key.return_value = "folder-456" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24filter=contains%28Name%2C%20%27Slack%27%29%20and%20connector%2Fkey%20eq%20%27uipath-slack%27&%24expand=connector%2Cfolder", + status_code=200, + json={"value": []}, + ) + + service.list(name="Slack", folder_path="Finance", connector_key="uipath-slack") + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + # Decode URL-encoded characters (including + as space) + url_str = unquote_plus(str(sent_request.url)) + assert "contains(Name, 'Slack')" in url_str + assert "connector/key eq 'uipath-slack'" in url_str + assert " and " in url_str + + @pytest.mark.anyio + async def test_list_async( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test async version of list method.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24expand=connector%2Cfolder", + status_code=200, + json={ + "value": [ + { + "id": "conn-1", + "name": "Test Connection", + "state": "active", + "elementInstanceId": 101, + } + ] + }, + ) + + connections = await service.list_async() + + assert len(connections) == 1 + assert connections[0].name == "Test Connection" + + def test_retrieve_event_payload( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + event_id = "test-event-id" + additional_event_data = '{"processedEventId": "test-event-id"}' + + event_args = EventArguments(additional_event_data=additional_event_data) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}", + status_code=200, + json={ + "eventId": event_id, + "eventType": "test-event", + "data": {"key": "value"}, + "timestamp": "2025-08-12T10:00:00Z", + }, + ) + + payload = service.retrieve_event_payload(event_args=event_args) + + assert payload["eventId"] == event_id + assert payload["eventType"] == "test-event" + assert payload["data"]["key"] == "value" + assert payload["timestamp"] == "2025-08-12T10:00:00Z" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve_event_payload/{version}" + ) + + def test_retrieve_event_payload_with_raw_event_id( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + event_id = "test-raw-event-id" + additional_event_data = '{"rawEventId": "test-raw-event-id"}' + + event_args = EventArguments(additional_event_data=additional_event_data) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}", + status_code=200, + json={ + "eventId": event_id, + "eventType": "test-raw-event", + "data": {"rawKey": "rawValue"}, + }, + ) + + payload = service.retrieve_event_payload(event_args=event_args) + + assert payload["eventId"] == event_id + assert payload["eventType"] == "test-raw-event" + assert payload["data"]["rawKey"] == "rawValue" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}" + ) + + def test_retrieve_event_payload_missing_additional_event_data( + self, + service: ConnectionsService, + ) -> None: + event_args = EventArguments(additional_event_data=None) + + with pytest.raises(ValueError, match="additional_event_data is required"): + service.retrieve_event_payload(event_args=event_args) + + def test_retrieve_event_payload_missing_event_id( + self, + service: ConnectionsService, + ) -> None: + additional_event_data = '{"someOtherField": "value"}' + event_args = EventArguments(additional_event_data=additional_event_data) + + with pytest.raises( + ValueError, match="Event Id not found in additional event data" + ): + service.retrieve_event_payload(event_args=event_args) + + @pytest.mark.anyio + async def test_retrieve_event_payload_async( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + event_id = "test-event-id-async" + additional_event_data = '{"processedEventId": "test-event-id-async"}' + + event_args = EventArguments(additional_event_data=additional_event_data) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}", + status_code=200, + json={ + "eventId": event_id, + "eventType": "test-async-event", + "data": {"asyncKey": "asyncValue"}, + "timestamp": "2025-08-12T11:00:00Z", + }, + ) + + payload = await service.retrieve_event_payload_async(event_args=event_args) + + assert payload["eventId"] == event_id + assert payload["eventType"] == "test-async-event" + assert payload["data"]["asyncKey"] == "asyncValue" + assert payload["timestamp"] == "2025-08-12T11:00:00Z" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ConnectionsService.retrieve_event_payload_async/{version}" + ) + + @pytest.mark.anyio + async def test_retrieve_event_payload_async_with_raw_event_id( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + event_id = "test-raw-event-id-async" + additional_event_data = '{"rawEventId": "test-raw-event-id-async"}' + + event_args = EventArguments(additional_event_data=additional_event_data) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}", + status_code=200, + json={ + "eventId": event_id, + "eventType": "test-async-raw-event", + "data": {"asyncRawKey": "asyncRawValue"}, + }, + ) + + payload = await service.retrieve_event_payload_async(event_args=event_args) + + assert payload["eventId"] == event_id + assert payload["eventType"] == "test-async-raw-event" + assert payload["data"]["asyncRawKey"] == "asyncRawValue" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/elements_/v1/events/{event_id}" + ) + + @pytest.mark.anyio + async def test_retrieve_event_payload_async_missing_additional_event_data( + self, + service: ConnectionsService, + ) -> None: + event_args = EventArguments(additional_event_data=None) + + with pytest.raises(ValueError, match="additional_event_data is required"): + await service.retrieve_event_payload_async(event_args=event_args) + + @pytest.mark.anyio + async def test_retrieve_event_payload_async_missing_event_id( + self, + service: ConnectionsService, + ) -> None: + additional_event_data = '{"someOtherField": "value"}' + event_args = EventArguments(additional_event_data=additional_event_data) + + with pytest.raises( + ValueError, match="Event Id not found in additional event data" + ): + await service.retrieve_event_payload_async(event_args=event_args) + + def test_list_with_name_containing_quote( + self, httpx_mock: HTTPXMock, service: ConnectionsService + ) -> None: + """Test that names with quotes are properly escaped.""" + httpx_mock.add_response(json={"value": []}) + + service.list(name="O'Malley") + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + # Verify the single quote was doubled (escaped) in the OData filter + # The URL should contain O''Malley (with doubled single quote) + url_str = str(sent_request.url) + # Check that the filter contains the escaped quote + assert "O%27%27Malley" in url_str or "O''Malley" in url_str.replace( + "%27%27", "''" + ) + + def test_list_with_raw_list_response( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test that list method handles raw list responses (not wrapped in 'value').""" + # Some API endpoints return a raw list instead of OData format + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections?%24expand=connector%2Cfolder", + status_code=200, + json=[ + { + "id": "conn-1", + "name": "Direct List Connection", + "state": "active", + "elementInstanceId": 101, + } + ], + ) + + connections = service.list() + + assert isinstance(connections, list) + assert len(connections) == 1 + assert connections[0].id == "conn-1" + assert connections[0].name == "Direct List Connection" + + def test_get_jit_action_url_with_api_action( + self, service: ConnectionsService + ) -> None: + """Test _get_jit_action_url extracts URL from first API action.""" + metadata = ConnectionMetadata( + fields={}, + metadata={ + "method": { + "POST": { + "design": { + "actions": [ + { + "actionType": "reset", + "name": "Reset Form", + }, + { + "actionType": "api", + "name": "Load Issue Types", + "apiConfiguration": { + "method": "GET", + "url": "elements/jira/projects/{project.id}/issuetypes", + }, + }, + ] + } + } + } + }, + ) + + url = service._get_jit_action_url(metadata) + + assert url == "elements/jira/projects/{project.id}/issuetypes" + + def test_metadata_with_jit_parameters( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test metadata() triggers JIT fetch when parameters are provided.""" + element_instance_id = 123 + connector_key = "uipath-jira" + tool_path = "Issue" + parameters = {"project.id": "PROJ-123"} + + # Mock initial metadata response + initial_response = { + "fields": { + "project.id": {"type": "string", "displayName": "Project ID"}, + "summary": {"type": "string", "displayName": "Summary"}, + }, + "metadata": { + "method": { + "POST": { + "design": { + "actions": [ + { + "actionType": "api", + "apiConfiguration": { + "url": "elements/jira/projects/{project.id}/issuetypes" + }, + } + ] + } + } + } + }, + } + + # Mock JIT metadata response + jit_response = { + "fields": { + "CustomIssueType": { + "type": "string", + "displayName": "Custom Issue Type", + }, + }, + } + + # Add mock responses + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/{connector_key}/objects/{tool_path}/metadata", + json=initial_response, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/jira/projects/PROJ-123/issuetypes", + json=jit_response, + ) + + metadata = service.metadata( + element_instance_id, connector_key, tool_path, parameters + ) + + # Should return JIT metadata + assert isinstance(metadata, ConnectionMetadata) + assert "CustomIssueType" in metadata.fields + + # Verify both requests were made + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + @pytest.mark.httpx_mock(assert_all_responses_were_requested=False) + async def test_metadata_with_max_jit_depth( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test metadata() stops at max JIT depth to prevent infinite loops.""" + element_instance_id = 123 + connector_key = "uipath-jira" + tool_path = "Issue" + parameters = {"param": "value"} + max_jit_depth = 5 + + # Create a response that always has another action (infinite chain) + def create_response_with_action(level: int): + return { + "fields": { + f"field_level_{level}": { + "type": "string", + "displayName": f"Field Level {level}", + }, + }, + "metadata": { + "method": { + "POST": { + "design": { + "actions": [ + { + "actionType": "api", + "apiConfiguration": { + "url": f"elements/jira/level{level + 1}" + }, + } + ] + } + } + } + }, + } + + # Add initial response + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/{connector_key}/objects/{tool_path}/metadata", + json=create_response_with_action(0), + ) + + # Add 10 more levels (more than max JIT depth) to test limit + for level in range(1, 11): + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/jira/level{level}", + json=create_response_with_action(level), + ) + + metadata = service.metadata( + element_instance_id, connector_key, tool_path, parameters, max_jit_depth + ) + + # Should return metadata from level 5 (stopped at max JIT depth) + assert isinstance(metadata, ConnectionMetadata) + assert "field_level_5" in metadata.fields + + # Verify exactly 6 requests were made (initial + 5 JIT levels) + requests = httpx_mock.get_requests() + assert len(requests) == 6 + + def test_metadata_stops_on_repeated_url( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test metadata() stops early when action URL repeats.""" + element_instance_id = 123 + connector_key = "uipath-jira" + tool_path = "Issue" + parameters = {"project.id": "PROJ-123"} + + # First response with action URL + level1_response = { + "fields": { + "field1": {"type": "string", "displayName": "Field 1"}, + }, + "metadata": { + "method": { + "POST": { + "design": { + "actions": [ + { + "actionType": "api", + "apiConfiguration": { + "url": "elements/jira/projects/{project.id}/metadata" + }, + } + ] + } + } + } + }, + } + + # Second response with the same action URL + level2_response = { + "fields": { + "field2": {"type": "string", "displayName": "Field 2"}, + }, + "metadata": { + "method": { + "POST": { + "design": { + "actions": [ + { + "actionType": "api", + "apiConfiguration": { + "url": "elements/jira/projects/{project.id}/metadata" + }, + } + ] + } + } + } + }, + } + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/{connector_key}/objects/{tool_path}/metadata", + json=level1_response, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/elements_/v3/element/instances/{element_instance_id}/elements/jira/projects/PROJ-123/metadata", + json=level2_response, + ) + + metadata = service.metadata( + element_instance_id, connector_key, tool_path, parameters + ) + + # Should return metadata from level 2 (stopped because next URL is same) + assert isinstance(metadata, ConnectionMetadata) + assert "field2" in metadata.fields + + # Verify exactly 2 requests were made (initial + 1 JIT level, then stopped) + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + +@pytest.fixture +def simple_activity_metadata() -> ActivityMetadata: + """Simple activity metadata for non-path tests.""" + return ActivityMetadata( + object_path="/elements/test-connector/test-activity", + method_name="POST", + content_type="application/json", + parameter_location_info=ActivityParameterLocationInfo( + query_params=["query_param", "query_param2"], + header_params=["custom_header", "custom_header2"], + path_params=[], + multipart_params=[], + body_fields=["body_field1", "body_field2", "body_field3"], + ), + ) + + +@pytest.fixture +def path_activity_metadata() -> ActivityMetadata: + """Sample activity metadata for testing with all parameter types.""" + return ActivityMetadata( + object_path="/elements/test-connector/users/{userId}/posts/{postId}", + method_name="POST", + content_type="application/json", + parameter_location_info=ActivityParameterLocationInfo( + query_params=[], + header_params=[], + path_params=["userId", "postId"], + multipart_params=[], + body_fields=[], + ), + ) + + +@pytest.fixture +def multipart_activity_metadata() -> ActivityMetadata: + """Sample multipart activity metadata for testing.""" + return ActivityMetadata( + object_path="/elements/test-connector/upload", + method_name="POST", + content_type="multipart/form-data", + parameter_location_info=ActivityParameterLocationInfo( + query_params=[], + header_params=[], + path_params=[], + multipart_params=["file_param"], + body_fields=["description"], + ), + json_body_section="body", + ) + + +@pytest.fixture +def multipart_custom_section_metadata() -> ActivityMetadata: + """Sample multipart activity metadata with custom json_body_section.""" + return ActivityMetadata( + object_path="/elements/test-connector/rag", + method_name="POST", + content_type="multipart/form-data", + parameter_location_info=ActivityParameterLocationInfo( + query_params=[], + header_params=[], + path_params=[], + multipart_params=["file_param"], + body_fields=["prompt", "model"], + ), + json_body_section="RagRequest", + ) + + +class TestConnectorActivityInvocation: + def test_invoke_activity_with_query_params( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + simple_activity_metadata: ActivityMetadata, + ) -> None: + """Test invoking with query parameters only.""" + connection_id = "test-connection-123" + activity_input = { + "query_param": "test search query", + "query_param2": "additional query", + } + expected_response = {"results": [], "total": 0} + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response( + method="POST", + status_code=200, + json=expected_response, + ) + + _ = service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + requests = httpx_mock.get_requests() + sent_request = requests[1] + + # Check query parameters + assert sent_request.url.params["query_param"] == "test search query" + assert sent_request.url.params["query_param2"] == "additional query" + + def test_invoke_activity_with_header_params( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + simple_activity_metadata: ActivityMetadata, + ) -> None: + """Test invoking with header parameters only.""" + connection_id = "test-connection-123" + activity_input = { + "custom_header": "secret-api-key", + "custom_header2": "client-123", + } + expected_response = {"authenticated": True} + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response( + method="POST", + status_code=200, + json=expected_response, + ) + + _ = service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + requests = httpx_mock.get_requests() + sent_request = requests[1] + + # Check custom headers + assert sent_request.headers["custom_header"] == "secret-api-key" + assert sent_request.headers["custom_header2"] == "client-123" + + def test_invoke_activity_sets_standard_headers( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + simple_activity_metadata: ActivityMetadata, + ) -> None: + """Test invoking sets standard headers correctly.""" + connection_id = "test-connection-123" + activity_input = { + "body_field1": "Test Item", + } + expected_response = {"status": "success"} + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response( + method="POST", + status_code=200, + json=expected_response, + ) + + _ = service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + requests = httpx_mock.get_requests() + sent_request = requests[1] + + # Check standard headers + assert sent_request.headers["x-uipath-originator"] == "uipath-python" + assert sent_request.headers["x-uipath-source"] == "uipath-python" + + def test_invoke_activity_with_body_fields( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + simple_activity_metadata: ActivityMetadata, + ) -> None: + """Test invoking with JSON body fields only.""" + connection_id = "test-connection-123" + activity_input = { + "body_field1": "Test Item", + "body_field2": "This is a test item", + "body_field3": "high", + } + expected_response = {"id": 456, "status": "created"} + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response( + method="POST", + status_code=200, + json=expected_response, + ) + + _ = service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + requests = httpx_mock.get_requests() + sent_request = requests[1] + + # Check JSON body + request_json = json.loads(sent_request.content.decode()) + assert request_json == { + "body_field1": "Test Item", + "body_field2": "This is a test item", + "body_field3": "high", + } + + def test_invoke_activity_with_path_parameters( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + path_activity_metadata: ActivityMetadata, + ) -> None: + """Test invoking with path parameters only.""" + connection_id = "test-connection-123" + activity_input = { + "userId": "user456", + "postId": "post789", + } + expected_response = {"user": "user456", "post": "post789"} + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response( + method="POST", + status_code=200, + json=expected_response, + ) + + _ = service.invoke_activity( + activity_metadata=path_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + requests = httpx_mock.get_requests() + sent_request = requests[1] + + # Verify URL path substitution worked correctly + assert sent_request.url.path.endswith( + "/elements_/v3/element/instances/test-connection-123/elements/test-connector/users/user456/posts/post789" + ) + + def test_invoke_activity_multipart_request( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + multipart_activity_metadata: ActivityMetadata, + ) -> None: + """Test invoking an Integration Service activity with multipart content.""" + connection_id = "test-connection-123" + activity_input = { + "file_param": b"test file content", + "description": "Test file upload", + } + expected_response = {"upload_id": "upload123", "status": "success"} + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response( + method="POST", + status_code=200, + json=expected_response, + ) + + _ = service.invoke_activity( + activity_metadata=multipart_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + requests = httpx_mock.get_requests() + sent_request = requests[1] + + assert "multipart/form-data" in sent_request.headers.get("content-type", "") + + @pytest.mark.asyncio + async def test_invoke_activity_async_json_request( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + simple_activity_metadata: ActivityMetadata, + ) -> None: + """Test async invocation of an Integration Service activity.""" + connection_id = "test-connection-123" + activity_input = { + "query_param": "test_query", + "body_field1": "async_value1", + "body_field2": "async_value2", + } + expected_response = {"result": "async_success", "data": {"id": 456}} + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response( + method="POST", + status_code=200, + json=expected_response, + ) + + response = await service.invoke_activity_async( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + assert response == expected_response + + def test_invoke_activity_with_none_values_filtered( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + simple_activity_metadata: ActivityMetadata, + ) -> None: + """Test that None values are filtered out from the request.""" + connection_id = "test-connection-123" + activity_input = { + "query_param": "test_query", + "custom_header": None, # This should be filtered out + "body_field1": "value1", + "body_field2": None, # This should be filtered out + } + expected_response = {"result": "success"} + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response( + method="POST", + status_code=200, + json=expected_response, + ) + + _ = service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + requests = httpx_mock.get_requests() + sent_request = requests[1] + + # custom_header should not be present since it was None + assert "custom_header" not in sent_request.headers + + # Only non-None body fields should be present + request_json = json.loads(sent_request.content.decode()) + assert request_json == {"body_field1": "value1"} + + def test_invoke_activity_unknown_parameter_raises_error( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + simple_activity_metadata: ActivityMetadata, + ) -> None: + """Test that unknown parameters raise a ValueError.""" + connection_id = "test-connection-123" + activity_input = { + "unknown_param": "value", # This parameter doesn't exist in metadata + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + + with pytest.raises( + ValueError, + match="Parameter unknown_param does not exist in activity metadata", + ): + service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + def test_invoke_activity_unsupported_content_type_raises_error( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + ) -> None: + """Test that unsupported content types raise a ValueError.""" + unsupported_metadata = ActivityMetadata( + object_path="/elements/test-connector/test-activity", + method_name="POST", + content_type="application/xml", # Unsupported content type + parameter_location_info=ActivityParameterLocationInfo( + query_params=[], + header_params=[], + path_params=[], + multipart_params=[], + body_fields=["xml_data"], + ), + ) + + connection_id = "test-connection-123" + activity_input = {"xml_data": "data"} + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + + with pytest.raises( + ValueError, match="Unsupported content type: application/xml" + ): + service.invoke_activity( + activity_metadata=unsupported_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + def test_invoke_activity_empty_input( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test invoking with empty input.""" + activity_metadata = ActivityMetadata( + object_path="/elements/test-connector/ping", + method_name="GET", + content_type="application/json", + parameter_location_info=ActivityParameterLocationInfo( + query_params=[], + header_params=[], + path_params=[], + multipart_params=[], + body_fields=[], + ), + ) + + connection_id = "test-connection-123" + expected_response = {"status": "pong"} + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_id}", + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response( + method="GET", + status_code=200, + json=expected_response, + ) + + result = service.invoke_activity( + activity_metadata=activity_metadata, + connection_id=connection_id, + activity_input={}, + ) + + assert result == expected_response + + def test_invoke_activity_multipart_custom_json_body_section( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + multipart_custom_section_metadata: ActivityMetadata, + ) -> None: + """Test multipart request uses custom json_body_section name instead of 'body'.""" + connection_id = "test-connection-123" + activity_input = { + "file_param": b"test file content", + "prompt": "Summarize this document", + "model": "gpt-4", + } + expected_response = {"result": "summary text"} + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response( + method="POST", + status_code=200, + json=expected_response, + ) + + _ = service.invoke_activity( + activity_metadata=multipart_custom_section_metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + requests = httpx_mock.get_requests() + sent_request = requests[1] + + assert "multipart/form-data" in sent_request.headers.get("content-type", "") + + # Parse the multipart body to verify part names + content_type = sent_request.headers["content-type"] + boundary = content_type.split("boundary=")[1] + body = sent_request.content.decode("utf-8", errors="replace") + parts = body.split(f"--{boundary}") + + # Find the part names in the multipart body + part_names = [] + for part in parts: + if 'name="' in part: + name = part.split('name="')[1].split('"')[0] + part_names.append(name) + + # The JSON body should be in "RagRequest" part, not "body" + assert "RagRequest" in part_names + assert "body" not in part_names + assert "file_param" in part_names + + def test_invoke_activity_multipart_default_json_body_section( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + ) -> None: + """Test multipart request defaults to 'body' when json_body_section is None.""" + metadata = ActivityMetadata( + object_path="/elements/test-connector/upload", + method_name="POST", + content_type="multipart/form-data", + parameter_location_info=ActivityParameterLocationInfo( + query_params=[], + header_params=[], + path_params=[], + multipart_params=["file_param"], + body_fields=["description"], + ), + # json_body_section is None (default) + ) + connection_id = "test-connection-123" + activity_input = { + "file_param": b"file data", + "description": "A file", + } + + httpx_mock.add_response( + method="GET", + status_code=200, + json={"id": connection_id, "name": "Test", "elementInstanceId": 1}, + ) + httpx_mock.add_response(method="POST", status_code=200, json={"ok": True}) + + _ = service.invoke_activity( + activity_metadata=metadata, + connection_id=connection_id, + activity_input=activity_input, + ) + + requests = httpx_mock.get_requests() + sent_request = requests[1] + + content_type = sent_request.headers["content-type"] + boundary = content_type.split("boundary=")[1] + body = sent_request.content.decode("utf-8", errors="replace") + parts = body.split(f"--{boundary}") + + part_names = [] + for part in parts: + if 'name="' in part: + name = part.split('name="')[1].split('"')[0] + part_names.append(name) + + # Should default to "body" when json_body_section is None + assert "body" in part_names + assert "file_param" in part_names + + def test_invoke_activity_retrieves_connection_and_sends_folder_key( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + simple_activity_metadata: ActivityMetadata, + ) -> None: + """Test that invoke_activity retrieves the connection and sends its folder key.""" + connection_id = "test-connection-123" + folder_key = "d6f5c54a-e2b2-4083-be93-623aa670ed40" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_id}", + method="GET", + status_code=200, + json={ + "id": connection_id, + "name": "Test Connection", + "elementInstanceId": 123, + "folder": {"key": folder_key}, + }, + ) + + expected_response = {"status": "success"} + httpx_mock.add_response( + method="POST", + status_code=200, + json=expected_response, + ) + + result = service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input={"body_field1": "test value"}, + ) + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + retrieve_request = requests[0] + assert retrieve_request.method == "GET" + assert f"/Connections/{connection_id}" in str(retrieve_request.url) + + activity_request = requests[1] + assert activity_request.headers[HEADER_FOLDER_KEY] == folder_key + assert result == expected_response + + @pytest.mark.asyncio + async def test_invoke_activity_async_retrieves_connection_and_sends_folder_key( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + simple_activity_metadata: ActivityMetadata, + ) -> None: + """Test that invoke_activity_async retrieves the connection and sends its folder key.""" + connection_id = "test-connection-123" + folder_key = "d6f5c54a-e2b2-4083-be93-623aa670ed40" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_id}", + method="GET", + status_code=200, + json={ + "id": connection_id, + "name": "Test Connection", + "elementInstanceId": 123, + "folder": {"key": folder_key}, + }, + ) + + expected_response = {"result": "async_success"} + httpx_mock.add_response( + method="POST", + status_code=200, + json=expected_response, + ) + + result = await service.invoke_activity_async( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input={"body_field1": "async_value"}, + ) + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + retrieve_request = requests[0] + assert retrieve_request.method == "GET" + assert f"/Connections/{connection_id}" in str(retrieve_request.url) + + activity_request = requests[1] + assert activity_request.headers[HEADER_FOLDER_KEY] == folder_key + assert result == expected_response + + def test_invoke_activity_omits_folder_header_when_no_folder( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + simple_activity_metadata: ActivityMetadata, + ) -> None: + """Test that folder key header is omitted when connection has no folder.""" + connection_id = "test-connection-123" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{connection_id}", + method="GET", + status_code=200, + json={ + "id": connection_id, + "name": "Test Connection", + "elementInstanceId": 123, + "folder": None, + }, + ) + + httpx_mock.add_response( + method="POST", + status_code=200, + json={"status": "success"}, + ) + + _ = service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=connection_id, + activity_input={"body_field1": "test value"}, + ) + + requests = httpx_mock.get_requests() + activity_request = requests[1] + assert HEADER_FOLDER_KEY not in activity_request.headers + + def test_invoke_activity_uses_connection_id_from_retrieve_response( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + simple_activity_metadata: ActivityMetadata, + ) -> None: + """Test that the activity URL uses connection.id from the retrieve response.""" + original_connection_id = "original-key-abc" + resolved_connection_id = "resolved-id-456" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{original_connection_id}", + method="GET", + status_code=200, + json={ + "id": resolved_connection_id, + "name": "Test Connection", + "elementInstanceId": 789, + "folder": None, + }, + ) + + httpx_mock.add_response( + method="POST", + status_code=200, + json={"status": "success"}, + ) + + _ = service.invoke_activity( + activity_metadata=simple_activity_metadata, + connection_id=original_connection_id, + activity_input={"body_field1": "test value"}, + ) + + requests = httpx_mock.get_requests() + activity_request = requests[1] + assert f"/element/instances/{resolved_connection_id}/" in str( + activity_request.url + ) + assert f"/element/instances/{original_connection_id}/" not in str( + activity_request.url + ) + + @pytest.mark.asyncio + async def test_invoke_activity_async_uses_connection_id_from_retrieve_response( + self, + httpx_mock: HTTPXMock, + service: ConnectionsService, + base_url: str, + org: str, + tenant: str, + simple_activity_metadata: ActivityMetadata, + ) -> None: + """Test that the async activity URL uses connection.id from the retrieve response.""" + original_connection_id = "original-key-abc" + resolved_connection_id = "resolved-id-456" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/connections_/api/v1/Connections/{original_connection_id}", + method="GET", + status_code=200, + json={ + "id": resolved_connection_id, + "name": "Test Connection", + "elementInstanceId": 789, + "folder": None, + }, + ) + + httpx_mock.add_response( + method="POST", + status_code=200, + json={"status": "success"}, + ) + + _ = await service.invoke_activity_async( + activity_metadata=simple_activity_metadata, + connection_id=original_connection_id, + activity_input={"body_field1": "test value"}, + ) + + requests = httpx_mock.get_requests() + activity_request = requests[1] + assert f"/element/instances/{resolved_connection_id}/" in str( + activity_request.url + ) + assert f"/element/instances/{original_connection_id}/" not in str( + activity_request.url + ) diff --git a/packages/uipath-platform/tests/services/test_context_grounding_service.py b/packages/uipath-platform/tests/services/test_context_grounding_service.py new file mode 100644 index 000000000..16560e315 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_context_grounding_service.py @@ -0,0 +1,2430 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import ValidationError +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.context_grounding import ( + BatchTransformCreationResponse, + BatchTransformOutputColumn, + BatchTransformResponse, + BatchTransformStatus, + BucketSourceConfig, + Citation, + CitationMode, + ConfluenceSourceConfig, + ContextGroundingIndex, + ContextGroundingQueryResponse, + DeepRagCreationResponse, + DeepRagResponse, + DropboxSourceConfig, + GoogleDriveSourceConfig, + Indexer, + OneDriveSourceConfig, +) +from uipath.platform.context_grounding._context_grounding_service import ( + ContextGroundingService, +) +from uipath.platform.orchestrator._buckets_service import BucketsService +from uipath.platform.orchestrator._folder_service import FolderService + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> ContextGroundingService: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + folders_service = FolderService(config=config, execution_context=execution_context) + buckets_service = BucketsService(config=config, execution_context=execution_context) + return ContextGroundingService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + buckets_service=buckets_service, + ) + + +class TestContextGroundingService: + def test_search( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v1/search", + status_code=200, + json=[ + { + "source": "test-source", + "page_number": "1", + "content": "Test content", + "metadata": { + "operation_id": "test-op", + "strategy": "test-strategy", + }, + "score": 0.95, + } + ], + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + response = service.search( + name="test-index", query="test query", number_of_results=1 + ) + + assert isinstance(response, list) + assert len(response) == 1 + assert isinstance(response[0], ContextGroundingQueryResponse) + assert response[0].source == "test-source" + assert response[0].page_number == "1" + assert response[0].content == "Test content" + assert response[0].metadata.operation_id == "test-op" + assert response[0].metadata.strategy == "test-strategy" + assert response[0].score == 0.95 + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[3].method == "POST" + assert sent_requests[3].url == f"{base_url}{org}{tenant}/ecs_/v1/search" + + assert HEADER_USER_AGENT in sent_requests[3].headers + assert ( + sent_requests[3].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.search/{version}" + ) + + @pytest.mark.anyio + async def test_search_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v1/search", + status_code=200, + json=[ + { + "source": "test-source", + "page_number": "1", + "content": "Test content", + "metadata": { + "operation_id": "test-op", + "strategy": "test-strategy", + }, + "score": 0.95, + } + ], + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + response = await service.search_async( + name="test-index", query="test query", number_of_results=1 + ) + + assert isinstance(response, list) + assert len(response) == 1 + assert isinstance(response[0], ContextGroundingQueryResponse) + assert response[0].source == "test-source" + assert response[0].page_number == "1" + assert response[0].content == "Test content" + assert response[0].metadata.operation_id == "test-op" + assert response[0].metadata.strategy == "test-strategy" + assert response[0].score == 0.95 + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[3].method == "POST" + assert sent_requests[3].url == f"{base_url}{org}{tenant}/ecs_/v1/search" + + assert HEADER_USER_AGENT in sent_requests[3].headers + assert ( + sent_requests[3].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.search_async/{version}" + ) + + def test_retrieve( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + index = service.retrieve(name="test-index") + + assert isinstance(index, ContextGroundingIndex) + assert index.id == "test-index-id" + assert index.name == "test-index" + assert index.last_ingestion_status == "Completed" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[1].method == "GET" + assert ( + sent_requests[1].url + == f"{base_url}{org}{tenant}/ecs_/v2/indexes?%24filter=Name+eq+%27test-index%27&%24expand=dataSource" + ) + + assert HEADER_USER_AGENT in sent_requests[1].headers + assert ( + sent_requests[1].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.retrieve/{version}" + ) + + @pytest.mark.anyio + async def test_retrieve_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + index = await service.retrieve_async(name="test-index") + + assert isinstance(index, ContextGroundingIndex) + assert index.id == "test-index-id" + assert index.name == "test-index" + assert index.last_ingestion_status == "Completed" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[1].method == "GET" + assert ( + sent_requests[1].url + == f"{base_url}{org}{tenant}/ecs_/v2/indexes?%24filter=Name+eq+%27test-index%27&%24expand=dataSource" + ) + + assert HEADER_USER_AGENT in sent_requests[1].headers + assert ( + sent_requests[1].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.retrieve_async/{version}" + ) + + def test_create_index_bucket( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/create", + status_code=200, + json={ + "id": "new-index-id", + "name": "test-bucket-index", + "description": "Test bucket index", + "lastIngestionStatus": "Queued", + "dataSource": {"bucketName": "test-bucket", "folder": "/test/folder"}, + }, + ) + + source = BucketSourceConfig( + bucket_name="test-bucket", + folder_path="/test/folder", + directory_path="/", + file_type="pdf", + ) + + index = service.create_index( + name="test-bucket-index", + description="Test bucket index", + source=source, + extraction_strategy="LLMV4", + ) + + assert isinstance(index, ContextGroundingIndex) + assert index.id == "new-index-id" + assert index.name == "test-bucket-index" + assert index.description == "Test bucket index" + assert index.last_ingestion_status == "Queued" + + sent_requests = httpx_mock.get_requests() + assert len(sent_requests) == 2 + + create_request = sent_requests[1] + assert create_request.method == "POST" + assert create_request.url == f"{base_url}{org}{tenant}/ecs_/v2/indexes/create" + assert HEADER_USER_AGENT in create_request.headers + assert ( + create_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.create_index/{version}" + ) + + request_data = json.loads(create_request.content) + assert request_data["name"] == "test-bucket-index" + assert request_data["description"] == "Test bucket index" + assert ( + request_data["dataSource"]["@odata.type"] + == "#UiPath.Vdbs.Domain.Api.V20Models.StorageBucketDataSourceRequest" + ) + assert request_data["dataSource"]["bucketName"] == "test-bucket" + assert request_data["dataSource"]["folder"] == "/test/folder" + assert request_data["dataSource"]["directoryPath"] == "/" + assert request_data["dataSource"]["fileNameGlob"] == "**/*.pdf" + assert request_data["extractionStrategy"] == "LLMV4" + + def test_create_index_google_drive( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/create", + status_code=200, + json={ + "id": "google-index-id", + "name": "test-google-index", + "description": "Test Google Drive index", + "lastIngestionStatus": "Queued", + "dataSource": {"connectionId": "conn-123", "folder": "/test/folder"}, + }, + ) + + source = GoogleDriveSourceConfig( + connection_id="conn-123", + connection_name="Google Drive Connection", + leaf_folder_id="folder-456", + directory_path="/shared-docs", + folder_path="/test/folder", + file_type="docx", + indexer=Indexer( + cron_expression="0 18 * * 2", time_zone_id="Pacific Standard Time" + ), + ) + + index = service.create_index( + name="test-google-index", + description="Test Google Drive index", + source=source, + ) + + assert isinstance(index, ContextGroundingIndex) + assert index.id == "google-index-id" + assert index.name == "test-google-index" + + sent_requests = httpx_mock.get_requests() + create_request = sent_requests[1] + + request_data = json.loads(create_request.content) + assert ( + request_data["dataSource"]["@odata.type"] + == "#UiPath.Vdbs.Domain.Api.V20Models.GoogleDriveDataSourceRequest" + ) + assert request_data["dataSource"]["connectionId"] == "conn-123" + assert request_data["dataSource"]["connectionName"] == "Google Drive Connection" + assert request_data["dataSource"]["leafFolderId"] == "folder-456" + assert request_data["dataSource"]["directoryPath"] == "/shared-docs" + assert request_data["dataSource"]["fileNameGlob"] == "**/*.docx" + assert request_data["dataSource"]["indexer"]["cronExpression"] == "0 18 * * 2" + assert ( + request_data["dataSource"]["indexer"]["timeZoneId"] + == "Pacific Standard Time" + ) + + def test_create_index_dropbox( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/create", + status_code=200, + json={ + "id": "dropbox-index-id", + "name": "test-dropbox-index", + "lastIngestionStatus": "Queued", + }, + ) + + source = DropboxSourceConfig( + connection_id="dropbox-conn-789", + connection_name="Dropbox Connection", + directory_path="/company-files", + folder_path="/test/folder", + ) + + index = service.create_index(name="test-dropbox-index", source=source) + + assert isinstance(index, ContextGroundingIndex) + assert index.id == "dropbox-index-id" + + sent_requests = httpx_mock.get_requests() + create_request = sent_requests[1] + + request_data = json.loads(create_request.content) + assert ( + request_data["dataSource"]["@odata.type"] + == "#UiPath.Vdbs.Domain.Api.V20Models.DropboxDataSourceRequest" + ) + assert request_data["dataSource"]["connectionId"] == "dropbox-conn-789" + assert request_data["dataSource"]["connectionName"] == "Dropbox Connection" + assert request_data["dataSource"]["directoryPath"] == "/company-files" + assert request_data["dataSource"]["fileNameGlob"] == "**/*" + assert "preProcessing" not in request_data + assert "extractionStrategy" not in request_data + + def test_create_index_onedrive( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/create", + status_code=200, + json={ + "id": "onedrive-index-id", + "name": "test-onedrive-index", + "lastIngestionStatus": "Queued", + }, + ) + + source = OneDriveSourceConfig( + connection_id="onedrive-conn-101", + connection_name="OneDrive Connection", + leaf_folder_id="onedrive-folder-202", + directory_path="/reports", + folder_path="/test/folder", + file_type="xlsx", + ) + + index = service.create_index(name="test-onedrive-index", source=source) + + assert isinstance(index, ContextGroundingIndex) + assert index.id == "onedrive-index-id" + + sent_requests = httpx_mock.get_requests() + create_request = sent_requests[1] + + request_data = json.loads(create_request.content) + assert ( + request_data["dataSource"]["@odata.type"] + == "#UiPath.Vdbs.Domain.Api.V20Models.OneDriveDataSourceRequest" + ) + assert request_data["dataSource"]["connectionId"] == "onedrive-conn-101" + assert request_data["dataSource"]["leafFolderId"] == "onedrive-folder-202" + assert request_data["dataSource"]["fileNameGlob"] == "**/*.xlsx" + + def test_create_index_confluence( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/create", + status_code=200, + json={ + "id": "confluence-index-id", + "name": "test-confluence-index", + "lastIngestionStatus": "Queued", + }, + ) + + source = ConfluenceSourceConfig( + connection_id="confluence-conn-303", + connection_name="Confluence Connection", + space_id="space-404", + directory_path="/wiki-docs", + folder_path="/test/folder", + ) + + index = service.create_index(name="test-confluence-index", source=source) + + assert isinstance(index, ContextGroundingIndex) + assert index.id == "confluence-index-id" + + sent_requests = httpx_mock.get_requests() + create_request = sent_requests[1] + + request_data = json.loads(create_request.content) + assert ( + request_data["dataSource"]["@odata.type"] + == "#UiPath.Vdbs.Domain.Api.V20Models.ConfluenceDataSourceRequest" + ) + assert request_data["dataSource"]["connectionId"] == "confluence-conn-303" + assert request_data["dataSource"]["connectionName"] == "Confluence Connection" + + @pytest.mark.anyio + async def test_create_index_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/create", + status_code=200, + json={ + "id": "async-index-id", + "name": "test-async-index", + "description": "Test async index", + "lastIngestionStatus": "Queued", + }, + ) + + source = BucketSourceConfig( + bucket_name="async-bucket", + folder_path="/async/folder", + ) + + index = await service.create_index_async( + name="test-async-index", description="Test async index", source=source + ) + + assert isinstance(index, ContextGroundingIndex) + assert index.id == "async-index-id" + assert index.name == "test-async-index" + + sent_requests = httpx_mock.get_requests() + create_request = sent_requests[1] + assert create_request.method == "POST" + assert HEADER_USER_AGENT in create_request.headers + assert ( + create_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.create_index_async/{version}" + ) + + def test_create_index_missing_bucket_name( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + # Pydantic will raise ValidationError for missing required fields + with pytest.raises(ValidationError, match="bucket_name"): + BucketSourceConfig(folder_path="/test/folder") # type: ignore[call-arg] + + def test_create_index_missing_google_drive_fields( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + # Pydantic will raise ValidationError for missing required fields + with pytest.raises(ValidationError, match="connection_name"): + GoogleDriveSourceConfig( # type: ignore[call-arg] + connection_id="conn-123", + folder_path="/test/folder", + ) + + def test_create_index_with_extraction_strategy( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/create", + status_code=200, + json={ + "id": "custom-strategy-index-id", + "name": "test-custom-strategy-index", + "lastIngestionStatus": "Queued", + "extractionStrategy": "NativeV1", + "embeddingsEnabled": True, + "isEncrypted": True, + }, + ) + + source = BucketSourceConfig( + bucket_name="test-bucket", + folder_path="/test/folder", + ) + + index = service.create_index( + name="test-custom-strategy-index", + source=source, + extraction_strategy="NativeV1", + embeddings_enabled=True, + is_encrypted=True, + ) + + assert isinstance(index, ContextGroundingIndex) + assert index.extraction_strategy == "NativeV1" + assert index.embeddings_enabled is True + assert index.is_encrypted is True + + sent_requests = httpx_mock.get_requests() + create_request = sent_requests[1] + + request_data = json.loads(create_request.content) + assert request_data["extractionStrategy"] == "NativeV1" + assert request_data["embeddingsEnabled"] is True + assert request_data["isEncrypted"] is True + + def test_all_requests_pass_spec_parameters( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Verify that all requests pass spec.method, spec.endpoint, spec.params, and spec.headers correctly.""" + # Mock folder service to always return the test folder key + with patch.object( + service._folders_service, "retrieve_key", return_value="test-folder-key" + ): + # Test retrieve method + with patch.object(service, "request") as mock_request: + mock_response = MagicMock() + mock_response.json.return_value = { + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + } + mock_request.return_value = mock_response + + service.retrieve(name="test-index") + + # Verify request was called with spec parameters + assert mock_request.called + call_args = mock_request.call_args + # Check positional args (method and endpoint) + assert call_args[0][0] == "GET" # method + assert str(call_args[0][1]) == "/ecs_/v2/indexes" # endpoint + # Check keyword args (params and headers) + assert "params" in call_args[1] + assert call_args[1]["params"]["$filter"] == "Name eq 'test-index'" + assert call_args[1]["params"]["$expand"] == "dataSource" + assert "headers" in call_args[1] + assert "x-uipath-folderkey" in call_args[1]["headers"] + assert ( + call_args[1]["headers"]["x-uipath-folderkey"] == "test-folder-key" + ) + + # Test search method + with patch.object(service, "request") as mock_request: + # First call for retrieve + retrieve_response = MagicMock() + retrieve_response.json.return_value = { + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + } + # Second call for search + search_response = MagicMock() + search_response.json.return_value = [] + mock_request.side_effect = [retrieve_response, search_response] + + service.search( + name="test-index", query="test query", number_of_results=10 + ) + + # Check the search request (second call) + assert mock_request.call_count == 2 + search_call = mock_request.call_args_list[1] + assert search_call[0][0] == "POST" # method + assert str(search_call[0][1]) == "/ecs_/v1/search" # endpoint + assert "json" in search_call[1] + assert search_call[1]["json"]["query"]["query"] == "test query" + assert search_call[1]["json"]["query"]["numberOfResults"] == 10 + assert "headers" in search_call[1] + assert "x-uipath-folderkey" in search_call[1]["headers"] + assert ( + search_call[1]["headers"]["x-uipath-folderkey"] == "test-folder-key" + ) + + # Test create_index method + with patch.object(service, "request") as mock_request: + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "new-index-id", + "name": "test-new-index", + "lastIngestionStatus": "Queued", + } + mock_request.return_value = mock_response + + source = BucketSourceConfig( + bucket_name="test-bucket", + folder_path="/test/folder", + directory_path="/", + ) + service.create_index(name="test-new-index", source=source) + + assert mock_request.called + call_args = mock_request.call_args + assert call_args[0][0] == "POST" # method + assert str(call_args[0][1]) == "/ecs_/v2/indexes/create" # endpoint + assert "json" in call_args[1] + assert "headers" in call_args[1] + assert "x-uipath-folderkey" in call_args[1]["headers"] + assert ( + call_args[1]["headers"]["x-uipath-folderkey"] == "test-folder-key" + ) + + # Test ingest_data method + with patch.object(service, "request") as mock_request: + mock_request.return_value = MagicMock() + + test_index = ContextGroundingIndex( + id="test-index-id", + name="test-index", + last_ingestion_status="Completed", + ) + service.ingest_data(test_index) + + assert mock_request.called + call_args = mock_request.call_args + assert call_args[0][0] == "POST" # method + assert ( + str(call_args[0][1]) == "/ecs_/v2/indexes/test-index-id/ingest" + ) # endpoint + assert "headers" in call_args[1] + assert "x-uipath-folderkey" in call_args[1]["headers"] + assert ( + call_args[1]["headers"]["x-uipath-folderkey"] == "test-folder-key" + ) + + # Test delete_index method + with patch.object(service, "request") as mock_request: + mock_request.return_value = MagicMock() + + test_index = ContextGroundingIndex( + id="test-index-id", + name="test-index", + last_ingestion_status="Completed", + ) + service.delete_index(test_index) + + assert mock_request.called + call_args = mock_request.call_args + assert call_args[0][0] == "DELETE" # method + assert ( + str(call_args[0][1]) == "/ecs_/v2/indexes/test-index-id" + ) # endpoint + assert "headers" in call_args[1] + assert "x-uipath-folderkey" in call_args[1]["headers"] + assert ( + call_args[1]["headers"]["x-uipath-folderkey"] == "test-folder-key" + ) + + def test_retrieve_deep_rag( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + citation = Citation(ordinal=1, page_number=1, source="abc", reference="abc") + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/deeprag/test-task-id?$expand=content&$select=content,name,createdDate,lastDeepRagStatus", + status_code=200, + json={ + "name": "test-deep-rag-task", + "createdDate": "2024-01-15T10:30:00Z", + "lastDeepRagStatus": "Successful", + "content": { + "text": "This is the deep RAG response text.", + "citations": [citation.model_dump()], + }, + }, + ) + + response = service.retrieve_deep_rag(id="test-task-id") + + assert isinstance(response, DeepRagResponse) + assert response.name == "test-deep-rag-task" + assert response.created_date == "2024-01-15T10:30:00Z" + assert response.last_deep_rag_status == "Successful" + assert response.content is not None + assert response.content.text == "This is the deep RAG response text." + assert response.content.citations == [citation] + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[0].method == "GET" + assert ( + sent_requests[0].url + == f"{base_url}{org}{tenant}/ecs_/v2/deeprag/test-task-id?%24expand=content&%24select=content%2Cname%2CcreatedDate%2ClastDeepRagStatus" + ) + + assert HEADER_USER_AGENT in sent_requests[0].headers + assert ( + sent_requests[0].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.retrieve_deep_rag/{version}" + ) + + @pytest.mark.anyio + async def test_retrieve_deep_rag_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + citation = Citation(ordinal=1, page_number=1, source="abc", reference="abc") + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/deeprag/test-task-id?$expand=content&$select=content,name,createdDate,lastDeepRagStatus", + status_code=200, + json={ + "name": "test-deep-rag-task", + "createdDate": "2024-01-15T10:30:00Z", + "lastDeepRagStatus": "Successful", + "content": { + "text": "This is the deep RAG response text.", + "citations": [citation.model_dump()], + }, + }, + ) + + response = await service.retrieve_deep_rag_async(id="test-task-id") + + assert isinstance(response, DeepRagResponse) + assert response.name == "test-deep-rag-task" + assert response.created_date == "2024-01-15T10:30:00Z" + assert response.last_deep_rag_status == "Successful" + assert response.content is not None + assert response.content.text == "This is the deep RAG response text." + assert response.content.citations == [citation] + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[0].method == "GET" + assert ( + sent_requests[0].url + == f"{base_url}{org}{tenant}/ecs_/v2/deeprag/test-task-id?%24expand=content&%24select=content%2Cname%2CcreatedDate%2ClastDeepRagStatus" + ) + + assert HEADER_USER_AGENT in sent_requests[0].headers + assert ( + sent_requests[0].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.retrieve_deep_rag_async/{version}" + ) + + def test_start_deep_rag( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createDeepRag?$select=id,lastDeepRagStatus,createdDate", + status_code=200, + json={ + "id": "new-deep-rag-task-id", + "lastDeepRagStatus": "Queued", + "createdDate": "2024-01-15T10:30:00Z", + }, + ) + + response = service.start_deep_rag( + index_name="test-index", + name="my-deep-rag-task", + prompt="Summarize all documents related to financial reports", + glob_pattern="*.pdf", + citation_mode=CitationMode.INLINE, + ) + + assert isinstance(response, DeepRagCreationResponse) + assert response.id == "new-deep-rag-task-id" + assert response.last_deep_rag_status == "Queued" + assert response.created_date == "2024-01-15T10:30:00Z" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[3].method == "POST" + assert ( + f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createDeepRag" + in str(sent_requests[3].url) + ) + + request_data = json.loads(sent_requests[3].content) + assert request_data["name"] == "my-deep-rag-task" + assert ( + request_data["prompt"] + == "Summarize all documents related to financial reports" + ) + assert request_data["globPattern"] == "*.pdf" + assert request_data["citationMode"] == "Inline" + + assert HEADER_USER_AGENT in sent_requests[3].headers + assert ( + sent_requests[3].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_deep_rag/{version}" + ) + + @pytest.mark.anyio + async def test_start_deep_rag_task( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createDeepRag?$select=id,lastDeepRagStatus,createdDate", + status_code=200, + json={ + "id": "new-deep-rag-task-id", + "lastDeepRagStatus": "Queued", + "createdDate": "2024-01-15T10:30:00Z", + }, + ) + + response = await service.start_deep_rag_async( + index_name="test-index", + name="my-deep-rag-task", + prompt="Summarize all documents related to financial reports", + glob_pattern="*.pdf", + citation_mode=CitationMode.INLINE, + ) + + assert isinstance(response, DeepRagCreationResponse) + assert response.id == "new-deep-rag-task-id" + assert response.last_deep_rag_status == "Queued" + assert response.created_date == "2024-01-15T10:30:00Z" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[3].method == "POST" + assert ( + f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createDeepRag" + in str(sent_requests[3].url) + ) + + request_data = json.loads(sent_requests[3].content) + assert request_data["name"] == "my-deep-rag-task" + assert ( + request_data["prompt"] + == "Summarize all documents related to financial reports" + ) + assert request_data["globPattern"] == "*.pdf" + assert request_data["citationMode"] == "Inline" + + assert HEADER_USER_AGENT in sent_requests[3].headers + assert ( + sent_requests[3].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_deep_rag_async/{version}" + ) + + def test_start_batch_transform( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag", + status_code=200, + json={ + "id": "new-batch-transform-id", + "lastBatchRagStatus": "Queued", + "errorMessage": None, + }, + ) + + output_columns = [ + BatchTransformOutputColumn( + name="summary", + description="A summary of the document", + ) + ] + + response = service.start_batch_transform( + name="my-batch-transform", + index_name="test-index", + prompt="Summarize all documents", + output_columns=output_columns, + storage_bucket_folder_path_prefix="data", + enable_web_search_grounding=False, + ) + + assert isinstance(response, BatchTransformCreationResponse) + assert response.id == "new-batch-transform-id" + assert response.last_batch_rag_status == "Queued" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[3].method == "POST" + assert ( + f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag" + in str(sent_requests[3].url) + ) + + request_data = json.loads(sent_requests[3].content) + assert request_data["name"] == "my-batch-transform" + assert request_data["prompt"] == "Summarize all documents" + assert request_data["targetFileGlobPattern"] == "data/*" + assert request_data["useWebSearchGrounding"] is False + + assert HEADER_USER_AGENT in sent_requests[3].headers + assert ( + sent_requests[3].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_batch_transform/{version}" + ) + + @pytest.mark.anyio + async def test_start_batch_transform_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag", + status_code=200, + json={ + "id": "new-batch-transform-id", + "lastBatchRagStatus": "Queued", + "errorMessage": None, + }, + ) + + output_columns = [ + BatchTransformOutputColumn( + name="summary", + description="A summary of the document", + ) + ] + + response = await service.start_batch_transform_async( + name="my-batch-transform", + index_name="test-index", + prompt="Summarize all documents", + output_columns=output_columns, + storage_bucket_folder_path_prefix="data", + enable_web_search_grounding=False, + ) + + assert isinstance(response, BatchTransformCreationResponse) + assert response.id == "new-batch-transform-id" + assert response.last_batch_rag_status == "Queued" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[3].method == "POST" + assert ( + f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag" + in str(sent_requests[3].url) + ) + + assert HEADER_USER_AGENT in sent_requests[3].headers + assert ( + sent_requests[3].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_batch_transform_async/{version}" + ) + + def test_start_batch_transform_with_target_file_name( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag", + status_code=200, + json={ + "id": "new-batch-transform-id", + "lastBatchRagStatus": "Queued", + "errorMessage": None, + }, + ) + + output_columns = [ + BatchTransformOutputColumn( + name="Emoji", + description="Emoji", + ), + BatchTransformOutputColumn( + name="Language", + description="The output Language should be loaded from the row", + ), + ] + + response = service.start_batch_transform( + name="my-batch-transform", + index_name="test-index", + prompt="Extract emojis and language", + output_columns=output_columns, + target_file_name="size_1KB.csv", + enable_web_search_grounding=True, + ) + + assert isinstance(response, BatchTransformCreationResponse) + assert response.id == "new-batch-transform-id" + assert response.last_batch_rag_status == "Queued" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[3].method == "POST" + assert ( + f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag" + in str(sent_requests[3].url) + ) + + request_data = json.loads(sent_requests[3].content) + assert request_data["name"] == "my-batch-transform" + assert request_data["prompt"] == "Extract emojis and language" + assert request_data["targetFileGlobPattern"] == "size_1KB.csv" + assert request_data["useWebSearchGrounding"] is True + + assert HEADER_USER_AGENT in sent_requests[3].headers + assert ( + sent_requests[3].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_batch_transform/{version}" + ) + + @pytest.mark.anyio + async def test_start_batch_transform_async_with_target_file_name( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag", + status_code=200, + json={ + "id": "new-batch-transform-id", + "lastBatchRagStatus": "Queued", + "errorMessage": None, + }, + ) + + output_columns = [ + BatchTransformOutputColumn( + name="Emoji", + description="Emoji", + ), + BatchTransformOutputColumn( + name="Language", + description="The output Language should be loaded from the row", + ), + ] + + response = await service.start_batch_transform_async( + name="my-batch-transform", + index_name="test-index", + prompt="Extract emojis and language", + output_columns=output_columns, + target_file_name="size_1KB.csv", + enable_web_search_grounding=True, + ) + + assert isinstance(response, BatchTransformCreationResponse) + assert response.id == "new-batch-transform-id" + assert response.last_batch_rag_status == "Queued" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[3].method == "POST" + assert ( + f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag" + in str(sent_requests[3].url) + ) + + request_data = json.loads(sent_requests[3].content) + assert request_data["name"] == "my-batch-transform" + assert request_data["prompt"] == "Extract emojis and language" + assert request_data["targetFileGlobPattern"] == "size_1KB.csv" + assert request_data["useWebSearchGrounding"] is True + + assert HEADER_USER_AGENT in sent_requests[3].headers + assert ( + sent_requests[3].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_batch_transform_async/{version}" + ) + + def test_start_batch_transform_with_combined_prefix_and_filename( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes?$filter=Name eq 'test-index'&$expand=dataSource", + status_code=200, + json={ + "value": [ + { + "id": "test-index-id", + "name": "test-index", + "lastIngestionStatus": "Completed", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag", + status_code=200, + json={ + "id": "new-batch-transform-id", + "lastBatchRagStatus": "Queued", + "errorMessage": None, + }, + ) + + output_columns = [ + BatchTransformOutputColumn( + name="summary", + description="A summary of the document", + ) + ] + + response = service.start_batch_transform( + name="my-batch-transform", + index_name="test-index", + prompt="Summarize the document", + output_columns=output_columns, + storage_bucket_folder_path_prefix="data", + target_file_name="size_1KB.csv", + enable_web_search_grounding=False, + ) + + assert isinstance(response, BatchTransformCreationResponse) + assert response.id == "new-batch-transform-id" + assert response.last_batch_rag_status == "Queued" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[3].method == "POST" + assert ( + f"{base_url}{org}{tenant}/ecs_/v2/indexes/test-index-id/createBatchRag" + in str(sent_requests[3].url) + ) + + request_data = json.loads(sent_requests[3].content) + assert request_data["name"] == "my-batch-transform" + assert request_data["prompt"] == "Summarize the document" + # Verify that both prefix and filename are combined + assert request_data["targetFileGlobPattern"] == "data/size_1KB.csv" + assert request_data["useWebSearchGrounding"] is False + + assert HEADER_USER_AGENT in sent_requests[3].headers + assert ( + sent_requests[3].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_batch_transform/{version}" + ) + + def test_retrieve_batch_transform( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", + status_code=200, + json={ + "id": "test-batch-id", + "name": "test-batch-transform", + "lastBatchRagStatus": "Successful", + "prompt": "Summarize documents", + "targetFileGlobPattern": "**", + "useWebSearchGrounding": False, + "outputColumns": [ + {"name": "summary", "description": "Document summary"} + ], + "createdDate": "2024-01-15T10:30:00Z", + }, + ) + + response = service.retrieve_batch_transform(id="test-batch-id") + + assert isinstance(response, BatchTransformResponse) + assert response.id == "test-batch-id" + assert response.name == "test-batch-transform" + assert response.last_batch_rag_status == BatchTransformStatus.SUCCESSFUL + assert response.prompt == "Summarize documents" + assert response.created_date == "2024-01-15T10:30:00Z" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[0].method == "GET" + assert ( + sent_requests[0].url + == f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id" + ) + + assert HEADER_USER_AGENT in sent_requests[0].headers + assert ( + sent_requests[0].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.retrieve_batch_transform/{version}" + ) + + @pytest.mark.anyio + async def test_retrieve_batch_transform_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", + status_code=200, + json={ + "id": "test-batch-id", + "name": "test-batch-transform", + "lastBatchRagStatus": "Successful", + "prompt": "Summarize documents", + "targetFileGlobPattern": "**", + "useWebSearchGrounding": False, + "outputColumns": [ + {"name": "summary", "description": "Document summary"} + ], + "createdDate": "2024-01-15T10:30:00Z", + }, + ) + + response = await service.retrieve_batch_transform_async(id="test-batch-id") + + assert isinstance(response, BatchTransformResponse) + assert response.id == "test-batch-id" + assert response.name == "test-batch-transform" + assert response.last_batch_rag_status == BatchTransformStatus.SUCCESSFUL + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[0].method == "GET" + assert ( + sent_requests[0].url + == f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id" + ) + + assert HEADER_USER_AGENT in sent_requests[0].headers + assert ( + sent_requests[0].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.retrieve_batch_transform_async/{version}" + ) + + def test_download_batch_transform_result( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + tmp_path, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", + status_code=200, + json={ + "id": "test-batch-id", + "name": "test-batch-transform", + "lastBatchRagStatus": "Successful", + "prompt": "Summarize documents", + "targetFileGlobPattern": "**", + "useWebSearchGrounding": False, + "outputColumns": [ + {"name": "summary", "description": "Document summary"} + ], + "createdDate": "2024-01-15T10:30:00Z", + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri", + status_code=200, + json={ + "uri": "https://storage.example.com/result.csv", + "isEncrypted": False, + }, + ) + + httpx_mock.add_response( + url="https://storage.example.com/result.csv", + status_code=200, + content=b"col1,col2\nval1,val2", + ) + + destination = tmp_path / "result.csv" + service.download_batch_transform_result( + id="test-batch-id", + destination_path=str(destination), + ) + + assert destination.exists() + assert destination.read_bytes() == b"col1,col2\nval1,val2" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[0].method == "GET" + assert ( + sent_requests[0].url + == f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id" + ) + + assert sent_requests[1].method == "GET" + assert ( + sent_requests[1].url + == f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri" + ) + + assert HEADER_USER_AGENT in sent_requests[1].headers + assert ( + sent_requests[1].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.download_batch_transform_result/{version}" + ) + + @pytest.mark.anyio + async def test_download_batch_transform_result_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + tmp_path, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", + status_code=200, + json={ + "id": "test-batch-id", + "name": "test-batch-transform", + "lastBatchRagStatus": "Successful", + "prompt": "Summarize documents", + "targetFileGlobPattern": "**", + "useWebSearchGrounding": False, + "outputColumns": [ + {"name": "summary", "description": "Document summary"} + ], + "createdDate": "2024-01-15T10:30:00Z", + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri", + status_code=200, + json={ + "uri": "https://storage.example.com/result.csv", + "isEncrypted": False, + }, + ) + + httpx_mock.add_response( + url="https://storage.example.com/result.csv", + status_code=200, + content=b"col1,col2\nval1,val2", + ) + + destination = tmp_path / "result.csv" + await service.download_batch_transform_result_async( + id="test-batch-id", + destination_path=str(destination), + ) + + assert destination.exists() + assert destination.read_bytes() == b"col1,col2\nval1,val2" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[0].method == "GET" + assert ( + sent_requests[0].url + == f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id" + ) + + assert sent_requests[1].method == "GET" + assert ( + sent_requests[1].url + == f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri" + ) + + assert HEADER_USER_AGENT in sent_requests[1].headers + assert ( + sent_requests[1].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.download_batch_transform_result_async/{version}" + ) + + def test_download_batch_transform_result_creates_nested_directories( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + tmp_path, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", + status_code=200, + json={ + "id": "test-batch-id", + "name": "test-batch-transform", + "lastBatchRagStatus": "Successful", + "prompt": "Summarize documents", + "targetFileGlobPattern": "**", + "useWebSearchGrounding": False, + "outputColumns": [ + {"name": "summary", "description": "Document summary"} + ], + "createdDate": "2024-01-15T10:30:00Z", + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri", + status_code=200, + json={ + "uri": "https://storage.example.com/result.csv", + "isEncrypted": False, + }, + ) + + httpx_mock.add_response( + url="https://storage.example.com/result.csv", + status_code=200, + content=b"col1,col2\nval1,val2", + ) + + destination = tmp_path / "output" / "nested" / "result.csv" + service.download_batch_transform_result( + id="test-batch-id", + destination_path=str(destination), + ) + + assert destination.exists() + assert destination.read_bytes() == b"col1,col2\nval1,val2" + assert destination.parent.exists() + + def test_download_batch_transform_result_encrypted( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + tmp_path, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", + status_code=200, + json={ + "id": "test-batch-id", + "name": "test-batch-transform", + "lastBatchRagStatus": "Successful", + "prompt": "Summarize documents", + "targetFileGlobPattern": "**", + "useWebSearchGrounding": False, + "outputColumns": [ + {"name": "summary", "description": "Document summary"} + ], + "createdDate": "2024-01-15T10:30:00Z", + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri", + status_code=200, + json={ + "uri": f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/DownloadBlob", + "isEncrypted": True, + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/DownloadBlob", + status_code=200, + content=b"encrypted,data\nval1,val2", + ) + + destination = tmp_path / "result_encrypted.csv" + service.download_batch_transform_result( + id="test-batch-id", + destination_path=str(destination), + ) + + assert destination.exists() + assert destination.read_bytes() == b"encrypted,data\nval1,val2" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + # Verify the DownloadBlob endpoint was called with Authorization header + download_request = sent_requests[2] + assert download_request.method == "GET" + assert "/DownloadBlob" in str(download_request.url) + assert "Authorization" in download_request.headers + assert download_request.headers["Authorization"].startswith("Bearer ") + + def test_create_ephemeral_index( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + import uuid + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral", + status_code=200, + json={ + "id": "ephemeral-index-id", + "name": "ephemeral-index", + "lastIngestionStatus": "Queued", + }, + ) + + attachment_ids = [str(uuid.uuid4()), str(uuid.uuid4())] + index = service.create_ephemeral_index( + usage="DeepRAG", + attachments=attachment_ids, + ) + + assert isinstance(index, ContextGroundingIndex) + assert index.id == "ephemeral-index-id" + assert index.name == "ephemeral-index" + assert index.last_ingestion_status == "Queued" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[0].method == "POST" + assert ( + sent_requests[0].url + == f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral" + ) + + request_data = json.loads(sent_requests[0].content) + assert request_data["usage"] == "DeepRAG" + assert "dataSource" in request_data + assert request_data["dataSource"]["attachments"] == [ + str(att) for att in attachment_ids + ] + + assert HEADER_USER_AGENT in sent_requests[0].headers + assert ( + sent_requests[0].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.create_ephemeral_index/{version}" + ) + + @pytest.mark.anyio + async def test_create_ephemeral_index_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + import uuid + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral", + status_code=200, + json={ + "id": "ephemeral-index-id", + "name": "ephemeral-index", + "lastIngestionStatus": "Queued", + }, + ) + + attachment_ids = [str(uuid.uuid4()), str(uuid.uuid4())] + index = await service.create_ephemeral_index_async( + usage="DeepRAG", + attachments=attachment_ids, + ) + + assert isinstance(index, ContextGroundingIndex) + assert index.id == "ephemeral-index-id" + assert index.name == "ephemeral-index" + assert index.last_ingestion_status == "Queued" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[0].method == "POST" + assert ( + sent_requests[0].url + == f"{base_url}{org}{tenant}/ecs_/v2/indexes/createephemeral" + ) + + request_data = json.loads(sent_requests[0].content) + assert request_data["usage"] == "DeepRAG" + assert "dataSource" in request_data + assert request_data["dataSource"]["attachments"] == [ + str(att) for att in attachment_ids + ] + + assert HEADER_USER_AGENT in sent_requests[0].headers + assert ( + sent_requests[0].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.create_ephemeral_index_async/{version}" + ) + + @pytest.mark.anyio + async def test_download_batch_transform_result_async_creates_nested_directories( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + tmp_path, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", + status_code=200, + json={ + "id": "test-batch-id", + "name": "test-batch-transform", + "lastBatchRagStatus": "Successful", + "prompt": "Summarize documents", + "targetFileGlobPattern": "**", + "useWebSearchGrounding": False, + "outputColumns": [ + {"name": "summary", "description": "Document summary"} + ], + "createdDate": "2024-01-15T10:30:00Z", + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri", + status_code=200, + json={ + "uri": "https://storage.example.com/result.csv", + "isEncrypted": False, + }, + ) + + httpx_mock.add_response( + url="https://storage.example.com/result.csv", + status_code=200, + content=b"col1,col2\nval1,val2", + ) + + destination = tmp_path / "output" / "nested" / "result.csv" + await service.download_batch_transform_result_async( + id="test-batch-id", + destination_path=str(destination), + ) + + assert destination.exists() + assert destination.read_bytes() == b"col1,col2\nval1,val2" + assert destination.parent.exists() + + @pytest.mark.anyio + async def test_start_deep_rag_ephemeral_async( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/indexes/ephemeral-index-id/createDeepRag?$select=id,lastDeepRagStatus,createdDate", + status_code=200, + json={ + "id": "new-deep-rag-task-id", + "lastDeepRagStatus": "Queued", + "createdDate": "2024-01-15T10:30:00Z", + }, + ) + + response = await service.start_deep_rag_ephemeral_async( + name="my-ephemeral-deep-rag-task", + prompt="Summarize all documents related to financial reports", + glob_pattern="*.pdf", + citation_mode=CitationMode.INLINE, + index_id="ephemeral-index-id", + ) + + assert isinstance(response, DeepRagCreationResponse) + assert response.id == "new-deep-rag-task-id" + assert response.last_deep_rag_status == "Queued" + assert response.created_date == "2024-01-15T10:30:00Z" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + assert sent_requests[0].method == "POST" + assert ( + f"{base_url}{org}{tenant}/ecs_/v2/indexes/ephemeral-index-id/createDeepRag" + in str(sent_requests[0].url) + ) + + request_data = json.loads(sent_requests[0].content) + assert request_data["name"] == "my-ephemeral-deep-rag-task" + assert ( + request_data["prompt"] + == "Summarize all documents related to financial reports" + ) + assert request_data["globPattern"] == "*.pdf" + assert request_data["citationMode"] == "Inline" + + assert HEADER_USER_AGENT in sent_requests[0].headers + assert ( + sent_requests[0].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ContextGroundingService.start_deep_rag_ephemeral_async/{version}" + ) + + @pytest.mark.anyio + async def test_download_batch_transform_result_async_encrypted( + self, + httpx_mock: HTTPXMock, + service: ContextGroundingService, + base_url: str, + org: str, + tenant: str, + version: str, + tmp_path, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id", + status_code=200, + json={ + "id": "test-batch-id", + "name": "test-batch-transform", + "lastBatchRagStatus": "Successful", + "prompt": "Summarize documents", + "targetFileGlobPattern": "**", + "useWebSearchGrounding": False, + "outputColumns": [ + {"name": "summary", "description": "Document summary"} + ], + "createdDate": "2024-01-15T10:30:00Z", + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/GetReadUri", + status_code=200, + json={ + "uri": f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/DownloadBlob", + "isEncrypted": True, + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/ecs_/v2/batchRag/test-batch-id/DownloadBlob", + status_code=200, + content=b"encrypted,data\nval1,val2", + ) + + destination = tmp_path / "result_encrypted.csv" + await service.download_batch_transform_result_async( + id="test-batch-id", + destination_path=str(destination), + ) + + assert destination.exists() + assert destination.read_bytes() == b"encrypted,data\nval1,val2" + + sent_requests = httpx_mock.get_requests() + if sent_requests is None: + raise Exception("No request was sent") + + # Verify the DownloadBlob endpoint was called with Authorization header + download_request = sent_requests[2] + assert download_request.method == "GET" + assert "/DownloadBlob" in str(download_request.url) + assert "Authorization" in download_request.headers + assert download_request.headers["Authorization"].startswith("Bearer ") diff --git a/packages/uipath-platform/tests/services/test_conversations_service.py b/packages/uipath-platform/tests/services/test_conversations_service.py new file mode 100644 index 000000000..31aa4a653 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_conversations_service.py @@ -0,0 +1,166 @@ +import pytest +from pytest_httpx import HTTPXMock +from uipath.core.chat import UiPathConversationMessage + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.chat import ConversationsService + + +@pytest.fixture +def service( + config: UiPathApiConfig, execution_context: UiPathExecutionContext +) -> ConversationsService: + return ConversationsService(config=config, execution_context=execution_context) + + +class TestConversationsService: + class TestRetrieveMessage: + @pytest.mark.anyio + async def test_retrieve_message( + self, + httpx_mock: HTTPXMock, + service: ConversationsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test retrieving a specific message from an exchange.""" + conversation_id = "123" + exchange_id = "202cf2d1-926e-422d-8cf2-4f5735fa91fa" + message_id = "08de239e-90da-4d17-b986-b7785268d8d7" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/autopilotforeveryone_/api/v1/conversation/{conversation_id}/exchange/{exchange_id}/message/{message_id}", + status_code=200, + json={ + "messageId": message_id, + "role": "assistant", + "contentParts": [], + "toolCalls": [], + "interrupts": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + }, + ) + + result = await service.retrieve_message_async( + conversation_id=conversation_id, + exchange_id=exchange_id, + message_id=message_id, + ) + + assert isinstance(result, UiPathConversationMessage) + assert result.message_id == message_id + assert result.role == "assistant" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/autopilotforeveryone_/api/v1/conversation/{conversation_id}/exchange/{exchange_id}/message/{message_id}" + ) + + @pytest.mark.anyio + async def test_retrieve_message_with_content_parts( + self, + httpx_mock: HTTPXMock, + service: ConversationsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieving a message with content parts.""" + conversation_id = "123" + exchange_id = "202cf2d1-926e-422d-8cf2-4f5735fa91fa" + message_id = "08de239e-90da-4d17-b986-b7785268d8d7" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/autopilotforeveryone_/api/v1/conversation/{conversation_id}/exchange/{exchange_id}/message/{message_id}", + status_code=200, + json={ + "messageId": message_id, + "role": "user", + "contentParts": [ + { + "contentPartId": "cp-1", + "mimeType": "text/plain", + "data": {"inline": "Hello, world!"}, + "citations": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + } + ], + "toolCalls": [], + "interrupts": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + }, + ) + + result = await service.retrieve_message_async( + conversation_id=conversation_id, + exchange_id=exchange_id, + message_id=message_id, + ) + + assert isinstance(result, UiPathConversationMessage) + assert result.message_id == message_id + assert result.role == "user" + assert result.content_parts is not None + assert len(result.content_parts) == 1 + assert result.content_parts[0].content_part_id == "cp-1" + assert result.content_parts[0].mime_type == "text/plain" + + @pytest.mark.anyio + async def test_retrieve_message_with_tool_calls( + self, + httpx_mock: HTTPXMock, + service: ConversationsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieving a message with tool calls.""" + conversation_id = "123" + exchange_id = "202cf2d1-926e-422d-8cf2-4f5735fa91fa" + message_id = "08de239e-90da-4d17-b986-b7785268d8d7" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/autopilotforeveryone_/api/v1/conversation/{conversation_id}/exchange/{exchange_id}/message/{message_id}", + status_code=200, + json={ + "messageId": message_id, + "role": "assistant", + "contentParts": [], + "toolCalls": [ + { + "toolCallId": "tc-1", + "name": "get_weather", + "input": {"city": "San Francisco"}, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + } + ], + "interrupts": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + }, + ) + + result = await service.retrieve_message_async( + conversation_id=conversation_id, + exchange_id=exchange_id, + message_id=message_id, + ) + + assert isinstance(result, UiPathConversationMessage) + assert result.message_id == message_id + assert result.role == "assistant" + assert result.tool_calls is not None + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].tool_call_id == "tc-1" + assert result.tool_calls[0].name == "get_weather" diff --git a/packages/uipath-platform/tests/services/test_documents_service.py b/packages/uipath-platform/tests/services/test_documents_service.py new file mode 100644 index 000000000..1a6543afb --- /dev/null +++ b/packages/uipath-platform/tests/services/test_documents_service.py @@ -0,0 +1,3644 @@ +import json +from pathlib import Path +from typing import Any +from unittest.mock import Mock, patch +from uuid import UUID, uuid4 + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.documents import ( + ActionPriority, + ClassificationResult, + DocumentsService, + ExtractionResponse, + ProjectType, + ValidateClassificationAction, + ValidateExtractionAction, +) +from uipath.platform.errors import ( + OperationFailedException, + OperationNotCompleteException, +) + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, +): + return DocumentsService( + config=config, + execution_context=execution_context, + polling_interval=0.001, # 1ms for fast tests + polling_timeout=10, # 10 seconds for tests + ) + + +@pytest.fixture +def documents_tests_data_path(tests_data_path: Path) -> Path: + return tests_data_path / "documents_service" + + +@pytest.fixture +def classification_response(documents_tests_data_path: Path) -> dict: # type: ignore + with open(documents_tests_data_path / "classification_response.json", "r") as f: + return json.load(f) + + +@pytest.fixture +def ixp_extraction_response(documents_tests_data_path: Path) -> dict: # type: ignore + with open(documents_tests_data_path / "ixp_extraction_response.json", "r") as f: + return json.load(f) + + +@pytest.fixture +def modern_extraction_response(documents_tests_data_path: Path) -> dict: # type: ignore + with open(documents_tests_data_path / "modern_extraction_response.json", "r") as f: + return json.load(f) + + +@pytest.fixture +def create_validation_action_response(documents_tests_data_path: Path) -> dict: # type: ignore + with open( + documents_tests_data_path + / "extraction_validation_action_response_unassigned.json", + "r", + ) as f: + return json.load(f) + + +@pytest.fixture +def extraction_validation_action_response_completed( + documents_tests_data_path: Path, +) -> dict: # type: ignore + with open( + documents_tests_data_path + / "extraction_validation_action_response_completed.json", + "r", + ) as f: + return json.load(f) + + +class TestDocumentsService: + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.parametrize( + "tag,version,project_name,project_type,file,file_path,error", + [ + ( + "Production", + None, + "TestProject", + ProjectType.MODERN, + None, + None, + "Exactly one of `file, file_path` must be provided", + ), + ( + "Production", + 4, + "TestProject", + ProjectType.MODERN, + b"something", + None, + "Exactly one of `tag, version` must be provided", + ), + ( + "Production", + None, + "TestProject", + ProjectType.MODERN, + b"something", + "something", + "Exactly one of `file, file_path` must be provided", + ), + ( + "Production", + None, + None, + ProjectType.PRETRAINED, + b"something", + None, + "`tag` must not be provided", + ), + ( + None, + None, + "TestProject", + ProjectType.PRETRAINED, + b"something", + None, + "`project_name` must not be provided", + ), + ( + None, + None, + None, + ProjectType.PRETRAINED, + b"something", + "pathto/file.pdf", + "Exactly one of `file, file_path` must be provided", + ), + ], + ) + @pytest.mark.asyncio + async def test_classify_with_invalid_parameters( + self, + service: DocumentsService, + mode: str, + tag, + version, + project_name, + project_type, + file, + file_path, + error, + ): + # ACT & ASSERT + with pytest.raises( + ValueError, + match=error, + ): + if mode == "async": + await service.classify_async( + tag=tag, + version=version, + project_name=project_name, + project_type=project_type, + file=file, + file_path=file_path, + ) + else: + service.classify( + tag=tag, + version=version, + project_name=project_name, + project_type=project_type, + file=file, + file_path=file_path, + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_with_classification_result_predefined( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + classification_response: dict, # type: ignore + modern_extraction_response: dict, # type: ignore + ): + # ARRANGE + project_id = str(UUID(int=0)) + document_id = str(uuid4()) + document_type_id = "receipts" + operation_id = str(uuid4()) + classification_response["classificationResults"][0]["ProjectId"] = project_id + classification_response["classificationResults"][0]["ProjectType"] = ( + ProjectType.PRETRAINED.value + ) + classification_response["classificationResults"][0]["ClassifierId"] = ( + "ml-classification" + ) + classification_response["classificationResults"][0]["Tag"] = None + classification_response["classificationResults"][0]["DocumentId"] = document_id + classification_response["classificationResults"][0]["DocumentTypeId"] = ( + document_type_id + ) + classification_result = ClassificationResult.model_validate( + classification_response["classificationResults"][0] + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/extraction/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_json={"documentId": document_id}, + json={"operationId": operation_id}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": modern_extraction_response}, + ) + + # ACT + if mode == "async": + response = await service.extract_async( + classification_result=classification_result + ) + else: + response = service.extract(classification_result=classification_result) + + # ASSERT + modern_extraction_response["projectId"] = project_id + modern_extraction_response["projectType"] = ProjectType.PRETRAINED + modern_extraction_response["extractorId"] = document_type_id + modern_extraction_response["tag"] = None + modern_extraction_response["documentTypeId"] = document_type_id + assert response.model_dump() == modern_extraction_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_with_classification_result_modern_with_version( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + classification_response: dict, # type: ignore + modern_extraction_response: dict, # type: ignore + ): + # ARRANGE + project_id = str(uuid4()) + document_id = str(uuid4()) + document_type_id = str(uuid4()) + classifier_id = "classifier_2" + version = 2 + extractor_id = "extractor_2" + classification_response["classificationResults"][0]["ProjectId"] = project_id + classification_response["classificationResults"][0]["ProjectType"] = ( + ProjectType.MODERN.value + ) + classification_response["classificationResults"][0]["ClassifierId"] = ( + classifier_id + ) + classification_response["classificationResults"][0]["Tag"] = None + classification_response["classificationResults"][0]["DocumentId"] = document_id + classification_response["classificationResults"][0]["DocumentTypeId"] = ( + document_type_id + ) + classification_result = ClassificationResult.model_validate( + classification_response["classificationResults"][0] + ) + + operation_id = str(uuid4()) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "id": classifier_id, + "projectVersion": version, + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "extractors": [ + { + "id": str(uuid4()), + "projectVersion": 1, + "documentTypeId": str(uuid4()), + }, + { + "id": extractor_id, + "projectVersion": version, + "documentTypeId": document_type_id, + }, + { + "id": str(uuid4()), + "projectVersion": 3, + "documentTypeId": str(uuid4()), + }, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_json={"documentId": document_id}, + json={"operationId": operation_id}, + ) + + statuses = ["NotStarted", "Running", "Succeeded"] + for status in statuses: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": status, "result": modern_extraction_response}, + ) + + # ACT + if mode == "async": + response = await service.extract_async( + classification_result=classification_result + ) + else: + response = service.extract(classification_result=classification_result) + + # ASSERT + modern_extraction_response["projectId"] = project_id + modern_extraction_response["projectType"] = ProjectType.MODERN + modern_extraction_response["extractorId"] = extractor_id + modern_extraction_response["tag"] = None + modern_extraction_response["documentTypeId"] = document_type_id + assert response.model_dump() == modern_extraction_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_with_classification_result_modern_with_tag( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + classification_response: dict, # type: ignore + modern_extraction_response: dict, # type: ignore + ): + # ARRANGE + project_id = str(uuid4()) + document_id = str(uuid4()) + document_type_id = str(uuid4()) + classification_response["classificationResults"][0]["ProjectId"] = project_id + classification_response["classificationResults"][0]["ProjectType"] = ( + ProjectType.MODERN.value + ) + classification_response["classificationResults"][0]["ClassifierId"] = None + classification_response["classificationResults"][0]["Tag"] = "Production" + classification_response["classificationResults"][0]["DocumentId"] = document_id + classification_response["classificationResults"][0]["DocumentTypeId"] = ( + document_type_id + ) + classification_result = ClassificationResult.model_validate( + classification_response["classificationResults"][0] + ) + + operation_id = str(uuid4()) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/document-types/{document_type_id}/extraction/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_json={"documentId": document_id}, + json={"operationId": operation_id}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/document-types/{document_type_id}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": modern_extraction_response}, + ) + + # ACT + if mode == "async": + response = await service.extract_async( + classification_result=classification_result + ) + else: + response = service.extract(classification_result=classification_result) + + # ASSERT + modern_extraction_response["projectId"] = project_id + modern_extraction_response["projectType"] = ProjectType.MODERN + modern_extraction_response["extractorId"] = None + modern_extraction_response["tag"] = "Production" + modern_extraction_response["documentTypeId"] = document_type_id + assert response.model_dump() == modern_extraction_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_classify_predefined( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + classification_response: dict, # type: ignore + ): + # ARRANGE + project_id = str(UUID(int=0)) + document_id = str(uuid4()) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_files={"File": b"test content"}, + json={"documentId": document_id}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": {}}, + ) + + operation_id = str(uuid4()) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/classification/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_json={"documentId": document_id}, + json={"operationId": operation_id}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/classification/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": classification_response}, + ) + + # ACT + if mode == "async": + response = await service.classify_async( + project_type=ProjectType.PRETRAINED, + file=b"test content", + ) + else: + response = service.classify( + project_type=ProjectType.PRETRAINED, + file=b"test content", + ) + + # ASSERT + classification_response["classificationResults"][0]["ProjectId"] = project_id + classification_response["classificationResults"][0]["ProjectType"] = ( + ProjectType.PRETRAINED.value + ) + classification_response["classificationResults"][0]["ClassifierId"] = ( + "ml-classification" + ) + classification_response["classificationResults"][0]["Tag"] = None + assert ( + response[0].model_dump() + == classification_response["classificationResults"][0] + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_classify_with_version_classifier_not_found( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + document_id = str(uuid4()) + version = 5 + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=Modern", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "projects": [ + {"id": str(uuid4()), "name": "OtherProject"}, + {"id": project_id, "name": "TestProject"}, + {"id": str(uuid4()), "name": "AnotherProject"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_files={"File": b"test content"}, + json={"documentId": document_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": {}}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "classifiers": [ + {"id": "classifier_1", "projectVersion": 1}, + {"id": "classifier_2", "projectVersion": 2}, + {"id": "classifier_3", "projectVersion": 3}, + ] + }, + ) + + # ACT & ASSERT + with pytest.raises( + ValueError, + match=f"Classifier for version '{version}' not found.", + ): + if mode == "async": + await service.classify_async( + version=version, + project_name="TestProject", + project_type=ProjectType.MODERN, + file=b"test content", + ) + else: + service.classify( + version=version, + project_name="TestProject", + project_type=ProjectType.MODERN, + file=b"test content", + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_classify_modern_with_version( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + classification_response: dict, # type: ignore + ): + # ARRANGE + project_id = str(uuid4()) + document_id = str(uuid4()) + operation_id = str(uuid4()) + version = 2 + classifier_id = "classifier_2" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=Modern", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "projects": [ + {"id": str(uuid4()), "name": "OtherProject"}, + {"id": project_id, "name": "TestProject"}, + {"id": str(uuid4()), "name": "AnotherProject"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_files={"File": b"test content"}, + json={"documentId": document_id}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": {}}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "classifiers": [ + {"id": "classifier_1", "projectVersion": 1}, + {"id": classifier_id, "projectVersion": version}, + {"id": "classifier_3", "projectVersion": 3}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/classification/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_json={"documentId": document_id}, + json={"operationId": operation_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/classification/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": classification_response}, + ) + + # ACT + if mode == "async": + response = await service.classify_async( + version=version, + project_name="TestProject", + project_type=ProjectType.MODERN, + file=b"test content", + ) + else: + response = service.classify( + version=version, + project_name="TestProject", + project_type=ProjectType.MODERN, + file=b"test content", + ) + + # ASSERT + classification_response["classificationResults"][0]["ProjectId"] = project_id + classification_response["classificationResults"][0]["ProjectType"] = ( + ProjectType.MODERN.value + ) + classification_response["classificationResults"][0]["ClassifierId"] = ( + classifier_id + ) + classification_response["classificationResults"][0]["Tag"] = None + assert ( + response[0].model_dump() + == classification_response["classificationResults"][0] + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_classify_modern_with_tag( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + classification_response: dict, # type: ignore + ): + # ARRANGE + project_id = str(uuid4()) + document_id = str(uuid4()) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=Modern", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "projects": [ + {"id": str(uuid4()), "name": "OtherProject"}, + {"id": project_id, "name": "TestProject"}, + {"id": str(uuid4()), "name": "AnotherProject"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/tags?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "tags": [ + {"name": "Production"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_files={"File": b"test content"}, + json={"documentId": document_id}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": {}}, + ) + + operation_id = str(uuid4()) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/classification/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_json={"documentId": document_id}, + json={"operationId": operation_id}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/classification/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": classification_response}, + ) + + # ACT + if mode == "async": + response = await service.classify_async( + tag="Production", + project_name="TestProject", + project_type=ProjectType.MODERN, + file=b"test content", + ) + else: + response = service.classify( + tag="Production", + project_name="TestProject", + project_type=ProjectType.MODERN, + file=b"test content", + ) + + # ASSERT + classification_response["classificationResults"][0]["ProjectId"] = project_id + classification_response["classificationResults"][0]["ProjectType"] = ( + ProjectType.MODERN.value + ) + classification_response["classificationResults"][0]["ClassifierId"] = None + classification_response["classificationResults"][0]["Tag"] = "Production" + assert ( + response[0].model_dump() + == classification_response["classificationResults"][0] + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + @pytest.mark.parametrize( + "tag,version,project_name,file,file_path,classification_result,project_type,document_type_name, error", + [ + ( + None, + None, + None, + None, + None, + None, + None, + None, + "`classification_result` must be provided", + ), + ( + "live", + None, + "TestProject", + None, + None, + None, + None, + None, + "`classification_result` must be provided", + ), + ( + "live", + None, + "TestProject", + None, + None, + None, + ProjectType.IXP, + None, + "`classification_result` must be provided", + ), + ( + "live", + None, + "TestProject", + b"something", + None, + None, + ProjectType.MODERN, + None, + "`document_type_name` must be provided", + ), + ( + "live", + None, + "TestProject", + b"something", + None, + "dummy classification result", + ProjectType.MODERN, + "dummy doctype", + "`classification_result` must not be provided", + ), + ( + None, + None, + "TestProject", + b"something", + None, + None, + ProjectType.MODERN, + "dummy doctype", + "Exactly one of `version, tag` must be provided", + ), + ( + "live", + None, + "TestProject", + b"something", + "path/to/file.pdf", + None, + ProjectType.MODERN, + "dummy doctype", + "Exactly one of `file, file_path` must be provided", + ), + ( + "live", + 4, + "TestProject", + b"something", + None, + None, + ProjectType.MODERN, + "dummy doctype", + "Exactly one of `version, tag` must be provided", + ), + ( + "live", + None, + None, + b"something", + None, + None, + ProjectType.PRETRAINED, + "dummy doctype", + "`tag` must not be provided", + ), + ], + ) + async def test_extract_with_invalid_parameters( + self, + service: DocumentsService, + mode: str, + tag, + version, + project_name, + file, + file_path, + classification_result, + project_type, + document_type_name, + error, + ): + # ACT & ASSERT + with pytest.raises(ValueError, match=error): + if mode == "async": + await service.extract_async( + tag=tag, + version=version, + project_name=project_name, + project_type=project_type, + file=file, + file_path=file_path, + classification_result=classification_result, + document_type_name=document_type_name, + ) + else: + service.extract( + tag=tag, + version=version, + project_name=project_name, + project_type=project_type, + file=file, + file_path=file_path, + classification_result=classification_result, + document_type_name=document_type_name, + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_ixp_with_version( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + ixp_extraction_response: dict, # type: ignore + mode: str, + ): + project_id = str(uuid4()) + document_id = str(uuid4()) + operation_id = str(uuid4()) + extractor_id = "ixp_3" + version = 3 + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=IXP", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "projects": [ + {"id": str(uuid4()), "name": "OtherProject"}, + {"id": project_id, "name": "TestProjectIXP"}, + {"id": str(uuid4()), "name": "AnotherProject"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_files={"File": b"test content"}, + json={"documentId": document_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": {}}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "extractors": [ + { + "id": extractor_id, + "projectVersion": version, + "documentTypeId": str(UUID(int=0)), + }, + { + "id": "ixp_2", + "projectVersion": 2, + "documentTypeId": str(UUID(int=0)), + }, + { + "id": "ixp_1", + "projectVersion": 1, + "documentTypeId": str(UUID(int=0)), + }, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_json={"documentId": document_id}, + json={"operationId": operation_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": ixp_extraction_response}, + ) + + # ACT + if mode == "async": + response = await service.extract_async( + project_name="TestProjectIXP", + project_type=ProjectType.IXP, + version=version, + file=b"test content", + ) + else: + response = service.extract( + project_name="TestProjectIXP", + project_type=ProjectType.IXP, + version=version, + file=b"test content", + ) + + # ASSERT + expected_response = ixp_extraction_response + expected_response["projectId"] = project_id + expected_response["projectType"] = ProjectType.IXP.value + expected_response["extractorId"] = extractor_id + expected_response["tag"] = None + expected_response["documentTypeId"] = str(UUID(int=0)) + assert response.model_dump() == expected_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_ixp_with_tag( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + ixp_extraction_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + document_id = str(uuid4()) + operation_id = str(uuid4()) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=IXP", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "projects": [ + {"id": str(uuid4()), "name": "OtherProject"}, + {"id": project_id, "name": "TestProjectIXP"}, + {"id": str(uuid4()), "name": "AnotherProject"}, + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/tags?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "tags": [ + {"name": "draft"}, + {"name": "live"}, + {"name": "production"}, + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_files={"File": b"test content"}, + json={"documentId": document_id}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": {}}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/live/document-types/{UUID(int=0)}/extraction/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_json={"documentId": document_id}, + json={"operationId": operation_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/live/document-types/{UUID(int=0)}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "NotStarted", "result": ixp_extraction_response}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/live/document-types/{UUID(int=0)}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Running", "result": ixp_extraction_response}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/live/document-types/{UUID(int=0)}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": ixp_extraction_response}, + ) + + # ACT + if mode == "async": + response = await service.extract_async( + project_name="TestProjectIXP", + project_type=ProjectType.IXP, + tag="live", + file=b"test content", + ) + else: + response = service.extract( + project_name="TestProjectIXP", + project_type=ProjectType.IXP, + tag="live", + file=b"test content", + ) + + # ASSERT + expected_response = ixp_extraction_response + expected_response["projectId"] = project_id + expected_response["projectType"] = ProjectType.IXP.value + expected_response["extractorId"] = None + expected_response["tag"] = "live" + expected_response["documentTypeId"] = str(UUID(int=0)) + assert response.model_dump() == expected_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_predefined( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + modern_extraction_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(UUID(int=0)) + document_id = str(uuid4()) + document_type_id = "receipts" + operation_id = str(uuid4()) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_files={"File": b"test content"}, + json={"documentId": document_id}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": {}}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/document-types?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "documentTypes": [ + {"id": str(uuid4()), "name": "Receipt"}, + {"id": document_type_id, "name": "Invoice"}, + {"id": str(uuid4()), "name": "Contract"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/extraction/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_json={"documentId": document_id}, + json={"operationId": operation_id}, + ) + + statuses = ["NotStarted", "Running", "Succeeded"] + for status in statuses: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": status, "result": modern_extraction_response}, + ) + + # ACT + if mode == "async": + response = await service.extract_async( + project_type=ProjectType.PRETRAINED, + file=b"test content", + document_type_name="Invoice", + ) + else: + response = service.extract( + project_type=ProjectType.PRETRAINED, + file=b"test content", + document_type_name="Invoice", + ) + + # ASSERT + expected_response = modern_extraction_response + expected_response["projectId"] = project_id + expected_response["projectType"] = ProjectType.PRETRAINED.value + expected_response["extractorId"] = document_type_id + expected_response["tag"] = None + expected_response["documentTypeId"] = document_type_id + assert response.model_dump() == expected_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_modern_with_version_extractor_not_found( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + document_id = str(uuid4()) + version = 5 + document_type_id = str(uuid4()) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=Modern", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "projects": [ + {"id": str(uuid4()), "name": "OtherProject"}, + {"id": project_id, "name": "TestProjectModern"}, + {"id": str(uuid4()), "name": "AnotherProject"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_files={"File": b"test content"}, + json={"documentId": document_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": {}}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/document-types?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "documentTypes": [ + {"id": str(uuid4()), "name": "Receipt"}, + {"id": document_type_id, "name": "Invoice"}, + {"id": str(uuid4()), "name": "Contract"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "extractors": [ + { + "id": str(uuid4()), + "projectVersion": 1, + "documentTypeId": str(uuid4()), + }, + { + "id": str(uuid4()), + "projectVersion": 2, + "documentTypeId": str(uuid4()), + }, + { + "id": str(uuid4()), + "projectVersion": 3, + "documentTypeId": str(uuid4()), + }, + ] + }, + ) + + # ACT & ASSERT + with pytest.raises( + ValueError, + match=f"Extractor for version '{version}' and document type id '{document_type_id}' not found.", + ): + if mode == "async": + await service.extract_async( + project_name="TestProjectModern", + version=version, + file=b"test content", + project_type=ProjectType.MODERN, + document_type_name="Invoice", + ) + else: + service.extract( + project_name="TestProjectModern", + version=version, + file=b"test content", + project_type=ProjectType.MODERN, + document_type_name="Invoice", + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_modern_with_version( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + modern_extraction_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + document_type_id = str(uuid4()) + document_id = str(uuid4()) + operation_id = str(uuid4()) + extractor_id = str(uuid4()) + version = 2 + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=Modern", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "projects": [ + {"id": str(uuid4()), "name": "OtherProject"}, + {"id": project_id, "name": "TestProjectModern"}, + {"id": str(uuid4()), "name": "AnotherProject"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_files={"File": b"test content"}, + json={"documentId": document_id}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": {}}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/document-types?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "documentTypes": [ + {"id": str(uuid4()), "name": "Receipt"}, + {"id": document_type_id, "name": "Invoice"}, + {"id": str(uuid4()), "name": "Contract"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "extractors": [ + { + "id": str(uuid4()), + "projectVersion": 1, + "documentTypeId": str(uuid4()), + }, + { + "id": extractor_id, + "projectVersion": version, + "documentTypeId": document_type_id, + }, + { + "id": str(uuid4()), + "projectVersion": 3, + "documentTypeId": str(uuid4()), + }, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_json={"documentId": document_id}, + json={"operationId": operation_id}, + ) + + statuses = ["NotStarted", "Running", "Succeeded"] + for status in statuses: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": status, "result": modern_extraction_response}, + ) + + # ACT + if mode == "async": + response = await service.extract_async( + project_name="TestProjectModern", + version=version, + file=b"test content", + project_type=ProjectType.MODERN, + document_type_name="Invoice", + ) + else: + response = service.extract( + project_name="TestProjectModern", + version=version, + file=b"test content", + project_type=ProjectType.MODERN, + document_type_name="Invoice", + ) + + # ASSERT + expected_response = modern_extraction_response + expected_response["projectId"] = project_id + expected_response["projectType"] = ProjectType.MODERN.value + expected_response["extractorId"] = extractor_id + expected_response["tag"] = None + expected_response["documentTypeId"] = document_type_id + assert response.model_dump() == expected_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_modern_with_tag( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + modern_extraction_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + document_type_id = str(uuid4()) + document_id = str(uuid4()) + operation_id = str(uuid4()) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=Modern", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "projects": [ + {"id": str(uuid4()), "name": "OtherProject"}, + {"id": project_id, "name": "TestProjectModern"}, + {"id": str(uuid4()), "name": "AnotherProject"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/tags?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "tags": [ + {"name": "Development"}, + {"name": "Staging"}, + {"name": "Production"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_files={"File": b"test content"}, + json={"documentId": document_id}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/result/{document_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": "Succeeded", "result": {}}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/document-types?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={ + "documentTypes": [ + {"id": str(uuid4()), "name": "Receipt"}, + {"id": document_type_id, "name": "Invoice"}, + {"id": str(uuid4()), "name": "Contract"}, + ] + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/document-types/{document_type_id}/extraction/start?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + match_json={"documentId": document_id}, + json={"operationId": operation_id}, + ) + + statuses = ["NotStarted", "Running", "Succeeded"] + for status in statuses: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/document-types/{document_type_id}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={ + "X-UiPath-Internal-ConsumptionSourceType": "CodedAgents", + }, + json={"status": status, "result": modern_extraction_response}, + ) + + # ACT + if mode == "async": + response = await service.extract_async( + project_name="TestProjectModern", + tag="Production", + file=b"test content", + project_type=ProjectType.MODERN, + document_type_name="Invoice", + ) + else: + response = service.extract( + project_name="TestProjectModern", + tag="Production", + file=b"test content", + project_type=ProjectType.MODERN, + document_type_name="Invoice", + ) + + # ASSERT + expected_response = modern_extraction_response + expected_response["projectId"] = project_id + expected_response["projectType"] = ProjectType.MODERN.value + expected_response["extractorId"] = None + expected_response["tag"] = "Production" + expected_response["documentTypeId"] = document_type_id + assert response.model_dump() == expected_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_modern_without_document_type_name( + self, service: DocumentsService, mode: str + ): + # ACT & ASSERT + with pytest.raises( + ValueError, + match="`document_type_name` must be provided", + ): + if mode == "async": + await service.extract_async( + project_name="TestProjectModern", + tag="Production", + file=b"test content", + project_type=ProjectType.MODERN, + ) + else: + service.extract( + project_name="TestProjectModern", + tag="Production", + file=b"test content", + project_type=ProjectType.MODERN, + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_get_document_type_id_not_found( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + ): + # ARRANGE + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/dummy_project_id/document-types?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={ + "documentTypes": [ + {"id": str(uuid4()), "name": "Receipt"}, + {"id": str(uuid4()), "name": "Invoice"}, + {"id": str(uuid4()), "name": "Contract"}, + ] + }, + ) + + # ACT & ASSERT + with pytest.raises( + ValueError, + match="Document type 'NonExistentType' not found.", + ): + if mode == "async": + await service._get_document_type_id_async( + project_id="dummy_project_id", + document_type_name="NonExistentType", + project_type=ProjectType.MODERN, + classification_result=None, + ) + else: + service._get_document_type_id( + project_id="dummy_project_id", + document_type_name="NonExistentType", + project_type=ProjectType.MODERN, + classification_result=None, + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_with_both_file_and_file_path_provided( + self, + service: DocumentsService, + mode: str, + ): + # ACT & ASSERT + with pytest.raises( + ValueError, + match="Exactly one of `file, file_path` must be provided", + ): + if mode == "async": + await service.extract_async( + project_name="TestProject", + project_type=ProjectType.IXP, + tag="live", + file=b"test content", + file_path="path/to/file.pdf", + ) + else: + service.extract( + project_name="TestProject", + project_type=ProjectType.IXP, + tag="live", + file=b"test content", + file_path="path/to/file.pdf", + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_with_wrong_project_name( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + ): + # ARRANGE + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=IXP", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={ + "projects": [ + {"id": str(uuid4()), "name": "OtherProject"}, + {"id": str(uuid4()), "name": "YetAnotherProject"}, + {"id": str(uuid4()), "name": "AnotherProject"}, + ] + }, + ) + + # ACT & ASSERT + with pytest.raises(ValueError, match="Project 'TestProject' not found."): + if mode == "async": + await service.extract_async( + project_name="TestProject", + project_type=ProjectType.IXP, + tag="live", + file=b"test content", + ) + else: + service.extract( + project_name="TestProject", + project_type=ProjectType.IXP, + tag="live", + file=b"test content", + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_extract_with_wrong_tag( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=IXP", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={ + "projects": [ + {"id": str(uuid4()), "name": "OtherProject"}, + {"id": project_id, "name": "TestProject"}, + {"id": str(uuid4()), "name": "AnotherProject"}, + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/tags?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"tags": [{"name": "staging"}]}, + ) + + # ACT & ASSERT + with pytest.raises(ValueError, match="Tag 'live' not found."): + if mode == "async": + await service.extract_async( + project_name="TestProject", + project_type=ProjectType.IXP, + tag="live", + file=b"test content", + ) + else: + service.extract( + project_name="TestProject", + project_type=ProjectType.IXP, + tag="live", + file=b"test content", + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_create_validate_classification_action_pretrained( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + classification_response: dict, # type: ignore + create_validation_action_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(UUID(int=0)) + operation_id = str(uuid4()) + tag = None + action_title = "TestAction" + action_priority = ActionPriority.LOW + action_catalog = "TestCatalog" + action_folder = "TestFolder" + storage_bucket_name = "TestBucket" + storage_bucket_directory_path = "Test/Directory/Path" + + classification_result = classification_response["classificationResults"][0] + classification_result["ProjectId"] = project_id + classification_result["ProjectType"] = ProjectType.PRETRAINED.value + classification_result["ClassifierId"] = "ml-classification" + classification_result["Tag"] = tag + classification_result = ClassificationResult.model_validate( + classification_result + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/validation/start?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + match_json={ + "actionTitle": action_title, + "actionPriority": action_priority, + "actionCatalog": action_catalog, + "actionFolder": action_folder, + "storageBucketName": storage_bucket_name, + "storageBucketDirectoryPath": storage_bucket_directory_path, + "classificationResults": [ + classification_result.model_dump(), + ], + "documentId": classification_result.document_id, + }, + json={"operationId": operation_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + # ACT + if mode == "async": + response = await service.create_validate_classification_action_async( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + classification_results=[classification_result], + ) + else: + response = service.create_validate_classification_action( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + classification_results=[classification_result], + ) + + # ASSERT + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.PRETRAINED.value + create_validation_action_response["classifierId"] = "ml-classification" + create_validation_action_response["tag"] = tag + create_validation_action_response["operationId"] = operation_id + assert response.model_dump() == create_validation_action_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_create_validate_classification_action_modern_with_version( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + classification_response: dict, # type: ignore + create_validation_action_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + classifier_id = str(uuid4()) + action_title = "TestAction" + action_priority = ActionPriority.HIGH + action_catalog = "TestCatalog" + action_folder = "TestFolder" + storage_bucket_name = "TestBucket" + storage_bucket_directory_path = "Test/Directory/Path" + + classification_result = classification_response["classificationResults"][0] + classification_result["ProjectId"] = project_id + classification_result["ProjectType"] = ProjectType.MODERN.value + classification_result["ClassifierId"] = classifier_id + classification_result["Tag"] = None + classification_result = ClassificationResult.model_validate( + classification_result + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/start?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + match_json={ + "actionTitle": action_title, + "actionPriority": action_priority, + "actionCatalog": action_catalog, + "actionFolder": action_folder, + "storageBucketName": storage_bucket_name, + "storageBucketDirectoryPath": storage_bucket_directory_path, + "classificationResults": [ + classification_result.model_dump(), + ], + "documentId": classification_result.document_id, + }, + json={"operationId": operation_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + # ACT + if mode == "async": + response = await service.create_validate_classification_action_async( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + classification_results=[classification_result], + ) + else: + response = service.create_validate_classification_action( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + classification_results=[classification_result], + ) + + # ASSERT + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.MODERN.value + create_validation_action_response["classifierId"] = classifier_id + create_validation_action_response["tag"] = None + create_validation_action_response["operationId"] = operation_id + assert response.model_dump() == create_validation_action_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_create_validate_classification_action_modern_with_tag( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + classification_response: dict, # type: ignore + create_validation_action_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + tag = "Production" + action_title = "TestAction" + action_priority = ActionPriority.MEDIUM + action_catalog = "TestCatalog" + action_folder = "TestFolder" + storage_bucket_name = "TestBucket" + storage_bucket_directory_path = "Test/Directory/Path" + + classification_result = classification_response["classificationResults"][0] + classification_result["ProjectId"] = project_id + classification_result["ProjectType"] = ProjectType.MODERN.value + classification_result["ClassifierId"] = None + classification_result["Tag"] = tag + classification_result = ClassificationResult.model_validate( + classification_result + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/classifiers/validation/start?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + match_json={ + "actionTitle": action_title, + "actionPriority": action_priority, + "actionCatalog": action_catalog, + "actionFolder": action_folder, + "storageBucketName": storage_bucket_name, + "storageBucketDirectoryPath": storage_bucket_directory_path, + "classificationResults": [ + classification_result.model_dump(), + ], + "documentId": classification_result.document_id, + }, + json={"operationId": operation_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/classifiers/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + # ACT + if mode == "async": + response = await service.create_validate_classification_action_async( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + classification_results=[classification_result], + ) + else: + response = service.create_validate_classification_action( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + classification_results=[classification_result], + ) + # ASSERT + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.MODERN.value + create_validation_action_response["classifierId"] = None + create_validation_action_response["tag"] = tag + create_validation_action_response["operationId"] = operation_id + assert response.model_dump() == create_validation_action_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_create_validate_classification_action_with_empty_classification_results( + self, + service: DocumentsService, + mode: str, + ): + # ACT & ASSERT + with pytest.raises( + ValueError, + match="`classification_results` must not be empty", + ): + if mode == "async": + await service.create_validate_classification_action_async( + action_title="TestAction", + action_priority=ActionPriority.MEDIUM, + action_catalog="TestCatalog", + action_folder="TestFolder", + storage_bucket_name="TestBucket", + storage_bucket_directory_path="Test/Directory/Path", + classification_results=[], + ) + else: + service.create_validate_classification_action( + action_title="TestAction", + action_priority=ActionPriority.MEDIUM, + action_catalog="TestCatalog", + action_folder="TestFolder", + storage_bucket_name="TestBucket", + storage_bucket_directory_path="Test/Directory/Path", + classification_results=[], + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_create_validate_classification_action_with_optional_params_omitted( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + classification_response: dict, # type: ignore + create_validation_action_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(UUID(int=0)) + operation_id = str(uuid4()) + classifier_id = "ml-classification" + tag = None + action_title = "TestAction" + + classification_result = classification_response["classificationResults"][0] + classification_result["ProjectId"] = project_id + classification_result["ProjectType"] = ProjectType.PRETRAINED.value + classification_result["ClassifierId"] = classifier_id + classification_result["Tag"] = tag + classification_result = ClassificationResult.model_validate( + classification_result + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/validation/start?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + match_json={ + "actionTitle": action_title, + "actionPriority": None, + "actionCatalog": None, + "actionFolder": None, + "storageBucketName": None, + "storageBucketDirectoryPath": None, + "classificationResults": [ + classification_result.model_dump(), + ], + "documentId": classification_result.document_id, + }, + json={"operationId": operation_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + # ACT + if mode == "async": + response = await service.create_validate_classification_action_async( + classification_results=[classification_result], + action_title=action_title, + ) + else: + response = service.create_validate_classification_action( + classification_results=[classification_result], + action_title=action_title, + ) + + # ASSERT + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.PRETRAINED.value + create_validation_action_response["classifierId"] = classifier_id + create_validation_action_response["tag"] = tag + create_validation_action_response["operationId"] = operation_id + assert response.model_dump() == create_validation_action_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_create_validate_extraction_action_pretrained( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + modern_extraction_response: dict, # type: ignore + create_validation_action_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(UUID(int=0)) + operation_id = str(uuid4()) + document_type_id = "invoices" + tag = None + action_title = "TestAction" + action_priority = ActionPriority.MEDIUM + action_catalog = "TestCatalog" + action_folder = "TestFolder" + storage_bucket_name = "TestBucket" + storage_bucket_directory_path = "Test/Directory/Path" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/validation/start?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + match_json={ + "extractionResult": modern_extraction_response["extractionResult"], + "documentId": modern_extraction_response["extractionResult"][ + "DocumentId" + ], + "actionTitle": action_title, + "actionPriority": action_priority, + "actionCatalog": action_catalog, + "actionFolder": action_folder, + "storageBucketName": storage_bucket_name, + "allowChangeOfDocumentType": True, + "storageBucketDirectoryPath": storage_bucket_directory_path, + }, + json={"operationId": operation_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + modern_extraction_response["projectId"] = project_id + modern_extraction_response["projectType"] = ProjectType.PRETRAINED.value + modern_extraction_response["extractorId"] = document_type_id + modern_extraction_response["tag"] = tag + modern_extraction_response["documentTypeId"] = document_type_id + + # ACT + if mode == "async": + response = await service.create_validate_extraction_action_async( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + extraction_response=ExtractionResponse.model_validate( + modern_extraction_response + ), + ) + else: + response = service.create_validate_extraction_action( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + extraction_response=ExtractionResponse.model_validate( + modern_extraction_response + ), + ) + + # ASSERT + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.PRETRAINED.value + create_validation_action_response["extractorId"] = document_type_id + create_validation_action_response["tag"] = tag + create_validation_action_response["documentTypeId"] = document_type_id + create_validation_action_response["operationId"] = operation_id + create_validation_action_response["validatedExtractionResults"] = None + create_validation_action_response["dataProjection"] = None + assert response.model_dump() == create_validation_action_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_create_validate_extraction_action_ixp_with_version( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + ixp_extraction_response: dict, # type: ignore + create_validation_action_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + document_type_id = str(UUID(int=0)) + extractor_id = "ixp-4" + tag = None + action_title = "TestAction" + action_priority = ActionPriority.LOW + action_catalog = "TestCatalog" + action_folder = "TestFolder" + storage_bucket_name = "TestBucket" + storage_bucket_directory_path = "Test/Directory/Path" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/start?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + match_json={ + "extractionResult": ixp_extraction_response["extractionResult"], + "documentId": ixp_extraction_response["extractionResult"]["DocumentId"], + "actionTitle": action_title, + "actionPriority": action_priority, + "actionCatalog": action_catalog, + "actionFolder": action_folder, + "storageBucketName": storage_bucket_name, + "allowChangeOfDocumentType": True, + "storageBucketDirectoryPath": storage_bucket_directory_path, + }, + json={"operationId": operation_id}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + ixp_extraction_response["projectId"] = project_id + ixp_extraction_response["projectType"] = ProjectType.IXP.value + ixp_extraction_response["extractorId"] = extractor_id + ixp_extraction_response["tag"] = tag + ixp_extraction_response["documentTypeId"] = document_type_id + + # ACT + if mode == "async": + response = await service.create_validate_extraction_action_async( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + extraction_response=ExtractionResponse.model_validate( + ixp_extraction_response + ), + ) + else: + response = service.create_validate_extraction_action( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + extraction_response=ExtractionResponse.model_validate( + ixp_extraction_response + ), + ) + + # ASSERT + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.IXP.value + create_validation_action_response["extractorId"] = extractor_id + create_validation_action_response["tag"] = tag + create_validation_action_response["documentTypeId"] = document_type_id + create_validation_action_response["operationId"] = operation_id + create_validation_action_response["validatedExtractionResults"] = None + create_validation_action_response["dataProjection"] = None + assert response.model_dump() == create_validation_action_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_create_validate_extraction_action_ixp_with_tag( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + ixp_extraction_response: dict, # type: ignore + create_validation_action_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + document_type_id = str(UUID(int=0)) + tag = "live" + action_title = "TestAction" + action_priority = ActionPriority.HIGH + action_catalog = "TestCatalog" + action_folder = "TestFolder" + storage_bucket_name = "TestBucket" + storage_bucket_directory_path = "Test/Directory/Path" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/start?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + match_json={ + "extractionResult": ixp_extraction_response["extractionResult"], + "documentId": ixp_extraction_response["extractionResult"]["DocumentId"], + "actionTitle": action_title, + "actionPriority": action_priority, + "actionCatalog": action_catalog, + "actionFolder": action_folder, + "storageBucketName": storage_bucket_name, + "allowChangeOfDocumentType": True, + "storageBucketDirectoryPath": storage_bucket_directory_path, + }, + json={"operationId": operation_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "NotStarted"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Running"}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + ixp_extraction_response["projectId"] = project_id + ixp_extraction_response["projectType"] = ProjectType.IXP.value + ixp_extraction_response["extractorId"] = None + ixp_extraction_response["tag"] = tag + ixp_extraction_response["documentTypeId"] = document_type_id + + # ACT + if mode == "async": + response = await service.create_validate_extraction_action_async( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + extraction_response=ExtractionResponse.model_validate( + ixp_extraction_response + ), + ) + else: + response = service.create_validate_extraction_action( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + extraction_response=ExtractionResponse.model_validate( + ixp_extraction_response + ), + ) + + # ASSERT + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.IXP.value + create_validation_action_response["extractorId"] = None + create_validation_action_response["tag"] = tag + create_validation_action_response["documentTypeId"] = document_type_id + create_validation_action_response["operationId"] = operation_id + create_validation_action_response["validatedExtractionResults"] = None + create_validation_action_response["dataProjection"] = None + assert response.model_dump() == create_validation_action_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_create_validate_extraction_action_with_optional_params_omitted( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + modern_extraction_response: dict, # type: ignore + create_validation_action_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(UUID(int=0)) + operation_id = str(uuid4()) + document_type_id = "invoices" + tag = None + action_title = "TestAction" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/validation/start?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + match_json={ + "extractionResult": modern_extraction_response["extractionResult"], + "documentId": modern_extraction_response["extractionResult"][ + "DocumentId" + ], + "actionTitle": action_title, + "actionPriority": None, + "actionCatalog": None, + "actionFolder": None, + "storageBucketName": None, + "allowChangeOfDocumentType": True, + "storageBucketDirectoryPath": None, + }, + json={"operationId": operation_id}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + modern_extraction_response["projectId"] = project_id + modern_extraction_response["projectType"] = ProjectType.PRETRAINED.value + modern_extraction_response["extractorId"] = document_type_id + modern_extraction_response["tag"] = tag + modern_extraction_response["documentTypeId"] = document_type_id + + # ACT + if mode == "async": + response = await service.create_validate_extraction_action_async( + extraction_response=ExtractionResponse.model_validate( + modern_extraction_response + ), + action_title=action_title, + ) + else: + response = service.create_validate_extraction_action( + extraction_response=ExtractionResponse.model_validate( + modern_extraction_response + ), + action_title=action_title, + ) + + # ASSERT + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.PRETRAINED.value + create_validation_action_response["extractorId"] = document_type_id + create_validation_action_response["tag"] = tag + create_validation_action_response["documentTypeId"] = document_type_id + create_validation_action_response["operationId"] = operation_id + create_validation_action_response["validatedExtractionResults"] = None + create_validation_action_response["dataProjection"] = None + assert response.model_dump() == create_validation_action_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_get_validate_classification_result_pretrained( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + service: DocumentsService, + create_validation_action_response: dict, # type: ignore + classification_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(UUID(int=0)) + operation_id = str(uuid4()) + + create_validation_action_response["actionStatus"] = "Completed" + create_validation_action_response["validatedClassificationResults"] = ( + classification_response["classificationResults"] + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/ml-classification/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.PRETRAINED.value + create_validation_action_response["classifierId"] = "ml-classification" + create_validation_action_response["tag"] = None + create_validation_action_response["operationId"] = operation_id + + # ACT + if mode == "async": + results = await service.get_validate_classification_result_async( + validation_action=ValidateClassificationAction.model_validate( + create_validation_action_response + ) + ) + else: + results = service.get_validate_classification_result( + validation_action=ValidateClassificationAction.model_validate( + create_validation_action_response + ) + ) + + # ASSERT + classification_response["classificationResults"][0]["ProjectId"] = project_id + classification_response["classificationResults"][0]["ProjectType"] = ( + ProjectType.PRETRAINED.value + ) + classification_response["classificationResults"][0]["ClassifierId"] = ( + "ml-classification" + ) + classification_response["classificationResults"][0]["Tag"] = None + assert ( + results[0].model_dump() + == classification_response["classificationResults"][0] + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_get_validate_classification_result_modern_with_version( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + service: DocumentsService, + create_validation_action_response: dict, # type: ignore + classification_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + classifier_id = str(uuid4()) + + create_validation_action_response["actionStatus"] = "Completed" + create_validation_action_response["validatedClassificationResults"] = ( + classification_response["classificationResults"] + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/classifiers/{classifier_id}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.MODERN.value + create_validation_action_response["classifierId"] = classifier_id + create_validation_action_response["tag"] = None + create_validation_action_response["operationId"] = operation_id + + # ACT + if mode == "async": + results = await service.get_validate_classification_result_async( + validation_action=ValidateClassificationAction.model_validate( + create_validation_action_response + ) + ) + else: + results = service.get_validate_classification_result( + validation_action=ValidateClassificationAction.model_validate( + create_validation_action_response + ) + ) + + # ASSERT + classification_response["classificationResults"][0]["ProjectId"] = project_id + classification_response["classificationResults"][0]["ProjectType"] = ( + ProjectType.MODERN.value + ) + classification_response["classificationResults"][0]["ClassifierId"] = ( + classifier_id + ) + classification_response["classificationResults"][0]["Tag"] = None + assert ( + results[0].model_dump() + == classification_response["classificationResults"][0] + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_get_validate_classification_result_modern_with_tag( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + service: DocumentsService, + create_validation_action_response: dict, # type: ignore + classification_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + + create_validation_action_response["actionStatus"] = "Completed" + create_validation_action_response["validatedClassificationResults"] = ( + classification_response["classificationResults"] + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/Production/classifiers/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.MODERN.value + create_validation_action_response["classifierId"] = None + create_validation_action_response["tag"] = "Production" + create_validation_action_response["operationId"] = operation_id + + # ACT + if mode == "async": + results = await service.get_validate_classification_result_async( + validation_action=ValidateClassificationAction.model_validate( + create_validation_action_response + ) + ) + else: + results = service.get_validate_classification_result( + validation_action=ValidateClassificationAction.model_validate( + create_validation_action_response + ) + ) + + # ASSERT + classification_response["classificationResults"][0]["ProjectId"] = project_id + classification_response["classificationResults"][0]["ProjectType"] = ( + ProjectType.MODERN.value + ) + classification_response["classificationResults"][0]["ClassifierId"] = None + classification_response["classificationResults"][0]["Tag"] = "Production" + assert ( + results[0].model_dump() + == classification_response["classificationResults"][0] + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_get_validate_extraction_result_pretrained( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + service: DocumentsService, + create_validation_action_response: dict, # type: ignore + modern_extraction_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(UUID(int=0)) + operation_id = str(uuid4()) + document_type_id = "invoices" + + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.PRETRAINED.value + create_validation_action_response["extractorId"] = document_type_id + create_validation_action_response["tag"] = None + create_validation_action_response["documentTypeId"] = document_type_id + create_validation_action_response["operationId"] = operation_id + create_validation_action_response["actionStatus"] = "Completed" + create_validation_action_response["validatedExtractionResults"] = ( + modern_extraction_response["extractionResult"] + ) + create_validation_action_response["dataProjection"] = ( + modern_extraction_response.get("dataProjection", None) + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{document_type_id}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + # ACT + if mode == "async": + response = await service.get_validate_extraction_result_async( + validation_action=ValidateExtractionAction.model_validate( + create_validation_action_response + ) + ) + else: + response = service.get_validate_extraction_result( + validation_action=ValidateExtractionAction.model_validate( + create_validation_action_response + ) + ) + + # ASSERT + modern_extraction_response["projectId"] = project_id + modern_extraction_response["projectType"] = ProjectType.PRETRAINED.value + modern_extraction_response["extractorId"] = document_type_id + modern_extraction_response["tag"] = None + modern_extraction_response["documentTypeId"] = document_type_id + assert response.model_dump() == modern_extraction_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.parametrize( + "project_type,extraction_response_fixture", + [ + (ProjectType.MODERN, "modern_extraction_response"), + (ProjectType.IXP, "ixp_extraction_response"), + ], + ) + @pytest.mark.asyncio + async def test_get_validate_extraction_result_with_version( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + service: DocumentsService, + create_validation_action_response: dict, # type: ignore + modern_extraction_response: dict, # type: ignore + ixp_extraction_response: dict, # type: ignore + mode: str, + project_type: ProjectType, + extraction_response_fixture: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + document_type_id = str(UUID(int=0)) + extractor_id = str(uuid4()) + + # Select the appropriate extraction response based on the fixture name + extraction_response = ( + modern_extraction_response + if extraction_response_fixture == "modern_extraction_response" + else ixp_extraction_response + ) + + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = project_type.value + create_validation_action_response["extractorId"] = extractor_id + create_validation_action_response["tag"] = None + create_validation_action_response["documentTypeId"] = document_type_id + create_validation_action_response["operationId"] = operation_id + create_validation_action_response["actionStatus"] = "Completed" + create_validation_action_response["validatedExtractionResults"] = ( + extraction_response["extractionResult"] + ) + create_validation_action_response["dataProjection"] = extraction_response.get( + "dataProjection", None + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/extractors/{extractor_id}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + # ACT + if mode == "async": + response = await service.get_validate_extraction_result_async( + validation_action=ValidateExtractionAction.model_validate( + create_validation_action_response + ) + ) + else: + response = service.get_validate_extraction_result( + validation_action=ValidateExtractionAction.model_validate( + create_validation_action_response + ) + ) + + # ASSERT + extraction_response["projectId"] = project_id + extraction_response["projectType"] = project_type + extraction_response["extractorId"] = extractor_id + extraction_response["tag"] = None + extraction_response["documentTypeId"] = document_type_id + assert response.model_dump() == extraction_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.parametrize( + "project_type,tag,extraction_response_fixture", + [ + (ProjectType.MODERN, "Production", "modern_extraction_response"), + (ProjectType.IXP, "live", "ixp_extraction_response"), + ], + ) + @pytest.mark.asyncio + async def test_get_validate_extraction_result_with_tag( + self, + httpx_mock: HTTPXMock, + base_url: str, + org: str, + tenant: str, + service: DocumentsService, + create_validation_action_response: dict, # type: ignore + modern_extraction_response: dict, # type: ignore + ixp_extraction_response: dict, # type: ignore + mode: str, + project_type: ProjectType, + tag: str, + extraction_response_fixture: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + document_type_id = str(UUID(int=0)) + + # Select the appropriate extraction response based on the fixture name + extraction_response = ( + modern_extraction_response + if extraction_response_fixture == "modern_extraction_response" + else ixp_extraction_response + ) + + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = project_type.value + create_validation_action_response["extractorId"] = None + create_validation_action_response["tag"] = tag + create_validation_action_response["documentTypeId"] = document_type_id + create_validation_action_response["operationId"] = operation_id + create_validation_action_response["actionStatus"] = "Completed" + create_validation_action_response["validatedExtractionResults"] = ( + extraction_response["extractionResult"] + ) + create_validation_action_response["dataProjection"] = extraction_response.get( + "dataProjection", None + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{document_type_id}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + # ACT + if mode == "async": + response = await service.get_validate_extraction_result_async( + validation_action=ValidateExtractionAction.model_validate( + create_validation_action_response + ) + ) + else: + response = service.get_validate_extraction_result( + validation_action=ValidateExtractionAction.model_validate( + create_validation_action_response + ) + ) + + # ASSERT + extraction_response["projectId"] = project_id + extraction_response["projectType"] = project_type + extraction_response["extractorId"] = None + extraction_response["tag"] = tag + extraction_response["documentTypeId"] = document_type_id + assert response.model_dump() == extraction_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + @patch("uipath.platform.documents._documents_service.time") + async def test_wait_for_operation_timeout( + self, + mock_time: Mock, + service: DocumentsService, + mode: str, + ): + # ARRANGE + mock_time.monotonic.side_effect = [0, 10, 30, 60, 200, 280, 310, 350] + + def mock_result_getter(): + return "Running", None, None + + async def mock_result_getter_async(): + return "Running", None, None + + # ACT & ASSERT + with pytest.raises(TimeoutError, match="Operation timed out."): + if mode == "async": + await service._wait_for_operation_async( + result_getter=mock_result_getter_async, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + else: + service._wait_for_operation( + result_getter=mock_result_getter, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_wait_for_operation_failed( + self, + service: DocumentsService, + mode: str, + ): + # ARRANGE + + def mock_result_getter(): + return "Failed", "Dummy error", None + + async def mock_result_getter_async(): + return "Failed", "Dummy error", None + + # ACT & ASSERT + with pytest.raises( + Exception, match="Operation failed with status: Failed, error: Dummy error" + ): + if mode == "async": + await service._wait_for_operation_async( + result_getter=mock_result_getter_async, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + else: + service._wait_for_operation( + result_getter=mock_result_getter, + wait_statuses=["NotStarted", "Running"], + success_status="Succeeded", + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_start_ixp_extraction( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + document_id = str(uuid4()) + operation_id = str(uuid4()) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects?api-version=1.1&type=IXP", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={ + "projects": [ + {"id": project_id, "name": "TestProjectIXP"}, + ] + }, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/digitization/start?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + match_files={"File": b"test content"}, + json={"documentId": document_id}, + ) + + httpx_mock.add_response( + method="POST", + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/staging/document-types/{UUID(int=0)}/extraction/start?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + match_json={"documentId": document_id}, + json={"operationId": operation_id}, + ) + + # ACT + if mode == "async": + response = await service.start_ixp_extraction_async( + project_name="TestProjectIXP", + tag="staging", + file=b"test content", + ) + else: + response = service.start_ixp_extraction( + project_name="TestProjectIXP", + tag="staging", + file=b"test content", + ) + + # ASSERT + assert response.operation_id == operation_id + assert response.document_id == document_id + assert response.project_id == project_id + assert response.tag == "staging" + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_start_ixp_extraction_invalid_parameters( + self, + service: DocumentsService, + mode: str, + ): + # ACT & ASSERT + with pytest.raises( + ValueError, + match="Exactly one of `file, file_path` must be provided", + ): + if mode == "async": + await service.start_ixp_extraction_async( + project_name="TestProject", + tag="staging", + ) + else: + service.start_ixp_extraction( + project_name="TestProject", + tag="staging", + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_retrieve_ixp_extraction_result_success( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + ixp_extraction_response: dict[str, Any], + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/staging/document-types/{UUID(int=0)}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": ixp_extraction_response}, + ) + + # ACT + if mode == "async": + response = await service.retrieve_ixp_extraction_result_async( + project_id=project_id, + tag="staging", + operation_id=operation_id, + ) + else: + response = service.retrieve_ixp_extraction_result( + project_id=project_id, + tag="staging", + operation_id=operation_id, + ) + + # ASSERT + assert response.project_id == project_id + assert response.tag == "staging" + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_retrieve_ixp_extraction_result_not_complete( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/staging/document-types/{UUID(int=0)}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Running"}, + ) + + # ACT & ASSERT + with pytest.raises( + OperationNotCompleteException, + match=f"IXP extraction '{operation_id}' is not complete. Current status: Running", + ) as exc_info: + if mode == "async": + await service.retrieve_ixp_extraction_result_async( + project_id=project_id, + tag="staging", + operation_id=operation_id, + ) + else: + service.retrieve_ixp_extraction_result( + project_id=project_id, + tag="staging", + operation_id=operation_id, + ) + + assert exc_info.value.operation_id == operation_id + assert exc_info.value.status == "Running" + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_retrieve_ixp_extraction_result_failed( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/staging/document-types/{UUID(int=0)}/extraction/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Failed", "error": "Dummy extraction error"}, + ) + + # ACT & ASSERT + with pytest.raises( + OperationFailedException, + match=f"IXP extraction '{operation_id}' failed with status: Failed error: Dummy extraction error", + ): + if mode == "async": + await service.retrieve_ixp_extraction_result_async( + project_id=project_id, + tag="staging", + operation_id=operation_id, + ) + else: + service.retrieve_ixp_extraction_result( + project_id=project_id, + tag="staging", + operation_id=operation_id, + ) + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_start_ixp_extraction_validation( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + ixp_extraction_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + tag = "live" + action_title = "TestAction" + action_priority = ActionPriority.HIGH + action_catalog = "TestCatalog" + action_folder = "TestFolder" + storage_bucket_name = "TestBucket" + storage_bucket_directory_path = "Test/Directory/Path" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/start?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + match_json={ + "extractionResult": ixp_extraction_response["extractionResult"], + "documentId": ixp_extraction_response["extractionResult"]["DocumentId"], + "actionTitle": action_title, + "actionPriority": action_priority, + "actionCatalog": action_catalog, + "actionFolder": action_folder, + "storageBucketName": storage_bucket_name, + "allowChangeOfDocumentType": True, + "storageBucketDirectoryPath": storage_bucket_directory_path, + }, + json={"operationId": operation_id}, + ) + + ixp_extraction_response["projectId"] = project_id + ixp_extraction_response["projectType"] = ProjectType.IXP.value + ixp_extraction_response["extractorId"] = None + ixp_extraction_response["tag"] = tag + ixp_extraction_response["documentTypeId"] = str(UUID(int=0)) + + # ACT + if mode == "async": + response = await service.start_ixp_extraction_validation_async( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + extraction_response=ExtractionResponse.model_validate( + ixp_extraction_response + ), + ) + else: + response = service.start_ixp_extraction_validation( + action_title=action_title, + action_priority=action_priority, + action_catalog=action_catalog, + action_folder=action_folder, + storage_bucket_name=storage_bucket_name, + storage_bucket_directory_path=storage_bucket_directory_path, + extraction_response=ExtractionResponse.model_validate( + ixp_extraction_response + ), + ) + + # ASSERT + assert response.model_dump() == { + "projectId": project_id, + "tag": tag, + "documentId": ixp_extraction_response["extractionResult"]["DocumentId"], + "operationId": operation_id, + } + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_start_ixp_extraction_validation_with_optional_params_omitted( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + ixp_extraction_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + tag = "live" + action_title = "TestAction" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/start?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + match_json={ + "extractionResult": ixp_extraction_response["extractionResult"], + "documentId": ixp_extraction_response["extractionResult"]["DocumentId"], + "actionTitle": action_title, + "actionPriority": None, + "actionCatalog": None, + "actionFolder": None, + "storageBucketName": None, + "allowChangeOfDocumentType": True, + "storageBucketDirectoryPath": None, + }, + json={"operationId": operation_id}, + ) + + ixp_extraction_response["projectId"] = project_id + ixp_extraction_response["projectType"] = ProjectType.IXP.value + ixp_extraction_response["extractorId"] = None + ixp_extraction_response["tag"] = tag + ixp_extraction_response["documentTypeId"] = str(UUID(int=0)) + + # ACT + if mode == "async": + response = await service.start_ixp_extraction_validation_async( + extraction_response=ExtractionResponse.model_validate( + ixp_extraction_response + ), + action_title=action_title, + ) + else: + response = service.start_ixp_extraction_validation( + extraction_response=ExtractionResponse.model_validate( + ixp_extraction_response + ), + action_title=action_title, + ) + + # ASSERT + assert response.model_dump() == { + "projectId": project_id, + "tag": tag, + "documentId": ixp_extraction_response["extractionResult"]["DocumentId"], + "operationId": operation_id, + } + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_retrieve_ixp_extraction_validation_result_unassigned( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + create_validation_action_response: dict, # type: ignore + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + tag = "live" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Succeeded", "result": create_validation_action_response}, + ) + + # ACT + if mode == "async": + response = await service.retrieve_ixp_extraction_validation_result_async( + project_id=project_id, + tag=tag, + operation_id=operation_id, + ) + else: + response = service.retrieve_ixp_extraction_validation_result( + project_id=project_id, + tag=tag, + operation_id=operation_id, + ) + + # ASSERT + create_validation_action_response["projectId"] = project_id + create_validation_action_response["projectType"] = ProjectType.IXP.value + create_validation_action_response["extractorId"] = None + create_validation_action_response["tag"] = tag + create_validation_action_response["documentTypeId"] = str(UUID(int=0)) + create_validation_action_response["operationId"] = operation_id + create_validation_action_response["validatedExtractionResults"] = None + create_validation_action_response["dataProjection"] = None + assert response.model_dump() == create_validation_action_response + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_retrieve_ixp_extraction_validation_result_completed( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + extraction_validation_action_response_completed: dict, # type: ignore + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + tag = "live" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={ + "status": "Succeeded", + "result": extraction_validation_action_response_completed, + }, + ) + + # ACT + if mode == "async": + response = await service.retrieve_ixp_extraction_validation_result_async( + project_id=project_id, + tag=tag, + operation_id=operation_id, + ) + else: + response = service.retrieve_ixp_extraction_validation_result( + project_id=project_id, + tag=tag, + operation_id=operation_id, + ) + + # ASSERT + extraction_validation_action_response_completed["projectId"] = project_id + extraction_validation_action_response_completed["projectType"] = ( + ProjectType.IXP.value + ) + extraction_validation_action_response_completed["extractorId"] = None + extraction_validation_action_response_completed["tag"] = tag + extraction_validation_action_response_completed["documentTypeId"] = str( + UUID(int=0) + ) + extraction_validation_action_response_completed["operationId"] = operation_id + assert response.model_dump() == extraction_validation_action_response_completed + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_retrieve_ixp_extraction_validation_result_not_complete( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + tag = "live" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Running"}, + ) + + # ACT & ASSERT + with pytest.raises( + OperationNotCompleteException, + match=f"IXP Create Validate Extraction Action '{operation_id}' is not complete. Current status: Running", + ) as exc_info: + if mode == "async": + await service.retrieve_ixp_extraction_validation_result_async( + project_id=project_id, + tag=tag, + operation_id=operation_id, + ) + else: + service.retrieve_ixp_extraction_validation_result( + project_id=project_id, + tag=tag, + operation_id=operation_id, + ) + + assert exc_info.value.operation_id == operation_id + assert exc_info.value.status == "Running" + + @pytest.mark.parametrize("mode", ["sync", "async"]) + @pytest.mark.asyncio + async def test_retrieve_ixp_extraction_validation_result_failed( + self, + httpx_mock: HTTPXMock, + service: DocumentsService, + base_url: str, + org: str, + tenant: str, + mode: str, + ): + # ARRANGE + project_id = str(uuid4()) + operation_id = str(uuid4()) + tag = "live" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/du_/api/framework/projects/{project_id}/{tag}/document-types/{UUID(int=0)}/validation/result/{operation_id}?api-version=1.1", + status_code=200, + match_headers={"X-UiPath-Internal-ConsumptionSourceType": "CodedAgents"}, + json={"status": "Failed", "error": "Dummy error"}, + ) + + # ACT & ASSERT + with pytest.raises( + OperationFailedException, + match=f"IXP Create Validate Extraction Action '{operation_id}' failed with status: Failed error: Dummy error", + ): + if mode == "async": + await service.retrieve_ixp_extraction_validation_result_async( + project_id=project_id, + tag=tag, + operation_id=operation_id, + ) + else: + service.retrieve_ixp_extraction_validation_result( + project_id=project_id, + tag=tag, + operation_id=operation_id, + ) diff --git a/packages/uipath-platform/tests/services/test_entities_service.py b/packages/uipath-platform/tests/services/test_entities_service.py new file mode 100644 index 000000000..4c6c85882 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_entities_service.py @@ -0,0 +1,262 @@ +import uuid +from dataclasses import make_dataclass +from typing import Optional + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.entities import Entity +from uipath.platform.entities._entities_service import EntitiesService + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> EntitiesService: + return EntitiesService(config=config, execution_context=execution_context) + + +@pytest.fixture(params=[True, False], ids=["correct_schema", "incorrect_schema"]) +def record_schema(request): + is_correct = request.param + field_type = int if is_correct else str + schema_name = f"RecordSchema{'Correct' if is_correct else 'Incorrect'}" + + RecordSchema = make_dataclass( + schema_name, [("name", str), ("integer_field", field_type)] + ) + + return RecordSchema, is_correct + + +@pytest.fixture(params=[True, False], ids=["optional_field", "required_field"]) +def record_schema_optional(request): + is_optional = request.param + field_type = Optional[int] | None if is_optional else int + schema_name = f"RecordSchema{'Optional' if is_optional else 'Required'}" + + RecordSchemaOptional = make_dataclass( + schema_name, [("name", str), ("integer_field", field_type)] + ) + + return RecordSchemaOptional, is_optional + + +class TestEntitiesService: + def test_retrieve( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/Entity/{entity_key}", + status_code=200, + json={ + "name": "TestEntity", + "displayName": "TestEntity", + "entityType": "TestEntityType", + "description": "TestEntity Description", + "fields": [ + { + "id": "12345", + "name": "field_name", + "isPrimaryKey": True, + "isForeignKey": False, + "isExternalField": False, + "isHiddenField": True, + "isUnique": True, + "referenceType": "ManyToOne", + "sqlType": {"name": "VARCHAR", "LengthLimit": 100}, + "isRequired": True, + "displayName": "Field Display Name", + "description": "This is a brief description of the field.", + "isSystemField": False, + "isAttachment": False, + "isRbacEnabled": True, + } + ], + "isRbacEnabled": False, + "id": f"{entity_key}", + }, + ) + + entity = service.retrieve(entity_key=str(entity_key)) + + assert isinstance(entity, Entity) + assert entity.id == f"{entity_key}" + assert entity.name == "TestEntity" + assert entity.display_name == "TestEntity" + assert entity.entity_type == "TestEntityType" + assert entity.description == "TestEntity Description" + assert entity.fields is not None + assert entity.fields[0].id == "12345" + assert entity.fields[0].name == "field_name" + assert entity.fields[0].is_primary_key + assert not entity.fields[0].is_foreign_key + assert entity.fields[0].sql_type.name == "VARCHAR" + assert entity.fields[0].sql_type.length_limit == 100 + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/datafabric_/api/Entity/{entity_key}" + ) + + def test_retrieve_records_with_no_schema_succeeds( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{str(entity_key)}/read?start=0&limit=1", + status_code=200, + json={ + "totalCount": 1, + "value": [ + {"Id": "12345", "name": "record_name", "integer_field": 10}, + {"Id": "12346", "name": "record_name2", "integer_field": 11}, + ], + }, + ) + + records = service.list_records(entity_key=str(entity_key), start=0, limit=1) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert isinstance(records, list) + assert len(records) == 2 + assert records[0].id == "12345" + assert records[0].name == "record_name" + assert records[0].integer_field == 10 + assert records[1].id == "12346" + assert records[1].name == "record_name2" + assert records[1].integer_field == 11 + + def test_retrieve_records_with_schema_succeeds( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + record_schema, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{str(entity_key)}/read?start=0&limit=1", + status_code=200, + json={ + "totalCount": 1, + "value": [ + {"Id": "12345", "name": "record_name", "integer_field": 10}, + {"Id": "12346", "name": "record_name2", "integer_field": 11}, + ], + }, + ) + + # Define the schema for the record. A wrong schema should make the validation fail + RecordSchema, is_schema_correct = record_schema + + if is_schema_correct: + records = service.list_records( + entity_key=str(entity_key), schema=RecordSchema, start=0, limit=1 + ) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert isinstance(records, list) + assert len(records) == 2 + assert records[0].id == "12345" + assert records[0].name == "record_name" + assert records[0].integer_field == 10 + assert records[1].id == "12346" + assert records[1].name == "record_name2" + assert records[1].integer_field == 11 + else: + # Validation should fail and raise an exception + with pytest.raises((ValueError, TypeError)): + service.list_records( + entity_key=str(entity_key), schema=RecordSchema, start=0, limit=1 + ) + + # Schema validation should take into account optional fields + def test_retrieve_records_with_optional_fields( + self, + httpx_mock: HTTPXMock, + service: EntitiesService, + base_url: str, + org: str, + tenant: str, + version: str, + record_schema_optional, + ) -> None: + entity_key = uuid.uuid4() + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/datafabric_/api/EntityService/entity/{str(entity_key)}/read?start=0&limit=1", + status_code=200, + json={ + "totalCount": 1, + "value": [ + { + "Id": "12345", + "name": "record_name", + }, + { + "Id": "12346", + "name": "record_name2", + }, + ], + }, + ) + + RecordSchemaOptional, is_field_optional = record_schema_optional + + if is_field_optional: + records = service.list_records( + entity_key=str(entity_key), + schema=RecordSchemaOptional, + start=0, + limit=1, + ) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert isinstance(records, list) + assert len(records) == 2 + assert records[0].id == "12345" + assert records[0].name == "record_name" + assert records[1].id == "12346" + assert records[1].name == "record_name2" + else: + # Validation should fail and raise an exception for missing required field + with pytest.raises((ValueError, TypeError)): + service.list_records( + entity_key=str(entity_key), + schema=RecordSchemaOptional, + start=0, + limit=1, + ) diff --git a/packages/uipath-platform/tests/services/test_external_application_service.py b/packages/uipath-platform/tests/services/test_external_application_service.py new file mode 100644 index 000000000..849f566f3 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_external_application_service.py @@ -0,0 +1,114 @@ +import httpx +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform.common._external_application_service import ( + ExternalApplicationService, +) +from uipath.platform.errors import EnrichedException + + +class TestExternalApplicationService: + @pytest.mark.parametrize( + "url,expected_domain", + [ + ("https://alpha.uipath.com", "alpha"), + ("https://sub.alpha.uipath.com", "alpha"), + ("https://staging.uipath.com", "staging"), + ("https://env.staging.uipath.com", "staging"), + ("https://cloud.uipath.com", "cloud"), + ("https://org.cloud.uipath.com", "cloud"), + ("https://something-else.com", "cloud"), + ("not-a-url", "cloud"), + ], + ) + def test_extract_domain_from_base_url(self, url: str, expected_domain: str): + service = ExternalApplicationService(url) + assert service._domain == expected_domain + + @pytest.mark.parametrize( + "domain,expected_url", + [ + ("alpha", "https://alpha.uipath.com/identity_/connect/token"), + ("staging", "https://staging.uipath.com/identity_/connect/token"), + ("cloud", "https://cloud.uipath.com/identity_/connect/token"), + ("unknown", "https://cloud.uipath.com/identity_/connect/token"), + ], + ) + def test_get_token_url(self, domain: str, expected_url: str): + service = ExternalApplicationService("https://cloud.uipath.com") + service._domain = domain + assert service.get_token_url() == expected_url + + def test_get_access_token_success(self, httpx_mock: HTTPXMock): + service = ExternalApplicationService("https://cloud.uipath.com") + + token_url = service.get_token_url() + httpx_mock.add_response( + url=token_url, + method="POST", + status_code=200, + json={"access_token": "fake-token"}, + ) + + token = service.get_token_data("client-id", "client-secret") + assert token.access_token == "fake-token" + + def test_get_access_token_invalid_client(self, httpx_mock: HTTPXMock): + service = ExternalApplicationService("https://cloud.uipath.com") + + token_url = service.get_token_url() + httpx_mock.add_response(url=token_url, method="POST", status_code=400, json={}) + + with pytest.raises(EnrichedException) as exc: + service.get_token_data("bad-id", "bad-secret") + + assert "400" in str(exc.value) + + def test_get_access_token_unauthorized(self, httpx_mock: HTTPXMock): + service = ExternalApplicationService("https://cloud.uipath.com") + + token_url = service.get_token_url() + httpx_mock.add_response(url=token_url, method="POST", status_code=401, json={}) + + with pytest.raises(EnrichedException) as exc: + service.get_token_data("bad-id", "bad-secret") + + assert "401" in str(exc.value) + + def test_get_access_token_unexpected_status(self, httpx_mock: HTTPXMock): + service = ExternalApplicationService("https://cloud.uipath.com") + + token_url = service.get_token_url() + httpx_mock.add_response(url=token_url, method="POST", status_code=500, json={}) + + with pytest.raises(EnrichedException) as exc: + service.get_token_data("client-id", "client-secret") + + assert "500" in str(exc.value).lower() + + def test_get_access_token_network_error(self, monkeypatch): + service = ExternalApplicationService("https://cloud.uipath.com") + + def fake_client_post(*args, **kwargs): + raise httpx.RequestError("network down") + + monkeypatch.setattr(httpx.Client, "post", fake_client_post) + + with pytest.raises(Exception) as exc: + service.get_token_data("client-id", "client-secret") + + assert "Network error during authentication" in str(exc.value) + + def test_get_access_token_unexpected_exception(self, monkeypatch): + service = ExternalApplicationService("https://cloud.uipath.com") + + def fake_client_post(*args, **kwargs): + raise ValueError("weird error") + + monkeypatch.setattr(httpx.Client, "post", fake_client_post) + + with pytest.raises(Exception) as exc: + service.get_token_data("client-id", "client-secret") + + assert "Unexpected error during authentication" in str(exc.value) diff --git a/packages/uipath-platform/tests/services/test_folder_service.py b/packages/uipath-platform/tests/services/test_folder_service.py new file mode 100644 index 000000000..ae4b6804f --- /dev/null +++ b/packages/uipath-platform/tests/services/test_folder_service.py @@ -0,0 +1,501 @@ +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.errors import FolderNotFoundException +from uipath.platform.orchestrator._folder_service import FolderService + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> FolderService: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + return FolderService(config=config, execution_context=execution_context) + + +class TestFolderService: + def test_retrieve_key_by_folder_path( + self, + httpx_mock: HTTPXMock, + service: FolderService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + with pytest.warns(DeprecationWarning, match="Use retrieve_key instead"): + folder_key = service.retrieve_key_by_folder_path("test-folder-path") + + assert folder_key == "test-folder-key" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.FolderService.retrieve_key/{version}" + ) + + def test_retrieve_key_by_folder_path_not_found( + self, + httpx_mock: HTTPXMock, + service: FolderService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=non-existent-folder&skip=0&take=20", + status_code=200, + json={"PageItems": []}, + ) + + with pytest.warns(DeprecationWarning, match="Use retrieve_key instead"): + folder_key = service.retrieve_key_by_folder_path("non-existent-folder") + + assert folder_key is None + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=non-existent-folder&skip=0&take=20" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.FolderService.retrieve_key/{version}" + ) + + def test_retrieve_key( + self, + httpx_mock: HTTPXMock, + service: FolderService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ] + }, + ) + + folder_key = service.retrieve_key(folder_path="test-folder-path") + + assert folder_key == "test-folder-key" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.FolderService.retrieve_key/{version}" + ) + + def test_retrieve_key_not_found( + self, + httpx_mock: HTTPXMock, + service: FolderService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=non-existent-folder&skip=0&take=20", + status_code=200, + json={"PageItems": []}, + ) + + folder_key = service.retrieve_key(folder_path="non-existent-folder") + + assert folder_key is None + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=non-existent-folder&skip=0&take=20" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.FolderService.retrieve_key/{version}" + ) + + def test_retrieve_key_found_on_second_page( + self, + httpx_mock: HTTPXMock, + service: FolderService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test that retrieve_key can find a folder on subsequent pages through pagination.""" + # First page - folder not found + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=target-folder&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": f"folder-key-{i}", + "FullyQualifiedName": f"other-folder-{i}", + } + for i in range(20) # Full page of 20 items, none matching + ] + }, + ) + + # Second page - folder found + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=target-folder&skip=20&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "target-folder-key", + "FullyQualifiedName": "target-folder", + }, + { + "Key": "another-folder-key", + "FullyQualifiedName": "another-folder", + }, + ] + }, + ) + + folder_key = service.retrieve_key(folder_path="target-folder") + + assert folder_key == "target-folder-key" + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + assert requests[0].method == "GET" + assert ( + requests[0].url + == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=target-folder&skip=0&take=20" + ) + + assert requests[1].method == "GET" + assert ( + requests[1].url + == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=target-folder&skip=20&take=20" + ) + + def test_retrieve_key_not_found_after_pagination( + self, + httpx_mock: HTTPXMock, + service: FolderService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test that retrieve_key returns None when folder is not found after paginating through all results.""" + # First page - full page, no match + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=missing-folder&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": f"folder-key-{i}", + "FullyQualifiedName": f"other-folder-{i}", + } + for i in range(20) # Full page of 20 items + ] + }, + ) + + # Second page - no match + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=missing-folder&skip=20&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "final-folder-key", + "FullyQualifiedName": "final-folder", + }, + ] + }, + ) + + folder_key = service.retrieve_key(folder_path="missing-folder") + + assert folder_key is None + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + assert requests[0].method == "GET" + assert ( + requests[0].url + == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=missing-folder&skip=0&take=20" + ) + + assert requests[1].method == "GET" + assert ( + requests[1].url + == f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=missing-folder&skip=20&take=20" + ) + + def test_retrieve_key_found_on_third_page( + self, + httpx_mock: HTTPXMock, + service: FolderService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test that retrieve_key can find a folder on the third page through multiple pagination requests.""" + # First page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=deep-folder&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": f"folder-key-{i}", + "FullyQualifiedName": f"page1-folder-{i}", + } + for i in range(20) + ] + }, + ) + + # Second page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=deep-folder&skip=20&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": f"folder-key-{i}", + "FullyQualifiedName": f"page2-folder-{i}", + } + for i in range(20) + ] + }, + ) + + # Third page - folder found + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=deep-folder&skip=40&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "some-other-key", + "FullyQualifiedName": "some-other-folder", + }, + { + "Key": "deep-folder-key", + "FullyQualifiedName": "deep-folder", + }, + ] + }, + ) + + folder_key = service.retrieve_key(folder_path="deep-folder") + + assert folder_key == "deep-folder-key" + + requests = httpx_mock.get_requests() + assert len(requests) == 3 + + expected_urls = [ + f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=deep-folder&skip=0&take=20", + f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=deep-folder&skip=20&take=20", + f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=deep-folder&skip=40&take=20", + ] + + for i, request in enumerate(requests): + assert request.method == "GET" + assert request.url == expected_urls[i] + + def test_retrieve_folder_key_with_folder_path( + self, + httpx_mock: HTTPXMock, + service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_folder_key resolves folder_path to folder_key.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=Production&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "retrieved-folder-key", + "FullyQualifiedName": "Finance/Production", + } + ] + }, + ) + + retrieved_key = service.retrieve_folder_key(folder_path="Finance/Production") + + assert retrieved_key == "retrieved-folder-key" + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + + def test_retrieve_folder_key_raises_error( + self, + service: FolderService, + ) -> None: + """Test retrieve_folder_key raises ValueError when folder_path is not provided.""" + with pytest.raises(ValueError) as exc_info: + service.retrieve_folder_key(folder_path=None) + + assert "Cannot obtain folder_key without providing folder_path" in str( + exc_info.value + ) + + def test_retrieve_folder_key_not_found_raises_error( + self, + httpx_mock: HTTPXMock, + service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_folder_key raises ValueError when folder_path is not found.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=Folder&skip=0&take=20", + status_code=200, + json={"PageItems": []}, + ) + + with pytest.raises(FolderNotFoundException) as exc_info: + service.retrieve_folder_key(folder_path="NonExistent/Folder") + + assert "Folder NonExistent/Folder not found" in str(exc_info.value) + + @pytest.mark.anyio + async def test_retrieve_folder_key_async_with_folder_path( + self, + httpx_mock: HTTPXMock, + service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_folder_key_async resolves folder_path to folder_key.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=Production&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "async-retrieved-key", + "FullyQualifiedName": "Finance/Production", + } + ] + }, + ) + + retrieved_key = await service.retrieve_folder_key_async( + folder_path="Finance/Production" + ) + + assert retrieved_key == "async-retrieved-key" + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + + @pytest.mark.anyio + async def test_retrieve_folder_key_async_raises_error( + self, + service: FolderService, + ) -> None: + """Test retrieve_folder_key_async raises ValueError when folder_path is not provided.""" + with pytest.raises(ValueError) as exc_info: + await service.retrieve_folder_key_async(folder_path=None) + + assert "Cannot obtain folder_key without providing folder_path" in str( + exc_info.value + ) + + @pytest.mark.anyio + async def test_retrieve_folder_key_async_not_found_raises_error( + self, + httpx_mock: HTTPXMock, + service: FolderService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test retrieve_folder_key_async raises ValueError when folder_path is not found.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=Folder&skip=0&take=20", + status_code=200, + json={"PageItems": []}, + ) + + with pytest.raises(FolderNotFoundException) as exc_info: + await service.retrieve_folder_key_async(folder_path="NonExistent/Folder") + + assert "Folder NonExistent/Folder not found" in str(exc_info.value) diff --git a/packages/uipath-platform/tests/services/test_guardrails_service.py b/packages/uipath-platform/tests/services/test_guardrails_service.py new file mode 100644 index 000000000..9d8f5a900 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_guardrails_service.py @@ -0,0 +1,300 @@ +import json + +import httpx +import pytest +from pytest_httpx import HTTPXMock +from uipath.core.guardrails import ( + GuardrailScope, + GuardrailSelector, + GuardrailValidationResultType, +) + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.guardrails import ( + BuiltInValidatorGuardrail, + EnumListParameterValue, + GuardrailsService, + MapEnumParameterValue, +) + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> GuardrailsService: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + return GuardrailsService(config=config, execution_context=execution_context) + + +class TestGuardrailsService: + """Test GuardrailsService functionality.""" + + class TestEvaluateGuardrail: + """Test evaluate_guardrail method.""" + + def test_evaluate_guardrail_validation( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + print(f"base_url: {base_url}, org: {org}, tenant: {tenant}") + # Mock the API response + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + status_code=200, + json={ + "result": "PASSED", + "details": "Validation passed", + }, + ) + + # Create a PII detection guardrail + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII detection guardrail", + description="Test PII detection", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["StringToNumber"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[ + EnumListParameterValue( + parameter_type="enum-list", + id="entities", + value=["Email", "Address"], + ), + MapEnumParameterValue( + parameter_type="map-enum", + id="entityThresholds", + value={"Email": 1, "Address": 0.7}, + ), + ], + ) + + test_input = "There is no email or address here." + + result = service.evaluate_guardrail(test_input, pii_guardrail) + + assert result.result == GuardrailValidationResultType.PASSED + assert result.reason == "Validation passed" + + def test_evaluate_guardrail_validation_failed( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + # Mock API response for failed validation + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + status_code=200, + json={ + "result": "VALIDATION_FAILED", + "details": "PII detected: Email found", + }, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII detection guardrail", + description="Test PII detection", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["StringToNumber"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + test_input = "Contact me at john@example.com" + + result = service.evaluate_guardrail(test_input, pii_guardrail) + + assert result.result == GuardrailValidationResultType.VALIDATION_FAILED + assert result.reason == "PII detected: Email found" + + def test_evaluate_guardrail_feature_disabled_403( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + # Mock API response with 403 status for FEATURE_DISABLED + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + status_code=403, + json={ + "result": "FEATURE_DISABLED", + "details": "Guardrail feature is disabled", + }, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII detection guardrail", + description="Test PII detection", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["StringToNumber"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + test_input = "Contact me at john@example.com" + + result = service.evaluate_guardrail(test_input, pii_guardrail) + + assert result.result == GuardrailValidationResultType.FEATURE_DISABLED + assert result.reason == "Guardrail feature is disabled" + + def test_evaluate_guardrail_entitlements_missing_403( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + # Mock API response with 403 status for ENTITLEMENTS_MISSING + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + status_code=403, + json={ + "result": "ENTITLEMENTS_MISSING", + "details": "Guardrail entitlement is missing", + }, + ) + + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII detection guardrail", + description="Test PII detection", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["StringToNumber"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[], + ) + + test_input = "Contact me at john@example.com" + + result = service.evaluate_guardrail(test_input, pii_guardrail) + + assert result.result == GuardrailValidationResultType.ENTITLEMENTS_MISSING + assert result.reason == "Guardrail entitlement is missing" + + def test_evaluate_guardrail_request_payload_structure( + self, + httpx_mock: HTTPXMock, + service: GuardrailsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test that the request payload has the correct structure after revert.""" + captured_request = None + + def capture_request(request): + nonlocal captured_request + captured_request = request + return httpx.Response( + status_code=200, + json={ + "result": "PASSED", + "details": "Validation passed", + }, + ) + + httpx_mock.add_callback( + method="POST", + url=f"{base_url}{org}{tenant}/agentsruntime_/api/execution/guardrails/validate", + callback=capture_request, + ) + + # Create a PII detection guardrail with parameters + pii_guardrail = BuiltInValidatorGuardrail( + id="test-id", + name="PII detection guardrail", + description="Test PII detection", + enabled_for_evals=True, + selector=GuardrailSelector( + scopes=[GuardrailScope.TOOL], match_names=["StringToNumber"] + ), + guardrail_type="builtInValidator", + validator_type="pii_detection", + validator_parameters=[ + EnumListParameterValue( + parameter_type="enum-list", + id="entities", + value=["Email", "Address"], + ), + MapEnumParameterValue( + parameter_type="map-enum", + id="entityThresholds", + value={"Email": 1, "Address": 0.7}, + ), + ], + ) + + test_input = "There is no email or address here." + + result = service.evaluate_guardrail(test_input, pii_guardrail) + + # Verify the request was captured + assert captured_request is not None + + # Parse the request payload + request_payload = json.loads(captured_request.content) + + # Verify the payload structure matches the reverted format: + # { + # "validator": guardrail.validator_type, + # "input": input_data, + # "parameters": parameters, + # } + assert "validator" in request_payload + assert "input" in request_payload + assert "parameters" in request_payload + + # Verify validator is a string (not an object) + assert isinstance(request_payload["validator"], str) + assert request_payload["validator"] == "pii_detection" + + # Verify input is a string + assert isinstance(request_payload["input"], str) + assert request_payload["input"] == "There is no email or address here." + + # Verify parameters is an array + assert isinstance(request_payload["parameters"], list) + assert len(request_payload["parameters"]) == 2 + + # Verify parameter structure + entities_param = request_payload["parameters"][0] + assert entities_param["$parameterType"] == "enum-list" + assert entities_param["id"] == "entities" + assert entities_param["value"] == ["Email", "Address"] + + thresholds_param = request_payload["parameters"][1] + assert thresholds_param["$parameterType"] == "map-enum" + assert thresholds_param["id"] == "entityThresholds" + assert thresholds_param["value"] == {"Email": 1, "Address": 0.7} + + # Verify result fields + assert result.result == GuardrailValidationResultType.PASSED + assert result.reason == "Validation passed" diff --git a/packages/uipath-platform/tests/services/test_hitl.py b/packages/uipath-platform/tests/services/test_hitl.py new file mode 100644 index 000000000..89cb6c4c3 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_hitl.py @@ -0,0 +1,1431 @@ +import uuid +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from pytest_httpx import HTTPXMock +from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError +from uipath.core.triggers import ( + UiPathApiTrigger, + UiPathResumeTrigger, + UiPathResumeTriggerName, + UiPathResumeTriggerType, +) + +from uipath.platform.action_center import Task +from uipath.platform.action_center.tasks import TaskStatus +from uipath.platform.common import ( + CreateBatchTransform, + CreateDeepRag, + CreateEphemeralIndex, + CreateTask, + DocumentExtraction, + DocumentExtractionValidation, + InvokeProcess, + InvokeSystemAgent, + WaitBatchTransform, + WaitDeepRag, + WaitDocumentExtractionValidation, + WaitEphemeralIndex, + WaitJob, + WaitSystemAgent, + WaitTask, +) +from uipath.platform.context_grounding import ( + BatchTransformCreationResponse, + BatchTransformOutputColumn, + Citation, + CitationMode, + DeepRagCreationResponse, + DeepRagStatus, + EphemeralIndexUsage, + IndexStatus, +) +from uipath.platform.context_grounding.context_grounding_index import ( + ContextGroundingIndex, +) +from uipath.platform.documents import ( + ExtractionResponseIXP, + ExtractionResult, + FieldGroupValueProjection, + FieldType, + FieldValueProjection, + ProjectType, + StartExtractionValidationResponse, + ValidateExtractionAction, +) +from uipath.platform.orchestrator import Job, JobErrorInfo +from uipath.platform.orchestrator.job import JobState +from uipath.platform.resume_triggers import ( + PropertyName, + TriggerMarker, + UiPathResumeTriggerCreator, + UiPathResumeTriggerReader, +) + + +@pytest.fixture +def base_url(mock_env_vars: dict[str, str]) -> str: + return mock_env_vars["UIPATH_URL"] + + +@pytest.fixture +def setup_test_env( + monkeypatch: pytest.MonkeyPatch, mock_env_vars: dict[str, str] +) -> None: + """Setup test environment variables.""" + for key, value in mock_env_vars.items(): + monkeypatch.setenv(key, value) + + +class TestHitlReader: + """Tests for the HitlReader class.""" + + @pytest.mark.anyio + async def test_read_task_trigger( + self, + setup_test_env: None, + ) -> None: + """Test reading an action trigger.""" + action_key = "test-action-key" + action_data = {"answer": "test-action-data"} + + mock_action = Task(key=action_key, data=action_data) + mock_retrieve_async = AsyncMock(return_value=mock_action) + + with patch( + "uipath.platform.action_center._tasks_service.TasksService.retrieve_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.TASK, + item_key=action_key, + folder_key="test-folder", + folder_path="test-path", + ) + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + assert result == action_data + mock_retrieve_async.assert_called_once_with( + action_key, + app_folder_key="test-folder", + app_folder_path="test-path", + app_name=None, + ) + + @pytest.mark.anyio + async def test_read_task_trigger_empty_response( + self, + setup_test_env: None, + ) -> None: + """Test reading an action trigger.""" + action_key = "test-action-key" + action_data: dict[str, Any] = {} + + mock_task = Task(key=action_key, data=action_data, status=2) + mock_retrieve_async = AsyncMock(return_value=mock_task) + + with patch( + "uipath.platform.action_center._tasks_service.TasksService.retrieve_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.TASK, + item_key=action_key, + folder_key="test-folder", + folder_path="test-path", + ) + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + assert result == { + "status": TaskStatus(2).name.lower(), + PropertyName.INTERNAL.value: TriggerMarker.NO_CONTENT.value, + } + mock_retrieve_async.assert_called_once_with( + action_key, + app_folder_key="test-folder", + app_folder_path="test-path", + app_name=None, + ) + + @pytest.mark.anyio + async def test_read_task_trigger_deleted_raises_faulted_error( + self, + setup_test_env: None, + ) -> None: + """Test that deleted task raises UiPathFaultedTriggerError for TASK trigger.""" + task_key = "test-task-key" + + mock_task = Task( + key=task_key, + title="test_task", + data={}, + status=TaskStatus.PENDING.value, + is_deleted=True, + organization_unit_id=12345, + ) + mock_retrieve_async = AsyncMock(return_value=mock_task) + + with patch( + "uipath.platform.action_center._tasks_service.TasksService.retrieve_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.TASK, + trigger_name=UiPathResumeTriggerName.TASK, + item_key=task_key, + folder_key="test-folder", + folder_path="test-path", + ) + reader = UiPathResumeTriggerReader() + + with pytest.raises(UiPathFaultedTriggerError) as exc_info: + await reader.read_trigger(resume_trigger) + + assert "was deleted" in str(exc_info.value) + assert exc_info.value.category == ErrorCategory.USER + + @pytest.mark.anyio + async def test_read_task_trigger_deleted_returns_task_for_escalation( + self, + setup_test_env: None, + ) -> None: + """Test that deleted task returns task object for ESCALATION trigger.""" + task_key = "test-task-key" + + mock_task = Task( + key=task_key, + title="test_task", + data={}, + status=TaskStatus.PENDING.value, + is_deleted=True, + organization_unit_id=12345, + ) + mock_retrieve_async = AsyncMock(return_value=mock_task) + + with patch( + "uipath.platform.action_center._tasks_service.TasksService.retrieve_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.TASK, + trigger_name=UiPathResumeTriggerName.ESCALATION, + item_key=task_key, + folder_key="test-folder", + folder_path="test-path", + ) + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + + assert result is mock_task + assert result.is_deleted is True + + @pytest.mark.anyio + async def test_read_job_trigger_successful( + self, + setup_test_env: None, + ) -> None: + """Test reading a successful job trigger.""" + job_key = "test-job-key" + job_id = 1234 + output_args = str({"result": "success"}) + + mock_job = Job( + id=job_id, + key=job_key, + state=JobState.SUCCESSFUL.value, + output_arguments=output_args, + folder_key="d0e09040-5997-44e1-93b7-4087689521b7", + ) + mock_retrieve_async = AsyncMock(return_value=mock_job) + + with patch( + "uipath.platform.orchestrator._jobs_service.JobsService.retrieve_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.JOB, + item_key=job_key, + folder_key="test-folder", + folder_path="test-path", + ) + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + assert result == output_args + mock_retrieve_async.assert_called_once_with( + job_key, + folder_key="test-folder", + folder_path="test-path", + process_name=None, + ) + + @pytest.mark.anyio + async def test_read_job_trigger_successful_empty_output( + self, + setup_test_env: None, + ) -> None: + """Test reading a successful job trigger with empty output returns job state.""" + job_key = "test-job-key" + job_id = 1234 + job_state = JobState.SUCCESSFUL.value + + mock_job = Job( + id=job_id, + key=job_key, + state=job_state, + output_arguments="{}", + folder_key="d0e09040-5997-44e1-93b7-4087689521b7", + ) + mock_retrieve_async = AsyncMock(return_value=mock_job) + + with patch( + "uipath.platform.orchestrator._jobs_service.JobsService.retrieve_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.JOB, + item_key=job_key, + folder_key="test-folder", + folder_path="test-path", + ) + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + assert result == { + "state": job_state.lower(), + PropertyName.INTERNAL.value: TriggerMarker.NO_CONTENT.value, + } + mock_retrieve_async.assert_called_once_with( + job_key, + folder_key="test-folder", + folder_path="test-path", + process_name=None, + ) + + @pytest.mark.anyio + async def test_read_job_trigger_failed( + self, + setup_test_env: None, + ) -> None: + """Test reading a failed job trigger.""" + job_key = "test-job-key" + job_error_info = JobErrorInfo(code="error code") + job_id = 1234 + + mock_job = Job( + id=job_id, + key=job_key, + state="Faulted", + job_error=job_error_info, + folder_key="d0e09040-5997-44e1-93b7-4087689521b7", + ) + mock_retrieve_async = AsyncMock(return_value=mock_job) + + with patch( + "uipath.platform.orchestrator._jobs_service.JobsService.retrieve_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.JOB, + item_key=job_key, + folder_key="test-folder", + folder_path="test-path", + payload={"name": "process_name"}, + ) + + with pytest.raises(UiPathFaultedTriggerError) as exc_info: + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + assert exc_info.value.category == ErrorCategory.USER + mock_retrieve_async.assert_called_once_with( + job_key, + folder_key="test-folder", + folder_path="test-path", + process_name="process_name", + ) + + @pytest.mark.anyio + async def test_read_system_agent_job_trigger_successful( + self, + setup_test_env: None, + ) -> None: + """Test reading a successful system agent job trigger.""" + job_key = "test-system-agent-job-key" + job_id = 1234 + output_args = str({"result": "agent completed successfully"}) + + mock_job = Job( + id=job_id, + key=job_key, + state=JobState.SUCCESSFUL.value, + output_arguments=output_args, + folder_key="d0e09040-5997-44e1-93b7-4087689521b7", + ) + mock_retrieve_async = AsyncMock(return_value=mock_job) + + with patch( + "uipath.platform.orchestrator._jobs_service.JobsService.retrieve_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.JOB, + item_key=job_key, + folder_key="test-folder", + folder_path="test-path", + payload={ + "agent_name": "template-filler-agent", + "entrypoint": "main", + }, + ) + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + assert result == output_args + mock_retrieve_async.assert_called_once_with( + job_key, + folder_key="test-folder", + folder_path="test-path", + process_name=None, + ) + + @pytest.mark.anyio + async def test_read_api_trigger( + self, + httpx_mock: HTTPXMock, + base_url: str, + setup_test_env: None, + ) -> None: + """Test reading an API trigger.""" + inbox_id = str(uuid.uuid4()) + payload_data = {"key": "value"} + + httpx_mock.add_response( + url=f"{base_url}/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}", + status_code=200, + json={"payload": payload_data}, + ) + + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.API, + api_resume=UiPathApiTrigger(inbox_id=inbox_id, request="test"), + ) + + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + assert result == payload_data + + @pytest.mark.anyio + async def test_read_api_trigger_failure( + self, + httpx_mock: HTTPXMock, + base_url: str, + setup_test_env: None, + ) -> None: + """Test reading an API trigger with a failed response.""" + inbox_id = str(uuid.uuid4()) + + httpx_mock.add_response( + url=f"{base_url}/orchestrator_/api/JobTriggers/GetPayload/{inbox_id}", + status_code=500, + ) + + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.API, + api_resume=UiPathApiTrigger(inbox_id=inbox_id, request="test"), + ) + + with pytest.raises(UiPathFaultedTriggerError) as exc_info: + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + assert exc_info.value.category == ErrorCategory.SYSTEM + + @pytest.mark.anyio + async def test_read_deep_rag_trigger_successful( + self, + setup_test_env: None, + ) -> None: + """Test reading a successful deep rag trigger.""" + from uipath.platform.context_grounding import DeepRagResponse + from uipath.platform.context_grounding.context_grounding import DeepRagContent + + task_id = "test-deep-rag-id" + content = DeepRagContent( + text="test content", + citations=[ + Citation( + ordinal=1, page_number=1, source="source", reference="reference" + ) + ], + ) + mock_deep_rag = DeepRagResponse( + name="test-deep-rag", + created_date="2024-01-01", + last_deep_rag_status=DeepRagStatus.SUCCESSFUL, + content=content, + ) + mock_retrieve_async = AsyncMock(return_value=mock_deep_rag) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.retrieve_deep_rag_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.DEEP_RAG, + item_key=task_id, + folder_key="test-folder", + folder_path="test-path", + payload={"index_name": "test-index"}, + ) + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + expected_content = content.model_dump() + expected_content["deepRagId"] = task_id + assert result == expected_content + mock_retrieve_async.assert_called_once_with( + task_id, + index_name="test-index", + ) + + @pytest.mark.anyio + async def test_read_deep_rag_trigger_pending( + self, + setup_test_env: None, + ) -> None: + """Test reading a pending deep rag trigger raises pending error.""" + from uipath.core.errors import UiPathPendingTriggerError + + from uipath.platform.context_grounding import DeepRagResponse + + task_id = "test-deep-rag-id" + mock_deep_rag = DeepRagResponse( + name="test-deep-rag", + created_date="2024-01-01", + last_deep_rag_status=DeepRagStatus.QUEUED, + content=None, + ) + mock_retrieve_async = AsyncMock(return_value=mock_deep_rag) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.retrieve_deep_rag_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.DEEP_RAG, + item_key=task_id, + folder_key="test-folder", + folder_path="test-path", + ) + + with pytest.raises(UiPathPendingTriggerError): + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + + @pytest.mark.anyio + async def test_read_deep_rag_trigger_failed( + self, + setup_test_env: None, + ) -> None: + """Test reading a failed deep rag trigger raises faulted error.""" + from uipath.platform.context_grounding import DeepRagResponse + + task_id = "test-deep-rag-id" + mock_deep_rag = DeepRagResponse( + name="test-deep-rag", + created_date="2024-01-01", + last_deep_rag_status=DeepRagStatus.FAILED, + content=None, + ) + mock_retrieve_async = AsyncMock(return_value=mock_deep_rag) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.retrieve_deep_rag_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.DEEP_RAG, + item_key=task_id, + folder_key="test-folder", + folder_path="test-path", + ) + + with pytest.raises(UiPathFaultedTriggerError) as exc_info: + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + assert exc_info.value.category == ErrorCategory.USER + + @pytest.mark.anyio + async def test_read_deep_rag_trigger_empty_response( + self, + setup_test_env: None, + ) -> None: + """Test reading a deep rag trigger with empty content returns placeholder.""" + from uipath.platform.context_grounding import DeepRagResponse + + task_id = "test-deep-rag-id" + mock_deep_rag = DeepRagResponse( + name="test-deep-rag", + created_date="2024-01-01", + last_deep_rag_status=DeepRagStatus.SUCCESSFUL, + content=None, + ) + mock_retrieve_async = AsyncMock(return_value=mock_deep_rag) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.retrieve_deep_rag_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.DEEP_RAG, + item_key=task_id, + folder_key="test-folder", + folder_path="test-path", + ) + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + assert result == { + "status": DeepRagStatus.SUCCESSFUL.value, + PropertyName.INTERNAL.value: TriggerMarker.NO_CONTENT.value, + } + + @pytest.mark.anyio + async def test_read_batch_rag_trigger_successful( + self, + setup_test_env: None, + ) -> None: + """Test reading a successful batch rag trigger.""" + import os + + task_id = "test-batch-rag-id" + destination_path = "test/output.xlsx" + mock_download_async = AsyncMock(return_value=None) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.download_batch_transform_result_async", + new=mock_download_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.BATCH_RAG, + item_key=task_id, + folder_key="test-folder", + folder_path="test-path", + payload={ + "index_name": "test-index", + "destination_path": destination_path, + }, + ) + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + assert ( + result + == f"Batch transform completed. Modified file available at {os.path.abspath(destination_path)}" + ) + mock_download_async.assert_called_once_with( + task_id, + destination_path, + validate_status=True, + index_name="test-index", + ) + + @pytest.mark.anyio + async def test_read_batch_rag_trigger_pending( + self, + setup_test_env: None, + ) -> None: + """Test reading a pending batch rag trigger raises pending error.""" + from uipath.core.errors import UiPathPendingTriggerError + + from uipath.platform.errors import BatchTransformNotCompleteException + + task_id = "test-batch-rag-id" + destination_path = "test/output.xlsx" + mock_download_async = AsyncMock( + side_effect=BatchTransformNotCompleteException(task_id, "InProgress") + ) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.download_batch_transform_result_async", + new=mock_download_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.BATCH_RAG, + item_key=task_id, + folder_key="test-folder", + folder_path="test-path", + payload={ + "index_name": "test-index", + "destination_path": destination_path, + }, + ) + + with pytest.raises(UiPathPendingTriggerError): + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + + @pytest.mark.anyio + async def test_read_ephemeral_index_trigger_successful( + self, + setup_test_env: None, + ) -> None: + """Test reading a successful ephemeral index trigger.""" + index_id = "test-ephemeral-index-id" + index_data = { + "id": index_id, + "name": "test-index", + "lastIngestionStatus": IndexStatus.SUCCESSFUL.value, + } + + mock_retrieve_by_id = AsyncMock(return_value=index_data) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.retrieve_by_id_async", + new=mock_retrieve_by_id, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.INDEX_INGESTION, + item_key=index_id, + ) + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + + assert isinstance(result, dict) + assert result["id"] == index_id + mock_retrieve_by_id.assert_called_once_with(index_id) + + @pytest.mark.anyio + async def test_read_ephemeral_index_trigger_pending( + self, + setup_test_env: None, + ) -> None: + """Test reading a pending ephemeral index trigger raises pending error.""" + from uipath.core.errors import UiPathPendingTriggerError + + index_id = "test-ephemeral-index-id" + index_data = { + "id": index_id, + "name": "test-index", + "lastIngestionStatus": IndexStatus.IN_PROGRESS.value, + } + + mock_retrieve_by_id = AsyncMock(return_value=index_data) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.retrieve_by_id_async", + new=mock_retrieve_by_id, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.INDEX_INGESTION, + item_key=index_id, + ) + + with pytest.raises(UiPathPendingTriggerError): + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + + @pytest.mark.anyio + async def test_read_ephemeral_index_trigger_failed( + self, + setup_test_env: None, + ) -> None: + """Test reading a failed ephemeral index trigger raises faulted error.""" + index_id = "test-ephemeral-index-id" + index_data = { + "id": index_id, + "name": "test-index", + "lastIngestionStatus": IndexStatus.FAILED.value, + } + + mock_retrieve_by_id = AsyncMock(return_value=index_data) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.retrieve_by_id_async", + new=mock_retrieve_by_id, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.INDEX_INGESTION, + item_key=index_id, + ) + + with pytest.raises(UiPathFaultedTriggerError) as exc_info: + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + assert exc_info.value.category == ErrorCategory.USER + + @pytest.mark.anyio + async def test_read_ixp_vs_escalation_trigger_successful( + self, + setup_test_env: None, + ) -> None: + """Test reading a successful IXP VS escalation trigger.""" + operation_id = "test-escalation-id" + project_id = "test-project-id" + tag = "test-tag" + action_data = { + "status": TaskStatus.COMPLETED.name.lower(), + "data": {"field": "value"}, + } + + mock_escalation = ValidateExtractionAction( + action_data=action_data, + action_status=TaskStatus.COMPLETED.name, + project_id=project_id, + project_type=ProjectType.IXP, + extractor_id=None, + tag=tag, + operation_id=operation_id, + document_type_id="test-doc-type", + ) + mock_retrieve_async = AsyncMock(return_value=mock_escalation) + + with patch( + "uipath.platform.documents._documents_service.DocumentsService.retrieve_ixp_extraction_validation_result_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.IXP_VS_ESCALATION, + item_key=operation_id, + payload={"project_id": project_id, "tag": tag}, + ) + reader = UiPathResumeTriggerReader() + result = await reader.read_trigger(resume_trigger) + assert result == mock_escalation.model_dump() + mock_retrieve_async.assert_called_once_with( + project_id, + tag, + operation_id, + ) + + @pytest.mark.anyio + async def test_read_ixp_vs_escalation_trigger_pending( + self, + setup_test_env: None, + ) -> None: + """Test reading a pending IXP VS escalation trigger raises pending error.""" + from uipath.core.errors import UiPathPendingTriggerError + + operation_id = "test-escalation-id" + project_id = "test-project-id" + tag = "test-tag" + action_data = { + "status": TaskStatus.PENDING.name.lower(), + "data": {"field": "value"}, + } + + mock_escalation = ValidateExtractionAction( + action_data=action_data, + action_status=TaskStatus.PENDING.name, + project_id=project_id, + project_type=ProjectType.IXP, + extractor_id=None, + tag=tag, + operation_id=operation_id, + document_type_id="test-doc-type", + ) + mock_retrieve_async = AsyncMock(return_value=mock_escalation) + + with patch( + "uipath.platform.documents._documents_service.DocumentsService.retrieve_ixp_extraction_validation_result_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.IXP_VS_ESCALATION, + item_key=operation_id, + payload={"project_id": project_id, "tag": tag}, + ) + + with pytest.raises(UiPathPendingTriggerError): + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + + @pytest.mark.anyio + async def test_read_ixp_vs_escalation_trigger_unassigned( + self, + setup_test_env: None, + ) -> None: + """Test reading an unassigned IXP VS escalation trigger raises pending error.""" + from uipath.core.errors import UiPathPendingTriggerError + + operation_id = "test-escalation-id" + project_id = "test-project-id" + tag = "test-tag" + action_data = { + "status": TaskStatus.UNASSIGNED.name.lower(), + "data": {"field": "value"}, + } + + mock_escalation = ValidateExtractionAction( + action_data=action_data, + action_status=TaskStatus.UNASSIGNED.name, + project_id=project_id, + project_type=ProjectType.IXP, + extractor_id=None, + tag=tag, + operation_id=operation_id, + document_type_id="test-doc-type", + ) + mock_retrieve_async = AsyncMock(return_value=mock_escalation) + + with patch( + "uipath.platform.documents._documents_service.DocumentsService.retrieve_ixp_extraction_validation_result_async", + new=mock_retrieve_async, + ): + resume_trigger = UiPathResumeTrigger( + trigger_type=UiPathResumeTriggerType.IXP_VS_ESCALATION, + item_key=operation_id, + payload={"project_id": project_id, "tag": tag}, + ) + + with pytest.raises(UiPathPendingTriggerError): + reader = UiPathResumeTriggerReader() + await reader.read_trigger(resume_trigger) + + +class TestHitlProcessor: + """Tests for the HitlProcessor class.""" + + @pytest.mark.anyio + async def test_create_resume_trigger_create_task( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for CreateTask.""" + action_key = "test-action-key" + create_action = CreateTask( + title="Test Action", + app_name="TestApp", + app_folder_path="/test/path", + data={"input": "test-input"}, + ) + + mock_action = Task(key=action_key) + mock_create_async = AsyncMock(return_value=mock_action) + + with patch( + "uipath.platform.action_center._tasks_service.TasksService.create_async", + new=mock_create_async, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(create_action) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.TASK + assert resume_trigger.item_key == action_key + assert resume_trigger.folder_path == create_action.app_folder_path + mock_create_async.assert_called_once_with( + title=create_action.title, + app_name=create_action.app_name, + app_folder_path=create_action.app_folder_path, + app_folder_key="", + app_key="", + assignee="", + recipient="", + data=create_action.data, + priority=None, + labels=None, + is_actionable_message_enabled=None, + actionable_message_metadata=None, + source_name="Agent", + ) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_task( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for WaitTask.""" + action_key = "test-action-key" + action = Task(key=action_key) + wait_action = WaitTask(action=action, app_folder_path="/test/path") + + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_action) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.TASK + assert resume_trigger.item_key == action_key + assert resume_trigger.folder_path == wait_action.app_folder_path + + @pytest.mark.anyio + async def test_create_resume_trigger_invoke_process( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for InvokeProcess.""" + job_key = "test-job-key" + invoke_process = InvokeProcess( + name="TestProcess", + process_folder_path="/test/path", + input_arguments={"key": "value"}, + ) + + mock_job = Job( + id=1234, key=job_key, folder_key="d0e09040-5997-44e1-93b7-4087689521b7" + ) + mock_invoke = AsyncMock(return_value=mock_job) + + with patch( + "uipath.platform.orchestrator._processes_service.ProcessesService.invoke_async", + new=mock_invoke, + ) as mock_process_invoke_async: + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(invoke_process) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.JOB + assert resume_trigger.item_key == job_key + assert resume_trigger.folder_path == invoke_process.process_folder_path + mock_process_invoke_async.assert_called_once_with( + name=invoke_process.name, + input_arguments=invoke_process.input_arguments, + attachments=None, + folder_path=invoke_process.process_folder_path, + folder_key=None, + ) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_job( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for WaitJob.""" + job_key = "test-job-key" + job = Job( + id=1234, key=job_key, folder_key="d0e09040-5997-44e1-93b7-4087689521b7" + ) + wait_job = WaitJob(job=job, process_folder_path="/test/path") + + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_job) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.JOB + assert resume_trigger.item_key == job_key + assert resume_trigger.folder_path == wait_job.process_folder_path + + @pytest.mark.anyio + async def test_create_resume_trigger_invoke_system_agent( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for InvokeSystemAgent.""" + job_key = "test-system-agent-job-key" + invoke_system_agent = InvokeSystemAgent( + agent_name="template-filler-agent", + entrypoint="main", + folder_path="/test/path", + folder_key="test-folder-key", + input_arguments={"metadata_id": "123", "template_id": "456"}, + ) + + mock_invoke_system_agent = AsyncMock(return_value=job_key) + + with patch( + "uipath.platform.agenthub._agenthub_service.AgentHubService.invoke_system_agent_async", + new=mock_invoke_system_agent, + ) as mock_agent_invoke: + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(invoke_system_agent) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.JOB + assert resume_trigger.item_key == job_key + assert resume_trigger.folder_path == invoke_system_agent.folder_path + assert resume_trigger.folder_key == invoke_system_agent.folder_key + mock_agent_invoke.assert_called_once_with( + agent_name=invoke_system_agent.agent_name, + entrypoint=invoke_system_agent.entrypoint, + input_arguments=invoke_system_agent.input_arguments, + folder_path=invoke_system_agent.folder_path, + folder_key=invoke_system_agent.folder_key, + ) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_system_agent( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for WaitSystemAgent.""" + job_key = "1234" + wait_system_agent = WaitSystemAgent( + job_key="1234", + process_folder_path="/test/path", + process_folder_key="test-folder-key", + ) + + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_system_agent) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.JOB + assert resume_trigger.item_key == job_key + assert resume_trigger.folder_path == wait_system_agent.process_folder_path + assert resume_trigger.folder_key == wait_system_agent.process_folder_key + + @pytest.mark.anyio + async def test_create_resume_trigger_api( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for API type.""" + api_input = "payload" + + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(api_input) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.API + assert resume_trigger.api_resume is not None + assert isinstance(resume_trigger.api_resume.inbox_id, str) + assert resume_trigger.api_resume.request == api_input + + @pytest.mark.anyio + async def test_create_resume_trigger_create_deep_rag( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for CreateDeepRag.""" + deep_rag_id = "test-deep-rag-id" + create_deep_rag = CreateDeepRag( + name="test-deep-rag", + index_name="test-index", + prompt="test prompt", + glob_pattern="**/*.pdf", + citation_mode=CitationMode.INLINE, + index_folder_path="/test/path", + ) + + mock_deep_rag = DeepRagCreationResponse( + id=deep_rag_id, + last_deep_rag_status=DeepRagStatus.QUEUED, + created_date="2024-01-01", + ) + mock_start_deep_rag_async = AsyncMock(return_value=mock_deep_rag) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.start_deep_rag_async", + new=mock_start_deep_rag_async, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(create_deep_rag) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.DEEP_RAG + assert resume_trigger.item_key == deep_rag_id + mock_start_deep_rag_async.assert_called_once_with( + name=create_deep_rag.name, + index_name=create_deep_rag.index_name, + index_id=None, + prompt=create_deep_rag.prompt, + glob_pattern=create_deep_rag.glob_pattern, + citation_mode=create_deep_rag.citation_mode, + folder_path=create_deep_rag.index_folder_path, + folder_key=create_deep_rag.index_folder_key, + ) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_deep_rag( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for WaitDeepRag.""" + deep_rag_id = "test-deep-rag-id" + deep_rag = DeepRagCreationResponse( + id=deep_rag_id, + last_deep_rag_status=DeepRagStatus.IN_PROGRESS, + created_date="2024-01-01", + ) + wait_deep_rag = WaitDeepRag(deep_rag=deep_rag, index_folder_path="/test/path") + + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_deep_rag) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.DEEP_RAG + assert resume_trigger.item_key == deep_rag_id + + @pytest.mark.anyio + async def test_create_resume_trigger_create_batch_transform( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for CreateBatchTransform.""" + batch_transform_id = "test-batch-transform-id" + output_columns = [ + BatchTransformOutputColumn(name="column1", description="desc1"), + ] + create_batch_transform = CreateBatchTransform( + name="test-batch-transform", + index_name="test-index", + prompt="test prompt", + output_columns=output_columns, + destination_path="/output/path.xlsx", + index_folder_path="/test/path", + ) + + mock_batch_transform = BatchTransformCreationResponse( + id=batch_transform_id, + last_batch_rag_status=DeepRagStatus.QUEUED, + ) + mock_start_batch_transform = AsyncMock(return_value=mock_batch_transform) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.start_batch_transform_async", + new=mock_start_batch_transform, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(create_batch_transform) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.BATCH_RAG + assert resume_trigger.item_key == batch_transform_id + mock_start_batch_transform.assert_called_once_with( + name=create_batch_transform.name, + index_name=create_batch_transform.index_name, + index_id=None, + prompt=create_batch_transform.prompt, + output_columns=create_batch_transform.output_columns, + storage_bucket_folder_path_prefix=create_batch_transform.storage_bucket_folder_path_prefix, + enable_web_search_grounding=create_batch_transform.enable_web_search_grounding, + folder_path=create_batch_transform.index_folder_path, + folder_key=create_batch_transform.index_folder_key, + ) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_batch_transform( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for WaitBatchTransform.""" + batch_transform_id = "test-batch-transform-id" + batch_transform = BatchTransformCreationResponse( + id=batch_transform_id, + last_batch_rag_status=DeepRagStatus.IN_PROGRESS, + ) + wait_batch_transform = WaitBatchTransform( + batch_transform=batch_transform, index_folder_path="/test/path" + ) + + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_batch_transform) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.BATCH_RAG + assert resume_trigger.item_key == batch_transform_id + + @pytest.mark.anyio + async def test_create_resume_trigger_create_ephemeral_index( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for CreateEphemeralIndex.""" + index_id = "test-ephemeral-index-id" + attachments = ["attachment-uuid-1", "attachment-uuid-2"] + create_ephemeral_index = CreateEphemeralIndex( + usage=EphemeralIndexUsage.DEEP_RAG, + attachments=attachments, + ) + + mock_index = ContextGroundingIndex( + id=index_id, + name="ephemeral-index", + last_ingestion_status=IndexStatus.QUEUED.value, + ) + mock_create_ephemeral_index = AsyncMock(return_value=mock_index) + + with patch( + "uipath.platform.context_grounding._context_grounding_service.ContextGroundingService.create_ephemeral_index_async", + new=mock_create_ephemeral_index, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(create_ephemeral_index) + + assert resume_trigger is not None + assert ( + resume_trigger.trigger_type == UiPathResumeTriggerType.INDEX_INGESTION + ) + assert resume_trigger.item_key == index_id + mock_create_ephemeral_index.assert_called_once_with( + usage=create_ephemeral_index.usage, + attachments=create_ephemeral_index.attachments, + ) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_ephemeral_index( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for WaitEphemeralIndex.""" + index_id = "test-ephemeral-index-id" + ephemeral_index = ContextGroundingIndex( + id=index_id, + name="ephemeral-index", + last_ingestion_status=IndexStatus.IN_PROGRESS.value, + ) + wait_ephemeral_index = WaitEphemeralIndex(index=ephemeral_index) + + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_ephemeral_index) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.INDEX_INGESTION + assert resume_trigger.item_key == index_id + + @pytest.mark.anyio + async def test_create_resume_trigger_document_extraction_validation( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for DocumentExtractionValidation.""" + operation_id = "test-validation-operation-id" + project_id = "test-project-id" + tag = "test-tag" + + # Create a mock extraction response + extraction_result = ExtractionResult( + document_id="doc-123", + results_version=1, + results_document={}, + ) + mock_extraction_response = ExtractionResponseIXP( + extraction_result=extraction_result, + project_id=project_id, + project_type=ProjectType.IXP, + extractor_id=None, + tag=tag, + document_type_id="doc-type-123", + data_projection=[ + FieldGroupValueProjection( + field_group_name="test_group", + field_values=[ + FieldValueProjection( + id="field-1", + name="test_field", + value="test_value", + unformatted_value="test_value", + confidence=0.95, + ocr_confidence=0.98, + type=FieldType.TEXT, + ) + ], + ) + ], + ) + + document_extraction_validation = DocumentExtractionValidation( + extraction_response=mock_extraction_response, + action_title="Test Validation", + ) + + mock_validation_response = StartExtractionValidationResponse( + operation_id=operation_id, + document_id="doc-123", + project_id=project_id, + tag=tag, + ) + mock_start_validation = AsyncMock(return_value=mock_validation_response) + + with patch( + "uipath.platform.documents._documents_service.DocumentsService.start_ixp_extraction_validation_async", + new=mock_start_validation, + ): + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger( + document_extraction_validation + ) + + assert resume_trigger is not None + assert ( + resume_trigger.trigger_type == UiPathResumeTriggerType.IXP_VS_ESCALATION + ) + assert resume_trigger.item_key == operation_id + # Verify the payload contains project_id and tag + assert isinstance(resume_trigger.payload, dict) + assert resume_trigger.payload.get("project_id") == project_id + assert resume_trigger.payload.get("tag") == tag + mock_start_validation.assert_called_once_with( + extraction_response=document_extraction_validation.extraction_response, + action_title=document_extraction_validation.action_title, + action_priority=document_extraction_validation.action_priority, + action_folder=document_extraction_validation.action_folder, + storage_bucket_name=document_extraction_validation.storage_bucket_name, + storage_bucket_directory_path=document_extraction_validation.storage_bucket_directory_path, + ) + + @pytest.mark.anyio + async def test_create_resume_trigger_wait_document_extraction_validation( + self, + setup_test_env: None, + ) -> None: + """Test creating a resume trigger for WaitDocumentExtractionValidation.""" + operation_id = "test-validation-operation-id" + project_id = "test-project-id" + tag = "test-tag" + + validation_response = StartExtractionValidationResponse( + operation_id=operation_id, + document_id="doc-123", + project_id=project_id, + tag=tag, + ) + wait_validation = WaitDocumentExtractionValidation( + extraction_validation=validation_response + ) + + processor = UiPathResumeTriggerCreator() + resume_trigger = await processor.create_trigger(wait_validation) + + assert resume_trigger is not None + assert resume_trigger.trigger_type == UiPathResumeTriggerType.IXP_VS_ESCALATION + assert resume_trigger.item_key == operation_id + + +class TestDocumentExtractionModels: + """Tests for document extraction models.""" + + def test_create_document_extraction_with_file(self) -> None: + """Test DocumentExtraction with file provided.""" + file_content = b"test content" + extraction = DocumentExtraction( + project_name="test_project", + tag="test_tag", + file=file_content, + ) + + assert extraction.project_name == "test_project" + assert extraction.tag == "test_tag" + assert extraction.file == file_content + assert extraction.file_path is None + + def test_create_document_extraction_with_file_path(self) -> None: + """Test DocumentExtraction with file_path provided.""" + extraction = DocumentExtraction( + project_name="test_project", + tag="test_tag", + file_path="/path/to/file.pdf", + ) + + assert extraction.project_name == "test_project" + assert extraction.tag == "test_tag" + assert extraction.file is None + assert extraction.file_path == "/path/to/file.pdf" + + def test_create_document_extraction_with_both_raises_error(self) -> None: + """Test DocumentExtraction with both file and file_path raises ValueError.""" + file_content = b"test content" + + with pytest.raises(ValueError) as exc_info: + DocumentExtraction( + project_name="test_project", + tag="test_tag", + file=file_content, + file_path="/path/to/file.pdf", + ) + + assert "not both or neither" in str(exc_info.value) + + def test_create_document_extraction_with_neither_raises_error(self) -> None: + """Test DocumentExtraction with neither file nor file_path raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + DocumentExtraction( + project_name="test_project", + tag="test_tag", + ) + + assert "not both or neither" in str(exc_info.value) diff --git a/packages/uipath-platform/tests/services/test_jobs_service.py b/packages/uipath-platform/tests/services/test_jobs_service.py new file mode 100644 index 000000000..e0321717e --- /dev/null +++ b/packages/uipath-platform/tests/services/test_jobs_service.py @@ -0,0 +1,1392 @@ +import json +import os +import shutil +import uuid +from typing import TYPE_CHECKING, Any, Generator, Tuple + +import pytest +from pytest_httpx import HTTPXMock +from pytest_mock import MockerFixture + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common.constants import HEADER_USER_AGENT, TEMP_ATTACHMENTS_FOLDER +from uipath.platform.orchestrator import Job +from uipath.platform.orchestrator._jobs_service import JobsService + +if TYPE_CHECKING: + from _pytest.monkeypatch import MonkeyPatch + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> JobsService: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + jobs_service = JobsService(config=config, execution_context=execution_context) + # We'll leave the real AttachmentsService for HTTP tests, + # and mock it in specific tests as needed + return jobs_service + + +@pytest.fixture +def temp_attachments_dir(tmp_path: Any) -> Generator[str, None, None]: + """Create a temporary directory for attachments and clean it up after the test. + + Args: + tmp_path: Pytest's temporary directory fixture. + + Returns: + The path to the temporary directory. + """ + test_temp_dir = os.path.join(tmp_path, TEMP_ATTACHMENTS_FOLDER) + os.makedirs(test_temp_dir, exist_ok=True) + + yield test_temp_dir + + # Clean up the directory after the test + if os.path.exists(test_temp_dir): + shutil.rmtree(test_temp_dir) + + +@pytest.fixture +def temp_file(tmp_path: Any) -> Generator[Tuple[str, str, str], None, None]: + """Create a temporary file and clean it up after the test. + + Args: + tmp_path: Pytest's temporary directory fixture. + + Returns: + A tuple containing the file content, file name, and file path. + """ + content = "Test source file content" + name = f"test_file_{uuid.uuid4()}.txt" + path = os.path.join(tmp_path, name) + + with open(path, "w") as f: + f.write(content) + + yield content, name, path + + # Clean up the file after the test + if os.path.exists(path): + os.remove(path) + + +@pytest.fixture +def local_attachment_file( + temp_attachments_dir: str, +) -> Generator[Tuple[uuid.UUID, str, str], None, None]: + """Creates a local attachment file in the temporary attachments directory. + + Args: + temp_attachments_dir: The temporary attachments directory. + + Returns: + A tuple containing the attachment ID, file name, and file content. + """ + attachment_id = uuid.uuid4() + file_name = "test_local_file.txt" + file_content = "Local test content" + + # Create the local file with the format {uuid}_{filename} + file_path = os.path.join(temp_attachments_dir, f"{attachment_id}_{file_name}") + with open(file_path, "w") as f: + f.write(file_content) + + yield attachment_id, file_name, file_content + + # Cleanup is handled by temp_attachments_dir fixture + + +class TestJobsService: + def test_retrieve( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + job_key = "test-job-key" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + status_code=200, + json={ + "Key": job_key, + "State": "Running", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + }, + ) + + job = service.retrieve(job_key) + + assert isinstance(job, Job) + assert job.key == job_key + assert job.state == "Running" + assert job.start_time == "2024-01-01T00:00:00Z" + assert job.id == 123 + assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.JobsService.retrieve/{version}" + ) + + @pytest.mark.asyncio + async def test_retrieve_async( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + job_key = "test-job-key" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + status_code=200, + json={ + "Key": job_key, + "State": "Running", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + }, + ) + + job = await service.retrieve_async(job_key) + + assert isinstance(job, Job) + assert job.key == job_key + assert job.state == "Running" + assert job.start_time == "2024-01-01T00:00:00Z" + assert job.id == 123 + assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.JobsService.retrieve_async/{version}" + ) + + def test_resume_with_inbox_id( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + inbox_id = "test-inbox-id" + payload = {"key": "value"} + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}", + status_code=200, + ) + + service.resume(inbox_id=inbox_id, payload=payload) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}" + ) + + assert json.loads(sent_request.content.decode()) == {"payload": payload} + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.JobsService.resume/{version}" + ) + + def test_resume_with_job_id( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + job_id = "test-job-id" + inbox_id = "test-inbox-id" + payload = {"key": "value"} + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/JobTriggers?$filter=JobId eq {job_id}&$top=1&$select=ItemKey", + status_code=200, + json={"value": [{"ItemKey": inbox_id}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}", + status_code=200, + ) + + service.resume(job_id=job_id, payload=payload) + + sent_requests = httpx_mock.get_requests() + assert sent_requests is not None + assert sent_requests[1].method == "POST" + assert ( + sent_requests[1].url + == f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}" + ) + + assert json.loads(sent_requests[1].content.decode()) == {"payload": payload} + + assert HEADER_USER_AGENT in sent_requests[1].headers + assert ( + sent_requests[1].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.JobsService.resume/{version}" + ) + + @pytest.mark.asyncio + async def test_resume_async_with_inbox_id( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + inbox_id = "test-inbox-id" + payload = {"key": "value"} + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}", + status_code=200, + ) + + await service.resume_async(inbox_id=inbox_id, payload=payload) + + sent_request = httpx_mock.get_request() + assert sent_request is not None + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}" + ) + + assert json.loads(sent_request.content.decode()) == {"payload": payload} + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.JobsService.resume_async/{version}" + ) + + @pytest.mark.asyncio + async def test_resume_async_with_job_id( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + job_id = "test-job-id" + inbox_id = "test-inbox-id" + payload = {"key": "value"} + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/JobTriggers?$filter=JobId eq {job_id}&$top=1&$select=ItemKey", + status_code=200, + json={"value": [{"ItemKey": inbox_id}]}, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}", + status_code=200, + ) + + await service.resume_async(job_id=job_id, payload=payload) + + sent_requests = httpx_mock.get_requests() + assert sent_requests is not None + assert sent_requests[1].method == "POST" + assert ( + sent_requests[1].url + == f"{base_url}{org}{tenant}/orchestrator_/api/JobTriggers/DeliverPayload/{inbox_id}" + ) + + assert json.loads(sent_requests[1].content.decode()) == {"payload": payload} + + assert HEADER_USER_AGENT in sent_requests[1].headers + assert ( + sent_requests[1].headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.JobsService.resume_async/{version}" + ) + + def test_list_attachments( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + job_key = uuid.uuid4() + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/GetByJobKey?jobKey={job_key}", + method="GET", + status_code=200, + json=[ + { + "attachmentId": "12345678-1234-1234-1234-123456789012", + "creationTime": "2023-01-01T12:00:00Z", + "lastModificationTime": "2023-01-02T12:00:00Z", + }, + { + "attachmentId": "87654321-1234-1234-1234-123456789012", + "creationTime": "2023-01-03T12:00:00Z", + "lastModificationTime": "2023-01-04T12:00:00Z", + }, + ], + ) + + attachments = service.list_attachments(job_key=job_key) + + assert len(attachments) == 2 + assert isinstance(attachments[0], str) + assert attachments[0] == "12345678-1234-1234-1234-123456789012" + assert isinstance(attachments[1], str) + assert attachments[1] == "87654321-1234-1234-1234-123456789012" + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert ( + request.url.path + == f"{org}{tenant}/orchestrator_/api/JobAttachments/GetByJobKey" + ) + assert request.url.params.get("jobKey") == str(job_key) + assert HEADER_USER_AGENT in request.headers + + @pytest.mark.asyncio + async def test_list_attachments_async( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + job_key = uuid.uuid4() + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/GetByJobKey?jobKey={job_key}", + method="GET", + status_code=200, + json=[ + { + "attachmentId": "12345678-1234-1234-1234-123456789012", + "creationTime": "2023-01-01T12:00:00Z", + "lastModificationTime": "2023-01-02T12:00:00Z", + }, + { + "attachmentId": "87654321-1234-1234-1234-123456789012", + "creationTime": "2023-01-03T12:00:00Z", + "lastModificationTime": "2023-01-04T12:00:00Z", + }, + ], + ) + + attachments = await service.list_attachments_async(job_key=job_key) + + assert len(attachments) == 2 + assert isinstance(attachments[0], str) + assert attachments[0] == "12345678-1234-1234-1234-123456789012" + assert isinstance(attachments[1], str) + assert attachments[1] == "87654321-1234-1234-1234-123456789012" + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "GET" + assert ( + request.url.path + == f"{org}{tenant}/orchestrator_/api/JobAttachments/GetByJobKey" + ) + assert request.url.params.get("jobKey") == str(job_key) + assert HEADER_USER_AGENT in request.headers + + def test_link_attachment( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + attachment_key = uuid.uuid4() + job_key = uuid.uuid4() + category = "Result" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/Post", + method="POST", + status_code=200, + ) + + service.link_attachment( + attachment_key=attachment_key, job_key=job_key, category=category + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert ( + request.url + == f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/Post" + ) + assert HEADER_USER_AGENT in request.headers + + body = json.loads(request.content) + assert body["attachmentId"] == str(attachment_key) + assert body["jobKey"] == str(job_key) + assert body["category"] == category + + @pytest.mark.asyncio + async def test_link_attachment_async( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + attachment_key = uuid.uuid4() + job_key = uuid.uuid4() + category = "Result" + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/Post", + method="POST", + status_code=200, + ) + + await service.link_attachment_async( + attachment_key=attachment_key, job_key=job_key, category=category + ) + + request = httpx_mock.get_request() + assert request is not None + assert request.method == "POST" + assert ( + request.url + == f"{base_url}{org}{tenant}/orchestrator_/api/JobAttachments/Post" + ) + assert HEADER_USER_AGENT in request.headers + + body = json.loads(request.content) + assert body["attachmentId"] == str(attachment_key) + assert body["jobKey"] == str(job_key) + assert body["category"] == category + + def test_create_job_attachment_with_job( + self, + service: JobsService, + mocker: MockerFixture, + ) -> None: + """Test creating a job attachment when a job is available. + + This tests that the attachment is created in UiPath and linked to the job + when a job key is provided. + + Args: + service: JobsService fixture. + mocker: MockerFixture for mocking dependencies. + """ + # Arrange + job_key = str(uuid.uuid4()) + attachment_key = uuid.uuid4() + content = "Test attachment content" + name = "test_attachment.txt" + + # Mock the attachment service's upload method + mock_upload = mocker.patch.object( + service._attachments_service, "upload", return_value=attachment_key + ) + + # Mock the link_attachment method + mock_link = mocker.patch.object(service, "link_attachment") + + # Act + result = service.create_attachment(name=name, content=content, job_key=job_key) + + # Assert + assert result == attachment_key + mock_upload.assert_called_once_with( + name=name, + content=content, + folder_key=None, + folder_path=None, + ) + mock_link.assert_called_once_with( + attachment_key=attachment_key, + job_key=uuid.UUID(job_key), + category=None, + folder_key=None, + folder_path=None, + ) + + def test_create_job_attachment_with_job_context( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: "MonkeyPatch", + mocker: MockerFixture, + ) -> None: + """Test creating a job attachment when a job is available in the context. + + This tests that the attachment is created in UiPath and linked to the job + when a job key is available in the execution context. + + Args: + config: UiPathApiConfig fixture. + execution_context: UiPathExecutionContext fixture. + monkeypatch: MonkeyPatch fixture. + mocker: MockerFixture for mocking dependencies. + """ + # Arrange + job_key = uuid.uuid4() + attachment_key = uuid.uuid4() + content = "Test attachment content" + name = "test_attachment.txt" + + # Set job key in environment - must be string + monkeypatch.setenv("UIPATH_JOB_KEY", str(job_key)) + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + + # Create fresh execution context after setting environment variables + fresh_execution_context = UiPathExecutionContext() + service = JobsService(config=config, execution_context=fresh_execution_context) + + # Mock the attachment service's upload method + mock_upload = mocker.patch.object( + service._attachments_service, "upload", return_value=attachment_key + ) + + # Mock the link_attachment method + mock_link = mocker.patch.object(service, "link_attachment") + + # Act + result = service.create_attachment(name=name, content=content) + + # Assert + assert result == attachment_key + mock_upload.assert_called_once_with( + name=name, + content=content, + folder_key=None, + folder_path=None, + ) + mock_link.assert_called_once_with( + attachment_key=attachment_key, + job_key=job_key, + category=None, + folder_key=None, + folder_path=None, + ) + + def test_create_job_attachment_no_job( + self, + service: JobsService, + temp_attachments_dir: str, + ) -> None: + """Test creating a job attachment when no job is available. + + This tests that the attachment is stored locally when no job key is provided + or available in the context. + + Args: + service: JobsService fixture. + temp_attachments_dir: Temporary directory fixture that handles cleanup. + """ + # Arrange + content = "Test local attachment content" + name = "test_local_attachment.txt" + + # Use the temporary directory provided by the fixture + service._temp_dir = temp_attachments_dir + + # Act + result = service.create_attachment(name=name, content=content) + + # Assert + assert isinstance(result, uuid.UUID) + # Verify file was created + expected_path = os.path.join(temp_attachments_dir, f"{result}_{name}") + assert os.path.exists(expected_path) + + # Check content + with open(expected_path, "r") as f: + assert f.read() == content + + def test_create_job_attachment_from_file( + self, + service: JobsService, + temp_attachments_dir: str, + temp_file: Tuple[str, str, str], + ) -> None: + """Test creating a job attachment from a file when no job is available. + + Args: + service: JobsService fixture. + temp_attachments_dir: Temporary directory fixture that handles cleanup. + temp_file: Temporary file fixture that handles cleanup. + """ + # Arrange + source_content, source_name, source_path = temp_file + + # Use the temporary directory provided by the fixture + service._temp_dir = temp_attachments_dir + + # Act + result = service.create_attachment(name=source_name, source_path=source_path) + + # Assert + assert isinstance(result, uuid.UUID) + # Verify file was created + expected_path = os.path.join(temp_attachments_dir, f"{result}_{source_name}") + assert os.path.exists(expected_path) + + # Check content + with open(expected_path, "r") as f: + assert f.read() == source_content + + def test_extract_output_with_inline_arguments( + self, + service: JobsService, + ) -> None: + """Test extracting job output when output is stored inline (small output).""" + + job_data = { + "Key": "test-job-key", + "State": "Successful", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + "OutputArguments": '{"result": "small output data", "status": "completed"}', + "OutputFile": None, + } + job = Job.model_validate(job_data) + + result = service.extract_output(job) + + assert result == '{"result": "small output data", "status": "completed"}' + + def test_extract_output_with_attachment( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + temp_attachments_dir: str, + ) -> None: + """Test extracting job output when output is stored as attachment (large output).""" + + service._temp_dir = temp_attachments_dir + attachment_id = str(uuid.uuid4()) + large_output = '{"result": "' + "x" * 10001 + '", "status": "completed"}' + + job_data = { + "Key": "test-job-key", + "State": "Successful", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + "OutputArguments": None, + "OutputFile": attachment_id, + } + job = Job.model_validate(job_data) + + blob_uri = "https://test-storage.com/test-container/test-blob" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="GET", + status_code=200, + json={ + "Id": attachment_id, + "Name": "output.json", + "BlobFileAccess": { + "Uri": blob_uri, + "Headers": { + "Keys": ["Content-Type"], + "Values": ["application/json"], + }, + "RequiresAuth": False, + }, + }, + ) + + httpx_mock.add_response( + url=blob_uri, + method="GET", + status_code=200, + content=large_output.encode("utf-8"), + ) + + result = service.extract_output(job) + + assert result == large_output + + @pytest.mark.asyncio + async def test_extract_output_async_with_inline_arguments( + self, + service: JobsService, + ) -> None: + """Test extracting job output asynchronously when output is stored inline.""" + + job_data = { + "Key": "test-job-key", + "State": "Successful", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + "OutputArguments": '{"result": "small output data", "status": "completed"}', + "OutputFile": None, + } + job = Job.model_validate(job_data) + + result = await service.extract_output_async(job) + + assert result == '{"result": "small output data", "status": "completed"}' + + @pytest.mark.asyncio + async def test_extract_output_async_with_attachment( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + temp_attachments_dir: str, + ) -> None: + """Test extracting job output asynchronously when output is stored as attachment.""" + + service._temp_dir = temp_attachments_dir + attachment_id = str(uuid.uuid4()) + large_output = '{"result": "' + "y" * 10001 + '", "status": "completed"}' + + job_data = { + "Key": "test-job-key", + "State": "Successful", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + "OutputArguments": None, + "OutputFile": attachment_id, + } + job = Job.model_validate(job_data) + + blob_uri = "https://test-storage.com/test-container/test-blob" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="GET", + status_code=200, + json={ + "Id": attachment_id, + "Name": "output.json", + "BlobFileAccess": { + "Uri": blob_uri, + "Headers": { + "Keys": ["Content-Type"], + "Values": ["application/json"], + }, + "RequiresAuth": False, + }, + }, + ) + + httpx_mock.add_response( + url=blob_uri, + method="GET", + status_code=200, + content=large_output.encode("utf-8"), + ) + + result = await service.extract_output_async(job) + + assert result == large_output + + def test_extract_output_no_output( + self, + service: JobsService, + ) -> None: + """Test extracting job output when no output is available.""" + + job_data = { + "Key": "test-job-key", + "State": "Successful", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + "OutputArguments": None, + "OutputFile": None, + } + job = Job.model_validate(job_data) + + result = service.extract_output(job) + + assert result is None + + @pytest.mark.asyncio + async def test_extract_output_async_no_output( + self, + service: JobsService, + ) -> None: + """Test extracting job output asynchronously when no output is available.""" + + job_data = { + "Key": "test-job-key", + "State": "Successful", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + "OutputArguments": None, + "OutputFile": None, + } + job = Job.model_validate(job_data) + + result = await service.extract_output_async(job) + + assert result is None + + def test_retrieve_job_with_large_output_integration( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + temp_attachments_dir: str, + ) -> None: + """Retrieve job with large output stored as attachment and extract it. + + This test verifies the complete flow: + 1. Job retrieval returns a job with OutputFile (not OutputArguments) + 2. Extract output correctly downloads from the attachment + 3. The attachment ID matches between job and download + """ + # Arrange + service._temp_dir = temp_attachments_dir + job_key = "test-job-key-with-large-output" + attachment_id = str(uuid.uuid4()) + large_output_content = ( + '{"result": "' + + "z" * 10001 + + '", "status": "completed", "metadata": {"size": "large"}}' + ) + + # job has OutputFile instead of OutputArguments for large output + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + method="GET", + status_code=200, + json={ + "Key": job_key, + "State": "Successful", + "StartTime": "2024-01-01T00:00:00Z", + "EndTime": "2024-01-01T00:05:00Z", + "Id": 456, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + "OutputArguments": None, # large output is NOT stored inline + "OutputFile": attachment_id, # large output IS stored as attachment + "InputArguments": '{"input": "test"}', # small input stored inline + "InputFile": None, + }, + ) + + blob_uri = "https://test-storage.com/large-output-container/output-blob" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="GET", + status_code=200, + json={ + "Id": attachment_id, + "Name": "large_output.json", + "BlobFileAccess": { + "Uri": blob_uri, + "Headers": { + "Keys": ["Content-Type", "x-ms-blob-type"], + "Values": ["application/json", "BlockBlob"], + }, + "RequiresAuth": False, + }, + }, + ) + + httpx_mock.add_response( + url=blob_uri, + method="GET", + status_code=200, + content=large_output_content.encode("utf-8"), + ) + + job = service.retrieve(job_key) + + # job structure is correct for large output + assert job.key == job_key + assert job.state == "Successful" + assert job.output_arguments is None # large output not stored inline + assert job.output_file == attachment_id # large output stored as attachment + assert job.input_arguments == '{"input": "test"}' # small input stored inline + assert job.input_file is None + + extracted_output = service.extract_output(job) + + assert extracted_output == large_output_content + + requests = httpx_mock.get_requests() + assert len(requests) == 3 + + job_request = requests[0] + assert job_request.method == "GET" + assert job_key in str(job_request.url) + + attachment_request = requests[1] + assert attachment_request.method == "GET" + assert attachment_id in str(attachment_request.url) + assert "Attachments" in str(attachment_request.url) + + blob_request = requests[2] + assert blob_request.method == "GET" + assert blob_request.url == blob_uri + + @pytest.mark.asyncio + async def test_retrieve_job_with_large_output_integration_async( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + temp_attachments_dir: str, + ) -> None: + """Async integration test: Retrieve job with large output and extract it.""" + service._temp_dir = temp_attachments_dir + job_key = "test-job-key-async-large-output" + attachment_id = str(uuid.uuid4()) + large_output_content = ( + '{"result": "' + + "w" * 10001 + + '", "status": "completed", "metadata": {"async": true}}' + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={job_key})", + method="GET", + status_code=200, + json={ + "Key": job_key, + "State": "Successful", + "StartTime": "2024-01-01T00:00:00Z", + "EndTime": "2024-01-01T00:10:00Z", + "Id": 789, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + "OutputArguments": None, + "OutputFile": attachment_id, + "InputArguments": None, + "InputFile": None, + }, + ) + + blob_uri = "https://test-storage.com/async-output-container/output-blob" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments({attachment_id})", + method="GET", + status_code=200, + json={ + "Id": attachment_id, + "Name": "async_large_output.json", + "BlobFileAccess": { + "Uri": blob_uri, + "Headers": { + "Keys": ["Content-Type"], + "Values": ["application/json"], + }, + "RequiresAuth": False, + }, + }, + ) + + httpx_mock.add_response( + url=blob_uri, + method="GET", + status_code=200, + content=large_output_content.encode("utf-8"), + ) + + job = await service.retrieve_async(job_key) + + assert job.key == job_key + assert job.state == "Successful" + assert job.output_arguments is None + assert job.output_file == attachment_id + + extracted_output = await service.extract_output_async(job) + + assert extracted_output == large_output_content + + requests = httpx_mock.get_requests() + assert len(requests) == 3 + + job_request = requests[0] + attachment_request = requests[1] + blob_request = requests[2] + + assert job_key in str(job_request.url) + assert attachment_id in str(attachment_request.url) + assert blob_request.url == blob_uri + + def test_retrieve_job_with_small_output_vs_large_output( + self, + httpx_mock: HTTPXMock, + service: JobsService, + base_url: str, + org: str, + tenant: str, + ) -> None: + """Test that demonstrates the difference between small and large output handling.""" + + small_job_key = "job-with-small-output" + small_output = '{"result": "small", "status": "ok"}' + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={small_job_key})", + method="GET", + status_code=200, + json={ + "Key": small_job_key, + "State": "Successful", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 100, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + "OutputArguments": small_output, # small output stored inline + "OutputFile": None, # no attachment needed + "InputArguments": '{"input": "test"}', + "InputFile": None, + }, + ) + + large_job_key = "job-with-large-output" + large_attachment_id = str(uuid.uuid4()) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier={large_job_key})", + method="GET", + status_code=200, + json={ + "Key": large_job_key, + "State": "Successful", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 200, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + "OutputArguments": None, # large output NOT stored inline + "OutputFile": large_attachment_id, # large output stored as attachment + "InputArguments": '{"input": "test"}', + "InputFile": None, + }, + ) + + small_job = service.retrieve(small_job_key) + large_job = service.retrieve(large_job_key) + + assert small_job.output_arguments == small_output + assert small_job.output_file is None + + assert large_job.output_arguments is None + assert large_job.output_file == large_attachment_id + + assert small_job.input_arguments == '{"input": "test"}' + assert small_job.input_file is None + assert large_job.input_arguments == '{"input": "test"}' + assert large_job.input_file is None + + small_extracted = service.extract_output(small_job) + assert small_extracted == small_output + + # only 2 requests made (job retrievals, no attachment downloads) + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + def test_create_job_attachment_validation_errors( + self, + service: JobsService, + ) -> None: + """Test validation errors in create_job_attachment. + + Args: + service: JobsService fixture. + """ + # Test missing both content and source_path + with pytest.raises(ValueError, match="Content or source_path is required"): + service.create_attachment(name="test.txt") + + # Test providing both content and source_path + with pytest.raises( + ValueError, match="Content and source_path are mutually exclusive" + ): + service.create_attachment( + name="test.txt", content="test content", source_path="/path/to/file.txt" + ) + + @pytest.mark.asyncio + async def test_create_job_attachment_async_with_job( + self, + service: JobsService, + mocker: MockerFixture, + ) -> None: + """Test creating a job attachment asynchronously when a job is available. + + Args: + service: JobsService fixture. + mocker: MockerFixture for mocking dependencies. + """ + # Arrange + job_key = str(uuid.uuid4()) + attachment_key = uuid.uuid4() + content = "Test attachment content" + name = "test_attachment.txt" + + # Mock the attachment service's upload_async method + # Create a mock that returns a coroutine returning a UUID + async_mock = mocker.AsyncMock(return_value=attachment_key) + mocker.patch.object( + service._attachments_service, "upload_async", side_effect=async_mock + ) + + # Mock the link_attachment_async method + mock_link = mocker.patch.object( + service, "link_attachment_async", side_effect=mocker.AsyncMock() + ) + + # Act + result = await service.create_attachment_async( + name=name, content=content, job_key=job_key + ) + + # Assert + assert result == attachment_key + async_mock.assert_called_once_with( + name=name, + content=content, + folder_key=None, + folder_path=None, + ) + mock_link.assert_called_once_with( + attachment_key=attachment_key, + job_key=uuid.UUID(job_key), + category=None, + folder_key=None, + folder_path=None, + ) + + @pytest.mark.asyncio + async def test_create_job_attachment_async_no_job( + self, + service: JobsService, + temp_attachments_dir: str, + ) -> None: + """Test creating a job attachment asynchronously when no job is available. + + Args: + service: JobsService fixture. + temp_attachments_dir: Temporary directory fixture that handles cleanup. + """ + # Arrange + content = "Test local attachment content async" + name = "test_local_attachment_async.txt" + + # Use the temporary directory provided by the fixture + service._temp_dir = temp_attachments_dir + + # Act + result = await service.create_attachment_async(name=name, content=content) + + # Assert + assert isinstance(result, uuid.UUID) + + # Verify file was created + expected_path = os.path.join(temp_attachments_dir, f"{result}_{name}") + assert os.path.exists(expected_path) + + # Check content + with open(expected_path, "r") as f: + assert f.read() == content + + def test_create_job_attachment_with_job_from_file( + self, + service: JobsService, + mocker: MockerFixture, + temp_file: Tuple[str, str, str], + ) -> None: + """Test creating a job attachment from a file when a job is available. + + This tests that the attachment is created in UiPath from a file and linked to the job + when a job key is provided. + + Args: + service: JobsService fixture. + mocker: MockerFixture for mocking dependencies. + temp_file: Temporary file fixture that handles cleanup. + """ + # Arrange + job_key = str(uuid.uuid4()) + attachment_key = uuid.uuid4() + + # Get file details from fixture + source_content, source_name, source_path = temp_file + + # Mock the attachment service's upload method + mock_upload = mocker.patch.object( + service._attachments_service, "upload", return_value=attachment_key + ) + + # Mock the link_attachment method + mock_link = mocker.patch.object(service, "link_attachment") + + # Act + result = service.create_attachment( + name=source_name, source_path=source_path, job_key=job_key + ) + + # Assert + assert result == attachment_key + mock_upload.assert_called_once_with( + name=source_name, + source_path=source_path, + folder_key=None, + folder_path=None, + ) + mock_link.assert_called_once_with( + attachment_key=attachment_key, + job_key=uuid.UUID(job_key), + category=None, + folder_key=None, + folder_path=None, + ) + + @pytest.mark.asyncio + async def test_create_job_attachment_async_with_job_from_file( + self, + service: JobsService, + mocker: MockerFixture, + temp_file: Tuple[str, str, str], + ) -> None: + """Test creating a job attachment asynchronously from a file when a job is available. + + Args: + service: JobsService fixture. + mocker: MockerFixture for mocking dependencies. + temp_file: Temporary file fixture that handles cleanup. + """ + # Arrange + job_key = str(uuid.uuid4()) + attachment_key = uuid.uuid4() + + # Get file details from fixture + source_content, source_name, source_path = temp_file + + # Mock the attachment service's upload_async method + async_mock = mocker.AsyncMock(return_value=attachment_key) + mocker.patch.object( + service._attachments_service, "upload_async", side_effect=async_mock + ) + + # Mock the link_attachment_async method + mock_link = mocker.patch.object( + service, "link_attachment_async", side_effect=mocker.AsyncMock() + ) + + # Act + result = await service.create_attachment_async( + name=source_name, source_path=source_path, job_key=job_key + ) + + # Assert + assert result == attachment_key + async_mock.assert_called_once_with( + name=source_name, + source_path=source_path, + folder_key=None, + folder_path=None, + ) + mock_link.assert_called_once_with( + attachment_key=attachment_key, + job_key=uuid.UUID(job_key), + category=None, + folder_key=None, + folder_path=None, + ) + + @pytest.mark.asyncio + async def test_create_job_attachment_async_from_file( + self, + service: JobsService, + temp_attachments_dir: str, + temp_file: Tuple[str, str, str], + ) -> None: + """Test creating a job attachment asynchronously from a file when no job is available. + + Args: + service: JobsService fixture. + temp_attachments_dir: Temporary directory fixture that handles cleanup. + temp_file: Temporary file fixture that handles cleanup. + """ + # Arrange + # Get file details from fixture + source_content, source_name, source_path = temp_file + + # Use the temporary directory provided by the fixture + service._temp_dir = temp_attachments_dir + + # Act + result = await service.create_attachment_async( + name=source_name, source_path=source_path + ) + + # Assert + assert isinstance(result, uuid.UUID) + + # Verify file was created + expected_path = os.path.join(temp_attachments_dir, f"{result}_{source_name}") + assert os.path.exists(expected_path) + + # Check content + with open(expected_path, "r") as f: + assert f.read() == source_content diff --git a/packages/uipath-platform/tests/services/test_jobs_service_bulk_operations.py b/packages/uipath-platform/tests/services/test_jobs_service_bulk_operations.py new file mode 100644 index 000000000..ce2f48b28 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_jobs_service_bulk_operations.py @@ -0,0 +1,213 @@ +"""Tests for bulk job operations and N+1 fix.""" + +import pytest + +# Test UUIDs +KEY1 = "11111111-1111-1111-1111-111111111111" +KEY2 = "22222222-2222-2222-2222-222222222222" +KEY3 = "33333333-3333-3333-3333-333333333333" + + +class TestResolveJobIdentifiers: + """Test _resolve_job_identifiers() bulk query.""" + + def test_resolve_single_key(self, jobs_service, httpx_mock, base_url, org, tenant): + """Test resolving single job key to ID.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24filter=Key+in+%28%27{KEY1}%27%29&%24select=Id%2CKey&%24top=1", + json={"value": [{"Key": KEY1, "Id": 100}]}, + ) + + ids = jobs_service._resolve_job_identifiers(job_keys=[KEY1]) + + assert ids == [100] + assert len(httpx_mock.get_requests()) == 1 + + def test_resolve_multiple_keys_single_query( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test resolving multiple job keys in single query (N+1 fix verification).""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24filter=Key+in+%28%27{KEY1}%27%2C%27{KEY2}%27%2C%27{KEY3}%27%29&%24select=Id%2CKey&%24top=3", + json={ + "value": [ + {"Key": KEY1, "Id": 100}, + {"Key": KEY2, "Id": 101}, + {"Key": KEY3, "Id": 102}, + ] + }, + ) + + ids = jobs_service._resolve_job_identifiers(job_keys=[KEY1, KEY2, KEY3]) + + assert ids == [100, 101, 102] + assert len(httpx_mock.get_requests()) == 1 # Only 1 API call! + + def test_resolve_preserves_order( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test that returned IDs maintain input key order.""" + # API may return in different order + httpx_mock.add_response( + json={ + "value": [ + {"Key": KEY3, "Id": 102}, + {"Key": KEY1, "Id": 100}, + {"Key": KEY2, "Id": 101}, + ] + }, + ) + + ids = jobs_service._resolve_job_identifiers(job_keys=[KEY1, KEY2, KEY3]) + + assert ids == [100, 101, 102] # Correct order preserved + + def test_resolve_missing_key(self, jobs_service, httpx_mock, base_url, org, tenant): + """Test error when some keys not found.""" + httpx_mock.add_response( + json={ + "value": [ + {"Key": KEY1, "Id": 100}, + # KEY2 missing + {"Key": KEY3, "Id": 102}, + ] + }, + ) + + with pytest.raises(LookupError, match=f"Jobs not found for keys: {KEY2}"): + jobs_service._resolve_job_identifiers(job_keys=[KEY1, KEY2, KEY3]) + + def test_resolve_empty_list(self, jobs_service): + """Test handling of empty key list.""" + ids = jobs_service._resolve_job_identifiers(job_keys=[]) + assert ids == [] + + def test_resolve_duplicate_keys( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test that duplicate keys are handled correctly.""" + httpx_mock.add_response( + json={ + "value": [ + {"Key": KEY1, "Id": 100}, + {"Key": KEY2, "Id": 101}, + ] + }, + ) + + # Request with duplicates + ids = jobs_service._resolve_job_identifiers(job_keys=[KEY1, KEY2, KEY1]) + + # Should return corresponding IDs maintaining order (including duplicates) + assert ids == [100, 101, 100] + assert len(httpx_mock.get_requests()) == 1 # Only 1 query despite duplicates + + def test_resolve_invalid_uuid(self, jobs_service): + """Test that invalid UUID keys raise ValueError.""" + with pytest.raises(ValueError, match="Invalid job key format: not-a-uuid"): + jobs_service._resolve_job_identifiers(job_keys=["not-a-uuid"]) + + def test_resolve_large_batch_chunks( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test that large batches are chunked (50 keys per request).""" + # Create 100 test keys (should result in 2 chunks) + test_keys = [f"{i:08x}-0000-0000-0000-000000000000" for i in range(100)] + + # Mock first chunk (keys 0-49) + chunk1_keys = test_keys[:50] + chunk1_filter = "%27%2C%27".join(chunk1_keys) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24filter=Key+in+%28%27{chunk1_filter}%27%29&%24select=Id%2CKey&%24top=50", + json={"value": [{"Key": k, "Id": i} for i, k in enumerate(chunk1_keys)]}, + ) + + # Mock second chunk (keys 50-99) + chunk2_keys = test_keys[50:] + chunk2_filter = "%27%2C%27".join(chunk2_keys) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24filter=Key+in+%28%27{chunk2_filter}%27%29&%24select=Id%2CKey&%24top=50", + json={ + "value": [{"Key": k, "Id": i + 50} for i, k in enumerate(chunk2_keys)] + }, + ) + + ids = jobs_service._resolve_job_identifiers(job_keys=test_keys) + + assert len(ids) == 100 + assert len(httpx_mock.get_requests()) == 2 # 2 chunks = 2 API calls + + +class TestStopWithBulkResolution: + """Test stop() uses bulk resolution and pure spec pattern.""" + + def test_stop_multiple_jobs_only_two_calls( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test stopping multiple jobs makes only 2 API calls (resolve + stop).""" + # Mock bulk resolution + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24filter=Key+in+%28%27{KEY1}%27%2C%27{KEY2}%27%29&%24select=Id%2CKey&%24top=2", + json={"value": [{"Key": KEY1, "Id": 100}, {"Key": KEY2, "Id": 101}]}, + ) + + # Mock stop request + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StopJobs", + method="POST", + json={}, + ) + + jobs_service.stop(job_keys=[KEY1, KEY2], strategy="SoftStop") + + requests = httpx_mock.get_requests() + assert len(requests) == 2 # Not N+1! + + # Verify stop request body matches Swagger schema + import json + + stop_request = requests[-1] + body = json.loads(stop_request.content) + assert body == {"jobIds": [100, 101], "strategy": "SoftStop"} + assert all(isinstance(id, int) for id in body["jobIds"]) # int64 validation + + def test_stop_single_job(self, jobs_service, httpx_mock, base_url, org, tenant): + """Test stopping single job.""" + # Mock bulk resolution + httpx_mock.add_response( + json={"value": [{"Key": KEY1, "Id": 100}]}, + ) + + # Mock stop request + httpx_mock.add_response( + method="POST", + json={}, + ) + + jobs_service.stop(job_keys=[KEY1], strategy="Kill") + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + def test_stop_invalid_uuid_raises_error(self, jobs_service): + """Test that invalid UUID in stop() raises ValueError.""" + with pytest.raises(ValueError, match="Invalid job key format"): + jobs_service.stop(job_keys=["invalid-uuid"]) + + @pytest.mark.asyncio + async def test_stop_async_uses_async_resolution( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test stop_async uses async bulk resolution.""" + httpx_mock.add_response( + json={"value": [{"Key": KEY1, "Id": 100}]}, + ) + + httpx_mock.add_response( + method="POST", + json={}, + ) + + await jobs_service.stop_async(job_keys=[KEY1]) + + assert len(httpx_mock.get_requests()) == 2 diff --git a/packages/uipath-platform/tests/services/test_jobs_service_pagination.py b/packages/uipath-platform/tests/services/test_jobs_service_pagination.py new file mode 100644 index 000000000..f135c3c15 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_jobs_service_pagination.py @@ -0,0 +1,221 @@ +"""Tests for JobsService PagedResult pagination.""" + +import pytest + +from uipath.platform.common.paging import PagedResult +from uipath.platform.orchestrator.job import Job + + +class TestJobsListPagination: + """Test list() pagination with PagedResult.""" + + def test_list_returns_paged_result( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test that list() returns PagedResult[Job].""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=0&%24top=100", + json={ + "value": [ + { + "Key": "job-1", + "Id": 1, + "State": "Successful", + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + ] + }, + ) + + result = jobs_service.list() + + assert isinstance(result, PagedResult) + assert len(result.items) == 1 + assert isinstance(result.items[0], Job) + assert result.skip == 0 + assert result.top == 100 + + def test_list_has_more_true_when_full_page( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test has_more=True when page is full.""" + jobs = [ + { + "Key": f"job-{i}", + "Id": i, + "State": "Successful", + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + for i in range(100) + ] + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=0&%24top=100", + json={"value": jobs}, + ) + + result = jobs_service.list(top=100) + + assert result.has_more is True + assert len(result.items) == 100 + + def test_list_has_more_false_when_partial_page( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test has_more=False when page is partial.""" + jobs = [ + { + "Key": f"job-{i}", + "Id": i, + "State": "Successful", + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + for i in range(50) + ] + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=0&%24top=100", + json={"value": jobs}, + ) + + result = jobs_service.list(top=100) + + assert result.has_more is False + assert len(result.items) == 50 + + def test_list_with_filter(self, jobs_service, httpx_mock, base_url, org, tenant): + """Test list with OData filter.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24filter=State+eq+%27Successful%27&%24skip=0&%24top=100", + json={ + "value": [ + { + "Key": "job-1", + "Id": 1, + "State": "Successful", + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + ] + }, + ) + + result = jobs_service.list(filter="State eq 'Successful'") + + assert len(result.items) == 1 + assert result.items[0].state == "Successful" + + def test_list_manual_pagination( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test manual pagination across multiple pages.""" + # First page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=0&%24top=10", + json={ + "value": [ + { + "Key": f"job-{i}", + "Id": i, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + for i in range(10) + ] + }, + ) + # Second page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=10&%24top=10", + json={ + "value": [ + { + "Key": f"job-{i}", + "Id": i, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + for i in range(10, 15) + ] + }, + ) + + # Fetch first page + page1 = jobs_service.list(skip=0, top=10) + assert len(page1.items) == 10 + assert page1.has_more is True + + # Fetch second page + page2 = jobs_service.list(skip=10, top=10) + assert len(page2.items) == 5 + assert page2.has_more is False + + +class TestJobsListValidation: + """Test parameter validation for list().""" + + def test_list_skip_exceeds_maximum(self, jobs_service): + """Test error when skip > MAX_SKIP_OFFSET.""" + with pytest.raises( + ValueError, match=r"skip must be <= 10000.*requested: 10001" + ): + jobs_service.list(skip=10001) + + def test_list_top_exceeds_maximum(self, jobs_service): + """Test error when top > MAX_PAGE_SIZE.""" + with pytest.raises(ValueError, match=r"top must be <= 1000.*requested: 1001"): + jobs_service.list(top=1001) + + def test_list_uses_shared_validation(self, jobs_service): + """Test that list() uses shared validation utility.""" + with pytest.raises(ValueError, match="skip must be >= 0"): + jobs_service.list(skip=-1) + + with pytest.raises(ValueError, match="top must be >= 1"): + jobs_service.list(top=0) + + def test_list_skip_at_boundary( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test that skip=10000 is allowed.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=10000&%24top=100", + json={"value": []}, + ) + + result = jobs_service.list(skip=10000) + assert result is not None + + def test_list_top_at_boundary( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test that top=1000 is allowed.""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=0&%24top=1000", + json={"value": []}, + ) + + result = jobs_service.list(top=1000) + assert result is not None + + +class TestJobsListAsync: + """Test async version of list().""" + + @pytest.mark.asyncio + async def test_list_async_returns_paged_result( + self, jobs_service, httpx_mock, base_url, org, tenant + ): + """Test that list_async() returns PagedResult[Job].""" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs?%24skip=0&%24top=100", + json={ + "value": [ + { + "Key": "job-1", + "Id": 1, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + ] + }, + ) + + result = await jobs_service.list_async() + + assert isinstance(result, PagedResult) + assert len(result.items) == 1 diff --git a/packages/uipath-platform/tests/services/test_llm_integration.py b/packages/uipath-platform/tests/services/test_llm_integration.py new file mode 100644 index 000000000..e1c3a549f --- /dev/null +++ b/packages/uipath-platform/tests/services/test_llm_integration.py @@ -0,0 +1,120 @@ +import os + +import httpx +import pytest + +from uipath.platform import UiPathExecutionContext +from uipath.platform.chat import ( + ChatModels, + EmbeddingModels, + UiPathOpenAIService, +) +from uipath.platform.common import UiPathApiConfig + + +def get_env_var(name: str) -> str: + """Get environment variable or skip test if not present.""" + value = os.environ.get(name) + if value is None: + pytest.skip(f"Environment variable {name} is not set") + return value + + +def get_access_token() -> str: + try: + client_id = get_env_var("UIPATH_CLIENT_ID") + client_secret = get_env_var("UIPATH_CLIENT_SECRET") + payload = { + "client_id": client_id, + "client_secret": client_secret, + "grant_type": "client_credentials", + } + headers = { + "Content-Type": "application/x-www-form-urlencoded", + } + url = f"{get_env_var('UIPATH_BASE_URL')}/identity_/connect/token" + response = httpx.post(url, data=payload, headers=headers) + json = response.json() + token = json.get("access_token") + + return token + except Exception: + pytest.skip("Failed to get access token. Check your credentials.") + + +class TestLLMIntegration: + @pytest.fixture + def llm_service(self): + """Create an OpenAIService instance with environment variables.""" + # skip tests on CI, only run locally + pytest.skip("Failed to get access token. Check your credentials.") + + base_url = get_env_var("UIPATH_URL") + api_key = get_access_token() + + config = UiPathApiConfig(base_url=base_url, secret=api_key) + execution_context = UiPathExecutionContext() + return UiPathOpenAIService(config=config, execution_context=execution_context) + + @pytest.mark.asyncio + async def test_embeddings_real(self, llm_service): + """Test the embeddings function with a real API call.""" + input_text = "This is a test for embedding a sentence." + + # Make the actual API call + result = await llm_service.embeddings(input=input_text) + + # Validate the response + assert result is not None + assert hasattr(result, "data") + assert len(result.data) > 0 + assert hasattr(result.data[0], "embedding") + assert len(result.data[0].embedding) > 0 + assert hasattr(result, "model") + assert hasattr(result, "usage") + assert result.usage.prompt_tokens > 0 + + @pytest.mark.asyncio + async def test_chat_completions_real(self, llm_service): + """Test the chat_completions function with a real API call.""" + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is the capital of France?"}, + ] + + # Make the actual API call + result = await llm_service.chat_completions( + messages=messages, + model=ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens=50, + temperature=0.7, + ) + + # Validate the response + assert result is not None + assert hasattr(result, "id") + assert hasattr(result, "choices") + assert len(result.choices) > 0 + assert hasattr(result.choices[0], "message") + assert hasattr(result.choices[0].message, "content") + assert result.choices[0].message.content.strip() != "" + assert hasattr(result, "usage") + assert result.usage.prompt_tokens > 0 + + @pytest.mark.asyncio + async def test_embeddings_with_custom_model_real(self, llm_service): + """Test the embeddings function with a custom model.""" + input_text = "Testing embeddings with a different model." + + # Make the actual API call with a specific embedding model + result = await llm_service.embeddings( + input=input_text, embedding_model=EmbeddingModels.text_embedding_3_large + ) + + # Validate the response + assert result is not None + assert hasattr(result, "data") + assert len(result.data) > 0 + assert hasattr(result.data[0], "embedding") + assert len(result.data[0].embedding) > 0 + assert result.model == "text-embedding-3-large" diff --git a/packages/uipath-platform/tests/services/test_llm_schema_cleanup.py b/packages/uipath-platform/tests/services/test_llm_schema_cleanup.py new file mode 100644 index 000000000..5cddddbc0 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_llm_schema_cleanup.py @@ -0,0 +1,229 @@ +"""Tests for the _cleanup_schema function in LLM Gateway Service.""" + +from pydantic import BaseModel + +from uipath.platform.chat._llm_gateway_service import _cleanup_schema + + +# Simple test models +class SimpleModel(BaseModel): + name: str + age: int + active: bool + + +class ModelWithList(BaseModel): + names: list[str] + numbers: list[int] + + +class ModelWithOptional(BaseModel): + required_field: str + optional_field: str | None = None + + +# Complex nested models for comprehensive testing +class Task(BaseModel): + task_id: int + description: str + completed: bool + + +class Project(BaseModel): + project_id: int + name: str + tasks: list[Task] + + +class Team(BaseModel): + team_id: int + team_name: str + members: list[str] + projects: list[Project] + + +class Department(BaseModel): + department_id: int + department_name: str + teams: list[Team] + + +class Company(BaseModel): + company_id: int + company_name: str + departments: list[Department] + + +class TestCleanupSchema: + """Test cases for the _cleanup_schema function.""" + + def test_simple_model_cleanup(self): + """Test cleanup of a simple model without nested structures.""" + schema = _cleanup_schema(SimpleModel.model_json_schema()) + + assert schema["type"] == "object" + assert schema["additionalProperties"] is False + assert "required" in schema + assert set(schema["required"]) == {"name", "age", "active"} + + # Check properties are cleaned (no titles) + properties = schema["properties"] + assert "name" in properties + assert "age" in properties + assert "active" in properties + + # Ensure no 'title' fields are present + for _prop_name, prop_def in properties.items(): + assert "title" not in prop_def + + def test_model_with_list_cleanup(self): + """Test cleanup of a model with list fields.""" + schema = _cleanup_schema(ModelWithList.model_json_schema()) + + assert schema["type"] == "object" + assert schema["additionalProperties"] is False + + # Check list properties + names_prop = schema["properties"]["names"] + assert names_prop["type"] == "array" + assert "items" in names_prop + assert names_prop["items"]["type"] == "string" + # Ensure no 'title' in items + assert "title" not in names_prop["items"] + + numbers_prop = schema["properties"]["numbers"] + assert numbers_prop["type"] == "array" + assert "items" in numbers_prop + assert numbers_prop["items"]["type"] == "integer" + assert "title" not in numbers_prop["items"] + + def test_model_with_optional_cleanup(self): + """Test cleanup of a model with optional fields.""" + schema = _cleanup_schema(ModelWithOptional.model_json_schema()) + + assert schema["type"] == "object" + assert schema["additionalProperties"] is False + + # Only required_field should be in required array + assert schema["required"] == ["required_field", "optional_field"] + + # Both fields should be in properties + assert "required_field" in schema["properties"] + assert "optional_field" in schema["properties"] + + def test_complex_nested_model_cleanup(self): + """Test cleanup of the complex nested Company model.""" + schema = _cleanup_schema(Company.model_json_schema()) + + assert schema["type"] == "object" + assert schema["additionalProperties"] is False + assert set(schema["required"]) == {"company_id", "company_name", "departments"} + + # Check top-level properties + properties = schema["properties"] + assert "company_id" in properties + assert "company_name" in properties + assert "departments" in properties + + # Check departments is array of objects + departments_prop = properties["departments"] + assert departments_prop["type"] == "array" + assert "items" in departments_prop + assert "title" not in departments_prop["items"] + + # Verify no 'title' fields exist anywhere in the schema + self._assert_no_titles_recursive(schema) + + def test_schema_structure_integrity(self): + """Test that the cleaned schema maintains proper JSON Schema structure.""" + schema = _cleanup_schema(Company.model_json_schema()) + + # Must have these top-level keys + required_keys = {"type", "properties", "required", "additionalProperties"} + assert all(key in schema for key in required_keys) + + # Type must be object + assert schema["type"] == "object" + + # additionalProperties must be False + assert schema["additionalProperties"] is False + + # Properties must be a dict + assert isinstance(schema["properties"], dict) + + # Required must be a list + assert isinstance(schema["required"], list) + + def test_email_field_handling(self): + """Test that EmailStr fields are properly handled.""" + schema = _cleanup_schema(Team.model_json_schema()) + + members_prop = schema["properties"]["members"] + assert members_prop["type"] == "array" + + # EmailStr should be treated as string with format + items = members_prop["items"] + assert items["type"] == "string" + # Email format might be present + if "format" in items: + assert items["format"] == "email" + + def test_nested_objects_cleanup(self): + """Test that nested objects are properly cleaned.""" + schema = _cleanup_schema(Department.model_json_schema()) + + # Check teams property (array of Team objects) + teams_prop = schema["properties"]["teams"] + assert teams_prop["type"] == "array" + assert "items" in teams_prop + + # The items should not have title + team_items = teams_prop["items"] + assert "title" not in team_items + + # If it's a nested object, check its properties + if "properties" in team_items: + for _prop_name, prop_def in team_items["properties"].items(): + assert "title" not in prop_def + + def _assert_no_titles_recursive(self, obj): + """Recursively assert that no 'title' fields exist in the schema.""" + if isinstance(obj, dict): + assert "title" not in obj, f"Found 'title' field in: {obj}" + for value in obj.values(): + self._assert_no_titles_recursive(value) + elif isinstance(obj, list): + for item in obj: + self._assert_no_titles_recursive(item) + + def test_function_returns_dict(self): + """Test that the function returns a dictionary.""" + result = _cleanup_schema(SimpleModel.model_json_schema()) + assert isinstance(result, dict) + + def test_function_with_inheritance(self): + """Test cleanup with model inheritance.""" + + class BaseEntity(BaseModel): + id: int + created_at: str + + class ExtendedEntity(BaseEntity): + name: str + description: str | None = None + + schema = _cleanup_schema(ExtendedEntity.model_json_schema()) + + # Should include fields from both base and derived class + properties = schema["properties"] + assert "id" in properties + assert "created_at" in properties + assert "name" in properties + assert "description" in properties + + # Required fields from both classes + required_fields = set(schema["required"]) + assert "id" in required_fields + assert "created_at" in required_fields + assert "name" in required_fields + assert "description" in required_fields diff --git a/packages/uipath-platform/tests/services/test_llm_service.py b/packages/uipath-platform/tests/services/test_llm_service.py new file mode 100644 index 000000000..e1ab8299b --- /dev/null +++ b/packages/uipath-platform/tests/services/test_llm_service.py @@ -0,0 +1,545 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import BaseModel + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.chat import ( + ChatModels, + EmbeddingModels, + TextEmbedding, + UiPathOpenAIService, +) + + +class TestOpenAIService: + @pytest.fixture + def config(self): + return UiPathApiConfig(base_url="https://example.com", secret="test_secret") + + @pytest.fixture + def execution_context(self): + return UiPathExecutionContext() + + @pytest.fixture + def openai_service(self, config, execution_context): + return UiPathOpenAIService(config=config, execution_context=execution_context) + + @pytest.fixture + def llm_service(self, config, execution_context): + return UiPathOpenAIService(config=config, execution_context=execution_context) + + def test_init(self, config, execution_context): + service = UiPathOpenAIService( + config=config, execution_context=execution_context + ) + assert service._config == config + assert service._execution_context == execution_context + + @patch.object(UiPathOpenAIService, "request_async") + @pytest.mark.asyncio + async def test_embeddings(self, mock_request, openai_service): + # Mock response + mock_response = MagicMock() + mock_response.json.return_value = { + "data": [{"embedding": [0.1, 0.2, 0.3], "index": 0, "object": "embedding"}], + "model": "text-embedding-ada-002", + "object": "list", + "usage": {"prompt_tokens": 4, "total_tokens": 4}, + } + mock_request.return_value = mock_response + + # Call the method + result = await openai_service.embeddings(input="Test input") + + # Assertions + mock_request.assert_called_once() + assert isinstance(result, TextEmbedding) + assert result.data[0].embedding == [0.1, 0.2, 0.3] + assert result.model == "text-embedding-ada-002" + assert result.usage.prompt_tokens == 4 + + @patch.object(UiPathOpenAIService, "request_async") + @pytest.mark.asyncio + async def test_embeddings_with_custom_model(self, mock_request, openai_service): + # Mock response + mock_response = MagicMock() + mock_response.json.return_value = { + "data": [{"embedding": [0.1, 0.2, 0.3], "index": 0, "object": "embedding"}], + "model": "text-embedding-3-large", + "object": "list", + "usage": {"prompt_tokens": 4, "total_tokens": 4}, + } + mock_request.return_value = mock_response + + # Call the method with custom model + result = await openai_service.embeddings( + input="Test input", embedding_model=EmbeddingModels.text_embedding_3_large + ) + + # Assertions for the result + mock_request.assert_called_once() + assert result.model == "text-embedding-3-large" + assert len(result.data) == 1 + assert result.data[0].embedding == [0.1, 0.2, 0.3] + assert result.data[0].index == 0 + assert result.object == "list" + assert result.usage.prompt_tokens == 4 + assert result.usage.total_tokens == 4 + + @patch.object(UiPathOpenAIService, "request_async") + @pytest.mark.asyncio + async def test_complex_company_pydantic_model(self, mock_request, llm_service): + """Test using complex Company Pydantic model as response_format.""" + + # Define the complex nested models + class Task(BaseModel): + task_id: int + description: str + completed: bool + + class Project(BaseModel): + project_id: int + name: str + tasks: list[Task] + + class Team(BaseModel): + team_id: int + team_name: str + members: list[str] + projects: list[Project] + + class Department(BaseModel): + department_id: int + department_name: str + teams: list[Team] + + class Company(BaseModel): + company_id: int + company_name: str + departments: list[Department] + + # Mock response + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-test123", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": json.dumps( + { + "company_id": 1, + "company_name": "FutureTech Ltd", + "departments": [ + { + "department_id": 1, + "department_name": "Engineering", + "teams": [ + { + "team_id": 1, + "team_name": "Backend Team", + "members": [ + "john@futuretech.com", + "jane@futuretech.com", + ], + "projects": [ + { + "project_id": 1, + "name": "API Development", + "tasks": [ + { + "task_id": 1, + "description": "Design REST endpoints", + "completed": True, + }, + { + "task_id": 2, + "description": "Implement authentication", + "completed": False, + }, + ], + } + ], + } + ], + }, + { + "department_id": 2, + "department_name": "Marketing", + "teams": [ + { + "team_id": 2, + "team_name": "Digital Marketing", + "members": ["sarah@futuretech.com"], + "projects": [ + { + "project_id": 2, + "name": "Social Media Campaign", + "tasks": [ + { + "task_id": 3, + "description": "Create content calendar", + "completed": True, + } + ], + } + ], + } + ], + }, + ], + } + ), + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 150, + "completion_tokens": 300, + "total_tokens": 450, + }, + } + mock_request.return_value = mock_response + + messages = [ + { + "role": "system", + "content": ( + "You are a helpful assistant. Respond with structured JSON according to this schema:\n" + "Company -> departments -> teams -> projects -> tasks.\n" + "Each company has a company_id and company_name.\n" + "Each department has a department_id and department_name.\n" + "Each team has a team_id, team_name, members (email addresses), and projects.\n" + "Each project has a project_id, name, and tasks.\n" + "Each task has a task_id, description, and completed status." + ), + }, + { + "role": "user", + "content": ( + "Give me an example of a software company called 'FutureTech Ltd' with two departments: " + "Engineering and Marketing. Each department should have at least one team, with projects and tasks." + ), + }, + ] + + result = await llm_service.chat_completions( + messages=messages, + model=ChatModels.gpt_4_1_mini_2025_04_14, + response_format=Company, # Pass BaseModel directly instead of dict + max_tokens=2000, + temperature=0, + ) + + # Validate the response + assert result is not None + assert len(result.choices) > 0 + assert result.choices[0].message.content is not None + + # Parse and validate the JSON response + response_json = json.loads(result.choices[0].message.content) + + # Validate the structure matches our Company model + assert "company_id" in response_json + assert "company_name" in response_json + assert "departments" in response_json + assert response_json["company_name"] == "FutureTech Ltd" + assert len(response_json["departments"]) >= 2 + + # Check for Engineering and Marketing departments + dept_names = [dept["department_name"] for dept in response_json["departments"]] + assert "Engineering" in dept_names + assert "Marketing" in dept_names + + # Validate that each department has teams with proper structure + for department in response_json["departments"]: + assert "teams" in department + assert len(department["teams"]) >= 1 + + # Validate team structure + for team in department["teams"]: + assert "team_id" in team + assert "team_name" in team + assert "members" in team + assert "projects" in team + + # Validate projects and tasks + for project in team["projects"]: + assert "project_id" in project + assert "name" in project + assert "tasks" in project + + for task in project["tasks"]: + assert "task_id" in task + assert "description" in task + assert "completed" in task + + # Try to parse it with our Pydantic model to ensure it's completely valid + company_instance = Company.model_validate(response_json) + assert company_instance.company_name == "FutureTech Ltd" + assert len(company_instance.departments) >= 2 + + @patch.object(UiPathOpenAIService, "request_async") + @pytest.mark.asyncio + async def test_optional_request_format_model(self, mock_request, llm_service): + """Test using complex Company Pydantic model as response_format.""" + + class Article(BaseModel): + title: str | None = None + + # Mock response + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-test123", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{}", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 150, + "completion_tokens": 300, + "total_tokens": 450, + }, + } + mock_request.return_value = mock_response + + messages = [ + { + "role": "system", + "content": "system-content", + }, + { + "role": "user", + "content": "user-content", + }, + ] + + result = await llm_service.chat_completions( + messages=messages, + model=ChatModels.gpt_4_1_mini_2025_04_14, + response_format=Article, # Pass BaseModel directly instead of dict + max_tokens=2000, + temperature=0, + ) + captured_request = mock_request.call_args[1]["json"] + expected_request = { + "messages": [ + {"role": "system", "content": "system-content"}, + {"role": "user", "content": "user-content"}, + ], + "max_tokens": 2000, + "temperature": 0, + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "article", + "strict": True, + "schema": { + "type": "object", + "additionalProperties": False, + "properties": { + "title": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": None, + } + }, + "required": ["title"], + }, + }, + }, + } + + # validate the request to LLM gateway + assert expected_request == captured_request + + # Validate the response + assert result is not None + assert len(result.choices) > 0 + assert result.choices[0].message.content is not None + + # Parse and validate the JSON response + response_json = json.loads(result.choices[0].message.content) + + # Validate the structure matches our Company model + assert response_json == {} + + # Try to parse it with our Pydantic model to ensure it's completely valid + article_instance = Article.model_validate(response_json) + assert article_instance.title is None + + +class TestNormalizedLlmServiceClaudeFiltering: + """Test that Claude models correctly filter out OpenAI-specific parameters. + + The UiPath Normalized API gateway passes parameters through to the underlying + provider. Claude/Anthropic models do NOT support n, frequency_penalty, + presence_penalty, or top_p, and sending them causes 400 errors. + """ + + @pytest.fixture + def config(self): + return UiPathApiConfig(base_url="https://example.com", secret="test_secret") + + @pytest.fixture + def execution_context(self): + return UiPathExecutionContext() + + @pytest.fixture + def llm_service(self, config, execution_context): + from uipath.platform.chat._llm_gateway_service import UiPathLlmChatService + + return UiPathLlmChatService(config=config, execution_context=execution_context) + + @patch( + "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" + ) + @pytest.mark.asyncio + async def test_claude_model_excludes_openai_params(self, mock_request, llm_service): + """Test that Claude models do not include n, frequency_penalty, presence_penalty, top_p.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": "anthropic.claude-haiku-4-5-20251001-v1:0", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + mock_request.return_value = mock_response + + await llm_service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model="anthropic.claude-haiku-4-5-20251001-v1:0", + max_tokens=1000, + temperature=0, + ) + + # Get the request body and headers + call_kwargs = mock_request.call_args[1] + request_body = call_kwargs["json"] + headers = call_kwargs["headers"] + + # Claude models should NOT have these OpenAI-specific params + assert "n" not in request_body, "Claude request must not include 'n'" + assert "frequency_penalty" not in request_body, ( + "Claude request must not include 'frequency_penalty'" + ) + assert "presence_penalty" not in request_body, ( + "Claude request must not include 'presence_penalty'" + ) + assert "top_p" not in request_body, "Claude request must not include 'top_p'" + + # Model is sent in headers, not body (Normalized API pattern) + assert ( + headers["X-UiPath-LlmGateway-NormalizedApi-ModelName"] + == "anthropic.claude-haiku-4-5-20251001-v1:0" + ) + # Basic params should still be in the body + assert request_body["max_tokens"] == 1000 + assert request_body["temperature"] == 0 + + @patch( + "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" + ) + @pytest.mark.asyncio + async def test_openai_model_includes_all_params(self, mock_request, llm_service): + """Test that OpenAI models DO include n, frequency_penalty, presence_penalty.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + mock_request.return_value = mock_response + + await llm_service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model="gpt-4o-mini-2024-07-18", + max_tokens=1000, + temperature=0, + ) + + call_kwargs = mock_request.call_args[1] + request_body = call_kwargs["json"] + + # OpenAI models should have all params + assert "n" in request_body, "OpenAI request must include 'n'" + assert "frequency_penalty" in request_body, ( + "OpenAI request must include 'frequency_penalty'" + ) + assert "presence_penalty" in request_body, ( + "OpenAI request must include 'presence_penalty'" + ) + + @patch( + "uipath.platform.chat._llm_gateway_service.UiPathLlmChatService.request_async" + ) + @pytest.mark.asyncio + async def test_claude_sonnet_45_excluded_params(self, mock_request, llm_service): + """Test Claude Sonnet 4.5 specifically, since it was failing in production.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1234567890, + "model": "anthropic.claude-sonnet-4-5-20250929-v1:0", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + mock_request.return_value = mock_response + + await llm_service.chat_completions( + messages=[{"role": "user", "content": "Hello"}], + model="anthropic.claude-sonnet-4-5-20250929-v1:0", + max_tokens=8000, + temperature=0, + ) + + call_kwargs = mock_request.call_args[1] + request_body = call_kwargs["json"] + + assert "n" not in request_body + assert "frequency_penalty" not in request_body + assert "presence_penalty" not in request_body + assert "top_p" not in request_body + assert request_body["max_tokens"] == 8000 diff --git a/packages/uipath-platform/tests/services/test_llm_throttle.py b/packages/uipath-platform/tests/services/test_llm_throttle.py new file mode 100644 index 000000000..733cc9275 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_llm_throttle.py @@ -0,0 +1,429 @@ +"""Tests for LLM request throttling functionality.""" + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.chat import UiPathLlmChatService, UiPathOpenAIService +from uipath.platform.chat.llm_throttle import ( + DEFAULT_LLM_CONCURRENCY, + get_llm_semaphore, + set_llm_concurrency, +) + + +class TestLLMThrottling: + """Tests for LLM throttling mechanism.""" + + @pytest.fixture(autouse=True) + def reset_semaphore(self): + """Reset the global semaphore and limit before each test.""" + import uipath.platform.chat.llm_throttle as module + + module._llm_semaphore = None + module._llm_semaphore_loop = None + module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY + yield + module._llm_semaphore = None + module._llm_semaphore_loop = None + module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY + + @pytest.fixture + def config(self): + """Create a test config.""" + return UiPathApiConfig(base_url="https://example.com", secret="test_secret") + + @pytest.fixture + def execution_context(self): + """Create a test execution context.""" + return UiPathExecutionContext() + + @pytest.fixture + def openai_service(self, config, execution_context): + """Create an OpenAI service instance.""" + return UiPathOpenAIService(config=config, execution_context=execution_context) + + @pytest.fixture + def llm_service(self, config, execution_context): + """Create an LLM chat service instance.""" + return UiPathLlmChatService(config=config, execution_context=execution_context) + + def test_default_concurrency_constant(self): + """Test that DEFAULT_LLM_CONCURRENCY is set correctly.""" + assert DEFAULT_LLM_CONCURRENCY == 20 + + @pytest.mark.asyncio + async def testget_llm_semaphore_creates_semaphore(self): + """Test that get_llm_semaphore creates a semaphore with default limit.""" + semaphore = get_llm_semaphore() + assert isinstance(semaphore, asyncio.Semaphore) + # Semaphore should allow DEFAULT_LLM_CONCURRENCY concurrent acquisitions + assert semaphore._value == DEFAULT_LLM_CONCURRENCY + + @pytest.mark.asyncio + async def testget_llm_semaphore_returns_same_instance(self): + """Test that get_llm_semaphore returns the same semaphore instance.""" + semaphore1 = get_llm_semaphore() + semaphore2 = get_llm_semaphore() + assert semaphore1 is semaphore2 + + @pytest.mark.asyncio + async def test_set_llm_concurrency_changes_limit(self): + """Test that set_llm_concurrency sets a custom limit.""" + set_llm_concurrency(5) + semaphore = get_llm_semaphore() + assert semaphore._value == 5 + + @pytest.mark.asyncio + async def test_throttle_limits_concurrency(self): + """Test that throttling actually limits concurrent operations.""" + set_llm_concurrency(2) + + concurrent_count = 0 + max_concurrent = 0 + + async def task(): + nonlocal concurrent_count, max_concurrent + async with get_llm_semaphore(): + concurrent_count += 1 + max_concurrent = max(max_concurrent, concurrent_count) + await asyncio.sleep(0.05) + concurrent_count -= 1 + + # Run 10 tasks with concurrency limit of 2 + await asyncio.gather(*[task() for _ in range(10)]) + + assert max_concurrent == 2 + + @patch.object(UiPathOpenAIService, "request_async") + @pytest.mark.asyncio + async def test_openai_service_uses_throttle(self, mock_request, openai_service): + """Test that OpenAI service chat_completions uses throttling.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "test", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + mock_request.return_value = mock_response + + set_llm_concurrency(1) + semaphore = get_llm_semaphore() + + # Verify semaphore is used during the call + initial_value = semaphore._value + + await openai_service.chat_completions( + messages=[{"role": "user", "content": "Hi"}] + ) + + # After the call, semaphore should be back to initial value + assert semaphore._value == initial_value + + @patch.object(UiPathLlmChatService, "request_async") + @pytest.mark.asyncio + async def test_llm_service_uses_throttle(self, mock_request, llm_service): + """Test that LLM chat service chat_completions uses throttling.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "test", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + mock_request.return_value = mock_response + + set_llm_concurrency(1) + semaphore = get_llm_semaphore() + + initial_value = semaphore._value + + await llm_service.chat_completions(messages=[{"role": "user", "content": "Hi"}]) + + assert semaphore._value == initial_value + + @patch.object(UiPathOpenAIService, "request_async") + @pytest.mark.asyncio + async def test_embeddings_uses_throttle(self, mock_request, openai_service): + """Test that embeddings endpoint uses throttling.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "data": [{"embedding": [0.1, 0.2, 0.3], "index": 0, "object": "embedding"}], + "model": "text-embedding-ada-002", + "object": "list", + "usage": {"prompt_tokens": 4, "total_tokens": 4}, + } + mock_request.return_value = mock_response + + set_llm_concurrency(1) + semaphore = get_llm_semaphore() + + initial_value = semaphore._value + + await openai_service.embeddings(input="Test input") + + assert semaphore._value == initial_value + + +class TestEventLoopBug: + """Tests for the event loop binding bug. + + The bug: If set_llm_concurrency() creates the semaphore before asyncio.run(), + the semaphore is bound to the wrong event loop and will fail with: + RuntimeError: Semaphore object is bound to a different event loop + + The fix: set_llm_concurrency() only stores the limit, doesn't create semaphore. + """ + + @pytest.fixture(autouse=True) + def reset_semaphore(self): + """Reset the global semaphore and limit before each test.""" + import uipath.platform.chat.llm_throttle as module + + module._llm_semaphore = None + module._llm_semaphore_loop = None + module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY + yield + module._llm_semaphore = None + module._llm_semaphore_loop = None + module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY + + def test_set_llm_concurrency_before_asyncio_run(self): + """Test that set_llm_concurrency called before asyncio.run causes issues. + + This test reproduces the bug where the semaphore is created in one + event loop context but used in another (created by asyncio.run). + """ + # This simulates what happens in cli_eval.py: + # 1. set_llm_concurrency() is called (creates semaphore) + # 2. asyncio.run() starts a NEW event loop + # 3. Code tries to use the semaphore in the new loop + + # Step 1: Call set_llm_concurrency outside any event loop + # (simulating CLI code before asyncio.run) + set_llm_concurrency(5) + + # Step 2 & 3: Run async code in a new event loop + async def use_semaphore(): + semaphore = get_llm_semaphore() + async with semaphore: + pass + + # This should raise RuntimeError if the bug exists + # because the semaphore was created in a different loop context + try: + asyncio.run(use_semaphore()) + # If we get here, either: + # a) The bug is fixed (semaphore created lazily in correct loop) + # b) Python version handles this gracefully + bug_exists = False + except RuntimeError as e: + if "different event loop" in str( + e + ) or "attached to a different loop" in str(e): + bug_exists = True + else: + raise + + # This assertion documents expected behavior: + # - If bug_exists is True, the fix is needed + # - If bug_exists is False, the fix has been applied or Python handles it + # Currently we expect the bug to exist (test should fail after fix is applied) + assert not bug_exists, ( + "Event loop bug detected! The semaphore was created outside the running " + "event loop. Fix: set_llm_concurrency should only store the limit, not " + "create the semaphore." + ) + + def test_lazy_semaphore_creation_in_correct_loop(self): + """Test that semaphore created inside asyncio.run works correctly. + + This is the expected behavior after the fix is applied. + """ + import uipath.platform.chat.llm_throttle as module + + # Ensure semaphore is None (not pre-created) + module._llm_semaphore = None + + async def use_semaphore(): + # Semaphore should be created here, inside the running loop + semaphore = get_llm_semaphore() + async with semaphore: + return True + + # This should work because semaphore is created in the correct loop + result = asyncio.run(use_semaphore()) + assert result is True + + def test_set_llm_concurrency_does_not_create_semaphore(self): + """Test that set_llm_concurrency only stores limit, doesn't create semaphore. + + This is the key fix - the semaphore should be created lazily inside + the running event loop, not when set_llm_concurrency is called. + """ + import uipath.platform.chat.llm_throttle as module + + # Ensure semaphore is None initially + module._llm_semaphore = None + + # Call set_llm_concurrency + set_llm_concurrency(5) + + # Verify semaphore is still None (not created yet) + assert module._llm_semaphore is None + + # Verify limit was stored + assert module._llm_concurrency_limit == 5 + + # Now when we get the semaphore, it should be created with the stored limit + async def get_sem(): + return get_llm_semaphore() + + semaphore = asyncio.run(get_sem()) + assert semaphore._value == 5 + + +class TestMultipleEventLoops: + """Tests for semaphore behavior across multiple event loops. + + This tests the scenario where: + 1. First asyncio.run() creates semaphore bound to loop A + 2. Loop A closes + 3. Second asyncio.run() creates loop B + 4. Code tries to use semaphore still bound to dead loop A + 5. Should NOT crash - semaphore should be recreated for loop B + """ + + @pytest.fixture(autouse=True) + def reset_semaphore(self): + """Reset the global semaphore before each test.""" + import uipath.platform.chat.llm_throttle as module + + module._llm_semaphore = None + module._llm_semaphore_loop = None + module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY + yield + module._llm_semaphore = None + module._llm_semaphore_loop = None + module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY + + def test_semaphore_works_across_multiple_asyncio_runs(self): + """Test that semaphore works correctly across multiple asyncio.run() calls. + + This is the key test for the event-loop binding bug. Without the fix, + this test will fail with: + RuntimeError: Semaphore object is bound to a different event loop + + NOTE: The bug only triggers when there's CONTENTION on the semaphore + (multiple tasks competing). Without contention, _get_loop() is not + called and the semaphore doesn't bind to the event loop. + """ + # Use a limit of 1 to force contention + set_llm_concurrency(1) + + async def use_semaphore_with_contention(): + """Use the semaphore with contention to trigger loop binding.""" + semaphore = get_llm_semaphore() + + async def contender(): + async with semaphore: + await asyncio.sleep(0.001) + + # Hold the semaphore while another task tries to acquire it + async with semaphore: + # Create a contending task - this forces _get_loop() to be called + task = asyncio.create_task(contender()) + await asyncio.sleep(0.001) + + await task + return True + + # First run - creates semaphore and binds to loop A due to contention + result1 = asyncio.run(use_semaphore_with_contention()) + assert result1 is True + + # Second run - loop A is closed, loop B is created + # Without fix: crashes because semaphore is still bound to loop A + # With fix: should work because semaphore is recreated for loop B + result2 = asyncio.run(use_semaphore_with_contention()) + assert result2 is True + + # Third run - just to be sure + result3 = asyncio.run(use_semaphore_with_contention()) + assert result3 is True + + def test_semaphore_limit_preserved_across_loops(self): + """Test that concurrency limit is preserved when semaphore is recreated.""" + set_llm_concurrency(3) + + async def get_semaphore_value(): + semaphore = get_llm_semaphore() + return semaphore._value + + # First run + value1 = asyncio.run(get_semaphore_value()) + assert value1 == 3 + + # Second run - should still have limit of 3 + value2 = asyncio.run(get_semaphore_value()) + assert value2 == 3 + + +class TestConcurrencyValidation: + """Tests for input validation of concurrency settings.""" + + @pytest.fixture(autouse=True) + def reset_semaphore(self): + """Reset the global semaphore before each test.""" + import uipath.platform.chat.llm_throttle as module + + module._llm_semaphore = None + module._llm_semaphore_loop = None + module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY + yield + module._llm_semaphore = None + module._llm_semaphore_loop = None + module._llm_concurrency_limit = module.DEFAULT_LLM_CONCURRENCY + + def test_set_llm_concurrency_zero_raises_error(self): + """Test that setting concurrency to 0 raises ValueError. + + A semaphore with value 0 would deadlock all requests. + """ + with pytest.raises(ValueError, match="must be at least 1"): + set_llm_concurrency(0) + + def test_set_llm_concurrency_negative_raises_error(self): + """Test that setting negative concurrency raises ValueError.""" + with pytest.raises(ValueError, match="must be at least 1"): + set_llm_concurrency(-1) + + def test_set_llm_concurrency_one_is_valid(self): + """Test that setting concurrency to 1 (minimum valid) works.""" + set_llm_concurrency(1) + + async def check_semaphore(): + semaphore = get_llm_semaphore() + return semaphore._value + + value = asyncio.run(check_semaphore()) + assert value == 1 diff --git a/packages/uipath-platform/tests/services/test_mcp_service.py b/packages/uipath-platform/tests/services/test_mcp_service.py new file mode 100644 index 000000000..70d41d2ea --- /dev/null +++ b/packages/uipath-platform/tests/services/test_mcp_service.py @@ -0,0 +1,541 @@ +from unittest.mock import Mock, patch + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common.constants import HEADER_FOLDER_KEY, HEADER_USER_AGENT +from uipath.platform.orchestrator import McpService +from uipath.platform.orchestrator._folder_service import FolderService +from uipath.platform.orchestrator.mcp import McpServer + + +@pytest.fixture +def folders_service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> FolderService: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "test-folder-key") + return FolderService(config=config, execution_context=execution_context) + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + folders_service: FolderService, + monkeypatch: pytest.MonkeyPatch, +) -> McpService: + monkeypatch.setenv("UIPATH_FOLDER_KEY", "test-folder-key") + return McpService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + + +class TestMcpService: + class TestListServers: + def test_list_with_folder_path( + self, + httpx_mock: HTTPXMock, + service: McpService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test listing MCP servers with a folder_path parameter that gets resolved.""" + mock_servers = [ + { + "id": "server-id-1", + "name": "Test MCP Server", + "slug": "test-mcp-server", + "description": "Test description", + "version": "1.0.0", + "createdAt": "2025-07-24T11:30:52.031427", + "updatedAt": "2025-07-24T12:29:53.4765887", + "isActive": True, + "type": 2, + "status": 1, + "command": "", + "arguments": "", + "environmentVariables": "", + "processKey": "test-process-key", + "folderKey": "test-folder-key", + "runtimesCount": 0, + "mcpUrl": "https://test.com/mcp/test-mcp-server", + } + ] + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "resolved-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ], + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agenthub_/api/servers", + status_code=200, + json=mock_servers, + ) + + servers = service.list(folder_path="test-folder-path") + + assert len(servers) == 1 + assert isinstance(servers[0], McpServer) + assert servers[0].name == "Test MCP Server" + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + servers_request = requests[1] + assert servers_request.method == "GET" + assert ( + servers_request.url == f"{base_url}{org}{tenant}/agenthub_/api/servers" + ) + assert HEADER_FOLDER_KEY in servers_request.headers + assert servers_request.headers[HEADER_FOLDER_KEY] == "resolved-folder-key" + + def test_list_without_folder_raises_error( + self, + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Test that listing servers without a folder_path raises ValueError.""" + monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) + monkeypatch.delenv("UIPATH_FOLDER_PATH", raising=False) + + folders_service = FolderService( + config=config, execution_context=execution_context + ) + service = McpService( + config=config, + execution_context=execution_context, + folders_service=folders_service, + ) + + with pytest.raises( + ValueError, + match="Cannot obtain folder_key without providing folder_path", + ): + service.list() + + @pytest.mark.anyio + async def test_list_async( + self, + httpx_mock: HTTPXMock, + service: McpService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test asynchronously listing MCP servers.""" + mock_servers = [ + { + "id": "server-id-1", + "name": "Async Test Server", + "slug": "async-test-server", + "description": "Async test description", + "version": "1.0.0", + "createdAt": "2025-07-24T11:30:52.031427", + "updatedAt": "2025-07-24T12:29:53.4765887", + "isActive": True, + "type": 2, + "status": 1, + "command": "", + "arguments": "", + "environmentVariables": "", + "processKey": "test-process-key", + "folderKey": "test-folder-key", + "runtimesCount": 0, + "mcpUrl": "https://test.com/mcp/async-test-server", + } + ] + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ], + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agenthub_/api/servers", + status_code=200, + json=mock_servers, + ) + + servers = await service.list_async(folder_path="test-folder-path") + + assert len(servers) == 1 + assert isinstance(servers[0], McpServer) + assert servers[0].name == "Async Test Server" + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + servers_request = requests[1] + assert servers_request.method == "GET" + assert ( + servers_request.url == f"{base_url}{org}{tenant}/agenthub_/api/servers" + ) + assert HEADER_FOLDER_KEY in servers_request.headers + assert servers_request.headers[HEADER_FOLDER_KEY] == "test-folder-key" + assert HEADER_USER_AGENT in servers_request.headers + assert ( + servers_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.McpService.list_async/{version}" + ) + + class TestRetrieveServer: + def test_retrieve_server_with_folder_path( + self, + httpx_mock: HTTPXMock, + service: McpService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test retrieving a specific MCP server by slug with folder_path.""" + mock_server = { + "id": "server-id-1", + "name": "Test MCP Server", + "slug": "test-mcp-server", + "description": "A test server", + "version": "1.0.0", + "createdAt": "2025-07-24T11:30:52.031427", + "updatedAt": "2025-07-24T12:29:53.4765887", + "isActive": True, + "type": 2, + "status": 1, + "command": "", + "arguments": "", + "environmentVariables": "", + "processKey": "test-process-key", + "folderKey": "test-folder-key", + "runtimesCount": 0, + "mcpUrl": "https://test.com/mcp/test-mcp-server", + } + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ], + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agenthub_/api/servers/test-mcp-server", + status_code=200, + json=mock_server, + ) + + server = service.retrieve("test-mcp-server", folder_path="test-folder-path") + + assert isinstance(server, McpServer) + assert server.name == "Test MCP Server" + assert server.slug == "test-mcp-server" + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + retrieve_request = requests[1] + assert retrieve_request.method == "GET" + assert ( + retrieve_request.url + == f"{base_url}{org}{tenant}/agenthub_/api/servers/test-mcp-server" + ) + assert HEADER_FOLDER_KEY in retrieve_request.headers + assert retrieve_request.headers[HEADER_FOLDER_KEY] == "test-folder-key" + assert HEADER_USER_AGENT in retrieve_request.headers + assert ( + retrieve_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.McpService.retrieve/{version}" + ) + + @pytest.mark.anyio + async def test_retrieve_server_async( + self, + httpx_mock: HTTPXMock, + service: McpService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + """Test asynchronously retrieving a specific MCP server.""" + mock_server = { + "id": "server-id-1", + "name": "Async Test Server", + "slug": "async-test-server", + "description": "Async test server", + "version": "1.0.0", + "createdAt": "2025-07-24T11:30:52.031427", + "updatedAt": "2025-07-24T12:29:53.4765887", + "isActive": True, + "type": 2, + "status": 1, + "command": "", + "arguments": "", + "environmentVariables": "", + "processKey": "test-process-key", + "folderKey": "test-folder-key", + "runtimesCount": 0, + "mcpUrl": "https://test.com/mcp/async-test-server", + } + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/api/FoldersNavigation/GetFoldersForCurrentUser?searchText=test-folder-path&skip=0&take=20", + status_code=200, + json={ + "PageItems": [ + { + "Key": "test-folder-key", + "FullyQualifiedName": "test-folder-path", + } + ], + }, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/agenthub_/api/servers/async-test-server", + status_code=200, + json=mock_server, + ) + + server = await service.retrieve_async( + "async-test-server", folder_path="test-folder-path" + ) + + assert isinstance(server, McpServer) + assert server.name == "Async Test Server" + + requests = httpx_mock.get_requests() + assert len(requests) == 2 + + retrieve_request = requests[1] + assert retrieve_request.method == "GET" + assert ( + retrieve_request.url + == f"{base_url}{org}{tenant}/agenthub_/api/servers/async-test-server" + ) + assert HEADER_FOLDER_KEY in retrieve_request.headers + assert retrieve_request.headers[HEADER_FOLDER_KEY] == "test-folder-key" + assert HEADER_USER_AGENT in retrieve_request.headers + assert ( + retrieve_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.McpService.retrieve_async/{version}" + ) + + class TestRequestKwargs: + """Test that all methods pass the correct kwargs to request/request_async.""" + + def test_list_passes_all_kwargs(self, service: McpService) -> None: + """Test that list passes all kwargs to request.""" + mock_response = Mock() + mock_response.json.return_value = [ + { + "id": "test-id", + "name": "Test Server", + "slug": "test-server", + "description": "Test", + "version": "1.0.0", + "createdAt": "2025-07-24T11:30:52.031427", + "updatedAt": "2025-07-24T12:29:53.4765887", + "isActive": True, + "type": 2, + "status": 1, + "processKey": "test-process-key", + "folderKey": "test-folder-key", + "mcpUrl": "https://test.com/mcp/test", + } + ] + + with patch.object( + service._folders_service, + "retrieve_folder_key", + return_value="test-folder-key", + ): + with patch.object( + service, "request", return_value=mock_response + ) as mock_request: + service.list(folder_path="test-folder-path") + + mock_request.assert_called_once() + call_kwargs = mock_request.call_args + + assert "url" in call_kwargs.kwargs + assert "params" in call_kwargs.kwargs + assert "headers" in call_kwargs.kwargs + + assert call_kwargs.args[0] == "GET" + + assert HEADER_FOLDER_KEY in call_kwargs.kwargs["headers"] + assert ( + call_kwargs.kwargs["headers"][HEADER_FOLDER_KEY] + == "test-folder-key" + ) + + @pytest.mark.anyio + async def test_list_async_passes_all_kwargs(self, service: McpService) -> None: + """Test that list_async passes all kwargs to request_async.""" + mock_response = Mock() + mock_response.json.return_value = [ + { + "id": "test-id", + "name": "Test Server", + "slug": "test-server", + "description": "Test", + "version": "1.0.0", + "createdAt": "2025-07-24T11:30:52.031427", + "updatedAt": "2025-07-24T12:29:53.4765887", + "isActive": True, + "type": 2, + "status": 1, + "processKey": "test-process-key", + "folderKey": "test-folder-key", + "mcpUrl": "https://test.com/mcp/test", + } + ] + + with patch.object( + service._folders_service, + "retrieve_folder_key", + return_value="test-folder-key", + ): + with patch.object( + service, "request_async", return_value=mock_response + ) as mock_request: + await service.list_async(folder_path="test-folder-path") + + mock_request.assert_called_once() + call_kwargs = mock_request.call_args + + assert "url" in call_kwargs.kwargs + assert "params" in call_kwargs.kwargs + assert "headers" in call_kwargs.kwargs + + assert call_kwargs.args[0] == "GET" + + assert HEADER_FOLDER_KEY in call_kwargs.kwargs["headers"] + assert ( + call_kwargs.kwargs["headers"][HEADER_FOLDER_KEY] + == "test-folder-key" + ) + + def test_retrieve_passes_all_kwargs(self, service: McpService) -> None: + """Test that retrieve passes all kwargs to request.""" + mock_response = Mock() + mock_response.json.return_value = { + "id": "test-id", + "name": "Test Server", + "slug": "test-server", + "description": "Test", + "version": "1.0.0", + "createdAt": "2025-07-24T11:30:52.031427", + "updatedAt": "2025-07-24T12:29:53.4765887", + "isActive": True, + "type": 2, + "status": 1, + "processKey": "test-process-key", + "folderKey": "test-folder-key", + "mcpUrl": "https://test.com/mcp/test", + } + + with patch.object( + service._folders_service, + "retrieve_folder_key", + return_value="test-folder-key", + ): + with patch.object( + service, "request", return_value=mock_response + ) as mock_request: + service.retrieve("test-server", folder_path="test-folder-path") + + mock_request.assert_called_once() + call_kwargs = mock_request.call_args + + assert "url" in call_kwargs.kwargs + assert "params" in call_kwargs.kwargs + assert "headers" in call_kwargs.kwargs + + assert call_kwargs.args[0] == "GET" + + assert HEADER_FOLDER_KEY in call_kwargs.kwargs["headers"] + assert ( + call_kwargs.kwargs["headers"][HEADER_FOLDER_KEY] + == "test-folder-key" + ) + + @pytest.mark.anyio + async def test_retrieve_async_passes_all_kwargs( + self, service: McpService + ) -> None: + """Test that retrieve_async passes all kwargs to request_async.""" + mock_response = Mock() + mock_response.json.return_value = { + "id": "test-id", + "name": "Test Server", + "slug": "test-server", + "description": "Test", + "version": "1.0.0", + "createdAt": "2025-07-24T11:30:52.031427", + "updatedAt": "2025-07-24T12:29:53.4765887", + "isActive": True, + "type": 2, + "status": 1, + "processKey": "test-process-key", + "folderKey": "test-folder-key", + "mcpUrl": "https://test.com/mcp/test", + } + + with patch.object( + service._folders_service, + "retrieve_folder_key", + return_value="test-folder-key", + ): + with patch.object( + service, "request_async", return_value=mock_response + ) as mock_request: + await service.retrieve_async( + "test-server", folder_path="test-folder-path" + ) + + mock_request.assert_called_once() + call_kwargs = mock_request.call_args + + assert "url" in call_kwargs.kwargs + assert "params" in call_kwargs.kwargs + assert "headers" in call_kwargs.kwargs + + assert call_kwargs.args[0] == "GET" + + assert HEADER_FOLDER_KEY in call_kwargs.kwargs["headers"] + assert ( + call_kwargs.kwargs["headers"][HEADER_FOLDER_KEY] + == "test-folder-key" + ) diff --git a/packages/uipath-platform/tests/services/test_processes_service.py b/packages/uipath-platform/tests/services/test_processes_service.py new file mode 100644 index 000000000..85b2e3691 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_processes_service.py @@ -0,0 +1,473 @@ +import json +import uuid + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.orchestrator import Job +from uipath.platform.orchestrator._attachments_service import AttachmentsService +from uipath.platform.orchestrator._processes_service import ProcessesService + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> ProcessesService: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + attachments_service = AttachmentsService( + config=config, execution_context=execution_context + ) + return ProcessesService( + config=config, + execution_context=execution_context, + attachment_service=attachments_service, + ) + + +class TestProcessesService: + def test_invoke( + self, + httpx_mock: HTTPXMock, + service: ProcessesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + process_name = "test-process" + input_arguments = {"key": "value"} + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", + status_code=200, + json={ + "value": [ + { + "Key": "test-job-key", + "State": "Running", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + ] + }, + ) + + job = service.invoke(process_name, input_arguments) + + assert isinstance(job, Job) + assert job.key == "test-job-key" + assert job.state == "Running" + assert job.start_time == "2024-01-01T00:00:00Z" + assert job.id == 123 + assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" + ) + assert sent_request.content.decode("utf-8") == json.dumps( + { + "startInfo": { + "ReleaseName": process_name, + "InputArguments": json.dumps(input_arguments), + } + }, + separators=(",", ":"), + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke/{version}" + ) + + def test_invoke_without_input_arguments( + self, + httpx_mock: HTTPXMock, + service: ProcessesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + process_name = "test-process" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", + status_code=200, + json={ + "value": [ + { + "Key": "test-job-key", + "State": "Running", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + ] + }, + ) + + job = service.invoke(process_name) + + assert isinstance(job, Job) + assert job.key == "test-job-key" + assert job.state == "Running" + assert job.start_time == "2024-01-01T00:00:00Z" + assert job.id == 123 + assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" + ) + assert sent_request.content.decode("utf-8") == json.dumps( + { + "startInfo": { + "ReleaseName": process_name, + "InputArguments": "{}", + } + }, + separators=(",", ":"), + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke/{version}" + ) + + def test_invoke_over_10k_limit_input( + self, + httpx_mock: HTTPXMock, + service: ProcessesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + process_name = "test-process" + # Create input arguments that exceed 10k characters + large_text = "a" * 10001 + input_arguments = {"large_text": large_text} + + test_attachment_id = uuid.uuid4() + blob_uri = "https://test-storage.com/test-container/test-blob" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=201, + json={ + "Id": str(test_attachment_id), + "Name": "test-input.json", + "BlobFileAccess": { + "Uri": blob_uri, + "Headers": { + "Keys": ["x-ms-blob-type", "Content-Type"], + "Values": ["BlockBlob", "application/json"], + }, + "RequiresAuth": False, + }, + }, + ) + + httpx_mock.add_response( + url=blob_uri, + method="PUT", + status_code=201, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", + status_code=200, + json={ + "value": [ + { + "Key": "test-job-key", + "State": "Running", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + ] + }, + ) + + job = service.invoke(process_name, input_arguments) + + assert isinstance(job, Job) + assert job.key == "test-job-key" + assert job.state == "Running" + assert job.start_time == "2024-01-01T00:00:00Z" + assert job.id == 123 + assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" + + # attachment creation, blob upload, job start + requests = httpx_mock.get_requests() + assert len(requests) == 3 + + attachment_request = requests[0] + assert attachment_request.method == "POST" + assert "Attachments" in str(attachment_request.url) + + blob_request = requests[1] + assert blob_request.method == "PUT" + assert blob_request.url == blob_uri + + job_request = requests[2] + assert job_request.method == "POST" + assert ( + job_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" + ) + + # verify InputFile is used + job_content = json.loads(job_request.content.decode("utf-8").replace("'", '"')) + assert "startInfo" in job_content + assert "ReleaseName" in job_content["startInfo"] + assert job_content["startInfo"]["ReleaseName"] == process_name + assert "InputFile" in job_content["startInfo"] + assert "InputArguments" not in job_content["startInfo"] + assert job_content["startInfo"]["InputFile"] == str(test_attachment_id) + + assert HEADER_USER_AGENT in job_request.headers + assert ( + job_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke/{version}" + ) + + @pytest.mark.asyncio + async def test_invoke_async( + self, + httpx_mock: HTTPXMock, + service: ProcessesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + process_name = "test-process" + input_arguments = {"key": "value"} + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", + status_code=200, + json={ + "value": [ + { + "Key": "test-job-key", + "State": "Running", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + ] + }, + ) + + job = await service.invoke_async(process_name, input_arguments) + + assert isinstance(job, Job) + assert job.key == "test-job-key" + assert job.state == "Running" + assert job.start_time == "2024-01-01T00:00:00Z" + assert job.id == 123 + assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" + ) + assert sent_request.content.decode("utf-8") == json.dumps( + { + "startInfo": { + "ReleaseName": process_name, + "InputArguments": json.dumps(input_arguments), + } + }, + separators=(",", ":"), + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke_async/{version}" + ) + + @pytest.mark.asyncio + async def test_invoke_async_without_input_arguments( + self, + httpx_mock: HTTPXMock, + service: ProcessesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + process_name = "test-process" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", + status_code=200, + json={ + "value": [ + { + "Key": "test-job-key", + "State": "Running", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + ] + }, + ) + + job = await service.invoke_async(process_name) + + assert isinstance(job, Job) + assert job.key == "test-job-key" + assert job.state == "Running" + assert job.start_time == "2024-01-01T00:00:00Z" + assert job.id == 123 + assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" + ) + assert sent_request.content.decode("utf-8") == json.dumps( + { + "startInfo": { + "ReleaseName": process_name, + "InputArguments": "{}", + } + }, + separators=(",", ":"), + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke_async/{version}" + ) + + @pytest.mark.asyncio + async def test_invoke_async_over_10k_limit_input( + self, + httpx_mock: HTTPXMock, + service: ProcessesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + process_name = "test-process" + # Create input arguments that exceed 10k characters + large_text = "a" * 10001 + input_arguments = {"large_text": large_text} + + test_attachment_id = uuid.uuid4() + blob_uri = "https://test-storage.com/test-container/test-blob" + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Attachments", + method="POST", + status_code=201, + json={ + "Id": str(test_attachment_id), + "Name": "test-input.json", + "BlobFileAccess": { + "Uri": blob_uri, + "Headers": { + "Keys": ["x-ms-blob-type", "Content-Type"], + "Values": ["BlockBlob", "application/json"], + }, + "RequiresAuth": False, + }, + }, + ) + + httpx_mock.add_response( + url=blob_uri, + method="PUT", + status_code=201, + ) + + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs", + status_code=200, + json={ + "value": [ + { + "Key": "test-job-key", + "State": "Running", + "StartTime": "2024-01-01T00:00:00Z", + "Id": 123, + "FolderKey": "d0e09040-5997-44e1-93b7-4087689521b7", + } + ] + }, + ) + + job = await service.invoke_async(process_name, input_arguments) + + assert isinstance(job, Job) + assert job.key == "test-job-key" + assert job.state == "Running" + assert job.start_time == "2024-01-01T00:00:00Z" + assert job.id == 123 + assert job.folder_key == "d0e09040-5997-44e1-93b7-4087689521b7" + + # attachment creation, blob upload, job start + requests = httpx_mock.get_requests() + assert len(requests) == 3 + + attachment_request = requests[0] + assert attachment_request.method == "POST" + assert "Attachments" in str(attachment_request.url) + + blob_request = requests[1] + assert blob_request.method == "PUT" + assert blob_request.url == blob_uri + + job_request = requests[2] + assert job_request.method == "POST" + assert ( + job_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Jobs/UiPath.Server.Configuration.OData.StartJobs" + ) + + # verify InputFile is used + job_content = json.loads(job_request.content.decode("utf-8").replace("'", '"')) + assert "startInfo" in job_content + assert "ReleaseName" in job_content["startInfo"] + assert job_content["startInfo"]["ReleaseName"] == process_name + assert "InputFile" in job_content["startInfo"] + assert "InputArguments" not in job_content["startInfo"] + assert job_content["startInfo"]["InputFile"] == str(test_attachment_id) + + assert HEADER_USER_AGENT in job_request.headers + assert ( + job_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ProcessesService.invoke_async/{version}" + ) diff --git a/packages/uipath-platform/tests/services/test_queues_service.py b/packages/uipath-platform/tests/services/test_queues_service.py new file mode 100644 index 000000000..a70c27c59 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_queues_service.py @@ -0,0 +1,808 @@ +import json + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.orchestrator import ( + CommitType, + QueueItem, + QueueItemPriority, + TransactionItem, + TransactionItemResult, +) +from uipath.platform.orchestrator._queues_service import QueuesService + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + monkeypatch: pytest.MonkeyPatch, +) -> QueuesService: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + return QueuesService(config=config, execution_context=execution_context) + + +class TestQueuesService: + def test_list_items( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems", + status_code=200, + json={ + "value": [ + { + "Id": 1, + "Name": "test-queue", + "Priority": "High", + } + ] + }, + ) + + response = service.list_items() + + assert response["value"][0]["Id"] == 1 + assert response["value"][0]["Name"] == "test-queue" + assert response["value"][0]["Priority"] == "High" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.list_items/{version}" + ) + + @pytest.mark.asyncio + async def test_list_items_async( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems", + status_code=200, + json={ + "value": [ + { + "Id": 1, + "Name": "test-queue", + "Priority": "High", + } + ] + }, + ) + + response = await service.list_items_async() + + assert response["value"][0]["Id"] == 1 + assert response["value"][0]["Name"] == "test-queue" + assert response["value"][0]["Priority"] == "High" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems" + ) + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.list_items_async/{version}" + ) + + def test_create_item( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + 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", + "Priority": "High", + "SpecificContent": {"key": "value"}, + }, + ) + + response = service.create_item(queue_item) + + assert response["Id"] == 1 + assert response["Name"] == "test-queue" + assert response["Priority"] == "High" + assert response["SpecificContent"] == {"key": "value"} + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + 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"}, + } + } + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_item/{version}" + ) + + @pytest.mark.asyncio + async def test_create_item_async( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + 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", + "Priority": "High", + "SpecificContent": {"key": "value"}, + }, + ) + + response = await service.create_item_async(queue_item) + + assert response["Id"] == 1 + assert response["Name"] == "test-queue" + assert response["Priority"] == "High" + assert response["SpecificContent"] == {"key": "value"} + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + 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"}, + } + } + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_item_async/{version}" + ) + + def test_create_items( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + queue_items = [ + QueueItem( + name="test-queue", + priority=QueueItemPriority.HIGH, + specific_content={"key": "value"}, + ), + QueueItem( + name="test-queue", + priority=QueueItemPriority.LOW, + specific_content={"key2": "value2"}, + ), + ] + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.BulkAddQueueItems", + status_code=200, + json={ + "value": [ + { + "Id": 1, + "Name": "test-queue", + "Priority": "High", + "SpecificContent": {"key": "value"}, + }, + { + "Id": 2, + "Name": "test-queue", + "Priority": "Low", + "SpecificContent": {"key2": "value2"}, + }, + ] + }, + ) + + response = service.create_items( + queue_items, "test-queue", CommitType.ALL_OR_NOTHING + ) + + assert len(response["value"]) == 2 + assert response["value"][0]["Id"] == 1 + assert response["value"][0]["Name"] == "test-queue" + assert response["value"][0]["Priority"] == "High" + assert response["value"][0]["SpecificContent"] == {"key": "value"} + assert response["value"][1]["Id"] == 2 + assert response["value"][1]["Name"] == "test-queue" + assert response["value"][1]["Priority"] == "Low" + assert response["value"][1]["SpecificContent"] == {"key2": "value2"} + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.BulkAddQueueItems" + ) + assert json.loads(sent_request.content.decode()) == { + "queueName": "test-queue", + "commitType": "AllOrNothing", + "queueItems": [ + { + "Name": "test-queue", + "Priority": "High", + "SpecificContent": {"key": "value"}, + }, + { + "Name": "test-queue", + "Priority": "Low", + "SpecificContent": {"key2": "value2"}, + }, + ], + } + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_items/{version}" + ) + + @pytest.mark.asyncio + async def test_create_items_async( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + queue_items = [ + QueueItem( + name="test-queue", + priority=QueueItemPriority.HIGH, + specific_content={"key": "value"}, + ), + QueueItem( + name="test-queue", + priority=QueueItemPriority.LOW, + specific_content={"key2": "value2"}, + ), + ] + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.BulkAddQueueItems", + status_code=200, + json={ + "value": [ + { + "Id": 1, + "Name": "test-queue", + "Priority": "High", + "SpecificContent": {"key": "value"}, + }, + { + "Id": 2, + "Name": "test-queue", + "Priority": "Low", + "SpecificContent": {"key2": "value2"}, + }, + ] + }, + ) + + response = await service.create_items_async( + queue_items, "test-queue", CommitType.ALL_OR_NOTHING + ) + + assert len(response["value"]) == 2 + assert response["value"][0]["Id"] == 1 + assert response["value"][0]["Name"] == "test-queue" + assert response["value"][0]["Priority"] == "High" + assert response["value"][0]["SpecificContent"] == {"key": "value"} + assert response["value"][1]["Id"] == 2 + assert response["value"][1]["Name"] == "test-queue" + assert response["value"][1]["Priority"] == "Low" + assert response["value"][1]["SpecificContent"] == {"key2": "value2"} + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.BulkAddQueueItems" + ) + assert json.loads(sent_request.content.decode()) == { + "queueName": "test-queue", + "commitType": "AllOrNothing", + "queueItems": [ + { + "Name": "test-queue", + "Priority": "High", + "SpecificContent": {"key": "value"}, + }, + { + "Name": "test-queue", + "Priority": "Low", + "SpecificContent": {"key2": "value2"}, + }, + ], + } + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_items_async/{version}" + ) + + def test_create_item_with_reference( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + reference_value = "TEST-REF-12345" + queue_item = QueueItem( + name="test-queue", + reference=reference_value, + priority=QueueItemPriority.HIGH, + specific_content={"invoice_id": "INV-001"}, + ) + 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": reference_value, + "Priority": "High", + "SpecificContent": {"invoice_id": "INV-001"}, + }, + ) + + response = service.create_item(queue_item) + + assert response["Id"] == 1 + assert response["Name"] == "test-queue" + assert response["Reference"] == reference_value + assert response["Priority"] == "High" + assert response["SpecificContent"] == {"invoice_id": "INV-001"} + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem" + ) + assert json.loads(sent_request.content.decode()) == { + "itemData": { + "Name": "test-queue", + "Reference": reference_value, + "Priority": "High", + "SpecificContent": {"invoice_id": "INV-001"}, + } + } + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_item/{version}" + ) + + @pytest.mark.asyncio + async def test_create_item_with_reference_async( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + reference_value = "TEST-REF-12345" + queue_item = QueueItem( + name="test-queue", + reference=reference_value, + priority=QueueItemPriority.HIGH, + specific_content={"invoice_id": "INV-001"}, + ) + 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": reference_value, + "Priority": "High", + "SpecificContent": {"invoice_id": "INV-001"}, + }, + ) + + response = await service.create_item_async(queue_item) + + assert response["Id"] == 1 + assert response["Name"] == "test-queue" + assert response["Reference"] == reference_value + assert response["Priority"] == "High" + assert response["SpecificContent"] == {"invoice_id": "INV-001"} + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.AddQueueItem" + ) + assert json.loads(sent_request.content.decode()) == { + "itemData": { + "Name": "test-queue", + "Reference": reference_value, + "Priority": "High", + "SpecificContent": {"invoice_id": "INV-001"}, + } + } + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_item_async/{version}" + ) + + def test_create_transaction_item( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + transaction_item = TransactionItem( + name="test-queue", + specific_content={"key": "value"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction", + status_code=200, + json={ + "Id": 1, + "Name": "test-queue", + "SpecificContent": {"key": "value"}, + }, + ) + + response = service.create_transaction_item(transaction_item) + + assert response["Id"] == 1 + assert response["Name"] == "test-queue" + assert response["SpecificContent"] == {"key": "value"} + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction" + ) + assert json.loads(sent_request.content.decode()) == { + "transactionData": { + "Name": "test-queue", + "RobotIdentifier": "test-robot-key", + "SpecificContent": {"key": "value"}, + } + } + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_transaction_item/{version}" + ) + + @pytest.mark.asyncio + async def test_create_transaction_item_async( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + transaction_item = TransactionItem( + name="test-queue", + specific_content={"key": "value"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction", + status_code=200, + json={ + "Id": 1, + "Name": "test-queue", + "SpecificContent": {"key": "value"}, + }, + ) + + response = await service.create_transaction_item_async(transaction_item) + + assert response["Id"] == 1 + assert response["Name"] == "test-queue" + assert response["SpecificContent"] == {"key": "value"} + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues/UiPathODataSvc.StartTransaction" + ) + assert json.loads(sent_request.content.decode()) == { + "transactionData": { + "Name": "test-queue", + "RobotIdentifier": "test-robot-key", + "SpecificContent": {"key": "value"}, + } + } + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.create_transaction_item_async/{version}" + ) + + def test_update_progress_of_transaction_item( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + transaction_key = "test-transaction-key" + progress = "Processing..." + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems({transaction_key})/UiPathODataSvc.SetTransactionProgress", + status_code=200, + json={"status": "success"}, + ) + + response = service.update_progress_of_transaction_item( + transaction_key, progress + ) + + assert response["status"] == "success" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems({transaction_key})/UiPathODataSvc.SetTransactionProgress" + ) + assert json.loads(sent_request.content.decode()) == {"progress": progress} + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.update_progress_of_transaction_item/{version}" + ) + + @pytest.mark.asyncio + async def test_update_progress_of_transaction_item_async( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + transaction_key = "test-transaction-key" + progress = "Processing..." + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems({transaction_key})/UiPathODataSvc.SetTransactionProgress", + status_code=200, + json={"status": "success"}, + ) + + response = await service.update_progress_of_transaction_item_async( + transaction_key, progress + ) + + assert response["status"] == "success" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/QueueItems({transaction_key})/UiPathODataSvc.SetTransactionProgress" + ) + assert json.loads(sent_request.content.decode()) == {"progress": progress} + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.update_progress_of_transaction_item_async/{version}" + ) + + def test_complete_transaction_item( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + transaction_key = "test-transaction-key" + result = TransactionItemResult( + is_successful=True, + output={"result": "success"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues({transaction_key})/UiPathODataSvc.SetTransactionResult", + status_code=200, + json={"status": "success"}, + ) + + response = service.complete_transaction_item(transaction_key, result) + + assert response["status"] == "success" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues({transaction_key})/UiPathODataSvc.SetTransactionResult" + ) + assert json.loads(sent_request.content.decode()) == { + "transactionResult": { + "IsSuccessful": True, + "Output": {"result": "success"}, + } + } + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.complete_transaction_item/{version}" + ) + + @pytest.mark.asyncio + async def test_complete_transaction_item_async( + self, + httpx_mock: HTTPXMock, + service: QueuesService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + transaction_key = "test-transaction-key" + result = TransactionItemResult( + is_successful=True, + output={"result": "success"}, + ) + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/orchestrator_/odata/Queues({transaction_key})/UiPathODataSvc.SetTransactionResult", + status_code=200, + json={"status": "success"}, + ) + + response = await service.complete_transaction_item_async( + transaction_key, result + ) + + assert response["status"] == "success" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "POST" + assert ( + sent_request.url + == f"{base_url}{org}{tenant}/orchestrator_/odata/Queues({transaction_key})/UiPathODataSvc.SetTransactionResult" + ) + assert json.loads(sent_request.content.decode()) == { + "transactionResult": { + "IsSuccessful": True, + "Output": {"result": "success"}, + } + } + + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.QueuesService.complete_transaction_item_async/{version}" + ) diff --git a/packages/uipath-platform/tests/services/test_resource_catalog_service.py b/packages/uipath-platform/tests/services/test_resource_catalog_service.py new file mode 100644 index 000000000..3db6ee60e --- /dev/null +++ b/packages/uipath-platform/tests/services/test_resource_catalog_service.py @@ -0,0 +1,861 @@ +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pytest_httpx import HTTPXMock + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.common.constants import HEADER_USER_AGENT +from uipath.platform.orchestrator._folder_service import FolderService +from uipath.platform.resource_catalog import ResourceType +from uipath.platform.resource_catalog._resource_catalog_service import ( + ResourceCatalogService, +) + + +@pytest.fixture +def mock_folder_service() -> MagicMock: + """Mock FolderService for testing.""" + service = MagicMock(spec=FolderService) + service.retrieve_folder_key.return_value = "test-folder-key" + service.retrieve_folder_key_async = AsyncMock(return_value="test-folder-key") + return service + + +@pytest.fixture +def service( + config: UiPathApiConfig, + execution_context: UiPathExecutionContext, + mock_folder_service: MagicMock, + monkeypatch: pytest.MonkeyPatch, +) -> ResourceCatalogService: + monkeypatch.setenv("UIPATH_FOLDER_PATH", "test-folder-path") + return ResourceCatalogService( + config=config, + execution_context=execution_context, + folder_service=mock_folder_service, + ) + + +class TestResourceCatalogService: + @staticmethod + def _mock_response( + entity_id: str, + name: str, + entity_type: str, + entity_sub_type: str = "default", + description: str = "", + folder_key: str = "test-folder-key", + **extra_fields, + ) -> dict[str, Any]: + """Generate a mock Resource response.""" + response = { + "entityKey": entity_id, + "name": name, + "entityType": entity_type, + "entitySubType": entity_sub_type, + "description": description, + "scope": "Tenant", + "searchState": "Available", + "timestamp": "2024-01-01T00:00:00Z", + "folderKey": folder_key, + "folderKeys": [folder_key], + "tenantKey": "test-tenant-key", + "accountKey": "test-account-key", + "userKey": "test-user-key", + "tags": [], + "folders": [], + "linkedFoldersCount": 0, + "dependencies": [], + } + response.update(extra_fields) + return response + + class TestSearchResources: + def test_search_resources_with_name_filter( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/Search?skip=0&take=20&name=invoice", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="invoice-processor", + entity_type="process", + entity_sub_type="automation", + description="Process invoice documents", + ), + TestResourceCatalogService._mock_response( + entity_id="2", + name="invoice-queue", + entity_type="queue", + entity_sub_type="transactional", + description="Queue for invoice processing", + ), + ] + }, + ) + + resources = list(service.search(name="invoice")) + + assert len(resources) == 2 + assert resources[0].name == "invoice-processor" + assert resources[0].resource_type == "process" + assert resources[1].name == "invoice-queue" + assert resources[1].resource_type == "queue" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert "name=invoice" in str(sent_request.url) + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ResourceCatalogService.search/{version}" + ) + + def test_search_resources_with_resource_types_filter( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/Search?skip=0&take=20&entityTypes=asset&entityTypes=queue", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="3", + name="config-asset", + entity_type="asset", + entity_sub_type="text", + ), + TestResourceCatalogService._mock_response( + entity_id="4", + name="work-queue", + entity_type="queue", + entity_sub_type="transactional", + ), + ] + }, + ) + + resources = list( + service.search(resource_types=[ResourceType.ASSET, ResourceType.QUEUE]) + ) + + assert len(resources) == 2 + assert resources[0].resource_type == "asset" + assert resources[1].resource_type == "queue" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert "entityTypes=asset" in str( + sent_request.url + ) or "entityTypes%5B%5D=asset" in str(sent_request.url) + assert "entityTypes=queue" in str( + sent_request.url + ) or "entityTypes%5B%5D=queue" in str(sent_request.url) + + def test_search_resources_pagination( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + base_url: str, + org: str, + tenant: str, + ) -> None: + # First page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/Search?skip=0&take=2", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + "1", "resource-1", "asset" + ), + TestResourceCatalogService._mock_response( + "2", "resource-2", "queue" + ), + ] + }, + ) + # Second page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/Search?skip=2&take=2", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + "3", "resource-3", "process" + ), + ] + }, + ) + + resources = list(service.search(page_size=2)) + + assert len(resources) == 3 + assert resources[0].name == "resource-1" + assert resources[1].name == "resource-2" + assert resources[2].name == "resource-3" + + class TestListResources: + def test_list_resources_without_filters( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=0&take=20", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="test-asset", + entity_type="asset", + entity_sub_type="text", + ), + TestResourceCatalogService._mock_response( + entity_id="2", + name="test-queue", + entity_type="queue", + entity_sub_type="transactional", + ), + ] + }, + ) + + resources = list(service.list()) + + assert len(resources) == 2 + assert resources[0].name == "test-asset" + assert resources[1].name == "test-queue" + mock_folder_service.retrieve_folder_key.assert_called_once_with(None) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert str(sent_request.url).endswith("/Entities?skip=0&take=20") + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ResourceCatalogService.list/{version}" + ) + + def test_list_resources_with_folder_path( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=0&take=20&entityTypes=asset", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="finance-asset", + entity_type="asset", + entity_sub_type="number", + ) + ] + }, + ) + + resources = list( + service.list( + folder_path="/Shared/Finance", resource_types=[ResourceType.ASSET] + ) + ) + + assert len(resources) == 1 + assert resources[0].name == "finance-asset" + mock_folder_service.retrieve_folder_key.assert_called_once_with( + "/Shared/Finance" + ) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert "X-UIPATH-FolderKey" in sent_request.headers + + def test_list_resources_with_resource_filters( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=0&take=20&entityTypes=process&entityTypes=mcpserver&entitySubType=automation", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="automation-process", + entity_type="process", + entity_sub_type="automation", + ) + ] + }, + ) + + resources = list( + service.list( + resource_types=[ResourceType.PROCESS, ResourceType.MCP_SERVER], + resource_sub_types=["automation"], + ) + ) + + assert len(resources) == 1 + assert resources[0].resource_type == "process" + assert resources[0].resource_sub_type == "automation" + + def test_list_resources_pagination( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + ) -> None: + # First page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=0&take=3", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + "1", "resource-1", "asset" + ), + TestResourceCatalogService._mock_response( + "2", "resource-2", "queue" + ), + TestResourceCatalogService._mock_response( + "3", "resource-3", "process" + ), + ] + }, + ) + # Second page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=3&take=3", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + "4", "resource-4", "bucket" + ), + ] + }, + ) + + resources = list(service.list(page_size=3)) + + assert len(resources) == 4 + assert resources[0].name == "resource-1" + assert resources[3].name == "resource-4" + + def test_list_resources_invalid_page_size( + self, + service: ResourceCatalogService, + ) -> None: + with pytest.raises(ValueError, match="page_size must be greater than 0"): + list(service.list(page_size=0)) + + with pytest.raises(ValueError, match="page_size must be greater than 0"): + list(service.list(page_size=-1)) + + class TestListResourcesByType: + def test_list_by_type_basic( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + version: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/asset?skip=0&take=20", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="config-asset", + entity_type="asset", + entity_sub_type="text", + ), + TestResourceCatalogService._mock_response( + entity_id="2", + name="number-asset", + entity_type="asset", + entity_sub_type="number", + ), + ] + }, + ) + + resources = list(service.list_by_type(resource_type=ResourceType.ASSET)) + + assert len(resources) == 2 + assert resources[0].name == "config-asset" + assert resources[0].resource_type == "asset" + assert resources[1].name == "number-asset" + assert resources[1].resource_type == "asset" + mock_folder_service.retrieve_folder_key.assert_called_once_with(None) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert sent_request.method == "GET" + assert str(sent_request.url).endswith("/Entities/asset?skip=0&take=20") + assert HEADER_USER_AGENT in sent_request.headers + assert ( + sent_request.headers[HEADER_USER_AGENT] + == f"UiPath.Python.Sdk/UiPath.Python.Sdk.Activities.ResourceCatalogService.list_by_type/{version}" + ) + + def test_list_by_type_with_name_filter( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/process?skip=0&take=20&name=invoice", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="invoice-processor", + entity_type="process", + entity_sub_type="automation", + ) + ] + }, + ) + + resources = list( + service.list_by_type(resource_type=ResourceType.PROCESS, name="invoice") + ) + + assert len(resources) == 1 + assert resources[0].name == "invoice-processor" + assert resources[0].resource_type == "process" + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert "name=invoice" in str(sent_request.url) + + def test_list_by_type_with_folder_and_subtype( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/asset?skip=0&take=20&entitySubType=number", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="finance-number", + entity_type="asset", + entity_sub_type="number", + ) + ] + }, + ) + + resources = list( + service.list_by_type( + resource_type=ResourceType.ASSET, + folder_path="/Shared/Finance", + resource_sub_types=["number"], + ) + ) + + assert len(resources) == 1 + assert resources[0].resource_sub_type == "number" + mock_folder_service.retrieve_folder_key.assert_called_once_with( + "/Shared/Finance" + ) + + sent_request = httpx_mock.get_request() + if sent_request is None: + raise Exception("No request was sent") + + assert "entitySubType=number" in str(sent_request.url) + assert "X-UIPATH-FolderKey" in sent_request.headers + + def test_list_by_type_pagination( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + ) -> None: + # First page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/queue?skip=0&take=2", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + "1", "queue-1", "queue" + ), + TestResourceCatalogService._mock_response( + "2", "queue-2", "queue" + ), + ] + }, + ) + # Second page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/queue?skip=2&take=2", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + "3", "queue-3", "queue" + ), + ] + }, + ) + + resources = list( + service.list_by_type(resource_type=ResourceType.QUEUE, page_size=2) + ) + + assert len(resources) == 3 + assert all(r.resource_type == "queue" for r in resources) + + def test_list_by_type_invalid_page_size( + self, + service: ResourceCatalogService, + ) -> None: + with pytest.raises(ValueError, match="page_size must be greater than 0"): + list( + service.list_by_type(resource_type=ResourceType.ASSET, page_size=0) + ) + + with pytest.raises(ValueError, match="page_size must be greater than 0"): + list( + service.list_by_type(resource_type=ResourceType.ASSET, page_size=-1) + ) + + class TestAsyncMethods: + @pytest.mark.asyncio + async def test_search_async( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/Search?skip=0&take=20&name=test", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="test-resource", + entity_type="asset", + ) + ] + }, + ) + + resources = [] + async for resource in service.search_async(name="test"): + resources.append(resource) + + assert len(resources) == 1 + assert resources[0].name == "test-resource" + + @pytest.mark.asyncio + async def test_list_resources_async( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=0&take=20", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="async-resource", + entity_type="queue", + ) + ] + }, + ) + + resources = [] + async for resource in service.list_async(): + resources.append(resource) + + assert len(resources) == 1 + assert resources[0].name == "async-resource" + mock_folder_service.retrieve_folder_key_async.assert_called_once_with(None) + + @pytest.mark.asyncio + async def test_list_resources_async_with_filters( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities?skip=0&take=20&entityTypes=asset&entitySubType=text", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="text-asset", + entity_type="asset", + entity_sub_type="text", + ) + ] + }, + ) + + resources = [] + async for resource in service.list_async( + resource_types=[ResourceType.ASSET], + resource_sub_types=["text"], + folder_path="/Test/Folder", + ): + resources.append(resource) + + assert len(resources) == 1 + assert resources[0].resource_sub_type == "text" + mock_folder_service.retrieve_folder_key_async.assert_called_once_with( + "/Test/Folder" + ) + + @pytest.mark.asyncio + async def test_list_by_type_async_basic( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/asset?skip=0&take=20", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="async-asset-1", + entity_type="asset", + entity_sub_type="text", + ), + TestResourceCatalogService._mock_response( + entity_id="2", + name="async-asset-2", + entity_type="asset", + entity_sub_type="number", + ), + ] + }, + ) + + resources = [] + async for resource in service.list_by_type_async( + resource_type=ResourceType.ASSET + ): + resources.append(resource) + + assert len(resources) == 2 + assert resources[0].name == "async-asset-1" + assert resources[0].resource_type == "asset" + assert resources[1].name == "async-asset-2" + assert resources[1].resource_type == "asset" + mock_folder_service.retrieve_folder_key_async.assert_called_once_with(None) + + @pytest.mark.asyncio + async def test_list_by_type_async_with_name_filter( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/process?skip=0&take=20&name=workflow", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="workflow-processor", + entity_type="process", + entity_sub_type="automation", + ) + ] + }, + ) + + resources = [] + async for resource in service.list_by_type_async( + resource_type=ResourceType.PROCESS, name="workflow" + ): + resources.append(resource) + + assert len(resources) == 1 + assert resources[0].name == "workflow-processor" + assert resources[0].resource_type == "process" + + @pytest.mark.asyncio + async def test_list_by_type_async_with_folder_and_subtype( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + ) -> None: + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/queue?skip=0&take=20&entitySubType=transactional", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + entity_id="1", + name="transactional-queue", + entity_type="queue", + entity_sub_type="transactional", + ) + ] + }, + ) + + resources = [] + async for resource in service.list_by_type_async( + resource_type=ResourceType.QUEUE, + folder_path="/Production", + resource_sub_types=["transactional"], + ): + resources.append(resource) + + assert len(resources) == 1 + assert resources[0].resource_sub_type == "transactional" + mock_folder_service.retrieve_folder_key_async.assert_called_once_with( + "/Production" + ) + + @pytest.mark.asyncio + async def test_list_by_type_async_pagination( + self, + httpx_mock: HTTPXMock, + service: ResourceCatalogService, + mock_folder_service: MagicMock, + base_url: str, + org: str, + tenant: str, + ) -> None: + # First page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/bucket?skip=0&take=2", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + "1", "bucket-1", "bucket" + ), + TestResourceCatalogService._mock_response( + "2", "bucket-2", "bucket" + ), + ] + }, + ) + # Second page + httpx_mock.add_response( + url=f"{base_url}{org}{tenant}/resourcecatalog_/Entities/bucket?skip=2&take=2", + status_code=200, + json={ + "value": [ + TestResourceCatalogService._mock_response( + "3", "bucket-3", "bucket" + ), + ] + }, + ) + + resources = [] + async for resource in service.list_by_type_async( + resource_type=ResourceType.BUCKET, page_size=2 + ): + resources.append(resource) + + assert len(resources) == 3 + assert all(r.resource_type == "bucket" for r in resources) diff --git a/packages/uipath-platform/tests/services/test_retry.py b/packages/uipath-platform/tests/services/test_retry.py new file mode 100644 index 000000000..ce27ef823 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_retry.py @@ -0,0 +1,253 @@ +import httpx +from tenacity import Future, RetryCallState, Retrying + +from uipath.platform.common.retry import ( + _MAX_BACKOFF_DELAY, + _MAX_RETRY_AFTER_DELAY, + RETRYABLE_STATUS_CODES, + exponential_backoff_with_jitter, + extract_retry_after_from_chain, + is_retryable_platform_exception, + is_retryable_response, + parse_retry_after, + platform_wait_strategy, +) +from uipath.platform.errors import EnrichedException + + +class TestRetryableStatusCodes: + def test_contains_expected_codes(self): + assert 408 in RETRYABLE_STATUS_CODES + assert 429 in RETRYABLE_STATUS_CODES + assert 502 in RETRYABLE_STATUS_CODES + assert 503 in RETRYABLE_STATUS_CODES + assert 504 in RETRYABLE_STATUS_CODES + + def test_does_not_contain_non_retryable(self): + assert 400 not in RETRYABLE_STATUS_CODES + assert 401 not in RETRYABLE_STATUS_CODES + assert 403 not in RETRYABLE_STATUS_CODES + assert 404 not in RETRYABLE_STATUS_CODES + assert 500 not in RETRYABLE_STATUS_CODES + assert 501 not in RETRYABLE_STATUS_CODES + + def test_is_frozenset(self): + assert isinstance(RETRYABLE_STATUS_CODES, frozenset) + + +class TestParseRetryAfter: + def test_valid_integer(self): + assert parse_retry_after("120") == 120.0 + + def test_valid_float(self): + assert parse_retry_after("1.5") == 1.5 + + def test_zero(self): + assert parse_retry_after("0") == 0.0 + + def test_whitespace(self): + assert parse_retry_after(" 30 ") == 30.0 + + def test_negative_returns_none(self): + assert parse_retry_after("-1") is None + + def test_non_numeric_returns_none(self): + assert parse_retry_after("not-a-number") is None + + def test_http_date_returns_none(self): + assert parse_retry_after("Wed, 21 Oct 2015 07:28:00 GMT") is None + + def test_empty_string_returns_none(self): + assert parse_retry_after("") is None + + +class TestExponentialBackoffWithJitter: + def test_first_attempt(self): + result = exponential_backoff_with_jitter(attempt=1, initial=1.0) + # 1.0 * 2^0 + jitter(0,1) = 1.0 + [0, 1) + assert 1.0 <= result < 2.0 + + def test_second_attempt(self): + result = exponential_backoff_with_jitter(attempt=2, initial=1.0) + # 1.0 * 2^1 + jitter(0,1) = 2.0 + [0, 1) + assert 2.0 <= result < 3.0 + + def test_third_attempt(self): + result = exponential_backoff_with_jitter(attempt=3, initial=1.0) + # 1.0 * 2^2 + jitter(0,1) = 4.0 + [0, 1) + assert 4.0 <= result < 5.0 + + def test_growth_pattern(self): + # With large enough initial, growth dominates jitter + results = [ + exponential_backoff_with_jitter(attempt=i, initial=10.0) + for i in range(1, 5) + ] + # Each base should roughly double (jitter is <=1.0, base starts at 10) + for i in range(1, len(results)): + assert results[i] > results[i - 1] + + def test_custom_initial(self): + result = exponential_backoff_with_jitter(attempt=1, initial=5.0) + assert 5.0 <= result < 6.0 + + +class TestExtractRetryAfterFromChain: + @staticmethod + def _make_http_status_error( + status_code: int, retry_after: str | None = None + ) -> httpx.HTTPStatusError: + headers = {} + if retry_after is not None: + headers["retry-after"] = retry_after + response = httpx.Response( + status_code=status_code, + headers=headers, + request=httpx.Request("GET", "https://example.com"), + ) + return httpx.HTTPStatusError( + message=f"{status_code}", request=response.request, response=response + ) + + def test_direct_http_status_error(self): + err = self._make_http_status_error(429, retry_after="30") + assert extract_retry_after_from_chain(err) == 30.0 + + def test_enriched_exception_wrapping(self): + http_err = self._make_http_status_error(429, retry_after="60") + enriched = EnrichedException(http_err) + enriched.__cause__ = http_err + assert extract_retry_after_from_chain(enriched) == 60.0 + + def test_missing_header(self): + err = self._make_http_status_error(500) + assert extract_retry_after_from_chain(err) is None + + def test_unrelated_exception(self): + err = ValueError("something unrelated") + assert extract_retry_after_from_chain(err) is None + + def test_zero_retry_after(self): + err = self._make_http_status_error(429, retry_after="0") + assert extract_retry_after_from_chain(err) == 0.0 + + def test_negative_retry_after_ignored(self): + err = self._make_http_status_error(429, retry_after="-5") + assert extract_retry_after_from_chain(err) is None + + +def _make_http_status_error( + status_code: int, retry_after: str | None = None +) -> httpx.HTTPStatusError: + headers = {} + if retry_after is not None: + headers["retry-after"] = retry_after + response = httpx.Response( + status_code=status_code, + headers=headers, + request=httpx.Request("GET", "https://example.com"), + ) + return httpx.HTTPStatusError( + message=f"{status_code}", request=response.request, response=response + ) + + +def _make_retry_state(exception: BaseException, attempt: int = 1) -> RetryCallState: + rs = RetryCallState(Retrying(), None, None, None) + rs.attempt_number = attempt + f = Future(attempt) + f.set_exception(exception) + rs.outcome = f + return rs + + +class TestIsRetryablePlatformException: + def test_connect_timeout(self): + err = httpx.ConnectTimeout("timed out") + assert is_retryable_platform_exception(err) is True + + def test_timeout_exception(self): + err = httpx.TimeoutException("timed out") + assert is_retryable_platform_exception(err) is True + + def test_enriched_429(self): + http_err = _make_http_status_error(429) + err = EnrichedException(http_err) + assert is_retryable_platform_exception(err) is True + + def test_enriched_503(self): + http_err = _make_http_status_error(503) + err = EnrichedException(http_err) + assert is_retryable_platform_exception(err) is True + + def test_enriched_400_not_retryable(self): + http_err = _make_http_status_error(400) + err = EnrichedException(http_err) + assert is_retryable_platform_exception(err) is False + + def test_enriched_500_not_retryable(self): + http_err = _make_http_status_error(500) + err = EnrichedException(http_err) + assert is_retryable_platform_exception(err) is False + + def test_raw_http_error_not_matched(self): + err = _make_http_status_error(429) + assert is_retryable_platform_exception(err) is False + + def test_unrelated_exception_not_retryable(self): + assert is_retryable_platform_exception(ValueError("nope")) is False + + +class TestIsRetryableResponse: + def test_500(self): + assert is_retryable_response(httpx.Response(500)) is True + + def test_502(self): + assert is_retryable_response(httpx.Response(502)) is True + + def test_503(self): + assert is_retryable_response(httpx.Response(503)) is True + + def test_599(self): + assert is_retryable_response(httpx.Response(599)) is True + + def test_200_not_retryable(self): + assert is_retryable_response(httpx.Response(200)) is False + + def test_400_not_retryable(self): + assert is_retryable_response(httpx.Response(400)) is False + + def test_429_not_retryable(self): + assert is_retryable_response(httpx.Response(429)) is False + + +class TestPlatformWaitStrategy: + def test_uses_retry_after_header(self): + http_err = _make_http_status_error(429, retry_after="5") + rs = _make_retry_state(http_err) + assert platform_wait_strategy(rs) == 5.0 + + def test_caps_retry_after_at_max(self): + http_err = _make_http_status_error(429, retry_after="999") + rs = _make_retry_state(http_err) + assert platform_wait_strategy(rs) == _MAX_RETRY_AFTER_DELAY + + def test_falls_back_to_backoff_without_header(self): + http_err = _make_http_status_error(503) + rs = _make_retry_state(http_err, attempt=1) + wait = platform_wait_strategy(rs) + # attempt 1: 1.0 * 2^0 + jitter(0,1) = [1.0, 2.0) + assert 1.0 <= wait < 2.0 + + def test_backoff_capped_at_max(self): + http_err = _make_http_status_error(503) + # attempt 5: 1.0 * 2^4 = 16.0 + jitter, but capped at _MAX_BACKOFF_DELAY + rs = _make_retry_state(http_err, attempt=5) + assert platform_wait_strategy(rs) == _MAX_BACKOFF_DELAY + + def test_no_outcome_falls_back_to_backoff(self): + rs = RetryCallState(Retrying(), None, None, None) + rs.attempt_number = 1 + rs.outcome = None + wait = platform_wait_strategy(rs) + assert 1.0 <= wait < 2.0 diff --git a/packages/uipath-platform/tests/services/test_service_url_overrides.py b/packages/uipath-platform/tests/services/test_service_url_overrides.py new file mode 100644 index 000000000..cc038a3b9 --- /dev/null +++ b/packages/uipath-platform/tests/services/test_service_url_overrides.py @@ -0,0 +1,90 @@ +import pytest + +from uipath.platform.common._service_url_overrides import ( + inject_routing_headers, + resolve_service_url, +) + + +class TestResolveServiceUrl: + def test_returns_none_when_no_override( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("UIPATH_SERVICE_URL_AGENTHUB", raising=False) + assert resolve_service_url("agenthub_/llm/api/chat/completions") is None + + def test_agenthub_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UIPATH_SERVICE_URL_AGENTHUB", "http://localhost:5200") + result = resolve_service_url("agenthub_/llm/api/chat/completions") + assert result == "http://localhost:5200/llm/api/chat/completions" + + def test_orchestrator_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UIPATH_SERVICE_URL_ORCHESTRATOR", "http://localhost:8080") + result = resolve_service_url("orchestrator_/odata/Buckets") + assert result == "http://localhost:8080/odata/Buckets" + + def test_leading_slash_stripped(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UIPATH_SERVICE_URL_AGENTHUB", "http://localhost:5200") + result = resolve_service_url("/agenthub_/llm/api/chat/completions") + assert result == "http://localhost:5200/llm/api/chat/completions" + + def test_trailing_slash_on_override_url( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_SERVICE_URL_AGENTHUB", "http://localhost:5200/") + result = resolve_service_url("agenthub_/llm/api/chat/completions") + assert result == "http://localhost:5200/llm/api/chat/completions" + + def test_compound_service_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UIPATH_SERVICE_URL_AGENTSRUNTIME", "http://localhost:6100") + result = resolve_service_url("agentsruntime_/api/execution/guardrails/validate") + assert result == "http://localhost:6100/api/execution/guardrails/validate" + + def test_no_service_prefix_returns_none(self) -> None: + assert resolve_service_url("api/v1/users") is None + + def test_empty_path_returns_none(self) -> None: + assert resolve_service_url("") is None + + def test_service_only_path(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UIPATH_SERVICE_URL_AGENTHUB", "http://localhost:5200") + result = resolve_service_url("agenthub_/") + assert result == "http://localhost:5200/" + + def test_query_params_preserved(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UIPATH_SERVICE_URL_AGENTHUB", "http://localhost:5200") + result = resolve_service_url( + "agenthub_/llm/openai/deployments/gpt-4/chat/completions?api-version=2024-10-21" + ) + assert ( + result + == "http://localhost:5200/llm/openai/deployments/gpt-4/chat/completions?api-version=2024-10-21" + ) + + +class TestInjectRoutingHeaders: + def test_injects_tenant_and_org(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-123") + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-456") + headers: dict[str, str] = {} + inject_routing_headers(headers) + assert headers["X-UiPath-Internal-TenantId"] == "tenant-123" + assert headers["X-UiPath-Internal-AccountId"] == "org-456" + + def test_skips_missing_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + monkeypatch.delenv("UIPATH_ORGANIZATION_ID", raising=False) + headers: dict[str, str] = {} + inject_routing_headers(headers) + assert "X-UiPath-Internal-TenantId" not in headers + assert "X-UiPath-Internal-AccountId" not in headers + + def test_does_not_overwrite_existing_headers( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-123") + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-456") + headers: dict[str, str] = {"X-Custom": "keep-me"} + inject_routing_headers(headers) + assert headers["X-Custom"] == "keep-me" + assert headers["X-UiPath-Internal-TenantId"] == "tenant-123" diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py new file mode 100644 index 000000000..80cd0d2db --- /dev/null +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -0,0 +1,429 @@ +import json +import os +from datetime import datetime +from unittest.mock import Mock, patch + +import pytest +from opentelemetry.sdk.trace import Span as OTelSpan +from opentelemetry.trace import SpanContext, StatusCode + +from uipath.platform.common import UiPathSpan, _SpanUtils + + +class TestNormalizeIds: + """Tests for OTEL ID normalization functions.""" + + def test_normalize_trace_id_from_hex(self): + """Test normalizing a 32-char hex trace ID.""" + trace_id = "1234567890abcdef1234567890abcdef" + result = _SpanUtils.normalize_trace_id(trace_id) + assert result == "1234567890abcdef1234567890abcdef" + + def test_normalize_trace_id_from_uuid(self): + """Test normalizing a UUID format trace ID to hex.""" + trace_id = "12345678-90ab-cdef-1234-567890abcdef" + result = _SpanUtils.normalize_trace_id(trace_id) + assert result == "1234567890abcdef1234567890abcdef" + + def test_normalize_trace_id_uppercase(self): + """Test normalizing uppercase hex to lowercase.""" + trace_id = "1234567890ABCDEF1234567890ABCDEF" + result = _SpanUtils.normalize_trace_id(trace_id) + assert result == "1234567890abcdef1234567890abcdef" + + def test_normalize_trace_id_invalid_length(self): + """Test that invalid length raises ValueError.""" + with pytest.raises(ValueError, match="Invalid trace ID format"): + _SpanUtils.normalize_trace_id("1234") + + def test_normalize_span_id_from_hex(self): + """Test normalizing a 16-char hex span ID.""" + span_id = "1234567890abcdef" + result = _SpanUtils.normalize_span_id(span_id) + assert result == "1234567890abcdef" + + def test_normalize_span_id_from_uuid(self): + """Test normalizing a UUID format span ID (takes last 16 chars).""" + span_id = "00000000-0000-0000-1234-567890abcdef" + result = _SpanUtils.normalize_span_id(span_id) + assert result == "1234567890abcdef" + + def test_normalize_span_id_uppercase(self): + """Test normalizing uppercase hex to lowercase.""" + span_id = "1234567890ABCDEF" + result = _SpanUtils.normalize_span_id(span_id) + assert result == "1234567890abcdef" + + def test_normalize_span_id_invalid_length(self): + """Test that invalid length raises ValueError.""" + with pytest.raises(ValueError, match="Invalid span ID format"): + _SpanUtils.normalize_span_id("1234") + + +class TestSpanUtils: + @patch.dict( + os.environ, + { + "UIPATH_ORGANIZATION_ID": "test-org", + "UIPATH_TENANT_ID": "test-tenant", + "UIPATH_FOLDER_KEY": "test-folder", + "UIPATH_PROCESS_UUID": "test-process", + "UIPATH_JOB_KEY": "test-job", + }, + ) + def test_otel_span_to_uipath_span(self): + # Create a mock OTel span + mock_span = Mock(spec=OTelSpan) + + # Set span context + trace_id = 0x123456789ABCDEF0123456789ABCDEF0 + span_id = 0x0123456789ABCDEF + mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) + mock_span.get_span_context.return_value = mock_context + + # Set span properties + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = { + "key1": "value1", + "key2": 123, + "span_type": "CustomSpanType", + } + mock_span.events = [] + mock_span.links = [] + + # Set times + current_time_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = current_time_ns + mock_span.end_time = current_time_ns + 1000000 # 1ms later + + # Convert to UiPath span + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + + # Verify the conversion + assert isinstance(uipath_span, UiPathSpan) + assert uipath_span.name == "test-span" + assert uipath_span.status == 1 # OK + assert uipath_span.span_type == "CustomSpanType" + + # Verify IDs are in OTEL hex format + assert uipath_span.trace_id == "123456789abcdef0123456789abcdef0" # 32-char hex + assert uipath_span.id == "0123456789abcdef" # 16-char hex + assert uipath_span.parent_id is None + + # Verify attributes + attributes_value = uipath_span.attributes + attributes = ( + json.loads(attributes_value) + if isinstance(attributes_value, str) + else attributes_value + ) + assert attributes["key1"] == "value1" + assert attributes["key2"] == 123 + + # Test with error status + mock_span.status.description = "Test error description" + mock_span.status.status_code = StatusCode.ERROR + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + assert uipath_span.status == 2 # Error + + @patch.dict( + os.environ, + { + "UIPATH_ORGANIZATION_ID": "test-org", + "UIPATH_TENANT_ID": "test-tenant", + }, + ) + def test_otel_span_to_uipath_span_optimized_path(self): + """Test the optimized path where attributes are kept as dict.""" + # Create a mock OTel span + mock_span = Mock(spec=OTelSpan) + + # Set span context + trace_id = 0x123456789ABCDEF0123456789ABCDEF0 + span_id = 0x0123456789ABCDEF + mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) + mock_span.get_span_context.return_value = mock_context + + # Set span properties + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = { + "key1": "value1", + "key2": 123, + } + mock_span.events = [] + mock_span.links = [] + + # Set times + current_time_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = current_time_ns + mock_span.end_time = current_time_ns + 1000000 + + # Test optimized path: serialize_attributes=False + uipath_span = _SpanUtils.otel_span_to_uipath_span( + mock_span, serialize_attributes=False + ) + + # Verify attributes is a dict (not JSON string) + assert isinstance(uipath_span.attributes, dict) + assert uipath_span.attributes["key1"] == "value1" + assert uipath_span.attributes["key2"] == 123 + + # Test to_dict with serialize_attributes=False + span_dict = uipath_span.to_dict(serialize_attributes=False) + assert isinstance(span_dict["Attributes"], dict) + assert span_dict["Attributes"]["key1"] == "value1" + + # Test to_dict with serialize_attributes=True + span_dict_serialized = uipath_span.to_dict(serialize_attributes=True) + assert isinstance(span_dict_serialized["Attributes"], str) + attrs = json.loads(span_dict_serialized["Attributes"]) + assert attrs["key1"] == "value1" + assert attrs["key2"] == 123 + + @patch.dict(os.environ, {"UIPATH_TRACE_ID": "00000000-0000-4000-8000-000000000000"}) + def test_otel_span_to_uipath_span_with_env_trace_id_uuid_format(self): + """Test that UUID format UIPATH_TRACE_ID is normalized to hex.""" + # Create a mock OTel span + mock_span = Mock(spec=OTelSpan) + + # Set span context + trace_id = 0x123456789ABCDEF0123456789ABCDEF0 + span_id = 0x0123456789ABCDEF + mock_context = SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + + # Set span properties + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {} + mock_span.events = [] + mock_span.links = [] + + # Set times + current_time_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = current_time_ns + mock_span.end_time = current_time_ns + 1000000 # 1ms later + + # Convert to UiPath span + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + + # Verify the trace ID is normalized to 32-char hex format + assert uipath_span.trace_id == "00000000000040008000000000000000" + + @patch.dict(os.environ, {"UIPATH_TRACE_ID": "1234567890abcdef1234567890abcdef"}) + def test_otel_span_to_uipath_span_with_env_trace_id_hex_format(self): + """Test that hex format UIPATH_TRACE_ID is used directly.""" + # Create a mock OTel span + mock_span = Mock(spec=OTelSpan) + + # Set span context + trace_id = 0x123456789ABCDEF0123456789ABCDEF0 + span_id = 0x0123456789ABCDEF + mock_context = SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + + # Set span properties + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {} + mock_span.events = [] + mock_span.links = [] + + # Set times + current_time_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = current_time_ns + mock_span.end_time = current_time_ns + 1000000 # 1ms later + + # Convert to UiPath span + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + + # Verify the trace ID is used as-is (lowercase) + assert uipath_span.trace_id == "1234567890abcdef1234567890abcdef" + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_uipath_span_includes_execution_type(self): + """Test that executionType from attributes becomes top-level ExecutionType.""" + mock_span = Mock(spec=OTelSpan) + + trace_id = 0x123456789ABCDEF0123456789ABCDEF0 + span_id = 0x0123456789ABCDEF + mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) + mock_span.get_span_context.return_value = mock_context + + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {"executionType": 0} + mock_span.events = [] + mock_span.links = [] + + current_time_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = current_time_ns + mock_span.end_time = current_time_ns + 1000000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + assert span_dict["ExecutionType"] == 0 + assert uipath_span.execution_type == 0 + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_uipath_span_includes_agent_version(self): + """Test that agentVersion from attributes becomes top-level AgentVersion.""" + mock_span = Mock(spec=OTelSpan) + + trace_id = 0x123456789ABCDEF0123456789ABCDEF0 + span_id = 0x0123456789ABCDEF + mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) + mock_span.get_span_context.return_value = mock_context + + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {"agentVersion": "2.0.0"} + mock_span.events = [] + mock_span.links = [] + + current_time_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = current_time_ns + mock_span.end_time = current_time_ns + 1000000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + assert span_dict["AgentVersion"] == "2.0.0" + assert uipath_span.agent_version == "2.0.0" + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_uipath_span_execution_type_and_agent_version_both(self): + """Test that both executionType and agentVersion are extracted together.""" + mock_span = Mock(spec=OTelSpan) + + trace_id = 0x123456789ABCDEF0123456789ABCDEF0 + span_id = 0x0123456789ABCDEF + mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) + mock_span.get_span_context.return_value = mock_context + + mock_span.name = "Agent run - Agent" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {"executionType": 1, "agentVersion": "1.0.0"} + mock_span.events = [] + mock_span.links = [] + + current_time_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = current_time_ns + mock_span.end_time = current_time_ns + 1000000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + assert span_dict["ExecutionType"] == 1 + assert span_dict["AgentVersion"] == "1.0.0" + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_uipath_span_missing_execution_type_and_agent_version(self): + """Test that missing executionType and agentVersion default to None.""" + mock_span = Mock(spec=OTelSpan) + + trace_id = 0x123456789ABCDEF0123456789ABCDEF0 + span_id = 0x0123456789ABCDEF + mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) + mock_span.get_span_context.return_value = mock_context + + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + mock_span.attributes = {"someOtherAttr": "value"} + mock_span.events = [] + mock_span.links = [] + + current_time_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = current_time_ns + mock_span.end_time = current_time_ns + 1000000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + assert span_dict["ExecutionType"] is None + assert span_dict["AgentVersion"] is None + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_uipath_span_source_defaults_to_robots(self): + """Test that Source defaults to 4 (Robots) and ignores attributes.source.""" + mock_span = Mock(spec=OTelSpan) + + trace_id = 0x123456789ABCDEF0123456789ABCDEF0 + span_id = 0x0123456789ABCDEF + mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) + mock_span.get_span_context.return_value = mock_context + + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + # source in attributes should NOT override top-level Source + mock_span.attributes = {"source": "runtime"} + mock_span.events = [] + mock_span.links = [] + + current_time_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = current_time_ns + mock_span.end_time = current_time_ns + 1000000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + # Top-level Source should be 4 (Robots), string "runtime" is ignored + assert uipath_span.source == 4 + assert span_dict["Source"] == 4 + + # attributes.source string should still be in Attributes JSON + attrs = json.loads(span_dict["Attributes"]) + assert attrs["source"] == "runtime" + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_uipath_span_source_override_with_uipath_source(self): + """Test that uipath.source attribute overrides default (for low-code agents).""" + mock_span = Mock(spec=OTelSpan) + + trace_id = 0x123456789ABCDEF0123456789ABCDEF0 + span_id = 0x0123456789ABCDEF + mock_context = SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False) + mock_span.get_span_context.return_value = mock_context + + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = StatusCode.OK + # uipath.source=1 (Agents) overrides default of 4 (Robots) + mock_span.attributes = {"uipath.source": 1, "source": "runtime"} + mock_span.events = [] + mock_span.links = [] + + current_time_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = current_time_ns + mock_span.end_time = current_time_ns + 1000000 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + # uipath.source overrides - low-code agents use 1 (Agents) + assert uipath_span.source == 1 + assert span_dict["Source"] == 1 + + # String source still in Attributes JSON + attrs = json.loads(span_dict["Attributes"]) + assert attrs["source"] == "runtime" diff --git a/packages/uipath-platform/tests/services/test_uipath_llm_integration.py b/packages/uipath-platform/tests/services/test_uipath_llm_integration.py new file mode 100644 index 000000000..124ccad8b --- /dev/null +++ b/packages/uipath-platform/tests/services/test_uipath_llm_integration.py @@ -0,0 +1,510 @@ +import os +from unittest.mock import MagicMock, patch + +import pytest + +from uipath.platform import UiPathApiConfig, UiPathExecutionContext +from uipath.platform.chat import ( + AutoToolChoice, + ChatModels, + SpecificToolChoice, + ToolDefinition, + ToolFunctionDefinition, + ToolParametersDefinition, + ToolPropertyDefinition, + UiPathLlmChatService, +) + + +def get_env_var(name: str) -> str: + """Get environment variable or skip test if not present.""" + value = os.environ.get(name) + if value is None: + pytest.skip(f"Environment variable {name} is not set") + return value + + +class TestUiPathLLMIntegration: + @pytest.fixture + def llm_service(self): + """Create a UiPathLLMService instance with environment variables.""" + # skip tests on CI, only run locally + pytest.skip("Failed to get access token. Check your credentials.") + + # In a real-world scenario, these would be environment variables + base_url = get_env_var("UIPATH_URL") + api_key = get_env_var("UIPATH_ACCESS_TOKEN") + + config = UiPathApiConfig(base_url=base_url, secret=api_key) + execution_context = UiPathExecutionContext() + return UiPathLlmChatService(config=config, execution_context=execution_context) + + @pytest.mark.asyncio + async def test_basic_chat_completions(self, llm_service): + """Test basic chat completions functionality.""" + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is the capital of France?"}, + ] + + result = await llm_service.chat_completions( + messages=messages, + model=ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens=50, + temperature=0, + ) + + # Validate the response + assert result is not None + assert hasattr(result, "id") + assert hasattr(result, "choices") + assert len(result.choices) > 0 + assert hasattr(result.choices[0], "message") + assert hasattr(result.choices[0].message, "content") + assert "Paris" in result.choices[0].message.content + + @pytest.mark.asyncio + async def test_tool_call_required(self, llm_service): + """Test the tool call functionality with a specific required tool.""" + messages = [ + { + "role": "system", + "content": "You are given two tools/functions and a user and password. You must first call test_tool with the given credentials then call submit_answer with the result. If the result is nested, extract the result string and pass it to submit_answer. Do not respond with text, only call the tools/functions.", + }, + {"role": "user", "content": "username: John, password: 1234"}, + ] + + # Define the test_tool + test_tool = ToolDefinition( + type="function", + function=ToolFunctionDefinition( + name="test_tool", + description="call this to obtain the result", + parameters=ToolParametersDefinition( + type="object", + properties={ + "name": ToolPropertyDefinition( + type="string", description="the name of the user" + ), + "password": ToolPropertyDefinition( + type="string", description="the password of the user" + ), + }, + required=["name", "password"], + ), + ), + ) + + # Define tool choice to specifically use test_tool + tool_choice = SpecificToolChoice(type="tool", name="test_tool") + + result = await llm_service.chat_completions( + messages=messages, + model=ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens=250, + temperature=0, + tools=[test_tool], + tool_choice=tool_choice, + ) + + # Validate the response + assert result is not None + assert len(result.choices) > 0 + assert result.choices[0].message.tool_calls is not None + assert len(result.choices[0].message.tool_calls) > 0 + assert result.choices[0].message.tool_calls[0].name == "test_tool" + assert "name" in result.choices[0].message.tool_calls[0].arguments + assert result.choices[0].message.tool_calls[0].arguments["name"] == "John" + assert "password" in result.choices[0].message.tool_calls[0].arguments + assert result.choices[0].message.tool_calls[0].arguments["password"] == "1234" + + @pytest.mark.asyncio + async def test_chat_with_conversation_history(self, llm_service): + """Test chat completions with a conversation history including assistant messages.""" + messages = [ + {"role": "system", "content": "You are a helpful assistant"}, + {"role": "user", "content": "Hi my name is John"}, + {"content": "Hello John! How can I assist you today?", "role": "assistant"}, + {"role": "user", "content": "What is my name?"}, + ] + + # Define the test_tool but with auto tool choice + test_tool = ToolDefinition( + type="function", + function=ToolFunctionDefinition( + name="test_tool", + description="call this to obtain the result", + parameters=ToolParametersDefinition( + type="object", + properties={ + "name": ToolPropertyDefinition( + type="string", description="the name of the user" + ), + "password": ToolPropertyDefinition( + type="string", description="the password of the user" + ), + }, + required=["name", "password"], + ), + ), + ) + + # Use auto tool choice + tool_choice = AutoToolChoice(type="auto") + + result = await llm_service.chat_completions( + messages=messages, + model=ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens=250, + temperature=0, + frequency_penalty=1, + presence_penalty=1, + tools=[test_tool], + tool_choice=tool_choice, + ) + + # Validate the response + assert result is not None + assert len(result.choices) > 0 + assert result.choices[0].message.content is not None + assert "John" in result.choices[0].message.content + # The model chose to respond with text instead of using the tool + assert ( + result.choices[0].message.tool_calls is None + or len(result.choices[0].message.tool_calls) == 0 + ) + + @pytest.mark.asyncio + async def test_no_tools(self, llm_service): + """Test chat completions without any tools.""" + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Write a haiku about Python programming."}, + ] + + result = await llm_service.chat_completions( + messages=messages, + model=ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens=100, + temperature=0.7, + ) + + # Validate the response + assert result is not None + assert len(result.choices) > 0 + assert result.choices[0].message.content is not None + assert len(result.choices[0].message.content.strip()) > 0 + # No tools were provided, so no tool calls should be in the response + assert ( + result.choices[0].message.tool_calls is None + or len(result.choices[0].message.tool_calls) == 0 + ) + + +class TestUiPathLLMServiceMocked: + @pytest.fixture + def config(self): + return UiPathApiConfig(base_url="https://example.com", secret="test_secret") + + @pytest.fixture + def execution_context(self): + return UiPathExecutionContext() + + @pytest.fixture + def llm_service(self, config, execution_context): + return UiPathLlmChatService(config=config, execution_context=execution_context) + + def test_init(self, config, execution_context): + service = UiPathLlmChatService( + config=config, execution_context=execution_context + ) + assert service._config == config + assert service._execution_context == execution_context + + @pytest.mark.asyncio + @patch.object(UiPathLlmChatService, "request_async") + async def test_basic_chat_completions_mocked(self, mock_request, llm_service): + # Mock response + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677858242, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The capital of France is Paris.", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 30, + "completion_tokens": 10, + "total_tokens": 40, + "cache_read_input_tokens": None, + }, + } + mock_request.return_value = mock_response + + # Test messages + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is the capital of France?"}, + ] + + # Call the method + result = await llm_service.chat_completions( + messages=messages, + model=ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens=50, + temperature=0, + ) + + # Assertions + mock_request.assert_called_once() + assert result.id == "chatcmpl-123" + assert len(result.choices) == 1 + assert result.choices[0].message.content == "The capital of France is Paris." + assert "Paris" in result.choices[0].message.content + assert result.usage.prompt_tokens == 30 + assert result.usage.completion_tokens == 10 + + # Verify the correct endpoint and payload + args, kwargs = mock_request.call_args + assert "/orchestrator_/llm/api/chat/completions" in args[1] + assert kwargs["json"]["messages"] == messages + assert kwargs["json"]["max_tokens"] == 50 + assert kwargs["json"]["temperature"] == 0 + + @pytest.mark.asyncio + @patch.object(UiPathLlmChatService, "request_async") + async def test_tool_call_required_mocked(self, mock_request, llm_service): + # Mock response + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-456", + "object": "chat.completion", + "created": 1677858242, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc123", + "name": "test_tool", + "arguments": {"name": "John", "password": "1234"}, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": { + "prompt_tokens": 50, + "completion_tokens": 25, + "total_tokens": 75, + "cache_read_input_tokens": None, + }, + } + mock_request.return_value = mock_response + + # Test messages + messages = [ + { + "role": "system", + "content": "You are given two tools/functions and a user and password. You must first call test_tool with the given credentials then call submit_answer with the result. If the result is nested, extract the result string and pass it to submit_answer. Do not respond with text, only call the tools/functions.", + }, + {"role": "user", "content": "username: John, password: 1234"}, + ] + + # Define the test_tool + test_tool = ToolDefinition( + type="function", + function=ToolFunctionDefinition( + name="test_tool", + description="call this to obtain the result", + parameters=ToolParametersDefinition( + type="object", + properties={ + "name": ToolPropertyDefinition( + type="string", description="the name of the user" + ), + "password": ToolPropertyDefinition( + type="string", description="the password of the user" + ), + }, + required=["name", "password"], + ), + ), + ) + + # Define tool choice + tool_choice = SpecificToolChoice(type="tool", name="test_tool") + + # Call the method + result = await llm_service.chat_completions( + messages=messages, + model=ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens=250, + temperature=0, + tools=[test_tool], + tool_choice=tool_choice, + ) + + # Assertions + mock_request.assert_called_once() + assert result.id == "chatcmpl-456" + assert len(result.choices) == 1 + assert result.choices[0].message.tool_calls is not None + assert len(result.choices[0].message.tool_calls) == 1 + assert result.choices[0].message.tool_calls[0].name == "test_tool" + assert result.choices[0].message.tool_calls[0].arguments["name"] == "John" + assert result.choices[0].message.tool_calls[0].arguments["password"] == "1234" + + @pytest.mark.asyncio + @patch.object(UiPathLlmChatService, "request_async") + async def test_chat_with_conversation_history_mocked( + self, mock_request, llm_service + ): + # Mock response + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-789", + "object": "chat.completion", + "created": 1677858242, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Your name is John, as you mentioned earlier.", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 70, + "completion_tokens": 15, + "total_tokens": 85, + "cache_read_input_tokens": None, + }, + } + mock_request.return_value = mock_response + + # Test messages with conversation history + messages = [ + {"role": "system", "content": "You are a helpful assistant"}, + {"role": "user", "content": "Hi my name is John"}, + {"content": "Hello John! How can I assist you today?", "role": "assistant"}, + {"role": "user", "content": "What is my name?"}, + ] + + # Define test tool + test_tool = ToolDefinition( + type="function", + function=ToolFunctionDefinition( + name="test_tool", + description="call this to obtain the result", + parameters=ToolParametersDefinition( + type="object", + properties={ + "name": ToolPropertyDefinition( + type="string", description="the name of the user" + ), + "password": ToolPropertyDefinition( + type="string", description="the password of the user" + ), + }, + required=["name", "password"], + ), + ), + ) + + # Use auto tool choice + tool_choice = AutoToolChoice(type="auto") + + # Call the method + result = await llm_service.chat_completions( + messages=messages, + model=ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens=250, + temperature=0, + frequency_penalty=1, + presence_penalty=1, + tools=[test_tool], + tool_choice=tool_choice, + ) + + # Assertions + mock_request.assert_called_once() + assert result.id == "chatcmpl-789" + assert len(result.choices) == 1 + assert result.choices[0].message.content is not None + assert "John" in result.choices[0].message.content + assert result.choices[0].message.tool_calls is None + + @pytest.mark.asyncio + @patch.object(UiPathLlmChatService, "request_async") + async def test_no_tools_mocked(self, mock_request, llm_service): + # Mock response + mock_response = MagicMock() + mock_response.json.return_value = { + "id": "chatcmpl-abc", + "object": "chat.completion", + "created": 1677858242, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Silently coding,\nPython's logic unfolds clear,\nBugs hide, then reveal.", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 40, + "completion_tokens": 20, + "total_tokens": 60, + "cache_read_input_tokens": None, + }, + } + mock_request.return_value = mock_response + + # Test messages + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Write a haiku about Python programming."}, + ] + + # Call the method + result = await llm_service.chat_completions( + messages=messages, + model=ChatModels.gpt_4_1_mini_2025_04_14, + max_tokens=100, + temperature=0.7, + ) + + # Assertions + mock_request.assert_called_once() + assert result.id == "chatcmpl-abc" + assert len(result.choices) == 1 + assert result.choices[0].message.content is not None + assert len(result.choices[0].message.content.strip()) > 0 + assert result.choices[0].message.tool_calls is None + + # Verify the correct payload was sent + args, kwargs = mock_request.call_args + assert kwargs["json"]["messages"] == messages + assert kwargs["json"]["max_tokens"] == 100 + assert kwargs["json"]["temperature"] == 0.7 diff --git a/packages/uipath-platform/tests/services/tests_data/documents_service/classification_response.json b/packages/uipath-platform/tests/services/tests_data/documents_service/classification_response.json new file mode 100644 index 000000000..a815fc783 --- /dev/null +++ b/packages/uipath-platform/tests/services/tests_data/documents_service/classification_response.json @@ -0,0 +1,23 @@ +{ + "classificationResults": [ + { + "DocumentTypeId": "0d209d75-9afd-ef11-aaa7-000d3a234147", + "DocumentId": "0a9f9927-e6af-f011-8e60-6045bd9ba6d0", + "Confidence": 0.53288215, + "OcrConfidence": -1.0, + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DocumentBounds": { + "StartPage": 0, + "PageCount": 1, + "TextStartIndex": 0, + "TextLength": 629, + "PageRange": "1" + }, + "ClassifierName": "Production_classifier" + } + ] +} diff --git a/packages/uipath-platform/tests/services/tests_data/documents_service/extraction_validation_action_response_completed.json b/packages/uipath-platform/tests/services/tests_data/documents_service/extraction_validation_action_response_completed.json new file mode 100644 index 000000000..0f340b00e --- /dev/null +++ b/packages/uipath-platform/tests/services/tests_data/documents_service/extraction_validation_action_response_completed.json @@ -0,0 +1,1277 @@ +{ + "actionData": { + "type": "Validation", + "id": 10432640, + "status": "Completed", + "title": "Test Validation Action", + "priority": "Medium", + "taskCatalogName": "default_du_actions", + "taskUrl": "https://dummy.uipath.com/82e69757-09ff-4e6d-83e7-d530f2a99999/bd829329-42ff-40aa-96dc-95a781699999/actions_/tasks/10499999", + "folderPath": "Shared", + "folderId": 1744784, + "data": { + "automaticExtractionResultsPath": "TestDirectory/12c04908-67f0-f011-8196-6045bd99999/input_results.zip", + "validatedExtractionResultsPath": "TestDirectory/12c04908-67f0-f011-8196-6045bd99999/output_results.zip", + "documentRejectionDetails": null + }, + "action": "Completed", + "isDeleted": false, + "assignedToUser": { + "id": 4303796, + "emailAddress": "dummy.dummy@dummy.com" + }, + "creatorUser": null, + "deleterUser": null, + "lastModifierUser": { + "id": 4303796, + "emailAddress": "dummy.dummy@dummy.com" + }, + "completedByUser": { + "id": 4303796, + "emailAddress": "dummy.dummy@dummy.com" + }, + "creationTime": "2026-01-22T10:03:02.9434351Z", + "lastAssignedTime": "2026-01-22T10:03:45.587Z", + "completionTime": "2026-01-22T10:03:56.71Z", + "processingTime": null + }, + "validatedExtractionResults": { + "DocumentId": "47f037fe-66f0-f011-8196-6045bd99999", + "ResultsVersion": 1, + "ResultsDocument": { + "Bounds": { + "StartPage": 0, + "PageCount": 1, + "TextStartIndex": 0, + "TextLength": 635, + "PageRange": "1" + }, + "Language": "eng", + "DocumentGroup": "", + "DocumentCategory": "", + "DocumentTypeId": "00000000-0000-0000-0000-000000000000", + "DocumentTypeName": "Default", + "DocumentTypeDataVersion": 0, + "DataVersion": 1, + "DocumentTypeSource": "Automatic", + "DocumentTypeField": { + "Components": [], + "Value": "Default", + "UnformattedValue": "", + "Reference": { "TextStartIndex": 0, "TextLength": 0, "Tokens": [] }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + "Fields": [ + { + "FieldId": "Uber Receipt", + "FieldName": "Uber Receipt", + "FieldType": "Table", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [ + { + "FieldId": "Uber Receipt.Header", + "FieldName": "Header", + "FieldType": "Internal", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [ + { + "FieldId": "Date of Trip", + "FieldName": "Date of Trip", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "Date of Trip", + "UnformattedValue": "Date of Trip", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Customer Name", + "FieldName": "Customer Name", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "Customer Name", + "UnformattedValue": "Customer Name", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Total Amount Paid", + "FieldName": "Total Amount Paid", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "Total Amount Paid", + "UnformattedValue": "Total Amount Paid", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "Value": "", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Uber Receipt.Body", + "FieldName": "Body", + "FieldType": "Internal", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [ + { + "FieldId": "Date of Trip", + "FieldName": "Date of Trip", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "12/11/2023 00:00:00", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 565, + "TextLength": 17, + "Tokens": [ + { + "TextStartIndex": 565, + "TextLength": 8, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [47.2702, 464.3362, 33.9578, 5.9088] + ] + }, + { + "TextStartIndex": 574, + "TextLength": 3, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [47.2702, 500.5087, 6.6439, 5.9088] + ] + }, + { + "TextStartIndex": 574, + "TextLength": 3, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [47.2702, 507.8908, 1.4764, 5.9088] + ] + }, + { + "TextStartIndex": 578, + "TextLength": 4, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [47.2702, 511.5819, 16.2407, 5.9088] + ] + } + ] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Customer Name", + "FieldName": "Customer Name", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "Alex", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 41, + "TextLength": 5, + "Tokens": [ + { + "TextStartIndex": 41, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [84.2, 275.3536, 33.9578, 13.2947] + ] + } + ] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Total Amount Paid", + "FieldName": "Total Amount Paid", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "66.79 RON", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 584, + "TextLength": 9, + "Tokens": [ + { + "TextStartIndex": 584, + "TextLength": 3, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [146.2421, 473.933, 22.1464, 8.1246] + ] + }, + { + "TextStartIndex": 588, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [146.2421, 500.5087, 26.5757, 8.1246] + ] + } + ] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "Value": "", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "Value": "", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Uber Receipt > Fare Breakdown", + "FieldName": "Uber Receipt > Fare Breakdown", + "FieldType": "Table", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [ + { + "FieldId": "Uber Receipt > Fare Breakdown.Header", + "FieldName": "Header", + "FieldType": "Internal", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [ + { + "FieldId": "Fare Amount", + "FieldName": "Fare Amount", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "Fare Amount", + "UnformattedValue": "Fare Amount", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Subtotal", + "FieldName": "Subtotal", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "Subtotal", + "UnformattedValue": "Subtotal", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Promotion Amount", + "FieldName": "Promotion Amount", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "Promotion Amount", + "UnformattedValue": "Promotion Amount", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "Value": "", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Uber Receipt > Fare Breakdown.Body", + "FieldName": "Body", + "FieldType": "Internal", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [ + { + "FieldId": "Fare Amount", + "FieldName": "Fare Amount", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "65.29 RON", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 595, + "TextLength": 9, + "Tokens": [ + { + "TextStartIndex": 595, + "TextLength": 3, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [187.6035, 493.8648, 14.026, 4.4316] + ] + }, + { + "TextStartIndex": 599, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [187.6035, 509.3672, 16.9789, 4.4316] + ] + } + ] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Subtotal", + "FieldName": "Subtotal", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "65.29 RON", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 606, + "TextLength": 9, + "Tokens": [ + { + "TextStartIndex": 606, + "TextLength": 3, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [224.5333, 491.6501, 14.7643, 5.1702] + ] + }, + { + "TextStartIndex": 610, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [224.5333, 508.629, 18.4553, 5.1702] + ] + } + ] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Promotion Amount", + "FieldName": "Promotion Amount", + "FieldType": "Text", + "IsMissing": true, + "DataSource": "Automatic", + "Values": [], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "Value": "", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "Value": "", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": true, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "Tables": [ + { + "FieldId": "Uber Receipt", + "FieldName": "Uber Receipt", + "IsMissing": false, + "DataSource": "Automatic", + "DataVersion": 0, + "OperatorConfirmed": true, + "Values": [ + { + "OperatorConfirmed": true, + "Confidence": 1.0, + "OcrConfidence": 1.0, + "Cells": [ + { + "RowIndex": 0, + "ColumnIndex": 0, + "IsHeader": true, + "IsMissing": false, + "OperatorConfirmed": true, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "Date of Trip", + "UnformattedValue": "Date of Trip", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 0, + "ColumnIndex": 1, + "IsHeader": true, + "IsMissing": false, + "OperatorConfirmed": true, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "Customer Name", + "UnformattedValue": "Customer Name", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 0, + "ColumnIndex": 2, + "IsHeader": true, + "IsMissing": false, + "OperatorConfirmed": true, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "Total Amount Paid", + "UnformattedValue": "Total Amount Paid", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 1, + "ColumnIndex": 0, + "IsHeader": false, + "IsMissing": false, + "OperatorConfirmed": true, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "12/11/2023 00:00:00", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 565, + "TextLength": 17, + "Tokens": [ + { + "TextStartIndex": 565, + "TextLength": 8, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[47.2702, 464.3362, 33.9578, 5.9088]] + }, + { + "TextStartIndex": 574, + "TextLength": 3, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[47.2702, 500.5087, 6.6439, 5.9088]] + }, + { + "TextStartIndex": 574, + "TextLength": 3, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[47.2702, 507.8908, 1.4764, 5.9088]] + }, + { + "TextStartIndex": 578, + "TextLength": 4, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[47.2702, 511.5819, 16.2407, 5.9088]] + } + ] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 1, + "ColumnIndex": 1, + "IsHeader": false, + "IsMissing": false, + "OperatorConfirmed": true, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "Alex", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 41, + "TextLength": 5, + "Tokens": [ + { + "TextStartIndex": 41, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[84.2, 275.3536, 33.9578, 13.2947]] + } + ] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 1, + "ColumnIndex": 2, + "IsHeader": false, + "IsMissing": false, + "OperatorConfirmed": true, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "66.79 RON", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 584, + "TextLength": 9, + "Tokens": [ + { + "TextStartIndex": 584, + "TextLength": 3, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[146.2421, 473.933, 22.1464, 8.1246]] + }, + { + "TextStartIndex": 588, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[146.2421, 500.5087, 26.5757, 8.1246]] + } + ] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + } + ], + "ColumnInfo": [ + { + "FieldId": "Date of Trip", + "FieldName": "Date of Trip", + "FieldType": "Text" + }, + { + "FieldId": "Customer Name", + "FieldName": "Customer Name", + "FieldType": "Text" + }, + { + "FieldId": "Total Amount Paid", + "FieldName": "Total Amount Paid", + "FieldType": "Text" + } + ], + "NumberOfRows": 2, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Uber Receipt > Fare Breakdown", + "FieldName": "Uber Receipt > Fare Breakdown", + "IsMissing": false, + "DataSource": "Automatic", + "DataVersion": 0, + "OperatorConfirmed": true, + "Values": [ + { + "OperatorConfirmed": true, + "Confidence": 1.0, + "OcrConfidence": 1.0, + "Cells": [ + { + "RowIndex": 0, + "ColumnIndex": 0, + "IsHeader": true, + "IsMissing": false, + "OperatorConfirmed": true, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "Fare Amount", + "UnformattedValue": "Fare Amount", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 0, + "ColumnIndex": 1, + "IsHeader": true, + "IsMissing": false, + "OperatorConfirmed": true, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "Subtotal", + "UnformattedValue": "Subtotal", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 0, + "ColumnIndex": 2, + "IsHeader": true, + "IsMissing": false, + "OperatorConfirmed": true, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "Promotion Amount", + "UnformattedValue": "Promotion Amount", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 1, + "ColumnIndex": 0, + "IsHeader": false, + "IsMissing": false, + "OperatorConfirmed": true, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "65.29 RON", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 595, + "TextLength": 9, + "Tokens": [ + { + "TextStartIndex": 595, + "TextLength": 3, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[187.6035, 493.8648, 14.026, 4.4316]] + }, + { + "TextStartIndex": 599, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[187.6035, 509.3672, 16.9789, 4.4316]] + } + ] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 1, + "ColumnIndex": 1, + "IsHeader": false, + "IsMissing": false, + "OperatorConfirmed": true, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "65.29 RON", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 606, + "TextLength": 9, + "Tokens": [ + { + "TextStartIndex": 606, + "TextLength": 3, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[224.5333, 491.6501, 14.7643, 5.1702]] + }, + { + "TextStartIndex": 610, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[224.5333, 508.629, 18.4553, 5.1702]] + } + ] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 1, + "ColumnIndex": 2, + "IsHeader": false, + "IsMissing": true, + "OperatorConfirmed": true, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [] + } + ], + "ColumnInfo": [ + { + "FieldId": "Fare Amount", + "FieldName": "Fare Amount", + "FieldType": "Text" + }, + { + "FieldId": "Subtotal", + "FieldName": "Subtotal", + "FieldType": "Text" + }, + { + "FieldId": "Promotion Amount", + "FieldName": "Promotion Amount", + "FieldType": "Text" + } + ], + "NumberOfRows": 2, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + "ExtractorPayloads": null, + "BusinessRulesResults": null + }, + "dataProjection": [ + { + "fieldGroupName": "Uber Receipt", + "fieldValues": [ + { + "id": "Date of Trip", + "name": "Date of Trip", + "value": "12/11/2023 00:00:00", + "unformattedValue": "", + "confidence": 1, + "ocrConfidence": 1, + "type": "Text" + }, + { + "id": "Customer Name", + "name": "Customer Name", + "value": "Alex", + "unformattedValue": "", + "confidence": 1, + "ocrConfidence": 1, + "type": "Text" + }, + { + "id": "Total Amount Paid", + "name": "Total Amount Paid", + "value": "66.79 RON", + "unformattedValue": "", + "confidence": 1, + "ocrConfidence": 1, + "type": "Text" + } + ] + }, + { + "fieldGroupName": "Uber Receipt > Fare Breakdown", + "fieldValues": [ + { + "id": "Fare Amount", + "name": "Fare Amount", + "value": "65.29 RON", + "unformattedValue": "", + "confidence": 1, + "ocrConfidence": 1, + "type": "Text" + }, + { + "id": "Subtotal", + "name": "Subtotal", + "value": "65.29 RON", + "unformattedValue": "", + "confidence": 1, + "ocrConfidence": 1, + "type": "Text" + }, + { + "id": "Promotion Amount", + "name": "Promotion Amount", + "value": null, + "unformattedValue": null, + "confidence": null, + "ocrConfidence": null, + "type": "Text" + } + ] + } + ], + "actionStatus": "Completed" +} diff --git a/packages/uipath-platform/tests/services/tests_data/documents_service/extraction_validation_action_response_unassigned.json b/packages/uipath-platform/tests/services/tests_data/documents_service/extraction_validation_action_response_unassigned.json new file mode 100644 index 000000000..d3342117a --- /dev/null +++ b/packages/uipath-platform/tests/services/tests_data/documents_service/extraction_validation_action_response_unassigned.json @@ -0,0 +1,29 @@ +{ + "actionData": { + "type": "Validation", + "id": 7372483, + "status": "Unassigned", + "title": "Auto Validation", + "priority": "Medium", + "taskCatalogName": "default_du_actions", + "taskUrl": "https://dummy.uipath.com/40921a85-2b9d-44ea-8655-abf869de8588/f994a44a-10d7-44cf-a1c1-a9f20b469ac3/actions_/tasks/7372483", + "folderPath": "Shared", + "folderId": 249142, + "data": { + "automaticExtractionResultsPath": "Dummy/e777cf4c-2083-f011-b484-000d3a2a9e3c/input_results.zip", + "validatedExtractionResultsPath": "Dummy/e777cf4c-2083-f011-b484-000d3a2a9e3c/output_results.zip", + "documentRejectionDetails": null + }, + "action": null, + "isDeleted": false, + "assignedToUser": null, + "creatorUser": null, + "deleterUser": null, + "lastModifierUser": null, + "completedByUser": null, + "creationTime": "2025-08-27T08:32:02.7115597Z", + "lastAssignedTime": null, + "completionTime": null + }, + "actionStatus": "Unassigned" +} diff --git a/packages/uipath-platform/tests/services/tests_data/documents_service/ixp_extraction_response.json b/packages/uipath-platform/tests/services/tests_data/documents_service/ixp_extraction_response.json new file mode 100644 index 000000000..219f37e5f --- /dev/null +++ b/packages/uipath-platform/tests/services/tests_data/documents_service/ixp_extraction_response.json @@ -0,0 +1,1078 @@ +{ + "extractionResult": { + "DocumentId": "8253c809-2dbe-f011-8194-000d3a296f55", + "ResultsVersion": 0, + "ResultsDocument": { + "Bounds": { + "StartPage": 0, + "PageCount": 1, + "TextStartIndex": 0, + "TextLength": 629, + "PageRange": "1" + }, + "Language": "eng", + "DocumentGroup": "", + "DocumentCategory": "", + "DocumentTypeId": "00000000-0000-0000-0000-000000000000", + "DocumentTypeName": "Default", + "DocumentTypeDataVersion": 0, + "DataVersion": 0, + "DocumentTypeSource": "Automatic", + "DocumentTypeField": { + "Components": [], + "Value": "Default", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": false, + "OcrConfidence": -1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + "Fields": [ + { + "FieldId": "Details", + "FieldName": "Details", + "FieldType": "Table", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [ + { + "FieldId": "Details.Header", + "FieldName": "Header", + "FieldType": "Internal", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [ + { + "FieldId": "Total", + "FieldName": "Total", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "Total", + "UnformattedValue": "Total", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": -1.0, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "From", + "FieldName": "From", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "From", + "UnformattedValue": "From", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": -1.0, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "To", + "FieldName": "To", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "To", + "UnformattedValue": "To", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": -1.0, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Dummy field", + "FieldName": "Dummy field", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "Dummy field", + "UnformattedValue": "Dummy field", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": -1.0, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "Value": "", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": -1.0, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Details.Body", + "FieldName": "Body", + "FieldType": "Internal", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [ + { + "FieldId": "Total", + "FieldName": "Total", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "66.79 RON", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 620, + "TextLength": 9, + "Tokens": [ + { + "TextStartIndex": 620, + "TextLength": 3, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [304.3017, 479.8387, 19.9318, 6.6474] + ] + }, + { + "TextStartIndex": 624, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [304.3017, 503.4615, 23.6228, 6.6474] + ] + } + ] + }, + "DerivedFields": [], + "Confidence": 0.9975757, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "From", + "FieldName": "From", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "Aleea Muscel 9, Cluj-Napoca 400347, Romania", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 337, + "TextLength": 43, + "Tokens": [ + { + "TextStartIndex": 337, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [423.9544, 122.5434, 19.1935, 7.386] + ] + }, + { + "TextStartIndex": 343, + "TextLength": 6, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [423.9544, 143.9516, 22.8846, 7.386] + ] + }, + { + "TextStartIndex": 350, + "TextLength": 2, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [423.9544, 168.3126, 4.4293, 7.386] + ] + }, + { + "TextStartIndex": 350, + "TextLength": 2, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [423.9544, 172.7419, 2.2147, 7.386] + ] + }, + { + "TextStartIndex": 353, + "TextLength": 11, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [423.9544, 176.433, 12.5496, 7.386] + ] + }, + { + "TextStartIndex": 353, + "TextLength": 11, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [423.9544, 188.9826, 2.9528, 7.386] + ] + }, + { + "TextStartIndex": 353, + "TextLength": 11, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [423.9544, 191.1973, 24.361, 7.386] + ] + }, + { + "TextStartIndex": 365, + "TextLength": 7, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [423.9544, 217.773, 24.361, 7.386] + ] + }, + { + "TextStartIndex": 365, + "TextLength": 7, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [423.9544, 242.134, 2.2147, 7.386] + ] + }, + { + "TextStartIndex": 373, + "TextLength": 7, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [423.9544, 246.5633, 28.7903, 7.386] + ] + } + ] + }, + "DerivedFields": [], + "Confidence": 0.9979634, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "To", + "FieldName": "To", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "Strada Traian Vuia 149-151, Cluj-Napoca 400397, Romania", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 391, + "TextLength": 55, + "Tokens": [ + { + "TextStartIndex": 391, + "TextLength": 6, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [443.1579, 123.2816, 22.1464, 7.3859] + ] + }, + { + "TextStartIndex": 398, + "TextLength": 6, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [443.1579, 146.9045, 19.9318, 7.3859] + ] + }, + { + "TextStartIndex": 405, + "TextLength": 4, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [443.1579, 168.3126, 15.5025, 7.3859] + ] + }, + { + "TextStartIndex": 410, + "TextLength": 8, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [443.1579, 186.0298, 25.0992, 7.3859] + ] + }, + { + "TextStartIndex": 410, + "TextLength": 8, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [443.1579, 211.8672, 2.2147, 7.3859] + ] + }, + { + "TextStartIndex": 419, + "TextLength": 11, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [443.1579, 215.5583, 12.5496, 7.3859] + ] + }, + { + "TextStartIndex": 419, + "TextLength": 11, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [443.1579, 228.1079, 2.9528, 7.3859] + ] + }, + { + "TextStartIndex": 419, + "TextLength": 11, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [443.1579, 231.0608, 24.361, 7.3859] + ] + }, + { + "TextStartIndex": 431, + "TextLength": 7, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [443.1579, 256.8983, 25.0992, 7.3859] + ] + }, + { + "TextStartIndex": 431, + "TextLength": 7, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [443.1579, 281.2593, 2.2147, 7.3859] + ] + }, + { + "TextStartIndex": 439, + "TextLength": 7, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [ + [443.1579, 285.6886, 28.7903, 7.3859] + ] + } + ] + }, + "DerivedFields": [], + "Confidence": 0.99986005, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "Dummy field", + "FieldName": "Dummy field", + "FieldType": "Text", + "IsMissing": true, + "DataSource": "Automatic", + "Values": [], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "Value": "", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 0.9975757, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "Value": "", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 0.9975757, + "OperatorConfirmed": true, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "Tables": [ + { + "FieldId": "Details", + "FieldName": "Details", + "IsMissing": false, + "DataSource": "Automatic", + "DataVersion": 0, + "OperatorConfirmed": false, + "Values": [ + { + "OperatorConfirmed": true, + "Confidence": 0.9975757, + "OcrConfidence": 1.0, + "Cells": [ + { + "RowIndex": 0, + "ColumnIndex": 0, + "IsHeader": true, + "IsMissing": false, + "OperatorConfirmed": false, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "Total", + "UnformattedValue": "Total", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": -1.0, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 0, + "ColumnIndex": 1, + "IsHeader": true, + "IsMissing": false, + "OperatorConfirmed": false, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "From", + "UnformattedValue": "From", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": -1.0, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 0, + "ColumnIndex": 2, + "IsHeader": true, + "IsMissing": false, + "OperatorConfirmed": false, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "To", + "UnformattedValue": "To", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": -1.0, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 0, + "ColumnIndex": 3, + "IsHeader": true, + "IsMissing": false, + "OperatorConfirmed": false, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "Dummy field", + "UnformattedValue": "Dummy field", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": -1.0, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 1, + "ColumnIndex": 0, + "IsHeader": false, + "IsMissing": false, + "OperatorConfirmed": false, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "66.79 RON", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 620, + "TextLength": 9, + "Tokens": [ + { + "TextStartIndex": 620, + "TextLength": 3, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[304.3017, 479.8387, 19.9318, 6.6474]] + }, + { + "TextStartIndex": 624, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[304.3017, 503.4615, 23.6228, 6.6474]] + } + ] + }, + "DerivedFields": [], + "Confidence": 0.9975757, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 1, + "ColumnIndex": 1, + "IsHeader": false, + "IsMissing": false, + "OperatorConfirmed": false, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "Aleea Muscel 9, Cluj-Napoca 400347, Romania", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 337, + "TextLength": 43, + "Tokens": [ + { + "TextStartIndex": 337, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[423.9544, 122.5434, 19.1935, 7.386]] + }, + { + "TextStartIndex": 343, + "TextLength": 6, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[423.9544, 143.9516, 22.8846, 7.386]] + }, + { + "TextStartIndex": 350, + "TextLength": 2, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[423.9544, 168.3126, 4.4293, 7.386]] + }, + { + "TextStartIndex": 350, + "TextLength": 2, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[423.9544, 172.7419, 2.2147, 7.386]] + }, + { + "TextStartIndex": 353, + "TextLength": 11, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[423.9544, 176.433, 12.5496, 7.386]] + }, + { + "TextStartIndex": 353, + "TextLength": 11, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[423.9544, 188.9826, 2.9528, 7.386]] + }, + { + "TextStartIndex": 353, + "TextLength": 11, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[423.9544, 191.1973, 24.361, 7.386]] + }, + { + "TextStartIndex": 365, + "TextLength": 7, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[423.9544, 217.773, 24.361, 7.386]] + }, + { + "TextStartIndex": 365, + "TextLength": 7, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[423.9544, 242.134, 2.2147, 7.386]] + }, + { + "TextStartIndex": 373, + "TextLength": 7, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[423.9544, 246.5633, 28.7903, 7.386]] + } + ] + }, + "DerivedFields": [], + "Confidence": 0.9979634, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 1, + "ColumnIndex": 2, + "IsHeader": false, + "IsMissing": false, + "OperatorConfirmed": false, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [ + { + "Components": [], + "Value": "Strada Traian Vuia 149-151, Cluj-Napoca 400397, Romania", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 391, + "TextLength": 55, + "Tokens": [ + { + "TextStartIndex": 391, + "TextLength": 6, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[443.1579, 123.2816, 22.1464, 7.3859]] + }, + { + "TextStartIndex": 398, + "TextLength": 6, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[443.1579, 146.9045, 19.9318, 7.3859]] + }, + { + "TextStartIndex": 405, + "TextLength": 4, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[443.1579, 168.3126, 15.5025, 7.3859]] + }, + { + "TextStartIndex": 410, + "TextLength": 8, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[443.1579, 186.0298, 25.0992, 7.3859]] + }, + { + "TextStartIndex": 410, + "TextLength": 8, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[443.1579, 211.8672, 2.2147, 7.3859]] + }, + { + "TextStartIndex": 419, + "TextLength": 11, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[443.1579, 215.5583, 12.5496, 7.3859]] + }, + { + "TextStartIndex": 419, + "TextLength": 11, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[443.1579, 228.1079, 2.9528, 7.3859]] + }, + { + "TextStartIndex": 419, + "TextLength": 11, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[443.1579, 231.0608, 24.361, 7.3859]] + }, + { + "TextStartIndex": 431, + "TextLength": 7, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[443.1579, 256.8983, 25.0992, 7.3859]] + }, + { + "TextStartIndex": 431, + "TextLength": 7, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[443.1579, 281.2593, 2.2147, 7.3859]] + }, + { + "TextStartIndex": 439, + "TextLength": 7, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[443.1579, 285.6886, 28.7903, 7.3859]] + } + ] + }, + "DerivedFields": [], + "Confidence": 0.99986005, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + { + "RowIndex": 1, + "ColumnIndex": 3, + "IsHeader": false, + "IsMissing": true, + "OperatorConfirmed": false, + "DataSource": "Automatic", + "DataVersion": 0, + "Values": [] + } + ], + "ColumnInfo": [ + { + "FieldId": "Total", + "FieldName": "Total", + "FieldType": "Text" + }, + { + "FieldId": "From", + "FieldName": "From", + "FieldType": "Text" + }, + { + "FieldId": "To", + "FieldName": "To", + "FieldType": "Text" + }, + { + "FieldId": "Dummy field", + "FieldName": "Dummy field", + "FieldType": "Text" + } + ], + "NumberOfRows": 2, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ] + }, + "ExtractorPayloads": null, + "BusinessRulesResults": null + }, + "projectId": "c390c3ab-358a-80ee-9dcf-9ea0d5ca7bb3", + "projectType": "IXP", + "tag": "live", + "documentTypeId": "00000000-0000-0000-0000-000000000000", + "dataProjection": [ + { + "fieldGroupName": "Details", + "fieldValues": [ + { + "id": "Total", + "name": "Total", + "value": "66.79 RON", + "unformattedValue": "", + "confidence": 0.9975757, + "ocrConfidence": 1.0, + "type": "Text" + }, + { + "id": "From", + "name": "From", + "value": "Aleea Muscel 9, Cluj-Napoca 400347, Romania", + "unformattedValue": "", + "confidence": 0.9979634, + "ocrConfidence": 1.0, + "type": "Text" + }, + { + "id": "To", + "name": "To", + "value": "Strada Traian Vuia 149-151, Cluj-Napoca 400397, Romania", + "unformattedValue": "", + "confidence": 0.99986005, + "ocrConfidence": 1.0, + "type": "Text" + }, + { + "id": "Dummy field", + "name": "Dummy field", + "value": null, + "unformattedValue": null, + "confidence": null, + "ocrConfidence": null, + "type": "Text" + } + ] + } + ] +} diff --git a/packages/uipath-platform/tests/services/tests_data/documents_service/modern_extraction_response.json b/packages/uipath-platform/tests/services/tests_data/documents_service/modern_extraction_response.json new file mode 100644 index 000000000..141919c16 --- /dev/null +++ b/packages/uipath-platform/tests/services/tests_data/documents_service/modern_extraction_response.json @@ -0,0 +1,192 @@ +{ + "extractionResult": { + "DocumentId": "da303456-7ba3-f011-8e60-6045bd9ba6d0", + "ResultsVersion": 0, + "ResultsDocument": { + "Bounds": { + "StartPage": 0, + "PageCount": 1, + "TextStartIndex": 0, + "TextLength": 629, + "PageRange": "1" + }, + "Language": "eng", + "DocumentGroup": "", + "DocumentCategory": "", + "DocumentTypeId": "2e4e0ad9-72a3-f011-8e61-000d3a395253", + "DocumentTypeName": "receipts", + "DocumentTypeDataVersion": 0, + "DataVersion": 0, + "DocumentTypeSource": "Automatic", + "DocumentTypeField": { + "Components": [], + "Value": "receipts", + "UnformattedValue": "", + "Reference": { + "TextStartIndex": 0, + "TextLength": 0, + "Tokens": [] + }, + "DerivedFields": [], + "Confidence": 1.0, + "OperatorConfirmed": false, + "OcrConfidence": -1.0, + "TextType": "Unknown", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + "Fields": [ + { + "FieldId": "field-2", + "FieldName": "To", + "FieldType": "Text", + "IsMissing": true, + "DataSource": "Automatic", + "Values": [], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "field-1", + "FieldName": "From", + "FieldType": "Text", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "with", + "UnformattedValue": "with", + "Reference": { + "TextStartIndex": 275, + "TextLength": 4, + "Tokens": [ + { + "TextStartIndex": 275, + "TextLength": 4, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[378.08, 96.32, 12.27, 8.54]] + } + ] + }, + "DerivedFields": [], + "Confidence": 0.56592697, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "date", + "FieldName": "Transaction Date", + "FieldType": "Date", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "2025-12-01", + "UnformattedValue": "December", + "Reference": { + "TextStartIndex": 559, + "TextLength": 8, + "Tokens": [ + { + "TextStartIndex": 559, + "TextLength": 8, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[45.09, 464.79, 33.09, 9.87]] + } + ] + }, + "DerivedFields": [ + { + "FieldId": "Year", + "Value": "2025" + }, + { + "FieldId": "Month", + "Value": "12" + }, + { + "FieldId": "Day", + "Value": "1" + } + ], + "Confidence": 0.8514132, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + }, + { + "FieldId": "total", + "FieldName": "Total Amount", + "FieldType": "Number", + "IsMissing": false, + "DataSource": "Automatic", + "Values": [ + { + "Components": [], + "Value": "66.79", + "UnformattedValue": "66.79", + "Reference": { + "TextStartIndex": 582, + "TextLength": 5, + "Tokens": [ + { + "TextStartIndex": 582, + "TextLength": 5, + "Page": 0, + "PageWidth": 595.0, + "PageHeight": 842.0, + "Boxes": [[143.28, 500.81, 26.95, 14.68]] + } + ] + }, + "DerivedFields": [ + { + "FieldId": "Value", + "Value": "66.79" + } + ], + "Confidence": 0.9245848, + "OperatorConfirmed": false, + "OcrConfidence": 1.0, + "TextType": "Text", + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "DataVersion": 0, + "OperatorConfirmed": false, + "ValidatorNotes": "", + "ValidatorNotesInfo": "" + } + ], + "Tables": [] + }, + "ExtractorPayloads": null, + "BusinessRulesResults": null + } +} diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock new file mode 100644 index 000000000..af0f80415 --- /dev/null +++ b/packages/uipath-platform/uv.lock @@ -0,0 +1,1193 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/76/a7f3e639b78601118aaa4a394db2c66ae2597fbd8c39644c32874ed11e0c/bandit-1.9.3.tar.gz", hash = "sha256:ade4b9b7786f89ef6fc7344a52b34558caec5da74cb90373aed01de88472f774", size = 4242154, upload-time = "2026-01-19T04:05:22.802Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/0b/8bdc52111c83e2dc2f97403dc87c0830b8989d9ae45732b34b686326fb2c/bandit-1.9.3-py3-none-any.whl", hash = "sha256:4745917c88d2246def79748bde5e08b9d5e9b92f877863d43fab70cd8814ce6a", size = 134451, upload-time = "2026-01-19T04:05:20.938Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.24.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-function-models" +version = "0.1.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/9f/9f89602abf782693974d1812657b7b0bab346c011f31d7a05ca71f5643e2/pydantic_function_models-0.1.11.tar.gz", hash = "sha256:28292961bc71f9e4d75ae608ef1cf820ce650ba019067776ee82c6612ccf1cca", size = 9018, upload-time = "2025-11-29T20:16:39.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5c/cd8bd73e893604e335f4347539851a8f8dede32fbf6d7a009f564c2c6681/pydantic_function_models-0.1.11-py3-none-any.whl", hash = "sha256:1c17d45f7b7b95ad1644226a9b8d6d05ce1565a0d0bbe03f4ec86e21487aff2b", size = 8028, upload-time = "2025-11-29T20:16:38.391Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-httpx" +version = "0.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/5574834da9499066fa1a5ea9c336f94dba2eae02298d36dab192fcf95c86/pytest_httpx-0.36.0.tar.gz", hash = "sha256:9edb66a5fd4388ce3c343189bc67e7e1cb50b07c2e3fc83b97d511975e8a831b", size = 56793, upload-time = "2025-12-02T16:34:57.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-trio" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "outcome" }, + { name = "pytest" }, + { name = "trio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/08/056279526554c6c6e6ad6d4a479a338d14dc785ac30be8bdc6ca0153c1be/pytest-trio-0.8.0.tar.gz", hash = "sha256:8363db6336a79e6c53375a2123a41ddbeccc4aa93f93788651641789a56fb52e", size = 46525, upload-time = "2022-11-01T17:24:29.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/22/71953f47e0da5852c899f58cd7a31e6100f37c632b7b9ee52d067613a844/pytest_trio-0.8.0-py3-none-any.whl", hash = "sha256:e6a7e7351ae3e8ec3f4564d30ee77d1ec66e1df611226e5618dbb32f9545c841", size = 27221, upload-time = "2022-11-01T17:24:27.501Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/dc/4e6ac71b511b141cf626357a3946679abeba4cf67bc7cc5a17920f31e10d/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f", size = 4540855, upload-time = "2026-02-12T23:09:09.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/bf/e6e4324238c17f9d9120a9d60aa99a7daaa21204c07fcd84e2ef03bb5fd1/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a", size = 10367819, upload-time = "2026-02-12T23:09:03.598Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/c8f89d32e7912269d38c58f3649e453ac32c528f93bb7f4219258be2e7ed/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602", size = 10798618, upload-time = "2026-02-12T23:09:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0f/1d0d88bc862624247d82c20c10d4c0f6bb2f346559d8af281674cf327f15/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899", size = 10148518, upload-time = "2026-02-12T23:08:58.339Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c8/291c49cefaa4a9248e986256df2ade7add79388fe179e0691be06fae6f37/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16", size = 10518811, upload-time = "2026-02-12T23:09:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/f5707440e5ae43ffa5365cac8bbb91e9665f4a883f560893829cf16a606b/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc", size = 10196169, upload-time = "2026-02-12T23:09:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ff/26ddc8c4da04c8fd3ee65a89c9fb99eaa5c30394269d424461467be2271f/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779", size = 10990491, upload-time = "2026-02-12T23:09:25.503Z" }, + { url = "https://files.pythonhosted.org/packages/fc/00/50920cb385b89413f7cdb4bb9bc8fc59c1b0f30028d8bccc294189a54955/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb", size = 11843280, upload-time = "2026-02-12T23:09:19.88Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6d/2f5cad8380caf5632a15460c323ae326f1e1a2b5b90a6ee7519017a017ca/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83", size = 11274336, upload-time = "2026-02-12T23:09:14.907Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1d/5f56cae1d6c40b8a318513599b35ea4b075d7dc1cd1d04449578c29d1d75/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2", size = 11137288, upload-time = "2026-02-12T23:09:07.475Z" }, + { url = "https://files.pythonhosted.org/packages/cd/20/6f8d7d8f768c93b0382b33b9306b3b999918816da46537d5a61635514635/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454", size = 11070681, upload-time = "2026-02-12T23:08:55.43Z" }, + { url = "https://files.pythonhosted.org/packages/9a/67/d640ac76069f64cdea59dba02af2e00b1fa30e2103c7f8d049c0cff4cafd/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c", size = 10486401, upload-time = "2026-02-12T23:09:27.927Z" }, + { url = "https://files.pythonhosted.org/packages/65/3d/e1429f64a3ff89297497916b88c32a5cc88eeca7e9c787072d0e7f1d3e1e/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330", size = 10197452, upload-time = "2026-02-12T23:09:12.147Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/e2c3bade17dad63bf1e1c2ffaf11490603b760be149e1419b07049b36ef2/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61", size = 10693900, upload-time = "2026-02-12T23:09:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/a1/27/fdc0e11a813e6338e0706e8b39bb7a1d61ea5b36873b351acee7e524a72a/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f", size = 11227302, upload-time = "2026-02-12T23:09:36.536Z" }, + { url = "https://files.pythonhosted.org/packages/f6/58/ac864a75067dcbd3b95be5ab4eb2b601d7fbc3d3d736a27e391a4f92a5c1/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098", size = 10462555, upload-time = "2026-02-12T23:09:29.899Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5e/d4ccc8a27ecdb78116feac4935dfc39d1304536f4296168f91ed3ec00cd2/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336", size = 11599956, upload-time = "2026-02-12T23:09:01.157Z" }, + { url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" }, +] + +[[package]] +name = "rust-just" +version = "1.46.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/50/3828817f76e19977a4048c2c8b39a7f48babc21dd9dbed4af2f3c18d4570/rust_just-1.46.0.tar.gz", hash = "sha256:84437481c814577529835132e2cc5fcc35a981c1712e4877cb20fc2f5ec5b2d6", size = 1447346, upload-time = "2026-01-03T02:03:17.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/0b/a5bf2707b02a484d91f8275efa39f76fe19304f5bfba82293fa4b18608d2/rust_just-1.46.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7d6d4c67a443f1acb1f78f9ba4b3349fa04f17e8be2d4448b771cdc93a382812", size = 1739556, upload-time = "2026-01-03T02:02:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ae/40bcd996ccb2fcb0152b5bfde7beaf3840877a8837611421c495b45c82da/rust_just-1.46.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0caf9b77d30455558d017c9e625ce94c373f88d81656477127727604fa5d36ab", size = 1620974, upload-time = "2026-01-03T02:02:45.341Z" }, + { url = "https://files.pythonhosted.org/packages/62/36/7067e0eaf674ed7c98b35ed50d713c0c885f2d2b57847a627e11502da1b8/rust_just-1.46.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b63521acd91c65164c202ded3ae730130c7fb4377f59cd2f9847b45161c94fd", size = 1703423, upload-time = "2026-01-03T02:02:47.681Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/3e98182f5e03c48880d647651385863552a3e24cfec5c51d116c06e6f180/rust_just-1.46.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46a35110c7acf27bdded79cf3bbfea9eb80a53f6f81f374248fe3340584c92e5", size = 1666645, upload-time = "2026-01-03T02:02:50.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/5e/b9badf6e6982e5744f076d12ab911e5ac8b4b03a0674bab4f498ed9d0b4c/rust_just-1.46.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a2407cefc2e5ed4527297747bd5bcb61a885776021cb2438c3a7b118b2cabc2", size = 1847430, upload-time = "2026-01-03T02:02:52.716Z" }, + { url = "https://files.pythonhosted.org/packages/98/10/6916d7c862b99de600a1fd3739d13353c220dfbc0229a0b2c5012c2f801d/rust_just-1.46.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0dab319619f600561b993242312a344953b1ea44637b30257af905a70ce6f568", size = 1926224, upload-time = "2026-01-03T02:02:55.194Z" }, + { url = "https://files.pythonhosted.org/packages/24/93/18bc615e68a80f43105d5e7cc3571e85776aec829ac40faae4de5d5dc2f3/rust_just-1.46.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3b4c26dd86e5d96047fc0935967f22cb9f49c687767d78b7d3fe511eba39ffa", size = 1902165, upload-time = "2026-01-03T02:02:57.636Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/e3c19a24ff64e78a04df0bdf4c61e15c28dcac8b7b5c3a5505eb5749d40a/rust_just-1.46.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acdb496ca26efc508be0e625309b74b1f6316b4f7295d13247c3b791dfa77eb1", size = 1835209, upload-time = "2026-01-03T02:03:00.256Z" }, + { url = "https://files.pythonhosted.org/packages/26/75/0850c38e41025794826165329a097f657152902a785c0579f213b7d61ae6/rust_just-1.46.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3cf1c32a258f8ee44af877ec271e2eea257923a3303a6d2610b0b5f1523daaab", size = 1719519, upload-time = "2026-01-03T02:03:02.774Z" }, + { url = "https://files.pythonhosted.org/packages/48/85/53c6ee2b9cdbbe1bd43cd0f8096036c29e9e6ba2d3d6344206c490e2ce18/rust_just-1.46.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:657ab85882c124b0fbcd75763035d0dbd20b06c582cc6d4f55017d7b517d5a89", size = 1685664, upload-time = "2026-01-03T02:03:05.514Z" }, + { url = "https://files.pythonhosted.org/packages/33/04/1ad3a66bef0d0f554f0f9971b048bbaf7b3955458f3fda47b48fbf8ff009/rust_just-1.46.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:644b71bfe68863b71ee2618a88dbfd446ea70e2dcfa7b0e5eaec7b6dc4faceca", size = 1838231, upload-time = "2026-01-03T02:03:07.618Z" }, + { url = "https://files.pythonhosted.org/packages/ab/75/33c2e887a68e57b356cda74d325d6ebe406bb72ad8c4e2d067d4fa9b697b/rust_just-1.46.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e9d8a879fb86eb1c7f7f83953999ae4ce53ea4e5c0ca531cf6ff09e1e9335ff7", size = 1900319, upload-time = "2026-01-03T02:03:09.873Z" }, + { url = "https://files.pythonhosted.org/packages/44/30/6b1677aa64a4f69f3ec174b5e2a9a49e0ffd06946d4b4dc8295366fbd9dd/rust_just-1.46.0-py3-none-win32.whl", hash = "sha256:100701de91bded3f6f2bf564d09c2f8e483b8dfb490d1c74008ce3c01ff0ff67", size = 1623463, upload-time = "2026-01-03T02:03:12.343Z" }, + { url = "https://files.pythonhosted.org/packages/64/61/97ad7a1ea67b9485404b18150c258015842cf116a1ce626421863fd8f0e1/rust_just-1.46.0-py3-none-win_amd64.whl", hash = "sha256:ccaf8e473f64f5c815b0039e883a1feaf5634b9cdffd1dbff9e5fde77b5926f4", size = 1801103, upload-time = "2026-01-03T02:03:15.256Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "stevedore" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/5b/496f8abebd10c3301129abba7ddafd46c71d799a70c44ab080323987c4c9/stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945", size = 516074, upload-time = "2025-11-20T10:06:07.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/40/8561ce06dc46fd17242c7724ab25b257a2ac1b35f4ebf551b40ce6105cfa/stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", size = 54428, upload-time = "2025-11-20T10:06:05.946Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "trio" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uipath-core" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/76/568bbe81e2c502b0b3d34b35f0f2d7557ceed58fc9161820d186276b47ac/uipath_core-0.5.3.tar.gz", hash = "sha256:5ff386c9bf85006648f111496b74534925fab1de4b35d5d0c2f6dfdf81e6e103", size = 119096, upload-time = "2026-02-25T14:08:47.548Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/35/87a346abe7485c0a63802487050e3550723bfd97925f85cc8814d34bb2a3/uipath_core-0.5.3-py3-none-any.whl", hash = "sha256:2ad9670d3d8e62d7e4f5ed090dffeff00281b8d20d159fff67cac941889d6748", size = 42858, upload-time = "2026-02-25T14:08:46.037Z" }, +] + +[[package]] +name = "uipath-platform" +version = "0.0.11" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic-function-models" }, + { name = "tenacity" }, + { name = "truststore" }, + { name = "uipath-core" }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-httpx" }, + { name = "pytest-mock" }, + { name = "pytest-trio" }, + { name = "ruff" }, + { name = "rust-just" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pydantic-function-models", specifier = ">=0.1.11" }, + { name = "tenacity", specifier = ">=9.0.0" }, + { name = "truststore", specifier = ">=0.10.1" }, + { name = "uipath-core", specifier = ">=0.5.3,<0.6.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.8.2" }, + { name = "mypy", specifier = ">=1.19.0" }, + { name = "pre-commit", specifier = ">=4.1.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, + { name = "pytest-cov", specifier = ">=4.1.0" }, + { name = "pytest-httpx", specifier = ">=0.35.0" }, + { name = "pytest-mock", specifier = ">=3.11.1" }, + { name = "pytest-trio", specifier = ">=0.8.0" }, + { name = "ruff", specifier = ">=0.9.4" }, + { name = "rust-just", specifier = ">=1.39.0" }, +] + +[[package]] +name = "virtualenv" +version = "20.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/03/a94d404ca09a89a7301a7008467aed525d4cdeb9186d262154dd23208709/virtualenv-20.38.0.tar.gz", hash = "sha256:94f39b1abaea5185bf7ea5a46702b56f1d0c9aa2f41a6c2b8b0af4ddc74c10a7", size = 5864558, upload-time = "2026-02-19T07:48:02.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] diff --git a/packages/uipath/.python-version b/packages/uipath/.python-version new file mode 100644 index 000000000..2c0733315 --- /dev/null +++ b/packages/uipath/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/CONTRIBUTING.md b/packages/uipath/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to packages/uipath/CONTRIBUTING.md diff --git a/README.md b/packages/uipath/README.md similarity index 100% rename from README.md rename to packages/uipath/README.md diff --git a/docs/AutomationSuite.md b/packages/uipath/docs/AutomationSuite.md similarity index 100% rename from docs/AutomationSuite.md rename to packages/uipath/docs/AutomationSuite.md diff --git a/docs/CONTRIBUTING.md b/packages/uipath/docs/CONTRIBUTING.md similarity index 100% rename from docs/CONTRIBUTING.md rename to packages/uipath/docs/CONTRIBUTING.md diff --git a/docs/FAQ.md b/packages/uipath/docs/FAQ.md similarity index 100% rename from docs/FAQ.md rename to packages/uipath/docs/FAQ.md diff --git a/docs/assets/env-preparation-failed-dark.png b/packages/uipath/docs/assets/env-preparation-failed-dark.png similarity index 100% rename from docs/assets/env-preparation-failed-dark.png rename to packages/uipath/docs/assets/env-preparation-failed-dark.png diff --git a/docs/assets/env-preparation-failed-light.png b/packages/uipath/docs/assets/env-preparation-failed-light.png similarity index 100% rename from docs/assets/env-preparation-failed-light.png rename to packages/uipath/docs/assets/env-preparation-failed-light.png diff --git a/docs/assets/favicon.png b/packages/uipath/docs/assets/favicon.png similarity index 100% rename from docs/assets/favicon.png rename to packages/uipath/docs/assets/favicon.png diff --git a/docs/assets/llms.txt b/packages/uipath/docs/assets/llms.txt similarity index 100% rename from docs/assets/llms.txt rename to packages/uipath/docs/assets/llms.txt diff --git a/docs/assets/logo-dark.svg b/packages/uipath/docs/assets/logo-dark.svg similarity index 100% rename from docs/assets/logo-dark.svg rename to packages/uipath/docs/assets/logo-dark.svg diff --git a/docs/assets/logo-light.svg b/packages/uipath/docs/assets/logo-light.svg similarity index 100% rename from docs/assets/logo-light.svg rename to packages/uipath/docs/assets/logo-light.svg diff --git a/docs/cli/index.md b/packages/uipath/docs/cli/index.md similarity index 100% rename from docs/cli/index.md rename to packages/uipath/docs/cli/index.md diff --git a/docs/core/assets.md b/packages/uipath/docs/core/assets.md similarity index 100% rename from docs/core/assets.md rename to packages/uipath/docs/core/assets.md diff --git a/docs/core/assets/cloud_env_var_dark.gif b/packages/uipath/docs/core/assets/cloud_env_var_dark.gif similarity index 100% rename from docs/core/assets/cloud_env_var_dark.gif rename to packages/uipath/docs/core/assets/cloud_env_var_dark.gif diff --git a/docs/core/assets/cloud_env_var_light.gif b/packages/uipath/docs/core/assets/cloud_env_var_light.gif similarity index 100% rename from docs/core/assets/cloud_env_var_light.gif rename to packages/uipath/docs/core/assets/cloud_env_var_light.gif diff --git a/docs/core/assets/cloud_env_var_secret_dark.png b/packages/uipath/docs/core/assets/cloud_env_var_secret_dark.png similarity index 100% rename from docs/core/assets/cloud_env_var_secret_dark.png rename to packages/uipath/docs/core/assets/cloud_env_var_secret_dark.png diff --git a/docs/core/assets/cloud_env_var_secret_light.png b/packages/uipath/docs/core/assets/cloud_env_var_secret_light.png similarity index 100% rename from docs/core/assets/cloud_env_var_secret_light.png rename to packages/uipath/docs/core/assets/cloud_env_var_secret_light.png diff --git a/docs/core/assets/copy_path_dark.png b/packages/uipath/docs/core/assets/copy_path_dark.png similarity index 100% rename from docs/core/assets/copy_path_dark.png rename to packages/uipath/docs/core/assets/copy_path_dark.png diff --git a/docs/core/assets/copy_path_light.png b/packages/uipath/docs/core/assets/copy_path_light.png similarity index 100% rename from docs/core/assets/copy_path_light.png rename to packages/uipath/docs/core/assets/copy_path_light.png diff --git a/docs/core/attachments.md b/packages/uipath/docs/core/attachments.md similarity index 100% rename from docs/core/attachments.md rename to packages/uipath/docs/core/attachments.md diff --git a/docs/core/buckets.md b/packages/uipath/docs/core/buckets.md similarity index 100% rename from docs/core/buckets.md rename to packages/uipath/docs/core/buckets.md diff --git a/docs/core/connections.md b/packages/uipath/docs/core/connections.md similarity index 100% rename from docs/core/connections.md rename to packages/uipath/docs/core/connections.md diff --git a/docs/core/context_grounding.md b/packages/uipath/docs/core/context_grounding.md similarity index 100% rename from docs/core/context_grounding.md rename to packages/uipath/docs/core/context_grounding.md diff --git a/docs/core/documents.md b/packages/uipath/docs/core/documents.md similarity index 100% rename from docs/core/documents.md rename to packages/uipath/docs/core/documents.md diff --git a/docs/core/documents_models.md b/packages/uipath/docs/core/documents_models.md similarity index 100% rename from docs/core/documents_models.md rename to packages/uipath/docs/core/documents_models.md diff --git a/docs/core/entities.md b/packages/uipath/docs/core/entities.md similarity index 100% rename from docs/core/entities.md rename to packages/uipath/docs/core/entities.md diff --git a/docs/core/environment_variables.md b/packages/uipath/docs/core/environment_variables.md similarity index 100% rename from docs/core/environment_variables.md rename to packages/uipath/docs/core/environment_variables.md diff --git a/docs/core/getting_started.md b/packages/uipath/docs/core/getting_started.md similarity index 100% rename from docs/core/getting_started.md rename to packages/uipath/docs/core/getting_started.md diff --git a/docs/core/guardrails.md b/packages/uipath/docs/core/guardrails.md similarity index 100% rename from docs/core/guardrails.md rename to packages/uipath/docs/core/guardrails.md diff --git a/docs/core/jobs.md b/packages/uipath/docs/core/jobs.md similarity index 100% rename from docs/core/jobs.md rename to packages/uipath/docs/core/jobs.md diff --git a/docs/core/llm_gateway.md b/packages/uipath/docs/core/llm_gateway.md similarity index 100% rename from docs/core/llm_gateway.md rename to packages/uipath/docs/core/llm_gateway.md diff --git a/docs/core/processes.md b/packages/uipath/docs/core/processes.md similarity index 100% rename from docs/core/processes.md rename to packages/uipath/docs/core/processes.md diff --git a/docs/core/queues.md b/packages/uipath/docs/core/queues.md similarity index 100% rename from docs/core/queues.md rename to packages/uipath/docs/core/queues.md diff --git a/docs/core/release_notes.md b/packages/uipath/docs/core/release_notes.md similarity index 100% rename from docs/core/release_notes.md rename to packages/uipath/docs/core/release_notes.md diff --git a/docs/core/resource_catalog.md b/packages/uipath/docs/core/resource_catalog.md similarity index 100% rename from docs/core/resource_catalog.md rename to packages/uipath/docs/core/resource_catalog.md diff --git a/docs/core/tasks.md b/packages/uipath/docs/core/tasks.md similarity index 100% rename from docs/core/tasks.md rename to packages/uipath/docs/core/tasks.md diff --git a/docs/core/traced.md b/packages/uipath/docs/core/traced.md similarity index 100% rename from docs/core/traced.md rename to packages/uipath/docs/core/traced.md diff --git a/docs/eval/contains.md b/packages/uipath/docs/eval/contains.md similarity index 100% rename from docs/eval/contains.md rename to packages/uipath/docs/eval/contains.md diff --git a/docs/eval/custom_evaluators.md b/packages/uipath/docs/eval/custom_evaluators.md similarity index 100% rename from docs/eval/custom_evaluators.md rename to packages/uipath/docs/eval/custom_evaluators.md diff --git a/docs/eval/exact_match.md b/packages/uipath/docs/eval/exact_match.md similarity index 100% rename from docs/eval/exact_match.md rename to packages/uipath/docs/eval/exact_match.md diff --git a/docs/eval/index.md b/packages/uipath/docs/eval/index.md similarity index 100% rename from docs/eval/index.md rename to packages/uipath/docs/eval/index.md diff --git a/docs/eval/json_similarity.md b/packages/uipath/docs/eval/json_similarity.md similarity index 100% rename from docs/eval/json_similarity.md rename to packages/uipath/docs/eval/json_similarity.md diff --git a/docs/eval/llm_judge_output.md b/packages/uipath/docs/eval/llm_judge_output.md similarity index 100% rename from docs/eval/llm_judge_output.md rename to packages/uipath/docs/eval/llm_judge_output.md diff --git a/docs/eval/llm_judge_trajectory.md b/packages/uipath/docs/eval/llm_judge_trajectory.md similarity index 100% rename from docs/eval/llm_judge_trajectory.md rename to packages/uipath/docs/eval/llm_judge_trajectory.md diff --git a/docs/eval/tool_call_args.md b/packages/uipath/docs/eval/tool_call_args.md similarity index 100% rename from docs/eval/tool_call_args.md rename to packages/uipath/docs/eval/tool_call_args.md diff --git a/docs/eval/tool_call_count.md b/packages/uipath/docs/eval/tool_call_count.md similarity index 100% rename from docs/eval/tool_call_count.md rename to packages/uipath/docs/eval/tool_call_count.md diff --git a/docs/eval/tool_call_order.md b/packages/uipath/docs/eval/tool_call_order.md similarity index 100% rename from docs/eval/tool_call_order.md rename to packages/uipath/docs/eval/tool_call_order.md diff --git a/docs/eval/tool_call_output.md b/packages/uipath/docs/eval/tool_call_output.md similarity index 100% rename from docs/eval/tool_call_output.md rename to packages/uipath/docs/eval/tool_call_output.md diff --git a/docs/hooks.py b/packages/uipath/docs/hooks.py similarity index 100% rename from docs/hooks.py rename to packages/uipath/docs/hooks.py diff --git a/docs/index.md b/packages/uipath/docs/index.md similarity index 100% rename from docs/index.md rename to packages/uipath/docs/index.md diff --git a/docs/javascripts/extra.js b/packages/uipath/docs/javascripts/extra.js similarity index 100% rename from docs/javascripts/extra.js rename to packages/uipath/docs/javascripts/extra.js diff --git a/docs/overrides/main.html b/packages/uipath/docs/overrides/main.html similarity index 100% rename from docs/overrides/main.html rename to packages/uipath/docs/overrides/main.html diff --git a/docs/overrides/partials/actions.html b/packages/uipath/docs/overrides/partials/actions.html similarity index 100% rename from docs/overrides/partials/actions.html rename to packages/uipath/docs/overrides/partials/actions.html diff --git a/docs/overrides/partials/logo.html b/packages/uipath/docs/overrides/partials/logo.html similarity index 100% rename from docs/overrides/partials/logo.html rename to packages/uipath/docs/overrides/partials/logo.html diff --git a/docs/release_policy.md b/packages/uipath/docs/release_policy.md similarity index 100% rename from docs/release_policy.md rename to packages/uipath/docs/release_policy.md diff --git a/docs/sample_images/google-ADK-agent/agent-output.png b/packages/uipath/docs/sample_images/google-ADK-agent/agent-output.png similarity index 100% rename from docs/sample_images/google-ADK-agent/agent-output.png rename to packages/uipath/docs/sample_images/google-ADK-agent/agent-output.png diff --git a/docs/sample_images/resource-overrides/package-requirements.png b/packages/uipath/docs/sample_images/resource-overrides/package-requirements.png similarity index 100% rename from docs/sample_images/resource-overrides/package-requirements.png rename to packages/uipath/docs/sample_images/resource-overrides/package-requirements.png diff --git a/docs/stylesheets/extra.css b/packages/uipath/docs/stylesheets/extra.css similarity index 100% rename from docs/stylesheets/extra.css rename to packages/uipath/docs/stylesheets/extra.css diff --git a/justfile b/packages/uipath/justfile similarity index 100% rename from justfile rename to packages/uipath/justfile diff --git a/mkdocs.yml b/packages/uipath/mkdocs.yml similarity index 100% rename from mkdocs.yml rename to packages/uipath/mkdocs.yml diff --git a/tests/agent/react/__init__.py b/packages/uipath/py.typed similarity index 100% rename from tests/agent/react/__init__.py rename to packages/uipath/py.typed diff --git a/pyproject.toml b/packages/uipath/pyproject.toml similarity index 96% rename from pyproject.toml rename to packages/uipath/pyproject.toml index d5d5b4745..987ac171e 100644 --- a/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -147,6 +147,10 @@ show_missing = true [tool.coverage.run] source = ["src"] +[tool.uv.sources] +uipath-core = { path = "../uipath-core", editable = true } +uipath-platform = { path = "../uipath-platform", editable = true } + [[tool.uv.index]] name = "testpypi" url = "https://test.pypi.org/simple/" diff --git a/samples/asset-modifier-agent/.env.example b/packages/uipath/samples/asset-modifier-agent/.env.example similarity index 100% rename from samples/asset-modifier-agent/.env.example rename to packages/uipath/samples/asset-modifier-agent/.env.example diff --git a/samples/asset-modifier-agent/README.md b/packages/uipath/samples/asset-modifier-agent/README.md similarity index 100% rename from samples/asset-modifier-agent/README.md rename to packages/uipath/samples/asset-modifier-agent/README.md diff --git a/samples/asset-modifier-agent/bindings.json b/packages/uipath/samples/asset-modifier-agent/bindings.json similarity index 100% rename from samples/asset-modifier-agent/bindings.json rename to packages/uipath/samples/asset-modifier-agent/bindings.json diff --git a/samples/asset-modifier-agent/input.json b/packages/uipath/samples/asset-modifier-agent/input.json similarity index 100% rename from samples/asset-modifier-agent/input.json rename to packages/uipath/samples/asset-modifier-agent/input.json diff --git a/samples/asset-modifier-agent/main.py b/packages/uipath/samples/asset-modifier-agent/main.py similarity index 100% rename from samples/asset-modifier-agent/main.py rename to packages/uipath/samples/asset-modifier-agent/main.py diff --git a/samples/asset-modifier-agent/pyproject.toml b/packages/uipath/samples/asset-modifier-agent/pyproject.toml similarity index 100% rename from samples/asset-modifier-agent/pyproject.toml rename to packages/uipath/samples/asset-modifier-agent/pyproject.toml diff --git a/samples/asset-modifier-agent/uipath.json b/packages/uipath/samples/asset-modifier-agent/uipath.json similarity index 100% rename from samples/asset-modifier-agent/uipath.json rename to packages/uipath/samples/asset-modifier-agent/uipath.json diff --git a/samples/calculator/README.md b/packages/uipath/samples/calculator/README.md similarity index 100% rename from samples/calculator/README.md rename to packages/uipath/samples/calculator/README.md diff --git a/samples/calculator/bindings.json b/packages/uipath/samples/calculator/bindings.json similarity index 100% rename from samples/calculator/bindings.json rename to packages/uipath/samples/calculator/bindings.json diff --git a/samples/calculator/evaluations/eval-sets/context-precision-multi-model.json b/packages/uipath/samples/calculator/evaluations/eval-sets/context-precision-multi-model.json similarity index 100% rename from samples/calculator/evaluations/eval-sets/context-precision-multi-model.json rename to packages/uipath/samples/calculator/evaluations/eval-sets/context-precision-multi-model.json diff --git a/samples/calculator/evaluations/eval-sets/crash-scenarios.json b/packages/uipath/samples/calculator/evaluations/eval-sets/crash-scenarios.json similarity index 100% rename from samples/calculator/evaluations/eval-sets/crash-scenarios.json rename to packages/uipath/samples/calculator/evaluations/eval-sets/crash-scenarios.json diff --git a/samples/calculator/evaluations/eval-sets/default.json b/packages/uipath/samples/calculator/evaluations/eval-sets/default.json similarity index 100% rename from samples/calculator/evaluations/eval-sets/default.json rename to packages/uipath/samples/calculator/evaluations/eval-sets/default.json diff --git a/samples/calculator/evaluations/eval-sets/eval-level-expected-output-llm-judge.json b/packages/uipath/samples/calculator/evaluations/eval-sets/eval-level-expected-output-llm-judge.json similarity index 100% rename from samples/calculator/evaluations/eval-sets/eval-level-expected-output-llm-judge.json rename to packages/uipath/samples/calculator/evaluations/eval-sets/eval-level-expected-output-llm-judge.json diff --git a/samples/calculator/evaluations/eval-sets/eval-level-expected-output.json b/packages/uipath/samples/calculator/evaluations/eval-sets/eval-level-expected-output.json similarity index 100% rename from samples/calculator/evaluations/eval-sets/eval-level-expected-output.json rename to packages/uipath/samples/calculator/evaluations/eval-sets/eval-level-expected-output.json diff --git a/samples/calculator/evaluations/eval-sets/faithfulness-multi-model.json b/packages/uipath/samples/calculator/evaluations/eval-sets/faithfulness-multi-model.json similarity index 100% rename from samples/calculator/evaluations/eval-sets/faithfulness-multi-model.json rename to packages/uipath/samples/calculator/evaluations/eval-sets/faithfulness-multi-model.json diff --git a/samples/calculator/evaluations/eval-sets/legacy.json b/packages/uipath/samples/calculator/evaluations/eval-sets/legacy.json similarity index 100% rename from samples/calculator/evaluations/eval-sets/legacy.json rename to packages/uipath/samples/calculator/evaluations/eval-sets/legacy.json diff --git a/samples/calculator/evaluations/eval-sets/multi-model.json b/packages/uipath/samples/calculator/evaluations/eval-sets/multi-model.json similarity index 100% rename from samples/calculator/evaluations/eval-sets/multi-model.json rename to packages/uipath/samples/calculator/evaluations/eval-sets/multi-model.json diff --git a/samples/calculator/evaluations/eval-sets/trajectory-multi-model.json b/packages/uipath/samples/calculator/evaluations/eval-sets/trajectory-multi-model.json similarity index 100% rename from samples/calculator/evaluations/eval-sets/trajectory-multi-model.json rename to packages/uipath/samples/calculator/evaluations/eval-sets/trajectory-multi-model.json diff --git a/samples/calculator/evaluations/evaluators/contains.json b/packages/uipath/samples/calculator/evaluations/evaluators/contains.json similarity index 100% rename from samples/calculator/evaluations/evaluators/contains.json rename to packages/uipath/samples/calculator/evaluations/evaluators/contains.json diff --git a/samples/calculator/evaluations/evaluators/context-precision-claude.json b/packages/uipath/samples/calculator/evaluations/evaluators/context-precision-claude.json similarity index 100% rename from samples/calculator/evaluations/evaluators/context-precision-claude.json rename to packages/uipath/samples/calculator/evaluations/evaluators/context-precision-claude.json diff --git a/samples/calculator/evaluations/evaluators/context-precision-gemini.json b/packages/uipath/samples/calculator/evaluations/evaluators/context-precision-gemini.json similarity index 100% rename from samples/calculator/evaluations/evaluators/context-precision-gemini.json rename to packages/uipath/samples/calculator/evaluations/evaluators/context-precision-gemini.json diff --git a/samples/calculator/evaluations/evaluators/correct-operator-evaluator.json b/packages/uipath/samples/calculator/evaluations/evaluators/correct-operator-evaluator.json similarity index 100% rename from samples/calculator/evaluations/evaluators/correct-operator-evaluator.json rename to packages/uipath/samples/calculator/evaluations/evaluators/correct-operator-evaluator.json diff --git a/samples/calculator/evaluations/evaluators/custom/correct_operator.py b/packages/uipath/samples/calculator/evaluations/evaluators/custom/correct_operator.py similarity index 100% rename from samples/calculator/evaluations/evaluators/custom/correct_operator.py rename to packages/uipath/samples/calculator/evaluations/evaluators/custom/correct_operator.py diff --git a/samples/calculator/evaluations/evaluators/custom/types/correct-operator-evaluator-types.json b/packages/uipath/samples/calculator/evaluations/evaluators/custom/types/correct-operator-evaluator-types.json similarity index 100% rename from samples/calculator/evaluations/evaluators/custom/types/correct-operator-evaluator-types.json rename to packages/uipath/samples/calculator/evaluations/evaluators/custom/types/correct-operator-evaluator-types.json diff --git a/samples/calculator/evaluations/evaluators/exact-match.json b/packages/uipath/samples/calculator/evaluations/evaluators/exact-match.json similarity index 100% rename from samples/calculator/evaluations/evaluators/exact-match.json rename to packages/uipath/samples/calculator/evaluations/evaluators/exact-match.json diff --git a/samples/calculator/evaluations/evaluators/faithfulness-claude.json b/packages/uipath/samples/calculator/evaluations/evaluators/faithfulness-claude.json similarity index 100% rename from samples/calculator/evaluations/evaluators/faithfulness-claude.json rename to packages/uipath/samples/calculator/evaluations/evaluators/faithfulness-claude.json diff --git a/samples/calculator/evaluations/evaluators/faithfulness-gemini.json b/packages/uipath/samples/calculator/evaluations/evaluators/faithfulness-gemini.json similarity index 100% rename from samples/calculator/evaluations/evaluators/faithfulness-gemini.json rename to packages/uipath/samples/calculator/evaluations/evaluators/faithfulness-gemini.json diff --git a/samples/calculator/evaluations/evaluators/json-similarity.json b/packages/uipath/samples/calculator/evaluations/evaluators/json-similarity.json similarity index 100% rename from samples/calculator/evaluations/evaluators/json-similarity.json rename to packages/uipath/samples/calculator/evaluations/evaluators/json-similarity.json diff --git a/samples/calculator/evaluations/evaluators/legacy-equality-with-target-key.json b/packages/uipath/samples/calculator/evaluations/evaluators/legacy-equality-with-target-key.json similarity index 100% rename from samples/calculator/evaluations/evaluators/legacy-equality-with-target-key.json rename to packages/uipath/samples/calculator/evaluations/evaluators/legacy-equality-with-target-key.json diff --git a/samples/calculator/evaluations/evaluators/legacy-equality.json b/packages/uipath/samples/calculator/evaluations/evaluators/legacy-equality.json similarity index 100% rename from samples/calculator/evaluations/evaluators/legacy-equality.json rename to packages/uipath/samples/calculator/evaluations/evaluators/legacy-equality.json diff --git a/samples/calculator/evaluations/evaluators/legacy-json-similarity.json b/packages/uipath/samples/calculator/evaluations/evaluators/legacy-json-similarity.json similarity index 100% rename from samples/calculator/evaluations/evaluators/legacy-json-similarity.json rename to packages/uipath/samples/calculator/evaluations/evaluators/legacy-json-similarity.json diff --git a/samples/calculator/evaluations/evaluators/legacy-llm-as-a-judge-haiku-4.5.json b/packages/uipath/samples/calculator/evaluations/evaluators/legacy-llm-as-a-judge-haiku-4.5.json similarity index 100% rename from samples/calculator/evaluations/evaluators/legacy-llm-as-a-judge-haiku-4.5.json rename to packages/uipath/samples/calculator/evaluations/evaluators/legacy-llm-as-a-judge-haiku-4.5.json diff --git a/samples/calculator/evaluations/evaluators/legacy-llm-as-a-judge-sonnet-4.5.json b/packages/uipath/samples/calculator/evaluations/evaluators/legacy-llm-as-a-judge-sonnet-4.5.json similarity index 100% rename from samples/calculator/evaluations/evaluators/legacy-llm-as-a-judge-sonnet-4.5.json rename to packages/uipath/samples/calculator/evaluations/evaluators/legacy-llm-as-a-judge-sonnet-4.5.json diff --git a/samples/calculator/evaluations/evaluators/legacy-llm-as-a-judge.json b/packages/uipath/samples/calculator/evaluations/evaluators/legacy-llm-as-a-judge.json similarity index 100% rename from samples/calculator/evaluations/evaluators/legacy-llm-as-a-judge.json rename to packages/uipath/samples/calculator/evaluations/evaluators/legacy-llm-as-a-judge.json diff --git a/samples/calculator/evaluations/evaluators/legacy-trajectory.json b/packages/uipath/samples/calculator/evaluations/evaluators/legacy-trajectory.json similarity index 100% rename from samples/calculator/evaluations/evaluators/legacy-trajectory.json rename to packages/uipath/samples/calculator/evaluations/evaluators/legacy-trajectory.json diff --git a/samples/calculator/evaluations/evaluators/llm-judge-claude.json b/packages/uipath/samples/calculator/evaluations/evaluators/llm-judge-claude.json similarity index 100% rename from samples/calculator/evaluations/evaluators/llm-judge-claude.json rename to packages/uipath/samples/calculator/evaluations/evaluators/llm-judge-claude.json diff --git a/samples/calculator/evaluations/evaluators/llm-judge-gemini.json b/packages/uipath/samples/calculator/evaluations/evaluators/llm-judge-gemini.json similarity index 100% rename from samples/calculator/evaluations/evaluators/llm-judge-gemini.json rename to packages/uipath/samples/calculator/evaluations/evaluators/llm-judge-gemini.json diff --git a/samples/calculator/evaluations/evaluators/llm-judge-haiku-4.5.json b/packages/uipath/samples/calculator/evaluations/evaluators/llm-judge-haiku-4.5.json similarity index 100% rename from samples/calculator/evaluations/evaluators/llm-judge-haiku-4.5.json rename to packages/uipath/samples/calculator/evaluations/evaluators/llm-judge-haiku-4.5.json diff --git a/samples/calculator/evaluations/evaluators/llm-judge-semantic-similarity.json b/packages/uipath/samples/calculator/evaluations/evaluators/llm-judge-semantic-similarity.json similarity index 100% rename from samples/calculator/evaluations/evaluators/llm-judge-semantic-similarity.json rename to packages/uipath/samples/calculator/evaluations/evaluators/llm-judge-semantic-similarity.json diff --git a/samples/calculator/evaluations/evaluators/llm-judge-sonnet-4.5.json b/packages/uipath/samples/calculator/evaluations/evaluators/llm-judge-sonnet-4.5.json similarity index 100% rename from samples/calculator/evaluations/evaluators/llm-judge-sonnet-4.5.json rename to packages/uipath/samples/calculator/evaluations/evaluators/llm-judge-sonnet-4.5.json diff --git a/samples/calculator/evaluations/evaluators/llm-judge-strict-json-similarity.json b/packages/uipath/samples/calculator/evaluations/evaluators/llm-judge-strict-json-similarity.json similarity index 100% rename from samples/calculator/evaluations/evaluators/llm-judge-strict-json-similarity.json rename to packages/uipath/samples/calculator/evaluations/evaluators/llm-judge-strict-json-similarity.json diff --git a/samples/calculator/evaluations/evaluators/trajectory-claude.json b/packages/uipath/samples/calculator/evaluations/evaluators/trajectory-claude.json similarity index 100% rename from samples/calculator/evaluations/evaluators/trajectory-claude.json rename to packages/uipath/samples/calculator/evaluations/evaluators/trajectory-claude.json diff --git a/samples/calculator/evaluations/evaluators/trajectory-gemini.json b/packages/uipath/samples/calculator/evaluations/evaluators/trajectory-gemini.json similarity index 100% rename from samples/calculator/evaluations/evaluators/trajectory-gemini.json rename to packages/uipath/samples/calculator/evaluations/evaluators/trajectory-gemini.json diff --git a/samples/calculator/evaluations/evaluators/trajectory.json b/packages/uipath/samples/calculator/evaluations/evaluators/trajectory.json similarity index 100% rename from samples/calculator/evaluations/evaluators/trajectory.json rename to packages/uipath/samples/calculator/evaluations/evaluators/trajectory.json diff --git a/samples/calculator/main.py b/packages/uipath/samples/calculator/main.py similarity index 100% rename from samples/calculator/main.py rename to packages/uipath/samples/calculator/main.py diff --git a/samples/calculator/output.json b/packages/uipath/samples/calculator/output.json similarity index 100% rename from samples/calculator/output.json rename to packages/uipath/samples/calculator/output.json diff --git a/samples/calculator/pyproject.toml b/packages/uipath/samples/calculator/pyproject.toml similarity index 100% rename from samples/calculator/pyproject.toml rename to packages/uipath/samples/calculator/pyproject.toml diff --git a/samples/calculator/uipath.json b/packages/uipath/samples/calculator/uipath.json similarity index 100% rename from samples/calculator/uipath.json rename to packages/uipath/samples/calculator/uipath.json diff --git a/src/uipath/_resources/eval.md b/packages/uipath/samples/csv-processor/.claude/commands/eval.md similarity index 100% rename from src/uipath/_resources/eval.md rename to packages/uipath/samples/csv-processor/.claude/commands/eval.md diff --git a/src/uipath/_resources/new-agent.md b/packages/uipath/samples/csv-processor/.claude/commands/new-agent.md similarity index 100% rename from src/uipath/_resources/new-agent.md rename to packages/uipath/samples/csv-processor/.claude/commands/new-agent.md diff --git a/samples/csv-processor/bindings.json b/packages/uipath/samples/csv-processor/bindings.json similarity index 100% rename from samples/csv-processor/bindings.json rename to packages/uipath/samples/csv-processor/bindings.json diff --git a/samples/csv-processor/evaluations/eval-sets/file-input-tests-local.json b/packages/uipath/samples/csv-processor/evaluations/eval-sets/file-input-tests-local.json similarity index 100% rename from samples/csv-processor/evaluations/eval-sets/file-input-tests-local.json rename to packages/uipath/samples/csv-processor/evaluations/eval-sets/file-input-tests-local.json diff --git a/samples/csv-processor/evaluations/evaluators/attachment_created_evaluator.json b/packages/uipath/samples/csv-processor/evaluations/evaluators/attachment_created_evaluator.json similarity index 100% rename from samples/csv-processor/evaluations/evaluators/attachment_created_evaluator.json rename to packages/uipath/samples/csv-processor/evaluations/evaluators/attachment_created_evaluator.json diff --git a/samples/csv-processor/evaluations/evaluators/csv_columns_evaluator.json b/packages/uipath/samples/csv-processor/evaluations/evaluators/csv_columns_evaluator.json similarity index 100% rename from samples/csv-processor/evaluations/evaluators/csv_columns_evaluator.json rename to packages/uipath/samples/csv-processor/evaluations/evaluators/csv_columns_evaluator.json diff --git a/samples/csv-processor/evaluations/evaluators/csv_shape_evaluator.json b/packages/uipath/samples/csv-processor/evaluations/evaluators/csv_shape_evaluator.json similarity index 100% rename from samples/csv-processor/evaluations/evaluators/csv_shape_evaluator.json rename to packages/uipath/samples/csv-processor/evaluations/evaluators/csv_shape_evaluator.json diff --git a/samples/csv-processor/evaluations/evaluators/custom/attachment_created_evaluator.py b/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/attachment_created_evaluator.py similarity index 100% rename from samples/csv-processor/evaluations/evaluators/custom/attachment_created_evaluator.py rename to packages/uipath/samples/csv-processor/evaluations/evaluators/custom/attachment_created_evaluator.py diff --git a/samples/csv-processor/evaluations/evaluators/custom/csv_columns_evaluator.py b/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/csv_columns_evaluator.py similarity index 100% rename from samples/csv-processor/evaluations/evaluators/custom/csv_columns_evaluator.py rename to packages/uipath/samples/csv-processor/evaluations/evaluators/custom/csv_columns_evaluator.py diff --git a/samples/csv-processor/evaluations/evaluators/custom/csv_shape_evaluator.py b/packages/uipath/samples/csv-processor/evaluations/evaluators/custom/csv_shape_evaluator.py similarity index 100% rename from samples/csv-processor/evaluations/evaluators/custom/csv_shape_evaluator.py rename to packages/uipath/samples/csv-processor/evaluations/evaluators/custom/csv_shape_evaluator.py diff --git a/samples/csv-processor/main.py b/packages/uipath/samples/csv-processor/main.py similarity index 100% rename from samples/csv-processor/main.py rename to packages/uipath/samples/csv-processor/main.py diff --git a/samples/csv-processor/pyproject.toml b/packages/uipath/samples/csv-processor/pyproject.toml similarity index 100% rename from samples/csv-processor/pyproject.toml rename to packages/uipath/samples/csv-processor/pyproject.toml diff --git a/samples/csv-processor/test-data/large_dataset.csv b/packages/uipath/samples/csv-processor/test-data/large_dataset.csv similarity index 100% rename from samples/csv-processor/test-data/large_dataset.csv rename to packages/uipath/samples/csv-processor/test-data/large_dataset.csv diff --git a/samples/csv-processor/test-data/minimal.csv b/packages/uipath/samples/csv-processor/test-data/minimal.csv similarity index 100% rename from samples/csv-processor/test-data/minimal.csv rename to packages/uipath/samples/csv-processor/test-data/minimal.csv diff --git a/samples/csv-processor/test-data/sales_data.csv b/packages/uipath/samples/csv-processor/test-data/sales_data.csv similarity index 100% rename from samples/csv-processor/test-data/sales_data.csv rename to packages/uipath/samples/csv-processor/test-data/sales_data.csv diff --git a/samples/csv-processor/uipath.json b/packages/uipath/samples/csv-processor/uipath.json similarity index 100% rename from samples/csv-processor/uipath.json rename to packages/uipath/samples/csv-processor/uipath.json diff --git a/samples/document-understanding-agent/README.md b/packages/uipath/samples/document-understanding-agent/README.md similarity index 100% rename from samples/document-understanding-agent/README.md rename to packages/uipath/samples/document-understanding-agent/README.md diff --git a/samples/document-understanding-agent/bindings.json b/packages/uipath/samples/document-understanding-agent/bindings.json similarity index 100% rename from samples/document-understanding-agent/bindings.json rename to packages/uipath/samples/document-understanding-agent/bindings.json diff --git a/samples/document-understanding-agent/main.py b/packages/uipath/samples/document-understanding-agent/main.py similarity index 100% rename from samples/document-understanding-agent/main.py rename to packages/uipath/samples/document-understanding-agent/main.py diff --git a/samples/document-understanding-agent/pyproject.toml b/packages/uipath/samples/document-understanding-agent/pyproject.toml similarity index 100% rename from samples/document-understanding-agent/pyproject.toml rename to packages/uipath/samples/document-understanding-agent/pyproject.toml diff --git a/samples/document-understanding-agent/src/du_modern.py b/packages/uipath/samples/document-understanding-agent/src/du_modern.py similarity index 100% rename from samples/document-understanding-agent/src/du_modern.py rename to packages/uipath/samples/document-understanding-agent/src/du_modern.py diff --git a/samples/document-understanding-agent/src/ixp.py b/packages/uipath/samples/document-understanding-agent/src/ixp.py similarity index 100% rename from samples/document-understanding-agent/src/ixp.py rename to packages/uipath/samples/document-understanding-agent/src/ixp.py diff --git a/samples/document-understanding-agent/src/pretrained.py b/packages/uipath/samples/document-understanding-agent/src/pretrained.py similarity index 100% rename from samples/document-understanding-agent/src/pretrained.py rename to packages/uipath/samples/document-understanding-agent/src/pretrained.py diff --git a/samples/document-understanding-agent/test.pdf b/packages/uipath/samples/document-understanding-agent/test.pdf similarity index 100% rename from samples/document-understanding-agent/test.pdf rename to packages/uipath/samples/document-understanding-agent/test.pdf diff --git a/samples/document-understanding-agent/uipath.json b/packages/uipath/samples/document-understanding-agent/uipath.json similarity index 100% rename from samples/document-understanding-agent/uipath.json rename to packages/uipath/samples/document-understanding-agent/uipath.json diff --git a/samples/event-trigger/.python-version b/packages/uipath/samples/event-trigger/.python-version similarity index 100% rename from samples/event-trigger/.python-version rename to packages/uipath/samples/event-trigger/.python-version diff --git a/samples/event-trigger/README.md b/packages/uipath/samples/event-trigger/README.md similarity index 100% rename from samples/event-trigger/README.md rename to packages/uipath/samples/event-trigger/README.md diff --git a/samples/event-trigger/bindings.json b/packages/uipath/samples/event-trigger/bindings.json similarity index 100% rename from samples/event-trigger/bindings.json rename to packages/uipath/samples/event-trigger/bindings.json diff --git a/samples/event-trigger/main.py b/packages/uipath/samples/event-trigger/main.py similarity index 100% rename from samples/event-trigger/main.py rename to packages/uipath/samples/event-trigger/main.py diff --git a/samples/event-trigger/pyproject.toml b/packages/uipath/samples/event-trigger/pyproject.toml similarity index 100% rename from samples/event-trigger/pyproject.toml rename to packages/uipath/samples/event-trigger/pyproject.toml diff --git a/samples/event-trigger/uipath.json b/packages/uipath/samples/event-trigger/uipath.json similarity index 100% rename from samples/event-trigger/uipath.json rename to packages/uipath/samples/event-trigger/uipath.json diff --git a/samples/hello-world/README.md b/packages/uipath/samples/hello-world/README.md similarity index 100% rename from samples/hello-world/README.md rename to packages/uipath/samples/hello-world/README.md diff --git a/samples/hello-world/bindings.json b/packages/uipath/samples/hello-world/bindings.json similarity index 100% rename from samples/hello-world/bindings.json rename to packages/uipath/samples/hello-world/bindings.json diff --git a/samples/hello-world/entry-points.json b/packages/uipath/samples/hello-world/entry-points.json similarity index 100% rename from samples/hello-world/entry-points.json rename to packages/uipath/samples/hello-world/entry-points.json diff --git a/samples/hello-world/main.py b/packages/uipath/samples/hello-world/main.py similarity index 100% rename from samples/hello-world/main.py rename to packages/uipath/samples/hello-world/main.py diff --git a/samples/hello-world/pyproject.toml b/packages/uipath/samples/hello-world/pyproject.toml similarity index 100% rename from samples/hello-world/pyproject.toml rename to packages/uipath/samples/hello-world/pyproject.toml diff --git a/samples/hello-world/uipath.json b/packages/uipath/samples/hello-world/uipath.json similarity index 100% rename from samples/hello-world/uipath.json rename to packages/uipath/samples/hello-world/uipath.json diff --git a/samples/list-mcp-agent/bindings.json b/packages/uipath/samples/list-mcp-agent/bindings.json similarity index 100% rename from samples/list-mcp-agent/bindings.json rename to packages/uipath/samples/list-mcp-agent/bindings.json diff --git a/samples/list-mcp-agent/input.json b/packages/uipath/samples/list-mcp-agent/input.json similarity index 100% rename from samples/list-mcp-agent/input.json rename to packages/uipath/samples/list-mcp-agent/input.json diff --git a/samples/list-mcp-agent/main.py b/packages/uipath/samples/list-mcp-agent/main.py similarity index 100% rename from samples/list-mcp-agent/main.py rename to packages/uipath/samples/list-mcp-agent/main.py diff --git a/samples/list-mcp-agent/pyproject.toml b/packages/uipath/samples/list-mcp-agent/pyproject.toml similarity index 100% rename from samples/list-mcp-agent/pyproject.toml rename to packages/uipath/samples/list-mcp-agent/pyproject.toml diff --git a/samples/list-mcp-agent/uipath.json b/packages/uipath/samples/list-mcp-agent/uipath.json similarity index 100% rename from samples/list-mcp-agent/uipath.json rename to packages/uipath/samples/list-mcp-agent/uipath.json diff --git a/samples/llm_chat_agent/README.md b/packages/uipath/samples/llm_chat_agent/README.md similarity index 100% rename from samples/llm_chat_agent/README.md rename to packages/uipath/samples/llm_chat_agent/README.md diff --git a/samples/llm_chat_agent/agent.py b/packages/uipath/samples/llm_chat_agent/agent.py similarity index 100% rename from samples/llm_chat_agent/agent.py rename to packages/uipath/samples/llm_chat_agent/agent.py diff --git a/samples/llm_chat_agent/bindings.json b/packages/uipath/samples/llm_chat_agent/bindings.json similarity index 100% rename from samples/llm_chat_agent/bindings.json rename to packages/uipath/samples/llm_chat_agent/bindings.json diff --git a/samples/llm_chat_agent/pyproject.toml b/packages/uipath/samples/llm_chat_agent/pyproject.toml similarity index 100% rename from samples/llm_chat_agent/pyproject.toml rename to packages/uipath/samples/llm_chat_agent/pyproject.toml diff --git a/samples/llm_chat_agent/uipath.json b/packages/uipath/samples/llm_chat_agent/uipath.json similarity index 100% rename from samples/llm_chat_agent/uipath.json rename to packages/uipath/samples/llm_chat_agent/uipath.json diff --git a/samples/multi-output-agent/bindings.json b/packages/uipath/samples/multi-output-agent/bindings.json similarity index 100% rename from samples/multi-output-agent/bindings.json rename to packages/uipath/samples/multi-output-agent/bindings.json diff --git a/samples/multi-output-agent/evaluations/eval-sets/target-output-key.json b/packages/uipath/samples/multi-output-agent/evaluations/eval-sets/target-output-key.json similarity index 100% rename from samples/multi-output-agent/evaluations/eval-sets/target-output-key.json rename to packages/uipath/samples/multi-output-agent/evaluations/eval-sets/target-output-key.json diff --git a/samples/multi-output-agent/evaluations/evaluators/legacy-equality-array-index.json b/packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-equality-array-index.json similarity index 100% rename from samples/multi-output-agent/evaluations/evaluators/legacy-equality-array-index.json rename to packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-equality-array-index.json diff --git a/samples/multi-output-agent/evaluations/evaluators/legacy-equality-array-tag.json b/packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-equality-array-tag.json similarity index 100% rename from samples/multi-output-agent/evaluations/evaluators/legacy-equality-array-tag.json rename to packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-equality-array-tag.json diff --git a/samples/multi-output-agent/evaluations/evaluators/legacy-equality-deep-nested.json b/packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-equality-deep-nested.json similarity index 100% rename from samples/multi-output-agent/evaluations/evaluators/legacy-equality-deep-nested.json rename to packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-equality-deep-nested.json diff --git a/samples/multi-output-agent/evaluations/evaluators/legacy-equality-flat-key.json b/packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-equality-flat-key.json similarity index 100% rename from samples/multi-output-agent/evaluations/evaluators/legacy-equality-flat-key.json rename to packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-equality-flat-key.json diff --git a/samples/multi-output-agent/evaluations/evaluators/legacy-equality-full-output.json b/packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-equality-full-output.json similarity index 100% rename from samples/multi-output-agent/evaluations/evaluators/legacy-equality-full-output.json rename to packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-equality-full-output.json diff --git a/samples/multi-output-agent/evaluations/evaluators/legacy-equality-nested-path.json b/packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-equality-nested-path.json similarity index 100% rename from samples/multi-output-agent/evaluations/evaluators/legacy-equality-nested-path.json rename to packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-equality-nested-path.json diff --git a/samples/multi-output-agent/evaluations/evaluators/legacy-json-similarity-customer.json b/packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-json-similarity-customer.json similarity index 100% rename from samples/multi-output-agent/evaluations/evaluators/legacy-json-similarity-customer.json rename to packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-json-similarity-customer.json diff --git a/samples/multi-output-agent/evaluations/evaluators/legacy-json-similarity-nested.json b/packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-json-similarity-nested.json similarity index 100% rename from samples/multi-output-agent/evaluations/evaluators/legacy-json-similarity-nested.json rename to packages/uipath/samples/multi-output-agent/evaluations/evaluators/legacy-json-similarity-nested.json diff --git a/samples/multi-output-agent/main.py b/packages/uipath/samples/multi-output-agent/main.py similarity index 100% rename from samples/multi-output-agent/main.py rename to packages/uipath/samples/multi-output-agent/main.py diff --git a/samples/multi-output-agent/pyproject.toml b/packages/uipath/samples/multi-output-agent/pyproject.toml similarity index 100% rename from samples/multi-output-agent/pyproject.toml rename to packages/uipath/samples/multi-output-agent/pyproject.toml diff --git a/samples/multi-output-agent/uipath.json b/packages/uipath/samples/multi-output-agent/uipath.json similarity index 100% rename from samples/multi-output-agent/uipath.json rename to packages/uipath/samples/multi-output-agent/uipath.json diff --git a/samples/pizza-order-function/README.md b/packages/uipath/samples/pizza-order-function/README.md similarity index 100% rename from samples/pizza-order-function/README.md rename to packages/uipath/samples/pizza-order-function/README.md diff --git a/samples/pizza-order-function/bindings.json b/packages/uipath/samples/pizza-order-function/bindings.json similarity index 100% rename from samples/pizza-order-function/bindings.json rename to packages/uipath/samples/pizza-order-function/bindings.json diff --git a/samples/pizza-order-function/main.mermaid b/packages/uipath/samples/pizza-order-function/main.mermaid similarity index 100% rename from samples/pizza-order-function/main.mermaid rename to packages/uipath/samples/pizza-order-function/main.mermaid diff --git a/samples/pizza-order-function/main.py b/packages/uipath/samples/pizza-order-function/main.py similarity index 100% rename from samples/pizza-order-function/main.py rename to packages/uipath/samples/pizza-order-function/main.py diff --git a/samples/pizza-order-function/pyproject.toml b/packages/uipath/samples/pizza-order-function/pyproject.toml similarity index 100% rename from samples/pizza-order-function/pyproject.toml rename to packages/uipath/samples/pizza-order-function/pyproject.toml diff --git a/samples/pizza-order-function/uipath.json b/packages/uipath/samples/pizza-order-function/uipath.json similarity index 100% rename from samples/pizza-order-function/uipath.json rename to packages/uipath/samples/pizza-order-function/uipath.json diff --git a/samples/queue-interactions/README.md b/packages/uipath/samples/queue-interactions/README.md similarity index 100% rename from samples/queue-interactions/README.md rename to packages/uipath/samples/queue-interactions/README.md diff --git a/samples/queue-interactions/bindings.json b/packages/uipath/samples/queue-interactions/bindings.json similarity index 100% rename from samples/queue-interactions/bindings.json rename to packages/uipath/samples/queue-interactions/bindings.json diff --git a/samples/queue-interactions/input.json b/packages/uipath/samples/queue-interactions/input.json similarity index 100% rename from samples/queue-interactions/input.json rename to packages/uipath/samples/queue-interactions/input.json diff --git a/samples/queue-interactions/main.py b/packages/uipath/samples/queue-interactions/main.py similarity index 100% rename from samples/queue-interactions/main.py rename to packages/uipath/samples/queue-interactions/main.py diff --git a/samples/queue-interactions/pyproject.toml b/packages/uipath/samples/queue-interactions/pyproject.toml similarity index 100% rename from samples/queue-interactions/pyproject.toml rename to packages/uipath/samples/queue-interactions/pyproject.toml diff --git a/samples/queue-interactions/uipath.json b/packages/uipath/samples/queue-interactions/uipath.json similarity index 100% rename from samples/queue-interactions/uipath.json rename to packages/uipath/samples/queue-interactions/uipath.json diff --git a/packages/uipath/samples/resource-overrides/.claude/commands/eval.md b/packages/uipath/samples/resource-overrides/.claude/commands/eval.md new file mode 100644 index 000000000..15fa04f7c --- /dev/null +++ b/packages/uipath/samples/resource-overrides/.claude/commands/eval.md @@ -0,0 +1,287 @@ +--- +allowed-tools: Bash, Read, Write, Edit, Glob +description: Create and run agent evaluations +--- + +I'll help you create and run evaluations for your UiPath agent. + +## Step 1: Check project setup + +Let me check your project structure: + +!ls -la evaluations/ entry-points.json 2>/dev/null || echo "NEEDS_SETUP" + +# Check if schemas might be stale (main.py newer than entry-points.json) +!if [ -f main.py ] && [ -f entry-points.json ] && [ main.py -nt entry-points.json ]; then echo "SCHEMAS_MAY_BE_STALE"; fi + +### If NEEDS_SETUP + +If `entry-points.json` doesn't exist, initialize the project first: + +!uv run uipath init + +Then re-run this skill. + +### If SCHEMAS_MAY_BE_STALE + +Your `main.py` is newer than `entry-points.json`. Refresh schemas: + +!uv run uipath init --no-agents-md-override + +## Step 2: What would you like to do? + +1. **Create new eval set** - Set up evaluations from scratch +2. **Add test case** - Add a test to existing eval set +3. **Run evaluations** - Execute tests and see results +4. **Analyze failures** - Debug failing tests + +--- + +## Creating an Eval Set + +First, create the directory structure: + +!mkdir -p evaluations/eval-sets evaluations/evaluators + +Read the agent's Input/Output schema from entry-points.json to understand the data types. + +### Evaluator Selection Guide + +| If your output is... | Use this evaluator | evaluatorTypeId | +|---------------------|-------------------|-----------------| +| Exact string/number | `ExactMatchEvaluator` | `uipath-exact-match` | +| Contains key phrases | `ContainsEvaluator` | `uipath-contains` | +| Semantically correct | `LLMJudgeOutputEvaluator` | `uipath-llm-judge-output-semantic-similarity` | +| JSON with numbers | `JsonSimilarityEvaluator` | `uipath-json-similarity` | + +### Step 1: Create Evaluator Config Files + +**Each evaluator needs a JSON config file** in `evaluations/evaluators/`. + +**ExactMatchEvaluator** (`evaluations/evaluators/exact-match.json`): +```json +{ + "version": "1.0", + "id": "ExactMatchEvaluator", + "name": "ExactMatchEvaluator", + "description": "Checks for exact output match", + "evaluatorTypeId": "uipath-exact-match", + "evaluatorConfig": { + "name": "ExactMatchEvaluator", + "targetOutputKey": "*" + } +} +``` + +**LLMJudgeOutputEvaluator** (`evaluations/evaluators/llm-judge-output.json`): +```json +{ + "version": "1.0", + "id": "LLMJudgeOutputEvaluator", + "name": "LLMJudgeOutputEvaluator", + "description": "Uses LLM to judge semantic similarity", + "evaluatorTypeId": "uipath-llm-judge-output-semantic-similarity", + "evaluatorConfig": { + "name": "LLMJudgeOutputEvaluator", + "model": "gpt-4o-mini-2024-07-18" + } +} +``` + +**JsonSimilarityEvaluator** (`evaluations/evaluators/json-similarity.json`): +```json +{ + "version": "1.0", + "id": "JsonSimilarityEvaluator", + "name": "JsonSimilarityEvaluator", + "description": "Compares JSON structures", + "evaluatorTypeId": "uipath-json-similarity", + "evaluatorConfig": { + "name": "JsonSimilarityEvaluator", + "targetOutputKey": "*" + } +} +``` + +**ContainsEvaluator** (`evaluations/evaluators/contains.json`): +```json +{ + "version": "1.0", + "id": "ContainsEvaluator", + "name": "ContainsEvaluator", + "description": "Checks if output contains text", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "ContainsEvaluator" + } +} +``` + +### Step 2: Create Eval Set + +**Eval Set Template** (`evaluations/eval-sets/default.json`): +```json +{ + "version": "1.0", + "id": "default-eval-set", + "name": "Default Evaluation Set", + "evaluatorRefs": ["ExactMatchEvaluator"], + "evaluations": [ + { + "id": "test-1", + "name": "Test description", + "inputs": { + "field": "value" + }, + "evaluationCriterias": { + "ExactMatchEvaluator": { + "expectedOutput": { + "result": "expected value" + } + } + } + } + ] +} +``` + +**Important notes:** +- `evaluatorRefs` must list ALL evaluators used in any test case +- Each evaluator in `evaluatorRefs` needs a matching JSON config in `evaluations/evaluators/` +- `evaluationCriterias` keys must match entries in `evaluatorRefs` +- Use `expectedOutput` for most evaluators +- LLM evaluators need `model` in their config. Available models are defined in the SDK's `ChatModels` class (`uipath.platform.chat.ChatModels`): + - `gpt-4o-mini-2024-07-18` (recommended for cost-efficiency) + - `gpt-4o-2024-08-06` (higher quality, higher cost) + - `o3-mini-2025-01-31` (latest reasoning model) + - Model availability varies by region and tenant configuration + - Check your UiPath Automation Cloud portal under AI Trust Layer for available models in your region + +--- + +## Adding a Test Case + +When adding a test to an existing eval set: + +1. Read the existing eval set +2. Check which evaluators are in `evaluatorRefs` +3. Add the new test to `evaluations` array +4. If using a new evaluator, add it to `evaluatorRefs` + +### Test Case Template + +```json +{ + "id": "test-{n}", + "name": "Description of what this tests", + "inputs": { }, + "evaluationCriterias": { + "EvaluatorName": { + "expectedOutput": { } + } + } +} +``` + +--- + +## Running Evaluations + +First, read entry-points.json to get the entrypoint name (e.g., `main`): + +!uv run uipath eval main evaluations/eval-sets/default.json --output-file eval-results.json + +**Note:** Replace `main` with your actual entrypoint from entry-points.json. + +### Analyze Results + +After running, read `eval-results.json` and show: +- Pass/fail summary table +- For failures: expected vs actual output +- Suggestions for fixing or changing evaluators + +### Results Format + +```json +{ + "evaluationSetResults": [{ + "evaluationRunResults": [ + { + "evaluationId": "test-1", + "evaluatorId": "ExactMatchEvaluator", + "result": { "score": 1.0 }, + "errorMessage": null + } + ] + }] +} +``` + +- Score 1.0 = PASS +- Score < 1.0 = FAIL (show expected vs actual) +- errorMessage present = ERROR (show message) + +--- + +## Evaluator Reference + +### Deterministic Evaluators + +**ExactMatchEvaluator** - Exact output matching +```json +"ExactMatchEvaluator": { + "expectedOutput": { "result": "exact value" } +} +``` + +**ContainsEvaluator** - Output contains substring +```json +"ContainsEvaluator": { + "searchText": "must contain this" +} +``` + +**JsonSimilarityEvaluator** - JSON comparison with tolerance +```json +"JsonSimilarityEvaluator": { + "expectedOutput": { "value": 10.0 } +} +``` + +### LLM-Based Evaluators + +**LLMJudgeOutputEvaluator** - Semantic correctness +```json +"LLMJudgeOutputEvaluator": { + "expectedOutput": { "summary": "Expected semantic meaning" } +} +``` + +**LLMJudgeTrajectoryEvaluator** - Validate agent reasoning +```json +"LLMJudgeTrajectoryEvaluator": { + "expectedAgentBehavior": "The agent should first fetch data, then process it" +} +``` + +--- + +## Common Issues + +### "No evaluations found" +- Check `evaluations/eval-sets/` directory exists +- Verify JSON file is valid + +### Evaluator not found +- Each evaluator needs a JSON config file in `evaluations/evaluators/` +- Config file must have correct `evaluatorTypeId` (see templates above) +- Config file must have `name` field at root level +- LLM evaluators need `model` in `evaluatorConfig` + +### Evaluator skipped +- Ensure evaluator is listed in root `evaluatorRefs` array +- Check evaluator config file exists in `evaluations/evaluators/` + +### Schema mismatch +- Run `uv run uipath init --no-agents-md-override` to refresh schemas +- Check `entry-points.json` matches your Input/Output models diff --git a/packages/uipath/samples/resource-overrides/.claude/commands/new-agent.md b/packages/uipath/samples/resource-overrides/.claude/commands/new-agent.md new file mode 100644 index 000000000..b1d051866 --- /dev/null +++ b/packages/uipath/samples/resource-overrides/.claude/commands/new-agent.md @@ -0,0 +1,103 @@ +--- +allowed-tools: Bash, Read, Write, Edit, Glob +description: Create a new UiPath coded agent from a description +--- + +I'll help you create a new UiPath coded agent. + +## Step 1: Check existing project + +Let me check if this is an existing UiPath project: + +!ls uipath.json main.py 2>/dev/null || echo "NEW_PROJECT" + +## Step 2: Gather requirements + +**What should this agent do?** + +Please describe: + +- What inputs it needs (e.g., "a file path and bucket name") +- What it should accomplish (e.g., "process CSV data") +- What outputs it should return (e.g., "total count and status") + +I'll generate the agent structure based on your description. + +## Step 3: Generate agent + +After you describe the agent, I will: + +1. Create `main.py` with Input/Output Pydantic models and `async def main()` +2. Add entrypoint to `uipath.json` under `"functions": {"agent_name": "main.py:main"}` +3. Run `uv run uipath init --no-agents-md-override` to generate schemas + +**Template structure** (from .agent/REQUIRED_STRUCTURE.md): + +```python +from pydantic import BaseModel +from uipath.platform import UiPath + +class Input(BaseModel): + """Input fields for the agent.""" + # Fields based on your description + pass + + +class Output(BaseModel): + """Output fields returned by the agent.""" + # Fields based on your description + pass + + +async def main(input: Input) -> Output: + """Main entry point for the agent. + + Args: + input: The input data for the agent. + + Returns: + The output data from the agent. + """ + + uipath = UiPath() + + # TODO: Implement agent logic + return Output() +``` + +**Important notes:** + +- Use `async def main` - many SDK methods are async +- Initialize `UiPath()` inside the function, not at module level +- After creating main.py, add entrypoint to `uipath.json` under `"functions"` + +## Step 4: Update entry-point schemas + +After creating main.py, regenerate the schemas: + +!uv run uipath init --no-agents-md-override + +## Step 5: Verify + +Quick test to verify the setup: + +!uv run uipath run main '{}' 2>&1 | head -30 + +## Summary + +Once complete, you'll have: + +| File | Purpose | +| ------------------- | ----------------------------------- | +| `main.py` | Agent code with Input/Output models | +| `uipath.json` | Project configuration | +| `entry-points.json` | Entry point schemas | +| `bindings.json` | Resource bindings | +| `.agent/` | SDK and CLI reference docs | + +**Next steps:** + +1. Implement your logic in `main()` +2. Test: `uv run uipath run main '{"field": "value"}'` +3. Create `eval_set.json` for evaluations +4. Evaluate: `uv run uipath eval` diff --git a/packages/uipath/samples/resource-overrides/.vscode/launch.json b/packages/uipath/samples/resource-overrides/.vscode/launch.json new file mode 100644 index 000000000..5fa0da7cc --- /dev/null +++ b/packages/uipath/samples/resource-overrides/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Attach", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "justMyCode": false + } + ] +} \ No newline at end of file diff --git a/samples/resource-overrides/README.md b/packages/uipath/samples/resource-overrides/README.md similarity index 100% rename from samples/resource-overrides/README.md rename to packages/uipath/samples/resource-overrides/README.md diff --git a/samples/resource-overrides/bindings.json b/packages/uipath/samples/resource-overrides/bindings.json similarity index 100% rename from samples/resource-overrides/bindings.json rename to packages/uipath/samples/resource-overrides/bindings.json diff --git a/samples/resource-overrides/main.py b/packages/uipath/samples/resource-overrides/main.py similarity index 100% rename from samples/resource-overrides/main.py rename to packages/uipath/samples/resource-overrides/main.py diff --git a/samples/resource-overrides/pyproject.toml b/packages/uipath/samples/resource-overrides/pyproject.toml similarity index 100% rename from samples/resource-overrides/pyproject.toml rename to packages/uipath/samples/resource-overrides/pyproject.toml diff --git a/samples/resource-overrides/uipath.json b/packages/uipath/samples/resource-overrides/uipath.json similarity index 100% rename from samples/resource-overrides/uipath.json rename to packages/uipath/samples/resource-overrides/uipath.json diff --git a/samples/tree-recursive-function/README.md b/packages/uipath/samples/tree-recursive-function/README.md similarity index 100% rename from samples/tree-recursive-function/README.md rename to packages/uipath/samples/tree-recursive-function/README.md diff --git a/samples/tree-recursive-function/bindings.json b/packages/uipath/samples/tree-recursive-function/bindings.json similarity index 100% rename from samples/tree-recursive-function/bindings.json rename to packages/uipath/samples/tree-recursive-function/bindings.json diff --git a/samples/tree-recursive-function/main.mermaid b/packages/uipath/samples/tree-recursive-function/main.mermaid similarity index 100% rename from samples/tree-recursive-function/main.mermaid rename to packages/uipath/samples/tree-recursive-function/main.mermaid diff --git a/samples/tree-recursive-function/main.py b/packages/uipath/samples/tree-recursive-function/main.py similarity index 100% rename from samples/tree-recursive-function/main.py rename to packages/uipath/samples/tree-recursive-function/main.py diff --git a/samples/tree-recursive-function/pyproject.toml b/packages/uipath/samples/tree-recursive-function/pyproject.toml similarity index 100% rename from samples/tree-recursive-function/pyproject.toml rename to packages/uipath/samples/tree-recursive-function/pyproject.toml diff --git a/samples/tree-recursive-function/uipath.json b/packages/uipath/samples/tree-recursive-function/uipath.json similarity index 100% rename from samples/tree-recursive-function/uipath.json rename to packages/uipath/samples/tree-recursive-function/uipath.json diff --git a/samples/weather_tools/README.md b/packages/uipath/samples/weather_tools/README.md similarity index 100% rename from samples/weather_tools/README.md rename to packages/uipath/samples/weather_tools/README.md diff --git a/samples/weather_tools/TOOL_EVALUATORS.md b/packages/uipath/samples/weather_tools/TOOL_EVALUATORS.md similarity index 100% rename from samples/weather_tools/TOOL_EVALUATORS.md rename to packages/uipath/samples/weather_tools/TOOL_EVALUATORS.md diff --git a/samples/weather_tools/bindings.json b/packages/uipath/samples/weather_tools/bindings.json similarity index 100% rename from samples/weather_tools/bindings.json rename to packages/uipath/samples/weather_tools/bindings.json diff --git a/samples/weather_tools/evaluations/eval-sets/default.json b/packages/uipath/samples/weather_tools/evaluations/eval-sets/default.json similarity index 100% rename from samples/weather_tools/evaluations/eval-sets/default.json rename to packages/uipath/samples/weather_tools/evaluations/eval-sets/default.json diff --git a/samples/weather_tools/evaluations/evaluators/tool-call-args.json b/packages/uipath/samples/weather_tools/evaluations/evaluators/tool-call-args.json similarity index 100% rename from samples/weather_tools/evaluations/evaluators/tool-call-args.json rename to packages/uipath/samples/weather_tools/evaluations/evaluators/tool-call-args.json diff --git a/samples/weather_tools/evaluations/evaluators/tool-call-count.json b/packages/uipath/samples/weather_tools/evaluations/evaluators/tool-call-count.json similarity index 100% rename from samples/weather_tools/evaluations/evaluators/tool-call-count.json rename to packages/uipath/samples/weather_tools/evaluations/evaluators/tool-call-count.json diff --git a/samples/weather_tools/evaluations/evaluators/tool-call-order.json b/packages/uipath/samples/weather_tools/evaluations/evaluators/tool-call-order.json similarity index 100% rename from samples/weather_tools/evaluations/evaluators/tool-call-order.json rename to packages/uipath/samples/weather_tools/evaluations/evaluators/tool-call-order.json diff --git a/samples/weather_tools/evaluations/evaluators/tool-call-output.json b/packages/uipath/samples/weather_tools/evaluations/evaluators/tool-call-output.json similarity index 100% rename from samples/weather_tools/evaluations/evaluators/tool-call-output.json rename to packages/uipath/samples/weather_tools/evaluations/evaluators/tool-call-output.json diff --git a/samples/weather_tools/evaluations/evaluators/trajectory.json b/packages/uipath/samples/weather_tools/evaluations/evaluators/trajectory.json similarity index 100% rename from samples/weather_tools/evaluations/evaluators/trajectory.json rename to packages/uipath/samples/weather_tools/evaluations/evaluators/trajectory.json diff --git a/samples/weather_tools/main.py b/packages/uipath/samples/weather_tools/main.py similarity index 100% rename from samples/weather_tools/main.py rename to packages/uipath/samples/weather_tools/main.py diff --git a/samples/weather_tools/pyproject.toml b/packages/uipath/samples/weather_tools/pyproject.toml similarity index 100% rename from samples/weather_tools/pyproject.toml rename to packages/uipath/samples/weather_tools/pyproject.toml diff --git a/samples/weather_tools/uipath.json b/packages/uipath/samples/weather_tools/uipath.json similarity index 100% rename from samples/weather_tools/uipath.json rename to packages/uipath/samples/weather_tools/uipath.json diff --git a/scripts/debug_test.py b/packages/uipath/scripts/debug_test.py similarity index 100% rename from scripts/debug_test.py rename to packages/uipath/scripts/debug_test.py diff --git a/scripts/lint_httpx_client.py b/packages/uipath/scripts/lint_httpx_client.py similarity index 100% rename from scripts/lint_httpx_client.py rename to packages/uipath/scripts/lint_httpx_client.py diff --git a/scripts/update_agents_md.py b/packages/uipath/scripts/update_agents_md.py similarity index 100% rename from scripts/update_agents_md.py rename to packages/uipath/scripts/update_agents_md.py diff --git a/specs/README.md b/packages/uipath/specs/README.md similarity index 100% rename from specs/README.md rename to packages/uipath/specs/README.md diff --git a/specs/bindings.schema.json b/packages/uipath/specs/bindings.schema.json similarity index 100% rename from specs/bindings.schema.json rename to packages/uipath/specs/bindings.schema.json diff --git a/specs/bindings.spec.md b/packages/uipath/specs/bindings.spec.md similarity index 100% rename from specs/bindings.spec.md rename to packages/uipath/specs/bindings.spec.md diff --git a/specs/entry-points.schema.json b/packages/uipath/specs/entry-points.schema.json similarity index 100% rename from specs/entry-points.schema.json rename to packages/uipath/specs/entry-points.schema.json diff --git a/specs/entry-points.spec.md b/packages/uipath/specs/entry-points.spec.md similarity index 100% rename from specs/entry-points.spec.md rename to packages/uipath/specs/entry-points.spec.md diff --git a/specs/uipath.schema.json b/packages/uipath/specs/uipath.schema.json similarity index 100% rename from specs/uipath.schema.json rename to packages/uipath/specs/uipath.schema.json diff --git a/specs/uipath.spec.md b/packages/uipath/specs/uipath.spec.md similarity index 100% rename from specs/uipath.spec.md rename to packages/uipath/specs/uipath.spec.md diff --git a/src/uipath/_cli/README.md b/packages/uipath/src/uipath/_cli/README.md similarity index 100% rename from src/uipath/_cli/README.md rename to packages/uipath/src/uipath/_cli/README.md diff --git a/src/uipath/_cli/__init__.py b/packages/uipath/src/uipath/_cli/__init__.py similarity index 100% rename from src/uipath/_cli/__init__.py rename to packages/uipath/src/uipath/_cli/__init__.py diff --git a/src/uipath/_cli/_auth/_auth_server.py b/packages/uipath/src/uipath/_cli/_auth/_auth_server.py similarity index 100% rename from src/uipath/_cli/_auth/_auth_server.py rename to packages/uipath/src/uipath/_cli/_auth/_auth_server.py diff --git a/src/uipath/_cli/_auth/_auth_service.py b/packages/uipath/src/uipath/_cli/_auth/_auth_service.py similarity index 100% rename from src/uipath/_cli/_auth/_auth_service.py rename to packages/uipath/src/uipath/_cli/_auth/_auth_service.py diff --git a/src/uipath/_cli/_auth/_models.py b/packages/uipath/src/uipath/_cli/_auth/_models.py similarity index 100% rename from src/uipath/_cli/_auth/_models.py rename to packages/uipath/src/uipath/_cli/_auth/_models.py diff --git a/src/uipath/_cli/_auth/_oidc_utils.py b/packages/uipath/src/uipath/_cli/_auth/_oidc_utils.py similarity index 100% rename from src/uipath/_cli/_auth/_oidc_utils.py rename to packages/uipath/src/uipath/_cli/_auth/_oidc_utils.py diff --git a/src/uipath/_cli/_auth/_portal_service.py b/packages/uipath/src/uipath/_cli/_auth/_portal_service.py similarity index 100% rename from src/uipath/_cli/_auth/_portal_service.py rename to packages/uipath/src/uipath/_cli/_auth/_portal_service.py diff --git a/src/uipath/_cli/_auth/_url_utils.py b/packages/uipath/src/uipath/_cli/_auth/_url_utils.py similarity index 100% rename from src/uipath/_cli/_auth/_url_utils.py rename to packages/uipath/src/uipath/_cli/_auth/_url_utils.py diff --git a/src/uipath/_cli/_auth/_utils.py b/packages/uipath/src/uipath/_cli/_auth/_utils.py similarity index 100% rename from src/uipath/_cli/_auth/_utils.py rename to packages/uipath/src/uipath/_cli/_auth/_utils.py diff --git a/src/uipath/_cli/_auth/auth_config_25_10.json b/packages/uipath/src/uipath/_cli/_auth/auth_config_25_10.json similarity index 100% rename from src/uipath/_cli/_auth/auth_config_25_10.json rename to packages/uipath/src/uipath/_cli/_auth/auth_config_25_10.json diff --git a/src/uipath/_cli/_auth/auth_config_cloud.json b/packages/uipath/src/uipath/_cli/_auth/auth_config_cloud.json similarity index 100% rename from src/uipath/_cli/_auth/auth_config_cloud.json rename to packages/uipath/src/uipath/_cli/_auth/auth_config_cloud.json diff --git a/src/uipath/_cli/_auth/index.html b/packages/uipath/src/uipath/_cli/_auth/index.html similarity index 100% rename from src/uipath/_cli/_auth/index.html rename to packages/uipath/src/uipath/_cli/_auth/index.html diff --git a/src/uipath/_cli/_auth/localhost.crt b/packages/uipath/src/uipath/_cli/_auth/localhost.crt similarity index 100% rename from src/uipath/_cli/_auth/localhost.crt rename to packages/uipath/src/uipath/_cli/_auth/localhost.crt diff --git a/src/uipath/_cli/_auth/localhost.key b/packages/uipath/src/uipath/_cli/_auth/localhost.key similarity index 100% rename from src/uipath/_cli/_auth/localhost.key rename to packages/uipath/src/uipath/_cli/_auth/localhost.key diff --git a/src/uipath/_cli/_chat/_bridge.py b/packages/uipath/src/uipath/_cli/_chat/_bridge.py similarity index 100% rename from src/uipath/_cli/_chat/_bridge.py rename to packages/uipath/src/uipath/_cli/_chat/_bridge.py diff --git a/src/uipath/_cli/_debug/_bridge.py b/packages/uipath/src/uipath/_cli/_debug/_bridge.py similarity index 100% rename from src/uipath/_cli/_debug/_bridge.py rename to packages/uipath/src/uipath/_cli/_debug/_bridge.py diff --git a/tests/cli/chat/__init__.py b/packages/uipath/src/uipath/_cli/_evals/__init__.py similarity index 100% rename from tests/cli/chat/__init__.py rename to packages/uipath/src/uipath/_cli/_evals/__init__.py diff --git a/src/uipath/_cli/_evals/_console_progress_reporter.py b/packages/uipath/src/uipath/_cli/_evals/_console_progress_reporter.py similarity index 100% rename from src/uipath/_cli/_evals/_console_progress_reporter.py rename to packages/uipath/src/uipath/_cli/_evals/_console_progress_reporter.py diff --git a/src/uipath/_cli/_evals/_progress_reporter.py b/packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py similarity index 100% rename from src/uipath/_cli/_evals/_progress_reporter.py rename to packages/uipath/src/uipath/_cli/_evals/_progress_reporter.py diff --git a/src/uipath/_cli/_evals/_telemetry.py b/packages/uipath/src/uipath/_cli/_evals/_telemetry.py similarity index 100% rename from src/uipath/_cli/_evals/_telemetry.py rename to packages/uipath/src/uipath/_cli/_evals/_telemetry.py diff --git a/src/uipath/_cli/_push/models.py b/packages/uipath/src/uipath/_cli/_push/models.py similarity index 100% rename from src/uipath/_cli/_push/models.py rename to packages/uipath/src/uipath/_cli/_push/models.py diff --git a/src/uipath/_cli/_push/sw_file_handler.py b/packages/uipath/src/uipath/_cli/_push/sw_file_handler.py similarity index 100% rename from src/uipath/_cli/_push/sw_file_handler.py rename to packages/uipath/src/uipath/_cli/_push/sw_file_handler.py diff --git a/src/uipath/_cli/_telemetry.py b/packages/uipath/src/uipath/_cli/_telemetry.py similarity index 100% rename from src/uipath/_cli/_telemetry.py rename to packages/uipath/src/uipath/_cli/_telemetry.py diff --git a/src/uipath/_cli/_templates/.psmdcp.template b/packages/uipath/src/uipath/_cli/_templates/.psmdcp.template similarity index 100% rename from src/uipath/_cli/_templates/.psmdcp.template rename to packages/uipath/src/uipath/_cli/_templates/.psmdcp.template diff --git a/src/uipath/_cli/_templates/.rels.template b/packages/uipath/src/uipath/_cli/_templates/.rels.template similarity index 100% rename from src/uipath/_cli/_templates/.rels.template rename to packages/uipath/src/uipath/_cli/_templates/.rels.template diff --git a/src/uipath/_cli/_templates/[Content_Types].xml.template b/packages/uipath/src/uipath/_cli/_templates/[Content_Types].xml.template similarity index 100% rename from src/uipath/_cli/_templates/[Content_Types].xml.template rename to packages/uipath/src/uipath/_cli/_templates/[Content_Types].xml.template diff --git a/src/uipath/_cli/_templates/custom_evaluator.py.template b/packages/uipath/src/uipath/_cli/_templates/custom_evaluator.py.template similarity index 100% rename from src/uipath/_cli/_templates/custom_evaluator.py.template rename to packages/uipath/src/uipath/_cli/_templates/custom_evaluator.py.template diff --git a/src/uipath/_cli/_templates/main.py.template b/packages/uipath/src/uipath/_cli/_templates/main.py.template similarity index 100% rename from src/uipath/_cli/_templates/main.py.template rename to packages/uipath/src/uipath/_cli/_templates/main.py.template diff --git a/src/uipath/_cli/_templates/package.nuspec.template b/packages/uipath/src/uipath/_cli/_templates/package.nuspec.template similarity index 100% rename from src/uipath/_cli/_templates/package.nuspec.template rename to packages/uipath/src/uipath/_cli/_templates/package.nuspec.template diff --git a/src/uipath/_cli/_utils/_common.py b/packages/uipath/src/uipath/_cli/_utils/_common.py similarity index 100% rename from src/uipath/_cli/_utils/_common.py rename to packages/uipath/src/uipath/_cli/_utils/_common.py diff --git a/src/uipath/_cli/_utils/_console.py b/packages/uipath/src/uipath/_cli/_utils/_console.py similarity index 100% rename from src/uipath/_cli/_utils/_console.py rename to packages/uipath/src/uipath/_cli/_utils/_console.py diff --git a/src/uipath/_cli/_utils/_constants.py b/packages/uipath/src/uipath/_cli/_utils/_constants.py similarity index 100% rename from src/uipath/_cli/_utils/_constants.py rename to packages/uipath/src/uipath/_cli/_utils/_constants.py diff --git a/src/uipath/_cli/_utils/_context.py b/packages/uipath/src/uipath/_cli/_utils/_context.py similarity index 100% rename from src/uipath/_cli/_utils/_context.py rename to packages/uipath/src/uipath/_cli/_utils/_context.py diff --git a/src/uipath/_cli/_utils/_debug.py b/packages/uipath/src/uipath/_cli/_utils/_debug.py similarity index 100% rename from src/uipath/_cli/_utils/_debug.py rename to packages/uipath/src/uipath/_cli/_utils/_debug.py diff --git a/src/uipath/_cli/_utils/_folders.py b/packages/uipath/src/uipath/_cli/_utils/_folders.py similarity index 100% rename from src/uipath/_cli/_utils/_folders.py rename to packages/uipath/src/uipath/_cli/_utils/_folders.py diff --git a/src/uipath/_cli/_utils/_formatters.py b/packages/uipath/src/uipath/_cli/_utils/_formatters.py similarity index 100% rename from src/uipath/_cli/_utils/_formatters.py rename to packages/uipath/src/uipath/_cli/_utils/_formatters.py diff --git a/src/uipath/_cli/_utils/_help_json.py b/packages/uipath/src/uipath/_cli/_utils/_help_json.py similarity index 100% rename from src/uipath/_cli/_utils/_help_json.py rename to packages/uipath/src/uipath/_cli/_utils/_help_json.py diff --git a/src/uipath/_cli/_utils/_parallelization.py b/packages/uipath/src/uipath/_cli/_utils/_parallelization.py similarity index 100% rename from src/uipath/_cli/_utils/_parallelization.py rename to packages/uipath/src/uipath/_cli/_utils/_parallelization.py diff --git a/src/uipath/_cli/_utils/_processes.py b/packages/uipath/src/uipath/_cli/_utils/_processes.py similarity index 100% rename from src/uipath/_cli/_utils/_processes.py rename to packages/uipath/src/uipath/_cli/_utils/_processes.py diff --git a/src/uipath/_cli/_utils/_project_files.py b/packages/uipath/src/uipath/_cli/_utils/_project_files.py similarity index 100% rename from src/uipath/_cli/_utils/_project_files.py rename to packages/uipath/src/uipath/_cli/_utils/_project_files.py diff --git a/src/uipath/_cli/_utils/_resources.py b/packages/uipath/src/uipath/_cli/_utils/_resources.py similarity index 100% rename from src/uipath/_cli/_utils/_resources.py rename to packages/uipath/src/uipath/_cli/_utils/_resources.py diff --git a/src/uipath/_cli/_utils/_service_base.py b/packages/uipath/src/uipath/_cli/_utils/_service_base.py similarity index 100% rename from src/uipath/_cli/_utils/_service_base.py rename to packages/uipath/src/uipath/_cli/_utils/_service_base.py diff --git a/src/uipath/_cli/_utils/_service_cli_generator.py b/packages/uipath/src/uipath/_cli/_utils/_service_cli_generator.py similarity index 100% rename from src/uipath/_cli/_utils/_service_cli_generator.py rename to packages/uipath/src/uipath/_cli/_utils/_service_cli_generator.py diff --git a/src/uipath/_cli/_utils/_service_metadata.py b/packages/uipath/src/uipath/_cli/_utils/_service_metadata.py similarity index 100% rename from src/uipath/_cli/_utils/_service_metadata.py rename to packages/uipath/src/uipath/_cli/_utils/_service_metadata.py diff --git a/src/uipath/_cli/_utils/_service_protocol.py b/packages/uipath/src/uipath/_cli/_utils/_service_protocol.py similarity index 100% rename from src/uipath/_cli/_utils/_service_protocol.py rename to packages/uipath/src/uipath/_cli/_utils/_service_protocol.py diff --git a/src/uipath/_cli/_utils/_studio_project.py b/packages/uipath/src/uipath/_cli/_utils/_studio_project.py similarity index 100% rename from src/uipath/_cli/_utils/_studio_project.py rename to packages/uipath/src/uipath/_cli/_utils/_studio_project.py diff --git a/src/uipath/_cli/_utils/_tracing.py b/packages/uipath/src/uipath/_cli/_utils/_tracing.py similarity index 100% rename from src/uipath/_cli/_utils/_tracing.py rename to packages/uipath/src/uipath/_cli/_utils/_tracing.py diff --git a/src/uipath/_cli/_utils/_type_registry.py b/packages/uipath/src/uipath/_cli/_utils/_type_registry.py similarity index 100% rename from src/uipath/_cli/_utils/_type_registry.py rename to packages/uipath/src/uipath/_cli/_utils/_type_registry.py diff --git a/src/uipath/_cli/_utils/_uv_helpers.py b/packages/uipath/src/uipath/_cli/_utils/_uv_helpers.py similarity index 100% rename from src/uipath/_cli/_utils/_uv_helpers.py rename to packages/uipath/src/uipath/_cli/_utils/_uv_helpers.py diff --git a/src/uipath/_cli/_utils/_validators.py b/packages/uipath/src/uipath/_cli/_utils/_validators.py similarity index 100% rename from src/uipath/_cli/_utils/_validators.py rename to packages/uipath/src/uipath/_cli/_utils/_validators.py diff --git a/src/uipath/_cli/cli_add.py b/packages/uipath/src/uipath/_cli/cli_add.py similarity index 100% rename from src/uipath/_cli/cli_add.py rename to packages/uipath/src/uipath/_cli/cli_add.py diff --git a/src/uipath/_cli/cli_auth.py b/packages/uipath/src/uipath/_cli/cli_auth.py similarity index 100% rename from src/uipath/_cli/cli_auth.py rename to packages/uipath/src/uipath/_cli/cli_auth.py diff --git a/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py similarity index 100% rename from src/uipath/_cli/cli_debug.py rename to packages/uipath/src/uipath/_cli/cli_debug.py diff --git a/src/uipath/_cli/cli_deploy.py b/packages/uipath/src/uipath/_cli/cli_deploy.py similarity index 100% rename from src/uipath/_cli/cli_deploy.py rename to packages/uipath/src/uipath/_cli/cli_deploy.py diff --git a/src/uipath/_cli/cli_dev.py b/packages/uipath/src/uipath/_cli/cli_dev.py similarity index 100% rename from src/uipath/_cli/cli_dev.py rename to packages/uipath/src/uipath/_cli/cli_dev.py diff --git a/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py similarity index 100% rename from src/uipath/_cli/cli_eval.py rename to packages/uipath/src/uipath/_cli/cli_eval.py diff --git a/src/uipath/_cli/cli_init.py b/packages/uipath/src/uipath/_cli/cli_init.py similarity index 100% rename from src/uipath/_cli/cli_init.py rename to packages/uipath/src/uipath/_cli/cli_init.py diff --git a/src/uipath/_cli/cli_invoke.py b/packages/uipath/src/uipath/_cli/cli_invoke.py similarity index 100% rename from src/uipath/_cli/cli_invoke.py rename to packages/uipath/src/uipath/_cli/cli_invoke.py diff --git a/src/uipath/_cli/cli_new.py b/packages/uipath/src/uipath/_cli/cli_new.py similarity index 100% rename from src/uipath/_cli/cli_new.py rename to packages/uipath/src/uipath/_cli/cli_new.py diff --git a/src/uipath/_cli/cli_pack.py b/packages/uipath/src/uipath/_cli/cli_pack.py similarity index 100% rename from src/uipath/_cli/cli_pack.py rename to packages/uipath/src/uipath/_cli/cli_pack.py diff --git a/src/uipath/_cli/cli_publish.py b/packages/uipath/src/uipath/_cli/cli_publish.py similarity index 100% rename from src/uipath/_cli/cli_publish.py rename to packages/uipath/src/uipath/_cli/cli_publish.py diff --git a/src/uipath/_cli/cli_pull.py b/packages/uipath/src/uipath/_cli/cli_pull.py similarity index 100% rename from src/uipath/_cli/cli_pull.py rename to packages/uipath/src/uipath/_cli/cli_pull.py diff --git a/src/uipath/_cli/cli_push.py b/packages/uipath/src/uipath/_cli/cli_push.py similarity index 100% rename from src/uipath/_cli/cli_push.py rename to packages/uipath/src/uipath/_cli/cli_push.py diff --git a/src/uipath/_cli/cli_register.py b/packages/uipath/src/uipath/_cli/cli_register.py similarity index 100% rename from src/uipath/_cli/cli_register.py rename to packages/uipath/src/uipath/_cli/cli_register.py diff --git a/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py similarity index 100% rename from src/uipath/_cli/cli_run.py rename to packages/uipath/src/uipath/_cli/cli_run.py diff --git a/src/uipath/_cli/cli_server.py b/packages/uipath/src/uipath/_cli/cli_server.py similarity index 100% rename from src/uipath/_cli/cli_server.py rename to packages/uipath/src/uipath/_cli/cli_server.py diff --git a/src/uipath/_cli/middlewares.py b/packages/uipath/src/uipath/_cli/middlewares.py similarity index 100% rename from src/uipath/_cli/middlewares.py rename to packages/uipath/src/uipath/_cli/middlewares.py diff --git a/tests/functions/__init__.py b/packages/uipath/src/uipath/_cli/models/__init__.py similarity index 100% rename from tests/functions/__init__.py rename to packages/uipath/src/uipath/_cli/models/__init__.py diff --git a/src/uipath/_cli/models/runtime_schema.py b/packages/uipath/src/uipath/_cli/models/runtime_schema.py similarity index 100% rename from src/uipath/_cli/models/runtime_schema.py rename to packages/uipath/src/uipath/_cli/models/runtime_schema.py diff --git a/src/uipath/_cli/models/uipath_json_schema.py b/packages/uipath/src/uipath/_cli/models/uipath_json_schema.py similarity index 100% rename from src/uipath/_cli/models/uipath_json_schema.py rename to packages/uipath/src/uipath/_cli/models/uipath_json_schema.py diff --git a/src/uipath/_cli/runtimes.py b/packages/uipath/src/uipath/_cli/runtimes.py similarity index 100% rename from src/uipath/_cli/runtimes.py rename to packages/uipath/src/uipath/_cli/runtimes.py diff --git a/src/uipath/_cli/services/__init__.py b/packages/uipath/src/uipath/_cli/services/__init__.py similarity index 100% rename from src/uipath/_cli/services/__init__.py rename to packages/uipath/src/uipath/_cli/services/__init__.py diff --git a/src/uipath/_cli/services/_buckets_metadata.py b/packages/uipath/src/uipath/_cli/services/_buckets_metadata.py similarity index 100% rename from src/uipath/_cli/services/_buckets_metadata.py rename to packages/uipath/src/uipath/_cli/services/_buckets_metadata.py diff --git a/src/uipath/_cli/services/cli_assets.py b/packages/uipath/src/uipath/_cli/services/cli_assets.py similarity index 100% rename from src/uipath/_cli/services/cli_assets.py rename to packages/uipath/src/uipath/_cli/services/cli_assets.py diff --git a/src/uipath/_cli/services/cli_buckets.py b/packages/uipath/src/uipath/_cli/services/cli_buckets.py similarity index 100% rename from src/uipath/_cli/services/cli_buckets.py rename to packages/uipath/src/uipath/_cli/services/cli_buckets.py diff --git a/src/uipath/_cli/spinner.py b/packages/uipath/src/uipath/_cli/spinner.py similarity index 100% rename from src/uipath/_cli/spinner.py rename to packages/uipath/src/uipath/_cli/spinner.py diff --git a/src/uipath/_resources/AGENTS.md b/packages/uipath/src/uipath/_resources/AGENTS.md similarity index 100% rename from src/uipath/_resources/AGENTS.md rename to packages/uipath/src/uipath/_resources/AGENTS.md diff --git a/src/uipath/_resources/CLAUDE.md b/packages/uipath/src/uipath/_resources/CLAUDE.md similarity index 100% rename from src/uipath/_resources/CLAUDE.md rename to packages/uipath/src/uipath/_resources/CLAUDE.md diff --git a/src/uipath/_resources/CLI_REFERENCE.md b/packages/uipath/src/uipath/_resources/CLI_REFERENCE.md similarity index 100% rename from src/uipath/_resources/CLI_REFERENCE.md rename to packages/uipath/src/uipath/_resources/CLI_REFERENCE.md diff --git a/src/uipath/_resources/REQUIRED_STRUCTURE.md b/packages/uipath/src/uipath/_resources/REQUIRED_STRUCTURE.md similarity index 100% rename from src/uipath/_resources/REQUIRED_STRUCTURE.md rename to packages/uipath/src/uipath/_resources/REQUIRED_STRUCTURE.md diff --git a/src/uipath/_resources/SDK_REFERENCE.md b/packages/uipath/src/uipath/_resources/SDK_REFERENCE.md similarity index 100% rename from src/uipath/_resources/SDK_REFERENCE.md rename to packages/uipath/src/uipath/_resources/SDK_REFERENCE.md diff --git a/packages/uipath/src/uipath/_resources/eval.md b/packages/uipath/src/uipath/_resources/eval.md new file mode 100644 index 000000000..15fa04f7c --- /dev/null +++ b/packages/uipath/src/uipath/_resources/eval.md @@ -0,0 +1,287 @@ +--- +allowed-tools: Bash, Read, Write, Edit, Glob +description: Create and run agent evaluations +--- + +I'll help you create and run evaluations for your UiPath agent. + +## Step 1: Check project setup + +Let me check your project structure: + +!ls -la evaluations/ entry-points.json 2>/dev/null || echo "NEEDS_SETUP" + +# Check if schemas might be stale (main.py newer than entry-points.json) +!if [ -f main.py ] && [ -f entry-points.json ] && [ main.py -nt entry-points.json ]; then echo "SCHEMAS_MAY_BE_STALE"; fi + +### If NEEDS_SETUP + +If `entry-points.json` doesn't exist, initialize the project first: + +!uv run uipath init + +Then re-run this skill. + +### If SCHEMAS_MAY_BE_STALE + +Your `main.py` is newer than `entry-points.json`. Refresh schemas: + +!uv run uipath init --no-agents-md-override + +## Step 2: What would you like to do? + +1. **Create new eval set** - Set up evaluations from scratch +2. **Add test case** - Add a test to existing eval set +3. **Run evaluations** - Execute tests and see results +4. **Analyze failures** - Debug failing tests + +--- + +## Creating an Eval Set + +First, create the directory structure: + +!mkdir -p evaluations/eval-sets evaluations/evaluators + +Read the agent's Input/Output schema from entry-points.json to understand the data types. + +### Evaluator Selection Guide + +| If your output is... | Use this evaluator | evaluatorTypeId | +|---------------------|-------------------|-----------------| +| Exact string/number | `ExactMatchEvaluator` | `uipath-exact-match` | +| Contains key phrases | `ContainsEvaluator` | `uipath-contains` | +| Semantically correct | `LLMJudgeOutputEvaluator` | `uipath-llm-judge-output-semantic-similarity` | +| JSON with numbers | `JsonSimilarityEvaluator` | `uipath-json-similarity` | + +### Step 1: Create Evaluator Config Files + +**Each evaluator needs a JSON config file** in `evaluations/evaluators/`. + +**ExactMatchEvaluator** (`evaluations/evaluators/exact-match.json`): +```json +{ + "version": "1.0", + "id": "ExactMatchEvaluator", + "name": "ExactMatchEvaluator", + "description": "Checks for exact output match", + "evaluatorTypeId": "uipath-exact-match", + "evaluatorConfig": { + "name": "ExactMatchEvaluator", + "targetOutputKey": "*" + } +} +``` + +**LLMJudgeOutputEvaluator** (`evaluations/evaluators/llm-judge-output.json`): +```json +{ + "version": "1.0", + "id": "LLMJudgeOutputEvaluator", + "name": "LLMJudgeOutputEvaluator", + "description": "Uses LLM to judge semantic similarity", + "evaluatorTypeId": "uipath-llm-judge-output-semantic-similarity", + "evaluatorConfig": { + "name": "LLMJudgeOutputEvaluator", + "model": "gpt-4o-mini-2024-07-18" + } +} +``` + +**JsonSimilarityEvaluator** (`evaluations/evaluators/json-similarity.json`): +```json +{ + "version": "1.0", + "id": "JsonSimilarityEvaluator", + "name": "JsonSimilarityEvaluator", + "description": "Compares JSON structures", + "evaluatorTypeId": "uipath-json-similarity", + "evaluatorConfig": { + "name": "JsonSimilarityEvaluator", + "targetOutputKey": "*" + } +} +``` + +**ContainsEvaluator** (`evaluations/evaluators/contains.json`): +```json +{ + "version": "1.0", + "id": "ContainsEvaluator", + "name": "ContainsEvaluator", + "description": "Checks if output contains text", + "evaluatorTypeId": "uipath-contains", + "evaluatorConfig": { + "name": "ContainsEvaluator" + } +} +``` + +### Step 2: Create Eval Set + +**Eval Set Template** (`evaluations/eval-sets/default.json`): +```json +{ + "version": "1.0", + "id": "default-eval-set", + "name": "Default Evaluation Set", + "evaluatorRefs": ["ExactMatchEvaluator"], + "evaluations": [ + { + "id": "test-1", + "name": "Test description", + "inputs": { + "field": "value" + }, + "evaluationCriterias": { + "ExactMatchEvaluator": { + "expectedOutput": { + "result": "expected value" + } + } + } + } + ] +} +``` + +**Important notes:** +- `evaluatorRefs` must list ALL evaluators used in any test case +- Each evaluator in `evaluatorRefs` needs a matching JSON config in `evaluations/evaluators/` +- `evaluationCriterias` keys must match entries in `evaluatorRefs` +- Use `expectedOutput` for most evaluators +- LLM evaluators need `model` in their config. Available models are defined in the SDK's `ChatModels` class (`uipath.platform.chat.ChatModels`): + - `gpt-4o-mini-2024-07-18` (recommended for cost-efficiency) + - `gpt-4o-2024-08-06` (higher quality, higher cost) + - `o3-mini-2025-01-31` (latest reasoning model) + - Model availability varies by region and tenant configuration + - Check your UiPath Automation Cloud portal under AI Trust Layer for available models in your region + +--- + +## Adding a Test Case + +When adding a test to an existing eval set: + +1. Read the existing eval set +2. Check which evaluators are in `evaluatorRefs` +3. Add the new test to `evaluations` array +4. If using a new evaluator, add it to `evaluatorRefs` + +### Test Case Template + +```json +{ + "id": "test-{n}", + "name": "Description of what this tests", + "inputs": { }, + "evaluationCriterias": { + "EvaluatorName": { + "expectedOutput": { } + } + } +} +``` + +--- + +## Running Evaluations + +First, read entry-points.json to get the entrypoint name (e.g., `main`): + +!uv run uipath eval main evaluations/eval-sets/default.json --output-file eval-results.json + +**Note:** Replace `main` with your actual entrypoint from entry-points.json. + +### Analyze Results + +After running, read `eval-results.json` and show: +- Pass/fail summary table +- For failures: expected vs actual output +- Suggestions for fixing or changing evaluators + +### Results Format + +```json +{ + "evaluationSetResults": [{ + "evaluationRunResults": [ + { + "evaluationId": "test-1", + "evaluatorId": "ExactMatchEvaluator", + "result": { "score": 1.0 }, + "errorMessage": null + } + ] + }] +} +``` + +- Score 1.0 = PASS +- Score < 1.0 = FAIL (show expected vs actual) +- errorMessage present = ERROR (show message) + +--- + +## Evaluator Reference + +### Deterministic Evaluators + +**ExactMatchEvaluator** - Exact output matching +```json +"ExactMatchEvaluator": { + "expectedOutput": { "result": "exact value" } +} +``` + +**ContainsEvaluator** - Output contains substring +```json +"ContainsEvaluator": { + "searchText": "must contain this" +} +``` + +**JsonSimilarityEvaluator** - JSON comparison with tolerance +```json +"JsonSimilarityEvaluator": { + "expectedOutput": { "value": 10.0 } +} +``` + +### LLM-Based Evaluators + +**LLMJudgeOutputEvaluator** - Semantic correctness +```json +"LLMJudgeOutputEvaluator": { + "expectedOutput": { "summary": "Expected semantic meaning" } +} +``` + +**LLMJudgeTrajectoryEvaluator** - Validate agent reasoning +```json +"LLMJudgeTrajectoryEvaluator": { + "expectedAgentBehavior": "The agent should first fetch data, then process it" +} +``` + +--- + +## Common Issues + +### "No evaluations found" +- Check `evaluations/eval-sets/` directory exists +- Verify JSON file is valid + +### Evaluator not found +- Each evaluator needs a JSON config file in `evaluations/evaluators/` +- Config file must have correct `evaluatorTypeId` (see templates above) +- Config file must have `name` field at root level +- LLM evaluators need `model` in `evaluatorConfig` + +### Evaluator skipped +- Ensure evaluator is listed in root `evaluatorRefs` array +- Check evaluator config file exists in `evaluations/evaluators/` + +### Schema mismatch +- Run `uv run uipath init --no-agents-md-override` to refresh schemas +- Check `entry-points.json` matches your Input/Output models diff --git a/packages/uipath/src/uipath/_resources/new-agent.md b/packages/uipath/src/uipath/_resources/new-agent.md new file mode 100644 index 000000000..b1d051866 --- /dev/null +++ b/packages/uipath/src/uipath/_resources/new-agent.md @@ -0,0 +1,103 @@ +--- +allowed-tools: Bash, Read, Write, Edit, Glob +description: Create a new UiPath coded agent from a description +--- + +I'll help you create a new UiPath coded agent. + +## Step 1: Check existing project + +Let me check if this is an existing UiPath project: + +!ls uipath.json main.py 2>/dev/null || echo "NEW_PROJECT" + +## Step 2: Gather requirements + +**What should this agent do?** + +Please describe: + +- What inputs it needs (e.g., "a file path and bucket name") +- What it should accomplish (e.g., "process CSV data") +- What outputs it should return (e.g., "total count and status") + +I'll generate the agent structure based on your description. + +## Step 3: Generate agent + +After you describe the agent, I will: + +1. Create `main.py` with Input/Output Pydantic models and `async def main()` +2. Add entrypoint to `uipath.json` under `"functions": {"agent_name": "main.py:main"}` +3. Run `uv run uipath init --no-agents-md-override` to generate schemas + +**Template structure** (from .agent/REQUIRED_STRUCTURE.md): + +```python +from pydantic import BaseModel +from uipath.platform import UiPath + +class Input(BaseModel): + """Input fields for the agent.""" + # Fields based on your description + pass + + +class Output(BaseModel): + """Output fields returned by the agent.""" + # Fields based on your description + pass + + +async def main(input: Input) -> Output: + """Main entry point for the agent. + + Args: + input: The input data for the agent. + + Returns: + The output data from the agent. + """ + + uipath = UiPath() + + # TODO: Implement agent logic + return Output() +``` + +**Important notes:** + +- Use `async def main` - many SDK methods are async +- Initialize `UiPath()` inside the function, not at module level +- After creating main.py, add entrypoint to `uipath.json` under `"functions"` + +## Step 4: Update entry-point schemas + +After creating main.py, regenerate the schemas: + +!uv run uipath init --no-agents-md-override + +## Step 5: Verify + +Quick test to verify the setup: + +!uv run uipath run main '{}' 2>&1 | head -30 + +## Summary + +Once complete, you'll have: + +| File | Purpose | +| ------------------- | ----------------------------------- | +| `main.py` | Agent code with Input/Output models | +| `uipath.json` | Project configuration | +| `entry-points.json` | Entry point schemas | +| `bindings.json` | Resource bindings | +| `.agent/` | SDK and CLI reference docs | + +**Next steps:** + +1. Implement your logic in `main()` +2. Test: `uv run uipath run main '{"field": "value"}'` +3. Create `eval_set.json` for evaluations +4. Evaluate: `uv run uipath eval` diff --git a/src/uipath/_utils/__init__.py b/packages/uipath/src/uipath/_utils/__init__.py similarity index 100% rename from src/uipath/_utils/__init__.py rename to packages/uipath/src/uipath/_utils/__init__.py diff --git a/src/uipath/_utils/_auth.py b/packages/uipath/src/uipath/_utils/_auth.py similarity index 100% rename from src/uipath/_utils/_auth.py rename to packages/uipath/src/uipath/_utils/_auth.py diff --git a/src/uipath/_utils/_endpoint.py b/packages/uipath/src/uipath/_utils/_endpoint.py similarity index 100% rename from src/uipath/_utils/_endpoint.py rename to packages/uipath/src/uipath/_utils/_endpoint.py diff --git a/src/uipath/_utils/_logs.py b/packages/uipath/src/uipath/_utils/_logs.py similarity index 100% rename from src/uipath/_utils/_logs.py rename to packages/uipath/src/uipath/_utils/_logs.py diff --git a/src/uipath/_utils/_request_override.py b/packages/uipath/src/uipath/_utils/_request_override.py similarity index 100% rename from src/uipath/_utils/_request_override.py rename to packages/uipath/src/uipath/_utils/_request_override.py diff --git a/src/uipath/_utils/_request_spec.py b/packages/uipath/src/uipath/_utils/_request_spec.py similarity index 100% rename from src/uipath/_utils/_request_spec.py rename to packages/uipath/src/uipath/_utils/_request_spec.py diff --git a/src/uipath/_utils/_ssl_context.py b/packages/uipath/src/uipath/_utils/_ssl_context.py similarity index 100% rename from src/uipath/_utils/_ssl_context.py rename to packages/uipath/src/uipath/_utils/_ssl_context.py diff --git a/packages/uipath/src/uipath/_utils/_url.py b/packages/uipath/src/uipath/_utils/_url.py new file mode 100644 index 000000000..4776120cd --- /dev/null +++ b/packages/uipath/src/uipath/_utils/_url.py @@ -0,0 +1,102 @@ +from typing import Literal +from urllib.parse import urlparse + + +class UiPathUrl: + """A class that represents a UiPath URL. + + This class is used to parse and manipulate UiPath URLs. + + >>> url = UiPathUrl("https://test.uipath.com/org/tenant") + >>> url.base_url + 'https://test.uipath.com' + >>> url.org_name + 'org' + >>> url.tenant_name + 'tenant' + + Args: + url (str): The URL to parse. + """ + + def __init__(self, url: str): + self._url = url + + def __str__(self): + return self._url + + def __repr__(self): + return f"UiPathUrl({self._url})" + + def __eq__(self, other: object): + if not isinstance(other, UiPathUrl): + return NotImplemented + + return self._url == str(other) + + def __ne__(self, other: object): + if not isinstance(other, UiPathUrl): + return NotImplemented + + return self._url != str(other) + + def __hash__(self): + return hash(self._url) + + @property + def base_url(self): + parsed = urlparse(self._url) + + return f"{parsed.scheme}://{parsed.hostname}{f':{parsed.port}' if parsed.port else ''}" + + @property + def org_name(self): + return self._org_tenant_names[0] + + @property + def tenant_name(self): + return self._org_tenant_names[1] + + def scope_url(self, url: str, scoped: Literal["org", "tenant"] = "tenant") -> str: + if not self._is_relative_url(url): + return url + + parts = [self.org_name] + if scoped == "tenant": + parts.append(self.tenant_name) + parts.append(url.strip("/")) + + return "/".join(parts) + + @property + def _org_tenant_names(self): + parsed = urlparse(self._url) + + try: + org_name, tenant_name = parsed.path.strip("/").split("/") + except ValueError: + return "", "" + + return org_name, tenant_name + + def _is_relative_url(self, url: str) -> bool: + # Empty URLs are considered relative + if not url: + return True + + parsed = urlparse(url) + + # Protocol-relative URLs (starting with //) are not relative + if url.startswith("//"): + return False + + # URLs with schemes are not relative (http:, https:, mailto:, etc.) + if parsed.scheme: + return False + + # URLs with network locations are not relative + if parsed.netloc: + return False + + # If we've passed all the checks, it's a relative URL + return True diff --git a/packages/uipath/src/uipath/_utils/_user_agent.py b/packages/uipath/src/uipath/_utils/_user_agent.py new file mode 100644 index 000000000..dcf28d3e0 --- /dev/null +++ b/packages/uipath/src/uipath/_utils/_user_agent.py @@ -0,0 +1,19 @@ +import importlib + +from .constants import HEADER_USER_AGENT + + +def user_agent_value(specific_component: str) -> str: + product = "UiPath.Python.Sdk" + product_component = f"UiPath.Python.Sdk.Activities.{specific_component}" + + try: + version = importlib.metadata.version("uipath") + except importlib.metadata.PackageNotFoundError: + version = "unknown" + + return f"{product}/{product_component}/{version}" + + +def header_user_agent(specific_component: str) -> dict[str, str]: + return {HEADER_USER_AGENT: user_agent_value(specific_component)} diff --git a/src/uipath/_utils/constants.py b/packages/uipath/src/uipath/_utils/constants.py similarity index 100% rename from src/uipath/_utils/constants.py rename to packages/uipath/src/uipath/_utils/constants.py diff --git a/packages/uipath/src/uipath/_utils/validation.py b/packages/uipath/src/uipath/_utils/validation.py new file mode 100644 index 000000000..2fa2b111c --- /dev/null +++ b/packages/uipath/src/uipath/_utils/validation.py @@ -0,0 +1,43 @@ +"""Validation utilities for SDK services.""" + + +def validate_pagination_params( + skip: int, + top: int, + max_skip: int = 10000, + max_top: int = 1000, +) -> None: + """Validate pagination parameters for OData queries. + + This validator is used across multiple services (BucketsService, JobsService, etc.) + to ensure consistent pagination behavior. + + Args: + skip: Number of items to skip (must be >= 0 and <= max_skip) + top: Maximum items per page (must be >= 1 and <= max_top) + max_skip: Maximum allowed skip value (default: 10000) + max_top: Maximum allowed top value (default: 1000) + + Raises: + ValueError: If parameters are invalid + + Examples: + >>> validate_pagination_params(skip=0, top=100) + >>> validate_pagination_params(skip=5000, top=500) + >>> validate_pagination_params(skip=-1, top=100) # Raises ValueError + >>> validate_pagination_params(skip=0, top=2000) # Raises ValueError + """ + if skip < 0: + raise ValueError("skip must be >= 0") + if skip > max_skip: + raise ValueError( + f"skip must be <= {max_skip} (requested: {skip}). " + f"Use filters to narrow results or manual pagination." + ) + if top < 1: + raise ValueError("top must be >= 1") + if top > max_top: + raise ValueError( + f"top must be <= {max_top} (requested: {top}). " + f"Use pagination with skip and top parameters." + ) diff --git a/src/uipath/agent/__init__.py b/packages/uipath/src/uipath/agent/__init__.py similarity index 100% rename from src/uipath/agent/__init__.py rename to packages/uipath/src/uipath/agent/__init__.py diff --git a/src/uipath/agent/models/_legacy.py b/packages/uipath/src/uipath/agent/models/_legacy.py similarity index 100% rename from src/uipath/agent/models/_legacy.py rename to packages/uipath/src/uipath/agent/models/_legacy.py diff --git a/src/uipath/agent/models/agent.py b/packages/uipath/src/uipath/agent/models/agent.py similarity index 100% rename from src/uipath/agent/models/agent.py rename to packages/uipath/src/uipath/agent/models/agent.py diff --git a/src/uipath/agent/models/evals.py b/packages/uipath/src/uipath/agent/models/evals.py similarity index 100% rename from src/uipath/agent/models/evals.py rename to packages/uipath/src/uipath/agent/models/evals.py diff --git a/src/uipath/agent/react/__init__.py b/packages/uipath/src/uipath/agent/react/__init__.py similarity index 100% rename from src/uipath/agent/react/__init__.py rename to packages/uipath/src/uipath/agent/react/__init__.py diff --git a/src/uipath/agent/react/conversational_prompts.py b/packages/uipath/src/uipath/agent/react/conversational_prompts.py similarity index 100% rename from src/uipath/agent/react/conversational_prompts.py rename to packages/uipath/src/uipath/agent/react/conversational_prompts.py diff --git a/src/uipath/agent/react/prompts.py b/packages/uipath/src/uipath/agent/react/prompts.py similarity index 100% rename from src/uipath/agent/react/prompts.py rename to packages/uipath/src/uipath/agent/react/prompts.py diff --git a/src/uipath/agent/react/tools.py b/packages/uipath/src/uipath/agent/react/tools.py similarity index 100% rename from src/uipath/agent/react/tools.py rename to packages/uipath/src/uipath/agent/react/tools.py diff --git a/src/uipath/agent/utils/__init__.py b/packages/uipath/src/uipath/agent/utils/__init__.py similarity index 100% rename from src/uipath/agent/utils/__init__.py rename to packages/uipath/src/uipath/agent/utils/__init__.py diff --git a/src/uipath/agent/utils/_utils.py b/packages/uipath/src/uipath/agent/utils/_utils.py similarity index 100% rename from src/uipath/agent/utils/_utils.py rename to packages/uipath/src/uipath/agent/utils/_utils.py diff --git a/src/uipath/agent/utils/text_tokens.py b/packages/uipath/src/uipath/agent/utils/text_tokens.py similarity index 100% rename from src/uipath/agent/utils/text_tokens.py rename to packages/uipath/src/uipath/agent/utils/text_tokens.py diff --git a/src/uipath/eval/_execution_context.py b/packages/uipath/src/uipath/eval/_execution_context.py similarity index 100% rename from src/uipath/eval/_execution_context.py rename to packages/uipath/src/uipath/eval/_execution_context.py diff --git a/src/uipath/eval/_helpers/__init__.py b/packages/uipath/src/uipath/eval/_helpers/__init__.py similarity index 100% rename from src/uipath/eval/_helpers/__init__.py rename to packages/uipath/src/uipath/eval/_helpers/__init__.py diff --git a/src/uipath/eval/_helpers/evaluators_helpers.py b/packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py similarity index 100% rename from src/uipath/eval/_helpers/evaluators_helpers.py rename to packages/uipath/src/uipath/eval/_helpers/evaluators_helpers.py diff --git a/src/uipath/eval/_helpers/helpers.py b/packages/uipath/src/uipath/eval/_helpers/helpers.py similarity index 100% rename from src/uipath/eval/_helpers/helpers.py rename to packages/uipath/src/uipath/eval/_helpers/helpers.py diff --git a/src/uipath/eval/_helpers/output_path.py b/packages/uipath/src/uipath/eval/_helpers/output_path.py similarity index 100% rename from src/uipath/eval/_helpers/output_path.py rename to packages/uipath/src/uipath/eval/_helpers/output_path.py diff --git a/src/uipath/eval/constants.py b/packages/uipath/src/uipath/eval/constants.py similarity index 100% rename from src/uipath/eval/constants.py rename to packages/uipath/src/uipath/eval/constants.py diff --git a/src/uipath/eval/evaluators/__init__.py b/packages/uipath/src/uipath/eval/evaluators/__init__.py similarity index 100% rename from src/uipath/eval/evaluators/__init__.py rename to packages/uipath/src/uipath/eval/evaluators/__init__.py diff --git a/src/uipath/eval/evaluators/base_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/base_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/base_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/base_evaluator.py diff --git a/src/uipath/eval/evaluators/base_legacy_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/base_legacy_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/base_legacy_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/base_legacy_evaluator.py diff --git a/src/uipath/eval/evaluators/binary_classification_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/binary_classification_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/binary_classification_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/binary_classification_evaluator.py diff --git a/src/uipath/eval/evaluators/contains_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/contains_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/contains_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/contains_evaluator.py diff --git a/src/uipath/eval/evaluators/evaluator.py b/packages/uipath/src/uipath/eval/evaluators/evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/evaluator.py diff --git a/src/uipath/eval/evaluators/evaluator_factory.py b/packages/uipath/src/uipath/eval/evaluators/evaluator_factory.py similarity index 100% rename from src/uipath/eval/evaluators/evaluator_factory.py rename to packages/uipath/src/uipath/eval/evaluators/evaluator_factory.py diff --git a/src/uipath/eval/evaluators/exact_match_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/exact_match_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/exact_match_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/exact_match_evaluator.py diff --git a/src/uipath/eval/evaluators/json_similarity_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/json_similarity_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/json_similarity_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/json_similarity_evaluator.py diff --git a/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/legacy_context_precision_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/legacy_context_precision_evaluator.py diff --git a/src/uipath/eval/evaluators/legacy_deterministic_evaluator_base.py b/packages/uipath/src/uipath/eval/evaluators/legacy_deterministic_evaluator_base.py similarity index 100% rename from src/uipath/eval/evaluators/legacy_deterministic_evaluator_base.py rename to packages/uipath/src/uipath/eval/evaluators/legacy_deterministic_evaluator_base.py diff --git a/src/uipath/eval/evaluators/legacy_evaluator_utils.py b/packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py similarity index 100% rename from src/uipath/eval/evaluators/legacy_evaluator_utils.py rename to packages/uipath/src/uipath/eval/evaluators/legacy_evaluator_utils.py diff --git a/src/uipath/eval/evaluators/legacy_exact_match_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_exact_match_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/legacy_exact_match_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/legacy_exact_match_evaluator.py diff --git a/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/legacy_faithfulness_evaluator.py diff --git a/src/uipath/eval/evaluators/legacy_json_similarity_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_json_similarity_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/legacy_json_similarity_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/legacy_json_similarity_evaluator.py diff --git a/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/legacy_llm_as_judge_evaluator.py diff --git a/src/uipath/eval/evaluators/legacy_llm_helpers.py b/packages/uipath/src/uipath/eval/evaluators/legacy_llm_helpers.py similarity index 100% rename from src/uipath/eval/evaluators/legacy_llm_helpers.py rename to packages/uipath/src/uipath/eval/evaluators/legacy_llm_helpers.py diff --git a/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/legacy_trajectory_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/legacy_trajectory_evaluator.py diff --git a/src/uipath/eval/evaluators/llm_as_judge_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/llm_as_judge_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/llm_as_judge_evaluator.py diff --git a/src/uipath/eval/evaluators/llm_judge_output_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/llm_judge_output_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/llm_judge_output_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/llm_judge_output_evaluator.py diff --git a/src/uipath/eval/evaluators/llm_judge_trajectory_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/llm_judge_trajectory_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/llm_judge_trajectory_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/llm_judge_trajectory_evaluator.py diff --git a/src/uipath/eval/evaluators/multiclass_classification_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/multiclass_classification_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/multiclass_classification_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/multiclass_classification_evaluator.py diff --git a/src/uipath/eval/evaluators/output_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/output_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/output_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/output_evaluator.py diff --git a/src/uipath/eval/evaluators/registration.py b/packages/uipath/src/uipath/eval/evaluators/registration.py similarity index 100% rename from src/uipath/eval/evaluators/registration.py rename to packages/uipath/src/uipath/eval/evaluators/registration.py diff --git a/src/uipath/eval/evaluators/tool_call_args_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/tool_call_args_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/tool_call_args_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/tool_call_args_evaluator.py diff --git a/src/uipath/eval/evaluators/tool_call_count_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/tool_call_count_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/tool_call_count_evaluator.py diff --git a/src/uipath/eval/evaluators/tool_call_order_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/tool_call_order_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/tool_call_order_evaluator.py diff --git a/src/uipath/eval/evaluators/tool_call_output_evaluator.py b/packages/uipath/src/uipath/eval/evaluators/tool_call_output_evaluator.py similarity index 100% rename from src/uipath/eval/evaluators/tool_call_output_evaluator.py rename to packages/uipath/src/uipath/eval/evaluators/tool_call_output_evaluator.py diff --git a/src/uipath/eval/evaluators_types/ContainsEvaluator.json b/packages/uipath/src/uipath/eval/evaluators_types/ContainsEvaluator.json similarity index 100% rename from src/uipath/eval/evaluators_types/ContainsEvaluator.json rename to packages/uipath/src/uipath/eval/evaluators_types/ContainsEvaluator.json diff --git a/src/uipath/eval/evaluators_types/ExactMatchEvaluator.json b/packages/uipath/src/uipath/eval/evaluators_types/ExactMatchEvaluator.json similarity index 100% rename from src/uipath/eval/evaluators_types/ExactMatchEvaluator.json rename to packages/uipath/src/uipath/eval/evaluators_types/ExactMatchEvaluator.json diff --git a/src/uipath/eval/evaluators_types/JsonSimilarityEvaluator.json b/packages/uipath/src/uipath/eval/evaluators_types/JsonSimilarityEvaluator.json similarity index 100% rename from src/uipath/eval/evaluators_types/JsonSimilarityEvaluator.json rename to packages/uipath/src/uipath/eval/evaluators_types/JsonSimilarityEvaluator.json diff --git a/src/uipath/eval/evaluators_types/LLMJudgeOutputEvaluator.json b/packages/uipath/src/uipath/eval/evaluators_types/LLMJudgeOutputEvaluator.json similarity index 100% rename from src/uipath/eval/evaluators_types/LLMJudgeOutputEvaluator.json rename to packages/uipath/src/uipath/eval/evaluators_types/LLMJudgeOutputEvaluator.json diff --git a/src/uipath/eval/evaluators_types/LLMJudgeStrictJSONSimilarityOutputEvaluator.json b/packages/uipath/src/uipath/eval/evaluators_types/LLMJudgeStrictJSONSimilarityOutputEvaluator.json similarity index 100% rename from src/uipath/eval/evaluators_types/LLMJudgeStrictJSONSimilarityOutputEvaluator.json rename to packages/uipath/src/uipath/eval/evaluators_types/LLMJudgeStrictJSONSimilarityOutputEvaluator.json diff --git a/src/uipath/eval/evaluators_types/LLMJudgeTrajectoryEvaluator.json b/packages/uipath/src/uipath/eval/evaluators_types/LLMJudgeTrajectoryEvaluator.json similarity index 100% rename from src/uipath/eval/evaluators_types/LLMJudgeTrajectoryEvaluator.json rename to packages/uipath/src/uipath/eval/evaluators_types/LLMJudgeTrajectoryEvaluator.json diff --git a/src/uipath/eval/evaluators_types/LLMJudgeTrajectorySimulationEvaluator.json b/packages/uipath/src/uipath/eval/evaluators_types/LLMJudgeTrajectorySimulationEvaluator.json similarity index 100% rename from src/uipath/eval/evaluators_types/LLMJudgeTrajectorySimulationEvaluator.json rename to packages/uipath/src/uipath/eval/evaluators_types/LLMJudgeTrajectorySimulationEvaluator.json diff --git a/src/uipath/eval/evaluators_types/ToolCallArgsEvaluator.json b/packages/uipath/src/uipath/eval/evaluators_types/ToolCallArgsEvaluator.json similarity index 100% rename from src/uipath/eval/evaluators_types/ToolCallArgsEvaluator.json rename to packages/uipath/src/uipath/eval/evaluators_types/ToolCallArgsEvaluator.json diff --git a/src/uipath/eval/evaluators_types/ToolCallCountEvaluator.json b/packages/uipath/src/uipath/eval/evaluators_types/ToolCallCountEvaluator.json similarity index 100% rename from src/uipath/eval/evaluators_types/ToolCallCountEvaluator.json rename to packages/uipath/src/uipath/eval/evaluators_types/ToolCallCountEvaluator.json diff --git a/src/uipath/eval/evaluators_types/ToolCallOrderEvaluator.json b/packages/uipath/src/uipath/eval/evaluators_types/ToolCallOrderEvaluator.json similarity index 100% rename from src/uipath/eval/evaluators_types/ToolCallOrderEvaluator.json rename to packages/uipath/src/uipath/eval/evaluators_types/ToolCallOrderEvaluator.json diff --git a/src/uipath/eval/evaluators_types/ToolCallOutputEvaluator.json b/packages/uipath/src/uipath/eval/evaluators_types/ToolCallOutputEvaluator.json similarity index 100% rename from src/uipath/eval/evaluators_types/ToolCallOutputEvaluator.json rename to packages/uipath/src/uipath/eval/evaluators_types/ToolCallOutputEvaluator.json diff --git a/src/uipath/eval/evaluators_types/generate_types.py b/packages/uipath/src/uipath/eval/evaluators_types/generate_types.py similarity index 100% rename from src/uipath/eval/evaluators_types/generate_types.py rename to packages/uipath/src/uipath/eval/evaluators_types/generate_types.py diff --git a/src/uipath/eval/helpers.py b/packages/uipath/src/uipath/eval/helpers.py similarity index 100% rename from src/uipath/eval/helpers.py rename to packages/uipath/src/uipath/eval/helpers.py diff --git a/src/uipath/eval/mocks/__init__.py b/packages/uipath/src/uipath/eval/mocks/__init__.py similarity index 100% rename from src/uipath/eval/mocks/__init__.py rename to packages/uipath/src/uipath/eval/mocks/__init__.py diff --git a/src/uipath/eval/mocks/_cache_manager.py b/packages/uipath/src/uipath/eval/mocks/_cache_manager.py similarity index 100% rename from src/uipath/eval/mocks/_cache_manager.py rename to packages/uipath/src/uipath/eval/mocks/_cache_manager.py diff --git a/src/uipath/eval/mocks/_input_mocker.py b/packages/uipath/src/uipath/eval/mocks/_input_mocker.py similarity index 100% rename from src/uipath/eval/mocks/_input_mocker.py rename to packages/uipath/src/uipath/eval/mocks/_input_mocker.py diff --git a/src/uipath/eval/mocks/_llm_mocker.py b/packages/uipath/src/uipath/eval/mocks/_llm_mocker.py similarity index 100% rename from src/uipath/eval/mocks/_llm_mocker.py rename to packages/uipath/src/uipath/eval/mocks/_llm_mocker.py diff --git a/src/uipath/eval/mocks/_mock_context.py b/packages/uipath/src/uipath/eval/mocks/_mock_context.py similarity index 100% rename from src/uipath/eval/mocks/_mock_context.py rename to packages/uipath/src/uipath/eval/mocks/_mock_context.py diff --git a/src/uipath/eval/mocks/_mock_runtime.py b/packages/uipath/src/uipath/eval/mocks/_mock_runtime.py similarity index 100% rename from src/uipath/eval/mocks/_mock_runtime.py rename to packages/uipath/src/uipath/eval/mocks/_mock_runtime.py diff --git a/src/uipath/eval/mocks/_mocker.py b/packages/uipath/src/uipath/eval/mocks/_mocker.py similarity index 100% rename from src/uipath/eval/mocks/_mocker.py rename to packages/uipath/src/uipath/eval/mocks/_mocker.py diff --git a/src/uipath/eval/mocks/_mocker_factory.py b/packages/uipath/src/uipath/eval/mocks/_mocker_factory.py similarity index 100% rename from src/uipath/eval/mocks/_mocker_factory.py rename to packages/uipath/src/uipath/eval/mocks/_mocker_factory.py diff --git a/src/uipath/eval/mocks/_mockito_mocker.py b/packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py similarity index 100% rename from src/uipath/eval/mocks/_mockito_mocker.py rename to packages/uipath/src/uipath/eval/mocks/_mockito_mocker.py diff --git a/src/uipath/eval/mocks/_types.py b/packages/uipath/src/uipath/eval/mocks/_types.py similarity index 100% rename from src/uipath/eval/mocks/_types.py rename to packages/uipath/src/uipath/eval/mocks/_types.py diff --git a/src/uipath/eval/mocks/mockable.py b/packages/uipath/src/uipath/eval/mocks/mockable.py similarity index 100% rename from src/uipath/eval/mocks/mockable.py rename to packages/uipath/src/uipath/eval/mocks/mockable.py diff --git a/src/uipath/eval/models/__init__.py b/packages/uipath/src/uipath/eval/models/__init__.py similarity index 100% rename from src/uipath/eval/models/__init__.py rename to packages/uipath/src/uipath/eval/models/__init__.py diff --git a/src/uipath/eval/models/_conversational_utils.py b/packages/uipath/src/uipath/eval/models/_conversational_utils.py similarity index 100% rename from src/uipath/eval/models/_conversational_utils.py rename to packages/uipath/src/uipath/eval/models/_conversational_utils.py diff --git a/src/uipath/eval/models/evaluation_set.py b/packages/uipath/src/uipath/eval/models/evaluation_set.py similarity index 100% rename from src/uipath/eval/models/evaluation_set.py rename to packages/uipath/src/uipath/eval/models/evaluation_set.py diff --git a/src/uipath/eval/models/llm_judge_types.py b/packages/uipath/src/uipath/eval/models/llm_judge_types.py similarity index 100% rename from src/uipath/eval/models/llm_judge_types.py rename to packages/uipath/src/uipath/eval/models/llm_judge_types.py diff --git a/src/uipath/eval/models/models.py b/packages/uipath/src/uipath/eval/models/models.py similarity index 100% rename from src/uipath/eval/models/models.py rename to packages/uipath/src/uipath/eval/models/models.py diff --git a/src/uipath/eval/runtime/__init__.py b/packages/uipath/src/uipath/eval/runtime/__init__.py similarity index 100% rename from src/uipath/eval/runtime/__init__.py rename to packages/uipath/src/uipath/eval/runtime/__init__.py diff --git a/src/uipath/eval/runtime/_evaluate.py b/packages/uipath/src/uipath/eval/runtime/_evaluate.py similarity index 100% rename from src/uipath/eval/runtime/_evaluate.py rename to packages/uipath/src/uipath/eval/runtime/_evaluate.py diff --git a/src/uipath/eval/runtime/_exporters.py b/packages/uipath/src/uipath/eval/runtime/_exporters.py similarity index 100% rename from src/uipath/eval/runtime/_exporters.py rename to packages/uipath/src/uipath/eval/runtime/_exporters.py diff --git a/src/uipath/eval/runtime/_parallelization.py b/packages/uipath/src/uipath/eval/runtime/_parallelization.py similarity index 100% rename from src/uipath/eval/runtime/_parallelization.py rename to packages/uipath/src/uipath/eval/runtime/_parallelization.py diff --git a/src/uipath/eval/runtime/_spans.py b/packages/uipath/src/uipath/eval/runtime/_spans.py similarity index 100% rename from src/uipath/eval/runtime/_spans.py rename to packages/uipath/src/uipath/eval/runtime/_spans.py diff --git a/src/uipath/eval/runtime/_types.py b/packages/uipath/src/uipath/eval/runtime/_types.py similarity index 100% rename from src/uipath/eval/runtime/_types.py rename to packages/uipath/src/uipath/eval/runtime/_types.py diff --git a/src/uipath/eval/runtime/_utils.py b/packages/uipath/src/uipath/eval/runtime/_utils.py similarity index 100% rename from src/uipath/eval/runtime/_utils.py rename to packages/uipath/src/uipath/eval/runtime/_utils.py diff --git a/src/uipath/eval/runtime/context.py b/packages/uipath/src/uipath/eval/runtime/context.py similarity index 100% rename from src/uipath/eval/runtime/context.py rename to packages/uipath/src/uipath/eval/runtime/context.py diff --git a/src/uipath/eval/runtime/events.py b/packages/uipath/src/uipath/eval/runtime/events.py similarity index 100% rename from src/uipath/eval/runtime/events.py rename to packages/uipath/src/uipath/eval/runtime/events.py diff --git a/src/uipath/eval/runtime/runtime.py b/packages/uipath/src/uipath/eval/runtime/runtime.py similarity index 100% rename from src/uipath/eval/runtime/runtime.py rename to packages/uipath/src/uipath/eval/runtime/runtime.py diff --git a/src/uipath/functions/__init__.py b/packages/uipath/src/uipath/functions/__init__.py similarity index 100% rename from src/uipath/functions/__init__.py rename to packages/uipath/src/uipath/functions/__init__.py diff --git a/src/uipath/functions/debug.py b/packages/uipath/src/uipath/functions/debug.py similarity index 100% rename from src/uipath/functions/debug.py rename to packages/uipath/src/uipath/functions/debug.py diff --git a/src/uipath/functions/factory.py b/packages/uipath/src/uipath/functions/factory.py similarity index 100% rename from src/uipath/functions/factory.py rename to packages/uipath/src/uipath/functions/factory.py diff --git a/src/uipath/functions/graph_builder.py b/packages/uipath/src/uipath/functions/graph_builder.py similarity index 100% rename from src/uipath/functions/graph_builder.py rename to packages/uipath/src/uipath/functions/graph_builder.py diff --git a/src/uipath/functions/runtime.py b/packages/uipath/src/uipath/functions/runtime.py similarity index 100% rename from src/uipath/functions/runtime.py rename to packages/uipath/src/uipath/functions/runtime.py diff --git a/src/uipath/functions/schema_gen.py b/packages/uipath/src/uipath/functions/schema_gen.py similarity index 100% rename from src/uipath/functions/schema_gen.py rename to packages/uipath/src/uipath/functions/schema_gen.py diff --git a/src/uipath/functions/type_conversion.py b/packages/uipath/src/uipath/functions/type_conversion.py similarity index 100% rename from src/uipath/functions/type_conversion.py rename to packages/uipath/src/uipath/functions/type_conversion.py diff --git a/packages/uipath/src/uipath/py.typed b/packages/uipath/src/uipath/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/uipath/telemetry/__init__.py b/packages/uipath/src/uipath/telemetry/__init__.py similarity index 100% rename from src/uipath/telemetry/__init__.py rename to packages/uipath/src/uipath/telemetry/__init__.py diff --git a/src/uipath/telemetry/_constants.py b/packages/uipath/src/uipath/telemetry/_constants.py similarity index 100% rename from src/uipath/telemetry/_constants.py rename to packages/uipath/src/uipath/telemetry/_constants.py diff --git a/src/uipath/telemetry/_track.py b/packages/uipath/src/uipath/telemetry/_track.py similarity index 100% rename from src/uipath/telemetry/_track.py rename to packages/uipath/src/uipath/telemetry/_track.py diff --git a/src/uipath/tracing/__init__.py b/packages/uipath/src/uipath/tracing/__init__.py similarity index 100% rename from src/uipath/tracing/__init__.py rename to packages/uipath/src/uipath/tracing/__init__.py diff --git a/src/uipath/tracing/_live_tracking_processor.py b/packages/uipath/src/uipath/tracing/_live_tracking_processor.py similarity index 100% rename from src/uipath/tracing/_live_tracking_processor.py rename to packages/uipath/src/uipath/tracing/_live_tracking_processor.py diff --git a/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py similarity index 100% rename from src/uipath/tracing/_otel_exporters.py rename to packages/uipath/src/uipath/tracing/_otel_exporters.py diff --git a/src/uipath/tracing/_utils.py b/packages/uipath/src/uipath/tracing/_utils.py similarity index 100% rename from src/uipath/tracing/_utils.py rename to packages/uipath/src/uipath/tracing/_utils.py diff --git a/src/uipath/utils/__init__.py b/packages/uipath/src/uipath/utils/__init__.py similarity index 100% rename from src/uipath/utils/__init__.py rename to packages/uipath/src/uipath/utils/__init__.py diff --git a/testcases/apicalls-testcase/main.py b/packages/uipath/testcases/apicalls-testcase/main.py similarity index 100% rename from testcases/apicalls-testcase/main.py rename to packages/uipath/testcases/apicalls-testcase/main.py diff --git a/testcases/apicalls-testcase/pyproject.toml b/packages/uipath/testcases/apicalls-testcase/pyproject.toml similarity index 100% rename from testcases/apicalls-testcase/pyproject.toml rename to packages/uipath/testcases/apicalls-testcase/pyproject.toml diff --git a/testcases/apicalls-testcase/run.sh b/packages/uipath/testcases/apicalls-testcase/run.sh similarity index 100% rename from testcases/apicalls-testcase/run.sh rename to packages/uipath/testcases/apicalls-testcase/run.sh diff --git a/testcases/apicalls-testcase/src/assert.py b/packages/uipath/testcases/apicalls-testcase/src/assert.py similarity index 100% rename from testcases/apicalls-testcase/src/assert.py rename to packages/uipath/testcases/apicalls-testcase/src/assert.py diff --git a/testcases/apicalls-testcase/uipath.json b/packages/uipath/testcases/apicalls-testcase/uipath.json similarity index 100% rename from testcases/apicalls-testcase/uipath.json rename to packages/uipath/testcases/apicalls-testcase/uipath.json diff --git a/testcases/basic-testcase/main.py b/packages/uipath/testcases/basic-testcase/main.py similarity index 100% rename from testcases/basic-testcase/main.py rename to packages/uipath/testcases/basic-testcase/main.py diff --git a/testcases/basic-testcase/pyproject.toml b/packages/uipath/testcases/basic-testcase/pyproject.toml similarity index 100% rename from testcases/basic-testcase/pyproject.toml rename to packages/uipath/testcases/basic-testcase/pyproject.toml diff --git a/testcases/basic-testcase/run.sh b/packages/uipath/testcases/basic-testcase/run.sh similarity index 100% rename from testcases/basic-testcase/run.sh rename to packages/uipath/testcases/basic-testcase/run.sh diff --git a/testcases/basic-testcase/src/assert.py b/packages/uipath/testcases/basic-testcase/src/assert.py similarity index 100% rename from testcases/basic-testcase/src/assert.py rename to packages/uipath/testcases/basic-testcase/src/assert.py diff --git a/testcases/basic-testcase/uipath.json b/packages/uipath/testcases/basic-testcase/uipath.json similarity index 100% rename from testcases/basic-testcase/uipath.json rename to packages/uipath/testcases/basic-testcase/uipath.json diff --git a/testcases/calculator-crash-evals/pyproject.toml b/packages/uipath/testcases/calculator-crash-evals/pyproject.toml similarity index 100% rename from testcases/calculator-crash-evals/pyproject.toml rename to packages/uipath/testcases/calculator-crash-evals/pyproject.toml diff --git a/testcases/calculator-crash-evals/run.sh b/packages/uipath/testcases/calculator-crash-evals/run.sh similarity index 100% rename from testcases/calculator-crash-evals/run.sh rename to packages/uipath/testcases/calculator-crash-evals/run.sh diff --git a/testcases/calculator-crash-evals/src/assert.py b/packages/uipath/testcases/calculator-crash-evals/src/assert.py similarity index 100% rename from testcases/calculator-crash-evals/src/assert.py rename to packages/uipath/testcases/calculator-crash-evals/src/assert.py diff --git a/testcases/calculator-crash-evals/uipath.json b/packages/uipath/testcases/calculator-crash-evals/uipath.json similarity index 100% rename from testcases/calculator-crash-evals/uipath.json rename to packages/uipath/testcases/calculator-crash-evals/uipath.json diff --git a/testcases/calculator-evals/pyproject.toml b/packages/uipath/testcases/calculator-evals/pyproject.toml similarity index 100% rename from testcases/calculator-evals/pyproject.toml rename to packages/uipath/testcases/calculator-evals/pyproject.toml diff --git a/testcases/calculator-evals/run.sh b/packages/uipath/testcases/calculator-evals/run.sh similarity index 100% rename from testcases/calculator-evals/run.sh rename to packages/uipath/testcases/calculator-evals/run.sh diff --git a/testcases/calculator-evals/src/assert.py b/packages/uipath/testcases/calculator-evals/src/assert.py similarity index 100% rename from testcases/calculator-evals/src/assert.py rename to packages/uipath/testcases/calculator-evals/src/assert.py diff --git a/testcases/calculator-evals/uipath.json b/packages/uipath/testcases/calculator-evals/uipath.json similarity index 100% rename from testcases/calculator-evals/uipath.json rename to packages/uipath/testcases/calculator-evals/uipath.json diff --git a/testcases/common/trace_assert.py b/packages/uipath/testcases/common/trace_assert.py similarity index 100% rename from testcases/common/trace_assert.py rename to packages/uipath/testcases/common/trace_assert.py diff --git a/testcases/common/validate_output.sh b/packages/uipath/testcases/common/validate_output.sh similarity index 100% rename from testcases/common/validate_output.sh rename to packages/uipath/testcases/common/validate_output.sh diff --git a/testcases/csv-processor-evals/pyproject.toml b/packages/uipath/testcases/csv-processor-evals/pyproject.toml similarity index 100% rename from testcases/csv-processor-evals/pyproject.toml rename to packages/uipath/testcases/csv-processor-evals/pyproject.toml diff --git a/testcases/csv-processor-evals/run.sh b/packages/uipath/testcases/csv-processor-evals/run.sh similarity index 100% rename from testcases/csv-processor-evals/run.sh rename to packages/uipath/testcases/csv-processor-evals/run.sh diff --git a/testcases/csv-processor-evals/src/assert.py b/packages/uipath/testcases/csv-processor-evals/src/assert.py similarity index 100% rename from testcases/csv-processor-evals/src/assert.py rename to packages/uipath/testcases/csv-processor-evals/src/assert.py diff --git a/testcases/csv-processor-evals/uipath.json b/packages/uipath/testcases/csv-processor-evals/uipath.json similarity index 100% rename from testcases/csv-processor-evals/uipath.json rename to packages/uipath/testcases/csv-processor-evals/uipath.json diff --git a/testcases/eval-input-overrides/entry-points.json b/packages/uipath/testcases/eval-input-overrides/entry-points.json similarity index 100% rename from testcases/eval-input-overrides/entry-points.json rename to packages/uipath/testcases/eval-input-overrides/entry-points.json diff --git a/testcases/eval-input-overrides/pyproject.toml b/packages/uipath/testcases/eval-input-overrides/pyproject.toml similarity index 100% rename from testcases/eval-input-overrides/pyproject.toml rename to packages/uipath/testcases/eval-input-overrides/pyproject.toml diff --git a/testcases/eval-input-overrides/run.sh b/packages/uipath/testcases/eval-input-overrides/run.sh similarity index 100% rename from testcases/eval-input-overrides/run.sh rename to packages/uipath/testcases/eval-input-overrides/run.sh diff --git a/testcases/eval-input-overrides/src/assert.py b/packages/uipath/testcases/eval-input-overrides/src/assert.py similarity index 100% rename from testcases/eval-input-overrides/src/assert.py rename to packages/uipath/testcases/eval-input-overrides/src/assert.py diff --git a/testcases/eval-input-overrides/src/input-overrides-eval-set.json b/packages/uipath/testcases/eval-input-overrides/src/input-overrides-eval-set.json similarity index 100% rename from testcases/eval-input-overrides/src/input-overrides-eval-set.json rename to packages/uipath/testcases/eval-input-overrides/src/input-overrides-eval-set.json diff --git a/testcases/eval-input-overrides/src/input-overrides.json b/packages/uipath/testcases/eval-input-overrides/src/input-overrides.json similarity index 100% rename from testcases/eval-input-overrides/src/input-overrides.json rename to packages/uipath/testcases/eval-input-overrides/src/input-overrides.json diff --git a/testcases/eval-input-overrides/uipath.json b/packages/uipath/testcases/eval-input-overrides/uipath.json similarity index 100% rename from testcases/eval-input-overrides/uipath.json rename to packages/uipath/testcases/eval-input-overrides/uipath.json diff --git a/testcases/eval-spans-testcase/pyproject.toml b/packages/uipath/testcases/eval-spans-testcase/pyproject.toml similarity index 100% rename from testcases/eval-spans-testcase/pyproject.toml rename to packages/uipath/testcases/eval-spans-testcase/pyproject.toml diff --git a/testcases/eval-spans-testcase/run.sh b/packages/uipath/testcases/eval-spans-testcase/run.sh similarity index 100% rename from testcases/eval-spans-testcase/run.sh rename to packages/uipath/testcases/eval-spans-testcase/run.sh diff --git a/testcases/eval-spans-testcase/src/assert.py b/packages/uipath/testcases/eval-spans-testcase/src/assert.py similarity index 100% rename from testcases/eval-spans-testcase/src/assert.py rename to packages/uipath/testcases/eval-spans-testcase/src/assert.py diff --git a/testcases/eval-spans-testcase/uipath.json b/packages/uipath/testcases/eval-spans-testcase/uipath.json similarity index 100% rename from testcases/eval-spans-testcase/uipath.json rename to packages/uipath/testcases/eval-spans-testcase/uipath.json diff --git a/testcases/eval-telemetry-testcase/pyproject.toml b/packages/uipath/testcases/eval-telemetry-testcase/pyproject.toml similarity index 100% rename from testcases/eval-telemetry-testcase/pyproject.toml rename to packages/uipath/testcases/eval-telemetry-testcase/pyproject.toml diff --git a/testcases/eval-telemetry-testcase/run.sh b/packages/uipath/testcases/eval-telemetry-testcase/run.sh similarity index 100% rename from testcases/eval-telemetry-testcase/run.sh rename to packages/uipath/testcases/eval-telemetry-testcase/run.sh diff --git a/testcases/eval-telemetry-testcase/src/assert.py b/packages/uipath/testcases/eval-telemetry-testcase/src/assert.py similarity index 100% rename from testcases/eval-telemetry-testcase/src/assert.py rename to packages/uipath/testcases/eval-telemetry-testcase/src/assert.py diff --git a/testcases/eval-telemetry-testcase/uipath.json b/packages/uipath/testcases/eval-telemetry-testcase/uipath.json similarity index 100% rename from testcases/eval-telemetry-testcase/uipath.json rename to packages/uipath/testcases/eval-telemetry-testcase/uipath.json diff --git a/testcases/langchain-cross/agent.mermaid b/packages/uipath/testcases/langchain-cross/agent.mermaid similarity index 100% rename from testcases/langchain-cross/agent.mermaid rename to packages/uipath/testcases/langchain-cross/agent.mermaid diff --git a/testcases/langchain-cross/expected_traces.json b/packages/uipath/testcases/langchain-cross/expected_traces.json similarity index 100% rename from testcases/langchain-cross/expected_traces.json rename to packages/uipath/testcases/langchain-cross/expected_traces.json diff --git a/testcases/langchain-cross/langgraph.json b/packages/uipath/testcases/langchain-cross/langgraph.json similarity index 100% rename from testcases/langchain-cross/langgraph.json rename to packages/uipath/testcases/langchain-cross/langgraph.json diff --git a/testcases/langchain-cross/pyproject.toml b/packages/uipath/testcases/langchain-cross/pyproject.toml similarity index 100% rename from testcases/langchain-cross/pyproject.toml rename to packages/uipath/testcases/langchain-cross/pyproject.toml diff --git a/testcases/langchain-cross/run.sh b/packages/uipath/testcases/langchain-cross/run.sh similarity index 100% rename from testcases/langchain-cross/run.sh rename to packages/uipath/testcases/langchain-cross/run.sh diff --git a/testcases/langchain-cross/src/assert.py b/packages/uipath/testcases/langchain-cross/src/assert.py similarity index 100% rename from testcases/langchain-cross/src/assert.py rename to packages/uipath/testcases/langchain-cross/src/assert.py diff --git a/testcases/langchain-cross/src/assert.py.orig b/packages/uipath/testcases/langchain-cross/src/assert.py.orig similarity index 100% rename from testcases/langchain-cross/src/assert.py.orig rename to packages/uipath/testcases/langchain-cross/src/assert.py.orig diff --git a/testcases/langchain-cross/src/main.py b/packages/uipath/testcases/langchain-cross/src/main.py similarity index 100% rename from testcases/langchain-cross/src/main.py rename to packages/uipath/testcases/langchain-cross/src/main.py diff --git a/testcases/llamaindexcross/agent.mermaid b/packages/uipath/testcases/llamaindexcross/agent.mermaid similarity index 100% rename from testcases/llamaindexcross/agent.mermaid rename to packages/uipath/testcases/llamaindexcross/agent.mermaid diff --git a/testcases/llamaindexcross/llama_index.json b/packages/uipath/testcases/llamaindexcross/llama_index.json similarity index 100% rename from testcases/llamaindexcross/llama_index.json rename to packages/uipath/testcases/llamaindexcross/llama_index.json diff --git a/testcases/llamaindexcross/pyproject.toml b/packages/uipath/testcases/llamaindexcross/pyproject.toml similarity index 100% rename from testcases/llamaindexcross/pyproject.toml rename to packages/uipath/testcases/llamaindexcross/pyproject.toml diff --git a/testcases/llamaindexcross/run.sh b/packages/uipath/testcases/llamaindexcross/run.sh similarity index 100% rename from testcases/llamaindexcross/run.sh rename to packages/uipath/testcases/llamaindexcross/run.sh diff --git a/testcases/llamaindexcross/src/assert.py b/packages/uipath/testcases/llamaindexcross/src/assert.py similarity index 100% rename from testcases/llamaindexcross/src/assert.py rename to packages/uipath/testcases/llamaindexcross/src/assert.py diff --git a/testcases/llamaindexcross/src/main.py b/packages/uipath/testcases/llamaindexcross/src/main.py similarity index 100% rename from testcases/llamaindexcross/src/main.py rename to packages/uipath/testcases/llamaindexcross/src/main.py diff --git a/testcases/target-output-key-evals/pyproject.toml b/packages/uipath/testcases/target-output-key-evals/pyproject.toml similarity index 100% rename from testcases/target-output-key-evals/pyproject.toml rename to packages/uipath/testcases/target-output-key-evals/pyproject.toml diff --git a/testcases/target-output-key-evals/run.sh b/packages/uipath/testcases/target-output-key-evals/run.sh similarity index 100% rename from testcases/target-output-key-evals/run.sh rename to packages/uipath/testcases/target-output-key-evals/run.sh diff --git a/testcases/target-output-key-evals/src/assert.py b/packages/uipath/testcases/target-output-key-evals/src/assert.py similarity index 100% rename from testcases/target-output-key-evals/src/assert.py rename to packages/uipath/testcases/target-output-key-evals/src/assert.py diff --git a/testcases/target-output-key-evals/uipath.json b/packages/uipath/testcases/target-output-key-evals/uipath.json similarity index 100% rename from testcases/target-output-key-evals/uipath.json rename to packages/uipath/testcases/target-output-key-evals/uipath.json diff --git a/testcases/tools-evals/pyproject.toml b/packages/uipath/testcases/tools-evals/pyproject.toml similarity index 100% rename from testcases/tools-evals/pyproject.toml rename to packages/uipath/testcases/tools-evals/pyproject.toml diff --git a/testcases/tools-evals/run.sh b/packages/uipath/testcases/tools-evals/run.sh similarity index 100% rename from testcases/tools-evals/run.sh rename to packages/uipath/testcases/tools-evals/run.sh diff --git a/testcases/tools-evals/src/assert.py b/packages/uipath/testcases/tools-evals/src/assert.py similarity index 100% rename from testcases/tools-evals/src/assert.py rename to packages/uipath/testcases/tools-evals/src/assert.py diff --git a/testcases/tools-evals/uipath.json b/packages/uipath/testcases/tools-evals/uipath.json similarity index 100% rename from testcases/tools-evals/uipath.json rename to packages/uipath/testcases/tools-evals/uipath.json diff --git a/tests/__init__.py b/packages/uipath/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to packages/uipath/tests/__init__.py diff --git a/tests/agent/models/test_agent.py b/packages/uipath/tests/agent/models/test_agent.py similarity index 100% rename from tests/agent/models/test_agent.py rename to packages/uipath/tests/agent/models/test_agent.py diff --git a/tests/agent/models/test_evals.py b/packages/uipath/tests/agent/models/test_evals.py similarity index 100% rename from tests/agent/models/test_evals.py rename to packages/uipath/tests/agent/models/test_evals.py diff --git a/tests/agent/models/test_legacy.py b/packages/uipath/tests/agent/models/test_legacy.py similarity index 100% rename from tests/agent/models/test_legacy.py rename to packages/uipath/tests/agent/models/test_legacy.py diff --git a/packages/uipath/tests/agent/react/__init__.py b/packages/uipath/tests/agent/react/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/agent/react/test_conversational_prompts.py b/packages/uipath/tests/agent/react/test_conversational_prompts.py similarity index 100% rename from tests/agent/react/test_conversational_prompts.py rename to packages/uipath/tests/agent/react/test_conversational_prompts.py diff --git a/tests/agent/utils/test_load_agent_definition.py b/packages/uipath/tests/agent/utils/test_load_agent_definition.py similarity index 100% rename from tests/agent/utils/test_load_agent_definition.py rename to packages/uipath/tests/agent/utils/test_load_agent_definition.py diff --git a/tests/agent/utils/test_text_tokens.py b/packages/uipath/tests/agent/utils/test_text_tokens.py similarity index 100% rename from tests/agent/utils/test_text_tokens.py rename to packages/uipath/tests/agent/utils/test_text_tokens.py diff --git a/packages/uipath/tests/cli/chat/__init__.py b/packages/uipath/tests/cli/chat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/chat/test_bridge.py b/packages/uipath/tests/cli/chat/test_bridge.py similarity index 100% rename from tests/cli/chat/test_bridge.py rename to packages/uipath/tests/cli/chat/test_bridge.py diff --git a/tests/cli/conftest.py b/packages/uipath/tests/cli/conftest.py similarity index 100% rename from tests/cli/conftest.py rename to packages/uipath/tests/cli/conftest.py diff --git a/tests/cli/contract/test_sdk_cli_alignment.py b/packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py similarity index 100% rename from tests/cli/contract/test_sdk_cli_alignment.py rename to packages/uipath/tests/cli/contract/test_sdk_cli_alignment.py diff --git a/tests/cli/eval/evals/eval-sets/default.json b/packages/uipath/tests/cli/eval/evals/eval-sets/default.json similarity index 100% rename from tests/cli/eval/evals/eval-sets/default.json rename to packages/uipath/tests/cli/eval/evals/eval-sets/default.json diff --git a/tests/cli/eval/evals/eval-sets/multiple-evals.json b/packages/uipath/tests/cli/eval/evals/eval-sets/multiple-evals.json similarity index 100% rename from tests/cli/eval/evals/eval-sets/multiple-evals.json rename to packages/uipath/tests/cli/eval/evals/eval-sets/multiple-evals.json diff --git a/tests/cli/eval/evals/evaluators/exact-match.json b/packages/uipath/tests/cli/eval/evals/evaluators/exact-match.json similarity index 100% rename from tests/cli/eval/evals/evaluators/exact-match.json rename to packages/uipath/tests/cli/eval/evals/evaluators/exact-match.json diff --git a/tests/cli/eval/mocks/test_cache_manager.py b/packages/uipath/tests/cli/eval/mocks/test_cache_manager.py similarity index 100% rename from tests/cli/eval/mocks/test_cache_manager.py rename to packages/uipath/tests/cli/eval/mocks/test_cache_manager.py diff --git a/tests/cli/eval/mocks/test_input_mocker.py b/packages/uipath/tests/cli/eval/mocks/test_input_mocker.py similarity index 100% rename from tests/cli/eval/mocks/test_input_mocker.py rename to packages/uipath/tests/cli/eval/mocks/test_input_mocker.py diff --git a/tests/cli/eval/mocks/test_mockable_mocked_annotation.py b/packages/uipath/tests/cli/eval/mocks/test_mockable_mocked_annotation.py similarity index 100% rename from tests/cli/eval/mocks/test_mockable_mocked_annotation.py rename to packages/uipath/tests/cli/eval/mocks/test_mockable_mocked_annotation.py diff --git a/tests/cli/eval/mocks/test_mocks.py b/packages/uipath/tests/cli/eval/mocks/test_mocks.py similarity index 100% rename from tests/cli/eval/mocks/test_mocks.py rename to packages/uipath/tests/cli/eval/mocks/test_mocks.py diff --git a/tests/cli/eval/test_apply_file_overrides.py b/packages/uipath/tests/cli/eval/test_apply_file_overrides.py similarity index 100% rename from tests/cli/eval/test_apply_file_overrides.py rename to packages/uipath/tests/cli/eval/test_apply_file_overrides.py diff --git a/tests/cli/eval/test_conversational_utils.py b/packages/uipath/tests/cli/eval/test_conversational_utils.py similarity index 100% rename from tests/cli/eval/test_conversational_utils.py rename to packages/uipath/tests/cli/eval/test_conversational_utils.py diff --git a/tests/cli/eval/test_eval_discovery.py b/packages/uipath/tests/cli/eval/test_eval_discovery.py similarity index 100% rename from tests/cli/eval/test_eval_discovery.py rename to packages/uipath/tests/cli/eval/test_eval_discovery.py diff --git a/tests/cli/eval/test_eval_resume_flow.py b/packages/uipath/tests/cli/eval/test_eval_resume_flow.py similarity index 100% rename from tests/cli/eval/test_eval_resume_flow.py rename to packages/uipath/tests/cli/eval/test_eval_resume_flow.py diff --git a/tests/cli/eval/test_eval_runtime_metadata.py b/packages/uipath/tests/cli/eval/test_eval_runtime_metadata.py similarity index 100% rename from tests/cli/eval/test_eval_runtime_metadata.py rename to packages/uipath/tests/cli/eval/test_eval_runtime_metadata.py diff --git a/tests/cli/eval/test_eval_runtime_spans.py b/packages/uipath/tests/cli/eval/test_eval_runtime_spans.py similarity index 100% rename from tests/cli/eval/test_eval_runtime_spans.py rename to packages/uipath/tests/cli/eval/test_eval_runtime_spans.py diff --git a/tests/cli/eval/test_eval_runtime_suspend_resume.py b/packages/uipath/tests/cli/eval/test_eval_runtime_suspend_resume.py similarity index 100% rename from tests/cli/eval/test_eval_runtime_suspend_resume.py rename to packages/uipath/tests/cli/eval/test_eval_runtime_suspend_resume.py diff --git a/tests/cli/eval/test_eval_set.py b/packages/uipath/tests/cli/eval/test_eval_set.py similarity index 100% rename from tests/cli/eval/test_eval_set.py rename to packages/uipath/tests/cli/eval/test_eval_set.py diff --git a/tests/cli/eval/test_eval_span_utils.py b/packages/uipath/tests/cli/eval/test_eval_span_utils.py similarity index 100% rename from tests/cli/eval/test_eval_span_utils.py rename to packages/uipath/tests/cli/eval/test_eval_span_utils.py diff --git a/tests/cli/eval/test_eval_telemetry.py b/packages/uipath/tests/cli/eval/test_eval_telemetry.py similarity index 100% rename from tests/cli/eval/test_eval_telemetry.py rename to packages/uipath/tests/cli/eval/test_eval_telemetry.py diff --git a/tests/cli/eval/test_eval_tracing_integration.py b/packages/uipath/tests/cli/eval/test_eval_tracing_integration.py similarity index 100% rename from tests/cli/eval/test_eval_tracing_integration.py rename to packages/uipath/tests/cli/eval/test_eval_tracing_integration.py diff --git a/tests/cli/eval/test_eval_util.py b/packages/uipath/tests/cli/eval/test_eval_util.py similarity index 100% rename from tests/cli/eval/test_eval_util.py rename to packages/uipath/tests/cli/eval/test_eval_util.py diff --git a/tests/cli/eval/test_evaluate.py b/packages/uipath/tests/cli/eval/test_evaluate.py similarity index 100% rename from tests/cli/eval/test_evaluate.py rename to packages/uipath/tests/cli/eval/test_evaluate.py diff --git a/tests/cli/eval/test_input_overrides_e2e.py b/packages/uipath/tests/cli/eval/test_input_overrides_e2e.py similarity index 100% rename from tests/cli/eval/test_input_overrides_e2e.py rename to packages/uipath/tests/cli/eval/test_input_overrides_e2e.py diff --git a/tests/cli/eval/test_live_tracking_span_processor.py b/packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py similarity index 100% rename from tests/cli/eval/test_live_tracking_span_processor.py rename to packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py diff --git a/tests/cli/eval/test_progress_reporter.py b/packages/uipath/tests/cli/eval/test_progress_reporter.py similarity index 100% rename from tests/cli/eval/test_progress_reporter.py rename to packages/uipath/tests/cli/eval/test_progress_reporter.py diff --git a/tests/cli/eval/test_span_persistence.py b/packages/uipath/tests/cli/eval/test_span_persistence.py similarity index 100% rename from tests/cli/eval/test_span_persistence.py rename to packages/uipath/tests/cli/eval/test_span_persistence.py diff --git a/tests/cli/evaluators/test_json_similarity_evaluator.py b/packages/uipath/tests/cli/evaluators/test_json_similarity_evaluator.py similarity index 100% rename from tests/cli/evaluators/test_json_similarity_evaluator.py rename to packages/uipath/tests/cli/evaluators/test_json_similarity_evaluator.py diff --git a/tests/cli/evaluators/test_legacy_context_precision_evaluator.py b/packages/uipath/tests/cli/evaluators/test_legacy_context_precision_evaluator.py similarity index 100% rename from tests/cli/evaluators/test_legacy_context_precision_evaluator.py rename to packages/uipath/tests/cli/evaluators/test_legacy_context_precision_evaluator.py diff --git a/tests/cli/evaluators/test_legacy_exact_match_evaluator.py b/packages/uipath/tests/cli/evaluators/test_legacy_exact_match_evaluator.py similarity index 100% rename from tests/cli/evaluators/test_legacy_exact_match_evaluator.py rename to packages/uipath/tests/cli/evaluators/test_legacy_exact_match_evaluator.py diff --git a/tests/cli/evaluators/test_legacy_faithfulness_evaluator.py b/packages/uipath/tests/cli/evaluators/test_legacy_faithfulness_evaluator.py similarity index 100% rename from tests/cli/evaluators/test_legacy_faithfulness_evaluator.py rename to packages/uipath/tests/cli/evaluators/test_legacy_faithfulness_evaluator.py diff --git a/tests/cli/evaluators/test_legacy_llm_as_judge_placeholder_validation.py b/packages/uipath/tests/cli/evaluators/test_legacy_llm_as_judge_placeholder_validation.py similarity index 100% rename from tests/cli/evaluators/test_legacy_llm_as_judge_placeholder_validation.py rename to packages/uipath/tests/cli/evaluators/test_legacy_llm_as_judge_placeholder_validation.py diff --git a/tests/cli/integration/test_assets_commands.py b/packages/uipath/tests/cli/integration/test_assets_commands.py similarity index 100% rename from tests/cli/integration/test_assets_commands.py rename to packages/uipath/tests/cli/integration/test_assets_commands.py diff --git a/tests/cli/integration/test_buckets_commands.py b/packages/uipath/tests/cli/integration/test_buckets_commands.py similarity index 100% rename from tests/cli/integration/test_buckets_commands.py rename to packages/uipath/tests/cli/integration/test_buckets_commands.py diff --git a/tests/cli/mocks/pyproject.toml b/packages/uipath/tests/cli/mocks/pyproject.toml similarity index 100% rename from tests/cli/mocks/pyproject.toml rename to packages/uipath/tests/cli/mocks/pyproject.toml diff --git a/tests/cli/mocks/simple_script.py b/packages/uipath/tests/cli/mocks/simple_script.py similarity index 100% rename from tests/cli/mocks/simple_script.py rename to packages/uipath/tests/cli/mocks/simple_script.py diff --git a/tests/cli/mocks/uipath-mock-legacy.json b/packages/uipath/tests/cli/mocks/uipath-mock-legacy.json similarity index 100% rename from tests/cli/mocks/uipath-mock-legacy.json rename to packages/uipath/tests/cli/mocks/uipath-mock-legacy.json diff --git a/tests/cli/mocks/uipath-simple-script-mock.json b/packages/uipath/tests/cli/mocks/uipath-simple-script-mock.json similarity index 100% rename from tests/cli/mocks/uipath-simple-script-mock.json rename to packages/uipath/tests/cli/mocks/uipath-simple-script-mock.json diff --git a/tests/cli/models/test_runtime_schema.py b/packages/uipath/tests/cli/models/test_runtime_schema.py similarity index 100% rename from tests/cli/models/test_runtime_schema.py rename to packages/uipath/tests/cli/models/test_runtime_schema.py diff --git a/tests/cli/test_auth.py b/packages/uipath/tests/cli/test_auth.py similarity index 100% rename from tests/cli/test_auth.py rename to packages/uipath/tests/cli/test_auth.py diff --git a/tests/cli/test_cli_telemetry.py b/packages/uipath/tests/cli/test_cli_telemetry.py similarity index 100% rename from tests/cli/test_cli_telemetry.py rename to packages/uipath/tests/cli/test_cli_telemetry.py diff --git a/tests/cli/test_debug_simulation.py b/packages/uipath/tests/cli/test_debug_simulation.py similarity index 100% rename from tests/cli/test_debug_simulation.py rename to packages/uipath/tests/cli/test_debug_simulation.py diff --git a/tests/cli/test_init.py b/packages/uipath/tests/cli/test_init.py similarity index 100% rename from tests/cli/test_init.py rename to packages/uipath/tests/cli/test_init.py diff --git a/tests/cli/test_init_agents_md.py b/packages/uipath/tests/cli/test_init_agents_md.py similarity index 100% rename from tests/cli/test_init_agents_md.py rename to packages/uipath/tests/cli/test_init_agents_md.py diff --git a/tests/cli/test_input_args.py b/packages/uipath/tests/cli/test_input_args.py similarity index 100% rename from tests/cli/test_input_args.py rename to packages/uipath/tests/cli/test_input_args.py diff --git a/tests/cli/test_invoke.py b/packages/uipath/tests/cli/test_invoke.py similarity index 100% rename from tests/cli/test_invoke.py rename to packages/uipath/tests/cli/test_invoke.py diff --git a/tests/cli/test_new.py b/packages/uipath/tests/cli/test_new.py similarity index 100% rename from tests/cli/test_new.py rename to packages/uipath/tests/cli/test_new.py diff --git a/tests/cli/test_oidc_utils.py b/packages/uipath/tests/cli/test_oidc_utils.py similarity index 100% rename from tests/cli/test_oidc_utils.py rename to packages/uipath/tests/cli/test_oidc_utils.py diff --git a/tests/cli/test_pack.py b/packages/uipath/tests/cli/test_pack.py similarity index 100% rename from tests/cli/test_pack.py rename to packages/uipath/tests/cli/test_pack.py diff --git a/tests/cli/test_portal_service_ensure_valid_token.py b/packages/uipath/tests/cli/test_portal_service_ensure_valid_token.py similarity index 100% rename from tests/cli/test_portal_service_ensure_valid_token.py rename to packages/uipath/tests/cli/test_portal_service_ensure_valid_token.py diff --git a/tests/cli/test_portal_service_refresh_token.py b/packages/uipath/tests/cli/test_portal_service_refresh_token.py similarity index 100% rename from tests/cli/test_portal_service_refresh_token.py rename to packages/uipath/tests/cli/test_portal_service_refresh_token.py diff --git a/tests/cli/test_publish.py b/packages/uipath/tests/cli/test_publish.py similarity index 100% rename from tests/cli/test_publish.py rename to packages/uipath/tests/cli/test_publish.py diff --git a/tests/cli/test_pull.py b/packages/uipath/tests/cli/test_pull.py similarity index 100% rename from tests/cli/test_pull.py rename to packages/uipath/tests/cli/test_pull.py diff --git a/tests/cli/test_push.py b/packages/uipath/tests/cli/test_push.py similarity index 100% rename from tests/cli/test_push.py rename to packages/uipath/tests/cli/test_push.py diff --git a/tests/cli/test_run.py b/packages/uipath/tests/cli/test_run.py similarity index 100% rename from tests/cli/test_run.py rename to packages/uipath/tests/cli/test_run.py diff --git a/tests/cli/test_server.py b/packages/uipath/tests/cli/test_server.py similarity index 100% rename from tests/cli/test_server.py rename to packages/uipath/tests/cli/test_server.py diff --git a/tests/cli/test_utils.py b/packages/uipath/tests/cli/test_utils.py similarity index 100% rename from tests/cli/test_utils.py rename to packages/uipath/tests/cli/test_utils.py diff --git a/tests/cli/unit/test_buckets_migration.py b/packages/uipath/tests/cli/unit/test_buckets_migration.py similarity index 100% rename from tests/cli/unit/test_buckets_migration.py rename to packages/uipath/tests/cli/unit/test_buckets_migration.py diff --git a/tests/cli/unit/test_context.py b/packages/uipath/tests/cli/unit/test_context.py similarity index 100% rename from tests/cli/unit/test_context.py rename to packages/uipath/tests/cli/unit/test_context.py diff --git a/tests/cli/unit/test_formatters.py b/packages/uipath/tests/cli/unit/test_formatters.py similarity index 100% rename from tests/cli/unit/test_formatters.py rename to packages/uipath/tests/cli/unit/test_formatters.py diff --git a/tests/cli/unit/test_service_cli_generator.py b/packages/uipath/tests/cli/unit/test_service_cli_generator.py similarity index 100% rename from tests/cli/unit/test_service_cli_generator.py rename to packages/uipath/tests/cli/unit/test_service_cli_generator.py diff --git a/tests/cli/unit/test_service_metadata.py b/packages/uipath/tests/cli/unit/test_service_metadata.py similarity index 100% rename from tests/cli/unit/test_service_metadata.py rename to packages/uipath/tests/cli/unit/test_service_metadata.py diff --git a/tests/cli/unit/test_service_protocol.py b/packages/uipath/tests/cli/unit/test_service_protocol.py similarity index 100% rename from tests/cli/unit/test_service_protocol.py rename to packages/uipath/tests/cli/unit/test_service_protocol.py diff --git a/tests/cli/unit/test_type_registry.py b/packages/uipath/tests/cli/unit/test_type_registry.py similarity index 100% rename from tests/cli/unit/test_type_registry.py rename to packages/uipath/tests/cli/unit/test_type_registry.py diff --git a/tests/cli/unit/test_validators.py b/packages/uipath/tests/cli/unit/test_validators.py similarity index 100% rename from tests/cli/unit/test_validators.py rename to packages/uipath/tests/cli/unit/test_validators.py diff --git a/tests/cli/utils/common.py b/packages/uipath/tests/cli/utils/common.py similarity index 100% rename from tests/cli/utils/common.py rename to packages/uipath/tests/cli/utils/common.py diff --git a/tests/cli/utils/project_details.py b/packages/uipath/tests/cli/utils/project_details.py similarity index 100% rename from tests/cli/utils/project_details.py rename to packages/uipath/tests/cli/utils/project_details.py diff --git a/tests/conftest.py b/packages/uipath/tests/conftest.py similarity index 100% rename from tests/conftest.py rename to packages/uipath/tests/conftest.py diff --git a/tests/evaluators/__init__.py b/packages/uipath/tests/evaluators/__init__.py similarity index 100% rename from tests/evaluators/__init__.py rename to packages/uipath/tests/evaluators/__init__.py diff --git a/tests/evaluators/test_documentation_examples.py b/packages/uipath/tests/evaluators/test_documentation_examples.py similarity index 100% rename from tests/evaluators/test_documentation_examples.py rename to packages/uipath/tests/evaluators/test_documentation_examples.py diff --git a/tests/evaluators/test_eval_level_expected_output.py b/packages/uipath/tests/evaluators/test_eval_level_expected_output.py similarity index 100% rename from tests/evaluators/test_eval_level_expected_output.py rename to packages/uipath/tests/evaluators/test_eval_level_expected_output.py diff --git a/tests/evaluators/test_evaluator_aggregation.py b/packages/uipath/tests/evaluators/test_evaluator_aggregation.py similarity index 100% rename from tests/evaluators/test_evaluator_aggregation.py rename to packages/uipath/tests/evaluators/test_evaluator_aggregation.py diff --git a/tests/evaluators/test_evaluator_factory.py b/packages/uipath/tests/evaluators/test_evaluator_factory.py similarity index 100% rename from tests/evaluators/test_evaluator_factory.py rename to packages/uipath/tests/evaluators/test_evaluator_factory.py diff --git a/tests/evaluators/test_evaluator_helpers.py b/packages/uipath/tests/evaluators/test_evaluator_helpers.py similarity index 100% rename from tests/evaluators/test_evaluator_helpers.py rename to packages/uipath/tests/evaluators/test_evaluator_helpers.py diff --git a/tests/evaluators/test_evaluator_methods.py b/packages/uipath/tests/evaluators/test_evaluator_methods.py similarity index 100% rename from tests/evaluators/test_evaluator_methods.py rename to packages/uipath/tests/evaluators/test_evaluator_methods.py diff --git a/tests/evaluators/test_evaluator_schemas.py b/packages/uipath/tests/evaluators/test_evaluator_schemas.py similarity index 100% rename from tests/evaluators/test_evaluator_schemas.py rename to packages/uipath/tests/evaluators/test_evaluator_schemas.py diff --git a/tests/evaluators/test_helpers.py b/packages/uipath/tests/evaluators/test_helpers.py similarity index 100% rename from tests/evaluators/test_helpers.py rename to packages/uipath/tests/evaluators/test_helpers.py diff --git a/tests/evaluators/test_legacy_target_output_key_paths.py b/packages/uipath/tests/evaluators/test_legacy_target_output_key_paths.py similarity index 100% rename from tests/evaluators/test_legacy_target_output_key_paths.py rename to packages/uipath/tests/evaluators/test_legacy_target_output_key_paths.py diff --git a/tests/evaluators/test_llm_judge_placeholder_validation.py b/packages/uipath/tests/evaluators/test_llm_judge_placeholder_validation.py similarity index 100% rename from tests/evaluators/test_llm_judge_placeholder_validation.py rename to packages/uipath/tests/evaluators/test_llm_judge_placeholder_validation.py diff --git a/tests/evaluators/test_output_path.py b/packages/uipath/tests/evaluators/test_output_path.py similarity index 100% rename from tests/evaluators/test_output_path.py rename to packages/uipath/tests/evaluators/test_output_path.py diff --git a/packages/uipath/tests/functions/__init__.py b/packages/uipath/tests/functions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/functions/test_debug_breakpoints.py b/packages/uipath/tests/functions/test_debug_breakpoints.py similarity index 100% rename from tests/functions/test_debug_breakpoints.py rename to packages/uipath/tests/functions/test_debug_breakpoints.py diff --git a/tests/functions/test_graph_builder.py b/packages/uipath/tests/functions/test_graph_builder.py similarity index 100% rename from tests/functions/test_graph_builder.py rename to packages/uipath/tests/functions/test_graph_builder.py diff --git a/tests/functions/test_unwrap_decorated.py b/packages/uipath/tests/functions/test_unwrap_decorated.py similarity index 100% rename from tests/functions/test_unwrap_decorated.py rename to packages/uipath/tests/functions/test_unwrap_decorated.py diff --git a/tests/resource_overrides/__init__.py b/packages/uipath/tests/resource_overrides/__init__.py similarity index 100% rename from tests/resource_overrides/__init__.py rename to packages/uipath/tests/resource_overrides/__init__.py diff --git a/tests/resource_overrides/overwrites.json b/packages/uipath/tests/resource_overrides/overwrites.json similarity index 100% rename from tests/resource_overrides/overwrites.json rename to packages/uipath/tests/resource_overrides/overwrites.json diff --git a/tests/resource_overrides/test_resource_overrides.py b/packages/uipath/tests/resource_overrides/test_resource_overrides.py similarity index 100% rename from tests/resource_overrides/test_resource_overrides.py rename to packages/uipath/tests/resource_overrides/test_resource_overrides.py diff --git a/tests/sdk/test_bindings.py b/packages/uipath/tests/sdk/test_bindings.py similarity index 100% rename from tests/sdk/test_bindings.py rename to packages/uipath/tests/sdk/test_bindings.py diff --git a/tests/sdk/test_config.py b/packages/uipath/tests/sdk/test_config.py similarity index 100% rename from tests/sdk/test_config.py rename to packages/uipath/tests/sdk/test_config.py diff --git a/tests/sdk/test_uipath_caching.py b/packages/uipath/tests/sdk/test_uipath_caching.py similarity index 100% rename from tests/sdk/test_uipath_caching.py rename to packages/uipath/tests/sdk/test_uipath_caching.py diff --git a/tests/sdk/test_utils_validation.py b/packages/uipath/tests/sdk/test_utils_validation.py similarity index 100% rename from tests/sdk/test_utils_validation.py rename to packages/uipath/tests/sdk/test_utils_validation.py diff --git a/tests/telemetry/__init__.py b/packages/uipath/tests/telemetry/__init__.py similarity index 100% rename from tests/telemetry/__init__.py rename to packages/uipath/tests/telemetry/__init__.py diff --git a/tests/telemetry/test_track.py b/packages/uipath/tests/telemetry/test_track.py similarity index 100% rename from tests/telemetry/test_track.py rename to packages/uipath/tests/telemetry/test_track.py diff --git a/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py similarity index 100% rename from tests/tracing/test_otel_exporters.py rename to packages/uipath/tests/tracing/test_otel_exporters.py diff --git a/uv.lock b/packages/uipath/uv.lock similarity index 99% rename from uv.lock rename to packages/uipath/uv.lock index b61079688..cb50c03c5 100644 --- a/uv.lock +++ b/packages/uipath/uv.lock @@ -2638,21 +2638,21 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.3" +version = "0.5.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/76/568bbe81e2c502b0b3d34b35f0f2d7557ceed58fc9161820d186276b47ac/uipath_core-0.5.3.tar.gz", hash = "sha256:5ff386c9bf85006648f111496b74534925fab1de4b35d5d0c2f6dfdf81e6e103", size = 119096, upload-time = "2026-02-25T14:08:47.548Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/00/fd070d738798c3bfb39398de1bb9375e99ab455fb0978354a5714bbd90e8/uipath_core-0.5.4.tar.gz", hash = "sha256:3cbc12b3632f7ec80ea1bbc6f758a578f81b6045ba625382f922b4b4782dbdce", size = 119114, upload-time = "2026-03-02T14:57:39.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/35/87a346abe7485c0a63802487050e3550723bfd97925f85cc8814d34bb2a3/uipath_core-0.5.3-py3-none-any.whl", hash = "sha256:2ad9670d3d8e62d7e4f5ed090dffeff00281b8d20d159fff67cac941889d6748", size = 42858, upload-time = "2026-02-25T14:08:46.037Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/59043dba044d5c2f418080d60cb8f902be856bb6e8985b2758311d03b597/uipath_core-0.5.4-py3-none-any.whl", hash = "sha256:92f80170c39cd8b4cc0341277140ab25862d251cc2271c4013bba24ac387b314", size = 42873, upload-time = "2026-03-02T14:57:38.485Z" }, ] [[package]] name = "uipath-platform" -version = "0.0.4" +version = "0.0.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2661,9 +2661,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/da/7bfaa11939f098dff16b123232ec33862d92508f8e38bd8243e2cd9cad5e/uipath_platform-0.0.4.tar.gz", hash = "sha256:fa1fddb26ca1f2fe388a876ed5e3bc629b219eb2a1288cde21c72f1c9cd4e9e3", size = 255031, upload-time = "2026-02-26T14:43:04.375Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/af/dc08ef8e89403302045bd76000c2ee2ec87490295f6a479ce9db0806942c/uipath_platform-0.0.10.tar.gz", hash = "sha256:be20c59a647498a406eeb03f087f13da88040c2fcd0c2ef49e30034e418a0c21", size = 268148, upload-time = "2026-03-03T12:31:49.401Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/f3/a952b9dffb45b102fbdfed295d89b97de4dacc0eebbf0f6edc78896219af/uipath_platform-0.0.4-py3-none-any.whl", hash = "sha256:36cd07a3fad6db9e6a6e0741e49f42666fb5df4fd74f4defdd2e88c80396f32d", size = 156065, upload-time = "2026-02-26T14:43:02.772Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9a/8c994ad740b251f5c0f5d6e79e96fba330fd3338756647cf14db8d0ac9cb/uipath_platform-0.0.10-py3-none-any.whl", hash = "sha256:8743d6eefcf54bb24476996e64fb62c57d6017f05ef777603dc12dd61715ccc7", size = 158905, upload-time = "2026-03-03T12:31:47.827Z" }, ] [[package]]