PyOPIA 2.13.1__tar.gz → 2.15.0__tar.gz

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.
Files changed (36) hide show
  1. {pyopia-2.13.1 → pyopia-2.15.0}/PKG-INFO +3 -2
  2. pyopia-2.15.0/pyopia/__init__.py +1 -0
  3. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/cf_metadata.json +49 -1
  4. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/cli.py +24 -0
  5. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/io.py +3 -0
  6. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/process.py +7 -1
  7. pyopia-2.15.0/pyopia/realtime.py +229 -0
  8. pyopia-2.15.0/pyopia/tests/test_realtime.py +331 -0
  9. {pyopia-2.13.1 → pyopia-2.15.0}/pyproject.toml +2 -1
  10. pyopia-2.13.1/pyopia/__init__.py +0 -1
  11. {pyopia-2.13.1 → pyopia-2.15.0}/.gitignore +0 -0
  12. {pyopia-2.13.1 → pyopia-2.15.0}/LICENSE +0 -0
  13. {pyopia-2.13.1 → pyopia-2.15.0}/README.md +0 -0
  14. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/auxillarydata.py +0 -0
  15. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/background.py +0 -0
  16. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/classify.py +0 -0
  17. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/dataexport/__init__.py +0 -0
  18. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/dataexport/ecotaxa.py +0 -0
  19. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/exampledata.py +0 -0
  20. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/instrument/__init__.py +0 -0
  21. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/instrument/common.py +0 -0
  22. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/instrument/holo.py +0 -0
  23. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/instrument/silcam.py +0 -0
  24. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/instrument/uvp.py +0 -0
  25. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/metadata.py +0 -0
  26. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/pipeline.py +0 -0
  27. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/plotting.py +0 -0
  28. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/simulator/__init__.py +0 -0
  29. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/simulator/silcam.py +0 -0
  30. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/statistics.py +0 -0
  31. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/tests/__init__.py +0 -0
  32. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/tests/test_auxillarydata.py +0 -0
  33. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/tests/test_classify.py +0 -0
  34. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/tests/test_io.py +0 -0
  35. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/tests/test_notebooks.py +0 -0
  36. {pyopia-2.13.1 → pyopia-2.15.0}/pyopia/tests/test_pipeline.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyOPIA
3
- Version: 2.13.1
3
+ Version: 2.15.0
4
4
  Summary: A Python Ocean Particle Image Analysis toolbox.
5
5
  Project-URL: Repository, https://github.com/sintef/pyopia
6
6
  Project-URL: Documentation, https://pyopia.readthedocs.io
7
7
  Author-email: Emlyn Davies <emlyn.davies@sintef.no>, "Alex Nimmo Smith@plymouth.ac.uk" <alex.nimmo.smith@plymouth.ac.uk>
8
8
  License-File: LICENSE
9
9
  Keywords: Imaging,Measurement,Ocean,Particles,Size distribution
10
- Requires-Python: ~=3.12
10
+ Requires-Python: ~=3.12.0
11
11
  Requires-Dist: click<8.2.0
12
12
  Requires-Dist: cmocean<4,>=3.0.3
13
13
  Requires-Dist: dask>=2024.8.1
@@ -41,6 +41,7 @@ Requires-Dist: toml<0.11,>=0.10.2
41
41
  Requires-Dist: tqdm<5,>=4.66.1
42
42
  Requires-Dist: typer[all]<0.10,>=0.9.0
43
43
  Requires-Dist: urllib3<2.0
44
+ Requires-Dist: watchdog>=6.0.0
44
45
  Requires-Dist: xarray<2024,>=2023.12.0
45
46
  Provides-Extra: classification
46
47
  Requires-Dist: keras==3.9.1; extra == 'classification'
@@ -0,0 +1 @@
1
+ __version__ = "2.15.0"
@@ -62,4 +62,52 @@
62
62
  "standard_name": "timestamp",
63
63
  "long_name": "Timestamp of particle observation",
64
64
  "calculation_method": "Recorded during particle observation",
65
- "pyopia_process_level": 0}}
65
+ "pyopia_process_level": 0},
66
+ "filename": {
67
+ "standard_name": "filename",
68
+ "long_name": "Filename of the raw image",
69
+ "units": "",
70
+ "calculation_method": "Recorded during image loading",
71
+ "pyopia_process_level": 0},
72
+ "particle_count": {
73
+ "standard_name": "particle_count",
74
+ "long_name": "Number of particles detected in the image",
75
+ "units": "1",
76
+ "calculation_method": "Count of particles from segmentation",
77
+ "pyopia_process_level": 1},
78
+ "saturation": {
79
+ "standard_name": "image_saturation",
80
+ "long_name": "Percentage saturation of the image",
81
+ "units": "percent",
82
+ "calculation_method": "Computed as the percentage of the image covered by particles relative to the maximum acceptable coverage",
83
+ "pyopia_process_level": 1},
84
+ "d50": {
85
+ "standard_name": "median_particle_diameter",
86
+ "long_name": "Median particle diameter (D50) from volume distribution",
87
+ "units": "micrometers",
88
+ "calculation_method": "Calculated from volume-based particle size distribution",
89
+ "pyopia_process_level": 2},
90
+ "nc": {
91
+ "standard_name": "number_concentration",
92
+ "long_name": "Number concentration of particles",
93
+ "units": "L^-1",
94
+ "calculation_method": "Calculated from particle count and sample volume",
95
+ "pyopia_process_level": 2},
96
+ "vc": {
97
+ "standard_name": "volume_concentration",
98
+ "long_name": "Volume concentration of particles",
99
+ "units": "uL L^-1",
100
+ "calculation_method": "Calculated from particle volumes and sample volume",
101
+ "pyopia_process_level": 2},
102
+ "sample_volume": {
103
+ "standard_name": "sample_volume",
104
+ "long_name": "Volume of water sampled",
105
+ "units": "L",
106
+ "calculation_method": "Calculated from image area, pixel size, and path length",
107
+ "pyopia_process_level": 2},
108
+ "junge": {
109
+ "standard_name": "junge_parameter",
110
+ "long_name": "Junge parameter (slope of particle size number distribution)",
111
+ "units": "1",
112
+ "calculation_method": "Fitted slope of particle size number distribution between 150-350 µm",
113
+ "pyopia_process_level": 2}}
@@ -42,6 +42,7 @@ import pyopia.auxillarydata
42
42
  import pyopia.exampledata
43
43
  import pyopia.metadata
44
44
  import pyopia.dataexport.ecotaxa
45
+ import pyopia.realtime
45
46
 
46
47
  app = typer.Typer()
47
48
 
@@ -350,6 +351,29 @@ def process(config_filename: str, num_chunks: int = 1, strategy: str = "block"):
350
351
  progress.console.print(f"[blue]PROCESSING COMPLETED IN {time_total}")
351
352
 
352
353
 
354
+ @app.command()
355
+ def process_realtime(config_filename: str, watch_folder: str = None):
356
+ """Run a PyOPIA processing pipeline in realtime by watching a folder.
357
+
358
+ Parameters
359
+ ----------
360
+ config_filename : str
361
+ Config filename
362
+
363
+ watch_folder : str, optional
364
+ Folder to monitor. If not provided, inferred from `general.raw_files` in config.
365
+
366
+ Notes
367
+ -----
368
+ - Single-core only: files are processed sequentially by a single worker thread.
369
+ - Uses `watchdog` moved events only (rename into place) to avoid half-written files.
370
+ """
371
+ # Load config and setup logging
372
+ pipeline_config = pyopia.io.load_toml(config_filename)
373
+ setup_logging(pipeline_config)
374
+ pyopia.realtime.run_realtime(pipeline_config, watch_folder=watch_folder)
375
+
376
+
353
377
  @app.command()
354
378
  def merge_mfdata(
355
379
  path_to_data: str,
@@ -128,6 +128,9 @@ def write_stats(
128
128
  # Add auxillary data to ximage_stats
129
129
  ximage_stats = auxillary_data.add_auxillary_data_to_xstats(ximage_stats)
130
130
 
131
+ # Add CF-compliant attributes
132
+ ximage_stats = add_cf_attributes(ximage_stats)
133
+
131
134
  encoding_imagestats = setup_xstats_encoding(ximage_stats)
132
135
  ximage_stats.to_netcdf(
133
136
  datafilename + "-STATS.nc",
@@ -639,7 +639,13 @@ class CalculateImageStats():
639
639
 
640
640
  path_length = getattr(data['settings']['general'], 'path_length', 40)
641
641
  if path_length is not None:
642
- nc_vc = pyopia.statistics.nc_vc_from_stats(data['stats'], pixel_size, path_length)
642
+ # Get image dimensions from the corrected image
643
+ image = data.get('imraw')
644
+ if image is not None:
645
+ imy, imx = image.shape[:2]
646
+ else:
647
+ imx, imy = 2048, 2448 # fallback defaults
648
+ nc_vc = pyopia.statistics.nc_vc_from_stats(data['stats'], pixel_size, path_length, imx=imx, imy=imy)
643
649
  for k, v in zip(['nc', 'vc', 'sample_volume', 'junge'], nc_vc):
644
650
  data['image_stats'].loc[data['timestamp'], k] = v
645
651
 
@@ -0,0 +1,229 @@
1
+ """Utilities for running PyOPIA pipelines in realtime mode."""
2
+
3
+ import fnmatch
4
+ import logging
5
+ import pathlib
6
+ import queue
7
+ import threading
8
+ import time
9
+
10
+ import pandas as pd
11
+ from rich import print as rich_print
12
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
13
+ from watchdog.events import FileSystemEventHandler
14
+ from watchdog.observers import Observer
15
+
16
+ import pyopia.pipeline
17
+
18
+
19
+ def _resolve_watch_settings(raw_files_pattern: str, watch_folder: str | None) -> tuple[str, str]:
20
+ raw_path = pathlib.Path(raw_files_pattern)
21
+ file_pattern = raw_path.name if raw_path.name else "*"
22
+ inferred_watch = str(raw_path.parent) if str(raw_path.parent) != "." else "."
23
+
24
+ if watch_folder is None:
25
+ watch_folder = inferred_watch
26
+
27
+ return watch_folder, file_pattern
28
+
29
+
30
+ def _enqueue_file_if_new(
31
+ file_path: pathlib.Path,
32
+ file_queue: queue.Queue,
33
+ file_pattern: str,
34
+ seen_files: set[str],
35
+ seen_lock: threading.Lock,
36
+ ) -> bool:
37
+ if not file_path.exists() or file_path.is_dir():
38
+ return False
39
+
40
+ if not fnmatch.fnmatch(file_path.name, file_pattern):
41
+ return False
42
+
43
+ file_key = file_path.as_posix()
44
+ with seen_lock:
45
+ if file_key in seen_files:
46
+ return False
47
+ seen_files.add(file_key)
48
+
49
+ file_queue.put(file_path)
50
+ return True
51
+
52
+
53
+ def _enqueue_existing_files(
54
+ watch_folder: str,
55
+ file_pattern: str,
56
+ file_queue: queue.Queue,
57
+ seen_files: set[str],
58
+ seen_lock: threading.Lock,
59
+ logger: logging.Logger,
60
+ ):
61
+ for file_path in sorted(pathlib.Path(watch_folder).glob(file_pattern)):
62
+ if _enqueue_file_if_new(file_path, file_queue, file_pattern, seen_files, seen_lock):
63
+ logger.info(f"Existing file queued: {file_path}")
64
+
65
+
66
+ def _build_event_handler(
67
+ file_queue: queue.Queue,
68
+ file_pattern: str,
69
+ seen_files: set[str],
70
+ seen_lock: threading.Lock,
71
+ logger: logging.Logger,
72
+ ):
73
+ class NewFileHandler(FileSystemEventHandler):
74
+ def _handle_path(self, path):
75
+ try:
76
+ file_path = pathlib.Path(path)
77
+ if _enqueue_file_if_new(
78
+ file_path,
79
+ file_queue,
80
+ file_pattern,
81
+ seen_files,
82
+ seen_lock,
83
+ ):
84
+ logger.info(f"New file detected: {file_path}")
85
+ except Exception:
86
+ logger.exception("Error handling filesystem event")
87
+
88
+ def on_moved(self, event):
89
+ destination = getattr(event, "dest_path", None)
90
+ if destination:
91
+ self._handle_path(destination)
92
+
93
+ return NewFileHandler()
94
+
95
+
96
+ def _worker_loop(
97
+ stop_event: threading.Event,
98
+ file_queue: queue.Queue,
99
+ processing_pipeline: pyopia.pipeline.Pipeline,
100
+ logger: logging.Logger,
101
+ runtime_state: dict,
102
+ state_lock: threading.Lock,
103
+ ):
104
+ while not stop_event.is_set():
105
+ try:
106
+ filepath = file_queue.get(timeout=1)
107
+ except queue.Empty:
108
+ continue
109
+
110
+ try:
111
+ with state_lock:
112
+ runtime_state["current_file"] = filepath.name
113
+
114
+ start = time.time()
115
+ logger.info(f"Starting processing: {filepath.name}")
116
+ processing_pipeline.run(filepath.as_posix())
117
+ elapsed = time.time() - start
118
+ logger.info(f"Completed {filepath.name} in {elapsed:.1f}s")
119
+ with state_lock:
120
+ runtime_state["processed_count"] += 1
121
+ except Exception as exc:
122
+ logger.exception(f"Error processing {filepath}: {exc}")
123
+ finally:
124
+ with state_lock:
125
+ runtime_state["current_file"] = "idle"
126
+ file_queue.task_done()
127
+
128
+
129
+ def run_realtime(pipeline_config: dict, watch_folder: str | None = None):
130
+ """Run a PyOPIA processing pipeline in realtime by watching a folder.
131
+
132
+ Parameters
133
+ ----------
134
+ pipeline_config : dict
135
+ Loaded PyOPIA pipeline config.
136
+ watch_folder : str, optional
137
+ Folder to monitor. If not provided, inferred from ``general.raw_files``.
138
+ """
139
+ logger = logging.getLogger("rich")
140
+ logger.info(f"PyOPIA realtime process started {pd.Timestamp.now()}")
141
+ t1 = time.time()
142
+
143
+ raw_files_pattern = pipeline_config["general"].get("raw_files", "*")
144
+ watch_folder, file_pattern = _resolve_watch_settings(raw_files_pattern, watch_folder)
145
+
146
+ processing_pipeline = pyopia.pipeline.Pipeline(pipeline_config)
147
+
148
+ file_queue = queue.Queue()
149
+ stop_event = threading.Event()
150
+ seen_files: set[str] = set()
151
+ seen_lock = threading.Lock()
152
+ runtime_state = {"processed_count": 0, "current_file": "idle"}
153
+ state_lock = threading.Lock()
154
+
155
+ observer = Observer()
156
+ handler = _build_event_handler(
157
+ file_queue,
158
+ file_pattern,
159
+ seen_files,
160
+ seen_lock,
161
+ logger,
162
+ )
163
+ observer.schedule(handler, path=watch_folder, recursive=False)
164
+ observer.start()
165
+
166
+ _enqueue_existing_files(
167
+ watch_folder,
168
+ file_pattern,
169
+ file_queue,
170
+ seen_files,
171
+ seen_lock,
172
+ logger,
173
+ )
174
+
175
+ worker_thread = threading.Thread(
176
+ target=_worker_loop,
177
+ args=(
178
+ stop_event,
179
+ file_queue,
180
+ processing_pipeline,
181
+ logger,
182
+ runtime_state,
183
+ state_lock,
184
+ ),
185
+ daemon=True,
186
+ )
187
+ worker_thread.start()
188
+
189
+ logger.info(
190
+ f"Watching folder {watch_folder} for moved files matching '{file_pattern}'"
191
+ )
192
+ rich_print(
193
+ f"[blue]Realtime started. Watching {watch_folder} for moved files matching '{file_pattern}'"
194
+ )
195
+
196
+ with Progress(
197
+ SpinnerColumn(),
198
+ TextColumn("{task.description}"),
199
+ BarColumn(),
200
+ TimeElapsedColumn(),
201
+ transient=True,
202
+ ) as progress:
203
+ task_id = progress.add_task("[blue]Realtime active", total=None)
204
+
205
+ try:
206
+ while True:
207
+ with state_lock:
208
+ processed_count = runtime_state["processed_count"]
209
+ current_file = runtime_state["current_file"]
210
+ progress.update(
211
+ task_id,
212
+ description=(
213
+ "[blue]Realtime active"
214
+ f" | processed: {processed_count}"
215
+ f" | queued: {file_queue.qsize()}"
216
+ f" | current: {current_file}"
217
+ ),
218
+ )
219
+ time.sleep(1)
220
+ except KeyboardInterrupt:
221
+ logger.info("Shutdown requested, stopping observer and worker...")
222
+ finally:
223
+ stop_event.set()
224
+ observer.stop()
225
+ observer.join()
226
+ worker_thread.join(timeout=5)
227
+
228
+ time_total = pd.to_timedelta(time.time() - t1, "seconds")
229
+ progress.console.print(f"[blue]REALTIME PROCESSING STOPPED AFTER {time_total}")
@@ -0,0 +1,331 @@
1
+ import logging
2
+ import queue
3
+ import threading
4
+ from pathlib import Path
5
+
6
+ import pyopia.realtime
7
+
8
+
9
+ class _DummyObserver:
10
+ def __init__(self):
11
+ self.scheduled_path = None
12
+ self.recursive = None
13
+ self.started = False
14
+ self.stopped = False
15
+ self.joined = False
16
+
17
+ def schedule(self, _handler, path, recursive):
18
+ self.scheduled_path = path
19
+ self.recursive = recursive
20
+
21
+ def start(self):
22
+ self.started = True
23
+
24
+ def stop(self):
25
+ self.stopped = True
26
+
27
+ def join(self):
28
+ self.joined = True
29
+
30
+
31
+ class _DummyThread:
32
+ def __init__(self, target, args, daemon):
33
+ self.target = target
34
+ self.args = args
35
+ self.daemon = daemon
36
+ self.started = False
37
+ self.join_timeout = None
38
+
39
+ def start(self):
40
+ self.started = True
41
+
42
+ def join(self, timeout=None):
43
+ self.join_timeout = timeout
44
+
45
+
46
+ class _DummyProgress:
47
+ def __init__(self, *args, transient=True):
48
+ self.args = args
49
+ self.transient = transient
50
+ self.console = self
51
+ self.task_id = 1
52
+ self.descriptions = []
53
+
54
+ def __enter__(self):
55
+ return self
56
+
57
+ def __exit__(self, exc_type, exc, tb):
58
+ return False
59
+
60
+ def print(self, *_args, **_kwargs):
61
+ return None
62
+
63
+ def add_task(self, description, total=None):
64
+ self.descriptions.append(description)
65
+ return self.task_id
66
+
67
+ def update(self, _task_id, description=None):
68
+ if description is not None:
69
+ self.descriptions.append(description)
70
+
71
+
72
+ class _DummyPipeline:
73
+ def __init__(self, config):
74
+ self.config = config
75
+
76
+
77
+ def _patch_realtime_runtime(monkeypatch, created):
78
+ def observer_factory():
79
+ created["observer"] = _DummyObserver()
80
+ return created["observer"]
81
+
82
+ def thread_factory(target, args, daemon):
83
+ created["thread"] = _DummyThread(target, args, daemon)
84
+ return created["thread"]
85
+
86
+ def raise_keyboard_interrupt(_seconds):
87
+ raise KeyboardInterrupt()
88
+
89
+ monkeypatch.setattr(pyopia.realtime, "Observer", observer_factory)
90
+ monkeypatch.setattr(pyopia.realtime.threading, "Thread", thread_factory)
91
+ monkeypatch.setattr(pyopia.realtime, "Progress", _DummyProgress)
92
+ monkeypatch.setattr(pyopia.realtime.pyopia.pipeline, "Pipeline", _DummyPipeline)
93
+ monkeypatch.setattr(pyopia.realtime.time, "sleep", raise_keyboard_interrupt)
94
+
95
+
96
+ def test_resolve_watch_settings_infers_from_raw_pattern(tmp_path: Path):
97
+ images_dir = tmp_path / "images"
98
+ images_dir.mkdir()
99
+
100
+ raw_pattern = str(images_dir / "*.silc")
101
+ watch_folder, file_pattern = pyopia.realtime._resolve_watch_settings(raw_pattern, None)
102
+
103
+ assert watch_folder == str(images_dir)
104
+ assert file_pattern == "*.silc"
105
+
106
+
107
+ def test_resolve_watch_settings_prefers_explicit_watch_folder(tmp_path: Path):
108
+ images_dir = tmp_path / "images"
109
+ images_dir.mkdir()
110
+
111
+ raw_pattern = str(images_dir / "*.silc")
112
+ watch_folder, file_pattern = pyopia.realtime._resolve_watch_settings(
113
+ raw_pattern,
114
+ watch_folder="/custom/watch",
115
+ )
116
+
117
+ assert watch_folder == "/custom/watch"
118
+ assert file_pattern == "*.silc"
119
+
120
+
121
+ def test_event_handler_enqueues_only_matching_moved_files(tmp_path: Path):
122
+ file_queue = queue.Queue()
123
+ logger = logging.getLogger("test")
124
+ seen_files = set()
125
+ seen_lock = threading.Lock()
126
+ handler = pyopia.realtime._build_event_handler(
127
+ file_queue,
128
+ "*.silc",
129
+ seen_files,
130
+ seen_lock,
131
+ logger,
132
+ )
133
+
134
+ matched = tmp_path / "image.silc"
135
+ matched.write_text("ok")
136
+ ignored = tmp_path / "other.txt"
137
+ ignored.write_text("skip")
138
+
139
+ moved_event_match = type("Event", (), {"dest_path": str(matched)})()
140
+ moved_event_no_match = type("Event", (), {"dest_path": str(ignored)})()
141
+
142
+ handler.on_moved(moved_event_match)
143
+ handler.on_moved(moved_event_no_match)
144
+
145
+ queued = file_queue.get_nowait()
146
+ assert queued == matched
147
+ assert file_queue.empty()
148
+
149
+
150
+ def test_event_handler_deduplicates_same_moved_file(tmp_path: Path):
151
+ file_queue = queue.Queue()
152
+ logger = logging.getLogger("test")
153
+ seen_files = set()
154
+ seen_lock = threading.Lock()
155
+ handler = pyopia.realtime._build_event_handler(
156
+ file_queue,
157
+ "*.silc",
158
+ seen_files,
159
+ seen_lock,
160
+ logger,
161
+ )
162
+
163
+ matched = tmp_path / "image.silc"
164
+ matched.write_text("ok")
165
+ moved_event = type("Event", (), {"dest_path": str(matched)})()
166
+
167
+ handler.on_moved(moved_event)
168
+ handler.on_moved(moved_event)
169
+
170
+ queued = file_queue.get_nowait()
171
+ assert queued == matched
172
+ assert file_queue.empty()
173
+
174
+
175
+ def test_enqueue_existing_files_matches_pattern_and_deduplicates(tmp_path: Path):
176
+ file_queue = queue.Queue()
177
+ seen_files = set()
178
+ seen_lock = threading.Lock()
179
+ logger = logging.getLogger("test")
180
+
181
+ matched = tmp_path / "a.silc"
182
+ matched.write_text("ok")
183
+ ignored = tmp_path / "b.txt"
184
+ ignored.write_text("skip")
185
+
186
+ pyopia.realtime._enqueue_existing_files(
187
+ str(tmp_path),
188
+ "*.silc",
189
+ file_queue,
190
+ seen_files,
191
+ seen_lock,
192
+ logger,
193
+ )
194
+ pyopia.realtime._enqueue_existing_files(
195
+ str(tmp_path),
196
+ "*.silc",
197
+ file_queue,
198
+ seen_files,
199
+ seen_lock,
200
+ logger,
201
+ )
202
+
203
+ queued = file_queue.get_nowait()
204
+ assert queued == matched
205
+ assert file_queue.empty()
206
+
207
+
208
+ def test_integration_existing_then_moved_files_processed_once(tmp_path: Path):
209
+ file_queue = queue.Queue()
210
+ seen_files = set()
211
+ seen_lock = threading.Lock()
212
+ logger = logging.getLogger("test")
213
+
214
+ existing = tmp_path / "existing.silc"
215
+ existing.write_text("old")
216
+ moved_in = tmp_path / "moved.silc"
217
+ moved_in.write_text("new")
218
+
219
+ pyopia.realtime._enqueue_existing_files(
220
+ str(tmp_path),
221
+ "*.silc",
222
+ file_queue,
223
+ seen_files,
224
+ seen_lock,
225
+ logger,
226
+ )
227
+
228
+ handler = pyopia.realtime._build_event_handler(
229
+ file_queue,
230
+ "*.silc",
231
+ seen_files,
232
+ seen_lock,
233
+ logger,
234
+ )
235
+ handler.on_moved(type("Event", (), {"dest_path": str(moved_in)})())
236
+ handler.on_moved(type("Event", (), {"dest_path": str(existing)})())
237
+
238
+ stop_event = threading.Event()
239
+ processed = []
240
+ runtime_state = {"processed_count": 0, "current_file": "idle"}
241
+ state_lock = threading.Lock()
242
+
243
+ class DummyPipeline:
244
+ def run(self, filename):
245
+ processed.append(filename)
246
+ if len(processed) == 2:
247
+ stop_event.set()
248
+
249
+ pyopia.realtime._worker_loop(
250
+ stop_event=stop_event,
251
+ file_queue=file_queue,
252
+ processing_pipeline=DummyPipeline(),
253
+ logger=logger,
254
+ runtime_state=runtime_state,
255
+ state_lock=state_lock,
256
+ )
257
+
258
+ assert set(processed) == {existing.as_posix(), moved_in.as_posix()}
259
+ assert runtime_state["processed_count"] == 2
260
+
261
+
262
+ def test_worker_loop_processes_one_file_and_uses_posix_path(tmp_path: Path):
263
+ class DummyPipeline:
264
+ def __init__(self):
265
+ self.processed = []
266
+
267
+ def run(self, filename):
268
+ self.processed.append(filename)
269
+ stop_event.set()
270
+
271
+ image_file = tmp_path / "sample.silc"
272
+ image_file.write_text("content")
273
+
274
+ stop_event = threading.Event()
275
+ file_queue = queue.Queue()
276
+ file_queue.put(image_file)
277
+ pipeline = DummyPipeline()
278
+ runtime_state = {"processed_count": 0, "current_file": "idle"}
279
+ state_lock = threading.Lock()
280
+
281
+ pyopia.realtime._worker_loop(
282
+ stop_event=stop_event,
283
+ file_queue=file_queue,
284
+ processing_pipeline=pipeline,
285
+ logger=logging.getLogger("test"),
286
+ runtime_state=runtime_state,
287
+ state_lock=state_lock,
288
+ )
289
+
290
+ assert pipeline.processed == [image_file.as_posix()]
291
+ assert runtime_state["processed_count"] == 1
292
+ assert runtime_state["current_file"] == "idle"
293
+
294
+
295
+ def test_run_realtime_schedules_observer_and_stops_on_keyboard_interrupt(monkeypatch, tmp_path: Path):
296
+ images_dir = tmp_path / "incoming"
297
+ images_dir.mkdir()
298
+ pipeline_config = {
299
+ "general": {
300
+ "raw_files": str(images_dir / "*.silc"),
301
+ },
302
+ "steps": {},
303
+ }
304
+
305
+ created = {}
306
+ progress_calls = {"added": 0, "updated": 0}
307
+
308
+ class TrackingProgress(_DummyProgress):
309
+ def add_task(self, description, total=None):
310
+ progress_calls["added"] += 1
311
+ return super().add_task(description, total)
312
+
313
+ def update(self, _task_id, description=None):
314
+ progress_calls["updated"] += 1
315
+ return super().update(_task_id, description)
316
+
317
+ _patch_realtime_runtime(monkeypatch, created)
318
+ monkeypatch.setattr(pyopia.realtime, "Progress", TrackingProgress)
319
+
320
+ pyopia.realtime.run_realtime(pipeline_config)
321
+
322
+ assert created["observer"].scheduled_path == str(images_dir)
323
+ assert created["observer"].recursive is False
324
+ assert created["observer"].started is True
325
+ assert created["observer"].stopped is True
326
+ assert created["observer"].joined is True
327
+ assert created["thread"].started is True
328
+ assert created["thread"].daemon is True
329
+ assert created["thread"].join_timeout == 5
330
+ assert progress_calls["added"] == 1
331
+ assert progress_calls["updated"] >= 1
@@ -6,7 +6,7 @@ authors = [
6
6
  { name = "Emlyn Davies", email = "emlyn.davies@sintef.no" },
7
7
  { name = "Alex Nimmo Smith@plymouth.ac.uk", email = "alex.nimmo.smith@plymouth.ac.uk" },
8
8
  ]
9
- requires-python = "~=3.12"
9
+ requires-python = "~=3.12.0"
10
10
  readme = "README.md"
11
11
  keywords = [
12
12
  "Ocean",
@@ -50,6 +50,7 @@ dependencies = [
50
50
  "click<8.2.0",
51
51
  "seaborn>=0.13.2",
52
52
  "pydantic>=2.11.7",
53
+ "watchdog>=6.0.0",
53
54
  ]
54
55
 
55
56
  [project.optional-dependencies]
@@ -1 +0,0 @@
1
- __version__ = "2.13.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes