ecosemantic-core 0.1.8__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.
- ecosemantic/backends/filesystem/__init__.py +0 -0
- ecosemantic/backends/filesystem/activity.py +103 -0
- ecosemantic/backends/filesystem/installs.py +81 -0
- ecosemantic/backends/filesystem/py.typed +0 -0
- ecosemantic/backends/filesystem/records.py +183 -0
- ecosemantic/backends/filesystem/run_folder.py +55 -0
- ecosemantic/backends/filesystem/runs.py +95 -0
- ecosemantic/backends/filesystem/storage.py +267 -0
- ecosemantic/backends/filesystem/watching.py +45 -0
- ecosemantic/backends/native/__init__.py +0 -0
- ecosemantic/backends/native/calculation.py +446 -0
- ecosemantic/backends/native/catalog.py +287 -0
- ecosemantic/backends/native/install.py +126 -0
- ecosemantic/backends/native/inventory.py +197 -0
- ecosemantic/backends/native/py.typed +0 -0
- ecosemantic/backends/native/search.py +230 -0
- ecosemantic/backends/native/store.py +439 -0
- ecosemantic/backends/native/terms.py +43 -0
- ecosemantic/backends/retry.py +54 -0
- ecosemantic/compute/__init__.py +0 -0
- ecosemantic/compute/accounts.py +52 -0
- ecosemantic/compute/consequences.py +66 -0
- ecosemantic/compute/edits.py +564 -0
- ecosemantic/compute/links.py +170 -0
- ecosemantic/compute/montecarlo.py +110 -0
- ecosemantic/compute/overrides.py +80 -0
- ecosemantic/compute/parameters.py +155 -0
- ecosemantic/compute/py.typed +0 -0
- ecosemantic/compute/render.py +76 -0
- ecosemantic/compute/results.py +105 -0
- ecosemantic/events/__init__.py +267 -0
- ecosemantic/events/py.typed +0 -0
- ecosemantic/geometry/__init__.py +175 -0
- ecosemantic/geometry/py.typed +0 -0
- ecosemantic/layout/__init__.py +304 -0
- ecosemantic/layout/py.typed +0 -0
- ecosemantic/package/__init__.py +0 -0
- ecosemantic/package/background.py +510 -0
- ecosemantic/package/base.py +159 -0
- ecosemantic/package/base_writer.py +321 -0
- ecosemantic/package/compile.py +57 -0
- ecosemantic/package/gates.py +58 -0
- ecosemantic/package/jsonld.py +699 -0
- ecosemantic/package/log.py +165 -0
- ecosemantic/package/manifest.py +112 -0
- ecosemantic/package/model_package.py +164 -0
- ecosemantic/package/olca.py +135 -0
- ecosemantic/package/package.py +180 -0
- ecosemantic/package/py.typed +0 -0
- ecosemantic/package/release.py +266 -0
- ecosemantic/package/requests.py +55 -0
- ecosemantic/package/resolver.py +56 -0
- ecosemantic/package/sidecar.py +122 -0
- ecosemantic/package/wholeness.py +148 -0
- ecosemantic/package/working.py +275 -0
- ecosemantic/ports/__init__.py +0 -0
- ecosemantic/ports/activity.py +25 -0
- ecosemantic/ports/calculation.py +64 -0
- ecosemantic/ports/graph.py +14 -0
- ecosemantic/ports/install.py +37 -0
- ecosemantic/ports/inventory.py +41 -0
- ecosemantic/ports/py.typed +0 -0
- ecosemantic/ports/resolver.py +43 -0
- ecosemantic/ports/run.py +37 -0
- ecosemantic/ports/search.py +37 -0
- ecosemantic/ports/storage.py +76 -0
- ecosemantic/ports/types.py +938 -0
- ecosemantic/quantity/__init__.py +15 -0
- ecosemantic/quantity/py.typed +0 -0
- ecosemantic/routes/__init__.py +233 -0
- ecosemantic/routes/py.typed +0 -0
- ecosemantic/scaling/__init__.py +69 -0
- ecosemantic/scaling/py.typed +0 -0
- ecosemantic/tables/__init__.py +1 -0
- ecosemantic/tables/folder.py +147 -0
- ecosemantic/tables/py.typed +0 -0
- ecosemantic/tables/reader.py +259 -0
- ecosemantic/tables/results.py +328 -0
- ecosemantic/textmap/__init__.py +231 -0
- ecosemantic/textmap/py.typed +0 -0
- ecosemantic/troubles/__init__.py +188 -0
- ecosemantic/troubles/py.typed +0 -0
- ecosemantic_core-0.1.8.dist-info/METADATA +62 -0
- ecosemantic_core-0.1.8.dist-info/RECORD +87 -0
- ecosemantic_core-0.1.8.dist-info/WHEEL +4 -0
- ecosemantic_core-0.1.8.dist-info/licenses/LICENSE +73 -0
- ecosemantic_core-0.1.8.dist-info/licenses/NOTICE +13 -0
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The activity port over a directory: one JSON Lines file per agent under the systems
|
|
2
|
+
directory."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from filelock import FileLock
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from ecosemantic.errors import NotFound, Unavailable
|
|
15
|
+
from ecosemantic.ports.types import (
|
|
16
|
+
AgentSummary,
|
|
17
|
+
Call,
|
|
18
|
+
CallDraft,
|
|
19
|
+
agents_of,
|
|
20
|
+
checked_agent,
|
|
21
|
+
selected,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
ACTIVITY_DIR = "activity"
|
|
25
|
+
SUFFIX = ".jsonl"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FilesystemActivity:
|
|
29
|
+
"""Recorded calls as one line each, appended to the file of the agent that made them.
|
|
30
|
+
|
|
31
|
+
Two processes over one directory may serve agents of one name, so a file is appended to and
|
|
32
|
+
read under a lock both take.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, root: Path) -> None:
|
|
36
|
+
self.root = root
|
|
37
|
+
|
|
38
|
+
def _folder(self) -> Path:
|
|
39
|
+
return self.root / ACTIVITY_DIR
|
|
40
|
+
|
|
41
|
+
def _lock(self, agent: str) -> FileLock:
|
|
42
|
+
return FileLock(self._folder() / f"{agent}.lock")
|
|
43
|
+
|
|
44
|
+
def _append(self, call: Call) -> Call:
|
|
45
|
+
self._folder().mkdir(parents=True, exist_ok=True)
|
|
46
|
+
with self._lock(call.agent):
|
|
47
|
+
with (self._folder() / f"{call.agent}{SUFFIX}").open("a", encoding="utf-8") as handle:
|
|
48
|
+
handle.write(call.model_dump_json() + "\n")
|
|
49
|
+
return call
|
|
50
|
+
|
|
51
|
+
def _read(self, agent: str | None) -> list[Call]:
|
|
52
|
+
if not self._folder().is_dir():
|
|
53
|
+
return []
|
|
54
|
+
if agent is not None:
|
|
55
|
+
files = [self._folder() / f"{checked_agent(agent)}{SUFFIX}"]
|
|
56
|
+
else:
|
|
57
|
+
files = sorted(self._folder().glob(f"*{SUFFIX}"))
|
|
58
|
+
calls: list[Call] = []
|
|
59
|
+
for file in files:
|
|
60
|
+
if file.is_file():
|
|
61
|
+
with self._lock(file.name.removesuffix(SUFFIX)):
|
|
62
|
+
calls.extend(_parsed(file))
|
|
63
|
+
return calls
|
|
64
|
+
|
|
65
|
+
async def record(self, draft: CallDraft) -> Call:
|
|
66
|
+
call = Call(id=uuid.uuid4(), **draft.model_dump())
|
|
67
|
+
return await asyncio.to_thread(self._append, call)
|
|
68
|
+
|
|
69
|
+
async def list_calls(
|
|
70
|
+
self,
|
|
71
|
+
agent: str | None = None,
|
|
72
|
+
product_system_id: uuid.UUID | None = None,
|
|
73
|
+
after: datetime | None = None,
|
|
74
|
+
) -> list[Call]:
|
|
75
|
+
calls = await asyncio.to_thread(self._read, agent)
|
|
76
|
+
return selected(calls, agent, product_system_id, after)
|
|
77
|
+
|
|
78
|
+
async def get_call(self, call_id: uuid.UUID) -> Call:
|
|
79
|
+
for call in await asyncio.to_thread(self._read, None):
|
|
80
|
+
if call.id == call_id:
|
|
81
|
+
return call
|
|
82
|
+
raise NotFound(f"no call {call_id}")
|
|
83
|
+
|
|
84
|
+
async def list_agents(self) -> list[AgentSummary]:
|
|
85
|
+
return agents_of(await asyncio.to_thread(self._read, None))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parsed(file: Path) -> list[Call]:
|
|
89
|
+
"""A file's calls, or Unavailable naming the file, the line and the first field this version
|
|
90
|
+
cannot read in it."""
|
|
91
|
+
calls: list[Call] = []
|
|
92
|
+
for number, line in enumerate(file.read_text(encoding="utf-8").splitlines(), start=1):
|
|
93
|
+
try:
|
|
94
|
+
calls.append(Call.model_validate_json(line))
|
|
95
|
+
except ValidationError as error:
|
|
96
|
+
first = error.errors(include_url=False)[0]
|
|
97
|
+
field = ".".join(str(part) for part in first["loc"])
|
|
98
|
+
raise Unavailable(
|
|
99
|
+
f"{file.name} line {number} cannot be read by this version: "
|
|
100
|
+
f"{field}: {first['msg']}",
|
|
101
|
+
hint=f"remove {file} to read the activity again",
|
|
102
|
+
) from error
|
|
103
|
+
return calls
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The install port over a directory: one folder per install under the data directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from filelock import AsyncFileLock
|
|
12
|
+
|
|
13
|
+
from ecosemantic.backends.filesystem.records import RecordFolder, newest_first
|
|
14
|
+
from ecosemantic.ports.types import Install, InstallDraft, InstallPatch
|
|
15
|
+
|
|
16
|
+
INSTALLS_DIR = "installs"
|
|
17
|
+
INSTALL_FILE = "install.json"
|
|
18
|
+
UPLOADS_DIR = "uploads"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def install_dir(root: Path, install_id: uuid.UUID) -> Path:
|
|
22
|
+
"""The directory of one install under the data directory, whether or not it exists yet."""
|
|
23
|
+
return root / INSTALLS_DIR / str(install_id)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def uploads_dir(root: Path) -> Path:
|
|
27
|
+
"""The directory a package uploaded for an install is written to before that install reads
|
|
28
|
+
it."""
|
|
29
|
+
return root / INSTALLS_DIR / UPLOADS_DIR
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FilesystemInstalls:
|
|
33
|
+
"""Install records as one JSON file per install, newest first by the time they were posted.
|
|
34
|
+
|
|
35
|
+
The install process writes a record from its own process while the server reads it, so a
|
|
36
|
+
record is replaced whole and every change is read and written under a lock both take.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, root: Path) -> None:
|
|
40
|
+
self.root = root
|
|
41
|
+
self.records = RecordFolder(root, INSTALLS_DIR, INSTALL_FILE, Install, "install")
|
|
42
|
+
|
|
43
|
+
def read_record(self, install_id: uuid.UUID) -> Install:
|
|
44
|
+
"""One install, read synchronously; the install process's callback runs off the event
|
|
45
|
+
loop."""
|
|
46
|
+
return self.records.read(install_id)
|
|
47
|
+
|
|
48
|
+
def apply_patch(self, install_id: uuid.UUID, patch: InstallPatch) -> Install:
|
|
49
|
+
"""The record with the patch's named fields applied, written synchronously."""
|
|
50
|
+
return self.records.patch(install_id, patch.applied_to)
|
|
51
|
+
|
|
52
|
+
async def post_install(self, draft: InstallDraft) -> Install:
|
|
53
|
+
def construct(posted_at: datetime) -> Install:
|
|
54
|
+
return Install(id=uuid.uuid4(), draft=draft, posted_at=posted_at)
|
|
55
|
+
|
|
56
|
+
return await asyncio.to_thread(self.records.post, construct)
|
|
57
|
+
|
|
58
|
+
async def get_install(self, install_id: uuid.UUID) -> Install:
|
|
59
|
+
return await asyncio.to_thread(self.read_record, install_id)
|
|
60
|
+
|
|
61
|
+
async def list_installs(self) -> list[Install]:
|
|
62
|
+
return newest_first(await asyncio.to_thread(self.records.all))
|
|
63
|
+
|
|
64
|
+
async def cancel_install(self, install_id: uuid.UUID) -> Install:
|
|
65
|
+
def ask() -> Install:
|
|
66
|
+
return self.records.patch(
|
|
67
|
+
install_id, lambda install: install.cancellation().applied_to(install)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return await asyncio.to_thread(ask)
|
|
71
|
+
|
|
72
|
+
async def update_install(self, install_id: uuid.UUID, patch: InstallPatch) -> Install:
|
|
73
|
+
return await asyncio.to_thread(self.apply_patch, install_id, patch)
|
|
74
|
+
|
|
75
|
+
def slot(self) -> AsyncFileLock:
|
|
76
|
+
"""The workspace's one-install slot, which every process over this directory shares."""
|
|
77
|
+
return self.records.slot()
|
|
78
|
+
|
|
79
|
+
def changes(self) -> AsyncIterator[uuid.UUID]:
|
|
80
|
+
"""Every install any process wrote, from the moment this is called."""
|
|
81
|
+
return self.records.changed()
|
|
File without changes
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""A record folder: one directory per record under its kind's directory, the record replaced
|
|
2
|
+
whole under a lock both processes take."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterable
|
|
9
|
+
from datetime import UTC, datetime, timedelta
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol, cast
|
|
12
|
+
|
|
13
|
+
from filelock import AsyncFileLock, FileLock
|
|
14
|
+
from pydantic import BaseModel, ValidationError
|
|
15
|
+
|
|
16
|
+
from ecosemantic.backends.filesystem.watching import touched
|
|
17
|
+
from ecosemantic.backends.retry import read_bytes, replace
|
|
18
|
+
from ecosemantic.errors import NotFound, Unavailable
|
|
19
|
+
|
|
20
|
+
SLOT_FILE = "slot.lock"
|
|
21
|
+
POST_LOCK = "post.lock"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Posted(Protocol):
|
|
25
|
+
"""What a record carries for a listing: its id, and the time it was posted."""
|
|
26
|
+
|
|
27
|
+
id: uuid.UUID
|
|
28
|
+
posted_at: datetime
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def newest_first[T: Posted](records: Iterable[T]) -> list[T]:
|
|
32
|
+
"""Records newest first, the id breaking a tie between two posted at once."""
|
|
33
|
+
return sorted(records, key=lambda record: (record.posted_at, str(record.id)), reverse=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def now() -> datetime:
|
|
37
|
+
"""The current instant in UTC, the clock a record's posted time is measured from."""
|
|
38
|
+
return datetime.now(UTC)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def next_posted_at(now: datetime, newest: datetime | None) -> datetime:
|
|
42
|
+
"""A new record's posted time, newest-first independent of the clock's resolution: the
|
|
43
|
+
clock, or one microsecond past the store's newest when the clock would tie two."""
|
|
44
|
+
if newest is None:
|
|
45
|
+
return now
|
|
46
|
+
return max(now, newest + timedelta(microseconds=1))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RecordFolder[T: BaseModel]:
|
|
50
|
+
"""The records of one kind, as one JSON file per record in a folder of its own.
|
|
51
|
+
|
|
52
|
+
A record is written from another process while this one reads it, so it is replaced whole,
|
|
53
|
+
never rewritten in place, and every change is read and written under a lock both take.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, root: Path, kind: str, record_file: str, model: type[T], noun: str) -> None:
|
|
57
|
+
self.root = root
|
|
58
|
+
self.kind = kind
|
|
59
|
+
self.record_file = record_file
|
|
60
|
+
self.model = model
|
|
61
|
+
self.noun = noun
|
|
62
|
+
|
|
63
|
+
def folder(self, record_id: uuid.UUID) -> Path:
|
|
64
|
+
"""The directory of one record under its kind's directory, whether or not it exists."""
|
|
65
|
+
return self.root / self.kind / str(record_id)
|
|
66
|
+
|
|
67
|
+
def lock(self, record_id: uuid.UUID) -> FileLock:
|
|
68
|
+
"""The record's file lock, taken by every read-and-write of it, from either process."""
|
|
69
|
+
folder = self.root / self.kind
|
|
70
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
return FileLock(folder / f"{record_id}.lock")
|
|
72
|
+
|
|
73
|
+
def post_lock(self) -> FileLock:
|
|
74
|
+
"""The lock every poster of this kind takes, so two cannot mint the same posted time."""
|
|
75
|
+
folder = self.root / self.kind
|
|
76
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
return FileLock(folder / POST_LOCK)
|
|
78
|
+
|
|
79
|
+
def read(self, record_id: uuid.UUID) -> T:
|
|
80
|
+
"""One record, or NotFound when no folder holds it."""
|
|
81
|
+
record = self.folder(record_id) / self.record_file
|
|
82
|
+
if not record.is_file():
|
|
83
|
+
raise NotFound(f"no {self.noun} {record_id}")
|
|
84
|
+
return self._parsed(record)
|
|
85
|
+
|
|
86
|
+
def write(self, record_id: uuid.UUID, record: T) -> T:
|
|
87
|
+
"""The record written, staged beside its file and moved over it within the bounded
|
|
88
|
+
retry, so a reader sees the whole of one version or the whole of the other."""
|
|
89
|
+
folder = self.folder(record_id)
|
|
90
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
staged = folder / f"{self.record_file}.tmp"
|
|
92
|
+
staged.write_bytes(record.model_dump_json(indent=1).encode("utf-8"))
|
|
93
|
+
replace(staged, folder / self.record_file)
|
|
94
|
+
return record
|
|
95
|
+
|
|
96
|
+
def patch(self, record_id: uuid.UUID, change: Callable[[T], T]) -> T:
|
|
97
|
+
"""The record with the change applied, read and written under its lock."""
|
|
98
|
+
with self.lock(record_id):
|
|
99
|
+
return self.write(record_id, change(self.read(record_id)))
|
|
100
|
+
|
|
101
|
+
def post(self, construct: Callable[[datetime], T]) -> T:
|
|
102
|
+
"""A new record posted under the kind's lock, its posted time advanced past the newest
|
|
103
|
+
stored so two posters cannot mint the same instant."""
|
|
104
|
+
with self.post_lock():
|
|
105
|
+
record = construct(self._next_posted_at())
|
|
106
|
+
return self.write(cast(Posted, record).id, record)
|
|
107
|
+
|
|
108
|
+
def _next_posted_at(self) -> datetime:
|
|
109
|
+
"""The posted time of the next record of this kind: the clock, advanced past the newest."""
|
|
110
|
+
newest = max(
|
|
111
|
+
(record.posted_at for record in cast(Iterable[Posted], self.all())), default=None
|
|
112
|
+
)
|
|
113
|
+
return next_posted_at(now(), newest)
|
|
114
|
+
|
|
115
|
+
def all(self) -> list[T]:
|
|
116
|
+
"""Every record of this kind."""
|
|
117
|
+
folder = self.root / self.kind
|
|
118
|
+
if not folder.is_dir():
|
|
119
|
+
return []
|
|
120
|
+
return [
|
|
121
|
+
self._parsed(path / self.record_file)
|
|
122
|
+
for path in folder.iterdir()
|
|
123
|
+
if (path / self.record_file).is_file()
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
def changed(self) -> AsyncGenerator[uuid.UUID]:
|
|
127
|
+
"""Every record any process wrote under this kind's directory, from the moment this is
|
|
128
|
+
called: the watch is armed eagerly, so a write between this call and the first read is
|
|
129
|
+
announced."""
|
|
130
|
+
queue: asyncio.Queue[uuid.UUID] = asyncio.Queue()
|
|
131
|
+
watching = asyncio.create_task(self._watch(touched(self.root / self.kind), queue))
|
|
132
|
+
|
|
133
|
+
async def iterate() -> AsyncGenerator[uuid.UUID]:
|
|
134
|
+
try:
|
|
135
|
+
while True:
|
|
136
|
+
yield await queue.get()
|
|
137
|
+
finally:
|
|
138
|
+
watching.cancel()
|
|
139
|
+
|
|
140
|
+
return iterate()
|
|
141
|
+
|
|
142
|
+
async def _watch(
|
|
143
|
+
self, batches: AsyncIterator[set[Path]], queue: asyncio.Queue[uuid.UUID]
|
|
144
|
+
) -> None:
|
|
145
|
+
async for batch in batches:
|
|
146
|
+
found = {record for path in batch if (record := self._record_of(path)) is not None}
|
|
147
|
+
for record_id in sorted(found, key=str):
|
|
148
|
+
if (self.folder(record_id) / self.record_file).is_file():
|
|
149
|
+
queue.put_nowait(record_id)
|
|
150
|
+
|
|
151
|
+
def _record_of(self, path: Path) -> uuid.UUID | None:
|
|
152
|
+
"""The record a written path belongs to, or none for a path that names no record."""
|
|
153
|
+
folder = self.root / self.kind
|
|
154
|
+
try:
|
|
155
|
+
first = path.resolve().relative_to(folder.resolve()).parts[0]
|
|
156
|
+
except (ValueError, IndexError):
|
|
157
|
+
return None
|
|
158
|
+
try:
|
|
159
|
+
return uuid.UUID(first)
|
|
160
|
+
except ValueError:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def slot(self) -> AsyncFileLock:
|
|
164
|
+
"""The single slot every process over this directory takes, released when its holder's
|
|
165
|
+
process ends. Each try is non-blocking on the event loop, so a caller cancelled while it
|
|
166
|
+
waits holds nothing."""
|
|
167
|
+
folder = self.root / self.kind
|
|
168
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
return AsyncFileLock(folder / SLOT_FILE, run_in_executor=False)
|
|
170
|
+
|
|
171
|
+
def _parsed(self, record: Path) -> T:
|
|
172
|
+
"""The record parsed, or Unavailable naming it and the first field this version cannot
|
|
173
|
+
read in it."""
|
|
174
|
+
try:
|
|
175
|
+
return self.model.model_validate_json(read_bytes(record))
|
|
176
|
+
except ValidationError as error:
|
|
177
|
+
first = error.errors(include_url=False)[0]
|
|
178
|
+
field = ".".join(str(part) for part in first["loc"])
|
|
179
|
+
raise Unavailable(
|
|
180
|
+
f"{self.noun} {record.parent.name} cannot be read by this version: "
|
|
181
|
+
f"{field}: {first['msg']}",
|
|
182
|
+
hint=f"remove {record.parent} to list and start {self.noun}s again",
|
|
183
|
+
) from error
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Where a run lives on disk: one directory per run under the systems directory, and the
|
|
2
|
+
heartbeat its worker beats there."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from threading import Event, Thread
|
|
12
|
+
|
|
13
|
+
RUNS_DIR = "runs"
|
|
14
|
+
RECORD_FILE = "run.json"
|
|
15
|
+
LOG_FILE = "worker.log"
|
|
16
|
+
HEARTBEAT_FILE = "heartbeat"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_dir(root: Path, run_id: uuid.UUID) -> Path:
|
|
20
|
+
"""The directory of one run under a systems directory, whether or not it exists yet."""
|
|
21
|
+
return root / RUNS_DIR / str(run_id)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def beat(folder: Path) -> None:
|
|
25
|
+
"""Mark the worker carrying the run in this folder as alive now, by the heartbeat file's
|
|
26
|
+
modification time."""
|
|
27
|
+
(folder / HEARTBEAT_FILE).touch()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def heartbeat_age(folder: Path) -> float | None:
|
|
31
|
+
"""Seconds since the worker carrying the run in this folder last beat, or None when it never
|
|
32
|
+
has."""
|
|
33
|
+
heartbeat = folder / HEARTBEAT_FILE
|
|
34
|
+
if not heartbeat.is_file():
|
|
35
|
+
return None
|
|
36
|
+
return time.time() - heartbeat.stat().st_mtime
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@contextmanager
|
|
40
|
+
def beating(folder: Path, every: float) -> Iterator[None]:
|
|
41
|
+
"""Beat this folder's heartbeat every `every` seconds until the block ends, so a step that
|
|
42
|
+
reports nothing does not look like a process that is gone."""
|
|
43
|
+
stop = Event()
|
|
44
|
+
|
|
45
|
+
def keep_beating() -> None:
|
|
46
|
+
while not stop.wait(every):
|
|
47
|
+
beat(folder)
|
|
48
|
+
|
|
49
|
+
beater = Thread(target=keep_beating, daemon=True)
|
|
50
|
+
beater.start()
|
|
51
|
+
try:
|
|
52
|
+
yield
|
|
53
|
+
finally:
|
|
54
|
+
stop.set()
|
|
55
|
+
beater.join()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The run port over a directory: one folder per run under the systems directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shutil
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from filelock import AsyncFileLock
|
|
13
|
+
|
|
14
|
+
from ecosemantic.backends.filesystem.records import RecordFolder, newest_first
|
|
15
|
+
from ecosemantic.backends.filesystem.run_folder import RECORD_FILE, RUNS_DIR
|
|
16
|
+
from ecosemantic.ports.types import Run, RunDraft, RunPatch, StoreChange, refuse_open
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FilesystemRuns:
|
|
20
|
+
"""Run records as one JSON file per run, newest first by the time they were posted.
|
|
21
|
+
|
|
22
|
+
The worker writes a record from its own process while the server reads it, so a record is
|
|
23
|
+
replaced whole, never rewritten in place, and every change is read and written under a lock
|
|
24
|
+
both processes take.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, root: Path) -> None:
|
|
28
|
+
self.root = root
|
|
29
|
+
self.records = RecordFolder(root, RUNS_DIR, RECORD_FILE, Run, "run")
|
|
30
|
+
|
|
31
|
+
def read_record(self, run_id: uuid.UUID) -> Run:
|
|
32
|
+
"""One run, read synchronously; the worker's callback runs off the event loop."""
|
|
33
|
+
return self.records.read(run_id)
|
|
34
|
+
|
|
35
|
+
def apply_patch(self, run_id: uuid.UUID, patch: RunPatch) -> Run:
|
|
36
|
+
"""The record with the patch's named fields applied, written synchronously."""
|
|
37
|
+
with self.records.lock(run_id):
|
|
38
|
+
return self.records.write(run_id, patch.applied_to(self.read_record(run_id)))
|
|
39
|
+
|
|
40
|
+
async def post_run(self, draft: RunDraft) -> Run:
|
|
41
|
+
def construct(posted_at: datetime) -> Run:
|
|
42
|
+
return Run(id=uuid.uuid4(), posted_at=posted_at, **draft.model_dump())
|
|
43
|
+
|
|
44
|
+
return await asyncio.to_thread(self.records.post, construct)
|
|
45
|
+
|
|
46
|
+
async def get_run(self, run_id: uuid.UUID) -> Run:
|
|
47
|
+
return await asyncio.to_thread(self.read_record, run_id)
|
|
48
|
+
|
|
49
|
+
async def list_runs(self, product_system_id: uuid.UUID | None = None) -> list[Run]:
|
|
50
|
+
runs = newest_first(await asyncio.to_thread(self.records.all))
|
|
51
|
+
if product_system_id is None:
|
|
52
|
+
return runs
|
|
53
|
+
return [run for run in runs if run.product_system_id == product_system_id]
|
|
54
|
+
|
|
55
|
+
async def cancel_run(self, run_id: uuid.UUID) -> Run:
|
|
56
|
+
def ask() -> Run:
|
|
57
|
+
return self.records.patch(run_id, lambda run: run.cancellation().applied_to(run))
|
|
58
|
+
|
|
59
|
+
return await asyncio.to_thread(ask)
|
|
60
|
+
|
|
61
|
+
async def update_run(self, run_id: uuid.UUID, patch: RunPatch) -> Run:
|
|
62
|
+
return await asyncio.to_thread(self.apply_patch, run_id, patch)
|
|
63
|
+
|
|
64
|
+
async def delete_runs(self, product_system_id: uuid.UUID) -> None:
|
|
65
|
+
"""Remove every run of a product system, record and folder; Conflict while one is
|
|
66
|
+
open, since its worker still writes it."""
|
|
67
|
+
|
|
68
|
+
def remove() -> None:
|
|
69
|
+
owned = [r for r in self.records.all() if r.product_system_id == product_system_id]
|
|
70
|
+
refuse_open(owned)
|
|
71
|
+
for run in owned:
|
|
72
|
+
with self.records.lock(run.id):
|
|
73
|
+
shutil.rmtree(self.records.folder(run.id))
|
|
74
|
+
|
|
75
|
+
await asyncio.to_thread(remove)
|
|
76
|
+
|
|
77
|
+
def changes(self) -> AsyncIterator[StoreChange]:
|
|
78
|
+
"""What any process wrote under the runs directory, from the moment this is called: the
|
|
79
|
+
watch is armed eagerly, so a write between this call and the first read is announced."""
|
|
80
|
+
found = self.records.changed()
|
|
81
|
+
|
|
82
|
+
async def iterate() -> AsyncIterator[StoreChange]:
|
|
83
|
+
try:
|
|
84
|
+
async for run_id in found:
|
|
85
|
+
run = await asyncio.to_thread(self.read_record, run_id)
|
|
86
|
+
yield StoreChange(product_system_id=run.product_system_id, run_id=run.id)
|
|
87
|
+
finally:
|
|
88
|
+
await found.aclose()
|
|
89
|
+
|
|
90
|
+
return iterate()
|
|
91
|
+
|
|
92
|
+
def slot(self) -> AsyncFileLock:
|
|
93
|
+
"""The workspace's one-run slot: a file lock every process over this directory takes,
|
|
94
|
+
released when its holder's process ends."""
|
|
95
|
+
return self.records.slot()
|