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 +54 -0
- betteroff/__main__.py +3 -0
- betteroff/_version.py +24 -0
- betteroff/api.py +280 -0
- betteroff/cache.py +142 -0
- betteroff/catalog.py +159 -0
- betteroff/charts.py +537 -0
- betteroff/cli.py +553 -0
- betteroff/data/catalog.toml +270 -0
- betteroff/data/recessions.csv +14 -0
- betteroff/errors.py +39 -0
- betteroff/export.py +86 -0
- betteroff/fetch.py +136 -0
- betteroff/models.py +129 -0
- betteroff/parsers/__init__.py +1 -0
- betteroff/parsers/historical.py +187 -0
- betteroff/py.typed +0 -0
- betteroff/recipes.py +212 -0
- betteroff/validate.py +93 -0
- betteroff-0.1.0.dist-info/METADATA +247 -0
- betteroff-0.1.0.dist-info/RECORD +24 -0
- betteroff-0.1.0.dist-info/WHEEL +4 -0
- betteroff-0.1.0.dist-info/entry_points.txt +2 -0
- betteroff-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|
+
)
|