qhost 0.1.0__tar.gz

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.
Files changed (44) hide show
  1. qhost-0.1.0/LICENSE +21 -0
  2. qhost-0.1.0/PKG-INFO +179 -0
  3. qhost-0.1.0/README.md +136 -0
  4. qhost-0.1.0/pyproject.toml +80 -0
  5. qhost-0.1.0/qhost/__init__.py +3 -0
  6. qhost-0.1.0/qhost/_io.py +66 -0
  7. qhost-0.1.0/qhost/_toml.py +99 -0
  8. qhost-0.1.0/qhost/builders/__init__.py +124 -0
  9. qhost-0.1.0/qhost/builders/directory.py +56 -0
  10. qhost-0.1.0/qhost/builders/quarto.py +88 -0
  11. qhost-0.1.0/qhost/cli.py +576 -0
  12. qhost-0.1.0/qhost/client.py +316 -0
  13. qhost-0.1.0/qhost/config.py +111 -0
  14. qhost-0.1.0/qhost/creds.py +177 -0
  15. qhost-0.1.0/qhost/doctor.py +325 -0
  16. qhost-0.1.0/qhost/errors.py +173 -0
  17. qhost-0.1.0/qhost/format.py +332 -0
  18. qhost-0.1.0/qhost/project.py +183 -0
  19. qhost-0.1.0/qhost/publish.py +467 -0
  20. qhost-0.1.0/qhost.egg-info/PKG-INFO +179 -0
  21. qhost-0.1.0/qhost.egg-info/SOURCES.txt +42 -0
  22. qhost-0.1.0/qhost.egg-info/dependency_links.txt +1 -0
  23. qhost-0.1.0/qhost.egg-info/entry_points.txt +2 -0
  24. qhost-0.1.0/qhost.egg-info/requires.txt +16 -0
  25. qhost-0.1.0/qhost.egg-info/top_level.txt +3 -0
  26. qhost-0.1.0/qhost_core/__init__.py +85 -0
  27. qhost-0.1.0/qhost_core/archive.py +230 -0
  28. qhost-0.1.0/qhost_core/durations.py +43 -0
  29. qhost-0.1.0/qhost_core/models.py +244 -0
  30. qhost-0.1.0/qhost_core/slugs.py +66 -0
  31. qhost-0.1.0/qhost_core/version.py +2 -0
  32. qhost-0.1.0/qhostd/__init__.py +23 -0
  33. qhost-0.1.0/qhostd/api.py +361 -0
  34. qhost-0.1.0/qhostd/app.py +181 -0
  35. qhost-0.1.0/qhostd/config.py +208 -0
  36. qhost-0.1.0/qhostd/content.py +199 -0
  37. qhost-0.1.0/qhostd/errors.py +225 -0
  38. qhost-0.1.0/qhostd/locks.py +36 -0
  39. qhost-0.1.0/qhostd/prune.py +77 -0
  40. qhost-0.1.0/qhostd/reconcile.py +130 -0
  41. qhost-0.1.0/qhostd/security.py +482 -0
  42. qhost-0.1.0/qhostd/serving.py +311 -0
  43. qhost-0.1.0/qhostd/store.py +398 -0
  44. qhost-0.1.0/setup.cfg +4 -0
qhost-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jan Fasnacht
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
qhost-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: qhost
3
+ Version: 0.1.0
4
+ Summary: Self-hosted static-site host with a single trusted publisher.
5
+ Author: Jan Fasnacht
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/janfasnacht/qhost
8
+ Project-URL: Source, https://github.com/janfasnacht/qhost
9
+ Project-URL: Issues, https://github.com/janfasnacht/qhost/issues
10
+ Project-URL: Changelog, https://github.com/janfasnacht/qhost/blob/main/CHANGELOG.md
11
+ Keywords: static-site,self-hosted,file-sharing,expiring-shares,preview-environments
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Environment :: Web Environment
15
+ Classifier: Framework :: FastAPI
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Intended Audience :: System Administrators
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
23
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
24
+ Classifier: Topic :: System :: Archiving :: Mirroring
25
+ Requires-Python: >=3.12
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: pydantic>=2.6
29
+ Requires-Dist: httpx>=0.27
30
+ Requires-Dist: typer>=0.12
31
+ Provides-Extra: server
32
+ Requires-Dist: fastapi>=0.111; extra == "server"
33
+ Requires-Dist: uvicorn[standard]>=0.30; extra == "server"
34
+ Requires-Dist: bcrypt>=4.1; extra == "server"
35
+ Requires-Dist: python-multipart>=0.0.9; extra == "server"
36
+ Provides-Extra: dev
37
+ Requires-Dist: pytest>=8; extra == "dev"
38
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
39
+ Requires-Dist: hypothesis>=6; extra == "dev"
40
+ Requires-Dist: ruff>=0.5; extra == "dev"
41
+ Requires-Dist: asgi-lifespan>=2.1; extra == "dev"
42
+ Dynamic: license-file
43
+
44
+ # qhost
45
+
46
+ qhost publishes a directory of static files — a built site, a rendered
47
+ report, a coverage tree — at a URL that requires access and expires on a
48
+ schedule. The operator runs one container, points a DNS record at their
49
+ own reverse proxy, and manages shares from the command line. It is
50
+ access-controlled hosting for a single trusted publisher per instance;
51
+ it does not isolate mutually distrusting shares from one another.
52
+
53
+ ## Self-host the server
54
+
55
+ qhostd ships as `ghcr.io/janfasnacht/qhost-server`. Drop a
56
+ `docker-compose.yml` and `.env` somewhere on your box and start it:
57
+
58
+ ```yaml
59
+ # docker-compose.yml
60
+ services:
61
+ qhostd:
62
+ image: ghcr.io/janfasnacht/qhost-server:0.1.0
63
+ restart: unless-stopped
64
+ environment:
65
+ QHOST_DOMAIN: ${QHOST_DOMAIN:?}
66
+ QHOST_TRUSTED_PROXY: ${QHOST_TRUSTED_PROXY:?}
67
+ QHOST_TOKEN: ${QHOST_TOKEN:-}
68
+ volumes:
69
+ - qhost_data:/data
70
+ ports:
71
+ - "127.0.0.1:8000:8000"
72
+
73
+ volumes:
74
+ qhost_data:
75
+ ```
76
+
77
+ ```
78
+ # .env
79
+ QHOST_DOMAIN=share.example.com
80
+ QHOST_TRUSTED_PROXY=127.0.0.1 # whatever IP / CIDR your proxy connects from
81
+ QHOST_TOKEN= # leave blank to auto-generate on first start
82
+ ```
83
+
84
+ ```bash
85
+ docker compose up -d
86
+ docker compose exec qhostd cat /data/operator-token # if you left it blank
87
+ ```
88
+
89
+ qhostd is now on `http://127.0.0.1:8000` of the host, ready to be
90
+ proxied. The full env-var surface is in
91
+ [`deploy/.env.example`](deploy/.env.example) and the canonical compose
92
+ is in [`deploy/docker-compose.yml`](deploy/docker-compose.yml).
93
+
94
+ ## Wire a reverse proxy
95
+
96
+ qhostd doesn't terminate TLS, doesn't strip incoming `X-Forwarded-For`,
97
+ and doesn't enforce a body-size cap before the request reaches Python.
98
+ A proxy in front of it has to do those three things, and qhostd has to
99
+ trust that proxy via `QHOST_TRUSTED_PROXY` (an IP or CIDR — see above).
100
+
101
+ Full ready-to-edit examples are in [`deploy/proxies/`](deploy/proxies/).
102
+ The interesting line in each:
103
+
104
+ **Caddy** ([`deploy/proxies/Caddyfile`](deploy/proxies/Caddyfile))
105
+
106
+ ```caddyfile
107
+ {$QHOST_DOMAIN} {
108
+ reverse_proxy 127.0.0.1:8000 {
109
+ header_up X-Forwarded-For {http.request.remote.host} # overwrite, not append
110
+ }
111
+ request_body { max_size 110MB }
112
+ header Strict-Transport-Security "max-age=31536000; includeSubDomains"
113
+ }
114
+ ```
115
+
116
+ **nginx** ([`deploy/proxies/nginx.conf`](deploy/proxies/nginx.conf))
117
+
118
+ ```nginx
119
+ proxy_set_header X-Forwarded-For $remote_addr; # overwrite, not append
120
+ client_max_body_size 110m;
121
+ ```
122
+
123
+ **Cloudflare Tunnel** ([`deploy/proxies/cloudflared.md`](deploy/proxies/cloudflared.md))
124
+
125
+ ```yaml
126
+ ingress:
127
+ - hostname: share.example.com
128
+ service: http://127.0.0.1:8000
129
+ ```
130
+
131
+ `cloudflared` connects from loopback, so `QHOST_TRUSTED_PROXY=127.0.0.1`
132
+ is what you want.
133
+
134
+ ## Install the CLI
135
+
136
+ ```bash
137
+ pip install qhost
138
+ qhost login https://share.example.com
139
+ ```
140
+
141
+ `qhost login` prompts for the operator token and writes it to
142
+ `~/.config/qhost/config.toml` (mode 0600).
143
+
144
+ ## Publish
145
+
146
+ ```bash
147
+ cd path/to/built/site && qhost publish
148
+ ```
149
+
150
+ `qhost publish` from a directory containing `index.html` publishes that
151
+ directory as-is. From a Quarto project (`_quarto.yml` present) it runs
152
+ `quarto render` first. Otherwise it refuses; pass `--dir <path>` to
153
+ point at a built output or `--build <name>` to force a specific builder.
154
+
155
+ `qhost --help` covers the rest: `publish`, `ls`, `info`, `extend`, `rm`,
156
+ `open`, `doctor`. Default expiry is seven days. `--password` gates a
157
+ share behind HTTP Basic with a generated password; `--public` makes a
158
+ guessable URL with no secret; the default `link` mode treats the URL
159
+ itself as the credential — `/s/<slug>/` carries ~80 bits of entropy and
160
+ is not enumerable.
161
+
162
+ `qhost publish --sandbox` serves a share under
163
+ `Content-Security-Policy: sandbox allow-scripts`, the only
164
+ intra-instance isolation primitive. `qhost doctor` flags content that
165
+ probably wants it.
166
+
167
+ ## Trust model
168
+
169
+ One operator token authorizes all publishing on an instance; every
170
+ visitor is untrusted. All shares serve from the same browser origin, so
171
+ a share's JavaScript can reach any other share on the same instance
172
+ with the visitor's ambient Basic-auth credentials. For mutually
173
+ distrusting publishers, run separate instances on separate origins.
174
+
175
+ ---
176
+
177
+ Configuration: [`deploy/.env.example`](deploy/.env.example). Reverse
178
+ proxy examples: [`deploy/proxies/`](deploy/proxies/). Vulnerability
179
+ reports: [`SECURITY.md`](SECURITY.md). MIT.
qhost-0.1.0/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # qhost
2
+
3
+ qhost publishes a directory of static files — a built site, a rendered
4
+ report, a coverage tree — at a URL that requires access and expires on a
5
+ schedule. The operator runs one container, points a DNS record at their
6
+ own reverse proxy, and manages shares from the command line. It is
7
+ access-controlled hosting for a single trusted publisher per instance;
8
+ it does not isolate mutually distrusting shares from one another.
9
+
10
+ ## Self-host the server
11
+
12
+ qhostd ships as `ghcr.io/janfasnacht/qhost-server`. Drop a
13
+ `docker-compose.yml` and `.env` somewhere on your box and start it:
14
+
15
+ ```yaml
16
+ # docker-compose.yml
17
+ services:
18
+ qhostd:
19
+ image: ghcr.io/janfasnacht/qhost-server:0.1.0
20
+ restart: unless-stopped
21
+ environment:
22
+ QHOST_DOMAIN: ${QHOST_DOMAIN:?}
23
+ QHOST_TRUSTED_PROXY: ${QHOST_TRUSTED_PROXY:?}
24
+ QHOST_TOKEN: ${QHOST_TOKEN:-}
25
+ volumes:
26
+ - qhost_data:/data
27
+ ports:
28
+ - "127.0.0.1:8000:8000"
29
+
30
+ volumes:
31
+ qhost_data:
32
+ ```
33
+
34
+ ```
35
+ # .env
36
+ QHOST_DOMAIN=share.example.com
37
+ QHOST_TRUSTED_PROXY=127.0.0.1 # whatever IP / CIDR your proxy connects from
38
+ QHOST_TOKEN= # leave blank to auto-generate on first start
39
+ ```
40
+
41
+ ```bash
42
+ docker compose up -d
43
+ docker compose exec qhostd cat /data/operator-token # if you left it blank
44
+ ```
45
+
46
+ qhostd is now on `http://127.0.0.1:8000` of the host, ready to be
47
+ proxied. The full env-var surface is in
48
+ [`deploy/.env.example`](deploy/.env.example) and the canonical compose
49
+ is in [`deploy/docker-compose.yml`](deploy/docker-compose.yml).
50
+
51
+ ## Wire a reverse proxy
52
+
53
+ qhostd doesn't terminate TLS, doesn't strip incoming `X-Forwarded-For`,
54
+ and doesn't enforce a body-size cap before the request reaches Python.
55
+ A proxy in front of it has to do those three things, and qhostd has to
56
+ trust that proxy via `QHOST_TRUSTED_PROXY` (an IP or CIDR — see above).
57
+
58
+ Full ready-to-edit examples are in [`deploy/proxies/`](deploy/proxies/).
59
+ The interesting line in each:
60
+
61
+ **Caddy** ([`deploy/proxies/Caddyfile`](deploy/proxies/Caddyfile))
62
+
63
+ ```caddyfile
64
+ {$QHOST_DOMAIN} {
65
+ reverse_proxy 127.0.0.1:8000 {
66
+ header_up X-Forwarded-For {http.request.remote.host} # overwrite, not append
67
+ }
68
+ request_body { max_size 110MB }
69
+ header Strict-Transport-Security "max-age=31536000; includeSubDomains"
70
+ }
71
+ ```
72
+
73
+ **nginx** ([`deploy/proxies/nginx.conf`](deploy/proxies/nginx.conf))
74
+
75
+ ```nginx
76
+ proxy_set_header X-Forwarded-For $remote_addr; # overwrite, not append
77
+ client_max_body_size 110m;
78
+ ```
79
+
80
+ **Cloudflare Tunnel** ([`deploy/proxies/cloudflared.md`](deploy/proxies/cloudflared.md))
81
+
82
+ ```yaml
83
+ ingress:
84
+ - hostname: share.example.com
85
+ service: http://127.0.0.1:8000
86
+ ```
87
+
88
+ `cloudflared` connects from loopback, so `QHOST_TRUSTED_PROXY=127.0.0.1`
89
+ is what you want.
90
+
91
+ ## Install the CLI
92
+
93
+ ```bash
94
+ pip install qhost
95
+ qhost login https://share.example.com
96
+ ```
97
+
98
+ `qhost login` prompts for the operator token and writes it to
99
+ `~/.config/qhost/config.toml` (mode 0600).
100
+
101
+ ## Publish
102
+
103
+ ```bash
104
+ cd path/to/built/site && qhost publish
105
+ ```
106
+
107
+ `qhost publish` from a directory containing `index.html` publishes that
108
+ directory as-is. From a Quarto project (`_quarto.yml` present) it runs
109
+ `quarto render` first. Otherwise it refuses; pass `--dir <path>` to
110
+ point at a built output or `--build <name>` to force a specific builder.
111
+
112
+ `qhost --help` covers the rest: `publish`, `ls`, `info`, `extend`, `rm`,
113
+ `open`, `doctor`. Default expiry is seven days. `--password` gates a
114
+ share behind HTTP Basic with a generated password; `--public` makes a
115
+ guessable URL with no secret; the default `link` mode treats the URL
116
+ itself as the credential — `/s/<slug>/` carries ~80 bits of entropy and
117
+ is not enumerable.
118
+
119
+ `qhost publish --sandbox` serves a share under
120
+ `Content-Security-Policy: sandbox allow-scripts`, the only
121
+ intra-instance isolation primitive. `qhost doctor` flags content that
122
+ probably wants it.
123
+
124
+ ## Trust model
125
+
126
+ One operator token authorizes all publishing on an instance; every
127
+ visitor is untrusted. All shares serve from the same browser origin, so
128
+ a share's JavaScript can reach any other share on the same instance
129
+ with the visitor's ambient Basic-auth credentials. For mutually
130
+ distrusting publishers, run separate instances on separate origins.
131
+
132
+ ---
133
+
134
+ Configuration: [`deploy/.env.example`](deploy/.env.example). Reverse
135
+ proxy examples: [`deploy/proxies/`](deploy/proxies/). Vulnerability
136
+ reports: [`SECURITY.md`](SECURITY.md). MIT.
@@ -0,0 +1,80 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "qhost"
7
+ version = "0.1.0"
8
+ description = "Self-hosted static-site host with a single trusted publisher."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.12"
12
+ authors = [{ name = "Jan Fasnacht" }]
13
+ keywords = [
14
+ "static-site",
15
+ "self-hosted",
16
+ "file-sharing",
17
+ "expiring-shares",
18
+ "preview-environments",
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 4 - Beta",
22
+ "Environment :: Console",
23
+ "Environment :: Web Environment",
24
+ "Framework :: FastAPI",
25
+ "Intended Audience :: Developers",
26
+ "Intended Audience :: System Administrators",
27
+ "License :: OSI Approved :: MIT License",
28
+ "Operating System :: POSIX :: Linux",
29
+ "Programming Language :: Python :: 3 :: Only",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
32
+ "Topic :: Internet :: WWW/HTTP :: Site Management",
33
+ "Topic :: System :: Archiving :: Mirroring",
34
+ ]
35
+ dependencies = [
36
+ "pydantic>=2.6",
37
+ "httpx>=0.27",
38
+ "typer>=0.12",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/janfasnacht/qhost"
43
+ Source = "https://github.com/janfasnacht/qhost"
44
+ Issues = "https://github.com/janfasnacht/qhost/issues"
45
+ Changelog = "https://github.com/janfasnacht/qhost/blob/main/CHANGELOG.md"
46
+
47
+ [project.scripts]
48
+ qhost = "qhost.cli:app"
49
+
50
+ [project.optional-dependencies]
51
+ server = [
52
+ "fastapi>=0.111",
53
+ "uvicorn[standard]>=0.30",
54
+ "bcrypt>=4.1",
55
+ "python-multipart>=0.0.9",
56
+ ]
57
+ dev = [
58
+ "pytest>=8",
59
+ "pytest-asyncio>=0.23",
60
+ "hypothesis>=6",
61
+ "ruff>=0.5",
62
+ "asgi-lifespan>=2.1",
63
+ ]
64
+
65
+ [tool.setuptools.packages.find]
66
+ where = ["."]
67
+ include = ["qhost_core*", "qhost*", "qhostd*"]
68
+ exclude = ["tests*"]
69
+
70
+ [tool.ruff]
71
+ line-length = 100
72
+ target-version = "py312"
73
+
74
+ [tool.ruff.lint]
75
+ select = ["E", "F", "W", "I", "B", "UP", "SIM"]
76
+
77
+ [tool.pytest.ini_options]
78
+ testpaths = ["tests"]
79
+ addopts = "-ra"
80
+ asyncio_mode = "auto"
@@ -0,0 +1,3 @@
1
+ from qhost_core.version import __version__
2
+
3
+ __all__ = ["__version__"]
@@ -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
+ )
@@ -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}"'