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.
- {starbash-0.3.1 → starbash-0.3.2}/PKG-INFO +1 -1
- {starbash-0.3.1 → starbash-0.3.2}/pyproject.toml +1 -1
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/app.py +4 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/defaults/starbash.toml +15 -2
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/doit.py +26 -5
- starbash-0.3.2/src/starbash/doit_types.py +85 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/exception.py +12 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/filtering.py +8 -1
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/fits.py +25 -2
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/processing.py +69 -32
- starbash-0.3.2/src/starbash/publish/github.py +259 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/publish/github_service.py +37 -11
- starbash-0.3.2/src/starbash/recipes/crop.py +139 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/recipes/osc.py +73 -97
- starbash-0.3.2/src/starbash/recipes/report_registration.py +308 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/siril/import_registration.py +14 -8
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/stages.py +15 -9
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/userconfig.toml +4 -1
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/base.py +19 -14
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/python.py +50 -40
- starbash-0.3.1/src/starbash/doit_types.py +0 -45
- starbash-0.3.1/src/starbash/publish/github.py +0 -234
- starbash-0.3.1/src/starbash/recipes/crop.py +0 -75
- {starbash-0.3.1 → starbash-0.3.2}/LICENSE +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/README.md +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/__init__.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/aliases.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/analytics.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/assets/__init__.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/assets/favicon.ico +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/check_version.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/__init__.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/info.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/process.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/publish.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/repo.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/select.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/commands/user.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/database.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/defaults/__init__.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/dwarf3.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/icon.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/linux.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/main.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/os.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/parameters.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/paths.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/processed_target.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/processing_like.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/publish/__init__.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/publish/credentials.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/recipes/README.md +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/recipes/__init__.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/report.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/rich.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/safety.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/score.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/selection.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/sim_siril/__init__.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/sim_siril/connection.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/sim_siril/enums.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/sim_siril/utility.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/stage_utils.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/__init__.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/repo/master.toml +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/repo/processed.toml +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/Gemfile +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/README.md.jinja +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/__init__.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/_config.yml +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/default.html +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/index.md.jinja +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/report/target.md.jinja +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/target/master.toml +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/target/processed/about.toml +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/target/processed/main.toml +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/templates/target/processed/sessions.toml +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/toml.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/__init__.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/context.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/graxpert.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/rcastro.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/siril.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/tool/starnet.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/ui/main.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/url.py +0 -0
- {starbash-0.3.1 → starbash-0.3.2}/src/starbash/windows.py +0 -0
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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",
|
|
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
|
-
|
|
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
|
|
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__(
|
|
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(
|
|
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
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
1045
|
-
|
|
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
|
-
|
|
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
|
{
|