fred-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.
- fred_client/__init__.py +89 -0
- fred_client/_config.py +92 -0
- fred_client/_endpoint.py +105 -0
- fred_client/catalog.py +34 -0
- fred_client/cli.py +221 -0
- fred_client/client.py +1447 -0
- fred_client/errors.py +75 -0
- fred_client/py.typed +0 -0
- fred_client/session.py +310 -0
- fred_client/types.py +148 -0
- fred_client-0.1.0.dist-info/METADATA +385 -0
- fred_client-0.1.0.dist-info/RECORD +15 -0
- fred_client-0.1.0.dist-info/WHEEL +4 -0
- fred_client-0.1.0.dist-info/entry_points.txt +2 -0
- fred_client-0.1.0.dist-info/licenses/LICENSE +21 -0
fred_client/__init__.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""fred-client -- read economic data from the FRED / ALFRED / GeoFRED API.
|
|
2
|
+
|
|
3
|
+
from fred_client import FRED
|
|
4
|
+
|
|
5
|
+
fred = FRED() # or set FRED_API_KEY
|
|
6
|
+
rows = fred.series.observations("GNPCA") # a series' observations
|
|
7
|
+
hit = fred.series.search("unemployment rate") # series matching text
|
|
8
|
+
|
|
9
|
+
Single lookups return one raw dictionary; collections return ``list[dict]`` with the
|
|
10
|
+
vendor's own field names. The offline :mod:`fred_client.catalog` describes every service
|
|
11
|
+
without a call.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
17
|
+
|
|
18
|
+
from . import catalog
|
|
19
|
+
from .client import FRED
|
|
20
|
+
from .errors import (
|
|
21
|
+
FREDAuthError,
|
|
22
|
+
FREDConfigError,
|
|
23
|
+
FREDError,
|
|
24
|
+
FREDNetworkError,
|
|
25
|
+
FREDProtocolError,
|
|
26
|
+
FREDRateLimitError,
|
|
27
|
+
FREDResponseError,
|
|
28
|
+
)
|
|
29
|
+
from .types import (
|
|
30
|
+
AggregationMethod,
|
|
31
|
+
Frequency,
|
|
32
|
+
OutputType,
|
|
33
|
+
RegionType,
|
|
34
|
+
ReleaseDateOrder,
|
|
35
|
+
ReleaseOrder,
|
|
36
|
+
Row,
|
|
37
|
+
SearchOrder,
|
|
38
|
+
SearchType,
|
|
39
|
+
Season,
|
|
40
|
+
SeriesFilter,
|
|
41
|
+
SeriesOrder,
|
|
42
|
+
Shape,
|
|
43
|
+
SortOrder,
|
|
44
|
+
SourceOrder,
|
|
45
|
+
TagGroup,
|
|
46
|
+
TagOrder,
|
|
47
|
+
Transformation,
|
|
48
|
+
Units,
|
|
49
|
+
UpdateFilter,
|
|
50
|
+
VintageDates,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
__version__ = version("fred-client") # single source of truth: pyproject.toml
|
|
55
|
+
except PackageNotFoundError: # running from source without an install
|
|
56
|
+
__version__ = "0.0.0+unknown"
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"FRED",
|
|
60
|
+
"AggregationMethod",
|
|
61
|
+
"FREDAuthError",
|
|
62
|
+
"FREDConfigError",
|
|
63
|
+
"FREDError",
|
|
64
|
+
"FREDNetworkError",
|
|
65
|
+
"FREDProtocolError",
|
|
66
|
+
"FREDRateLimitError",
|
|
67
|
+
"FREDResponseError",
|
|
68
|
+
"Frequency",
|
|
69
|
+
"OutputType",
|
|
70
|
+
"RegionType",
|
|
71
|
+
"ReleaseDateOrder",
|
|
72
|
+
"ReleaseOrder",
|
|
73
|
+
"Row",
|
|
74
|
+
"SearchOrder",
|
|
75
|
+
"Season",
|
|
76
|
+
"SearchType",
|
|
77
|
+
"SeriesFilter",
|
|
78
|
+
"SeriesOrder",
|
|
79
|
+
"Shape",
|
|
80
|
+
"SourceOrder",
|
|
81
|
+
"SortOrder",
|
|
82
|
+
"TagGroup",
|
|
83
|
+
"TagOrder",
|
|
84
|
+
"Transformation",
|
|
85
|
+
"Units",
|
|
86
|
+
"UpdateFilter",
|
|
87
|
+
"VintageDates",
|
|
88
|
+
"catalog",
|
|
89
|
+
]
|
fred_client/_config.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Resolve the FRED API key from the caller, the environment, or the config file.
|
|
2
|
+
|
|
3
|
+
The key is looked up in a fixed order, so an explicit value always wins and a set
|
|
4
|
+
environment variable beats a file on disk:
|
|
5
|
+
|
|
6
|
+
1. the ``api_key`` passed to ``FRED(...)``
|
|
7
|
+
2. the ``FRED_API_KEY`` environment variable
|
|
8
|
+
3. ``"FRED_API_KEY"`` in ``$XDG_CONFIG_HOME/fred-client/credentials.json``
|
|
9
|
+
(``$XDG_CONFIG_HOME`` defaults to ``~/.config``)
|
|
10
|
+
|
|
11
|
+
The file is optional -- its absence just means "no key here." But a file that is present
|
|
12
|
+
and unreadable, not JSON, or not a JSON object is an error, because a caller who wrote one
|
|
13
|
+
meant it to be used and a silent skip would hide the mistake.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .errors import FREDConfigError
|
|
24
|
+
|
|
25
|
+
_ENV_VAR = "FRED_API_KEY"
|
|
26
|
+
_CONFIG_DIR = "fred-client"
|
|
27
|
+
_CONFIG_FILE = "credentials.json"
|
|
28
|
+
_API_KEY_PATTERN = re.compile(r"[a-z0-9]{32}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_api_key(explicit: str | None) -> str:
|
|
32
|
+
"""Return the first key found across the three sources, or raise if none exists."""
|
|
33
|
+
# Surrounding whitespace is harmless copy/paste residue and is normalized. Control
|
|
34
|
+
# characters that remain inside the key are rejected below.
|
|
35
|
+
key = (explicit or "").strip() or os.environ.get(_ENV_VAR, "").strip() or _key_from_file()
|
|
36
|
+
if not key:
|
|
37
|
+
raise FREDConfigError(
|
|
38
|
+
f"no FRED API key: pass api_key=, set the {_ENV_VAR} environment "
|
|
39
|
+
f"variable, or put it in {credentials_path()}"
|
|
40
|
+
)
|
|
41
|
+
if _API_KEY_PATTERN.fullmatch(key) is None:
|
|
42
|
+
# FRED keys use only lowercase ASCII letters and digits. Enforcing that wire
|
|
43
|
+
# contract also means query encoding has exactly one representation.
|
|
44
|
+
raise FREDConfigError("the FRED API key must be 32 lowercase letters or digits")
|
|
45
|
+
return key
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def credentials_path() -> Path:
|
|
49
|
+
"""The path fred-client reads a stored key from (honoring ``$XDG_CONFIG_HOME``)."""
|
|
50
|
+
configured_home = os.environ.get("XDG_CONFIG_HOME")
|
|
51
|
+
if configured_home:
|
|
52
|
+
config_home = Path(configured_home)
|
|
53
|
+
if not config_home.is_absolute():
|
|
54
|
+
raise FREDConfigError("XDG_CONFIG_HOME must be an absolute path")
|
|
55
|
+
else:
|
|
56
|
+
config_home = Path.home() / ".config"
|
|
57
|
+
return config_home / _CONFIG_DIR / _CONFIG_FILE
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _key_from_file() -> str:
|
|
61
|
+
path = credentials_path()
|
|
62
|
+
failure: FREDConfigError | None = None
|
|
63
|
+
try:
|
|
64
|
+
text = path.read_text(encoding="utf-8")
|
|
65
|
+
except FileNotFoundError:
|
|
66
|
+
return ""
|
|
67
|
+
except UnicodeDecodeError:
|
|
68
|
+
failure = FREDConfigError(f"{path} is not valid UTF-8")
|
|
69
|
+
except OSError as err:
|
|
70
|
+
raise FREDConfigError(f"could not read {path}: {err}") from err
|
|
71
|
+
if failure is not None:
|
|
72
|
+
# A UnicodeDecodeError carries the offending bytes, and this file can hold the API
|
|
73
|
+
# key -- from None keeps those bytes out of any traceback.
|
|
74
|
+
raise failure from None
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads(text)
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
failure = FREDConfigError(f"{path} is not valid JSON")
|
|
80
|
+
if failure is not None:
|
|
81
|
+
# A JSONDecodeError retains the parsed text in .doc, and this file can hold the API
|
|
82
|
+
# key -- from None keeps the key out of any traceback.
|
|
83
|
+
raise failure from None
|
|
84
|
+
if not isinstance(data, dict):
|
|
85
|
+
raise FREDConfigError(f"{path} must contain a JSON object")
|
|
86
|
+
|
|
87
|
+
key = data.get(_ENV_VAR)
|
|
88
|
+
if key is None:
|
|
89
|
+
return ""
|
|
90
|
+
if not isinstance(key, str):
|
|
91
|
+
raise FREDConfigError(f"{path} property {_ENV_VAR} must be a string")
|
|
92
|
+
return key.strip()
|
fred_client/_endpoint.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""The :class:`_FREDEndpoint` value object and the registry of every FRED service.
|
|
2
|
+
|
|
3
|
+
Each endpoint is data: which API namespace it lives in, its path, the *shape* of the
|
|
4
|
+
response, and -- for the array-shaped responses -- the JSON key that holds the array.
|
|
5
|
+
``url`` resolves from namespace + path, so the wire location lives in exactly one place.
|
|
6
|
+
|
|
7
|
+
The FRED API answers most requests as a top-level object with one named array whose key
|
|
8
|
+
differs per endpoint (``seriess`` with two s's, ``observations``, ``categories``, ...),
|
|
9
|
+
but two core endpoints and the whole GeoFRED maps surface break that mould: a vintage-date
|
|
10
|
+
list is an array of bare strings, a release table is a nested tree, and the maps responses
|
|
11
|
+
are GeoJSON or doubly-nested objects. ``shape`` records which, so the session extracts each
|
|
12
|
+
correctly instead of assuming one form.
|
|
13
|
+
|
|
14
|
+
``_ENDPOINTS`` is kept read-only (a ``MappingProxyType``): it is package dispatch state
|
|
15
|
+
that every accessor reads, so a caller must not be able to mutate it.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from types import MappingProxyType
|
|
23
|
+
from typing import Literal
|
|
24
|
+
|
|
25
|
+
# How the session turns a parsed body into the caller's return value.
|
|
26
|
+
# objects -> body[key] is a list[dict] (the common case)
|
|
27
|
+
# strings -> body[key] is a list[str] (series/vintagedates)
|
|
28
|
+
# tree -> the whole body dict (release/tables, a nested element tree)
|
|
29
|
+
# geojson -> the whole body dict (maps shapes, a GeoJSON FeatureCollection)
|
|
30
|
+
# single -> the whole body dict (maps series/group, one meta object)
|
|
31
|
+
# nested -> the whole body dict (maps series/data & regional/data)
|
|
32
|
+
# bulk -> cursor-paged v2 release histories, merged into one whole object
|
|
33
|
+
_ResponseShape = Literal["objects", "strings", "tree", "geojson", "single", "nested", "bulk"]
|
|
34
|
+
_APINamespace = Literal["fred", "geofred"]
|
|
35
|
+
|
|
36
|
+
_HOST = "https://api.stlouisfed.org"
|
|
37
|
+
_BASE_URL_BY_API_NAMESPACE: Mapping[_APINamespace, str] = MappingProxyType(
|
|
38
|
+
{"fred": f"{_HOST}/fred", "geofred": f"{_HOST}/geofred"}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class _FREDEndpoint:
|
|
44
|
+
"""One FRED service: where its data lives and how its response is shaped."""
|
|
45
|
+
|
|
46
|
+
path: str # e.g. "series/observations"
|
|
47
|
+
shape: _ResponseShape # how to extract the return value
|
|
48
|
+
response_array_key: str = "" # "" for whole-dict response shapes
|
|
49
|
+
api_namespace: _APINamespace = "fred"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def url(self) -> str:
|
|
53
|
+
return f"{_BASE_URL_BY_API_NAMESPACE[self.api_namespace]}/{self.path}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _registry(*endpoints: _FREDEndpoint) -> Mapping[str, _FREDEndpoint]:
|
|
57
|
+
return MappingProxyType({endpoint.path: endpoint for endpoint in endpoints})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Every service, path -> _FREDEndpoint. Read-only. Grouped by the FRED doc's own sections,
|
|
61
|
+
# mirroring the vendor's taxonomy (the module lays out the same way).
|
|
62
|
+
_ENDPOINTS: Mapping[str, _FREDEndpoint] = _registry(
|
|
63
|
+
# -- Categories --------------------------------------------------------
|
|
64
|
+
_FREDEndpoint("category", "objects", "categories"),
|
|
65
|
+
_FREDEndpoint("category/children", "objects", "categories"),
|
|
66
|
+
_FREDEndpoint("category/related", "objects", "categories"),
|
|
67
|
+
_FREDEndpoint("category/series", "objects", "seriess"),
|
|
68
|
+
_FREDEndpoint("category/tags", "objects", "tags"),
|
|
69
|
+
_FREDEndpoint("category/related_tags", "objects", "tags"),
|
|
70
|
+
# -- Releases ----------------------------------------------------------
|
|
71
|
+
_FREDEndpoint("releases", "objects", "releases"),
|
|
72
|
+
_FREDEndpoint("releases/dates", "objects", "release_dates"),
|
|
73
|
+
_FREDEndpoint("release", "objects", "releases"),
|
|
74
|
+
_FREDEndpoint("release/dates", "objects", "release_dates"),
|
|
75
|
+
_FREDEndpoint("release/series", "objects", "seriess"),
|
|
76
|
+
_FREDEndpoint("release/sources", "objects", "sources"),
|
|
77
|
+
_FREDEndpoint("release/tags", "objects", "tags"),
|
|
78
|
+
_FREDEndpoint("release/related_tags", "objects", "tags"),
|
|
79
|
+
_FREDEndpoint("release/tables", "tree"),
|
|
80
|
+
_FREDEndpoint("v2/release/observations", "bulk"),
|
|
81
|
+
# -- Series ------------------------------------------------------------
|
|
82
|
+
_FREDEndpoint("series", "objects", "seriess"),
|
|
83
|
+
_FREDEndpoint("series/categories", "objects", "categories"),
|
|
84
|
+
_FREDEndpoint("series/observations", "objects", "observations"),
|
|
85
|
+
_FREDEndpoint("series/release", "objects", "releases"),
|
|
86
|
+
_FREDEndpoint("series/search", "objects", "seriess"),
|
|
87
|
+
_FREDEndpoint("series/search/tags", "objects", "tags"),
|
|
88
|
+
_FREDEndpoint("series/search/related_tags", "objects", "tags"),
|
|
89
|
+
_FREDEndpoint("series/tags", "objects", "tags"),
|
|
90
|
+
_FREDEndpoint("series/updates", "objects", "seriess"),
|
|
91
|
+
_FREDEndpoint("series/vintagedates", "strings", "vintage_dates"),
|
|
92
|
+
# -- Sources -----------------------------------------------------------
|
|
93
|
+
_FREDEndpoint("sources", "objects", "sources"),
|
|
94
|
+
_FREDEndpoint("source", "objects", "sources"),
|
|
95
|
+
_FREDEndpoint("source/releases", "objects", "releases"),
|
|
96
|
+
# -- Tags --------------------------------------------------------------
|
|
97
|
+
_FREDEndpoint("tags", "objects", "tags"),
|
|
98
|
+
_FREDEndpoint("related_tags", "objects", "tags"),
|
|
99
|
+
_FREDEndpoint("tags/series", "objects", "seriess"),
|
|
100
|
+
# -- Maps (GeoFRED) ----------------------------------------------------
|
|
101
|
+
_FREDEndpoint("shapes/file", "geojson", api_namespace="geofred"),
|
|
102
|
+
_FREDEndpoint("series/group", "single", api_namespace="geofred"),
|
|
103
|
+
_FREDEndpoint("series/data", "nested", api_namespace="geofred"),
|
|
104
|
+
_FREDEndpoint("regional/data", "nested", api_namespace="geofred"),
|
|
105
|
+
)
|
fred_client/catalog.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Offline catalog of the FRED services -- browse the surface without an API key.
|
|
2
|
+
|
|
3
|
+
Every service is data already in the internal endpoint registry. This module presents it
|
|
4
|
+
in a read-friendly form so a caller (or the CLI) can answer "what endpoints exist, in which
|
|
5
|
+
API namespace, returning what shape?" with no network call and no key.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ._endpoint import _ENDPOINTS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def endpoints() -> list[dict[str, str]]:
|
|
14
|
+
"""Every service as a plain dict: path, API namespace, response shape, and array key."""
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
"path": endpoint.path,
|
|
18
|
+
"api_namespace": endpoint.api_namespace,
|
|
19
|
+
"shape": endpoint.shape,
|
|
20
|
+
"response_array_key": endpoint.response_array_key,
|
|
21
|
+
}
|
|
22
|
+
for endpoint in _ENDPOINTS.values()
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def paths() -> list[str]:
|
|
27
|
+
"""The sorted list of every service path."""
|
|
28
|
+
return sorted(_ENDPOINTS)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find(text: str) -> list[dict[str, str]]:
|
|
32
|
+
"""The services whose path contains ``text`` (case-insensitive)."""
|
|
33
|
+
needle = text.lower()
|
|
34
|
+
return [entry for entry in endpoints() if needle in entry["path"].lower()]
|
fred_client/cli.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Command-line access to curated indicators and arbitrary FRED series.
|
|
2
|
+
|
|
3
|
+
``list`` browses the curated accessor groups offline. ``fetch`` mirrors either a
|
|
4
|
+
curated accessor (``fred fetch growth gdp``) or a raw series ID (``fred fetch GDP``).
|
|
5
|
+
``metadata`` describes one series, and ``search`` finds series IDs by text. Data
|
|
6
|
+
commands print aligned output by default and the raw response as JSON under ``--json``;
|
|
7
|
+
all data-shape knowledge stays in the library -- this only formats what it returns.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Callable, Sequence
|
|
16
|
+
from typing import get_args
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .client import FRED, _list_indicator_groups, _list_indicators
|
|
20
|
+
from .errors import FREDError
|
|
21
|
+
from .types import AggregationMethod, Frequency, OutputType, Row, SortOrder, Units
|
|
22
|
+
|
|
23
|
+
__all__ = ["main"]
|
|
24
|
+
|
|
25
|
+
_PROG = "fred"
|
|
26
|
+
_ERROR_PREFIX = f"{_PROG}: "
|
|
27
|
+
# How many rows the table view prints; the full result is always available with --json.
|
|
28
|
+
_MAX_SHOWN_ROWS = 20
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _cell(value: object) -> str:
|
|
32
|
+
"""A value as a single-line table cell (FRED metadata ``notes`` can hold newlines)."""
|
|
33
|
+
return str(value).replace("\n", " ")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _render(headers: Sequence[str], rows: Sequence[Sequence[object]]) -> str:
|
|
37
|
+
"""Rows as an aligned table over ``headers``, one row per line.
|
|
38
|
+
|
|
39
|
+
Widths are code-point counts, which assume the effectively-ASCII FRED payloads; a
|
|
40
|
+
wide or combining character would misalign a column.
|
|
41
|
+
"""
|
|
42
|
+
cells = [[_cell(value) for value in row] for row in rows]
|
|
43
|
+
if cells:
|
|
44
|
+
columns = list(zip(headers, *cells, strict=True))
|
|
45
|
+
else:
|
|
46
|
+
columns = [(header,) for header in headers]
|
|
47
|
+
widths = [max(len(cell) for cell in column) for column in columns]
|
|
48
|
+
header_line = " ".join(
|
|
49
|
+
header.ljust(width) for header, width in zip(headers, widths, strict=True)
|
|
50
|
+
)
|
|
51
|
+
body = "\n".join(
|
|
52
|
+
" ".join(cell.ljust(width) for cell, width in zip(row, widths, strict=True))
|
|
53
|
+
for row in cells
|
|
54
|
+
)
|
|
55
|
+
return f"{header_line}\n{body}" if body else header_line
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _table(rows: Sequence[Row]) -> tuple[list[str], list[list[object]]]:
|
|
59
|
+
"""A row list as ``(headers, cell-rows)``, unioning keys across the rows.
|
|
60
|
+
|
|
61
|
+
The key union (not just the first row's keys) keeps an ALFRED vintage view
|
|
62
|
+
(output_type 2/3), whose rows are keyed by different vintage dates, from silently
|
|
63
|
+
dropping columns the table should show.
|
|
64
|
+
"""
|
|
65
|
+
headers = list(dict.fromkeys(key for row in rows for key in row))
|
|
66
|
+
body: list[list[object]] = [[row.get(key, "") for key in headers] for row in rows]
|
|
67
|
+
return headers, body
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _render_rows(rows: Sequence[Row]) -> str:
|
|
71
|
+
"""Rows as an aligned table then a total count. Over ``_MAX_SHOWN_ROWS`` rows, show
|
|
72
|
+
the first and last halves split by a gap marker so both ends of the series stay
|
|
73
|
+
visible (use ``--json`` for everything). Empty -> ``(no rows)``."""
|
|
74
|
+
if not rows:
|
|
75
|
+
return "(no rows)"
|
|
76
|
+
noun = "row" if len(rows) == 1 else "rows"
|
|
77
|
+
if len(rows) <= _MAX_SHOWN_ROWS:
|
|
78
|
+
headers, body = _table(rows)
|
|
79
|
+
return f"{_render(headers, body)}\n({len(rows)} {noun})"
|
|
80
|
+
head = _MAX_SHOWN_ROWS // 2
|
|
81
|
+
tail = _MAX_SHOWN_ROWS - head
|
|
82
|
+
headers, body = _table([*rows[:head], *rows[-tail:]])
|
|
83
|
+
lines = _render(headers, body).splitlines() # header line + head rows + tail rows
|
|
84
|
+
# A bare "..." marks the omitted middle; the footer already gives the omitted count.
|
|
85
|
+
table = "\n".join([*lines[: head + 1], "...", *lines[head + 1 :]])
|
|
86
|
+
return f"{table}\n({len(rows)} {noun} total, first {head} and last {tail} shown)"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _render_mapping(mapping: Row) -> str:
|
|
90
|
+
"""One object as aligned ``key value`` lines. Empty -> ``(no fields)``."""
|
|
91
|
+
if not mapping:
|
|
92
|
+
return "(no fields)"
|
|
93
|
+
width = max(len(key) for key in mapping)
|
|
94
|
+
return "\n".join(f"{key.ljust(width)} {_cell(value)}" for key, value in mapping.items())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _emit_rows(rows: Sequence[Row], as_json: bool) -> None:
|
|
98
|
+
if as_json:
|
|
99
|
+
print(json.dumps(list(rows), ensure_ascii=False, indent=2))
|
|
100
|
+
else:
|
|
101
|
+
print(_render_rows(rows))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _emit_mapping(mapping: Row, as_json: bool) -> None:
|
|
105
|
+
if as_json:
|
|
106
|
+
print(json.dumps(mapping, ensure_ascii=False, indent=2))
|
|
107
|
+
else:
|
|
108
|
+
print(_render_mapping(mapping))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _add_observation_options(parser: argparse.ArgumentParser) -> None:
|
|
112
|
+
parser.add_argument("--realtime-start")
|
|
113
|
+
parser.add_argument("--realtime-end")
|
|
114
|
+
parser.add_argument("--observation-start")
|
|
115
|
+
parser.add_argument("--observation-end")
|
|
116
|
+
parser.add_argument("--units", choices=get_args(Units))
|
|
117
|
+
parser.add_argument("--frequency", choices=get_args(Frequency))
|
|
118
|
+
parser.add_argument("--aggregation-method", choices=get_args(AggregationMethod))
|
|
119
|
+
parser.add_argument("--output-type", type=int, choices=get_args(OutputType))
|
|
120
|
+
parser.add_argument("--vintage-dates")
|
|
121
|
+
parser.add_argument("--limit", type=int)
|
|
122
|
+
parser.add_argument("--offset", type=int)
|
|
123
|
+
parser.add_argument("--sort-order", choices=get_args(SortOrder))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _make_parser() -> argparse.ArgumentParser:
|
|
127
|
+
parser = argparse.ArgumentParser(prog=_PROG, description="Read FRED economic data.")
|
|
128
|
+
parser.add_argument("--version", action="version", version=f"fred-client {__version__}")
|
|
129
|
+
commands = parser.add_subparsers(required=True)
|
|
130
|
+
|
|
131
|
+
list_command = commands.add_parser("list", help="list curated indicator accessors (offline)")
|
|
132
|
+
list_command.add_argument("group", nargs="?", choices=_list_indicator_groups())
|
|
133
|
+
list_command.set_defaults(run=_run_list)
|
|
134
|
+
|
|
135
|
+
fetch = commands.add_parser("fetch", help="fetch observations by accessor or series ID")
|
|
136
|
+
fetch.add_argument("group_or_series", help="an accessor group or a FRED series ID")
|
|
137
|
+
fetch.add_argument("indicator", nargs="?", help="an indicator in the accessor group")
|
|
138
|
+
_add_observation_options(fetch)
|
|
139
|
+
fetch.add_argument("--json", action="store_true", help="emit JSON rows instead of a table")
|
|
140
|
+
fetch.set_defaults(run=_run_fetch)
|
|
141
|
+
|
|
142
|
+
metadata = commands.add_parser("metadata", help="show one series' metadata")
|
|
143
|
+
metadata.add_argument("series_id")
|
|
144
|
+
metadata.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
145
|
+
metadata.set_defaults(run=_run_metadata)
|
|
146
|
+
|
|
147
|
+
search = commands.add_parser("search", help="find series matching search text")
|
|
148
|
+
search.add_argument("text")
|
|
149
|
+
search.add_argument("--limit", type=int)
|
|
150
|
+
search.add_argument("--json", action="store_true", help="emit JSON rows instead of a table")
|
|
151
|
+
search.set_defaults(run=_run_search)
|
|
152
|
+
|
|
153
|
+
return parser
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main(argv: list[str] | None = None) -> int:
|
|
157
|
+
args = _make_parser().parse_args(argv)
|
|
158
|
+
run: Callable[[argparse.Namespace], int] = args.run
|
|
159
|
+
try:
|
|
160
|
+
return run(args)
|
|
161
|
+
except FREDError as err:
|
|
162
|
+
print(f"{_ERROR_PREFIX}{err}", file=sys.stderr)
|
|
163
|
+
return 1
|
|
164
|
+
except ValueError as err:
|
|
165
|
+
print(f"{_ERROR_PREFIX}{err}", file=sys.stderr)
|
|
166
|
+
return 2
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _run_list(args: argparse.Namespace) -> int:
|
|
170
|
+
groups = (args.group,) if args.group else _list_indicator_groups()
|
|
171
|
+
lines: list[str] = []
|
|
172
|
+
for group in groups:
|
|
173
|
+
lines.append(group)
|
|
174
|
+
lines.extend(f" {group} {indicator}" for indicator in _list_indicators(group))
|
|
175
|
+
print("\n".join(lines))
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _run_fetch(args: argparse.Namespace) -> int:
|
|
180
|
+
params = {
|
|
181
|
+
"realtime_start": args.realtime_start,
|
|
182
|
+
"realtime_end": args.realtime_end,
|
|
183
|
+
"observation_start": args.observation_start,
|
|
184
|
+
"observation_end": args.observation_end,
|
|
185
|
+
"units": args.units,
|
|
186
|
+
"frequency": args.frequency,
|
|
187
|
+
"aggregation_method": args.aggregation_method,
|
|
188
|
+
"output_type": args.output_type,
|
|
189
|
+
"vintage_dates": args.vintage_dates,
|
|
190
|
+
"limit": args.limit,
|
|
191
|
+
"offset": args.offset,
|
|
192
|
+
"sort_order": args.sort_order,
|
|
193
|
+
}
|
|
194
|
+
if args.indicator is None:
|
|
195
|
+
fred = FRED()
|
|
196
|
+
payload = fred.series.observations(args.group_or_series, **params)
|
|
197
|
+
else:
|
|
198
|
+
group = args.group_or_series.lower()
|
|
199
|
+
indicator = args.indicator.lower()
|
|
200
|
+
if group not in _list_indicator_groups():
|
|
201
|
+
raise ValueError(f"unknown indicator group {group!r} (try `{_PROG} list`)")
|
|
202
|
+
if indicator not in _list_indicators(group):
|
|
203
|
+
raise ValueError(f"unknown indicator {group}.{indicator} (try `{_PROG} list {group}`)")
|
|
204
|
+
fred = FRED()
|
|
205
|
+
payload = getattr(getattr(fred, group), indicator)(**params)
|
|
206
|
+
_emit_rows(payload, args.json)
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _run_metadata(args: argparse.Namespace) -> int:
|
|
211
|
+
_emit_mapping(FRED().series.get(args.series_id), args.json)
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _run_search(args: argparse.Namespace) -> int:
|
|
216
|
+
_emit_rows(FRED().series.search(args.text, limit=args.limit), args.json)
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
raise SystemExit(main())
|