versite 0.0.1__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.
versite/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ __all__ = ["command"]
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ class BuilderError(RuntimeError):
10
+ pass
11
+
12
+
13
+ @dataclass
14
+ class BuildResult:
15
+ site_dir: str
16
+ metadata: dict[str, Any]
17
+
18
+
19
+ @dataclass
20
+ class CommandBuilder:
21
+ name: str
22
+ command: list[str]
23
+
24
+ def build(
25
+ self,
26
+ *,
27
+ version: str,
28
+ output_dir: str,
29
+ config: dict[str, Any],
30
+ quiet: bool = False,
31
+ ) -> BuildResult:
32
+ variables = {
33
+ "version": version,
34
+ "output_dir": output_dir,
35
+ "source": config.get("source", "."),
36
+ "config_file": config.get("config_file", "mkdocs.yml"),
37
+ }
38
+ rendered = [part.format(**variables) for part in self.command]
39
+ env = os.environ.copy()
40
+ env["VERSITE_VERSION"] = version
41
+ if self.name == "mkdocs":
42
+ env["MIKE_DOCS_VERSION"] = version
43
+ kwargs: dict[str, Any] = {"check": True, "env": env}
44
+ if quiet:
45
+ kwargs["stdout"] = subprocess.DEVNULL
46
+ kwargs["stderr"] = subprocess.DEVNULL
47
+ try:
48
+ subprocess.run(rendered, **kwargs)
49
+ except FileNotFoundError as exc:
50
+ raise BuilderError(
51
+ f"builder command is unavailable: {rendered[0]}"
52
+ ) from exc
53
+ except subprocess.CalledProcessError as exc:
54
+ raise BuilderError(f"builder command failed with exit code {exc.returncode}") from exc
55
+ return BuildResult(site_dir=output_dir, metadata={"command": rendered})
56
+
57
+
58
+ def load_builder(name: str, config: dict[str, Any]) -> CommandBuilder:
59
+ try:
60
+ builder_config = config["builders"][name]
61
+ except KeyError as exc:
62
+ raise BuilderError(f"unknown builder: {name}") from exc
63
+ command = builder_config.get("command")
64
+ if not command:
65
+ raise BuilderError(f"builder '{name}' has no command configured")
66
+ return CommandBuilder(name=name, command=list(command))
versite/cli.py ADDED
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from versite.commands import (
7
+ VersiteError,
8
+ alias_version,
9
+ delete_versions,
10
+ deploy_version,
11
+ list_versions,
12
+ props_version,
13
+ retitle_version,
14
+ serve_site,
15
+ set_default,
16
+ )
17
+ from versite.config import apply_cli_overrides, load_config
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(prog="versite")
22
+ subparsers = parser.add_subparsers(dest="command", required=True)
23
+
24
+ deploy = subparsers.add_parser("deploy")
25
+ deploy.add_argument("version")
26
+ deploy.add_argument("aliases", nargs="*")
27
+ _add_common_options(deploy, include_builder=True)
28
+ deploy.add_argument("--source")
29
+ deploy.add_argument("--output-dir")
30
+ deploy.add_argument("--build-command", nargs=argparse.REMAINDER)
31
+ deploy.add_argument("-q", "--quiet", action="store_true")
32
+
33
+ list_parser = subparsers.add_parser("list")
34
+ list_parser.add_argument("identifier", nargs="?")
35
+ _add_common_options(list_parser)
36
+ list_parser.add_argument("--json", action="store_true")
37
+
38
+ delete = subparsers.add_parser("delete")
39
+ delete.add_argument("identifiers", nargs="*")
40
+ delete.add_argument("--all", action="store_true")
41
+ _add_common_options(delete)
42
+
43
+ alias = subparsers.add_parser("alias")
44
+ alias.add_argument("identifier")
45
+ alias.add_argument("aliases", nargs="*")
46
+ _add_common_options(alias)
47
+ alias.add_argument("--alias-type", choices=["redirect", "copy", "symlink"])
48
+
49
+ retitle = subparsers.add_parser("retitle")
50
+ retitle.add_argument("identifier")
51
+ retitle.add_argument("title")
52
+ _add_common_options(retitle)
53
+
54
+ props = subparsers.add_parser("props")
55
+ props.add_argument("identifier")
56
+ props.add_argument("prop", nargs="?")
57
+ _add_common_options(props)
58
+ props.add_argument("--json", action="store_true")
59
+
60
+ default = subparsers.add_parser("set-default")
61
+ default.add_argument("identifier")
62
+ _add_common_options(default)
63
+
64
+ serve = subparsers.add_parser("serve")
65
+ _add_common_options(serve)
66
+ serve.add_argument("--host", default="127.0.0.1")
67
+ serve.add_argument("--port", type=int, default=8000)
68
+
69
+ return parser
70
+
71
+
72
+ def _add_common_options(parser: argparse.ArgumentParser, include_builder: bool = False) -> None:
73
+ parser.add_argument("--config-file")
74
+ if include_builder:
75
+ parser.add_argument("--builder")
76
+ parser.add_argument("-r", "--remote")
77
+ parser.add_argument("-b", "--branch")
78
+ parser.add_argument("-m", "--message")
79
+ parser.add_argument("-p", "--push", action="store_true", default=None)
80
+ parser.add_argument("--allow-empty", action="store_true")
81
+ parser.add_argument("--deploy-prefix")
82
+ parser.add_argument("-T", "--template", dest="redirect_template")
83
+ parser.add_argument("--ignore-remote-status", action="store_true")
84
+
85
+
86
+ def _load_runtime_config(args: argparse.Namespace) -> dict:
87
+ config, _ = load_config(args.config_file)
88
+ return apply_cli_overrides(
89
+ config,
90
+ builder=getattr(args, "builder", None),
91
+ remote=args.remote,
92
+ branch=args.branch,
93
+ message=args.message,
94
+ push=args.push,
95
+ deploy_prefix=args.deploy_prefix,
96
+ alias_type=getattr(args, "alias_type", None),
97
+ redirect_template=args.redirect_template,
98
+ ignore_remote_status=args.ignore_remote_status,
99
+ source=getattr(args, "source", None),
100
+ output_dir=getattr(args, "output_dir", None),
101
+ build_command=getattr(args, "build_command", None),
102
+ )
103
+
104
+
105
+ def main(argv: list[str] | None = None) -> int:
106
+ parser = build_parser()
107
+ args = parser.parse_args(argv)
108
+ try:
109
+ config = _load_runtime_config(args)
110
+ push = config.get("push", False)
111
+ if args.command == "deploy":
112
+ return deploy_version(
113
+ config,
114
+ args.version,
115
+ args.aliases,
116
+ message=args.message,
117
+ push=push,
118
+ allow_empty=args.allow_empty,
119
+ quiet=args.quiet,
120
+ )
121
+ if args.command == "list":
122
+ return list_versions(config, args.identifier, as_json=args.json)
123
+ if args.command == "delete":
124
+ return delete_versions(
125
+ config,
126
+ args.identifiers,
127
+ delete_all=args.all,
128
+ message=args.message,
129
+ push=push,
130
+ allow_empty=args.allow_empty,
131
+ )
132
+ if args.command == "alias":
133
+ return alias_version(
134
+ config,
135
+ args.identifier,
136
+ args.aliases,
137
+ alias_type=args.alias_type or config["alias_type"],
138
+ message=args.message,
139
+ push=push,
140
+ allow_empty=args.allow_empty,
141
+ )
142
+ if args.command == "retitle":
143
+ return retitle_version(
144
+ config,
145
+ args.identifier,
146
+ args.title,
147
+ message=args.message,
148
+ push=push,
149
+ allow_empty=args.allow_empty,
150
+ )
151
+ if args.command == "props":
152
+ return props_version(
153
+ config,
154
+ args.identifier,
155
+ args.prop,
156
+ message=args.message,
157
+ push=push,
158
+ allow_empty=args.allow_empty,
159
+ as_json=args.json,
160
+ )
161
+ if args.command == "set-default":
162
+ return set_default(
163
+ config,
164
+ args.identifier,
165
+ message=args.message,
166
+ push=push,
167
+ allow_empty=args.allow_empty,
168
+ )
169
+ if args.command == "serve":
170
+ return serve_site(config, host=args.host, port=args.port)
171
+ parser.error(f"unsupported command: {args.command}")
172
+ except (VersiteError, ValueError, KeyError) as exc:
173
+ print(f"versite: {exc}", file=sys.stderr)
174
+ return 1
175
+ except Exception as exc: # pragma: no cover
176
+ print(f"versite: {exc}", file=sys.stderr)
177
+ return 1
178
+ return 0
179
+
180
+
181
+ if __name__ == "__main__":
182
+ raise SystemExit(main())
versite/commands.py ADDED
@@ -0,0 +1,406 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from versite.config import normalize_prefix
11
+ from versite.git_utils import GitError, branch_worktree, commit_all, git_root, push_branch
12
+ from versite.jsonpath import parse_assignment, parse_value
13
+ from versite.redirects import write_redirect
14
+ from versite.serve import serve_directory
15
+ from versite.versions import VersionStore
16
+
17
+
18
+ class VersiteError(RuntimeError):
19
+ pass
20
+
21
+
22
+ def _site_root(worktree: Path, deploy_prefix: str) -> Path:
23
+ prefix = normalize_prefix(deploy_prefix)
24
+ return worktree / prefix if prefix else worktree
25
+
26
+
27
+ def _versions_file(worktree: Path, deploy_prefix: str) -> Path:
28
+ return _site_root(worktree, deploy_prefix) / "versions.json"
29
+
30
+
31
+ def _identifier_path(worktree: Path, deploy_prefix: str, identifier: str) -> Path:
32
+ if identifier.startswith("/") or ".." in Path(identifier).parts:
33
+ raise VersiteError(f"unsafe identifier path: {identifier}")
34
+ return _site_root(worktree, deploy_prefix) / identifier
35
+
36
+
37
+ def _print(data: Any, as_json: bool = False) -> None:
38
+ if as_json:
39
+ print(json.dumps(data, indent=2, sort_keys=True))
40
+ elif isinstance(data, (dict, list)):
41
+ print(json.dumps(data, indent=2, sort_keys=True))
42
+ else:
43
+ print(data)
44
+
45
+
46
+ def _load_store(worktree: Path, deploy_prefix: str) -> VersionStore:
47
+ return VersionStore.load(_versions_file(worktree, deploy_prefix))
48
+
49
+
50
+ def _save_store(worktree: Path, deploy_prefix: str, store: VersionStore) -> None:
51
+ site_root = _site_root(worktree, deploy_prefix)
52
+ site_root.mkdir(parents=True, exist_ok=True)
53
+ store.save(_versions_file(worktree, deploy_prefix))
54
+
55
+
56
+ def _root_redirect_href(identifier: str, deploy_prefix: str) -> str:
57
+ prefix = normalize_prefix(deploy_prefix)
58
+ if prefix:
59
+ return f"./{prefix}/{identifier}/"
60
+ return f"./{identifier}/"
61
+
62
+
63
+ def _alias_redirect_href(alias: str, version: str) -> str:
64
+ alias_parts = Path(alias).parts
65
+ upward = "../" * max(len(alias_parts), 1)
66
+ return f"{upward}{version}/"
67
+
68
+
69
+ def _copy_contents(source: Path, destination: Path) -> None:
70
+ if destination.exists() or destination.is_symlink():
71
+ if destination.is_dir() and not destination.is_symlink():
72
+ shutil.rmtree(destination)
73
+ else:
74
+ destination.unlink()
75
+ shutil.copytree(source, destination, symlinks=True)
76
+
77
+
78
+ def _write_alias(
79
+ worktree: Path,
80
+ deploy_prefix: str,
81
+ alias: str,
82
+ version: str,
83
+ alias_type: str,
84
+ redirect_template: str | None,
85
+ ) -> None:
86
+ alias_path = _identifier_path(worktree, deploy_prefix, alias)
87
+ version_path = _identifier_path(worktree, deploy_prefix, version)
88
+ if alias_path.exists() or alias_path.is_symlink():
89
+ if alias_path.is_dir() and not alias_path.is_symlink():
90
+ shutil.rmtree(alias_path)
91
+ else:
92
+ alias_path.unlink()
93
+ if alias_type == "redirect":
94
+ write_redirect(alias_path / "index.html", _alias_redirect_href(alias, version), redirect_template)
95
+ return
96
+ if alias_type == "copy":
97
+ _copy_contents(version_path, alias_path)
98
+ return
99
+ if alias_type == "symlink":
100
+ alias_path.parent.mkdir(parents=True, exist_ok=True)
101
+ target = os.path.relpath(version_path, alias_path.parent)
102
+ os.symlink(target, alias_path)
103
+ return
104
+ raise VersiteError(f"unsupported alias type: {alias_type}")
105
+
106
+
107
+ def _remove_path(path: Path) -> None:
108
+ if path.is_symlink() or path.is_file():
109
+ path.unlink(missing_ok=True)
110
+ elif path.is_dir():
111
+ shutil.rmtree(path)
112
+
113
+
114
+ def list_versions(config: dict[str, Any], identifier: str | None = None, as_json: bool = False) -> int:
115
+ repo = git_root()
116
+ with branch_worktree(
117
+ repo,
118
+ config["branch"],
119
+ remote=config["remote"],
120
+ ignore_remote_status=config.get("ignore_remote_status", False),
121
+ ) as worktree:
122
+ store = _load_store(worktree, config["deploy_prefix"])
123
+ if identifier is None:
124
+ _print(store.as_list(), as_json)
125
+ return 0
126
+ record = store.get(identifier)
127
+ _print(
128
+ {
129
+ "version": record.version,
130
+ "title": record.title,
131
+ "aliases": record.aliases,
132
+ "properties": record.properties,
133
+ },
134
+ as_json,
135
+ )
136
+ return 0
137
+
138
+
139
+ def delete_versions(
140
+ config: dict[str, Any],
141
+ identifiers: list[str],
142
+ *,
143
+ delete_all: bool = False,
144
+ message: str | None = None,
145
+ push: bool = False,
146
+ allow_empty: bool = False,
147
+ ) -> int:
148
+ if not identifiers and not delete_all:
149
+ raise VersiteError("delete requires identifiers or --all")
150
+ repo = git_root()
151
+ with branch_worktree(
152
+ repo,
153
+ config["branch"],
154
+ remote=config["remote"],
155
+ ignore_remote_status=config.get("ignore_remote_status", False),
156
+ ) as worktree:
157
+ store = _load_store(worktree, config["deploy_prefix"])
158
+ if delete_all:
159
+ targets = [record.version for record in list(store.records)]
160
+ else:
161
+ targets = identifiers
162
+ removed_any = False
163
+ for identifier in targets:
164
+ version, aliases = store.delete_identifier(identifier)
165
+ removed_any = True
166
+ if version is not None:
167
+ _remove_path(_identifier_path(worktree, config["deploy_prefix"], version))
168
+ for alias in aliases:
169
+ _remove_path(_identifier_path(worktree, config["deploy_prefix"], alias))
170
+ _save_store(worktree, config["deploy_prefix"], store)
171
+ committed = commit_all(
172
+ worktree,
173
+ message or ("Delete all deployed versions" if delete_all else f"Delete {' '.join(targets)}"),
174
+ allow_empty=allow_empty,
175
+ )
176
+ if push and committed:
177
+ push_branch(worktree, config["remote"], config["branch"])
178
+ return 0 if removed_any else 1
179
+
180
+
181
+ def alias_version(
182
+ config: dict[str, Any],
183
+ identifier: str,
184
+ aliases: list[str],
185
+ *,
186
+ alias_type: str,
187
+ message: str | None = None,
188
+ push: bool = False,
189
+ allow_empty: bool = False,
190
+ ) -> int:
191
+ if not aliases:
192
+ raise VersiteError("alias requires at least one alias")
193
+ repo = git_root()
194
+ with branch_worktree(
195
+ repo,
196
+ config["branch"],
197
+ remote=config["remote"],
198
+ ignore_remote_status=config.get("ignore_remote_status", False),
199
+ ) as worktree:
200
+ store = _load_store(worktree, config["deploy_prefix"])
201
+ record = store.get(identifier)
202
+ store.upsert(record.version, aliases=aliases)
203
+ for alias in aliases:
204
+ _write_alias(
205
+ worktree,
206
+ config["deploy_prefix"],
207
+ alias,
208
+ record.version,
209
+ alias_type,
210
+ config.get("redirect_template"),
211
+ )
212
+ _save_store(worktree, config["deploy_prefix"], store)
213
+ committed = commit_all(
214
+ worktree,
215
+ message or f"Update aliases for {record.version}",
216
+ allow_empty=allow_empty,
217
+ )
218
+ if push and committed:
219
+ push_branch(worktree, config["remote"], config["branch"])
220
+ return 0
221
+
222
+
223
+ def retitle_version(
224
+ config: dict[str, Any],
225
+ identifier: str,
226
+ title: str,
227
+ *,
228
+ message: str | None = None,
229
+ push: bool = False,
230
+ allow_empty: bool = False,
231
+ ) -> int:
232
+ repo = git_root()
233
+ with branch_worktree(
234
+ repo,
235
+ config["branch"],
236
+ remote=config["remote"],
237
+ ignore_remote_status=config.get("ignore_remote_status", False),
238
+ ) as worktree:
239
+ store = _load_store(worktree, config["deploy_prefix"])
240
+ store.retitle(identifier, title)
241
+ _save_store(worktree, config["deploy_prefix"], store)
242
+ committed = commit_all(worktree, message or f"Retitle {identifier}", allow_empty=allow_empty)
243
+ if push and committed:
244
+ push_branch(worktree, config["remote"], config["branch"])
245
+ return 0
246
+
247
+
248
+ def props_version(
249
+ config: dict[str, Any],
250
+ identifier: str,
251
+ prop: str | None = None,
252
+ *,
253
+ message: str | None = None,
254
+ push: bool = False,
255
+ allow_empty: bool = False,
256
+ as_json: bool = False,
257
+ ) -> int:
258
+ repo = git_root()
259
+ needs_write = prop is not None and ("=" in prop or prop.endswith("-"))
260
+ with branch_worktree(
261
+ repo,
262
+ config["branch"],
263
+ remote=config["remote"],
264
+ ignore_remote_status=config.get("ignore_remote_status", False),
265
+ ) as worktree:
266
+ store = _load_store(worktree, config["deploy_prefix"])
267
+ if prop is None:
268
+ _print(store.get_properties(identifier), as_json)
269
+ return 0
270
+ if "=" in prop:
271
+ path, raw_value = parse_assignment(prop)
272
+ props = store.set_property(identifier, path, parse_value(raw_value))
273
+ _save_store(worktree, config["deploy_prefix"], store)
274
+ committed = commit_all(
275
+ worktree,
276
+ message or f"Update properties for {identifier}",
277
+ allow_empty=allow_empty,
278
+ )
279
+ if push and committed:
280
+ push_branch(worktree, config["remote"], config["branch"])
281
+ _print(props, as_json)
282
+ return 0
283
+ if prop.endswith("-"):
284
+ path = prop[:-1]
285
+ props = store.delete_property(identifier, path)
286
+ _save_store(worktree, config["deploy_prefix"], store)
287
+ committed = commit_all(
288
+ worktree,
289
+ message or f"Update properties for {identifier}",
290
+ allow_empty=allow_empty,
291
+ )
292
+ if push and committed:
293
+ push_branch(worktree, config["remote"], config["branch"])
294
+ _print(props, as_json)
295
+ return 0
296
+ _print(store.get_property(identifier, prop), as_json)
297
+ return 0
298
+
299
+
300
+ def set_default(
301
+ config: dict[str, Any],
302
+ identifier: str,
303
+ *,
304
+ message: str | None = None,
305
+ push: bool = False,
306
+ allow_empty: bool = False,
307
+ ) -> int:
308
+ repo = git_root()
309
+ with branch_worktree(
310
+ repo,
311
+ config["branch"],
312
+ remote=config["remote"],
313
+ ignore_remote_status=config.get("ignore_remote_status", False),
314
+ ) as worktree:
315
+ store = _load_store(worktree, config["deploy_prefix"])
316
+ store.get(identifier)
317
+ write_redirect(
318
+ worktree / "index.html",
319
+ _root_redirect_href(identifier, config["deploy_prefix"]),
320
+ config.get("redirect_template"),
321
+ )
322
+ committed = commit_all(worktree, message or f"Set default site to {identifier}", allow_empty=allow_empty)
323
+ if push and committed:
324
+ push_branch(worktree, config["remote"], config["branch"])
325
+ return 0
326
+
327
+
328
+ def serve_site(config: dict[str, Any], host: str = "127.0.0.1", port: int = 8000) -> int:
329
+ repo = git_root()
330
+ with branch_worktree(
331
+ repo,
332
+ config["branch"],
333
+ remote=config["remote"],
334
+ ignore_remote_status=True,
335
+ ) as worktree:
336
+ serve_directory(worktree, host=host, port=port)
337
+ return 0
338
+
339
+
340
+ def deploy_version(
341
+ config: dict[str, Any],
342
+ version: str,
343
+ aliases: list[str],
344
+ *,
345
+ message: str | None = None,
346
+ push: bool = False,
347
+ allow_empty: bool = False,
348
+ quiet: bool = False,
349
+ ) -> int:
350
+ from versite.builders.command import BuilderError, load_builder
351
+
352
+ repo = git_root()
353
+ builder_name = config["builder"]
354
+ builder = load_builder(builder_name, config)
355
+ builder_config = config["builders"][builder_name]
356
+ output_dir = config.get("output_dir")
357
+ temp_output: tempfile.TemporaryDirectory[str] | None = None
358
+ if output_dir is None:
359
+ temp_output = tempfile.TemporaryDirectory(prefix="versite-build-")
360
+ output_dir = temp_output.name
361
+ try:
362
+ builder.build(
363
+ version=version,
364
+ output_dir=output_dir,
365
+ config=builder_config,
366
+ quiet=quiet,
367
+ )
368
+ with branch_worktree(
369
+ repo,
370
+ config["branch"],
371
+ remote=config["remote"],
372
+ ignore_remote_status=config.get("ignore_remote_status", False),
373
+ ) as worktree:
374
+ site_root = _site_root(worktree, config["deploy_prefix"])
375
+ site_root.mkdir(parents=True, exist_ok=True)
376
+ version_path = _identifier_path(worktree, config["deploy_prefix"], version)
377
+ _copy_contents(Path(output_dir), version_path)
378
+ store = _load_store(worktree, config["deploy_prefix"])
379
+ title = version
380
+ existing = store.find(version)
381
+ if existing is not None:
382
+ title = existing.title
383
+ store.upsert(version, title=title, aliases=aliases)
384
+ for alias in aliases:
385
+ _write_alias(
386
+ worktree,
387
+ config["deploy_prefix"],
388
+ alias,
389
+ version,
390
+ config["alias_type"],
391
+ config.get("redirect_template"),
392
+ )
393
+ _save_store(worktree, config["deploy_prefix"], store)
394
+ committed = commit_all(
395
+ worktree,
396
+ message or f"Deploy {version}",
397
+ allow_empty=allow_empty,
398
+ )
399
+ if push and committed:
400
+ push_branch(worktree, config["remote"], config["branch"])
401
+ return 0
402
+ except BuilderError as exc:
403
+ raise VersiteError(str(exc)) from exc
404
+ finally:
405
+ if temp_output is not None:
406
+ temp_output.cleanup()