basebandflow 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.
File without changes
@@ -0,0 +1,58 @@
1
+ from datetime import timedelta
2
+
3
+ from basebandflow.api.auth.canfar import is_logged_in
4
+ from basebandflow.api.auth.tokens import (
5
+ load_tokens,
6
+ refresh_tokens,
7
+ token_time_remaining,
8
+ tokens_need_refresh,
9
+ )
10
+ from basebandflow.api.errors import InvalidTokens, NotLoggedIn, TokenRefreshFailed
11
+ from basebandflow.api.settings import require_settings
12
+ from basebandflow.models import TokenStatus
13
+ from basebandflow.tools import require_tool
14
+
15
+
16
+ def tokens_valid() -> bool:
17
+ settings = require_settings()
18
+ margin = timedelta(minutes=settings.token_refresh_timer)
19
+ access, _ = load_tokens()
20
+ return not tokens_need_refresh(access, margin)
21
+
22
+
23
+ def refresh_and_verify() -> None:
24
+ """Refresh (prompts for a password), then confirm the result. Call from the CLI, not from launch()."""
25
+ refresh_tokens()
26
+ if not tokens_valid():
27
+ raise TokenRefreshFailed("Tokens are still expired after refresh.")
28
+
29
+
30
+ def ensure_ready_for_launch() -> None:
31
+ require_tool("workflow")
32
+ logged_in = is_logged_in()
33
+ if logged_in is None:
34
+ raise NotLoggedIn(
35
+ "Couldn't reach CANFAR to verify your login. Check your connection and retry."
36
+ )
37
+ if not logged_in:
38
+ raise NotLoggedIn("Not logged in to CANFAR. Run `bbflow login`.")
39
+ if not tokens_valid():
40
+ raise InvalidTokens(
41
+ "CHIME/FRB tokens are expired or about to expire."
42
+ ) # no refresh here
43
+
44
+
45
+ def _token_status(token: str | None, margin: timedelta) -> TokenStatus:
46
+ return TokenStatus(
47
+ present=bool(token),
48
+ remaining=token_time_remaining(token),
49
+ needs_refresh=tokens_need_refresh(token, margin),
50
+ )
51
+
52
+
53
+ def check_tokens() -> tuple[TokenStatus, TokenStatus]:
54
+ settings = require_settings()
55
+ access, refresh = load_tokens()
56
+ return _token_status(
57
+ access, timedelta(minutes=settings.token_refresh_timer)
58
+ ), _token_status(refresh, timedelta(minutes=settings.token_refresh_timer))
@@ -0,0 +1,24 @@
1
+ from basebandflow import tools
2
+ from basebandflow.api.errors import ToolNotFound
3
+
4
+
5
+ def is_logged_in() -> bool | None:
6
+ """True/False, or None if it couldn't be verified (timeout)."""
7
+ try:
8
+ r = tools.canfar(
9
+ "ps",
10
+ stdin=tools.subprocess.DEVNULL,
11
+ capture_output=True,
12
+ text=True,
13
+ timeout=30,
14
+ )
15
+ except ToolNotFound:
16
+ return False
17
+ except tools.subprocess.TimeoutExpired:
18
+ return None
19
+ return r.returncode == 0
20
+
21
+
22
+ def login_interactive() -> int: # inherits terminal, returns exit code
23
+ r = tools.canfar("login", "--force")
24
+ return r.returncode
@@ -0,0 +1,102 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import sys
5
+ from datetime import datetime, timedelta, timezone
6
+ from pathlib import Path
7
+
8
+ import jwt
9
+ from platformdirs import user_state_dir
10
+
11
+ from basebandflow import tools
12
+ from basebandflow.api.errors import TokenRefreshFailed
13
+ from basebandflow.api.settings import require_settings
14
+
15
+ TOKEN_FILE = Path(user_state_dir("bbflow", appauthor=False)) / "chime_frb_tokens.json"
16
+
17
+
18
+ def token_time_remaining(token: str | None) -> timedelta | None:
19
+ """Time until the token expires (negative if already expired).
20
+ Returns None if the token is missing, malformed, or has no exp claim."""
21
+ if not token:
22
+ return None
23
+ try:
24
+ claims = jwt.decode(token, options={"verify_signature": False})
25
+ exp = claims.get("exp")
26
+ if exp is None:
27
+ return None
28
+ return datetime.fromtimestamp(exp, tz=timezone.utc) - datetime.now(timezone.utc)
29
+ except (jwt.DecodeError, ValueError, TypeError, OverflowError):
30
+ return None
31
+
32
+
33
+ def tokens_need_refresh(token: str | None, margin: timedelta) -> bool:
34
+ remaining = token_time_remaining(token)
35
+ return remaining is None or remaining <= margin
36
+
37
+
38
+ def parse_exports(output: str) -> tuple[str, str]:
39
+ """Parses the output from frb-api to find new tokens."""
40
+ a = re.search(r"CHIME_FRB_ACCESS_TOKEN=(\S+)", output)
41
+ r = re.search(r"CHIME_FRB_REFRESH_TOKEN=(\S+)", output)
42
+ if not (a and r):
43
+ raise TokenRefreshFailed("Couldn't find tokens in refresh output.")
44
+ return a.group(1).strip("'\""), r.group(1).strip("'\"")
45
+
46
+
47
+ def save_tokens(access: str, refresh: str) -> None:
48
+ """Save the tokens to local json file."""
49
+ TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
50
+ tmp = TOKEN_FILE.with_suffix(".tmp")
51
+ fd = os.open(
52
+ tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600
53
+ ) # owner read/write only
54
+ with os.fdopen(fd, "w") as f:
55
+ json.dump({"access": access, "refresh": refresh}, f)
56
+ tmp.replace(TOKEN_FILE)
57
+
58
+
59
+ def load_tokens() -> tuple[str | None, str | None]:
60
+ """Loads the tokens either from the environment (first check), or the stored json file."""
61
+ # environment wins, so a user who exports them manually still works
62
+ access = os.environ.get("CHIME_FRB_ACCESS_TOKEN")
63
+ refresh = os.environ.get("CHIME_FRB_REFRESH_TOKEN")
64
+ if access and refresh:
65
+ return access, refresh
66
+ try:
67
+ data = json.loads(TOKEN_FILE.read_text())
68
+ return data.get("access"), data.get("refresh")
69
+ except (OSError, ValueError):
70
+ return None, None
71
+
72
+
73
+ def refresh_tokens() -> None:
74
+ # require_tool("frb-api")
75
+ settings = require_settings()
76
+ if not sys.stdin.isatty():
77
+ raise TokenRefreshFailed(
78
+ "Token refresh needs a password; run `bbflow login` in a terminal."
79
+ )
80
+ try:
81
+ r = tools.frb_api(
82
+ "generate-tokens",
83
+ "-u",
84
+ settings.username,
85
+ stdout=tools.subprocess.PIPE,
86
+ text=True,
87
+ timeout=300,
88
+ )
89
+ except tools.subprocess.TimeoutExpired:
90
+ raise TokenRefreshFailed("Token refresh timed out.") from None
91
+ if r.returncode != 0:
92
+ raise TokenRefreshFailed("Token refresh failed (wrong password?).")
93
+
94
+ access, refresh = parse_exports(r.stdout)
95
+ save_tokens(access, refresh)
96
+ _export_to_env(access, refresh)
97
+
98
+
99
+ def _export_to_env(access: str, refresh: str) -> None:
100
+ """Export the tokens to the environment"""
101
+ os.environ["CHIME_FRB_ACCESS_TOKEN"] = access
102
+ os.environ["CHIME_FRB_REFRESH_TOKEN"] = refresh
@@ -0,0 +1,59 @@
1
+ from importlib.resources import files
2
+ from pathlib import Path
3
+
4
+ import yaml
5
+ from jinja2 import Environment, StrictUndefined
6
+
7
+ from basebandflow.api.errors import (
8
+ ConfigRenderError,
9
+ TemplateError,
10
+ )
11
+ from basebandflow.api.validate import validate_event_id, validate_username
12
+ from basebandflow.models import RenderedConfig
13
+
14
+ MATRIX_EXPR = "${{ matrix.job_id }}"
15
+
16
+
17
+ def load_template() -> str: # importlib.resources
18
+ return (files("basebandflow") / "templates" / "pipeline_config.yaml.j2").read_text()
19
+
20
+
21
+ def render_config(username: str, event_id: str, directory: Path) -> RenderedConfig:
22
+ username = validate_username(username)
23
+ event_id = validate_event_id(event_id)
24
+
25
+ env = Environment(undefined=StrictUndefined, keep_trailing_newline=True)
26
+ try:
27
+ text = env.from_string(load_template()).render(
28
+ username=username, event_id=event_id
29
+ )
30
+ parsed = yaml.safe_load(text)
31
+ except (TemplateError, yaml.YAMLError) as e:
32
+ raise ConfigRenderError(f"Could not render config: {e}") from e
33
+
34
+ _check_rendered(parsed, event_id)
35
+
36
+ path = directory / "config.yaml"
37
+ path.write_text(
38
+ text, encoding="utf-8"
39
+ ) # exact rendered text: anchors and comments intact
40
+ return RenderedConfig(text=text, parsed=parsed, path=path, event_id=event_id)
41
+
42
+
43
+ def _check_rendered(parsed, event_id: str) -> None:
44
+ """Sanity checks on the rendered result. Raises ConfigRenderError."""
45
+ if not isinstance(parsed, dict):
46
+ raise ConfigRenderError("Rendered config is not a YAML mapping.")
47
+ if (
48
+ str(parsed.get("event_number")) != event_id
49
+ or parsed.get("event_str") != event_id
50
+ ):
51
+ raise ConfigRenderError("Event ID did not render correctly.")
52
+
53
+ for step in parsed.get("pipeline", {}).get("steps", []):
54
+ if "matrix" in step: # workflow expression must survive rendering untouched
55
+ job_id = step.get("work", {}).get("parameters", {}).get("job_id")
56
+ if job_id != MATRIX_EXPR:
57
+ raise ConfigRenderError(
58
+ f"Step {step.get('name')!r}: matrix expression was altered."
59
+ )
@@ -0,0 +1,199 @@
1
+ from typing import Literal
2
+
3
+ import nest_asyncio
4
+ from canfar.sessions import Session
5
+
6
+ from basebandflow.api.auth import ensure_ready_for_launch
7
+ from basebandflow.api.auth.tokens import load_tokens
8
+ from basebandflow.api.settings import require_settings
9
+ from basebandflow.models import Container, Settings
10
+
11
+ nest_asyncio.apply()
12
+ s = Session()
13
+
14
+ # _STATUS_RE = re.compile(r"^\s*Status\s{2,}(\S.*?)\s*$", re.MULTILINE)
15
+ baseband_analysis_image = "images.canfar.net/chimefrb-public/baseband-analysis:latest"
16
+
17
+
18
+ # Launch the datatrail containers
19
+ def spawn_datatrail_container(settings: Settings) -> tuple[Container]:
20
+ payload = {
21
+ "name": "baseband-datatrail", # name of the session
22
+ "image": baseband_analysis_image, # image you're pulling #Try using chimefrb-public/baseband-analysis:latest if this image container does not work
23
+ "cores": 4, # data staging now supports multithreaded pulling
24
+ "ram": 2,
25
+ "kind": "headless",
26
+ "cmd": "workflow",
27
+ "args": f"run --site=canfar baseband-{settings.username}-datatrail", # the last args is the bucket name!!!!
28
+ "env": {"SITE": "canfar"},
29
+ "replicas": 2,
30
+ }
31
+ session_ids = s.create(**payload)
32
+ containers = []
33
+ for sid in session_ids:
34
+ container = Container(
35
+ payload.get("name"),
36
+ sid,
37
+ "pending",
38
+ "datatrail",
39
+ )
40
+ containers.append(container)
41
+ return containers
42
+
43
+
44
+ def prune_datatrail_container() -> bool:
45
+ res = s.destroy_with(prefix="baseband-datatrail", status=None)
46
+ return bool(res) and all(res.values())
47
+
48
+
49
+ def build_container_env(
50
+ settings: Settings, access_token: str, refresh_token: str
51
+ ) -> dict:
52
+ env = {
53
+ "SITE": "canfar",
54
+ "CHIME_FRB_ACCESS_TOKEN": access_token,
55
+ "CHIME_FRB_REFRESH_TOKEN": refresh_token,
56
+ # 'PYTHONPATH': '/arc/home/user/baseband-analysis/' # you can set this if you want to run your local branch on this headless session
57
+ }
58
+
59
+ if settings.baseband_path is not None:
60
+ env.update({"PYTHONPATH": settings.baseband_path})
61
+ return env
62
+
63
+
64
+ # Launch the analysis containers
65
+ def spawn_analysis_container(
66
+ settings: Settings, access_token: str, refresh_token: str
67
+ ) -> tuple[Container]:
68
+ env = build_container_env(settings, access_token, refresh_token)
69
+
70
+ payload = {
71
+ "name": "baseband-analysis",
72
+ "image": baseband_analysis_image,
73
+ "cores": 2,
74
+ "ram": 12,
75
+ "kind": "headless",
76
+ "cmd": "workflow",
77
+ "args": f"run --site=canfar baseband-{settings.username}",
78
+ "env": env,
79
+ "replicas": 10,
80
+ }
81
+
82
+ session_ids = s.create(**payload)
83
+ containers = []
84
+ for sid in session_ids:
85
+ container = Container(
86
+ payload.get("name"),
87
+ sid,
88
+ "pending",
89
+ "analysis",
90
+ )
91
+ containers.append(container)
92
+ return containers
93
+
94
+
95
+ def prune_analysis_container() -> bool:
96
+ res = s.destroy_with(prefix="baseband-analysis", status=None)
97
+ return bool(res) and all(res.values())
98
+
99
+
100
+ # Launch the refinement containers
101
+ def spawn_refinement_container(
102
+ settings: Settings, access_token: str, refresh_token: str
103
+ ) -> tuple[Container]:
104
+ env = build_container_env(settings, access_token, refresh_token)
105
+
106
+ payload = {
107
+ "name": "baseband-refinement",
108
+ "image": baseband_analysis_image,
109
+ "cores": 8,
110
+ "ram": 32,
111
+ "kind": "headless",
112
+ "cmd": "workflow",
113
+ "args": f"run --log-level=INFO --site=canfar baseband-{settings.username}-brightest",
114
+ "env": env,
115
+ "replicas": 1,
116
+ }
117
+
118
+ session_ids = s.create(**payload)
119
+ containers = []
120
+ for sid in session_ids:
121
+ container = Container(
122
+ payload.get("name"),
123
+ sid,
124
+ "pending",
125
+ "refinement",
126
+ )
127
+ containers.append(container)
128
+ return containers
129
+
130
+
131
+ def prune_refinement_container() -> bool:
132
+ res = s.destroy_with(prefix="baseband-refinement", status=None)
133
+ return bool(res) and all(res.values())
134
+
135
+
136
+ # Check health
137
+ def check_health(sid: list[str]) -> list[str] | None:
138
+ session_info = s.info(sid)
139
+ if session_info is None:
140
+ return
141
+
142
+ return [sinf.get("status") for sinf in session_info]
143
+
144
+
145
+ def delete_container(ids: str | list[str]) -> bool:
146
+ res = s.destroy(ids)
147
+ return all(res)
148
+
149
+
150
+ def prune_containers(session_type: Literal["datatrail", "analysis", "refinement"]):
151
+ if session_type == "datatrail":
152
+ return prune_datatrail_container()
153
+ if session_type == "analysis":
154
+ return prune_analysis_container()
155
+ if session_type == "refinement":
156
+ return prune_refinement_container()
157
+
158
+
159
+ def initialize_session_container(session_type) -> list[Container]:
160
+ settings = require_settings()
161
+
162
+ current_containers = list(
163
+ filter(lambda x: x.type == session_type, list_headless_containers())
164
+ )
165
+ if session_type == "datatrail":
166
+ if 0 < len(current_containers) <= 2:
167
+ prune_containers(session_type)
168
+
169
+ return spawn_datatrail_container(settings)
170
+
171
+ ensure_ready_for_launch()
172
+ access, refresh = load_tokens()
173
+
174
+ if session_type == "analysis":
175
+ if 0 < len(current_containers) <= 10:
176
+ prune_containers(session_type)
177
+
178
+ return spawn_analysis_container(settings, access, refresh)
179
+
180
+ if session_type == "refinement":
181
+ if 0 < len(current_containers) <= 1:
182
+ prune_containers(session_type)
183
+
184
+ return spawn_refinement_container(settings, access, refresh)
185
+
186
+
187
+ def list_headless_containers() -> list[Container]:
188
+ sessions = s.fetch(kind="headless")
189
+ containers = []
190
+ for session in sessions:
191
+ cont_type = session.get("name").split("-")[1]
192
+ container = Container(
193
+ session.get("name"), session.get("id"), session.get("status"), cont_type
194
+ )
195
+ containers.append(container)
196
+
197
+ containers.sort(key=lambda x: x.session_name)
198
+
199
+ return containers
@@ -0,0 +1,50 @@
1
+ class BBFlowError(Exception):
2
+ """Baseband Flow error."""
3
+
4
+
5
+ class SettingsMissing(BBFlowError):
6
+ """Configuration file or required settings are missing."""
7
+
8
+
9
+ class InvalidEventId(BBFlowError):
10
+ """Event ID must be a non-empty numeric value."""
11
+
12
+
13
+ class InvalidUsername(BBFlowError):
14
+ """Username is invalid or not recognized."""
15
+
16
+
17
+ class TemplateError(BBFlowError):
18
+ """Failed to load the pipeline template."""
19
+
20
+
21
+ class ConfigRenderError(BBFlowError):
22
+ """Failed to render the pipeline configuration."""
23
+
24
+
25
+ class NotLoggedIn(BBFlowError):
26
+ """You must log in before performing this action."""
27
+
28
+
29
+ class TokenRefreshFailed(BBFlowError):
30
+ """Unable to refresh the authentication token."""
31
+
32
+
33
+ class InvalidTokens(BBFlowError):
34
+ """Tokens are invalid. Refresh them."""
35
+
36
+
37
+ class ToolNotFound(BBFlowError):
38
+ """Required command-line tool was not found on PATH"""
39
+
40
+
41
+ class DeployFailed(BBFlowError):
42
+ """Pipeline deployment failed."""
43
+
44
+
45
+ class SessionError(BBFlowError):
46
+ """The user session is invalid or could not be restored."""
47
+
48
+
49
+ class SettingsCorrupt(SettingsMissing):
50
+ """Invalid configuration file. Reset it."""
@@ -0,0 +1,73 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+ from pydantic import ValidationError
7
+
8
+ from basebandflow.api.settings import RUNS_DIR
9
+ from basebandflow.models import LaunchResult
10
+
11
+
12
+ @dataclass
13
+ class RunRecord:
14
+ path: Path # the run folder
15
+ event_id: str
16
+ started: (
17
+ datetime | None
18
+ ) # parsed from folder names; None if they don't match the format
19
+ result: LaunchResult | None # None = launch_results.yaml missing or unreadable
20
+
21
+
22
+ def load_run(path: Path) -> LaunchResult | None:
23
+ try:
24
+ data = yaml.safe_load(
25
+ (path / "launch_results.yaml").read_text(encoding="utf-8")
26
+ )
27
+ return LaunchResult(**data)
28
+ except (OSError, yaml.YAMLError, ValidationError, TypeError):
29
+ return None
30
+
31
+
32
+ def _parse_run_dir(path: Path) -> tuple[str, datetime | None]:
33
+ """Folder layout: <YYYY-MM-DD>/<HHMMSS>_<event_id>"""
34
+ time_part, _, event_id = path.name.partition("_")
35
+ try:
36
+ started = datetime.strptime(
37
+ f"{path.parent.name} {time_part}", "%Y-%m-%d %H%M%S"
38
+ ).astimezone()
39
+ except ValueError:
40
+ started = None
41
+ return event_id, started
42
+
43
+
44
+ def list_runs(
45
+ event_id: str | None = None,
46
+ *,
47
+ limit: int | None = None,
48
+ include_dry_runs: bool = False,
49
+ ) -> list[RunRecord]:
50
+ """Newest first. Dry runs (status 'rendered') are hidden unless include_dry_runs."""
51
+ if not RUNS_DIR.exists():
52
+ return []
53
+
54
+ records: list[RunRecord] = []
55
+ # date folders and HHMMSS_ names both sort chronologically as plain strings
56
+ for path in sorted((p for p in RUNS_DIR.glob("*/*") if p.is_dir()), reverse=True):
57
+ eid, started = _parse_run_dir(path)
58
+ if event_id and eid != event_id:
59
+ continue
60
+ result = load_run(path)
61
+ if result is not None and result.status == "rendered" and not include_dry_runs:
62
+ continue
63
+ records.append(
64
+ RunRecord(path=path, event_id=eid, started=started, result=result)
65
+ )
66
+ if limit and len(records) >= limit:
67
+ break
68
+ return records
69
+
70
+
71
+ def latest_run(event_id: str) -> RunRecord | None:
72
+ runs = list_runs(event_id, limit=1)
73
+ return runs[0] if runs else None