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.
@@ -0,0 +1,321 @@
1
+ from __future__ import annotations
2
+ import json as json_lib
3
+ from datetime import datetime, timezone
4
+ from typing import Optional
5
+ import typer
6
+ from ermes_cli.client import ErmesAPIError
7
+
8
+ campaigns_app = typer.Typer(help="Manage campaigns", no_args_is_help=True)
9
+ COLUMNS = ["id", "name", "type", "channel", "state", "send_at", "tags"]
10
+
11
+
12
+ def _parse_send_at(value: str) -> str:
13
+ """Validate an ISO 8601 timestamp and normalize it to UTC.
14
+
15
+ Rejects tz-naive input: a naive timestamp interpreted in the wrong
16
+ zone mis-fires a blast, so the operator must state the offset.
17
+ """
18
+ try:
19
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
20
+ except ValueError:
21
+ typer.echo(
22
+ f"Invalid --send-at '{value}': expected ISO 8601, e.g. 2026-07-02T14:00:00-03:00",
23
+ err=True,
24
+ )
25
+ raise typer.Exit(code=1)
26
+ if parsed.tzinfo is None:
27
+ typer.echo(
28
+ f"--send-at '{value}' must include a UTC offset, e.g. 2026-07-02T14:00:00-03:00 or 2026-07-02T17:00:00Z",
29
+ err=True,
30
+ )
31
+ raise typer.Exit(code=1)
32
+ return parsed.astimezone(timezone.utc).isoformat()
33
+
34
+
35
+ def _parse_id_list(value: str, option: str) -> list[int]:
36
+ try:
37
+ ids = [int(part) for part in value.split(",") if part.strip()]
38
+ except ValueError:
39
+ typer.echo(
40
+ f"Invalid {option} '{value}': expected comma-separated integers, e.g. 3,7",
41
+ err=True,
42
+ )
43
+ raise typer.Exit(code=1)
44
+ if not ids:
45
+ typer.echo(
46
+ f"Invalid {option} '{value}': expected at least one integer ID", err=True
47
+ )
48
+ raise typer.Exit(code=1)
49
+ return ids
50
+
51
+
52
+ def _get_deps():
53
+ from ermes_cli.main import get_client, state
54
+ from ermes_cli.output import render, render_detail, render_message
55
+
56
+ return get_client(), state, render, render_detail, render_message
57
+
58
+
59
+ @campaigns_app.command("list")
60
+ def campaigns_list(
61
+ status: Optional[str] = typer.Option(None, "--status", help="Filter by state"),
62
+ campaign_type: Optional[str] = typer.Option(None, "--type", help="Filter by type"),
63
+ limit: int = typer.Option(100, "--limit", help="Max results (0 = all)"),
64
+ ):
65
+ """List campaigns."""
66
+ client, st, render, _, _ = _get_deps()
67
+ params = {}
68
+ filter_obj = {}
69
+ if status:
70
+ filter_obj["state"] = status
71
+ if campaign_type:
72
+ filter_obj["type"] = campaign_type
73
+ if filter_obj:
74
+ params["filter"] = json_lib.dumps(filter_obj)
75
+ try:
76
+ data = client.get_paginated("/campaigns", params=params, limit=limit)
77
+ render(data, columns=COLUMNS, use_json=st.use_json)
78
+ except ErmesAPIError as e:
79
+ typer.echo(str(e), err=True)
80
+ raise typer.Exit(code=1)
81
+
82
+
83
+ @campaigns_app.command("get")
84
+ def campaigns_get(campaign_id: int = typer.Argument(..., help="Campaign ID")):
85
+ """Get campaign details."""
86
+ client, st, _, render_detail, _ = _get_deps()
87
+ try:
88
+ data = client.get(f"/campaigns/{campaign_id}")
89
+ render_detail(data, use_json=st.use_json)
90
+ except ErmesAPIError as e:
91
+ typer.echo(str(e), err=True)
92
+ raise typer.Exit(code=1)
93
+
94
+
95
+ @campaigns_app.command("create")
96
+ def campaigns_create(
97
+ campaign_type: str = typer.Option(..., "--type", help="blast or trigger"),
98
+ subscription: int = typer.Option(..., "--subscription", help="Subscription ID"),
99
+ provider: int = typer.Option(..., "--provider", help="Provider ID"),
100
+ name: Optional[str] = typer.Option(None, "--name", help="Campaign name"),
101
+ channel: Optional[str] = typer.Option(
102
+ None, "--channel", help="email|text|push|webhook"
103
+ ),
104
+ list_ids: Optional[str] = typer.Option(
105
+ None, "--list-ids", help="Comma-separated list IDs, e.g. 3,7"
106
+ ),
107
+ exclusion_list_ids: Optional[str] = typer.Option(
108
+ None, "--exclusion-list-ids", help="Comma-separated list IDs to exclude"
109
+ ),
110
+ send_at: Optional[str] = typer.Option(
111
+ None,
112
+ "--send-at",
113
+ help="ISO 8601 with UTC offset, e.g. 2026-07-02T14:00:00-03:00",
114
+ ),
115
+ ):
116
+ """Create a campaign."""
117
+ client, st, _, render_detail, _ = _get_deps()
118
+ body: dict = {
119
+ "type": campaign_type,
120
+ "subscription_id": subscription,
121
+ "provider_id": provider,
122
+ }
123
+ if name:
124
+ body["name"] = name
125
+ if channel:
126
+ body["channel"] = channel
127
+ if list_ids:
128
+ body["list_ids"] = _parse_id_list(list_ids, "--list-ids")
129
+ if exclusion_list_ids:
130
+ body["exclusion_list_ids"] = _parse_id_list(
131
+ exclusion_list_ids, "--exclusion-list-ids"
132
+ )
133
+ if send_at:
134
+ body["send_at"] = _parse_send_at(send_at)
135
+ try:
136
+ data = client.post("/campaigns", json=body)
137
+ render_detail(data, use_json=st.use_json)
138
+ except ErmesAPIError as e:
139
+ typer.echo(str(e), err=True)
140
+ raise typer.Exit(code=1)
141
+
142
+
143
+ @campaigns_app.command("update")
144
+ def campaigns_update(
145
+ campaign_id: int = typer.Argument(..., help="Campaign ID"),
146
+ name: Optional[str] = typer.Option(None, "--name"),
147
+ status: Optional[str] = typer.Option(None, "--status"),
148
+ send_at: Optional[str] = typer.Option(
149
+ None,
150
+ "--send-at",
151
+ help="ISO 8601 with UTC offset, e.g. 2026-07-02T14:00:00-03:00",
152
+ ),
153
+ list_ids: Optional[str] = typer.Option(
154
+ None, "--list-ids", help="Comma-separated list IDs, e.g. 3,7"
155
+ ),
156
+ exclusion_list_ids: Optional[str] = typer.Option(
157
+ None, "--exclusion-list-ids", help="Comma-separated list IDs to exclude"
158
+ ),
159
+ ):
160
+ """Update a campaign."""
161
+ client, st, _, render_detail, _ = _get_deps()
162
+ body: dict = {}
163
+ if name:
164
+ body["name"] = name
165
+ if status:
166
+ body["state"] = status
167
+ if send_at:
168
+ body["send_at"] = _parse_send_at(send_at)
169
+ if list_ids:
170
+ body["list_ids"] = _parse_id_list(list_ids, "--list-ids")
171
+ if exclusion_list_ids:
172
+ body["exclusion_list_ids"] = _parse_id_list(
173
+ exclusion_list_ids, "--exclusion-list-ids"
174
+ )
175
+ if not body:
176
+ typer.echo("Nothing to update. Provide at least one option.", err=True)
177
+ raise typer.Exit(code=1)
178
+ try:
179
+ data = client.patch(f"/campaigns/{campaign_id}", json=body)
180
+ render_detail(data, use_json=st.use_json)
181
+ except ErmesAPIError as e:
182
+ typer.echo(str(e), err=True)
183
+ raise typer.Exit(code=1)
184
+
185
+
186
+ @campaigns_app.command("schedule")
187
+ def campaigns_schedule(
188
+ campaign_id: int = typer.Argument(..., help="Campaign ID"),
189
+ send_at: str = typer.Option(
190
+ ...,
191
+ "--send-at",
192
+ help="ISO 8601 with UTC offset, e.g. 2026-07-02T14:00:00-03:00",
193
+ ),
194
+ list_ids: Optional[str] = typer.Option(
195
+ None, "--list-ids", help="Comma-separated list IDs, e.g. 3,7"
196
+ ),
197
+ exclusion_list_ids: Optional[str] = typer.Option(
198
+ None, "--exclusion-list-ids", help="Comma-separated list IDs to exclude"
199
+ ),
200
+ ):
201
+ """Schedule a campaign: set send_at and move it to state=scheduled."""
202
+ client, st, _, render_detail, _ = _get_deps()
203
+ body: dict = {"send_at": _parse_send_at(send_at), "state": "scheduled"}
204
+ if list_ids:
205
+ body["list_ids"] = _parse_id_list(list_ids, "--list-ids")
206
+ if exclusion_list_ids:
207
+ body["exclusion_list_ids"] = _parse_id_list(
208
+ exclusion_list_ids, "--exclusion-list-ids"
209
+ )
210
+ try:
211
+ data = client.patch(f"/campaigns/{campaign_id}", json=body)
212
+ render_detail(data, use_json=st.use_json)
213
+ except ErmesAPIError as e:
214
+ typer.echo(str(e), err=True)
215
+ raise typer.Exit(code=1)
216
+
217
+
218
+ @campaigns_app.command("delete")
219
+ def campaigns_delete(campaign_id: int = typer.Argument(..., help="Campaign ID")):
220
+ """Delete (archive) a campaign."""
221
+ client, st, _, _, render_message = _get_deps()
222
+ try:
223
+ client.delete(f"/campaigns/{campaign_id}")
224
+ render_message(f"Campaign {campaign_id} deleted.", use_json=st.use_json)
225
+ except ErmesAPIError as e:
226
+ typer.echo(str(e), err=True)
227
+ raise typer.Exit(code=1)
228
+
229
+
230
+ @campaigns_app.command("duplicate")
231
+ def campaigns_duplicate(campaign_id: int = typer.Argument(..., help="Campaign ID")):
232
+ """Duplicate a campaign."""
233
+ client, st, _, render_detail, _ = _get_deps()
234
+ try:
235
+ data = client.post(f"/campaigns/{campaign_id}/duplicate")
236
+ render_detail(data, use_json=st.use_json)
237
+ except ErmesAPIError as e:
238
+ typer.echo(str(e), err=True)
239
+ raise typer.Exit(code=1)
240
+
241
+
242
+ @campaigns_app.command("restore")
243
+ def campaigns_restore(campaign_id: int = typer.Argument(..., help="Campaign ID")):
244
+ """Restore a deleted campaign."""
245
+ client, st, _, _, render_message = _get_deps()
246
+ try:
247
+ client.post(f"/campaigns/{campaign_id}/restore")
248
+ render_message(f"Campaign {campaign_id} restored.", use_json=st.use_json)
249
+ except ErmesAPIError as e:
250
+ typer.echo(str(e), err=True)
251
+ raise typer.Exit(code=1)
252
+
253
+
254
+ @campaigns_app.command("preview")
255
+ def campaigns_preview(campaign_id: int = typer.Argument(..., help="Campaign ID")):
256
+ """Preview campaign content."""
257
+ client, st, _, render_detail, _ = _get_deps()
258
+ try:
259
+ data = client.get(f"/campaigns/{campaign_id}/preview")
260
+ render_detail(data, use_json=st.use_json)
261
+ except ErmesAPIError as e:
262
+ typer.echo(str(e), err=True)
263
+ raise typer.Exit(code=1)
264
+
265
+
266
+ @campaigns_app.command("users")
267
+ def campaigns_users(
268
+ campaign_id: int = typer.Argument(..., help="Campaign ID"),
269
+ limit: int = typer.Option(100, "--limit"),
270
+ ):
271
+ """List users targeted by a campaign."""
272
+ client, st, render, _, _ = _get_deps()
273
+ try:
274
+ data = client.get_paginated(f"/campaigns/{campaign_id}/users", limit=limit)
275
+ render(data, columns=["id", "email", "external_id"], use_json=st.use_json)
276
+ except ErmesAPIError as e:
277
+ typer.echo(str(e), err=True)
278
+ raise typer.Exit(code=1)
279
+
280
+
281
+ @campaigns_app.command("trigger")
282
+ def campaigns_trigger(
283
+ campaign_id: int = typer.Argument(..., help="Campaign ID"),
284
+ external_id: str = typer.Option(..., "--external-id", help="User external ID"),
285
+ event: str = typer.Option(..., "--event", help="Event data as JSON"),
286
+ email: Optional[str] = typer.Option(None, "--email", help="User email"),
287
+ phone: Optional[str] = typer.Option(None, "--phone", help="User phone"),
288
+ device_token: Optional[str] = typer.Option(
289
+ None, "--device-token", help="Push device token to register before the send"
290
+ ),
291
+ locale: Optional[str] = typer.Option(None, "--locale", help="User locale, e.g. pt-BR"),
292
+ tz: Optional[str] = typer.Option(
293
+ None, "--timezone", help="User timezone, e.g. America/Sao_Paulo"
294
+ ),
295
+ ):
296
+ """Trigger a campaign send for a user, identified by external ID."""
297
+ client, st, _, render_detail, _ = _get_deps()
298
+ try:
299
+ event_data = json_lib.loads(event)
300
+ except json_lib.JSONDecodeError as e:
301
+ typer.echo(f"Invalid JSON for --event: {e}", err=True)
302
+ raise typer.Exit(code=1)
303
+ user = {"external_id": external_id}
304
+ for key, value in (
305
+ ("email", email),
306
+ ("phone", phone),
307
+ ("device_token", device_token),
308
+ ("locale", locale),
309
+ ("timezone", tz),
310
+ ):
311
+ if value is not None:
312
+ user[key] = value
313
+ try:
314
+ data = client.post(
315
+ f"/campaigns/{campaign_id}/trigger",
316
+ json={"user": user, "event": event_data},
317
+ )
318
+ render_detail(data, use_json=st.use_json)
319
+ except ErmesAPIError as e:
320
+ typer.echo(str(e), err=True)
321
+ raise typer.Exit(code=1)
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+ from typing import Optional
3
+ import typer
4
+ from ermes_cli.config import (
5
+ add_profile,
6
+ remove_profile,
7
+ set_default_profile,
8
+ list_profiles,
9
+ ConfigError,
10
+ DEFAULT_BASE_URL,
11
+ )
12
+ from ermes_cli.output import render, render_message
13
+
14
+ config_app = typer.Typer(help="Manage connection profiles", no_args_is_help=True)
15
+
16
+
17
+ @config_app.command("init")
18
+ def config_init(
19
+ name: str = typer.Option(..., prompt="Profile name"),
20
+ api_key: str = typer.Option(..., prompt="API key (sk_...)"),
21
+ base_url: str = typer.Option(
22
+ DEFAULT_BASE_URL, prompt="Base URL", show_default=True
23
+ ),
24
+ ):
25
+ """[Legacy] Single-project profile setup.
26
+
27
+ Prefer `ermes login` — runs an Auth0 device flow, auto-discovers every
28
+ project you have access to, and auto-creates per-project sk_ keys. One
29
+ auth event unlocks everything; this command requires a manual paste per
30
+ project.
31
+ """
32
+ from ermes_cli.main import state
33
+
34
+ typer.secho(
35
+ "DEPRECATED: `ermes config init` is single-project + manual. "
36
+ "Use `ermes login` for Auth0-based multi-project auto-discovery.",
37
+ fg=typer.colors.YELLOW,
38
+ err=True,
39
+ )
40
+
41
+ try:
42
+ add_profile(name, api_key, base_url)
43
+ render_message(f"Profile '{name}' created.", use_json=state.use_json)
44
+ except ConfigError as e:
45
+ typer.echo(str(e), err=True)
46
+ raise typer.Exit(code=1)
47
+
48
+
49
+ @config_app.command("list")
50
+ def config_list():
51
+ """Show all configured profiles."""
52
+ from ermes_cli.main import state
53
+
54
+ profiles = list_profiles()
55
+ render(
56
+ profiles,
57
+ columns=["name", "base_url", "api_key", "is_default"],
58
+ use_json=state.use_json,
59
+ title="Profiles",
60
+ )
61
+
62
+
63
+ @config_app.command("add")
64
+ def config_add(
65
+ name: str = typer.Argument(..., help="Profile name"),
66
+ api_key: str = typer.Option(..., "--api-key", help="API key (sk_...)"),
67
+ base_url: str = typer.Option(
68
+ DEFAULT_BASE_URL, "--base-url", help="API base URL"
69
+ ),
70
+ ):
71
+ """Add a new profile."""
72
+ from ermes_cli.main import state
73
+
74
+ try:
75
+ add_profile(name, api_key, base_url)
76
+ render_message(f"Profile '{name}' added.", use_json=state.use_json)
77
+ except ConfigError as e:
78
+ typer.echo(str(e), err=True)
79
+ raise typer.Exit(code=1)
80
+
81
+
82
+ @config_app.command("remove")
83
+ def config_remove(name: str = typer.Argument(..., help="Profile name")):
84
+ """Remove a profile."""
85
+ from ermes_cli.main import state
86
+
87
+ try:
88
+ remove_profile(name)
89
+ render_message(f"Profile '{name}' removed.", use_json=state.use_json)
90
+ except ConfigError as e:
91
+ typer.echo(str(e), err=True)
92
+ raise typer.Exit(code=1)
93
+
94
+
95
+ @config_app.command("set-default")
96
+ def config_set_default(name: str = typer.Argument(..., help="Profile name")):
97
+ """Set the default profile."""
98
+ from ermes_cli.main import state
99
+
100
+ try:
101
+ set_default_profile(name)
102
+ render_message(f"Default profile set to '{name}'.", use_json=state.use_json)
103
+ except ConfigError as e:
104
+ typer.echo(str(e), err=True)
105
+ raise typer.Exit(code=1)
@@ -0,0 +1,103 @@
1
+ """`ermes data-paths` — read the platform's schema introspection.
2
+
3
+ Output of /api/client/data/paths:
4
+ {
5
+ "userPaths": ["$.name", "$.email", ...], # flat list of JSON paths
6
+ "eventPaths": {"signup": ["$.utm_source", ...], # keyed by event name
7
+ "purchase": [...]}
8
+ }
9
+
10
+ We flatten that into `{type, name, path}` rows where:
11
+ type=user → name="" (user paths have no event)
12
+ type=event, name=signup, path=$.utm_source
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import typer
18
+
19
+ from ermes_cli.client import ErmesAPIError
20
+
21
+ data_paths_app = typer.Typer(help="Manage data paths", no_args_is_help=True)
22
+ COLUMNS = ["type", "name", "path"]
23
+
24
+
25
+ def _get_deps():
26
+ from ermes_cli.main import get_client, state
27
+ from ermes_cli.output import render, render_message
28
+
29
+ return get_client(), state, render, render_message
30
+
31
+
32
+ def _flatten_paths(data: dict) -> list[dict]:
33
+ """Normalize /data/paths response into a flat row list.
34
+
35
+ Handles three shapes for forward-compatibility:
36
+ 1. {userPaths: [str], eventPaths: {name: [str]}} ← current platform
37
+ 2. {user: [str], event: {name: [str]}} ← short-key variant
38
+ 3. {user: [{path, name}], event: [{path, name}]} ← hypothetical dict-row variant
39
+ """
40
+ rows: list[dict] = []
41
+
42
+ for user_key in ("userPaths", "user"):
43
+ user_paths = data.get(user_key)
44
+ if isinstance(user_paths, list):
45
+ for entry in user_paths:
46
+ if isinstance(entry, str):
47
+ rows.append({"type": "user", "name": "", "path": entry})
48
+ elif isinstance(entry, dict):
49
+ rows.append({"type": "user", "name": entry.get("name", ""),
50
+ "path": entry.get("path", "")})
51
+ break
52
+
53
+ for event_key in ("eventPaths", "event"):
54
+ event_paths = data.get(event_key)
55
+ if isinstance(event_paths, dict):
56
+ for event_name, paths in sorted(event_paths.items()):
57
+ if not isinstance(paths, list):
58
+ continue
59
+ for entry in paths:
60
+ if isinstance(entry, str):
61
+ rows.append({"type": "event", "name": event_name, "path": entry})
62
+ elif isinstance(entry, dict):
63
+ rows.append({
64
+ "type": "event",
65
+ "name": entry.get("name", event_name),
66
+ "path": entry.get("path", ""),
67
+ })
68
+ break
69
+ if isinstance(event_paths, list):
70
+ for entry in event_paths:
71
+ if isinstance(entry, str):
72
+ rows.append({"type": "event", "name": "", "path": entry})
73
+ elif isinstance(entry, dict):
74
+ rows.append({"type": "event", "name": entry.get("name", ""),
75
+ "path": entry.get("path", "")})
76
+ break
77
+
78
+ return rows
79
+
80
+
81
+ @data_paths_app.command("list")
82
+ def data_paths_list():
83
+ """List known user/event data field paths."""
84
+ client, st, render, _ = _get_deps()
85
+ try:
86
+ data = client.get("/data/paths")
87
+ rows = _flatten_paths(data) if isinstance(data, dict) else []
88
+ render(rows, columns=COLUMNS, use_json=st.use_json)
89
+ except ErmesAPIError as e:
90
+ typer.echo(str(e), err=True)
91
+ raise typer.Exit(code=1)
92
+
93
+
94
+ @data_paths_app.command("sync")
95
+ def data_paths_sync():
96
+ """Trigger a re-sync of data paths from recent users + events."""
97
+ client, st, _, render_message = _get_deps()
98
+ try:
99
+ client.post("/data/paths/sync")
100
+ render_message("Data paths sync triggered.", use_json=st.use_json)
101
+ except ErmesAPIError as e:
102
+ typer.echo(str(e), err=True)
103
+ raise typer.Exit(code=1)