starbash 0.2.6__tar.gz → 0.2.7__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 (62) hide show
  1. {starbash-0.2.6 → starbash-0.2.7}/PKG-INFO +2 -2
  2. {starbash-0.2.6 → starbash-0.2.7}/pyproject.toml +2 -2
  3. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/filtering.py +13 -0
  4. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/processing.py +27 -3
  5. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/tool/base.py +111 -41
  6. {starbash-0.2.6 → starbash-0.2.7}/LICENSE +0 -0
  7. {starbash-0.2.6 → starbash-0.2.7}/README.md +0 -0
  8. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/__init__.py +0 -0
  9. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/aliases.py +0 -0
  10. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/analytics.py +0 -0
  11. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/app.py +0 -0
  12. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/check_version.py +0 -0
  13. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/commands/__init__.py +0 -0
  14. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/commands/info.py +0 -0
  15. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/commands/process.py +0 -0
  16. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/commands/repo.py +0 -0
  17. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/commands/select.py +0 -0
  18. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/commands/user.py +0 -0
  19. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/database.py +0 -0
  20. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/defaults/__init__.py +0 -0
  21. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/defaults/starbash.toml +0 -0
  22. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/doit.py +0 -0
  23. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/doit_types.py +0 -0
  24. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/dwarf3.py +0 -0
  25. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/exception.py +0 -0
  26. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/icon.py +0 -0
  27. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/linux.py +0 -0
  28. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/main.py +0 -0
  29. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/os.py +0 -0
  30. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/parameters.py +0 -0
  31. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/paths.py +0 -0
  32. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/processed_target.py +0 -0
  33. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/processing_like.py +0 -0
  34. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/recipes/README.md +0 -0
  35. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/recipes/__init__.py +0 -0
  36. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/recipes/osc.py +0 -0
  37. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/rich.py +0 -0
  38. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/safety.py +0 -0
  39. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/score.py +0 -0
  40. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/selection.py +0 -0
  41. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/sim_siril/__init__.py +0 -0
  42. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/sim_siril/connection.py +0 -0
  43. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/sim_siril/enums.py +0 -0
  44. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/sim_siril/utility.py +0 -0
  45. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/stage_utils.py +0 -0
  46. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/stages.py +0 -0
  47. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/templates/__init__.py +0 -0
  48. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/templates/repo/master.toml +0 -0
  49. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/templates/repo/processed.toml +0 -0
  50. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/templates/target/master.toml +0 -0
  51. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/templates/target/processed.toml +0 -0
  52. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/templates/userconfig.toml +0 -0
  53. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/toml.py +0 -0
  54. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/tool/__init__.py +0 -0
  55. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/tool/context.py +0 -0
  56. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/tool/graxpert.py +0 -0
  57. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/tool/python.py +0 -0
  58. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/tool/rcastro.py +0 -0
  59. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/tool/siril.py +0 -0
  60. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/ui/main.py +0 -0
  61. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/url.py +0 -0
  62. {starbash-0.2.6 → starbash-0.2.7}/src/starbash/windows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: starbash
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: A tool for automating/standardizing/sharing astrophotography workflows.
5
5
  License-File: LICENSE
6
6
  Author: Kevin Hester
@@ -20,7 +20,7 @@ Requires-Dist: restrictedpython (>=8.1,<9.0)
20
20
  Requires-Dist: rich (>=14.2.0,<15.0.0)
21
21
  Requires-Dist: sentry-sdk (>=2.42.1,<3.0.0)
22
22
  Requires-Dist: textual (>=6.8.0,<7.0.0)
23
- Requires-Dist: toml-repo (>=0.1.2,<0.2.0)
23
+ Requires-Dist: toml-repo (>=0.1.6,<0.2.0)
24
24
  Requires-Dist: typer (>=0.20.0,<0.21.0)
25
25
  Requires-Dist: update-checker (>=0.18.0,<0.19.0)
26
26
  Project-URL: Documentation, https://github.com/geeksville/starbash/blob/main/README.md
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "starbash"
3
- version = "0.2.6"
3
+ version = "0.2.7"
4
4
  description = "A tool for automating/standardizing/sharing astrophotography workflows."
5
5
  authors = ["Kevin Hester <kevinh@geeksville.com>"]
6
6
  readme = "README.md"
@@ -34,7 +34,7 @@ graxpert = {version = "^3.2.0a2", extras = ["cpuonly"]}
34
34
  # experimenting with siril-scripts/processing/VeraLux_HyperMetric_Stretch.py
35
35
  numpy = "2.2.6"
36
36
  pyqt6 = "^6.10.1"
37
- toml-repo = "^0.1.2"
37
+ toml-repo = "^0.1.6"
38
38
 
39
39
  [tool.poetry.group.dev.dependencies]
40
40
  pytest = ">=8.4.2,<10.0.0"
@@ -1,4 +1,6 @@
1
1
  import logging
2
+ import os
3
+ import re
2
4
 
3
5
  from starbash import InputDef, Metadata, RequireDef
4
6
  from starbash.aliases import get_aliases
@@ -62,6 +64,16 @@ def _apply_filter(requires: RequireDef, candidates: list[ImageRow]) -> list[Imag
62
64
  repo_kind = metadata["repo"].kind() # I think repo is guaranteed to be present
63
65
  return repo_kind != "processed" and repo_kind != "master"
64
66
 
67
+ def _filter_filename(metadata: Metadata) -> bool:
68
+ """Keep images whose basename matches (or, with mode='exclude', does not match) a regex."""
69
+ pattern = get_safe(requires, "value")
70
+ mode = requires.get("mode", "include")
71
+ if mode not in ("include", "exclude"):
72
+ raise ValueError(f"Unknown filename filter mode: {mode}")
73
+ basename = os.path.basename(str(metadata.get("path") or metadata.get("abspath") or ""))
74
+ matched = re.search(pattern, basename) is not None
75
+ return matched if mode == "include" else not matched
76
+
65
77
  def _filter_min_count(metadata: Metadata) -> bool:
66
78
  """Min_count is handled in stage 2, so always return True here."""
67
79
  return True
@@ -71,6 +83,7 @@ def _apply_filter(requires: RequireDef, candidates: list[ImageRow]) -> list[Imag
71
83
  "metadata": _filter_metadata,
72
84
  "camera": _filter_camera,
73
85
  "unprocessed": _filter_unprocessed,
86
+ "filename": _filter_filename,
74
87
  "min_count": _filter_min_count,
75
88
  }
76
89
 
@@ -14,7 +14,7 @@ from toml_repo import Repo
14
14
  from tomlkit.items import AoT
15
15
 
16
16
  import starbash
17
- from starbash import InputDef, OutputDef, StageDict
17
+ from starbash import InputDef, OutputDef, RequireDef, StageDict
18
18
  from starbash.aliases import get_aliases, normalize_target_name
19
19
  from starbash.app import Starbash
20
20
  from starbash.database import (
@@ -900,6 +900,18 @@ class Processing(ProcessingLike):
900
900
  # Collect all image rows from prior stage outputs
901
901
  child_exception: Exception | None = None
902
902
 
903
+ # `filename` requires match the actual files we consume (the prior stage's
904
+ # OUTPUT names), which differ from its inputs; every other requires kind
905
+ # (metadata/camera/unprocessed/min_count) gates on the prior stage's rich
906
+ # INPUT rows. So split them and apply each to the correct row set.
907
+ all_requires: list[RequireDef] = input.get("requires", [])
908
+ filename_requires = [r for r in all_requires if r.get("kind") == "filename"]
909
+ gating_input: InputDef = (
910
+ {**input, "requires": [r for r in all_requires if r.get("kind") != "filename"]}
911
+ if filename_requires
912
+ else input
913
+ )
914
+
903
915
  base: str | None = None # We will set base from the first matching prior task output
904
916
  for task in prior_tasks:
905
917
  task_context: dict[str, Any] = task["meta"]["context"] # type: ignore
@@ -911,7 +923,7 @@ class Processing(ProcessingLike):
911
923
  images = file_info.image_rows
912
924
  images = [self._with_defaults(img) for img in images]
913
925
  try:
914
- task_filtered_input = filter_by_requires(input, images)
926
+ task_filtered_input = filter_by_requires(gating_input, images)
915
927
  if (
916
928
  task_filtered_input
917
929
  ): # This task had matching inputs for us, so therefore we want its outputs
@@ -922,7 +934,13 @@ class Processing(ProcessingLike):
922
934
  and task_output.image_rows
923
935
  ):
924
936
  base = task_output.base
925
- for img in task_output.image_rows:
937
+ # filename filters select which produced files we want.
938
+ output_rows = task_output.image_rows
939
+ if filename_requires:
940
+ output_rows = filter_by_requires(
941
+ {"requires": filename_requires}, output_rows
942
+ )
943
+ for img in output_rows:
926
944
  image_rows[img["abspath"]] = img
927
945
  except NotEnoughFilesError as e:
928
946
  child_exception = e # In case we need to raise later
@@ -933,6 +951,12 @@ class Processing(ProcessingLike):
933
951
  # we failed on every child, give up
934
952
  raise child_exception
935
953
 
954
+ if filename_requires and len(image_rows) == 0:
955
+ # Prior outputs existed but none matched the filename filter; skip this stage.
956
+ raise NotEnoughFilesError(
957
+ "No prior stage outputs matched the input filename filter", []
958
+ )
959
+
936
960
  return FileInfo(base=base, image_rows=list(image_rows.values()), definition=input)
937
961
 
938
962
  def preflight_tasks(self, pt: ProcessedTarget, tasks: list[TaskDict]) -> list[TaskDict]:
@@ -8,12 +8,16 @@ import subprocess
8
8
  import sys
9
9
  import tempfile
10
10
  import textwrap
11
+ from collections import deque
11
12
  from collections.abc import Callable
12
13
  from contextlib import nullcontext
13
14
  from typing import Any
14
15
 
16
+ from rich.console import Group, RenderableType
15
17
  from rich.live import Live
18
+ from rich.padding import Padding
16
19
  from rich.spinner import Spinner
20
+ from rich.text import Text
17
21
 
18
22
  from starbash.commands import SPINNER_STYLE
19
23
  from starbash.exception import UserHandledError
@@ -95,6 +99,56 @@ def color_lines(lines: list[str]) -> str:
95
99
  return "\n".join(color_line(line) for line in lines)
96
100
 
97
101
 
102
+ class ToolLiveDisplay:
103
+ """Live renderable: a running spinner plus the most recent tool output lines.
104
+
105
+ Fed incrementally by ``tool_run_streaming`` via :meth:`add_line`. stderr lines
106
+ render red, stdout lines yellow. Each line is truncated to a single row so the
107
+ display stays a fixed height while the tool runs.
108
+ """
109
+
110
+ MAX_LINES = 3
111
+
112
+ def __init__(self, name: str) -> None:
113
+ self.name = name
114
+ self.spinner = Spinner(
115
+ "arc",
116
+ text=f"Tool running: [bold]{name}[/bold]...",
117
+ speed=2.0,
118
+ style=SPINNER_STYLE,
119
+ )
120
+ self._lines: deque[Text] = deque(maxlen=self.MAX_LINES)
121
+ self._done = False
122
+
123
+ def add_line(self, line: str, is_stderr: bool) -> None:
124
+ """Append a tool output line, keeping only the most recent MAX_LINES."""
125
+ text = line.rstrip("\n")
126
+ if not text.strip():
127
+ return
128
+ self._lines.append(
129
+ Text(
130
+ text,
131
+ style="red" if is_stderr else "yellow",
132
+ no_wrap=True,
133
+ overflow="ellipsis",
134
+ )
135
+ )
136
+
137
+ def finish(self) -> None:
138
+ """Swap the spinner for a static header so the final frame persists."""
139
+ self._done = True
140
+
141
+ def __rich__(self) -> RenderableType:
142
+ header: RenderableType = (
143
+ Text(f"Tool completed: {self.name}", style="dim")
144
+ if self._done
145
+ else self.spinner
146
+ )
147
+ # Indent each output line by 4 spaces beneath the header.
148
+ lines = [Padding(line, (0, 0, 0, 4)) for line in self._lines]
149
+ return Group(header, *lines)
150
+
151
+
98
152
  def tool_emit_logs(lines: str, log_level: int = logging.INFO) -> None:
99
153
  """Emit log lines from a tool to the logger at the specified log level.
100
154
 
@@ -172,10 +226,10 @@ def tool_run_streaming(
172
226
  """Execute an external tool, invoking on_line for each stdout line as it arrives.
173
227
 
174
228
  Unlike a blocking communicate()-based runner, this streams stdout line-by-line so
175
- callers can react to incremental output (e.g. JSON progress events). A reader
176
- thread feeds a queue so the timeout fires correctly even for silent processes.
177
- stderr is collected and handled after the process exits. A non-zero exit code
178
- raises ToolError.
229
+ callers can react to incremental output (e.g. JSON progress events). Separate
230
+ reader threads for stdout and stderr feed a shared queue so the timeout fires
231
+ correctly even for silent processes and both streams are written to log_out in
232
+ approximate arrival order. A non-zero exit code raises ToolError.
179
233
  """
180
234
  import queue
181
235
  import threading
@@ -196,6 +250,7 @@ def tool_run_streaming(
196
250
  env=env,
197
251
  )
198
252
  assert process.stdout is not None
253
+ assert process.stderr is not None
199
254
 
200
255
  if commands:
201
256
  assert process.stdin is not None
@@ -209,57 +264,70 @@ def tool_run_streaming(
209
264
 
210
265
  threading.Thread(target=_write_stdin, daemon=True).start()
211
266
 
212
- line_queue: queue.Queue[str | None] = queue.Queue()
267
+ # Both reader threads push (stream_name, line) tuples so we can interleave the
268
+ # two streams into log_out in approximate arrival order. A None line marks EOF
269
+ # for that reader; the main loop stops once both readers have finished.
270
+ line_queue: queue.Queue[tuple[str, str | None]] = queue.Queue()
213
271
 
214
- def _reader() -> None:
272
+ def _reader(name: str, stream: io.TextIOBase) -> None:
215
273
  try:
216
- for line in process.stdout: # type: ignore[union-attr]
217
- line_queue.put(line)
274
+ for line in stream:
275
+ line_queue.put((name, line))
218
276
  except Exception:
219
277
  pass
220
278
  finally:
221
- line_queue.put(None) # EOF sentinel
279
+ line_queue.put((name, None)) # EOF sentinel
222
280
 
223
- threading.Thread(target=_reader, daemon=True).start()
281
+ threading.Thread(
282
+ target=_reader, args=("stdout", process.stdout), daemon=True
283
+ ).start()
284
+ threading.Thread(
285
+ target=_reader, args=("stderr", process.stderr), daemon=True
286
+ ).start()
224
287
 
225
288
  deadline = (time.monotonic() + timeout) if timeout else None
226
289
  stdout_captured: list[str] = []
290
+ stderr_captured: list[str] = []
291
+ readers_done = 0
227
292
 
228
293
  try:
229
- while True:
294
+ while readers_done < 2:
230
295
  remaining = max(0.0, deadline - time.monotonic()) if deadline else None
231
296
  try:
232
- line = line_queue.get(timeout=remaining)
297
+ stream_name, line = line_queue.get(timeout=remaining)
233
298
  except queue.Empty:
234
299
  process.kill()
235
300
  process.wait()
236
- if process.stderr:
237
- process.stderr.close()
238
301
  raise RuntimeError(f"Tool timed out after {timeout} seconds")
239
- if line is None: # EOF
240
- break
241
- stdout_captured.append(line)
302
+ if line is None: # EOF for one of the streams
303
+ readers_done += 1
304
+ continue
242
305
  if log_out:
243
306
  log_out.write(line)
244
307
  log_out.flush() # Just in case the user is 'tailing' the file
245
- if on_line:
246
- try:
247
- on_line(line)
248
- except Exception:
249
- logger.exception("Error in tool output line handler")
308
+ active_display = Tool._active_display
309
+ if active_display is not None:
310
+ active_display.add_line(line, is_stderr=stream_name == "stderr")
311
+ if stream_name == "stdout":
312
+ stdout_captured.append(line)
313
+ if on_line:
314
+ try:
315
+ on_line(line)
316
+ except Exception:
317
+ logger.exception("Error in tool output line handler")
318
+ else:
319
+ stderr_captured.append(line)
250
320
  finally:
251
321
  process.stdout.close()
252
-
253
- stderr_text = process.stderr.read() if process.stderr else ""
254
- if process.stderr:
255
322
  process.stderr.close()
323
+
256
324
  returncode = process.wait()
257
325
 
258
326
  stdout_str = "".join(stdout_captured)
259
327
  if returncode != 0:
260
328
  tool_emit_logs(stdout_str, log_level=logging.ERROR)
261
329
 
262
- stderr_list = stderr_text.splitlines() if stderr_text else []
330
+ stderr_list = [line.rstrip("\n") for line in stderr_captured]
263
331
  if stderr_fixup:
264
332
  stderr_list = stderr_fixup(stdout_captured, stderr_list)
265
333
  if stderr_list:
@@ -285,6 +353,10 @@ class Tool:
285
353
  # of our call tree, that variables get passed down to all sub-tools.
286
354
  _default_log_out: io.TextIOWrapper | None = None
287
355
 
356
+ # The live output display owned by the outermost tool in a call chain. Nested
357
+ # tools (and tool_run_streaming) feed their output lines into this same display.
358
+ _active_display: "ToolLiveDisplay | None" = None
359
+
288
360
  # If True, this tool renders its own progress display in _run() (e.g. a Rich
289
361
  # Progress bar), so Tool.run() suppresses the default spinner to avoid nested
290
362
  # Live displays.
@@ -320,22 +392,19 @@ class Tool:
320
392
  from starbash import console # Lazy import to avoid circular dependency
321
393
 
322
394
  temp_dir = None
323
- spinner = (
324
- None
325
- if self.manages_own_progress
326
- else Spinner(
327
- "arc",
328
- text=f"Tool running: [bold]{self.name}[/bold]...",
329
- speed=2.0,
330
- style=SPINNER_STYLE,
331
- )
332
- )
395
+ # Only the outermost tool in a call chain owns the live display; nested tools
396
+ # (and tool_run_streaming) feed their output into the already-active display.
397
+ owns_display = not self.manages_own_progress and Tool._active_display is None
398
+ display_obj = ToolLiveDisplay(self.name) if owns_display else None
333
399
  display = (
334
- nullcontext()
335
- if spinner is None
336
- else Live(spinner, console=console, refresh_per_second=5, transient=True)
400
+ Live(display_obj, console=console, refresh_per_second=8, transient=False)
401
+ if display_obj is not None
402
+ else nullcontext()
337
403
  )
338
404
  with display:
405
+ if display_obj is not None:
406
+ Tool._active_display = display_obj
407
+
339
408
  did_set_default_log = (
340
409
  False # Assume we are not the top entry into the chain of tool calls
341
410
  )
@@ -359,8 +428,9 @@ class Tool:
359
428
 
360
429
  self._run(cwd, commands, context=context, log_out=my_log, **kwargs)
361
430
  finally:
362
- if spinner is not None:
363
- spinner.update(text=f"Tool completed: [bold]{self.name}[/bold].")
431
+ if display_obj is not None:
432
+ display_obj.finish()
433
+ Tool._active_display = None
364
434
  if temp_dir:
365
435
  shutil.rmtree(temp_dir)
366
436
  context.pop("temp_dir", None)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes