Skip to content

Commit eeac57b

Browse files
authored
Merge pull request #151 from simvue-io/feature/148-offline-online-subclasses
Use base class for Online and Offline
2 parents 109fc05 + 5003b1e commit eeac57b

24 files changed

+1313
-118
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ torch = ["torch"]
2929
plot = ["matplotlib", "plotly"]
3030

3131
[tool.poetry.scripts]
32-
simvue_sender = "simvue.bin.simvue_sender:run"
32+
simvue_sender = "simvue.bin.sender:run"
3333

3434
[tool.poetry.group.dev.dependencies]
3535
pytest = "^8.0.0"

simvue/factory/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from .remote import Remote
2+
from .offline import Offline
3+
from .base import SimvueBaseClass
4+
5+
6+
def Simvue(
7+
name: str,
8+
uniq_id: str,
9+
mode: str,
10+
suppress_errors: bool = True
11+
) -> SimvueBaseClass:
12+
if mode == "offline":
13+
return Offline(name, uniq_id, suppress_errors)
14+
else:
15+
return Remote(name, uniq_id, suppress_errors)

simvue/factory/base.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import abc
2+
import uuid
3+
import typing
4+
import logging
5+
6+
class SimvueBaseClass(abc.ABC):
7+
@abc.abstractmethod
8+
def __init__(self, name: str, uniq_id: uuid.UUID, suppress_errors: bool) -> None:
9+
self._logger = logging.getLogger(f"simvue.{self.__class__.__name__}")
10+
self._suppress_errors: bool = suppress_errors
11+
self._uuid: str = uniq_id
12+
self._name: str = name
13+
self._id: typing.Optional[int] = None
14+
self._aborted: bool = False
15+
16+
def _error(self, message: str) -> None:
17+
"""
18+
Raise an exception if necessary and log error
19+
"""
20+
if not self._suppress_errors:
21+
raise RuntimeError(message)
22+
else:
23+
self._logger.error(message)
24+
self._aborted = True
25+
26+
@abc.abstractmethod
27+
def create_run(self, data: dict[str, typing.Any]) -> typing.Optional[dict[str, typing.Any]]:
28+
pass
29+
30+
@abc.abstractmethod
31+
def update(self, data: dict[str, typing.Any]) -> typing.Optional[dict[str, typing.Any]]:
32+
pass
33+
34+
@abc.abstractmethod
35+
def set_folder_details(self, data) -> typing.Optional[dict[str, typing.Any]]:
36+
pass
37+
38+
@abc.abstractmethod
39+
def save_file(self, data: dict[str, typing.Any]) -> typing.Optional[dict[str, typing.Any]]:
40+
pass
41+
42+
@abc.abstractmethod
43+
def add_alert(self, data: dict[str, typing.Any]) -> typing.Optional[dict[str, typing.Any]]:
44+
pass
45+
46+
@abc.abstractmethod
47+
def set_alert_state(self, alert_id: str, status: str) -> typing.Optional[dict[str, typing.Any]]:
48+
pass
49+
50+
@abc.abstractmethod
51+
def list_alerts(self) -> list[dict[str, typing.Any]]:
52+
pass
53+
54+
@abc.abstractmethod
55+
def send_metrics(self, data: dict[str, typing.Any]) -> typing.Optional[dict[str, typing.Any]]:
56+
pass
57+
58+
@abc.abstractmethod
59+
def send_event(self, data: dict[str, typing.Any]) -> typing.Optional[dict[str, typing.Any]]:
60+
pass
61+
62+
@abc.abstractmethod
63+
def send_heartbeat(self) -> typing.Optional[dict[str, typing.Any]]:
64+
pass
65+
66+
@abc.abstractmethod
67+
def check_token(self) -> bool:
68+
pass

simvue/factory/offline.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import json
2+
import os
3+
import time
4+
import uuid
5+
import typing
6+
import glob
7+
import logging
8+
import pathlib
9+
10+
from simvue.utilities import get_offline_directory, create_file, prepare_for_api, skip_if_failed
11+
from simvue.factory.base import SimvueBaseClass
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
class Offline(SimvueBaseClass):
17+
"""
18+
Class for offline runs
19+
"""
20+
def __init__(self, name: str, uniq_id: str, suppress_errors: bool=True) -> None:
21+
super().__init__(name, uniq_id, suppress_errors)
22+
23+
self._directory: str = os.path.join(get_offline_directory(), self._uuid)
24+
25+
os.makedirs(self._directory, exist_ok=True)
26+
27+
@skip_if_failed("_aborted", "_suppress_errors", None)
28+
def _write_json(self, filename: str, data: dict[str, typing.Any]) -> None:
29+
"""
30+
Write JSON to file
31+
"""
32+
if not os.path.isdir(os.path.dirname(filename)):
33+
self._error("Cannot write file '{filename}', parent directory does not exist")
34+
35+
try:
36+
with open(filename, 'w') as fh:
37+
json.dump(data, fh)
38+
except Exception as err:
39+
self._error(f"Unable to write file {filename} due to {str(err)}")
40+
41+
@skip_if_failed("_aborted", "_suppress_errors", None)
42+
def _mock_api_post(self, prefix: str, data: dict[str, typing.Any]) -> typing.Optional[dict[str, typing.Any]]:
43+
unique_id = time.time()
44+
filename = os.path.join(self._directory, f"{prefix}-{unique_id}.json")
45+
self._write_json(filename, data)
46+
return data
47+
48+
@skip_if_failed("_aborted", "_suppress_errors", (None, None))
49+
def create_run(self, data) -> tuple[typing.Optional[str], typing.Optional[str]]:
50+
"""
51+
Create a run
52+
"""
53+
if not self._directory:
54+
self._logger.error("No directory specified")
55+
return (None, None)
56+
try:
57+
os.makedirs(self._directory, exist_ok=True)
58+
except Exception as err:
59+
self._logger.error('Unable to create directory %s due to: %s', self._directory, str(err))
60+
return (None, None)
61+
62+
filename = f"{self._directory}/run.json"
63+
64+
logger.debug(f"Creating run in '{filename}'")
65+
66+
if 'name' not in data:
67+
data['name'] = None
68+
69+
self._write_json(filename, data)
70+
71+
status = data['status']
72+
filename = f"{self._directory}/{status}"
73+
create_file(filename)
74+
75+
return (self._name, self._id)
76+
77+
@skip_if_failed("_aborted", "_suppress_errors", None)
78+
def update(self, data) -> typing.Optional[dict[str, typing.Any]]:
79+
"""
80+
Update metadata, tags or status
81+
"""
82+
unique_id = time.time()
83+
filename = f"{self._directory}/update-{unique_id}.json"
84+
self._write_json(filename, data)
85+
86+
if 'status' in data:
87+
status = data['status']
88+
if not self._directory or not os.path.exists(self._directory):
89+
self._error("No directory defined for writing")
90+
return None
91+
filename = f"{self._directory}/{status}"
92+
93+
logger.debug(f"Writing API data to file '{filename}'")
94+
95+
create_file(filename)
96+
97+
if status == 'completed':
98+
status_running = f"{self._directory}/running"
99+
if os.path.isfile(status_running):
100+
os.remove(status_running)
101+
102+
return data
103+
104+
@skip_if_failed("_aborted", "_suppress_errors", None)
105+
def set_folder_details(self, data) -> typing.Optional[dict[str, typing.Any]]:
106+
"""
107+
Set folder details
108+
"""
109+
unique_id = time.time()
110+
filename = f"{self._directory}/folder-{unique_id}.json"
111+
self._write_json(filename, data)
112+
return data
113+
114+
@skip_if_failed("_aborted", "_suppress_errors", None)
115+
def save_file(self, data: dict[str, typing.Any]) -> typing.Optional[dict[str, typing.Any]]:
116+
"""
117+
Save file
118+
"""
119+
if 'pickled' in data:
120+
temp_file = f"{self._directory}/temp-{uuid.uuid4()}.pickle"
121+
with open(temp_file, 'wb') as fh:
122+
fh.write(data['pickled'])
123+
data['pickledFile'] = temp_file
124+
unique_id = time.time()
125+
filename = os.path.join(self._directory, f"file-{unique_id}.json")
126+
self._write_json(filename, prepare_for_api(data, False))
127+
return data
128+
129+
def add_alert(self, data: dict[str, typing.Any]) -> typing.Optional[dict[str, typing.Any]]:
130+
"""
131+
Add an alert
132+
"""
133+
return self._mock_api_post("alert", data)
134+
135+
@skip_if_failed("_aborted", "_suppress_errors", None)
136+
def set_alert_state(self, alert_id: str, status: str) -> typing.Optional[dict[str, typing.Any]]:
137+
if not os.path.exists(_alert_file := os.path.join(self._directory, f"alert-{alert_id}.json")):
138+
self._error(f"Failed to retrieve alert '{alert_id}' for modification")
139+
return None
140+
141+
with open(_alert_file) as alert_in:
142+
_alert_data = json.load(alert_in)
143+
144+
_alert_data |= {"run": self._id, "alert": alert_id, "status": status}
145+
146+
self._write_json(_alert_file, _alert_data)
147+
148+
return _alert_data
149+
150+
@skip_if_failed("_aborted", "_suppress_errors", [])
151+
def list_alerts(self) -> list[dict[str, typing.Any]]:
152+
return [
153+
json.load(open(alert_file))
154+
for alert_file
155+
in glob.glob(os.path.join(self._directory, "alert-*.json"))
156+
]
157+
158+
def send_metrics(self, data: dict[str, typing.Any]) -> typing.Optional[dict[str, typing.Any]]:
159+
"""
160+
Send metrics
161+
"""
162+
return self._mock_api_post("metrics", data)
163+
164+
def send_event(self, data: dict[str, typing.Any]) -> typing.Optional[dict[str, typing.Any]]:
165+
"""
166+
Send event
167+
"""
168+
return self._mock_api_post("event", data)
169+
170+
@skip_if_failed("_aborted", "_suppress_errors", None)
171+
def send_heartbeat(self) -> typing.Optional[dict[str, typing.Any]]:
172+
logger.debug(f"Creating heartbeat file: {os.path.join(self._directory, 'heartbeat')}")
173+
pathlib.Path(os.path.join(self._directory, "heartbeat"), "a").touch()
174+
return {"success": True}
175+
176+
@skip_if_failed("_aborted", "_suppress_errors", False)
177+
def check_token(self) -> bool:
178+
return True

0 commit comments

Comments
 (0)