framework-py 0.1.0.dev0__py3-none-any.whl → 0.1.0.dev2__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/__main__.py +6 -1
- framework/_assets/hooks/pre-commit +14 -6
- framework/_assets/schema/docs-reference.v1.json +137 -0
- framework/_paths.py +10 -0
- framework/ai.py +4 -1
- framework/bootstrap.py +8 -2
- framework/cache.py +20 -2
- framework/catalog.py +72 -20
- framework/cloud.py +69 -8
- framework/config_home.py +19 -2
- framework/connections.py +44 -1
- framework/console.py +6 -1
- framework/create.py +59 -14
- framework/data.py +43 -23
- framework/global_config.py +65 -5
- framework/hooks.py +47 -2
- framework/notebooks.py +94 -4
- framework/outputs.py +16 -1
- framework/packages.py +19 -1
- framework/project.py +7 -0
- framework/quarto.py +44 -4
- framework/scaffold.py +17 -2
- framework/scratch.py +1 -1
- framework/security.py +13 -11
- framework/settings.py +56 -8
- framework/spec/check.py +10 -1
- framework/spec/resolve.py +7 -1
- framework/storage.py +94 -3
- {framework_py-0.1.0.dev0.dist-info → framework_py-0.1.0.dev2.dist-info}/METADATA +9 -2
- {framework_py-0.1.0.dev0.dist-info → framework_py-0.1.0.dev2.dist-info}/RECORD +32 -31
- {framework_py-0.1.0.dev0.dist-info → framework_py-0.1.0.dev2.dist-info}/WHEEL +0 -0
- {framework_py-0.1.0.dev0.dist-info → framework_py-0.1.0.dev2.dist-info}/licenses/LICENSE +0 -0
framework/connections.py
CHANGED
|
@@ -280,6 +280,13 @@ class Connection:
|
|
|
280
280
|
def db_connect(name: str) -> Connection:
|
|
281
281
|
"""Open a database connection defined in the settings.
|
|
282
282
|
|
|
283
|
+
Args:
|
|
284
|
+
name: The connection's name under ``connections`` in the settings.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
A ``Connection`` that wraps the driver's connection object. Use it in a ``with`` block
|
|
288
|
+
to close it afterward.
|
|
289
|
+
|
|
283
290
|
Raises:
|
|
284
291
|
FrameworkError: The connection is not configured, its driver package
|
|
285
292
|
is not installed, or connecting failed.
|
|
@@ -314,6 +321,18 @@ def db_query(
|
|
|
314
321
|
Pass values in ``params``, never by formatting them into the SQL. The
|
|
315
322
|
placeholder style is the driver's: ``?`` for sqlite, duckdb, and
|
|
316
323
|
sqlserver, and ``%s`` for postgres and mysql.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
query: The SQL query.
|
|
327
|
+
connection_name: The connection's name in the settings. The connection is opened for
|
|
328
|
+
this query and closed afterward.
|
|
329
|
+
params: Values for the query's placeholders, as a sequence or a mapping.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
The result rows, with the query's column names.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
FrameworkError: When the connection cannot be opened or the query fails.
|
|
317
336
|
"""
|
|
318
337
|
with db_connect(connection_name) as connection:
|
|
319
338
|
cursor = connection.cursor()
|
|
@@ -331,7 +350,21 @@ def db_query(
|
|
|
331
350
|
def db_execute(
|
|
332
351
|
query: str, connection_name: str, params: Sequence[Any] | Mapping[str, Any] | None = None
|
|
333
352
|
) -> int:
|
|
334
|
-
"""Run a statement, commit it, and return the number of rows affected.
|
|
353
|
+
"""Run a statement, commit it, and return the number of rows affected.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
query: The SQL statement.
|
|
357
|
+
connection_name: The connection's name in the settings. The connection is opened for
|
|
358
|
+
this statement and closed afterward.
|
|
359
|
+
params: Values for the statement's placeholders, as a sequence or a mapping. The
|
|
360
|
+
placeholder style is the driver's, as in ``db_query()``.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
The driver's count of affected rows, or -1 when the driver does not report one.
|
|
364
|
+
|
|
365
|
+
Raises:
|
|
366
|
+
FrameworkError: When the connection cannot be opened or the statement fails.
|
|
367
|
+
"""
|
|
335
368
|
with db_connect(connection_name) as connection:
|
|
336
369
|
cursor = connection.cursor()
|
|
337
370
|
try:
|
|
@@ -355,6 +388,16 @@ def db_transaction(connection: Connection) -> Iterator[Connection]:
|
|
|
355
388
|
|
|
356
389
|
with fw.db_connect("warehouse") as conn, fw.db_transaction(conn):
|
|
357
390
|
conn.cursor().execute("UPDATE ...")
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
connection: An open connection from ``db_connect()``.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
A context manager that yields ``connection``.
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
FrameworkError: When the block raises an exception. The transaction is rolled back and
|
|
400
|
+
the original exception is chained as the cause.
|
|
358
401
|
"""
|
|
359
402
|
if connection._in_transaction:
|
|
360
403
|
yield connection
|
framework/console.py
CHANGED
|
@@ -10,7 +10,12 @@ from dataclasses import dataclass, field
|
|
|
10
10
|
|
|
11
11
|
@dataclass
|
|
12
12
|
class CapturedOutput:
|
|
13
|
-
"""Text printed inside a ``capture_output()`` block, stdout and stderr together.
|
|
13
|
+
"""Text printed inside a ``capture_output()`` block, stdout and stderr together.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
output: Everything printed to stdout and stderr in the block, in order. It is empty
|
|
17
|
+
until the block ends.
|
|
18
|
+
"""
|
|
14
19
|
|
|
15
20
|
output: str = ""
|
|
16
21
|
_buffer: io.StringIO = field(default_factory=io.StringIO, repr=False)
|
framework/create.py
CHANGED
|
@@ -23,15 +23,15 @@ from typing import Any
|
|
|
23
23
|
from framework import _yaml, db
|
|
24
24
|
from framework._log import inform, show_messages
|
|
25
25
|
from framework.ai import write_ai_files
|
|
26
|
-
from framework.config_home import global_settings, project_register
|
|
26
|
+
from framework.config_home import config_dir, global_settings, project_register
|
|
27
27
|
from framework.errors import FrameworkError
|
|
28
28
|
from framework.quarto import quarto_generate_all
|
|
29
|
-
from framework.spec.check import spec_check
|
|
29
|
+
from framework.spec.check import ASSISTANTS, spec_check
|
|
30
30
|
from framework.spec.merge import SpecError
|
|
31
31
|
from framework.spec.resolve import TYPES, author_normalize, spec_resolve
|
|
32
32
|
|
|
33
33
|
CHECKOUT_ALLOWLIST = frozenset({".git", ".github", ".framework", "README.md", "LICENSE"})
|
|
34
|
-
|
|
34
|
+
FILE_LIKE = re.compile(r"\.(qmd|Rmd|R|md|py|ipynb)$")
|
|
35
35
|
_TEMPLATE_VARS = ("project_name", "author", "author_email", "affiliation", "date")
|
|
36
36
|
_SPLIT_TYPES = ("project", "project_sensitive")
|
|
37
37
|
# Lines that ignore .env (or re-include it with a leading "!"), as in R.
|
|
@@ -95,13 +95,13 @@ def _assert_checkout(root: Path) -> None:
|
|
|
95
95
|
|
|
96
96
|
def _create_directories(root: Path, spec: Mapping[str, Any]) -> None:
|
|
97
97
|
for path in (spec.get("directories") or {}).values():
|
|
98
|
-
if path and not path.startswith("outputs/") and not
|
|
98
|
+
if path and not path.startswith("outputs/") and not FILE_LIKE.search(path):
|
|
99
99
|
(root / path).mkdir(parents=True, exist_ok=True)
|
|
100
100
|
for entry in (spec.get("extra_directories") or {}).values():
|
|
101
101
|
if entry.get("path"):
|
|
102
102
|
(root / entry["path"]).mkdir(parents=True, exist_ok=True)
|
|
103
103
|
for key, path in (spec.get("render_dirs") or {}).items():
|
|
104
|
-
if path and "notebook" in key.lower() and not
|
|
104
|
+
if path and "notebook" in key.lower() and not FILE_LIKE.search(path):
|
|
105
105
|
(root / path).mkdir(parents=True, exist_ok=True)
|
|
106
106
|
|
|
107
107
|
|
|
@@ -401,6 +401,24 @@ def project_create_from_layers(
|
|
|
401
401
|
|
|
402
402
|
With ``interactive=False`` (the default) it runs headless for automation,
|
|
403
403
|
as ``project_create(headless=True)`` describes.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
layers_file: A JSON file with ``spec_version`` (which must be 1), ``name``,
|
|
407
|
+
``layers``, and optionally ``author`` and ``source``. The layers are resolved with
|
|
408
|
+
``spec_resolve()``.
|
|
409
|
+
path: The project folder. Default: the current directory. In headless mode it must be
|
|
410
|
+
a fresh checkout. Otherwise it must not exist.
|
|
411
|
+
interactive: Create the project in regular mode, with git initialization and a
|
|
412
|
+
registry entry. Nothing is prompted for either way. Default: False, which creates
|
|
413
|
+
it headless.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
The project folder.
|
|
417
|
+
|
|
418
|
+
Raises:
|
|
419
|
+
FrameworkError: When the layers file cannot be read, has an unsupported
|
|
420
|
+
``spec_version``, or has no name, or when the project cannot be created there.
|
|
421
|
+
SpecError: When the resolved spec is invalid.
|
|
404
422
|
"""
|
|
405
423
|
source = Path(layers_file)
|
|
406
424
|
try:
|
|
@@ -421,14 +439,12 @@ def project_create_from_layers(
|
|
|
421
439
|
return project_create(spec, path, headless=not interactive)
|
|
422
440
|
|
|
423
441
|
|
|
424
|
-
def
|
|
425
|
-
"""
|
|
442
|
+
def _defaults_layer(settings: Mapping[str, Any], type: str) -> dict[str, Any]:
|
|
443
|
+
"""A spec layer from settings in the global-settings format.
|
|
426
444
|
|
|
427
|
-
Reads ``author
|
|
428
|
-
git, and the directories
|
|
429
|
-
``config_dir()``.
|
|
445
|
+
Reads ``author``, the ``defaults`` section (scaffold, AI assistants, and
|
|
446
|
+
git), and the directories of this project type.
|
|
430
447
|
"""
|
|
431
|
-
settings = global_settings()
|
|
432
448
|
raw_defaults = settings.get("defaults")
|
|
433
449
|
defaults: dict[str, Any] = raw_defaults if isinstance(raw_defaults, dict) else {}
|
|
434
450
|
layer: dict[str, Any] = {}
|
|
@@ -446,8 +462,13 @@ def user_layer(type: str) -> dict[str, Any]:
|
|
|
446
462
|
layer["ai"] = {
|
|
447
463
|
key: value for key, value in (("enabled", ai.get("enabled")),) if value is not None
|
|
448
464
|
}
|
|
449
|
-
if isinstance(assistants, list):
|
|
450
|
-
|
|
465
|
+
if isinstance(assistants, list) and assistants:
|
|
466
|
+
# The list replaces the selection, as in R: assistants it leaves
|
|
467
|
+
# out (Claude Code included) are turned off.
|
|
468
|
+
selected = {name: True for name in assistants}
|
|
469
|
+
layer["ai"]["assistants"] = {name: False for name in ASSISTANTS} | selected
|
|
470
|
+
elif isinstance(assistants, dict):
|
|
471
|
+
layer["ai"]["assistants"] = assistants
|
|
451
472
|
git = defaults.get("git")
|
|
452
473
|
if isinstance(git, dict):
|
|
453
474
|
layer["git"] = {
|
|
@@ -462,6 +483,29 @@ def user_layer(type: str) -> dict[str, Any]:
|
|
|
462
483
|
return layer
|
|
463
484
|
|
|
464
485
|
|
|
486
|
+
def user_layer(type: str) -> dict[str, Any]:
|
|
487
|
+
"""The user's defaults from the global settings, as a spec layer.
|
|
488
|
+
|
|
489
|
+
Reads ``author`` and the ``defaults`` section (scaffold, AI assistants,
|
|
490
|
+
git, and the directories for this project type) of ``settings.yml`` in
|
|
491
|
+
``config_dir()``. A list in ``defaults.ai.assistants`` replaces the
|
|
492
|
+
default selection.
|
|
493
|
+
"""
|
|
494
|
+
return _defaults_layer(global_settings(), type)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def catalog_layer(type: str) -> dict[str, Any]:
|
|
498
|
+
"""The user's settings catalog, ``settings-catalog.yml`` in ``config_dir()``, as a spec layer.
|
|
499
|
+
|
|
500
|
+
R's ``new()`` starts from the settings catalog with this file merged over
|
|
501
|
+
it, so Python applies it too, below the global settings. Its keys are read
|
|
502
|
+
as in the global settings. Without the file the layer is empty.
|
|
503
|
+
"""
|
|
504
|
+
path = config_dir() / "settings-catalog.yml"
|
|
505
|
+
catalog = _yaml.read(path) if path.is_file() else None
|
|
506
|
+
return _defaults_layer(catalog, type) if isinstance(catalog, dict) else {}
|
|
507
|
+
|
|
508
|
+
|
|
465
509
|
def new(
|
|
466
510
|
name: str | None = None,
|
|
467
511
|
location: str | Path | None = None,
|
|
@@ -510,6 +554,7 @@ def new(
|
|
|
510
554
|
raise FrameworkError(f"Invalid project type. Must be one of: {', '.join(TYPES)}")
|
|
511
555
|
|
|
512
556
|
layers: Sequence[Any] = [
|
|
557
|
+
{"kind": "user", "key": "settings-catalog", "layer": catalog_layer(type)},
|
|
513
558
|
{"kind": "user", "layer": user_layer(type)},
|
|
514
559
|
{"kind": "project", "layer": {"type": type, "language": "python"}},
|
|
515
560
|
]
|
|
@@ -559,7 +604,7 @@ def project_add_directory(project_path: str | Path, key: str, label: str, path:
|
|
|
559
604
|
"""
|
|
560
605
|
from framework.spec.check import is_safe_path
|
|
561
606
|
|
|
562
|
-
root = Path(project_path)
|
|
607
|
+
root = Path(project_path).expanduser()
|
|
563
608
|
if not is_safe_path(path):
|
|
564
609
|
raise FrameworkError(f"Path must be relative and stay inside the project: {path}")
|
|
565
610
|
settings_file = next(
|
framework/data.py
CHANGED
|
@@ -22,7 +22,7 @@ import pandas as pd
|
|
|
22
22
|
from framework import _yaml, db
|
|
23
23
|
from framework._log import inform
|
|
24
24
|
from framework._paths import in_root, relative_to_root
|
|
25
|
-
from framework.catalog import DataInfo, data_info
|
|
25
|
+
from framework.catalog import DataInfo, catalog_locate, data_info
|
|
26
26
|
from framework.errors import (
|
|
27
27
|
DataChangedWarning,
|
|
28
28
|
DataNotFoundError,
|
|
@@ -57,7 +57,9 @@ _DIRECT_TYPES = {
|
|
|
57
57
|
"parquet": "parquet",
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
# Extension -> type recorded by data_add()
|
|
60
|
+
# Extension -> type recorded by data_add(). R's data_add() records .por as
|
|
61
|
+
# spss and .xpt as sas, which its own data_read() then opens with the wrong
|
|
62
|
+
# reader. Python records the reader types, which R reads too.
|
|
61
63
|
_ADD_TYPES = {
|
|
62
64
|
"csv": "csv",
|
|
63
65
|
"tsv": "tsv",
|
|
@@ -69,12 +71,16 @@ _ADD_TYPES = {
|
|
|
69
71
|
"dta": "stata",
|
|
70
72
|
"sav": "spss",
|
|
71
73
|
"zsav": "spss",
|
|
72
|
-
"por": "
|
|
74
|
+
"por": "spss_por",
|
|
73
75
|
"sas7bdat": "sas",
|
|
74
|
-
"xpt": "
|
|
76
|
+
"xpt": "sas_xpt",
|
|
75
77
|
"parquet": "parquet",
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
# Catalog entries R's data_add() wrote for these extensions carry the generic
|
|
81
|
+
# type, so the extension picks the reader.
|
|
82
|
+
_GENERIC_TYPES = {("spss", ".por"): "spss_por", ("sas", ".xpt"): "sas_xpt"}
|
|
83
|
+
|
|
78
84
|
_SAVE_TYPES = ("csv", "parquet")
|
|
79
85
|
|
|
80
86
|
_R_ONLY = (
|
|
@@ -236,7 +242,7 @@ def data_read(
|
|
|
236
242
|
format Python cannot read.
|
|
237
243
|
IntegrityError: A locked file changed since its hash was recorded.
|
|
238
244
|
"""
|
|
239
|
-
direct = Path(path)
|
|
245
|
+
direct = Path(path).expanduser()
|
|
240
246
|
if direct.is_file():
|
|
241
247
|
extension = direct.suffix.lower().lstrip(".")
|
|
242
248
|
if extension not in _DIRECT_TYPES:
|
|
@@ -262,8 +268,9 @@ def data_read(
|
|
|
262
268
|
FrameworkWarning,
|
|
263
269
|
stacklevel=2,
|
|
264
270
|
)
|
|
271
|
+
file_type = _GENERIC_TYPES.get((info.type, info.path.suffix.lower()), info.type)
|
|
265
272
|
try:
|
|
266
|
-
data: pd.DataFrame = _reader(
|
|
273
|
+
data: pd.DataFrame = _reader(file_type)(info.path, delimiter, keep_attributes, dict(kwargs))
|
|
267
274
|
except (FrameworkError, ImportError):
|
|
268
275
|
raise
|
|
269
276
|
except Exception as error:
|
|
@@ -306,10 +313,17 @@ def _save_target(path: str, type: str | None) -> tuple[Path, str, bool]:
|
|
|
306
313
|
)
|
|
307
314
|
|
|
308
315
|
config = settings_read()
|
|
309
|
-
directories = config.get("directories")
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
316
|
+
directories = config.get("directories")
|
|
317
|
+
if not isinstance(directories, dict):
|
|
318
|
+
directories = {}
|
|
319
|
+
# The longest run of leading parts that names a directory key selects the
|
|
320
|
+
# folder, so inputs.private.raw.x uses inputs_private_raw. R joins only
|
|
321
|
+
# the first two parts, so it cannot reach three-part keys.
|
|
322
|
+
for end in range(len(parts) - 1, 1, -1):
|
|
323
|
+
folder = directories.get("_".join(parts[:end]))
|
|
324
|
+
if isinstance(folder, str) and folder:
|
|
325
|
+
break
|
|
326
|
+
else:
|
|
313
327
|
available = ", ".join(k.replace("_", ".") for k in directories) or "none"
|
|
314
328
|
raise FrameworkError(
|
|
315
329
|
f"Unknown directory '{parts[0]}.{parts[1]}'. Use a configured directory.\n\n"
|
|
@@ -318,7 +332,7 @@ def _save_target(path: str, type: str | None) -> tuple[Path, str, bool]:
|
|
|
318
332
|
" data_save(df, 'outputs.private.results')"
|
|
319
333
|
)
|
|
320
334
|
type = type or "parquet"
|
|
321
|
-
target = in_root(folder, project_root()) / f"{'_'.join(parts[
|
|
335
|
+
target = in_root(folder, project_root()) / f"{'_'.join(parts[end:])}.{type}"
|
|
322
336
|
return target, type, True
|
|
323
337
|
|
|
324
338
|
|
|
@@ -335,10 +349,11 @@ def data_save(
|
|
|
335
349
|
Args:
|
|
336
350
|
data: The data to save.
|
|
337
351
|
path: A catalog name such as ``"inputs.intermediate.cleaned"`` or a
|
|
338
|
-
file path ending in ``.csv`` or ``.parquet``. A name's
|
|
339
|
-
parts select a folder from ``directories
|
|
340
|
-
|
|
341
|
-
|
|
352
|
+
file path ending in ``.csv`` or ``.parquet``. A name's leading
|
|
353
|
+
parts select a folder from ``directories``: the longest run that
|
|
354
|
+
names a key, such as ``inputs_intermediate`` or
|
|
355
|
+
``inputs_private_raw``. The rest becomes the file name. A name is
|
|
356
|
+
also added to the data catalog, so ``data_read()`` finds it.
|
|
342
357
|
type: ``"parquet"`` or ``"csv"``. Default: from the file extension,
|
|
343
358
|
or Parquet for a catalog name.
|
|
344
359
|
delimiter: Delimiter for CSV files: comma, tab, semicolon, or space.
|
|
@@ -440,13 +455,18 @@ def _catalog_set(name: str, entry: dict[str, Any]) -> None:
|
|
|
440
455
|
if not isinstance(current, dict):
|
|
441
456
|
current = {}
|
|
442
457
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
node =
|
|
449
|
-
|
|
458
|
+
# An entry already written with dotted keys is updated where it is.
|
|
459
|
+
existing = catalog_locate(name, current)
|
|
460
|
+
if existing is not None:
|
|
461
|
+
existing[0][existing[1]] = entry
|
|
462
|
+
else:
|
|
463
|
+
node = current
|
|
464
|
+
parts = name.split(".")
|
|
465
|
+
for part in parts[:-1]:
|
|
466
|
+
if not isinstance(node.get(part), dict):
|
|
467
|
+
node[part] = {}
|
|
468
|
+
node = node[part]
|
|
469
|
+
node[parts[-1]] = entry
|
|
450
470
|
|
|
451
471
|
if split_path is not None:
|
|
452
472
|
_yaml.write(split_path, {"data": current})
|
|
@@ -478,7 +498,7 @@ def data_add(
|
|
|
478
498
|
The catalog entry. Its path is relative to the project root, so the
|
|
479
499
|
project still works after it is moved or cloned.
|
|
480
500
|
"""
|
|
481
|
-
path = Path(file_path)
|
|
501
|
+
path = Path(file_path).expanduser()
|
|
482
502
|
if not path.is_file():
|
|
483
503
|
raise DataNotFoundError(f"File not found: {file_path}")
|
|
484
504
|
path = path.resolve()
|
framework/global_config.py
CHANGED
|
@@ -143,12 +143,30 @@ _GITIGNORE = {
|
|
|
143
143
|
|
|
144
144
|
|
|
145
145
|
def load_settings_catalog(include_user: bool = True, validate: bool = True) -> dict[str, Any]:
|
|
146
|
-
"""
|
|
146
|
+
"""Framework's package defaults, with the user's settings catalog merged over them.
|
|
147
147
|
|
|
148
148
|
The catalog has ``defaults`` (Framework's package defaults, layer 0 of
|
|
149
149
|
``spec_resolve()``) and ``project_types``, each with its ``.gitignore``
|
|
150
150
|
text. With ``include_user``, ``settings-catalog.yml`` in ``config_dir()``
|
|
151
151
|
is merged over it.
|
|
152
|
+
|
|
153
|
+
``new()`` starts from the same package defaults, applies
|
|
154
|
+
``settings-catalog.yml`` over them as R's ``new()`` does, and then
|
|
155
|
+
applies the global ``settings.yml``. It reads ``author``, ``defaults.scaffold``,
|
|
156
|
+
``defaults.ai``, ``defaults.git``, and ``project_types.<type>.directories``
|
|
157
|
+
from the file, with the same keys as the global settings.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
include_user: Merge ``settings-catalog.yml`` from ``config_dir()`` over the shipped
|
|
161
|
+
catalog when that file exists.
|
|
162
|
+
validate: Check that the result has ``defaults`` and ``project_types`` mappings.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
The catalog, with ``meta``, ``defaults``, and ``project_types`` keys.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
SettingsError: When ``validate`` is true and ``defaults`` or ``project_types`` is
|
|
169
|
+
missing or not a mapping.
|
|
152
170
|
"""
|
|
153
171
|
shipped = files("framework").joinpath("templates")
|
|
154
172
|
defaults = _yaml.load(shipped.joinpath("defaults.yml").read_text(encoding="utf-8")) or {}
|
|
@@ -208,12 +226,32 @@ def _template_file(name: str) -> str:
|
|
|
208
226
|
|
|
209
227
|
|
|
210
228
|
def framework_template_path(name: str) -> Path:
|
|
211
|
-
"""The file your version of a template is saved in (it may not exist yet).
|
|
229
|
+
"""The file your version of a template is saved in (it may not exist yet).
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
name: The template. One of "notebook", "script", or "presentation".
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
The path in ``templates_dir()`` for that template.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
FrameworkError: When ``name`` is not a known template.
|
|
239
|
+
"""
|
|
212
240
|
return templates_dir() / _template_file(name)
|
|
213
241
|
|
|
214
242
|
|
|
215
243
|
def read_framework_template(name: str) -> str:
|
|
216
|
-
"""A template's text: yours when you saved one, otherwise Framework's.
|
|
244
|
+
"""A template's text: yours when you saved one, otherwise Framework's.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
name: The template. One of "notebook", "script", or "presentation".
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
The template's text.
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
FrameworkError: When ``name`` is not a known template.
|
|
254
|
+
"""
|
|
217
255
|
path = framework_template_path(name)
|
|
218
256
|
if path.is_file():
|
|
219
257
|
return path.read_text(encoding="utf-8")
|
|
@@ -222,7 +260,19 @@ def read_framework_template(name: str) -> str:
|
|
|
222
260
|
|
|
223
261
|
|
|
224
262
|
def write_framework_template(name: str, contents: str) -> Path:
|
|
225
|
-
"""Save your version of a template. Returns its path.
|
|
263
|
+
"""Save your version of a template. Returns its path.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
name: The template. One of "notebook", "script", or "presentation".
|
|
267
|
+
contents: The template's text. A trailing newline is added when it has none.
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
The path the template was written to.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
FrameworkError: When ``name`` is not a known template.
|
|
274
|
+
TypeError: When ``contents`` is not a string.
|
|
275
|
+
"""
|
|
226
276
|
if not isinstance(contents, str):
|
|
227
277
|
raise TypeError("contents must be text")
|
|
228
278
|
path = framework_template_path(name)
|
|
@@ -232,7 +282,17 @@ def write_framework_template(name: str, contents: str) -> Path:
|
|
|
232
282
|
|
|
233
283
|
|
|
234
284
|
def reset_framework_template(name: str) -> Path:
|
|
235
|
-
"""Remove your version of a template, so Framework's is used again.
|
|
285
|
+
"""Remove your version of a template, so Framework's is used again.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
name: The template. One of "notebook", "script", or "presentation".
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
The path of the removed file. Nothing fails when it did not exist.
|
|
292
|
+
|
|
293
|
+
Raises:
|
|
294
|
+
FrameworkError: When ``name`` is not a known template.
|
|
295
|
+
"""
|
|
236
296
|
path = framework_template_path(name)
|
|
237
297
|
path.unlink(missing_ok=True)
|
|
238
298
|
return path
|
framework/hooks.py
CHANGED
|
@@ -59,7 +59,7 @@ def hook_script() -> str:
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
def _settings_path(settings_file: str | Path | None) -> Path:
|
|
62
|
-
path = Path(settings_file) if settings_file is not None else find_settings_file()
|
|
62
|
+
path = Path(settings_file).expanduser() if settings_file is not None else find_settings_file()
|
|
63
63
|
if path is None or not path.is_file():
|
|
64
64
|
raise FrameworkError("Settings file not found (expected settings.yml or config.yml)")
|
|
65
65
|
return path.resolve()
|
|
@@ -150,8 +150,18 @@ def git_hooks_install(
|
|
|
150
150
|
A pre-commit hook Framework wrote earlier (by R or Python) is replaced. Any
|
|
151
151
|
other pre-commit hook is kept unless ``force`` is True.
|
|
152
152
|
|
|
153
|
+
Args:
|
|
154
|
+
settings_file: The project's settings file. Default: the settings.yml or config.yml
|
|
155
|
+
found from the current directory.
|
|
156
|
+
force: Replace a pre-commit hook that Framework did not write.
|
|
157
|
+
verbose: Print what was done.
|
|
158
|
+
|
|
153
159
|
Returns:
|
|
154
|
-
Whether the hook was installed.
|
|
160
|
+
Whether the hook was installed. False outside a git repository, when no check is
|
|
161
|
+
enabled, or when another hook is kept.
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
FrameworkError: When the settings file is not found.
|
|
155
165
|
"""
|
|
156
166
|
path = _settings_path(settings_file)
|
|
157
167
|
root = path.parent
|
|
@@ -181,6 +191,12 @@ def git_hooks_install(
|
|
|
181
191
|
def git_hooks_uninstall(settings_file: str | Path | None = None, *, verbose: bool = True) -> bool:
|
|
182
192
|
"""Remove Framework's pre-commit hook. A hook Framework did not write is kept.
|
|
183
193
|
|
|
194
|
+
Args:
|
|
195
|
+
settings_file: The project's settings file, whose folder holds the repository.
|
|
196
|
+
Default: the folder of the settings file found from the current directory, or the
|
|
197
|
+
current directory when there is none.
|
|
198
|
+
verbose: Print what was done.
|
|
199
|
+
|
|
184
200
|
Returns:
|
|
185
201
|
Whether a hook was removed.
|
|
186
202
|
"""
|
|
@@ -207,8 +223,20 @@ def git_hooks_enable(
|
|
|
207
223
|
) -> bool:
|
|
208
224
|
"""Turn on one hook check in the settings and (re)install the hook.
|
|
209
225
|
|
|
226
|
+
Args:
|
|
227
|
+
hook_name: The check. One of "data_security" or "check_sensitive_dirs". The older
|
|
228
|
+
name "warn_unignored_sensitive" is accepted for "check_sensitive_dirs".
|
|
229
|
+
settings_file: The project's settings file. Default: the settings.yml or config.yml
|
|
230
|
+
found from the current directory.
|
|
231
|
+
verbose: Print what was done.
|
|
232
|
+
|
|
210
233
|
Returns:
|
|
211
234
|
Whether the hook is installed.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
ValueError: When ``hook_name`` is not a known check, including the removed
|
|
238
|
+
"ai_sync".
|
|
239
|
+
FrameworkError: When the settings file is not found.
|
|
212
240
|
"""
|
|
213
241
|
name = _hook_name(hook_name)
|
|
214
242
|
path = _settings_path(settings_file)
|
|
@@ -225,8 +253,21 @@ def git_hooks_disable(
|
|
|
225
253
|
``"ai_sync"`` is accepted so older settings can be cleaned up: it is set
|
|
226
254
|
to false.
|
|
227
255
|
|
|
256
|
+
Args:
|
|
257
|
+
hook_name: The check. One of "data_security", "check_sensitive_dirs", or
|
|
258
|
+
"ai_sync". The older name "warn_unignored_sensitive" is accepted for
|
|
259
|
+
"check_sensitive_dirs", and turning that check off also turns off the older
|
|
260
|
+
setting.
|
|
261
|
+
settings_file: The project's settings file. Default: the settings.yml or config.yml
|
|
262
|
+
found from the current directory.
|
|
263
|
+
verbose: Print what was done.
|
|
264
|
+
|
|
228
265
|
Returns:
|
|
229
266
|
Whether the hook is still installed.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
ValueError: When ``hook_name`` is not a known check.
|
|
270
|
+
FrameworkError: When the settings file is not found.
|
|
230
271
|
"""
|
|
231
272
|
name = "ai_sync" if hook_name == "ai_sync" else _hook_name(hook_name)
|
|
232
273
|
path = _settings_path(settings_file)
|
|
@@ -247,6 +288,10 @@ def git_hooks_disable(
|
|
|
247
288
|
def git_hooks_list(settings_file: str | Path | None = None) -> pd.DataFrame:
|
|
248
289
|
"""The hook checks and whether each is enabled.
|
|
249
290
|
|
|
291
|
+
Args:
|
|
292
|
+
settings_file: The project's settings file. Default: the settings.yml or config.yml
|
|
293
|
+
found from the current directory.
|
|
294
|
+
|
|
250
295
|
Returns:
|
|
251
296
|
A DataFrame with columns hook, enabled, and description. Its
|
|
252
297
|
``attrs["installed"]`` says whether the pre-commit hook is installed.
|