starbash 0.1.0__py3-none-any.whl → 0.1.3__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.

Potentially problematic release.


This version of starbash might be problematic. Click here for more details.

starbash/main.py CHANGED
@@ -1,26 +1,155 @@
1
1
  import logging
2
+ from datetime import datetime
3
+ from tomlkit import table
2
4
  import typer
5
+ from rich.table import Table
3
6
 
4
- from .app import AstroGlue
5
- from .commands import repo
7
+ from starbash.database import Database
8
+ import starbash.url as url
6
9
 
7
- app = typer.Typer()
10
+ from .app import Starbash
11
+ from .commands import repo, user, selection
12
+ from . import console
13
+
14
+ app = typer.Typer(
15
+ rich_markup_mode="rich",
16
+ help=f"Starbash - Astrophotography workflows simplified.\n\nFor full instructions and support [link={url.project}]click here[/link].",
17
+ )
18
+ app.add_typer(user.app, name="user", help="Manage user settings.")
8
19
  app.add_typer(repo.app, name="repo", help="Manage Starbash repositories.")
20
+ app.add_typer(
21
+ selection.app, name="selection", help="Manage session and target selection."
22
+ )
23
+
9
24
 
25
+ @app.callback(invoke_without_command=True)
26
+ def main_callback(ctx: typer.Context):
27
+ """Main callback for the Starbash application."""
28
+ if ctx.invoked_subcommand is None:
29
+ # No command provided, show help
30
+ console.print(ctx.get_help())
31
+ raise typer.Exit()
10
32
 
11
- @app.command(hidden=True)
12
- def default_cmd():
13
- """Default entry point for the starbash application."""
14
33
 
15
- with AstroGlue() as ag:
16
- pass
34
+ def format_duration(seconds: int):
35
+ """Format seconds as a human-readable duration string."""
36
+ if seconds < 60:
37
+ return f"{int(seconds)}s"
38
+ elif seconds < 120:
39
+ minutes = int(seconds // 60)
40
+ secs = int(seconds % 60)
41
+ return f"{minutes}m {secs}s" if secs else f"{minutes}m"
42
+ else:
43
+ hours = int(seconds // 3600)
44
+ minutes = int((seconds % 3600) // 60)
45
+ return f"{hours}h {minutes}m" if minutes else f"{hours}h"
17
46
 
18
47
 
19
- @app.callback(invoke_without_command=True)
20
- def _default(ctx: typer.Context):
21
- # If the user didn’t specify a subcommand, run the default
22
- if ctx.invoked_subcommand is None:
23
- return default_cmd()
48
+ @app.command()
49
+ def session():
50
+ """List sessions (filtered based on the current selection)"""
51
+
52
+ with Starbash("session") as sb:
53
+ sessions = sb.search_session()
54
+ if sessions and isinstance(sessions, list):
55
+ len_all = sb.db.len_session()
56
+ table = Table(title=f"Sessions ({len(sessions)} selected out of {len_all})")
57
+
58
+ table.add_column("Date", style="cyan", no_wrap=True)
59
+ table.add_column("# images", style="cyan", no_wrap=True)
60
+ table.add_column("Time", style="cyan", no_wrap=True)
61
+ table.add_column("Type/Filter", style="cyan", no_wrap=True)
62
+ table.add_column("Telescope", style="cyan", no_wrap=True)
63
+ table.add_column(
64
+ "About", style="cyan", no_wrap=True
65
+ ) # type of frames, filter, target
66
+ # table.add_column("Released", justify="right", style="cyan", no_wrap=True)
67
+
68
+ total_images = 0
69
+ total_seconds = 0.0
70
+
71
+ for sess in sessions:
72
+ date_iso = sess.get(Database.START_KEY, "N/A")
73
+ # Try to cnvert ISO UTC datetime to local short date string
74
+ try:
75
+ dt_utc = datetime.fromisoformat(date_iso)
76
+ dt_local = dt_utc.astimezone()
77
+ date = dt_local.strftime("%Y-%m-%d")
78
+ except (ValueError, TypeError):
79
+ date = date_iso
80
+
81
+ object = str(sess.get(Database.OBJECT_KEY, "N/A"))
82
+ filter = sess.get(Database.FILTER_KEY, "N/A")
83
+ image_type = str(sess.get(Database.IMAGETYP_KEY, "N/A"))
84
+ telescop = str(sess.get(Database.TELESCOP_KEY, "N/A"))
85
+
86
+ # Format total exposure time as integer seconds
87
+ exptime_raw = str(sess.get(Database.EXPTIME_TOTAL_KEY, "N/A"))
88
+ try:
89
+ exptime_float = float(exptime_raw)
90
+ total_seconds += exptime_float
91
+ total_secs = format_duration(int(exptime_float))
92
+ except (ValueError, TypeError):
93
+ total_secs = exptime_raw
94
+
95
+ # Count images
96
+ try:
97
+ num_images = int(sess.get(Database.NUM_IMAGES_KEY, 0))
98
+ total_images += num_images
99
+ except (ValueError, TypeError):
100
+ num_images = sess.get(Database.NUM_IMAGES_KEY, "N/A")
101
+
102
+ type_str = image_type
103
+ if image_type.upper() == "LIGHT":
104
+ image_type = filter
105
+ elif image_type.upper() == "FLAT":
106
+ image_type = f"{image_type}/{filter}"
107
+ else: # either bias or dark
108
+ object = "" # Don't show meaningless target
109
+
110
+ table.add_row(
111
+ date,
112
+ str(num_images),
113
+ total_secs,
114
+ image_type,
115
+ telescop,
116
+ object,
117
+ )
118
+
119
+ # Add totals row
120
+ if sessions:
121
+ table.add_row(
122
+ "",
123
+ f"[bold]{total_images}[/bold]",
124
+ f"[bold]{format_duration(int(total_seconds))}[/bold]",
125
+ "",
126
+ "",
127
+ "",
128
+ )
129
+
130
+ console.print(table)
131
+
132
+
133
+ # @app.command(hidden=True)
134
+ # def default_cmd():
135
+ # """Default entry point for the starbash application."""
136
+ #
137
+ # with Starbash() as sb:
138
+
139
+
140
+ # @app.command(hidden=True)
141
+ # def default_cmd():
142
+ # """Default entry point for the starbash application."""
143
+ #
144
+ # with Starbash() as sb:
145
+ # pass
146
+ #
147
+ #
148
+ # @app.callback(invoke_without_command=True)
149
+ # def _default(ctx: typer.Context):
150
+ # # If the user didn’t specify a subcommand, run the default
151
+ # if ctx.invoked_subcommand is None:
152
+ # return default_cmd()
24
153
 
25
154
 
26
155
  if __name__ == "__main__":
starbash/paths.py ADDED
@@ -0,0 +1,38 @@
1
+ import os
2
+ from pathlib import Path
3
+ from platformdirs import PlatformDirs
4
+
5
+ app_name = "starbash"
6
+ app_author = "geeksville"
7
+ dirs = PlatformDirs(app_name, app_author)
8
+ config_dir = Path(dirs.user_config_dir)
9
+ data_dir = Path(dirs.user_data_dir)
10
+
11
+ # These can be overridden for testing
12
+ _override_config_dir: Path | None = None
13
+ _override_data_dir: Path | None = None
14
+
15
+
16
+ def set_test_directories(
17
+ config_dir_override: Path | None = None, data_dir_override: Path | None = None
18
+ ) -> None:
19
+ """Set override directories for testing. Used by test fixtures to isolate test data."""
20
+ global _override_config_dir, _override_data_dir
21
+ _override_config_dir = config_dir_override
22
+ _override_data_dir = data_dir_override
23
+
24
+
25
+ def get_user_config_dir() -> Path:
26
+ """Get the user config directory. Returns test override if set, otherwise the real user directory."""
27
+ dir_to_use = (
28
+ _override_config_dir if _override_config_dir is not None else config_dir
29
+ )
30
+ os.makedirs(dir_to_use, exist_ok=True)
31
+ return dir_to_use
32
+
33
+
34
+ def get_user_data_dir() -> Path:
35
+ """Get the user data directory. Returns test override if set, otherwise the real user directory."""
36
+ dir_to_use = _override_data_dir if _override_data_dir is not None else data_dir
37
+ os.makedirs(dir_to_use, exist_ok=True)
38
+ return dir_to_use
starbash/repo/manager.py CHANGED
@@ -5,20 +5,24 @@ Manages the repository of processing recipes and configurations.
5
5
  from __future__ import annotations
6
6
  import logging
7
7
  from pathlib import Path
8
+ from importlib import resources
8
9
 
9
10
  import tomlkit
11
+ from tomlkit.toml_file import TOMLFile
10
12
  from tomlkit.items import AoT
11
13
  from multidict import MultiDict
12
14
 
13
15
 
14
16
  repo_suffix = "starbash.toml"
15
17
 
18
+ REPO_REF = "repo-ref"
19
+
16
20
 
17
21
  class Repo:
18
22
  """
19
23
  Represents a single starbash repository."""
20
24
 
21
- def __init__(self, manager: RepoManager, url: str, config: str | None = None):
25
+ def __init__(self, manager: RepoManager, url: str):
22
26
  """
23
27
  Initializes a Repo instance.
24
28
 
@@ -27,15 +31,14 @@ class Repo:
27
31
  """
28
32
  self.manager = manager
29
33
  self.url = url
30
- self.config = tomlkit.parse(config) if config else self._load_config()
31
- self.manager.add_all_repos(self.config, self.get_path())
34
+ self.config = self._load_config()
32
35
 
33
36
  def __str__(self) -> str:
34
37
  """Return a concise one-line description of this repo.
35
38
 
36
39
  Example: "Repo(kind=recipe, local=True, url=file:///path/to/repo)"
37
40
  """
38
- return f"Repo(kind={self.kind}, local={self.is_local}, url={self.url})"
41
+ return f"Repo(kind={self.kind}, url={self.url})"
39
42
 
40
43
  __repr__ = __str__
41
44
 
@@ -49,8 +52,37 @@ class Repo:
49
52
  """
50
53
  return str(self.get("repo.kind", "unknown"))
51
54
 
52
- @property
53
- def is_local(self) -> bool:
55
+ def add_repo_ref(self, dir: str) -> None:
56
+ aot = self.config.get(REPO_REF, None)
57
+ if aot is None:
58
+ aot = tomlkit.aot()
59
+ else:
60
+ self.config.remove(
61
+ REPO_REF
62
+ ) # We want to completely replace it at the end of the file
63
+
64
+ ref = {"dir": dir}
65
+ aot.append(ref)
66
+ self.config[REPO_REF] = aot
67
+ self.add_from_ref(ref)
68
+
69
+ def write_config(self) -> None:
70
+ """
71
+ Writes the current (possibly modified) configuration back to the repository's config file.
72
+
73
+ Raises:
74
+ ValueError: If the repository is not a local file repository.
75
+ """
76
+ base_path = self.get_path()
77
+ if base_path is None:
78
+ raise ValueError("Cannot resolve path for non-local repository")
79
+
80
+ config_path = base_path / repo_suffix
81
+ # FIXME, be more careful to write the file atomically (by writing to a temp file and renaming)
82
+ TOMLFile(config_path).write(self.config)
83
+ logging.debug(f"Wrote config to {config_path}")
84
+
85
+ def is_scheme(self, scheme: str = "file") -> bool:
54
86
  """
55
87
  Read-only attribute indicating whether the repository URL points to a
56
88
  local file system path (file:// scheme).
@@ -58,7 +90,7 @@ class Repo:
58
90
  Returns:
59
91
  bool: True if the URL is a local file path, False otherwise.
60
92
  """
61
- return self.url.startswith("file://")
93
+ return self.url.startswith(f"{scheme}://")
62
94
 
63
95
  def get_path(self) -> Path | None:
64
96
  """
@@ -70,12 +102,39 @@ class Repo:
70
102
  Returns:
71
103
  A Path object if the URL is a local file, otherwise None.
72
104
  """
73
- if self.is_local:
105
+ if self.is_scheme("file"):
74
106
  return Path(self.url[len("file://") :])
75
107
 
76
108
  return None
77
109
 
78
- def read(self, filepath: str) -> str:
110
+ def add_from_ref(self, ref: dict) -> None:
111
+ """
112
+ Adds a repository based on a repo-ref dictionary.
113
+ """
114
+ if "url" in ref:
115
+ url = ref["url"]
116
+ elif "dir" in ref:
117
+ path = Path(ref["dir"])
118
+ base_path = self.get_path()
119
+ if base_path and not path.is_absolute():
120
+ # Resolve relative to the current TOML file's directory
121
+ path = (base_path / path).resolve()
122
+ else:
123
+ # Expand ~ and resolve from CWD
124
+ path = path.expanduser().resolve()
125
+ url = f"file://{path}"
126
+ else:
127
+ raise ValueError(f"Invalid repo reference: {ref}")
128
+ self.manager.add_repo(url)
129
+
130
+ def add_by_repo_refs(self) -> None:
131
+ """Add all repos mentioned by repo-refs in this repo's config."""
132
+ repo_refs = self.config.get(REPO_REF, [])
133
+
134
+ for ref in repo_refs:
135
+ self.add_from_ref(ref)
136
+
137
+ def _read_file(self, filepath: str) -> str:
79
138
  """
80
139
  Read a filepath relative to the base of this repo. Return the contents in a string.
81
140
 
@@ -96,7 +155,31 @@ class Repo:
96
155
 
97
156
  return target_path.read_text()
98
157
 
99
- def _load_config(self) -> dict:
158
+ def _read_resource(self, filepath: str) -> str:
159
+ """
160
+ Read a resource from the installed starbash package using a pkg:// URL.
161
+
162
+ Assumptions (simplified per project constraints):
163
+ - All pkg URLs point somewhere inside the already-imported 'starbash' package.
164
+ - The URL is treated as a path relative to the starbash package root.
165
+
166
+ Examples:
167
+ url: pkg://defaults + filepath: "starbash.toml"
168
+ -> reads starbash/defaults/starbash.toml
169
+
170
+ Args:
171
+ filepath: Path within the base resource directory for this repo.
172
+
173
+ Returns:
174
+ The content of the resource as a string (UTF-8).
175
+ """
176
+ # Path portion after pkg://, interpreted relative to the 'starbash' package
177
+ subpath = self.url[len("pkg://") :].strip("/")
178
+
179
+ res = resources.files("starbash").joinpath(subpath).joinpath(filepath)
180
+ return res.read_text()
181
+
182
+ def _load_config(self) -> tomlkit.TOMLDocument:
100
183
  """
101
184
  Loads the repository's configuration file (e.g., repo.sb.toml).
102
185
 
@@ -106,12 +189,19 @@ class Repo:
106
189
  A dictionary containing the parsed configuration.
107
190
  """
108
191
  try:
109
- config_content = self.read(repo_suffix)
192
+ if self.is_scheme("file"):
193
+ config_content = self._read_file(repo_suffix)
194
+ elif self.is_scheme("pkg"):
195
+ config_content = self._read_resource(repo_suffix)
196
+ else:
197
+ raise ValueError(f"Unsupported URL scheme for repo: {self.url}")
110
198
  logging.debug(f"Loading repo config from {repo_suffix}")
111
199
  return tomlkit.parse(config_content)
112
200
  except FileNotFoundError:
113
- logging.warning(f"No {repo_suffix} found")
114
- return {}
201
+ logging.debug(
202
+ f"No {repo_suffix} found"
203
+ ) # we currently make it optional to have the config file at root
204
+ return tomlkit.TOMLDocument() # empty placeholder
115
205
 
116
206
  def get(self, key: str, default=None):
117
207
  """
@@ -142,42 +232,36 @@ class RepoManager:
142
232
  files (like appdefaults.sb.toml).
143
233
  """
144
234
 
145
- def __init__(self, app_defaults: str):
235
+ def __init__(self):
146
236
  """
147
237
  Initializes the RepoManager by loading the application default repos.
148
238
  """
149
239
  self.repos = []
150
240
 
151
241
  # We expose the app default preferences as a special root repo with a private URL
152
- root_repo = Repo(self, "pkg://starbash-defaults", config=app_defaults)
153
- self.repos.append(root_repo)
242
+ # root_repo = Repo(self, "pkg://starbash-defaults", config=app_defaults)
243
+ # self.repos.append(root_repo)
154
244
 
155
245
  # Most users will just want to read from merged
156
- self.merged = self._union()
157
-
158
- def add_all_repos(self, toml: dict, base_path: Path | None = None) -> None:
159
- # From appdefaults.sb.toml, repo.ref is a list of tables
160
- repo_refs = toml.get("repo", {}).get("ref", [])
246
+ self.merged = MultiDict()
161
247
 
162
- for ref in repo_refs:
163
- if "url" in ref:
164
- url = ref["url"]
165
- elif "dir" in ref:
166
- path = Path(ref["dir"])
167
- if base_path and not path.is_absolute():
168
- # Resolve relative to the current TOML file's directory
169
- path = (base_path / path).resolve()
170
- else:
171
- # Expand ~ and resolve from CWD
172
- path = path.expanduser().resolve()
173
- url = f"file://{path}"
174
- else:
175
- raise ValueError(f"Invalid repo reference: {ref}")
176
- self.add_repo(url)
248
+ @property
249
+ def regular_repos(self) -> list[Repo]:
250
+ "We exclude certain repo types (preferences, recipe) from the list of repos users care about."
251
+ return [r for r in self.repos if r.kind not in ("preferences", "recipe")]
177
252
 
178
- def add_repo(self, url: str) -> None:
253
+ def add_repo(self, url: str) -> Repo:
179
254
  logging.debug(f"Adding repo: {url}")
180
- self.repos.append(Repo(self, url))
255
+ r = Repo(self, url)
256
+ self.repos.append(r)
257
+
258
+ # FIXME, generate the merged dict lazily
259
+ self._add_merged(r)
260
+
261
+ # if this new repo has sub-repos, add them too
262
+ r.add_by_repo_refs()
263
+
264
+ return r
181
265
 
182
266
  def get(self, key: str, default=None):
183
267
  """
@@ -214,32 +298,18 @@ class RepoManager:
214
298
  # For a debug dump, a simple string representation is usually sufficient.
215
299
  logging.info(f" %s: %s", key, value)
216
300
 
217
- def _union(self) -> MultiDict:
218
- """
219
- Merges the top-level keys from all repository configurations into a MultiDict.
220
-
221
- This method iterates through all loaded repositories in their original order
222
- and combines their top-level configuration keys. If a key exists in multiple
223
- repositories, all of its values will be present in the returned MultiDict.
224
-
225
- Returns:
226
- A MultiDict containing the union of all top-level keys.
227
- """
228
- merged_dict = MultiDict()
229
- for repo in self.repos:
230
- for key, value in repo.config.items():
231
- # if the toml object is an AoT type, monkey patch each element in the array instead
232
- if isinstance(value, AoT):
233
- for v in value:
234
- setattr(v, "source", repo)
301
+ def _add_merged(self, repo: Repo) -> None:
302
+ for key, value in repo.config.items():
303
+ # if the toml object is an AoT type, monkey patch each element in the array instead
304
+ if isinstance(value, AoT):
305
+ for v in value:
306
+ setattr(v, "source", repo)
235
307
  else:
236
308
  # We monkey patch source into any object that came from a repo, so that users can
237
309
  # find the source repo (for attribution, URL relative resolution, whatever...)
238
310
  setattr(value, "source", repo)
239
311
 
240
- merged_dict.add(key, value)
241
-
242
- return merged_dict
312
+ self.merged.add(key, value)
243
313
 
244
314
  def __str__(self):
245
315
  lines = [f"RepoManager with {len(self.repos)} repositories:"]