starbash 0.1.9__py3-none-any.whl → 0.1.15__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.
- repo/__init__.py +1 -1
- repo/manager.py +14 -23
- repo/repo.py +52 -10
- starbash/__init__.py +10 -3
- starbash/aliases.py +145 -0
- starbash/analytics.py +3 -2
- starbash/app.py +512 -473
- starbash/check_version.py +18 -0
- starbash/commands/__init__.py +2 -1
- starbash/commands/info.py +88 -14
- starbash/commands/process.py +76 -24
- starbash/commands/repo.py +41 -68
- starbash/commands/select.py +141 -142
- starbash/commands/user.py +88 -23
- starbash/database.py +219 -112
- starbash/defaults/starbash.toml +24 -3
- starbash/exception.py +21 -0
- starbash/main.py +29 -7
- starbash/paths.py +35 -5
- starbash/processing.py +724 -0
- starbash/recipes/README.md +3 -0
- starbash/recipes/master_bias/starbash.toml +16 -19
- starbash/recipes/master_dark/starbash.toml +33 -0
- starbash/recipes/master_flat/starbash.toml +26 -18
- starbash/recipes/osc.py +190 -0
- starbash/recipes/osc_dual_duo/starbash.toml +54 -44
- starbash/recipes/osc_simple/starbash.toml +82 -0
- starbash/recipes/osc_single_duo/starbash.toml +51 -32
- starbash/recipes/seestar/starbash.toml +82 -0
- starbash/recipes/starbash.toml +30 -9
- starbash/selection.py +32 -36
- starbash/templates/repo/master.toml +7 -3
- starbash/templates/repo/processed.toml +15 -0
- starbash/templates/userconfig.toml +9 -0
- starbash/toml.py +13 -13
- starbash/tool.py +230 -96
- starbash-0.1.15.dist-info/METADATA +216 -0
- starbash-0.1.15.dist-info/RECORD +45 -0
- starbash/recipes/osc_dual_duo/starbash.py +0 -151
- starbash-0.1.9.dist-info/METADATA +0 -145
- starbash-0.1.9.dist-info/RECORD +0 -37
- {starbash-0.1.9.dist-info → starbash-0.1.15.dist-info}/WHEEL +0 -0
- {starbash-0.1.9.dist-info → starbash-0.1.15.dist-info}/entry_points.txt +0 -0
- {starbash-0.1.9.dist-info → starbash-0.1.15.dist-info}/licenses/LICENSE +0 -0
starbash/processing.py
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import itertools
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
import textwrap
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.progress import Progress, track
|
|
13
|
+
|
|
14
|
+
import starbash
|
|
15
|
+
from starbash.aliases import normalize_target_name
|
|
16
|
+
from starbash.app import Starbash
|
|
17
|
+
from starbash.database import (
|
|
18
|
+
Database,
|
|
19
|
+
SessionRow,
|
|
20
|
+
get_column_name,
|
|
21
|
+
)
|
|
22
|
+
from starbash.exception import UserHandledError
|
|
23
|
+
from starbash.paths import get_user_cache_dir
|
|
24
|
+
from starbash.tool import expand_context_unsafe, tools
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ProcessingResult:
|
|
29
|
+
target: str # normalized target name, or in the case of masters the camera or instrument id
|
|
30
|
+
sessions: list[SessionRow] = field(
|
|
31
|
+
default_factory=list
|
|
32
|
+
) # the input sessions processed to make this result
|
|
33
|
+
success: bool | None = None # false if we had an error, None if skipped
|
|
34
|
+
notes: str | None = None # notes about what happened
|
|
35
|
+
# FIXME, someday we will add information about masters/flats that were used?
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def update_processing_result(result: ProcessingResult, e: Exception | None = None) -> None:
|
|
39
|
+
"""Handle exceptions during processing and update the ProcessingResult accordingly."""
|
|
40
|
+
|
|
41
|
+
result.success = True # assume success
|
|
42
|
+
if e:
|
|
43
|
+
result.success = False
|
|
44
|
+
|
|
45
|
+
if isinstance(e, UserHandledError):
|
|
46
|
+
if e.ask_user_handled():
|
|
47
|
+
logging.debug("UserHandledError was handled.")
|
|
48
|
+
result.notes = e.__rich__() # No matter what we want to show the fault in our results
|
|
49
|
+
|
|
50
|
+
elif isinstance(e, RuntimeError):
|
|
51
|
+
# Print errors for runtimeerrors but keep processing other runs...
|
|
52
|
+
logging.error(f"Skipping run due to: {e}")
|
|
53
|
+
result.notes = "Aborted due to possible error in (alpha) code, please report a bug on our github..."
|
|
54
|
+
else:
|
|
55
|
+
# Unexpected exception - log it and re-raise
|
|
56
|
+
logging.exception("Unexpected error during processing:")
|
|
57
|
+
raise e
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ProcessingContext(tempfile.TemporaryDirectory):
|
|
61
|
+
"""For processing a set of sessions for a particular target.
|
|
62
|
+
|
|
63
|
+
Keeps a shared temporary directory for intermediate files. We expose the path to that
|
|
64
|
+
directory in context["process_dir"].
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, p: "Processing"):
|
|
68
|
+
cache_dir = get_user_cache_dir()
|
|
69
|
+
super().__init__(prefix="sbprocessing_", dir=cache_dir)
|
|
70
|
+
self.p = p
|
|
71
|
+
logging.debug(f"Created processing context at {self.name}")
|
|
72
|
+
|
|
73
|
+
self.p.init_context()
|
|
74
|
+
self.p.context["process_dir"] = self.name
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> "ProcessingContext":
|
|
77
|
+
return super().__enter__()
|
|
78
|
+
|
|
79
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
80
|
+
"""Returns true if exceptions were handled"""
|
|
81
|
+
logging.debug(f"Cleaning up processing context at {self.name}")
|
|
82
|
+
|
|
83
|
+
# unregister our process dir
|
|
84
|
+
self.p.context.pop("process_dir", None)
|
|
85
|
+
|
|
86
|
+
super().__exit__(exc_type, exc_value, traceback)
|
|
87
|
+
# return handled
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class NotEnoughFilesError(UserHandledError):
|
|
91
|
+
"""Exception raised when not enough input files are provided for a processing stage."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, message: str, files: list[str]):
|
|
94
|
+
super().__init__(message)
|
|
95
|
+
self.files = files
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Processing:
|
|
99
|
+
"""Does all heavyweight processing operations for starbash"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, sb: Starbash) -> None:
|
|
102
|
+
self.sb = sb
|
|
103
|
+
|
|
104
|
+
# We create one top-level progress context so that when various subtasks are created
|
|
105
|
+
# the progress bars stack and don't mess up our logging.
|
|
106
|
+
self.progress = Progress(console=starbash.console, refresh_per_second=2)
|
|
107
|
+
self.progress.start()
|
|
108
|
+
|
|
109
|
+
# --- Lifecycle ---
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
self.progress.stop()
|
|
112
|
+
|
|
113
|
+
# Context manager support
|
|
114
|
+
def __enter__(self) -> "Processing":
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
118
|
+
handled = False
|
|
119
|
+
self.close()
|
|
120
|
+
return handled
|
|
121
|
+
|
|
122
|
+
def _get_stages(self, name: str) -> list[dict[str, Any]]:
|
|
123
|
+
"""Get all pipeline stages defined in the merged configuration.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
List of stage definitions (dictionaries with 'name' and 'priority')
|
|
127
|
+
"""
|
|
128
|
+
# 1. Get all pipeline definitions (the `[[stages]]` tables with name and priority).
|
|
129
|
+
pipeline_definitions = self.sb.repo_manager.merged.getall(name)
|
|
130
|
+
flat_pipeline_steps = list(itertools.chain.from_iterable(pipeline_definitions))
|
|
131
|
+
|
|
132
|
+
# 2. Sort the pipeline steps by their 'priority' field.
|
|
133
|
+
try:
|
|
134
|
+
sorted_pipeline = sorted(flat_pipeline_steps, key=lambda s: s["priority"])
|
|
135
|
+
except KeyError as e:
|
|
136
|
+
# Re-raise as a ValueError with a more descriptive message.
|
|
137
|
+
raise ValueError(
|
|
138
|
+
"invalid stage definition: a stage is missing the required 'priority' key"
|
|
139
|
+
) from e
|
|
140
|
+
|
|
141
|
+
logging.debug(f"Found {len(sorted_pipeline)} pipeline steps to run in order of priority.")
|
|
142
|
+
return sorted_pipeline
|
|
143
|
+
|
|
144
|
+
def run_pipeline_step(self, step_name: str):
|
|
145
|
+
logging.info(f"--- Running pipeline step: '{step_name}' ---")
|
|
146
|
+
|
|
147
|
+
# 3. Get all available task definitions (the `[[stage]]` tables with tool, script, when).
|
|
148
|
+
task_definitions = self.sb.repo_manager.merged.getall("stage")
|
|
149
|
+
all_tasks = list(itertools.chain.from_iterable(task_definitions))
|
|
150
|
+
|
|
151
|
+
# Find all tasks that should run during this pipeline step.
|
|
152
|
+
tasks_to_run = [task for task in all_tasks if task.get("when") == step_name]
|
|
153
|
+
for task in tasks_to_run:
|
|
154
|
+
self.run_stage(task)
|
|
155
|
+
|
|
156
|
+
def process_target(self, target: str, sessions: list[SessionRow]) -> ProcessingResult:
|
|
157
|
+
"""Do processing for a particular target (i.e. all sessions for a particular object)."""
|
|
158
|
+
|
|
159
|
+
pipeline = self._get_stages("stages")
|
|
160
|
+
|
|
161
|
+
lights_step = pipeline[
|
|
162
|
+
0
|
|
163
|
+
] # FIXME super nasty - we assume the array is exactly these two elements
|
|
164
|
+
stack_step = pipeline[1]
|
|
165
|
+
task_exception: Exception | None = None
|
|
166
|
+
|
|
167
|
+
result = ProcessingResult(target=target, sessions=sessions)
|
|
168
|
+
|
|
169
|
+
with ProcessingContext(self):
|
|
170
|
+
try:
|
|
171
|
+
# target specific processing here
|
|
172
|
+
|
|
173
|
+
# we find our recipe while processing our first light frame session
|
|
174
|
+
recipe = None
|
|
175
|
+
|
|
176
|
+
# process all light frames
|
|
177
|
+
step = lights_step
|
|
178
|
+
lights_task = self.progress.add_task("Processing session...", total=len(sessions))
|
|
179
|
+
try:
|
|
180
|
+
lights_processed = False # for better reporting
|
|
181
|
+
stack_processed = False
|
|
182
|
+
|
|
183
|
+
for session in sessions:
|
|
184
|
+
step_name = step["name"]
|
|
185
|
+
if not recipe:
|
|
186
|
+
# for the time being: The first step in the pipeline MUST be "light"
|
|
187
|
+
recipe = self.sb.get_recipe_for_session(session, step)
|
|
188
|
+
if not recipe:
|
|
189
|
+
continue # No recipe found for this target/session
|
|
190
|
+
|
|
191
|
+
# find the task for this step
|
|
192
|
+
task = None
|
|
193
|
+
if recipe:
|
|
194
|
+
task = recipe.get("recipe.stage." + step_name)
|
|
195
|
+
|
|
196
|
+
if task:
|
|
197
|
+
# put all relevant session info into context
|
|
198
|
+
self.set_session_in_context(session)
|
|
199
|
+
|
|
200
|
+
# The following operation might take a long time, so give the user some more info...
|
|
201
|
+
self.progress.update(
|
|
202
|
+
lights_task,
|
|
203
|
+
description=f"Processing {step_name} {self.context['date']}...",
|
|
204
|
+
)
|
|
205
|
+
try:
|
|
206
|
+
self.run_stage(task)
|
|
207
|
+
lights_processed = True
|
|
208
|
+
except NotEnoughFilesError:
|
|
209
|
+
logging.warning(
|
|
210
|
+
"Skipping session, siril requires at least two frames per session..."
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# We made progress - call once per iteration ;-)
|
|
214
|
+
self.progress.advance(lights_task)
|
|
215
|
+
finally:
|
|
216
|
+
self.progress.remove_task(lights_task)
|
|
217
|
+
|
|
218
|
+
# after all light frames are processed, do the stacking
|
|
219
|
+
step = stack_step
|
|
220
|
+
if recipe:
|
|
221
|
+
task = recipe.get("recipe.stage." + step["name"])
|
|
222
|
+
|
|
223
|
+
if task:
|
|
224
|
+
#
|
|
225
|
+
# FIXME - eventually we should allow hashing or somesuch to keep reusing processing
|
|
226
|
+
# dirs for particular targets?
|
|
227
|
+
try:
|
|
228
|
+
self.run_stage(task)
|
|
229
|
+
stack_processed = True
|
|
230
|
+
except NotEnoughFilesError:
|
|
231
|
+
logging.warning(
|
|
232
|
+
"Skipping stacking, siril requires at least two frames per session..."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Success! we processed all lights and did a stack (probably)
|
|
236
|
+
if not lights_processed:
|
|
237
|
+
result.notes = "Skipped, no suitable recipe found for light frames..."
|
|
238
|
+
elif not stack_processed:
|
|
239
|
+
result.notes = "Skipped, no suitable recipe found for stacking..."
|
|
240
|
+
else:
|
|
241
|
+
update_processing_result(result)
|
|
242
|
+
except Exception as e:
|
|
243
|
+
task_exception = e
|
|
244
|
+
update_processing_result(result, task_exception)
|
|
245
|
+
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
def run_all_stages(self) -> list[ProcessingResult]:
|
|
249
|
+
"""On the currently active session, run all processing stages
|
|
250
|
+
|
|
251
|
+
* for each target in the current selection:
|
|
252
|
+
* select ONE recipe for processing that target (check recipe.auto.require.* conditions)
|
|
253
|
+
* init session context (it will be shared for all following steps) - via ProcessingContext
|
|
254
|
+
* create a temporary processing directory (for intermediate files - shared by all stages)
|
|
255
|
+
* create a processed output directory (for high value final files) - via run_stage()
|
|
256
|
+
* iterate over all light frame sessions in the current selection
|
|
257
|
+
* for each session:
|
|
258
|
+
* update context input and output files
|
|
259
|
+
* run session.light stages
|
|
260
|
+
* after all sessions are processed, run final.stack stages (using the shared context and temp dir)
|
|
261
|
+
|
|
262
|
+
"""
|
|
263
|
+
sessions = self.sb.search_session()
|
|
264
|
+
targets = {
|
|
265
|
+
normalize_target_name(obj)
|
|
266
|
+
for s in sessions
|
|
267
|
+
if (obj := s.get(get_column_name(Database.OBJECT_KEY))) is not None
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
target_task = self.progress.add_task("Processing targets...", total=len(targets))
|
|
271
|
+
|
|
272
|
+
results: list[ProcessingResult] = []
|
|
273
|
+
try:
|
|
274
|
+
for target in targets:
|
|
275
|
+
self.progress.update(target_task, description=f"Processing target {target}...")
|
|
276
|
+
# select sessions for this target
|
|
277
|
+
target_sessions = self.sb.filter_sessions_by_target(sessions, target)
|
|
278
|
+
|
|
279
|
+
# we only want sessions with light frames
|
|
280
|
+
target_sessions = self.sb.filter_sessions_with_lights(target_sessions)
|
|
281
|
+
|
|
282
|
+
if target_sessions:
|
|
283
|
+
result = self.process_target(target, target_sessions)
|
|
284
|
+
results.append(result)
|
|
285
|
+
|
|
286
|
+
# We made progress - call once per iteration ;-)
|
|
287
|
+
self.progress.advance(target_task)
|
|
288
|
+
finally:
|
|
289
|
+
self.progress.remove_task(target_task)
|
|
290
|
+
|
|
291
|
+
return results
|
|
292
|
+
|
|
293
|
+
def run_master_stages(self) -> list[ProcessingResult]:
|
|
294
|
+
"""Generate any missing master frames
|
|
295
|
+
|
|
296
|
+
Steps:
|
|
297
|
+
* loop across all pipeline stages, first bias, then dark, then flat, etc... Very important that bias is before flat.
|
|
298
|
+
* set all_tasks to be all tasks for when == "setup.master.bias"
|
|
299
|
+
* loop over all currently unfiltered sessions
|
|
300
|
+
* if task input.type == the imagetyp for this current session
|
|
301
|
+
* add_input_to_context() add the input files to the context (from the session)
|
|
302
|
+
* run_stage(task) to generate the new master frame
|
|
303
|
+
"""
|
|
304
|
+
sorted_pipeline = self._get_stages("master-stages")
|
|
305
|
+
sessions = self.sb.search_session([]) # for masters we always search everything
|
|
306
|
+
results: list[ProcessingResult] = []
|
|
307
|
+
|
|
308
|
+
# we loop over pipeline steps in the
|
|
309
|
+
for step in sorted_pipeline:
|
|
310
|
+
step_name = step.get("name")
|
|
311
|
+
if not step_name:
|
|
312
|
+
raise ValueError("Invalid pipeline step found: missing 'name' key.")
|
|
313
|
+
for session in track(sessions, description=f"Processing {step_name} for sessions..."):
|
|
314
|
+
task = None
|
|
315
|
+
recipe = self.sb.get_recipe_for_session(session, step)
|
|
316
|
+
if recipe:
|
|
317
|
+
task = recipe.get("recipe.stage." + step_name)
|
|
318
|
+
|
|
319
|
+
processing_exception: Exception | None = None
|
|
320
|
+
if task:
|
|
321
|
+
try:
|
|
322
|
+
# Create a default process dir in /tmp.
|
|
323
|
+
# FIXME - eventually we should allow hashing or somesuch to keep reusing processing
|
|
324
|
+
# dirs for particular targets?
|
|
325
|
+
with ProcessingContext(self):
|
|
326
|
+
self.set_session_in_context(session)
|
|
327
|
+
|
|
328
|
+
# we want to allow already processed masters from other apps to be imported
|
|
329
|
+
self.run_stage(task, processed_ok=True)
|
|
330
|
+
except Exception as e:
|
|
331
|
+
processing_exception = e
|
|
332
|
+
|
|
333
|
+
# for user feedback we try to tell the name of the master we made
|
|
334
|
+
target = step_name
|
|
335
|
+
if self.context.get("output"):
|
|
336
|
+
output_path = self.context.get("output", {}).get("relative_base_path")
|
|
337
|
+
if output_path:
|
|
338
|
+
target = str(output_path)
|
|
339
|
+
result = ProcessingResult(target=target, sessions=[session])
|
|
340
|
+
|
|
341
|
+
# We did one processing run. add the results
|
|
342
|
+
update_processing_result(result, processing_exception)
|
|
343
|
+
|
|
344
|
+
# if we skipped leave the result as skipped
|
|
345
|
+
results.append(result)
|
|
346
|
+
|
|
347
|
+
return results
|
|
348
|
+
|
|
349
|
+
def init_context(self) -> None:
|
|
350
|
+
"""Do common session init"""
|
|
351
|
+
|
|
352
|
+
# Context is preserved through all stages, so each stage can add new symbols to it for use by later stages
|
|
353
|
+
self.context = {}
|
|
354
|
+
|
|
355
|
+
# Update the context with runtime values.
|
|
356
|
+
runtime_context = {
|
|
357
|
+
# "masters": "/workspaces/starbash/images/masters", # FIXME find this the correct way
|
|
358
|
+
}
|
|
359
|
+
self.context.update(runtime_context)
|
|
360
|
+
|
|
361
|
+
def set_session_in_context(self, session: SessionRow) -> None:
|
|
362
|
+
"""adds to context from the indicated session:
|
|
363
|
+
|
|
364
|
+
Sets the following context variables based on the provided session:
|
|
365
|
+
* target - the normalized target name of the session
|
|
366
|
+
* instrument - the telescope ID for this session
|
|
367
|
+
* camera_id - the camera ID for this session (cameras might be moved between telescopes by users)
|
|
368
|
+
* date - the localtimezone date of the session
|
|
369
|
+
* imagetyp - the imagetyp of the session
|
|
370
|
+
* session - the current session row (joined with a typical image) (can be used to
|
|
371
|
+
find things like telescope, temperature ...)
|
|
372
|
+
* session_config - a short human readable description of the session - suitable for logs or filenames
|
|
373
|
+
"""
|
|
374
|
+
# it is okay to give them the actual session row, because we're never using it again
|
|
375
|
+
self.context["session"] = session
|
|
376
|
+
|
|
377
|
+
target = session.get(get_column_name(Database.OBJECT_KEY))
|
|
378
|
+
if target:
|
|
379
|
+
self.context["target"] = normalize_target_name(target)
|
|
380
|
+
|
|
381
|
+
# the telescope name is our instrument id
|
|
382
|
+
instrument = session.get(get_column_name(Database.TELESCOP_KEY))
|
|
383
|
+
if instrument:
|
|
384
|
+
self.context["instrument"] = instrument
|
|
385
|
+
|
|
386
|
+
# the FITS INSTRUMEN keyword is the closest thing we have to a default camera ID. FIXME, let user override
|
|
387
|
+
# if needed?
|
|
388
|
+
# It isn't in the main session columns, so we look in metadata blob
|
|
389
|
+
metadata = session.get("metadata", {})
|
|
390
|
+
camera_id = metadata.get("INSTRUME", instrument) # Fall back to the telescope name
|
|
391
|
+
if camera_id:
|
|
392
|
+
self.context["camera_id"] = camera_id
|
|
393
|
+
|
|
394
|
+
logging.debug(f"Using camera_id={camera_id}")
|
|
395
|
+
|
|
396
|
+
# The type of images in this session
|
|
397
|
+
imagetyp = session.get(get_column_name(Database.IMAGETYP_KEY))
|
|
398
|
+
if imagetyp:
|
|
399
|
+
imagetyp = self.sb.aliases.normalize(imagetyp)
|
|
400
|
+
self.context["imagetyp"] = imagetyp
|
|
401
|
+
|
|
402
|
+
# add a short human readable description of the session - suitable for logs or in filenames
|
|
403
|
+
session_config = f"{imagetyp}"
|
|
404
|
+
|
|
405
|
+
metadata = session.get("metadata", {})
|
|
406
|
+
filter = metadata.get(Database.FILTER_KEY)
|
|
407
|
+
if (imagetyp == "flat" or imagetyp == "light") and filter:
|
|
408
|
+
# we only care about filters in these cases
|
|
409
|
+
session_config += f"_{filter}"
|
|
410
|
+
if imagetyp == "dark":
|
|
411
|
+
exptime = session.get(get_column_name(Database.EXPTIME_KEY))
|
|
412
|
+
if exptime:
|
|
413
|
+
session_config += f"_{int(float(exptime))}s"
|
|
414
|
+
gain = metadata.get("GAIN")
|
|
415
|
+
if gain is not None: # gain values can be zero
|
|
416
|
+
session_config += f"_gain{gain}"
|
|
417
|
+
|
|
418
|
+
self.context["session_config"] = session_config
|
|
419
|
+
|
|
420
|
+
# a short user friendly date for this session
|
|
421
|
+
date = session.get(get_column_name(Database.START_KEY))
|
|
422
|
+
if date:
|
|
423
|
+
from starbash import (
|
|
424
|
+
to_shortdate,
|
|
425
|
+
) # Lazy import to avoid circular dependency
|
|
426
|
+
|
|
427
|
+
self.context["date"] = to_shortdate(date)
|
|
428
|
+
|
|
429
|
+
def add_input_masters(self, stage: dict) -> None:
|
|
430
|
+
"""based on input.masters add the correct master frames as context.master.<type> filepaths"""
|
|
431
|
+
session = self.context.get("session")
|
|
432
|
+
assert session is not None, "context.session should have been already set"
|
|
433
|
+
|
|
434
|
+
input_config = stage.get("input", {})
|
|
435
|
+
master_types: list[str] = input_config.get("masters", [])
|
|
436
|
+
for master_type in master_types:
|
|
437
|
+
masters = self.sb.get_master_images(imagetyp=master_type, reference_session=session)
|
|
438
|
+
if not masters:
|
|
439
|
+
raise RuntimeError(
|
|
440
|
+
f"No master frames of type '{master_type}' found for stage '{stage.get('name')}'"
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
context_master = self.context.setdefault("master", {})
|
|
444
|
+
|
|
445
|
+
if len(masters) > 1:
|
|
446
|
+
logging.debug(
|
|
447
|
+
f"Multiple ({len(masters)}) master frames of type '{master_type}' found, using first. FIXME."
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Try to rank the images by desirability
|
|
451
|
+
masters = self.sb.score_candidates(masters, session)
|
|
452
|
+
|
|
453
|
+
self.sb._add_image_abspath(masters[0]) # make sure abspath is populated
|
|
454
|
+
selected_master = masters[0]["abspath"]
|
|
455
|
+
logging.info(f"For master '{master_type}', using: {selected_master}")
|
|
456
|
+
|
|
457
|
+
context_master[master_type] = selected_master
|
|
458
|
+
|
|
459
|
+
def add_input_files(self, stage: dict, processed_ok: bool = False) -> None:
|
|
460
|
+
"""adds to context.input_files based on the stage input config"""
|
|
461
|
+
input_config = stage.get("input")
|
|
462
|
+
input_required = 0
|
|
463
|
+
if input_config:
|
|
464
|
+
# if there is an "input" dict, we assume input.required is true if unset
|
|
465
|
+
input_required = input_config.get("required", 0)
|
|
466
|
+
source = input_config.get("source")
|
|
467
|
+
if source is None:
|
|
468
|
+
raise ValueError(
|
|
469
|
+
f"Stage '{stage.get('name')}' has invalid 'input' configuration: missing 'source'"
|
|
470
|
+
)
|
|
471
|
+
if source == "path":
|
|
472
|
+
# The path might contain context variables that need to be expanded.
|
|
473
|
+
# path_pattern = expand_context(input_config["path"], context)
|
|
474
|
+
path_pattern = input_config["path"]
|
|
475
|
+
input_files = glob.glob(path_pattern, recursive=True)
|
|
476
|
+
|
|
477
|
+
self.context["input_files"] = (
|
|
478
|
+
input_files # Pass in the file list via the context dict
|
|
479
|
+
)
|
|
480
|
+
elif source == "repo":
|
|
481
|
+
# Get images for this session (by pulling from repo)
|
|
482
|
+
session = self.context.get("session")
|
|
483
|
+
assert session is not None, "context.session should have been already set"
|
|
484
|
+
|
|
485
|
+
images = self.sb.get_session_images(session, processed_ok=processed_ok)
|
|
486
|
+
logging.debug(f"Using {len(images)} files as input_files")
|
|
487
|
+
self.context["input_files"] = [
|
|
488
|
+
img["abspath"] for img in images
|
|
489
|
+
] # Pass in the file list via the context dict
|
|
490
|
+
elif source == "recipe":
|
|
491
|
+
# The input files are already in the tempdir from the recipe processing
|
|
492
|
+
# therefore we don't need to do anything here
|
|
493
|
+
pass
|
|
494
|
+
else:
|
|
495
|
+
raise ValueError(
|
|
496
|
+
f"Stage '{stage.get('name')}' has invalid 'input' source: {source}"
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# FIXME compare context.output to see if it already exists and is newer than the input files, if so skip processing
|
|
500
|
+
else:
|
|
501
|
+
# The script doesn't mention input, therefore assume it doesn't want input_files
|
|
502
|
+
if "input_files" in self.context:
|
|
503
|
+
del self.context["input_files"]
|
|
504
|
+
|
|
505
|
+
input_files: list[str] = self.context.get("input_files", [])
|
|
506
|
+
if input_required:
|
|
507
|
+
if len(input_files) < input_required:
|
|
508
|
+
raise NotEnoughFilesError(
|
|
509
|
+
f"Stage requires >{input_required} input files ({len(input_files)} found)",
|
|
510
|
+
input_files,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def add_output_path(self, stage: dict) -> None:
|
|
514
|
+
"""Adds output path information to context based on the stage output config.
|
|
515
|
+
|
|
516
|
+
If the output dest is 'repo', it finds the appropriate repository and constructs
|
|
517
|
+
the full output path based on the repository's base path and relative path expression.
|
|
518
|
+
|
|
519
|
+
Sets the following context variables:
|
|
520
|
+
- context.output.root_path - base path of the destination repo
|
|
521
|
+
- context.output.base_path - full path without file extension
|
|
522
|
+
- context.output.suffix - file extension (e.g., .fits or .fit.gz)
|
|
523
|
+
- context.output.full_path - complete output file path
|
|
524
|
+
- context.output.repo - the destination Repo (if applicable)
|
|
525
|
+
"""
|
|
526
|
+
output_config = stage.get("output")
|
|
527
|
+
if not output_config:
|
|
528
|
+
# No output configuration, remove any existing output from context
|
|
529
|
+
if "output" in self.context:
|
|
530
|
+
del self.context["output"]
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
dest = output_config.get("dest")
|
|
534
|
+
if not dest:
|
|
535
|
+
raise ValueError(
|
|
536
|
+
f"Stage '{stage.get('description', 'unknown')}' has 'output' config but missing 'dest'"
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if dest == "repo":
|
|
540
|
+
# Find the destination repo by type/kind
|
|
541
|
+
output_type = output_config.get("type")
|
|
542
|
+
if not output_type:
|
|
543
|
+
raise ValueError(
|
|
544
|
+
f"Stage '{stage.get('description', 'unknown')}' has output.dest='repo' but missing 'type'"
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# Find the repo with matching kind
|
|
548
|
+
dest_repo = self.sb.repo_manager.get_repo_by_kind(output_type)
|
|
549
|
+
if not dest_repo:
|
|
550
|
+
raise ValueError(
|
|
551
|
+
f"No repository found with kind '{output_type}' for output destination"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
repo_base = dest_repo.get_path()
|
|
555
|
+
if not repo_base:
|
|
556
|
+
raise ValueError(f"Repository '{dest_repo.url}' has no filesystem path")
|
|
557
|
+
|
|
558
|
+
# try to find repo.relative.<imagetyp> first, fallback to repo.relative.default
|
|
559
|
+
# Note: we are guaranteed imagetyp is already normalized
|
|
560
|
+
imagetyp = self.context.get("imagetyp", "unspecified")
|
|
561
|
+
repo_relative: str | None = dest_repo.get(
|
|
562
|
+
f"repo.relative.{imagetyp}", dest_repo.get("repo.relative.default")
|
|
563
|
+
)
|
|
564
|
+
if not repo_relative:
|
|
565
|
+
raise ValueError(
|
|
566
|
+
f"Repository '{dest_repo.url}' is missing 'repo.relative.default' configuration"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# we support context variables in the relative path
|
|
570
|
+
repo_relative = expand_context_unsafe(repo_relative, self.context)
|
|
571
|
+
full_path = repo_base / repo_relative
|
|
572
|
+
|
|
573
|
+
# base_path but without spaces - because Siril doesn't like that
|
|
574
|
+
full_path = Path(str(full_path).replace(" ", r"_"))
|
|
575
|
+
|
|
576
|
+
base_path = full_path.parent / full_path.stem
|
|
577
|
+
if str(base_path).endswith("*"):
|
|
578
|
+
# The relative path must be of the form foo/blah/*.fits or somesuch. In that case we want the base
|
|
579
|
+
# path to just point to that directory prefix.
|
|
580
|
+
base_path = Path(str(base_path)[:-1])
|
|
581
|
+
|
|
582
|
+
# create output directory if needed
|
|
583
|
+
os.makedirs(base_path.parent, exist_ok=True)
|
|
584
|
+
|
|
585
|
+
# Set context variables as documented in the TOML
|
|
586
|
+
self.context["output"] = {
|
|
587
|
+
# "root_path": repo_relative, not needed I think
|
|
588
|
+
"base_path": base_path,
|
|
589
|
+
# "suffix": full_path.suffix, not needed I think
|
|
590
|
+
"full_path": full_path,
|
|
591
|
+
"relative_base_path": repo_relative,
|
|
592
|
+
"repo": dest_repo,
|
|
593
|
+
}
|
|
594
|
+
else:
|
|
595
|
+
raise ValueError(
|
|
596
|
+
f"Unsupported output destination type: {dest}. Only 'repo' is currently supported."
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
def expand_to_context(self, to_add: dict[str, Any]):
|
|
600
|
+
"""Expands any string values in to_add using the current context and updates the context.
|
|
601
|
+
|
|
602
|
+
This allows scripts to add new context variables - with general python expressions inside
|
|
603
|
+
"""
|
|
604
|
+
for key, value in to_add.items():
|
|
605
|
+
if isinstance(value, str):
|
|
606
|
+
expanded_value = expand_context_unsafe(value, self.context)
|
|
607
|
+
self.context[key] = expanded_value
|
|
608
|
+
else:
|
|
609
|
+
self.context[key] = value
|
|
610
|
+
|
|
611
|
+
def run_stage(self, stage: dict, processed_ok: bool = False) -> None:
|
|
612
|
+
"""
|
|
613
|
+
Executes a single processing stage.
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
stage: A dictionary representing the stage configuration, containing
|
|
617
|
+
at least 'tool' and 'script' keys.
|
|
618
|
+
"""
|
|
619
|
+
stage_desc = stage.get("description", "(missing description)")
|
|
620
|
+
stage_disabled = stage.get("disabled", False)
|
|
621
|
+
if stage_disabled:
|
|
622
|
+
logging.info(f"Skipping disabled stage: {stage_desc}")
|
|
623
|
+
return
|
|
624
|
+
|
|
625
|
+
logging.info(f"Running stage: {stage_desc}")
|
|
626
|
+
|
|
627
|
+
tool_dict = stage.get("tool")
|
|
628
|
+
if not tool_dict:
|
|
629
|
+
raise ValueError(f"Stage '{stage.get('name')}' is missing a 'tool' definition.")
|
|
630
|
+
tool_name = tool_dict.get("name")
|
|
631
|
+
if not tool_name:
|
|
632
|
+
raise ValueError(f"Stage '{stage.get('name')}' is missing a 'tool.name' definition.")
|
|
633
|
+
tool = tools.get(tool_name)
|
|
634
|
+
if not tool:
|
|
635
|
+
raise ValueError(f"Tool '{tool_name}' for stage '{stage.get('name')}' not found.")
|
|
636
|
+
logging.debug(f"Using tool: {tool_name}")
|
|
637
|
+
tool.set_defaults()
|
|
638
|
+
|
|
639
|
+
# Allow stage to override tool timeout if specified
|
|
640
|
+
tool_timeout = tool_dict.get("timeout")
|
|
641
|
+
if tool_timeout is not None:
|
|
642
|
+
tool.timeout = float(tool_timeout)
|
|
643
|
+
logging.debug(f"Using tool timeout: {tool.timeout} seconds")
|
|
644
|
+
|
|
645
|
+
# is the script included inline?
|
|
646
|
+
script = stage.get("script")
|
|
647
|
+
if script:
|
|
648
|
+
script = textwrap.dedent(script) # it might be indented in the toml
|
|
649
|
+
else:
|
|
650
|
+
# try to load it from a file
|
|
651
|
+
script_filename = stage.get("script-file", tool.default_script_file)
|
|
652
|
+
if script_filename:
|
|
653
|
+
source = stage.source # type: ignore (was monkeypatched by repo)
|
|
654
|
+
try:
|
|
655
|
+
script = source.read(script_filename)
|
|
656
|
+
except OSError as e:
|
|
657
|
+
raise ValueError(f"Error reading script file '{script_filename}'") from e
|
|
658
|
+
|
|
659
|
+
if script is None:
|
|
660
|
+
raise ValueError(
|
|
661
|
+
f"Stage '{stage.get('name')}' is missing a 'script' or 'script-file' definition."
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
# This allows recipe TOML to define their own default variables.
|
|
665
|
+
# (apply all of the changes to context that the task demands)
|
|
666
|
+
stage_context = stage.get("context", {})
|
|
667
|
+
self.expand_to_context(stage_context)
|
|
668
|
+
self.add_output_path(stage)
|
|
669
|
+
|
|
670
|
+
output_info: dict | None = self.context.get("output")
|
|
671
|
+
try:
|
|
672
|
+
self.add_input_files(stage, processed_ok=processed_ok)
|
|
673
|
+
self.add_input_masters(stage)
|
|
674
|
+
|
|
675
|
+
# if the output path already exists and is newer than all input files, skip processing
|
|
676
|
+
if output_info and not starbash.force_regen:
|
|
677
|
+
output_path = output_info.get("full_path")
|
|
678
|
+
if output_path:
|
|
679
|
+
# output_path might contain * wildcards, make output_files be a list
|
|
680
|
+
output_files = glob.glob(str(output_path))
|
|
681
|
+
if len(output_files) > 0:
|
|
682
|
+
logging.info(
|
|
683
|
+
f"Output file already exists, skipping processing: {output_path}"
|
|
684
|
+
)
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
# We normally run tools in a temp dir, but if input.source is recipe we assume we want to
|
|
688
|
+
# run in the shared processing directory. Because prior stages output files are waiting for us there.
|
|
689
|
+
cwd = None
|
|
690
|
+
if stage.get("input", {}).get("source") == "recipe":
|
|
691
|
+
cwd = self.context.get("process_dir")
|
|
692
|
+
|
|
693
|
+
tool.run(script, context=self.context, cwd=cwd)
|
|
694
|
+
except NotEnoughFilesError as e:
|
|
695
|
+
# Not enough input files provided
|
|
696
|
+
input_files = e.files
|
|
697
|
+
if len(input_files) != 1:
|
|
698
|
+
raise # We only handle the single file case here
|
|
699
|
+
|
|
700
|
+
# Copy the single input file to the output path
|
|
701
|
+
output_path = self.context.get("output", {}).get("full_path")
|
|
702
|
+
if output_path:
|
|
703
|
+
shutil.copy(input_files[0], output_path)
|
|
704
|
+
logging.warning(f"Copied single master from {input_files[0]} to {output_path}")
|
|
705
|
+
else:
|
|
706
|
+
# no output path specified, re-raise
|
|
707
|
+
raise
|
|
708
|
+
|
|
709
|
+
# verify context.output was created if it was specified
|
|
710
|
+
if output_info:
|
|
711
|
+
output_path = output_info[
|
|
712
|
+
"full_path"
|
|
713
|
+
] # This must be present, because we created it when we made the output node
|
|
714
|
+
|
|
715
|
+
# output_path might contain * wildcards, make output_files be a list
|
|
716
|
+
output_files = glob.glob(str(output_path))
|
|
717
|
+
|
|
718
|
+
if len(output_files) < 1:
|
|
719
|
+
raise RuntimeError(f"Expected output file not found: {output_path}")
|
|
720
|
+
else:
|
|
721
|
+
if output_info["repo"].kind() == "master":
|
|
722
|
+
# we add new masters to our image DB
|
|
723
|
+
# add to image DB (ONLY! we don't also create a session)
|
|
724
|
+
self.sb.add_image(output_info["repo"], Path(output_path), force=True)
|