starbash 0.1.0__py3-none-any.whl → 0.1.1__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/analytics.py ADDED
@@ -0,0 +1,121 @@
1
+ import logging
2
+
3
+ from starbash import console
4
+ import starbash.url as url
5
+
6
+ # Default to no analytics/auto crash reports
7
+ analytics_allowed = False
8
+
9
+
10
+ def analytics_setup(allowed: bool = False, user_email: str | None = None) -> None:
11
+ import sentry_sdk
12
+
13
+ global analytics_allowed
14
+ analytics_allowed = allowed
15
+ if analytics_allowed:
16
+ logging.info(
17
+ f"Analytics/crash-reports enabled. To change [link={url.analytics_docs}]click here[/link]",
18
+ extra={"markup": True},
19
+ )
20
+ sentry_sdk.init(
21
+ dsn="https://e9496a4ea8b37a053203a2cbc10d64e6@o209837.ingest.us.sentry.io/4510264204132352",
22
+ send_default_pii=True,
23
+ enable_logs=True,
24
+ traces_sample_rate=1.0,
25
+ )
26
+
27
+ if user_email:
28
+ sentry_sdk.set_user({"email": user_email})
29
+ else:
30
+ logging.info(
31
+ f"Analytics/crash-reports disabled. To learn more [link={url.analytics_docs}]click here[/link]",
32
+ extra={"markup": True},
33
+ )
34
+
35
+
36
+ def analytics_shutdown() -> None:
37
+ """Shut down the analytics service, if enabled."""
38
+ if analytics_allowed:
39
+ import sentry_sdk
40
+
41
+ sentry_sdk.flush()
42
+
43
+
44
+ def is_development_environment() -> bool:
45
+ """Detect if running in a development environment."""
46
+ import os
47
+ import sys
48
+ from pathlib import Path
49
+
50
+ # Check for explicit environment variable
51
+ if os.getenv("STARBASH_ENV") == "development":
52
+ return True
53
+
54
+ # Check if running under VS Code
55
+ if any(k.startswith("VSCODE_") for k in os.environ):
56
+ return True
57
+
58
+ return False
59
+
60
+
61
+ def analytics_exception(exc: Exception) -> bool:
62
+ """Report an exception to the analytics service, if enabled.
63
+ return True to suppress exception propagation/log messages"""
64
+
65
+ if is_development_environment():
66
+ return False # We want to let devs see full exception traces
67
+
68
+ if analytics_allowed:
69
+ import sentry_sdk
70
+
71
+ report_id = sentry_sdk.capture_exception(exc)
72
+
73
+ logging.info(
74
+ f"""An unexpected error has occurred and been reported. Thank you for your help.
75
+ If you'd like to chat with the devs about it, please click
76
+ [link={url.new_issue(str(report_id))}]here[/link] to open an issue.""",
77
+ extra={"markup": True},
78
+ )
79
+ else:
80
+ logging.error(
81
+ f"""An unexpected error has occurred. Automated crash reporting is disabled,
82
+ but we encourage you to contact the developers
83
+ at [link={url.new_issue()}]here[/link] and we will try to help.
84
+
85
+ The full exception is: {exc}""",
86
+ extra={"markup": True},
87
+ )
88
+ return True
89
+
90
+
91
+ class NopAnalytics:
92
+ """Used when users have disabled analytics/crash reporting."""
93
+
94
+ def __enter__(self):
95
+ return self
96
+
97
+ def __exit__(self, exc_type, exc_value, traceback):
98
+ return False
99
+
100
+ def set_data(self, key, value):
101
+ pass
102
+
103
+
104
+ def analytics_start_span(**kwargs):
105
+ """Start an analytics/tracing span if analytics is enabled, otherwise return a no-op context manager."""
106
+ if analytics_allowed:
107
+ import sentry_sdk
108
+
109
+ return sentry_sdk.start_span(**kwargs)
110
+ else:
111
+ return NopAnalytics()
112
+
113
+
114
+ def analytics_start_transaction(**kwargs):
115
+ """Start an analytics/tracing transaction if analytics is enabled, otherwise return a no-op context manager."""
116
+ if analytics_allowed:
117
+ import sentry_sdk
118
+
119
+ return sentry_sdk.start_transaction(**kwargs)
120
+ else:
121
+ return NopAnalytics()
starbash/app.py CHANGED
@@ -1,6 +1,9 @@
1
1
  import logging
2
2
  from importlib import resources
3
+ from pathlib import Path
3
4
 
5
+ import tomlkit
6
+ from tomlkit.toml_file import TOMLFile
4
7
  import glob
5
8
  from typing import Any
6
9
  from astropy.io import fits
@@ -8,9 +11,19 @@ import itertools
8
11
  from rich.progress import track
9
12
  from rich.logging import RichHandler
10
13
  from starbash.database import Database
14
+ from starbash.repo.manager import Repo
11
15
  from starbash.tool import Tool
12
16
  from starbash.repo import RepoManager
13
17
  from starbash.tool import tools
18
+ from starbash.paths import get_user_config_dir, get_user_data_dir
19
+ from starbash.selection import Selection
20
+ from starbash.analytics import (
21
+ NopAnalytics,
22
+ analytics_exception,
23
+ analytics_setup,
24
+ analytics_shutdown,
25
+ analytics_start_transaction,
26
+ )
14
27
 
15
28
 
16
29
  def setup_logging():
@@ -28,78 +41,176 @@ def setup_logging():
28
41
  setup_logging()
29
42
 
30
43
 
31
- class AstroGlue:
32
- """The main AstroGlue application class."""
44
+ def create_user() -> Path:
45
+ """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():
49
+ tomlstr = (
50
+ resources.files("starbash")
51
+ .joinpath("templates/userconfig.toml")
52
+ .read_text()
53
+ )
54
+ toml = tomlkit.parse(tomlstr)
55
+ TOMLFile(userconfig_path).write(toml)
56
+ logging.info(f"Created user config file: {userconfig_path}")
57
+ return config_dir
58
+
59
+
60
+ class Starbash:
61
+ """The main Starbash application class."""
33
62
 
34
- def __init__(self):
63
+ def __init__(self, cmd: str = "unspecified"):
35
64
  """
36
- Initializes the AstroGlue application by loading configurations
65
+ Initializes the Starbash application by loading configurations
37
66
  and setting up the repository manager.
38
67
  """
39
68
  setup_logging()
40
- logging.info("AstroGlue application initializing...")
69
+ logging.info("Starbash starting...")
41
70
 
42
71
  # Load app defaults and initialize the repository manager
43
- app_defaults_text = (
44
- resources.files("starbash").joinpath("appdefaults.sb.toml").read_text()
45
- )
46
- self.repo_manager = RepoManager(app_defaults_text)
72
+ self.repo_manager = RepoManager()
73
+ self.repo_manager.add_repo("pkg://defaults")
74
+
75
+ # Add user prefs as a repo
76
+ self.user_repo = self.repo_manager.add_repo("file://" + str(create_user()))
77
+
78
+ self.analytics = NopAnalytics()
79
+ if self.user_repo.get("analytics.enabled", True):
80
+ include_user = self.user_repo.get("analytics.include_user", False)
81
+ user_email = (
82
+ self.user_repo.get("user.email", None) if include_user else None
83
+ )
84
+ if user_email is not None:
85
+ user_email = str(user_email)
86
+ analytics_setup(allowed=True, user_email=user_email)
87
+ # this is intended for use with "with" so we manually do enter/exit
88
+ self.analytics = analytics_start_transaction(name="App session", op=cmd)
89
+ self.analytics.__enter__()
90
+
47
91
  logging.info(
48
92
  f"Repo manager initialized with {len(self.repo_manager.repos)} default repo references."
49
93
  )
50
94
  # self.repo_manager.dump()
51
95
 
52
96
  self.db = Database()
97
+ self.session_query = None # None means search all sessions
98
+
99
+ # Initialize selection state
100
+ data_dir = get_user_data_dir()
101
+ selection_file = data_dir / "selection.json"
102
+ self.selection = Selection(selection_file)
103
+
53
104
  # FIXME, call reindex somewhere and also index whenever new repos are added
54
105
  # self.reindex_repos()
55
106
 
56
107
  # --- Lifecycle ---
57
108
  def close(self) -> None:
109
+ self.analytics.__exit__(None, None, None)
110
+
111
+ analytics_shutdown()
58
112
  self.db.close()
59
113
 
60
114
  # Context manager support
61
- def __enter__(self) -> "AstroGlue":
115
+ def __enter__(self) -> "Starbash":
62
116
  return self
63
117
 
64
- def __exit__(self, exc_type, exc, tb) -> None:
118
+ def __exit__(self, exc_type, exc, tb) -> bool:
119
+ handled = False
120
+ if exc:
121
+ handled = analytics_exception(exc)
65
122
  self.close()
123
+ return handled
124
+
125
+ def _add_session(self, f: str, image_doc_id: int, header: dict) -> None:
126
+ filter = header.get(Database.FILTER_KEY, "unspecified")
127
+ image_type = header.get(Database.IMAGETYP_KEY)
128
+ date = header.get(Database.DATE_OBS_KEY)
129
+ if not date or not image_type:
130
+ logging.warning(
131
+ "Image %s missing critical FITS header, please submit image at https://github.com/geeksville/starbash/issues/new",
132
+ f,
133
+ )
134
+ else:
135
+ exptime = header.get(Database.EXPTIME_KEY, 0)
136
+ new = {
137
+ Database.FILTER_KEY: filter,
138
+ Database.START_KEY: date,
139
+ Database.END_KEY: date, # FIXME not quite correct, should be longer by exptime
140
+ Database.IMAGE_DOC_KEY: image_doc_id,
141
+ Database.IMAGETYP_KEY: image_type,
142
+ Database.NUM_IMAGES_KEY: 1,
143
+ Database.EXPTIME_TOTAL_KEY: exptime,
144
+ Database.OBJECT_KEY: header.get(Database.OBJECT_KEY, "unspecified"),
145
+ }
146
+ session = self.db.get_session(new)
147
+ self.db.upsert_session(new, existing=session)
148
+
149
+ def search_session(self) -> list[dict[str, Any]] | None:
150
+ """Search for sessions, optionally filtered by the current selection."""
151
+ # If selection has filters, use them; otherwise return all sessions
152
+ if self.selection.is_empty():
153
+ return self.db.search_session(None)
154
+ else:
155
+ # Get query conditions from selection
156
+ conditions = self.selection.get_query_conditions()
157
+ return self.db.search_session(conditions)
66
158
 
67
- def reindex_repos(self):
159
+ def reindex_repo(self, repo: Repo, force: bool = False):
68
160
  """Reindex all repositories managed by the RepoManager."""
69
- logging.info("Reindexing all repositories...")
70
- config = self.repo_manager.merged.get("config")
71
- if not config:
72
- raise ValueError(f"App config not found.")
73
- whitelist = config["fits-whitelist"]
74
-
75
- for repo in track(self.repo_manager.repos, description="Reindexing repos..."):
76
- # FIXME, add a method to get just the repos that contain images
77
- if repo.is_local and repo.kind != "recipe":
78
- logging.debug("Reindexing %s...", repo.url)
79
- path = repo.get_path()
80
-
81
- # Find all FITS files under this repo path
82
- for f in track(
83
- list(path.rglob("*.fit*")),
84
- description=f"Indexing {repo.url}...",
85
- ):
86
- # progress.console.print(f"Indexing {f}...")
87
- try:
161
+ # FIXME, add a method to get just the repos that contain images
162
+ if repo.is_scheme("file") and repo.kind != "recipe":
163
+ logging.debug("Reindexing %s...", repo.url)
164
+
165
+ config = self.repo_manager.merged.get("config")
166
+ if not config:
167
+ raise ValueError(f"App config not found.")
168
+ whitelist = config["fits-whitelist"]
169
+
170
+ path = repo.get_path()
171
+ if not path:
172
+ raise ValueError(f"Repo path not found for {repo}")
173
+
174
+ # Find all FITS files under this repo path
175
+ for f in track(
176
+ list(path.rglob("*.fit*")),
177
+ description=f"Indexing {repo.url}...",
178
+ ):
179
+ # progress.console.print(f"Indexing {f}...")
180
+ try:
181
+ found = self.db.get_image(str(f))
182
+ if not found or force:
88
183
  # Read and log the primary header (HDU 0)
89
184
  with fits.open(str(f), memmap=False) as hdul:
90
185
  # convert headers to dict
91
186
  hdu0: Any = hdul[0]
92
- items = hdu0.header.items()
187
+ header = hdu0.header
188
+ if type(header).__name__ == "Unknown":
189
+ raise ValueError("FITS header has Unknown type: %s", f)
190
+
191
+ items = header.items()
93
192
  headers = {}
94
193
  for key, value in items:
95
194
  if key in whitelist:
96
195
  headers[key] = value
97
196
  logging.debug("Headers for %s: %s", f, headers)
98
- self.db.add_from_fits(f, headers)
99
- except Exception as e:
100
- logging.warning("Failed to read FITS header for %s: %s", f, e)
197
+ headers["path"] = str(f)
198
+ image_doc_id = self.db.upsert_image(headers)
101
199
 
102
- logging.info("Reindexing complete.")
200
+ if not found:
201
+ # Update the session infos, but ONLY on first file scan
202
+ # (otherwise invariants will get messed up)
203
+ self._add_session(str(f), image_doc_id, header)
204
+
205
+ except Exception as e:
206
+ logging.warning("Failed to read FITS header for %s: %s", f, e)
207
+
208
+ def reindex_repos(self, force: bool = False):
209
+ """Reindex all repositories managed by the RepoManager."""
210
+ logging.info("Reindexing all repositories...")
211
+
212
+ for repo in track(self.repo_manager.repos, description="Reindexing repos..."):
213
+ self.reindex_repo(repo, force=force)
103
214
 
104
215
  def test_processing(self):
105
216
  """A crude test of image processing pipeline - FIXME move into testing"""
starbash/commands/repo.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import typer
2
2
  from typing_extensions import Annotated
3
3
 
4
- from starbash.app import AstroGlue
4
+ from starbash.app import Starbash
5
5
  from starbash import console
6
6
 
7
7
  app = typer.Typer()
@@ -12,7 +12,13 @@ def add(path: str):
12
12
  """
13
13
  Add a repository. path is either a local path or a remote URL.
14
14
  """
15
- pass
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}")
16
22
 
17
23
 
18
24
  @app.command()
@@ -20,7 +26,8 @@ def remove(reponame: str):
20
26
  """
21
27
  Remove a repository by name or number.
22
28
  """
23
- pass
29
+ with Starbash("repo-remove") as sb:
30
+ raise NotImplementedError("Removing repositories not yet implemented.")
24
31
 
25
32
 
26
33
  @app.command()
@@ -28,23 +35,33 @@ def list():
28
35
  """
29
36
  List all repositories. The listed names/numbers can be used with other commands.
30
37
  """
31
- with AstroGlue() as ag:
38
+ with Starbash("repo-list") as sb:
32
39
  for i, repo in enumerate(sb.repo_manager.repos):
33
40
  console.print(f"{ i + 1:2}: { repo.url } (kind={ repo.kind})")
34
41
 
35
42
 
36
43
  @app.command()
37
44
  def reindex(
38
- reponame: Annotated[
39
- str,
40
- typer.Argument(help="The repository name or number, or none to reindex all."),
41
- ],
45
+ repo: Annotated[
46
+ str | None,
47
+ typer.Argument(
48
+ help="The repository name or number, if not specified reindex all."
49
+ ),
50
+ ] = None,
51
+ force: bool = typer.Option(
52
+ default=False, help="Reread FITS headers, even if they are already indexed."
53
+ ),
42
54
  ):
43
55
  """
44
56
  Reindex the named repository.
45
57
  If no name is given, reindex all repositories.
46
58
  """
47
- pass
59
+ with Starbash("repo-reindex") as sb:
60
+ if repo is None:
61
+ console.print("Reindexing all repositories...")
62
+ sb.reindex_repos(force=force)
63
+ else:
64
+ raise NotImplementedError("Reindexing a single repo not yet implemented.")
48
65
 
49
66
 
50
67
  if __name__ == "__main__":
@@ -0,0 +1,117 @@
1
+ """Selection commands for filtering sessions and targets."""
2
+
3
+ import typer
4
+ from typing_extensions import Annotated
5
+ from rich.table import Table
6
+
7
+ from starbash.app import Starbash
8
+ from starbash import console
9
+
10
+ app = typer.Typer()
11
+
12
+
13
+ @app.command(name="any")
14
+ def clear():
15
+ """Remove any filters on sessions, etc... (select everything)."""
16
+ with Starbash("selection-clear") as sb:
17
+ sb.selection.clear()
18
+ console.print("[green]Selection cleared - now selecting all sessions[/green]")
19
+
20
+
21
+ @app.command()
22
+ def target(
23
+ target_name: Annotated[
24
+ str,
25
+ typer.Argument(
26
+ help="Target name to add to the selection (e.g., 'M31', 'NGC 7000')"
27
+ ),
28
+ ],
29
+ ):
30
+ """Limit the current selection to only the named target."""
31
+ with Starbash("selection-target") as sb:
32
+ # For now, replace existing targets with this one
33
+ # In the future, we could support adding multiple targets
34
+ sb.selection.clear()
35
+ sb.selection.add_target(target_name)
36
+ console.print(f"[green]Selection limited to target: {target_name}[/green]")
37
+
38
+
39
+ @app.command()
40
+ def date(
41
+ operation: Annotated[
42
+ str,
43
+ typer.Argument(help="Date operation: 'after', 'before', or 'between'"),
44
+ ],
45
+ date_value: Annotated[
46
+ str,
47
+ typer.Argument(
48
+ help="Date in ISO format (YYYY-MM-DD) or two dates separated by space for 'between'"
49
+ ),
50
+ ],
51
+ end_date: Annotated[
52
+ str | None,
53
+ typer.Argument(help="End date for 'between' operation (YYYY-MM-DD)"),
54
+ ] = None,
55
+ ):
56
+ """Limit to sessions in the specified date range.
57
+
58
+ Examples:
59
+ starbash selection date after 2023-10-01
60
+ starbash selection date before 2023-12-31
61
+ starbash selection date between 2023-10-01 2023-12-31
62
+ """
63
+ with Starbash("selection-date") as sb:
64
+ operation = operation.lower()
65
+
66
+ if operation == "after":
67
+ sb.selection.set_date_range(start=date_value, end=None)
68
+ console.print(
69
+ f"[green]Selection limited to sessions after {date_value}[/green]"
70
+ )
71
+ elif operation == "before":
72
+ sb.selection.set_date_range(start=None, end=date_value)
73
+ console.print(
74
+ f"[green]Selection limited to sessions before {date_value}[/green]"
75
+ )
76
+ elif operation == "between":
77
+ if not end_date:
78
+ console.print(
79
+ "[red]Error: 'between' operation requires two dates[/red]"
80
+ )
81
+ raise typer.Exit(1)
82
+ sb.selection.set_date_range(start=date_value, end=end_date)
83
+ console.print(
84
+ f"[green]Selection limited to sessions between {date_value} and {end_date}[/green]"
85
+ )
86
+ else:
87
+ console.print(
88
+ f"[red]Error: Unknown operation '{operation}'. Use 'after', 'before', or 'between'[/red]"
89
+ )
90
+ raise typer.Exit(1)
91
+
92
+
93
+ @app.callback(invoke_without_command=True)
94
+ def show_selection(ctx: typer.Context):
95
+ """List information about the current selection.
96
+
97
+ This is the default command when no subcommand is specified.
98
+ """
99
+ if ctx.invoked_subcommand is None:
100
+ with Starbash("selection-show") as sb:
101
+ summary = sb.selection.summary()
102
+
103
+ if summary["status"] == "all":
104
+ console.print(f"[yellow]{summary['message']}[/yellow]")
105
+ else:
106
+ table = Table(title="Current Selection")
107
+ table.add_column("Criteria", style="cyan")
108
+ table.add_column("Value", style="green")
109
+
110
+ for criterion in summary["criteria"]:
111
+ parts = criterion.split(": ", 1)
112
+ if len(parts) == 2:
113
+ table.add_row(parts[0], parts[1])
114
+ else:
115
+ table.add_row(criterion, "")
116
+
117
+ console.print(table)
@@ -0,0 +1,63 @@
1
+ import typer
2
+ from typing_extensions import Annotated
3
+
4
+ from starbash.app import Starbash
5
+ from starbash import console
6
+
7
+ app = typer.Typer()
8
+
9
+
10
+ @app.command()
11
+ def analytics(
12
+ enable: Annotated[
13
+ bool,
14
+ typer.Argument(
15
+ help="Enable or disable analytics (crash reports and usage data).",
16
+ ),
17
+ ],
18
+ ):
19
+ """
20
+ Enable or disable analytics (crash reports and usage data).
21
+ """
22
+ with Starbash("analytics-enable") as sb:
23
+ sb.analytics.set_data("analytics.enabled", enable)
24
+ sb.user_repo.config["analytics.enabled"] = enable
25
+ sb.user_repo.write_config()
26
+ status = "enabled" if enable else "disabled"
27
+ console.print(f"Analytics (crash reports) {status}.")
28
+
29
+
30
+ @app.command()
31
+ def name(
32
+ user_name: Annotated[
33
+ str,
34
+ typer.Argument(
35
+ help="Your name for attribution in generated images.",
36
+ ),
37
+ ],
38
+ ):
39
+ """
40
+ Set your name for attribution in generated images.
41
+ """
42
+ with Starbash("user-name") as sb:
43
+ sb.user_repo.config["user.name"] = user_name
44
+ sb.user_repo.write_config()
45
+ console.print(f"User name set to: {user_name}")
46
+
47
+
48
+ @app.command()
49
+ def email(
50
+ user_email: Annotated[
51
+ str,
52
+ typer.Argument(
53
+ help="Your email for attribution in generated images.",
54
+ ),
55
+ ],
56
+ ):
57
+ """
58
+ Set your email for attribution in generated images.
59
+ """
60
+ with Starbash("user-email") as sb:
61
+ sb.user_repo.config["user.email"] = user_email
62
+ sb.user_repo.write_config()
63
+ console.print(f"User email set to: {user_email}")