docverse 1.0.0b2__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.
docverse/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Docverse client library."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from ._client import DEFAULT_UPDATED_SINCE_OVERLAP, DocverseClient, ProjectList
6
+ from ._exceptions import BuildProcessingError, DocverseClientError
7
+ from ._tar import create_tarball
8
+
9
+ __all__ = [
10
+ "DEFAULT_UPDATED_SINCE_OVERLAP",
11
+ "BuildProcessingError",
12
+ "DocverseClient",
13
+ "DocverseClientError",
14
+ "ProjectList",
15
+ "__version__",
16
+ "create_tarball",
17
+ ]
18
+
19
+ try:
20
+ __version__ = version("docverse")
21
+ except PackageNotFoundError:
22
+ __version__ = "0.0.0"
@@ -0,0 +1,63 @@
1
+ """Auto-detection of build provenance annotations from CI environments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from .models.builds import BuildAnnotations
8
+
9
+ __all__ = [
10
+ "detect_github_actions_annotations",
11
+ "merge_annotations",
12
+ ]
13
+
14
+
15
+ def detect_github_actions_annotations() -> BuildAnnotations | None:
16
+ """Detect build provenance from GitHub Actions environment variables.
17
+
18
+ Returns a populated ``BuildAnnotations`` if running inside GitHub
19
+ Actions (``GITHUB_ACTIONS == "true"``), otherwise ``None``.
20
+ """
21
+ if os.environ.get("GITHUB_ACTIONS") != "true":
22
+ return None
23
+
24
+ server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
25
+ repository = os.environ.get("GITHUB_REPOSITORY")
26
+ run_id = os.environ.get("GITHUB_RUN_ID")
27
+
28
+ run_url: str | None = None
29
+ if repository and run_id:
30
+ run_url = f"{server_url}/{repository}/actions/runs/{run_id}"
31
+
32
+ return BuildAnnotations(
33
+ commit_sha=os.environ.get("GITHUB_SHA"),
34
+ github_repository=repository,
35
+ github_run_id=run_id,
36
+ github_run_url=run_url,
37
+ github_run_attempt=os.environ.get("GITHUB_RUN_ATTEMPT"),
38
+ github_workflow=os.environ.get("GITHUB_WORKFLOW"),
39
+ github_actor=os.environ.get("GITHUB_ACTOR"),
40
+ github_event_name=os.environ.get("GITHUB_EVENT_NAME"),
41
+ ci_platform="github-actions",
42
+ )
43
+
44
+
45
+ def merge_annotations(
46
+ auto: BuildAnnotations | None,
47
+ manual: dict[str, str] | None,
48
+ ) -> BuildAnnotations | None:
49
+ """Merge auto-detected and manual annotations.
50
+
51
+ Manual entries take precedence over auto-detected values. Returns
52
+ ``None`` if both inputs are empty/None.
53
+ """
54
+ result: dict[str, str] = {}
55
+ if auto is not None:
56
+ result.update(
57
+ {k: v for k, v in auto.model_dump().items() if v is not None}
58
+ )
59
+ if manual:
60
+ result.update(manual)
61
+ if not result:
62
+ return None
63
+ return BuildAnnotations.model_validate(result)
docverse/_cli.py ADDED
@@ -0,0 +1,413 @@
1
+ """Command-line interface for the Docverse client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import click
14
+
15
+ from ._annotations import detect_github_actions_annotations, merge_annotations
16
+ from ._client import DocverseClient
17
+ from ._exceptions import BuildProcessingError, DocverseClientError
18
+ from ._tar import create_tarball
19
+ from .models.queue_enums import JobStatus
20
+
21
+ __all__ = ["main"]
22
+
23
+
24
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
25
+ @click.version_option(message="%(version)s")
26
+ def main() -> None:
27
+ """Docverse documentation hosting client."""
28
+
29
+
30
+ @main.command()
31
+ @click.option(
32
+ "--org",
33
+ envvar="DOCVERSE_ORG",
34
+ show_envvar=True,
35
+ required=True,
36
+ help="Organization slug.",
37
+ )
38
+ @click.option(
39
+ "--project",
40
+ envvar="DOCVERSE_PROJECT",
41
+ show_envvar=True,
42
+ required=True,
43
+ help="Project slug.",
44
+ )
45
+ @click.option(
46
+ "--git-ref",
47
+ envvar="DOCVERSE_GIT_REF",
48
+ show_envvar=True,
49
+ default=None,
50
+ help="Git ref for the build. Defaults to HEAD.",
51
+ )
52
+ @click.option(
53
+ "--dir",
54
+ "source_dir",
55
+ envvar="DOCVERSE_DIR",
56
+ show_envvar=True,
57
+ required=True,
58
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
59
+ help="Directory containing the built documentation.",
60
+ )
61
+ @click.option(
62
+ "--token",
63
+ envvar="DOCVERSE_TOKEN",
64
+ show_envvar=True,
65
+ required=True,
66
+ help="API bearer token.",
67
+ )
68
+ @click.option(
69
+ "--base-url",
70
+ envvar="DOCVERSE_API",
71
+ show_envvar=True,
72
+ default="https://roundtable.lsst.cloud/docverse/api",
73
+ show_default=True,
74
+ help="Docverse API base URL.",
75
+ )
76
+ @click.option(
77
+ "--alternate-name",
78
+ envvar="DOCVERSE_ALTERNATE",
79
+ show_envvar=True,
80
+ default=None,
81
+ help="Alternate deployment name.",
82
+ )
83
+ @click.option(
84
+ "--annotation",
85
+ "-a",
86
+ "annotations",
87
+ multiple=True,
88
+ help="Manual annotation in KEY=VALUE format. Can be repeated.",
89
+ )
90
+ @click.option(
91
+ "--auto-annotations/--no-auto-annotations",
92
+ default=True,
93
+ show_default=True,
94
+ help="Auto-detect annotations from CI environment variables.",
95
+ )
96
+ @click.option(
97
+ "--no-wait",
98
+ is_flag=True,
99
+ default=False,
100
+ help="Exit after upload without waiting for processing.",
101
+ )
102
+ @click.option(
103
+ "--verbose",
104
+ "-v",
105
+ is_flag=True,
106
+ default=False,
107
+ help="Show detailed HTTP request/response information.",
108
+ )
109
+ def upload(
110
+ org: str,
111
+ project: str,
112
+ git_ref: str | None,
113
+ source_dir: Path,
114
+ token: str,
115
+ base_url: str,
116
+ alternate_name: str | None,
117
+ annotations: tuple[str, ...],
118
+ auto_annotations: bool, # noqa: FBT001
119
+ no_wait: bool, # noqa: FBT001
120
+ verbose: bool, # noqa: FBT001
121
+ ) -> None:
122
+ """Upload a documentation build."""
123
+ if git_ref is None:
124
+ git_ref = _detect_git_ref()
125
+
126
+ # Parse manual annotations
127
+ manual: dict[str, str] | None = None
128
+ if annotations:
129
+ manual = {}
130
+ for item in annotations:
131
+ if "=" not in item:
132
+ msg = f"Invalid annotation format (expected KEY=VALUE): {item}"
133
+ raise click.BadParameter(msg, param_hint="'--annotation'")
134
+ key, value = item.split("=", 1)
135
+ manual[key] = value
136
+
137
+ asyncio.run(
138
+ _upload_async(
139
+ org=org,
140
+ project=project,
141
+ git_ref=git_ref,
142
+ source_dir=source_dir,
143
+ token=token,
144
+ base_url=base_url,
145
+ alternate_name=alternate_name,
146
+ auto_annotations=auto_annotations,
147
+ manual_annotations=manual,
148
+ no_wait=no_wait,
149
+ verbose=verbose,
150
+ )
151
+ )
152
+
153
+
154
+ def _stage_worker_source(*, worker_dir: Path, dest_dir: Path) -> None:
155
+ """Pack the worker source and install it into the deployments repo.
156
+
157
+ Parameters
158
+ ----------
159
+ worker_dir
160
+ The monorepo's ``cloudflare-worker/`` directory.
161
+ dest_dir
162
+ The ``worker/`` directory inside the deployments repo.
163
+
164
+ Raises
165
+ ------
166
+ click.ClickException
167
+ If packing, unpacking, or dependency installation fails.
168
+ """
169
+ # Phase 1: npm pack
170
+ click.echo(f"Packing cloudflare worker from {worker_dir}")
171
+ try:
172
+ pack_result = subprocess.run(
173
+ ["npm", "pack", "--json"],
174
+ check=True,
175
+ capture_output=True,
176
+ text=True,
177
+ cwd=str(worker_dir),
178
+ )
179
+ except subprocess.CalledProcessError as exc:
180
+ msg = f"npm pack failed: {exc.stderr}"
181
+ raise click.ClickException(msg) from exc
182
+ try:
183
+ tarball_name = json.loads(pack_result.stdout)[0]["filename"]
184
+ except (json.JSONDecodeError, KeyError, IndexError) as exc:
185
+ msg = f"Failed to parse npm pack output: {pack_result.stdout!r}"
186
+ raise click.ClickException(msg) from exc
187
+
188
+ # Phase 2: copy and unpack into deployments repo
189
+ click.echo(f"Unpacking worker into {dest_dir}")
190
+ shutil.copy2(worker_dir / tarball_name, dest_dir / tarball_name)
191
+ try:
192
+ subprocess.run(
193
+ ["tar", "xzf", tarball_name, "--strip-components=1"],
194
+ check=True,
195
+ cwd=str(dest_dir),
196
+ )
197
+ except subprocess.CalledProcessError as exc:
198
+ msg = f"Failed to unpack worker tarball: {exc}"
199
+ raise click.ClickException(msg) from exc
200
+ finally:
201
+ for tgz in dest_dir.glob("*.tgz"):
202
+ tgz.unlink()
203
+ (worker_dir / tarball_name).unlink(missing_ok=True)
204
+
205
+ # Phase 2a: copy the lockfile alongside the unpacked source. ``npm
206
+ # pack`` always excludes package-lock.json, so without this the
207
+ # destination keeps whatever lockfile an earlier deploy left behind.
208
+ # Once that stale lockfile disagrees with the freshly packed
209
+ # package.json, npm re-resolves the whole tree and deploys stop being
210
+ # reproducible.
211
+ lockfile = worker_dir / "package-lock.json"
212
+ if not lockfile.is_file():
213
+ msg = f"package-lock.json not found in {worker_dir}"
214
+ raise click.ClickException(msg)
215
+ click.echo("Copying worker lockfile")
216
+ shutil.copy2(lockfile, dest_dir / "package-lock.json")
217
+
218
+ # Phase 2b: install worker runtime dependencies. ``npm ci`` installs
219
+ # exactly what the lockfile pins and wipes node_modules first, so no
220
+ # stale packages survive from an earlier deploy.
221
+ click.echo("Installing worker dependencies")
222
+ try:
223
+ subprocess.run(
224
+ ["npm", "ci", "--omit=dev"],
225
+ check=True,
226
+ capture_output=True,
227
+ text=True,
228
+ cwd=str(dest_dir),
229
+ )
230
+ except subprocess.CalledProcessError as exc:
231
+ msg = f"npm ci failed: {exc.stderr}"
232
+ raise click.ClickException(msg) from exc
233
+
234
+
235
+ @main.command()
236
+ @click.option(
237
+ "--docverse-repo",
238
+ required=True,
239
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
240
+ help="Path to the docverse monorepo root.",
241
+ )
242
+ @click.option(
243
+ "--deployments-repo",
244
+ required=True,
245
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
246
+ help="Path to local docverse-cloudflare-deployments checkout.",
247
+ )
248
+ @click.option(
249
+ "--env",
250
+ "wrangler_env",
251
+ required=True,
252
+ help="Wrangler environment name (e.g., dev, production).",
253
+ )
254
+ @click.option(
255
+ "--dry-run",
256
+ is_flag=True,
257
+ default=False,
258
+ help="Build the worker bundle without deploying.",
259
+ )
260
+ def deploy_worker(
261
+ *,
262
+ docverse_repo: Path,
263
+ deployments_repo: Path,
264
+ wrangler_env: str,
265
+ dry_run: bool,
266
+ ) -> None:
267
+ """Pack and deploy the Cloudflare Worker."""
268
+ if not re.match(r"^[a-zA-Z0-9_-]+$", wrangler_env):
269
+ msg = (
270
+ f"Invalid environment name {wrangler_env!r}: "
271
+ "must contain only alphanumeric characters, hyphens, "
272
+ "and underscores"
273
+ )
274
+ raise click.ClickException(msg)
275
+
276
+ worker_dir = docverse_repo.resolve() / "cloudflare-worker"
277
+ if not worker_dir.is_dir():
278
+ msg = f"cloudflare-worker/ not found in {docverse_repo.resolve()}"
279
+ raise click.ClickException(msg)
280
+
281
+ deployments_repo = deployments_repo.resolve()
282
+ dest_dir = deployments_repo / "worker"
283
+ dest_dir.mkdir(exist_ok=True)
284
+
285
+ # Phases 1 and 2: pack the worker and stage it in the deployments repo
286
+ _stage_worker_source(worker_dir=worker_dir, dest_dir=dest_dir)
287
+
288
+ # Phase 3: wrangler deploy
289
+ wrangler_cmd = [
290
+ "npx",
291
+ "wrangler",
292
+ "deploy",
293
+ "--env",
294
+ wrangler_env,
295
+ ]
296
+ if dry_run:
297
+ outdir = deployments_repo / "dist"
298
+ wrangler_cmd.extend(["--dry-run", f"--outdir={outdir}"])
299
+ click.echo(f"Deploying worker (env={wrangler_env}, dry_run={dry_run})")
300
+ try:
301
+ subprocess.run(
302
+ wrangler_cmd,
303
+ check=True,
304
+ cwd=str(deployments_repo),
305
+ )
306
+ except subprocess.CalledProcessError as exc:
307
+ msg = f"wrangler deploy failed: {exc}"
308
+ raise click.ClickException(msg) from exc
309
+
310
+ click.echo(f"Worker deployed successfully (env={wrangler_env})")
311
+
312
+
313
+ def _detect_git_ref() -> str:
314
+ """Detect the current git ref from HEAD."""
315
+ try:
316
+ result = subprocess.run(
317
+ ["git", "rev-parse", "HEAD"],
318
+ capture_output=True,
319
+ text=True,
320
+ check=True,
321
+ )
322
+ except (subprocess.CalledProcessError, FileNotFoundError) as exc:
323
+ msg = (
324
+ "Could not detect git ref from HEAD. "
325
+ "Provide --git-ref or set DOCVERSE_GIT_REF."
326
+ )
327
+ raise click.UsageError(msg) from exc
328
+ return result.stdout.strip()
329
+
330
+
331
+ async def _upload_async(
332
+ *,
333
+ org: str,
334
+ project: str,
335
+ git_ref: str,
336
+ source_dir: Path,
337
+ token: str,
338
+ base_url: str,
339
+ alternate_name: str | None,
340
+ auto_annotations: bool,
341
+ manual_annotations: dict[str, str] | None,
342
+ no_wait: bool,
343
+ verbose: bool,
344
+ ) -> None:
345
+ """Run the upload workflow."""
346
+ tarball_path: Path | None = None
347
+ try:
348
+ click.echo(f"Creating tarball from {source_dir}")
349
+ tarball_path, content_hash = create_tarball(source_dir)
350
+ click.echo(f"Content hash: {content_hash}")
351
+
352
+ # Build annotations from auto-detection and manual entries
353
+ auto = (
354
+ detect_github_actions_annotations() if auto_annotations else None
355
+ )
356
+ merged_annotations = merge_annotations(auto, manual_annotations)
357
+
358
+ async with DocverseClient(base_url, token, verbose=verbose) as client:
359
+ click.echo(f"Creating build for {org}/{project} @ {git_ref}")
360
+ build = await client.create_build(
361
+ org,
362
+ project,
363
+ git_ref=git_ref,
364
+ content_hash=content_hash,
365
+ alternate_name=alternate_name,
366
+ annotations=merged_annotations,
367
+ )
368
+ click.echo(f"Build created: {build.id}")
369
+
370
+ if build.upload_url is None:
371
+ msg = (
372
+ "Server did not return an upload URL. "
373
+ "The organization may not have an object "
374
+ "store configured."
375
+ )
376
+ raise DocverseClientError(msg)
377
+
378
+ click.echo("Uploading tarball")
379
+ await client.upload_tarball(build.upload_url, tarball_path)
380
+
381
+ click.echo("Signalling upload complete")
382
+ build = await client.complete_upload(build.self_url)
383
+
384
+ if no_wait:
385
+ click.echo("Upload complete (--no-wait specified)")
386
+ return
387
+
388
+ if build.job_url is None:
389
+ msg = "Server did not return a job URL after upload"
390
+ raise DocverseClientError(msg)
391
+
392
+ click.echo("Waiting for build processing")
393
+ job = await client.wait_for_job(build.job_url)
394
+
395
+ if job.status == JobStatus.completed_with_errors:
396
+ click.echo(
397
+ f"Build completed with errors (phase={job.phase})",
398
+ err=True,
399
+ )
400
+ sys.exit(2)
401
+ elif job.status == JobStatus.cancelled:
402
+ click.echo("Build processing was cancelled", err=True)
403
+ sys.exit(2)
404
+ else:
405
+ click.echo("Build processing complete")
406
+
407
+ except BuildProcessingError as exc:
408
+ raise click.ClickException(str(exc)) from exc
409
+ except DocverseClientError as exc:
410
+ raise click.ClickException(str(exc)) from exc
411
+ finally:
412
+ if tarball_path is not None:
413
+ tarball_path.unlink(missing_ok=True)