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 ADDED
@@ -0,0 +1,3 @@
1
+ from qhost_core.version import __version__
2
+
3
+ __all__ = ["__version__"]
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)
@@ -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
+ )