onedata-lambda-sdk 1.0.0__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.
@@ -0,0 +1,60 @@
1
+ """
2
+ SDK for writing Onedata Automation lambdas.
3
+
4
+ Public surface: the handler API (`Job`, `JobContext`, `JobException`), the contract
5
+ runtime (`run`, `per_job`), the Onedata wire/contract field types (the `Atm*` names),
6
+ the time-series measurement builder, and the mounted-Oneclient helpers.
7
+
8
+ Naming convention: `Atm*` marks the Onedata Automation *contract* -- the JSON shapes that
9
+ cross the wire to/from the provider (`AtmFile`, `AtmException`, …). Everything the SDK adds
10
+ purely as authoring ergonomics (`Job`, `JobContext`, `JobException`, `ResultStreamer`,
11
+ `TimeSeriesMeasurementBuilder`, …) is SDK-local and carries no prefix.
12
+ """
13
+
14
+ from .job import Job, JobContext, JobException
15
+ from .oneclient import mount_point, mounted_file_path
16
+ from .perjob import DEFAULT_MAX_WORKERS, per_job
17
+ from .runtime import run
18
+ from .stats import TimeSeriesMeasurementBuilder
19
+ from .types import (
20
+ AtmDataset,
21
+ AtmException,
22
+ AtmFile,
23
+ AtmGroup,
24
+ AtmObject,
25
+ AtmRange,
26
+ AtmTimeSeriesMeasurement,
27
+ FileType,
28
+ InheritancePath,
29
+ LogLevel,
30
+ ProtectionFlag,
31
+ )
32
+
33
+
34
+ __all__ = [
35
+ # runtime
36
+ "run",
37
+ "per_job",
38
+ "DEFAULT_MAX_WORKERS",
39
+ # handler API
40
+ "Job",
41
+ "JobContext",
42
+ "JobException",
43
+ # mounted Oneclient helpers
44
+ "mount_point",
45
+ "mounted_file_path",
46
+ # wire/contract field types (Atm*)
47
+ "AtmException",
48
+ "AtmObject",
49
+ "AtmDataset",
50
+ "AtmFile",
51
+ "AtmGroup",
52
+ "AtmRange",
53
+ "AtmTimeSeriesMeasurement",
54
+ "FileType",
55
+ "ProtectionFlag",
56
+ "InheritancePath",
57
+ "LogLevel",
58
+ # builders
59
+ "TimeSeriesMeasurementBuilder",
60
+ ]
@@ -0,0 +1,111 @@
1
+ """
2
+ Internal wire types: the raw JSON shapes exchanged between Oneprovider and a lambda.
3
+
4
+ These model the OpenFaaS job-batch request/response protocol verbatim (camelCase, as
5
+ sent on the wire). They are an implementation detail parsed by `run()` and are NOT
6
+ part of the public handler API -- handlers work with `Job` / `JobContext`
7
+ instead (see `job`). The full request context remains reachable as an escape hatch
8
+ via `JobContext.raw`.
9
+ """
10
+
11
+ __author__ = "Bartosz Walkowicz"
12
+ __copyright__ = "Copyright (C) 2026 Onedata (onedata.org)"
13
+ __license__ = "This software is released under the MIT license cited in LICENSE.txt"
14
+
15
+ from collections.abc import Sequence
16
+ from typing import TypedDict
17
+
18
+ from .types import AtmException, LogLevel
19
+
20
+
21
+ class AtmJobBatchRequestCtx[C](TypedDict):
22
+ """
23
+ JSON object describing job batch request context.
24
+
25
+ Fields
26
+ --------
27
+ userId
28
+ Id of the scheduling user.
29
+ spaceId
30
+ Id of the space in context of which the workflow is executed.
31
+ atmWorkflowExecutionId
32
+ Workflow execution Id.
33
+ oneproviderDomain
34
+ Domain of job scheduling Oneprovider.
35
+ oneproviderId
36
+ Id of job scheduling Oneprovider.
37
+ onezoneDomain
38
+ The domain of the Onezone service that manages the Onedata environment.
39
+ accessToken
40
+ Token used to authorize operations in Onedata.
41
+
42
+ Refer to the API specification for more information:
43
+ https://onedata.org/#/home/documentation/latest/doc/using_onedata/tokens[access-tokens].html
44
+ heartbeatUrl
45
+ The target url for sending heartbeats.
46
+ timeoutSeconds
47
+ The time interval from last heartbeat after which job batch is marked as failed.
48
+ logLevel
49
+ Level controlling the amount of information recorded in audit logs as only logs
50
+ with severity equal or higher to this level will be stored.
51
+ config
52
+ User defined task configuration.
53
+ """
54
+
55
+ userId: str
56
+ spaceId: str
57
+ atmWorkflowExecutionId: str
58
+ oneproviderDomain: str
59
+ oneproviderId: str
60
+ onezoneDomain: str
61
+ accessToken: str
62
+ heartbeatUrl: str
63
+ timeoutSeconds: int
64
+ logLevel: LogLevel
65
+ config: C
66
+
67
+
68
+ class AtmJobMeta(TypedDict):
69
+ """Per-job metadata the framework attaches to each job's args object on the wire."""
70
+
71
+ traceId: str
72
+
73
+
74
+ # Functional syntax because `__meta` is a dunder key (a class-body annotation would be
75
+ # name-mangled). Each wire args object also carries the lambda's own argument keys
76
+ # alongside `__meta`; the runtime strips `__meta` off (-> Job.trace_id) and hands the
77
+ # rest to the handler as the job's args.
78
+ AtmJobArgs = TypedDict("AtmJobArgs", {"__meta": AtmJobMeta})
79
+
80
+
81
+ class AtmJobBatchRequest[C](TypedDict):
82
+ """
83
+ JSON object describing job batch request.
84
+
85
+ Fields
86
+ --------
87
+ ctx
88
+ Context of job batch request.
89
+ argsBatch
90
+ List of per-job args objects. Each carries a `__meta` envelope (the job's
91
+ `traceId`) alongside the lambda's own input arguments.
92
+ """
93
+
94
+ ctx: AtmJobBatchRequestCtx[C]
95
+ argsBatch: list[AtmJobArgs]
96
+
97
+
98
+ class AtmJobBatchResponse[R](TypedDict):
99
+ """
100
+ JSON object describing job batch response.
101
+
102
+ Fields
103
+ --------
104
+ resultsBatch
105
+ List of JSON objects, each carrying results of a single job (lambda output).
106
+ The length of this list must be equal to the length of the corresponding
107
+ AtmJobBatchRequest.argsBatch. However, if the lambda returns empty results for
108
+ each job, the resultsBatch can be set to None.
109
+ """
110
+
111
+ resultsBatch: Sequence[AtmException | R | None] | None
@@ -0,0 +1,119 @@
1
+ """
2
+ The handler API: the per-job unit (`Job`), the shared batch context (`JobContext`), and
3
+ the failure signal (`JobException`) -- what a handler signs against.
4
+
5
+ These are SDK-local authoring abstractions, not the wire contract, so they carry no `Atm`
6
+ prefix (unlike `AtmFile`, `AtmException`, … which mirror what crosses the wire).
7
+ """
8
+
9
+ __author__ = "Bartosz Walkowicz"
10
+ __copyright__ = "Copyright (C) 2026 Onedata (onedata.org)"
11
+ __license__ = "This software is released under the MIT license cited in LICENSE.txt"
12
+
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING
16
+
17
+ from .types import LogLevel
18
+
19
+
20
+ if TYPE_CHECKING:
21
+ from ._wire import AtmJobBatchRequestCtx
22
+ from .logging import Logger
23
+ from .streaming import ResultStreamer
24
+
25
+
26
+ class JobException(Exception):
27
+ """
28
+ Raised by a handler to fail a job with a known, message-only error.
29
+
30
+ The SDK turns it into an `AtmException` entry carrying just `str(exception)` -- no
31
+ traceback -- so an expected failure (bad config, a failed REST call)
32
+ reads cleanly in the results. Any *other* exception is treated as an unexpected bug
33
+ and reported with a full traceback instead.
34
+ """
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Job[A]:
39
+ """
40
+ A single unit of work passed to a handler.
41
+
42
+ `args` is the job's input (the lambda's typed arguments). `trace_id` identifies this
43
+ job across logs and streamed results; it comes from the wire (the framework assigns
44
+ it) and lives on the job -- not on the shared context, which is common to all jobs of
45
+ a batch, including in parallel.
46
+ """
47
+
48
+ args: A
49
+ trace_id: str
50
+
51
+
52
+ class JobContext[C]:
53
+ """
54
+ The shared, per-batch context handed to a handler alongside its jobs.
55
+
56
+ Carries the (typed) task `config`, read-only identity/infrastructure fields, and the
57
+ runtime services. Services (`logger(name)`, `heartbeat()`, `result_streamer(name)`)
58
+ are vended by the context rather than imported globally, so they can hook into the
59
+ runtime (heartbeat-on-flush, the shared streaming flusher, dev-mode). They are
60
+ thread-safe; per-job state must never be stored here -- it travels on `Job`.
61
+
62
+ There is no default log stream: a lambda picks its stream names itself (and declares
63
+ them in its workflow schema), so `logger(name)` requires the name explicitly -- just
64
+ like `result_streamer(name)`. "logs" is only a common convention, not a requirement.
65
+
66
+ `raw` is an escape hatch to the full wire request context for fields not surfaced
67
+ here. The runtime (`run()`) constructs this; handlers only read it.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ *,
73
+ config: C,
74
+ user_id: str,
75
+ space_id: str,
76
+ workflow_execution_id: str,
77
+ oneprovider_id: str,
78
+ oneprovider_domain: str,
79
+ onezone_domain: str,
80
+ access_token: str,
81
+ timeout_seconds: int,
82
+ log_level: LogLevel,
83
+ heartbeat: Callable[[], None],
84
+ result_streamer_factory: "Callable[..., ResultStreamer]",
85
+ logger_factory: "Callable[[str], Logger]",
86
+ raw: "AtmJobBatchRequestCtx[C]",
87
+ ) -> None:
88
+ self.config = config
89
+ self.user_id = user_id
90
+ self.space_id = space_id
91
+ self.workflow_execution_id = workflow_execution_id
92
+ self.oneprovider_id = oneprovider_id
93
+ self.oneprovider_domain = oneprovider_domain
94
+ self.onezone_domain = onezone_domain
95
+ self.access_token = access_token
96
+ self.timeout_seconds = timeout_seconds
97
+ self.log_level = log_level
98
+ self.raw = raw
99
+ self._heartbeat = heartbeat
100
+ self._result_streamer_factory = result_streamer_factory
101
+ self._logger_factory = logger_factory
102
+
103
+ def heartbeat(self) -> None:
104
+ """Signal progress for the current job (throttled by the runtime)."""
105
+ self._heartbeat()
106
+
107
+ def result_streamer(self, name: str, *, buffered: bool = True) -> "ResultStreamer":
108
+ """Open a result/measurement stream by name (buffered by default)."""
109
+ return self._result_streamer_factory(name, buffered=buffered)
110
+
111
+ def logger(self, name: str) -> "Logger":
112
+ """
113
+ Open an audit-log stream by name, filtered to the batch's log level.
114
+
115
+ The name is the lambda's choice (declare it in the workflow schema); "logs" is a
116
+ common convention, not a requirement. Logs are written unbuffered, so they are
117
+ kept even if the handler crashes mid-batch.
118
+ """
119
+ return self._logger_factory(name)
@@ -0,0 +1,99 @@
1
+ """
2
+ Audit logging for lambdas: `Logger`.
3
+
4
+ A thin layer over a result stream that tags each entry with a severity and drops entries
5
+ below the configured level. Vended by `JobContext.logger(name)`; the runtime builds it
6
+ on the lambda's chosen log stream with the batch's `log_level` and wires it to the shared
7
+ streaming machinery (so it inherits the streamer's thread-safety).
8
+ """
9
+
10
+ __author__ = "Bartosz Walkowicz"
11
+ __copyright__ = "Copyright (C) 2023-2026 Onedata (onedata.org)"
12
+ __license__ = "This software is released under the MIT license cited in LICENSE.txt"
13
+
14
+ from collections.abc import Callable, Iterable
15
+ from typing import Any, Final
16
+
17
+ from .streaming import ResultStreamer, StreamFlusher
18
+ from .types import AtmObject
19
+
20
+
21
+ # Severity -> numeric level. Higher number == more verbose; a logger keeps entries
22
+ # whose level is at or below its threshold (i.e. of equal-or-higher importance).
23
+ LOG_LEVELS: Final[dict[str, int]] = {
24
+ "debug": 7,
25
+ "info": 6,
26
+ "notice": 5,
27
+ "warning": 4,
28
+ "error": 3,
29
+ "critical": 2,
30
+ "alert": 1,
31
+ "emergency": 0,
32
+ }
33
+
34
+ DEFAULT_LOG_LEVEL: Final[str] = "info"
35
+
36
+
37
+ def normalize_severity(severity: str) -> str:
38
+ return severity if severity in LOG_LEVELS else DEFAULT_LOG_LEVEL
39
+
40
+
41
+ class Logger(ResultStreamer[AtmObject]):
42
+ """A severity-filtered result streamer for `{severity, content}` log entries."""
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ result_name: str,
48
+ buffered: bool,
49
+ flusher: StreamFlusher,
50
+ heartbeat: Callable[[], None],
51
+ out_dir: str | None = None,
52
+ log_level: str = DEFAULT_LOG_LEVEL,
53
+ ) -> None:
54
+ super().__init__(
55
+ result_name=result_name,
56
+ buffered=buffered,
57
+ flusher=flusher,
58
+ heartbeat=heartbeat,
59
+ out_dir=out_dir,
60
+ )
61
+ self._threshold = LOG_LEVELS[normalize_severity(log_level)]
62
+
63
+ def stream_items(self, items: Iterable[AtmObject]) -> None:
64
+ kept = [item for item in items if LOG_LEVELS[self._severity_of(item)] <= self._threshold]
65
+ super().stream_items(kept)
66
+
67
+ @staticmethod
68
+ def _severity_of(item: AtmObject) -> str:
69
+ try:
70
+ return normalize_severity(item["severity"])
71
+ except (KeyError, TypeError):
72
+ return DEFAULT_LOG_LEVEL
73
+
74
+ def log(self, severity: str, content: Any) -> None:
75
+ self.stream_item({"severity": severity, "content": content})
76
+
77
+ def debug(self, content: Any) -> None:
78
+ self.log("debug", content)
79
+
80
+ def info(self, content: Any) -> None:
81
+ self.log("info", content)
82
+
83
+ def notice(self, content: Any) -> None:
84
+ self.log("notice", content)
85
+
86
+ def warning(self, content: Any) -> None:
87
+ self.log("warning", content)
88
+
89
+ def error(self, content: Any) -> None:
90
+ self.log("error", content)
91
+
92
+ def critical(self, content: Any) -> None:
93
+ self.log("critical", content)
94
+
95
+ def alert(self, content: Any) -> None:
96
+ self.log("alert", content)
97
+
98
+ def emergency(self, content: Any) -> None:
99
+ self.log("emergency", content)
@@ -0,0 +1,45 @@
1
+ """
2
+ Helpers for lambdas that read files through a mounted Oneclient.
3
+
4
+ The mount point is a deployment fact: the platform mounts Oneclient at
5
+ `ONECLIENT_MOUNT_POINT` -- the same variable the runtime waits on before handing a batch
6
+ to a mounted lambda (see `runtime._await_oneclient_mount`). Reading it here, in one place,
7
+ together with Oneclient's id-based path convention, keeps every mounted lambda agreeing
8
+ with the runtime instead of hardcoding a path that can silently drift.
9
+ """
10
+
11
+ __author__ = "Bartosz Walkowicz"
12
+ __copyright__ = "Copyright (C) 2026 Onedata (onedata.org)"
13
+ __license__ = "This software is released under the MIT license cited in LICENSE.txt"
14
+
15
+ import os
16
+ from typing import Final
17
+
18
+
19
+ # Environment variable carrying the Oneclient mount point (set by the platform).
20
+ ENV_MOUNT_POINT: Final[str] = "ONECLIENT_MOUNT_POINT"
21
+
22
+ # Fallback used only for local dev / tests; in production the platform always sets the env.
23
+ DEFAULT_MOUNT_POINT: Final[str] = "/mnt/onedata"
24
+
25
+
26
+ def mount_point() -> str:
27
+ """
28
+ The Oneclient mount point, read from the environment at call time.
29
+
30
+ Falls back to `DEFAULT_MOUNT_POINT` for local dev/tests; in production the platform
31
+ always sets `ONECLIENT_MOUNT_POINT`. Read at call time (not import time) so tests can
32
+ point the mount elsewhere via the environment.
33
+ """
34
+ return os.environ.get(ENV_MOUNT_POINT) or DEFAULT_MOUNT_POINT
35
+
36
+
37
+ def mounted_file_path(file_id: str) -> str:
38
+ """
39
+ Absolute path of a file, by its id, under the Oneclient mount.
40
+
41
+ Oneclient exposes every file at a stable id-based path
42
+ (`<mount>/.__onedata__file_id__<id>`), so a lambda can open a file knowing only its id,
43
+ without resolving its logical location.
44
+ """
45
+ return f"{mount_point()}/.__onedata__file_id__{file_id}"
@@ -0,0 +1,122 @@
1
+ """
2
+ The per-job handler adapter: `@per_job`.
3
+
4
+ Lets a handler be written in the natural per-job style -- `handle(job, ctx) -> result`
5
+ -- and adapts it into the batch-level handler `run()` calls, so the runtime stays
6
+ agnostic about the style used. Sibling of `runtime.run` (the batch entry point).
7
+ """
8
+
9
+ __author__ = "Bartosz Walkowicz"
10
+ __copyright__ = "Copyright (C) 2022-2026 Onedata (onedata.org)"
11
+ __license__ = "This software is released under the MIT license cited in LICENSE.txt"
12
+
13
+ import functools
14
+ import traceback
15
+ from collections.abc import Callable
16
+ from concurrent.futures import ThreadPoolExecutor, as_completed
17
+ from typing import Any
18
+
19
+ from .job import Job, JobContext, JobException
20
+ from .types import AtmException
21
+
22
+
23
+ # A per-job handler: one job in, one result out.
24
+ PerJobHandler = Callable[[Job[Any], JobContext[Any]], Any]
25
+ # A batch-level handler (what the runtime calls).
26
+ BatchHandler = Callable[[list[Job[Any]], JobContext[Any]], list[Any]]
27
+ # A once-per-batch precondition: validate config / set up shared state before any job runs.
28
+ Precondition = Callable[[JobContext[Any]], None]
29
+
30
+ # Default worker count for parallel `@per_job`. A deliberate fixed value, *not* derived
31
+ # from `os.cpu_count()`: that returns the host's cores and ignores the pod's cgroup CPU
32
+ # limit (and there is no `os.process_cpu_count()` on 3.12), and peak memory grows with the
33
+ # worker count (each job may hold a read buffer). Lambdas whose profile differs pass their
34
+ # own `max_workers`.
35
+ DEFAULT_MAX_WORKERS = 10
36
+
37
+
38
+ def per_job(
39
+ handle: PerJobHandler | None = None,
40
+ *,
41
+ max_workers: int = 1,
42
+ precondition: Precondition | None = None,
43
+ ) -> Any:
44
+ """
45
+ Adapt a per-job `handle(job, ctx) -> result` into the batch handler `run()` calls.
46
+
47
+ The SDK owns the batch loop: it runs each job, turns a per-job exception into an
48
+ `AtmException` entry (so one bad job fails only itself), assembles results in input
49
+ order, and heartbeats as each job completes. Sequential by default; `max_workers > 1`
50
+ runs jobs on a thread pool (the context services are thread-safe) -- pass
51
+ `DEFAULT_MAX_WORKERS` for the SDK's standard degree of parallelism. Usable bare
52
+ (`@per_job`) or parameterized (`@per_job(max_workers=4)`).
53
+
54
+ `precondition`, if given, runs **once** before any job is dispatched -- the place for
55
+ task-level validation (e.g. a config check) or one-time setup. Raising `JobException`
56
+ there fails the whole batch with a single clean top-level `{"exception": ...}` (via
57
+ `run()`), rather than repeating a per-job failure across every job.
58
+ """
59
+
60
+ def decorate(handle_one: PerJobHandler) -> BatchHandler:
61
+ # Copy name/qualname/doc/module so tracebacks and introspection point at the
62
+ # user's handler -- but NOT __annotations__: run_batch has the batch signature
63
+ # (jobs, ctx), which differs from handle_one's per-job (job, ctx).
64
+ @functools.wraps(
65
+ handle_one,
66
+ assigned=("__module__", "__name__", "__qualname__", "__doc__"),
67
+ )
68
+ def run_batch(jobs: list[Job[Any]], ctx: JobContext[Any]) -> list[Any]:
69
+ if precondition is not None:
70
+ # Runs once for the batch; a JobException here propagates to run()'s
71
+ # top-level handler -> clean whole-batch {"exception": ...}.
72
+ precondition(ctx)
73
+ if max_workers <= 1:
74
+ return _run_sequential(handle_one, jobs, ctx)
75
+ return _run_parallel(handle_one, jobs, ctx, max_workers)
76
+
77
+ return run_batch
78
+
79
+ # Support both bare `@per_job` and parameterized `@per_job(max_workers=N)`.
80
+ if handle is not None:
81
+ return decorate(handle)
82
+
83
+ return decorate
84
+
85
+
86
+ def _run_sequential(
87
+ handle_one: PerJobHandler, jobs: list[Job[Any]], ctx: JobContext[Any]
88
+ ) -> list[Any]:
89
+ results = []
90
+ for job in jobs:
91
+ results.append(_run_one(handle_one, job, ctx))
92
+ ctx.heartbeat()
93
+ return results
94
+
95
+
96
+ def _run_parallel(
97
+ handle_one: PerJobHandler,
98
+ jobs: list[Job[Any]],
99
+ ctx: JobContext[Any],
100
+ max_workers: int,
101
+ ) -> list[Any]:
102
+ results: list[Any] = [None] * len(jobs)
103
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
104
+ future_to_index = {
105
+ executor.submit(_run_one, handle_one, job, ctx): index for index, job in enumerate(jobs)
106
+ }
107
+ for future in as_completed(future_to_index):
108
+ index = future_to_index[future]
109
+ results[index] = future.result() # _run_one never raises
110
+ ctx.heartbeat()
111
+ return results
112
+
113
+
114
+ def _run_one(handle_one: PerJobHandler, job: Job[Any], ctx: JobContext[Any]) -> Any:
115
+ try:
116
+ return handle_one(job, ctx)
117
+ except JobException as ex:
118
+ # A known, expected failure -- report just the message, no traceback noise.
119
+ return AtmException(exception=str(ex))
120
+ except Exception:
121
+ # An unexpected bug -- keep the full traceback for debugging.
122
+ return AtmException(exception=traceback.format_exc())
File without changes