starbash 0.3.1__tar.gz → 0.3.2__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 (87) hide show
  1. {starbash-0.3.1 → starbash-0.3.2}/PKG-INFO +1 -1
  2. {starbash-0.3.1 → starbash-0.3.2}/pyproject.toml +1 -1
  3. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/app.py +4 -0
  4. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/defaults/starbash.toml +15 -2
  5. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/doit.py +26 -5
  6. starbash-0.3.2/src/starbash/doit_types.py +85 -0
  7. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/exception.py +12 -0
  8. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/filtering.py +8 -1
  9. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/fits.py +25 -2
  10. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/processing.py +69 -32
  11. starbash-0.3.2/src/starbash/publish/github.py +259 -0
  12. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/publish/github_service.py +37 -11
  13. starbash-0.3.2/src/starbash/recipes/crop.py +139 -0
  14. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/recipes/osc.py +73 -97
  15. starbash-0.3.2/src/starbash/recipes/report_registration.py +308 -0
  16. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/siril/import_registration.py +14 -8
  17. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/stages.py +15 -9
  18. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/userconfig.toml +4 -1
  19. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/base.py +19 -14
  20. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/python.py +50 -40
  21. starbash-0.3.1/src/starbash/doit_types.py +0 -45
  22. starbash-0.3.1/src/starbash/publish/github.py +0 -234
  23. starbash-0.3.1/src/starbash/recipes/crop.py +0 -75
  24. {starbash-0.3.1 → starbash-0.3.2}/LICENSE +0 -0
  25. {starbash-0.3.1 → starbash-0.3.2}/README.md +0 -0
  26. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/__init__.py +0 -0
  27. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/aliases.py +0 -0
  28. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/analytics.py +0 -0
  29. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/assets/__init__.py +0 -0
  30. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/assets/favicon.ico +0 -0
  31. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/check_version.py +0 -0
  32. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/__init__.py +0 -0
  33. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/info.py +0 -0
  34. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/process.py +0 -0
  35. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/publish.py +0 -0
  36. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/repo.py +0 -0
  37. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/select.py +0 -0
  38. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/user.py +0 -0
  39. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/database.py +0 -0
  40. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/defaults/__init__.py +0 -0
  41. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/dwarf3.py +0 -0
  42. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/icon.py +0 -0
  43. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/linux.py +0 -0
  44. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/main.py +0 -0
  45. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/os.py +0 -0
  46. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/parameters.py +0 -0
  47. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/paths.py +0 -0
  48. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/processed_target.py +0 -0
  49. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/processing_like.py +0 -0
  50. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/publish/__init__.py +0 -0
  51. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/publish/credentials.py +0 -0
  52. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/recipes/README.md +0 -0
  53. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/recipes/__init__.py +0 -0
  54. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/report.py +0 -0
  55. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/rich.py +0 -0
  56. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/safety.py +0 -0
  57. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/score.py +0 -0
  58. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/selection.py +0 -0
  59. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/sim_siril/__init__.py +0 -0
  60. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/sim_siril/connection.py +0 -0
  61. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/sim_siril/enums.py +0 -0
  62. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/sim_siril/utility.py +0 -0
  63. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/stage_utils.py +0 -0
  64. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/__init__.py +0 -0
  65. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/repo/master.toml +0 -0
  66. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/repo/processed.toml +0 -0
  67. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/Gemfile +0 -0
  68. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/README.md.jinja +0 -0
  69. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/__init__.py +0 -0
  70. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/_config.yml +0 -0
  71. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/default.html +0 -0
  72. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/index.md.jinja +0 -0
  73. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/target.md.jinja +0 -0
  74. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/target/master.toml +0 -0
  75. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/target/processed/about.toml +0 -0
  76. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/target/processed/main.toml +0 -0
  77. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/target/processed/sessions.toml +0 -0
  78. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/toml.py +0 -0
  79. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/__init__.py +0 -0
  80. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/context.py +0 -0
  81. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/graxpert.py +0 -0
  82. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/rcastro.py +0 -0
  83. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/siril.py +0 -0
  84. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/starnet.py +0 -0
  85. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/ui/main.py +0 -0
  86. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/url.py +0 -0
  87. {starbash-0.3.1 → starbash-0.3.2}/src/starbash/windows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: starbash
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: A tool for automating/standardizing/sharing astrophotography workflows.
5
5
  License-File: LICENSE
6
6
  Author: Kevin Hester
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "starbash"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  description = "A tool for automating/standardizing/sharing astrophotography workflows."
5
5
  authors = ["Kevin Hester <kevinh@geeksville.com>"]
6
6
  readme = "README.md"
@@ -15,6 +15,7 @@ from rich.progress import track
15
15
  from toml_repo import Repo, RepoManager, get_config_suffix
16
16
 
17
17
  import starbash
18
+ from starbash import doit_types
18
19
  from starbash.aliases import (
19
20
  Aliases,
20
21
  get_aliases,
@@ -185,6 +186,9 @@ class Starbash:
185
186
 
186
187
  # Load app defaults and initialize the repository manager
187
188
  self._init_repos()
189
+ doit_types.configure_max_contexts(
190
+ self.user_repo.get("config.max_contexts", doit_types.DEFAULT_MAX_CONTEXTS)
191
+ )
188
192
  self._init_analytics(cmd) # after init repos so we have user prefs
189
193
 
190
194
  app_version = version("starbash")
@@ -91,6 +91,14 @@ model.long = "Optolong 2-inch L-Extreme Dual Band 7nm Ha+Oiii Filter"
91
91
  url.info = "https://www.highpointscientific.com/optolong-2-inch-l-extreme-dual-band-7nm-ha-oiii-filter-lxt-200"
92
92
  subtype = "HaOiii"
93
93
 
94
+ [[equipment]]
95
+ type = "filter"
96
+ vendor = "Agena"
97
+ fits.filter = "Tri" # must match the FITS header FILTER field for this filter if you want auto matching.
98
+ model.short = "ant-tri"
99
+ model.long = "Agena Tri-Band RGB Ultra Filter"
100
+ url.info = "https://agenaastro.com/antlia-triband-rgb-ultra-filter-2-mounted.html"
101
+
94
102
  [[equipment]]
95
103
  type = "mount"
96
104
  vendor = "Juwei"
@@ -138,7 +146,9 @@ HaOiii = ["HaOiii", "HaO3", "Duo"]
138
146
  # Astro is the name Dwarf3 uses for their IR cut filter
139
147
  # VIS is the name Dwarf3 uses for their 'clear' filter
140
148
  # IRCUT/LP are the names Seestar uses for their filters
141
- None = ["None", "Astro", "IRCUT", "LP", "VIS"]
149
+ # Tri is a common name for a tri-band light pollution filter (LPF)
150
+
151
+ None = ["None", "Astro", "IRCUT", "LP", "VIS", "Tri"]
142
152
 
143
153
  camera_osc = ["OSC", "ZWO ASI2600MC Duo"]
144
154
  camera_seestar = ["Seestar", "Seestar S50", "Seestar S30", "Seestar S30 Pro"]
@@ -157,7 +167,10 @@ camera_seestar = ["Seestar", "Seestar S50", "Seestar S30", "Seestar S30 Pro"]
157
167
  # http://www.antliafilter.com/pd.jsp?fromColId=2&id=160#_pp=2_671
158
168
  SiiHb = ["SiiHb", "S2Hb"]
159
169
 
170
+ [config]
171
+ # Set this lower if you need to reduce disk usage. If omitted, the default is 2 contexts.
172
+ max_contexts = 2
160
173
 
161
174
  # List the initial search path to find other repos, when the app launches it always first loads this sb.toml file and this file should list all of the
162
175
  # standard default repo locations. When searching repos, repos listed LAST have precedence, so target file can override the root processing defaults,
163
- # then the user prefs, then a live github URL or whatever
176
+ # then the user prefs, then a live github URL or whatever
@@ -22,7 +22,7 @@ from toml_repo import Repo
22
22
  from starbash import InputDef
23
23
  from starbash.database import ImageRow
24
24
  from starbash.doit_types import TaskDict
25
- from starbash.exception import UserHandledError
25
+ from starbash.exception import FilesystemUnavailableError, UserHandledError
26
26
  from starbash.os import symlink_or_copy
27
27
  from starbash.paths import get_user_cache_dir
28
28
  from starbash.tool.base import Tool
@@ -257,7 +257,9 @@ def merge_to(base_name: str, fi: FileInfo) -> None:
257
257
  indexed_ids = []
258
258
  break
259
259
  indexed_ids.append(source_ids[source_index - 1])
260
- if len(indexed_ids) == len(matching_files) and len(set(indexed_ids)) == len(indexed_ids):
260
+ if len(indexed_ids) == len(matching_files) and len(set(indexed_ids)) == len(
261
+ indexed_ids
262
+ ):
261
263
  resolved_source_ids = indexed_ids
262
264
  else:
263
265
  logging.warning(
@@ -269,7 +271,9 @@ def merge_to(base_name: str, fi: FileInfo) -> None:
269
271
  )
270
272
 
271
273
  if resolved_source_ids is not None:
272
- for matching_file, source_id in zip(matching_files, resolved_source_ids, strict=True):
274
+ for matching_file, source_id in zip(
275
+ matching_files, resolved_source_ids, strict=True
276
+ ):
273
277
  source_path = Path(matching_file)
274
278
  collected_files.append(source_path)
275
279
  provenance[source_path.name] = source_id
@@ -288,7 +292,7 @@ def merge_to(base_name: str, fi: FileInfo) -> None:
288
292
  ):
289
293
  dest_name = f"{base_name}_{index:05d}.fits"
290
294
  dest_path = output_dir / dest_name
291
- #logging.debug(f"Linking {source_file} to {dest_path}")
295
+ # logging.debug(f"Linking {source_file} to {dest_path}")
292
296
  symlink_or_copy(str(source_file), str(dest_path))
293
297
 
294
298
  merged_provenance = {
@@ -355,6 +359,17 @@ class ToolAction(BaseAction):
355
359
  self.result = self.tool.run(
356
360
  self.commands, context=context, cwd=self.cwd, log_out=logfile, **self.parameters
357
361
  )
362
+ except OSError as e:
363
+ error = FilesystemUnavailableError(
364
+ f"running {self.tool.name} for task '{self.task.name}'", e
365
+ )
366
+ logging.error("%s (%s)", error, e)
367
+ self.task.meta["exception"] = error
368
+ return TaskFailed(str(error))
369
+ except FilesystemUnavailableError as e:
370
+ logging.error("%s", e)
371
+ self.task.meta["exception"] = e
372
+ return TaskFailed(str(e))
358
373
  except Exception as e:
359
374
  # We pass back any exceptions in task.meta - so that our ConsoleReporter can pick them up (doit normally strips exceptions)
360
375
  self.task.meta["exception"] = e
@@ -422,6 +437,12 @@ class ProcessingResult:
422
437
  # General error from user misconfiguration or tools - not a bug in our code
423
438
  logging.error(f"Skipping run due to: {e}")
424
439
  self.notes = str(e)
440
+ elif isinstance(e, OSError):
441
+ self.notes = (
442
+ "The filesystem became unavailable during processing. "
443
+ "Check that the drive or network mount is connected, then retry."
444
+ )
445
+ logging.error("Filesystem unavailable during processing: %s", e)
425
446
  else:
426
447
  # Unexpected exception - log it and re-raise
427
448
  logging.exception("Unexpected error during processing:")
@@ -562,7 +583,7 @@ class StarbashDoit(TaskLoader2):
562
583
  "verbosity": 2,
563
584
  "dep_file": dep_file,
564
585
  "reporter": MyReporter,
565
- "backend": "dbm", # the json backend is slow and buggy, use dbm instead
586
+ "backend": "dbm", # the json backend is slow and buggy, use dbm instead
566
587
  }
567
588
 
568
589
  def load_tasks(self, cmd: Any, pos_args: Any) -> list[Task]:
@@ -0,0 +1,85 @@
1
+ """Shared types and utilities for doit processing to avoid circular imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from starbash.paths import get_user_cache_dir
12
+
13
+ type TaskDict = dict[str, Any] # a doit task dictionary
14
+
15
+ DEFAULT_MAX_CONTEXTS = 2
16
+ max_contexts = DEFAULT_MAX_CONTEXTS
17
+
18
+
19
+ def configure_max_contexts(value: Any) -> None:
20
+ """Set the maximum number of processing contexts from user configuration.
21
+
22
+ Invalid values are ignored so a malformed preference cannot prevent
23
+ Starbash from starting.
24
+ """
25
+ global max_contexts
26
+
27
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
28
+ logging.warning(
29
+ "Ignoring invalid max_contexts preference %r; using %d.",
30
+ value,
31
+ DEFAULT_MAX_CONTEXTS,
32
+ )
33
+ max_contexts = DEFAULT_MAX_CONTEXTS
34
+ return
35
+
36
+ max_contexts = value
37
+
38
+
39
+ def get_processing_dir() -> Path:
40
+ """Get the base directory for processing contexts."""
41
+ cache_dir = get_user_cache_dir()
42
+ processing_dir = cache_dir / "processing"
43
+ processing_dir.mkdir(parents=True, exist_ok=True)
44
+ return processing_dir
45
+
46
+
47
+ def cleanup_old_contexts() -> None:
48
+ """Remove oldest context directories if we exceed max_contexts."""
49
+ processing_dir = get_processing_dir()
50
+ logging.debug(f"Removing old processing contexts in: {processing_dir}")
51
+ if not processing_dir.exists():
52
+ return
53
+
54
+ # Safety guard: if we're running under pytest but the processing dir is NOT a test
55
+ # override (no explicit cache override and no STARBASH_CACHE_DIR), refuse to delete
56
+ # anything. A leaked test global (e.g. a small max_contexts from a test config) must
57
+ # never prune the real user's cache.
58
+ from starbash import paths
59
+
60
+ if os.environ.get("PYTEST_CURRENT_TEST") is not None:
61
+ using_override = (
62
+ paths._override_cache_dir is not None or os.getenv("STARBASH_CACHE_DIR")
63
+ )
64
+ if not using_override:
65
+ logging.warning(
66
+ "Refusing to clean processing contexts during tests without a cache override: %s",
67
+ processing_dir,
68
+ )
69
+ return
70
+
71
+ # Get all subdirectories in processing_dir
72
+ contexts = [d for d in processing_dir.iterdir() if d.is_dir()]
73
+
74
+ # If we have more than max_contexts, delete the oldest ones
75
+ if len(contexts) > max_contexts:
76
+ # Sort by modification time (oldest first)
77
+ contexts.sort(key=lambda d: d.stat().st_mtime)
78
+
79
+ # Calculate how many to delete
80
+ num_to_delete = len(contexts) - max_contexts
81
+
82
+ # Delete the oldest directories
83
+ for context_dir in contexts[:num_to_delete]:
84
+ logging.debug(f"Removing old processing context: {context_dir}")
85
+ shutil.rmtree(context_dir, ignore_errors=True)
@@ -25,6 +25,17 @@ class UserHandledError(NonSoftwareError):
25
25
  return False
26
26
 
27
27
 
28
+ class FilesystemUnavailableError(UserHandledError):
29
+ """Raised when a filesystem disappears while processing is in progress."""
30
+
31
+ def __init__(self, operation: str, cause: OSError) -> None:
32
+ super().__init__(
33
+ f"The filesystem became unavailable while {operation}. "
34
+ "Check that the drive or network mount is connected, then retry."
35
+ )
36
+ self.cause = cause
37
+
38
+
28
39
  def raise_missing_repo(kind: str) -> NoReturn:
29
40
  """Raise a UserHandledError indicating that a repository of the given kind is missing."""
30
41
  raise UserHandledError(
@@ -54,6 +65,7 @@ class NoSuitableMastersException(NonFatalException):
54
65
 
55
66
  __all__ = [
56
67
  "UserHandledError",
68
+ "FilesystemUnavailableError",
57
69
  "NonFatalException",
58
70
  "NotEnoughFilesError",
59
71
  "NoSuitableMastersException",
@@ -30,6 +30,10 @@ def _apply_filter(requires: RequireDef, candidates: list[ImageRow]) -> list[Imag
30
30
 
31
31
  kind = get_safe(requires, "kind")
32
32
  value = requires.get("value") # value is optional for some kinds
33
+ # Optional `invert` negates the boolean match so non-matching candidates are
34
+ # kept (e.g. select sessions whose filter is NOT narrowband). Only meaningful
35
+ # for boolean-match kinds (metadata/camera/unprocessed/filename), not min_count.
36
+ invert = bool(requires.get("invert", False))
33
37
 
34
38
  # Stage 1: Filter candidates using kind-specific filter functions
35
39
  def _filter_metadata(metadata: Metadata) -> bool:
@@ -95,7 +99,10 @@ def _apply_filter(requires: RequireDef, candidates: list[ImageRow]) -> list[Imag
95
99
  raise ValueError(f"Unknown requires kind: {kind}")
96
100
 
97
101
  # Apply the filter function to all candidates
98
- filtered_candidates = [img for img in candidates if filter_func(img)]
102
+ if invert:
103
+ filtered_candidates = [img for img in candidates if not filter_func(img)]
104
+ else:
105
+ filtered_candidates = [img for img in candidates if filter_func(img)]
99
106
 
100
107
  # Stage 2: Handle min_count check after filtering
101
108
  if kind == "min_count":
@@ -8,11 +8,34 @@ from astropy.io import fits
8
8
  __all__ = ["read_dimensions", "read_fits_header"]
9
9
 
10
10
 
11
+ def _json_safe(value: Any) -> Any:
12
+ """Convert an astropy header value into a plain JSON-serializable Python value.
13
+
14
+ Commentary cards (COMMENT/HISTORY/blank keyword) come back as
15
+ ``_HeaderCommentaryCards`` (a list-like of strings) and other cards can be
16
+ ``bool``, numpy scalars etc. None of those serialize via ``json.dumps``.
17
+ """
18
+ # Commentary cards: list-like container of strings; join into one string
19
+ if isinstance(value, fits.header._HeaderCommentaryCards):
20
+ return "\n".join(value)
21
+ if isinstance(value, (list, tuple)):
22
+ return [_json_safe(v) for v in value]
23
+ if isinstance(value, bool): # must precede int check (bool is an int subclass)
24
+ return value
25
+ if isinstance(value, (int, float, str)) or value is None:
26
+ return value
27
+ # numpy scalars and anything else: fall back to str()
28
+ return str(value)
29
+
30
+
11
31
  def read_fits_header(file_path: str | Path) -> dict[str, Any]:
12
- """Read and return the primary FITS header from ``file_path``."""
32
+ """Read and return the primary FITS header from ``file_path``.
33
+
34
+ All values are converted to plain JSON-serializable Python types.
35
+ """
13
36
  with fits.open(str(file_path), memmap=False) as hdul:
14
37
  hdu0: Any = hdul[0]
15
- return dict(hdu0.header)
38
+ return {key: _json_safe(value) for key, value in hdu0.header.items()}
16
39
 
17
40
 
18
41
  def read_dimensions(file_path: str | Path) -> tuple[int, int]:
@@ -16,7 +16,7 @@ from toml_repo import Repo
16
16
  from tomlkit.items import AoT
17
17
 
18
18
  import starbash
19
- from starbash import InputDef, OutputDef, RequireDef, StageDict
19
+ from starbash import InputDef, Metadata, OutputDef, RequireDef, StageDict
20
20
  from starbash.aliases import get_aliases, normalize_target_name
21
21
  from starbash.app import Starbash
22
22
  from starbash.database import (
@@ -82,11 +82,7 @@ def _clone_context(context: dict[str, Any]) -> dict[str, Any]:
82
82
  Returns:
83
83
  A deep copy of the current context dictionary.
84
84
  """
85
- shared = {
86
- key: context[key]
87
- for key in ["session", "update_image_metadata"]
88
- if key in context
89
- }
85
+ shared = {key: context[key] for key in ["session", "update_image_metadata"] if key in context}
90
86
  r = copy.deepcopy({key: value for key, value in context.items() if key not in shared})
91
87
 
92
88
  # A few fields (if populated) we want SHARED between all contexts, so that if two contexts were initially pointing
@@ -197,7 +193,12 @@ class Processing(ProcessingLike):
197
193
  def __enter__(self) -> "Processing":
198
194
  return self
199
195
 
200
- def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: types.TracebackType | None) -> bool:
196
+ def __exit__(
197
+ self,
198
+ exc_type: type[BaseException] | None,
199
+ exc: BaseException | None,
200
+ tb: types.TracebackType | None,
201
+ ) -> bool:
201
202
  self.close()
202
203
  return False
203
204
 
@@ -637,9 +638,11 @@ class Processing(ProcessingLike):
637
638
  try:
638
639
  script = source.read(script_filename)
639
640
  try:
640
- script_filename = source.resolve_path(script_filename) # Try to let the tool give the full filepath in error messages
641
+ script_filename = source.resolve_path(
642
+ script_filename
643
+ ) # Try to let the tool give the full filepath in error messages
641
644
  except Exception:
642
- pass # some repos might not be on a local disk. In that case just use the base name
645
+ pass # some repos might not be on a local disk. In that case just use the base name
643
646
  tool_parameters["script_file"] = str(script_filename)
644
647
 
645
648
  except OSError as e:
@@ -691,9 +694,18 @@ class Processing(ProcessingLike):
691
694
  return None
692
695
 
693
696
  after = get_safe(input_with_after, "after")
694
- prior_task_name = self._get_unique_task_name(
695
- after
696
- ) # find the right task for our stage and multiplex
697
+ # ``after`` is a regex over task/stage names. Preserve it as a regex
698
+ # instead of passing it through ``_get_unique_task_name()``, which is
699
+ # intended for literal task names and would turn e.g.
700
+ # ``stack_(single|dual)_duo`` into a non-matching literal prefix.
701
+ prior_task_name = self._get_unique_task_name(after)
702
+ # ``_get_unique_task_name`` appends target/session suffixes after the
703
+ # regex. Wrap the dependency expression so the suffix remains outside
704
+ # the alternation (``stack_(single|dual)_duo_sh2126`` must match both
705
+ # concrete stage names).
706
+ if "(" in after or "[" in after or "|" in after:
707
+ suffix = prior_task_name[len(after) :]
708
+ prior_task_name = f"(?:{after}){suffix}"
697
709
 
698
710
  # Compile the prior_task_name into a regex pattern for prefix matching.
699
711
  # The pattern from TOML may contain wildcards like "light.*" which should match
@@ -970,11 +982,17 @@ class Processing(ProcessingLike):
970
982
  self.context.pop("stage_input", None)
971
983
  self.context.pop("multiplex_index", None)
972
984
 
973
- def _with_defaults(self, img: ImageRow) -> ImageRow:
985
+ def _with_defaults(self, img: ImageRow, defaults: Metadata | None = None) -> ImageRow:
974
986
  """Try to provide missing metadata for image rows. Some imagerows are 'sparse'
975
987
  with just a filename and minor other info. In that case try to assume the metadata matches
976
- the input metadata for this single pipeline of images."""
977
- r = self.context.get("default_metadata", {}).copy()
988
+ the input metadata for this single pipeline of images.
989
+
990
+ Args:
991
+ img: The image row to complete.
992
+ defaults: Metadata belonging to the task that produced ``img``.
993
+ When omitted, use the current processing context.
994
+ """
995
+ r = (defaults if defaults is not None else self.context.get("default_metadata", {})).copy()
978
996
 
979
997
  # values from the passed in img override our defaults
980
998
  for key, value in img.items():
@@ -1027,12 +1045,11 @@ class Processing(ProcessingLike):
1027
1045
  )
1028
1046
  ]
1029
1047
  if not prior_tasks:
1030
- raise NotEnoughFilesError(
1031
- "All prior stages for this input were excluded", []
1032
- )
1048
+ raise NotEnoughFilesError("All prior stages for this input were excluded", [])
1033
1049
 
1034
1050
  # Collect all image rows from prior stage outputs
1035
1051
  child_exception: Exception | None = None
1052
+ matching_input_rows: list[ImageRow] = []
1036
1053
 
1037
1054
  # `filename` requires match the actual files we consume (the prior stage's
1038
1055
  # OUTPUT names), which differ from its inputs; every other requires kind
@@ -1040,9 +1057,15 @@ class Processing(ProcessingLike):
1040
1057
  # INPUT rows. So split them and apply each to the correct row set.
1041
1058
  all_requires: list[RequireDef] = input.get("requires", [])
1042
1059
  filename_requires = [r for r in all_requires if r.get("kind") == "filename"]
1060
+ min_count_requires = [r for r in all_requires if r.get("kind") == "min_count"]
1043
1061
  gating_input: InputDef = (
1044
- {**input, "requires": [r for r in all_requires if r.get("kind") != "filename"]}
1045
- if filename_requires
1062
+ {
1063
+ **input,
1064
+ "requires": [
1065
+ r for r in all_requires if r.get("kind") not in ("filename", "min_count")
1066
+ ],
1067
+ }
1068
+ if filename_requires or min_count_requires
1046
1069
  else input
1047
1070
  )
1048
1071
 
@@ -1055,12 +1078,18 @@ class Processing(ProcessingLike):
1055
1078
  for _input_type, file_info in task_inputs.items():
1056
1079
  if isinstance(file_info, FileInfo) and file_info.image_rows:
1057
1080
  images = file_info.image_rows
1058
- images = [self._with_defaults(img) for img in images]
1081
+ # A multiplexed upstream stage has one task per channel.
1082
+ # Use that task's metadata, rather than the current
1083
+ # context's metadata (which is usually copied from the
1084
+ # last multiplexed task), when applying metadata filters.
1085
+ task_defaults = task_context.get("default_metadata", {})
1086
+ images = [self._with_defaults(img, task_defaults) for img in images]
1059
1087
  try:
1060
1088
  task_filtered_input = filter_by_requires(gating_input, images)
1061
1089
  if (
1062
1090
  task_filtered_input
1063
1091
  ): # This task had matching inputs for us, so therefore we want its outputs
1092
+ matching_input_rows.extend(task_filtered_input)
1064
1093
  task_output = task_context.get("output")
1065
1094
  if (
1066
1095
  task_output
@@ -1081,16 +1110,30 @@ class Processing(ProcessingLike):
1081
1110
  # just because one prior task doesn't have what we need, we shouldn't stop looking
1082
1111
  logging.debug(f"Prior task '{task['name']}' skipped, still looking... {e}")
1083
1112
 
1084
- if child_exception and len(image_rows) == 0:
1113
+ if child_exception and len(image_rows) == 0 and not input.get("optional", False):
1085
1114
  # we failed on every child, give up
1086
1115
  raise child_exception
1087
1116
 
1088
- if filename_requires and len(image_rows) == 0:
1117
+ if filename_requires and len(image_rows) == 0 and not input.get("optional", False):
1089
1118
  # Prior outputs existed but none matched the filename filter; skip this stage.
1090
1119
  raise NotEnoughFilesError(
1091
1120
  "No prior stage outputs matched the input filename filter", []
1092
1121
  )
1093
1122
 
1123
+ if min_count_requires:
1124
+ # Apply min_count to this input group's aggregate, rather than to
1125
+ # each multiplexed upstream task. This keeps the requirement
1126
+ # scoped to one [[stages.inputs]] block, so separate input groups
1127
+ # can have independent counts.
1128
+ try:
1129
+ filter_by_requires({"requires": min_count_requires}, matching_input_rows)
1130
+ except NotEnoughFilesError:
1131
+ if not input.get("optional", False):
1132
+ raise
1133
+
1134
+ if not image_rows and input.get("optional", False):
1135
+ return FileInfo(image_rows=[], definition=input)
1136
+
1094
1137
  sequence_provenance: dict[str, list[int]] = {}
1095
1138
  provenance: dict[str, int] = {}
1096
1139
  for task in prior_tasks:
@@ -1277,13 +1320,9 @@ class Processing(ProcessingLike):
1277
1320
  definition=input,
1278
1321
  )
1279
1322
  source_images = [image for image in images if image.get("id") is not None]
1280
- fi.sequence_provenance = {
1281
- f"{fi.base}_.seq": [image["id"] for image in source_images]
1282
- }
1323
+ fi.sequence_provenance = {f"{fi.base}_.seq": [image["id"] for image in source_images]}
1283
1324
  fi.provenance = {
1284
- image["path"]: image["id"]
1285
- for image in source_images
1286
- if image.get("path")
1325
+ image["path"]: image["id"] for image in source_images if image.get("path")
1287
1326
  }
1288
1327
  ci[imagetyp] = fi
1289
1328
 
@@ -1460,9 +1499,7 @@ class Processing(ProcessingLike):
1460
1499
  continue
1461
1500
  sequence_provenance[filename] = source_ids.copy()
1462
1501
  stem = (
1463
- filename[: -len("_.seq")]
1464
- if filename.endswith("_.seq")
1465
- else Path(filename).stem
1502
+ filename[: -len("_.seq")] if filename.endswith("_.seq") else Path(filename).stem
1466
1503
  )
1467
1504
  generated_provenance.update(
1468
1505
  {