async-task-api-worker 0.1.0rc2__py3-none-any.whl

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 (40) hide show
  1. async_task_api_worker/__init__.py +42 -0
  2. async_task_api_worker/__main__.py +5 -0
  3. async_task_api_worker/_runtime/__init__.py +1 -0
  4. async_task_api_worker/_runtime/amqp_transport.py +251 -0
  5. async_task_api_worker/_runtime/declare.py +40 -0
  6. async_task_api_worker/_runtime/engine.py +543 -0
  7. async_task_api_worker/_runtime/inflight.py +38 -0
  8. async_task_api_worker/_runtime/lifecycle.py +463 -0
  9. async_task_api_worker/_runtime/log_context.py +20 -0
  10. async_task_api_worker/_runtime/observer.py +78 -0
  11. async_task_api_worker/_runtime/progress.py +143 -0
  12. async_task_api_worker/_runtime/retry.py +29 -0
  13. async_task_api_worker/_runtime/sync_bridge.py +68 -0
  14. async_task_api_worker/_runtime/transport.py +62 -0
  15. async_task_api_worker/_version.py +11 -0
  16. async_task_api_worker/cli.py +88 -0
  17. async_task_api_worker/context.py +70 -0
  18. async_task_api_worker/errors.py +116 -0
  19. async_task_api_worker/observability/__init__.py +1 -0
  20. async_task_api_worker/observability/health.py +299 -0
  21. async_task_api_worker/observability/logging.py +89 -0
  22. async_task_api_worker/observability/loop_lag.py +56 -0
  23. async_task_api_worker/observability/metrics.py +188 -0
  24. async_task_api_worker/protocol/__init__.py +41 -0
  25. async_task_api_worker/protocol/envelope.py +103 -0
  26. async_task_api_worker/protocol/json_types.py +7 -0
  27. async_task_api_worker/protocol/messages.py +121 -0
  28. async_task_api_worker/protocol/version.py +11 -0
  29. async_task_api_worker/py.typed +0 -0
  30. async_task_api_worker/settings.py +127 -0
  31. async_task_api_worker/testing/__init__.py +27 -0
  32. async_task_api_worker/testing/_context.py +93 -0
  33. async_task_api_worker/testing/_harness.py +251 -0
  34. async_task_api_worker/testing/_memory_broker.py +162 -0
  35. async_task_api_worker/worker.py +254 -0
  36. async_task_api_worker-0.1.0rc2.dist-info/METADATA +269 -0
  37. async_task_api_worker-0.1.0rc2.dist-info/RECORD +40 -0
  38. async_task_api_worker-0.1.0rc2.dist-info/WHEEL +4 -0
  39. async_task_api_worker-0.1.0rc2.dist-info/entry_points.txt +2 -0
  40. async_task_api_worker-0.1.0rc2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,103 @@
1
+ """Inbound side of the protocol: the task envelope the API publishes on ``<service>_queue_in``.
2
+
3
+ Parsing only. Every model ignores unknown fields so that additive protocol changes never break a worker.
4
+ """
5
+
6
+ import json
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum
9
+ from typing import Final
10
+
11
+ from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, ValidationError
12
+
13
+ from async_task_api_worker.protocol.json_types import JsonValue
14
+
15
+ _KEY_TASK_ID: Final[str] = "task_id"
16
+ _KEY_DATA: Final[str] = "data"
17
+ _KEY_BODY: Final[str] = "body"
18
+ _DEFAULT_MESSAGE_TYPE: Final[str] = "submission"
19
+
20
+
21
+ class InputRef(BaseModel):
22
+ """A presigned input the API hands to the worker (optional; ignored when absent)."""
23
+
24
+ model_config = ConfigDict(frozen=True, extra="ignore")
25
+
26
+ url: AnyHttpUrl
27
+ size_bytes: int | None = Field(default=None, ge=0)
28
+ content_type: str | None = None
29
+
30
+
31
+ class EnvelopeContext(BaseModel):
32
+ """``data.context``: optional, absent unless the API provides it."""
33
+
34
+ model_config = ConfigDict(frozen=True, extra="ignore")
35
+
36
+ client_id: str | None = None
37
+ input_refs: dict[str, InputRef] = Field(default_factory=dict)
38
+
39
+
40
+ class SubmissionData(BaseModel):
41
+ model_config = ConfigDict(frozen=True, extra="ignore")
42
+
43
+ # Optional in the protocol; defaults to ``submission``. A missing value must not turn a task into poison.
44
+ message_type: str = _DEFAULT_MESSAGE_TYPE
45
+ body: dict[str, JsonValue]
46
+ context: EnvelopeContext | None = None
47
+
48
+
49
+ class TaskEnvelope(BaseModel):
50
+ model_config = ConfigDict(frozen=True, extra="ignore")
51
+
52
+ task_id: str = Field(min_length=1)
53
+ data: SubmissionData
54
+
55
+
56
+ class PoisonReason(StrEnum):
57
+ """Why a delivery cannot be turned into a task. Replaying it would never help, so it is acked."""
58
+
59
+ NOT_UTF8 = "NOT_UTF8"
60
+ NOT_JSON = "NOT_JSON"
61
+ NOT_AN_OBJECT = "NOT_AN_OBJECT"
62
+ NO_TASK_ID = "NO_TASK_ID"
63
+ NO_DATA = "NO_DATA"
64
+ NO_BODY = "NO_BODY"
65
+ MALFORMED = "MALFORMED"
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class PoisonDelivery:
70
+ """A delivery that cannot become a task.
71
+
72
+ ``task_id`` is kept whenever it could be read, for logging. It comes from an untrusted body (any length, may
73
+ contain control characters): bound and escape it before it reaches a log line.
74
+ """
75
+
76
+ reason: PoisonReason
77
+ task_id: str | None = None
78
+
79
+
80
+ def decode_envelope(raw: bytes) -> TaskEnvelope | PoisonDelivery: # noqa: PLR0911 - one early return per poison reason
81
+ """Decode a delivery body. Total: every failure is a ``PoisonDelivery``, nothing is ever raised."""
82
+ try:
83
+ text = raw.decode("utf-8")
84
+ except UnicodeDecodeError:
85
+ return PoisonDelivery(PoisonReason.NOT_UTF8)
86
+ try:
87
+ loaded: object = json.loads(text)
88
+ except (ValueError, RecursionError):
89
+ return PoisonDelivery(PoisonReason.NOT_JSON)
90
+ if not isinstance(loaded, dict):
91
+ return PoisonDelivery(PoisonReason.NOT_AN_OBJECT)
92
+ task_id = loaded.get(_KEY_TASK_ID)
93
+ if not isinstance(task_id, str) or not task_id:
94
+ return PoisonDelivery(PoisonReason.NO_TASK_ID)
95
+ data = loaded.get(_KEY_DATA)
96
+ if not isinstance(data, dict):
97
+ return PoisonDelivery(PoisonReason.NO_DATA, task_id)
98
+ if not isinstance(data.get(_KEY_BODY), dict):
99
+ return PoisonDelivery(PoisonReason.NO_BODY, task_id)
100
+ try:
101
+ return TaskEnvelope.model_validate(loaded)
102
+ except ValidationError:
103
+ return PoisonDelivery(PoisonReason.MALFORMED, task_id)
@@ -0,0 +1,7 @@
1
+ """JSON value aliases shared by the protocol models and the public API."""
2
+
3
+ from collections.abc import Mapping
4
+
5
+ type JsonValue = str | int | float | bool | list[JsonValue] | dict[str, JsonValue] | None
6
+ type JsonObject = dict[str, JsonValue]
7
+ type ProgressPayload = Mapping[str, JsonValue]
@@ -0,0 +1,121 @@
1
+ """Outbound side of the protocol: what a worker publishes on ``<service>_queue_out``.
2
+
3
+ Every byte goes through :class:`MessageEncoder`. The serialization is ``json.dumps`` of the model dump, with
4
+ the default separators and ``ensure_ascii``, which is the wire format of the protocol. It is **not**
5
+ ``model_dump_json()``, whose compact separators would change the bytes (the golden tests pin this).
6
+ """
7
+
8
+ import json
9
+ import math
10
+ import socket
11
+ from collections.abc import Callable, Mapping
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, datetime
14
+ from typing import Final, Literal
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field
17
+
18
+ from async_task_api_worker.errors import DEFAULT_PUBLIC_MESSAGES, ErrorCode, WorkerError, validate_public_message
19
+ from async_task_api_worker.protocol.json_types import JsonObject, JsonValue, ProgressPayload
20
+
21
+ # A progress fraction lives in [0, 1): the terminal success or failure message is the "100%".
22
+ _PROGRESS_UPPER_BOUND: Final[float] = 1.0
23
+
24
+
25
+ class InvalidProgressError(ValueError):
26
+ """The progress fraction is not a finite number in ``[0, 1)``."""
27
+
28
+
29
+ class _OutModel(BaseModel):
30
+ model_config = ConfigDict(frozen=True, allow_inf_nan=False)
31
+
32
+
33
+ class StartedData(_OutModel):
34
+ message_type: Literal["started"] = "started"
35
+ hostname: str
36
+ started_at: str
37
+
38
+
39
+ class ProgressData(_OutModel):
40
+ message_type: Literal["progress"] = "progress"
41
+ progress: float
42
+ response: JsonObject | None
43
+
44
+
45
+ class SuccessData(_OutModel):
46
+ message_type: Literal["success"] = "success"
47
+ response: JsonObject
48
+ ended_at: str
49
+
50
+
51
+ class ErrorDetail(_OutModel):
52
+ """Structured failure. The listener also accepts extra keys, which the SDK never sends."""
53
+
54
+ code: str
55
+ message: str
56
+
57
+
58
+ class FailureData(_OutModel):
59
+ message_type: Literal["failure"] = "failure"
60
+ error_message: ErrorDetail
61
+ ended_at: str
62
+
63
+
64
+ class OutMessage(_OutModel):
65
+ task_id: str
66
+ data: StartedData | ProgressData | SuccessData | FailureData = Field(discriminator="message_type")
67
+
68
+
69
+ def _utc_now() -> datetime:
70
+ return datetime.now(UTC)
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class MessageEncoder:
75
+ """Builds the exact bytes of every outbound message.
76
+
77
+ The worker dates its own messages (UTC, ``isoformat()``): the listener may process them out of order.
78
+ """
79
+
80
+ clock: Callable[[], datetime] = _utc_now
81
+ hostname: str = field(default_factory=socket.gethostname)
82
+
83
+ def _timestamp(self) -> str:
84
+ return self.clock().astimezone(UTC).isoformat()
85
+
86
+ @staticmethod
87
+ def _encode(message: OutMessage) -> bytes:
88
+ # ``allow_nan=False``: NaN and Infinity are not JSON, strict consumers downstream would choke on them.
89
+ return json.dumps(message.model_dump(mode="python"), allow_nan=False).encode()
90
+
91
+ def started(self, task_id: str) -> bytes:
92
+ data = StartedData(hostname=self.hostname, started_at=self._timestamp())
93
+ return self._encode(OutMessage(task_id=task_id, data=data))
94
+
95
+ def progress(self, task_id: str, fraction: float, payload: ProgressPayload | None) -> bytes:
96
+ """Encode a progress message. ``fraction`` must be finite and in ``[0, 1)``; it is always sent as a float."""
97
+ if isinstance(fraction, bool) or not math.isfinite(fraction) or not 0.0 <= fraction < _PROGRESS_UPPER_BOUND:
98
+ msg = f"progress must be a finite number in [0, {_PROGRESS_UPPER_BOUND}), got {fraction!r}"
99
+ raise InvalidProgressError(msg)
100
+ response: Mapping[str, JsonValue] | None = payload
101
+ data = ProgressData(progress=float(fraction), response=None if response is None else dict(response))
102
+ return self._encode(OutMessage(task_id=task_id, data=data))
103
+
104
+ def success(self, task_id: str, response: Mapping[str, JsonValue]) -> bytes:
105
+ data = SuccessData(response=dict(response), ended_at=self._timestamp())
106
+ return self._encode(OutMessage(task_id=task_id, data=data))
107
+
108
+ def failure(self, task_id: str, error: WorkerError | ErrorCode, message: str | None = None) -> bytes:
109
+ """Encode a failure from a ``WorkerError``, or from an SDK ``ErrorCode`` with its fixed public text."""
110
+ if isinstance(error, WorkerError):
111
+ if message is not None:
112
+ msg = "message cannot be combined with a WorkerError: it carries its own public message"
113
+ raise ValueError(msg)
114
+ detail = ErrorDetail(code=error.code, message=error.public_message)
115
+ else:
116
+ if message is None:
117
+ message = DEFAULT_PUBLIC_MESSAGES[error]
118
+ validate_public_message(message)
119
+ detail = ErrorDetail(code=error.value, message=message)
120
+ data = FailureData(error_message=detail, ended_at=self._timestamp())
121
+ return self._encode(OutMessage(task_id=task_id, data=data))
@@ -0,0 +1,11 @@
1
+ """Wire protocol version spoken by this SDK.
2
+
3
+ - ``0.1.0``: failure ``error_message`` is either the ``{"code", "message"}`` object (what the SDK sends) or a
4
+ ``"<CODE>: <message>"`` string (accepted from other producers).
5
+
6
+ The protocol stays in 0.x until the contract is validated in production.
7
+ """
8
+
9
+ from typing import Final
10
+
11
+ PROTOCOL_VERSION: Final[str] = "0.1.0"
File without changes
@@ -0,0 +1,127 @@
1
+ """Worker settings, read from the environment. The variable names are the SDK configuration contract."""
2
+
3
+ from enum import StrEnum
4
+ from typing import Final, Self
5
+
6
+ from pydantic import Field, ModelWrapValidatorHandler, SecretStr, ValidationError, field_validator, model_validator
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+ from async_task_api_worker.errors import WorkerConfigurationError
10
+
11
+ DEFAULT_WORKER_CONCURRENCY: Final[int] = 1
12
+ DEFAULT_HEALTH_CHECK_HOST: Final[str] = "0.0.0.0" # noqa: S104 - the probe must be reachable from the pod network
13
+ DEFAULT_HEALTH_CHECK_PORT: Final[int] = 8083
14
+ MAX_TCP_PORT: Final[int] = 65535
15
+ DEFAULT_SHUTDOWN_GRACE_S: Final[float] = 25.0
16
+ # Mirrors ``rebut.deliveryLimit`` in ``helm/values.yaml``; calibrated against the broker policy in a later PR.
17
+ DEFAULT_DELIVERY_LIMIT: Final[int] = 5
18
+ # Mirrors ``rebut.consumerTimeoutMs`` (30 minutes) in ``helm/values.yaml``.
19
+ DEFAULT_CONSUMER_TIMEOUT_S: Final[float] = 1800.0
20
+ DEFAULT_RETRY_BACKOFF_BASE_S: Final[float] = 2.0
21
+ DEFAULT_RETRY_BACKOFF_MAX_S: Final[float] = 30.0
22
+ DEFAULT_PROBE_TIMEOUT_S: Final[float] = 3.0
23
+ DEFAULT_PROGRESS_MIN_INTERVAL_S: Final[float] = 1.0
24
+ DEFAULT_TASK_OVERRUN_GRACE_S: Final[float] = 60.0
25
+ DEFAULT_BROKER_RECONNECT_INTERVAL_S: Final[float] = 5.0
26
+ # One budget for all the shutdown hooks together (not one per hook, which would depend on how many there are): it is a
27
+ # term of the shutdown bound documented in ADR 0005.
28
+ DEFAULT_SHUTDOWN_HOOKS_TIMEOUT_S: Final[float] = 5.0
29
+ DEFAULT_JOB_MAX_TASKS: Final[int] = 1
30
+ # The queue held a message when the Job was created, and a ready message is dispatched within milliseconds of the
31
+ # consumer registering: a longer wait only keeps an idle pod (and its resources) alive. See ADR 0005.
32
+ DEFAULT_JOB_IDLE_TIMEOUT_S: Final[float] = 10.0
33
+ # Job mode only: how long the broker may stay unreachable before the pod gives up and exits non-zero, so that the Job's
34
+ # ``backoffLimit`` applies. Several reconnect attempts, short of what a broker restart usually takes. See ADR 0005.
35
+ DEFAULT_JOB_CONNECT_TIMEOUT_S: Final[float] = 30.0
36
+ # In job mode a pod takes one delivery at a time, whatever ``WORKER_CONCURRENCY`` says.
37
+ JOB_PREFETCH: Final[int] = 1
38
+
39
+
40
+ class LogLevel(StrEnum):
41
+ DEBUG = "DEBUG"
42
+ INFO = "INFO"
43
+ WARNING = "WARNING"
44
+ ERROR = "ERROR"
45
+ CRITICAL = "CRITICAL"
46
+
47
+
48
+ class WorkerMode(StrEnum):
49
+ """How a deployment runs the same worker: a long-lived consumer, or a one-shot Job."""
50
+
51
+ SERVICE = "service"
52
+ JOB = "job"
53
+
54
+
55
+ class WorkerSettings(BaseSettings):
56
+ """Environment-driven settings. An invalid value raises ``WorkerConfigurationError``: the worker must not start.
57
+
58
+ Each field is read from the upper-cased environment variable of the same name (``broker_url`` ->
59
+ ``BROKER_URL``). No ``.env`` file is read by default.
60
+ """
61
+
62
+ model_config = SettingsConfigDict(extra="ignore", frozen=True)
63
+
64
+ broker_url: SecretStr = Field(min_length=1)
65
+ in_queue_name: str = Field(min_length=1)
66
+ out_queue_name: str = Field(min_length=1)
67
+ worker_concurrency: int = Field(default=DEFAULT_WORKER_CONCURRENCY, ge=1)
68
+ health_check_host: str = DEFAULT_HEALTH_CHECK_HOST
69
+ health_check_port: int = Field(default=DEFAULT_HEALTH_CHECK_PORT, ge=1, le=MAX_TCP_PORT)
70
+ log_level: LogLevel = LogLevel.INFO
71
+ worker_task_timeout_s: float | None = Field(default=None, gt=0)
72
+ worker_shutdown_grace_s: float = Field(default=DEFAULT_SHUTDOWN_GRACE_S, ge=0)
73
+ worker_delivery_limit: int = Field(default=DEFAULT_DELIVERY_LIMIT, ge=1)
74
+ worker_consumer_timeout_s: float = Field(default=DEFAULT_CONSUMER_TIMEOUT_S, gt=0)
75
+ worker_retry_backoff_base_s: float = Field(default=DEFAULT_RETRY_BACKOFF_BASE_S, gt=0)
76
+ worker_retry_backoff_max_s: float = Field(default=DEFAULT_RETRY_BACKOFF_MAX_S, gt=0)
77
+ worker_probe_timeout_s: float = Field(default=DEFAULT_PROBE_TIMEOUT_S, gt=0)
78
+ worker_progress_min_interval_s: float = Field(default=DEFAULT_PROGRESS_MIN_INTERVAL_S, ge=0)
79
+ worker_task_overrun_grace_s: float = Field(default=DEFAULT_TASK_OVERRUN_GRACE_S, ge=0)
80
+ worker_broker_reconnect_interval_s: float = Field(default=DEFAULT_BROKER_RECONNECT_INTERVAL_S, gt=0)
81
+ worker_shutdown_hooks_timeout_s: float = Field(default=DEFAULT_SHUTDOWN_HOOKS_TIMEOUT_S, gt=0)
82
+ worker_mode: WorkerMode = WorkerMode.SERVICE
83
+ worker_job_max_tasks: int = Field(default=DEFAULT_JOB_MAX_TASKS, ge=1)
84
+ worker_job_idle_timeout_s: float = Field(default=DEFAULT_JOB_IDLE_TIMEOUT_S, gt=0)
85
+ worker_job_connect_timeout_s: float = Field(default=DEFAULT_JOB_CONNECT_TIMEOUT_S, gt=0)
86
+
87
+ @property
88
+ def is_job(self) -> bool:
89
+ return self.worker_mode is WorkerMode.JOB
90
+
91
+ @property
92
+ def prefetch(self) -> int:
93
+ """How many unacknowledged deliveries the broker may hand this process."""
94
+ return JOB_PREFETCH if self.is_job else self.worker_concurrency
95
+
96
+ @field_validator("worker_mode", mode="before")
97
+ @classmethod
98
+ def _lowercase_worker_mode(cls, value: object) -> object:
99
+ return value.strip().lower() if isinstance(value, str) else value
100
+
101
+ @field_validator("log_level", mode="before")
102
+ @classmethod
103
+ def _uppercase_log_level(cls, value: object) -> object:
104
+ return value.strip().upper() if isinstance(value, str) else value
105
+
106
+ @model_validator(mode="after")
107
+ def _backoff_base_must_not_exceed_max(self) -> Self:
108
+ if self.worker_retry_backoff_base_s > self.worker_retry_backoff_max_s:
109
+ msg = "WORKER_RETRY_BACKOFF_BASE_S must not exceed WORKER_RETRY_BACKOFF_MAX_S"
110
+ raise ValueError(msg)
111
+ return self
112
+
113
+ @model_validator(mode="wrap")
114
+ @classmethod
115
+ def _refuse_to_start_on_invalid_settings(cls, data: object, handler: ModelWrapValidatorHandler[Self]) -> Self:
116
+ try:
117
+ return handler(data)
118
+ except ValidationError as error:
119
+ # Never echo ``input`` or the raw error: BROKER_URL carries credentials.
120
+ problems = "; ".join(
121
+ f"{location}: {issue['msg']}"
122
+ if (location := ".".join(map(str, issue["loc"])).upper())
123
+ else issue["msg"]
124
+ for issue in error.errors()
125
+ )
126
+ msg = f"Invalid worker settings: {problems}"
127
+ raise WorkerConfigurationError(msg) from None
@@ -0,0 +1,27 @@
1
+ """Test a worker without a broker.
2
+
3
+ - :class:`WorkerHarness` runs a worker's task in memory through the real engine and returns what it published and
4
+ what it did to the delivery (:class:`TaskRun`, :class:`Outcome`).
5
+ - :func:`make_task_context` and :func:`make_sync_task_context` build the context a handler receives, to call a handler
6
+ directly; :class:`ProgressRecorder` collects what it reports.
7
+
8
+ This is the first part of the testing kit: the conformance suite extends it in later releases.
9
+ """
10
+
11
+ from async_task_api_worker.testing._context import (
12
+ ProgressRecorder,
13
+ ProgressUpdate,
14
+ make_sync_task_context,
15
+ make_task_context,
16
+ )
17
+ from async_task_api_worker.testing._harness import Outcome, TaskRun, WorkerHarness
18
+
19
+ __all__ = [
20
+ "Outcome",
21
+ "ProgressRecorder",
22
+ "ProgressUpdate",
23
+ "TaskRun",
24
+ "WorkerHarness",
25
+ "make_sync_task_context",
26
+ "make_task_context",
27
+ ]
@@ -0,0 +1,93 @@
1
+ """Build the context a handler receives, to call a handler directly in a unit test."""
2
+
3
+ import logging
4
+ import threading
5
+ import time
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+ from typing import Final, NamedTuple
9
+
10
+ from async_task_api_worker.context import SyncTaskContext, TaskContext
11
+ from async_task_api_worker.protocol.envelope import InputRef
12
+ from async_task_api_worker.protocol.json_types import ProgressPayload
13
+
14
+ DEFAULT_TASK_ID: Final[str] = "test-task"
15
+ DEFAULT_TIMEOUT_S: Final[float] = 60.0
16
+ _LOGGER_NAME: Final[str] = "async_task_api_worker.testing"
17
+
18
+
19
+ class ProgressUpdate(NamedTuple):
20
+ """One call of ``ctx.progress``."""
21
+
22
+ fraction: float
23
+ payload: ProgressPayload | None
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class ProgressRecorder:
28
+ """Collects what a handler reports through ``ctx.progress``, untouched: no throttling, no validation."""
29
+
30
+ updates: list[ProgressUpdate] = field(default_factory=list)
31
+
32
+ async def _record(self, fraction: float, payload: ProgressPayload | None = None) -> None:
33
+ self.updates.append(ProgressUpdate(fraction, payload))
34
+
35
+ def _record_from_thread(self, fraction: float, payload: ProgressPayload | None = None) -> None:
36
+ self.updates.append(ProgressUpdate(fraction, payload))
37
+
38
+
39
+ def _logger(task_id: str, attempt: int) -> logging.LoggerAdapter[logging.Logger]:
40
+ return logging.LoggerAdapter(logging.getLogger(_LOGGER_NAME), {"task_id": task_id, "attempt": attempt})
41
+
42
+
43
+ def make_task_context(
44
+ *,
45
+ task_id: str = DEFAULT_TASK_ID,
46
+ client_id: str | None = None,
47
+ attempt: int = 1,
48
+ timeout_s: float = DEFAULT_TIMEOUT_S,
49
+ inputs: Mapping[str, InputRef] | None = None,
50
+ recorder: ProgressRecorder | None = None,
51
+ ) -> TaskContext:
52
+ """The context of an ``async def`` handler, to call it directly.
53
+
54
+ ``timeout_s`` sets ``ctx.remaining()``; ``recorder`` collects the progress updates the handler reports (a recorder
55
+ of its own is used when none is given).
56
+ """
57
+ sink = recorder if recorder is not None else ProgressRecorder()
58
+ return TaskContext(
59
+ task_id,
60
+ client_id,
61
+ attempt,
62
+ time.monotonic() + timeout_s,
63
+ _logger(task_id, attempt),
64
+ dict(inputs or {}),
65
+ sink._record,
66
+ )
67
+
68
+
69
+ def make_sync_task_context(
70
+ *,
71
+ task_id: str = DEFAULT_TASK_ID,
72
+ client_id: str | None = None,
73
+ attempt: int = 1,
74
+ timeout_s: float = DEFAULT_TIMEOUT_S,
75
+ inputs: Mapping[str, InputRef] | None = None,
76
+ recorder: ProgressRecorder | None = None,
77
+ cancel_event: threading.Event | None = None,
78
+ ) -> SyncTaskContext:
79
+ """The context of a ``def`` handler, to call it directly.
80
+
81
+ Set ``cancel_event`` to make ``ctx.cancelled()`` true, as a timeout or a shutdown would.
82
+ """
83
+ sink = recorder if recorder is not None else ProgressRecorder()
84
+ return SyncTaskContext(
85
+ task_id,
86
+ client_id,
87
+ attempt,
88
+ time.monotonic() + timeout_s,
89
+ _logger(task_id, attempt),
90
+ dict(inputs or {}),
91
+ sink._record_from_thread,
92
+ cancel_event if cancel_event is not None else threading.Event(),
93
+ )