starbash 0.1.8__py3-none-any.whl → 0.1.10__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.
- repo/__init__.py +2 -1
- repo/manager.py +31 -268
- repo/repo.py +294 -0
- starbash/__init__.py +20 -0
- starbash/aliases.py +100 -0
- starbash/analytics.py +4 -0
- starbash/app.py +740 -151
- starbash/commands/__init__.py +0 -17
- starbash/commands/info.py +72 -3
- starbash/commands/process.py +154 -0
- starbash/commands/repo.py +185 -78
- starbash/commands/select.py +135 -44
- starbash/database.py +397 -155
- starbash/defaults/starbash.toml +35 -0
- starbash/main.py +4 -1
- starbash/paths.py +18 -2
- starbash/recipes/master_bias/starbash.toml +32 -19
- starbash/recipes/master_dark/starbash.toml +36 -0
- starbash/recipes/master_flat/starbash.toml +27 -17
- starbash/recipes/osc_dual_duo/starbash.py +1 -5
- starbash/recipes/osc_dual_duo/starbash.toml +8 -4
- starbash/recipes/osc_single_duo/starbash.toml +4 -4
- starbash/recipes/starbash.toml +28 -3
- starbash/selection.py +115 -46
- starbash/templates/repo/master.toml +13 -0
- starbash/templates/repo/processed.toml +10 -0
- starbash/templates/userconfig.toml +1 -1
- starbash/toml.py +29 -0
- starbash/tool.py +199 -67
- {starbash-0.1.8.dist-info → starbash-0.1.10.dist-info}/METADATA +20 -13
- starbash-0.1.10.dist-info/RECORD +40 -0
- starbash-0.1.8.dist-info/RECORD +0 -33
- {starbash-0.1.8.dist-info → starbash-0.1.10.dist-info}/WHEEL +0 -0
- {starbash-0.1.8.dist-info → starbash-0.1.10.dist-info}/entry_points.txt +0 -0
- {starbash-0.1.8.dist-info → starbash-0.1.10.dist-info}/licenses/LICENSE +0 -0
starbash/database.py
CHANGED
|
@@ -2,11 +2,30 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import sqlite3
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import Any, Optional
|
|
5
|
+
from typing import Any, Optional, NamedTuple
|
|
6
6
|
from datetime import datetime, timedelta
|
|
7
7
|
import json
|
|
8
|
+
from typing import TypeAlias
|
|
8
9
|
|
|
9
10
|
from .paths import get_user_data_dir
|
|
11
|
+
from .aliases import normalize_target_name
|
|
12
|
+
|
|
13
|
+
SessionRow: TypeAlias = dict[str, Any]
|
|
14
|
+
ImageRow: TypeAlias = dict[str, Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SearchCondition(NamedTuple):
|
|
18
|
+
"""A search condition for database queries.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
column_name: The column name to filter on (e.g., 'i.date_obs', 'r.url')
|
|
22
|
+
comparison_op: The comparison operator (e.g., '=', '>=', '<=', 'LIKE')
|
|
23
|
+
value: The value to compare against
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
column_name: str
|
|
27
|
+
comparison_op: str
|
|
28
|
+
value: Any
|
|
10
29
|
|
|
11
30
|
|
|
12
31
|
def get_column_name(k: str) -> str:
|
|
@@ -21,10 +40,35 @@ class Database:
|
|
|
21
40
|
"""SQLite-backed application database.
|
|
22
41
|
|
|
23
42
|
Stores data under the OS-specific user data directory using platformdirs.
|
|
43
|
+
|
|
44
|
+
Tables:
|
|
45
|
+
#1: repos
|
|
46
|
+
A table with one row per repository. Contains only 'id' (primary key) and 'url' (unique).
|
|
47
|
+
The URL identifies the repository root (e.g., 'file:///path/to/repo').
|
|
48
|
+
|
|
49
|
+
#2: images
|
|
24
50
|
Provides an `images` table for FITS metadata and basic helpers.
|
|
25
51
|
|
|
26
|
-
The images table stores DATE-OBS and
|
|
27
|
-
efficient date-based queries, while other FITS metadata is stored in JSON.
|
|
52
|
+
The images table stores DATE-OBS, DATE, and IMAGETYP as indexed SQL columns for
|
|
53
|
+
efficient date-based and type-based queries, while other FITS metadata is stored in JSON.
|
|
54
|
+
|
|
55
|
+
The 'path' column contains a path **relative** to the repository root.
|
|
56
|
+
Each image belongs to exactly one repo, linked via the repo_id foreign key.
|
|
57
|
+
The combination of (repo_id, path) is unique.
|
|
58
|
+
|
|
59
|
+
Image retrieval methods (get_image, search_image, all_images) join with the repos
|
|
60
|
+
table to include repo_url in results, allowing callers to reconstruct absolute paths.
|
|
61
|
+
|
|
62
|
+
#3: sessions
|
|
63
|
+
The sessions table has one row per observing session, summarizing key info.
|
|
64
|
+
Sessions are identified by filter, image type, target, telescope, etc, and start/end times.
|
|
65
|
+
They correspond to groups of images taken together during an observing run (e.g.
|
|
66
|
+
session start/end describes the range of images DATE-OBS).
|
|
67
|
+
|
|
68
|
+
Each session also has an image_doc_id field which will point to a representative
|
|
69
|
+
image in the images table. Eventually we'll use joins to add extra info from images to
|
|
70
|
+
the exposed 'session' row.
|
|
71
|
+
|
|
28
72
|
"""
|
|
29
73
|
|
|
30
74
|
EXPTIME_KEY = "EXPTIME"
|
|
@@ -35,13 +79,17 @@ class Database:
|
|
|
35
79
|
EXPTIME_TOTAL_KEY = "exptime-total"
|
|
36
80
|
DATE_OBS_KEY = "DATE-OBS"
|
|
37
81
|
DATE_KEY = "DATE"
|
|
38
|
-
IMAGE_DOC_KEY = "image-doc"
|
|
82
|
+
IMAGE_DOC_KEY = "image-doc-id"
|
|
39
83
|
IMAGETYP_KEY = "IMAGETYP"
|
|
40
84
|
OBJECT_KEY = "OBJECT"
|
|
41
85
|
TELESCOP_KEY = "TELESCOP"
|
|
86
|
+
EXPTIME_KEY = "EXPTIME"
|
|
87
|
+
ID_KEY = "id" # for finding any row by its ID
|
|
88
|
+
REPO_URL_KEY = "repo_url"
|
|
42
89
|
|
|
43
90
|
SESSIONS_TABLE = "sessions"
|
|
44
91
|
IMAGES_TABLE = "images"
|
|
92
|
+
REPOS_TABLE = "repos"
|
|
45
93
|
|
|
46
94
|
def __init__(
|
|
47
95
|
self,
|
|
@@ -64,18 +112,39 @@ class Database:
|
|
|
64
112
|
self._init_tables()
|
|
65
113
|
|
|
66
114
|
def _init_tables(self) -> None:
|
|
67
|
-
"""Create the images and sessions tables if they don't exist."""
|
|
115
|
+
"""Create the repos, images and sessions tables if they don't exist."""
|
|
68
116
|
cursor = self._db.cursor()
|
|
69
117
|
|
|
70
|
-
# Create
|
|
118
|
+
# Create repos table
|
|
119
|
+
cursor.execute(
|
|
120
|
+
f"""
|
|
121
|
+
CREATE TABLE IF NOT EXISTS {self.REPOS_TABLE} (
|
|
122
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
123
|
+
url TEXT UNIQUE NOT NULL
|
|
124
|
+
)
|
|
125
|
+
"""
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Create index on url for faster lookups
|
|
129
|
+
cursor.execute(
|
|
130
|
+
f"""
|
|
131
|
+
CREATE INDEX IF NOT EXISTS idx_repos_url ON {self.REPOS_TABLE}(url)
|
|
132
|
+
"""
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Create images table with DATE-OBS, DATE, and IMAGETYP as indexed columns
|
|
71
136
|
cursor.execute(
|
|
72
137
|
f"""
|
|
73
138
|
CREATE TABLE IF NOT EXISTS {self.IMAGES_TABLE} (
|
|
74
139
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
75
|
-
|
|
140
|
+
repo_id INTEGER NOT NULL,
|
|
141
|
+
path TEXT NOT NULL,
|
|
76
142
|
date_obs TEXT,
|
|
77
143
|
date TEXT,
|
|
78
|
-
|
|
144
|
+
imagetyp TEXT COLLATE NOCASE,
|
|
145
|
+
metadata TEXT NOT NULL,
|
|
146
|
+
FOREIGN KEY (repo_id) REFERENCES {self.REPOS_TABLE}(id),
|
|
147
|
+
UNIQUE(repo_id, path)
|
|
79
148
|
)
|
|
80
149
|
"""
|
|
81
150
|
)
|
|
@@ -101,6 +170,13 @@ class Database:
|
|
|
101
170
|
"""
|
|
102
171
|
)
|
|
103
172
|
|
|
173
|
+
# Create index on imagetyp for efficient image type filtering
|
|
174
|
+
cursor.execute(
|
|
175
|
+
f"""
|
|
176
|
+
CREATE INDEX IF NOT EXISTS idx_images_imagetyp ON {self.IMAGES_TABLE}(imagetyp)
|
|
177
|
+
"""
|
|
178
|
+
)
|
|
179
|
+
|
|
104
180
|
# Create sessions table
|
|
105
181
|
cursor.execute(
|
|
106
182
|
f"""
|
|
@@ -108,13 +184,15 @@ class Database:
|
|
|
108
184
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
109
185
|
start TEXT NOT NULL,
|
|
110
186
|
end TEXT NOT NULL,
|
|
111
|
-
filter TEXT
|
|
112
|
-
imagetyp TEXT NOT NULL,
|
|
113
|
-
object TEXT
|
|
187
|
+
filter TEXT COLLATE NOCASE,
|
|
188
|
+
imagetyp TEXT COLLATE NOCASE NOT NULL,
|
|
189
|
+
object TEXT,
|
|
114
190
|
telescop TEXT NOT NULL,
|
|
115
191
|
num_images INTEGER NOT NULL,
|
|
116
192
|
exptime_total REAL NOT NULL,
|
|
117
|
-
|
|
193
|
+
exptime REAL NOT NULL,
|
|
194
|
+
image_doc_id INTEGER,
|
|
195
|
+
FOREIGN KEY (image_doc_id) REFERENCES {self.IMAGES_TABLE}(id)
|
|
118
196
|
)
|
|
119
197
|
"""
|
|
120
198
|
)
|
|
@@ -123,84 +201,204 @@ class Database:
|
|
|
123
201
|
cursor.execute(
|
|
124
202
|
f"""
|
|
125
203
|
CREATE INDEX IF NOT EXISTS idx_sessions_lookup
|
|
126
|
-
ON {self.SESSIONS_TABLE}(filter, imagetyp, object, telescop, start, end)
|
|
204
|
+
ON {self.SESSIONS_TABLE}(filter, imagetyp, object, telescop, exptime, start, end)
|
|
127
205
|
"""
|
|
128
206
|
)
|
|
129
207
|
|
|
130
208
|
self._db.commit()
|
|
131
209
|
|
|
210
|
+
# --- Convenience helpers for common repo operations ---
|
|
211
|
+
def remove_repo(self, url: str) -> None:
|
|
212
|
+
"""Remove a repo record by URL.
|
|
213
|
+
|
|
214
|
+
This will cascade delete all images belonging to this repo, and all sessions
|
|
215
|
+
that reference those images via image_doc_id.
|
|
216
|
+
|
|
217
|
+
The relationship is: repos -> images (via repo_id) -> sessions (via image_doc_id).
|
|
218
|
+
Sessions have an image_doc_id field that points to a representative image.
|
|
219
|
+
We delete sessions whose representative image belongs to the repo being deleted.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
url: The repository URL (e.g., 'file:///path/to/repo')
|
|
223
|
+
"""
|
|
224
|
+
cursor = self._db.cursor()
|
|
225
|
+
|
|
226
|
+
# Use a 3-way join to find and delete sessions that reference images from this repo
|
|
227
|
+
# repo_url -> repo_id -> images.id -> sessions.image_doc_id
|
|
228
|
+
cursor.execute(
|
|
229
|
+
f"""
|
|
230
|
+
DELETE FROM {self.SESSIONS_TABLE}
|
|
231
|
+
WHERE id IN (
|
|
232
|
+
SELECT s.id
|
|
233
|
+
FROM {self.SESSIONS_TABLE} s
|
|
234
|
+
INNER JOIN {self.IMAGES_TABLE} i ON s.image_doc_id = i.id
|
|
235
|
+
INNER JOIN {self.REPOS_TABLE} r ON i.repo_id = r.id
|
|
236
|
+
WHERE r.url = ?
|
|
237
|
+
)
|
|
238
|
+
""",
|
|
239
|
+
(url,),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Delete all images from this repo (using repo_id from URL)
|
|
243
|
+
cursor.execute(
|
|
244
|
+
f"""
|
|
245
|
+
DELETE FROM {self.IMAGES_TABLE}
|
|
246
|
+
WHERE repo_id = (SELECT id FROM {self.REPOS_TABLE} WHERE url = ?)
|
|
247
|
+
""",
|
|
248
|
+
(url,),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Finally delete the repo itself
|
|
252
|
+
cursor.execute(f"DELETE FROM {self.REPOS_TABLE} WHERE url = ?", (url,))
|
|
253
|
+
|
|
254
|
+
self._db.commit()
|
|
255
|
+
|
|
256
|
+
def upsert_repo(self, url: str) -> int:
|
|
257
|
+
"""Insert or update a repo record by unique URL.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
url: The repository URL (e.g., 'file:///path/to/repo')
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
The rowid of the inserted/updated record.
|
|
264
|
+
"""
|
|
265
|
+
cursor = self._db.cursor()
|
|
266
|
+
cursor.execute(
|
|
267
|
+
f"""
|
|
268
|
+
INSERT INTO {self.REPOS_TABLE} (url) VALUES (?)
|
|
269
|
+
ON CONFLICT(url) DO NOTHING
|
|
270
|
+
""",
|
|
271
|
+
(url,),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
self._db.commit()
|
|
275
|
+
|
|
276
|
+
# Get the rowid of the inserted/existing record
|
|
277
|
+
cursor.execute(f"SELECT id FROM {self.REPOS_TABLE} WHERE url = ?", (url,))
|
|
278
|
+
result = cursor.fetchone()
|
|
279
|
+
if result:
|
|
280
|
+
return result[0]
|
|
281
|
+
return cursor.lastrowid if cursor.lastrowid is not None else 0
|
|
282
|
+
|
|
283
|
+
def get_repo_id(self, url: str) -> int | None:
|
|
284
|
+
"""Get the repo_id for a given URL.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
url: The repository URL
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
The repo_id if found, None otherwise
|
|
291
|
+
"""
|
|
292
|
+
cursor = self._db.cursor()
|
|
293
|
+
cursor.execute(f"SELECT id FROM {self.REPOS_TABLE} WHERE url = ?", (url,))
|
|
294
|
+
result = cursor.fetchone()
|
|
295
|
+
return result[0] if result else None
|
|
296
|
+
|
|
297
|
+
def get_repo_url(self, repo_id: int) -> str | None:
|
|
298
|
+
"""Get the URL for a given repo_id.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
repo_id: The repository ID
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
The URL if found, None otherwise
|
|
305
|
+
"""
|
|
306
|
+
cursor = self._db.cursor()
|
|
307
|
+
cursor.execute(f"SELECT url FROM {self.REPOS_TABLE} WHERE id = ?", (repo_id,))
|
|
308
|
+
result = cursor.fetchone()
|
|
309
|
+
return result[0] if result else None
|
|
310
|
+
|
|
132
311
|
# --- Convenience helpers for common image operations ---
|
|
133
|
-
def upsert_image(self, record: dict[str, Any]) -> int:
|
|
312
|
+
def upsert_image(self, record: dict[str, Any], repo_url: str) -> int:
|
|
134
313
|
"""Insert or update an image record by unique path.
|
|
135
314
|
|
|
136
|
-
The record must include a 'path' key; other keys are arbitrary FITS metadata.
|
|
137
|
-
|
|
138
|
-
|
|
315
|
+
The record must include a 'path' key (relative to repo); other keys are arbitrary FITS metadata.
|
|
316
|
+
The path is stored as-is - caller is responsible for making it relative to the repo.
|
|
317
|
+
DATE-OBS, DATE, and IMAGETYP are extracted and stored as indexed columns for efficient queries.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
record: Dictionary containing image metadata including 'path' (relative to repo)
|
|
321
|
+
repo_url: The repository URL this image belongs to
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
The rowid of the inserted/updated record.
|
|
139
325
|
"""
|
|
140
326
|
path = record.get("path")
|
|
141
327
|
if not path:
|
|
142
328
|
raise ValueError("record must include 'path'")
|
|
143
329
|
|
|
144
|
-
#
|
|
330
|
+
# Get or create the repo_id for this URL
|
|
331
|
+
repo_id = self.get_repo_id(repo_url)
|
|
332
|
+
if repo_id is None:
|
|
333
|
+
repo_id = self.upsert_repo(repo_url)
|
|
334
|
+
|
|
335
|
+
# Extract special fields for column storage
|
|
145
336
|
date_obs = record.get(self.DATE_OBS_KEY)
|
|
146
337
|
date = record.get(self.DATE_KEY)
|
|
338
|
+
imagetyp = record.get(self.IMAGETYP_KEY)
|
|
147
339
|
|
|
148
|
-
# Separate path and
|
|
340
|
+
# Separate path and special fields from metadata
|
|
149
341
|
metadata = {k: v for k, v in record.items() if k != "path"}
|
|
150
342
|
metadata_json = json.dumps(metadata)
|
|
151
343
|
|
|
152
344
|
cursor = self._db.cursor()
|
|
153
345
|
cursor.execute(
|
|
154
346
|
f"""
|
|
155
|
-
INSERT INTO {self.IMAGES_TABLE} (path, date_obs, date, metadata) VALUES (?, ?, ?, ?)
|
|
156
|
-
ON CONFLICT(path) DO UPDATE SET
|
|
347
|
+
INSERT INTO {self.IMAGES_TABLE} (repo_id, path, date_obs, date, imagetyp, metadata) VALUES (?, ?, ?, ?, ?, ?)
|
|
348
|
+
ON CONFLICT(repo_id, path) DO UPDATE SET
|
|
157
349
|
date_obs = excluded.date_obs,
|
|
158
350
|
date = excluded.date,
|
|
351
|
+
imagetyp = excluded.imagetyp,
|
|
159
352
|
metadata = excluded.metadata
|
|
160
353
|
""",
|
|
161
|
-
(path, date_obs, date, metadata_json),
|
|
354
|
+
(repo_id, str(path), date_obs, date, imagetyp, metadata_json),
|
|
162
355
|
)
|
|
163
356
|
|
|
164
357
|
self._db.commit()
|
|
165
358
|
|
|
166
359
|
# Get the rowid of the inserted/updated record
|
|
167
|
-
cursor.execute(
|
|
360
|
+
cursor.execute(
|
|
361
|
+
f"SELECT id FROM {self.IMAGES_TABLE} WHERE repo_id = ? AND path = ?",
|
|
362
|
+
(repo_id, str(path)),
|
|
363
|
+
)
|
|
168
364
|
result = cursor.fetchone()
|
|
169
365
|
if result:
|
|
170
366
|
return result[0]
|
|
171
367
|
return cursor.lastrowid if cursor.lastrowid is not None else 0
|
|
172
368
|
|
|
173
|
-
def search_image(self, conditions:
|
|
369
|
+
def search_image(self, conditions: list[SearchCondition]) -> list[ImageRow]:
|
|
174
370
|
"""Search for images matching the given conditions.
|
|
175
371
|
|
|
176
372
|
Args:
|
|
177
|
-
conditions:
|
|
178
|
-
|
|
179
|
-
-
|
|
180
|
-
-
|
|
373
|
+
conditions: List of SearchCondition tuples, each containing:
|
|
374
|
+
- column_name: The column to filter on (e.g., 'i.date_obs', 'r.url', 'i.imagetyp')
|
|
375
|
+
- comparison_op: The comparison operator (e.g., '=', '>=', '<=')
|
|
376
|
+
- value: The value to compare against
|
|
181
377
|
|
|
182
378
|
Returns:
|
|
183
|
-
List of matching image records
|
|
379
|
+
List of matching image records with relative path, repo_id, and repo_url
|
|
380
|
+
|
|
381
|
+
Example:
|
|
382
|
+
conditions = [
|
|
383
|
+
SearchCondition('r.url', '=', 'file:///path/to/repo'),
|
|
384
|
+
SearchCondition('i.imagetyp', '=', 'BIAS'),
|
|
385
|
+
SearchCondition('i.date_obs', '>=', '2025-01-01'),
|
|
386
|
+
]
|
|
184
387
|
"""
|
|
185
|
-
#
|
|
186
|
-
conditions_copy = dict(conditions)
|
|
187
|
-
date_start = conditions_copy.pop("date_start", None)
|
|
188
|
-
date_end = conditions_copy.pop("date_end", None)
|
|
189
|
-
|
|
190
|
-
# Build SQL query with WHERE clauses for date filtering
|
|
388
|
+
# Build SQL query with WHERE clauses from conditions
|
|
191
389
|
where_clauses = []
|
|
192
390
|
params = []
|
|
193
391
|
|
|
194
|
-
|
|
195
|
-
where_clauses.append("
|
|
196
|
-
params.append(
|
|
392
|
+
for condition in conditions:
|
|
393
|
+
where_clauses.append(f"{condition.column_name} {condition.comparison_op} ?")
|
|
394
|
+
params.append(condition.value)
|
|
197
395
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
396
|
+
# Build the query with JOIN to repos table
|
|
397
|
+
query = f"""
|
|
398
|
+
SELECT i.id, i.repo_id, i.path, i.date_obs, i.date, i.imagetyp, i.metadata, r.url as repo_url
|
|
399
|
+
FROM {self.IMAGES_TABLE} i
|
|
400
|
+
JOIN {self.REPOS_TABLE} r ON i.repo_id = r.id
|
|
401
|
+
"""
|
|
204
402
|
if where_clauses:
|
|
205
403
|
query += " WHERE " + " AND ".join(where_clauses)
|
|
206
404
|
|
|
@@ -210,102 +408,58 @@ class Database:
|
|
|
210
408
|
results = []
|
|
211
409
|
for row in cursor.fetchall():
|
|
212
410
|
metadata = json.loads(row["metadata"])
|
|
411
|
+
# Store the relative path, repo_id, and repo_url for caller
|
|
213
412
|
metadata["path"] = row["path"]
|
|
413
|
+
metadata["repo_id"] = row["repo_id"]
|
|
414
|
+
metadata[Database.REPO_URL_KEY] = row[Database.REPO_URL_KEY]
|
|
214
415
|
metadata["id"] = row["id"]
|
|
215
416
|
|
|
216
|
-
# Add
|
|
417
|
+
# Add special fields back to metadata for compatibility
|
|
217
418
|
if row["date_obs"]:
|
|
218
419
|
metadata[self.DATE_OBS_KEY] = row["date_obs"]
|
|
219
420
|
if row["date"]:
|
|
220
421
|
metadata[self.DATE_KEY] = row["date"]
|
|
422
|
+
if row["imagetyp"]:
|
|
423
|
+
metadata[self.IMAGETYP_KEY] = row["imagetyp"]
|
|
221
424
|
|
|
222
|
-
|
|
223
|
-
match = all(metadata.get(k) == v for k, v in conditions_copy.items())
|
|
224
|
-
|
|
225
|
-
if match:
|
|
226
|
-
results.append(metadata)
|
|
227
|
-
|
|
228
|
-
return results if results else None
|
|
229
|
-
|
|
230
|
-
def where_session(self, conditions: dict[str, Any] | None) -> tuple[str, list[Any]]:
|
|
231
|
-
"""Search for sessions matching the given conditions.
|
|
232
|
-
|
|
233
|
-
Args:
|
|
234
|
-
conditions: Dictionary of session key-value pairs to match, or None for all.
|
|
235
|
-
Special keys:
|
|
236
|
-
- 'date_start': Filter sessions starting on or after this date
|
|
237
|
-
- 'date_end': Filter sessions starting on or before this date
|
|
238
|
-
|
|
239
|
-
Returns:
|
|
240
|
-
Tuple of (WHERE clause string, list of parameters)
|
|
241
|
-
"""
|
|
242
|
-
if conditions is None:
|
|
243
|
-
conditions = {}
|
|
244
|
-
|
|
245
|
-
# Build WHERE clause dynamically based on conditions
|
|
246
|
-
where_clauses = []
|
|
247
|
-
params = []
|
|
248
|
-
|
|
249
|
-
# Extract date range conditions
|
|
250
|
-
date_start = conditions.get("date_start")
|
|
251
|
-
date_end = conditions.get("date_end")
|
|
252
|
-
|
|
253
|
-
# Add date range filters to WHERE clause
|
|
254
|
-
if date_start:
|
|
255
|
-
where_clauses.append("start >= ?")
|
|
256
|
-
params.append(date_start)
|
|
257
|
-
|
|
258
|
-
if date_end:
|
|
259
|
-
where_clauses.append("start <= ?")
|
|
260
|
-
params.append(date_end)
|
|
261
|
-
|
|
262
|
-
# Add standard conditions to WHERE clause
|
|
263
|
-
for key, value in conditions.items():
|
|
264
|
-
if key not in ("date_start", "date_end") and value is not None:
|
|
265
|
-
column_name = key
|
|
266
|
-
where_clauses.append(f"{column_name} = ?")
|
|
267
|
-
params.append(value)
|
|
268
|
-
|
|
269
|
-
# Build the query
|
|
270
|
-
query = ""
|
|
271
|
-
|
|
272
|
-
if where_clauses:
|
|
273
|
-
query += " WHERE " + " AND ".join(where_clauses)
|
|
425
|
+
results.append(metadata)
|
|
274
426
|
|
|
275
|
-
return
|
|
427
|
+
return results
|
|
276
428
|
|
|
277
429
|
def search_session(
|
|
278
|
-
self,
|
|
279
|
-
) -> list[
|
|
430
|
+
self, where_tuple: tuple[str, list[Any]] = ("", [])
|
|
431
|
+
) -> list[SessionRow]:
|
|
280
432
|
"""Search for sessions matching the given conditions.
|
|
281
433
|
|
|
282
434
|
Args:
|
|
283
|
-
|
|
284
|
-
Special keys:
|
|
285
|
-
- 'date_start': Filter sessions starting on or after this date
|
|
286
|
-
- 'date_end': Filter sessions starting on or before this date
|
|
435
|
+
where_tuple
|
|
287
436
|
|
|
288
437
|
Returns:
|
|
289
|
-
List of matching session records
|
|
438
|
+
List of matching session records with metadata from the reference image
|
|
290
439
|
"""
|
|
291
|
-
if conditions is None:
|
|
292
|
-
conditions = {}
|
|
293
|
-
|
|
294
440
|
# Build WHERE clause dynamically based on conditions
|
|
295
|
-
where_clause, params =
|
|
441
|
+
where_clause, params = where_tuple
|
|
296
442
|
|
|
297
|
-
# Build the query
|
|
443
|
+
# Build the query with JOIN to images table to get reference image metadata
|
|
298
444
|
query = f"""
|
|
299
|
-
SELECT id, start, end, filter, imagetyp, object, telescop,
|
|
300
|
-
num_images, exptime_total, image_doc_id
|
|
301
|
-
FROM {self.SESSIONS_TABLE}
|
|
445
|
+
SELECT s.id, s.start, s.end, s.filter, s.imagetyp, s.object, s.telescop,
|
|
446
|
+
s.num_images, s.exptime_total, s.exptime, s.image_doc_id, i.metadata
|
|
447
|
+
FROM {self.SESSIONS_TABLE} s
|
|
448
|
+
LEFT JOIN {self.IMAGES_TABLE} i ON s.image_doc_id = i.id
|
|
302
449
|
{where_clause}
|
|
303
450
|
"""
|
|
304
451
|
|
|
305
452
|
cursor = self._db.cursor()
|
|
306
453
|
cursor.execute(query, params)
|
|
307
454
|
|
|
308
|
-
results = [
|
|
455
|
+
results = []
|
|
456
|
+
for row in cursor.fetchall():
|
|
457
|
+
session_dict = dict(row)
|
|
458
|
+
# Parse the metadata JSON if it exists
|
|
459
|
+
if session_dict.get("metadata"):
|
|
460
|
+
session_dict["metadata"] = json.loads(session_dict["metadata"])
|
|
461
|
+
results.append(session_dict)
|
|
462
|
+
|
|
309
463
|
return results
|
|
310
464
|
|
|
311
465
|
def len_table(self, table_name: str) -> int:
|
|
@@ -333,48 +487,74 @@ class Database:
|
|
|
333
487
|
result = cursor.fetchone()
|
|
334
488
|
return result[0] if result and result[0] is not None else 0
|
|
335
489
|
|
|
336
|
-
def get_image(self, path: str) ->
|
|
337
|
-
"""Get an image record by path.
|
|
490
|
+
def get_image(self, repo_url: str, path: str) -> ImageRow | None:
|
|
491
|
+
"""Get an image record by repo_url and relative path.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
repo_url: The repository URL
|
|
495
|
+
path: Path relative to the repository root
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
Image record with relative path, repo_id, and repo_url, or None if not found
|
|
499
|
+
"""
|
|
338
500
|
cursor = self._db.cursor()
|
|
339
501
|
cursor.execute(
|
|
340
|
-
f"
|
|
341
|
-
|
|
502
|
+
f"""
|
|
503
|
+
SELECT i.id, i.repo_id, i.path, i.date_obs, i.date, i.imagetyp, i.metadata, r.url as repo_url
|
|
504
|
+
FROM {self.IMAGES_TABLE} i
|
|
505
|
+
JOIN {self.REPOS_TABLE} r ON i.repo_id = r.id
|
|
506
|
+
WHERE r.url = ? AND i.path = ?
|
|
507
|
+
""",
|
|
508
|
+
(repo_url, path),
|
|
342
509
|
)
|
|
343
|
-
row = cursor.fetchone()
|
|
344
510
|
|
|
511
|
+
row = cursor.fetchone()
|
|
345
512
|
if row is None:
|
|
346
513
|
return None
|
|
347
514
|
|
|
348
515
|
metadata = json.loads(row["metadata"])
|
|
349
516
|
metadata["path"] = row["path"]
|
|
517
|
+
metadata["repo_id"] = row["repo_id"]
|
|
518
|
+
metadata[Database.REPO_URL_KEY] = row[Database.REPO_URL_KEY]
|
|
350
519
|
metadata["id"] = row["id"]
|
|
351
520
|
|
|
352
|
-
# Add
|
|
521
|
+
# Add special fields back to metadata for compatibility
|
|
353
522
|
if row["date_obs"]:
|
|
354
523
|
metadata[self.DATE_OBS_KEY] = row["date_obs"]
|
|
355
524
|
if row["date"]:
|
|
356
525
|
metadata[self.DATE_KEY] = row["date"]
|
|
526
|
+
if row["imagetyp"]:
|
|
527
|
+
metadata[self.IMAGETYP_KEY] = row["imagetyp"]
|
|
357
528
|
|
|
358
529
|
return metadata
|
|
359
530
|
|
|
360
|
-
def all_images(self) -> list[
|
|
361
|
-
"""Return all image records."""
|
|
531
|
+
def all_images(self) -> list[ImageRow]:
|
|
532
|
+
"""Return all image records with relative paths, repo_id, and repo_url."""
|
|
362
533
|
cursor = self._db.cursor()
|
|
363
534
|
cursor.execute(
|
|
364
|
-
f"
|
|
535
|
+
f"""
|
|
536
|
+
SELECT i.id, i.repo_id, i.path, i.date_obs, i.date, i.imagetyp, i.metadata, r.url as repo_url
|
|
537
|
+
FROM {self.IMAGES_TABLE} i
|
|
538
|
+
JOIN {self.REPOS_TABLE} r ON i.repo_id = r.id
|
|
539
|
+
"""
|
|
365
540
|
)
|
|
366
541
|
|
|
367
542
|
results = []
|
|
368
543
|
for row in cursor.fetchall():
|
|
369
544
|
metadata = json.loads(row["metadata"])
|
|
545
|
+
# Return relative path, repo_id, and repo_url for caller
|
|
370
546
|
metadata["path"] = row["path"]
|
|
547
|
+
metadata["repo_id"] = row["repo_id"]
|
|
548
|
+
metadata[Database.REPO_URL_KEY] = row[Database.REPO_URL_KEY]
|
|
371
549
|
metadata["id"] = row["id"]
|
|
372
550
|
|
|
373
|
-
# Add
|
|
551
|
+
# Add special fields back to metadata for compatibility
|
|
374
552
|
if row["date_obs"]:
|
|
375
553
|
metadata[self.DATE_OBS_KEY] = row["date_obs"]
|
|
376
554
|
if row["date"]:
|
|
377
555
|
metadata[self.DATE_KEY] = row["date"]
|
|
556
|
+
if row["imagetyp"]:
|
|
557
|
+
metadata[self.IMAGETYP_KEY] = row["imagetyp"]
|
|
378
558
|
|
|
379
559
|
results.append(metadata)
|
|
380
560
|
|
|
@@ -393,7 +573,7 @@ class Database:
|
|
|
393
573
|
cursor.execute(
|
|
394
574
|
f"""
|
|
395
575
|
SELECT id, start, end, filter, imagetyp, object, telescop,
|
|
396
|
-
num_images, exptime_total, image_doc_id
|
|
576
|
+
num_images, exptime_total, exptime, image_doc_id
|
|
397
577
|
FROM {self.SESSIONS_TABLE}
|
|
398
578
|
WHERE id = ?
|
|
399
579
|
""",
|
|
@@ -406,21 +586,16 @@ class Database:
|
|
|
406
586
|
|
|
407
587
|
return dict(row)
|
|
408
588
|
|
|
409
|
-
def get_session(self, to_find: dict[str, str]) ->
|
|
589
|
+
def get_session(self, to_find: dict[str, str]) -> SessionRow | None:
|
|
410
590
|
"""Find a session matching the given criteria.
|
|
411
591
|
|
|
412
592
|
Searches for sessions with the same filter, image type, target, and telescope
|
|
413
593
|
whose start time is within +/- 8 hours of the provided date.
|
|
414
594
|
"""
|
|
415
|
-
date = to_find.get(Database.START_KEY)
|
|
595
|
+
date = to_find.get(get_column_name(Database.START_KEY))
|
|
416
596
|
assert date
|
|
417
|
-
image_type = to_find.get(Database.IMAGETYP_KEY)
|
|
597
|
+
image_type = to_find.get(get_column_name(Database.IMAGETYP_KEY))
|
|
418
598
|
assert image_type
|
|
419
|
-
filter = to_find.get(Database.FILTER_KEY)
|
|
420
|
-
assert filter
|
|
421
|
-
target = to_find.get(Database.OBJECT_KEY)
|
|
422
|
-
assert target
|
|
423
|
-
telescop = to_find.get(Database.TELESCOP_KEY, "unspecified")
|
|
424
599
|
|
|
425
600
|
# Convert the provided ISO8601 date string to a datetime, then
|
|
426
601
|
# search for sessions with the same filter whose start time is
|
|
@@ -432,17 +607,75 @@ class Database:
|
|
|
432
607
|
|
|
433
608
|
# Since session 'start' is stored as ISO8601 strings, lexicographic
|
|
434
609
|
# comparison aligns with chronological ordering for a uniform format.
|
|
610
|
+
|
|
611
|
+
# Build WHERE clause handling NULL values properly
|
|
612
|
+
# In SQL, you cannot use = with NULL, must use IS NULL
|
|
613
|
+
# If a field is not in to_find, we don't filter on it at all
|
|
614
|
+
where_clauses = []
|
|
615
|
+
params = []
|
|
616
|
+
|
|
617
|
+
# Handle imagetyp (required)
|
|
618
|
+
where_clauses.append("imagetyp = ?")
|
|
619
|
+
params.append(image_type)
|
|
620
|
+
|
|
621
|
+
# Handle filter (optional - only filter if present in to_find)
|
|
622
|
+
filter_key = get_column_name(Database.FILTER_KEY)
|
|
623
|
+
filter = to_find.get(filter_key) # filter can be the string "None"
|
|
624
|
+
if filter:
|
|
625
|
+
if filter is None:
|
|
626
|
+
where_clauses.append("filter IS NULL")
|
|
627
|
+
else:
|
|
628
|
+
where_clauses.append("filter = ?")
|
|
629
|
+
params.append(filter)
|
|
630
|
+
|
|
631
|
+
# Handle object/target (optional - only filter if present in to_find)
|
|
632
|
+
object_key = get_column_name(Database.OBJECT_KEY)
|
|
633
|
+
target = to_find.get(object_key)
|
|
634
|
+
if target:
|
|
635
|
+
target = normalize_target_name(target)
|
|
636
|
+
if target is None:
|
|
637
|
+
where_clauses.append("object IS NULL")
|
|
638
|
+
else:
|
|
639
|
+
where_clauses.append("object = ?")
|
|
640
|
+
params.append(target)
|
|
641
|
+
|
|
642
|
+
# Handle telescop (optional - only filter if present in to_find)
|
|
643
|
+
telescop_key = get_column_name(Database.TELESCOP_KEY)
|
|
644
|
+
telescop = to_find.get(telescop_key)
|
|
645
|
+
if telescop:
|
|
646
|
+
if telescop is None:
|
|
647
|
+
where_clauses.append("telescop IS NULL")
|
|
648
|
+
else:
|
|
649
|
+
where_clauses.append("telescop = ?")
|
|
650
|
+
params.append(telescop)
|
|
651
|
+
|
|
652
|
+
# Handle exptime (optional - only filter if present in to_find)
|
|
653
|
+
exptime_key = get_column_name(Database.EXPTIME_KEY)
|
|
654
|
+
if exptime_key in to_find:
|
|
655
|
+
exptime = to_find.get(exptime_key)
|
|
656
|
+
if exptime is None:
|
|
657
|
+
where_clauses.append("exptime IS NULL")
|
|
658
|
+
else:
|
|
659
|
+
where_clauses.append("exptime = ?")
|
|
660
|
+
params.append(exptime)
|
|
661
|
+
|
|
662
|
+
# Time window
|
|
663
|
+
where_clauses.append("start >= ?")
|
|
664
|
+
where_clauses.append("start <= ?")
|
|
665
|
+
params.extend([start_min, start_max])
|
|
666
|
+
|
|
667
|
+
where_clause = " AND ".join(where_clauses)
|
|
668
|
+
|
|
435
669
|
cursor = self._db.cursor()
|
|
436
670
|
cursor.execute(
|
|
437
671
|
f"""
|
|
438
672
|
SELECT id, start, end, filter, imagetyp, object, telescop,
|
|
439
|
-
num_images, exptime_total, image_doc_id
|
|
673
|
+
num_images, exptime_total, exptime, image_doc_id
|
|
440
674
|
FROM {self.SESSIONS_TABLE}
|
|
441
|
-
WHERE
|
|
442
|
-
AND start >= ? AND start <= ?
|
|
675
|
+
WHERE {where_clause}
|
|
443
676
|
LIMIT 1
|
|
444
677
|
""",
|
|
445
|
-
|
|
678
|
+
params,
|
|
446
679
|
)
|
|
447
680
|
|
|
448
681
|
row = cursor.fetchone()
|
|
@@ -452,21 +685,27 @@ class Database:
|
|
|
452
685
|
return dict(row)
|
|
453
686
|
|
|
454
687
|
def upsert_session(
|
|
455
|
-
self, new:
|
|
688
|
+
self, new: SessionRow, existing: SessionRow | None = None
|
|
456
689
|
) -> None:
|
|
457
690
|
"""Insert or update a session record."""
|
|
458
691
|
cursor = self._db.cursor()
|
|
459
692
|
|
|
460
693
|
if existing:
|
|
461
694
|
# Update existing session with new data
|
|
462
|
-
updated_start = min(
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
695
|
+
updated_start = min(
|
|
696
|
+
new[get_column_name(Database.START_KEY)],
|
|
697
|
+
existing[get_column_name(Database.START_KEY)],
|
|
698
|
+
)
|
|
699
|
+
updated_end = max(
|
|
700
|
+
new[get_column_name(Database.END_KEY)],
|
|
701
|
+
existing[get_column_name(Database.END_KEY)],
|
|
466
702
|
)
|
|
703
|
+
updated_num_images = existing.get(
|
|
704
|
+
get_column_name(Database.NUM_IMAGES_KEY), 0
|
|
705
|
+
) + new.get(get_column_name(Database.NUM_IMAGES_KEY), 0)
|
|
467
706
|
updated_exptime_total = existing.get(
|
|
468
|
-
Database.EXPTIME_TOTAL_KEY, 0
|
|
469
|
-
) + new.get(Database.EXPTIME_TOTAL_KEY, 0)
|
|
707
|
+
get_column_name(Database.EXPTIME_TOTAL_KEY), 0
|
|
708
|
+
) + new.get(get_column_name(Database.EXPTIME_TOTAL_KEY), 0)
|
|
470
709
|
|
|
471
710
|
cursor.execute(
|
|
472
711
|
f"""
|
|
@@ -487,19 +726,22 @@ class Database:
|
|
|
487
726
|
cursor.execute(
|
|
488
727
|
f"""
|
|
489
728
|
INSERT INTO {self.SESSIONS_TABLE}
|
|
490
|
-
(start, end, filter, imagetyp, object, telescop, num_images, exptime_total, image_doc_id)
|
|
491
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
729
|
+
(start, end, filter, imagetyp, object, telescop, num_images, exptime_total, exptime, image_doc_id)
|
|
730
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
492
731
|
""",
|
|
493
732
|
(
|
|
494
|
-
new[Database.START_KEY],
|
|
495
|
-
new[Database.END_KEY],
|
|
496
|
-
new
|
|
497
|
-
new[Database.IMAGETYP_KEY],
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
new
|
|
502
|
-
new
|
|
733
|
+
new[get_column_name(Database.START_KEY)],
|
|
734
|
+
new[get_column_name(Database.END_KEY)],
|
|
735
|
+
new.get(get_column_name(Database.FILTER_KEY)),
|
|
736
|
+
new[get_column_name(Database.IMAGETYP_KEY)],
|
|
737
|
+
normalize_target_name(
|
|
738
|
+
new.get(get_column_name(Database.OBJECT_KEY))
|
|
739
|
+
),
|
|
740
|
+
new.get(get_column_name(Database.TELESCOP_KEY)),
|
|
741
|
+
new[get_column_name(Database.NUM_IMAGES_KEY)],
|
|
742
|
+
new[get_column_name(Database.EXPTIME_TOTAL_KEY)],
|
|
743
|
+
new[get_column_name(Database.EXPTIME_KEY)],
|
|
744
|
+
new[get_column_name(Database.IMAGE_DOC_KEY)],
|
|
503
745
|
),
|
|
504
746
|
)
|
|
505
747
|
|