qhost 0.1.0__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.
- qhost/__init__.py +3 -0
- qhost/_io.py +66 -0
- qhost/_toml.py +99 -0
- qhost/builders/__init__.py +124 -0
- qhost/builders/directory.py +56 -0
- qhost/builders/quarto.py +88 -0
- qhost/cli.py +576 -0
- qhost/client.py +316 -0
- qhost/config.py +111 -0
- qhost/creds.py +177 -0
- qhost/doctor.py +325 -0
- qhost/errors.py +173 -0
- qhost/format.py +332 -0
- qhost/project.py +183 -0
- qhost/publish.py +467 -0
- qhost-0.1.0.dist-info/METADATA +179 -0
- qhost-0.1.0.dist-info/RECORD +39 -0
- qhost-0.1.0.dist-info/WHEEL +5 -0
- qhost-0.1.0.dist-info/entry_points.txt +2 -0
- qhost-0.1.0.dist-info/licenses/LICENSE +21 -0
- qhost-0.1.0.dist-info/top_level.txt +3 -0
- qhost_core/__init__.py +85 -0
- qhost_core/archive.py +230 -0
- qhost_core/durations.py +43 -0
- qhost_core/models.py +244 -0
- qhost_core/slugs.py +66 -0
- qhost_core/version.py +2 -0
- qhostd/__init__.py +23 -0
- qhostd/api.py +361 -0
- qhostd/app.py +181 -0
- qhostd/config.py +208 -0
- qhostd/content.py +199 -0
- qhostd/errors.py +225 -0
- qhostd/locks.py +36 -0
- qhostd/prune.py +77 -0
- qhostd/reconcile.py +130 -0
- qhostd/security.py +482 -0
- qhostd/serving.py +311 -0
- qhostd/store.py +398 -0
qhost/__init__.py
ADDED
qhost/_io.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Shared filesystem helpers for the three local-state modules.
|
|
2
|
+
|
|
3
|
+
Atomic writes (tmp + chmod + replace) and mode-checks are identical for
|
|
4
|
+
`config.toml`, `credentials.toml`, and `.qhost`, so they live here. Each
|
|
5
|
+
state module imports from this — nothing else does.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_MODE_WARNED: set[str] = set()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def atomic_write(path: Path, payload: str, *, mode: int) -> None:
|
|
20
|
+
"""Write `payload` to `path` via tmp + chmod + replace.
|
|
21
|
+
|
|
22
|
+
Mode is set on the tmp file *before* the rename so the visible file is
|
|
23
|
+
never briefly readable in a wider mode. Skips chmod on Windows.
|
|
24
|
+
"""
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
27
|
+
prefix=path.name + ".",
|
|
28
|
+
suffix=".tmp",
|
|
29
|
+
dir=str(path.parent),
|
|
30
|
+
)
|
|
31
|
+
tmp = Path(tmp_name)
|
|
32
|
+
try:
|
|
33
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
34
|
+
fh.write(payload)
|
|
35
|
+
if not sys.platform.startswith("win"):
|
|
36
|
+
os.chmod(tmp, mode)
|
|
37
|
+
os.replace(tmp, path)
|
|
38
|
+
except BaseException:
|
|
39
|
+
with contextlib.suppress(FileNotFoundError):
|
|
40
|
+
tmp.unlink()
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def warn_if_world_readable(path: Path) -> None:
|
|
45
|
+
"""Print a one-time stderr warning if `path` is readable by group/other.
|
|
46
|
+
|
|
47
|
+
Used for secrets files (`config.toml`, `credentials.toml`). No-op on
|
|
48
|
+
Windows. Each path is warned about at most once per process to avoid
|
|
49
|
+
spamming CLI output across multiple commands in the same invocation.
|
|
50
|
+
"""
|
|
51
|
+
if sys.platform.startswith("win"):
|
|
52
|
+
return
|
|
53
|
+
key = str(path)
|
|
54
|
+
if key in _MODE_WARNED:
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
mode = path.stat().st_mode & 0o777
|
|
58
|
+
except OSError:
|
|
59
|
+
return
|
|
60
|
+
if mode & 0o077:
|
|
61
|
+
_MODE_WARNED.add(key)
|
|
62
|
+
print(
|
|
63
|
+
f"warning: {path} mode is {oct(mode)} (should be 0o600); "
|
|
64
|
+
"anyone on this machine can read it.",
|
|
65
|
+
file=sys.stderr,
|
|
66
|
+
)
|
qhost/_toml.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Minimal TOML writer for qhost's three state files.
|
|
2
|
+
|
|
3
|
+
Reads use stdlib `tomllib` (Python 3.11+). Writing TOML is implemented in-tree
|
|
4
|
+
to avoid pulling in a runtime dep (`tomli_w`) just to emit a few dozen lines.
|
|
5
|
+
|
|
6
|
+
The subset supported is deliberately narrow:
|
|
7
|
+
- Top-level scalar `key = value` lines.
|
|
8
|
+
- Section headers `[a]`, `[a.b]`, `[a."quoted"."key"]`.
|
|
9
|
+
- String, int, and bool scalars.
|
|
10
|
+
|
|
11
|
+
Anything else (arrays, dates, multi-line strings, inline tables, floats)
|
|
12
|
+
raises `TypeError`. If a future state file needs more, extend here — not at
|
|
13
|
+
the call site.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
_BARE_KEY = re.compile(r"[A-Za-z0-9_-]+")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def dumps(data: Mapping[str, Any]) -> str:
|
|
26
|
+
"""Render a nested mapping to TOML text."""
|
|
27
|
+
lines: list[str] = []
|
|
28
|
+
scalars, subs = _split(data)
|
|
29
|
+
for k, v in scalars:
|
|
30
|
+
lines.append(_format_kv(k, v))
|
|
31
|
+
for k, v in subs:
|
|
32
|
+
if lines:
|
|
33
|
+
lines.append("")
|
|
34
|
+
_emit_section(lines, [k], v)
|
|
35
|
+
text = "\n".join(lines)
|
|
36
|
+
return text + "\n" if text else ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _emit_section(lines: list[str], path: list[str], data: Mapping[str, Any]) -> None:
|
|
40
|
+
scalars, subs = _split(data)
|
|
41
|
+
# Emit a header for this path when it has scalars of its own or is a leaf;
|
|
42
|
+
# otherwise the path is purely a parent and its header is implicit.
|
|
43
|
+
if scalars or not subs:
|
|
44
|
+
lines.append(f"[{_format_path(path)}]")
|
|
45
|
+
for k, v in scalars:
|
|
46
|
+
lines.append(_format_kv(k, v))
|
|
47
|
+
for k, v in subs:
|
|
48
|
+
lines.append("")
|
|
49
|
+
_emit_section(lines, [*path, k], v)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _split(
|
|
53
|
+
data: Mapping[str, Any],
|
|
54
|
+
) -> tuple[list[tuple[str, Any]], list[tuple[str, Mapping[str, Any]]]]:
|
|
55
|
+
scalars: list[tuple[str, Any]] = []
|
|
56
|
+
subs: list[tuple[str, Mapping[str, Any]]] = []
|
|
57
|
+
for k, v in data.items():
|
|
58
|
+
if isinstance(v, Mapping):
|
|
59
|
+
subs.append((k, v))
|
|
60
|
+
else:
|
|
61
|
+
scalars.append((k, v))
|
|
62
|
+
return scalars, subs
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _format_path(path: list[str]) -> str:
|
|
66
|
+
return ".".join(_format_key(p) for p in path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _format_key(k: str) -> str:
|
|
70
|
+
if _BARE_KEY.fullmatch(k):
|
|
71
|
+
return k
|
|
72
|
+
return _quote_string(k)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _format_kv(k: str, v: Any) -> str:
|
|
76
|
+
return f"{_format_key(k)} = {_format_value(v)}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _format_value(v: Any) -> str:
|
|
80
|
+
if isinstance(v, bool):
|
|
81
|
+
return "true" if v else "false"
|
|
82
|
+
if isinstance(v, int):
|
|
83
|
+
return str(v)
|
|
84
|
+
if isinstance(v, str):
|
|
85
|
+
return _quote_string(v)
|
|
86
|
+
raise TypeError(f"unsupported TOML value type: {type(v).__name__}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _quote_string(s: str) -> str:
|
|
90
|
+
escaped = (
|
|
91
|
+
s.replace("\\", "\\\\")
|
|
92
|
+
.replace('"', '\\"')
|
|
93
|
+
.replace("\b", "\\b")
|
|
94
|
+
.replace("\t", "\\t")
|
|
95
|
+
.replace("\n", "\\n")
|
|
96
|
+
.replace("\f", "\\f")
|
|
97
|
+
.replace("\r", "\\r")
|
|
98
|
+
)
|
|
99
|
+
return f'"{escaped}"'
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Builder protocol and the v1.0 registry.
|
|
2
|
+
|
|
3
|
+
A builder turns a working directory into an output directory that's ready to
|
|
4
|
+
be packed and uploaded. v1.0 ships two: `directory` (selected by `--dir`,
|
|
5
|
+
publishes a prebuilt tree as-is) and `quarto` (auto-detected via
|
|
6
|
+
`_quarto.yml`, runs `quarto render` with the serving prefix wired in).
|
|
7
|
+
|
|
8
|
+
The registry is an explicit dict — `BUILDERS[name]` only. Entry-point
|
|
9
|
+
discovery is v2.0 territory per `notes/builders.md`; until then, adding a
|
|
10
|
+
new builder is a code change in this package and nowhere else.
|
|
11
|
+
|
|
12
|
+
Selection precedence (from `resolve_builder`):
|
|
13
|
+
1. `--dir <path>` → `DirectoryBuilder.for_dir(path)`. Always wins.
|
|
14
|
+
2. `--build <name>` → `BUILDERS[name]`. Errors if name isn't known.
|
|
15
|
+
3. `detects()` chain → first matching builder.
|
|
16
|
+
4. None → `NoBuilderDetected`.
|
|
17
|
+
|
|
18
|
+
Refusing-to-guess at step 4 is deliberate: publishing the current working
|
|
19
|
+
directory blindly (no index.html, no marker file, no flag) is too dangerous.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Protocol, runtime_checkable
|
|
28
|
+
|
|
29
|
+
from qhost.errors import BuilderUnknown, NoBuilderDetected
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class BuilderOpts:
|
|
34
|
+
"""Inputs the CLI hands to every builder.
|
|
35
|
+
|
|
36
|
+
`entrypoint_hint` is whatever the user passed on `--entrypoint`, or None
|
|
37
|
+
for the builder to pick its own default.
|
|
38
|
+
|
|
39
|
+
`sandbox` is informational; builders may want to emit different markup
|
|
40
|
+
(e.g., a `<base>` tag) when sandboxed, but most won't care.
|
|
41
|
+
|
|
42
|
+
`extra` is builder-specific. v1.0 leaves it empty.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
entrypoint_hint: str | None = None
|
|
46
|
+
sandbox: bool = False
|
|
47
|
+
extra: Mapping[str, str] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class BuildResult:
|
|
52
|
+
"""What a builder hands back. `output_dir` is what gets packed."""
|
|
53
|
+
|
|
54
|
+
output_dir: Path
|
|
55
|
+
suggested_entrypoint: str
|
|
56
|
+
warnings: list[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@runtime_checkable
|
|
60
|
+
class Builder(Protocol):
|
|
61
|
+
name: str
|
|
62
|
+
|
|
63
|
+
def detects(self, cwd: Path) -> bool: ...
|
|
64
|
+
|
|
65
|
+
def build(
|
|
66
|
+
self,
|
|
67
|
+
cwd: Path,
|
|
68
|
+
*,
|
|
69
|
+
slug: str,
|
|
70
|
+
base_url: str,
|
|
71
|
+
opts: BuilderOpts,
|
|
72
|
+
) -> BuildResult: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_builder(
|
|
76
|
+
cwd: Path,
|
|
77
|
+
*,
|
|
78
|
+
name: str | None = None,
|
|
79
|
+
dir_: Path | None = None,
|
|
80
|
+
) -> Builder:
|
|
81
|
+
"""Pick a builder per the precedence rules in this module's docstring."""
|
|
82
|
+
if dir_ is not None:
|
|
83
|
+
# Avoid a circular import — DirectoryBuilder imports nothing from here.
|
|
84
|
+
from qhost.builders.directory import DirectoryBuilder
|
|
85
|
+
|
|
86
|
+
return DirectoryBuilder.for_dir(dir_)
|
|
87
|
+
if name is not None:
|
|
88
|
+
if name not in BUILDERS:
|
|
89
|
+
available = ", ".join(sorted(BUILDERS))
|
|
90
|
+
raise BuilderUnknown(f"unknown builder: {name!r}. Available: {available}")
|
|
91
|
+
return BUILDERS[name]
|
|
92
|
+
for builder in BUILDERS.values():
|
|
93
|
+
if builder.detects(cwd):
|
|
94
|
+
return builder
|
|
95
|
+
available = ", ".join(sorted(BUILDERS))
|
|
96
|
+
raise NoBuilderDetected(
|
|
97
|
+
"no builder detected: this directory has no `index.html` and no "
|
|
98
|
+
"`_quarto.yml`. Point at a built output with `qhost publish --dir "
|
|
99
|
+
f"<path>`, or pass --build <name> (available: {available})."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _build_registry() -> dict[str, Builder]:
|
|
104
|
+
from qhost.builders.directory import DirectoryBuilder
|
|
105
|
+
from qhost.builders.quarto import QuartoBuilder
|
|
106
|
+
|
|
107
|
+
# Iteration order matters for auto-detect: builders that detect from a
|
|
108
|
+
# specific marker file (quarto's _quarto.yml) come before fallbacks.
|
|
109
|
+
return {"quarto": QuartoBuilder(), "directory": DirectoryBuilder()}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Populated at import time; mutable so additional builders can be registered
|
|
113
|
+
# without restructuring callers. Tests must not mutate this — they should
|
|
114
|
+
# patch instead — but it's not enforced.
|
|
115
|
+
BUILDERS: dict[str, Builder] = _build_registry()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = [
|
|
119
|
+
"BUILDERS",
|
|
120
|
+
"Builder",
|
|
121
|
+
"BuilderOpts",
|
|
122
|
+
"BuildResult",
|
|
123
|
+
"resolve_builder",
|
|
124
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""`directory` builder — publishes a prebuilt directory tree as-is.
|
|
2
|
+
|
|
3
|
+
Selected by `--dir <path>` (which goes through `for_dir`), by explicit
|
|
4
|
+
`--build directory`, or by auto-detection when the cwd contains an
|
|
5
|
+
`index.html`. The auto-detect signal mirrors what the server already
|
|
6
|
+
requires: a share's content must include the entrypoint file (default
|
|
7
|
+
`index.html`) or the upload is rejected. A cwd without `index.html` is
|
|
8
|
+
unlikely to be a published-ready directory, so detection stays off
|
|
9
|
+
there and the CLI falls through to `NoBuilderDetected`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from qhost.builders import BuilderOpts, BuildResult
|
|
17
|
+
from qhost.errors import BuilderFailed
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DirectoryBuilder:
|
|
21
|
+
name = "directory"
|
|
22
|
+
|
|
23
|
+
def __init__(self, dir_: Path | None = None) -> None:
|
|
24
|
+
self._dir = dir_
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def for_dir(cls, path: Path) -> DirectoryBuilder:
|
|
28
|
+
return cls(path)
|
|
29
|
+
|
|
30
|
+
def detects(self, cwd: Path) -> bool:
|
|
31
|
+
# `--dir <path>` instances are resolved before auto-detect runs, so
|
|
32
|
+
# only the registry instance (no pinned dir) ever reaches here. Use
|
|
33
|
+
# the same signal the server validates against post-upload.
|
|
34
|
+
return self._dir is None and (cwd / "index.html").is_file()
|
|
35
|
+
|
|
36
|
+
def build(
|
|
37
|
+
self,
|
|
38
|
+
cwd: Path,
|
|
39
|
+
*,
|
|
40
|
+
slug: str,
|
|
41
|
+
base_url: str,
|
|
42
|
+
opts: BuilderOpts,
|
|
43
|
+
) -> BuildResult:
|
|
44
|
+
out_dir = (self._dir or cwd).resolve()
|
|
45
|
+
if not out_dir.exists():
|
|
46
|
+
raise BuilderFailed(f"directory does not exist: {out_dir}")
|
|
47
|
+
if not out_dir.is_dir():
|
|
48
|
+
raise BuilderFailed(f"not a directory: {out_dir}")
|
|
49
|
+
entry = opts.entrypoint_hint or "index.html"
|
|
50
|
+
warnings: list[str] = []
|
|
51
|
+
if not (out_dir / entry).is_file():
|
|
52
|
+
warnings.append(
|
|
53
|
+
f"entrypoint {entry!r} not found in {out_dir} — "
|
|
54
|
+
"the server will reject the upload"
|
|
55
|
+
)
|
|
56
|
+
return BuildResult(output_dir=out_dir, suggested_entrypoint=entry, warnings=warnings)
|
qhost/builders/quarto.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""`quarto` builder — runs `quarto render` with the serving prefix wired in.
|
|
2
|
+
|
|
3
|
+
Selected automatically when `_quarto.yml` (or `_quarto.yaml`) is present, or
|
|
4
|
+
explicitly via `--build quarto`. The path-prefix flag is `--site-url`, which
|
|
5
|
+
sets `site-url` in the rendered project's config so links resolve correctly
|
|
6
|
+
under qhostd's `/s/<slug>/` prefix.
|
|
7
|
+
|
|
8
|
+
Output-dir handling (per Quarto 1.4+):
|
|
9
|
+
- Website projects render into `_site/`.
|
|
10
|
+
- Book projects render into `_book/`.
|
|
11
|
+
- Single-document renders (no project type) write the `.html` next to the
|
|
12
|
+
source. We don't handle this case here — `_quarto.yml` implies a project,
|
|
13
|
+
so we expect `_site/` or `_book/` to exist after render.
|
|
14
|
+
|
|
15
|
+
If Quarto exits non-zero, raise `BuilderFailed` with the tail of stderr; if
|
|
16
|
+
`quarto` isn't on PATH, raise `BuilderToolMissing`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from qhost.builders import BuilderOpts, BuildResult
|
|
26
|
+
from qhost.errors import BuilderFailed, BuilderToolMissing
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class QuartoBuilder:
|
|
30
|
+
name = "quarto"
|
|
31
|
+
|
|
32
|
+
def detects(self, cwd: Path) -> bool:
|
|
33
|
+
return (cwd / "_quarto.yml").is_file() or (cwd / "_quarto.yaml").is_file()
|
|
34
|
+
|
|
35
|
+
def build(
|
|
36
|
+
self,
|
|
37
|
+
cwd: Path,
|
|
38
|
+
*,
|
|
39
|
+
slug: str,
|
|
40
|
+
base_url: str,
|
|
41
|
+
opts: BuilderOpts,
|
|
42
|
+
) -> BuildResult:
|
|
43
|
+
if shutil.which("quarto") is None:
|
|
44
|
+
raise BuilderToolMissing(
|
|
45
|
+
"quarto is not on PATH. Install from https://quarto.org/docs/get-started/."
|
|
46
|
+
)
|
|
47
|
+
cmd = ["quarto", "render", str(cwd), "--site-url", base_url, "--quiet"]
|
|
48
|
+
try:
|
|
49
|
+
completed = subprocess.run(
|
|
50
|
+
cmd,
|
|
51
|
+
cwd=cwd,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
raise BuilderFailed(f"failed to run quarto: {exc}") from exc
|
|
58
|
+
if completed.returncode != 0:
|
|
59
|
+
tail = (completed.stderr or completed.stdout or "").strip().splitlines()[-10:]
|
|
60
|
+
raise BuilderFailed(
|
|
61
|
+
"quarto render failed (exit "
|
|
62
|
+
f"{completed.returncode}):\n" + "\n".join(tail)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
output_dir = _find_output_dir(cwd)
|
|
66
|
+
entrypoint = opts.entrypoint_hint or "index.html"
|
|
67
|
+
warnings: list[str] = []
|
|
68
|
+
if not (output_dir / entrypoint).is_file():
|
|
69
|
+
warnings.append(
|
|
70
|
+
f"entrypoint {entrypoint!r} not found in {output_dir} after render"
|
|
71
|
+
)
|
|
72
|
+
return BuildResult(
|
|
73
|
+
output_dir=output_dir,
|
|
74
|
+
suggested_entrypoint=entrypoint,
|
|
75
|
+
warnings=warnings,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _find_output_dir(cwd: Path) -> Path:
|
|
80
|
+
"""Pick the rendered output directory per Quarto's project conventions."""
|
|
81
|
+
for candidate in ("_site", "_book"):
|
|
82
|
+
path = cwd / candidate
|
|
83
|
+
if path.is_dir():
|
|
84
|
+
return path.resolve()
|
|
85
|
+
raise BuilderFailed(
|
|
86
|
+
f"quarto rendered successfully but neither _site/ nor _book/ exists in {cwd}. "
|
|
87
|
+
"If you use a custom `project.output-dir`, pass `--dir <that-dir>` instead."
|
|
88
|
+
)
|