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 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"]