starbash 0.1.11__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.
Files changed (43) hide show
  1. repo/__init__.py +1 -1
  2. repo/manager.py +14 -23
  3. repo/repo.py +52 -10
  4. starbash/__init__.py +10 -3
  5. starbash/aliases.py +49 -4
  6. starbash/analytics.py +3 -2
  7. starbash/app.py +287 -565
  8. starbash/check_version.py +18 -0
  9. starbash/commands/__init__.py +2 -1
  10. starbash/commands/info.py +26 -21
  11. starbash/commands/process.py +76 -24
  12. starbash/commands/repo.py +25 -68
  13. starbash/commands/select.py +140 -148
  14. starbash/commands/user.py +88 -23
  15. starbash/database.py +41 -27
  16. starbash/defaults/starbash.toml +1 -0
  17. starbash/exception.py +21 -0
  18. starbash/main.py +29 -7
  19. starbash/paths.py +23 -9
  20. starbash/processing.py +724 -0
  21. starbash/recipes/README.md +3 -0
  22. starbash/recipes/master_bias/starbash.toml +4 -1
  23. starbash/recipes/master_dark/starbash.toml +0 -1
  24. starbash/recipes/osc.py +190 -0
  25. starbash/recipes/osc_dual_duo/starbash.toml +31 -34
  26. starbash/recipes/osc_simple/starbash.toml +82 -0
  27. starbash/recipes/osc_single_duo/starbash.toml +51 -32
  28. starbash/recipes/seestar/starbash.toml +82 -0
  29. starbash/recipes/starbash.toml +8 -9
  30. starbash/selection.py +29 -38
  31. starbash/templates/repo/master.toml +7 -3
  32. starbash/templates/repo/processed.toml +7 -2
  33. starbash/templates/userconfig.toml +9 -0
  34. starbash/toml.py +13 -13
  35. starbash/tool.py +186 -149
  36. starbash-0.1.15.dist-info/METADATA +216 -0
  37. starbash-0.1.15.dist-info/RECORD +45 -0
  38. starbash/recipes/osc_dual_duo/starbash.py +0 -147
  39. starbash-0.1.11.dist-info/METADATA +0 -147
  40. starbash-0.1.11.dist-info/RECORD +0 -40
  41. {starbash-0.1.11.dist-info → starbash-0.1.15.dist-info}/WHEEL +0 -0
  42. {starbash-0.1.11.dist-info → starbash-0.1.15.dist-info}/entry_points.txt +0 -0
  43. {starbash-0.1.11.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)