framework-py 0.1.0.dev1__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 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/_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
- 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
 
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 named ``private``.
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 _build(path: str, base: Path, entry: dict[str, Any] | None, name: str | None) -> DataInfo:
81
- full = Path(path) if _is_absolute(path) else base / path
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.parent.name == "private",
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 _lookup(name: str, data: Any) -> Any:
126
- current = data
127
- for part in name.split("."):
128
- if not isinstance(current, dict) or current.get(part) is None:
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
- current = current[part]
131
- return current
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
- base = settings_file.parent if settings_file else Path.cwd()
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 == ".qmd":
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):