mini-arcade-core 1.2.0__py3-none-any.whl → 1.2.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.
@@ -8,11 +8,16 @@ from dataclasses import dataclass
8
8
  from datetime import datetime
9
9
  from pathlib import Path
10
10
  from typing import Optional
11
+ from uuid import uuid4
11
12
 
12
13
  from PIL import Image
13
14
 
14
15
  from mini_arcade_core.backend import Backend
15
16
  from mini_arcade_core.runtime.capture.capture_port import CapturePort
17
+ from mini_arcade_core.runtime.capture.capture_worker import (
18
+ CaptureJob,
19
+ CaptureWorker,
20
+ )
16
21
  from mini_arcade_core.utils import logger
17
22
 
18
23
 
@@ -87,9 +92,12 @@ class CaptureAdapter(CapturePort):
87
92
  self,
88
93
  backend: Backend,
89
94
  path_builder: Optional[CapturePathBuilder] = None,
95
+ worker: Optional[CaptureWorker] = None,
90
96
  ):
91
97
  self.backend = backend
92
98
  self.path_builder = path_builder or CapturePathBuilder()
99
+ self.worker = worker or CaptureWorker()
100
+ self.worker.start()
93
101
 
94
102
  def _bmp_to_image(self, bmp_path: str, out_path: str):
95
103
  img = Image.open(bmp_path)
@@ -98,23 +106,27 @@ class CaptureAdapter(CapturePort):
98
106
  def screenshot(self, label: str | None = None) -> str:
99
107
  label = label or "shot"
100
108
  out_path = self.path_builder.build(label)
101
- out_path.parent.mkdir(parents=True, exist_ok=True)
109
+ logger.critical(f"Capturing screenshot to: {out_path}")
102
110
 
103
- # If backend only saves BMP, capture to a temp bmp next to output
104
- bmp_path = out_path.with_suffix(".bmp")
111
+ # temp BMP next to output (unique)
112
+ bmp_path = out_path.with_suffix(f".{uuid4().hex}.bmp")
113
+ bmp_path.parent.mkdir(parents=True, exist_ok=True)
105
114
 
106
- self.backend.capture.bmp(str(bmp_path))
107
- if not bmp_path.exists():
108
- raise RuntimeError("Backend capture.bmp did not create BMP file")
115
+ ok_native = self.backend.capture.bmp(str(bmp_path)) # returns bool
116
+ if not ok_native or not bmp_path.exists():
117
+ raise RuntimeError("Backend capture.bmp failed to create BMP file")
109
118
 
110
- self._bmp_to_image(str(bmp_path), str(out_path))
111
- try:
112
- bmp_path.unlink(missing_ok=True)
113
- # Justification: Various exceptions can occur on file deletion
114
- # pylint: disable=broad-exception-caught
115
- except Exception:
116
- logger.warning(f"Failed to delete temporary BMP file: {bmp_path}")
117
- # pylint: enable=broad-exception-caught
119
+ job_id = uuid4().hex
120
+ ok = self.worker.enqueue(
121
+ CaptureJob(job_id=job_id, out_path=out_path, bmp_path=bmp_path)
122
+ )
123
+ if not ok:
124
+ logger.warning("Screenshot dropped: capture queue full")
125
+ # optional: cleanup temp bmp since we won't process it
126
+ try:
127
+ bmp_path.unlink(missing_ok=True)
128
+ except Exception: # pylint: disable=broad-exception-caught
129
+ pass
118
130
 
119
131
  return str(out_path)
120
132
 
@@ -127,24 +139,27 @@ class CaptureAdapter(CapturePort):
127
139
  def screenshot_sim(
128
140
  self, run_id: str, frame_index: int, label: str = "frame"
129
141
  ) -> str:
130
- """Screenshot for simulation frames."""
131
142
  out_path = self.path_builder.build_sim(run_id, frame_index, label)
132
- out_path.parent.mkdir(parents=True, exist_ok=True)
133
-
134
- bmp_path = out_path.with_suffix(".bmp")
135
- self.backend.capture.bmp(str(bmp_path))
136
143
 
137
- if not bmp_path.exists():
138
- raise RuntimeError("Backend capture.bmp did not create BMP file")
144
+ bmp_path = out_path.with_suffix(f".{uuid4().hex}.bmp")
145
+ bmp_path.parent.mkdir(parents=True, exist_ok=True)
139
146
 
140
- self._bmp_to_image(str(bmp_path), str(out_path))
147
+ ok_native = self.backend.capture.bmp(str(bmp_path))
148
+ if not ok_native or not bmp_path.exists():
149
+ raise RuntimeError("Backend capture.bmp failed to create BMP file")
141
150
 
142
- try:
143
- bmp_path.unlink(missing_ok=True)
144
- # Justification: Various exceptions can occur on file deletion
145
- # pylint: disable=broad-exception-caught
146
- except Exception:
147
- logger.warning(f"Failed to delete temporary BMP file: {bmp_path}")
148
- # pylint: enable=broad-exception-caught
151
+ job_id = f"{run_id}:{frame_index}"
152
+ ok = self.worker.enqueue(
153
+ CaptureJob(job_id=job_id, out_path=out_path, bmp_path=bmp_path)
154
+ )
155
+ if not ok:
156
+ logger.warning("Sim screenshot dropped: capture queue full")
157
+ try:
158
+ bmp_path.unlink(missing_ok=True)
159
+ # Justification: Broad exception catch for cleanup
160
+ # pylint: disable=broad-exception-caught
161
+ except Exception:
162
+ pass
163
+ # pylint: enable=broad-exception-caught
149
164
 
150
165
  return str(out_path)
@@ -0,0 +1,174 @@
1
+ """
2
+ Capture worker thread for saving screenshots.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from queue import Empty, Queue
10
+ from threading import Event, Thread
11
+ from typing import Callable, Optional
12
+
13
+ from PIL import Image
14
+
15
+ from mini_arcade_core.utils import logger
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CaptureJob:
20
+ """
21
+ Job representing a screenshot to be saved.
22
+
23
+ :ivar job_id (str): Unique identifier for the capture job.
24
+ :ivar out_path (Path): Destination path for the saved screenshot.
25
+ :ivar bmp_path (Path): Temporary path of the bitmap image to be saved.
26
+ """
27
+
28
+ job_id: str
29
+ out_path: Path
30
+ bmp_path: Path # <-- file-based now
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CaptureResult:
35
+ """
36
+ Result of a completed capture job.
37
+
38
+ :ivar job_id (str): Unique identifier for the capture job.
39
+ :ivar out_path (Path): Destination path where the screenshot was saved.
40
+ :ivar ok (bool): Whether the capture was successful.
41
+ :ivar error (Optional[str]): Error message if the capture failed.
42
+ """
43
+
44
+ job_id: str
45
+ out_path: Path
46
+ ok: bool
47
+ error: str | None = None
48
+
49
+
50
+ @dataclass
51
+ class WorkerConfig:
52
+ """
53
+ Configuration options for the CaptureWorker.
54
+
55
+ :ivar queue_size (int): Maximum number of jobs to queue.
56
+ :ivar on_done (Optional[Callable[[CaptureResult], None]]):
57
+ Optional callback invoked when a job is done.
58
+ :ivar name (str): Name of the worker thread.
59
+ :ivar daemon (bool): Whether the thread is a daemon thread.
60
+ :ivar delete_temp (bool): Whether to delete temporary bitmap files after saving.
61
+ """
62
+
63
+ queue_size: int = 64
64
+ on_done: Optional[Callable[[CaptureResult], None]] = None
65
+ name: str = "capture-worker"
66
+ daemon: bool = True
67
+ delete_temp: bool = True
68
+
69
+
70
+ class CaptureWorker:
71
+ """Capture worker thread for saving screenshots asynchronously."""
72
+
73
+ def __init__(
74
+ self,
75
+ worker_config: WorkerConfig | None = None,
76
+ ):
77
+ """
78
+ :param queue_size: Maximum number of jobs to queue.
79
+ :type queue_size: int
80
+ :param on_done: Optional callback invoked when a job is done.
81
+ :type on_done: Optional[Callable[[CaptureResult], None]]
82
+ :param name: Name of the worker thread.
83
+ :type name: str
84
+ :param daemon: Whether the thread is a daemon thread.
85
+ :type daemon: bool
86
+ :param delete_temp: Whether to delete temporary bitmap files after saving.
87
+ :type delete_temp: bool
88
+ """
89
+ if worker_config is None:
90
+ worker_config = WorkerConfig()
91
+ self._q: Queue[CaptureJob] = Queue(maxsize=worker_config.queue_size)
92
+ self._stop = Event()
93
+ self._thread = Thread(
94
+ target=self._run,
95
+ name=worker_config.name,
96
+ daemon=worker_config.daemon,
97
+ )
98
+ self._on_done = worker_config.on_done
99
+ self._delete_temp = worker_config.delete_temp
100
+
101
+ def start(self):
102
+ """Start the capture worker thread."""
103
+ if self._thread.is_alive():
104
+ return
105
+ self._stop.clear()
106
+ self._thread.start()
107
+
108
+ def stop(self):
109
+ """Stop the capture worker thread."""
110
+ self._stop.set()
111
+ if self._thread.is_alive():
112
+ self._thread.join(timeout=2.0)
113
+
114
+ def enqueue(self, job: CaptureJob) -> bool:
115
+ """
116
+ Enqueue a capture job.
117
+
118
+ :param job: CaptureJob to enqueue.
119
+ :type job: CaptureJob
120
+ :return: True if the job was enqueued successfully, False otherwise.
121
+ :rtype: bool
122
+ """
123
+ if self._stop.is_set():
124
+ return False
125
+ try:
126
+ self._q.put_nowait(job)
127
+ return True
128
+ # Justification: Queue.put_nowait can raise a broad exception
129
+ # pylint: disable=broad-exception-caught
130
+ except Exception:
131
+ return False
132
+ # pylint: enable=broad-exception-caught
133
+
134
+ def _run(self):
135
+ while not self._stop.is_set():
136
+ try:
137
+ job = self._q.get(timeout=0.1)
138
+ except Empty:
139
+ continue
140
+
141
+ try:
142
+ job.out_path.parent.mkdir(parents=True, exist_ok=True)
143
+
144
+ img = Image.open(str(job.bmp_path))
145
+ img.save(str(job.out_path))
146
+
147
+ if self._delete_temp:
148
+ try:
149
+ job.bmp_path.unlink(missing_ok=True)
150
+ except Exception: # pylint: disable=broad-exception-caught
151
+ logger.warning(
152
+ f"Failed to delete temp bmp: {job.bmp_path}"
153
+ )
154
+
155
+ res = CaptureResult(
156
+ job_id=job.job_id, out_path=job.out_path, ok=True
157
+ )
158
+
159
+ except Exception as exc: # pylint: disable=broad-exception-caught
160
+ logger.exception("CaptureWorker failed to save screenshot")
161
+ res = CaptureResult(
162
+ job_id=job.job_id,
163
+ out_path=job.out_path,
164
+ ok=False,
165
+ error=str(exc),
166
+ )
167
+
168
+ if self._on_done:
169
+ try:
170
+ self._on_done(res)
171
+ except Exception: # pylint: disable=broad-exception-caught
172
+ logger.warning("CaptureWorker on_done callback failed")
173
+
174
+ self._q.task_done()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-arcade-core
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Tiny scene-based game loop core for small arcade games.
5
5
  License: Copyright (c) 2025 Santiago Rincón
6
6
 
@@ -46,8 +46,9 @@ mini_arcade_core/runtime/audio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5
46
46
  mini_arcade_core/runtime/audio/audio_adapter.py,sha256=lnP35txPzSKX1_il0nXcK7RMF5Qp9Qhi9YMh_7LTdPM,588
47
47
  mini_arcade_core/runtime/audio/audio_port.py,sha256=jBd9WabN41uK3MHjg_1n4AOw83NivJlGE2m430WZTnk,831
48
48
  mini_arcade_core/runtime/capture/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
- mini_arcade_core/runtime/capture/capture_adapter.py,sha256=XBtiKw3AS2dzB4QogPm9kjhiQAenS25guX87tg-zK58,4882
49
+ mini_arcade_core/runtime/capture/capture_adapter.py,sha256=T3Ca7qCB4ZtGDLE5iKxSWwStMen4RTWdQEn8Zs48tqk,5447
50
50
  mini_arcade_core/runtime/capture/capture_port.py,sha256=niHi0pAo10mC9p73FxFkYBIGLOLRN0PiOvxE4Zgo5fM,1162
51
+ mini_arcade_core/runtime/capture/capture_worker.py,sha256=XAzL28jD8jnGoG3bvsd9yQW5S4Nv7licNJn94QTIzp4,5393
51
52
  mini_arcade_core/runtime/context.py,sha256=ONKQryO3KEOOqHaByxCUola07kdjrnvr4WfXwgwTobk,1777
52
53
  mini_arcade_core/runtime/file/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
54
  mini_arcade_core/runtime/file/file_adapter.py,sha256=09q7G9Qijml9d4AAjo6HLC1yuoVTjE_7xaT8apT4mk0,523
@@ -86,7 +87,7 @@ mini_arcade_core/utils/__init__.py,sha256=id1C0au8r1oIzGha42xXwnI9ojcU1hxPgto6QS
86
87
  mini_arcade_core/utils/deprecated_decorator.py,sha256=yrrW2ZqPskK-4MUTyIrMb465Wc54X2poV53ZQutZWqc,1140
87
88
  mini_arcade_core/utils/logging.py,sha256=ygKpey6nikp30PrNDP_yRs8pxPPRbsQ0ivR6LUuEn3Q,6413
88
89
  mini_arcade_core/utils/profiler.py,sha256=vLzrxDfAplgKGxpuzk4eFJx4t5DU5M3DQAn6sfS5D_4,8733
89
- mini_arcade_core-1.2.0.dist-info/METADATA,sha256=PeD3ImQ4FG5bjuYKFdLThR59fOvr-lgOS3OaF1XVRis,8188
90
- mini_arcade_core-1.2.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
91
- mini_arcade_core-1.2.0.dist-info/licenses/LICENSE,sha256=3lHAuV0584cVS5vAqi2uC6GcsVgxUijvwvtZckyvaZ4,1096
92
- mini_arcade_core-1.2.0.dist-info/RECORD,,
90
+ mini_arcade_core-1.2.1.dist-info/METADATA,sha256=me0QR43UEolQONEt9HpCqPM-R9Khq6nq9YNa2GcePtw,8188
91
+ mini_arcade_core-1.2.1.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
92
+ mini_arcade_core-1.2.1.dist-info/licenses/LICENSE,sha256=3lHAuV0584cVS5vAqi2uC6GcsVgxUijvwvtZckyvaZ4,1096
93
+ mini_arcade_core-1.2.1.dist-info/RECORD,,