robo-goggles 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,26 @@
1
+ """Integration module for Goggles core.
2
+
3
+ This module defines the handlers to be attached to the EventBus to dispatch
4
+ events to the appropriate integration modules.
5
+
6
+ Example:
7
+ class PrintHandler(TextHandler):
8
+ def emit(self, record: LogRecord) -> None:
9
+ print(self.format(record))
10
+
11
+ """
12
+
13
+ # TODO: actually write the docs here
14
+
15
+ from .console import ConsoleHandler
16
+ from .storage import LocalStorageHandler
17
+
18
+ __all__ = [
19
+ "ConsoleHandler",
20
+ "LocalStorageHandler",
21
+ ]
22
+
23
+ try:
24
+ from .wandb import WandBHandler
25
+ except ImportError:
26
+ __all__.append("WandBHandler")
@@ -0,0 +1,111 @@
1
+ """Console-based log handler for EventBus integration."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import ClassVar, FrozenSet, Literal
6
+ from typing_extensions import Self
7
+
8
+ from goggles.types import Event, Kind
9
+
10
+
11
+ class ConsoleHandler:
12
+ """Handle 'log' events and output them to console using Python's logging API.
13
+
14
+ Attributes:
15
+ name (str): Stable handler identifier.
16
+ capabilities (set[str]): Supported event kinds (only {"log"}).
17
+
18
+ """
19
+
20
+ name: str = "goggles.console"
21
+ capabilities: ClassVar[FrozenSet[Kind]] = frozenset({"log"})
22
+
23
+ def __init__(
24
+ self,
25
+ *,
26
+ name: str = "goggles.console",
27
+ level: int = logging.NOTSET,
28
+ path_style: Literal["absolute", "relative"] = "relative",
29
+ project_root: Path | None = None,
30
+ ) -> None:
31
+ """Initialize the ConsoleHandler.
32
+
33
+ Args:
34
+ name (str): Stable handler identifier.
35
+ level (int): Minimum log level to handle.
36
+ path_style (Literal["absolute", "relative"]): Whether to print absolute
37
+ or relative file paths. Defaults to "relative".
38
+ project_root (Path | None): Root path used for relative paths.
39
+
40
+ """
41
+ self.name = name
42
+ self.level = int(level)
43
+ self.path_style = path_style
44
+ self.project_root = Path(project_root or Path.cwd())
45
+ self._logger: logging.Logger
46
+
47
+ def can_handle(self, kind: Kind) -> bool:
48
+ """Return whether this handler can process the given kind."""
49
+ return kind in self.capabilities
50
+
51
+ def handle(self, event: Event) -> None:
52
+ """Forward a log event to Python's logging system."""
53
+ if event.kind != "log":
54
+ raise ValueError(f"Unsupported event kind '{event.kind}'")
55
+
56
+ level = int(event.level) if event.level else logging.NOTSET
57
+ message = str(event.payload)
58
+
59
+ # Derive display path
60
+ path = Path(event.filepath)
61
+ if self.path_style == "relative":
62
+ try:
63
+ path = path.relative_to(self.project_root)
64
+ except ValueError:
65
+ pass # fallback to absolute if outside root
66
+ path_str = f"{path}:{event.lineno}"
67
+
68
+ # We manually construct prefix since stacklevel=3 may mislead
69
+ self._logger.log(level, f"{path_str} - {message}", stacklevel=2)
70
+
71
+ def open(self) -> None:
72
+ """Initialize the handler (create logger and formatter)."""
73
+ self._logger = logging.getLogger(self.name)
74
+ if not self._logger.handlers:
75
+ handler = logging.StreamHandler()
76
+ handler.setFormatter(
77
+ logging.Formatter(
78
+ "%(asctime)s - %(levelname)s - %(message)s",
79
+ datefmt="%Y-%m-%d %H:%M:%S",
80
+ )
81
+ )
82
+ self._logger.addHandler(handler)
83
+ self._logger.setLevel(self.level or logging.INFO)
84
+
85
+ def close(self) -> None:
86
+ """Flush and release console handler resources."""
87
+ for handler in self._logger.handlers:
88
+ handler.flush()
89
+
90
+ def to_dict(self) -> dict:
91
+ """Serialize the handler for later reconstruction."""
92
+ return {
93
+ "cls": self.__class__.__name__,
94
+ "data": {
95
+ "name": self.name,
96
+ "level": self.level,
97
+ "path_style": self.path_style,
98
+ "project_root": str(self.project_root),
99
+ },
100
+ }
101
+
102
+ @classmethod
103
+ def from_dict(cls, serialized: dict) -> Self:
104
+ """Reconstruct a handler from its serialized representation."""
105
+ data = serialized.get("data", serialized)
106
+ return cls(
107
+ name=data["name"],
108
+ level=data["level"],
109
+ path_style=data.get("path_style", "relative"),
110
+ project_root=Path(data.get("project_root", Path.cwd())),
111
+ )
@@ -0,0 +1,382 @@
1
+ """JSONL integration for Goggles logging framework."""
2
+
3
+ import json
4
+ import threading
5
+ from pathlib import Path
6
+ from typing import Any, FrozenSet, Optional
7
+ from uuid import uuid4
8
+ from typing_extensions import Self
9
+ import logging
10
+ import numpy as np
11
+
12
+ from goggles.types import Event, Kind
13
+ from goggles.media import (
14
+ save_numpy_gif,
15
+ save_numpy_image,
16
+ save_numpy_mp4,
17
+ save_numpy_vector_field_visualization,
18
+ )
19
+
20
+
21
+ class LocalStorageHandler:
22
+ """Write events to a structured directory locally.
23
+
24
+ This handler creates a directory structure:
25
+ - {base_path}/log.jsonl: Main JSONL log file with all events
26
+ - {base_path}/images/: Directory for image files
27
+ - {base_path}/videos/: Directory for video files
28
+ - {base_path}/artifacts/: Directory for other artifact files
29
+
30
+ For media events (image, video, artifact), the binary data is saved to
31
+ the appropriate subdirectory and the relative path is logged in the
32
+ JSONL file instead of the raw data.
33
+
34
+ Thread-safe and line-buffered, ensuring atomic writes per event.
35
+
36
+ Attributes:
37
+ name (str): Stable handler identifier.
38
+ capabilities (set[str]): Supported event kinds.
39
+
40
+ """
41
+
42
+ name: str = "jsonl"
43
+ capabilities: FrozenSet[str] = frozenset(
44
+ {"log", "metric", "image", "video", "artifact", "vector_field", "histogram"}
45
+ )
46
+
47
+ def __init__(self, path: Path, name: str = "jsonl") -> None:
48
+ """Initialize the handler with a base directory.
49
+
50
+ Args:
51
+ path (Path): Base directory for logs and media files. Will be created if it doesn't exist.
52
+ name (str): Handler identifier (for logging diagnostics).
53
+
54
+ """
55
+ self.name = name
56
+ self._base_path = Path(path)
57
+
58
+ def open(self) -> None:
59
+ """Create directory structure and open the JSONL file for appending."""
60
+ self._lock = threading.Lock()
61
+
62
+ # Create directory structure
63
+ self._log_file = self._base_path / "log.jsonl"
64
+ self._images_dir = self._base_path / "images"
65
+ self._videos_dir = self._base_path / "videos"
66
+ self._artifacts_dir = self._base_path / "artifacts"
67
+ self._vector_fields_dir = self._base_path / "vector_fields"
68
+ self._histograms_dir = self._base_path / "histograms"
69
+ self._base_path.mkdir(parents=True, exist_ok=True)
70
+ self._images_dir.mkdir(exist_ok=True)
71
+ self._videos_dir.mkdir(exist_ok=True)
72
+ self._artifacts_dir.mkdir(exist_ok=True)
73
+ self._vector_fields_dir.mkdir(exist_ok=True)
74
+ self._histograms_dir.mkdir(exist_ok=True)
75
+
76
+ # Open log file
77
+ self._fp = open(self._log_file, "a", encoding="utf-8", buffering=1)
78
+
79
+ # Open logger for diagnostics
80
+ self._logger = logging.getLogger(self.name)
81
+
82
+ def close(self) -> None:
83
+ """Flush and close the JSONL file."""
84
+ if self._fp and not self._fp.closed:
85
+ with self._lock:
86
+ self._fp.flush()
87
+ self._fp.close()
88
+
89
+ def can_handle(self, kind: Kind) -> bool:
90
+ """Return True if this handler supports the given event kind.
91
+
92
+ Args:
93
+ kind (Kind): Kind of event ("log", "metric", "image", "artifact").
94
+
95
+ Returns:
96
+ bool: True if the kind is supported, False otherwise.
97
+
98
+ """
99
+ return kind in self.capabilities
100
+
101
+ def handle(self, event: Event) -> None:
102
+ """Write a single event to the JSONL file.
103
+
104
+ Args:
105
+ event (Event): The event to serialize.
106
+
107
+ """
108
+ event = event.to_dict()
109
+
110
+ # Handle media events by saving files and updating payload
111
+ kind = event["kind"]
112
+ if kind == "image":
113
+ event = self._save_image_to_file(event)
114
+ elif kind == "video":
115
+ event = self._save_video_to_file(event)
116
+ elif kind == "artifact":
117
+ event = self._save_artifact_to_file(event)
118
+ elif kind == "vector_field":
119
+ event = self._save_vector_field_to_file(event)
120
+ elif kind == "histogram":
121
+ event = self._save_histogram_to_file(event)
122
+
123
+ if event is None:
124
+ self._logger.warning(
125
+ "Skipping event logging due to unsupported media format."
126
+ )
127
+ return
128
+
129
+ try:
130
+ with self._lock:
131
+ json.dump(
132
+ event, self._fp, ensure_ascii=False, default=self._json_serializer
133
+ )
134
+ self._fp.write("\n")
135
+ self._fp.flush()
136
+ except Exception:
137
+ logging.getLogger(self.name).exception("Failed to write JSONL event")
138
+
139
+ def to_dict(self) -> dict:
140
+ """Serialize handler configuration to dictionary."""
141
+ return {
142
+ "cls": self.__class__.__name__,
143
+ "data": {
144
+ "path": str(self._base_path),
145
+ "name": self.name,
146
+ },
147
+ }
148
+
149
+ @classmethod
150
+ def from_dict(cls, serialized: dict) -> Self:
151
+ """Reconstruct a handler from its serialized representation."""
152
+ data = serialized.get("data", serialized)
153
+ return cls(
154
+ path=Path(data["path"]),
155
+ name=data["name"],
156
+ )
157
+
158
+ def _json_serializer(self, obj: Any) -> str:
159
+ """Serialize object to JSON-compatible format.
160
+
161
+ Args:
162
+ obj: Object to serialize.
163
+
164
+ """
165
+ if isinstance(obj, np.ndarray):
166
+ return obj.tolist()
167
+ elif isinstance(obj, (np.integer, np.floating)):
168
+ return obj.item()
169
+
170
+ # For other non-serializable objects, convert to string
171
+ return str(obj)
172
+
173
+ def _save_image_to_file(self, event: dict) -> dict:
174
+ """Save image data to file and update event with file path.
175
+
176
+ Args:
177
+ event (dict): Event dictionary.
178
+
179
+ Returns:
180
+ dict: Updated event with file path instead of raw data.
181
+
182
+ """
183
+ image_format = "png"
184
+ if event["extra"] and "format" in event["extra"]:
185
+ image_format = event["extra"]["format"]
186
+
187
+ image_name = str(uuid4())
188
+ if event["extra"] and "name" in event["extra"]:
189
+ image_name = event["extra"]["name"]
190
+
191
+ image_path = self._images_dir / Path(f"{image_name}.{image_format}")
192
+ save_numpy_image(
193
+ event["payload"],
194
+ image_path,
195
+ format=image_format,
196
+ )
197
+
198
+ event["payload"] = str(image_path.relative_to(self._base_path))
199
+ return event
200
+
201
+ def _save_video_to_file(self, event: dict) -> dict:
202
+ """Save video data to file and update event with file path.
203
+
204
+ Args:
205
+ event (dict): Event dictionary.
206
+
207
+ Returns:
208
+ dict: Updated event with file path instead of raw data.
209
+
210
+ """
211
+ video_format = "mp4"
212
+ if "format" in event["extra"]:
213
+ video_format = event["extra"]["format"]
214
+ if video_format not in {"mp4", "gif"}:
215
+ self._logger.warning(
216
+ f"Unknown video format '{video_format}'."
217
+ " Supported formats are: 'mp4', 'gif'."
218
+ " The video will not be logged."
219
+ )
220
+ return None
221
+
222
+ video_name = str(uuid4())
223
+ if event["extra"] and "name" in event["extra"]:
224
+ video_name = event["extra"]["name"]
225
+
226
+ fps = 1.0
227
+ if event["extra"] and "fps" in event["extra"]:
228
+ fps = float(event["extra"]["fps"])
229
+
230
+ if video_format == "gif":
231
+ video_data: np.ndarray = event["payload"]
232
+ loop = 0
233
+ if event["extra"] and "loop" in event["extra"]:
234
+ loop = event["extra"]["loop"]
235
+ gif_path = self._videos_dir / Path(f"{video_name}.gif")
236
+ save_numpy_gif(video_data, gif_path, fps=fps, loop=loop)
237
+ event["payload"] = str(gif_path.relative_to(self._base_path))
238
+ elif video_format == "mp4":
239
+ video_data: np.ndarray = event["payload"]
240
+ video_codec = "libx264"
241
+ pix_fmt = "yuv420p"
242
+ bitrate = None
243
+ crf = 18
244
+ convert_gray_to_rgb = True
245
+ preset = "medium"
246
+ if event["extra"]:
247
+ if "codec" in event["extra"]:
248
+ video_codec = event["extra"]["codec"]
249
+ if "pix_fmt" in event["extra"]:
250
+ pix_fmt = event["extra"]["pix_fmt"]
251
+ if "bitrate" in event["extra"]:
252
+ bitrate = event["extra"]["bitrate"]
253
+ if "crf" in event["extra"]:
254
+ crf = event["extra"]["crf"]
255
+ if "convert_gray_to_rgb" in event["extra"]:
256
+ convert_gray_to_rgb = event["extra"]["convert_gray_to_rgb"]
257
+ if "preset" in event["extra"]:
258
+ preset = event["extra"]["preset"]
259
+
260
+ mp4_path = self._videos_dir / Path(f"{video_name}.mp4")
261
+ save_numpy_mp4(
262
+ video_data,
263
+ mp4_path,
264
+ fps=fps,
265
+ codec=video_codec,
266
+ pix_fmt=pix_fmt,
267
+ bitrate=bitrate,
268
+ crf=crf,
269
+ convert_gray_to_rgb=convert_gray_to_rgb,
270
+ preset=preset,
271
+ )
272
+ event["payload"] = str(mp4_path.relative_to(self._base_path))
273
+ return event
274
+
275
+ def _save_artifact_to_file(self, event: dict) -> Optional[dict]:
276
+ """Save artifact data to file and update event with file path.
277
+
278
+ Args:
279
+ event (dict): Event dictionary.
280
+
281
+ Returns:
282
+ Optional[dict]: Updated event with file path instead of raw data.
283
+ If the artifact format is unknown, returns None.
284
+
285
+ """
286
+ artifact_format = "txt"
287
+ if event["extra"] and "format" in event["extra"]:
288
+ artifact_format = event["extra"]["format"]
289
+
290
+ if artifact_format not in {"txt", "csv", "json", "yaml"}:
291
+ self._logger.warning(
292
+ f"Unknown artifact format '{artifact_format}'."
293
+ " Supported formats are: 'txt', 'csv', 'json', 'yaml'."
294
+ " The artifact will not be logged."
295
+ )
296
+ return None
297
+
298
+ if artifact_format == "json":
299
+ import json
300
+
301
+ event["payload"] = json.dumps(event["payload"], indent=2)
302
+
303
+ if artifact_format == "yaml":
304
+ import yaml
305
+
306
+ event["payload"] = yaml.dump(event["payload"])
307
+
308
+ artifact_name = str(uuid4())
309
+ if event["extra"] and "name" in event["extra"]:
310
+ artifact_name = event["extra"]["name"]
311
+
312
+ artifact_path = self._artifacts_dir / Path(f"{artifact_name}.{artifact_format}")
313
+
314
+ with open(artifact_path, "w") as f:
315
+ f.write(event["payload"])
316
+
317
+ event["payload"] = str(artifact_path.relative_to(self._base_path))
318
+ return event
319
+
320
+ def _save_vector_field_to_file(self, event: dict) -> Optional[dict]:
321
+ """Save vector field data to file and update event with file path.
322
+
323
+ Args:
324
+ event (dict): Event dictionary.
325
+
326
+ Returns:
327
+ dict: Updated event with file path instead of raw data.
328
+
329
+ """
330
+ vector_field_name = str(uuid4())
331
+ if event["extra"] and "name" in event["extra"]:
332
+ vector_field_name = event["extra"]["name"]
333
+
334
+ if event["extra"] and "store_visualization" in event["extra"]:
335
+ add_colorbar = False
336
+ if event["extra"] and "add_colorbar" in event["extra"]:
337
+ add_colorbar = event["extra"]["add_colorbar"]
338
+
339
+ mode = "magnitude"
340
+ if event["extra"] and "mode" in event["extra"]:
341
+ mode = event["extra"]["mode"]
342
+
343
+ if mode not in {"vorticity", "magnitude"}:
344
+ self._logger.warning(
345
+ f"Unknown vector field visualization mode '{mode}'."
346
+ " Supported modes are: 'vorticity', 'magnitude'."
347
+ " The vector field visualization will not be saved."
348
+ )
349
+ else:
350
+ save_numpy_vector_field_visualization(
351
+ event["payload"],
352
+ dir=self._vector_fields_dir,
353
+ name=f"{vector_field_name}_visualization",
354
+ mode=mode,
355
+ add_colorbar=add_colorbar,
356
+ )
357
+
358
+ vector_field_path = self._vector_fields_dir / Path(f"{vector_field_name}.npy")
359
+ np.save(vector_field_path, event["payload"])
360
+
361
+ event["payload"] = str(vector_field_path.relative_to(self._base_path))
362
+ return event
363
+
364
+ def _save_histogram_to_file(self, event: dict) -> Optional[dict]:
365
+ """Save histogram data to file and update event with file path.
366
+
367
+ Args:
368
+ event (dict): Event dictionary.
369
+
370
+ Returns:
371
+ dict: Updated event with file path instead of raw data.
372
+
373
+ """
374
+ histogram_name = str(uuid4())
375
+ if event["extra"] and "name" in event["extra"]:
376
+ histogram_name = event["extra"]["name"]
377
+
378
+ histogram_path = self._histograms_dir / Path(f"{histogram_name}.npy")
379
+ np.save(histogram_path, event["payload"])
380
+
381
+ event["payload"] = str(histogram_path.relative_to(self._base_path))
382
+ return event