Skip to content

Commit 55e69e4

Browse files
committed
fix(cli)
- refactor typer app context as a pydantic model - ensure campaign name and id is always available in commands
1 parent 58e5d89 commit 55e69e4

File tree

7 files changed

+102
-50
lines changed

7 files changed

+102
-50
lines changed
Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,41 @@
1+
import re
12
from typing import Annotated, Literal
2-
from uuid import UUID, uuid5
3+
from uuid import uuid5
34

45
import typer
56

7+
from .models import TypedContext
68
from .settings import settings
79

810

9-
def preprocess_campaign_name(campaign: str) -> str:
10-
"""Preprocesses a campaign NAME by translating it to a campaign ID"""
11-
try:
12-
campaign_id = UUID(campaign)
13-
return str(campaign_id)
14-
except ValueError:
15-
return str(uuid5(settings.default_namespace, campaign))
11+
def as_snake_case(s: str) -> str:
12+
"""Preprocesses a string by sanitizing it and producing snake case."""
13+
# TODO clean up unicode characters and etc
14+
return re.sub(r"\W+?", "_", s)
1615

1716

18-
campaign_name = Annotated[str, typer.Argument()]
17+
def preprocess_campaign_name(ctx: TypedContext, campaign: str) -> str:
18+
"""Preprocesses a campaign NAME by translating it to a campaign ID and
19+
storing the result in the application context
20+
"""
21+
ctx.obj.campaign_name = campaign
22+
ctx.obj.campaign_id = str(uuid5(settings.default_namespace, campaign))
23+
return campaign
24+
25+
26+
campaign_name = Annotated[
27+
str,
28+
typer.Argument(
29+
envvar="CM_CAMPAIGN",
30+
callback=preprocess_campaign_name,
31+
help="A campaign name that is coerced into a UUID, or a UUID.",
32+
),
33+
]
1934

20-
campaign_id = Annotated[str, typer.Argument(envvar="CM_CAMPAIGN", callback=preprocess_campaign_name)]
2135

2236
campaign_status = Annotated[
2337
Literal["paused", "rejected", "accepted", "failed"], typer.Argument(help="Campaign status name")
2438
]
2539

26-
node_id = Annotated[str, typer.Argument()]
40+
41+
node_id = Annotated[str, typer.Argument(help="An id for a node, as a UUID value.")]

packages/cm-commandline/src/lsst/cmservice/commandline/campaigns/app.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,16 @@
1818

1919
from .. import arguments, formatters
2020
from ..client import http_client
21+
from ..models import TypedContext
2122

2223
app = typer.Typer()
2324
console = Console()
2425

2526

2627
@app.command()
27-
def list(ctx: typer.Context) -> None:
28+
def list(ctx: TypedContext) -> None:
2829
"""list all campaigns"""
29-
output_format = formatters.Formatters[ctx.obj.get("output_format")]
30+
output_format = formatters.Formatters[ctx.obj.output_format]
3031
with http_client(ctx) as session:
3132
try:
3233
r = session.get("/campaigns")
@@ -48,11 +49,11 @@ def list(ctx: typer.Context) -> None:
4849

4950
@app.command(name="new")
5051
def new_campaign(
51-
ctx: typer.Context,
52+
ctx: TypedContext,
5253
campaign: arguments.campaign_name,
5354
) -> None:
54-
"""create a new empty campaign"""
55-
output_format = formatters.Formatters[ctx.obj.get("output_format")]
55+
"""Create a new empty campaign"""
56+
output_format = formatters.Formatters[ctx.obj.output_format]
5657
data = {
5758
"apiVersion": "io.lsst.cmservice/v1",
5859
"kind": "campaign",
@@ -67,7 +68,7 @@ def new_campaign(
6768

6869
match output_format:
6970
case formatters.Formatters.table:
70-
t = formatters.as_table(r.json())
71+
t = formatters.as_table([r.json()])
7172
Console().print(t)
7273
case formatters.Formatters.json:
7374
pretty = JSONHighlighter()
@@ -78,12 +79,12 @@ def new_campaign(
7879

7980
@app.command(name="describe")
8081
def describe_campaign(
81-
ctx: typer.Context,
82-
campaign: arguments.campaign_id,
82+
ctx: TypedContext,
83+
campaign: arguments.campaign_name,
8384
) -> None:
8485
"""describe a specific campaign"""
8586
with http_client(ctx) as session:
86-
r = session.get(f"/campaigns/{campaign}/summary")
87+
r = session.get(f"/campaigns/{ctx.obj.campaign_id}/summary")
8788
r.raise_for_status()
8889
# edges_url = r.headers["Edges"]
8990
# nodes_url = r.headers["Nodes"]
@@ -145,14 +146,14 @@ def describe_campaign(
145146

146147

147148
@app.command(name="start")
148-
def start_campaign(ctx: typer.Context, campaign: arguments.campaign_id) -> None:
149+
def start_campaign(ctx: TypedContext, campaign: arguments.campaign_name) -> None:
149150
"""start a campaign"""
150151
data = {"status": "running"}
151152
status_update_url = None
152153

153154
with http_client(ctx) as session:
154155
r = session.patch(
155-
f"/campaigns/{campaign}",
156+
f"/campaigns/{ctx.obj.campaign_id}",
156157
json=data,
157158
headers={"Content-Type": "application/merge-patch+json"},
158159
)
@@ -195,8 +196,8 @@ def start_campaign(ctx: typer.Context, campaign: arguments.campaign_id) -> None:
195196

196197
@app.command(name="set")
197198
def set_campaign_status(
198-
ctx: typer.Context,
199-
campaign: arguments.campaign_id,
199+
ctx: TypedContext,
200+
campaign: arguments.campaign_name,
200201
desired_state: arguments.campaign_status,
201202
*,
202203
force: Annotated[
@@ -213,7 +214,7 @@ def set_campaign_status(
213214

214215
with http_client(ctx) as session:
215216
r = session.patch(
216-
f"/campaigns/{campaign}",
217+
f"/campaigns/{ctx.obj.campaign_id}",
217218
json=data,
218219
headers={"Content-Type": "application/merge-patch+json"},
219220
)
@@ -254,15 +255,15 @@ def set_campaign_status(
254255

255256

256257
@app.command(name="advance")
257-
def advance_campaign(ctx: typer.Context, campaign: arguments.campaign_id) -> None:
258+
def advance_campaign(ctx: TypedContext, campaign: arguments.campaign_name) -> None:
258259
"""advances a paused campaign"""
259260

260261
status_update_url = None
261262

262263
with http_client(ctx) as session:
263264
r = session.post(
264265
"/rpc/process",
265-
json={"campaign_id": campaign},
266+
json={"campaign_id": ctx.obj.campaign_id},
266267
headers={},
267268
)
268269
r.raise_for_status()

packages/cm-commandline/src/lsst/cmservice/commandline/cli.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .campaigns.app import app as campaigns_app
55
from .loader.app import app as loader_app
66
from .manifests.app import app as manifests_app
7+
from .models import AppContext, TypedContext
78
from .nodes.app import app as nodes_app
89
from .settings import settings
910

@@ -16,14 +17,12 @@
1617

1718
@app.callback()
1819
def build_context(
19-
ctx: typer.Context,
20+
ctx: TypedContext,
2021
output: options.output = "table",
2122
endpoint: options.endpoint = settings.endpoint,
2223
token: options.token = settings.token,
2324
) -> None:
24-
ctx.ensure_object(dict)
25-
ctx.obj["output_format"] = output
26-
ctx.obj["cm_endpoint_url"] = endpoint
25+
ctx.obj = AppContext(output_format=output, endpoint_url=endpoint, auth_token=token)
2726

2827

2928
if __name__ == "__main__":

packages/cm-commandline/src/lsst/cmservice/commandline/client.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,24 @@
44
from contextlib import contextmanager
55
from uuid import uuid4
66

7-
import typer
87
from httpx import Client, HTTPStatusError, HTTPTransport
98

109
from .logging import LOGGER
11-
from .settings import settings
10+
from .models import TypedContext
1211

1312
logger = LOGGER.bind(module=__name__)
1413

1514

1615
@contextmanager
17-
def http_client(ctx: typer.Context) -> Generator[Client]:
16+
def http_client(ctx: TypedContext) -> Generator[Client]:
1817
"""Generate a client session for cmclient API operations."""
1918
transport = HTTPTransport(
2019
verify=False,
2120
retries=3,
2221
)
23-
endpoint_url = ctx.obj.get("endpoint_url", settings.endpoint)
24-
api_version = ctx.obj.get("api_version", settings.api_version)
25-
auth_token = ctx.obj.get("auth_token", settings.token)
22+
endpoint_url = ctx.obj.endpoint_url
23+
api_version = ctx.obj.api_version
24+
auth_token = ctx.obj.auth_token
2625

2726
with Client(
2827
base_url=f"{endpoint_url}/{api_version}",

packages/cm-commandline/src/lsst/cmservice/commandline/loader/app.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,42 @@
55
from rich.console import Console
66
from rich.table import Table
77

8+
from .. import arguments
9+
from ..campaigns.app import start_campaign
10+
from ..models import TypedContext
811
from .loader import load_selected_file
912

1013
app = typer.Typer()
1114

1215

1316
@app.command(name="yaml")
1417
def load_from_yaml(
15-
ctx: typer.Context,
18+
ctx: TypedContext,
1619
filename: str,
17-
campaign_name: Annotated[str | None, typer.Argument(envvar="CM_CAMPAIGN")] = None,
20+
campaign: arguments.campaign_name,
21+
*,
22+
auto_run: Annotated[
23+
bool,
24+
typer.Option(
25+
"--start",
26+
help="Start campaign automatically after loading",
27+
),
28+
] = False,
1829
) -> None:
1930
"""Load manifests from a YAML file"""
20-
headers = load_selected_file(ctx, Path(filename), campaign_name)
31+
headers = load_selected_file(ctx, Path(filename), campaign)
2132

2233
if headers:
23-
table = Table(title=campaign_name)
34+
table = Table(title=ctx.obj.campaign_name)
2435
table.add_column("Link Name")
2536
table.add_column("Link")
2637

2738
for k, v in headers.items():
2839
table.add_row(k, v)
2940

3041
Console().print(table)
42+
43+
# If argument "auto-start" is set, try to set campaign status after loading
44+
if auto_run:
45+
typer.echo(f"Starting campaign {ctx.obj.campaign_name}")
46+
ctx.invoke(start_campaign, ctx=ctx, campaign=campaign)

packages/cm-commandline/src/lsst/cmservice/commandline/loader/loader.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
import typer
55
import yaml
66
from httpx import Headers, HTTPStatusError
7-
from rich import print
87

98
from ..client import http_client
9+
from ..models import TypedContext
1010

1111

1212
def yaml_to_json(yaml_docs: str) -> Any:
@@ -15,7 +15,7 @@ def yaml_to_json(yaml_docs: str) -> Any:
1515
yield from _yamls
1616

1717

18-
def load_selected_file(ctx: typer.Context, yaml_file: Path, campaign_name: str | None) -> Headers | None:
18+
def load_selected_file(ctx: TypedContext, yaml_file: Path, campaign: str | None) -> Headers | None:
1919
"""Load a collection of Manifests from a YAML file.
2020
2121
If `campaign_name` is None, the campaign name must be set in the YAML file.
@@ -31,26 +31,31 @@ def load_selected_file(ctx: typer.Context, yaml_file: Path, campaign_name: str |
3131
match yaml["kind"]:
3232
case "campaign":
3333
uri = "/campaigns"
34-
yaml["metadata"]["name"] = campaign_name or yaml["metadata"]["name"]
34+
yaml["metadata"]["name"] = yaml["metadata"]["name"] if campaign is None else campaign
3535
capture_headers = True
3636
case "node":
3737
uri = "/nodes"
38-
yaml["metadata"]["namespace"] = campaign_name or yaml["metadata"]["namespace"]
38+
yaml["metadata"]["namespace"] = (
39+
yaml["metadata"]["namespace"] if campaign is None else ctx.obj.campaign_id
40+
)
3941
case "edge":
4042
uri = "/edges"
41-
yaml["metadata"]["namespace"] = campaign_name or yaml["metadata"]["namespace"]
43+
yaml["metadata"]["namespace"] = (
44+
yaml["metadata"]["namespace"] if campaign is None else ctx.obj.campaign_id
45+
)
4246
case _:
4347
uri = "/manifests"
44-
yaml["metadata"]["namespace"] = campaign_name or yaml["metadata"]["namespace"]
48+
yaml["metadata"]["namespace"] = (
49+
yaml["metadata"]["namespace"] if campaign is None else ctx.obj.campaign_id
50+
)
4551

4652
try:
4753
r = session.post(uri, json=yaml)
4854
r.raise_for_status()
4955
if capture_headers:
5056
headers = r.headers
5157
except HTTPStatusError:
52-
print(f"Failed to create manifests: {r.text}")
53-
print(r.headers)
54-
break
58+
typer.echo(f"Failed to create manifests: {r.text}", err=True)
59+
raise typer.Exit(1)
5560

5661
return headers
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import typer
2+
from pydantic import BaseModel
3+
4+
from .settings import settings
5+
6+
7+
class AppContext(BaseModel):
8+
auth_token: str = settings.token
9+
campaign_name: str | None = None
10+
campaign_id: str | None = None
11+
endpoint_url: str = settings.endpoint
12+
output_format: str
13+
api_version: str = settings.api_version
14+
15+
16+
class TypedContext(typer.Context):
17+
obj: AppContext

0 commit comments

Comments
 (0)