jstdata 0.2.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.
- jstdata/__init__.py +13 -0
- jstdata/agent_guide.py +530 -0
- jstdata/cli.py +682 -0
- jstdata/client.py +543 -0
- jstdata/models.py +161 -0
- jstdata/session.py +251 -0
- jstdata/utils.py +103 -0
- jstdata/workflows/__init__.py +88 -0
- jstdata/workflows/base.py +356 -0
- jstdata/workflows/bundled/__init__.py +1 -0
- jstdata/workflows/bundled/tutorial.yaml +14 -0
- jstdata/workflows/bundled/tutorial_script.yaml +45 -0
- jstdata/workflows/chart.py +357 -0
- jstdata/workflows/console.py +560 -0
- jstdata/workflows/discover.py +502 -0
- jstdata/workflows/export.py +171 -0
- jstdata/workflows/find.py +386 -0
- jstdata/workflows/host.py +255 -0
- jstdata/workflows/rank.py +658 -0
- jstdata/workflows/session_manager.py +277 -0
- jstdata/workflows/store.py +308 -0
- jstdata/workflows/tutorial.py +163 -0
- jstdata/workflows/tutorial_host.py +178 -0
- jstdata-0.2.0.dist-info/METADATA +227 -0
- jstdata-0.2.0.dist-info/RECORD +28 -0
- jstdata-0.2.0.dist-info/WHEEL +4 -0
- jstdata-0.2.0.dist-info/entry_points.txt +3 -0
- jstdata-0.2.0.dist-info/licenses/LICENSE +21 -0
jstdata/client.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .models import (
|
|
13
|
+
Entity,
|
|
14
|
+
EntityRelationship,
|
|
15
|
+
Metric,
|
|
16
|
+
Observation,
|
|
17
|
+
Series,
|
|
18
|
+
TimeSeries,
|
|
19
|
+
Resource,
|
|
20
|
+
Taxonomy,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
APP_DIR = Path.home() / ".jstdata"
|
|
24
|
+
CONFIG_FILE = APP_DIR / "config.json"
|
|
25
|
+
|
|
26
|
+
DEFAULT_QUERY_TAIL = 20
|
|
27
|
+
DEFAULT_QUERY_SERIES_LIMIT = 50
|
|
28
|
+
MAX_QUERY_SERIES_LIMIT = 50
|
|
29
|
+
MAX_QUERY_OBSERVATION_DEPTH = 100
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApiKeyNotSetError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class InvalidApiKeyError(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _as_id_list(value: Optional[Union[str, List[str]]]) -> Optional[List[str]]:
|
|
41
|
+
"""Normalize a search/query id argument to a non-empty list."""
|
|
42
|
+
if value is None:
|
|
43
|
+
return None
|
|
44
|
+
if isinstance(value, str):
|
|
45
|
+
return [value] if value else None
|
|
46
|
+
items = [v for v in value if v]
|
|
47
|
+
return items or None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _format_as_of(value: Union[str, datetime]) -> str:
|
|
51
|
+
"""ISO-8601 timestamp; naive datetimes are treated as UTC."""
|
|
52
|
+
if isinstance(value, datetime):
|
|
53
|
+
if value.tzinfo is None:
|
|
54
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
55
|
+
return value.isoformat()
|
|
56
|
+
return str(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InvalidInputError(Exception):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class JSTDataClientConfig:
|
|
65
|
+
api_key: Optional[str] = None
|
|
66
|
+
base_url: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
def __post_init__(self):
|
|
69
|
+
APP_DIR.mkdir(exist_ok=True)
|
|
70
|
+
|
|
71
|
+
# 1. Start with defaults
|
|
72
|
+
default_url = "https://api.jeffersonst.io"
|
|
73
|
+
|
|
74
|
+
# 2. Layer on config file if it exists
|
|
75
|
+
file_api_key = None
|
|
76
|
+
file_base_url = None
|
|
77
|
+
if CONFIG_FILE.exists():
|
|
78
|
+
try:
|
|
79
|
+
with open(CONFIG_FILE, "r") as f:
|
|
80
|
+
cfg = json.load(f)
|
|
81
|
+
file_api_key = cfg.get("api_key")
|
|
82
|
+
file_base_url = cfg.get("base_url")
|
|
83
|
+
except (json.JSONDecodeError, IOError):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
# Precedence: Env > Arg > File > Default
|
|
87
|
+
# self.api_key and self.base_url contain 'Arg' if passed, else None.
|
|
88
|
+
|
|
89
|
+
self.api_key = os.environ.get("JSTDATA_API_KEY") or self.api_key or file_api_key
|
|
90
|
+
self.base_url = os.environ.get("JSTDATA_BASE_URL") or self.base_url or file_base_url or default_url
|
|
91
|
+
|
|
92
|
+
def write(self, **kwargs) -> None:
|
|
93
|
+
"""Write configuration to the config file."""
|
|
94
|
+
# Read current to preserve keys we aren't updating
|
|
95
|
+
current = {}
|
|
96
|
+
if CONFIG_FILE.exists():
|
|
97
|
+
try:
|
|
98
|
+
with open(CONFIG_FILE, "r") as f:
|
|
99
|
+
current = json.load(f)
|
|
100
|
+
except:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
current["api_key"] = kwargs.get("api_key") or current.get("api_key") or self.api_key
|
|
104
|
+
current["base_url"] = kwargs.get("base_url") or current.get("base_url") or self.base_url
|
|
105
|
+
|
|
106
|
+
with open(CONFIG_FILE, "w") as f:
|
|
107
|
+
json.dump(current, f, indent=2)
|
|
108
|
+
|
|
109
|
+
# Restrict permissions to owner read/write
|
|
110
|
+
CONFIG_FILE.chmod(0o600)
|
|
111
|
+
|
|
112
|
+
def read(self) -> Dict[str, Any]:
|
|
113
|
+
"""Read the current configuration from file."""
|
|
114
|
+
if not CONFIG_FILE.exists():
|
|
115
|
+
return {}
|
|
116
|
+
with open(CONFIG_FILE, "r") as f:
|
|
117
|
+
return json.load(f)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class JSTDataCache:
|
|
122
|
+
endpoint: str
|
|
123
|
+
params: Optional[Dict[str, Any]]
|
|
124
|
+
|
|
125
|
+
def __post_init__(self):
|
|
126
|
+
if self.params is None:
|
|
127
|
+
self.params = {}
|
|
128
|
+
|
|
129
|
+
# Normalize params for hashing
|
|
130
|
+
sorted_items = sorted(
|
|
131
|
+
[(k, str(v)) for k, v in self.params.items() if v is not None]
|
|
132
|
+
)
|
|
133
|
+
self.params = dict(sorted_items)
|
|
134
|
+
|
|
135
|
+
self._cache_dir = APP_DIR / "cache"
|
|
136
|
+
self._cache_dir.mkdir(exist_ok=True)
|
|
137
|
+
|
|
138
|
+
json_data = json.dumps(
|
|
139
|
+
{
|
|
140
|
+
"endpoint": self.endpoint,
|
|
141
|
+
"params": self.params,
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
key = hashlib.sha256(json_data.encode()).hexdigest()
|
|
146
|
+
self._cache_file = self._cache_dir / f"{key}.parquet"
|
|
147
|
+
|
|
148
|
+
def read(self):
|
|
149
|
+
if not self._cache_file.exists():
|
|
150
|
+
return None
|
|
151
|
+
return pd.read_parquet(self._cache_file)
|
|
152
|
+
|
|
153
|
+
def write(self, df: pd.DataFrame):
|
|
154
|
+
df.to_parquet(self._cache_file)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class JSTDataClient:
|
|
158
|
+
def __init__(
|
|
159
|
+
self, api_key: Optional[str] = None, base_url: Optional[str] = None
|
|
160
|
+
):
|
|
161
|
+
"""
|
|
162
|
+
Initializes the JSTDataClient.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
api_key: The API key for authenticating with the Jefferson Street REST API.
|
|
166
|
+
base_url: The base URL of the API.
|
|
167
|
+
"""
|
|
168
|
+
# Pass non-None values to override defaults/config/env
|
|
169
|
+
kwargs = {}
|
|
170
|
+
if api_key: kwargs["api_key"] = api_key
|
|
171
|
+
if base_url: kwargs["base_url"] = base_url
|
|
172
|
+
self._cfg = JSTDataClientConfig(**kwargs)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def api_key(self):
|
|
176
|
+
if not self._cfg.api_key:
|
|
177
|
+
raise ApiKeyNotSetError("API key is not set. Run 'jstdata login' or set JSTDATA_API_KEY.")
|
|
178
|
+
return self._cfg.api_key
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def base_url(self):
|
|
182
|
+
return self._cfg.base_url
|
|
183
|
+
|
|
184
|
+
def validate_key(self, api_key: Optional[str] = None) -> bool:
|
|
185
|
+
"""
|
|
186
|
+
Validates the API key by making a lightweight request.
|
|
187
|
+
"""
|
|
188
|
+
original_key = self._cfg.api_key
|
|
189
|
+
if api_key:
|
|
190
|
+
self._cfg.api_key = api_key
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
# Simple lightweight request to verify the key
|
|
194
|
+
self.make_request("metric", params={"limit": 1})
|
|
195
|
+
return True
|
|
196
|
+
except InvalidApiKeyError:
|
|
197
|
+
return False
|
|
198
|
+
except Exception:
|
|
199
|
+
raise
|
|
200
|
+
finally:
|
|
201
|
+
self._cfg.api_key = original_key
|
|
202
|
+
|
|
203
|
+
def make_request(
|
|
204
|
+
self,
|
|
205
|
+
endpoint: str,
|
|
206
|
+
params: Optional[Dict[str, Any]] = None,
|
|
207
|
+
enable_cache: bool = False,
|
|
208
|
+
) -> Dict[str, Any]:
|
|
209
|
+
"""
|
|
210
|
+
Low-level method to make a request to the API.
|
|
211
|
+
"""
|
|
212
|
+
if endpoint[0] != "/":
|
|
213
|
+
endpoint = f"/{endpoint}"
|
|
214
|
+
|
|
215
|
+
url = f"{self.base_url}{endpoint}"
|
|
216
|
+
|
|
217
|
+
# Caching logic could be more sophisticated, but keeping it simple for now
|
|
218
|
+
if enable_cache:
|
|
219
|
+
cache = JSTDataCache(endpoint, params)
|
|
220
|
+
cached_df = cache.read()
|
|
221
|
+
if cached_df is not None:
|
|
222
|
+
return {"records": cached_df.to_dict("records")}
|
|
223
|
+
|
|
224
|
+
api_key = self.api_key
|
|
225
|
+
with requests.Session() as session:
|
|
226
|
+
session.params = {"api-key": api_key}
|
|
227
|
+
response = session.get(url, params=params)
|
|
228
|
+
|
|
229
|
+
if response.status_code == 403:
|
|
230
|
+
raise InvalidApiKeyError("Invalid API key")
|
|
231
|
+
response.raise_for_status()
|
|
232
|
+
|
|
233
|
+
data = response.json()
|
|
234
|
+
if enable_cache and "records" in data:
|
|
235
|
+
cache.write(pd.DataFrame(data["records"]))
|
|
236
|
+
|
|
237
|
+
return data
|
|
238
|
+
|
|
239
|
+
# --- Metrics ---
|
|
240
|
+
|
|
241
|
+
def list_metrics(
|
|
242
|
+
self, limit: int = 100, offset: int = 0, sort_order: str = "asc"
|
|
243
|
+
) -> List[Metric]:
|
|
244
|
+
"""List all available metrics."""
|
|
245
|
+
data = self.make_request(
|
|
246
|
+
"metric", {"limit": limit, "offset": offset, "sort_order": sort_order}
|
|
247
|
+
)
|
|
248
|
+
return [Metric.from_dict(m) for m in data["records"]]
|
|
249
|
+
|
|
250
|
+
def get_metric(self, metric_id: str) -> Metric:
|
|
251
|
+
"""Get details for a specific metric."""
|
|
252
|
+
data = self.make_request(f"metric/{metric_id}")
|
|
253
|
+
return Metric.from_dict(data)
|
|
254
|
+
|
|
255
|
+
def get_metric_series(
|
|
256
|
+
self, metric_id: str, limit: int = 100, offset: int = 0
|
|
257
|
+
) -> List[Series]:
|
|
258
|
+
"""Get all series associated with a metric."""
|
|
259
|
+
data = self.make_request(f"metric/{metric_id}/series", {"limit": limit, "offset": offset})
|
|
260
|
+
return [Series.from_dict(s) for s in data["records"]]
|
|
261
|
+
|
|
262
|
+
# --- Series ---
|
|
263
|
+
|
|
264
|
+
def list_series(
|
|
265
|
+
self, limit: int = 100, offset: int = 0, sort_order: str = "asc"
|
|
266
|
+
) -> List[Series]:
|
|
267
|
+
"""List all available series."""
|
|
268
|
+
data = self.make_request(
|
|
269
|
+
"series", {"limit": limit, "offset": offset, "sort_order": sort_order}
|
|
270
|
+
)
|
|
271
|
+
return [Series.from_dict(s) for s in data["records"]]
|
|
272
|
+
|
|
273
|
+
def get_series(self, series_id: str) -> Series:
|
|
274
|
+
"""Get details for a specific series."""
|
|
275
|
+
data = self.make_request(f"series/{series_id}")
|
|
276
|
+
return Series.from_dict(data)
|
|
277
|
+
|
|
278
|
+
# --- Entities ---
|
|
279
|
+
|
|
280
|
+
def get_entity(self, entity_id: str) -> Entity:
|
|
281
|
+
"""Get details for a specific entity."""
|
|
282
|
+
data = self.make_request(f"entity/{entity_id}")
|
|
283
|
+
return Entity.from_dict(data)
|
|
284
|
+
|
|
285
|
+
def get_entity_series(
|
|
286
|
+
self, entity_id: str, limit: int = 100, offset: int = 0
|
|
287
|
+
) -> List[Series]:
|
|
288
|
+
"""Get all series associated with an entity."""
|
|
289
|
+
data = self.make_request(f"entity/{entity_id}/series", {"limit": limit, "offset": offset})
|
|
290
|
+
return [Series.from_dict(s) for s in data["records"]]
|
|
291
|
+
|
|
292
|
+
def get_entity_relations(
|
|
293
|
+
self, entity_id: str, limit: int = 100, offset: int = 0
|
|
294
|
+
) -> List[EntityRelationship]:
|
|
295
|
+
"""Get relationships for an entity (the graph view)."""
|
|
296
|
+
data = self.make_request(
|
|
297
|
+
f"entity/{entity_id}/relations", {"limit": limit, "offset": offset}
|
|
298
|
+
)
|
|
299
|
+
return [EntityRelationship.from_dict(r) for r in data["records"]]
|
|
300
|
+
|
|
301
|
+
# --- Taxonomies ---
|
|
302
|
+
|
|
303
|
+
def list_taxonomies(
|
|
304
|
+
self, limit: int = 100, offset: int = 0, sort_order: str = "asc"
|
|
305
|
+
) -> List[Taxonomy]:
|
|
306
|
+
"""List taxonomies that have identity (membership) relationships."""
|
|
307
|
+
data = self.make_request(
|
|
308
|
+
"taxonomy", {"limit": limit, "offset": offset, "sort_order": sort_order}
|
|
309
|
+
)
|
|
310
|
+
return [Taxonomy.from_dict(t) for t in data["records"]]
|
|
311
|
+
|
|
312
|
+
def get_taxonomy(self, taxonomy_id: str) -> Taxonomy:
|
|
313
|
+
"""Get details for a specific taxonomy."""
|
|
314
|
+
data = self.make_request(f"taxonomy/{taxonomy_id}")
|
|
315
|
+
return Taxonomy.from_dict(data)
|
|
316
|
+
|
|
317
|
+
def get_taxonomy_entities(
|
|
318
|
+
self, taxonomy_id: str, limit: int = 100, offset: int = 0
|
|
319
|
+
) -> List[Entity]:
|
|
320
|
+
"""List entities that themselves participate in a taxonomy."""
|
|
321
|
+
data = self.make_request(
|
|
322
|
+
f"taxonomy/{taxonomy_id}/entities", {"limit": limit, "offset": offset}
|
|
323
|
+
)
|
|
324
|
+
return [Entity.from_dict(e) for e in data["records"]]
|
|
325
|
+
|
|
326
|
+
def get_taxonomy_metrics(
|
|
327
|
+
self, taxonomy_id: str, limit: int = 100, offset: int = 0
|
|
328
|
+
) -> List[Metric]:
|
|
329
|
+
"""List metrics with series on entities in a taxonomy."""
|
|
330
|
+
data = self.make_request(
|
|
331
|
+
f"taxonomy/{taxonomy_id}/metrics", {"limit": limit, "offset": offset}
|
|
332
|
+
)
|
|
333
|
+
return [Metric.from_dict(m) for m in data["records"]]
|
|
334
|
+
|
|
335
|
+
# --- Search ---
|
|
336
|
+
|
|
337
|
+
def search(
|
|
338
|
+
self, query: str, limit: int = 15, offset: int = 0
|
|
339
|
+
) -> List[Union[Entity, Metric, Series]]:
|
|
340
|
+
"""Unified search across all resource types."""
|
|
341
|
+
data = self.make_request(
|
|
342
|
+
"search", {"query": query, "limit": limit, "offset": offset}
|
|
343
|
+
)
|
|
344
|
+
results = []
|
|
345
|
+
for r in data["records"]:
|
|
346
|
+
res_type = r.get("type")
|
|
347
|
+
if res_type == "entity":
|
|
348
|
+
results.append(Entity.from_dict(r))
|
|
349
|
+
elif res_type == "metric":
|
|
350
|
+
results.append(Metric.from_dict(r))
|
|
351
|
+
elif res_type == "series":
|
|
352
|
+
results.append(Series.from_dict(r))
|
|
353
|
+
return results
|
|
354
|
+
|
|
355
|
+
def search_entities(
|
|
356
|
+
self,
|
|
357
|
+
query: Optional[str] = None,
|
|
358
|
+
metric: Optional[Union[str, List[str]]] = None,
|
|
359
|
+
taxonomy: Optional[str] = None,
|
|
360
|
+
relation: Optional[Union[str, List[str]]] = None,
|
|
361
|
+
mode: str = "union",
|
|
362
|
+
limit: int = 5,
|
|
363
|
+
offset: int = 0,
|
|
364
|
+
) -> List[Entity]:
|
|
365
|
+
"""Search or list entities.
|
|
366
|
+
|
|
367
|
+
Omit ``query`` (or pass blank) to list the matching set in label order.
|
|
368
|
+
``metric`` may be one id or many; ``mode`` is ``union`` or ``intersect``
|
|
369
|
+
when more than one metric is given.
|
|
370
|
+
``relation`` filters to entities with a typed edge to an anchor
|
|
371
|
+
(``<relationship_type>:<to_entity_id>``). Multiple relations are OR'd.
|
|
372
|
+
"""
|
|
373
|
+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
374
|
+
if query is not None and str(query).strip():
|
|
375
|
+
params["query"] = query
|
|
376
|
+
metrics = _as_id_list(metric)
|
|
377
|
+
if metrics:
|
|
378
|
+
params["metric"] = metrics
|
|
379
|
+
params["mode"] = mode
|
|
380
|
+
if taxonomy:
|
|
381
|
+
params["taxonomy"] = taxonomy
|
|
382
|
+
relations = _as_id_list(relation)
|
|
383
|
+
if relations:
|
|
384
|
+
params["relation"] = relations
|
|
385
|
+
data = self.make_request("search/entities", params)
|
|
386
|
+
return [Entity.from_dict(e) for e in data["records"]]
|
|
387
|
+
|
|
388
|
+
def search_metrics(
|
|
389
|
+
self,
|
|
390
|
+
query: Optional[str] = None,
|
|
391
|
+
entity: Optional[Union[str, List[str]]] = None,
|
|
392
|
+
taxonomy: Optional[str] = None,
|
|
393
|
+
mode: str = "union",
|
|
394
|
+
limit: int = 5,
|
|
395
|
+
offset: int = 0,
|
|
396
|
+
) -> List[Metric]:
|
|
397
|
+
"""Search or list metrics.
|
|
398
|
+
|
|
399
|
+
Omit ``query`` (or pass blank) to list the matching set in name order.
|
|
400
|
+
``entity`` may be one id or many; ``mode`` is ``union`` or ``intersect``
|
|
401
|
+
when more than one entity is given.
|
|
402
|
+
"""
|
|
403
|
+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
404
|
+
if query is not None and str(query).strip():
|
|
405
|
+
params["query"] = query
|
|
406
|
+
entities = _as_id_list(entity)
|
|
407
|
+
if entities:
|
|
408
|
+
params["entity"] = entities
|
|
409
|
+
params["mode"] = mode
|
|
410
|
+
if taxonomy:
|
|
411
|
+
params["taxonomy"] = taxonomy
|
|
412
|
+
data = self.make_request("search/metrics", params)
|
|
413
|
+
return [Metric.from_dict(m) for m in data["records"]]
|
|
414
|
+
|
|
415
|
+
def search_series(
|
|
416
|
+
self, query: str, limit: int = 5, offset: int = 0
|
|
417
|
+
) -> List[Series]:
|
|
418
|
+
"""Search for series."""
|
|
419
|
+
data = self.make_request(
|
|
420
|
+
"search/series", {"query": query, "limit": limit, "offset": offset}
|
|
421
|
+
)
|
|
422
|
+
return [Series.from_dict(s) for s in data["records"]]
|
|
423
|
+
|
|
424
|
+
# --- Query ---
|
|
425
|
+
|
|
426
|
+
def query(
|
|
427
|
+
self,
|
|
428
|
+
metric: Optional[Union[str, List[str]]] = None,
|
|
429
|
+
entity: Optional[Union[str, List[str]]] = None,
|
|
430
|
+
series: Optional[Union[str, List[str]]] = None,
|
|
431
|
+
frequency: Optional[str] = None,
|
|
432
|
+
taxonomy: Optional[str] = None,
|
|
433
|
+
head: Optional[int] = None,
|
|
434
|
+
tail: Optional[int] = None,
|
|
435
|
+
as_of: Optional[Union[str, datetime]] = None,
|
|
436
|
+
sort_by: Optional[str] = None,
|
|
437
|
+
order_by: Optional[str] = None,
|
|
438
|
+
limit: Optional[int] = None,
|
|
439
|
+
offset: Optional[int] = None,
|
|
440
|
+
) -> List[TimeSeries]:
|
|
441
|
+
"""Bounded cross-sectional observations (``GET /query``).
|
|
442
|
+
|
|
443
|
+
Exactly one of ``head`` or ``tail`` is sent. If neither is given,
|
|
444
|
+
``tail`` defaults to ``DEFAULT_QUERY_TAIL``. Time ranges are not
|
|
445
|
+
accepted here; use :meth:`get_series_observations` for history.
|
|
446
|
+
``limit`` / ``offset`` paginate series, not observations.
|
|
447
|
+
|
|
448
|
+
``sort_by`` is ``id`` (default catalog order) or ``value`` (rank by
|
|
449
|
+
the chronologically last observation in each series' window,
|
|
450
|
+
descending). ``taxonomy`` restricts to series whose entities have an
|
|
451
|
+
identity relation to that taxonomy.
|
|
452
|
+
"""
|
|
453
|
+
if head is not None and tail is not None:
|
|
454
|
+
raise InvalidInputError("Provide exactly one of 'head' or 'tail'.")
|
|
455
|
+
if head is None and tail is None:
|
|
456
|
+
tail = DEFAULT_QUERY_TAIL
|
|
457
|
+
if sort_by is not None and sort_by not in ("id", "value"):
|
|
458
|
+
raise InvalidInputError("'sort_by' must be 'id' or 'value'.")
|
|
459
|
+
|
|
460
|
+
params: Dict[str, Any] = {
|
|
461
|
+
"metric": metric,
|
|
462
|
+
"entity": entity,
|
|
463
|
+
"series": series,
|
|
464
|
+
"frequency": frequency,
|
|
465
|
+
"taxonomy": taxonomy,
|
|
466
|
+
"head": head,
|
|
467
|
+
"tail": tail,
|
|
468
|
+
"sort_by": sort_by,
|
|
469
|
+
"order_by": order_by,
|
|
470
|
+
"limit": limit,
|
|
471
|
+
"offset": offset,
|
|
472
|
+
}
|
|
473
|
+
if as_of is not None:
|
|
474
|
+
params["as_of"] = _format_as_of(as_of)
|
|
475
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
476
|
+
|
|
477
|
+
data = self.make_request("query", params)
|
|
478
|
+
return [TimeSeries.from_dict(record) for record in data.get("records", [])]
|
|
479
|
+
|
|
480
|
+
def get_series_observations(
|
|
481
|
+
self,
|
|
482
|
+
series_id: str,
|
|
483
|
+
start_date: Optional[str] = None,
|
|
484
|
+
end_date: Optional[str] = None,
|
|
485
|
+
start_time: Optional[int] = None,
|
|
486
|
+
end_time: Optional[int] = None,
|
|
487
|
+
order_by: Optional[str] = None,
|
|
488
|
+
limit: Optional[int] = None,
|
|
489
|
+
offset: Optional[int] = None,
|
|
490
|
+
) -> List[Observation]:
|
|
491
|
+
"""Paginated history for one series (``GET /series/{id}/observations``)."""
|
|
492
|
+
params: Dict[str, Any] = {
|
|
493
|
+
"start_date": start_date,
|
|
494
|
+
"end_date": end_date,
|
|
495
|
+
"start_time": start_time,
|
|
496
|
+
"end_time": end_time,
|
|
497
|
+
"order_by": order_by,
|
|
498
|
+
"limit": limit,
|
|
499
|
+
"offset": offset,
|
|
500
|
+
}
|
|
501
|
+
params = {k: v for k, v in params.items() if v is not None}
|
|
502
|
+
data = self.make_request(f"series/{series_id}/observations", params)
|
|
503
|
+
sid = data.get("series_id", series_id)
|
|
504
|
+
return [
|
|
505
|
+
Observation.from_dict(dict(o, series_id=sid))
|
|
506
|
+
for o in data.get("observations", [])
|
|
507
|
+
]
|
|
508
|
+
|
|
509
|
+
def get_resources(self, resource: Union[str, List[str]]) -> List[Resource]:
|
|
510
|
+
"""Get details for a specific metric."""
|
|
511
|
+
data = self.make_request("resource", params={"resource": resource})
|
|
512
|
+
records = []
|
|
513
|
+
for r in data["records"]:
|
|
514
|
+
records.append(Resource(id=r["id"], label=r["label"]))
|
|
515
|
+
return records
|
|
516
|
+
|
|
517
|
+
def query_df(self, **kwargs) -> pd.DataFrame:
|
|
518
|
+
"""Flatten ``query`` results to one row per observation."""
|
|
519
|
+
results = self.query(**kwargs)
|
|
520
|
+
rows: List[Dict[str, Any]] = []
|
|
521
|
+
for ts in results:
|
|
522
|
+
entity_id = ",".join(e.id for e in ts.series.entities)
|
|
523
|
+
for obs in ts.observations:
|
|
524
|
+
rows.append(
|
|
525
|
+
{
|
|
526
|
+
"series_id": ts.series.id,
|
|
527
|
+
"series_label": ts.series.label,
|
|
528
|
+
"metric_id": ts.series.metric_id,
|
|
529
|
+
"entity_id": entity_id,
|
|
530
|
+
"frequency": ts.series.frequency,
|
|
531
|
+
"units": ts.series.units,
|
|
532
|
+
"source": ts.series.source,
|
|
533
|
+
"observation_timestamp": obs.observation_timestamp,
|
|
534
|
+
"release_timestamp": obs.release_timestamp,
|
|
535
|
+
"value": obs.value,
|
|
536
|
+
}
|
|
537
|
+
)
|
|
538
|
+
df = pd.DataFrame(rows)
|
|
539
|
+
if df.empty:
|
|
540
|
+
return df
|
|
541
|
+
df["observation_timestamp"] = pd.to_datetime(df["observation_timestamp"])
|
|
542
|
+
df["release_timestamp"] = pd.to_datetime(df["release_timestamp"])
|
|
543
|
+
return df
|
jstdata/models.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, Dict, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Frequency(str, Enum):
|
|
8
|
+
ANNUAL = "Annual"
|
|
9
|
+
QUARTERLY = "Quarterly"
|
|
10
|
+
MONTHLY = "Monthly"
|
|
11
|
+
DAILY = "Daily"
|
|
12
|
+
INTRADAY = "Intraday"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RelationshipType(str, Enum):
|
|
16
|
+
EQUIVALENT_TO = "equivalent_to"
|
|
17
|
+
CLASSIFIED_AS = "classified_as"
|
|
18
|
+
PART_OF = "part_of"
|
|
19
|
+
HAS_SECURITY = "has_security"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Entity:
|
|
24
|
+
id: str
|
|
25
|
+
label: str
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Entity":
|
|
29
|
+
return cls(id=data["id"], label=data["label"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Metric:
|
|
34
|
+
id: str
|
|
35
|
+
name: str
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Metric":
|
|
39
|
+
return cls(id=data["id"], name=data.get("name", data.get("label", "")))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Taxonomy:
|
|
44
|
+
id: str
|
|
45
|
+
name: str
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Taxonomy":
|
|
49
|
+
return cls(id=data["id"], name=data.get("name", data.get("label", "")))
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Resource:
|
|
53
|
+
id: str
|
|
54
|
+
label: str
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Series:
|
|
58
|
+
id: str
|
|
59
|
+
label: str
|
|
60
|
+
frequency: str
|
|
61
|
+
source: str
|
|
62
|
+
units: str
|
|
63
|
+
seasonal_adjustment: str
|
|
64
|
+
last_updated: datetime
|
|
65
|
+
metric_id: str
|
|
66
|
+
entities: List[Entity] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Series":
|
|
70
|
+
# Handle datetime conversion
|
|
71
|
+
last_updated = data.get("last_updated")
|
|
72
|
+
if isinstance(last_updated, str):
|
|
73
|
+
try:
|
|
74
|
+
# API format: 2024-01-01 00:00:00 or ISO
|
|
75
|
+
last_updated = datetime.fromisoformat(last_updated.replace(" ", "T"))
|
|
76
|
+
except ValueError:
|
|
77
|
+
# Fallback for other potential formats
|
|
78
|
+
last_updated = datetime.strptime(last_updated, "%Y-%m-%d %H:%M:%S")
|
|
79
|
+
|
|
80
|
+
if last_updated is None:
|
|
81
|
+
last_updated = datetime.min
|
|
82
|
+
|
|
83
|
+
entities = [Entity.from_dict(e) for e in data.get("entities", [])]
|
|
84
|
+
|
|
85
|
+
return cls(
|
|
86
|
+
id=data["id"],
|
|
87
|
+
label=data.get("label", ""),
|
|
88
|
+
frequency=data.get("frequency", ""),
|
|
89
|
+
source=data.get("source", ""),
|
|
90
|
+
units=data.get("units", ""),
|
|
91
|
+
# Handle the typo 'seasonal_adjsustment' from API spec while supporting the correct spelling
|
|
92
|
+
seasonal_adjustment=data.get(
|
|
93
|
+
"seasonal_adjustment", data.get("seasonal_adjsustment", "")
|
|
94
|
+
),
|
|
95
|
+
last_updated=last_updated,
|
|
96
|
+
metric_id=data.get("metric_id", ""),
|
|
97
|
+
entities=entities,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class EntityRelationship:
|
|
103
|
+
id: str
|
|
104
|
+
relationship: RelationshipType
|
|
105
|
+
taxonomy: Optional[str] = None
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_dict(cls, data: Dict[str, Any]) -> "EntityRelationship":
|
|
109
|
+
return cls(
|
|
110
|
+
id=data["id"],
|
|
111
|
+
relationship=RelationshipType(data["relationship"]),
|
|
112
|
+
taxonomy=data.get("taxonomy"),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class Observation:
|
|
118
|
+
series_id: str
|
|
119
|
+
observation_timestamp: datetime
|
|
120
|
+
release_timestamp: datetime
|
|
121
|
+
value: float
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_dict(cls, data: Dict[str, Any]) -> "Observation":
|
|
125
|
+
obs_ts = data["observation_timestamp"]
|
|
126
|
+
rel_ts = data["release_timestamp"]
|
|
127
|
+
|
|
128
|
+
if isinstance(obs_ts, str):
|
|
129
|
+
obs_ts = datetime.fromisoformat(obs_ts.replace(" ", "T"))
|
|
130
|
+
if isinstance(rel_ts, str):
|
|
131
|
+
rel_ts = datetime.fromisoformat(rel_ts.replace(" ", "T"))
|
|
132
|
+
|
|
133
|
+
return cls(
|
|
134
|
+
series_id=data["series_id"],
|
|
135
|
+
observation_timestamp=obs_ts,
|
|
136
|
+
release_timestamp=rel_ts,
|
|
137
|
+
value=data["value"]
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class TimeSeries:
|
|
142
|
+
series: Series
|
|
143
|
+
observations: List[Observation]
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def from_dict(cls, data: Dict[str, Any]) -> "TimeSeries":
|
|
147
|
+
series = {
|
|
148
|
+
"id": data.get("id", ""),
|
|
149
|
+
"label": data.get("label", ""),
|
|
150
|
+
"frequency": data.get("frequency", ""),
|
|
151
|
+
"source": data.get("source", ""),
|
|
152
|
+
"units": data.get("units", ""),
|
|
153
|
+
"seasonal_adjustment": data.get("seasonal_adjustment", ""),
|
|
154
|
+
"last_updated": data.get("last_updated", ""),
|
|
155
|
+
"metric_id": data.get("metric_id", ""),
|
|
156
|
+
"entities": data.get("entities", []),
|
|
157
|
+
}
|
|
158
|
+
return cls(
|
|
159
|
+
series=Series.from_dict(series),
|
|
160
|
+
observations=[Observation.from_dict(dict(o, series_id=data["id"])) for o in data["observations"]]
|
|
161
|
+
)
|