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/__main__.py
CHANGED
|
@@ -37,7 +37,12 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
37
37
|
create.add_argument(
|
|
38
38
|
"--interactive",
|
|
39
39
|
action="store_true",
|
|
40
|
-
help=
|
|
40
|
+
help=(
|
|
41
|
+
"with --name, ask for the type when --type is missing (the project is "
|
|
42
|
+
"registered and gets a git repository either way, unless your defaults turn "
|
|
43
|
+
"git off); with --from-layers, initialize git and register the project "
|
|
44
|
+
"instead of running headless"
|
|
45
|
+
),
|
|
41
46
|
)
|
|
42
47
|
|
|
43
48
|
bootstrap = commands.add_parser("bootstrap", help="prepare a cloned project")
|
|
@@ -12,23 +12,31 @@ set -u
|
|
|
12
12
|
|
|
13
13
|
cd "$(git rev-parse --show-toplevel)" || exit 1
|
|
14
14
|
|
|
15
|
+
# Each runner returns 127 when its runtime or the framework package is
|
|
16
|
+
# missing, so the next runtime gets a turn and the message below can say why
|
|
17
|
+
|
|
15
18
|
run_r() {
|
|
16
19
|
command -v Rscript >/dev/null 2>&1 || return 127
|
|
20
|
+
# framework 1.x from CRAN has no shared hook entry point
|
|
21
|
+
probe='ok <- requireNamespace("framework", quietly = TRUE) && exists(".hook_run", envir = asNamespace("framework"))'
|
|
22
|
+
Rscript -e "$probe; quit(status = if (ok) 0 else 127)" >/dev/null 2>&1 || return 127
|
|
17
23
|
Rscript -e 'quit(status = framework:::.hook_run("pre-commit"))'
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
run_python() {
|
|
21
27
|
if [ -x .venv/bin/python ]; then
|
|
22
|
-
.venv/bin/python
|
|
28
|
+
set -- .venv/bin/python
|
|
23
29
|
elif [ -x .venv/Scripts/python.exe ]; then
|
|
24
|
-
.venv/Scripts/python.exe
|
|
30
|
+
set -- .venv/Scripts/python.exe
|
|
25
31
|
elif [ -f uv.lock ] && command -v uv >/dev/null 2>&1; then
|
|
26
|
-
uv run --frozen python
|
|
32
|
+
set -- uv run --frozen python
|
|
27
33
|
elif command -v python3 >/dev/null 2>&1; then
|
|
28
|
-
|
|
34
|
+
set -- python3
|
|
29
35
|
else
|
|
30
36
|
return 127
|
|
31
37
|
fi
|
|
38
|
+
"$@" -c 'import framework' >/dev/null 2>&1 || return 127
|
|
39
|
+
"$@" -m framework hook pre-commit
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
has_rproj() {
|
|
@@ -52,8 +60,8 @@ if [ "$status" -eq 127 ] && { [ -f pyproject.toml ] || [ -f uv.lock ] || [ -d .v
|
|
|
52
60
|
fi
|
|
53
61
|
|
|
54
62
|
if [ "$status" -eq 127 ]; then
|
|
55
|
-
echo "Framework hook:
|
|
56
|
-
echo "Install one, or run 'git commit --no-verify' if you are sure this commit is safe." >&2
|
|
63
|
+
echo "Framework hook: found neither R with framework 2.0 or later nor Python with framework-py." >&2
|
|
64
|
+
echo "Install one of them, or run 'git commit --no-verify' if you are sure this commit is safe." >&2
|
|
57
65
|
exit 1
|
|
58
66
|
fi
|
|
59
67
|
exit "$status"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://framework.pub/schema/docs-reference.v1.json",
|
|
4
|
+
"title": "Framework docs reference",
|
|
5
|
+
"description": "The generated function or command reference for one product's docs site (docs/site/reference.json). Text fields hold CommonMark with GFM tables.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schema", "product", "version", "categories", "entries"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema": { "const": "docs-reference.v1" },
|
|
11
|
+
"product": { "enum": ["r", "py", "cli"] },
|
|
12
|
+
"version": { "type": "string", "minLength": 1, "description": "The release this reference was built from, such as 2.0.0 or 0.1.0.dev1." },
|
|
13
|
+
"categories": {
|
|
14
|
+
"type": "array",
|
|
15
|
+
"items": { "$ref": "#/$defs/category" }
|
|
16
|
+
},
|
|
17
|
+
"entries": {
|
|
18
|
+
"type": "array",
|
|
19
|
+
"items": { "$ref": "#/$defs/entry" }
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"$defs": {
|
|
23
|
+
"id": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*$",
|
|
26
|
+
"description": "URL-safe and unique within the product: /reference/{id}."
|
|
27
|
+
},
|
|
28
|
+
"markdown": { "type": "string" },
|
|
29
|
+
"category": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"required": ["id", "title", "position"],
|
|
32
|
+
"additionalProperties": false,
|
|
33
|
+
"properties": {
|
|
34
|
+
"id": { "$ref": "#/$defs/id" },
|
|
35
|
+
"title": { "type": "string", "minLength": 1 },
|
|
36
|
+
"description": { "type": "string", "description": "One line, shown under the title in the Reference dropdown." },
|
|
37
|
+
"position": { "type": "integer" },
|
|
38
|
+
"common": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"items": { "type": "string" },
|
|
41
|
+
"description": "Entry names listed first, under a Common heading."
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"titled": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"required": ["title", "content"],
|
|
48
|
+
"additionalProperties": false,
|
|
49
|
+
"properties": {
|
|
50
|
+
"title": { "type": "string", "minLength": 1 },
|
|
51
|
+
"content": { "$ref": "#/$defs/markdown" }
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"entry": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"required": ["id", "name", "kind", "category", "summary"],
|
|
57
|
+
"additionalProperties": false,
|
|
58
|
+
"properties": {
|
|
59
|
+
"id": { "$ref": "#/$defs/id" },
|
|
60
|
+
"name": { "type": "string", "minLength": 1, "description": "As users write it: data_read, AuditResult, or \"project bootstrap\"." },
|
|
61
|
+
"kind": { "enum": ["function", "class", "command"] },
|
|
62
|
+
"category": { "type": "string", "description": "A category id." },
|
|
63
|
+
"title": { "type": "string", "description": "A short heading. Defaults to name." },
|
|
64
|
+
"summary": { "type": "string", "minLength": 1, "description": "One line, used in lists and search results." },
|
|
65
|
+
"usage": { "type": "string", "description": "The signature or command line, shown as code." },
|
|
66
|
+
"description": { "$ref": "#/$defs/markdown", "description": "A paragraph or two (roxygen @description, a docstring's opening paragraphs)." },
|
|
67
|
+
"details": { "$ref": "#/$defs/markdown", "description": "The long body (roxygen @details)." },
|
|
68
|
+
"parameters": {
|
|
69
|
+
"type": "array",
|
|
70
|
+
"description": "Arguments, flags, or a class's fields, in signature order.",
|
|
71
|
+
"items": {
|
|
72
|
+
"type": "object",
|
|
73
|
+
"required": ["name", "description"],
|
|
74
|
+
"additionalProperties": false,
|
|
75
|
+
"properties": {
|
|
76
|
+
"name": { "type": "string", "minLength": 1, "description": "x, *, --path, or -p, --path." },
|
|
77
|
+
"type": { "type": "string" },
|
|
78
|
+
"default": { "type": "string", "description": "As code, such as NULL, None, or \".\"." },
|
|
79
|
+
"required": { "type": "boolean" },
|
|
80
|
+
"env": { "type": "string", "description": "An environment variable that sets it (commands)." },
|
|
81
|
+
"description": { "$ref": "#/$defs/markdown" }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"returns": { "$ref": "#/$defs/markdown" },
|
|
86
|
+
"note": { "$ref": "#/$defs/markdown" },
|
|
87
|
+
"sections": {
|
|
88
|
+
"type": "array",
|
|
89
|
+
"items": {
|
|
90
|
+
"type": "object",
|
|
91
|
+
"required": ["title", "content"],
|
|
92
|
+
"additionalProperties": false,
|
|
93
|
+
"properties": {
|
|
94
|
+
"title": { "type": "string", "minLength": 1 },
|
|
95
|
+
"content": { "$ref": "#/$defs/markdown" },
|
|
96
|
+
"subsections": { "type": "array", "items": { "$ref": "#/$defs/titled" } }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"examples": {
|
|
101
|
+
"type": "array",
|
|
102
|
+
"items": {
|
|
103
|
+
"type": "object",
|
|
104
|
+
"required": ["code"],
|
|
105
|
+
"additionalProperties": false,
|
|
106
|
+
"properties": {
|
|
107
|
+
"title": { "type": "string" },
|
|
108
|
+
"code": { "type": "string", "minLength": 1 },
|
|
109
|
+
"runnable": { "type": "boolean", "default": true, "description": "False for R's \\dontrun and for examples that need a server or credentials." }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"see_also": {
|
|
114
|
+
"type": "array",
|
|
115
|
+
"items": {
|
|
116
|
+
"oneOf": [
|
|
117
|
+
{ "type": "string", "minLength": 1, "description": "An entry name in this product, or a link target such as py:ref:data_read." },
|
|
118
|
+
{
|
|
119
|
+
"type": "object",
|
|
120
|
+
"required": ["title", "url"],
|
|
121
|
+
"additionalProperties": false,
|
|
122
|
+
"properties": {
|
|
123
|
+
"title": { "type": "string", "minLength": 1 },
|
|
124
|
+
"url": { "type": "string", "pattern": "^https://" }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
"aliases": { "type": "array", "items": { "type": "string" } },
|
|
131
|
+
"lifecycle": { "enum": ["stable", "deprecated", "experimental"], "default": "stable" },
|
|
132
|
+
"replaced_by": { "type": "string", "description": "The entry name to use instead, for deprecated entries." },
|
|
133
|
+
"since": { "type": "string", "description": "The version that added it." }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
framework/_paths.py
CHANGED
|
@@ -2,11 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
from framework.project import project_root
|
|
9
10
|
|
|
11
|
+
# A folder named like private, confidential, or sensitive (outputs_private and
|
|
12
|
+
# private_raw count too), as the git security audit and R's audit match them.
|
|
13
|
+
PRIVATE_SEGMENT = re.compile(r"(^|/)[^/]*(private|confidential|sensitive)[^/]*(/|$)", re.IGNORECASE)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def has_private_segment(path: str | Path) -> bool:
|
|
17
|
+
"""Whether a folder path, with ``/`` separators, has a private segment."""
|
|
18
|
+
return bool(PRIVATE_SEGMENT.search(Path(path).as_posix()))
|
|
19
|
+
|
|
10
20
|
|
|
11
21
|
def relative_to_root(path: Path, root: Path) -> str:
|
|
12
22
|
"""``path`` relative to the project root when it is inside it, else absolute."""
|
framework/ai.py
CHANGED
|
@@ -179,13 +179,16 @@ def ai_skills_update(project_path: str | Path = ".") -> list[str]:
|
|
|
179
179
|
come from ``.framework/spec.json``, or from the settings for projects
|
|
180
180
|
without one.
|
|
181
181
|
|
|
182
|
+
Args:
|
|
183
|
+
project_path: The project's root directory. Default: the current directory.
|
|
184
|
+
|
|
182
185
|
Returns:
|
|
183
186
|
The skill folders written.
|
|
184
187
|
"""
|
|
185
188
|
from framework._log import inform, show_messages
|
|
186
189
|
|
|
187
190
|
show_messages()
|
|
188
|
-
root = Path(project_path)
|
|
191
|
+
root = Path(project_path).expanduser()
|
|
189
192
|
spec = _installed_spec(root)
|
|
190
193
|
folders, _ = assistant_targets(spec)
|
|
191
194
|
updated = []
|
framework/bootstrap.py
CHANGED
|
@@ -29,7 +29,7 @@ from typing import Any
|
|
|
29
29
|
from framework import db, dotenv
|
|
30
30
|
from framework._log import inform, show_messages
|
|
31
31
|
from framework._paths import in_root
|
|
32
|
-
from framework.create import ensure_secrets_ignored
|
|
32
|
+
from framework.create import FILE_LIKE, ensure_secrets_ignored
|
|
33
33
|
from framework.errors import FrameworkError
|
|
34
34
|
from framework.hooks import git_hooks_install
|
|
35
35
|
from framework.project import _settings_in
|
|
@@ -111,7 +111,13 @@ def _folders(config: dict[str, Any]) -> list[str]:
|
|
|
111
111
|
folders: list[str] = []
|
|
112
112
|
directories = config.get("directories")
|
|
113
113
|
if isinstance(directories, dict):
|
|
114
|
-
|
|
114
|
+
# Entries that name a file, such as presentation.qmd, are skipped, as
|
|
115
|
+
# project creation skips them. R's bootstrap() creates them as folders.
|
|
116
|
+
folders += [
|
|
117
|
+
value
|
|
118
|
+
for value in directories.values()
|
|
119
|
+
if isinstance(value, str) and value and not FILE_LIKE.search(value)
|
|
120
|
+
]
|
|
115
121
|
extra = config.get("extra_directories")
|
|
116
122
|
if isinstance(extra, list):
|
|
117
123
|
folders += [
|
framework/cache.py
CHANGED
|
@@ -210,7 +210,19 @@ def _lookup(name: str, file: str | Path | None) -> Any:
|
|
|
210
210
|
|
|
211
211
|
|
|
212
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.
|
|
213
|
+
"""A cached value, or ``default`` when it is missing, expired, or unreadable.
|
|
214
|
+
|
|
215
|
+
An expired value is removed. A value saved by R (``.rds``) or one whose file hash no longer
|
|
216
|
+
matches its record counts as missing.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
name: The cache name.
|
|
220
|
+
file: Read the value from this file instead of the cache folder.
|
|
221
|
+
default: The value to return when the cache has no usable value.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
The cached value, or ``default``.
|
|
225
|
+
"""
|
|
214
226
|
value = _lookup(name, file)
|
|
215
227
|
return default if value is _MISSING else value
|
|
216
228
|
|
|
@@ -298,7 +310,13 @@ def cache_remember(
|
|
|
298
310
|
|
|
299
311
|
|
|
300
312
|
def cache_forget(name: str, file: str | Path | None = None) -> None:
|
|
301
|
-
"""Remove a cached value and its record, whichever language saved it.
|
|
313
|
+
"""Remove a cached value and its record, whichever language saved it.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
name: The cache name.
|
|
317
|
+
file: Remove this file instead of the files in the cache folder and the one in the
|
|
318
|
+
cache record.
|
|
319
|
+
"""
|
|
302
320
|
_remove(name, file)
|
|
303
321
|
|
|
304
322
|
|
framework/catalog.py
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
The catalog is the ``data`` section of the settings. An entry is either a
|
|
4
4
|
path string or a mapping with ``path`` and optional ``type``, ``delimiter``,
|
|
5
5
|
``locked``, ``description``, and other fields. Nested mappings without a
|
|
6
|
-
``path`` group entries, and their keys form the dot-notation name.
|
|
6
|
+
``path`` group entries, and their keys form the dot-notation name. A key may
|
|
7
|
+
itself contain dots, so ``inputs.raw.survey`` is found as nested YAML, as
|
|
8
|
+
``raw.survey:`` under ``inputs:``, or as ``inputs.raw.survey:`` directly under
|
|
9
|
+
``data:``.
|
|
7
10
|
"""
|
|
8
11
|
|
|
9
12
|
from __future__ import annotations
|
|
@@ -15,6 +18,7 @@ from typing import Any
|
|
|
15
18
|
|
|
16
19
|
import pandas as pd
|
|
17
20
|
|
|
21
|
+
from framework._paths import has_private_segment
|
|
18
22
|
from framework.errors import DataNotFoundError
|
|
19
23
|
from framework.project import find_settings_file
|
|
20
24
|
from framework.settings import settings_read
|
|
@@ -48,7 +52,9 @@ class DataInfo:
|
|
|
48
52
|
delimiter: For delimited text: comma, tab, semicolon, or space. None
|
|
49
53
|
means it is guessed from the extension.
|
|
50
54
|
locked: Whether the file's hash is checked against framework.db on read.
|
|
51
|
-
private: Whether the file is in a folder
|
|
55
|
+
private: Whether the file is in a private folder: one whose name
|
|
56
|
+
contains private, confidential, or sensitive, at any depth below
|
|
57
|
+
the project root.
|
|
52
58
|
description: Shown when the data is read.
|
|
53
59
|
name: The catalog name, when the file was found by name.
|
|
54
60
|
extra: Any other fields of the catalog entry.
|
|
@@ -77,15 +83,33 @@ def _is_absolute(path: str) -> bool:
|
|
|
77
83
|
return Path(path).is_absolute() or bool(re.match(r"^[A-Za-z]:[/\\]", path))
|
|
78
84
|
|
|
79
85
|
|
|
80
|
-
def
|
|
81
|
-
|
|
86
|
+
def _in_private_folder(path: Path, root: Path | None) -> bool:
|
|
87
|
+
"""Whether any folder above ``path``, below the project root, is private.
|
|
88
|
+
|
|
89
|
+
R checks only the immediate folder, so it misses ``inputs/private/raw``.
|
|
90
|
+
"""
|
|
91
|
+
folder = path.parent
|
|
92
|
+
if root is not None and folder.is_relative_to(root.resolve()):
|
|
93
|
+
folder = folder.relative_to(root.resolve())
|
|
94
|
+
return has_private_segment(folder)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build(
|
|
98
|
+
path: str,
|
|
99
|
+
base: Path,
|
|
100
|
+
entry: dict[str, Any] | None,
|
|
101
|
+
name: str | None,
|
|
102
|
+
root: Path | None = None,
|
|
103
|
+
) -> DataInfo:
|
|
104
|
+
expanded = Path(path).expanduser()
|
|
105
|
+
full = expanded if _is_absolute(str(expanded)) else base / expanded
|
|
82
106
|
full = full.resolve()
|
|
83
107
|
file_type, delimiter = type_from_extension(full)
|
|
84
108
|
fields: dict[str, Any] = {
|
|
85
109
|
"type": file_type,
|
|
86
110
|
"delimiter": delimiter,
|
|
87
111
|
"locked": False,
|
|
88
|
-
"private": full
|
|
112
|
+
"private": _in_private_folder(full, root),
|
|
89
113
|
"description": None,
|
|
90
114
|
}
|
|
91
115
|
extra: dict[str, Any] = {}
|
|
@@ -122,13 +146,35 @@ def catalog_names(data: Any, prefix: str = "") -> list[str]:
|
|
|
122
146
|
return names
|
|
123
147
|
|
|
124
148
|
|
|
125
|
-
def
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
149
|
+
def catalog_locate(name: str, data: Any) -> tuple[dict[str, Any], str] | None:
|
|
150
|
+
"""The mapping that holds a catalog name's value, and its key there.
|
|
151
|
+
|
|
152
|
+
Keys may contain dots, so each run of name parts is tried as one key:
|
|
153
|
+
nested keys first, then ``raw.survey:``-style mixed keys, then a flat
|
|
154
|
+
``inputs.raw.survey:`` key.
|
|
155
|
+
"""
|
|
156
|
+
parts = name.split(".")
|
|
157
|
+
|
|
158
|
+
def search(node: Any, start: int) -> tuple[dict[str, Any], str] | None:
|
|
159
|
+
if not isinstance(node, dict):
|
|
129
160
|
return None
|
|
130
|
-
|
|
131
|
-
|
|
161
|
+
for end in range(start + 1, len(parts) + 1):
|
|
162
|
+
key = ".".join(parts[start:end])
|
|
163
|
+
if node.get(key) is None:
|
|
164
|
+
continue
|
|
165
|
+
if end == len(parts):
|
|
166
|
+
return node, key
|
|
167
|
+
found = search(node[key], end)
|
|
168
|
+
if found is not None:
|
|
169
|
+
return found
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
return search(data, 0)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _lookup(name: str, data: Any) -> Any:
|
|
176
|
+
found = catalog_locate(name, data)
|
|
177
|
+
return None if found is None else found[0][found[1]]
|
|
132
178
|
|
|
133
179
|
|
|
134
180
|
def _not_found(name: str, data: Any) -> DataNotFoundError:
|
|
@@ -153,24 +199,30 @@ def data_info(path: str) -> DataInfo:
|
|
|
153
199
|
relative to the current folder (anything with a slash), or an absolute
|
|
154
200
|
path. Catalog paths are relative to the project root.
|
|
155
201
|
|
|
202
|
+
Args:
|
|
203
|
+
path: A dot-notation catalog name, a relative path containing a slash, or an absolute
|
|
204
|
+
path.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
The file's path, type, and catalog fields.
|
|
208
|
+
|
|
156
209
|
Raises:
|
|
157
|
-
DataNotFoundError: The name is not in the catalog.
|
|
210
|
+
DataNotFoundError: The name is not in the catalog, or its entry has no path.
|
|
158
211
|
"""
|
|
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
212
|
settings_file = find_settings_file()
|
|
165
|
-
|
|
213
|
+
root = settings_file.parent if settings_file else None
|
|
214
|
+
if _is_absolute(path) or "/" in path or "\\" in path:
|
|
215
|
+
return _build(path, Path.cwd(), None, None, root)
|
|
216
|
+
|
|
217
|
+
base = root or Path.cwd()
|
|
166
218
|
data = settings_read(settings_file).get("data")
|
|
167
219
|
entry = _lookup(path, data)
|
|
168
220
|
if entry is None:
|
|
169
221
|
raise _not_found(path, data)
|
|
170
222
|
if isinstance(entry, str):
|
|
171
|
-
return _build(entry, base, None, path)
|
|
223
|
+
return _build(entry, base, None, path, root)
|
|
172
224
|
if isinstance(entry, dict) and isinstance(entry.get("path"), str):
|
|
173
|
-
return _build(entry["path"], base, entry, path)
|
|
225
|
+
return _build(entry["path"], base, entry, path, root)
|
|
174
226
|
raise DataNotFoundError(f"Catalog entry '{path}' has no path")
|
|
175
227
|
|
|
176
228
|
|
framework/cloud.py
CHANGED
|
@@ -413,7 +413,19 @@ def cloud_login(
|
|
|
413
413
|
|
|
414
414
|
|
|
415
415
|
def cloud_logout(url: str | None = None) -> list[str]:
|
|
416
|
-
"""Remove this machine's stored token for a server. Returns what was removed.
|
|
416
|
+
"""Remove this machine's stored token for a server. Returns what was removed.
|
|
417
|
+
|
|
418
|
+
Both the keyring entry and the token file are removed. For the default server, the legacy
|
|
419
|
+
keyring entry and ``~/.secrets/framework/token`` are removed too.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
url: The server. Default: ``FW_CLOUD_URL``, then your saved default, then
|
|
423
|
+
https://framework.pub.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
A description of each keyring entry and the path of each file removed. The list is
|
|
427
|
+
empty when no token was stored.
|
|
428
|
+
"""
|
|
417
429
|
show_messages()
|
|
418
430
|
server = cloud_server(url).url
|
|
419
431
|
removed = []
|
|
@@ -436,7 +448,19 @@ def cloud_logout(url: str | None = None) -> list[str]:
|
|
|
436
448
|
|
|
437
449
|
|
|
438
450
|
def cloud_status(url: str | None = None) -> bool:
|
|
439
|
-
"""Report the server in use and whether this machine is connected to it.
|
|
451
|
+
"""Report the server in use and whether this machine is connected to it.
|
|
452
|
+
|
|
453
|
+
The server, where that setting came from, and the account are printed. When the project
|
|
454
|
+
publishes to a different server, that server is printed too.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
url: The server. Default: ``FW_CLOUD_URL``, then your saved default, then
|
|
458
|
+
https://framework.pub.
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
True when a token is stored and the server accepts it. False when no token is stored,
|
|
462
|
+
the server is unreachable, or the token is invalid.
|
|
463
|
+
"""
|
|
440
464
|
show_messages()
|
|
441
465
|
server = cloud_server(url)
|
|
442
466
|
inform(f"Server: {server.url} (from {server.source})")
|
|
@@ -488,6 +512,18 @@ def cloud_sync(direction: str = "auto") -> int:
|
|
|
488
512
|
sends your local mirror, and ``"auto"`` picks the side that changed and
|
|
489
513
|
stops when both did. The mirror is ``cloud-settings.yml`` in
|
|
490
514
|
``config_dir()``.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
direction: One of "auto", "pull", or "push". Default: "auto". With "auto", a pull
|
|
518
|
+
happens when there is no local mirror or sync state yet.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
The settings revision after the sync.
|
|
522
|
+
|
|
523
|
+
Raises:
|
|
524
|
+
CloudError: When ``direction`` is not allowed, no token is stored, both sides changed
|
|
525
|
+
under "auto", a push has no local mirror or sync state, or the server rejects a
|
|
526
|
+
push.
|
|
491
527
|
"""
|
|
492
528
|
show_messages()
|
|
493
529
|
if direction not in ("auto", "pull", "push"):
|
|
@@ -662,7 +698,17 @@ def ledger_push() -> dict[str, int]:
|
|
|
662
698
|
|
|
663
699
|
|
|
664
700
|
def ledger_list(name: str | None = None) -> pd.DataFrame:
|
|
665
|
-
"""The project's cloud ledger, newest first.
|
|
701
|
+
"""The project's cloud ledger, newest first.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
name: Only list entries for this catalog name. Default: every entry.
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
A DataFrame with columns sequence, name, hash, size_bytes, and recorded_at.
|
|
708
|
+
|
|
709
|
+
Raises:
|
|
710
|
+
CloudError: When the project has no token, or the server request fails.
|
|
711
|
+
"""
|
|
666
712
|
columns = ["sequence", "name", "hash", "size_bytes", "recorded_at"]
|
|
667
713
|
rows = [{key: entry.get(key) for key in columns} for entry in _ledger_entries(name)]
|
|
668
714
|
return pd.DataFrame(rows, columns=columns)
|
|
@@ -672,6 +718,21 @@ def data_verify(name: str) -> bool:
|
|
|
672
718
|
"""Check a catalog file against framework.db and, if connected, the cloud ledger.
|
|
673
719
|
|
|
674
720
|
Returns True when every record matches. A mismatch issues a warning.
|
|
721
|
+
|
|
722
|
+
The file's SHA-256 digest is compared with its framework.db record and, when the project
|
|
723
|
+
has a token, with the newest cloud ledger entry for the name. A missing record, an
|
|
724
|
+
unreachable ledger, or a name with no ledger entries is reported but does not count as a
|
|
725
|
+
mismatch.
|
|
726
|
+
|
|
727
|
+
Args:
|
|
728
|
+
name: A catalog name or file path, as accepted by ``data_info()``.
|
|
729
|
+
|
|
730
|
+
Returns:
|
|
731
|
+
True when every available record matches the file, otherwise False.
|
|
732
|
+
|
|
733
|
+
Raises:
|
|
734
|
+
DataNotFoundError: When the name is not in the catalog.
|
|
735
|
+
FrameworkError: When the file does not exist.
|
|
675
736
|
"""
|
|
676
737
|
import warnings
|
|
677
738
|
|
|
@@ -743,10 +804,10 @@ def cloud_publish(
|
|
|
743
804
|
slug: str | bool | None = None,
|
|
744
805
|
title: str | None = None,
|
|
745
806
|
) -> str:
|
|
746
|
-
"""Publish rendered HTML (or a .qmd to render) to the project's cloud server.
|
|
807
|
+
"""Publish rendered HTML (or a .qmd or .ipynb to render) to the project's cloud server.
|
|
747
808
|
|
|
748
809
|
Accepts a self-contained ``.html``, an ``.html`` with a sibling
|
|
749
|
-
``<name>_files/`` folder, a render output folder, or a ``.qmd``. Each
|
|
810
|
+
``<name>_files/`` folder, a render output folder, or a ``.qmd`` or ``.ipynb``. Each
|
|
750
811
|
publish adds a version. ``slug`` names the document and turns on its
|
|
751
812
|
readable URL (``True`` uses the derived name, ``False`` turns it off).
|
|
752
813
|
"""
|
|
@@ -766,7 +827,7 @@ def cloud_publish(
|
|
|
766
827
|
if not pages:
|
|
767
828
|
raise CloudError(f"No HTML file found at the top of {source}")
|
|
768
829
|
entrypoint = "index.html" if "index.html" in pages else pages[0]
|
|
769
|
-
elif suffix
|
|
830
|
+
elif suffix in (".qmd", ".ipynb"):
|
|
770
831
|
quarto = shutil.which("quarto")
|
|
771
832
|
if quarto is None:
|
|
772
833
|
raise CloudError("Rendering requires the quarto command on your PATH.")
|
|
@@ -803,8 +864,8 @@ def cloud_publish(
|
|
|
803
864
|
html = source
|
|
804
865
|
else:
|
|
805
866
|
raise CloudError(
|
|
806
|
-
"Framework Cloud publishing takes rendered HTML, a render folder, or a .qmd "
|
|
807
|
-
f"to render. Got: {suffix or source.name}"
|
|
867
|
+
"Framework Cloud publishing takes rendered HTML, a render folder, or a .qmd or "
|
|
868
|
+
f".ipynb to render. Got: {suffix or source.name}"
|
|
808
869
|
)
|
|
809
870
|
|
|
810
871
|
readable = None if slug is None else slug is not False
|
framework/config_home.py
CHANGED
|
@@ -150,7 +150,16 @@ def _save_global_settings(settings: dict[str, Any]) -> None:
|
|
|
150
150
|
|
|
151
151
|
|
|
152
152
|
def project_register(path: str | Path) -> int:
|
|
153
|
-
"""Add a project to the registry in the global settings. Returns its id.
|
|
153
|
+
"""Add a project to the registry in the global settings. Returns its id.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
path: The project folder. It is expanded and resolved to an absolute path before it is
|
|
157
|
+
stored.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The project's id. A project already in the registry keeps its id and is not added
|
|
161
|
+
again.
|
|
162
|
+
"""
|
|
154
163
|
settings = global_settings()
|
|
155
164
|
projects = settings.get("projects") or []
|
|
156
165
|
resolved = str(Path(path).expanduser().resolve())
|
|
@@ -165,7 +174,15 @@ def project_register(path: str | Path) -> int:
|
|
|
165
174
|
|
|
166
175
|
|
|
167
176
|
def project_unregister(project: int | str | Path) -> bool:
|
|
168
|
-
"""Remove a project from the registry by id or path. Returns whether it was there.
|
|
177
|
+
"""Remove a project from the registry by id or path. Returns whether it was there.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
project: The project's id as an integer, or its folder as a path or string. A string is
|
|
181
|
+
always read as a path, so pass ids as integers.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
True when a project was removed, False when none matched.
|
|
185
|
+
"""
|
|
169
186
|
settings = global_settings()
|
|
170
187
|
projects = settings.get("projects") or []
|
|
171
188
|
if isinstance(project, int):
|