starbash 0.1.1__py3-none-any.whl → 0.1.4__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/__init__.py CHANGED
@@ -1,6 +1,11 @@
1
+ import logging
2
+
1
3
  from .database import Database # re-export for convenience
2
4
  from rich.console import Console
3
5
 
4
6
  console = Console()
5
7
 
8
+ # Global variable for log filter level (can be changed via --debug flag)
9
+ log_filter_level = logging.INFO
10
+
6
11
  __all__ = ["Database"]
starbash/analytics.py CHANGED
@@ -1,5 +1,7 @@
1
1
  import logging
2
+ import os
2
3
 
4
+ import starbash
3
5
  from starbash import console
4
6
  import starbash.url as url
5
7
 
@@ -8,11 +10,12 @@ analytics_allowed = False
8
10
 
9
11
 
10
12
  def analytics_setup(allowed: bool = False, user_email: str | None = None) -> None:
11
- import sentry_sdk
12
-
13
13
  global analytics_allowed
14
14
  analytics_allowed = allowed
15
15
  if analytics_allowed:
16
+ import sentry_sdk
17
+ from sentry_sdk.integrations.logging import LoggingIntegration
18
+
16
19
  logging.info(
17
20
  f"Analytics/crash-reports enabled. To change [link={url.analytics_docs}]click here[/link]",
18
21
  extra={"markup": True},
@@ -22,6 +25,13 @@ def analytics_setup(allowed: bool = False, user_email: str | None = None) -> Non
22
25
  send_default_pii=True,
23
26
  enable_logs=True,
24
27
  traces_sample_rate=1.0,
28
+ integrations=[
29
+ LoggingIntegration(
30
+ level=starbash.log_filter_level, # Capture INFO and above as breadcrumbs
31
+ event_level=None, # Don't automatically convert error messages to sentry events
32
+ sentry_logs_level=starbash.log_filter_level, # Capture INFO and above as logs
33
+ ),
34
+ ],
25
35
  )
26
36
 
27
37
  if user_email:
@@ -41,11 +51,13 @@ def analytics_shutdown() -> None:
41
51
  sentry_sdk.flush()
42
52
 
43
53
 
54
+ def is_running_in_pytest() -> bool:
55
+ """Detect if code is being run inside pytest."""
56
+ return "PYTEST_CURRENT_TEST" in os.environ
57
+
58
+
44
59
  def is_development_environment() -> bool:
45
60
  """Detect if running in a development environment."""
46
- import os
47
- import sys
48
- from pathlib import Path
49
61
 
50
62
  # Check for explicit environment variable
51
63
  if os.getenv("STARBASH_ENV") == "development":
@@ -68,7 +80,10 @@ def analytics_exception(exc: Exception) -> bool:
68
80
  if analytics_allowed:
69
81
  import sentry_sdk
70
82
 
71
- report_id = sentry_sdk.capture_exception(exc)
83
+ if is_running_in_pytest():
84
+ report_id = "TESTING-ENVIRONMENT"
85
+ else:
86
+ report_id = sentry_sdk.capture_exception(exc)
72
87
 
73
88
  logging.info(
74
89
  f"""An unexpected error has occurred and been reported. Thank you for your help.
starbash/app.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  from importlib import resources
3
3
  from pathlib import Path
4
-
4
+ import typer
5
5
  import tomlkit
6
6
  from tomlkit.toml_file import TOMLFile
7
7
  import glob
@@ -10,6 +10,10 @@ from astropy.io import fits
10
10
  import itertools
11
11
  from rich.progress import track
12
12
  from rich.logging import RichHandler
13
+ import shutil
14
+
15
+ import starbash
16
+ from starbash import console
13
17
  from starbash.database import Database
14
18
  from starbash.repo.manager import Repo
15
19
  from starbash.tool import Tool
@@ -31,30 +35,81 @@ def setup_logging():
31
35
  Configures basic logging.
32
36
  """
33
37
  logging.basicConfig(
34
- level="INFO", # don't print messages of lower priority than this
38
+ level=starbash.log_filter_level, # use the global log filter level
35
39
  format="%(message)s",
36
40
  datefmt="[%X]",
37
41
  handlers=[RichHandler(rich_tracebacks=True)],
38
42
  )
39
43
 
40
44
 
41
- setup_logging()
45
+ def get_user_config_path() -> Path:
46
+ """Returns the path to the user config file."""
47
+ config_dir = get_user_config_dir()
48
+ return config_dir / "starbash.toml"
42
49
 
43
50
 
44
51
  def create_user() -> Path:
45
52
  """Create user directories if they don't exist yet."""
46
- config_dir = get_user_config_dir()
47
- userconfig_path = config_dir / "starbash.toml"
48
- if not (userconfig_path).exists():
53
+ path = get_user_config_path()
54
+ if not path.exists():
49
55
  tomlstr = (
50
56
  resources.files("starbash")
51
57
  .joinpath("templates/userconfig.toml")
52
58
  .read_text()
53
59
  )
54
60
  toml = tomlkit.parse(tomlstr)
55
- TOMLFile(userconfig_path).write(toml)
56
- logging.info(f"Created user config file: {userconfig_path}")
57
- return config_dir
61
+ TOMLFile(path).write(toml)
62
+ logging.info(f"Created user config file: {path}")
63
+ return get_user_config_dir()
64
+
65
+
66
+ def copy_images_to_dir(images: list[dict[str, Any]], output_dir: Path) -> None:
67
+ """Copy images to the specified output directory (using symbolic links if possible)."""
68
+
69
+ # Export images
70
+ console.print(f"[cyan]Exporting {len(images)} images to {output_dir}...[/cyan]")
71
+
72
+ linked_count = 0
73
+ copied_count = 0
74
+ error_count = 0
75
+
76
+ for image in images:
77
+ # Get the source path from the image metadata
78
+ source_path = Path(image.get("path", ""))
79
+
80
+ if not source_path.exists():
81
+ console.print(f"[red]Warning: Source file not found: {source_path}[/red]")
82
+ error_count += 1
83
+ continue
84
+
85
+ # Determine destination filename
86
+ dest_path = output_dir / source_path.name
87
+ if dest_path.exists():
88
+ console.print(f"[yellow]Skipping existing file: {dest_path}[/yellow]")
89
+ error_count += 1
90
+ continue
91
+
92
+ # Try to create a symbolic link first
93
+ try:
94
+ dest_path.symlink_to(source_path.resolve())
95
+ linked_count += 1
96
+ except (OSError, NotImplementedError):
97
+ # If symlink fails, try to copy
98
+ try:
99
+ shutil.copy2(source_path, dest_path)
100
+ copied_count += 1
101
+ except Exception as e:
102
+ console.print(f"[red]Error copying {source_path.name}: {e}[/red]")
103
+ error_count += 1
104
+
105
+ # Print summary
106
+ console.print(f"[green]Export complete![/green]")
107
+ if linked_count > 0:
108
+ console.print(f" Linked: {linked_count} files")
109
+ if copied_count > 0:
110
+ console.print(f" Copied: {copied_count} files")
111
+ if error_count > 0:
112
+ console.print(f" [red]Errors: {error_count} files[/red]")
58
113
 
59
114
 
60
115
  class Starbash:
@@ -89,7 +144,7 @@ class Starbash:
89
144
  self.analytics.__enter__()
90
145
 
91
146
  logging.info(
92
- f"Repo manager initialized with {len(self.repo_manager.repos)} default repo references."
147
+ f"Repo manager initialized with {len(self.repo_manager.repos)} repos."
93
148
  )
94
149
  # self.repo_manager.dump()
95
150
 
@@ -117,7 +172,8 @@ class Starbash:
117
172
 
118
173
  def __exit__(self, exc_type, exc, tb) -> bool:
119
174
  handled = False
120
- if exc:
175
+ # Don't suppress typer.Exit - it's used for controlled exit codes
176
+ if exc and not isinstance(exc, typer.Exit):
121
177
  handled = analytics_exception(exc)
122
178
  self.close()
123
179
  return handled
@@ -128,11 +184,12 @@ class Starbash:
128
184
  date = header.get(Database.DATE_OBS_KEY)
129
185
  if not date or not image_type:
130
186
  logging.warning(
131
- "Image %s missing critical FITS header, please submit image at https://github.com/geeksville/starbash/issues/new",
187
+ "Image %s missing either DATE-OBS or IMAGETYP FITS header, skipping...",
132
188
  f,
133
189
  )
134
190
  else:
135
191
  exptime = header.get(Database.EXPTIME_KEY, 0)
192
+ telescop = header.get(Database.TELESCOP_KEY, "unspecified")
136
193
  new = {
137
194
  Database.FILTER_KEY: filter,
138
195
  Database.START_KEY: date,
@@ -142,6 +199,7 @@ class Starbash:
142
199
  Database.NUM_IMAGES_KEY: 1,
143
200
  Database.EXPTIME_TOTAL_KEY: exptime,
144
201
  Database.OBJECT_KEY: header.get(Database.OBJECT_KEY, "unspecified"),
202
+ Database.TELESCOP_KEY: telescop,
145
203
  }
146
204
  session = self.db.get_session(new)
147
205
  self.db.upsert_session(new, existing=session)
@@ -156,16 +214,86 @@ class Starbash:
156
214
  conditions = self.selection.get_query_conditions()
157
215
  return self.db.search_session(conditions)
158
216
 
217
+ def get_session_images(self, session_id: int) -> list[dict[str, Any]]:
218
+ """
219
+ Get all images belonging to a specific session.
220
+
221
+ Sessions are defined by a unique combination of filter, imagetyp (image type),
222
+ object (target name), telescope, and date range. This method queries the images
223
+ table for all images matching the session's criteria in a single database query.
224
+
225
+ Args:
226
+ session_id: The database ID of the session
227
+
228
+ Returns:
229
+ List of image records (dictionaries with path, metadata, etc.)
230
+ Returns empty list if session not found or has no images.
231
+
232
+ Raises:
233
+ ValueError: If session_id is not found in the database
234
+ """
235
+ # First get the session details
236
+ session = self.db.get_session_by_id(session_id)
237
+ if session is None:
238
+ raise ValueError(f"Session with id {session_id} not found")
239
+
240
+ # Query images that match ALL session criteria including date range
241
+ conditions = {
242
+ Database.FILTER_KEY: session[Database.FILTER_KEY],
243
+ Database.IMAGETYP_KEY: session[Database.IMAGETYP_KEY],
244
+ Database.OBJECT_KEY: session[Database.OBJECT_KEY],
245
+ Database.TELESCOP_KEY: session[Database.TELESCOP_KEY],
246
+ "date_start": session[Database.START_KEY],
247
+ "date_end": session[Database.END_KEY],
248
+ }
249
+
250
+ # Single query with all conditions
251
+ images = self.db.search_image(conditions)
252
+ return images if images else []
253
+
254
+ def remove_repo_ref(self, url: str) -> None:
255
+ """
256
+ Remove a repository reference from the user configuration.
257
+
258
+ Args:
259
+ url: The repository URL to remove (e.g., 'file:///path/to/repo')
260
+
261
+ Raises:
262
+ ValueError: If the repository URL is not found in user configuration
263
+ """
264
+ # Get the repo-ref list from user config
265
+ repo_refs = self.user_repo.config.get("repo-ref")
266
+
267
+ if not repo_refs:
268
+ raise ValueError(f"No repository references found in user configuration.")
269
+
270
+ # Find and remove the matching repo-ref
271
+ found = False
272
+ refs_copy = [r for r in repo_refs] # Make a copy to iterate
273
+ for ref in refs_copy:
274
+ ref_dir = ref.get("dir", "")
275
+ # Match by converting to file:// URL format if needed
276
+ if ref_dir == url or f"file://{ref_dir}" == url:
277
+ repo_refs.remove(ref)
278
+ found = True
279
+ break
280
+
281
+ if not found:
282
+ raise ValueError(f"Repository '{url}' not found in user configuration.")
283
+
284
+ # Write the updated config
285
+ self.user_repo.write_config()
286
+
159
287
  def reindex_repo(self, repo: Repo, force: bool = False):
160
288
  """Reindex all repositories managed by the RepoManager."""
161
289
  # FIXME, add a method to get just the repos that contain images
162
290
  if repo.is_scheme("file") and repo.kind != "recipe":
163
291
  logging.debug("Reindexing %s...", repo.url)
164
292
 
293
+ whitelist = None
165
294
  config = self.repo_manager.merged.get("config")
166
- if not config:
167
- raise ValueError(f"App config not found.")
168
- whitelist = config["fits-whitelist"]
295
+ if config:
296
+ whitelist = config.get("fits-whitelist", None)
169
297
 
170
298
  path = repo.get_path()
171
299
  if not path:
@@ -191,7 +319,7 @@ class Starbash:
191
319
  items = header.items()
192
320
  headers = {}
193
321
  for key, value in items:
194
- if key in whitelist:
322
+ if (not whitelist) or (key in whitelist):
195
323
  headers[key] = value
196
324
  logging.debug("Headers for %s: %s", f, headers)
197
325
  headers["path"] = str(f)
@@ -207,7 +335,7 @@ class Starbash:
207
335
 
208
336
  def reindex_repos(self, force: bool = False):
209
337
  """Reindex all repositories managed by the RepoManager."""
210
- logging.info("Reindexing all repositories...")
338
+ logging.debug("Reindexing all repositories...")
211
339
 
212
340
  for repo in track(self.repo_manager.repos, description="Reindexing repos..."):
213
341
  self.reindex_repo(repo, force=force)
starbash/commands/repo.py CHANGED
@@ -4,64 +4,136 @@ from typing_extensions import Annotated
4
4
  from starbash.app import Starbash
5
5
  from starbash import console
6
6
 
7
- app = typer.Typer()
7
+ app = typer.Typer(invoke_without_command=True)
8
8
 
9
9
 
10
- @app.command()
11
- def add(path: str):
10
+ @app.callback()
11
+ def main(
12
+ ctx: typer.Context,
13
+ verbose: bool = typer.Option(
14
+ False, "--verbose", "-v", help="Show all repos including system repos"
15
+ ),
16
+ ):
12
17
  """
13
- Add a repository. path is either a local path or a remote URL.
18
+ Manage repositories.
19
+
20
+ When called without a subcommand, lists all repositories.
21
+ Use --verbose to show all repos including system/recipe repos.
14
22
  """
15
- with Starbash("repo-add") as sb:
16
- sb.user_repo.add_repo_ref(path)
17
- # we don't yet write default config files at roots of repos, but it would be easy to add here
18
- # r.write_config()
19
- sb.user_repo.write_config()
20
- # FIXME, we also need to index the newly added repo!!!
21
- console.print(f"Added repository: {path}")
23
+ # If no subcommand is invoked, run the list behavior
24
+ if ctx.invoked_subcommand is None:
25
+ with Starbash("repo.list") as sb:
26
+ repos = sb.repo_manager.repos if verbose else sb.repo_manager.regular_repos
27
+ for i, repo in enumerate(repos):
28
+ kind = repo.kind("input")
29
+ # for unknown repos (probably because we haven't written a starbash.toml file to the root yet),
30
+ # we call them "input" because users will be less confused by that
31
+
32
+ if verbose:
33
+ # No numbers for verbose mode (system repos can't be removed)
34
+ console.print(f"{ repo.url } (kind={ kind })")
35
+ else:
36
+ # Show numbers for user repos (can be removed later)
37
+ console.print(f"{ i + 1:2}: { repo.url } (kind={ kind })")
22
38
 
23
39
 
24
40
  @app.command()
25
- def remove(reponame: str):
41
+ def add(path: str):
26
42
  """
27
- Remove a repository by name or number.
43
+ Add a repository. path is either a local path or a remote URL.
28
44
  """
29
- with Starbash("repo-remove") as sb:
30
- raise NotImplementedError("Removing repositories not yet implemented.")
45
+ with Starbash("repo.add") as sb:
46
+ repo = sb.user_repo.add_repo_ref(path)
47
+ if repo:
48
+ console.print(f"Added repository: {path}")
49
+ sb.reindex_repo(repo)
50
+
51
+ # we don't yet write default config files at roots of repos, but it would be easy to add here
52
+ # r.write_config()
53
+ sb.user_repo.write_config()
54
+ # FIXME, we also need to index the newly added repo!!!
31
55
 
32
56
 
33
57
  @app.command()
34
- def list():
58
+ def remove(reponum: str):
35
59
  """
36
- List all repositories. The listed names/numbers can be used with other commands.
60
+ Remove a repository by number (from list).
61
+ Use 'starbash repo' to see the repository numbers.
37
62
  """
38
- with Starbash("repo-list") as sb:
39
- for i, repo in enumerate(sb.repo_manager.repos):
40
- console.print(f"{ i + 1:2}: { repo.url } (kind={ repo.kind})")
63
+ with Starbash("repo.remove") as sb:
64
+ try:
65
+ # Parse the repo number (1-indexed)
66
+ repo_index = int(reponum) - 1
67
+
68
+ # Get only the regular (user-visible) repos
69
+ regular_repos = sb.repo_manager.regular_repos
70
+
71
+ if repo_index < 0 or repo_index >= len(regular_repos):
72
+ console.print(
73
+ f"[red]Error: Repository number {reponum} is out of range. Valid range: 1-{len(regular_repos)}[/red]"
74
+ )
75
+ raise typer.Exit(code=1)
76
+
77
+ # Get the repo to remove
78
+ repo_to_remove = regular_repos[repo_index]
79
+ repo_url = repo_to_remove.url
80
+
81
+ # Remove the repo reference from user config
82
+ sb.remove_repo_ref(repo_url)
83
+ console.print(f"[green]Removed repository: {repo_url}[/green]")
84
+
85
+ except ValueError:
86
+ console.print(
87
+ f"[red]Error: '{reponum}' is not a valid repository number. Please use a number from 'repo list'.[/red]"
88
+ )
89
+ raise typer.Exit(code=1)
41
90
 
42
91
 
43
92
  @app.command()
44
93
  def reindex(
45
- repo: Annotated[
94
+ reponum: Annotated[
46
95
  str | None,
47
- typer.Argument(
48
- help="The repository name or number, if not specified reindex all."
49
- ),
96
+ typer.Argument(help="The repository number, if not specified reindex all."),
50
97
  ] = None,
51
98
  force: bool = typer.Option(
52
99
  default=False, help="Reread FITS headers, even if they are already indexed."
53
100
  ),
54
101
  ):
55
102
  """
56
- Reindex the named repository.
57
- If no name is given, reindex all repositories.
103
+ Reindex a repository by number.
104
+ If no number is given, reindex all repositories.
105
+ Use 'starbash repo' to see the repository numbers.
58
106
  """
59
- with Starbash("repo-reindex") as sb:
60
- if repo is None:
61
- console.print("Reindexing all repositories...")
107
+ with Starbash("repo.reindex") as sb:
108
+ if reponum is None:
62
109
  sb.reindex_repos(force=force)
63
110
  else:
64
- raise NotImplementedError("Reindexing a single repo not yet implemented.")
111
+ try:
112
+ # Parse the repo number (1-indexed)
113
+ repo_index = int(reponum) - 1
114
+
115
+ # Get only the regular (user-visible) repos
116
+ regular_repos = sb.repo_manager.regular_repos
117
+
118
+ if repo_index < 0 or repo_index >= len(regular_repos):
119
+ console.print(
120
+ f"[red]Error: Repository number {reponum} is out of range. Valid range: 1-{len(regular_repos)}[/red]"
121
+ )
122
+ raise typer.Exit(code=1)
123
+
124
+ # Get the repo to reindex
125
+ repo_to_reindex = regular_repos[repo_index]
126
+ console.print(f"Reindexing repository: {repo_to_reindex.url}")
127
+ sb.reindex_repo(repo_to_reindex, force=force)
128
+ console.print(
129
+ f"[green]Successfully reindexed repository {reponum}[/green]"
130
+ )
131
+
132
+ except ValueError:
133
+ console.print(
134
+ f"[red]Error: '{reponum}' is not a valid repository number. Please use a number from 'starbash repo'.[/red]"
135
+ )
136
+ raise typer.Exit(code=1)
65
137
 
66
138
 
67
139
  if __name__ == "__main__":