boom-client 0.1.0__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.
- boom/__init__.py +71 -0
- boom/cache.py +218 -0
- boom/client.py +821 -0
- boom/config.py +115 -0
- boom/exceptions.py +36 -0
- boom/models.py +356 -0
- boom/objects.py +486 -0
- boom/photometry.py +227 -0
- boom/projections.py +90 -0
- boom/py.typed +0 -0
- boom_client-0.1.0.dist-info/METADATA +334 -0
- boom_client-0.1.0.dist-info/RECORD +14 -0
- boom_client-0.1.0.dist-info/WHEEL +4 -0
- boom_client-0.1.0.dist-info/licenses/LICENSE +21 -0
boom/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""boom: a Python client for the BOOM API.
|
|
2
|
+
|
|
3
|
+
Talks to the main BOOM API, the one backing the alert pipeline: free-form
|
|
4
|
+
Mongo queries over the catalogs, cone searches, aggregation pipelines and
|
|
5
|
+
cutouts, plus an analysis layer that turns the raw catalog documents into
|
|
6
|
+
typed sources with cleaned photometry.
|
|
7
|
+
|
|
8
|
+
It needs a BOOM account, which is provisioned by an admin. If you only
|
|
9
|
+
have a Babamul account, this package cannot authenticate you: use the
|
|
10
|
+
``babamul`` package, which serves the public, curated endpoints. This
|
|
11
|
+
package builds on it and reuses its models and plotting.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .cache import Cache
|
|
15
|
+
from .client import BoomClient, arcsec_to_metres, get_user_agent
|
|
16
|
+
from .config import API_URLS, BoomConfig, get_base_url
|
|
17
|
+
from .exceptions import (
|
|
18
|
+
APIAuthenticationError,
|
|
19
|
+
APIError,
|
|
20
|
+
APINotFoundError,
|
|
21
|
+
BoomError,
|
|
22
|
+
ConfigurationError,
|
|
23
|
+
QueryError,
|
|
24
|
+
)
|
|
25
|
+
from .models import COLUMNS, Observation, Source
|
|
26
|
+
from .objects import (
|
|
27
|
+
alert_collection,
|
|
28
|
+
aux_collection,
|
|
29
|
+
get_alerts,
|
|
30
|
+
get_object,
|
|
31
|
+
get_radec,
|
|
32
|
+
get_source,
|
|
33
|
+
photometry,
|
|
34
|
+
plot_cutouts,
|
|
35
|
+
plot_lightcurve,
|
|
36
|
+
)
|
|
37
|
+
from .photometry import CUTS, QualityCuts, build_photometry
|
|
38
|
+
from .projections import alert_projection, aux_projection
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"API_URLS",
|
|
42
|
+
"APIAuthenticationError",
|
|
43
|
+
"APIError",
|
|
44
|
+
"APINotFoundError",
|
|
45
|
+
"COLUMNS",
|
|
46
|
+
"CUTS",
|
|
47
|
+
"BoomClient",
|
|
48
|
+
"BoomConfig",
|
|
49
|
+
"BoomError",
|
|
50
|
+
"Cache",
|
|
51
|
+
"ConfigurationError",
|
|
52
|
+
"Observation",
|
|
53
|
+
"QualityCuts",
|
|
54
|
+
"QueryError",
|
|
55
|
+
"Source",
|
|
56
|
+
"alert_collection",
|
|
57
|
+
"alert_projection",
|
|
58
|
+
"arcsec_to_metres",
|
|
59
|
+
"aux_collection",
|
|
60
|
+
"aux_projection",
|
|
61
|
+
"build_photometry",
|
|
62
|
+
"get_alerts",
|
|
63
|
+
"get_base_url",
|
|
64
|
+
"get_object",
|
|
65
|
+
"get_radec",
|
|
66
|
+
"get_source",
|
|
67
|
+
"get_user_agent",
|
|
68
|
+
"photometry",
|
|
69
|
+
"plot_cutouts",
|
|
70
|
+
"plot_lightcurve",
|
|
71
|
+
]
|
boom/cache.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""On-disk cache for data pulled from the BOOM API.
|
|
2
|
+
|
|
3
|
+
The cache is opt-in: it does nothing unless a directory is configured,
|
|
4
|
+
through ``BOOM_CACHE_DIR`` or the *directory* argument. Entries expire,
|
|
5
|
+
because alert catalogs keep growing and a cached object that never goes
|
|
6
|
+
stale would quietly hide new photometry.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from .exceptions import ConfigurationError
|
|
23
|
+
|
|
24
|
+
#: How long an entry stays usable, in seconds. One day, so a re-run the
|
|
25
|
+
#: next morning picks up the night's alerts.
|
|
26
|
+
DEFAULT_MAX_AGE = 86_400
|
|
27
|
+
|
|
28
|
+
_SAFE_KEY = re.compile(r"^[A-Za-z0-9._-]{1,120}$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _slug(key: str) -> str:
|
|
32
|
+
"""Turn an arbitrary key into a safe, collision-free file name."""
|
|
33
|
+
if _SAFE_KEY.match(key):
|
|
34
|
+
return key
|
|
35
|
+
digest = hashlib.sha256(key.encode()).hexdigest()[:16]
|
|
36
|
+
stem = re.sub(r"[^A-Za-z0-9._-]", "_", key)[:60]
|
|
37
|
+
return f"{stem}-{digest}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Cache:
|
|
41
|
+
"""A directory of cached payloads, keyed by namespace and key.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
directory : str | Path | None
|
|
46
|
+
Where to store entries. ``None`` disables the cache.
|
|
47
|
+
max_age : float | None
|
|
48
|
+
Seconds before an entry is considered stale. ``None`` or ``0``
|
|
49
|
+
keeps entries forever.
|
|
50
|
+
|
|
51
|
+
Examples
|
|
52
|
+
--------
|
|
53
|
+
>>> cache = Cache("/tmp/boom-cache")
|
|
54
|
+
>>> cache.set_json("objects", "ZTF18abcdefg", {"objectId": "ZTF18abcdefg"})
|
|
55
|
+
>>> cache.get_json("objects", "ZTF18abcdefg")["objectId"]
|
|
56
|
+
'ZTF18abcdefg'
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
directory: str | Path | None = None,
|
|
62
|
+
*,
|
|
63
|
+
max_age: float | None = DEFAULT_MAX_AGE,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.directory = Path(directory).expanduser() if directory else None
|
|
66
|
+
self.max_age = max_age
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_env(cls, max_age: float | None = None) -> Cache:
|
|
70
|
+
"""Build a cache from ``BOOM_CACHE_DIR`` and ``BOOM_CACHE_MAX_AGE``.
|
|
71
|
+
|
|
72
|
+
Returns a disabled cache when ``BOOM_CACHE_DIR`` is unset.
|
|
73
|
+
"""
|
|
74
|
+
directory = os.environ.get("BOOM_CACHE_DIR")
|
|
75
|
+
if max_age is None:
|
|
76
|
+
raw = os.environ.get("BOOM_CACHE_MAX_AGE")
|
|
77
|
+
try:
|
|
78
|
+
max_age = float(raw) if raw else DEFAULT_MAX_AGE
|
|
79
|
+
except ValueError as error:
|
|
80
|
+
raise ConfigurationError(
|
|
81
|
+
f"BOOM_CACHE_MAX_AGE must be a number, got {raw!r}"
|
|
82
|
+
) from error
|
|
83
|
+
return cls(directory, max_age=max_age)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def enabled(self) -> bool:
|
|
87
|
+
return self.directory is not None
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str:
|
|
90
|
+
if not self.enabled:
|
|
91
|
+
return "Cache(disabled)"
|
|
92
|
+
return f"Cache({str(self.directory)!r}, max_age={self.max_age})"
|
|
93
|
+
|
|
94
|
+
def path(self, namespace: str, key: str, suffix: str) -> Path | None:
|
|
95
|
+
"""Resolve the file backing an entry, or ``None`` when disabled."""
|
|
96
|
+
if self.directory is None:
|
|
97
|
+
return None
|
|
98
|
+
return self.directory / _slug(namespace) / f"{_slug(key)}{suffix}"
|
|
99
|
+
|
|
100
|
+
def _fresh(self, path: Path | None) -> Path | None:
|
|
101
|
+
if path is None or not path.exists():
|
|
102
|
+
return None
|
|
103
|
+
if self.max_age and time.time() - path.stat().st_mtime > self.max_age:
|
|
104
|
+
return None
|
|
105
|
+
return path
|
|
106
|
+
|
|
107
|
+
def get_json(self, namespace: str, key: str) -> Any | None:
|
|
108
|
+
"""Read a JSON entry, or ``None`` if absent, stale or unreadable."""
|
|
109
|
+
path = self._fresh(self.path(namespace, key, ".json"))
|
|
110
|
+
if path is None:
|
|
111
|
+
return None
|
|
112
|
+
try:
|
|
113
|
+
with path.open() as handle:
|
|
114
|
+
return json.load(handle)
|
|
115
|
+
except (OSError, json.JSONDecodeError):
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def set_json(self, namespace: str, key: str, value: Any) -> None:
|
|
119
|
+
"""Write a JSON entry. A no-op when the cache is disabled."""
|
|
120
|
+
path = self.path(namespace, key, ".json")
|
|
121
|
+
if path is None:
|
|
122
|
+
return
|
|
123
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
_atomic_write(path, lambda tmp: tmp.write_text(json.dumps(value)))
|
|
125
|
+
|
|
126
|
+
def get_frame(self, namespace: str, key: str) -> pd.DataFrame | None:
|
|
127
|
+
"""Read a Parquet entry, or ``None`` if absent, stale or unreadable."""
|
|
128
|
+
path = self._fresh(self.path(namespace, key, ".parquet"))
|
|
129
|
+
if path is None:
|
|
130
|
+
return None
|
|
131
|
+
try:
|
|
132
|
+
return pd.read_parquet(path)
|
|
133
|
+
except (OSError, ValueError):
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def set_frame(self, namespace: str, key: str, frame: pd.DataFrame) -> None:
|
|
137
|
+
"""Write a DataFrame as Parquet. A no-op when the cache is disabled."""
|
|
138
|
+
path = self.path(namespace, key, ".parquet")
|
|
139
|
+
if path is None:
|
|
140
|
+
return
|
|
141
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
_atomic_write(path, lambda tmp: frame.to_parquet(tmp, index=False))
|
|
143
|
+
|
|
144
|
+
def load_frames(
|
|
145
|
+
self, namespace: str, *, fresh_only: bool = False
|
|
146
|
+
) -> pd.DataFrame:
|
|
147
|
+
"""Concatenate every Parquet entry in a namespace into one table.
|
|
148
|
+
|
|
149
|
+
This is how a cache built one object at a time becomes a sample:
|
|
150
|
+
``load_frames("sources")`` gives one row per cached source, with
|
|
151
|
+
its summary statistics, ready to filter and plot.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
namespace : str
|
|
156
|
+
Which namespace to read, e.g. ``"sources"`` or
|
|
157
|
+
``"photometry"``.
|
|
158
|
+
fresh_only : bool
|
|
159
|
+
Skip entries older than *max_age*. Off by default: a bulk
|
|
160
|
+
load usually wants everything on disk, and silently dropping
|
|
161
|
+
half a sample is worse than reading a stale row.
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
pandas.DataFrame
|
|
166
|
+
Empty when the cache is off or the namespace has no entries.
|
|
167
|
+
"""
|
|
168
|
+
if self.directory is None:
|
|
169
|
+
return pd.DataFrame()
|
|
170
|
+
folder = self.directory / _slug(namespace)
|
|
171
|
+
if not folder.is_dir():
|
|
172
|
+
return pd.DataFrame()
|
|
173
|
+
|
|
174
|
+
frames = []
|
|
175
|
+
for path in sorted(folder.glob("*.parquet")):
|
|
176
|
+
if fresh_only and self._fresh(path) is None:
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
frames.append(pd.read_parquet(path))
|
|
180
|
+
except (OSError, ValueError):
|
|
181
|
+
continue
|
|
182
|
+
if not frames:
|
|
183
|
+
return pd.DataFrame()
|
|
184
|
+
return pd.concat(frames, ignore_index=True)
|
|
185
|
+
|
|
186
|
+
def keys(self, namespace: str) -> list[str]:
|
|
187
|
+
"""Entry names stored in a namespace, sorted."""
|
|
188
|
+
if self.directory is None:
|
|
189
|
+
return []
|
|
190
|
+
folder = self.directory / _slug(namespace)
|
|
191
|
+
if not folder.is_dir():
|
|
192
|
+
return []
|
|
193
|
+
return sorted(path.stem for path in folder.iterdir() if path.is_file())
|
|
194
|
+
|
|
195
|
+
def clear(self, namespace: str | None = None) -> None:
|
|
196
|
+
"""Delete a namespace, or the whole cache when *namespace* is None."""
|
|
197
|
+
if self.directory is None:
|
|
198
|
+
return
|
|
199
|
+
target = (
|
|
200
|
+
self.directory
|
|
201
|
+
if namespace is None
|
|
202
|
+
else self.directory / _slug(namespace)
|
|
203
|
+
)
|
|
204
|
+
if target.exists():
|
|
205
|
+
shutil.rmtree(target)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _atomic_write(path: Path, write: Any) -> None:
|
|
209
|
+
"""Write through a temporary file so a crash leaves no half entry."""
|
|
210
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
211
|
+
try:
|
|
212
|
+
write(tmp)
|
|
213
|
+
tmp.replace(path)
|
|
214
|
+
finally:
|
|
215
|
+
tmp.unlink(missing_ok=True)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
__all__ = ["Cache", "DEFAULT_MAX_AGE"]
|