substantial 0.0.1__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.
pyproject.toml ADDED
@@ -0,0 +1,51 @@
1
+ [tool.poetry]
2
+ name = "substantial"
3
+ version = "0.0.1"
4
+ description = "Brokerless durable execution for Python."
5
+ authors = []
6
+ license = "MPL-2.0"
7
+ readme = "README.md"
8
+ include = [
9
+ "dataconf", "pyproject.toml"
10
+ ]
11
+ classifiers = [
12
+ "Topic :: Software Development :: Libraries :: Python Modules",
13
+ "Programming Language :: Python",
14
+ "Programming Language :: Python :: 3.9",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12"
18
+ ]
19
+ keywords = [
20
+ "durable",
21
+ "execution",
22
+ "redis",
23
+ "s3",
24
+ "filesystem",
25
+ ]
26
+
27
+ [tool.poetry.dependencies]
28
+ python = "^3.9"
29
+ asyncio = "^3.4.3"
30
+ uvloop = "^0.21.0"
31
+ aioprocessing = { version = "2.0.1", extras = ["dill"] }
32
+ pydantic = "^2.9.2"
33
+ redis = "^5.2.0"
34
+ betterproto = { version = "2.0.0b7", extras = ["compiler"]}
35
+
36
+ [tool.poetry.group.dev.dependencies]
37
+ pytest = "^8.3.3"
38
+ pytest-asyncio = "^0.23.7"
39
+ commitizen = ">=3.29.0"
40
+
41
+ [tool.commitizen]
42
+ name = "cz_conventional_commits"
43
+ version = "3.3.0"
44
+ tag_format = "v$major.$minor.$patch$prerelease"
45
+ version_files = [
46
+ "pyproject.toml:version",
47
+ ]
48
+
49
+ [build-system]
50
+ requires = ["poetry-core"]
51
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,14 @@
1
+ from substantial.workflows.workflow import workflow, Workflow
2
+ from substantial.workflows.context import Context
3
+ from substantial.workflows.run import Run
4
+ from substantial.conductor import Conductor
5
+ from substantial.backends.fs import FSBackend
6
+
7
+ __all__ = [
8
+ "workflow",
9
+ "Workflow",
10
+ "Context",
11
+ "Conductor",
12
+ "Run",
13
+ "FSBackend",
14
+ ]
substantial/agent.py ADDED
@@ -0,0 +1,70 @@
1
+ import asyncio
2
+ from contextlib import suppress
3
+ from substantial.backends.backend import Backend
4
+ import uvloop
5
+
6
+ from substantial.workflows.run import Run
7
+
8
+
9
+ # move to backend?
10
+ lease_seconds = 10
11
+ renew_seconds = 8
12
+ pool_interval = 1
13
+
14
+
15
+ class Agent:
16
+ def __init__(self, backend: Backend, queue: str):
17
+ self.backend = backend
18
+ self.queue = queue
19
+
20
+ def run_sync(self):
21
+ with asyncio.Runner(loop_factory=uvloop.new_event_loop) as runner:
22
+ runner.run(self.run())
23
+
24
+ async def run(self):
25
+ while True:
26
+ await self.poll_with_lease()
27
+ await asyncio.sleep(pool_interval)
28
+
29
+ async def poll_with_lease(self):
30
+ active_leases = await self.backend.active_leases(lease_seconds)
31
+ next_run = await self.backend.next_run(self.queue, active_leases)
32
+ print("run_id", next_run)
33
+
34
+ if next_run is None:
35
+ print("no runs")
36
+ await asyncio.sleep(1)
37
+ return
38
+
39
+ run_id, schedule = next_run
40
+
41
+ async def heartbeat():
42
+ while True:
43
+ await asyncio.sleep(renew_seconds)
44
+ renewed = await self.backend.renew_lease(run_id, lease_seconds)
45
+ if not renewed:
46
+ return
47
+
48
+ leased = await self.backend.acquire_lease(run_id, lease_seconds)
49
+ if not leased:
50
+ return
51
+
52
+ renew_task = asyncio.create_task(heartbeat())
53
+ run = Run(run_id, self.queue, self.backend)
54
+ process_task = asyncio.create_task(run.replay(schedule))
55
+
56
+ done, pending = await asyncio.wait(
57
+ [
58
+ renew_task,
59
+ process_task,
60
+ ],
61
+ return_when=asyncio.FIRST_COMPLETED,
62
+ )
63
+ done = done.pop()
64
+ pending = pending.pop()
65
+
66
+ pending.cancel()
67
+ with suppress(asyncio.CancelledError):
68
+ await pending
69
+
70
+ await self.backend.remove_lease(run_id, lease_seconds)
@@ -0,0 +1,54 @@
1
+ from datetime import datetime
2
+ from typing import List, Tuple, Union
3
+ from substantial.protos.events import Event, Records
4
+ from substantial.protos.metadata import Metadata
5
+
6
+
7
+ class Backend:
8
+ # run related
9
+
10
+ async def read_events(self, run_id: str) -> Union[Records, None]:
11
+ raise NotImplementedError()
12
+
13
+ async def write_events(self, run_id: str, content: Records) -> None:
14
+ raise NotImplementedError()
15
+
16
+ async def read_all_metadata(self, run_id: str) -> List[Metadata]:
17
+ raise NotImplementedError()
18
+
19
+ async def append_metadata(
20
+ self, run_id: str, schedule: datetime, content: str
21
+ ) -> None:
22
+ raise NotImplementedError()
23
+
24
+ async def add_schedule(
25
+ self, queue: str, run_id: str, schedule: datetime, content: Union[Event, None]
26
+ ) -> None:
27
+ raise NotImplementedError()
28
+
29
+ async def read_schedule(
30
+ self, queue: str, run_id: str, schedule: datetime
31
+ ) -> Union[Event, None]:
32
+ raise NotImplementedError()
33
+
34
+ async def close_schedule(self, queue: str, run_id: str, schedule: datetime) -> None:
35
+ raise NotImplementedError()
36
+
37
+ # agent related
38
+
39
+ async def next_run(
40
+ self, queue: str, excludes: list[str]
41
+ ) -> Union[Tuple[str, datetime], None]:
42
+ raise NotImplementedError()
43
+
44
+ async def active_leases(self, lease_seconds: int) -> List[str]:
45
+ raise NotImplementedError()
46
+
47
+ async def acquire_lease(self, run_id: str, lease_seconds: int) -> bool:
48
+ raise NotImplementedError()
49
+
50
+ async def renew_lease(self, run_id: str, lease_seconds: int) -> bool:
51
+ raise NotImplementedError()
52
+
53
+ async def remove_lease(self, run_id: str, lease_seconds: int) -> None:
54
+ raise NotImplementedError()
@@ -0,0 +1,160 @@
1
+ from datetime import datetime, timedelta
2
+ from pathlib import Path
3
+ from typing import List, Tuple, Union
4
+ from uuid import uuid4
5
+ from substantial.backends.backend import Backend
6
+ from substantial.protos.events import Event, Records
7
+
8
+
9
+ class FSBackend(Backend):
10
+ """
11
+ This backend is for testing purposes only as it is not scalable:
12
+ - there is no file order in POSIX
13
+ - all the runs need to be loaded in memory
14
+ - not all required operations are atomic or using compare-and-swap
15
+ """
16
+
17
+ def __init__(self, root: str):
18
+ self.root = Path(root)
19
+ for d in ["runs", "schedules", "leases"]:
20
+ (self.root / d).mkdir(parents=True, exist_ok=True)
21
+
22
+ async def read_events(self, run_id: str) -> Union[Records, None]:
23
+ f = self.root / "runs" / run_id / "events"
24
+ if not f.exists():
25
+ return None
26
+ return Records().from_json(f.read_text())
27
+
28
+ async def write_events(self, run_id: str, content: Records) -> None:
29
+ f = self.root / "runs" / run_id / "events"
30
+ f.parent.mkdir(parents=True, exist_ok=True)
31
+ f.write_text(content.to_json(indent=4))
32
+
33
+ async def read_all_metadata(self, run_id: str) -> List[str]:
34
+ f = self.root / "runs" / run_id / "logs"
35
+ ret = []
36
+ for log in f.iterdir():
37
+ # could be polymorphic
38
+ ret.append(log.read_text())
39
+ return ret
40
+
41
+ async def append_metadata(self, run_id: str, schedule: datetime, content: str):
42
+ f = self.root / "runs" / run_id / "logs" / schedule.isoformat()
43
+ f.parent.mkdir(parents=True, exist_ok=True)
44
+ f.write_text(content)
45
+
46
+ async def next_run(
47
+ self, queue: str, excludes: list[str]
48
+ ) -> Union[Tuple[str, datetime], None]:
49
+ f = self.root / "schedules" / queue
50
+ excludes_set = set(excludes) # Note: lease related
51
+
52
+ for schedule in sorted(f.iterdir()):
53
+ for run_id in schedule.iterdir():
54
+ if run_id.name not in excludes_set:
55
+ return run_id.name, datetime.fromisoformat(schedule.name)
56
+
57
+ return None
58
+
59
+ async def add_schedule(
60
+ self, queue: str, run_id: str, schedule: datetime, content: Union[Event, None]
61
+ ) -> None:
62
+ q = self.root / "schedules" / queue
63
+
64
+ if q.exists():
65
+ # Note: new schedule should always overwrite replays (but not scheduled events)
66
+ # This is for keeping the run consistent,
67
+ # Case 1: ======= r1 == ev1 == r2 ====== r3 ========>
68
+ # x r4
69
+ # Result: ============== ev1 = r4 ====== r3 ========>
70
+ # Rationale being that the scheduled replays are often triggered by Interrupts and such
71
+ # for more sane runs, fuse planned replays (induced by add_schedule calls)?
72
+
73
+ for sched in sorted(q.iterdir()):
74
+ for planned in sched.iterdir():
75
+ planned_date = datetime.fromisoformat(sched.name)
76
+ if planned.name == run_id and planned_date <= schedule:
77
+ event = await self.read_schedule(queue, run_id, planned_date)
78
+ if event is None: # event => None == scheduled replays..
79
+ await self.close_schedule(queue, run_id, planned_date)
80
+
81
+ f1 = q / schedule.isoformat() / run_id
82
+ f1.parent.mkdir(parents=True, exist_ok=False)
83
+ f1.write_text("" if content is None else content.to_json())
84
+
85
+ async def read_schedule(
86
+ self, queue: str, run_id: str, schedule: datetime
87
+ ) -> Union[Event, None]:
88
+ f = self.root / "schedules" / queue / schedule.isoformat() / run_id
89
+ if not f.exists():
90
+ raise Exception(f"run not found: {f}")
91
+ ret = f.read_text()
92
+ return None if ret == "" else Event().from_json(ret)
93
+
94
+ async def close_schedule(self, queue: str, run_id: str, schedule: datetime) -> None:
95
+ f = self.root / "schedules" / queue / schedule.isoformat() / run_id
96
+ if not f.exists():
97
+ # raise Exception(f"run not found: {f}")
98
+ return
99
+ print(f"closed {f}")
100
+ f.unlink()
101
+
102
+ async def active_leases(self, lease_seconds: int) -> List[str]:
103
+ f = self.root / "leases"
104
+ ret = []
105
+ for lease in f.iterdir():
106
+ if not lease_held(lease, lease_seconds):
107
+ continue
108
+ ret.append(lease.name)
109
+ return ret
110
+
111
+ async def acquire_lease(self, run_id: str, lease_seconds: int) -> bool:
112
+ f = self.root / "leases" / run_id
113
+ return leasing_cas(f, "acquire", lease_seconds)
114
+
115
+ async def renew_lease(self, run_id: str, lease_seconds: int) -> bool:
116
+ f = self.root / "leases" / run_id
117
+
118
+ if not f.exists():
119
+ raise Exception(f"lease not found: {f}")
120
+
121
+ return leasing_cas(f, "renew", lease_seconds)
122
+
123
+ async def remove_lease(self, run_id: str, lease_seconds: int):
124
+ f = self.root / "leases" / run_id
125
+
126
+ if not f.exists():
127
+ raise Exception(f"lease not found: {f}")
128
+
129
+ still_holding = leasing_cas(f, "remove", lease_seconds)
130
+ if still_holding:
131
+ f.unlink()
132
+
133
+
134
+ def leasing_cas(f: Path, suffix: str, lease_seconds: int) -> bool:
135
+ if lease_held(f, lease_seconds):
136
+ return False
137
+
138
+ return cas(f, suffix)
139
+
140
+
141
+ def lease_held(f: Path, lease_seconds: int) -> bool:
142
+ return (
143
+ f.exists()
144
+ and datetime.fromtimestamp(f.stat().st_mtime) + timedelta(seconds=lease_seconds)
145
+ > datetime.now()
146
+ )
147
+
148
+
149
+ def cas(f: Path, suffix: str) -> bool:
150
+ nonce = str(uuid4())
151
+ witness = f.parent / f"{f.name}.{suffix}"
152
+ witness.write_text(nonce)
153
+
154
+ try:
155
+ witness.rename(f)
156
+ return f.read_text() == nonce
157
+ except FileExistsError:
158
+ # another rename has already happened
159
+ witness.unlink()
160
+ return False
@@ -0,0 +1,181 @@
1
+ from datetime import datetime, timedelta
2
+ from pathlib import Path
3
+ from typing import List, Tuple, Union
4
+ from uuid import uuid4
5
+
6
+ import redis
7
+ from substantial.backends.backend import Backend
8
+ from substantial.protos.events import Event, Records
9
+
10
+
11
+ class RedisBackend(Backend):
12
+ def __init__(self, host: str, port: int, **kwargs):
13
+ self.redis = redis.Redis(host=host, port=port, decode_responses=True, **kwargs)
14
+ # self.redis.flushall()
15
+
16
+ async def read_events(self, run_id: str) -> Union[Records, None]:
17
+ key = "_".join(["runs", run_id, "events"])
18
+ val = self.redis.get(key)
19
+ return None if val is None else Records().from_json(val)
20
+
21
+ async def write_events(self, run_id: str, content: Records) -> None:
22
+ key = "_".join(["runs", run_id, "events"])
23
+ self.redis.set(key, content.to_json(indent=4))
24
+
25
+ async def read_all_metadata(self, run_id: str) -> List[str]:
26
+ base_key = "_".join(["runs", run_id, "logs"]) # queue
27
+ ret = []
28
+ for schedule in self.redis.lrange(base_key, 0, -1):
29
+ log = self.redis.get("_".join[run_id, schedule])
30
+ # could be polymorphic
31
+ ret.append(log)
32
+ return ret
33
+
34
+ async def append_metadata(self, run_id: str, schedule: datetime, content: str):
35
+ base_key = "_".join(["runs", run_id, "logs"]) # queue
36
+ sched_key = "_".join([run_id, schedule.isoformat()])
37
+
38
+ self.redis.register_script("""
39
+ local base_key = KEYS[1]
40
+ local sched_key = KEYS[2]
41
+ local content = ARGV[1]
42
+
43
+ redis.call("LPUSH", base_key, sched_key)
44
+ redis.call("ZREM", sched_key, content)
45
+ """)(keys=[base_key, sched_key], args=[content])
46
+
47
+ async def next_run(
48
+ self, queue: str, excludes: list[str]
49
+ ) -> Union[Tuple[str, datetime], None]:
50
+ q_key = "_".join(["schedules", queue]) # priority queue
51
+ excludes_set = set(excludes)
52
+
53
+ for schedule in self.redis.zrange(q_key, 0, -1):
54
+ for run_id in self.redis.zrange(schedule, 0, -1):
55
+ if run_id not in excludes_set:
56
+ return run_id, datetime.fromisoformat(schedule)
57
+
58
+ return None
59
+
60
+ async def add_schedule(
61
+ self, queue: str, run_id: str, schedule: datetime, content: Union[Event, None]
62
+ ) -> None:
63
+ q_key = "_".join(["schedules", queue]) # priority queue
64
+
65
+ # avoid having a bunch of replays happening
66
+ for sched_ref in self.redis.zrange(q_key, 0, -1):
67
+ for planned_key in self.redis.zrange(sched_ref, 0, -1):
68
+ _dt, planned_id = planned_key.split("_")
69
+ planned_date = datetime.fromisoformat(sched_ref)
70
+ if planned_id == run_id and planned_date <= schedule:
71
+ event = await self.read_schedule(queue, run_id, planned_date)
72
+ if event is None: # event => None == scheduled replays..
73
+ await self.close_schedule(queue, run_id, planned_date)
74
+
75
+ sched_ref = schedule.isoformat()
76
+ sched_score = 1 / schedule.timestamp()
77
+ sched_key = "_".join([sched_ref, run_id])
78
+
79
+ self.redis.register_script("""
80
+ local q_key = KEYS[1]
81
+ local sched_ref = KEYS[2]
82
+ local sched_key = KEYS[3]
83
+ local sched_score = tonumber(ARGV[1])
84
+ local run_id = ARGV[2]
85
+ local content = ARGV[3]
86
+
87
+ redis.call("ZADD", q_key, 0, sched_ref)
88
+ redis.call("ZADD", sched_ref, sched_score, run_id)
89
+ redis.call("SET", sched_key, content)
90
+ """)(
91
+ keys=[q_key, sched_ref, sched_key],
92
+ args=[sched_score, run_id, "" if content is None else content.to_json()],
93
+ )
94
+
95
+ async def read_schedule(
96
+ self, queue: str, run_id: str, schedule: datetime
97
+ ) -> Union[Event, None]:
98
+ sched_key = "_".join([schedule.isoformat(), run_id])
99
+ ret = self.redis.get(sched_key)
100
+ if ret is None:
101
+ raise Exception(f"run not found: {sched_key}")
102
+ return None if ret == "" else Event().from_json(ret)
103
+
104
+ async def close_schedule(self, queue: str, run_id: str, schedule: datetime) -> None:
105
+ q_key = "_".join(["schedules", queue])
106
+ sched_ref = schedule.isoformat()
107
+ sched_key = "_".join([sched_ref, run_id])
108
+
109
+ self.redis.register_script("""
110
+ local q_key = KEYS[1]
111
+ local sched_ref = KEYS[2]
112
+ local sched_key = KEYS[3]
113
+ local run_id = ARGV[1]
114
+
115
+ redis.call("ZREM", q_key, sched_ref)
116
+ redis.call("ZREM", sched_ref, run_id)
117
+ redis.call("DEL", sched_key)
118
+ """)(keys=[q_key, sched_ref, sched_key], args=[run_id])
119
+
120
+ print(f"closed {run_id}")
121
+
122
+ # TODO
123
+ async def active_leases(self, lease_seconds: int) -> List[str]:
124
+ f = Path("logs") / "leases"
125
+ ret = []
126
+ for lease in f.iterdir():
127
+ if not lease_held(lease, lease_seconds):
128
+ continue
129
+ ret.append(lease.name)
130
+ return ret
131
+
132
+ async def acquire_lease(self, run_id: str, lease_seconds: int) -> bool:
133
+ f = Path("logs") / "leases" / run_id
134
+ return leasing_cas(f, "acquire", lease_seconds)
135
+
136
+ async def renew_lease(self, run_id: str, lease_seconds: int) -> bool:
137
+ f = Path("logs") / "leases" / run_id
138
+
139
+ if not f.exists():
140
+ raise Exception(f"lease not found: {f}")
141
+
142
+ return leasing_cas(f, "renew", lease_seconds)
143
+
144
+ async def remove_lease(self, run_id: str, lease_seconds: int):
145
+ f = Path("logs") / "leases" / run_id
146
+
147
+ if not f.exists():
148
+ raise Exception(f"lease not found: {f}")
149
+
150
+ still_holding = leasing_cas(f, "remove", lease_seconds)
151
+ if still_holding:
152
+ f.unlink()
153
+
154
+
155
+ def leasing_cas(f: Path, suffix: str, lease_seconds: int) -> bool:
156
+ if lease_held(f, lease_seconds):
157
+ return False
158
+
159
+ return cas(f, suffix)
160
+
161
+
162
+ def lease_held(f: Path, lease_seconds: int) -> bool:
163
+ return (
164
+ f.exists()
165
+ and datetime.fromtimestamp(f.stat().st_mtime) + timedelta(seconds=lease_seconds)
166
+ > datetime.now()
167
+ )
168
+
169
+
170
+ def cas(f: Path, suffix: str) -> bool:
171
+ nonce = str(uuid4())
172
+ witness = f.parent / f"{f.name}.{suffix}"
173
+ witness.write_text(nonce)
174
+
175
+ try:
176
+ witness.rename(f)
177
+ return f.read_text() == nonce
178
+ except FileExistsError:
179
+ # another rename has already happened
180
+ witness.unlink()
181
+ return False
@@ -0,0 +1,50 @@
1
+ import asyncio
2
+ from uuid import uuid4
3
+
4
+ from substantial.agent import Agent
5
+ from substantial.backends.backend import Backend
6
+ from substantial.workflows import Store
7
+ from substantial.workflows.run import Run
8
+ from substantial.workflows.workflow import Workflow
9
+ import aioprocessing
10
+
11
+
12
+ class Conductor:
13
+ def __init__(self, backend: Backend):
14
+ self.backend = backend
15
+
16
+ def register(self, workflow: Workflow):
17
+ Store.register(workflow)
18
+
19
+ async def start(
20
+ self,
21
+ workflow: Workflow,
22
+ kwargs=None,
23
+ queue="default",
24
+ ):
25
+ if kwargs is None:
26
+ kwargs = {}
27
+
28
+ run_id = f"{workflow.id}-{uuid4()}"
29
+ run = Run(run_id, queue, self.backend)
30
+ await run.start(kwargs)
31
+ return run
32
+
33
+ def run_for(self, run_id, queue="default"):
34
+ return Run(run_id, queue, self.backend)
35
+
36
+ def run(self, queue="default"):
37
+ return asyncio.create_task(self.run_as_process(queue))
38
+
39
+ async def run_as_process(self, queue):
40
+ agent = Agent(self.backend, queue)
41
+ p = aioprocessing.AioProcess(target=agent.run_sync)
42
+ try:
43
+ p.start()
44
+ await p.coro_join()
45
+ return p
46
+ except asyncio.CancelledError:
47
+ p.terminate()
48
+ print("Cancelled")
49
+ except Exception:
50
+ raise
File without changes
@@ -0,0 +1,58 @@
1
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
2
+ # sources: protocol/events.proto
3
+ # plugin: python-betterproto
4
+ # This file has been @generated
5
+
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import List
9
+
10
+ import betterproto
11
+ import betterproto.lib.google.protobuf as betterproto_lib_google_protobuf
12
+
13
+
14
+ @dataclass(eq=False, repr=False)
15
+ class Start(betterproto.Message):
16
+ kwargs: "betterproto_lib_google_protobuf.Struct" = betterproto.message_field(1)
17
+
18
+
19
+ @dataclass(eq=False, repr=False)
20
+ class Save(betterproto.Message):
21
+ id: int = betterproto.uint32_field(1)
22
+ value: str = betterproto.string_field(2)
23
+ counter: int = betterproto.int32_field(3)
24
+
25
+
26
+ @dataclass(eq=False, repr=False)
27
+ class Sleep(betterproto.Message):
28
+ id: int = betterproto.uint32_field(1)
29
+ start: datetime = betterproto.message_field(2)
30
+ end: datetime = betterproto.message_field(3)
31
+
32
+
33
+ @dataclass(eq=False, repr=False)
34
+ class Send(betterproto.Message):
35
+ name: str = betterproto.string_field(1)
36
+ value: str = betterproto.string_field(2)
37
+
38
+
39
+ @dataclass(eq=False, repr=False)
40
+ class Stop(betterproto.Message):
41
+ ok: str = betterproto.string_field(1, group="result")
42
+ err: str = betterproto.string_field(2, group="result")
43
+
44
+
45
+ @dataclass(eq=False, repr=False)
46
+ class Event(betterproto.Message):
47
+ at: datetime = betterproto.message_field(1)
48
+ start: "Start" = betterproto.message_field(10, group="of")
49
+ save: "Save" = betterproto.message_field(11, group="of")
50
+ sleep: "Sleep" = betterproto.message_field(12, group="of")
51
+ send: "Send" = betterproto.message_field(13, group="of")
52
+ stop: "Stop" = betterproto.message_field(14, group="of")
53
+
54
+
55
+ @dataclass(eq=False, repr=False)
56
+ class Records(betterproto.Message):
57
+ run_id: str = betterproto.string_field(1)
58
+ events: List["Event"] = betterproto.message_field(2)
@@ -0,0 +1,35 @@
1
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
2
+ # sources: protocol/metadata.proto
3
+ # plugin: python-betterproto
4
+ # This file has been @generated
5
+
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import List
9
+
10
+ import betterproto
11
+
12
+
13
+ @dataclass(eq=False, repr=False)
14
+ class Info(betterproto.Message):
15
+ message: str = betterproto.string_field(1)
16
+
17
+
18
+ @dataclass(eq=False, repr=False)
19
+ class Error(betterproto.Message):
20
+ message: str = betterproto.string_field(1)
21
+ stack: str = betterproto.string_field(2)
22
+ type: str = betterproto.string_field(3)
23
+
24
+
25
+ @dataclass(eq=False, repr=False)
26
+ class Metadata(betterproto.Message):
27
+ at: datetime = betterproto.message_field(1)
28
+ info: "Info" = betterproto.message_field(10, group="of")
29
+ error: "Error" = betterproto.message_field(11, group="of")
30
+
31
+
32
+ @dataclass(eq=False, repr=False)
33
+ class Records(betterproto.Message):
34
+ run_id: str = betterproto.string_field(1)
35
+ metadata: List["Metadata"] = betterproto.message_field(2)