dex-python-sdk 1.25.0__tar.gz → 1.28.0__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.
Files changed (71) hide show
  1. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/PKG-INFO +111 -14
  2. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/README.md +110 -13
  3. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/__init__.py +4 -0
  4. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/_async_worker_dispatcher.py +14 -3
  5. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/_invocation_context.py +10 -0
  6. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/_pull_worker.py +22 -4
  7. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/_value_mapper.py +11 -3
  8. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/_worker_dispatcher.py +12 -6
  9. dex_python_sdk-1.28.0/dex/codec.py +849 -0
  10. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/context.py +45 -0
  11. dex_python_sdk-1.28.0/dex/dexpb/dex_pb2.py +417 -0
  12. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/dexpb/dex_pb2.pyi +6 -621
  13. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/dexpb/dex_pb2_grpc.py +64 -277
  14. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/flow.py +99 -19
  15. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/flow_options.py +14 -5
  16. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/step.py +121 -21
  17. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/pyproject.toml +1 -1
  18. dex_python_sdk-1.25.0/dex/codec.py +0 -427
  19. dex_python_sdk-1.25.0/dex/dexpb/dex_pb2.py +0 -551
  20. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/LEGACY_NOTICES.md +0 -0
  21. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/LICENSE +0 -0
  22. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/_async_value_hydrator.py +0 -0
  23. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/_credential_transport.py +0 -0
  24. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/_grpc_errors.py +0 -0
  25. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/_native.pyi +0 -0
  26. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/_server_protocol.py +0 -0
  27. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/_utils.py +0 -0
  28. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/_value_hydrator.py +0 -0
  29. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/async_client.py +0 -0
  30. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/async_worker.py +0 -0
  31. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/attribute.py +0 -0
  32. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/attribute_match.py +0 -0
  33. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/blob_cache.py +0 -0
  34. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/channel.py +0 -0
  35. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/client.py +0 -0
  36. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/client_options.py +0 -0
  37. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/condition.py +0 -0
  38. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/credential.py +0 -0
  39. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/dexpb/__init__.py +0 -0
  40. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/flow_config.py +0 -0
  41. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/flow_info.py +0 -0
  42. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/flow_result.py +0 -0
  43. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/pull_registration.py +0 -0
  44. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/py.typed +0 -0
  45. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/rpc_invoke_options.py +0 -0
  46. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/runtime_errors.py +0 -0
  47. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/step_execution.py +0 -0
  48. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/stream.py +0 -0
  49. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/subflow.py +0 -0
  50. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/timer.py +0 -0
  51. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/wait.py +0 -0
  52. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/wait_options.py +0 -0
  53. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/worker.py +0 -0
  54. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/dex/worker_options.py +0 -0
  55. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/Cargo.lock +0 -0
  56. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/Cargo.toml +0 -0
  57. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  58. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  59. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache/README.md +0 -0
  60. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  61. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  62. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  63. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  64. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  65. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  66. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  67. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  68. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  69. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  70. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache-python/README.md +0 -0
  71. {dex_python_sdk-1.25.0 → dex_python_sdk-1.28.0}/sdk-rust/crates/dex-blob-cache-python/src/lib.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dex-python-sdk
3
- Version: 1.25.0
3
+ Version: 1.28.0
4
4
  Requires-Dist: grpcio>=1.83.0
5
5
  Requires-Dist: grpcio-status>=1.83.0
6
6
  Requires-Dist: protobuf>=7.35.1
@@ -394,6 +394,7 @@ registry = dex.Registry((flow,))
394
394
  Registry derives codecs from declared Python types and handler annotations.
395
395
  Built-in primitive types and dataclasses need no codec arguments. Register an
396
396
  explicit codec only for a custom encoding or a type Registry cannot derive.
397
+
397
398
  `PersistenceSchema.of(...)` accepts attributes, channels, and streams together and
398
399
  partitions them by definition type.
399
400
 
@@ -465,6 +466,56 @@ pip install dex-python-sdk==0.1.0
465
466
 
466
467
  See [samples](../examples/python) for use case examples.
467
468
 
469
+ ### JSON dataclasses
470
+
471
+ Automatic JSON codecs support parameterized generic dataclasses such as
472
+ `Result[Receipt]`, including nested generics, typed containers, `Optional[T]`, and
473
+ `T | None`. Decoding restores nested dataclass instances. Missing keys use the
474
+ field's `default` or `default_factory`; a missing required key raises `ValueError`
475
+ with the dataclass and wire field name. Unknown keys are ignored.
476
+
477
+ Field metadata controls the wire representation:
478
+
479
+ - `field(metadata={"dex_json_name": "callId"})` uses `callId` instead of the
480
+ Python field name for encoding and decoding. Wire names must be non-empty and
481
+ unique within a dataclass.
482
+ - `field(default=None, metadata={"dex_json_omit_default": True})` omits a field
483
+ only when it equals its declared default. This also supports `default_factory`;
484
+ the factory is called on each encode to obtain the comparison value, so use a
485
+ deterministic factory without side effects. Without this opt-in, default
486
+ values, empty containers, and `None` remain present in JSON.
487
+ - `field(metadata={"dex_json_format": "rfc3339"})` formats a datetime field like
488
+ Go's `encoding/json`: UTC uses `Z` and trailing fractional zeroes are removed.
489
+ This requires a datetime or optional datetime field with an aware value and a
490
+ whole-minute timezone offset. Ordinary datetime fields keep their existing
491
+ `isoformat()` encoding. All datetime fields accept Go RFC3339 strings on
492
+ decode, including `Z`, numeric offsets, and fractional seconds. Python retains
493
+ microsecond precision; additional fractional digits are truncated.
494
+
495
+ Metadata options can be combined on one field. Existing dataclasses retain their
496
+ field names and encoded values unless metadata opts into a change.
497
+
498
+ Unions select by JSON kind: null, boolean, number, string, array, or object.
499
+ A union declared directly, such as a Step input of `Receipt | None`, needs
500
+ alternatives with different kinds. `Receipt | None`, `int | str`, and
501
+ `int | bool` are supported. `int | float`, `str | datetime`, two dataclasses,
502
+ or two list types are ambiguous and raise `TypeError` when the codec is
503
+ resolved. Use an explicit `JsonCodec` with a discriminator for these shapes.
504
+ Union values always use JSON on the wire, including primitive alternatives.
505
+
506
+ Unions inside dataclass fields and containers keep the decoding of earlier
507
+ releases and never raise when the codec is resolved. Such a union decodes a
508
+ value into a dataclass, Enum, or datetime alternative, or a container of those,
509
+ when the value's JSON kind matches only that alternative. It returns any other
510
+ value unchanged, without a type check. Fields typed by a type variable and
511
+ `Mapping` or `Sequence` types follow the same rule: `Result[Receipt]` decodes a
512
+ `Receipt`, but `Result[int]` returns the JSON value of its type-variable field
513
+ unchanged. A `float` accepts a JSON integer and keeps it as an `int`.
514
+
515
+ See `help(CodecRegistry)` for a complete generic dataclass example and
516
+ [`tests/fixtures/json_codec`](tests/fixtures/json_codec) for a reproducible
517
+ Go-to-Python wire fixture.
518
+
468
519
  ## Requirements
469
520
 
470
521
  - Python 3.11+
@@ -484,6 +535,37 @@ Applications implement two generic interfaces from [`dex`](dex/):
484
535
  With `AsyncWorker` and `Registry(..., allow_async_handlers=True)`, Step
485
536
  coroutines use `AsyncContext`; async RPCs keep `Context`.
486
537
 
538
+ Steps are registered by `get_step_type()`, which defaults to the class name.
539
+ Multiple instances of one class may return different Step types in the same Flow.
540
+ A duplicate Step type fails registration. `go_to`, `StepMovement.of`,
541
+ `StepOptions.on_execute_failure_proceed_to`,
542
+ `FlowTimeoutHandlerOptions.on_failure_proceed_to`, and decision or RPC
543
+ cancellation selections accept a registered instance, `StepRef("StepType")`, or
544
+ a class. A class must have exactly one registered instance; otherwise select an
545
+ instance or use `StepRef`. Instances match by identity and must belong to the
546
+ containing Flow, so a new instance of a registered class is rejected.
547
+
548
+ Declared execute-failure targets are validated when the Registry is constructed.
549
+ The Registry also rejects an execute-failure target whose input type differs from
550
+ the failing Step's input type. It skips this check when either type is `Any`,
551
+ `object`, or an unresolved TypeVar. Timeout-handler failure targets are validated
552
+ when Flow start options are mapped. Handler-created movements and cancellation
553
+ selections, including StepRef targets, are validated when the Worker applies the
554
+ returned decision; unknown Step types and ambiguous classes raise
555
+ `FlowDefinitionError` with the Flow and target details. StepRef contains only a
556
+ Step type and cannot execute or be registered in a StepList. Its input is checked
557
+ by the destination codec at runtime.
558
+
559
+ For a generic Step whose input annotation is a TypeVar, override
560
+ `get_input_type()` to return a concrete type supported by `CodecRegistry`. The
561
+ override takes precedence over both `execute` and `wait_for` input annotations;
562
+ Context and return annotations are still required. The default returns `None`,
563
+ which preserves annotation inference; return `type(None)` for a Step that accepts
564
+ no input. The Registry resolves the type through its codec registrations, so
565
+ custom codecs work too. See the runnable
566
+ [generic Step instances test](tests/integ/step_instances_flow.py), which registers
567
+ integer and string instances and routes to both by instance and StepRef.
568
+
487
569
  `StepOptions.wait_for_method_timeout` and `execute_method_timeout` bound the
488
570
  two handler calls. Timer and channel conditions determine how long a Step waits.
489
571
 
@@ -501,6 +583,26 @@ meet the server minimum, which defaults to ten seconds. Asynchronous durability
501
583
  first allows at most three local attempts in seven seconds. The local phase ignores
502
584
  method timeouts and heartbeat frames before falling back to a regular activity.
503
585
 
586
+ ### Flow run start time
587
+
588
+ `Context.flow_started_at` returns the current Flow run's server start time as a
589
+ timezone-aware UTC `datetime` with one-second precision. It stays the same across
590
+ Steps and retries in that run. It is available in `wait_for`, `execute`, Flow
591
+ timeout handlers, and RPC handlers for both Worker and AsyncWorker. Compare it
592
+ with `datetime.now(timezone.utc)` to measure the run's age. It matches
593
+ `Client.describe_flow(flow_id).started_at` to the second.
594
+
595
+ ### Step attempt deadline
596
+
597
+ `Context.deadline` is the absolute deadline supplied by the Dex Server for the
598
+ current `wait_for`, `execute`, or Flow timeout handler attempt. It is a
599
+ timezone-aware UTC `datetime` with microsecond precision, not a duration.
600
+ Subtract `datetime.now(timezone.utc)` and call `total_seconds()` to budget
601
+ remaining work after checking for `None`. A shorter remaining
602
+ `RetryPolicy.total_duration` limits this deadline; each retry receives its own
603
+ effective deadline. RPC handlers and tasks without a supplied deadline return
604
+ `None`.
605
+
504
606
  ### Step progress and heartbeat recovery
505
607
 
506
608
  A synchronous handler yields every heartbeat and Stream write. The generator return
@@ -619,9 +721,9 @@ options = dex.StartFlowOptions(
619
721
  ```
620
722
 
621
723
  Register async hooks with `allow_async_handlers=True` and run them with
622
- `AsyncWorker`. `FAIL` produces `FlowErrorType.FLOW_TIMEOUT` and permits Flow
623
- retry; `CANCEL` cancels without retry. Continue-as-new preserves the deadline,
624
- while retry runs receive a fresh budget. A zero or absent timeout
724
+ `AsyncWorker`. `FAIL` produces `FlowErrorType.FLOW_TIMEOUT` and fails the
725
+ Flow; `CANCEL` cancels it. The deadline is preserved across Flow state
726
+ transfers. A zero or absent timeout
625
727
  disables the feature.
626
728
 
627
729
  `Registry` validates every Flow, Step, RPC signature, durable name, lock, and
@@ -651,12 +753,7 @@ Request IDs are optional for both durable waits. When omitted, the server
651
753
  derives a stable ID from the Step execution or Attribute condition. Reuse an
652
754
  override only for the same wait. `request_timeout` is the total SDK call budget
653
755
  across transparent transport reattachments. Zero waits indefinitely.
654
- Temporal permits 10 in-flight Updates and 2,000 total Updates in History per
655
- Workflow Execution. A caller timeout or cancellation does not finish an
656
- accepted durable wait, so an abandoned handler can retain one in-flight slot.
657
- Set `internal_handler_timeout` only when abandoned waits can approach that
658
- limit. An active call transparently starts another generation, which counts
659
- toward the 2,000-Update history limit. Prefer a value comfortably longer than
756
+ A caller timeout or cancellation does not finish an accepted durable wait. Set `internal_handler_timeout` only when abandoned waits can accumulate. An active call transparently registers its wait again. Prefer a value comfortably longer than
660
757
  normal request timeouts and reconnect gaps. Zero disables timed rollover.
661
758
  Request expiry raises `RequestTimeoutError`.
662
759
 
@@ -701,7 +798,7 @@ SubFlow.
701
798
  Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
702
799
  (`describe_flow`, `wait_for_flow`, and `time_travel`) raise
703
800
  `FlowNotFoundError` when the Flow does not exist. Mutations, RPCs, timer/Step
704
- waits, config updates, and continue-as-new triggers raise
801
+ waits, config updates, and state-transfer triggers raise
705
802
  `FlowNotActiveOrNotFoundError` when no running Flow can accept the operation.
706
803
 
707
804
  RPCs also use this error when the target Flow cannot be found, including query-only
@@ -794,8 +891,8 @@ environment:
794
891
  ./run-integration-tests.sh
795
892
  ```
796
893
 
797
- The suite always uses pull Workers. Set `DEX_INTEG_ENGINE=zd` to test against
798
- server-zd, or leave it unset to use the Temporal-backed server.
894
+ The suite uses server-zd and pull Workers. `DEX_INTEG_ENGINE=zd` remains accepted.
895
+ Set `DEX_SERVER_ZD_BINARY` to reuse a built `dex-server-zd`.
799
896
 
800
897
  ### Measure integration coverage
801
898
 
@@ -888,7 +985,7 @@ original terms as described in [LEGACY_NOTICES.md](LEGACY_NOTICES.md).
888
985
 
889
986
  ## Search run inclusion
890
987
 
891
- Dex Server excludes ContinuedAsNew runs by default. Application queries still
988
+ Searches exclude runs with the ContinuedAsNew status by default. server-zd never reports that status. Application queries still
892
989
  need `FlowType` when using shared index slots. An explicit inclusion option
893
990
  removes only the default exclusion; it does not override query filters. Keep
894
991
  the query, inclusion option, and page size consistent across pages.
@@ -378,6 +378,7 @@ registry = dex.Registry((flow,))
378
378
  Registry derives codecs from declared Python types and handler annotations.
379
379
  Built-in primitive types and dataclasses need no codec arguments. Register an
380
380
  explicit codec only for a custom encoding or a type Registry cannot derive.
381
+
381
382
  `PersistenceSchema.of(...)` accepts attributes, channels, and streams together and
382
383
  partitions them by definition type.
383
384
 
@@ -449,6 +450,56 @@ pip install dex-python-sdk==0.1.0
449
450
 
450
451
  See [samples](../examples/python) for use case examples.
451
452
 
453
+ ### JSON dataclasses
454
+
455
+ Automatic JSON codecs support parameterized generic dataclasses such as
456
+ `Result[Receipt]`, including nested generics, typed containers, `Optional[T]`, and
457
+ `T | None`. Decoding restores nested dataclass instances. Missing keys use the
458
+ field's `default` or `default_factory`; a missing required key raises `ValueError`
459
+ with the dataclass and wire field name. Unknown keys are ignored.
460
+
461
+ Field metadata controls the wire representation:
462
+
463
+ - `field(metadata={"dex_json_name": "callId"})` uses `callId` instead of the
464
+ Python field name for encoding and decoding. Wire names must be non-empty and
465
+ unique within a dataclass.
466
+ - `field(default=None, metadata={"dex_json_omit_default": True})` omits a field
467
+ only when it equals its declared default. This also supports `default_factory`;
468
+ the factory is called on each encode to obtain the comparison value, so use a
469
+ deterministic factory without side effects. Without this opt-in, default
470
+ values, empty containers, and `None` remain present in JSON.
471
+ - `field(metadata={"dex_json_format": "rfc3339"})` formats a datetime field like
472
+ Go's `encoding/json`: UTC uses `Z` and trailing fractional zeroes are removed.
473
+ This requires a datetime or optional datetime field with an aware value and a
474
+ whole-minute timezone offset. Ordinary datetime fields keep their existing
475
+ `isoformat()` encoding. All datetime fields accept Go RFC3339 strings on
476
+ decode, including `Z`, numeric offsets, and fractional seconds. Python retains
477
+ microsecond precision; additional fractional digits are truncated.
478
+
479
+ Metadata options can be combined on one field. Existing dataclasses retain their
480
+ field names and encoded values unless metadata opts into a change.
481
+
482
+ Unions select by JSON kind: null, boolean, number, string, array, or object.
483
+ A union declared directly, such as a Step input of `Receipt | None`, needs
484
+ alternatives with different kinds. `Receipt | None`, `int | str`, and
485
+ `int | bool` are supported. `int | float`, `str | datetime`, two dataclasses,
486
+ or two list types are ambiguous and raise `TypeError` when the codec is
487
+ resolved. Use an explicit `JsonCodec` with a discriminator for these shapes.
488
+ Union values always use JSON on the wire, including primitive alternatives.
489
+
490
+ Unions inside dataclass fields and containers keep the decoding of earlier
491
+ releases and never raise when the codec is resolved. Such a union decodes a
492
+ value into a dataclass, Enum, or datetime alternative, or a container of those,
493
+ when the value's JSON kind matches only that alternative. It returns any other
494
+ value unchanged, without a type check. Fields typed by a type variable and
495
+ `Mapping` or `Sequence` types follow the same rule: `Result[Receipt]` decodes a
496
+ `Receipt`, but `Result[int]` returns the JSON value of its type-variable field
497
+ unchanged. A `float` accepts a JSON integer and keeps it as an `int`.
498
+
499
+ See `help(CodecRegistry)` for a complete generic dataclass example and
500
+ [`tests/fixtures/json_codec`](tests/fixtures/json_codec) for a reproducible
501
+ Go-to-Python wire fixture.
502
+
452
503
  ## Requirements
453
504
 
454
505
  - Python 3.11+
@@ -468,6 +519,37 @@ Applications implement two generic interfaces from [`dex`](dex/):
468
519
  With `AsyncWorker` and `Registry(..., allow_async_handlers=True)`, Step
469
520
  coroutines use `AsyncContext`; async RPCs keep `Context`.
470
521
 
522
+ Steps are registered by `get_step_type()`, which defaults to the class name.
523
+ Multiple instances of one class may return different Step types in the same Flow.
524
+ A duplicate Step type fails registration. `go_to`, `StepMovement.of`,
525
+ `StepOptions.on_execute_failure_proceed_to`,
526
+ `FlowTimeoutHandlerOptions.on_failure_proceed_to`, and decision or RPC
527
+ cancellation selections accept a registered instance, `StepRef("StepType")`, or
528
+ a class. A class must have exactly one registered instance; otherwise select an
529
+ instance or use `StepRef`. Instances match by identity and must belong to the
530
+ containing Flow, so a new instance of a registered class is rejected.
531
+
532
+ Declared execute-failure targets are validated when the Registry is constructed.
533
+ The Registry also rejects an execute-failure target whose input type differs from
534
+ the failing Step's input type. It skips this check when either type is `Any`,
535
+ `object`, or an unresolved TypeVar. Timeout-handler failure targets are validated
536
+ when Flow start options are mapped. Handler-created movements and cancellation
537
+ selections, including StepRef targets, are validated when the Worker applies the
538
+ returned decision; unknown Step types and ambiguous classes raise
539
+ `FlowDefinitionError` with the Flow and target details. StepRef contains only a
540
+ Step type and cannot execute or be registered in a StepList. Its input is checked
541
+ by the destination codec at runtime.
542
+
543
+ For a generic Step whose input annotation is a TypeVar, override
544
+ `get_input_type()` to return a concrete type supported by `CodecRegistry`. The
545
+ override takes precedence over both `execute` and `wait_for` input annotations;
546
+ Context and return annotations are still required. The default returns `None`,
547
+ which preserves annotation inference; return `type(None)` for a Step that accepts
548
+ no input. The Registry resolves the type through its codec registrations, so
549
+ custom codecs work too. See the runnable
550
+ [generic Step instances test](tests/integ/step_instances_flow.py), which registers
551
+ integer and string instances and routes to both by instance and StepRef.
552
+
471
553
  `StepOptions.wait_for_method_timeout` and `execute_method_timeout` bound the
472
554
  two handler calls. Timer and channel conditions determine how long a Step waits.
473
555
 
@@ -485,6 +567,26 @@ meet the server minimum, which defaults to ten seconds. Asynchronous durability
485
567
  first allows at most three local attempts in seven seconds. The local phase ignores
486
568
  method timeouts and heartbeat frames before falling back to a regular activity.
487
569
 
570
+ ### Flow run start time
571
+
572
+ `Context.flow_started_at` returns the current Flow run's server start time as a
573
+ timezone-aware UTC `datetime` with one-second precision. It stays the same across
574
+ Steps and retries in that run. It is available in `wait_for`, `execute`, Flow
575
+ timeout handlers, and RPC handlers for both Worker and AsyncWorker. Compare it
576
+ with `datetime.now(timezone.utc)` to measure the run's age. It matches
577
+ `Client.describe_flow(flow_id).started_at` to the second.
578
+
579
+ ### Step attempt deadline
580
+
581
+ `Context.deadline` is the absolute deadline supplied by the Dex Server for the
582
+ current `wait_for`, `execute`, or Flow timeout handler attempt. It is a
583
+ timezone-aware UTC `datetime` with microsecond precision, not a duration.
584
+ Subtract `datetime.now(timezone.utc)` and call `total_seconds()` to budget
585
+ remaining work after checking for `None`. A shorter remaining
586
+ `RetryPolicy.total_duration` limits this deadline; each retry receives its own
587
+ effective deadline. RPC handlers and tasks without a supplied deadline return
588
+ `None`.
589
+
488
590
  ### Step progress and heartbeat recovery
489
591
 
490
592
  A synchronous handler yields every heartbeat and Stream write. The generator return
@@ -603,9 +705,9 @@ options = dex.StartFlowOptions(
603
705
  ```
604
706
 
605
707
  Register async hooks with `allow_async_handlers=True` and run them with
606
- `AsyncWorker`. `FAIL` produces `FlowErrorType.FLOW_TIMEOUT` and permits Flow
607
- retry; `CANCEL` cancels without retry. Continue-as-new preserves the deadline,
608
- while retry runs receive a fresh budget. A zero or absent timeout
708
+ `AsyncWorker`. `FAIL` produces `FlowErrorType.FLOW_TIMEOUT` and fails the
709
+ Flow; `CANCEL` cancels it. The deadline is preserved across Flow state
710
+ transfers. A zero or absent timeout
609
711
  disables the feature.
610
712
 
611
713
  `Registry` validates every Flow, Step, RPC signature, durable name, lock, and
@@ -635,12 +737,7 @@ Request IDs are optional for both durable waits. When omitted, the server
635
737
  derives a stable ID from the Step execution or Attribute condition. Reuse an
636
738
  override only for the same wait. `request_timeout` is the total SDK call budget
637
739
  across transparent transport reattachments. Zero waits indefinitely.
638
- Temporal permits 10 in-flight Updates and 2,000 total Updates in History per
639
- Workflow Execution. A caller timeout or cancellation does not finish an
640
- accepted durable wait, so an abandoned handler can retain one in-flight slot.
641
- Set `internal_handler_timeout` only when abandoned waits can approach that
642
- limit. An active call transparently starts another generation, which counts
643
- toward the 2,000-Update history limit. Prefer a value comfortably longer than
740
+ A caller timeout or cancellation does not finish an accepted durable wait. Set `internal_handler_timeout` only when abandoned waits can accumulate. An active call transparently registers its wait again. Prefer a value comfortably longer than
644
741
  normal request timeouts and reconnect gaps. Zero disables timed rollover.
645
742
  Request expiry raises `RequestTimeoutError`.
646
743
 
@@ -685,7 +782,7 @@ SubFlow.
685
782
  Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
686
783
  (`describe_flow`, `wait_for_flow`, and `time_travel`) raise
687
784
  `FlowNotFoundError` when the Flow does not exist. Mutations, RPCs, timer/Step
688
- waits, config updates, and continue-as-new triggers raise
785
+ waits, config updates, and state-transfer triggers raise
689
786
  `FlowNotActiveOrNotFoundError` when no running Flow can accept the operation.
690
787
 
691
788
  RPCs also use this error when the target Flow cannot be found, including query-only
@@ -778,8 +875,8 @@ environment:
778
875
  ./run-integration-tests.sh
779
876
  ```
780
877
 
781
- The suite always uses pull Workers. Set `DEX_INTEG_ENGINE=zd` to test against
782
- server-zd, or leave it unset to use the Temporal-backed server.
878
+ The suite uses server-zd and pull Workers. `DEX_INTEG_ENGINE=zd` remains accepted.
879
+ Set `DEX_SERVER_ZD_BINARY` to reuse a built `dex-server-zd`.
783
880
 
784
881
  ### Measure integration coverage
785
882
 
@@ -872,7 +969,7 @@ original terms as described in [LEGACY_NOTICES.md](LEGACY_NOTICES.md).
872
969
 
873
970
  ## Search run inclusion
874
971
 
875
- Dex Server excludes ContinuedAsNew runs by default. Application queries still
972
+ Searches exclude runs with the ContinuedAsNew status by default. server-zd never reports that status. Application queries still
876
973
  need `FlowType` when using shared index slots. An explicit inclusion option
877
974
  removes only the default exclusion; it does not override query filters. Keep
878
975
  the query, inclusion option, and page size consistent across pages.
@@ -101,6 +101,8 @@ from dex.step import (
101
101
  StepMovement,
102
102
  StepOptions,
103
103
  StepOutput,
104
+ StepRef,
105
+ StepTarget,
104
106
  WaitForFailurePolicy,
105
107
  dead_end,
106
108
  force_complete,
@@ -210,6 +212,8 @@ __all__ = [
210
212
  "StepDurability",
211
213
  "StepMovement",
212
214
  "StepOutput",
215
+ "StepRef",
216
+ "StepTarget",
213
217
  "StepOptions",
214
218
  "Stream",
215
219
  "StreamMessage",
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
  import asyncio
12
12
  from collections.abc import AsyncGenerator, AsyncIterator
13
13
  from contextlib import suppress
14
+ from datetime import datetime
14
15
  from inspect import isawaitable, isgenerator
15
16
  from typing import Any, Callable
16
17
 
@@ -80,10 +81,11 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
80
81
  self,
81
82
  original: pb.InvokeWaitForMethodRequest,
82
83
  is_active: Callable[[], bool] | None = None,
84
+ deadline: datetime | None = None,
83
85
  ) -> AsyncGenerator[pb.InvokeWaitForMethodOutput, None]:
84
86
  emitter = _AsyncStepOutputEmitter()
85
87
  handler = asyncio.create_task(
86
- self._invoke_wait_for_result(original, emitter, is_active)
88
+ self._invoke_wait_for_result(original, emitter, is_active, deadline)
87
89
  )
88
90
  try:
89
91
  async for output in self._drain_outputs(emitter, handler):
@@ -97,6 +99,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
97
99
  original: pb.InvokeWaitForMethodRequest,
98
100
  emitter: _AsyncStepOutputEmitter,
99
101
  is_active: Callable[[], bool] | None,
102
+ deadline: datetime | None,
100
103
  ) -> pb.InvokeWaitForMethodResponse:
101
104
  request = await self._async_hydrator.wait_for_request(original)
102
105
  flow = self._registry._flow_by_type(request.flow_type)
@@ -113,6 +116,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
113
116
  loaded_channel_names=request.loaded_channel_names,
114
117
  loaded_channel_map_instances=request.loaded_channel_map_instances,
115
118
  is_active=is_active,
119
+ deadline=deadline,
116
120
  output_emitter=emitter,
117
121
  )
118
122
  input = self._values.decode(request.step_input, step.input_codec)
@@ -164,11 +168,14 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
164
168
  self,
165
169
  original: pb.InvokeExecuteMethodRequest,
166
170
  is_active: Callable[[], bool] | None = None,
171
+ deadline: datetime | None = None,
167
172
  ) -> AsyncGenerator[pb.InvokeExecuteMethodOutput, None]:
168
173
  if original.step_type == _TIMEOUT_HANDLER_STEP_TYPE:
169
174
  emitter = _AsyncStepOutputEmitter()
170
175
  handler = asyncio.create_task(
171
- self._invoke_timeout_handler_async(original, emitter, is_active)
176
+ self._invoke_timeout_handler_async(
177
+ original, emitter, is_active, deadline
178
+ )
172
179
  )
173
180
  try:
174
181
  async for output in self._drain_outputs(emitter, handler):
@@ -179,7 +186,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
179
186
  return
180
187
  emitter = _AsyncStepOutputEmitter()
181
188
  handler = asyncio.create_task(
182
- self._invoke_execute_result(original, emitter, is_active)
189
+ self._invoke_execute_result(original, emitter, is_active, deadline)
183
190
  )
184
191
  try:
185
192
  async for output in self._drain_outputs(emitter, handler):
@@ -193,6 +200,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
193
200
  original: pb.InvokeExecuteMethodRequest,
194
201
  emitter: _AsyncStepOutputEmitter,
195
202
  is_active: Callable[[], bool] | None,
203
+ deadline: datetime | None,
196
204
  ) -> pb.InvokeExecuteMethodResponse:
197
205
  request = await self._async_hydrator.execute_request(original)
198
206
  flow = self._registry._flow_by_type(request.flow_type)
@@ -214,6 +222,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
214
222
  loaded_channel_names=request.loaded_channel_names,
215
223
  loaded_channel_map_instances=request.loaded_channel_map_instances,
216
224
  is_active=is_active,
225
+ deadline=deadline,
217
226
  output_emitter=emitter,
218
227
  )
219
228
  input = self._values.decode(request.step_input, step.input_codec)
@@ -264,6 +273,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
264
273
  original: pb.InvokeExecuteMethodRequest,
265
274
  emitter: _AsyncStepOutputEmitter,
266
275
  is_active: Callable[[], bool] | None,
276
+ deadline: datetime | None,
267
277
  ) -> pb.InvokeExecuteMethodResponse:
268
278
  request = await self._async_hydrator.execute_request(original)
269
279
  flow = self._registry._flow_by_type(request.flow_type)
@@ -295,6 +305,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
295
305
  loaded_channel_names=request.loaded_channel_names,
296
306
  loaded_channel_map_instances=request.loaded_channel_map_instances,
297
307
  is_active=is_active,
308
+ deadline=deadline,
298
309
  output_emitter=emitter,
299
310
  )
300
311
  response: pb.InvokeExecuteMethodResponse | None = None
@@ -83,7 +83,9 @@ class InvocationContext:
83
83
  loaded_channel_map_instances: Sequence[str] = (),
84
84
  is_active: Callable[[], bool] | None = None,
85
85
  output_emitter: _StepOutputEmitter | None = None,
86
+ deadline: datetime | None = None,
86
87
  ) -> None:
88
+ self._deadline = None if method is InvocationMethod.RPC else deadline
87
89
  self._method = method
88
90
  self._flow = flow
89
91
  self._metadata = metadata
@@ -115,6 +117,10 @@ class InvocationContext:
115
117
  def run_id(self) -> str:
116
118
  return self._metadata.run_id
117
119
 
120
+ @property
121
+ def flow_started_at(self) -> datetime:
122
+ return datetime.fromtimestamp(self._metadata.flow_started_timestamp, UTC)
123
+
118
124
  @property
119
125
  def step_execution_id(self) -> str:
120
126
  return self._metadata.step_execution_id
@@ -136,6 +142,10 @@ class InvocationContext:
136
142
  def first_attempt_at(self) -> datetime:
137
143
  return datetime.fromtimestamp(self._metadata.first_attempt_timestamp, UTC)
138
144
 
145
+ @property
146
+ def deadline(self) -> datetime | None:
147
+ return self._deadline
148
+
139
149
  @property
140
150
  def attempt(self) -> int:
141
151
  return self._metadata.attempt
@@ -705,16 +705,25 @@ class PullStream:
705
705
  await self._send_error(task.task_id, failure, status)
706
706
 
707
707
  async def _execute_async(self, task: pb.PollWorkerTask, kind: str | None) -> None:
708
+ deadline = (
709
+ task.deadline.ToDatetime(tzinfo=timezone.utc)
710
+ if task.HasField("deadline")
711
+ else None
712
+ )
708
713
  if kind == "wait_for":
709
714
  async for output in self.runtime._dispatcher.invoke_wait_for(
710
- task.wait_for, self._active.is_set
715
+ task.wait_for,
716
+ self._active.is_set,
717
+ deadline,
711
718
  ):
712
719
  await self._send_frame(
713
720
  pb.PollWorkerTaskFrame(task_id=task.task_id, wait_for=output)
714
721
  )
715
722
  elif kind == "execute":
716
723
  async for output in self.runtime._dispatcher.invoke_execute(
717
- task.execute, self._active.is_set
724
+ task.execute,
725
+ self._active.is_set,
726
+ deadline,
718
727
  ):
719
728
  await self._send_frame(
720
729
  pb.PollWorkerTaskFrame(task_id=task.task_id, execute=output)
@@ -730,9 +739,16 @@ class PullStream:
730
739
  raise ValueError(f"unsupported Worker task request {kind}")
731
740
 
732
741
  def _execute_sync(self, task: pb.PollWorkerTask, kind: str | None) -> None:
742
+ deadline = (
743
+ task.deadline.ToDatetime(tzinfo=timezone.utc)
744
+ if task.HasField("deadline")
745
+ else None
746
+ )
733
747
  if kind == "wait_for":
734
748
  outputs = self.runtime._dispatcher.invoke_wait_for(
735
- task.wait_for, self._active.is_set
749
+ task.wait_for,
750
+ self._active.is_set,
751
+ deadline,
736
752
  )
737
753
  for output in outputs:
738
754
  self._send_from_thread(
@@ -740,7 +756,9 @@ class PullStream:
740
756
  )
741
757
  elif kind == "execute":
742
758
  outputs = self.runtime._dispatcher.invoke_execute(
743
- task.execute, self._active.is_set
759
+ task.execute,
760
+ self._active.is_set,
761
+ deadline,
744
762
  )
745
763
  for output in outputs:
746
764
  self._send_from_thread(
@@ -9,7 +9,7 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  import json
12
- from dataclasses import asdict, is_dataclass
12
+ from dataclasses import is_dataclass
13
13
  from datetime import datetime
14
14
  from enum import Enum
15
15
  from typing import Any, cast
@@ -17,7 +17,14 @@ from typing import Any, cast
17
17
  from google.protobuf import struct_pb2
18
18
 
19
19
  from dex.attribute import AttributeIndex, IndexType
20
- from dex.codec import Codec, CodecRegistry, Value, WireKind
20
+ from dex.codec import (
21
+ Codec,
22
+ CodecRegistry,
23
+ Value,
24
+ WireKind,
25
+ _encode_json_value,
26
+ _validate_json_value_metadata,
27
+ )
21
28
  from dex.dexpb import dex_pb2 as pb
22
29
  from dex.runtime_errors import ValueMappingError
23
30
 
@@ -87,6 +94,7 @@ class ValueMapper:
87
94
  codec = self._codecs.resolve(value_type)
88
95
  except TypeError:
89
96
  if isinstance(value, (list, tuple, dict)):
97
+ _validate_json_value_metadata(value, set())
90
98
  return pb.Value(
91
99
  obj_value=pb.EncodedObject(
92
100
  encoding=_JSON_ENCODING,
@@ -213,7 +221,7 @@ class ValueMapper:
213
221
  def _encode_json_collection(value: object) -> bytes:
214
222
  def default(item: object) -> object:
215
223
  if is_dataclass(item) and not isinstance(item, type):
216
- return asdict(item)
224
+ return _encode_json_value(item)
217
225
  if isinstance(item, datetime):
218
226
  return item.isoformat()
219
227
  if isinstance(item, Enum):