ermes-cli 1.0.0__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.
- ermes_cli/__init__.py +1 -0
- ermes_cli/admin_api.py +285 -0
- ermes_cli/auth0.py +220 -0
- ermes_cli/client.py +100 -0
- ermes_cli/commands/__init__.py +0 -0
- ermes_cli/commands/auth_cmds.py +386 -0
- ermes_cli/commands/campaigns.py +321 -0
- ermes_cli/commands/config_cmd.py +105 -0
- ermes_cli/commands/data_paths.py +103 -0
- ermes_cli/commands/export.py +238 -0
- ermes_cli/commands/ingest.py +101 -0
- ermes_cli/commands/journeys.py +182 -0
- ermes_cli/commands/keys.py +113 -0
- ermes_cli/commands/lists.py +137 -0
- ermes_cli/commands/metrics.py +398 -0
- ermes_cli/commands/projects.py +286 -0
- ermes_cli/commands/providers.py +184 -0
- ermes_cli/commands/subscriptions.py +75 -0
- ermes_cli/commands/tags.py +86 -0
- ermes_cli/commands/templates.py +133 -0
- ermes_cli/commands/users.py +133 -0
- ermes_cli/commands/write_cmds.py +87 -0
- ermes_cli/config.py +393 -0
- ermes_cli/errors.py +34 -0
- ermes_cli/export_utils.py +263 -0
- ermes_cli/keyring_store.py +145 -0
- ermes_cli/main.py +193 -0
- ermes_cli/manifest.py +218 -0
- ermes_cli/mcp_server.py +167 -0
- ermes_cli/output.py +58 -0
- ermes_cli/routes.yaml +163 -0
- ermes_cli/rules.py +54 -0
- ermes_cli/write_mode.py +178 -0
- ermes_cli-1.0.0.dist-info/METADATA +281 -0
- ermes_cli-1.0.0.dist-info/RECORD +37 -0
- ermes_cli-1.0.0.dist-info/WHEEL +4 -0
- ermes_cli-1.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json as json_lib
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import typer
|
|
5
|
+
from ermes_cli.client import ErmesAPIError
|
|
6
|
+
from ermes_cli.rules import normalize_rule_values
|
|
7
|
+
|
|
8
|
+
lists_app = typer.Typer(help="Manage lists", no_args_is_help=True)
|
|
9
|
+
COLUMNS = ["id", "name", "type", "state", "users_count", "tags"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_deps():
|
|
13
|
+
from ermes_cli.main import get_client, state
|
|
14
|
+
from ermes_cli.output import render, render_detail, render_message
|
|
15
|
+
return get_client(), state, render, render_detail, render_message
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@lists_app.command("list")
|
|
19
|
+
def lists_list(
|
|
20
|
+
list_type: Optional[str] = typer.Option(None, "--type", help="Filter by type"),
|
|
21
|
+
limit: int = typer.Option(100, "--limit", help="Max results (0 = all)"),
|
|
22
|
+
):
|
|
23
|
+
"""List lists."""
|
|
24
|
+
client, st, render, _, _ = _get_deps()
|
|
25
|
+
params = {}
|
|
26
|
+
if list_type:
|
|
27
|
+
params["filter[type]"] = list_type
|
|
28
|
+
try:
|
|
29
|
+
data = client.get_paginated("/lists", params=params, limit=limit)
|
|
30
|
+
render(data, columns=COLUMNS, use_json=st.use_json)
|
|
31
|
+
except ErmesAPIError as e:
|
|
32
|
+
typer.echo(str(e), err=True)
|
|
33
|
+
raise typer.Exit(code=1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@lists_app.command("get")
|
|
37
|
+
def lists_get(list_id: int = typer.Argument(..., help="List ID")):
|
|
38
|
+
"""Get list details."""
|
|
39
|
+
client, st, _, render_detail, _ = _get_deps()
|
|
40
|
+
try:
|
|
41
|
+
data = client.get(f"/lists/{list_id}")
|
|
42
|
+
render_detail(data, use_json=st.use_json)
|
|
43
|
+
except ErmesAPIError as e:
|
|
44
|
+
typer.echo(str(e), err=True)
|
|
45
|
+
raise typer.Exit(code=1)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@lists_app.command("create")
|
|
49
|
+
def lists_create(
|
|
50
|
+
name: str = typer.Option(..., "--name", help="List name"),
|
|
51
|
+
list_type: str = typer.Option(..., "--type", help="static or dynamic"),
|
|
52
|
+
rule: Optional[str] = typer.Option(None, "--rule", help="Rule as JSON (for dynamic lists)"),
|
|
53
|
+
):
|
|
54
|
+
"""Create a list."""
|
|
55
|
+
client, st, _, render_detail, _ = _get_deps()
|
|
56
|
+
body: dict = {"name": name, "type": list_type}
|
|
57
|
+
if rule:
|
|
58
|
+
try:
|
|
59
|
+
body["rule"] = normalize_rule_values(json_lib.loads(rule))
|
|
60
|
+
except json_lib.JSONDecodeError as e:
|
|
61
|
+
typer.echo(f"Invalid JSON for --rule: {e}", err=True)
|
|
62
|
+
raise typer.Exit(code=1)
|
|
63
|
+
try:
|
|
64
|
+
data = client.post("/lists", json=body)
|
|
65
|
+
render_detail(data, use_json=st.use_json)
|
|
66
|
+
except ErmesAPIError as e:
|
|
67
|
+
typer.echo(str(e), err=True)
|
|
68
|
+
raise typer.Exit(code=1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@lists_app.command("update")
|
|
72
|
+
def lists_update(
|
|
73
|
+
list_id: int = typer.Argument(..., help="List ID"),
|
|
74
|
+
name: Optional[str] = typer.Option(None, "--name"),
|
|
75
|
+
):
|
|
76
|
+
"""Update a list."""
|
|
77
|
+
client, st, _, render_detail, _ = _get_deps()
|
|
78
|
+
body = {}
|
|
79
|
+
if name:
|
|
80
|
+
body["name"] = name
|
|
81
|
+
if not body:
|
|
82
|
+
typer.echo("Nothing to update. Provide at least one option.", err=True)
|
|
83
|
+
raise typer.Exit(code=1)
|
|
84
|
+
try:
|
|
85
|
+
data = client.patch(f"/lists/{list_id}", json=body)
|
|
86
|
+
render_detail(data, use_json=st.use_json)
|
|
87
|
+
except ErmesAPIError as e:
|
|
88
|
+
typer.echo(str(e), err=True)
|
|
89
|
+
raise typer.Exit(code=1)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@lists_app.command("delete")
|
|
93
|
+
def lists_delete(list_id: int = typer.Argument(..., help="List ID")):
|
|
94
|
+
"""Delete a list."""
|
|
95
|
+
client, st, _, _, render_message = _get_deps()
|
|
96
|
+
try:
|
|
97
|
+
client.delete(f"/lists/{list_id}")
|
|
98
|
+
render_message(f"List {list_id} deleted.", use_json=st.use_json)
|
|
99
|
+
except ErmesAPIError as e:
|
|
100
|
+
typer.echo(str(e), err=True)
|
|
101
|
+
raise typer.Exit(code=1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@lists_app.command("duplicate")
|
|
105
|
+
def lists_duplicate(list_id: int = typer.Argument(..., help="List ID")):
|
|
106
|
+
"""Duplicate a list."""
|
|
107
|
+
client, st, _, render_detail, _ = _get_deps()
|
|
108
|
+
try:
|
|
109
|
+
data = client.post(f"/lists/{list_id}/duplicate")
|
|
110
|
+
render_detail(data, use_json=st.use_json)
|
|
111
|
+
except ErmesAPIError as e:
|
|
112
|
+
typer.echo(str(e), err=True)
|
|
113
|
+
raise typer.Exit(code=1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@lists_app.command("users")
|
|
117
|
+
def lists_users(list_id: int = typer.Argument(..., help="List ID"), limit: int = typer.Option(100, "--limit")):
|
|
118
|
+
"""List users in a list."""
|
|
119
|
+
client, st, render, _, _ = _get_deps()
|
|
120
|
+
try:
|
|
121
|
+
data = client.get_paginated(f"/lists/{list_id}/users", limit=limit)
|
|
122
|
+
render(data, columns=["id", "email", "external_id"], use_json=st.use_json)
|
|
123
|
+
except ErmesAPIError as e:
|
|
124
|
+
typer.echo(str(e), err=True)
|
|
125
|
+
raise typer.Exit(code=1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@lists_app.command("recount")
|
|
129
|
+
def lists_recount(list_id: int = typer.Argument(..., help="List ID")):
|
|
130
|
+
"""Trigger a recount of list users."""
|
|
131
|
+
client, st, _, render_detail, _ = _get_deps()
|
|
132
|
+
try:
|
|
133
|
+
data = client.post(f"/lists/{list_id}/recount")
|
|
134
|
+
render_detail(data, use_json=st.use_json)
|
|
135
|
+
except ErmesAPIError as e:
|
|
136
|
+
typer.echo(str(e), err=True)
|
|
137
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""`ermes metrics` — parity with the UI Metrics view.
|
|
2
|
+
|
|
3
|
+
Hits `/api/client/metrics` (the same endpoint the UI uses through admin
|
|
4
|
+
scope; sk_ keys reach it via the secret-scope leg of the client router).
|
|
5
|
+
Returns the per-campaign breakdown plus an aggregate summary plus the
|
|
6
|
+
data freshness timestamp.
|
|
7
|
+
|
|
8
|
+
Default behavior matches the UI:
|
|
9
|
+
- type=trigger
|
|
10
|
+
- channel=email
|
|
11
|
+
- last 7 days, ending yesterday (pass --end to include today's partial row)
|
|
12
|
+
- sorted by sends desc
|
|
13
|
+
- max 90-day range, capped at 100 rows server-side
|
|
14
|
+
|
|
15
|
+
Channels: the endpoint filters on `campaigns.channel` and accepts exactly
|
|
16
|
+
`email` or `push` (lowercase — anything else is a 400). Omitting the param
|
|
17
|
+
means `email`, which is why a push-only project used to look empty. There is
|
|
18
|
+
no server-side "both channels" mode, so `--channel all` here issues the two
|
|
19
|
+
requests and prints two labelled blocks.
|
|
20
|
+
|
|
21
|
+
Push rows carry `opens`/`bounces`/`complaints` (and the rates derived from
|
|
22
|
+
them) as hard zeros — push has no open or bounce event — so the push table
|
|
23
|
+
renders the UI's Sent / Delivered / Failed / Clicks columns instead:
|
|
24
|
+
- Delivered = `sends`
|
|
25
|
+
- Sent = `sends + failed`
|
|
26
|
+
- Clicks = `clicks` (a push must be tapped, so opens would equal clicks)
|
|
27
|
+
|
|
28
|
+
`aggregated_through` is the newest local day the hourly rollup has marked
|
|
29
|
+
complete (re-read after its own midnight) that has data for the requested
|
|
30
|
+
channel+type. Today's row — and yesterday's, until the first run after local
|
|
31
|
+
midnight — is a partial "so far" number: returned, but it never moves
|
|
32
|
+
`aggregated_through`. So a window ending today includes partial data, and one
|
|
33
|
+
past today is empty. Dates are passed through verbatim; the empty-state message
|
|
34
|
+
just surfaces the frontier so an empty result is legible.
|
|
35
|
+
|
|
36
|
+
JSON output: `--json` is a **root** flag (`ermes --json metrics …`), NOT an
|
|
37
|
+
option on this command — `ermes metrics --json` exits with `No such option`.
|
|
38
|
+
Per-campaign rows live under **`results`**, not `campaigns`/`rows`, next to
|
|
39
|
+
`channel`, `summary`, `truncated` and `aggregated_through`. `--channel all`
|
|
40
|
+
nests instead: `{"channel": "all", "channels": {"email": {…}, "push": {…}}}`,
|
|
41
|
+
so a consumer has to branch on `channel` before reaching for `results`.
|
|
42
|
+
|
|
43
|
+
Which PROJECT this queries is resolved by `config.resolve_profile()`, which
|
|
44
|
+
honours `ERMES_PROJECT` — so `ERMES_PROJECT=224 ermes metrics …` reads project
|
|
45
|
+
224 without disturbing the on-disk active project (useful when another session
|
|
46
|
+
is working in a different one). `whoami` reports that same effective project
|
|
47
|
+
via `config.get_effective_project()`.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
from datetime import date, timedelta
|
|
53
|
+
from typing import Optional
|
|
54
|
+
|
|
55
|
+
import typer
|
|
56
|
+
from rich.console import Console
|
|
57
|
+
from rich.table import Table
|
|
58
|
+
|
|
59
|
+
from ermes_cli.client import ErmesAPIError
|
|
60
|
+
|
|
61
|
+
metrics_app = typer.Typer(
|
|
62
|
+
help="Campaign metrics (mirrors the UI Metrics view)",
|
|
63
|
+
no_args_is_help=False,
|
|
64
|
+
invoke_without_command=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
console = Console()
|
|
68
|
+
|
|
69
|
+
_VALID_TYPES = {"trigger", "blast", "all"}
|
|
70
|
+
|
|
71
|
+
# The server accepts only these two (CampaignMetricsController); "all" is a
|
|
72
|
+
# CLI-side convenience that fans out into one request per channel.
|
|
73
|
+
_SERVER_CHANNELS = ("email", "push")
|
|
74
|
+
_VALID_CHANNELS = {*_SERVER_CHANNELS, "all"}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _format_int(value: int | float | None) -> str:
|
|
78
|
+
if value is None:
|
|
79
|
+
return "—"
|
|
80
|
+
try:
|
|
81
|
+
return f"{int(value):,}"
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
return str(value)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _format_pct(value: int | float | None) -> str:
|
|
87
|
+
if value is None:
|
|
88
|
+
return "—"
|
|
89
|
+
try:
|
|
90
|
+
# API returns rates as percentages already (e.g. 23.4 means 23.4%).
|
|
91
|
+
return f"{float(value):.2f}%"
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
return str(value)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _sent(row: dict) -> int | None:
|
|
97
|
+
"""Push "Sent" = delivered + failed (the UI's naming, DEV-1099).
|
|
98
|
+
|
|
99
|
+
`sends` alone is Delivered; a campaign that failed every push still has
|
|
100
|
+
real activity, so the attempted total is the honest headline number.
|
|
101
|
+
"""
|
|
102
|
+
sends, failed = row.get("sends"), row.get("failed")
|
|
103
|
+
if sends is None and failed is None:
|
|
104
|
+
return None
|
|
105
|
+
return int(sends or 0) + int(failed or 0)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _email_table(results: list, summary: dict, title: str) -> Table:
|
|
109
|
+
table = Table(title=title)
|
|
110
|
+
table.add_column("ID", justify="right")
|
|
111
|
+
table.add_column("Campaign", overflow="fold")
|
|
112
|
+
table.add_column("Sends", justify="right")
|
|
113
|
+
table.add_column("Opens", justify="right")
|
|
114
|
+
table.add_column("Clicks", justify="right")
|
|
115
|
+
table.add_column("Bounces", justify="right")
|
|
116
|
+
table.add_column("Spam", justify="right")
|
|
117
|
+
table.add_column("Open %", justify="right")
|
|
118
|
+
table.add_column("Click %", justify="right")
|
|
119
|
+
table.add_column("CTOR %", justify="right")
|
|
120
|
+
|
|
121
|
+
for row in results:
|
|
122
|
+
table.add_row(
|
|
123
|
+
str(row.get("campaign_id", "")),
|
|
124
|
+
str(row.get("name") or ""),
|
|
125
|
+
_format_int(row.get("sends")),
|
|
126
|
+
_format_int(row.get("opens")),
|
|
127
|
+
_format_int(row.get("clicks")),
|
|
128
|
+
_format_int(row.get("bounces")),
|
|
129
|
+
_format_int(row.get("complaints")),
|
|
130
|
+
_format_pct(row.get("open_rate")),
|
|
131
|
+
_format_pct(row.get("click_rate")),
|
|
132
|
+
_format_pct(row.get("ctor")),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if results:
|
|
136
|
+
table.add_section()
|
|
137
|
+
table.add_row(
|
|
138
|
+
"",
|
|
139
|
+
f"[bold]Summary ({summary.get('campaign_count', 0)} campaigns)[/bold]",
|
|
140
|
+
_format_int(summary.get("sends")),
|
|
141
|
+
_format_int(summary.get("opens")),
|
|
142
|
+
_format_int(summary.get("clicks")),
|
|
143
|
+
_format_int(summary.get("bounces")),
|
|
144
|
+
_format_int(summary.get("complaints")),
|
|
145
|
+
_format_pct(summary.get("open_rate")),
|
|
146
|
+
_format_pct(summary.get("click_rate")),
|
|
147
|
+
_format_pct(summary.get("ctor")),
|
|
148
|
+
)
|
|
149
|
+
return table
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _push_table(results: list, summary: dict, title: str) -> Table:
|
|
153
|
+
"""Push-only columns.
|
|
154
|
+
|
|
155
|
+
Opens / Open % / CTOR / Bounces / Spam are structurally zero for push
|
|
156
|
+
(there is no open or bounce event) — printing them reads as breakage, so
|
|
157
|
+
they are omitted entirely rather than shown as 0.
|
|
158
|
+
"""
|
|
159
|
+
table = Table(title=title)
|
|
160
|
+
table.add_column("ID", justify="right")
|
|
161
|
+
table.add_column("Campaign", overflow="fold")
|
|
162
|
+
table.add_column("Sent", justify="right")
|
|
163
|
+
table.add_column("Delivered", justify="right")
|
|
164
|
+
table.add_column("Failed", justify="right")
|
|
165
|
+
table.add_column("Clicks", justify="right")
|
|
166
|
+
table.add_column("Click %", justify="right")
|
|
167
|
+
|
|
168
|
+
for row in results:
|
|
169
|
+
table.add_row(
|
|
170
|
+
str(row.get("campaign_id", "")),
|
|
171
|
+
str(row.get("name") or ""),
|
|
172
|
+
_format_int(_sent(row)),
|
|
173
|
+
_format_int(row.get("sends")),
|
|
174
|
+
_format_int(row.get("failed")),
|
|
175
|
+
_format_int(row.get("clicks")),
|
|
176
|
+
_format_pct(row.get("click_rate")),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if results:
|
|
180
|
+
table.add_section()
|
|
181
|
+
table.add_row(
|
|
182
|
+
"",
|
|
183
|
+
f"[bold]Summary ({summary.get('campaign_count', 0)} campaigns)[/bold]",
|
|
184
|
+
_format_int(_sent(summary)),
|
|
185
|
+
_format_int(summary.get("sends")),
|
|
186
|
+
_format_int(summary.get("failed")),
|
|
187
|
+
_format_int(summary.get("clicks")),
|
|
188
|
+
_format_pct(summary.get("click_rate")),
|
|
189
|
+
)
|
|
190
|
+
return table
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _empty_message(channel: str, kind: str, start: str, freshness: str | None) -> str:
|
|
194
|
+
"""Explain an empty window without asserting a cause we can't know.
|
|
195
|
+
|
|
196
|
+
The old copy claimed "no email campaigns sent", which is wrong whenever a
|
|
197
|
+
channel filter is in play, or when the requested window simply sits past
|
|
198
|
+
the rollup frontier. Days after `aggregated_through` are partial (or not
|
|
199
|
+
rolled up yet), so a window that *starts* after it can be empty for that
|
|
200
|
+
reason alone.
|
|
201
|
+
"""
|
|
202
|
+
scope = f"channel={channel}, type={kind}"
|
|
203
|
+
# The complaint this flag exists for: a push-only project reads as "no
|
|
204
|
+
# data at all" under the (default) email filter.
|
|
205
|
+
hint = (
|
|
206
|
+
" If this project sends push, retry with --channel push."
|
|
207
|
+
if channel == "email"
|
|
208
|
+
else ""
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
if freshness is None:
|
|
212
|
+
return (
|
|
213
|
+
f"[yellow]No {channel} metrics in this window.[/yellow] "
|
|
214
|
+
f"No completed day is recorded for {scope} in this project "
|
|
215
|
+
f"(aggregated_through is null).{hint}"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
base = (
|
|
219
|
+
f"[yellow]No {channel} metrics in this window[/yellow] "
|
|
220
|
+
f"(data aggregated through {freshness}; {scope})."
|
|
221
|
+
)
|
|
222
|
+
if start > freshness:
|
|
223
|
+
return (
|
|
224
|
+
f"{base} The whole window starts after {freshness}: those days "
|
|
225
|
+
"are partial (refreshed hourly) and may have no sends yet."
|
|
226
|
+
)
|
|
227
|
+
return base + hint
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _render_table(data: dict, kind: str, start: str, end: str, channel: str) -> None:
|
|
231
|
+
results = data.get("results") or []
|
|
232
|
+
summary = data.get("summary") or {}
|
|
233
|
+
freshness = data.get("aggregated_through")
|
|
234
|
+
truncated = bool(data.get("truncated"))
|
|
235
|
+
|
|
236
|
+
# Title wording for email is deliberately unchanged from the pre-channel
|
|
237
|
+
# CLI so the default view stays byte-identical for existing scripts.
|
|
238
|
+
title = (
|
|
239
|
+
f"{channel.capitalize()} metrics — type={kind}, {start} → {end}"
|
|
240
|
+
f" (data through {freshness or 'n/a'})"
|
|
241
|
+
)
|
|
242
|
+
build = _push_table if channel == "push" else _email_table
|
|
243
|
+
console.print(build(results, summary, title))
|
|
244
|
+
|
|
245
|
+
if not results:
|
|
246
|
+
console.print(_empty_message(channel, kind, start, freshness))
|
|
247
|
+
if truncated:
|
|
248
|
+
console.print(
|
|
249
|
+
"[yellow]Note:[/yellow] result set was truncated server-side "
|
|
250
|
+
"(>100 campaigns). Narrow the date range or filter by type."
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@metrics_app.callback()
|
|
255
|
+
def metrics(
|
|
256
|
+
ctx: typer.Context,
|
|
257
|
+
type_: str = typer.Option(
|
|
258
|
+
"trigger",
|
|
259
|
+
"--type",
|
|
260
|
+
"-t",
|
|
261
|
+
help="Campaign type: trigger | blast | all.",
|
|
262
|
+
show_default=True,
|
|
263
|
+
),
|
|
264
|
+
start: Optional[str] = typer.Option(
|
|
265
|
+
None,
|
|
266
|
+
"--start",
|
|
267
|
+
"-s",
|
|
268
|
+
help="Start date YYYY-MM-DD (default: 7 days before --end, or 7 days ago).",
|
|
269
|
+
),
|
|
270
|
+
end: Optional[str] = typer.Option(
|
|
271
|
+
None,
|
|
272
|
+
"--end",
|
|
273
|
+
"-e",
|
|
274
|
+
help="End date YYYY-MM-DD (default: yesterday; today is partial).",
|
|
275
|
+
),
|
|
276
|
+
days: Optional[int] = typer.Option(
|
|
277
|
+
None,
|
|
278
|
+
"--days",
|
|
279
|
+
"-d",
|
|
280
|
+
help="Convenience: the N days before today (ending yesterday). Overrides --start/--end.",
|
|
281
|
+
),
|
|
282
|
+
channel: str = typer.Option(
|
|
283
|
+
"email",
|
|
284
|
+
"--channel",
|
|
285
|
+
"-c",
|
|
286
|
+
help=(
|
|
287
|
+
"Channel: email | push | all. 'all' runs one request per channel "
|
|
288
|
+
"and prints two blocks (the API has no combined mode)."
|
|
289
|
+
),
|
|
290
|
+
show_default=True,
|
|
291
|
+
),
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Show campaign metrics for the active project.
|
|
294
|
+
|
|
295
|
+
With no arguments: last 7 days, type=trigger, channel=email — matches the
|
|
296
|
+
UI's default view.
|
|
297
|
+
"""
|
|
298
|
+
# If a subcommand was invoked (none defined today, but future-proof), skip
|
|
299
|
+
# the top-level fetch.
|
|
300
|
+
if ctx.invoked_subcommand is not None:
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
if type_ not in _VALID_TYPES:
|
|
304
|
+
typer.echo(
|
|
305
|
+
f"--type must be one of {sorted(_VALID_TYPES)}, got {type_!r}",
|
|
306
|
+
err=True,
|
|
307
|
+
)
|
|
308
|
+
raise typer.Exit(code=2)
|
|
309
|
+
|
|
310
|
+
# The API rejects a non-lowercase channel with a 400, so fold the case here
|
|
311
|
+
# rather than making the user guess.
|
|
312
|
+
channel = (channel or "").strip().lower()
|
|
313
|
+
if channel not in _VALID_CHANNELS:
|
|
314
|
+
typer.echo(
|
|
315
|
+
f"--channel must be one of {sorted(_VALID_CHANNELS)}, got {channel!r}",
|
|
316
|
+
err=True,
|
|
317
|
+
)
|
|
318
|
+
raise typer.Exit(code=2)
|
|
319
|
+
|
|
320
|
+
# Default windows end yesterday, like the UI: today's row is a partial
|
|
321
|
+
# "so far" number (refreshed hourly) that would dilute the rates.
|
|
322
|
+
today = date.today()
|
|
323
|
+
yesterday = (today - timedelta(days=1)).isoformat()
|
|
324
|
+
if days is not None:
|
|
325
|
+
if days <= 0:
|
|
326
|
+
typer.echo("--days must be a positive integer", err=True)
|
|
327
|
+
raise typer.Exit(code=2)
|
|
328
|
+
start = (today - timedelta(days=days)).isoformat()
|
|
329
|
+
end = yesterday
|
|
330
|
+
elif end is None:
|
|
331
|
+
start = start or (today - timedelta(days=7)).isoformat()
|
|
332
|
+
# `--start <today>` alone still asks for today's partial row.
|
|
333
|
+
end = max(start, yesterday)
|
|
334
|
+
elif start is None:
|
|
335
|
+
start = (date.fromisoformat(end) - timedelta(days=7)).isoformat()
|
|
336
|
+
|
|
337
|
+
from ermes_cli.main import get_client, state
|
|
338
|
+
|
|
339
|
+
wanted = _SERVER_CHANNELS if channel == "all" else (channel,)
|
|
340
|
+
client = get_client()
|
|
341
|
+
fetched: dict[str, dict] = {}
|
|
342
|
+
errors: dict[str, str] = {}
|
|
343
|
+
for chan in wanted:
|
|
344
|
+
try:
|
|
345
|
+
resp = client.get(
|
|
346
|
+
"/metrics",
|
|
347
|
+
params={"type": type_, "start": start, "end": end, "channel": chan},
|
|
348
|
+
)
|
|
349
|
+
fetched[chan] = resp or {}
|
|
350
|
+
except ErmesAPIError as exc:
|
|
351
|
+
errors[chan] = str(exc)
|
|
352
|
+
# Under --channel all the channels are independent requests: one failing
|
|
353
|
+
# is no reason to throw away the other's data, which the user has already
|
|
354
|
+
# paid for. Name the channel only when fanning out, so the single-channel
|
|
355
|
+
# message stays exactly what it has always been.
|
|
356
|
+
label = (
|
|
357
|
+
f"Metrics fetch failed for channel={chan}"
|
|
358
|
+
if len(wanted) > 1
|
|
359
|
+
else "Metrics fetch failed"
|
|
360
|
+
)
|
|
361
|
+
typer.echo(f"{label}: {exc}", err=True)
|
|
362
|
+
|
|
363
|
+
# Nothing at all came back — same outcome as before for the single-channel case.
|
|
364
|
+
if not fetched:
|
|
365
|
+
raise typer.Exit(code=1)
|
|
366
|
+
|
|
367
|
+
if state.use_json:
|
|
368
|
+
import json
|
|
369
|
+
|
|
370
|
+
if channel == "all":
|
|
371
|
+
# Two independent payloads — nesting them keeps each one's summary
|
|
372
|
+
# and aggregated_through unambiguous instead of silently merging
|
|
373
|
+
# incomparable channels.
|
|
374
|
+
payload: dict = {"channel": "all", "channels": fetched}
|
|
375
|
+
else:
|
|
376
|
+
# Response keys first: `channel` is the CLI's own annotation and must win
|
|
377
|
+
# over any same-named key the server might grow later.
|
|
378
|
+
payload = {**fetched[channel], "channel": channel}
|
|
379
|
+
if errors:
|
|
380
|
+
payload["errors"] = errors
|
|
381
|
+
typer.echo(json.dumps(payload, indent=2, default=str))
|
|
382
|
+
if errors:
|
|
383
|
+
raise typer.Exit(code=1)
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
if channel == "all":
|
|
387
|
+
console.print(
|
|
388
|
+
"[dim]--channel all is client-side: one request per channel, two "
|
|
389
|
+
"blocks. The API has no combined mode, and the two channels' "
|
|
390
|
+
"metrics are not comparable (push has no opens/bounces).[/dim]"
|
|
391
|
+
)
|
|
392
|
+
for chan in wanted:
|
|
393
|
+
if chan in fetched:
|
|
394
|
+
_render_table(fetched[chan], type_, start, end, chan)
|
|
395
|
+
|
|
396
|
+
# Partial success is still a failure for a script: render what we have, exit non-zero.
|
|
397
|
+
if errors:
|
|
398
|
+
raise typer.Exit(code=1)
|