framework-py 0.1.0.dev1__py3-none-any.whl → 0.1.0.dev3__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/_pages.py +228 -0
- framework/_paths.py +10 -0
- framework/_private.py +69 -0
- framework/ai.py +4 -1
- framework/bootstrap.py +8 -2
- framework/cache.py +20 -2
- framework/catalog.py +72 -20
- framework/cloud.py +80 -9
- 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 +178 -41
- framework/templates/skills/framework-outputs/SKILL.md +5 -5
- framework/templates/skills/framework-sensitive-data/SKILL.md +3 -2
- {framework_py-0.1.0.dev1.dist-info → framework_py-0.1.0.dev3.dist-info}/METADATA +1 -1
- {framework_py-0.1.0.dev1.dist-info → framework_py-0.1.0.dev3.dist-info}/RECORD +36 -33
- {framework_py-0.1.0.dev1.dist-info → framework_py-0.1.0.dev3.dist-info}/WHEEL +0 -0
- {framework_py-0.1.0.dev1.dist-info → framework_py-0.1.0.dev3.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/_pages.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Publishing to GitHub Pages: ``publish(..., to="github")``.
|
|
2
|
+
|
|
3
|
+
Framework Cloud's "Publish with GitHub Pages" commits a workflow that deploys
|
|
4
|
+
the repository's ``published/`` folder from the default branch. This side
|
|
5
|
+
copies a document into ``published/<slug>/``, commits only that folder, and
|
|
6
|
+
pushes. Documents never pass through the cloud. R and fw behave the same way.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import warnings
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from framework._log import inform
|
|
21
|
+
from framework.errors import FrameworkError, FrameworkWarning
|
|
22
|
+
|
|
23
|
+
PUBLIC_NOTICE = "GitHub Pages sites are public, even when the repository is private."
|
|
24
|
+
_SLUG = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
|
25
|
+
_REMOTE = re.compile(r"github\.com[:/]+([^/]+)/([^/]+?)(?:\.git)?/?$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class _Remote:
|
|
30
|
+
name: str
|
|
31
|
+
owner: str
|
|
32
|
+
repo: str
|
|
33
|
+
has_upstream: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _git(top: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
37
|
+
return subprocess.run(
|
|
38
|
+
["git", "-C", str(top), *args], capture_output=True, text=True, check=False
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def github_remote(url: str) -> tuple[str, str] | None:
|
|
43
|
+
"""The owner and repository of a GitHub remote URL (https, ssh, or scp-style)."""
|
|
44
|
+
match = _REMOTE.search(url.strip())
|
|
45
|
+
return (match[1], match[2]) if match else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def pages_base(owner: str, repo: str) -> str:
|
|
49
|
+
"""The URL GitHub gives a repository's Pages site."""
|
|
50
|
+
host = f"{owner.lower()}.github.io"
|
|
51
|
+
if repo.lower() == host:
|
|
52
|
+
return f"https://{host}/"
|
|
53
|
+
return f"https://{host}/{repo}/"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def pages_slug(source: Path) -> str:
|
|
57
|
+
"""The file name lowercased, with each run of other characters as "-".
|
|
58
|
+
|
|
59
|
+
GitHub Pages URLs are case-sensitive. "Final Report (v2).html" becomes
|
|
60
|
+
"final-report-v2".
|
|
61
|
+
"""
|
|
62
|
+
name = source.name if source.is_dir() else source.stem
|
|
63
|
+
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _remote(top: Path) -> _Remote | None:
|
|
67
|
+
"""The branch's upstream remote, then origin, then any GitHub remote."""
|
|
68
|
+
upstream = _git(top, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
|
|
69
|
+
upstream_name = upstream.stdout.strip().split("/")[0] if upstream.returncode == 0 else None
|
|
70
|
+
listed = _git(top, "remote")
|
|
71
|
+
names = listed.stdout.split() if listed.returncode == 0 else []
|
|
72
|
+
candidates = [n for n in (upstream_name, "origin", *names) if n is not None]
|
|
73
|
+
for name in dict.fromkeys(candidates):
|
|
74
|
+
if name not in names:
|
|
75
|
+
continue
|
|
76
|
+
url = _git(top, "remote", "get-url", name)
|
|
77
|
+
found = github_remote(url.stdout) if url.returncode == 0 else None
|
|
78
|
+
if found is not None:
|
|
79
|
+
return _Remote(name, found[0], found[1], name == upstream_name)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _stage(source: Path, stage: Path, self_contained: bool) -> None:
|
|
84
|
+
"""Copy ``source`` into ``stage`` as a folder whose front page is index.html."""
|
|
85
|
+
suffix = source.suffix.lower()
|
|
86
|
+
if source.is_dir():
|
|
87
|
+
if not (source / "index.html").is_file():
|
|
88
|
+
raise FrameworkError(
|
|
89
|
+
f"A folder published to GitHub Pages needs an index.html at its top: {source}"
|
|
90
|
+
)
|
|
91
|
+
shutil.copytree(source, stage, dirs_exist_ok=True)
|
|
92
|
+
return
|
|
93
|
+
if suffix in (".html", ".htm"):
|
|
94
|
+
shutil.copy2(source, stage / source.name)
|
|
95
|
+
assets = source.with_name(f"{source.stem}_files")
|
|
96
|
+
if assets.is_dir():
|
|
97
|
+
# The supporting folder keeps its name, so relative links still resolve
|
|
98
|
+
shutil.copytree(assets, stage / assets.name)
|
|
99
|
+
elif suffix in (".qmd", ".ipynb"):
|
|
100
|
+
quarto = shutil.which("quarto")
|
|
101
|
+
if quarto is None:
|
|
102
|
+
raise FrameworkError("Rendering requires the quarto command on your PATH.")
|
|
103
|
+
command = [quarto, "render", str(source.resolve()), "--output-dir", str(stage)]
|
|
104
|
+
command += ["--to", "html", *(["--embed-resources"] if self_contained else [])]
|
|
105
|
+
inform(f"Rendering {source.name}...")
|
|
106
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
107
|
+
if result.returncode != 0:
|
|
108
|
+
raise FrameworkError(f"Quarto render failed:\n{result.stdout}{result.stderr}")
|
|
109
|
+
else:
|
|
110
|
+
raise FrameworkError(
|
|
111
|
+
"GitHub Pages publishing takes rendered HTML, a render folder, or a .qmd or "
|
|
112
|
+
f".ipynb to render. Got: {suffix or source.name}"
|
|
113
|
+
)
|
|
114
|
+
if not (stage / "index.html").is_file():
|
|
115
|
+
main = stage / f"{source.stem}.html"
|
|
116
|
+
if not main.is_file():
|
|
117
|
+
pages = sorted(stage.glob("*.htm*"))
|
|
118
|
+
if len(pages) != 1:
|
|
119
|
+
raise FrameworkError(f"No single HTML page found to publish from {source}")
|
|
120
|
+
main = pages[0]
|
|
121
|
+
main.rename(stage / "index.html")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _cloud_pages_url(root: Path) -> tuple[bool, str | None, str | None]:
|
|
125
|
+
"""Whether the cloud answered, its Pages URL (None when Pages is off), and the server.
|
|
126
|
+
|
|
127
|
+
One call to ``/api/v1/me`` with the project token. Not known when there is
|
|
128
|
+
no project token or the call fails.
|
|
129
|
+
"""
|
|
130
|
+
from framework.cloud import CloudError, api, cloud_server, project_token
|
|
131
|
+
|
|
132
|
+
token = project_token(root)
|
|
133
|
+
if token is None:
|
|
134
|
+
return False, None, None
|
|
135
|
+
try:
|
|
136
|
+
server = cloud_server(scope="project", project_dir=root).url
|
|
137
|
+
me = api("/api/v1/me", token, server)
|
|
138
|
+
except CloudError:
|
|
139
|
+
return False, None, None
|
|
140
|
+
if not isinstance(me, dict) or me.get("kind") != "project":
|
|
141
|
+
return False, None, None
|
|
142
|
+
pages = me.get("pages_url")
|
|
143
|
+
return True, pages if isinstance(pages, str) and pages else None, server
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _confirm() -> None:
|
|
147
|
+
# Shown on every run, even when Framework's messages are off
|
|
148
|
+
print(PUBLIC_NOTICE, file=sys.stderr)
|
|
149
|
+
if sys.stdin.isatty():
|
|
150
|
+
answer = input("Publish it? [y/N] ").strip().lower()
|
|
151
|
+
if answer not in ("y", "yes"):
|
|
152
|
+
raise FrameworkError("Cancelled: nothing was published.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def github_publish(source: Path, slug: str | None = None, self_contained: bool = True) -> str:
|
|
156
|
+
"""Copy ``source`` to ``published/<slug>/``, commit only that folder, and push."""
|
|
157
|
+
from framework.project import find_settings_file
|
|
158
|
+
|
|
159
|
+
if slug is None:
|
|
160
|
+
slug = pages_slug(source)
|
|
161
|
+
if not _SLUG.match(slug):
|
|
162
|
+
raise FrameworkError(
|
|
163
|
+
"slug must start with a lowercase letter or digit and use only lowercase letters, "
|
|
164
|
+
"digits, '.', '_', and '-' (GitHub Pages URLs are case-sensitive)."
|
|
165
|
+
)
|
|
166
|
+
settings_file = find_settings_file()
|
|
167
|
+
root = settings_file.parent if settings_file is not None else Path.cwd()
|
|
168
|
+
shown = _git(root, "rev-parse", "--show-toplevel")
|
|
169
|
+
if shown.returncode != 0:
|
|
170
|
+
raise FrameworkError(
|
|
171
|
+
"This project isn't a git repository. Create the project on GitHub first."
|
|
172
|
+
)
|
|
173
|
+
top = Path(shown.stdout.strip())
|
|
174
|
+
remote = _remote(top)
|
|
175
|
+
if remote is None:
|
|
176
|
+
raise FrameworkError(
|
|
177
|
+
"This project has no GitHub remote. Create the project on GitHub first, then "
|
|
178
|
+
"publish again."
|
|
179
|
+
)
|
|
180
|
+
_confirm()
|
|
181
|
+
|
|
182
|
+
# published/ sits at the top of the repository, where the workflow deploys from
|
|
183
|
+
rel = f"published/{slug}"
|
|
184
|
+
with tempfile.TemporaryDirectory(prefix="fw_pages_") as temp:
|
|
185
|
+
_stage(source, Path(temp), self_contained)
|
|
186
|
+
target = top / rel
|
|
187
|
+
if target.exists():
|
|
188
|
+
shutil.rmtree(target)
|
|
189
|
+
shutil.copytree(temp, target)
|
|
190
|
+
|
|
191
|
+
# Commit only this folder; anything else the user staged stays staged
|
|
192
|
+
_git(top, "add", "-A", "--", rel)
|
|
193
|
+
if _git(top, "diff", "--cached", "--quiet", "--", rel).returncode == 0:
|
|
194
|
+
inform(f"No change in {rel}; nothing committed or pushed.")
|
|
195
|
+
else:
|
|
196
|
+
commit = _git(top, "commit", "-m", f"Publish {slug}", "--", rel)
|
|
197
|
+
if commit.returncode != 0:
|
|
198
|
+
raise FrameworkError(f"git commit failed:\n{commit.stdout}{commit.stderr}")
|
|
199
|
+
branch = _git(top, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
|
200
|
+
default = _git(top, "symbolic-ref", "--short", f"refs/remotes/{remote.name}/HEAD")
|
|
201
|
+
if default.returncode == 0:
|
|
202
|
+
default_branch = default.stdout.strip().split("/", 1)[-1]
|
|
203
|
+
if branch != default_branch:
|
|
204
|
+
warnings.warn(
|
|
205
|
+
f"You're on '{branch}'. GitHub Pages deploys only from the default branch "
|
|
206
|
+
f"('{default_branch}'), so this won't appear until it's merged there.",
|
|
207
|
+
FrameworkWarning,
|
|
208
|
+
stacklevel=3,
|
|
209
|
+
)
|
|
210
|
+
push = (
|
|
211
|
+
_git(top, "push")
|
|
212
|
+
if remote.has_upstream
|
|
213
|
+
else _git(top, "push", "-u", remote.name, "HEAD")
|
|
214
|
+
)
|
|
215
|
+
if push.returncode != 0:
|
|
216
|
+
raise FrameworkError(f"Committed, but git push failed:\n{push.stdout}{push.stderr}")
|
|
217
|
+
|
|
218
|
+
known, pages_url, server = _cloud_pages_url(root)
|
|
219
|
+
if known and pages_url is None:
|
|
220
|
+
warnings.warn(
|
|
221
|
+
f"GitHub Pages is off for this project. Turn it on under Projects at {server}.",
|
|
222
|
+
FrameworkWarning,
|
|
223
|
+
stacklevel=3,
|
|
224
|
+
)
|
|
225
|
+
base = pages_url or pages_base(remote.owner, remote.repo)
|
|
226
|
+
url = f"{base.rstrip('/')}/{slug}/"
|
|
227
|
+
inform(f"Published to {url}")
|
|
228
|
+
return url
|
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/_private.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The check before an upload: a path with "private" in it needs confirmation.
|
|
2
|
+
|
|
3
|
+
Uploads are always the user's explicit choice, so the check never refuses
|
|
4
|
+
outright. Any source whose full path contains "private", in any letter case,
|
|
5
|
+
is listed with a warning. An interactive session asks before uploading. A
|
|
6
|
+
session with no terminal (scripts, CI, notebooks run by agents) raises an
|
|
7
|
+
error unless ``include_private=True`` is passed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
import warnings
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from framework.errors import FrameworkError
|
|
18
|
+
|
|
19
|
+
# macOS resolves /tmp, /var, and /etc to /private/..., which the user did not name
|
|
20
|
+
_SYSTEM_PREFIX = "/private/"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_private_path(path: Path) -> bool:
|
|
24
|
+
"""Whether the full absolute path contains "private", ignoring the system prefix."""
|
|
25
|
+
full = path.expanduser().resolve().as_posix()
|
|
26
|
+
if full.startswith(_SYSTEM_PREFIX):
|
|
27
|
+
full = full[len(_SYSTEM_PREFIX) - 1 :]
|
|
28
|
+
return "private" in full.lower()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def include_private_arg(include_private: bool, allow_private: bool | None) -> bool:
|
|
32
|
+
"""``include_private``, honoring the deprecated ``allow_private`` name."""
|
|
33
|
+
if allow_private is None:
|
|
34
|
+
return include_private
|
|
35
|
+
warnings.warn(
|
|
36
|
+
"allow_private is deprecated; use include_private instead.",
|
|
37
|
+
DeprecationWarning,
|
|
38
|
+
stacklevel=3,
|
|
39
|
+
)
|
|
40
|
+
return include_private or allow_private
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def confirm_private_upload(paths: Iterable[Path], include_private: bool) -> None:
|
|
44
|
+
"""Warn about private paths among ``paths`` and ask before uploading them.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
FrameworkError: When there is no terminal to ask on and ``include_private`` is
|
|
48
|
+
False, or when the user declines.
|
|
49
|
+
"""
|
|
50
|
+
if include_private:
|
|
51
|
+
return
|
|
52
|
+
flagged = [p for p in paths if is_private_path(p)]
|
|
53
|
+
if not flagged:
|
|
54
|
+
return
|
|
55
|
+
shown = "".join(f"\n {p}" for p in flagged[:5])
|
|
56
|
+
if len(flagged) > 5:
|
|
57
|
+
shown += f"\n ... and {len(flagged) - 5} more"
|
|
58
|
+
print(
|
|
59
|
+
f"Warning: {len(flagged)} file(s) to upload have 'private' in their path:{shown}",
|
|
60
|
+
file=sys.stderr,
|
|
61
|
+
)
|
|
62
|
+
if not sys.stdin.isatty():
|
|
63
|
+
raise FrameworkError(
|
|
64
|
+
"Not uploading files with 'private' in their path without confirmation. "
|
|
65
|
+
"Review them, then pass include_private=True to upload them."
|
|
66
|
+
)
|
|
67
|
+
answer = input("Upload them anyway? [y/N] ").strip().lower()
|
|
68
|
+
if answer not in ("y", "yes"):
|
|
69
|
+
raise FrameworkError("Cancelled. Nothing was uploaded.")
|
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
|
|