fred-loader 0.1.2__tar.gz → 0.1.4__tar.gz

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.
Files changed (22) hide show
  1. {fred_loader-0.1.2/src/fred_loader.egg-info → fred_loader-0.1.4}/PKG-INFO +13 -1
  2. {fred_loader-0.1.2 → fred_loader-0.1.4}/pyproject.toml +18 -2
  3. {fred_loader-0.1.2 → fred_loader-0.1.4}/src/fred_loader/load.py +2 -2
  4. {fred_loader-0.1.2 → fred_loader-0.1.4}/src/fred_loader/macro_scores.py +1 -1
  5. {fred_loader-0.1.2 → fred_loader-0.1.4}/src/fred_loader/utils.py +69 -9
  6. {fred_loader-0.1.2 → fred_loader-0.1.4/src/fred_loader.egg-info}/PKG-INFO +13 -1
  7. {fred_loader-0.1.2 → fred_loader-0.1.4}/src/fred_loader.egg-info/SOURCES.txt +2 -0
  8. {fred_loader-0.1.2 → fred_loader-0.1.4}/tests/test_discovery_and_resolver.py +19 -0
  9. fred_loader-0.1.4/tests/test_output_encoding.py +84 -0
  10. fred_loader-0.1.4/tests/test_package_metadata.py +20 -0
  11. {fred_loader-0.1.2 → fred_loader-0.1.4}/tests/test_pull_loop.py +21 -0
  12. {fred_loader-0.1.2 → fred_loader-0.1.4}/LICENSE +0 -0
  13. {fred_loader-0.1.2 → fred_loader-0.1.4}/README.md +0 -0
  14. {fred_loader-0.1.2 → fred_loader-0.1.4}/setup.cfg +0 -0
  15. {fred_loader-0.1.2 → fred_loader-0.1.4}/src/fred_loader/__init__.py +0 -0
  16. {fred_loader-0.1.2 → fred_loader-0.1.4}/src/fred_loader/py.typed +0 -0
  17. {fred_loader-0.1.2 → fred_loader-0.1.4}/src/fred_loader/series.py +0 -0
  18. {fred_loader-0.1.2 → fred_loader-0.1.4}/src/fred_loader.egg-info/dependency_links.txt +0 -0
  19. {fred_loader-0.1.2 → fred_loader-0.1.4}/src/fred_loader.egg-info/requires.txt +0 -0
  20. {fred_loader-0.1.2 → fred_loader-0.1.4}/src/fred_loader.egg-info/top_level.txt +0 -0
  21. {fred_loader-0.1.2 → fred_loader-0.1.4}/tests/test_macro_scores.py +0 -0
  22. {fred_loader-0.1.2 → fred_loader-0.1.4}/tests/test_typed.py +0 -0
@@ -1,9 +1,21 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fred-loader
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A Python wrapper around the Federal Reserve FRED API that abstracts away series IDs, resampling rules, and macro scoring behind a clean, human-readable interface.
5
5
  Author: Ian Solberg
6
6
  License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/iasolb/fred-loader
8
+ Project-URL: Repository, https://github.com/iasolb/fred-loader
9
+ Project-URL: Issues, https://github.com/iasolb/fred-loader/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering
7
19
  Classifier: Typing :: Typed
8
20
  Requires-Python: >=3.10
9
21
  Description-Content-Type: text/markdown
@@ -4,14 +4,25 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fred-loader"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "A Python wrapper around the Federal Reserve FRED API that abstracts away series IDs, resampling rules, and macro scoring behind a clean, human-readable interface."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = "MIT"
12
12
  license-files = ["LICENSE"]
13
13
  authors = [{ name = "Ian Solberg" }]
14
- classifiers = ["Typing :: Typed"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Science/Research",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Scientific/Engineering",
24
+ "Typing :: Typed",
25
+ ]
15
26
  dependencies = [
16
27
  "fredapi",
17
28
  "pandas",
@@ -19,6 +30,11 @@ dependencies = [
19
30
  "python-dotenv",
20
31
  ]
21
32
 
33
+ [project.urls]
34
+ Homepage = "https://github.com/iasolb/fred-loader"
35
+ Repository = "https://github.com/iasolb/fred-loader"
36
+ Issues = "https://github.com/iasolb/fred-loader/issues"
37
+
22
38
  [project.optional-dependencies]
23
39
  dev = ["pytest"]
24
40
 
@@ -38,7 +38,7 @@ Available categories::
38
38
  """
39
39
 
40
40
  import pandas as pd
41
- from .utils import Config, load_fred_master
41
+ from .utils import Config, _say, load_fred_master
42
42
  from .macro_scores import score
43
43
 
44
44
  # Re-export every category so users only need `from fred_loader import ...`
@@ -139,5 +139,5 @@ def pull_fred(config: Config, apply_scores: bool = False) -> pd.DataFrame | None
139
139
  out_file = config.OUTPUT_PATH / config.FILENAME
140
140
  output.to_csv(out_file)
141
141
 
142
- print(f"\nSaved → {out_file}")
142
+ _say(f"\nSaved -> {out_file}")
143
143
  return output
@@ -772,7 +772,7 @@ def score(
772
772
  meaningful = [s for s in resolved if s.requires]
773
773
  if not meaningful:
774
774
  print(
775
- "\n⚠ No scores can be computed from the columns present.\n"
775
+ "\n! No scores can be computed from the columns present.\n"
776
776
  " Scoring requires series from categories like INFLATION, LABOR,\n"
777
777
  " RATES, or SEP. Returning raw data unchanged."
778
778
  )
@@ -2,12 +2,60 @@ from fredapi import Fred
2
2
  import pandas as pd
3
3
  import os
4
4
  from dotenv import load_dotenv
5
+ from pandas.tseries.frequencies import to_offset
5
6
  from pathlib import Path
6
7
  from typing import Optional
8
+ import sys
7
9
  import time
8
10
  from .series import ALL_SERIES, CATEGORIES, SUBCATEGORIES
9
11
 
10
12
 
13
+ def _say(line: str = "") -> None:
14
+ """Print one progress line, never raising on the stream's encoding.
15
+
16
+ A Windows script whose stdout is piped or redirected writes cp1252, which
17
+ cannot encode every character an API error, a custom series name or a file
18
+ path may carry. The line reports on work that already happened, so it
19
+ degrades to '?' instead of crashing the pull it describes.
20
+ """
21
+ encoding = getattr(sys.stdout, "encoding", None) or "utf-8"
22
+ print(line.encode(encoding, errors="replace").decode(encoding))
23
+
24
+
25
+ # One row of the output stands for one of these, keyed by the offset's class
26
+ # name so the lookup does not depend on how a pandas version spells the alias.
27
+ _PERIOD_WORDS = {
28
+ "Week": "weeks",
29
+ "MonthEnd": "months",
30
+ "MonthBegin": "months",
31
+ "BusinessMonthEnd": "months",
32
+ "BusinessMonthBegin": "months",
33
+ "QuarterEnd": "quarters",
34
+ "QuarterBegin": "quarters",
35
+ "YearEnd": "years",
36
+ "YearBegin": "years",
37
+ "Day": "days",
38
+ "BusinessDay": "business days",
39
+ "Hour": "hours",
40
+ }
41
+
42
+
43
+ def _period_word(rule: str) -> str:
44
+ """Name the period one output row stands for under *rule*, else "rows".
45
+
46
+ The summary line said "weeks" whatever the rule, so a monthly pull
47
+ printed "56 weeks" (2026-10-17). A multiple such as "2W" says "rows",
48
+ since "weeks" would be wrong by the multiple.
49
+ """
50
+ try:
51
+ offset = to_offset(rule)
52
+ except ValueError:
53
+ return "rows"
54
+ if offset.n != 1:
55
+ return "rows"
56
+ return _PERIOD_WORDS.get(type(offset).__name__, "rows")
57
+
58
+
11
59
  # ╔═══════════════════════════════════════════════════════════════════════════╗
12
60
  # ║ SERIES RESOLVER ║
13
61
  # ╚═══════════════════════════════════════════════════════════════════════════╝
@@ -108,19 +156,30 @@ def available(category: str | None = None) -> None:
108
156
  print()
109
157
 
110
158
 
159
+ def _words(text: str) -> list[str]:
160
+ """Lowercase words of *text*, with underscores read as spaces.
161
+
162
+ Friendly names are underscore_joined (``Headline_CPI``), so a reader
163
+ typing "headline cpi" has to land on them.
164
+ """
165
+ return text.lower().replace("_", " ").split()
166
+
167
+
111
168
  def search(keyword: str) -> list[str]:
112
169
  """Search the catalog by keyword, case-insensitive.
113
170
 
114
171
  Matches the FRED series id AND the friendly name, so both
115
- `search("CPIAUCSL")` and `search("inflation")` work.
172
+ `search("CPIAUCSL")` and `search("inflation")` work. Spaces and
173
+ underscores are the same thing, and a multi-word query matches only
174
+ series carrying every word, so `search("headline cpi")` works too.
116
175
 
117
176
  Returns the list of matching series ids.
118
177
  """
119
- kw = keyword.lower()
178
+ words = _words(keyword)
120
179
  hits = [
121
180
  series_id
122
181
  for series_id, (name, _freq) in ALL_SERIES.items()
123
- if kw in series_id.lower() or kw in name.lower()
182
+ if all(w in " ".join(_words(f"{series_id} {name}")) for w in words)
124
183
  ]
125
184
 
126
185
  if not hits:
@@ -277,11 +336,11 @@ def load_fred_master(config: Config) -> pd.DataFrame:
277
336
 
278
337
  frames[name] = s
279
338
  agg = "mean" if native_freq in config.MEAN_FREQS else "last"
280
- print(f" ✓ {name:<40s} ({series_id:<18s} {native_freq} → {agg})")
339
+ _say(f" ok {name:<40s} ({series_id:<18s} {native_freq} -> {agg})")
281
340
  time.sleep(0.5) # be nice to the API
282
341
  except Exception as e:
283
342
  failed.append((series_id, name, str(e)))
284
- print(f" ✗ {name:<40s} ({series_id}) — {e}")
343
+ _say(f" FAIL {name:<40s} ({series_id}): {e}")
285
344
  time.sleep(0.5) # be nice to the API
286
345
 
287
346
  df = pd.DataFrame(frames)
@@ -289,12 +348,13 @@ def load_fred_master(config: Config) -> pd.DataFrame:
289
348
  df.index.name = "date"
290
349
 
291
350
  if failed:
292
- print(f"\n⚠ {len(failed)} series failed:")
351
+ _say(f"\n! {len(failed)} series failed:")
293
352
  for sid, nm, err in failed:
294
- print(f" {nm} ({sid}): {err}")
353
+ _say(f" {nm} ({sid}): {err}")
295
354
 
296
- print(
297
- f"\nLoaded {len(frames)} series | {df.shape[0]} weeks | {df.columns.size} columns"
355
+ period = _period_word(config.RESAMPLE_RULE)
356
+ _say(
357
+ f"\nLoaded {len(frames)} series | {df.shape[0]} {period} | {df.columns.size} columns"
298
358
  )
299
359
  return df
300
360
 
@@ -1,9 +1,21 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fred-loader
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: A Python wrapper around the Federal Reserve FRED API that abstracts away series IDs, resampling rules, and macro scoring behind a clean, human-readable interface.
5
5
  Author: Ian Solberg
6
6
  License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/iasolb/fred-loader
8
+ Project-URL: Repository, https://github.com/iasolb/fred-loader
9
+ Project-URL: Issues, https://github.com/iasolb/fred-loader/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering
7
19
  Classifier: Typing :: Typed
8
20
  Requires-Python: >=3.10
9
21
  Description-Content-Type: text/markdown
@@ -14,5 +14,7 @@ src/fred_loader.egg-info/requires.txt
14
14
  src/fred_loader.egg-info/top_level.txt
15
15
  tests/test_discovery_and_resolver.py
16
16
  tests/test_macro_scores.py
17
+ tests/test_output_encoding.py
18
+ tests/test_package_metadata.py
17
19
  tests/test_pull_loop.py
18
20
  tests/test_typed.py
@@ -141,6 +141,25 @@ def test_search_returns_empty_and_says_so_when_nothing_matches(capsys):
141
141
  assert "No series matching" in capsys.readouterr().out
142
142
 
143
143
 
144
+ def test_search_reads_spaces_as_underscores(capsys):
145
+ """Friendly names are underscore_joined, so the words a reader types must
146
+ still find them: "headline cpi" returned nothing until 2026-10-17."""
147
+ hits = search("headline cpi")
148
+ capsys.readouterr()
149
+ assert "CPIAUCSL" in hits
150
+
151
+
152
+ def test_search_with_several_words_needs_every_word_in_any_order(capsys):
153
+ hits = search("cpi headline")
154
+ narrower_than = search("cpi")
155
+ capsys.readouterr()
156
+ assert "CPIAUCSL" in hits
157
+ for series_id in hits:
158
+ words = f"{series_id} {ALL_SERIES[series_id][0]}".lower().replace("_", " ")
159
+ assert "cpi" in words and "headline" in words
160
+ assert set(hits) < set(narrower_than)
161
+
162
+
144
163
  def test_info_reports_the_series_with_its_frequency_and_lineage(capsys):
145
164
  info("CPIAUCSL")
146
165
  out = capsys.readouterr().out
@@ -0,0 +1,84 @@
1
+ """Printed output must never crash a stream that is not UTF-8.
2
+
3
+ A Windows script whose stdout is piped or redirected writes cp1252. The pull
4
+ loop printed check marks and arrows there and raised UnicodeEncodeError inside
5
+ pull_all after the data had already arrived, so a successful pull read as a
6
+ crash (2026-10-17). Each test runs its call under a strict cp1252 stdout,
7
+ which is what that script sees. The swap happens inside the test body because
8
+ pytest re-installs its own capture stream after fixture setup.
9
+
10
+ Offline: the client is faked by patching ``fred_loader.utils.Fred``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ from contextlib import contextmanager, redirect_stdout
16
+ from unittest.mock import MagicMock, patch
17
+
18
+ import pandas as pd
19
+
20
+ import fred_loader.utils as utils
21
+ from fred_loader.load import pull_fred
22
+ from fred_loader.utils import Config, available
23
+
24
+
25
+ class _Cp1252Out:
26
+ def __init__(self):
27
+ self.raw = io.BytesIO()
28
+ self.stream = io.TextIOWrapper(
29
+ self.raw, encoding="cp1252", errors="strict", newline="\n"
30
+ )
31
+
32
+ def text(self) -> str:
33
+ self.stream.flush()
34
+ return self.raw.getvalue().decode("cp1252")
35
+
36
+
37
+ @contextmanager
38
+ def cp1252_stdout():
39
+ out = _Cp1252Out()
40
+ with redirect_stdout(out.stream):
41
+ yield out
42
+
43
+
44
+ def test_say_degrades_a_character_the_stream_cannot_encode():
45
+ with cp1252_stdout() as out:
46
+ utils._say("before ✓ after")
47
+ assert out.text() == "before ? after\n"
48
+
49
+
50
+ def test_a_pull_with_a_failure_prints_cleanly_to_cp1252(tmp_path):
51
+ def _get(series_id, observation_start=None):
52
+ if series_id == "BAD_ID":
53
+ raise RuntimeError("upstream said → no")
54
+ return pd.Series(
55
+ [1.0, 2.0, 3.0],
56
+ index=pd.DatetimeIndex(pd.date_range("2020-01-01", periods=3, freq="MS")),
57
+ )
58
+
59
+ mock_fred = MagicMock()
60
+ mock_fred.get_series.side_effect = _get
61
+ cfg = Config(
62
+ filename="enc.csv",
63
+ output_path=tmp_path,
64
+ start="2020-01-01",
65
+ series={"GOOD_ID": ("Good Series", "M"), "BAD_ID": ("Bad Series", "M")},
66
+ )
67
+
68
+ with patch("fred_loader.utils.Fred", return_value=mock_fred), \
69
+ patch("fred_loader.utils.time.sleep"), \
70
+ cp1252_stdout() as out:
71
+ df = pull_fred(cfg, apply_scores=False)
72
+
73
+ assert list(df.columns) == ["Good Series"]
74
+ text = out.text()
75
+ assert "ok Good Series" in text
76
+ assert "FAIL Bad Series" in text
77
+ assert "upstream said ? no" in text
78
+ assert "Saved -> " in text
79
+
80
+
81
+ def test_available_prints_cleanly_to_cp1252():
82
+ with cp1252_stdout() as out:
83
+ available()
84
+ assert "TOTAL" in out.text()
@@ -0,0 +1,20 @@
1
+ """What a stranger sees on PyPI, pinned so it cannot quietly regress.
2
+
3
+ Read as text, not with tomllib: the suite runs on Python 3.10, which has none.
4
+ """
5
+
6
+ from pathlib import Path
7
+
8
+ PYPROJECT = (Path(__file__).resolve().parent.parent / "pyproject.toml").read_text(
9
+ encoding="utf-8"
10
+ )
11
+
12
+
13
+ def test_the_pypi_page_links_back_to_the_repo():
14
+ assert "[project.urls]" in PYPROJECT
15
+ assert 'Repository = "https://github.com/iasolb/fred-loader"' in PYPROJECT
16
+
17
+
18
+ def test_the_pypi_page_names_the_supported_pythons():
19
+ for version in ("3.10", "3.11", "3.12"):
20
+ assert f'"Programming Language :: Python :: {version}"' in PYPROJECT
@@ -276,6 +276,27 @@ def test_resample_rule_honoured(tmp_path):
276
276
  assert len(df_w) >= len(df_m)
277
277
 
278
278
 
279
+ # ---------------------------------------------------------------------------
280
+ # Test 8b: the summary line names the period the rule actually produced
281
+ # ---------------------------------------------------------------------------
282
+
283
+ @pytest.mark.parametrize(
284
+ "rule, word",
285
+ [("W-FRI", "weeks"), ("ME", "months"), ("QE", "quarters"), ("YE", "years"), ("2W", "rows")],
286
+ )
287
+ def test_summary_line_names_the_real_period(tmp_path, capsys, rule, word):
288
+ """It said "weeks" whatever the rule: a monthly pull printed "56 weeks"
289
+ (2026-10-17)."""
290
+ cfg = _config(tmp_path, resample_rule=rule)
291
+ mock_fred = _make_fred_mock(_fake_get_series())
292
+
293
+ with patch("fred_loader.utils.Fred", return_value=mock_fred):
294
+ df = load_fred_master(cfg)
295
+
296
+ summary = [ln for ln in capsys.readouterr().out.splitlines() if ln.startswith("Loaded ")]
297
+ assert summary == [f"Loaded 2 series | {len(df)} {word} | 2 columns"]
298
+
299
+
279
300
  # ---------------------------------------------------------------------------
280
301
  # Test 9: pull_fred end-to-end — writes CSV, returns frame; apply_scores paths
281
302
  # ---------------------------------------------------------------------------
File without changes
File without changes
File without changes