fluidattacks_batch_client 0.1.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,9 @@
1
+ from ._logger import (
2
+ setup_logger,
3
+ )
4
+ from fa_purity import (
5
+ Unsafe,
6
+ )
7
+
8
+ __version__ = "0.1.0"
9
+ Unsafe.compute(setup_logger(__name__))
@@ -0,0 +1,171 @@
1
+ import dataclasses
2
+
3
+ from fluidattacks_batch_client.decode import JobDefDecoder
4
+ from fluidattacks_batch_client.sender import new_sender
5
+ from .api import (
6
+ new_client,
7
+ )
8
+ from .core import (
9
+ AllowDuplicates,
10
+ JobPipeline,
11
+ JobRequest,
12
+ JobName,
13
+ )
14
+ import click
15
+ from fa_purity import (
16
+ Bool,
17
+ Cmd,
18
+ FrozenList,
19
+ NewFrozenList,
20
+ PureIterFactory,
21
+ PureIterTransform,
22
+ ResultE,
23
+ UnitType,
24
+ Unsafe,
25
+ unit,
26
+ )
27
+ from fa_purity.json import (
28
+ JsonObj,
29
+ JsonValueFactory,
30
+ UnfoldedFactory,
31
+ Unfolder,
32
+ )
33
+ from typing import (
34
+ IO,
35
+ Callable,
36
+ NoReturn,
37
+ TypeVar,
38
+ )
39
+ import logging
40
+ from . import _utils
41
+
42
+ LOG = logging.getLogger(__name__)
43
+
44
+ _T = TypeVar("_T")
45
+
46
+
47
+ def _read_file(file_path: str, transform: Callable[[IO[str]], _T]) -> Cmd[_T]:
48
+ def _action() -> _T:
49
+ with open(file_path, "r", encoding="utf-8") as file:
50
+ return transform(file)
51
+
52
+ return Cmd.wrap_impure(_action)
53
+
54
+
55
+ def _decode_json(file_path: str) -> Cmd[ResultE[JsonObj]]:
56
+ return _read_file(file_path, UnfoldedFactory.load)
57
+
58
+
59
+ def _decode_json_list(file_path: str) -> Cmd[ResultE[FrozenList[JsonObj]]]:
60
+ return _read_file(
61
+ file_path,
62
+ lambda file: JsonValueFactory.load(file).bind(
63
+ lambda v: Unfolder.to_list_of(v, Unfolder.to_json)
64
+ ),
65
+ )
66
+
67
+
68
+ @click.command()
69
+ @click.option("--job", required=True, help="json encoded str defining a JobRequest")
70
+ @click.option("--allow-duplicates", is_flag=True)
71
+ @click.option("--args-in-name", is_flag=True)
72
+ @click.option("--dry-run", is_flag=True)
73
+ @click.argument("args", nargs=-1) # additional args to append into the job command
74
+ def submit_job(
75
+ job: str,
76
+ allow_duplicates: bool,
77
+ args_in_name: bool,
78
+ dry_run: bool,
79
+ args: FrozenList[str],
80
+ ) -> NoReturn:
81
+ def _execute(job: JobRequest) -> Cmd[None]:
82
+ name = Bool.from_primitive(len(args) > 0 and args_in_name).map(
83
+ lambda _: JobName.normalize(job.name.raw + "-" + "-".join(args)),
84
+ lambda _: job.name,
85
+ )
86
+ overriden_job = dataclasses.replace(job, name=name)
87
+ if dry_run:
88
+ return Cmd.wrap_impure(
89
+ lambda: LOG.info(
90
+ "[dry-run] the following batch job will be sent: %s", overriden_job
91
+ )
92
+ )
93
+ return (
94
+ new_client()
95
+ .map(new_sender)
96
+ .bind(
97
+ lambda s: s.send_single_job(
98
+ overriden_job, AllowDuplicates(allow_duplicates)
99
+ ).map(lambda _: None)
100
+ )
101
+ )
102
+
103
+ _decoder = _utils.get_environment.map(JobDefDecoder)
104
+ cmd: Cmd[None] = _decoder.bind(
105
+ lambda decoder: _decode_json(job)
106
+ .map(lambda r: r.bind(decoder.decode_job))
107
+ .map(lambda r: r.alt(Unsafe.raise_exception).to_union())
108
+ .bind(_execute)
109
+ )
110
+ cmd.compute()
111
+
112
+
113
+ @click.command()
114
+ @click.option(
115
+ "--pipeline", required=True, help="json encoded str defining a JobRequest"
116
+ )
117
+ @click.option("--dry-run", is_flag=True)
118
+ def submit_pipeline(
119
+ pipeline: str,
120
+ dry_run: bool,
121
+ ) -> NoReturn:
122
+ def _execute(job_pipeline: JobPipeline) -> Cmd[UnitType]:
123
+ if dry_run:
124
+ msg = Cmd.wrap_impure(
125
+ lambda: LOG.info(
126
+ "[dry-run] A batch pipeline will be sent",
127
+ )
128
+ )
129
+
130
+ msgs = NewFrozenList(tuple(enumerate(job_pipeline.jobs, start=1))).map(
131
+ lambda j: Cmd.wrap_impure(
132
+ lambda: LOG.info(
133
+ "[dry-run] [pipeline-job-#%s] this job will be sent: %s",
134
+ j[0],
135
+ j[1],
136
+ )
137
+ )
138
+ )
139
+ jobs_msgs = (
140
+ PureIterFactory.from_list(msgs.items)
141
+ .transform(PureIterTransform.consume)
142
+ .map(lambda _: unit)
143
+ )
144
+ return msg + jobs_msgs
145
+ return (
146
+ new_client()
147
+ .map(new_sender)
148
+ .bind(
149
+ lambda s: s.send_pipeline(
150
+ PureIterFactory.from_list(job_pipeline.jobs.items)
151
+ )
152
+ )
153
+ )
154
+
155
+ _decoder = _utils.get_environment.map(JobDefDecoder)
156
+ cmd: Cmd[UnitType] = _decoder.bind(
157
+ lambda decoder: _decode_json_list(pipeline)
158
+ .map(lambda r: r.map(NewFrozenList).bind(decoder.decode_pipeline))
159
+ .map(lambda r: r.alt(Unsafe.raise_exception).to_union())
160
+ .bind(_execute)
161
+ )
162
+ cmd.compute()
163
+
164
+
165
+ @click.group()
166
+ def main() -> None:
167
+ pass
168
+
169
+
170
+ main.add_command(submit_job)
171
+ main.add_command(submit_pipeline)
@@ -0,0 +1,17 @@
1
+ from fa_purity import (
2
+ Cmd,
3
+ )
4
+ import logging
5
+ import sys
6
+
7
+
8
+ def setup_logger(name: str) -> Cmd[None]:
9
+ def _action() -> None:
10
+ handler = logging.StreamHandler(sys.stderr)
11
+ formatter = logging.Formatter("[%(levelname)s] %(message)s")
12
+ handler.setFormatter(formatter)
13
+ log = logging.getLogger(name)
14
+ log.addHandler(handler)
15
+ log.setLevel(logging.INFO)
16
+
17
+ return Cmd.wrap_impure(_action)
@@ -0,0 +1,102 @@
1
+ from __future__ import (
2
+ annotations,
3
+ )
4
+
5
+ from collections.abc import (
6
+ Callable,
7
+ )
8
+ from dataclasses import (
9
+ dataclass,
10
+ )
11
+ from fa_purity import (
12
+ Cmd,
13
+ FrozenDict,
14
+ FrozenList,
15
+ Maybe,
16
+ PureIter,
17
+ Result,
18
+ ResultE,
19
+ Coproduct,
20
+ CoproductFactory,
21
+ Unsafe,
22
+ )
23
+ from typing import (
24
+ NoReturn,
25
+ TypeVar,
26
+ )
27
+ import os
28
+
29
+ _T = TypeVar("_T")
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class LibraryBug(Exception):
34
+ traceback: Exception
35
+
36
+ def __str__(self) -> str:
37
+ return (
38
+ "If raised then there is a bug in the `fluidattacks_batch_client` library"
39
+ )
40
+
41
+
42
+ def str_to_int(raw: str) -> ResultE[int]:
43
+ try:
44
+ return Result.success(int(raw))
45
+ except ValueError as err:
46
+ return Result.failure(Exception(err))
47
+
48
+
49
+ def int_to_str(item: int) -> str:
50
+ return str(item)
51
+
52
+
53
+ def handle_value_error(transform: Callable[[], _T | NoReturn]) -> ResultE[_T]:
54
+ try:
55
+ return Result.success(transform())
56
+ except ValueError as err:
57
+ return Result.failure(Exception(err))
58
+
59
+
60
+ def handle_key_error(transform: Callable[[], _T | NoReturn]) -> ResultE[_T]:
61
+ try:
62
+ return Result.success(transform())
63
+ except KeyError as err:
64
+ return Result.failure(Exception(err))
65
+
66
+
67
+ def handle_index_error(transform: Callable[[], _T | NoReturn]) -> ResultE[_T]:
68
+ try:
69
+ return Result.success(transform())
70
+ except IndexError as err:
71
+ return Result.failure(Exception(err))
72
+
73
+
74
+ def get_index(items: FrozenList[_T], index: int) -> Maybe[_T]:
75
+ return Maybe.from_result(
76
+ handle_index_error(lambda: items[index]).alt(lambda _: None)
77
+ )
78
+
79
+
80
+ def extract_single(items: PureIter[_T]) -> Coproduct[_T, PureIter[_T]]:
81
+ _factory: CoproductFactory[_T, PureIter[_T]] = CoproductFactory()
82
+ single_element = (
83
+ items.enumerate(1)
84
+ .find_first(lambda t: t[0] >= 2)
85
+ .map(lambda _: False)
86
+ .value_or(True)
87
+ )
88
+ if single_element:
89
+ return _factory.inl(
90
+ items.enumerate(1)
91
+ .find_first(lambda t: t[0] == 1)
92
+ .to_result()
93
+ .alt(lambda _: LibraryBug(Exception("no first element")))
94
+ .alt(Unsafe.raise_exception)
95
+ .to_union()[1]
96
+ )
97
+ return _factory.inr(items)
98
+
99
+
100
+ get_environment = Cmd[FrozenDict[str, str]].wrap_impure(
101
+ lambda: FrozenDict(dict(os.environ))
102
+ )
@@ -0,0 +1,11 @@
1
+ from ._client_1 import (
2
+ new_client,
3
+ )
4
+ from ._core import (
5
+ ApiClient,
6
+ )
7
+
8
+ __all__ = [
9
+ "ApiClient",
10
+ "new_client",
11
+ ]
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+ from . import (
4
+ _list_jobs,
5
+ _send_job,
6
+ _get_command,
7
+ )
8
+ from fluidattacks_batch_client.api._core import (
9
+ ApiClient,
10
+ )
11
+ import boto3
12
+ from fa_purity import (
13
+ Cmd,
14
+ )
15
+
16
+ if TYPE_CHECKING:
17
+ from mypy_boto3_batch.client import (
18
+ BatchClient,
19
+ )
20
+
21
+
22
+ def _new_batch_client() -> Cmd[BatchClient]:
23
+ def _action() -> BatchClient:
24
+ return boto3.client("batch")
25
+
26
+ return Cmd.wrap_impure(_action)
27
+
28
+
29
+ def new_client() -> Cmd[ApiClient]:
30
+ return _new_batch_client().map(
31
+ lambda client: ApiClient(
32
+ lambda n, q, s: _list_jobs.list_jobs(client, n, q, s),
33
+ lambda j: _send_job.send_job(client, j),
34
+ lambda j: _get_command.get_command(client, j),
35
+ )
36
+ )
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING, TypeVar
3
+
4
+ from mypy_boto3_batch.type_defs import (
5
+ ContainerPropertiesOutputTypeDef,
6
+ JobDefinitionTypeDef,
7
+ )
8
+ from fluidattacks_batch_client.core import (
9
+ Command,
10
+ JobDefinitionName,
11
+ )
12
+ from fa_purity import (
13
+ Cmd,
14
+ FrozenList,
15
+ Maybe,
16
+ Result,
17
+ ResultE,
18
+ cast_exception,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from mypy_boto3_batch.client import (
23
+ BatchClient,
24
+ )
25
+
26
+ _T = TypeVar("_T")
27
+
28
+
29
+ def _decode_command(raw: JobDefinitionTypeDef) -> Maybe[Command]:
30
+ props: Maybe[ContainerPropertiesOutputTypeDef] = Maybe.from_optional(
31
+ raw.get("containerProperties")
32
+ )
33
+ return props.bind_optional(lambda c: c.get("command")).map(
34
+ lambda c: Command(tuple(c))
35
+ )
36
+
37
+
38
+ def _assert_one(items: FrozenList[_T]) -> ResultE[_T]:
39
+ if len(items) == 0:
40
+ return Result.failure(ValueError("list does not have elements"))
41
+ if len(items) > 1:
42
+ return Result.failure(ValueError("list has more than one element"))
43
+ return Result.success(items[0])
44
+
45
+
46
+ def get_command(
47
+ client: BatchClient,
48
+ job_def: JobDefinitionName,
49
+ ) -> Cmd[ResultE[Maybe[Command]]]:
50
+ def _action() -> ResultE[Maybe[Command]]:
51
+ try:
52
+ result = client.describe_job_definitions(
53
+ jobDefinitionName=job_def.raw, status="ACTIVE"
54
+ )
55
+ except Exception as e:
56
+ return Result.failure(e)
57
+ return (
58
+ _assert_one(tuple(result["jobDefinitions"]))
59
+ .alt(
60
+ lambda e: ValueError(
61
+ f"Could not determine the current active job definition i.e. {e}"
62
+ )
63
+ )
64
+ .alt(cast_exception)
65
+ .map(_decode_command)
66
+ )
67
+
68
+ return Cmd.wrap_impure(_action)
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+ from fluidattacks_batch_client import (
4
+ _utils,
5
+ )
6
+ from fluidattacks_batch_client.core import (
7
+ BatchJob,
8
+ BatchJobObj,
9
+ JobArn,
10
+ JobId,
11
+ JobName,
12
+ JobStatus,
13
+ QueueName,
14
+ )
15
+ from dataclasses import (
16
+ dataclass,
17
+ )
18
+ from fa_purity import (
19
+ Cmd,
20
+ FrozenList,
21
+ Maybe,
22
+ ResultE,
23
+ Stream,
24
+ PureIterFactory,
25
+ StreamFactory,
26
+ StreamTransform,
27
+ ResultTransform,
28
+ Unsafe,
29
+ )
30
+
31
+ if TYPE_CHECKING:
32
+ from mypy_boto3_batch.client import (
33
+ BatchClient,
34
+ )
35
+ from mypy_boto3_batch.type_defs import (
36
+ JobSummaryTypeDef,
37
+ KeyValuesPairTypeDef,
38
+ ListJobsResponseTypeDef,
39
+ )
40
+
41
+
42
+ def _decode_job(raw: JobSummaryTypeDef) -> ResultE[BatchJob]:
43
+ def _inner() -> ResultE[BatchJob]:
44
+ return JobStatus.to_status(raw["status"]).map(
45
+ lambda status: BatchJob(
46
+ raw["createdAt"],
47
+ status,
48
+ Maybe.from_optional(raw.get("statusReason")),
49
+ Maybe.from_optional(raw.get("startedAt")),
50
+ Maybe.from_optional(raw.get("stoppedAt")),
51
+ )
52
+ )
53
+
54
+ return _utils.handle_key_error(_inner).bind(lambda x: x)
55
+
56
+
57
+ def _decode_job_obj(
58
+ raw: JobSummaryTypeDef,
59
+ ) -> ResultE[BatchJobObj]:
60
+ def _inner() -> ResultE[BatchJobObj]:
61
+ _arn = JobArn(raw["jobArn"])
62
+ _id = JobId(raw["jobId"])
63
+ _name = JobName.new(raw["jobName"])
64
+ return _name.bind(
65
+ lambda name: _decode_job(raw).map(lambda j: BatchJobObj(_id, _arn, name, j))
66
+ )
67
+
68
+ return _utils.handle_key_error(_inner).bind(lambda x: x)
69
+
70
+
71
+ @dataclass
72
+ class JobsPage:
73
+ items: FrozenList[BatchJobObj]
74
+ next_item: Maybe[str]
75
+
76
+
77
+ def _decode_respose(response: ListJobsResponseTypeDef) -> ResultE[JobsPage]:
78
+ def _inner() -> ResultE[JobsPage]:
79
+ items = PureIterFactory.from_list(response["jobSummaryList"]).map(
80
+ _decode_job_obj
81
+ )
82
+ _next = Maybe.from_optional(response.get("nextToken"))
83
+ return ResultTransform.all_ok(items.to_list()).map(lambda i: JobsPage(i, _next))
84
+
85
+ return _utils.handle_key_error(_inner).bind(lambda x: x)
86
+
87
+
88
+ def _list_jobs_page(
89
+ client: BatchClient,
90
+ queue: QueueName,
91
+ name: JobName,
92
+ _next: Maybe[str],
93
+ ) -> Cmd[JobsPage]:
94
+ def _action() -> JobsPage:
95
+ _filter: FrozenList[KeyValuesPairTypeDef] = (
96
+ {"name": "JOB_NAME", "values": [name.raw]},
97
+ )
98
+ result = _next.map(
99
+ lambda n: client.list_jobs(jobQueue=queue.raw, filters=_filter, nextToken=n)
100
+ ).or_else_call(lambda: client.list_jobs(jobQueue=queue.raw, filters=_filter))
101
+ return _decode_respose(result).alt(Unsafe.raise_exception).to_union()
102
+
103
+ return Cmd.wrap_impure(_action)
104
+
105
+
106
+ def list_jobs(
107
+ client: BatchClient,
108
+ name: JobName,
109
+ queue: QueueName,
110
+ status: frozenset[JobStatus],
111
+ ) -> Stream[BatchJobObj]:
112
+ def _extract(page: JobsPage) -> Maybe[Maybe[str]]:
113
+ return page.next_item.map(lambda n: Maybe.some(n))
114
+
115
+ def _cmd(index: Maybe[str]) -> Cmd[JobsPage]:
116
+ return _list_jobs_page(client, queue, name, index)
117
+
118
+ return (
119
+ StreamFactory.generate(_cmd, _extract, Maybe.empty(str))
120
+ .map(lambda j: PureIterFactory.from_list(j.items))
121
+ .transform(lambda x: StreamTransform.chain(x))
122
+ .filter(lambda j: j.job.status in status)
123
+ )