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 CHANGED
@@ -37,7 +37,12 @@ def _parser() -> argparse.ArgumentParser:
37
37
  create.add_argument(
38
38
  "--interactive",
39
39
  action="store_true",
40
- help="prompt for missing answers, initialize git, and register the project",
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 -m framework hook pre-commit
28
+ set -- .venv/bin/python
23
29
  elif [ -x .venv/Scripts/python.exe ]; then
24
- .venv/Scripts/python.exe -m framework hook pre-commit
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 -m framework hook pre-commit
32
+ set -- uv run --frozen python
27
33
  elif command -v python3 >/dev/null 2>&1; then
28
- python3 -m framework hook pre-commit
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: no R or Python runtime with framework installed was found." >&2
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
- folders += [value for value in directories.values() if isinstance(value, str) and value]
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