durable-workflow 2.9.1__tar.gz → 2.10.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {durable_workflow-2.9.1/src/durable_workflow.egg-info → durable_workflow-2.10.1}/PKG-INFO +1 -1
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/pyproject.toml +3 -3
- durable_workflow-2.10.1/src/durable_workflow/_metric_routes.py +58 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/client.py +68 -43
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/errors.py +15 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/history_bundle_verify.py +143 -3
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/sync.py +6 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/testing.py +283 -25
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/worker.py +144 -13
- {durable_workflow-2.9.1 → durable_workflow-2.10.1/src/durable_workflow.egg-info}/PKG-INFO +1 -1
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow.egg-info/SOURCES.txt +10 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_cooperative_cancellation_worker.py +8 -3
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_cooperative_remote_worker.py +19 -5
- durable_workflow-2.10.1/tests/test_history_bundle_schema2.py +214 -0
- durable_workflow-2.10.1/tests/test_metric_routes.py +137 -0
- durable_workflow-2.10.1/tests/test_poll_failure_backoff.py +315 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_replay_regression_corpus.py +4 -0
- durable_workflow-2.10.1/tests/test_sdk_metrics_contract.py +246 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_storage_admission.py +17 -3
- durable_workflow-2.10.1/tests/test_testing_failures.py +303 -0
- durable_workflow-2.10.1/tests/test_testing_nexus.py +149 -0
- durable_workflow-2.10.1/tests/test_tls_trust.py +200 -0
- durable_workflow-2.10.1/tests/test_transport_diagnostics.py +136 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_worker.py +20 -16
- durable_workflow-2.10.1/tests/test_worker_deregistration.py +361 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_worker_observation.py +12 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/LICENSE +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/README.md +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/setup.cfg +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/__init__.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/_activity_lease.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/_activity_process.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/_avro.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/_cancellation_scope.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/_cancellation_scope_history.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/_cooperative_cancellation.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/_prepared_local_activity.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/_query_task_authority.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/_sticky_workflow_cache.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/activity.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/auth_composition.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/cancellation.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/external_storage.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/external_task_input.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/external_task_result.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/interceptors.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/invocable.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/metrics.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/nexus.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/py.typed +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/python_conformance.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/replay_conformance.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/replay_verify.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/retry_policy.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/serializer.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/telemetry.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/telemetry_carrier.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/worker_session.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/workflow.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/workflow_updates_conformance.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow.egg-info/dependency_links.txt +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow.egg-info/entry_points.txt +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow.egg-info/requires.txt +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow.egg-info/top_level.txt +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_activity_cancellation_policies.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_activity_context.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_activity_heartbeat_authority.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_activity_lease_authority.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_activity_lease_renewal.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_activity_lease_worker.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_activity_process.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_auth_composition.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_avro_value_benchmark.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_avro_value_protocol.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_cancellation_context.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_cancellation_remaining_time.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_cancellation_scope_admission.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_cancellation_scope_authoring.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_cancellation_scope_boundary.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_cancellation_scope_opening.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_child_workflow_policies.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_ci_checkout.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_ci_integration_endpoint.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_client.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_client_telemetry.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_client_timeouts.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_codec_regression_corpus.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_committed_cancellation_scope_descendants.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_committed_cancellation_scope_groups.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_committed_cancellation_scope_history.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_committed_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_condition_replay_drift.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_control_plane_parity_fixtures.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_cooperative_cancellation.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_cooperative_cancellation_client.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_docstring_cross_references.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_durable_selection.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_errors.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_external_storage.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_external_task_input.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_external_task_result.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_golden_history_replay.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_history_bundle_verify.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_history_event_contract.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_invocable.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_message_streams.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_metrics.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_nexus_durable_wait.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_nexus_namespace.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_nexus_wire.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_order_processing_example.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_parallel_completion_order_replay.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_parallel_saga_authoring.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_patch_history.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_pending_operation_replay.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_prepared_local_activity_client.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_prepared_local_activity_groups.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_prepared_local_activity_policies.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_prepared_local_activity_worker.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_public_boundary_scanner.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_pypi_project_surface.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_python_conformance.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_queries.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_query_task_authority.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_query_timeout_retry.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_readme_quickstart.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_redrive_replay.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_registration_ambiguity.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_release_metadata.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_replay.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_replay_clock.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_replay_conformance.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_replay_verify.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_retry_policy.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_run_cancellation_scope_replay.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_runtime_external_payload_transport.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_schedules.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_scoped_run_cancellation_context.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_serializer.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_signals.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_sleep.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_standalone_activity_client.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_sticky_execution.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_sync.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_sync_activities.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_telemetry.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_telemetry_carrier.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_testing_harness.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_threaded_heartbeat_example.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_update_signal_condition_replay.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_updates.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_wait_condition.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_worker_session.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_worker_telemetry_wire.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_workflow_entry_arguments.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_workflow_entrypoint_validation.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_workflow_handler_validation.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_workflow_identity.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_workflow_result_timeout.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_workflow_streams.py +0 -0
- {durable_workflow-2.9.1 → durable_workflow-2.10.1}/tests/test_workflow_updates_conformance.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "durable-workflow"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.10.1"
|
|
8
8
|
description = "Python client and worker SDK for Durable Workflow Cloud and self-hosted Server"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -76,8 +76,8 @@ durable-workflow-replay-conformance = "durable_workflow.replay_conformance:main"
|
|
|
76
76
|
durable-workflow-workflow-updates-conformance = "durable_workflow.workflow_updates_conformance:main"
|
|
77
77
|
|
|
78
78
|
[tool.durable-workflow]
|
|
79
|
-
product-train = "2.
|
|
80
|
-
registry-version = "2.
|
|
79
|
+
product-train = "2.10.1"
|
|
80
|
+
registry-version = "2.10.1"
|
|
81
81
|
supported-server-versions = "2.8.0"
|
|
82
82
|
worker-protocol-version = "1.20"
|
|
83
83
|
control-plane-version = "2"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Bound request labels to reviewed SDK operations, never arbitrary URL values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_ROUTES = (
|
|
6
|
+
"/", "/health", "/cluster/info", "/activities", "/activities/{activity_id}",
|
|
7
|
+
"/namespaces", "/namespaces/{name}", "/namespaces/{name}/external-storage",
|
|
8
|
+
"/search-attributes", "/search-attributes/{name}", "/storage/test",
|
|
9
|
+
"/task-queues", "/task-queues/{task_queue}", "/task-queues/{task_queue}/build-ids",
|
|
10
|
+
*(f"/task-queues/{{task_queue}}/build-ids/{action}" for action in ("drain", "promote", "resume")),
|
|
11
|
+
*(f"/system/{operation}{suffix}" for operation in ("activity-timeouts", "repair", "retention")
|
|
12
|
+
for suffix in ("", "/pass")),
|
|
13
|
+
"/workers", "/workers/{worker_id}",
|
|
14
|
+
"/worker/register", "/worker/heartbeat", "/worker/registrations/{worker_id}",
|
|
15
|
+
"/worker/registrations/{worker_id}/deregister",
|
|
16
|
+
"/worker/sessions", "/worker/sessions/{session_id}", "/worker/sessions/{session_id}/heartbeat",
|
|
17
|
+
*(f"/worker/{kind}-tasks/poll" for kind in ("workflow", "activity", "query", "update-validation")),
|
|
18
|
+
*(f"/worker/{kind}-tasks/{{task_id}}/{action}" for kind, actions in (
|
|
19
|
+
("workflow", ("complete", "fail", "heartbeat", "history", "deliver-cancellation")),
|
|
20
|
+
("activity", ("complete", "fail", "heartbeat", "status", "renew-lease", "acknowledge-cancellation")),
|
|
21
|
+
("query", ("complete", "fail", "status")),
|
|
22
|
+
("update-validation", ("approve", "reject")),
|
|
23
|
+
) for action in actions),
|
|
24
|
+
*(f"/worker/workflow-tasks/{{task_id}}/cancellation-scopes/{action}"
|
|
25
|
+
for action in ("open", "prepare", "deliver")),
|
|
26
|
+
*(f"/worker/workflow-tasks/{{task_id}}/local-activities/{action}"
|
|
27
|
+
for action in ("checkpoint", "checkpoint-group", "prepare", "recover")),
|
|
28
|
+
*(f"/worker/workflow-tasks/{{task_id}}/local-activities/{{activity_attempt_id}}/{action}"
|
|
29
|
+
for action in ("control", "heartbeat", "outcome", "acknowledge-cancellation")),
|
|
30
|
+
"/workflows", "/workflows/{workflow_id}",
|
|
31
|
+
*(f"/workflows/{{workflow_id}}/{action}"
|
|
32
|
+
for action in ("archive", "cancel", "repair", "terminate", "request-cancellation", "runs")),
|
|
33
|
+
*(f"/workflows/{{workflow_id}}/{action}/{{name}}" for action in ("signal", "query", "update")),
|
|
34
|
+
"/workflows/{workflow_id}/message-streams/{name}/messages",
|
|
35
|
+
"/workflows/{workflow_id}/runs/{run_id}",
|
|
36
|
+
*(f"/workflows/{{workflow_id}}/runs/{{run_id}}/{action}"
|
|
37
|
+
for action in ("history", "history/export", "redrive", "request-cancellation", "streams")),
|
|
38
|
+
"/workflows/{workflow_id}/runs/{run_id}/streams/{name}",
|
|
39
|
+
*(f"/workflows/{{workflow_id}}/runs/{{run_id}}/streams/{{name}}/{action}" for action in ("items", "close")),
|
|
40
|
+
"/schedules", "/schedules/{schedule_id}",
|
|
41
|
+
*(f"/schedules/{{schedule_id}}/{action}" for action in ("backfill", "pause", "resume", "trigger", "history")),
|
|
42
|
+
"/bridge-adapters/webhook/{adapter}",
|
|
43
|
+
"/service-endpoints/{endpoint_name}/services/{service_name}/operations/{operation_name}/execute",
|
|
44
|
+
)
|
|
45
|
+
_TEMPLATES = tuple((route, tuple(route.strip("/").split("/"))) for route in _ROUTES)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def route_for_metrics(path: str) -> str:
|
|
49
|
+
"""Return a supported operation template or the single bounded fallback."""
|
|
50
|
+
clean_path = path.split("?", 1)[0].split("#", 1)[0]
|
|
51
|
+
parts = tuple(clean_path.strip("/").split("/"))
|
|
52
|
+
for route, template in _TEMPLATES:
|
|
53
|
+
if len(parts) == len(template) and all(
|
|
54
|
+
expected == actual or expected.startswith("{") and bool(actual)
|
|
55
|
+
for actual, expected in zip(parts, template, strict=True)
|
|
56
|
+
):
|
|
57
|
+
return route
|
|
58
|
+
return "unknown"
|
|
@@ -26,6 +26,7 @@ import json as json_module
|
|
|
26
26
|
import math
|
|
27
27
|
import os
|
|
28
28
|
import re
|
|
29
|
+
import ssl
|
|
29
30
|
import time
|
|
30
31
|
import uuid
|
|
31
32
|
import warnings
|
|
@@ -47,6 +48,7 @@ from ._cancellation_scope_history import (
|
|
|
47
48
|
scope_timestamp,
|
|
48
49
|
)
|
|
49
50
|
from ._cooperative_cancellation import CancellationDelivery, CancellationRequest
|
|
51
|
+
from ._metric_routes import route_for_metrics as _route_for_metrics
|
|
50
52
|
from ._query_task_authority import QueryTaskAuthority, current_query_authority
|
|
51
53
|
from .errors import (
|
|
52
54
|
REQUEST_ID_CONFLICT,
|
|
@@ -289,38 +291,6 @@ def _command_request_failure(exc: Exception, *, command: str, request_id: str) -
|
|
|
289
291
|
return exc
|
|
290
292
|
|
|
291
293
|
|
|
292
|
-
def _route_for_metrics(path: str) -> str:
|
|
293
|
-
clean_path = path.split("?", 1)[0]
|
|
294
|
-
parts = [part for part in clean_path.strip("/").split("/") if part]
|
|
295
|
-
if not parts:
|
|
296
|
-
return "/"
|
|
297
|
-
|
|
298
|
-
if parts[0] == "workflows" and len(parts) >= 2:
|
|
299
|
-
parts[1] = "{workflow_id}"
|
|
300
|
-
if len(parts) >= 4 and parts[2] in {"signal", "query", "update"}:
|
|
301
|
-
parts[3] = "{name}"
|
|
302
|
-
if len(parts) >= 4 and parts[2] == "runs":
|
|
303
|
-
parts[3] = "{run_id}"
|
|
304
|
-
elif parts[0] == "schedules" and len(parts) >= 2:
|
|
305
|
-
parts[1] = "{schedule_id}"
|
|
306
|
-
elif parts[0] in {"namespaces", "search-attributes"} and len(parts) >= 2:
|
|
307
|
-
parts[1] = "{name}"
|
|
308
|
-
elif parts[0] == "workers" and len(parts) >= 2:
|
|
309
|
-
parts[1] = "{worker_id}"
|
|
310
|
-
elif parts[:2] == ["worker", "registrations"] and len(parts) >= 3:
|
|
311
|
-
parts[2] = "{worker_id}"
|
|
312
|
-
elif parts[:2] == ["bridge-adapters", "webhook"] and len(parts) >= 3:
|
|
313
|
-
parts[2] = "{adapter}"
|
|
314
|
-
elif (
|
|
315
|
-
parts[:2] == ["worker", "workflow-tasks"]
|
|
316
|
-
or parts[:2] == ["worker", "activity-tasks"]
|
|
317
|
-
or parts[:2] == ["worker", "query-tasks"]
|
|
318
|
-
) and len(parts) >= 3:
|
|
319
|
-
parts[2] = "{task_id}"
|
|
320
|
-
|
|
321
|
-
return "/" + "/".join(parts)
|
|
322
|
-
|
|
323
|
-
|
|
324
294
|
def _resolve_namespace_name(
|
|
325
295
|
name: str | None,
|
|
326
296
|
namespace_alias: str | None,
|
|
@@ -1631,6 +1601,10 @@ class Client:
|
|
|
1631
1601
|
|
|
1632
1602
|
The client owns one `httpx.AsyncClient` connection pool. Use it as an async
|
|
1633
1603
|
context manager or call `aclose()` when finished.
|
|
1604
|
+
|
|
1605
|
+
``tls_context`` selects an explicit trust configuration. It must require
|
|
1606
|
+
certificate and hostname verification. Treat the context as read-only after
|
|
1607
|
+
construction; worker query clones and Nexus namespace views reuse it.
|
|
1634
1608
|
"""
|
|
1635
1609
|
|
|
1636
1610
|
def __init__(
|
|
@@ -1642,6 +1616,7 @@ class Client:
|
|
|
1642
1616
|
worker_token: str | None = None,
|
|
1643
1617
|
namespace: str = "default",
|
|
1644
1618
|
timeout: float = 60.0,
|
|
1619
|
+
tls_context: ssl.SSLContext | None = None,
|
|
1645
1620
|
retry_policy: TransportRetryPolicy | None = None,
|
|
1646
1621
|
metrics: MetricsRecorder | None = None,
|
|
1647
1622
|
payload_size_limit_bytes: int = serializer.DEFAULT_PAYLOAD_SIZE_BYTES,
|
|
@@ -1652,6 +1627,11 @@ class Client:
|
|
|
1652
1627
|
external_storage_cache: ExternalPayloadCache | None = None,
|
|
1653
1628
|
telemetry: ExecutionTelemetry | None = None,
|
|
1654
1629
|
) -> None:
|
|
1630
|
+
if tls_context is not None:
|
|
1631
|
+
if not isinstance(tls_context, ssl.SSLContext):
|
|
1632
|
+
raise TypeError("tls_context must be an ssl.SSLContext")
|
|
1633
|
+
if tls_context.verify_mode != ssl.CERT_REQUIRED or not tls_context.check_hostname:
|
|
1634
|
+
raise ValueError("tls_context must enable certificate and hostname verification")
|
|
1655
1635
|
self.base_url = _normalize_base_url(base_url)
|
|
1656
1636
|
self.token = token
|
|
1657
1637
|
self.control_token = control_token
|
|
@@ -1660,8 +1640,9 @@ class Client:
|
|
|
1660
1640
|
self.telemetry = telemetry
|
|
1661
1641
|
self._observability_capability = False
|
|
1662
1642
|
self.timeout = timeout
|
|
1643
|
+
self._tls_context = tls_context
|
|
1663
1644
|
self.retry_policy = retry_policy or TransportRetryPolicy()
|
|
1664
|
-
self.metrics = metrics
|
|
1645
|
+
self.metrics = metrics if metrics is not None else NOOP_METRICS
|
|
1665
1646
|
self.payload_size_warning_config = (
|
|
1666
1647
|
serializer.PayloadSizeWarningConfig(
|
|
1667
1648
|
limit_bytes=payload_size_limit_bytes,
|
|
@@ -1679,7 +1660,9 @@ class Client:
|
|
|
1679
1660
|
if external_storage_cache is not None
|
|
1680
1661
|
else ExternalPayloadCache()
|
|
1681
1662
|
)
|
|
1682
|
-
self._http = httpx.AsyncClient(
|
|
1663
|
+
self._http = httpx.AsyncClient(
|
|
1664
|
+
base_url=self.base_url, timeout=timeout, verify=tls_context if tls_context is not None else True,
|
|
1665
|
+
)
|
|
1683
1666
|
self._cluster_info: dict[str, Any] | None = None
|
|
1684
1667
|
self._cluster_info_lock = asyncio.Lock()
|
|
1685
1668
|
self._runtime_external_payload_transport_cache: (
|
|
@@ -1688,6 +1671,11 @@ class Client:
|
|
|
1688
1671
|
self._runtime_external_payload_transport_resolved = False
|
|
1689
1672
|
self._workflow_payload_observers: dict[str, Callable[[dict[str, Any]], Awaitable[None]]] = {}
|
|
1690
1673
|
|
|
1674
|
+
@property
|
|
1675
|
+
def tls_context(self) -> ssl.SSLContext | None:
|
|
1676
|
+
"""The explicit TLS context, or ``None`` for httpx's default trust selection."""
|
|
1677
|
+
return self._tls_context
|
|
1678
|
+
|
|
1691
1679
|
async def aclose(self) -> None:
|
|
1692
1680
|
"""Close the underlying ``httpx`` connection pool.
|
|
1693
1681
|
|
|
@@ -1865,6 +1853,7 @@ class Client:
|
|
|
1865
1853
|
json: Any = None,
|
|
1866
1854
|
timeout: float | None = None,
|
|
1867
1855
|
context: str = "",
|
|
1856
|
+
retry: bool = True,
|
|
1868
1857
|
) -> Any:
|
|
1869
1858
|
if worker and discovery:
|
|
1870
1859
|
raise ValueError("a request cannot be both worker-plane and cluster discovery")
|
|
@@ -1932,7 +1921,7 @@ class Client:
|
|
|
1932
1921
|
|
|
1933
1922
|
try:
|
|
1934
1923
|
try:
|
|
1935
|
-
resp = await self.retry_policy.execute(_do_request)
|
|
1924
|
+
resp = await self.retry_policy.execute(_do_request) if retry else await _do_request()
|
|
1936
1925
|
except httpx.HTTPStatusError as exc:
|
|
1937
1926
|
status_code = str(exc.response.status_code)
|
|
1938
1927
|
outcome = "http_error"
|
|
@@ -1993,6 +1982,9 @@ class Client:
|
|
|
1993
1982
|
await query_authority.confirm()
|
|
1994
1983
|
outcome = "ok"
|
|
1995
1984
|
return result
|
|
1985
|
+
except asyncio.CancelledError:
|
|
1986
|
+
outcome = "CancelledError"
|
|
1987
|
+
raise
|
|
1996
1988
|
except Exception as exc:
|
|
1997
1989
|
if outcome == "pending":
|
|
1998
1990
|
outcome = type(exc).__name__
|
|
@@ -2679,6 +2671,9 @@ class Client:
|
|
|
2679
2671
|
)
|
|
2680
2672
|
outcome = "bridge_rejected" if resp.status_code == 422 else "ok"
|
|
2681
2673
|
return data
|
|
2674
|
+
except asyncio.CancelledError:
|
|
2675
|
+
outcome = "CancelledError"
|
|
2676
|
+
raise
|
|
2682
2677
|
except Exception as exc:
|
|
2683
2678
|
if outcome == "pending":
|
|
2684
2679
|
outcome = type(exc).__name__
|
|
@@ -5251,39 +5246,69 @@ class Client:
|
|
|
5251
5246
|
json={"worker_id": worker_id, "reason": reason},
|
|
5252
5247
|
))
|
|
5253
5248
|
|
|
5254
|
-
async def deregister_worker_registration(
|
|
5249
|
+
async def deregister_worker_registration(
|
|
5250
|
+
self, worker_id: str, *, registration_token: str | None = None, timeout: float | None = None,
|
|
5251
|
+
expected_namespace: str | None = None,
|
|
5252
|
+
) -> dict[str, Any]:
|
|
5255
5253
|
"""Remove this runtime's successful worker-plane registration.
|
|
5256
5254
|
|
|
5257
5255
|
Called by :class:`~durable_workflow.Worker` after its pollers and
|
|
5258
5256
|
in-flight tasks have drained. This is distinct from the operator-only
|
|
5259
5257
|
:meth:`deregister_worker` control-plane management operation. Returns
|
|
5260
5258
|
the worker deregistration envelope, including recovered workflow-task
|
|
5261
|
-
count.
|
|
5259
|
+
count. With a registration token, use the runtime's generation-fenced
|
|
5260
|
+
receipt endpoint. Each call makes one attempt: Worker owns safe retries
|
|
5261
|
+
and the remaining shutdown budget. An unfenced DELETE cannot safely be
|
|
5262
|
+
repeated after an uncertain response because the worker ID may be reused.
|
|
5263
|
+
``expected_namespace`` is a local registration-scope precondition, not
|
|
5264
|
+
a namespace override.
|
|
5262
5265
|
"""
|
|
5266
|
+
if expected_namespace is not None and self.namespace != expected_namespace:
|
|
5267
|
+
raise RuntimeError("worker registration namespace changed; registration removal is unconfirmed")
|
|
5268
|
+
if registration_token is not None and (
|
|
5269
|
+
not isinstance(registration_token, str) or re.fullmatch(r"[a-f0-9]{32}", registration_token) is None
|
|
5270
|
+
):
|
|
5271
|
+
raise ValueError("registration_token must contain exactly 32 lowercase hexadecimal characters")
|
|
5272
|
+
fenced = registration_token is not None
|
|
5263
5273
|
try:
|
|
5264
5274
|
data = await self._request(
|
|
5265
|
-
"DELETE",
|
|
5266
|
-
f"/worker/registrations/{quote(worker_id, safe='')}",
|
|
5275
|
+
"POST" if fenced else "DELETE",
|
|
5276
|
+
f"/worker/registrations/{quote(worker_id, safe='')}" + ("/deregister" if fenced else ""),
|
|
5267
5277
|
worker=True,
|
|
5268
5278
|
context=worker_id,
|
|
5279
|
+
json={"registration_token": registration_token} if fenced else None,
|
|
5280
|
+
timeout=timeout,
|
|
5281
|
+
retry=False,
|
|
5269
5282
|
)
|
|
5270
5283
|
except ServerError as error:
|
|
5271
|
-
if error.status == 404 and error.reason() == "worker_not_found":
|
|
5284
|
+
if not fenced and error.status == 404 and error.reason() == "worker_not_found":
|
|
5272
5285
|
return {
|
|
5273
5286
|
"worker_id": worker_id,
|
|
5274
5287
|
"outcome": "already_deregistered",
|
|
5275
5288
|
"recovered_workflow_task_count": 0,
|
|
5276
5289
|
}
|
|
5277
5290
|
raise
|
|
5278
|
-
if not
|
|
5291
|
+
if not self._valid_worker_deregistration_response(data, worker_id, registration_token):
|
|
5279
5292
|
raise ServerError(
|
|
5280
5293
|
200,
|
|
5281
5294
|
{
|
|
5282
5295
|
"reason": "invalid_worker_deregistration_response",
|
|
5283
|
-
"message":
|
|
5296
|
+
"message": "expected a deregistration receipt for this worker registration",
|
|
5284
5297
|
},
|
|
5285
5298
|
)
|
|
5286
|
-
return data
|
|
5299
|
+
return cast(dict[str, Any], data)
|
|
5300
|
+
|
|
5301
|
+
@staticmethod
|
|
5302
|
+
def _valid_worker_deregistration_response(data: Any, worker_id: str, token: str | None) -> bool:
|
|
5303
|
+
if not isinstance(data, dict):
|
|
5304
|
+
return False
|
|
5305
|
+
count = data.get("recovered_workflow_task_count")
|
|
5306
|
+
return (
|
|
5307
|
+
data.get("worker_id") == worker_id
|
|
5308
|
+
and data.get("outcome") == "deregistered"
|
|
5309
|
+
and type(count) is int and count >= 0
|
|
5310
|
+
and (token is None or data.get("registration_token") == token)
|
|
5311
|
+
)
|
|
5287
5312
|
|
|
5288
5313
|
async def heartbeat_worker(
|
|
5289
5314
|
self,
|
|
@@ -219,6 +219,21 @@ class ServerError(DurableWorkflowError):
|
|
|
219
219
|
return self.body.get("reason")
|
|
220
220
|
return None
|
|
221
221
|
|
|
222
|
+
def worker_deregistration_retry_delay(self, worker_id: str, registration_token: str) -> int | None:
|
|
223
|
+
"""Return a delay only for an identity-preserving fenced transient."""
|
|
224
|
+
body = self.body
|
|
225
|
+
if (
|
|
226
|
+
self.status != 503 or not isinstance(body, dict)
|
|
227
|
+
or body.get("reason") not in ("backend_lock_pressure", "backend_unavailable")
|
|
228
|
+
or body.get("operation") != "deregister_worker" or body.get("outcome") != "unknown"
|
|
229
|
+
or body.get("worker_id") != worker_id or body.get("registration_token") != registration_token
|
|
230
|
+
or body.get("retryable") is not True
|
|
231
|
+
or type(body.get("retry_after_seconds")) is not int
|
|
232
|
+
or not 0 < body["retry_after_seconds"] <= 60
|
|
233
|
+
):
|
|
234
|
+
return None
|
|
235
|
+
return int(body["retry_after_seconds"])
|
|
236
|
+
|
|
222
237
|
def poll_capacity_backpressure_delay(self, task_kind: str, task_queue: str) -> int | None:
|
|
223
238
|
"""Return the advertised wait only for a complete poll-capacity refusal."""
|
|
224
239
|
body = self.body
|
{durable_workflow-2.9.1 → durable_workflow-2.10.1}/src/durable_workflow/history_bundle_verify.py
RENAMED
|
@@ -23,11 +23,15 @@ a workflow-php verdict, so promotion gates can compare them directly.
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import argparse
|
|
26
|
+
import base64
|
|
26
27
|
import hashlib
|
|
27
28
|
import hmac
|
|
28
29
|
import json
|
|
30
|
+
import math
|
|
31
|
+
import re
|
|
29
32
|
import sys
|
|
30
33
|
from collections.abc import Mapping, Sequence
|
|
34
|
+
from decimal import Decimal
|
|
31
35
|
from pathlib import Path
|
|
32
36
|
from typing import Any
|
|
33
37
|
|
|
@@ -37,7 +41,9 @@ REPORT_SCHEMA = "durable-workflow.v2.history-bundle-verification"
|
|
|
37
41
|
REPORT_SCHEMA_VERSION = 1
|
|
38
42
|
|
|
39
43
|
BUNDLE_SCHEMA = "durable-workflow.v2.history-export"
|
|
44
|
+
# Retained for callers that construct the supported legacy format.
|
|
40
45
|
BUNDLE_SCHEMA_VERSION = 1
|
|
46
|
+
SUPPORTED_BUNDLE_SCHEMA_VERSIONS = frozenset({1, 2})
|
|
41
47
|
|
|
42
48
|
STATUS_OK = "ok"
|
|
43
49
|
STATUS_WARNING = "warning"
|
|
@@ -138,13 +144,14 @@ def _check_envelope(bundle: Mapping[str, Any], findings: list[dict[str, Any]]) -
|
|
|
138
144
|
"Bundle is missing the [schema_version] field.",
|
|
139
145
|
)
|
|
140
146
|
)
|
|
141
|
-
elif bundle["schema_version"]
|
|
147
|
+
elif (type(bundle["schema_version"]) is not int
|
|
148
|
+
or bundle["schema_version"] not in SUPPORTED_BUNDLE_SCHEMA_VERSIONS):
|
|
142
149
|
findings.append(
|
|
143
150
|
_finding(
|
|
144
151
|
"bundle.schema_version_unsupported",
|
|
145
152
|
SEVERITY_ERROR,
|
|
146
153
|
f"Bundle schema_version [{bundle['schema_version']!s}] is unsupported; "
|
|
147
|
-
|
|
154
|
+
"expected integer 1 or 2.",
|
|
148
155
|
{"observed": bundle["schema_version"]},
|
|
149
156
|
)
|
|
150
157
|
)
|
|
@@ -230,6 +237,9 @@ def _check_workflow(bundle: Mapping[str, Any], findings: list[dict[str, Any]]) -
|
|
|
230
237
|
)
|
|
231
238
|
)
|
|
232
239
|
|
|
240
|
+
if type(bundle.get("schema_version")) is int and bundle["schema_version"] == 2:
|
|
241
|
+
_check_v2_memo(bundle, workflow, findings)
|
|
242
|
+
|
|
233
243
|
declared = _int_or_none(workflow.get("last_history_sequence"))
|
|
234
244
|
events = bundle.get("history_events")
|
|
235
245
|
if declared is not None and isinstance(events, list) and events:
|
|
@@ -253,6 +263,85 @@ def _check_workflow(bundle: Mapping[str, Any], findings: list[dict[str, Any]]) -
|
|
|
253
263
|
)
|
|
254
264
|
|
|
255
265
|
|
|
266
|
+
def _check_v2_memo(
|
|
267
|
+
bundle: Mapping[str, Any], workflow: Mapping[str, Any], findings: list[dict[str, Any]],
|
|
268
|
+
) -> None:
|
|
269
|
+
redaction = bundle.get("redaction")
|
|
270
|
+
paths = redaction.get("paths") if isinstance(redaction, Mapping) else None
|
|
271
|
+
paths = paths if isinstance(paths, list) else []
|
|
272
|
+
if "workflow.memo_payload" in paths:
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
payload = workflow.get("memo_payload")
|
|
276
|
+
if not isinstance(payload, dict):
|
|
277
|
+
findings.append(_finding(
|
|
278
|
+
"workflow.memo_payload_missing", SEVERITY_ERROR,
|
|
279
|
+
"Schema-2 workflow.memo_payload must contain the lossless portable Avro memo authority.",
|
|
280
|
+
))
|
|
281
|
+
return
|
|
282
|
+
try:
|
|
283
|
+
entries = _decode_memo_entries(payload)
|
|
284
|
+
except (TypeError, ValueError) as exc:
|
|
285
|
+
findings.append(_finding(
|
|
286
|
+
"workflow.memo_payload_invalid", SEVERITY_ERROR,
|
|
287
|
+
f"workflow.memo_payload is not a valid portable Avro memo map: {exc}",
|
|
288
|
+
))
|
|
289
|
+
return
|
|
290
|
+
if "workflow.memo" in paths:
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
# MemoPayload::decodeEntries normalizes the top-level map to a PHP array;
|
|
294
|
+
# its empty display projection is [], while nested empty maps retain an
|
|
295
|
+
# explicit map adapter. Compare JSON kinds as well as values (True != 1).
|
|
296
|
+
projection = {key: _memo_value_projection(value) for key, value in entries.items()} if entries else []
|
|
297
|
+
try:
|
|
298
|
+
projection_matches = json.dumps(workflow.get("memo"), sort_keys=True) == json.dumps(projection, sort_keys=True)
|
|
299
|
+
except (TypeError, ValueError):
|
|
300
|
+
projection_matches = False
|
|
301
|
+
if not projection_matches:
|
|
302
|
+
findings.append(_finding(
|
|
303
|
+
"workflow.memo_projection_mismatch", SEVERITY_ERROR,
|
|
304
|
+
"workflow.memo does not match the JSON-safe projection of workflow.memo_payload.",
|
|
305
|
+
))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _decode_memo_entries(payload: dict[str, Any]) -> dict[str, Any]:
|
|
309
|
+
if set(payload) != {"codec", "blob"} or payload["codec"] != "avro":
|
|
310
|
+
raise ValueError("expected exactly the codec=avro and blob fields")
|
|
311
|
+
if not isinstance(payload["blob"], str) or not payload["blob"]:
|
|
312
|
+
raise ValueError("blob must be a non-empty base64 string")
|
|
313
|
+
entries = _avro.decode(payload["blob"])
|
|
314
|
+
if not isinstance(entries, dict):
|
|
315
|
+
raise ValueError("memo authority must decode to a string-keyed map")
|
|
316
|
+
for key in entries:
|
|
317
|
+
if not re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", key) or re.fullmatch(r"-?\d+", key, flags=re.ASCII):
|
|
318
|
+
raise ValueError(f"invalid portable memo key: {key!r}")
|
|
319
|
+
return entries
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _memo_value_projection(value: Any) -> Any:
|
|
323
|
+
if isinstance(value, bytes):
|
|
324
|
+
return {"$type": "bytes", "base64": base64.b64encode(value).decode("ascii")}
|
|
325
|
+
if isinstance(value, list):
|
|
326
|
+
return [_memo_value_projection(item) for item in value]
|
|
327
|
+
if isinstance(value, dict):
|
|
328
|
+
# PHP uses AvroMapValue for empty maps and maps containing keys that
|
|
329
|
+
# its native arrays would coerce to signed 64-bit integers.
|
|
330
|
+
if not value or any(_php_integer_key(key) for key in value):
|
|
331
|
+
return {"$type": "map", "entries": [
|
|
332
|
+
{"key": key, "value": _memo_value_projection(item)} for key, item in value.items()
|
|
333
|
+
]}
|
|
334
|
+
return {key: _memo_value_projection(item) for key, item in value.items()}
|
|
335
|
+
return value
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _php_integer_key(key: str) -> bool:
|
|
339
|
+
# PHP numeric array keys use ASCII digits; Unicode decimal digits remain strings.
|
|
340
|
+
if len(key) > 20 or not re.fullmatch(r"0|-?[1-9]\d*", key, flags=re.ASCII):
|
|
341
|
+
return False
|
|
342
|
+
return -(2**63) <= int(key) <= 2**63 - 1
|
|
343
|
+
|
|
344
|
+
|
|
256
345
|
def _check_history_events(bundle: Mapping[str, Any], findings: list[dict[str, Any]]) -> None:
|
|
257
346
|
events = bundle.get("history_events")
|
|
258
347
|
if not isinstance(events, list):
|
|
@@ -664,7 +753,57 @@ def _check_integrity(
|
|
|
664
753
|
def _canonicalize(bundle: Mapping[str, Any]) -> str:
|
|
665
754
|
cloned = {k: v for k, v in bundle.items() if k != "integrity"}
|
|
666
755
|
canonical = _canonicalize_value(cloned)
|
|
667
|
-
return
|
|
756
|
+
return _canonical_json_value(canonical)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _canonical_json_value(value: Any) -> str:
|
|
760
|
+
"""Encode the existing PHP json-recursive-ksort-v1 byte representation."""
|
|
761
|
+
if isinstance(value, dict):
|
|
762
|
+
if not all(isinstance(key, str) for key in value):
|
|
763
|
+
raise TypeError("History bundle JSON object keys must be strings")
|
|
764
|
+
return "{" + ",".join(_canonical_json_string(key) + ":" + _canonical_json_value(item)
|
|
765
|
+
for key, item in value.items()) + "}"
|
|
766
|
+
if isinstance(value, list):
|
|
767
|
+
return "[" + ",".join(_canonical_json_value(item) for item in value) + "]"
|
|
768
|
+
if isinstance(value, str):
|
|
769
|
+
return _canonical_json_string(value)
|
|
770
|
+
if isinstance(value, float):
|
|
771
|
+
return _canonical_json_float(value)
|
|
772
|
+
return json.dumps(value, separators=(",", ":"), allow_nan=False)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _canonical_json_string(value: str) -> str:
|
|
776
|
+
# PHP escapes non-ASCII characters as UTF-16 code units while leaving
|
|
777
|
+
# ASCII DEL unescaped. Start with standard JSON quoting, then escape only
|
|
778
|
+
# its non-ASCII characters. Invalid surrogate strings fail encoding.
|
|
779
|
+
encoded = json.dumps(value, ensure_ascii=False)
|
|
780
|
+
if encoded.isascii():
|
|
781
|
+
return encoded
|
|
782
|
+
return "".join(character if ord(character) < 128 else _json_utf16_escape(character) for character in encoded)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _json_utf16_escape(character: str) -> str:
|
|
786
|
+
units = character.encode("utf-16-be").hex()
|
|
787
|
+
return "".join("\\u" + units[offset:offset + 4] for offset in range(0, len(units), 4))
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _canonical_json_float(value: float) -> str:
|
|
791
|
+
if not math.isfinite(value):
|
|
792
|
+
raise ValueError("History bundle JSON numbers must be finite")
|
|
793
|
+
text = float.__repr__(value)
|
|
794
|
+
mantissa, separator, exponent_text = text.partition("e")
|
|
795
|
+
if not separator:
|
|
796
|
+
return text
|
|
797
|
+
exponent = int(exponent_text)
|
|
798
|
+
# PHP uses fixed notation through exponent 16 and preserves a decimal
|
|
799
|
+
# fraction for floats. Both runtimes otherwise use shortest-roundtrip
|
|
800
|
+
# digits, but PHP omits exponent leading zeros and writes 1.0e rather than 1e.
|
|
801
|
+
if -4 <= exponent < 17:
|
|
802
|
+
fixed = format(Decimal(text), "f")
|
|
803
|
+
return fixed if "." in fixed else fixed + ".0"
|
|
804
|
+
if "." not in mantissa:
|
|
805
|
+
mantissa += ".0"
|
|
806
|
+
return f"{mantissa}e{exponent:+d}"
|
|
668
807
|
|
|
669
808
|
|
|
670
809
|
def _canonicalize_value(value: Any) -> Any:
|
|
@@ -846,6 +985,7 @@ __all__ = [
|
|
|
846
985
|
"REPORT_SCHEMA_VERSION",
|
|
847
986
|
"BUNDLE_SCHEMA",
|
|
848
987
|
"BUNDLE_SCHEMA_VERSION",
|
|
988
|
+
"SUPPORTED_BUNDLE_SCHEMA_VERSIONS",
|
|
849
989
|
"STATUS_OK",
|
|
850
990
|
"STATUS_WARNING",
|
|
851
991
|
"STATUS_FAILED",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import ssl
|
|
6
7
|
from collections.abc import Coroutine
|
|
7
8
|
from typing import Any, TypeVar
|
|
8
9
|
|
|
@@ -266,6 +267,9 @@ class Client:
|
|
|
266
267
|
|
|
267
268
|
The client keeps one event loop for its lifetime so its async HTTP connection
|
|
268
269
|
pool and returned handles always run on the loop that owns them.
|
|
270
|
+
|
|
271
|
+
``tls_context`` forwards the same verified trust configuration accepted by
|
|
272
|
+
the async client. Treat the supplied context as read-only during use.
|
|
269
273
|
"""
|
|
270
274
|
|
|
271
275
|
def __init__(
|
|
@@ -277,6 +281,7 @@ class Client:
|
|
|
277
281
|
worker_token: str | None = None,
|
|
278
282
|
namespace: str = "default",
|
|
279
283
|
timeout: float = 60.0,
|
|
284
|
+
tls_context: ssl.SSLContext | None = None,
|
|
280
285
|
retry_policy: TransportRetryPolicy | None = None,
|
|
281
286
|
metrics: MetricsRecorder | None = None,
|
|
282
287
|
external_storage: ExternalStorageDriver | None = None,
|
|
@@ -290,6 +295,7 @@ class Client:
|
|
|
290
295
|
worker_token=worker_token,
|
|
291
296
|
namespace=namespace,
|
|
292
297
|
timeout=timeout,
|
|
298
|
+
tls_context=tls_context,
|
|
293
299
|
retry_policy=retry_policy,
|
|
294
300
|
metrics=metrics,
|
|
295
301
|
external_storage=external_storage,
|