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/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
+ )