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.
- basebandflow/api/__init__.py +0 -0
- basebandflow/api/auth/__init__.py +58 -0
- basebandflow/api/auth/canfar.py +24 -0
- basebandflow/api/auth/tokens.py +102 -0
- basebandflow/api/config.py +59 -0
- basebandflow/api/containers.py +199 -0
- basebandflow/api/errors.py +50 -0
- basebandflow/api/history.py +73 -0
- basebandflow/api/launch.py +154 -0
- basebandflow/api/settings.py +60 -0
- basebandflow/api/status.py +19 -0
- basebandflow/api/validate.py +23 -0
- basebandflow/api/workflow.py +126 -0
- basebandflow/cli/config.py +128 -0
- basebandflow/cli/containers.py +159 -0
- basebandflow/cli/documentation.py +47 -0
- basebandflow/cli/history.py +97 -0
- basebandflow/cli/launch.py +83 -0
- basebandflow/cli/login.py +27 -0
- basebandflow/cli/main.py +65 -0
- basebandflow/cli/tokens.py +41 -0
- basebandflow/cli/ui.py +196 -0
- basebandflow/cli/workflow.py +46 -0
- basebandflow/models.py +97 -0
- basebandflow/templates/pipeline_config.yaml.j2 +251 -0
- basebandflow/tools.py +31 -0
- basebandflow-0.0.1.dist-info/METADATA +15 -0
- basebandflow-0.0.1.dist-info/RECORD +31 -0
- basebandflow-0.0.1.dist-info/WHEEL +5 -0
- basebandflow-0.0.1.dist-info/entry_points.txt +2 -0
- basebandflow-0.0.1.dist-info/top_level.txt +1 -0
|
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
|