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/data.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""Reading and saving data with integrity tracking.
|
|
2
|
+
|
|
3
|
+
``data_read()`` checks a catalog file's SHA-256 against the hash recorded in
|
|
4
|
+
framework.db: a locked file that changed is an error, and an unlocked file
|
|
5
|
+
that changed is a warning. ``data_save()`` and ``data_add()`` record the hash.
|
|
6
|
+
Hashes match R's, so a file saved in one language verifies in the other.
|
|
7
|
+
|
|
8
|
+
Python reads CSV, TSV, Parquet, Excel, Stata, SPSS, and SAS files. It writes
|
|
9
|
+
CSV and Parquet, which R reads too. ``.rds`` files are R-only.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import warnings
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import pandas as pd
|
|
21
|
+
|
|
22
|
+
from framework import _yaml, db
|
|
23
|
+
from framework._log import inform
|
|
24
|
+
from framework._paths import in_root, relative_to_root
|
|
25
|
+
from framework.catalog import DataInfo, data_info
|
|
26
|
+
from framework.errors import (
|
|
27
|
+
DataChangedWarning,
|
|
28
|
+
DataNotFoundError,
|
|
29
|
+
FrameworkError,
|
|
30
|
+
FrameworkWarning,
|
|
31
|
+
IntegrityError,
|
|
32
|
+
SettingsError,
|
|
33
|
+
UnsupportedFormatError,
|
|
34
|
+
)
|
|
35
|
+
from framework.integrity import file_hash
|
|
36
|
+
from framework.project import find_settings_file, project_root
|
|
37
|
+
from framework.settings import settings_read
|
|
38
|
+
|
|
39
|
+
DELIMITERS = {"comma": ",", "tab": "\t", "semicolon": ";", "space": " "}
|
|
40
|
+
|
|
41
|
+
# Extension -> type for files read directly by path, as R's data_read() does.
|
|
42
|
+
_DIRECT_TYPES = {
|
|
43
|
+
"csv": "csv",
|
|
44
|
+
"tsv": "tsv",
|
|
45
|
+
"txt": "tsv",
|
|
46
|
+
"dat": "tsv",
|
|
47
|
+
"rds": "rds",
|
|
48
|
+
"xlsx": "excel",
|
|
49
|
+
"xls": "excel",
|
|
50
|
+
"dta": "stata",
|
|
51
|
+
"sav": "spss",
|
|
52
|
+
"zsav": "spss",
|
|
53
|
+
"por": "spss_por",
|
|
54
|
+
"sas7bdat": "sas",
|
|
55
|
+
"sas7bcat": "sas",
|
|
56
|
+
"xpt": "sas_xpt",
|
|
57
|
+
"parquet": "parquet",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Extension -> type recorded by data_add(), as R's data_add() does.
|
|
61
|
+
_ADD_TYPES = {
|
|
62
|
+
"csv": "csv",
|
|
63
|
+
"tsv": "tsv",
|
|
64
|
+
"txt": "tsv",
|
|
65
|
+
"dat": "tsv",
|
|
66
|
+
"rds": "rds",
|
|
67
|
+
"xlsx": "excel",
|
|
68
|
+
"xls": "excel",
|
|
69
|
+
"dta": "stata",
|
|
70
|
+
"sav": "spss",
|
|
71
|
+
"zsav": "spss",
|
|
72
|
+
"por": "spss",
|
|
73
|
+
"sas7bdat": "sas",
|
|
74
|
+
"xpt": "sas",
|
|
75
|
+
"parquet": "parquet",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_SAVE_TYPES = ("csv", "parquet")
|
|
79
|
+
|
|
80
|
+
_R_ONLY = (
|
|
81
|
+
"{path} is an .rds file, which only R can read. "
|
|
82
|
+
"Save it from R as Parquet or CSV to use it in Python, for example "
|
|
83
|
+
"data_save(df, 'inputs.intermediate.name', type = 'parquet')."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _separator(delimiter: str | None) -> str:
|
|
88
|
+
if delimiter is None:
|
|
89
|
+
return ","
|
|
90
|
+
if delimiter in DELIMITERS:
|
|
91
|
+
return DELIMITERS[delimiter]
|
|
92
|
+
if delimiter in DELIMITERS.values():
|
|
93
|
+
return delimiter
|
|
94
|
+
raise FrameworkError(
|
|
95
|
+
f"Unknown delimiter: {delimiter}. Must be one of: comma, tab, semicolon, space"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _require(module: str, extra: str, what: str) -> None:
|
|
100
|
+
try:
|
|
101
|
+
__import__(module)
|
|
102
|
+
except ImportError as error:
|
|
103
|
+
raise FrameworkError(
|
|
104
|
+
f"{what} need the {module} package. Install it with: pip install framework-py[{extra}]"
|
|
105
|
+
) from error
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _read_spss(path: Path, keep_attributes: bool, kwargs: dict[str, Any], portable: bool) -> Any:
|
|
109
|
+
_require("pyreadstat", "spss", "SPSS files")
|
|
110
|
+
import pyreadstat
|
|
111
|
+
|
|
112
|
+
reader = pyreadstat.read_por if portable else pyreadstat.read_sav
|
|
113
|
+
frame, _ = reader(str(path), apply_value_formats=keep_attributes, **kwargs)
|
|
114
|
+
return frame
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _reader(file_type: str) -> Callable[[Path, str | None, bool, dict[str, Any]], Any]:
|
|
118
|
+
def csv(path: Path, delimiter: str | None, _: bool, kwargs: dict[str, Any]) -> Any:
|
|
119
|
+
return pd.read_csv(path, sep=_separator(delimiter), **kwargs)
|
|
120
|
+
|
|
121
|
+
def tsv(path: Path, _d: str | None, _k: bool, kwargs: dict[str, Any]) -> Any:
|
|
122
|
+
return pd.read_csv(path, sep="\t", **kwargs)
|
|
123
|
+
|
|
124
|
+
def parquet(path: Path, _d: str | None, _k: bool, kwargs: dict[str, Any]) -> Any:
|
|
125
|
+
return pd.read_parquet(path, **kwargs)
|
|
126
|
+
|
|
127
|
+
def excel(path: Path, _d: str | None, _k: bool, kwargs: dict[str, Any]) -> Any:
|
|
128
|
+
if path.suffix.lower() == ".xlsx":
|
|
129
|
+
_require("openpyxl", "excel", "Excel files")
|
|
130
|
+
return pd.read_excel(path, **kwargs)
|
|
131
|
+
|
|
132
|
+
def stata(path: Path, _d: str | None, keep: bool, kwargs: dict[str, Any]) -> Any:
|
|
133
|
+
kwargs.setdefault("convert_categoricals", keep)
|
|
134
|
+
return pd.read_stata(path, **kwargs)
|
|
135
|
+
|
|
136
|
+
def spss(path: Path, _d: str | None, keep: bool, kwargs: dict[str, Any]) -> Any:
|
|
137
|
+
return _read_spss(path, keep, kwargs, portable=False)
|
|
138
|
+
|
|
139
|
+
def spss_por(path: Path, _d: str | None, keep: bool, kwargs: dict[str, Any]) -> Any:
|
|
140
|
+
return _read_spss(path, keep, kwargs, portable=True)
|
|
141
|
+
|
|
142
|
+
# Without an encoding, pandas returns SAS text columns as bytes.
|
|
143
|
+
def sas(path: Path, _d: str | None, _k: bool, kwargs: dict[str, Any]) -> Any:
|
|
144
|
+
kwargs.setdefault("encoding", "infer")
|
|
145
|
+
return pd.read_sas(path, format="sas7bdat", **kwargs)
|
|
146
|
+
|
|
147
|
+
def sas_xpt(path: Path, _d: str | None, _k: bool, kwargs: dict[str, Any]) -> Any:
|
|
148
|
+
# pandas reads only version 5 transport files. pyreadstat (the sas
|
|
149
|
+
# extra) also reads version 8, which R's haven writes by default.
|
|
150
|
+
try:
|
|
151
|
+
import pyreadstat
|
|
152
|
+
except ImportError:
|
|
153
|
+
if path.read_bytes()[:80].find(b"LIBV8") != -1:
|
|
154
|
+
raise FrameworkError(
|
|
155
|
+
"This is a version 8 SAS transport file, which needs the pyreadstat "
|
|
156
|
+
"package. Install it with: pip install framework-py[sas]"
|
|
157
|
+
) from None
|
|
158
|
+
kwargs.setdefault("encoding", "utf-8")
|
|
159
|
+
return pd.read_sas(path, format="xport", **kwargs)
|
|
160
|
+
frame, _ = pyreadstat.read_xport(str(path), **kwargs)
|
|
161
|
+
return frame
|
|
162
|
+
|
|
163
|
+
readers: dict[str, Callable[[Path, str | None, bool, dict[str, Any]], Any]] = {
|
|
164
|
+
"csv": csv,
|
|
165
|
+
"tsv": tsv,
|
|
166
|
+
"parquet": parquet,
|
|
167
|
+
"excel": excel,
|
|
168
|
+
"xlsx": excel,
|
|
169
|
+
"xls": excel,
|
|
170
|
+
"stata": stata,
|
|
171
|
+
"dta": stata,
|
|
172
|
+
"spss": spss,
|
|
173
|
+
"sav": spss,
|
|
174
|
+
"zsav": spss,
|
|
175
|
+
"spss_por": spss_por,
|
|
176
|
+
"por": spss_por,
|
|
177
|
+
"sas": sas,
|
|
178
|
+
"sas7bdat": sas,
|
|
179
|
+
"sas7bcat": sas,
|
|
180
|
+
"sas_xpt": sas_xpt,
|
|
181
|
+
"xpt": sas_xpt,
|
|
182
|
+
}
|
|
183
|
+
if file_type not in readers:
|
|
184
|
+
raise UnsupportedFormatError(f"Unsupported file type: {file_type}")
|
|
185
|
+
return readers[file_type]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _guess_delimiter(path: Path) -> str | None:
|
|
189
|
+
return {"csv": "comma", "tsv": "tab", "txt": "tab", "dat": "tab"}.get(
|
|
190
|
+
path.suffix.lower().lstrip(".")
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _check_integrity(name: str, info: DataInfo) -> None:
|
|
195
|
+
"""Compare the file with framework.db and record its current hash."""
|
|
196
|
+
current = file_hash(info.path)
|
|
197
|
+
record = db.get_data_record(name)
|
|
198
|
+
if record is not None and record.hash and record.hash != current:
|
|
199
|
+
detail = f"'{name}'\nExpected hash: {record.hash[:12]}\nCurrent hash: {current[:12]}"
|
|
200
|
+
if info.locked:
|
|
201
|
+
raise IntegrityError(f"Hash mismatch for locked data {detail}")
|
|
202
|
+
warnings.warn(f"File hash changed for {detail}", DataChangedWarning, stacklevel=3)
|
|
203
|
+
root = project_root()
|
|
204
|
+
db.set_data_record(
|
|
205
|
+
name,
|
|
206
|
+
path=relative_to_root(info.path, root),
|
|
207
|
+
type=info.type,
|
|
208
|
+
delimiter=info.delimiter,
|
|
209
|
+
locked=info.locked,
|
|
210
|
+
hash=current,
|
|
211
|
+
root=root,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def data_read(
|
|
216
|
+
path: str, delim: str | None = None, keep_attributes: bool = False, **kwargs: Any
|
|
217
|
+
) -> pd.DataFrame:
|
|
218
|
+
"""Read data by catalog name or file path.
|
|
219
|
+
|
|
220
|
+
Files found by catalog name are integrity-checked: Framework records the
|
|
221
|
+
file's SHA-256 in framework.db the first time and compares it on every
|
|
222
|
+
later read. A changed locked file raises ``IntegrityError``. A changed
|
|
223
|
+
unlocked file issues a ``DataChangedWarning``.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
path: A catalog name such as ``"inputs.raw.survey"``, or a file path.
|
|
227
|
+
delim: Delimiter for delimited text: comma, tab, semicolon, or space.
|
|
228
|
+
Default: the catalog's ``delimiter``, then the file extension.
|
|
229
|
+
keep_attributes: Keep value labels from Stata and SPSS files as
|
|
230
|
+
categories. By default the stored codes are returned, as in R.
|
|
231
|
+
**kwargs: Passed to the pandas (or pyreadstat) reader.
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
DataNotFoundError: The name is not in the catalog, or the file is missing.
|
|
235
|
+
UnsupportedFormatError: The file is an ``.rds`` file or another
|
|
236
|
+
format Python cannot read.
|
|
237
|
+
IntegrityError: A locked file changed since its hash was recorded.
|
|
238
|
+
"""
|
|
239
|
+
direct = Path(path)
|
|
240
|
+
if direct.is_file():
|
|
241
|
+
extension = direct.suffix.lower().lstrip(".")
|
|
242
|
+
if extension not in _DIRECT_TYPES:
|
|
243
|
+
raise UnsupportedFormatError(f"Unsupported file extension: {extension}")
|
|
244
|
+
info = DataInfo(path=direct.resolve(), type=_DIRECT_TYPES[extension])
|
|
245
|
+
tracked = False
|
|
246
|
+
else:
|
|
247
|
+
info = data_info(path)
|
|
248
|
+
tracked = True
|
|
249
|
+
|
|
250
|
+
if not info.path.is_file():
|
|
251
|
+
raise DataNotFoundError(f"File not found: {info.path}")
|
|
252
|
+
if info.type == "rds":
|
|
253
|
+
raise UnsupportedFormatError(_R_ONLY.format(path=info.path))
|
|
254
|
+
|
|
255
|
+
if tracked:
|
|
256
|
+
_check_integrity(path, info)
|
|
257
|
+
|
|
258
|
+
delimiter = delim or info.delimiter or _guess_delimiter(info.path)
|
|
259
|
+
if delimiter is None and info.type == "csv":
|
|
260
|
+
warnings.warn(
|
|
261
|
+
f"Could not determine delimiter from extension for {info.path}, defaulting to comma",
|
|
262
|
+
FrameworkWarning,
|
|
263
|
+
stacklevel=2,
|
|
264
|
+
)
|
|
265
|
+
try:
|
|
266
|
+
data: pd.DataFrame = _reader(info.type)(info.path, delimiter, keep_attributes, dict(kwargs))
|
|
267
|
+
except (FrameworkError, ImportError):
|
|
268
|
+
raise
|
|
269
|
+
except Exception as error:
|
|
270
|
+
raise FrameworkError(f"Failed to load data: {error}") from error
|
|
271
|
+
|
|
272
|
+
if info.description:
|
|
273
|
+
inform(f"Note: {path}: {info.description}")
|
|
274
|
+
return data
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _save_target(path: str, type: str | None) -> tuple[Path, str, bool]:
|
|
278
|
+
"""The file to write, its type, and whether ``path`` was a catalog name."""
|
|
279
|
+
has_slash = "/" in path or "\\" in path
|
|
280
|
+
extension = re.search(r"\.(csv|parquet|rds)$", path, re.IGNORECASE)
|
|
281
|
+
|
|
282
|
+
if has_slash or extension:
|
|
283
|
+
if type is None:
|
|
284
|
+
if extension is None:
|
|
285
|
+
raise FrameworkError(
|
|
286
|
+
"Cannot auto-detect file type from path. Please specify 'type'."
|
|
287
|
+
)
|
|
288
|
+
type = extension.group(1).lower()
|
|
289
|
+
target = Path(path)
|
|
290
|
+
if not path.lower().endswith(f".{type}"):
|
|
291
|
+
target = Path(f"{path}.{type}")
|
|
292
|
+
inform(f"Note: Added .{type} extension to path")
|
|
293
|
+
return target, type, False
|
|
294
|
+
|
|
295
|
+
parts = path.split(".")
|
|
296
|
+
if len(parts) == 1:
|
|
297
|
+
suffix = type or "parquet"
|
|
298
|
+
raise FrameworkError(
|
|
299
|
+
f"Path '{path}' has no directory. Either:\n"
|
|
300
|
+
f" - Use dot notation: 'inputs.raw.{path}' or 'inputs.intermediate.{path}'\n"
|
|
301
|
+
f" - Provide a full path: 'inputs/raw/{path}.{suffix}'"
|
|
302
|
+
)
|
|
303
|
+
if len(parts) < 3:
|
|
304
|
+
raise FrameworkError(
|
|
305
|
+
"Dot notation requires at least three parts (e.g., 'inputs.raw.filename')"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
config = settings_read()
|
|
309
|
+
directories = config.get("directories") or {}
|
|
310
|
+
key = f"{parts[0]}_{parts[1]}"
|
|
311
|
+
folder = directories.get(key) if isinstance(directories, dict) else None
|
|
312
|
+
if not isinstance(folder, str) or not folder:
|
|
313
|
+
available = ", ".join(k.replace("_", ".") for k in directories) or "none"
|
|
314
|
+
raise FrameworkError(
|
|
315
|
+
f"Unknown directory '{parts[0]}.{parts[1]}'. Use a configured directory.\n\n"
|
|
316
|
+
f"Available directories:\n {available}\n\n"
|
|
317
|
+
"Examples:\n data_save(df, 'inputs.raw.mydata')\n"
|
|
318
|
+
" data_save(df, 'outputs.private.results')"
|
|
319
|
+
)
|
|
320
|
+
type = type or "parquet"
|
|
321
|
+
target = in_root(folder, project_root()) / f"{'_'.join(parts[2:])}.{type}"
|
|
322
|
+
return target, type, True
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def data_save(
|
|
326
|
+
data: pd.DataFrame,
|
|
327
|
+
path: str,
|
|
328
|
+
type: str | None = None,
|
|
329
|
+
delimiter: str = "comma",
|
|
330
|
+
locked: bool = True,
|
|
331
|
+
force: bool = False,
|
|
332
|
+
) -> pd.DataFrame:
|
|
333
|
+
"""Save a DataFrame and record its hash.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
data: The data to save.
|
|
337
|
+
path: A catalog name such as ``"inputs.intermediate.cleaned"`` or a
|
|
338
|
+
file path ending in ``.csv`` or ``.parquet``. A name's first two
|
|
339
|
+
parts select a folder from ``directories`` (``inputs_intermediate``),
|
|
340
|
+
and the rest becomes the file name. A name is also added to the
|
|
341
|
+
data catalog, so ``data_read()`` finds it.
|
|
342
|
+
type: ``"parquet"`` or ``"csv"``. Default: from the file extension,
|
|
343
|
+
or Parquet for a catalog name.
|
|
344
|
+
delimiter: Delimiter for CSV files: comma, tab, semicolon, or space.
|
|
345
|
+
locked: Whether later reads must find the file unchanged.
|
|
346
|
+
force: Create the folder if it does not exist.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
``data``, so the call can end a pipeline.
|
|
350
|
+
"""
|
|
351
|
+
if not isinstance(data, pd.DataFrame):
|
|
352
|
+
raise FrameworkError("data_save() needs a pandas DataFrame")
|
|
353
|
+
target, file_type, is_name = _save_target(path, type)
|
|
354
|
+
if file_type == "rds":
|
|
355
|
+
raise UnsupportedFormatError(
|
|
356
|
+
".rds files are R-only. Save as 'parquet' or 'csv' so both languages can read it."
|
|
357
|
+
)
|
|
358
|
+
if file_type not in _SAVE_TYPES:
|
|
359
|
+
raise UnsupportedFormatError(f"data_save() writes csv or parquet, not {file_type}")
|
|
360
|
+
|
|
361
|
+
folder = target.parent
|
|
362
|
+
if not folder.is_dir():
|
|
363
|
+
if not force:
|
|
364
|
+
raise FrameworkError(
|
|
365
|
+
f"Directory '{folder}' does not exist. Use force=True to create it."
|
|
366
|
+
)
|
|
367
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
368
|
+
inform(f"Created directory: {folder}")
|
|
369
|
+
|
|
370
|
+
if file_type == "csv":
|
|
371
|
+
# "\n" on every platform, as readr writes, so hashes match R's.
|
|
372
|
+
data.to_csv(target, sep=_separator(delimiter), index=False, lineterminator="\n")
|
|
373
|
+
else:
|
|
374
|
+
data.to_parquet(target, index=False)
|
|
375
|
+
inform(f"Data saved to: {target}")
|
|
376
|
+
|
|
377
|
+
root = project_root()
|
|
378
|
+
digest = file_hash(target)
|
|
379
|
+
db.set_data_record(
|
|
380
|
+
path,
|
|
381
|
+
path=relative_to_root(target, root),
|
|
382
|
+
type=file_type,
|
|
383
|
+
delimiter=delimiter if file_type == "csv" else None,
|
|
384
|
+
locked=locked,
|
|
385
|
+
hash=digest,
|
|
386
|
+
root=root,
|
|
387
|
+
)
|
|
388
|
+
inform(f"Database updated: {path}")
|
|
389
|
+
|
|
390
|
+
# Mirror the digest to the project's cloud ledger in live mode only;
|
|
391
|
+
# framework.db stays the record.
|
|
392
|
+
from framework.cloud import ledger_push_quietly
|
|
393
|
+
|
|
394
|
+
ledger_push_quietly(path, digest, target)
|
|
395
|
+
|
|
396
|
+
if is_name:
|
|
397
|
+
try:
|
|
398
|
+
data_add(target, name=path, type=file_type, delimiter=delimiter, locked=locked)
|
|
399
|
+
except FrameworkError as error:
|
|
400
|
+
warnings.warn(
|
|
401
|
+
f"File saved but could not update data catalog for '{path}': {error}",
|
|
402
|
+
FrameworkWarning,
|
|
403
|
+
stacklevel=2,
|
|
404
|
+
)
|
|
405
|
+
return data
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _derive_name(file_path: Path, config: dict[str, Any], root: Path) -> str:
|
|
409
|
+
directories = config.get("directories") or {}
|
|
410
|
+
for key in ("inputs_raw", "inputs_intermediate", "inputs_final", "inputs_reference"):
|
|
411
|
+
folder = directories.get(key) if isinstance(directories, dict) else None
|
|
412
|
+
if not isinstance(folder, str):
|
|
413
|
+
continue
|
|
414
|
+
base = in_root(folder, root).resolve()
|
|
415
|
+
if file_path.is_relative_to(base):
|
|
416
|
+
return f"{key.replace('_', '.')}.{file_path.stem}"
|
|
417
|
+
inform(
|
|
418
|
+
f"Note: Using '{file_path.stem}' as data name. Consider specifying a more descriptive name."
|
|
419
|
+
)
|
|
420
|
+
return file_path.stem
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _catalog_set(name: str, entry: dict[str, Any]) -> None:
|
|
424
|
+
"""Write one catalog entry into settings.yml or the split file that holds ``data``."""
|
|
425
|
+
settings_file = find_settings_file()
|
|
426
|
+
if settings_file is None:
|
|
427
|
+
raise SettingsError("Configuration file 'settings.yml' (or legacy config.yml) not found")
|
|
428
|
+
raw = _yaml.read(settings_file) or {}
|
|
429
|
+
environment = "default" if isinstance(raw.get("default"), dict) else None
|
|
430
|
+
config = raw[environment] if environment else raw
|
|
431
|
+
source = config.get("data")
|
|
432
|
+
|
|
433
|
+
if isinstance(source, str):
|
|
434
|
+
split_path = in_root(source, settings_file.parent)
|
|
435
|
+
content = _yaml.read(split_path) if split_path.is_file() else None
|
|
436
|
+
current = content.get("data", content) if isinstance(content, dict) else None
|
|
437
|
+
else:
|
|
438
|
+
split_path = None
|
|
439
|
+
current = source
|
|
440
|
+
if not isinstance(current, dict):
|
|
441
|
+
current = {}
|
|
442
|
+
|
|
443
|
+
node = current
|
|
444
|
+
parts = name.split(".")
|
|
445
|
+
for part in parts[:-1]:
|
|
446
|
+
if not isinstance(node.get(part), dict):
|
|
447
|
+
node[part] = {}
|
|
448
|
+
node = node[part]
|
|
449
|
+
node[parts[-1]] = entry
|
|
450
|
+
|
|
451
|
+
if split_path is not None:
|
|
452
|
+
_yaml.write(split_path, {"data": current})
|
|
453
|
+
else:
|
|
454
|
+
config["data"] = current
|
|
455
|
+
_yaml.write(settings_file, raw)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def data_add(
|
|
459
|
+
file_path: str | Path,
|
|
460
|
+
name: str | None = None,
|
|
461
|
+
type: str | None = None,
|
|
462
|
+
delimiter: str = "comma",
|
|
463
|
+
locked: bool = True,
|
|
464
|
+
update_config: bool = True,
|
|
465
|
+
) -> dict[str, Any]:
|
|
466
|
+
"""Add an existing file to the data catalog and record its hash.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
file_path: The file to add. It must exist.
|
|
470
|
+
name: Catalog name. Default: from the folder (``inputs.raw.<file>``
|
|
471
|
+
for a file in ``inputs_raw``), else the file name.
|
|
472
|
+
type: File type. Default: from the extension.
|
|
473
|
+
delimiter: Delimiter for CSV files.
|
|
474
|
+
locked: Whether later reads must find the file unchanged.
|
|
475
|
+
update_config: Write the entry to the settings, not just framework.db.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
The catalog entry. Its path is relative to the project root, so the
|
|
479
|
+
project still works after it is moved or cloned.
|
|
480
|
+
"""
|
|
481
|
+
path = Path(file_path)
|
|
482
|
+
if not path.is_file():
|
|
483
|
+
raise DataNotFoundError(f"File not found: {file_path}")
|
|
484
|
+
path = path.resolve()
|
|
485
|
+
root = project_root()
|
|
486
|
+
if type is None:
|
|
487
|
+
extension = path.suffix.lower().lstrip(".")
|
|
488
|
+
if extension not in _ADD_TYPES:
|
|
489
|
+
raise UnsupportedFormatError(
|
|
490
|
+
f"Cannot auto-detect type for extension: {extension}. Please specify 'type'."
|
|
491
|
+
)
|
|
492
|
+
type = _ADD_TYPES[extension]
|
|
493
|
+
_separator(delimiter)
|
|
494
|
+
if name is None:
|
|
495
|
+
name = _derive_name(path, settings_read(), root)
|
|
496
|
+
|
|
497
|
+
relative = relative_to_root(path, root)
|
|
498
|
+
stored_delimiter = delimiter if type == "csv" else None
|
|
499
|
+
db.set_data_record(
|
|
500
|
+
name,
|
|
501
|
+
path=relative,
|
|
502
|
+
type=type,
|
|
503
|
+
delimiter=stored_delimiter,
|
|
504
|
+
locked=locked,
|
|
505
|
+
hash=file_hash(path),
|
|
506
|
+
root=root,
|
|
507
|
+
)
|
|
508
|
+
inform(f"Data registered in database: {name}")
|
|
509
|
+
|
|
510
|
+
entry: dict[str, Any] = {"path": relative, "type": type}
|
|
511
|
+
if stored_delimiter is not None:
|
|
512
|
+
entry["delimiter"] = delimiter
|
|
513
|
+
if locked:
|
|
514
|
+
entry["locked"] = True
|
|
515
|
+
if update_config:
|
|
516
|
+
_catalog_set(name, entry)
|
|
517
|
+
inform(f"Data spec added to config: {name}")
|
|
518
|
+
inform(f'\nYou can now read this data with: data_read("{name}")')
|
|
519
|
+
return entry
|