framework-py 0.1.0.dev0__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.
- framework/__init__.py +250 -0
- framework/__main__.py +89 -0
- framework/_assets/hooks/pre-commit +59 -0
- framework/_assets/init.sql +63 -0
- framework/_assets/schema/layer.v1.json +605 -0
- framework/_assets/schema/layers.v1.json +791 -0
- framework/_assets/schema/spec.v1.json +604 -0
- framework/_log.py +29 -0
- framework/_paths.py +45 -0
- framework/_secretservice.py +69 -0
- framework/_time.py +11 -0
- framework/_wincred.py +105 -0
- framework/_yaml.py +90 -0
- framework/ai.py +204 -0
- framework/bootstrap.py +206 -0
- framework/cache.py +335 -0
- framework/catalog.py +193 -0
- framework/cloud.py +899 -0
- framework/config_home.py +194 -0
- framework/connections.py +442 -0
- framework/console.py +41 -0
- framework/create.py +579 -0
- framework/data.py +519 -0
- framework/db.py +222 -0
- framework/dotenv.py +79 -0
- framework/errors.py +39 -0
- framework/global_config.py +238 -0
- framework/hooks.py +364 -0
- framework/integrity.py +22 -0
- framework/notebooks.py +384 -0
- framework/outputs.py +324 -0
- framework/packages.py +276 -0
- framework/project.py +52 -0
- framework/py.typed +0 -0
- framework/quarto.py +163 -0
- framework/scaffold.py +269 -0
- framework/scratch.py +108 -0
- framework/security.py +646 -0
- framework/settings.py +439 -0
- framework/spec/__init__.py +0 -0
- framework/spec/check.py +211 -0
- framework/spec/merge.py +195 -0
- framework/spec/resolve.py +254 -0
- framework/storage.py +516 -0
- framework/templates/defaults.yml +144 -0
- framework/templates/gitignore/course +120 -0
- framework/templates/gitignore/presentation +114 -0
- framework/templates/gitignore/project +108 -0
- framework/templates/gitignore/sensitive +139 -0
- framework/templates/skills/framework-data/SKILL.md +47 -0
- framework/templates/skills/framework-outputs/SKILL.md +68 -0
- framework/templates/skills/framework-packages/SKILL.md +33 -0
- framework/templates/skills/framework-sensitive-data/SKILL.md +38 -0
- framework/templates/skills/framework-workflow/SKILL.md +58 -0
- framework/templates/stubs/notebook-default.ipynb +34 -0
- framework/templates/stubs/notebook-default.qmd +43 -0
- framework/templates/stubs/notebook-minimal.qmd +15 -0
- framework/templates/stubs/notebook-revealjs.qmd +34 -0
- framework/templates/stubs/script-default.py +18 -0
- framework_py-0.1.0.dev0.dist-info/METADATA +111 -0
- framework_py-0.1.0.dev0.dist-info/RECORD +63 -0
- framework_py-0.1.0.dev0.dist-info/WHEEL +4 -0
- framework_py-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
framework/cache.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Caching expensive results between runs.
|
|
2
|
+
|
|
3
|
+
Values are stored as files in the project's cache folder and tracked in the
|
|
4
|
+
``cache`` table of framework.db with a hash and an optional expiry. DataFrames
|
|
5
|
+
are stored as Parquet and other values with pickle. R stores its cache values
|
|
6
|
+
as ``.rds``, so a name R cached is a miss in Python (with a warning) and is
|
|
7
|
+
left in place for R.
|
|
8
|
+
|
|
9
|
+
Only load caches you trust: reading a pickle can run code.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import functools
|
|
15
|
+
import os
|
|
16
|
+
import pickle
|
|
17
|
+
import re
|
|
18
|
+
import warnings
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, TypeVar, overload
|
|
23
|
+
|
|
24
|
+
import pandas as pd
|
|
25
|
+
|
|
26
|
+
from framework import db
|
|
27
|
+
from framework._log import inform
|
|
28
|
+
from framework._paths import in_root, relative_to_root
|
|
29
|
+
from framework.errors import CacheWarning, FrameworkError
|
|
30
|
+
from framework.integrity import file_hash
|
|
31
|
+
from framework.project import project_root
|
|
32
|
+
from framework.settings import settings_read
|
|
33
|
+
|
|
34
|
+
T = TypeVar("T")
|
|
35
|
+
|
|
36
|
+
_FORMATS = (".parquet", ".pickle")
|
|
37
|
+
_R_FORMAT = ".rds"
|
|
38
|
+
_UNITS = {
|
|
39
|
+
**dict.fromkeys(("hour", "hours", "hr", "hrs", "h"), 1),
|
|
40
|
+
**dict.fromkeys(("day", "days", "d"), 24),
|
|
41
|
+
**dict.fromkeys(("week", "weeks", "w"), 24 * 7),
|
|
42
|
+
}
|
|
43
|
+
_DURATION = re.compile(r"^([0-9]*\.?[0-9]+)\s*(hour|hours|hr|hrs|h|day|days|d|week|weeks|w)$")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize_expire_after(value: float | str | None, default: float | None = None) -> float | None:
|
|
47
|
+
"""Hours until expiry, from a number of hours or a string like ``"2 days"``."""
|
|
48
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
49
|
+
return default
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
match = _DURATION.match(value.strip().lower())
|
|
52
|
+
if match is None:
|
|
53
|
+
raise FrameworkError(
|
|
54
|
+
"Could not parse expire_after value. Use numbers (hours) or strings like "
|
|
55
|
+
"'1 day', '2 hours', '1 week'."
|
|
56
|
+
)
|
|
57
|
+
return float(match.group(1)) * _UNITS[match.group(2)]
|
|
58
|
+
if value < 0:
|
|
59
|
+
raise FrameworkError("expire_after must not be negative")
|
|
60
|
+
return float(value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cache_dir() -> Path:
|
|
64
|
+
"""The cache folder: ``FW_CACHE_DIR``, then ``directories.cache`` in the settings."""
|
|
65
|
+
custom = os.environ.get("FW_CACHE_DIR")
|
|
66
|
+
root = project_root()
|
|
67
|
+
if custom:
|
|
68
|
+
return in_root(custom, root)
|
|
69
|
+
config = settings_read()
|
|
70
|
+
directories = config.get("directories") or {}
|
|
71
|
+
options = config.get("options") or {}
|
|
72
|
+
data_options = options.get("data") if isinstance(options, dict) else None
|
|
73
|
+
folder = (directories.get("cache") if isinstance(directories, dict) else None) or (
|
|
74
|
+
data_options.get("cache_dir") if isinstance(data_options, dict) else None
|
|
75
|
+
)
|
|
76
|
+
return in_root(folder or "outputs/cache", root)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _default_expire() -> float | None:
|
|
80
|
+
options = settings_read().get("options") or {}
|
|
81
|
+
data_options = options.get("data") if isinstance(options, dict) else None
|
|
82
|
+
value = data_options.get("cache_default_expire") if isinstance(data_options, dict) else None
|
|
83
|
+
return normalize_expire_after(value)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _row(name: str) -> dict[str, Any] | None:
|
|
87
|
+
with db.connect() as connection:
|
|
88
|
+
row = connection.execute(
|
|
89
|
+
"SELECT name, file_path, hash, expire_at FROM cache WHERE name = ?", (name,)
|
|
90
|
+
).fetchone()
|
|
91
|
+
return dict(row) if row is not None else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _candidates(name: str, file: str | Path | None, row: dict[str, Any] | None) -> list[Path]:
|
|
95
|
+
if file is not None:
|
|
96
|
+
return [Path(file)]
|
|
97
|
+
root = project_root()
|
|
98
|
+
paths = []
|
|
99
|
+
if row and row.get("file_path"):
|
|
100
|
+
paths.append(in_root(str(row["file_path"]), root))
|
|
101
|
+
folder = cache_dir()
|
|
102
|
+
paths.extend(folder / f"{name}{suffix}" for suffix in (*_FORMATS, _R_FORMAT))
|
|
103
|
+
return paths
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _remove(name: str, file: str | Path | None) -> None:
|
|
107
|
+
for path in _candidates(name, file, _row(name)):
|
|
108
|
+
path.unlink(missing_ok=True)
|
|
109
|
+
with db.connect() as connection:
|
|
110
|
+
connection.execute("DELETE FROM cache WHERE name = ?", (name,))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cache(
|
|
114
|
+
name: str, value: T, file: str | Path | None = None, expire_after: float | str | None = None
|
|
115
|
+
) -> T:
|
|
116
|
+
"""Store a value in the cache and return it.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
name: The cache name.
|
|
120
|
+
value: The value. DataFrames are stored as Parquet, anything else with pickle.
|
|
121
|
+
file: Store the value in this file instead of the cache folder.
|
|
122
|
+
expire_after: Hours until the value expires, or a string like
|
|
123
|
+
``"2 days"``. Default: ``options.data.cache_default_expire`` in the
|
|
124
|
+
settings, else never.
|
|
125
|
+
"""
|
|
126
|
+
is_frame = isinstance(value, pd.DataFrame)
|
|
127
|
+
path = (
|
|
128
|
+
Path(file) if file is not None else cache_dir() / f"{name}{_FORMATS[0 if is_frame else 1]}"
|
|
129
|
+
)
|
|
130
|
+
if not path.parent.is_dir():
|
|
131
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
inform(f"Creating cache directory: {path.parent}")
|
|
133
|
+
|
|
134
|
+
if is_frame:
|
|
135
|
+
value.to_parquet(path) # type: ignore[attr-defined]
|
|
136
|
+
else:
|
|
137
|
+
with path.open("wb") as handle:
|
|
138
|
+
pickle.dump(value, handle)
|
|
139
|
+
|
|
140
|
+
hours = normalize_expire_after(expire_after, _default_expire())
|
|
141
|
+
now = db.timestamp()
|
|
142
|
+
expire_at = now + hours * 3600 if hours is not None else None
|
|
143
|
+
root = project_root()
|
|
144
|
+
record = (relative_to_root(path, root), file_hash(path), expire_at, now)
|
|
145
|
+
with db.connect(root) as connection:
|
|
146
|
+
if connection.execute("SELECT 1 FROM cache WHERE name = ?", (name,)).fetchone():
|
|
147
|
+
connection.execute(
|
|
148
|
+
"UPDATE cache SET file_path = ?, hash = ?, expire_at = ?, last_read_at = ?, "
|
|
149
|
+
"updated_at = ? WHERE name = ?",
|
|
150
|
+
(*record, now, name),
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
connection.execute(
|
|
154
|
+
"INSERT INTO cache (name, file_path, hash, expire_at, last_read_at, created_at, "
|
|
155
|
+
"updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
156
|
+
(name, *record, now, now),
|
|
157
|
+
)
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_MISSING: Any = object()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _lookup(name: str, file: str | Path | None) -> Any:
|
|
165
|
+
row = _row(name)
|
|
166
|
+
if row is None:
|
|
167
|
+
inform(f"Cache '{name}' not found")
|
|
168
|
+
return _MISSING
|
|
169
|
+
|
|
170
|
+
path = next((p for p in _candidates(name, file, row) if p.is_file()), None)
|
|
171
|
+
if path is None:
|
|
172
|
+
inform(f"Cache '{name}' not found")
|
|
173
|
+
return _MISSING
|
|
174
|
+
if path.suffix == _R_FORMAT:
|
|
175
|
+
warnings.warn(
|
|
176
|
+
f"Cache '{name}' was saved by R (.rds), so Python treats it as a miss. "
|
|
177
|
+
"R's entry is left in place.",
|
|
178
|
+
CacheWarning,
|
|
179
|
+
stacklevel=3,
|
|
180
|
+
)
|
|
181
|
+
return _MISSING
|
|
182
|
+
|
|
183
|
+
expire_at = db.parse_timestamp(row["expire_at"])
|
|
184
|
+
if expire_at is not None and datetime.now().astimezone() > expire_at:
|
|
185
|
+
inform(f"Cache '{name}' has expired")
|
|
186
|
+
_remove(name, file)
|
|
187
|
+
return _MISSING
|
|
188
|
+
|
|
189
|
+
if row["hash"] and file_hash(path) != row["hash"]:
|
|
190
|
+
warnings.warn(
|
|
191
|
+
f"Hash mismatch for cache '{name}' - cache may be corrupted", CacheWarning, stacklevel=3
|
|
192
|
+
)
|
|
193
|
+
return _MISSING
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
if path.suffix == ".parquet":
|
|
197
|
+
value = pd.read_parquet(path)
|
|
198
|
+
else:
|
|
199
|
+
with path.open("rb") as handle:
|
|
200
|
+
value = pickle.load(handle)
|
|
201
|
+
except Exception as error:
|
|
202
|
+
warnings.warn(f"Failed to read cache '{name}': {error}", CacheWarning, stacklevel=3)
|
|
203
|
+
return _MISSING
|
|
204
|
+
|
|
205
|
+
with db.connect() as connection:
|
|
206
|
+
connection.execute(
|
|
207
|
+
"UPDATE cache SET last_read_at = ? WHERE name = ?", (db.timestamp(), name)
|
|
208
|
+
)
|
|
209
|
+
return value
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def cache_get(name: str, file: str | Path | None = None, default: Any = None) -> Any:
|
|
213
|
+
"""A cached value, or ``default`` when it is missing, expired, or unreadable."""
|
|
214
|
+
value = _lookup(name, file)
|
|
215
|
+
return default if value is _MISSING else value
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@overload
|
|
219
|
+
def cache_remember(
|
|
220
|
+
name: str,
|
|
221
|
+
compute: Callable[[], T],
|
|
222
|
+
*,
|
|
223
|
+
file: str | Path | None = ...,
|
|
224
|
+
expire_after: float | str | None = ...,
|
|
225
|
+
refresh: bool | Callable[[], bool] = ...,
|
|
226
|
+
) -> T: ...
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@overload
|
|
230
|
+
def cache_remember(
|
|
231
|
+
name: str,
|
|
232
|
+
compute: None = ...,
|
|
233
|
+
*,
|
|
234
|
+
file: str | Path | None = ...,
|
|
235
|
+
expire_after: float | str | None = ...,
|
|
236
|
+
refresh: bool | Callable[[], bool] = ...,
|
|
237
|
+
) -> Callable[[Callable[[], T]], Callable[[], T]]: ...
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def cache_remember(
|
|
241
|
+
name: str,
|
|
242
|
+
compute: Callable[[], T] | None = None,
|
|
243
|
+
*,
|
|
244
|
+
file: str | Path | None = None,
|
|
245
|
+
expire_after: float | str | None = None,
|
|
246
|
+
refresh: bool | Callable[[], bool] = False,
|
|
247
|
+
) -> T | Callable[[Callable[[], T]], Callable[[], T]]:
|
|
248
|
+
"""Return the cached value, or compute it, cache it, and return it.
|
|
249
|
+
|
|
250
|
+
Call it with a function that takes no arguments::
|
|
251
|
+
|
|
252
|
+
model = fw.cache_remember("model", lambda: fit(data), expire_after="1 week")
|
|
253
|
+
|
|
254
|
+
or use it as a decorator, which caches the function's result::
|
|
255
|
+
|
|
256
|
+
@fw.cache_remember("survey_clean")
|
|
257
|
+
def survey_clean():
|
|
258
|
+
return fw.data_read("inputs.raw.survey").dropna()
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
name: The cache name.
|
|
262
|
+
compute: Computes the value on a miss.
|
|
263
|
+
file: Store the value in this file instead of the cache folder.
|
|
264
|
+
expire_after: Hours until the value expires, or a string like ``"2 days"``.
|
|
265
|
+
refresh: Recompute even when cached. A function is called to decide.
|
|
266
|
+
"""
|
|
267
|
+
if compute is None:
|
|
268
|
+
|
|
269
|
+
def decorator(function: Callable[[], T]) -> Callable[[], T]:
|
|
270
|
+
@functools.wraps(function)
|
|
271
|
+
def wrapper() -> T:
|
|
272
|
+
return cache_remember(
|
|
273
|
+
name, function, file=file, expire_after=expire_after, refresh=refresh
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return wrapper
|
|
277
|
+
|
|
278
|
+
return decorator
|
|
279
|
+
|
|
280
|
+
should_refresh = refresh() if callable(refresh) else refresh
|
|
281
|
+
if not isinstance(should_refresh, bool):
|
|
282
|
+
warnings.warn("refresh must return a single boolean value", CacheWarning, stacklevel=2)
|
|
283
|
+
should_refresh = False
|
|
284
|
+
if should_refresh:
|
|
285
|
+
inform(f"Cache '{name}' refresh requested")
|
|
286
|
+
_remove(name, file)
|
|
287
|
+
else:
|
|
288
|
+
value = _lookup(name, file)
|
|
289
|
+
if value is not _MISSING:
|
|
290
|
+
return value # type: ignore[no-any-return]
|
|
291
|
+
|
|
292
|
+
computed = compute()
|
|
293
|
+
try:
|
|
294
|
+
cache(name, computed, file, expire_after)
|
|
295
|
+
except Exception as error:
|
|
296
|
+
warnings.warn(f"Failed to cache value: {error}", CacheWarning, stacklevel=2)
|
|
297
|
+
return computed
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def cache_forget(name: str, file: str | Path | None = None) -> None:
|
|
301
|
+
"""Remove a cached value and its record, whichever language saved it."""
|
|
302
|
+
_remove(name, file)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def cache_flush() -> None:
|
|
306
|
+
"""Remove every cached value (from R and Python) and every cache record."""
|
|
307
|
+
folder = cache_dir()
|
|
308
|
+
if folder.is_dir():
|
|
309
|
+
for path in folder.iterdir():
|
|
310
|
+
if path.is_file() and path.suffix in (*_FORMATS, _R_FORMAT):
|
|
311
|
+
path.unlink()
|
|
312
|
+
with db.connect() as connection:
|
|
313
|
+
connection.execute("DELETE FROM cache")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def cache_list() -> pd.DataFrame:
|
|
317
|
+
"""Every cache record, with its timestamps and status (``active`` or ``expired``)."""
|
|
318
|
+
columns = ["name", "expire_at", "created_at", "updated_at", "last_read_at", "status"]
|
|
319
|
+
with db.connect() as connection:
|
|
320
|
+
rows = connection.execute(
|
|
321
|
+
"SELECT name, expire_at, created_at, updated_at, last_read_at FROM cache ORDER BY name"
|
|
322
|
+
).fetchall()
|
|
323
|
+
now = datetime.now().astimezone()
|
|
324
|
+
records = []
|
|
325
|
+
for row in rows:
|
|
326
|
+
record = {key: db.parse_timestamp(row[key]) for key in columns[1:5]}
|
|
327
|
+
expire_at = record["expire_at"]
|
|
328
|
+
records.append(
|
|
329
|
+
{
|
|
330
|
+
"name": row["name"],
|
|
331
|
+
**record,
|
|
332
|
+
"status": "expired" if expire_at is not None and now > expire_at else "active",
|
|
333
|
+
}
|
|
334
|
+
)
|
|
335
|
+
return pd.DataFrame(records, columns=columns)
|
framework/catalog.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""The data catalog: names like ``inputs.raw.survey`` that resolve to data files.
|
|
2
|
+
|
|
3
|
+
The catalog is the ``data`` section of the settings. An entry is either a
|
|
4
|
+
path string or a mapping with ``path`` and optional ``type``, ``delimiter``,
|
|
5
|
+
``locked``, ``description``, and other fields. Nested mappings without a
|
|
6
|
+
``path`` group entries, and their keys form the dot-notation name.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
from framework.errors import DataNotFoundError
|
|
19
|
+
from framework.project import find_settings_file
|
|
20
|
+
from framework.settings import settings_read
|
|
21
|
+
|
|
22
|
+
# Extension -> (type, delimiter), in the order R checks them. Anything else
|
|
23
|
+
# is read as delimited text with the delimiter guessed at read time.
|
|
24
|
+
_TYPES: tuple[tuple[str, str, str | None], ...] = (
|
|
25
|
+
(r"\.rds$", "rds", None),
|
|
26
|
+
(r"\.tsv$", "csv", "tab"),
|
|
27
|
+
(r"\.csv$", "csv", "comma"),
|
|
28
|
+
(r"\.(xlsx|xls)$", "excel", None),
|
|
29
|
+
(r"\.parquet$", "parquet", None),
|
|
30
|
+
(r"\.dta$", "stata", None),
|
|
31
|
+
(r"\.(sav|zsav)$", "spss", None),
|
|
32
|
+
(r"\.por$", "spss_por", None),
|
|
33
|
+
(r"\.(sas7bdat|sas7bcat)$", "sas", None),
|
|
34
|
+
(r"\.xpt$", "sas_xpt", None),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_KNOWN_FIELDS = frozenset({"path", "type", "delimiter", "locked", "private", "description"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class DataInfo:
|
|
42
|
+
"""How to read one data file.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
path: Absolute path to the file.
|
|
46
|
+
type: File type: csv, parquet, rds, excel, stata, spss, spss_por, sas,
|
|
47
|
+
or sas_xpt.
|
|
48
|
+
delimiter: For delimited text: comma, tab, semicolon, or space. None
|
|
49
|
+
means it is guessed from the extension.
|
|
50
|
+
locked: Whether the file's hash is checked against framework.db on read.
|
|
51
|
+
private: Whether the file is in a folder named ``private``.
|
|
52
|
+
description: Shown when the data is read.
|
|
53
|
+
name: The catalog name, when the file was found by name.
|
|
54
|
+
extra: Any other fields of the catalog entry.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
path: Path
|
|
58
|
+
type: str
|
|
59
|
+
delimiter: str | None = None
|
|
60
|
+
locked: bool = False
|
|
61
|
+
private: bool = False
|
|
62
|
+
description: str | None = None
|
|
63
|
+
name: str | None = None
|
|
64
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def type_from_extension(path: str | Path) -> tuple[str, str | None]:
|
|
68
|
+
"""The file type and delimiter for a path, from its extension."""
|
|
69
|
+
text = str(path)
|
|
70
|
+
for pattern, file_type, delimiter in _TYPES:
|
|
71
|
+
if re.search(pattern, text, re.IGNORECASE):
|
|
72
|
+
return file_type, delimiter
|
|
73
|
+
return "csv", None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_absolute(path: str) -> bool:
|
|
77
|
+
return Path(path).is_absolute() or bool(re.match(r"^[A-Za-z]:[/\\]", path))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build(path: str, base: Path, entry: dict[str, Any] | None, name: str | None) -> DataInfo:
|
|
81
|
+
full = Path(path) if _is_absolute(path) else base / path
|
|
82
|
+
full = full.resolve()
|
|
83
|
+
file_type, delimiter = type_from_extension(full)
|
|
84
|
+
fields: dict[str, Any] = {
|
|
85
|
+
"type": file_type,
|
|
86
|
+
"delimiter": delimiter,
|
|
87
|
+
"locked": False,
|
|
88
|
+
"private": full.parent.name == "private",
|
|
89
|
+
"description": None,
|
|
90
|
+
}
|
|
91
|
+
extra: dict[str, Any] = {}
|
|
92
|
+
for key, value in (entry or {}).items():
|
|
93
|
+
if key == "path" or value is None:
|
|
94
|
+
continue
|
|
95
|
+
if key in _KNOWN_FIELDS:
|
|
96
|
+
fields[key] = value
|
|
97
|
+
else:
|
|
98
|
+
extra[key] = value
|
|
99
|
+
return DataInfo(
|
|
100
|
+
path=full,
|
|
101
|
+
type=str(fields["type"]),
|
|
102
|
+
delimiter=fields["delimiter"],
|
|
103
|
+
locked=bool(fields["locked"]),
|
|
104
|
+
private=bool(fields["private"]),
|
|
105
|
+
description=fields["description"] or None,
|
|
106
|
+
name=name,
|
|
107
|
+
extra=extra,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def catalog_names(data: Any, prefix: str = "") -> list[str]:
|
|
112
|
+
"""Every dot-notation name in a catalog section, in file order."""
|
|
113
|
+
names: list[str] = []
|
|
114
|
+
if not isinstance(data, dict):
|
|
115
|
+
return names
|
|
116
|
+
for key, item in data.items():
|
|
117
|
+
full = f"{prefix}.{key}" if prefix else str(key)
|
|
118
|
+
if isinstance(item, str) or (isinstance(item, dict) and "path" in item):
|
|
119
|
+
names.append(full)
|
|
120
|
+
elif isinstance(item, dict):
|
|
121
|
+
names.extend(catalog_names(item, full))
|
|
122
|
+
return names
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _lookup(name: str, data: Any) -> Any:
|
|
126
|
+
current = data
|
|
127
|
+
for part in name.split("."):
|
|
128
|
+
if not isinstance(current, dict) or current.get(part) is None:
|
|
129
|
+
return None
|
|
130
|
+
current = current[part]
|
|
131
|
+
return current
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _not_found(name: str, data: Any) -> DataNotFoundError:
|
|
135
|
+
message = f"No data specification found for path: {name}"
|
|
136
|
+
names = catalog_names(data)
|
|
137
|
+
if names:
|
|
138
|
+
shown = names[:20]
|
|
139
|
+
if len(names) > 20:
|
|
140
|
+
shown.append(f"... and {len(names) - 20} more")
|
|
141
|
+
message += "\n\nAvailable data paths:\n " + "\n ".join(shown)
|
|
142
|
+
message += (
|
|
143
|
+
"\n\nTip: Use settings('data') to explore the full catalog, "
|
|
144
|
+
"or narrow it down with settings('data.inputs')"
|
|
145
|
+
)
|
|
146
|
+
return DataNotFoundError(message)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def data_info(path: str) -> DataInfo:
|
|
150
|
+
"""How to read a data file, given a catalog name or a file path.
|
|
151
|
+
|
|
152
|
+
``path`` is a dot-notation catalog name (``"inputs.raw.survey"``), a path
|
|
153
|
+
relative to the current folder (anything with a slash), or an absolute
|
|
154
|
+
path. Catalog paths are relative to the project root.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
DataNotFoundError: The name is not in the catalog.
|
|
158
|
+
"""
|
|
159
|
+
if _is_absolute(path):
|
|
160
|
+
return _build(path, Path.cwd(), None, None)
|
|
161
|
+
if "/" in path or "\\" in path:
|
|
162
|
+
return _build(path, Path.cwd(), None, None)
|
|
163
|
+
|
|
164
|
+
settings_file = find_settings_file()
|
|
165
|
+
base = settings_file.parent if settings_file else Path.cwd()
|
|
166
|
+
data = settings_read(settings_file).get("data")
|
|
167
|
+
entry = _lookup(path, data)
|
|
168
|
+
if entry is None:
|
|
169
|
+
raise _not_found(path, data)
|
|
170
|
+
if isinstance(entry, str):
|
|
171
|
+
return _build(entry, base, None, path)
|
|
172
|
+
if isinstance(entry, dict) and isinstance(entry.get("path"), str):
|
|
173
|
+
return _build(entry["path"], base, entry, path)
|
|
174
|
+
raise DataNotFoundError(f"Catalog entry '{path}' has no path")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def data_list() -> pd.DataFrame:
|
|
178
|
+
"""Every catalog entry, with columns name, path, type, locked, and description."""
|
|
179
|
+
data = settings_read().get("data")
|
|
180
|
+
rows = []
|
|
181
|
+
for name in catalog_names(data):
|
|
182
|
+
entry = _lookup(name, data)
|
|
183
|
+
fields = {"path": entry} if isinstance(entry, str) else entry
|
|
184
|
+
rows.append(
|
|
185
|
+
{
|
|
186
|
+
"name": name,
|
|
187
|
+
"path": fields.get("path"),
|
|
188
|
+
"type": fields.get("type"),
|
|
189
|
+
"locked": bool(fields.get("locked", False)),
|
|
190
|
+
"description": fields.get("description"),
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
return pd.DataFrame(rows, columns=["name", "path", "type", "locked", "description"])
|