mascope_cli 2026.7.28__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.
Files changed (66) hide show
  1. mascope_cli/__init__.py +1 -0
  2. mascope_cli/checkout.py +38 -0
  3. mascope_cli/cmd/__init__.py +10 -0
  4. mascope_cli/cmd/agent.py +25 -0
  5. mascope_cli/cmd/backend.py +19 -0
  6. mascope_cli/cmd/cert.py +64 -0
  7. mascope_cli/cmd/demo/__init__.py +2 -0
  8. mascope_cli/cmd/demo/_fetch.py +147 -0
  9. mascope_cli/cmd/demo/_rebuild.py +148 -0
  10. mascope_cli/cmd/demo/_seed.py +170 -0
  11. mascope_cli/cmd/demo/build_bundle.py +493 -0
  12. mascope_cli/cmd/demo/bundles.py +220 -0
  13. mascope_cli/cmd/demo/main.py +564 -0
  14. mascope_cli/cmd/demo/verify.py +135 -0
  15. mascope_cli/cmd/dev/__init__.py +2 -0
  16. mascope_cli/cmd/dev/db/__init__.py +2 -0
  17. mascope_cli/cmd/dev/db/backup.py +268 -0
  18. mascope_cli/cmd/dev/db/main.py +646 -0
  19. mascope_cli/cmd/dev/db/scripts.py +188 -0
  20. mascope_cli/cmd/dev/docker.py +248 -0
  21. mascope_cli/cmd/dev/main.py +548 -0
  22. mascope_cli/cmd/dev/migrate.py +365 -0
  23. mascope_cli/cmd/dev/redis.py +246 -0
  24. mascope_cli/cmd/dev/tools.py +53 -0
  25. mascope_cli/cmd/env/__init__.py +2 -0
  26. mascope_cli/cmd/env/_create.py +104 -0
  27. mascope_cli/cmd/env/_paths.py +172 -0
  28. mascope_cli/cmd/env/_ssh.py +228 -0
  29. mascope_cli/cmd/env/_sync.py +613 -0
  30. mascope_cli/cmd/env/main.py +354 -0
  31. mascope_cli/cmd/fleet.py +181 -0
  32. mascope_cli/cmd/init.py +145 -0
  33. mascope_cli/cmd/instance.py +152 -0
  34. mascope_cli/cmd/lib.py +65 -0
  35. mascope_cli/cmd/logs.py +190 -0
  36. mascope_cli/cmd/prod/__init__.py +2 -0
  37. mascope_cli/cmd/prod/auto_update.py +451 -0
  38. mascope_cli/cmd/prod/cli_drift.py +135 -0
  39. mascope_cli/cmd/prod/db/__init__.py +2 -0
  40. mascope_cli/cmd/prod/db/backup.py +277 -0
  41. mascope_cli/cmd/prod/db/main.py +448 -0
  42. mascope_cli/cmd/prod/db/scripts.py +275 -0
  43. mascope_cli/cmd/prod/doctor.py +313 -0
  44. mascope_cli/cmd/prod/main.py +1061 -0
  45. mascope_cli/cmd/prod/preflight.py +269 -0
  46. mascope_cli/cmd/prod/release_manifest.py +134 -0
  47. mascope_cli/cmd/test.py +463 -0
  48. mascope_cli/data/base.mascope.toml +178 -0
  49. mascope_cli/data/dev.mascope.toml +24 -0
  50. mascope_cli/data/docker-compose.demo.yaml +145 -0
  51. mascope_cli/data/docker-compose.dev.yaml +90 -0
  52. mascope_cli/data/docker-compose.yaml +215 -0
  53. mascope_cli/data/prod.mascope.toml +78 -0
  54. mascope_cli/docker.py +29 -0
  55. mascope_cli/home.py +45 -0
  56. mascope_cli/instance.py +294 -0
  57. mascope_cli/main.py +184 -0
  58. mascope_cli/pg/__init__.py +3 -0
  59. mascope_cli/pg/admin.py +632 -0
  60. mascope_cli/pg/utils.py +228 -0
  61. mascope_cli/runtime.py +74 -0
  62. mascope_cli/version.py +38 -0
  63. mascope_cli-2026.7.28.dist-info/METADATA +91 -0
  64. mascope_cli-2026.7.28.dist-info/RECORD +66 -0
  65. mascope_cli-2026.7.28.dist-info/WHEEL +4 -0
  66. mascope_cli-2026.7.28.dist-info/entry_points.txt +3 -0
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,38 @@
1
+ """
2
+ Monorepo source-checkout detection.
3
+
4
+ The CLI has two audiences: developers running it editable from the monorepo,
5
+ and operators running it from a wheel. Developer commands (`dev`, `test`,
6
+ `agent`, `backend`) drive the source tree itself and are only registered when
7
+ the CLI actually runs from a checkout.
8
+ """
9
+
10
+ from pathlib import Path
11
+
12
+
13
+ def source_checkout(anchor: Path | None = None) -> Path | None:
14
+ """
15
+ Repo root of the Mascope checkout the CLI runs from, if any.
16
+
17
+ An editable install resolves ``mascope_cli`` inside the checkout
18
+ (``<root>/tooling/cli/src/mascope_cli``); a wheel install resolves it in
19
+ site-packages, where walking up never lands in the monorepo.
20
+
21
+ :param anchor: Location of the mascope_cli package; defaults to the
22
+ imported package. Overridable for testing.
23
+ :type anchor: Path, optional
24
+ :return: The checkout root, or None when not running from source.
25
+ :rtype: Path | None
26
+ """
27
+ if anchor is None:
28
+ import mascope_cli
29
+
30
+ anchor = Path(mascope_cli.__file__).resolve().parent
31
+ # <root>/tooling/cli/src/mascope_cli -> root is 4 levels up.
32
+ parents = anchor.parents
33
+ if len(parents) < 4:
34
+ return None
35
+ root = parents[3]
36
+ if (root / "pyproject.toml").is_file() and (root / "tooling" / "cli").is_dir():
37
+ return root
38
+ return None
@@ -0,0 +1,10 @@
1
+ # flake8: noqa
2
+ # Operator command groups — always available, wheel and checkout alike.
3
+ # Developer groups (dev, test, agent, backend) are imported by main.py only
4
+ # when running from a source checkout: they drive the monorepo itself and
5
+ # pull in the `dev` extra's dependencies (alembic, sqlalchemy, psycopg2).
6
+ from .cert import cert_app
7
+ from .demo import demo_app
8
+ from .env import env_app
9
+ from .logs import logs_app
10
+ from .prod import prod_app
@@ -0,0 +1,25 @@
1
+ from typing import List, Optional
2
+
3
+ import typer
4
+ from typing_extensions import Annotated
5
+
6
+ from mascope_cli.cmd import lib
7
+ from mascope_cli.runtime import runtime
8
+
9
+
10
+ agent_app = typer.Typer(add_help_option=False)
11
+
12
+
13
+ @agent_app.callback(
14
+ invoke_without_command=True, context_settings={"ignore_unknown_options": True}
15
+ )
16
+ def main(args: Annotated[Optional[List[str]], typer.Argument()] = None) -> None:
17
+ """
18
+ Run a Mascope agent
19
+ """
20
+ if len(args) != 1:
21
+ runtime.logger.error("mascope agent requires exactly 1 argument: file or tof")
22
+ return
23
+ lib.run(
24
+ command=f"uv run mascope-{args[0]}-agent", cwd=runtime.path("agents", args[0])
25
+ )
@@ -0,0 +1,19 @@
1
+ from typing import List, Optional
2
+
3
+ import typer
4
+ from typing_extensions import Annotated
5
+
6
+ import mascope_cli.cmd.lib as lib
7
+
8
+
9
+ backend_app = typer.Typer(add_help_option=False)
10
+
11
+
12
+ @backend_app.callback(
13
+ invoke_without_command=True, context_settings={"ignore_unknown_options": True}
14
+ )
15
+ def main(args: Annotated[Optional[List[str]], typer.Argument()] = None) -> None:
16
+ """
17
+ Run a Mascope backend service.
18
+ """
19
+ lib.run(command=f"uv run mascope-backend {' '.join(args)}")
@@ -0,0 +1,64 @@
1
+ import shutil
2
+ from pathlib import Path
3
+
4
+ import typer
5
+
6
+ import mascope_cli.cmd.lib as lib
7
+ from mascope_cli.runtime import runtime
8
+
9
+
10
+ cert_app = typer.Typer()
11
+
12
+
13
+ @cert_app.callback()
14
+ def main():
15
+ """
16
+ Manage Mascope SSL certificates
17
+ """
18
+
19
+
20
+ @cert_app.command()
21
+ def gen():
22
+ """
23
+ Generate a self-signed SSL certificate
24
+
25
+ Writes mascope.app.key + mascope.app.pem into .runtime/secrets/. Use for
26
+ local HTTPS prod testing or a small LAN deployment (clients see a
27
+ self-signed warning). For a localhost-only trial, prefer the HTTP demo
28
+ stack (docker-compose.demo.yaml) instead.
29
+ """
30
+ secrets_dir = Path(runtime.path(".runtime", "secrets"))
31
+ secrets_dir.mkdir(parents=True, exist_ok=True)
32
+
33
+ key_path = secrets_dir / "mascope.app.key"
34
+ pem_path = secrets_dir / "mascope.app.pem"
35
+
36
+ # Docker creates an empty DIRECTORY at a missing `file:` secret source, which
37
+ # then blocks openssl from writing the file. Clear any such placeholders.
38
+ for path in (key_path, pem_path):
39
+ if path.is_dir():
40
+ shutil.rmtree(path)
41
+
42
+ # Use forward-slash (posix) paths: lib.run shlex-splits the command, which
43
+ # mangles Windows backslashes. openssl accepts forward slashes on Windows.
44
+ command = (
45
+ "openssl req "
46
+ + "-x509 "
47
+ + "-nodes "
48
+ + '-subj "/CN=mascope.app" '
49
+ + '-addext "subjectAltName=DNS:mascope.app" '
50
+ + "-days 365 "
51
+ + "-newkey rsa:2048 "
52
+ + f'-keyout "{key_path.as_posix()}" '
53
+ + f'-out "{pem_path.as_posix()}"'
54
+ )
55
+ lib.run(command)
56
+
57
+ if pem_path.is_file() and key_path.is_file():
58
+ runtime.logger.success(f"Wrote self-signed certificate to {secrets_dir}")
59
+ else:
60
+ runtime.logger.error(
61
+ "Certificate generation did not produce both files. On Windows you "
62
+ "may need to set OPENSSL_CONF, e.g. "
63
+ '$env:OPENSSL_CONF="C:\\Program Files\\Git\\usr\\ssl\\openssl.cnf".'
64
+ )
@@ -0,0 +1,2 @@
1
+ # flake8: noqa
2
+ from .main import demo_app
@@ -0,0 +1,147 @@
1
+ """
2
+ Download and verify a published demo bundle into the local cache.
3
+
4
+ Pure download/extract/verify logic - no Typer concerns. The CLI command in
5
+ ``main.py`` wraps these helpers with user-facing messaging.
6
+ """
7
+
8
+ import shutil
9
+ import tarfile
10
+ import urllib.request
11
+ import zipfile
12
+ from pathlib import Path
13
+
14
+ from mascope_cli.cmd.demo import bundles
15
+ from mascope_cli.runtime import runtime
16
+
17
+
18
+ def _extract_archive(archive: Path, dest: Path) -> None:
19
+ """
20
+ Extract a ``.zip`` or ``.tar.gz`` archive into ``dest``.
21
+
22
+ The archive is expected to contain the bundle contents at its top level
23
+ (``manifest.json``, ``raw/``, ``snapshot/`` ...). If it instead wraps them in
24
+ a single top-level directory, that directory's contents are flattened into
25
+ ``dest`` so the layout is consistent regardless of how it was packed.
26
+
27
+ :param archive: Path to the downloaded archive.
28
+ :param dest: Target bundle directory (created/overwritten by the caller).
29
+ :raises ValueError: If the archive format is unsupported.
30
+ """
31
+ staging = dest.parent / f"{dest.name}.staging"
32
+ if staging.exists():
33
+ shutil.rmtree(staging)
34
+ staging.mkdir(parents=True)
35
+
36
+ if zipfile.is_zipfile(archive):
37
+ with zipfile.ZipFile(archive) as zf:
38
+ zf.extractall(staging)
39
+ elif tarfile.is_tarfile(archive):
40
+ with tarfile.open(archive) as tf:
41
+ tf.extractall(staging)
42
+ else:
43
+ shutil.rmtree(staging, ignore_errors=True)
44
+ raise ValueError(f"Unsupported archive format: {archive.name}")
45
+
46
+ # Flatten a single wrapping directory if present.
47
+ entries = list(staging.iterdir())
48
+ root = entries[0] if len(entries) == 1 and entries[0].is_dir() else staging
49
+
50
+ if dest.exists():
51
+ shutil.rmtree(dest)
52
+ shutil.move(str(root), str(dest))
53
+ shutil.rmtree(staging, ignore_errors=True)
54
+
55
+
56
+ def _download(url: str, dest: Path) -> None:
57
+ """
58
+ Stream a URL to a local file, logging coarse progress.
59
+
60
+ :param url: Source URL.
61
+ :param dest: Destination file path (parent created automatically).
62
+ :raises OSError: On network/IO failure (propagated from urllib).
63
+ """
64
+ dest.parent.mkdir(parents=True, exist_ok=True)
65
+ runtime.logger.info(f"Downloading {url}")
66
+
67
+ with urllib.request.urlopen(url) as response: # noqa: S310 - trusted Zenodo URL
68
+ total = int(response.headers.get("Content-Length", 0))
69
+ read = 0
70
+ next_mark = 10
71
+ with dest.open("wb") as out:
72
+ for chunk in iter(lambda: response.read(1 << 20), b""):
73
+ out.write(chunk)
74
+ read += len(chunk)
75
+ if total:
76
+ pct = read * 100 // total
77
+ if pct >= next_mark:
78
+ runtime.logger.info(f" ...{pct}%")
79
+ next_mark = pct - (pct % 10) + 10
80
+
81
+
82
+ def fetch(version: str | None = None, force: bool = False) -> Path:
83
+ """
84
+ Ensure a demo bundle is present and verified in the local cache.
85
+
86
+ If already cached and intact, returns immediately unless ``force`` is set.
87
+ Otherwise downloads the archive from the registered URL, extracts it, and
88
+ verifies every file against the manifest checksums.
89
+
90
+ :param version: Bundle version tag. Defaults to the registry default.
91
+ :param force: Re-download even if a valid cached copy exists.
92
+ :raises RuntimeError: If the bundle has no published URL, the download
93
+ fails, or checksum verification fails.
94
+ :return: Path to the verified bundle directory.
95
+ """
96
+ bundle = bundles.get_bundle(version)
97
+ dest = bundles.bundle_dir(version)
98
+
99
+ if bundles.is_cached(version) and not force:
100
+ problems = bundles.verify_manifest(version)
101
+ if not problems:
102
+ runtime.logger.success(f"Demo bundle '{bundle.version}' already cached")
103
+ return dest
104
+ runtime.logger.warning(
105
+ f"Cached bundle '{bundle.version}' failed verification; re-fetching"
106
+ )
107
+
108
+ if not bundle.url:
109
+ raise RuntimeError(
110
+ f"Demo bundle '{bundle.version}' has no published download URL yet. "
111
+ "Build one locally with 'mascope demo snapshot', or set its URL in "
112
+ "tooling/cli/src/mascope_cli/cmd/demo/bundles.py once published."
113
+ )
114
+
115
+ archive = bundles.cache_root() / f"{bundle.version}{_suffix(bundle.url)}"
116
+ _download(bundle.url, archive)
117
+
118
+ if bundle.archive_md5:
119
+ actual = bundles.md5_file(archive)
120
+ # Case-insensitive: hashlib emits lowercase, but a checksum pasted from
121
+ # Zenodo (or PowerShell's Get-FileHash) may be uppercase.
122
+ if actual.lower() != bundle.archive_md5.lower():
123
+ raise RuntimeError(
124
+ f"Archive checksum mismatch for '{bundle.version}': "
125
+ f"expected {bundle.archive_md5[:12]}..., got {actual[:12]}..."
126
+ )
127
+
128
+ runtime.logger.info("Extracting bundle...")
129
+ _extract_archive(archive, dest)
130
+ archive.unlink(missing_ok=True)
131
+
132
+ problems = bundles.verify_manifest(version)
133
+ if problems:
134
+ raise RuntimeError(
135
+ "Bundle verification failed after download:\n - " + "\n - ".join(problems)
136
+ )
137
+
138
+ runtime.logger.success(f"Demo bundle '{bundle.version}' fetched and verified")
139
+ return dest
140
+
141
+
142
+ def _suffix(url: str) -> str:
143
+ """Return the archive suffix (``.zip`` or ``.tar.gz``) implied by a URL."""
144
+ lowered = url.lower()
145
+ if lowered.endswith(".tar.gz") or lowered.endswith(".tgz"):
146
+ return ".tar.gz"
147
+ return ".zip"
@@ -0,0 +1,148 @@
1
+ """
2
+ Rebuild path: ingest the bundle's raw files through the real upload pipeline.
3
+
4
+ Instead of dropping files into the file-converter's watch directory (which has
5
+ no auth/context and never registers a file context), this uploads each raw file
6
+ to the real ``/api/sample/files/upload`` endpoint - exactly what the File Agent
7
+ does. The endpoint registers the file context (auth) and writes the file to
8
+ ``filestreams``, where the file-converter ingests it and runs the full
9
+ ``RawProcessor`` -> peak detection -> matching pipeline.
10
+
11
+ Uploads are driven from a background thread because the app is launched in the
12
+ foreground; the thread waits for the backend HTTP server, then uploads.
13
+ """
14
+
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from mascope_cli.cmd.demo import bundles
20
+ from mascope_cli.cmd.demo._seed import DEMO_ENV # noqa: F401 - re-exported convenience
21
+ from mascope_cli.runtime import runtime
22
+
23
+
24
+ # The fixed file-agent token + service name the demo seeds (mirror
25
+ # server seed_demo.DEMO_TOKENS["file-agent"]). Using the file-agent service
26
+ # replicates the File Agent's exact upload request.
27
+ _UPLOAD_TOKEN = "mascope_demo_file_agent_token"
28
+ _UPLOAD_SERVICE = "file-agent"
29
+ _UPLOAD_PATH = "sample/files/upload"
30
+
31
+
32
+ def _raw_dir(version: str | None, source_dir: "Path | None") -> Path:
33
+ """Resolve the bundle's raw/ directory (published cache or local override)."""
34
+ root = Path(source_dir) if source_dir else bundles.bundle_dir(version)
35
+ src = root / "raw"
36
+ if not src.is_dir():
37
+ raise FileNotFoundError(f"No raw/ directory found at: {src}")
38
+ return src
39
+
40
+
41
+ def _wait_for_backend(url: str, timeout: float = 300.0, poll: float = 2.0) -> bool:
42
+ """
43
+ Poll the backend until it serves HTTP.
44
+
45
+ Probes a public, always-present endpoint (the first-owner status route)
46
+ rather than the OpenAPI schema, which is disabled outside dev mode.
47
+
48
+ :param url: Backend base URL (e.g. ``http://localhost:8090``).
49
+ :param timeout: Max seconds to wait.
50
+ :param poll: Seconds between attempts.
51
+ :return: ``True`` once ready, ``False`` on timeout.
52
+ """
53
+ import requests
54
+
55
+ deadline = time.monotonic() + timeout
56
+ while time.monotonic() < deadline:
57
+ try:
58
+ resp = requests.get(f"{url}/api/users/first-owner/status", timeout=5)
59
+ if resp.status_code == 200:
60
+ return True
61
+ except requests.RequestException:
62
+ pass
63
+ time.sleep(poll)
64
+ return False
65
+
66
+
67
+ def upload_raw(version: str | None = None, source_dir: "Path | None" = None) -> int:
68
+ """
69
+ Upload the bundle's raw files to the real sample-file upload endpoint.
70
+
71
+ Replicates the File Agent's request (bearer ``file-agent`` token +
72
+ ``X-Service-Name: file-agent``) so the server registers context, stores the
73
+ file, and the converter ingests it.
74
+
75
+ :param version: Bundle version tag. Defaults to the registry default.
76
+ :param source_dir: Local bundle directory override (see module docstring).
77
+ :return: Number of files successfully uploaded.
78
+ """
79
+ import mascope_sdk
80
+ from mascope_sdk import api_post_file
81
+ from mascope_sdk.exceptions import MascopeError
82
+
83
+ src = _raw_dir(version, source_dir)
84
+ url = f"http://localhost:{runtime.meta.api_port}"
85
+
86
+ # Match the File Agent's service header for the upload request.
87
+ mascope_sdk.SERVICE_NAME = _UPLOAD_SERVICE
88
+
89
+ raw_files = sorted(p for p in src.iterdir() if p.suffix.lower() == ".raw")
90
+ total = len(raw_files)
91
+ runtime.logger.info(f"Uploading {total} raw file(s) to {url}/api/{_UPLOAD_PATH}")
92
+
93
+ uploaded = 0
94
+ for i, raw in enumerate(raw_files, 1):
95
+ try:
96
+ api_post_file(
97
+ url=url,
98
+ path=_UPLOAD_PATH,
99
+ access_token=_UPLOAD_TOKEN,
100
+ filepath=str(raw),
101
+ )
102
+ uploaded += 1
103
+ except MascopeError as e:
104
+ runtime.logger.error(f"Upload of {raw.name} failed: {e}")
105
+ if i % 25 == 0 or i == total:
106
+ runtime.logger.info(f" uploaded {uploaded}/{total}")
107
+
108
+ if uploaded == total:
109
+ runtime.logger.success(f"Uploaded all {total} raw file(s); ingestion proceeds")
110
+ else:
111
+ runtime.logger.warning(
112
+ f"Uploaded {uploaded}/{total} raw file(s); {total - uploaded} failed "
113
+ "(see errors above)"
114
+ )
115
+ return uploaded
116
+
117
+
118
+ def upload_raw_deferred(
119
+ version: str | None = None, source_dir: "Path | None" = None
120
+ ) -> None:
121
+ """
122
+ Upload raw files once the backend is serving, from a background daemon thread.
123
+
124
+ The app is launched in the foreground, so this defers the uploads to a thread
125
+ that first waits for the backend HTTP server to come up.
126
+
127
+ :param version: Bundle version tag. Defaults to the registry default.
128
+ :param source_dir: Local bundle directory override.
129
+ """
130
+
131
+ def _worker() -> None:
132
+ url = f"http://localhost:{runtime.meta.api_port}"
133
+ if not _wait_for_backend(url):
134
+ runtime.logger.error(
135
+ "Backend did not become ready in time; skipping raw upload. "
136
+ "Once it is up, re-run 'mascope demo --rebuild' or upload manually."
137
+ )
138
+ return
139
+ try:
140
+ upload_raw(version, source_dir=source_dir)
141
+ except Exception as e: # noqa: BLE001 - background thread, just log
142
+ runtime.logger.error(f"Deferred raw upload failed: {e}")
143
+
144
+ threading.Thread(target=_worker, name="demo-upload-raw", daemon=True).start()
145
+ runtime.logger.info(
146
+ "Raw files will be uploaded to the server once the backend is ready; "
147
+ "ingestion then proceeds in the background."
148
+ )
@@ -0,0 +1,170 @@
1
+ """
2
+ Seed the demo runtime env from a cached bundle's database snapshot.
3
+
4
+ This is the "instant" path: restore the pre-built ``pg_dump`` into the
5
+ ``mascope_demo`` database and copy the bundle's filestore tree into the demo
6
+ env so the backend can serve the stored files. No instrument pipeline runs.
7
+
8
+ The slower ``--rebuild`` path (ingest raw through the real file-converter) lives
9
+ in ``_rebuild.py`` and is what the reproducibility test drives.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import shutil
15
+ from pathlib import Path
16
+
17
+ from mascope_cli.cmd.demo import bundles
18
+ from mascope_cli.pg import dirs, drop_database, pg_restore
19
+ from mascope_cli.runtime import runtime
20
+
21
+
22
+ # The demo always uses a dedicated env so it never touches a developer's work.
23
+ DEMO_ENV = "demo"
24
+ _MODE = "dev"
25
+
26
+
27
+ def env_dir(env: str = DEMO_ENV) -> Path:
28
+ """Return the runtime directory for an env (may not exist yet)."""
29
+ return Path(os.environ["MASCOPE_PATH"]) / ".runtime" / "env" / env
30
+
31
+
32
+ def _resolve(version: str | None, source_dir: "Path | None") -> tuple[Path, dict]:
33
+ """Resolve the bundle root and load its manifest (published cache or local)."""
34
+ if source_dir:
35
+ root = Path(source_dir)
36
+ manifest_path = root / bundles.MANIFEST_NAME
37
+ if not manifest_path.is_file():
38
+ raise FileNotFoundError(
39
+ f"No {bundles.MANIFEST_NAME} in {root}. A snapshot restore needs a "
40
+ "built bundle; for a raw-only local directory use 'mascope demo "
41
+ "--rebuild --local <dir>'."
42
+ )
43
+ return root, json.loads(manifest_path.read_text(encoding="utf-8"))
44
+ return bundles.bundle_dir(version), bundles.load_manifest(version)
45
+
46
+
47
+ def has_block(
48
+ block: str, version: str | None = None, source_dir: "Path | None" = None
49
+ ) -> bool:
50
+ """
51
+ Report whether a manifest block (``"seed"`` or ``"snapshot"``) is available.
52
+
53
+ Returns ``False`` (rather than raising) when there is no manifest - e.g. a
54
+ raw-only ``--local`` directory - so callers can fall back gracefully.
55
+
56
+ :param block: Manifest key, ``"seed"`` or ``"snapshot"``.
57
+ :param version: Bundle version tag. Defaults to the registry default.
58
+ :param source_dir: Local bundle directory override.
59
+ :return: ``True`` if the block's dump exists on disk.
60
+ """
61
+ try:
62
+ root, manifest = _resolve(version, source_dir)
63
+ except FileNotFoundError:
64
+ return False
65
+ dump_rel = manifest.get(block, {}).get("dump")
66
+ return bool(dump_rel) and (root / dump_rel).is_file()
67
+
68
+
69
+ def _restore_block(
70
+ block: str, version: str | None = None, source_dir: "Path | None" = None
71
+ ) -> None:
72
+ """
73
+ Restore a manifest dump block into the ``mascope_demo`` database.
74
+
75
+ Stages the dump into the dev backups directory (bind-mounted into the
76
+ PostgreSQL container), then drops/recreates the demo database and restores
77
+ into it. Assumes the dev PostgreSQL container is running and the demo env
78
+ exists.
79
+
80
+ :param block: Manifest key, ``"seed"`` (reference data) or ``"snapshot"``
81
+ (full state).
82
+ :param version: Bundle version tag. Defaults to the registry default.
83
+ :param source_dir: Local bundle directory override.
84
+ :raises FileNotFoundError: If the bundle or the block's dump is missing.
85
+ :raises RuntimeError: If the restore fails.
86
+ """
87
+ # Lazy import: keeps `mascope demo fetch` from pulling the dev.db graph.
88
+ from mascope_cli.cmd.dev.db import create_database
89
+
90
+ root, manifest = _resolve(version, source_dir)
91
+ dump_rel = manifest.get(block, {}).get("dump")
92
+ if not dump_rel:
93
+ raise FileNotFoundError(
94
+ f"Bundle manifest has no '{block}' dump. Build it with "
95
+ f"'mascope demo snapshot --{block if block == 'seed' else 'update'}'."
96
+ )
97
+
98
+ src_dump = root / dump_rel
99
+ if not src_dump.is_file():
100
+ raise FileNotFoundError(f"{block} dump not found: {src_dump}")
101
+
102
+ db_cfg = runtime.full_config.backend.database
103
+ container = db_cfg.get_postgres_container_name(mode=_MODE)
104
+ database = db_cfg.get_postgres_database_name(DEMO_ENV)
105
+
106
+ # Stage the dump where pg_restore expects it (bind-mounted backups dir).
107
+ backups_dir, mount = dirs(transfer=False, mode=_MODE)
108
+ backups_dir.mkdir(parents=True, exist_ok=True)
109
+ staged = backups_dir / src_dump.name
110
+ shutil.copy2(src_dump, staged)
111
+
112
+ try:
113
+ runtime.logger.info(f"Dropping and recreating '{database}'...")
114
+ drop_database(container, db_cfg.user, database)
115
+ if not create_database(DEMO_ENV):
116
+ raise RuntimeError(f"Failed to recreate database '{database}'")
117
+
118
+ runtime.logger.info(f"Restoring {block} into '{database}'...")
119
+ pg_restore(container, db_cfg.user, database, staged, mount)
120
+ finally:
121
+ staged.unlink(missing_ok=True)
122
+
123
+ runtime.logger.success(f"Demo database '{database}' restored from {block}")
124
+
125
+
126
+ def restore_snapshot(
127
+ version: str | None = None, source_dir: "Path | None" = None
128
+ ) -> None:
129
+ """Restore the full ``snapshot`` dump (instant path). See :func:`_restore_block`."""
130
+ _restore_block("snapshot", version, source_dir)
131
+
132
+
133
+ def restore_seed(version: str | None = None, source_dir: "Path | None" = None) -> None:
134
+ """
135
+ Restore the reference ``seed`` dump (ionization modes, instrument config,
136
+ calibration/diagnostic collections, demo user) before a rebuild ingests raw.
137
+ See :func:`_restore_block`.
138
+ """
139
+ _restore_block("seed", version, source_dir)
140
+
141
+
142
+ def restore_filestore(
143
+ version: str | None = None, source_dir: "Path | None" = None
144
+ ) -> None:
145
+ """
146
+ Copy the bundle's filestore tree into the demo env's filestore directory.
147
+
148
+ Mirrors ``snapshot/filestore/`` from the bundle into
149
+ ``.runtime/env/demo/filestore/`` so the restored database rows resolve to
150
+ real files on disk. Existing demo filestore contents are replaced.
151
+
152
+ :param version: Bundle version tag. Defaults to the registry default.
153
+ :param source_dir: Local bundle directory to use instead of the published cache.
154
+ """
155
+ root, manifest = _resolve(version, source_dir)
156
+ snapshot = manifest.get("snapshot", {})
157
+ filestore_rel = snapshot.get("filestore", "snapshot/filestore")
158
+ src = root / filestore_rel
159
+
160
+ if not src.is_dir():
161
+ runtime.logger.warning(
162
+ f"Bundle has no filestore tree at {src}; skipping filestore restore"
163
+ )
164
+ return
165
+
166
+ dest = env_dir() / "filestore"
167
+ if dest.exists():
168
+ shutil.rmtree(dest)
169
+ shutil.copytree(src, dest)
170
+ runtime.logger.success(f"Demo filestore restored to {dest}")