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.
Files changed (63) hide show
  1. framework/__init__.py +250 -0
  2. framework/__main__.py +89 -0
  3. framework/_assets/hooks/pre-commit +59 -0
  4. framework/_assets/init.sql +63 -0
  5. framework/_assets/schema/layer.v1.json +605 -0
  6. framework/_assets/schema/layers.v1.json +791 -0
  7. framework/_assets/schema/spec.v1.json +604 -0
  8. framework/_log.py +29 -0
  9. framework/_paths.py +45 -0
  10. framework/_secretservice.py +69 -0
  11. framework/_time.py +11 -0
  12. framework/_wincred.py +105 -0
  13. framework/_yaml.py +90 -0
  14. framework/ai.py +204 -0
  15. framework/bootstrap.py +206 -0
  16. framework/cache.py +335 -0
  17. framework/catalog.py +193 -0
  18. framework/cloud.py +899 -0
  19. framework/config_home.py +194 -0
  20. framework/connections.py +442 -0
  21. framework/console.py +41 -0
  22. framework/create.py +579 -0
  23. framework/data.py +519 -0
  24. framework/db.py +222 -0
  25. framework/dotenv.py +79 -0
  26. framework/errors.py +39 -0
  27. framework/global_config.py +238 -0
  28. framework/hooks.py +364 -0
  29. framework/integrity.py +22 -0
  30. framework/notebooks.py +384 -0
  31. framework/outputs.py +324 -0
  32. framework/packages.py +276 -0
  33. framework/project.py +52 -0
  34. framework/py.typed +0 -0
  35. framework/quarto.py +163 -0
  36. framework/scaffold.py +269 -0
  37. framework/scratch.py +108 -0
  38. framework/security.py +646 -0
  39. framework/settings.py +439 -0
  40. framework/spec/__init__.py +0 -0
  41. framework/spec/check.py +211 -0
  42. framework/spec/merge.py +195 -0
  43. framework/spec/resolve.py +254 -0
  44. framework/storage.py +516 -0
  45. framework/templates/defaults.yml +144 -0
  46. framework/templates/gitignore/course +120 -0
  47. framework/templates/gitignore/presentation +114 -0
  48. framework/templates/gitignore/project +108 -0
  49. framework/templates/gitignore/sensitive +139 -0
  50. framework/templates/skills/framework-data/SKILL.md +47 -0
  51. framework/templates/skills/framework-outputs/SKILL.md +68 -0
  52. framework/templates/skills/framework-packages/SKILL.md +33 -0
  53. framework/templates/skills/framework-sensitive-data/SKILL.md +38 -0
  54. framework/templates/skills/framework-workflow/SKILL.md +58 -0
  55. framework/templates/stubs/notebook-default.ipynb +34 -0
  56. framework/templates/stubs/notebook-default.qmd +43 -0
  57. framework/templates/stubs/notebook-minimal.qmd +15 -0
  58. framework/templates/stubs/notebook-revealjs.qmd +34 -0
  59. framework/templates/stubs/script-default.py +18 -0
  60. framework_py-0.1.0.dev0.dist-info/METADATA +111 -0
  61. framework_py-0.1.0.dev0.dist-info/RECORD +63 -0
  62. framework_py-0.1.0.dev0.dist-info/WHEEL +4 -0
  63. 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