waymark 0.30.0.dev2__py3-none-macosx_26_0_arm64.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.
waymark/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """Public API for user-defined waymark actions."""
2
+
3
+ from . import bridge # noqa: F401
4
+ from . import workflow_runtime as _workflow_runtime # noqa: F401
5
+ from .actions import (
6
+ ActionResultPayload,
7
+ action,
8
+ deserialize_result_payload,
9
+ serialize_error_payload,
10
+ serialize_result_payload,
11
+ )
12
+ from .dependencies import Depend, Depends, provide_dependencies
13
+ from .exceptions import (
14
+ ExhaustedRetries,
15
+ ExhaustedRetriesError,
16
+ ScheduleAlreadyExistsError,
17
+ )
18
+ from .ir_builder import UnsupportedPatternError, build_workflow_ir
19
+ from .registry import registry
20
+ from .schedule import (
21
+ ScheduleInfo,
22
+ delete_schedule,
23
+ list_schedules,
24
+ pause_schedule,
25
+ resume_schedule,
26
+ schedule_workflow,
27
+ )
28
+ from .workflow import (
29
+ RetryPolicy,
30
+ Workflow,
31
+ workflow,
32
+ workflow_registry,
33
+ )
34
+
35
+ __all__ = [
36
+ "action",
37
+ "registry",
38
+ "ActionResultPayload",
39
+ "Workflow",
40
+ "workflow",
41
+ "workflow_registry",
42
+ "RetryPolicy",
43
+ "build_workflow_ir",
44
+ "serialize_result_payload",
45
+ "deserialize_result_payload",
46
+ "serialize_error_payload",
47
+ "Depend",
48
+ "Depends",
49
+ "provide_dependencies",
50
+ "bridge",
51
+ "ExhaustedRetries",
52
+ "ExhaustedRetriesError",
53
+ "ScheduleAlreadyExistsError",
54
+ "UnsupportedPatternError",
55
+ # Schedule functions
56
+ "schedule_workflow",
57
+ "pause_schedule",
58
+ "resume_schedule",
59
+ "delete_schedule",
60
+ "list_schedules",
61
+ "ScheduleInfo",
62
+ ]
waymark/actions.py ADDED
@@ -0,0 +1,108 @@
1
+ import inspect
2
+ from dataclasses import dataclass
3
+ from functools import wraps
4
+ from typing import Any, Callable, Optional, TypeVar, overload
5
+
6
+ from waymark.proto import messages_pb2 as pb2
7
+
8
+ from .dependencies import provide_dependencies
9
+ from .registry import AsyncAction, registry
10
+ from .serialization import dumps, loads
11
+
12
+ TAsync = TypeVar("TAsync", bound=AsyncAction)
13
+
14
+
15
+ @dataclass
16
+ class ActionResultPayload:
17
+ result: Any | None
18
+ error: dict[str, Any] | None
19
+
20
+
21
+ def serialize_result_payload(value: Any) -> pb2.WorkflowArguments:
22
+ """Serialize a successful action result."""
23
+ arguments = pb2.WorkflowArguments()
24
+ entry = arguments.arguments.add()
25
+ entry.key = "result"
26
+ entry.value.CopyFrom(dumps(value))
27
+ return arguments
28
+
29
+
30
+ def serialize_error_payload(_action: str, exc: BaseException) -> pb2.WorkflowArguments:
31
+ """Serialize an error raised during action execution."""
32
+ arguments = pb2.WorkflowArguments()
33
+ entry = arguments.arguments.add()
34
+ entry.key = "error"
35
+ entry.value.CopyFrom(dumps(exc))
36
+ return arguments
37
+
38
+
39
+ def deserialize_result_payload(payload: pb2.WorkflowArguments | None) -> ActionResultPayload:
40
+ """Deserialize WorkflowArguments produced by serialize_result_payload/error."""
41
+ if payload is None:
42
+ return ActionResultPayload(result=None, error=None)
43
+ values = {entry.key: entry.value for entry in payload.arguments}
44
+ if "error" in values:
45
+ error_value = values["error"]
46
+ data = loads(error_value)
47
+ if not isinstance(data, dict):
48
+ raise ValueError("error payload must deserialize to a mapping")
49
+ return ActionResultPayload(result=None, error=data)
50
+ result_value = values.get("result")
51
+ if result_value is None:
52
+ raise ValueError("result payload missing 'result' field")
53
+ return ActionResultPayload(result=loads(result_value), error=None)
54
+
55
+
56
+ @overload
57
+ def action(func: TAsync, /) -> TAsync: ...
58
+
59
+
60
+ @overload
61
+ def action(*, name: Optional[str] = None) -> Callable[[TAsync], TAsync]: ...
62
+
63
+
64
+ def action(
65
+ func: Optional[TAsync] = None,
66
+ *,
67
+ name: Optional[str] = None,
68
+ ) -> Callable[[TAsync], TAsync] | TAsync:
69
+ """Decorator for registering async actions.
70
+
71
+ Actions decorated with @action will automatically resolve dependency markers
72
+ when called directly (e.g., during pytest runs where workflows bypass the
73
+ gRPC bridge).
74
+ """
75
+
76
+ def decorator(target: TAsync) -> TAsync:
77
+ if not inspect.iscoroutinefunction(target):
78
+ raise TypeError(f"action '{target.__name__}' must be defined with 'async def'")
79
+ action_name = name or target.__name__
80
+ action_module = target.__module__
81
+
82
+ @wraps(target)
83
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
84
+ # Convert positional args to kwargs based on the signature
85
+ sig = inspect.signature(target)
86
+ params = list(sig.parameters.keys())
87
+ for i, arg in enumerate(args):
88
+ if i < len(params):
89
+ kwargs[params[i]] = arg
90
+
91
+ # Resolve dependencies using the same mechanism as execute_action
92
+ async with provide_dependencies(target, kwargs) as call_kwargs:
93
+ return await target(**call_kwargs)
94
+
95
+ # Copy over the original function's attributes for introspection
96
+ wrapper.__wrapped__ = target # type: ignore[attr-defined]
97
+ wrapper.__waymark_action_name__ = action_name # type: ignore[attr-defined]
98
+ wrapper.__waymark_action_module__ = action_module # type: ignore[attr-defined]
99
+
100
+ # Register the original function (not the wrapper) so execute_action
101
+ # doesn't double-resolve dependencies
102
+ registry.register(action_module, action_name, target)
103
+
104
+ return wrapper # type: ignore[return-value]
105
+
106
+ if func is not None:
107
+ return decorator(func)
108
+ return decorator
Binary file
Binary file
Binary file
waymark/bridge.py ADDED
@@ -0,0 +1,432 @@
1
+ import asyncio
2
+ import os
3
+ import shlex
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import time
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from threading import Lock, RLock
12
+ from typing import AsyncIterator, NoReturn, Optional
13
+
14
+ import grpc
15
+ from grpc import aio # type: ignore[attr-defined]
16
+
17
+ from waymark.logger import configure as configure_logger
18
+ from waymark.proto import messages_pb2 as pb2
19
+ from waymark.proto import messages_pb2_grpc as pb2_grpc
20
+
21
+ from .actions import serialize_error_payload, serialize_result_payload
22
+ from .grpc_config import GRPC_CHANNEL_OPTIONS
23
+ from .workflow_runtime import execute_action
24
+
25
+ DEFAULT_HOST = "127.0.0.1"
26
+ LOGGER = configure_logger("waymark.bridge")
27
+
28
+ _PORT_LOCK = RLock()
29
+ _CACHED_GRPC_PORT: Optional[int] = None
30
+ _GRPC_TARGET: Optional[str] = None
31
+ _GRPC_CHANNEL: Optional[aio.Channel] = None
32
+ _GRPC_STUB: Optional[pb2_grpc.WorkflowServiceStub] = None
33
+ _GRPC_LOOP: Optional[asyncio.AbstractEventLoop] = None
34
+ _BOOT_MUTEX = Lock()
35
+ _ASYNC_BOOT_LOCK: asyncio.Lock = asyncio.Lock()
36
+
37
+
38
+ @dataclass
39
+ class RunInstanceResult:
40
+ workflow_version_id: str
41
+ workflow_instance_id: str
42
+
43
+
44
+ @dataclass
45
+ class RunBatchResult:
46
+ workflow_version_id: str
47
+ workflow_instance_ids: list[str]
48
+ queued: int
49
+
50
+
51
+ def _boot_command() -> list[str]:
52
+ override = os.environ.get("WAYMARK_BOOT_COMMAND")
53
+ if override:
54
+ LOGGER.debug("Using WAYMARK_BOOT_COMMAND=%s", override)
55
+ return shlex.split(override)
56
+ binary = os.environ.get("WAYMARK_BOOT_BINARY", "waymark-boot-singleton")
57
+ LOGGER.debug("Using WAYMARK_BOOT_BINARY=%s", binary)
58
+ return [binary]
59
+
60
+
61
+ def _repo_root() -> Path:
62
+ return Path(__file__).resolve().parents[3]
63
+
64
+
65
+ def _resolve_boot_binary(binary: str) -> str:
66
+ if Path(binary).is_absolute():
67
+ return binary
68
+ resolved = shutil.which(binary)
69
+ if resolved:
70
+ return resolved
71
+ repo_root = _repo_root()
72
+ # Check multiple target directories:
73
+ # - CARGO_TARGET_DIR (set by cargo llvm-cov and other tools)
74
+ # - target/llvm-cov-target (used by cargo llvm-cov by default)
75
+ # - target (standard cargo target)
76
+ target_dirs = []
77
+ if cargo_target_dir := os.environ.get("CARGO_TARGET_DIR"):
78
+ target_dirs.append(Path(cargo_target_dir))
79
+ target_dirs.extend(
80
+ [
81
+ repo_root / "target" / "llvm-cov-target",
82
+ repo_root / "target",
83
+ ]
84
+ )
85
+ for target_dir in target_dirs:
86
+ for profile in ("debug", "release"):
87
+ candidate = target_dir / profile / binary
88
+ if candidate.exists():
89
+ return str(candidate)
90
+ return binary
91
+
92
+
93
+ def _ensure_boot_binary(binary: str) -> str:
94
+ resolved = _resolve_boot_binary(binary)
95
+ if Path(resolved).exists():
96
+ return resolved
97
+ repo_root = _repo_root()
98
+ cargo_toml = repo_root / "Cargo.toml"
99
+ if cargo_toml.exists():
100
+ LOGGER.info("boot binary %s not found; building via cargo", binary)
101
+ subprocess.run(
102
+ [
103
+ "cargo",
104
+ "build",
105
+ "--bin",
106
+ "waymark-boot-singleton",
107
+ "--bin",
108
+ "waymark-bridge",
109
+ ],
110
+ cwd=repo_root,
111
+ check=True,
112
+ )
113
+ resolved = _resolve_boot_binary(binary)
114
+ return resolved
115
+
116
+
117
+ def _remember_grpc_port(port: int) -> int:
118
+ global _CACHED_GRPC_PORT
119
+ with _PORT_LOCK:
120
+ _CACHED_GRPC_PORT = port
121
+ return port
122
+
123
+
124
+ def _cached_grpc_port() -> Optional[int]:
125
+ with _PORT_LOCK:
126
+ return _CACHED_GRPC_PORT
127
+
128
+
129
+ def _env_grpc_port_override() -> Optional[int]:
130
+ """Check for explicit gRPC port override via environment."""
131
+ override = os.environ.get("WAYMARK_BRIDGE_GRPC_PORT")
132
+ if not override:
133
+ return None
134
+ try:
135
+ return int(override)
136
+ except ValueError as exc: # pragma: no cover
137
+ raise RuntimeError(f"invalid WAYMARK_BRIDGE_GRPC_PORT value: {override}") from exc
138
+
139
+
140
+ def _boot_singleton_blocking() -> int:
141
+ """Boot the singleton and return the gRPC port."""
142
+ command = _boot_command()
143
+ if os.environ.get("WAYMARK_BOOT_COMMAND") is None:
144
+ command[0] = _ensure_boot_binary(command[0])
145
+ with tempfile.NamedTemporaryFile(mode="w+", suffix=".txt") as f:
146
+ output_file = Path(f.name)
147
+
148
+ command.extend(["--output-file", str(output_file)])
149
+ LOGGER.info("Booting waymark singleton via: %s", " ".join(command))
150
+
151
+ try:
152
+ subprocess.run(
153
+ command,
154
+ check=True,
155
+ timeout=10,
156
+ )
157
+ except subprocess.TimeoutExpired as exc: # pragma: no cover
158
+ LOGGER.error("boot command timed out after %s seconds", exc.timeout)
159
+ raise RuntimeError("unable to boot waymark server") from exc
160
+ except subprocess.CalledProcessError as exc: # pragma: no cover
161
+ LOGGER.error("boot command failed: %s", exc)
162
+ raise RuntimeError("unable to boot waymark server") from exc
163
+ except OSError as exc: # pragma: no cover
164
+ LOGGER.error("unable to spawn boot command: %s", exc)
165
+ raise RuntimeError("unable to boot waymark server") from exc
166
+
167
+ try:
168
+ # We use a file as a message passer because passing a PIPE to the singleton launcher
169
+ # will block our code indefinitely
170
+ # The singleton launches the webserver subprocess to inherit the stdin/stdout that the
171
+ # singleton launcher receives; which means that in the case of a PIPE it would pass that
172
+ # pipe to the subprocess and therefore never correctly close the file descriptor and signal
173
+ # exit process status to Python.
174
+ port_str = output_file.read_text().strip()
175
+ grpc_port = int(port_str)
176
+ LOGGER.info("boot command reported singleton gRPC port %s", grpc_port)
177
+ return grpc_port
178
+ except (ValueError, FileNotFoundError) as exc: # pragma: no cover
179
+ raise RuntimeError(f"unable to read port from output file: {exc}") from exc
180
+
181
+
182
+ def _resolve_grpc_port() -> int:
183
+ """Resolve the gRPC port, booting singleton if necessary."""
184
+ cached = _cached_grpc_port()
185
+ if cached is not None:
186
+ return cached
187
+ env_port = _env_grpc_port_override()
188
+ if env_port is not None:
189
+ return _remember_grpc_port(env_port)
190
+ with _BOOT_MUTEX:
191
+ cached = _cached_grpc_port()
192
+ if cached is not None:
193
+ return cached
194
+ port = _boot_singleton_blocking()
195
+ return _remember_grpc_port(port)
196
+
197
+
198
+ async def _ensure_grpc_port_async() -> int:
199
+ """Ensure we have a gRPC port, booting singleton if necessary."""
200
+ cached = _cached_grpc_port()
201
+ if cached is not None:
202
+ return cached
203
+ env_port = _env_grpc_port_override()
204
+ if env_port is not None:
205
+ return _remember_grpc_port(env_port)
206
+ async with _ASYNC_BOOT_LOCK:
207
+ cached = _cached_grpc_port()
208
+ if cached is not None:
209
+ return cached
210
+ loop = asyncio.get_running_loop()
211
+ LOGGER.info("No cached singleton found, booting new instance")
212
+ port = await loop.run_in_executor(None, _boot_singleton_blocking)
213
+ LOGGER.info("Singleton ready on gRPC port %s", port)
214
+ return _remember_grpc_port(port)
215
+
216
+
217
+ @asynccontextmanager
218
+ async def ensure_singleton() -> AsyncIterator[int]:
219
+ """Yield the gRPC port for the singleton server, booting it exactly once."""
220
+ port = await _ensure_grpc_port_async()
221
+ yield port
222
+
223
+
224
+ def _grpc_target() -> str:
225
+ """Get the gRPC target address for the bridge server."""
226
+ # Check for explicit full address override
227
+ explicit = os.environ.get("WAYMARK_BRIDGE_GRPC_ADDR")
228
+ if explicit:
229
+ return explicit
230
+
231
+ # Otherwise, use host + port
232
+ host = os.environ.get("WAYMARK_BRIDGE_GRPC_HOST", DEFAULT_HOST)
233
+ port = _resolve_grpc_port()
234
+ return f"{host}:{port}"
235
+
236
+
237
+ def assert_never(value: object) -> NoReturn:
238
+ raise AssertionError(f"Unhandled value: {value!r}")
239
+
240
+
241
+ async def _workflow_stub() -> pb2_grpc.WorkflowServiceStub:
242
+ global _GRPC_TARGET, _GRPC_CHANNEL, _GRPC_STUB, _GRPC_LOOP
243
+ target = _grpc_target()
244
+ loop = asyncio.get_running_loop()
245
+ channel_to_wait: Optional[aio.Channel] = None
246
+ with _PORT_LOCK:
247
+ if (
248
+ _GRPC_STUB is not None
249
+ and _GRPC_TARGET == target
250
+ and _GRPC_LOOP is loop
251
+ and not loop.is_closed()
252
+ ):
253
+ return _GRPC_STUB
254
+ channel = aio.insecure_channel(target, options=GRPC_CHANNEL_OPTIONS)
255
+ stub = pb2_grpc.WorkflowServiceStub(channel)
256
+ _GRPC_CHANNEL = channel
257
+ _GRPC_STUB = stub
258
+ _GRPC_TARGET = target
259
+ _GRPC_LOOP = loop
260
+ channel_to_wait = channel
261
+ if channel_to_wait is not None:
262
+ await channel_to_wait.channel_ready()
263
+ return _GRPC_STUB # type: ignore[return-value]
264
+
265
+
266
+ async def run_instance(payload: bytes) -> RunInstanceResult:
267
+ """Register a workflow definition and start an instance over the gRPC bridge."""
268
+ async with ensure_singleton():
269
+ stub = await _workflow_stub()
270
+ registration = pb2.WorkflowRegistration()
271
+ registration.ParseFromString(payload)
272
+ request = pb2.RegisterWorkflowRequest(
273
+ registration=registration,
274
+ )
275
+ try:
276
+ response = await stub.RegisterWorkflow(request, timeout=30.0)
277
+ except aio.AioRpcError as exc: # pragma: no cover
278
+ raise RuntimeError(f"register_workflow failed: {exc}") from exc
279
+ return RunInstanceResult(
280
+ workflow_version_id=response.workflow_version_id,
281
+ workflow_instance_id=response.workflow_instance_id,
282
+ )
283
+
284
+
285
+ async def run_instances_batch(
286
+ payload: bytes,
287
+ *,
288
+ count: int = 1,
289
+ inputs: Optional[pb2.WorkflowArguments] = None,
290
+ inputs_list: Optional[list[pb2.WorkflowArguments]] = None,
291
+ batch_size: int = 500,
292
+ include_instance_ids: bool = False,
293
+ ) -> RunBatchResult:
294
+ """Register a workflow definition and start multiple instances over the gRPC bridge."""
295
+ if count < 1 and not inputs_list:
296
+ raise ValueError("count must be >= 1 when inputs_list is empty")
297
+ if batch_size < 1:
298
+ raise ValueError("batch_size must be >= 1")
299
+
300
+ async with ensure_singleton():
301
+ stub = await _workflow_stub()
302
+ registration = pb2.WorkflowRegistration()
303
+ registration.ParseFromString(payload)
304
+ request = pb2.RegisterWorkflowBatchRequest(
305
+ registration=registration,
306
+ count=count,
307
+ batch_size=batch_size,
308
+ include_instance_ids=include_instance_ids,
309
+ )
310
+ if inputs is not None:
311
+ request.inputs.CopyFrom(inputs)
312
+ if inputs_list:
313
+ request.inputs_list.extend(inputs_list)
314
+ try:
315
+ response = await stub.RegisterWorkflowBatch(request, timeout=30.0)
316
+ except aio.AioRpcError as exc: # pragma: no cover
317
+ raise RuntimeError(f"register_workflow_batch failed: {exc}") from exc
318
+ return RunBatchResult(
319
+ workflow_version_id=response.workflow_version_id,
320
+ workflow_instance_ids=list(response.workflow_instance_ids),
321
+ queued=response.queued,
322
+ )
323
+
324
+
325
+ async def execute_workflow(payload: bytes) -> bytes:
326
+ """Execute a workflow via the in-memory workflow streaming API."""
327
+ os.environ.setdefault("WAYMARK_BRIDGE_IN_MEMORY", "1")
328
+ async with ensure_singleton():
329
+ stub = await _workflow_stub()
330
+
331
+ registration = pb2.WorkflowRegistration()
332
+ registration.ParseFromString(payload)
333
+ LOGGER.debug(
334
+ "pytest stream start: workflow=%s ir_hash=%s",
335
+ registration.workflow_name,
336
+ registration.ir_hash,
337
+ )
338
+
339
+ queue: asyncio.Queue[Optional[pb2.WorkflowStreamRequest]] = asyncio.Queue()
340
+ skip_sleep = bool(os.environ.get("PYTEST_CURRENT_TEST"))
341
+ await queue.put(pb2.WorkflowStreamRequest(registration=registration, skip_sleep=skip_sleep))
342
+
343
+ async def request_stream() -> AsyncIterator[pb2.WorkflowStreamRequest]:
344
+ while True:
345
+ item = await queue.get()
346
+ if item is None:
347
+ return
348
+ yield item
349
+
350
+ call = stub.ExecuteWorkflow(request_stream(), timeout=300.0)
351
+ result_payload: Optional[bytes] = None
352
+
353
+ async for response in call:
354
+ kind = response.WhichOneof("kind")
355
+ match kind:
356
+ case "action_dispatch":
357
+ dispatch = response.action_dispatch
358
+ LOGGER.debug(
359
+ "pytest stream dispatch: action_id=%s module=%s action=%s",
360
+ dispatch.action_id,
361
+ dispatch.module_name,
362
+ dispatch.action_name,
363
+ )
364
+ start_ns = time.monotonic_ns()
365
+ execution = await execute_action(dispatch)
366
+ end_ns = time.monotonic_ns()
367
+ action_result = pb2.ActionResult(
368
+ action_id=dispatch.action_id,
369
+ success=execution.exception is None,
370
+ payload=(
371
+ serialize_result_payload(execution.result)
372
+ if execution.exception is None
373
+ else serialize_error_payload(dispatch.action_name, execution.exception)
374
+ ),
375
+ worker_start_ns=start_ns,
376
+ worker_end_ns=end_ns,
377
+ error_type=(
378
+ type(execution.exception).__name__
379
+ if execution.exception is not None
380
+ else ""
381
+ ),
382
+ error_message=str(execution.exception)
383
+ if execution.exception is not None
384
+ else "",
385
+ )
386
+ # Echo the opaque server correlation metadata untouched.
387
+ if dispatch.metadata:
388
+ action_result.metadata = dispatch.metadata
389
+ LOGGER.debug(
390
+ "pytest stream result: action_id=%s success=%s",
391
+ dispatch.action_id,
392
+ execution.exception is None,
393
+ )
394
+ await queue.put(pb2.WorkflowStreamRequest(action_result=action_result))
395
+ case "workflow_result":
396
+ result_payload = response.workflow_result.payload
397
+ LOGGER.debug(
398
+ "pytest stream complete: workflow=%s payload_bytes=%s",
399
+ registration.workflow_name,
400
+ len(result_payload),
401
+ )
402
+ await queue.put(None)
403
+ break
404
+ case None:
405
+ continue
406
+ case _:
407
+ assert_never(kind)
408
+
409
+ if result_payload is None:
410
+ raise RuntimeError("workflow stream ended without a result")
411
+ return result_payload
412
+
413
+
414
+ async def wait_for_instance(
415
+ instance_id: str,
416
+ poll_interval_secs: float = 1.0,
417
+ ) -> Optional[bytes]:
418
+ """Block until the workflow daemon produces the requested instance payload."""
419
+ async with ensure_singleton():
420
+ stub = await _workflow_stub()
421
+ request = pb2.WaitForInstanceRequest(
422
+ instance_id=instance_id,
423
+ poll_interval_secs=poll_interval_secs,
424
+ )
425
+ try:
426
+ response = await stub.WaitForInstance(request, timeout=None)
427
+ except aio.AioRpcError as exc: # pragma: no cover
428
+ status_fn = exc.code
429
+ if callable(status_fn) and status_fn() == grpc.StatusCode.NOT_FOUND:
430
+ return None
431
+ raise RuntimeError(f"wait_for_instance failed: {exc}") from exc
432
+ return bytes(response.payload)
@@ -0,0 +1,36 @@
1
+ from typing import Any, Callable
2
+
3
+ from mountaineer_di import (
4
+ DependencyResolver,
5
+ Depends,
6
+ get_function_dependencies,
7
+ isolate_dependency_only_function,
8
+ provide_dependencies,
9
+ strip_depends_from_signature,
10
+ )
11
+
12
+ DependMarker = type(Depends())
13
+ DependsMarker = DependMarker
14
+
15
+
16
+ def Depend( # noqa: N802
17
+ dependency: Callable[..., Any] | None = None,
18
+ *,
19
+ use_cache: bool = True,
20
+ ) -> Any:
21
+ """Compatibility alias for ``mountaineer_di.Depends``."""
22
+
23
+ return Depends(dependency, use_cache=use_cache)
24
+
25
+
26
+ __all__ = [
27
+ "DependencyResolver",
28
+ "Depend",
29
+ "DependMarker",
30
+ "Depends",
31
+ "DependsMarker",
32
+ "get_function_dependencies",
33
+ "isolate_dependency_only_function",
34
+ "provide_dependencies",
35
+ "strip_depends_from_signature",
36
+ ]
waymark/exceptions.py ADDED
@@ -0,0 +1,18 @@
1
+ """Custom exception types raised by waymark workflows."""
2
+
3
+
4
+ class ExhaustedRetriesError(Exception):
5
+ """Raised when an action exhausts its allotted retry attempts."""
6
+
7
+ def __init__(self, message: str | None = None) -> None:
8
+ super().__init__(message or "action exhausted retries")
9
+
10
+
11
+ ExhaustedRetries = ExhaustedRetriesError
12
+
13
+
14
+ class ScheduleAlreadyExistsError(Exception):
15
+ """Raised when a schedule name is already registered."""
16
+
17
+ def __init__(self, message: str | None = None) -> None:
18
+ super().__init__(message or "schedule already exists")