artoo-artifacts 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.
artoo/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """artoo — generate and manage artifacts.
2
+
3
+ An artifact is a directory marked by an ``artifact.toml``: a self-contained,
4
+ publishable HTML mini-site (``site/``) plus the research material that backs
5
+ it, kept behind a deny-by-default deploy firewall.
6
+ """
7
+
8
+ __version__ = "0.1.0"
artoo/build.py ADDED
@@ -0,0 +1,47 @@
1
+ """Build an artifact: run its refresh commands, then verify the site."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from dataclasses import dataclass, field
7
+
8
+ from . import firewall
9
+ from .manifest import Manifest
10
+
11
+
12
+ @dataclass
13
+ class BuildResult:
14
+ ok: bool
15
+ ran: list[str] = field(default_factory=list)
16
+ problems: list[str] = field(default_factory=list)
17
+ withheld: list[str] = field(default_factory=list)
18
+
19
+
20
+ def build(m: Manifest, *, dry_run: bool = False) -> BuildResult:
21
+ """Run ``[build] commands`` from the artifact directory, then firewall-check.
22
+
23
+ Build commands refresh generated inputs (exports, data snapshots). They
24
+ run with the artifact directory as cwd so relative paths in the manifest
25
+ stay portable.
26
+ """
27
+ result = BuildResult(ok=True)
28
+ for command in m.build_commands:
29
+ result.ran.append(command)
30
+ if dry_run:
31
+ continue
32
+ proc = subprocess.run(
33
+ command, shell=True, cwd=m.dir, capture_output=True, text=True
34
+ )
35
+ if proc.returncode != 0:
36
+ detail = (proc.stderr or proc.stdout or "").strip()
37
+ result.ok = False
38
+ result.problems.append(
39
+ f"build command failed ({proc.returncode}): {command}\n{detail}"
40
+ )
41
+ return result
42
+
43
+ result.problems.extend(firewall.check(m))
44
+ if result.problems:
45
+ result.ok = False
46
+ result.withheld = [str(p) for p in firewall.withheld(m.site_dir)] if m.site_dir.is_dir() else []
47
+ return result
artoo/cli.py ADDED
@@ -0,0 +1,274 @@
1
+ """The artoo CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from . import __version__, build as build_mod, deploy as deploy_mod
11
+ from . import discover, firewall, generators
12
+ from . import libraries as libraries_mod
13
+ from . import manifest as manifest_mod
14
+ from . import scaffold
15
+ from .manifest import KINDS, Manifest
16
+
17
+
18
+ def _resolve(path: str | None) -> Manifest:
19
+ try:
20
+ return discover.resolve_artifact(Path(path) if path else None)
21
+ except FileNotFoundError as exc:
22
+ raise click.ClickException(str(exc)) from exc
23
+
24
+
25
+ @click.group()
26
+ @click.version_option(version=__version__, prog_name="artoo")
27
+ def main():
28
+ """Generate and manage artifacts — self-contained HTML mini-sites
29
+ that pair presentation with the research backing it."""
30
+
31
+
32
+ @main.command()
33
+ @click.argument("path", type=click.Path(path_type=Path))
34
+ @click.option("--slug", default="", help="Artifact slug (default: directory name).")
35
+ @click.option("--title", default="", help="Human title.")
36
+ @click.option("--kind", default="report", type=click.Choice(KINDS), show_default=True)
37
+ @click.option("--description", default="")
38
+ @click.option("--notebook", is_flag=True, help="Also create a research notebook.")
39
+ def init(path: Path, slug: str, title: str, kind: str, description: str, notebook: bool):
40
+ """Scaffold a new artifact at PATH."""
41
+ try:
42
+ m = scaffold.init_artifact(
43
+ path, slug=slug, title=title, kind=kind,
44
+ description=description, with_notebook=notebook,
45
+ )
46
+ except FileExistsError as exc:
47
+ raise click.ClickException(str(exc)) from exc
48
+ click.echo(f"created {m.dir}")
49
+ click.echo(f" manifest {m.path.relative_to(Path.cwd()) if m.path.is_relative_to(Path.cwd()) else m.path}")
50
+ click.echo(f" site {m.site}/index.html")
51
+ if notebook:
52
+ click.echo(" notebook notebook/")
53
+
54
+
55
+ @main.command(name="list")
56
+ @click.argument("root", type=click.Path(exists=True, path_type=Path), default=".")
57
+ def list_cmd(root: Path):
58
+ """Discover artifacts under ROOT."""
59
+ paths = discover.find_artifacts(root)
60
+ if not paths:
61
+ click.echo(f"no artifacts under {root.resolve()}")
62
+ return
63
+ for p in paths:
64
+ try:
65
+ m = manifest_mod.load(p)
66
+ label = f"{m.slug:24} {m.kind:16} {m.status:9}"
67
+ target = m.deploy_target or "-"
68
+ click.echo(f"{label} {target:14} {p}")
69
+ except Exception as exc:
70
+ click.echo(f"{'?':24} {'invalid':16} {'':9} {'':14} {p} ({exc})")
71
+
72
+
73
+ @main.command()
74
+ @click.argument("path", type=click.Path(path_type=Path), required=False)
75
+ def status(path: Path | None):
76
+ """Manifest health, firewall report, and library drift for an artifact."""
77
+ m = _resolve(str(path) if path else None)
78
+ click.echo(f"{m.slug} — {m.title}")
79
+ click.echo(f" kind {m.kind} · status {m.status} · deploy {m.deploy_target or '(unset)'}")
80
+
81
+ problems = m.validate() + firewall.check(m)
82
+ for problem in problems:
83
+ click.secho(f" ✗ {problem}", fg="red")
84
+
85
+ if m.site_dir.is_dir():
86
+ held = firewall.withheld(m.site_dir)
87
+ if held:
88
+ click.echo(f" firewall withholds {len(held)} file(s) inside site/:")
89
+ for rel in held[:8]:
90
+ click.echo(f" - {rel}")
91
+
92
+ for row in libraries_mod.status(m):
93
+ mark = "✓" if row["state"] == "intact" else "!"
94
+ click.echo(f" {mark} lib {row['name']} {row['version']} — {row['state']}")
95
+
96
+ if not problems:
97
+ click.secho(" ✓ manifest and firewall clean", fg="green")
98
+
99
+
100
+ @main.command(name="build")
101
+ @click.argument("path", type=click.Path(path_type=Path), required=False)
102
+ @click.option("--dry-run", is_flag=True)
103
+ def build_cmd(path: Path | None, dry_run: bool):
104
+ """Run the artifact's build commands, then verify the site."""
105
+ m = _resolve(str(path) if path else None)
106
+ result = build_mod.build(m, dry_run=dry_run)
107
+ for command in result.ran:
108
+ click.echo(f"{'would run' if dry_run else 'ran'}: {command}")
109
+ for problem in result.problems:
110
+ click.secho(f"✗ {problem}", fg="red")
111
+ if result.ok:
112
+ click.secho(f"✓ site ready: {m.site_dir}", fg="green")
113
+ else:
114
+ raise SystemExit(1)
115
+
116
+
117
+ @main.command(name="deploy")
118
+ @click.argument("path", type=click.Path(path_type=Path), required=False)
119
+ @click.option("--dry-run", is_flag=True, help="Show what would happen; touch nothing remote.")
120
+ @click.option("--skip-build", is_flag=True, help="Skip build commands before deploying.")
121
+ def deploy_cmd(path: Path | None, dry_run: bool, skip_build: bool):
122
+ """Firewall-stage the site, then hand it to the deploy adapter."""
123
+ m = _resolve(str(path) if path else None)
124
+ if not m.deploy_target:
125
+ raise click.ClickException(
126
+ "no [deploy] target in the manifest. Set one, e.g.\n"
127
+ ' [deploy]\n target = "github-pages"'
128
+ )
129
+
130
+ if not skip_build:
131
+ result = build_mod.build(m, dry_run=dry_run)
132
+ if not result.ok:
133
+ for problem in result.problems:
134
+ click.secho(f"✗ {problem}", fg="red")
135
+ raise SystemExit(1)
136
+
137
+ try:
138
+ adapter_cls = deploy_mod.get(m.deploy_target)
139
+ except KeyError as exc:
140
+ raise click.ClickException(str(exc)) from exc
141
+
142
+ with tempfile.TemporaryDirectory(prefix="artoo-deploy-") as tmp:
143
+ staged = Path(tmp) / "site"
144
+ staged_files = firewall.stage(m, staged)
145
+ click.echo(f"staged {len(staged_files)} file(s) through the firewall")
146
+ ctx = deploy_mod.DeployContext(
147
+ manifest=m, staged=staged, config=m.deploy_config, dry_run=dry_run
148
+ )
149
+ outcome = adapter_cls().deploy(ctx)
150
+
151
+ for action in outcome.actions:
152
+ if action:
153
+ click.echo(f" {action}")
154
+ if outcome.ok:
155
+ click.secho(f"✓ {outcome.message}", fg="green")
156
+ else:
157
+ click.secho(f"✗ {outcome.message}", fg="red")
158
+ raise SystemExit(1)
159
+
160
+
161
+ # -- lib subcommands ---------------------------------------------------------
162
+
163
+
164
+ @main.group()
165
+ def lib():
166
+ """Manage vendored site libraries."""
167
+
168
+
169
+ @lib.command(name="list")
170
+ def lib_list():
171
+ """Libraries available to vendor."""
172
+ for name, library in sorted(libraries_mod.available().items()):
173
+ click.echo(f"{name:20} {library.version:10} {library.root}")
174
+
175
+
176
+ @lib.command(name="add")
177
+ @click.argument("name")
178
+ @click.option("--artifact", type=click.Path(path_type=Path), default=None)
179
+ def lib_add(name: str, artifact: Path | None):
180
+ """Vendor a library into the artifact's site."""
181
+ m = _resolve(str(artifact) if artifact else None)
182
+ try:
183
+ record = libraries_mod.add(m, name)
184
+ except KeyError as exc:
185
+ raise click.ClickException(str(exc)) from exc
186
+ click.echo(f"vendored {record['name']} {record['version']} → {m.site}/lib/{name}/")
187
+
188
+
189
+ @lib.command(name="status")
190
+ @click.option("--artifact", type=click.Path(path_type=Path), default=None)
191
+ def lib_status(artifact: Path | None):
192
+ """Intact / modified / outdated state of each vendored library."""
193
+ m = _resolve(str(artifact) if artifact else None)
194
+ rows = libraries_mod.status(m)
195
+ if not rows:
196
+ click.echo("no libraries recorded in the manifest")
197
+ return
198
+ for row in rows:
199
+ click.echo(f"{row['name']:20} {row['version']:10} {row['state']}")
200
+
201
+
202
+ @lib.command(name="update")
203
+ @click.argument("name")
204
+ @click.option("--artifact", type=click.Path(path_type=Path), default=None)
205
+ def lib_update(name: str, artifact: Path | None):
206
+ """Re-vendor a library at its current version."""
207
+ m = _resolve(str(artifact) if artifact else None)
208
+ try:
209
+ record = libraries_mod.update(m, name)
210
+ except KeyError as exc:
211
+ raise click.ClickException(str(exc)) from exc
212
+ click.echo(f"updated {record['name']} → {record['version']}")
213
+
214
+
215
+ @lib.command(name="vendor")
216
+ @click.argument("name")
217
+ @click.argument("url")
218
+ @click.option("--artifact", type=click.Path(path_type=Path), default=None)
219
+ def lib_vendor(name: str, url: str, artifact: Path | None):
220
+ """Vendor a single asset from a URL with a pinned hash."""
221
+ m = _resolve(str(artifact) if artifact else None)
222
+ record = libraries_mod.vendor_url(m, name, url)
223
+ click.echo(f"vendored {name} → {record['path']} ({record['sha256'][:12]}…)")
224
+
225
+
226
+ # -- generate ---------------------------------------------------------------
227
+
228
+
229
+ class _GenerateGroup(click.Group):
230
+ def list_commands(self, ctx):
231
+ return sorted(generators.available())
232
+
233
+ def get_command(self, ctx, name):
234
+ return generators.available().get(name)
235
+
236
+
237
+ @main.command(cls=_GenerateGroup, name="generate")
238
+ def generate_cmd():
239
+ """Run a generator plugin."""
240
+
241
+
242
+ # -- doctor -------------------------------------------------------------------
243
+
244
+
245
+ @main.command()
246
+ @click.argument("root", type=click.Path(exists=True, path_type=Path), default=".")
247
+ def doctor(root: Path):
248
+ """Repo-wide coherence report over every artifact under ROOT."""
249
+ paths = discover.find_artifacts(root)
250
+ if not paths:
251
+ click.echo(f"no artifacts under {root.resolve()}")
252
+ return
253
+ healthy = 0
254
+ for p in paths:
255
+ try:
256
+ m = manifest_mod.load(p)
257
+ except Exception as exc:
258
+ click.secho(f"✗ {p}: unreadable manifest ({exc})", fg="red")
259
+ continue
260
+ problems = m.validate() + firewall.check(m)
261
+ drifted = [r for r in libraries_mod.status(m) if r["state"] != "intact"]
262
+ if not problems and not drifted:
263
+ healthy += 1
264
+ continue
265
+ click.echo(f"{m.slug} ({p})")
266
+ for problem in problems:
267
+ click.secho(f" ✗ {problem}", fg="red")
268
+ for row in drifted:
269
+ click.secho(f" ! lib {row['name']}: {row['state']}", fg="yellow")
270
+ click.echo(f"{healthy}/{len(paths)} artifacts clean")
271
+
272
+
273
+ if __name__ == "__main__":
274
+ main()
@@ -0,0 +1,46 @@
1
+ """Deploy adapters: registry and dispatch.
2
+
3
+ Adapters are discovered from the ``artoo.deployers`` entry-point group so
4
+ they can ship as separate packages; the built-ins are registered directly
5
+ so artoo works uninstalled (from a source checkout).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib.metadata import entry_points
11
+
12
+ from .base import DeployContext, Deployer, DeployResult
13
+
14
+
15
+ def _builtin() -> dict[str, type[Deployer]]:
16
+ from .command import CommandDeployer
17
+ from .github_pages import GitHubPagesDeployer
18
+ from .rsync import RsyncDeployer
19
+
20
+ return {
21
+ GitHubPagesDeployer.name: GitHubPagesDeployer,
22
+ RsyncDeployer.name: RsyncDeployer,
23
+ CommandDeployer.name: CommandDeployer,
24
+ }
25
+
26
+
27
+ def available() -> dict[str, type[Deployer]]:
28
+ registry = _builtin()
29
+ for ep in entry_points(group="artoo.deployers"):
30
+ if ep.name not in registry:
31
+ try:
32
+ registry[ep.name] = ep.load()
33
+ except Exception: # a broken third-party plugin must not break artoo
34
+ continue
35
+ return registry
36
+
37
+
38
+ def get(name: str) -> type[Deployer]:
39
+ registry = available()
40
+ if name not in registry:
41
+ known = ", ".join(sorted(registry))
42
+ raise KeyError(f"no deploy adapter named {name!r} (available: {known})")
43
+ return registry[name]
44
+
45
+
46
+ __all__ = ["DeployContext", "Deployer", "DeployResult", "available", "get"]
artoo/deploy/base.py ADDED
@@ -0,0 +1,45 @@
1
+ """Deploy adapter protocol and shared context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import ClassVar, Protocol, runtime_checkable
9
+
10
+ from ..manifest import Manifest
11
+
12
+
13
+ @dataclass
14
+ class DeployContext:
15
+ manifest: Manifest
16
+ staged: Path # firewall-cleared copy of the site, owned by the caller
17
+ config: dict = field(default_factory=dict)
18
+ dry_run: bool = False
19
+
20
+ @property
21
+ def repo_root(self) -> Path | None:
22
+ """Root of the git repo containing the artifact, if any."""
23
+ proc = subprocess.run(
24
+ ["git", "rev-parse", "--show-toplevel"],
25
+ cwd=self.manifest.dir,
26
+ capture_output=True,
27
+ text=True,
28
+ )
29
+ if proc.returncode != 0:
30
+ return None
31
+ return Path(proc.stdout.strip())
32
+
33
+
34
+ @dataclass
35
+ class DeployResult:
36
+ ok: bool
37
+ message: str
38
+ actions: list[str] = field(default_factory=list)
39
+
40
+
41
+ @runtime_checkable
42
+ class Deployer(Protocol):
43
+ name: ClassVar[str]
44
+
45
+ def deploy(self, ctx: DeployContext) -> DeployResult: ...
@@ -0,0 +1,53 @@
1
+ """The escape-hatch adapter: publish with an arbitrary command.
2
+
3
+ The command runs from the artifact directory with the staged (firewall-
4
+ cleared) site exposed via environment variables. Whatever bespoke pipeline
5
+ already exists — ssh scripts, CI triggers, cloud CLIs — works today.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import subprocess
12
+ from typing import ClassVar
13
+
14
+ from .base import DeployContext, DeployResult
15
+
16
+
17
+ class CommandDeployer:
18
+ name: ClassVar[str] = "command"
19
+
20
+ def deploy(self, ctx: DeployContext) -> DeployResult:
21
+ command = ctx.config.get("run", "")
22
+ if not command:
23
+ return DeployResult(
24
+ ok=False,
25
+ message='command adapter needs [deploy.command] run = "<shell command>"',
26
+ )
27
+ env = os.environ.copy()
28
+ env.update(
29
+ {
30
+ "ARTOO_SITE_DIR": str(ctx.staged),
31
+ "ARTOO_SLUG": ctx.manifest.slug,
32
+ "ARTOO_ARTIFACT_DIR": str(ctx.manifest.dir),
33
+ "ARTOO_REPO_ROOT": str(ctx.repo_root or ""),
34
+ }
35
+ )
36
+ if ctx.dry_run:
37
+ return DeployResult(
38
+ ok=True,
39
+ message="dry run — command not executed",
40
+ actions=[f"would run: {command}"],
41
+ )
42
+ proc = subprocess.run(
43
+ command, shell=True, cwd=ctx.manifest.dir, env=env,
44
+ capture_output=True, text=True,
45
+ )
46
+ output = (proc.stdout + proc.stderr).strip()
47
+ if proc.returncode != 0:
48
+ return DeployResult(
49
+ ok=False,
50
+ message=f"publish command exited {proc.returncode}",
51
+ actions=[command, output],
52
+ )
53
+ return DeployResult(ok=True, message="published", actions=[command, output])