bedrok 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.
Files changed (68) hide show
  1. bedrok/__init__.py +22 -0
  2. bedrok/__main__.py +75 -0
  3. bedrok/core/__init__.py +5 -0
  4. bedrok/core/aliases.py +25 -0
  5. bedrok/core/charting.py +176 -0
  6. bedrok/core/config.py +228 -0
  7. bedrok/core/geography.py +97 -0
  8. bedrok/core/licensing.py +152 -0
  9. bedrok/core/query.py +47 -0
  10. bedrok/core/schema.py +46 -0
  11. bedrok/core/sources.py +84 -0
  12. bedrok/core/store.py +233 -0
  13. bedrok/core/units.py +64 -0
  14. bedrok/core/warnings.py +139 -0
  15. bedrok/engines/__init__.py +0 -0
  16. bedrok/engines/pctile.py +104 -0
  17. bedrok/engines/rates_infl.py +91 -0
  18. bedrok/ingest/__init__.py +4 -0
  19. bedrok/ingest/adapters/__init__.py +0 -0
  20. bedrok/ingest/adapters/_util.py +92 -0
  21. bedrok/ingest/adapters/bea.py +88 -0
  22. bedrok/ingest/adapters/bls.py +115 -0
  23. bedrok/ingest/adapters/boc.py +41 -0
  24. bedrok/ingest/adapters/boe.py +85 -0
  25. bedrok/ingest/adapters/census.py +81 -0
  26. bedrok/ingest/adapters/cftc.py +139 -0
  27. bedrok/ingest/adapters/destatis.py +107 -0
  28. bedrok/ingest/adapters/eia.py +51 -0
  29. bedrok/ingest/adapters/estat.py +73 -0
  30. bedrok/ingest/adapters/eurostat.py +112 -0
  31. bedrok/ingest/adapters/fedboard.py +139 -0
  32. bedrok/ingest/adapters/fedfiles.py +259 -0
  33. bedrok/ingest/adapters/fred.py +32 -0
  34. bedrok/ingest/adapters/ine.py +63 -0
  35. bedrok/ingest/adapters/mof_jp.py +64 -0
  36. bedrok/ingest/adapters/nber.py +46 -0
  37. bedrok/ingest/adapters/nyfed.py +118 -0
  38. bedrok/ingest/adapters/ons.py +78 -0
  39. bedrok/ingest/adapters/pxweb.py +91 -0
  40. bedrok/ingest/adapters/sdmx.py +143 -0
  41. bedrok/ingest/adapters/statcan.py +53 -0
  42. bedrok/ingest/adapters/worldbank.py +127 -0
  43. bedrok/ingest/backfill.py +74 -0
  44. bedrok/ingest/derived.py +225 -0
  45. bedrok/ingest/live.py +38 -0
  46. bedrok/ingest/pipeline.py +820 -0
  47. bedrok/ingest/refresh.py +73 -0
  48. bedrok/ingest/transforms.py +228 -0
  49. bedrok/ops/__init__.py +4 -0
  50. bedrok/ops/docs.py +219 -0
  51. bedrok/ops/doctor.py +142 -0
  52. bedrok/ops/freshness.py +69 -0
  53. bedrok/ops/smoke.py +108 -0
  54. bedrok/ops/tieout.py +201 -0
  55. bedrok/ops/validate.py +172 -0
  56. bedrok/seeds/charting.md +70 -0
  57. bedrok/seeds/gaps.md +51 -0
  58. bedrok/seeds/macro_catalog.csv +199 -0
  59. bedrok/seeds/sources.csv +32 -0
  60. bedrok/seeds/warnings.csv +44 -0
  61. bedrok/surfaces/__init__.py +5 -0
  62. bedrok/surfaces/mcp_server.py +950 -0
  63. bedrok-0.2.0.dist-info/METADATA +113 -0
  64. bedrok-0.2.0.dist-info/RECORD +68 -0
  65. bedrok-0.2.0.dist-info/WHEEL +5 -0
  66. bedrok-0.2.0.dist-info/entry_points.txt +2 -0
  67. bedrok-0.2.0.dist-info/licenses/LICENSE +21 -0
  68. bedrok-0.2.0.dist-info/top_level.txt +1 -0
bedrok/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Bedrok — a local store of public macroeconomic and market data, for analysis
2
+ from inside an AI assistant.
3
+
4
+ The package is layered, and the direction of dependency is one way:
5
+
6
+ surfaces -> ops -> engines -> ingest -> core
7
+
8
+ core/ configuration, the store, and shared vocabulary
9
+ ingest/ source adapters and the sync drivers
10
+ engines/ the analysis maths — percentiles, rates
11
+ ops/ the audit reports and the connectivity check
12
+ surfaces/ things that render the store for a human or an assistant
13
+
14
+ `config` and `store` are re-exported here because they are the documented
15
+ embedding entry point — a sandbox session does `from bedrok import config;
16
+ config.set_profile(...)`, and that recipe is written down in
17
+ docs/engineering/notes.md. Those two names do not move. Everything else should
18
+ be imported from its real home so the layer it belongs to stays visible.
19
+ """
20
+ from bedrok.core import config, store # noqa: F401
21
+
22
+ __all__ = ["config", "store"]
bedrok/__main__.py ADDED
@@ -0,0 +1,75 @@
1
+ """Bedrok CLI — one entry point over the store and the engines.
2
+
3
+ bedrok [--profile PATH] <command> [command args...]
4
+
5
+ Commands:
6
+ smoke connectivity check, one call per data source
7
+ backfill full deep historical pull (idempotent) [--only ids --stage S]
8
+ refresh incremental sync of the local store
9
+ validate coverage + trust audit (staleness, licence)
10
+ query read-only SQL over the store "SELECT ..." [--csv]
11
+ tieout compare re-pointed series with the store [--only ids]
12
+ doctor store + freshness report (offline; run this first)
13
+ schema the store's table/column layout
14
+ pctile percentile of a series' latest value SERIES_ID [--value V --since D]
15
+ rates EUR-investor rates/inflation toolkit [--csv]
16
+ serve MCP server over stdio, for Claude and other assistants
17
+ docs regenerate docs/SOURCES.md and docs/LICENSES.md [--check]
18
+
19
+ The profile (user config + data) resolves from --profile, $BEDROK_PROFILE, or
20
+ ./profile. See docs/SETUP.md.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import sys
25
+
26
+ _COMMANDS = {
27
+ "smoke": "bedrok.ops.smoke",
28
+ "backfill": "bedrok.ingest.backfill",
29
+ "refresh": "bedrok.ingest.refresh",
30
+ "validate": "bedrok.ops.validate",
31
+ "query": "bedrok.core.query",
32
+ "tieout": "bedrok.ops.tieout",
33
+ "doctor": "bedrok.ops.doctor",
34
+ "schema": "bedrok.core.schema",
35
+ "pctile": "bedrok.engines.pctile",
36
+ "rates": "bedrok.engines.rates_infl",
37
+ "serve": "bedrok.surfaces.mcp_server",
38
+ "docs": "bedrok.ops.docs",
39
+ }
40
+
41
+
42
+ def main() -> int:
43
+ argv = sys.argv[1:]
44
+
45
+ profile = None
46
+ if "--profile" in argv:
47
+ i = argv.index("--profile")
48
+ try:
49
+ profile = argv[i + 1]
50
+ except IndexError:
51
+ print("--profile requires a path", file=sys.stderr)
52
+ return 2
53
+ argv = argv[:i] + argv[i + 2:]
54
+
55
+ if not argv or argv[0] in ("-h", "--help", "help"):
56
+ print(__doc__)
57
+ return 0
58
+ cmd, rest = argv[0], argv[1:]
59
+ if cmd not in _COMMANDS:
60
+ print(f"unknown command {cmd!r}\n", file=sys.stderr)
61
+ print(__doc__)
62
+ return 2
63
+
64
+ from bedrok.core import config
65
+ if profile:
66
+ config.set_profile(profile)
67
+
68
+ import importlib
69
+ mod = importlib.import_module(_COMMANDS[cmd])
70
+ sys.argv = [f"bedrok {cmd}"] + rest
71
+ return mod.main()
72
+
73
+
74
+ if __name__ == "__main__":
75
+ sys.exit(main())
@@ -0,0 +1,5 @@
1
+ """Foundations: configuration, the store, and vocabulary shared by everything.
2
+
3
+ Nothing here imports from ingest, engines, ops or surfaces. If something in this
4
+ package needs one of those, it is in the wrong package.
5
+ """
bedrok/core/aliases.py ADDED
@@ -0,0 +1,25 @@
1
+ """One id per concept: the ids that point at another.
2
+
3
+ The bulk families name a series by family and geography (`hicp_yoy_es`); the
4
+ seed names the most-asked ones by geography first (`es_hicp_yoy`), the
5
+ convention every instruction teaches. Where both exist they are the same
6
+ number from the same dataset, so the family id becomes an alias: it still
7
+ answers, with `alias_of` naming the canonical id, but it is neither stored nor
8
+ listed (session 11, after the feedback session found the duplicates).
9
+
10
+ Pure data, in core: nothing here imports a layer above.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ ALIASES: dict[str, str] = {
15
+ "hicp_yoy_ea": "ea_hicp_yoy",
16
+ "hicp_yoy_es": "es_hicp_yoy",
17
+ "hicp_yoy_de": "de_hicp_yoy",
18
+ "hicp_yoy_fr": "fr_hicp_yoy",
19
+ "gdp_qoq_ea20": "ea_gdp_qoq",
20
+ "gdp_qoq_es": "es_gdp_qoq",
21
+ }
22
+
23
+
24
+ def canonical(series_id: str) -> str:
25
+ return ALIASES.get(series_id, series_id)
@@ -0,0 +1,176 @@
1
+ """The chart recipe: what every Bedrok chart says, whoever draws it.
2
+
3
+ Bedrok does not draw. The assistant in the host does, with whatever chart tool
4
+ that host has, so two charts drawn in two places used to look like two
5
+ products. What Bedrok can fix is the content: the same title rules, the same
6
+ subtitle carrying the units, the same axes, colours, labels, source line and
7
+ footnotes, every time. The prose version is bedrok/seeds/charting.md
8
+ (bedrok://charting); this module is the arithmetic of it.
9
+
10
+ The house decisions (2026-10-11): two vertical scales when units differ; units
11
+ in the subtitle and on the vertical axis, never in the title; a chart by
12
+ default, a table only when asked; short answers.
13
+
14
+ Pure data, in core: nothing here imports a layer above.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from datetime import date
20
+
21
+ # Okabe–Ito, without its yellow (unreadable on white): seven hues that stay
22
+ # distinct for the common forms of colour blindness. Assigned in the order the
23
+ # series were asked for, so a series keeps its colour across charts.
24
+ PALETTE = ["#0072B2", "#E69F00", "#009E73", "#D55E00", "#56B4E9", "#CC79A7", "#7F7F7F"]
25
+
26
+ # The house style, repeated on every recipe so a host that never read
27
+ # bedrok://charting still draws it the same way.
28
+ LAYOUT = {
29
+ "container": "a card with a border",
30
+ "title": "top, bold; never carries units",
31
+ "subtitle": "under the title; carries the units and how the data is measured",
32
+ "legend": "below the chart; clicking a series hides or shows it",
33
+ "source": "one line under the legend",
34
+ "footnotes": "under the source line, smaller",
35
+ "toggle": "a switch between the chart and a table of the same numbers",
36
+ "y_axis": "labelled with the units; left scale solid lines, right scale dashed",
37
+ "x_axis": "the labels given, every `tick_every`-th one shown",
38
+ "gaps": "leave empty: never draw a line across a null",
39
+ "type": "lines; bars only when every series has a single period",
40
+ }
41
+
42
+ _HOW_WORDS = {
43
+ ("weekly", "last"): "weekly closes", ("weekly", "mean"): "weekly averages",
44
+ ("weekly", "sum"): "weekly totals", ("weekly", "first"): "first value of each week",
45
+ ("monthly", "mean"): "monthly averages", ("monthly", "last"): "month-end values",
46
+ ("monthly", "sum"): "monthly totals", ("monthly", "first"): "first value of each month",
47
+ ("quarterly", "mean"): "quarterly averages", ("quarterly", "last"): "quarter-end values",
48
+ ("quarterly", "sum"): "quarterly totals", ("quarterly", "first"): "first value of each quarter",
49
+ ("annual", "mean"): "annual averages", ("annual", "last"): "year-end values",
50
+ ("annual", "sum"): "annual totals", ("annual", "first"): "first value of each year",
51
+ }
52
+ _MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
53
+ MAX_TICKS = 10
54
+
55
+
56
+ def colour(i: int) -> str:
57
+ return PALETTE[i % len(PALETTE)]
58
+
59
+
60
+ def title_suggestion(names: list[str]) -> str:
61
+ """One series: its name. Two: both. More: the first and a count. The
62
+ assistant may always replace it; it never carries units."""
63
+ if not names:
64
+ return ""
65
+ if len(names) == 1:
66
+ return names[0]
67
+ if len(names) == 2:
68
+ return f"{names[0]} and {names[1]}"
69
+ return f"{names[0]} and {len(names) - 1} others"
70
+
71
+
72
+ def axes(series: list[tuple[str, str]]) -> dict:
73
+ """[(id, units)] → which series go on the left scale and which on the right.
74
+
75
+ One unit: one scale. Two: the first-listed series' units on the left, the
76
+ other on the right. Three or more cannot be read on two scales: the first
77
+ two groups are placed and `problem` says what to do instead."""
78
+ groups: list[str] = []
79
+ for _, u in series:
80
+ if u not in groups:
81
+ groups.append(u)
82
+ left = [i for i, u in series if u == groups[0]] if groups else []
83
+ right = [i for i, u in series if len(groups) > 1 and u == groups[1]]
84
+ out = {"left": {"units": groups[0] if groups else None, "series": left},
85
+ "right": {"units": groups[1], "series": right} if len(groups) > 1 else None,
86
+ "problem": None}
87
+ if len(groups) > 2:
88
+ dropped = [i for i, u in series if u in groups[2:]]
89
+ out["problem"] = (f"{len(groups)} different units cannot share two scales; "
90
+ f"{', '.join(dropped)} are not placed. Draw two charts, or ask "
91
+ "for transform='index_to' so every series is an index.")
92
+ return out
93
+
94
+
95
+ def _span_label(d: date, frequency: str) -> str:
96
+ if frequency == "quarterly":
97
+ return f"Q{(d.month - 1) // 3 + 1} {d.year}"
98
+ if frequency == "annual":
99
+ return str(d.year)
100
+ return f"{_MONTHS[d.month - 1]} {d.year}"
101
+
102
+
103
+ def subtitle(placed: dict, frequency: str, how: str | None, first: date | None,
104
+ last: date | None) -> str:
105
+ """Units first (per scale when there are two), then how the data is
106
+ measured, then the span: "Percent; weekly closes; Oct 2023 to Oct 2026"."""
107
+ def cap(u: str | None) -> str:
108
+ return (u[:1].upper() + u[1:]) if u else ""
109
+ if placed.get("right"):
110
+ units = f"Left: {placed['left']['units']}. Right: {placed['right']['units']}"
111
+ else:
112
+ units = cap(placed["left"]["units"])
113
+ parts = [units]
114
+ parts.append(_HOW_WORDS.get((frequency, how or ""), frequency))
115
+ if first and last:
116
+ parts.append(f"{_span_label(first, frequency)} to {_span_label(last, frequency)}")
117
+ return "; ".join(p for p in parts if p)
118
+
119
+
120
+ def x_labels(dates: list[date], frequency: str) -> dict:
121
+ """Labels by frequency, and how many to skip so about ten show."""
122
+ def one(d: date) -> str:
123
+ if frequency == "weekly":
124
+ return f"{d.day} {_MONTHS[d.month - 1]} {d.year}"
125
+ if frequency == "monthly":
126
+ return f"{_MONTHS[d.month - 1]} {str(d.year)[2:]}"
127
+ if frequency == "quarterly":
128
+ return f"Q{(d.month - 1) // 3 + 1} {str(d.year)[2:]}"
129
+ return str(d.year)
130
+ n = len(dates)
131
+ return {"labels": [one(d) for d in dates],
132
+ "tick_every": max(1, math.ceil(n / MAX_TICKS))}
133
+
134
+
135
+ def source_line(publishers: list[tuple[str, bool, bool]]) -> str:
136
+ """[(publisher name, personal use?, fetched live?)] → one line, each
137
+ publisher once, in order, with its condition when it has one."""
138
+ seen, parts = set(), []
139
+ for name, personal, live in publishers:
140
+ if name in seen:
141
+ continue
142
+ seen.add(name)
143
+ cond = [c for c, on in (("personal use only", personal), ("fetched live, not stored", live)) if on]
144
+ parts.append(name + (f" ({', '.join(cond)})" if cond else ""))
145
+ return "Source: " + "; ".join(parts) + "." if parts else ""
146
+
147
+
148
+ def footnotes(entries: list[dict], table: dict, frequency: str) -> list[str]:
149
+ """Everything the chart cannot say by itself, one short line each: the
150
+ loud warnings for the period shown, periods not over or missing a part,
151
+ a cut, and series drawn as steps."""
152
+ notes: list[str] = []
153
+ names = {e["series_id"]: e.get("name", e["series_id"]) for e in entries}
154
+ for e in entries:
155
+ for w in e.get("warnings") or []:
156
+ period = w.get("period_start") or ""
157
+ if w.get("period_end"):
158
+ period += f" to {w['period_end']}"
159
+ # The whole effect: warning texts are kept short in the seed, and a
160
+ # cut at the first ". " breaks on abbreviations ("incl.").
161
+ line = f"{names[e['series_id']]}, {period}: {(w.get('effect_on_data') or '').strip()}"
162
+ if line not in notes:
163
+ notes.append(line)
164
+ for sid, stepped in (table.get("stepped") or {}).items():
165
+ if stepped:
166
+ notes.append(f"{names.get(sid, sid)} is published less often than {frequency}; "
167
+ "it is drawn as a step, each value held until the next.")
168
+ for sid, partial in (table.get("partial_last") or {}).items():
169
+ if partial:
170
+ notes.append(f"{names.get(sid, sid)}: the latest period is not over yet.")
171
+ for sid, short in (table.get("incomplete") or {}).items():
172
+ notes.append(f"{names.get(sid, sid)}: {', '.join(short)} hold only part of their period "
173
+ "(a month or quarter not published).")
174
+ if table.get("cut"):
175
+ notes.append(table["cut"])
176
+ return notes
bedrok/core/config.py ADDED
@@ -0,0 +1,228 @@
1
+ """Bedrok configuration: profile resolution, profile.toml parsing, DB connection.
2
+
3
+ A *profile* (the Bedrok home folder) holds everything user-specific:
4
+
5
+ <home>/
6
+ ├── profile.toml # the config parsed here (all keys optional)
7
+ ├── data/market.duckdb # the store — one file; $BEDROK_DB overrides the path
8
+ └── .env # API keys, all optional (chmod 600); which source
9
+ # reads which: bedrok/seeds/sources.csv
10
+
11
+ The catalog and the source list are not in the profile: they ship inside the
12
+ package, in bedrok/seeds/ (SEEDS_DIR), so an installed wheel has them too.
13
+
14
+ Resolution order for the home folder: explicit `set_profile()` (CLI --profile)
15
+ → $BEDROK_PROFILE (what the Claude Desktop bundle's folder field sets) →
16
+ legacy $FDP_ROOT → ./profile under the current directory → the per-user data
17
+ folder (~/Library/Application Support/bedrok on macOS, ~/.local/share/bedrok
18
+ on Linux). There is always a home, so a fresh install needs no setup: the
19
+ first `bedrok serve` creates the folder and the store. The DB path may be
20
+ overridden independently via $BEDROK_DB (legacy $FDP_DB).
21
+
22
+ An environment value that is an unfilled placeholder — `${user_config.x}`,
23
+ which a Claude Desktop bundle may pass for a blank optional field — counts as
24
+ unset, here and for API keys (core/sources.py).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import importlib.resources
29
+ import os
30
+ from pathlib import Path
31
+
32
+ try:
33
+ import tomllib # stdlib from 3.11
34
+ except ModuleNotFoundError: # 3.10 sandboxes (Cowork's system python) need the backport
35
+ import tomli as tomllib
36
+
37
+ import duckdb
38
+
39
+ try:
40
+ from dotenv import load_dotenv
41
+ except ImportError: # read-only runs don't need python-dotenv
42
+ def load_dotenv(*_a, **_k):
43
+ return False
44
+
45
+
46
+ # ---- defaults (a missing/empty profile.toml gets exactly these) -------------
47
+ # Sections a profile.toml carries that are not listed here pass through _merge
48
+ # untouched and are simply never read — a [market] block from the days of the
49
+ # pack, or the portfolio-era [broker]/[sleeves]/[strategy], load without error.
50
+ _DEFAULTS = {
51
+ "owner": {"name": "Investor"},
52
+ "base": {"currency": "EUR", "symbol": "€"},
53
+ "macro": {"hicp_europe": True},
54
+ }
55
+
56
+
57
+ def _merge(base: dict, over: dict) -> dict:
58
+ out = dict(base)
59
+ for k, v in over.items():
60
+ out[k] = _merge(base[k], v) if isinstance(v, dict) and isinstance(base.get(k), dict) else v
61
+ return out
62
+
63
+
64
+ # The seeds ship inside the package and are found through the package itself,
65
+ # never by counting ".parent" off a file: that once repointed every seed path
66
+ # when config.py moved into core/, and an installed wheel has no repo root at all.
67
+ SEEDS_DIR = Path(str(importlib.resources.files("bedrok") / "seeds"))
68
+
69
+
70
+ def unset(value: str | None) -> bool:
71
+ """True for a missing, blank, or unfilled-placeholder (`${...}`) value."""
72
+ v = (value or "").strip()
73
+ return not v or (v.startswith("${") and v.endswith("}"))
74
+
75
+
76
+ def default_home() -> Path:
77
+ """The per-user data folder a fresh install uses."""
78
+ import platformdirs
79
+ return Path(platformdirs.user_data_dir("bedrok", appauthor=False))
80
+
81
+
82
+ def _resolve_default_dir() -> Path:
83
+ for env in ("BEDROK_PROFILE", "FDP_ROOT"):
84
+ if not unset(os.getenv(env)):
85
+ return Path(os.getenv(env).strip()).expanduser()
86
+ cwd_profile = Path.cwd() / "profile"
87
+ if cwd_profile.is_dir():
88
+ return cwd_profile
89
+ return default_home()
90
+
91
+
92
+ # ---- module state (recomputed by set_profile) -------------------------------
93
+ PROFILE_DIR: Path | None = None
94
+ PROFILE: dict = dict(_DEFAULTS)
95
+ ROOT: Path | None = None
96
+ DATA_DIR: Path | None = None
97
+ DB_PATH: Path | None = None
98
+ BASE_CCY = "EUR"
99
+ BASE_SYMBOL = "€"
100
+
101
+
102
+
103
+ def fx_pairs() -> list[tuple[str, str, str]]:
104
+ """(pair, source, code) list for sync_fx, derived from the base currency.
105
+
106
+ ECB reference rates are EUR-based; non-EUR bases get 'derived' cross pairs
107
+ computed from them at sync time (best-effort — EUR is the fully-tested path).
108
+ """
109
+ # The engines look up f"{BASE_CCY}{ccy}", so the store carries the three
110
+ # fetchable pairs plus their reciprocals; EUR, GBP and USD bases all resolve.
111
+ fetchable = [
112
+ ("EURUSD", "ecb", "EXR/D.USD.EUR.SP00.A"),
113
+ ("EURGBP", "ecb", "EXR/D.GBP.EUR.SP00.A"),
114
+ ("GBPUSD", "fedboard", "H10:RXI$US_N.B.UK"), # FRED's DEXUSUK is this H.10 series
115
+ ]
116
+ derived = [
117
+ ("USDEUR", "derived", "1/EURUSD"),
118
+ ("GBPEUR", "derived", "1/EURGBP"),
119
+ ("USDGBP", "derived", "1/GBPUSD"),
120
+ ]
121
+ if BASE_CCY in ("EUR", "USD", "GBP"):
122
+ return fetchable + derived
123
+ raise SystemExit(
124
+ f"base currency {BASE_CCY!r} not supported yet "
125
+ "(EUR/USD/GBP ship as a full cross set; others need new pairs)")
126
+
127
+
128
+ def set_profile(path: str | Path | None = None) -> None:
129
+ """Load (or reload) the profile and recompute all module paths/settings."""
130
+ global PROFILE_DIR, PROFILE, ROOT, DATA_DIR, DB_PATH
131
+ global BASE_CCY, BASE_SYMBOL
132
+
133
+ PROFILE_DIR = Path(path) if path else _resolve_default_dir()
134
+ toml_data = {}
135
+ if PROFILE_DIR and (PROFILE_DIR / "profile.toml").exists():
136
+ toml_data = tomllib.loads((PROFILE_DIR / "profile.toml").read_text())
137
+ PROFILE = _merge(_DEFAULTS, toml_data)
138
+
139
+ ROOT = PROFILE_DIR
140
+ DATA_DIR = PROFILE_DIR / "data" if PROFILE_DIR else None
141
+ db_env = next((os.getenv(e) for e in ("BEDROK_DB", "FDP_DB") if not unset(os.getenv(e))), None)
142
+ DB_PATH = Path(db_env) if db_env else (DATA_DIR / "market.duckdb" if DATA_DIR else None)
143
+
144
+ BASE_CCY = PROFILE["base"]["currency"].upper()
145
+ BASE_SYMBOL = PROFILE["base"]["symbol"]
146
+
147
+ # A Claude Desktop bundle sets every key it declares, blank or as the
148
+ # unfilled `${user_config.x}`, and load_dotenv never overrides a variable
149
+ # that exists — so a blank field would hide the same key in the home
150
+ # folder's .env. Clear blank and placeholder keys first, then load .env.
151
+ for name in [k for k, v in os.environ.items() if k.endswith("_API_KEY") and unset(v)]:
152
+ del os.environ[name]
153
+ if PROFILE_DIR:
154
+ load_dotenv(PROFILE_DIR / ".env")
155
+ # Keys stay in the environment: core/sources.py reads the one a source
156
+ # names (`key_env`) at the moment it is needed.
157
+
158
+
159
+ def require_profile() -> Path:
160
+ if PROFILE_DIR is None:
161
+ raise SystemExit(
162
+ "No Bedrok home folder resolved. Pass --profile PATH or set "
163
+ "$BEDROK_PROFILE. See docs/INSTALL.md.")
164
+ return PROFILE_DIR
165
+
166
+
167
+ def require_key(name: str) -> str:
168
+ val = os.getenv(name)
169
+ if not val:
170
+ raise RuntimeError(
171
+ f"{name} missing. Add it to "
172
+ f"{PROFILE_DIR / '.env' if PROFILE_DIR else 'your profile .env'}.")
173
+ return val
174
+
175
+
176
+ def sandbox_copy_recipe() -> str:
177
+ """The copy-store workaround, spelled out with this profile's real path."""
178
+ db = Path(DB_PATH) if DB_PATH else Path("profile/data/market.duckdb")
179
+ return (
180
+ " Writes are impossible against a read-only mount. To work anyway:\n"
181
+ " mkdir -p /tmp/bedrok/data\n"
182
+ f" cp '{db}'* /tmp/bedrok/data/\n"
183
+ f" export BEDROK_DB=/tmp/bedrok/data/{db.name}\n"
184
+ " then re-run. NOTE: everything written to /tmp DIES WITH THE SESSION —\n"
185
+ " replay the refresh Mac-side afterwards."
186
+ )
187
+
188
+
189
+ def _open(path: Path, read_only: bool) -> duckdb.DuckDBPyConnection:
190
+ """duckdb.connect, with the read-only-mount WAL failure made actionable.
191
+
192
+ DuckDB removes the `.wal` when it opens a database. The Cowork mount permits
193
+ write and truncate but NOT unlink, so any `.wal` left behind by an earlier
194
+ command makes every subsequent open fail — the first write of a session
195
+ works and the second does not, which reads as an intermittent fault rather
196
+ than a permission model. `store.close_checkpointed` stops us leaving one;
197
+ this turns somebody else's leftover into instructions instead of a traceback.
198
+ """
199
+ try:
200
+ return duckdb.connect(str(path), read_only=read_only)
201
+ except duckdb.IOException as e:
202
+ msg = str(e)
203
+ if "Could not remove file" not in msg or ".wal" not in msg:
204
+ raise
205
+ raise SystemExit(
206
+ f"Cannot open {path}: a stale write-ahead log is in the way and this "
207
+ f"filesystem does not allow deleting it.\n"
208
+ f" {msg.strip()}\n\n"
209
+ f" This mount allows writing and truncating but not unlinking, so\n"
210
+ f" emptying the file works where removing it does not:\n"
211
+ f" : > '{path}.wal'\n"
212
+ f" then re-run. If that is also refused, copy the store instead:\n"
213
+ f"{sandbox_copy_recipe()}") from e
214
+
215
+
216
+ def connect(read_only: bool = False) -> duckdb.DuckDBPyConnection:
217
+ """Open the profile's one store at DB_PATH."""
218
+ require_profile()
219
+ if DB_PATH is None:
220
+ raise SystemExit("no store path resolved (profile has no data/ dir?)")
221
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
222
+ if read_only and not DB_PATH.exists():
223
+ raise SystemExit(f"no store at {DB_PATH} — run `bedrok backfill` to build it.")
224
+ return _open(DB_PATH, read_only)
225
+
226
+
227
+ # Initialise from environment/cwd at import; the CLI re-calls set_profile(--profile).
228
+ set_profile()
@@ -0,0 +1,97 @@
1
+ """Places: which geography a series is about, and which words name one.
2
+
3
+ One table, used by search (a place in the question filters the catalog rather
4
+ than being scored like any other word), by the warnings search, and by the
5
+ test that every name leads with its geography.
6
+
7
+ Series ids carry their geography in one of two places: first, by convention
8
+ (`es_hicp_yoy`, `us_10y`), or last, for the families the bulk stages build
9
+ (`hicp_core_es`, `gdp_qoq_ea20`, `oecd_cli_deu`). Commodity and positioning
10
+ series have no geography of their own: "us" in `wb_banana_us` is where the
11
+ banana price is quoted, not a country to search by.
12
+
13
+ Pure data, in core: nothing here imports a layer above.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import re
18
+
19
+ # id code → how names begin (the names test) and a readable label.
20
+ CODES = {
21
+ "us": "US", "ea": "Euro area", "eu": "EU", "uk": "UK", "jp": "Japan",
22
+ "cn": "China", "ca": "Canada", "au": "Australia", "ch": "Switzerland",
23
+ "de": "Germany", "fr": "France", "it": "Italy", "es": "Spain",
24
+ "nl": "Netherlands", "be": "Belgium", "at": "Austria", "pt": "Portugal",
25
+ "ie": "Ireland", "el": "Greece", "fi": "Finland", "se": "Sweden",
26
+ "no": "Norway", "dk": "Denmark",
27
+ }
28
+
29
+ # Words a person types for a place → its code. Two-letter country codes that
30
+ # are also common words (it, no, de, es, at, be, ie) are deliberately absent:
31
+ # "it" in a question is a pronoun before it is Italy.
32
+ NAMES = {
33
+ "us": "us", "usa": "us", "america": "us", "american": "us", "united states": "us",
34
+ "uk": "uk", "britain": "uk", "british": "uk", "united kingdom": "uk", "england": "uk",
35
+ "euro area": "ea", "eurozone": "ea", "euro-area": "ea", "ea": "ea",
36
+ "eu": "eu", "european union": "eu",
37
+ "japan": "jp", "japanese": "jp", "china": "cn", "chinese": "cn",
38
+ "canada": "ca", "canadian": "ca", "australia": "au", "australian": "au",
39
+ "switzerland": "ch", "swiss": "ch", "germany": "de", "german": "de",
40
+ "france": "fr", "french": "fr", "italy": "it", "italian": "it",
41
+ "spain": "es", "spanish": "es", "netherlands": "nl", "dutch": "nl",
42
+ "belgium": "be", "belgian": "be", "austria": "at", "austrian": "at",
43
+ "portugal": "pt", "portuguese": "pt", "ireland": "ie", "irish": "ie",
44
+ "greece": "el", "greek": "el", "finland": "fi", "finnish": "fi",
45
+ "sweden": "se", "swedish": "se", "norway": "no", "norwegian": "no",
46
+ "denmark": "dk", "danish": "dk",
47
+ }
48
+
49
+ # Where an id suffix spells a place differently: OECD's ISO-3 codes and
50
+ # Eurostat's euro-area and EU aggregates.
51
+ _SUFFIX = {"usa": "us", "deu": "de", "fra": "fr", "ita": "it", "esp": "es", "gbr": "uk",
52
+ "jpn": "jp", "chn": "cn", "ea20": "ea", "ea21": "ea", "eu27": "eu", "cn": "cn"}
53
+
54
+ # ISO 3166 alpha-2 where it differs from the id code (the warnings carry ISO).
55
+ ISO2 = {**{c: c.upper() for c in CODES}, "uk": "GB", "el": "GR", "ea": "", "eu": ""}
56
+
57
+ # Sources whose series are not about a country.
58
+ _PLACELESS_SOURCES = {"worldbank", "cftc"}
59
+
60
+
61
+ def of_series(series_id: str, source: str | None = None) -> str | None:
62
+ """The geography code a series is about, or None."""
63
+ if source in _PLACELESS_SOURCES:
64
+ return None
65
+ sid = series_id.lower()
66
+ first = sid.split("_", 1)[0]
67
+ if first in CODES:
68
+ return first
69
+ sid = re.sub(r"_eu27_2020$", "_eu27", sid)
70
+ last = sid.rsplit("_", 1)[-1]
71
+ if last in _SUFFIX:
72
+ return _SUFFIX[last]
73
+ return last if last in CODES and "_" in sid else None
74
+
75
+
76
+ def in_text(text: str) -> tuple[set[str], list[str]]:
77
+ """(codes of the places named, the other words), two-word names first."""
78
+ words = re.findall(r"[a-z0-9-]+", text.lower())
79
+ codes, rest, i = set(), [], 0
80
+ while i < len(words):
81
+ pair = " ".join(words[i:i + 2])
82
+ if i + 1 < len(words) and pair in NAMES:
83
+ codes.add(NAMES[pair])
84
+ i += 2
85
+ continue
86
+ if words[i] in NAMES:
87
+ codes.add(NAMES[words[i]])
88
+ else:
89
+ rest.append(words[i])
90
+ i += 1
91
+ return codes, rest
92
+
93
+
94
+ def words_for_iso2(iso2: str) -> set[str]:
95
+ """Every single word in NAMES that names the country with this ISO code."""
96
+ codes = {c for c, iso in ISO2.items() if iso and iso == (iso2 or "").upper()}
97
+ return {w for w, c in NAMES.items() if c in codes and " " not in w}