mascope_cli 2026.7.28__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 (65) hide show
  1. mascope_cli-2026.7.28/PKG-INFO +91 -0
  2. mascope_cli-2026.7.28/README.md +63 -0
  3. mascope_cli-2026.7.28/pyproject.toml +51 -0
  4. mascope_cli-2026.7.28/src/mascope_cli/__init__.py +1 -0
  5. mascope_cli-2026.7.28/src/mascope_cli/checkout.py +38 -0
  6. mascope_cli-2026.7.28/src/mascope_cli/cmd/__init__.py +10 -0
  7. mascope_cli-2026.7.28/src/mascope_cli/cmd/agent.py +25 -0
  8. mascope_cli-2026.7.28/src/mascope_cli/cmd/backend.py +19 -0
  9. mascope_cli-2026.7.28/src/mascope_cli/cmd/cert.py +64 -0
  10. mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/__init__.py +2 -0
  11. mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/_fetch.py +147 -0
  12. mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/_rebuild.py +148 -0
  13. mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/_seed.py +170 -0
  14. mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/build_bundle.py +493 -0
  15. mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/bundles.py +220 -0
  16. mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/main.py +564 -0
  17. mascope_cli-2026.7.28/src/mascope_cli/cmd/demo/verify.py +135 -0
  18. mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/__init__.py +2 -0
  19. mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/db/__init__.py +2 -0
  20. mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/db/backup.py +268 -0
  21. mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/db/main.py +646 -0
  22. mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/db/scripts.py +188 -0
  23. mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/docker.py +248 -0
  24. mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/main.py +548 -0
  25. mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/migrate.py +365 -0
  26. mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/redis.py +246 -0
  27. mascope_cli-2026.7.28/src/mascope_cli/cmd/dev/tools.py +53 -0
  28. mascope_cli-2026.7.28/src/mascope_cli/cmd/env/__init__.py +2 -0
  29. mascope_cli-2026.7.28/src/mascope_cli/cmd/env/_create.py +104 -0
  30. mascope_cli-2026.7.28/src/mascope_cli/cmd/env/_paths.py +172 -0
  31. mascope_cli-2026.7.28/src/mascope_cli/cmd/env/_ssh.py +228 -0
  32. mascope_cli-2026.7.28/src/mascope_cli/cmd/env/_sync.py +613 -0
  33. mascope_cli-2026.7.28/src/mascope_cli/cmd/env/main.py +354 -0
  34. mascope_cli-2026.7.28/src/mascope_cli/cmd/fleet.py +181 -0
  35. mascope_cli-2026.7.28/src/mascope_cli/cmd/init.py +145 -0
  36. mascope_cli-2026.7.28/src/mascope_cli/cmd/instance.py +152 -0
  37. mascope_cli-2026.7.28/src/mascope_cli/cmd/lib.py +65 -0
  38. mascope_cli-2026.7.28/src/mascope_cli/cmd/logs.py +190 -0
  39. mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/__init__.py +2 -0
  40. mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/auto_update.py +451 -0
  41. mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/cli_drift.py +135 -0
  42. mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/db/__init__.py +2 -0
  43. mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/db/backup.py +277 -0
  44. mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/db/main.py +448 -0
  45. mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/db/scripts.py +275 -0
  46. mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/doctor.py +313 -0
  47. mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/main.py +1061 -0
  48. mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/preflight.py +269 -0
  49. mascope_cli-2026.7.28/src/mascope_cli/cmd/prod/release_manifest.py +134 -0
  50. mascope_cli-2026.7.28/src/mascope_cli/cmd/test.py +463 -0
  51. mascope_cli-2026.7.28/src/mascope_cli/data/base.mascope.toml +178 -0
  52. mascope_cli-2026.7.28/src/mascope_cli/data/dev.mascope.toml +24 -0
  53. mascope_cli-2026.7.28/src/mascope_cli/data/docker-compose.demo.yaml +145 -0
  54. mascope_cli-2026.7.28/src/mascope_cli/data/docker-compose.dev.yaml +90 -0
  55. mascope_cli-2026.7.28/src/mascope_cli/data/docker-compose.yaml +215 -0
  56. mascope_cli-2026.7.28/src/mascope_cli/data/prod.mascope.toml +78 -0
  57. mascope_cli-2026.7.28/src/mascope_cli/docker.py +29 -0
  58. mascope_cli-2026.7.28/src/mascope_cli/home.py +45 -0
  59. mascope_cli-2026.7.28/src/mascope_cli/instance.py +294 -0
  60. mascope_cli-2026.7.28/src/mascope_cli/main.py +184 -0
  61. mascope_cli-2026.7.28/src/mascope_cli/pg/__init__.py +3 -0
  62. mascope_cli-2026.7.28/src/mascope_cli/pg/admin.py +632 -0
  63. mascope_cli-2026.7.28/src/mascope_cli/pg/utils.py +228 -0
  64. mascope_cli-2026.7.28/src/mascope_cli/runtime.py +74 -0
  65. mascope_cli-2026.7.28/src/mascope_cli/version.py +38 -0
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.3
2
+ Name: mascope_cli
3
+ Version: 2026.7.28
4
+ Summary: Mascope's operations CLI: install, launch and back up a Mascope deployment.
5
+ Author: Karsa Oy
6
+ Author-email: Karsa Oy <support@karsa.fi>
7
+ License: Apache-2.0
8
+ Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
9
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
10
+ Requires-Dist: mascope-runtime[logs]>=2026.7.28
11
+ Requires-Dist: typer>=0.27.0,<0.28
12
+ Requires-Dist: rich>=15.0.0,<16
13
+ Requires-Dist: pandas>=2.0.0 ; extra == 'demo'
14
+ Requires-Dist: requests>=2.32.0,<3 ; extra == 'demo'
15
+ Requires-Dist: alembic>=1.18.1 ; extra == 'dev'
16
+ Requires-Dist: sqlalchemy~=2.0 ; extra == 'dev'
17
+ Requires-Dist: psycopg2-binary>=2.9.12 ; extra == 'dev'
18
+ Requires-Dist: pyyaml>=6,<7 ; extra == 'dev'
19
+ Maintainer: Oskari Kausiala
20
+ Maintainer-email: Oskari Kausiala <oskari.kausiala@karsa.fi>
21
+ Requires-Python: >=3.12, <3.13
22
+ Project-URL: homepage, https://karsa.fi/
23
+ Project-URL: repository, https://github.com/karsa-oy/mascope
24
+ Project-URL: Bug Tracker, https://github.com/karsa-oy/mascope/issues
25
+ Provides-Extra: demo
26
+ Provides-Extra: dev
27
+ Description-Content-Type: text/markdown
28
+
29
+ # Mascope CLI
30
+
31
+ The operations CLI for [Mascope](https://karsa.fi/), Karsa's mass-spectrometry
32
+ analysis platform. It installs, launches, backs up and manages a Mascope
33
+ deployment — the application itself runs as Docker containers pulled from the
34
+ project's registry.
35
+
36
+ ## Requirements
37
+
38
+ - Python 3.12
39
+ - Docker (with the compose plugin)
40
+
41
+ ## Quickstart
42
+
43
+ ```sh
44
+ pip install mascope-cli
45
+
46
+ mascope init # create a runtime home: config, compose files, secrets
47
+ mascope cert gen # self-signed TLS cert (or install real certs)
48
+ mascope prod up # pull and start the production stack
49
+ ```
50
+
51
+ `mascope init` creates the runtime home at `~/.mascope`
52
+ (`%LOCALAPPDATA%\Mascope` on Windows) — configuration TOMLs you can edit,
53
+ docker compose files, and generated application secrets. Set `MASCOPE_PATH`
54
+ to place it elsewhere. Re-running `init` is safe: existing files are kept and
55
+ secrets are never regenerated.
56
+
57
+ To try Mascope without a deployment, the self-contained demo stack ships in
58
+ the runtime home as well:
59
+
60
+ ```sh
61
+ docker compose -f docker-compose.demo.yaml up -d
62
+ # then open http://localhost:8080 — login demo@mascope.app / mascope-demo
63
+ ```
64
+
65
+ ## Everyday operations
66
+
67
+ ```sh
68
+ mascope prod ps # container status
69
+ mascope prod logs --follow # stream logs
70
+ mascope prod update # update the stack to the latest release
71
+ mascope prod db backup # dump the database
72
+ mascope prod db restore --yes # restore the latest dump
73
+ mascope logs query --grep error # search the application's file logs
74
+ mascope env list # manage runtime environments
75
+ ```
76
+
77
+ Run `mascope --help` or `mascope <command> --help` for the full reference.
78
+
79
+ `mascope prod update` pulls the target release images, restarts the stack
80
+ with them and runs any pending database migrations (preceded by an automatic
81
+ pre-migration dump). Move to a specific release with
82
+ `mascope prod update --version v1.2.0`, or pin the deployed version for all
83
+ commands with `MASCOPE_VERSION`; without a pin the stack follows the
84
+ `latest` release images.
85
+
86
+ ## Development
87
+
88
+ The Mascope source checkout exposes additional developer commands (`dev`,
89
+ `test`, `agent`, `backend`) that operate on the monorepo; they are not part
90
+ of the standalone install. See the
91
+ [repository](https://github.com/karsa-oy/mascope) for the developer guide.
@@ -0,0 +1,63 @@
1
+ # Mascope CLI
2
+
3
+ The operations CLI for [Mascope](https://karsa.fi/), Karsa's mass-spectrometry
4
+ analysis platform. It installs, launches, backs up and manages a Mascope
5
+ deployment — the application itself runs as Docker containers pulled from the
6
+ project's registry.
7
+
8
+ ## Requirements
9
+
10
+ - Python 3.12
11
+ - Docker (with the compose plugin)
12
+
13
+ ## Quickstart
14
+
15
+ ```sh
16
+ pip install mascope-cli
17
+
18
+ mascope init # create a runtime home: config, compose files, secrets
19
+ mascope cert gen # self-signed TLS cert (or install real certs)
20
+ mascope prod up # pull and start the production stack
21
+ ```
22
+
23
+ `mascope init` creates the runtime home at `~/.mascope`
24
+ (`%LOCALAPPDATA%\Mascope` on Windows) — configuration TOMLs you can edit,
25
+ docker compose files, and generated application secrets. Set `MASCOPE_PATH`
26
+ to place it elsewhere. Re-running `init` is safe: existing files are kept and
27
+ secrets are never regenerated.
28
+
29
+ To try Mascope without a deployment, the self-contained demo stack ships in
30
+ the runtime home as well:
31
+
32
+ ```sh
33
+ docker compose -f docker-compose.demo.yaml up -d
34
+ # then open http://localhost:8080 — login demo@mascope.app / mascope-demo
35
+ ```
36
+
37
+ ## Everyday operations
38
+
39
+ ```sh
40
+ mascope prod ps # container status
41
+ mascope prod logs --follow # stream logs
42
+ mascope prod update # update the stack to the latest release
43
+ mascope prod db backup # dump the database
44
+ mascope prod db restore --yes # restore the latest dump
45
+ mascope logs query --grep error # search the application's file logs
46
+ mascope env list # manage runtime environments
47
+ ```
48
+
49
+ Run `mascope --help` or `mascope <command> --help` for the full reference.
50
+
51
+ `mascope prod update` pulls the target release images, restarts the stack
52
+ with them and runs any pending database migrations (preceded by an automatic
53
+ pre-migration dump). Move to a specific release with
54
+ `mascope prod update --version v1.2.0`, or pin the deployed version for all
55
+ commands with `MASCOPE_VERSION`; without a pin the stack follows the
56
+ `latest` release images.
57
+
58
+ ## Development
59
+
60
+ The Mascope source checkout exposes additional developer commands (`dev`,
61
+ `test`, `agent`, `backend`) that operate on the monorepo; they are not part
62
+ of the standalone install. See the
63
+ [repository](https://github.com/karsa-oy/mascope) for the developer guide.
@@ -0,0 +1,51 @@
1
+ [project]
2
+ name = "mascope_cli"
3
+ version = "2026.7.28"
4
+ description = "Mascope's operations CLI: install, launch and back up a Mascope deployment."
5
+ classifiers = [
6
+ "Topic :: Scientific/Engineering :: Atmospheric Science",
7
+ "Topic :: Software Development :: Libraries :: Python Modules",
8
+ ]
9
+ authors = [{ name = "Karsa Oy", email = "support@karsa.fi" }]
10
+ maintainers = [
11
+ { name = "Oskari Kausiala", email = "oskari.kausiala@karsa.fi" },
12
+ ]
13
+ license.text = "Apache-2.0"
14
+ readme = "README.md"
15
+ requires-python = ">=3.12,<3.13"
16
+ # Operator surface only (init, prod, env, demo, logs, cert). Developer
17
+ # commands live behind the `dev` extra and a source-checkout gate in main.py.
18
+ dependencies = [
19
+ # >=2026.7.28: `logs query` passes the service/json_output kwargs added
20
+ # to RuntimeLogging.query in that release.
21
+ "mascope_runtime[logs]>=2026.7.28",
22
+ "typer>=0.27.0,<0.28",
23
+ "rich>=15.0.0,<16",
24
+ ]
25
+
26
+ [project.scripts]
27
+ mascope = "mascope_cli.main:app"
28
+
29
+ [project.optional-dependencies]
30
+ # `mascope dev migrate` / `mascope dev db` / `mascope fleet` — monorepo
31
+ # development only.
32
+ dev = [
33
+ "alembic>=1.18.1",
34
+ "sqlalchemy~=2.0",
35
+ "psycopg2-binary>=2.9.12",
36
+ "pyyaml>=6,<7",
37
+ ]
38
+ # `mascope demo build-bundle` / `verify` peak comparison (imported lazily).
39
+ demo = [
40
+ "pandas>=2.0.0",
41
+ "requests>=2.32.0,<3",
42
+ ]
43
+
44
+ [project.urls]
45
+ homepage = "https://karsa.fi/"
46
+ repository = "https://github.com/karsa-oy/mascope"
47
+ "Bug Tracker" = "https://github.com/karsa-oy/mascope/issues"
48
+
49
+ [build-system]
50
+ requires = ["uv_build>=0.11.32,<0.12"]
51
+ build-backend = "uv_build"
@@ -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
+ )