betteroff 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.
betteroff/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """Betteroff: clean data and charts from the Michigan consumer surveys.
2
+
3
+ Independent project; not affiliated with or endorsed by the University of Michigan.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ try:
11
+ from ._version import __version__
12
+ except ImportError: # running from a source checkout without a build
13
+ from importlib.metadata import PackageNotFoundError, version
14
+
15
+ try:
16
+ __version__ = version('betteroff')
17
+ except PackageNotFoundError:
18
+ __version__ = '0.0.0+unknown'
19
+
20
+ if TYPE_CHECKING:
21
+ from .api import BetteroffWarning, describe, get, list_questions, search
22
+ from .catalog import Question
23
+ from .models import Dataset
24
+
25
+ __all__ = [
26
+ 'BetteroffWarning',
27
+ 'Dataset',
28
+ 'Question',
29
+ '__version__',
30
+ 'describe',
31
+ 'get',
32
+ 'list_questions',
33
+ 'search',
34
+ ]
35
+
36
+ _LAZY = {
37
+ 'BetteroffWarning': 'api',
38
+ 'describe': 'api',
39
+ 'get': 'api',
40
+ 'list_questions': 'api',
41
+ 'search': 'api',
42
+ 'Question': 'catalog',
43
+ 'Dataset': 'models',
44
+ }
45
+
46
+
47
+ def __getattr__(name: str) -> Any:
48
+ # Keeps `import betteroff` and the fast CLI paths free of pandas and HTTPX.
49
+ if name in _LAZY:
50
+ import importlib
51
+
52
+ module = importlib.import_module(f'.{_LAZY[name]}', __name__)
53
+ return getattr(module, name)
54
+ raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
betteroff/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
betteroff/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
betteroff/api.py ADDED
@@ -0,0 +1,280 @@
1
+ """Orchestration shared by the CLI, Python API and recipes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import warnings
7
+ from dataclasses import replace
8
+ from datetime import date
9
+ from typing import Any
10
+
11
+ import pandas as pd
12
+
13
+ from . import __version__
14
+ from .cache import Cache, Snapshot, now_utc
15
+ from .catalog import Question, load_catalog
16
+ from .errors import FetchError, InputError
17
+ from .models import Dataset, check_measure, parse_date
18
+ from .parsers import historical
19
+
20
+ log = logging.getLogger('betteroff')
21
+ METADATA_SCHEMA_VERSION = 1
22
+
23
+
24
+ class BetteroffWarning(UserWarning):
25
+ """A condition that changes what the returned data means."""
26
+
27
+
28
+ def search(text: str) -> list[Question]:
29
+ return load_catalog().search(text)
30
+
31
+
32
+ def list_questions() -> list[Question]:
33
+ return list(load_catalog().questions)
34
+
35
+
36
+ def describe(identifier: str) -> Question:
37
+ return load_catalog().resolve(identifier)
38
+
39
+
40
+ def _period(ts: pd.Timestamp) -> str:
41
+ return f'{ts:%Y-%m}'
42
+
43
+
44
+ def compare_snapshots(old: pd.DataFrame, new: pd.DataFrame, columns: list[str]) -> dict[str, Any]:
45
+ """Describe appended, removed and revised months between two parsed frames."""
46
+ o = old.set_index('date')[columns]
47
+ n = new.set_index('date')[columns]
48
+ appended = n.index.difference(o.index)
49
+ removed = o.index.difference(n.index)
50
+ common = n.index.intersection(o.index)
51
+ a, b = o.loc[common], n.loc[common]
52
+ differs = ~((a == b) | (a.isna() & b.isna()))
53
+ changed = differs.any(axis=1)
54
+ return {
55
+ 'appended_months': [_period(d) for d in appended],
56
+ 'removed_months': [_period(d) for d in removed],
57
+ 'changed_months': [_period(d) for d in changed[changed].index],
58
+ 'changed_values': int(differs.to_numpy().sum()),
59
+ }
60
+
61
+
62
+ def _parse_and_validate(content: bytes, question: Question) -> tuple[Any, list[dict]]:
63
+ from .validate import validate
64
+
65
+ parsed = historical.parse(content, question)
66
+ flags = validate(parsed, question)
67
+ return parsed, flags
68
+
69
+
70
+ def _fetch(question: Question, client: Any = None) -> Any:
71
+ from .fetch import download_table
72
+
73
+ return download_table(question.table, load_catalog().landing_url, client=client)
74
+
75
+
76
+ def load(
77
+ question: Question,
78
+ *,
79
+ refresh: bool = False,
80
+ offline: bool = False,
81
+ cache: Cache | None = None,
82
+ client: Any = None,
83
+ ) -> tuple[Any, list[dict], Snapshot, str, dict[str, Any] | None]:
84
+ """Return parsed data, flags, snapshot, retrieval mode and revisions."""
85
+ if refresh and offline:
86
+ raise InputError('--refresh and --offline cannot be used together.', 'Choose one.')
87
+ cache = cache or Cache()
88
+ current = cache.latest(question.id)
89
+
90
+ if offline:
91
+ if current is None:
92
+ raise FetchError(
93
+ f'No saved snapshot for {question.id}.',
94
+ f'Run `betteroff get {question.id}` once while online.',
95
+ )
96
+ parsed, flags = _parse_and_validate(cache.read_bytes(current), question)
97
+ warnings.warn(
98
+ f'Offline: using {question.id} snapshot {current.id} retrieved '
99
+ f'{current.retrieved_at}; latest month {current.latest_period}.',
100
+ BetteroffWarning,
101
+ stacklevel=3,
102
+ )
103
+ return parsed, flags, current, 'offline', None
104
+
105
+ if current is not None and not refresh and current.is_fresh():
106
+ parsed, flags = _parse_and_validate(cache.read_bytes(current), question)
107
+ return parsed, flags, current, 'cached', None
108
+
109
+ log.info('Checking source for table %s (%s)...', question.table, question.id)
110
+ try:
111
+ download = _fetch(question, client)
112
+ except FetchError:
113
+ if refresh or current is None:
114
+ raise
115
+ parsed, flags = _parse_and_validate(cache.read_bytes(current), question)
116
+ warnings.warn(
117
+ f'Could not reach the source; using {question.id} snapshot {current.id} '
118
+ f'last checked {current.checked_at}. Use --refresh to require fresh data.',
119
+ BetteroffWarning,
120
+ stacklevel=3,
121
+ )
122
+ return parsed, flags, current, 'stale', None
123
+
124
+ parsed, flags = _parse_and_validate(download.content, question)
125
+ sha = Cache.digest(download.content)
126
+ stamp = now_utc().isoformat()
127
+ frame = parsed.frame
128
+ snapshot = Snapshot(
129
+ sha256=sha,
130
+ question_id=question.id,
131
+ table=question.table,
132
+ url=download.url,
133
+ source_period=download.source_period,
134
+ retrieved_at=stamp,
135
+ checked_at=stamp,
136
+ first_period=_period(frame['date'].min()),
137
+ latest_period=_period(frame['date'].max()),
138
+ observations=len(frame),
139
+ parser_version=historical.PARSER_VERSION,
140
+ )
141
+ revisions = None
142
+ if current is not None and current.sha256 == sha:
143
+ snapshot = replace(snapshot, retrieved_at=current.retrieved_at)
144
+ log.info(
145
+ 'No change since %s; latest month %s.', current.retrieved_at, snapshot.latest_period
146
+ )
147
+ elif current is not None:
148
+ try:
149
+ old, _ = _parse_and_validate(cache.read_bytes(current), question)
150
+ except Exception: # an unreadable old snapshot shouldn't block a good new one
151
+ old = None
152
+ if old is not None:
153
+ columns = [*(r.id for r in question.responses), 'index']
154
+ revisions = compare_snapshots(old.frame, frame, columns)
155
+ revisions['previous_snapshot'] = current.id
156
+ _report_revisions(question, revisions)
157
+ cache.store(snapshot, download.content)
158
+ return parsed, flags, snapshot, 'fresh', revisions
159
+
160
+
161
+ def _report_revisions(question: Question, rev: dict[str, Any]) -> None:
162
+ if rev['appended_months']:
163
+ log.info(
164
+ '%s: %d new month(s) through %s.',
165
+ question.id,
166
+ len(rev['appended_months']),
167
+ rev['appended_months'][-1],
168
+ )
169
+ else:
170
+ log.info('%s: no new months.', question.id)
171
+ if rev['changed_months'] or rev['removed_months']:
172
+ months = rev['changed_months'] + rev['removed_months']
173
+ shown = ', '.join(months[:6]) + (' ...' if len(months) > 6 else '')
174
+ warnings.warn(
175
+ f'{question.id}: source revised {len(rev["changed_months"])} month(s) and removed '
176
+ f'{len(rev["removed_months"])} since snapshot {rev["previous_snapshot"]} ({shown}).',
177
+ BetteroffWarning,
178
+ stacklevel=4,
179
+ )
180
+
181
+
182
+ def build_metadata(
183
+ question: Question,
184
+ parsed: Any,
185
+ flags: list[dict],
186
+ snapshot: Snapshot,
187
+ mode: str,
188
+ revisions: dict[str, Any] | None,
189
+ ) -> dict[str, Any]:
190
+ catalog = load_catalog()
191
+ quirks = [f for f in flags if f['check'] != 'wording_changed']
192
+ if quirks and mode == 'fresh':
193
+ log.info(
194
+ '%s: %d source quirk(s) flagged in metadata (quality_flags).', question.id, len(quirks)
195
+ )
196
+ for f in flags:
197
+ if f['check'] == 'wording_changed':
198
+ warnings.warn(
199
+ f'{question.id}: the question wording in the source file no longer matches the '
200
+ f'catalog. {f["detail"]}',
201
+ BetteroffWarning,
202
+ stacklevel=4,
203
+ )
204
+ return {
205
+ 'schema_version': METADATA_SCHEMA_VERSION,
206
+ 'betteroff_version': __version__,
207
+ 'parser_version': historical.PARSER_VERSION,
208
+ 'question': {
209
+ **question.to_dict(),
210
+ 'index': {'label': catalog.index_label, 'definition': catalog.index_definition},
211
+ },
212
+ 'population': catalog.population,
213
+ 'units': {'share': 'percent', 'index': 'index'},
214
+ 'source': {
215
+ 'publisher': catalog.attribution,
216
+ 'landing_url': catalog.landing_url,
217
+ 'download_url': snapshot.url,
218
+ 'table': question.table,
219
+ 'source_period': snapshot.source_period,
220
+ 'release_status': 'unknown',
221
+ 'usage_agreement': catalog.agreement_url,
222
+ },
223
+ 'snapshot': {
224
+ 'id': snapshot.id,
225
+ 'sha256': snapshot.sha256,
226
+ 'retrieved_at': snapshot.retrieved_at,
227
+ 'checked_at': snapshot.checked_at,
228
+ 'mode': mode,
229
+ },
230
+ 'coverage': {
231
+ 'first_period': snapshot.first_period,
232
+ 'latest_period': snapshot.latest_period,
233
+ 'observations': snapshot.observations,
234
+ 'monthly_since': (question.monthly_since or catalog.monthly_since).isoformat()[:7],
235
+ },
236
+ 'notes': [*catalog.notes, *question.notes],
237
+ 'source_notes': parsed.source_notes,
238
+ 'quality_flags': flags,
239
+ 'revisions': revisions,
240
+ }
241
+
242
+
243
+ def get(
244
+ identifier: str,
245
+ *,
246
+ refresh: bool = False,
247
+ offline: bool = False,
248
+ start: str | date | None = None,
249
+ end: str | date | None = None,
250
+ measure: str = 'share',
251
+ cache: Cache | None = None,
252
+ client: Any = None,
253
+ ) -> Dataset:
254
+ """Fetch (or reuse a cached copy of) a table, validate it and return a Dataset."""
255
+ question = load_catalog().resolve(identifier)
256
+ check_measure(measure)
257
+ start_d, end_d = parse_date(start, 'start'), parse_date(end, 'end')
258
+ parsed, flags, snapshot, mode, revisions = load(
259
+ question, refresh=refresh, offline=offline, cache=cache, client=client
260
+ )
261
+ metadata = build_metadata(question, parsed, flags, snapshot, mode, revisions)
262
+ return Dataset(
263
+ question=question,
264
+ frame=parsed.frame,
265
+ metadata=metadata,
266
+ measure=measure,
267
+ start=start_d,
268
+ end=end_d,
269
+ )
270
+
271
+
272
+ def cached_dataset(question: Question, cache: Cache | None = None) -> Dataset | None:
273
+ """Dataset from the latest snapshot without touching the network, or None."""
274
+ cache = cache or Cache()
275
+ snapshot = cache.latest(question.id)
276
+ if snapshot is None:
277
+ return None
278
+ parsed, flags = _parse_and_validate(cache.read_bytes(snapshot), question)
279
+ metadata = build_metadata(question, parsed, flags, snapshot, 'cached', None)
280
+ return Dataset(question=question, frame=parsed.frame, metadata=metadata)
betteroff/cache.py ADDED
@@ -0,0 +1,142 @@
1
+ """Content-addressed snapshots of source downloads plus a JSON manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import tempfile
9
+ from dataclasses import asdict, dataclass
10
+ from datetime import UTC, datetime, timedelta
11
+ from pathlib import Path
12
+
13
+ from .errors import FetchError
14
+
15
+ MANIFEST_VERSION = 1
16
+ FRESH_FOR = timedelta(hours=24)
17
+
18
+
19
+ def cache_dir() -> Path:
20
+ env = os.environ.get('BETTEROFF_CACHE_DIR')
21
+ if env:
22
+ return Path(env).expanduser()
23
+ from platformdirs import user_cache_path
24
+
25
+ return user_cache_path('betteroff', appauthor=False)
26
+
27
+
28
+ def now_utc() -> datetime:
29
+ return datetime.now(UTC).replace(microsecond=0)
30
+
31
+
32
+ def _atomic_write(path: Path, data: bytes) -> None:
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix='.tmp-')
35
+ try:
36
+ with os.fdopen(fd, 'wb') as fh:
37
+ fh.write(data)
38
+ os.replace(tmp, path)
39
+ except BaseException:
40
+ Path(tmp).unlink(missing_ok=True)
41
+ raise
42
+
43
+
44
+ @dataclass
45
+ class Snapshot:
46
+ sha256: str
47
+ question_id: str
48
+ table: str
49
+ url: str
50
+ source_period: str | None
51
+ retrieved_at: str
52
+ checked_at: str
53
+ first_period: str
54
+ latest_period: str
55
+ observations: int
56
+ parser_version: str
57
+
58
+ @property
59
+ def id(self) -> str:
60
+ return self.sha256[:12]
61
+
62
+ def is_fresh(self, now: datetime | None = None) -> bool:
63
+ checked = datetime.fromisoformat(self.checked_at)
64
+ return (now or now_utc()) - checked < FRESH_FOR
65
+
66
+
67
+ class Cache:
68
+ def __init__(self, root: Path | None = None) -> None:
69
+ self.root = root or cache_dir()
70
+
71
+ @property
72
+ def manifest_path(self) -> Path:
73
+ return self.root / 'manifest.json'
74
+
75
+ def snapshot_path(self, sha256: str) -> Path:
76
+ return self.root / 'snapshots' / f'{sha256}.xls'
77
+
78
+ def _read(self) -> dict:
79
+ try:
80
+ data = json.loads(self.manifest_path.read_text('utf-8'))
81
+ except FileNotFoundError:
82
+ return {'schema_version': MANIFEST_VERSION, 'questions': {}}
83
+ except (OSError, json.JSONDecodeError) as exc:
84
+ raise FetchError(
85
+ f'The cache manifest at {self.manifest_path} is unreadable ({exc}).',
86
+ 'Delete the cache directory (see `betteroff cache path`) and run again.',
87
+ ) from exc
88
+ return data
89
+
90
+ def _write(self, data: dict) -> None:
91
+ _atomic_write(self.manifest_path, json.dumps(data, indent=2).encode('utf-8'))
92
+
93
+ def snapshots(self, question_id: str) -> list[Snapshot]:
94
+ entry = self._read()['questions'].get(question_id, {})
95
+ return [Snapshot(**s) for s in entry.get('snapshots', [])]
96
+
97
+ def latest(self, question_id: str) -> Snapshot | None:
98
+ entry = self._read()['questions'].get(question_id)
99
+ if not entry:
100
+ return None
101
+ for s in entry['snapshots']:
102
+ if s['sha256'] == entry['latest']:
103
+ return Snapshot(**s)
104
+ return None
105
+
106
+ def all_latest(self) -> list[Snapshot]:
107
+ out = []
108
+ for qid in self._read()['questions']:
109
+ snap = self.latest(qid)
110
+ if snap:
111
+ out.append(snap)
112
+ return out
113
+
114
+ def read_bytes(self, snapshot: Snapshot) -> bytes:
115
+ path = self.snapshot_path(snapshot.sha256)
116
+ try:
117
+ content = path.read_bytes()
118
+ except FileNotFoundError:
119
+ content = b''
120
+ if hashlib.sha256(content).hexdigest() != snapshot.sha256:
121
+ raise FetchError(
122
+ f'The cached snapshot for {snapshot.question_id} is missing or damaged.',
123
+ 'Run the command again with --refresh.',
124
+ )
125
+ return content
126
+
127
+ def store(self, snapshot: Snapshot, content: bytes) -> Snapshot:
128
+ """Save validated bytes and make them the latest snapshot for the question."""
129
+ path = self.snapshot_path(snapshot.sha256)
130
+ if not path.exists():
131
+ _atomic_write(path, content)
132
+ data = self._read()
133
+ entry = data['questions'].setdefault(snapshot.question_id, {'snapshots': []})
134
+ entry['snapshots'] = [s for s in entry['snapshots'] if s['sha256'] != snapshot.sha256]
135
+ entry['snapshots'].append(asdict(snapshot))
136
+ entry['latest'] = snapshot.sha256
137
+ self._write(data)
138
+ return snapshot
139
+
140
+ @staticmethod
141
+ def digest(content: bytes) -> str:
142
+ return hashlib.sha256(content).hexdigest()
betteroff/catalog.py ADDED
@@ -0,0 +1,159 @@
1
+ """Packaged catalog of supported tables. Reads only TOML, so it stays fast."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import re
7
+ import tomllib
8
+ from dataclasses import dataclass, field
9
+ from datetime import date
10
+ from functools import cache
11
+ from importlib import resources
12
+
13
+ from .errors import InputError
14
+
15
+ POLARITIES = ('positive', 'neutral', 'negative', 'nonresponse')
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Response:
20
+ id: str
21
+ label: str
22
+ source_label: str
23
+ polarity: str
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Break:
28
+ date: date
29
+ label: str
30
+ comparable: bool
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Question:
35
+ id: str
36
+ table: str
37
+ title: str
38
+ wording: str
39
+ responses: tuple[Response, ...]
40
+ keywords: tuple[str, ...] = ()
41
+ notes: tuple[str, ...] = ()
42
+ breaks: tuple[Break, ...] = ()
43
+ monthly_since: date | None = None # Only when later than the survey-wide date
44
+
45
+ def response(self, response_id: str) -> Response:
46
+ for r in self.responses:
47
+ if r.id == response_id:
48
+ return r
49
+ raise KeyError(response_id)
50
+
51
+ def to_dict(self) -> dict[str, object]:
52
+ return {
53
+ 'id': self.id,
54
+ 'table': self.table,
55
+ 'title': self.title,
56
+ 'wording': self.wording,
57
+ 'responses': [r.__dict__ for r in self.responses],
58
+ 'notes': list(self.notes),
59
+ 'breaks': [
60
+ {'date': b.date.isoformat(), 'label': b.label, 'comparable': b.comparable}
61
+ for b in self.breaks
62
+ ],
63
+ 'monthly_since': self.monthly_since.isoformat() if self.monthly_since else None,
64
+ }
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Catalog:
69
+ landing_url: str
70
+ attribution: str
71
+ agreement_url: str
72
+ population: str
73
+ monthly_since: date
74
+ index_label: str
75
+ index_definition: str
76
+ notes: tuple[str, ...]
77
+ questions: tuple[Question, ...] = field(default_factory=tuple)
78
+
79
+ def resolve(self, identifier: str) -> Question:
80
+ key = identifier.strip().lower()
81
+ key = re.sub(r'^table\s*', '', key)
82
+ for q in self.questions:
83
+ if key in (q.id, q.table.lower()):
84
+ return q
85
+ ids = [q.id for q in self.questions]
86
+ close = difflib.get_close_matches(key, ids, n=3)
87
+ hint = f'Did you mean {", ".join(close)}?' if close else ''
88
+ raise InputError(
89
+ f'Unknown question or table "{identifier}". {hint}'.strip(),
90
+ 'Run `betteroff list` or `betteroff search TEXT` to find an identifier.',
91
+ )
92
+
93
+ def search(self, text: str) -> list[Question]:
94
+ terms = [t for t in re.split(r'\W+', text.lower()) if t]
95
+ if not terms:
96
+ return list(self.questions)
97
+ scored = []
98
+ for q in self.questions:
99
+ fields = {
100
+ 'id': q.id.replace('_', ' '),
101
+ 'title': q.title.lower(),
102
+ 'keywords': ' '.join(q.keywords).lower(),
103
+ 'wording': q.wording.lower(),
104
+ 'responses': ' '.join(r.label.lower() for r in q.responses),
105
+ }
106
+ weights = {'id': 3, 'title': 3, 'keywords': 2, 'wording': 1, 'responses': 1}
107
+ score = 0
108
+ for term in terms:
109
+ hit = 0
110
+ for name, value in fields.items():
111
+ if re.search(rf'\b{re.escape(term)}', value):
112
+ hit = max(hit, weights[name])
113
+ if term == q.table.lower():
114
+ hit = max(hit, 3)
115
+ if not hit:
116
+ break
117
+ score += hit
118
+ else:
119
+ scored.append((score, q))
120
+ scored.sort(key=lambda pair: -pair[0])
121
+ return [q for _, q in scored]
122
+
123
+
124
+ def _question(raw: dict) -> Question:
125
+ responses = tuple(Response(**r) for r in raw['responses'])
126
+ for r in responses:
127
+ if r.polarity not in POLARITIES:
128
+ raise ValueError(f'{raw["id"]}: bad polarity {r.polarity!r}')
129
+ return Question(
130
+ id=raw['id'],
131
+ table=str(raw['table']),
132
+ title=raw['title'],
133
+ wording=raw['wording'],
134
+ responses=responses,
135
+ keywords=tuple(raw.get('keywords', ())),
136
+ notes=tuple(raw.get('notes', ())),
137
+ breaks=tuple(
138
+ Break(date.fromisoformat(b['date']), b['label'], bool(b['comparable']))
139
+ for b in raw.get('breaks', ())
140
+ ),
141
+ monthly_since=date.fromisoformat(raw['monthly_since']) if 'monthly_since' in raw else None,
142
+ )
143
+
144
+
145
+ @cache
146
+ def load_catalog() -> Catalog:
147
+ text = resources.files('betteroff').joinpath('data/catalog.toml').read_text('utf-8')
148
+ raw = tomllib.loads(text)
149
+ return Catalog(
150
+ landing_url=raw['landing_url'],
151
+ attribution=raw['attribution'],
152
+ agreement_url=raw['agreement_url'],
153
+ population=raw['population'],
154
+ monthly_since=date.fromisoformat(raw['monthly_since']),
155
+ index_label=raw['index_label'],
156
+ index_definition=raw['index_definition'],
157
+ notes=tuple(raw['notes']),
158
+ questions=tuple(_question(q) for q in raw['questions']),
159
+ )