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
ermes_cli/config.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Config storage for ermes-cli.
|
|
2
|
+
|
|
3
|
+
Two storage shapes coexist:
|
|
4
|
+
|
|
5
|
+
~/.ermes/projects.yml (new in v0.3, written by `ermes login`)
|
|
6
|
+
auth0_email: admin@ermes.local
|
|
7
|
+
active_project: 72
|
|
8
|
+
base_url: https://ermes.publinker.com
|
|
9
|
+
projects:
|
|
10
|
+
72:
|
|
11
|
+
name: martechdm
|
|
12
|
+
organization_id: 1
|
|
13
|
+
role: admin
|
|
14
|
+
api_key: sk_xxxxx
|
|
15
|
+
api_key_id: 42
|
|
16
|
+
api_key_name: cli-managed-<hostname>
|
|
17
|
+
|
|
18
|
+
~/.ermes/config.yml (legacy, written by `ermes config init`)
|
|
19
|
+
default_profile: martechdm
|
|
20
|
+
profiles:
|
|
21
|
+
martechdm:
|
|
22
|
+
api_key: sk_xxxxx
|
|
23
|
+
base_url: https://ermes.publinker.com
|
|
24
|
+
|
|
25
|
+
`resolve_profile()` prefers projects.yml (see its docstring for the full
|
|
26
|
+
1.0 order). Per-project keys are provisioned lazily on first use — login
|
|
27
|
+
no longer mints a key on every project.
|
|
28
|
+
|
|
29
|
+
Both files are written chmod 600.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import contextlib
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
import yaml
|
|
41
|
+
|
|
42
|
+
DEFAULT_BASE_URL = "https://ermes.publinker.com"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_warned_profile: list[bool] = []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConfigError(Exception):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Paths
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _config_dir() -> Path:
|
|
58
|
+
return Path(os.environ.get("ERMES_CONFIG_DIR", Path.home() / ".ermes"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _config_path() -> Path:
|
|
62
|
+
return _config_dir() / "config.yml"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _projects_path() -> Path:
|
|
66
|
+
return _config_dir() / "projects.yml"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Legacy profile storage (config.yml)
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_config() -> dict:
|
|
75
|
+
path = _config_path()
|
|
76
|
+
if not path.exists():
|
|
77
|
+
return {"profiles": {}}
|
|
78
|
+
with open(path) as f:
|
|
79
|
+
data = yaml.safe_load(f)
|
|
80
|
+
return data if data else {"profiles": {}}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@contextlib.contextmanager
|
|
84
|
+
def _open_private(path: Path):
|
|
85
|
+
"""Write `path` atomically as a fresh 0600 file.
|
|
86
|
+
|
|
87
|
+
The bytes go to a sibling temp file opened O_EXCL|O_NOFOLLOW (a planted
|
|
88
|
+
symlink or stale temp is never followed), then `os.replace` swaps it in: a
|
|
89
|
+
reader sees the old file or the new one, never a truncated one, and a
|
|
90
|
+
pre-existing wide-mode file or symlink is replaced by a fresh 0600 inode.
|
|
91
|
+
"""
|
|
92
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
93
|
+
tmp.unlink(missing_ok=True)
|
|
94
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
95
|
+
try:
|
|
96
|
+
if hasattr(os, "fchmod"): # absent on Windows < 3.13; the 0o600 at open still applies
|
|
97
|
+
os.fchmod(fd, 0o600)
|
|
98
|
+
f = os.fdopen(fd, "w")
|
|
99
|
+
except OSError:
|
|
100
|
+
os.close(fd)
|
|
101
|
+
tmp.unlink(missing_ok=True)
|
|
102
|
+
raise
|
|
103
|
+
try:
|
|
104
|
+
with f:
|
|
105
|
+
yield f
|
|
106
|
+
f.flush()
|
|
107
|
+
os.fsync(f.fileno())
|
|
108
|
+
os.replace(tmp, path)
|
|
109
|
+
except BaseException:
|
|
110
|
+
tmp.unlink(missing_ok=True)
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate_api_key(raw: str, source: str = "API key") -> str:
|
|
115
|
+
"""A key with whitespace or control chars would make httpx raise a header
|
|
116
|
+
error that repeats the whole key; reject it before it reaches the client."""
|
|
117
|
+
key = raw.strip()
|
|
118
|
+
if not key or any(ch.isspace() or not ch.isprintable() for ch in key):
|
|
119
|
+
raise ConfigError(
|
|
120
|
+
f"{source} contains whitespace or control characters; "
|
|
121
|
+
"use the bare sk_… value (watch for a trailing newline)."
|
|
122
|
+
)
|
|
123
|
+
return key
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def save_config(config: dict) -> None:
|
|
127
|
+
path = _config_path()
|
|
128
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
with _open_private(path) as f:
|
|
130
|
+
yaml.dump(config, f, default_flow_style=False)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def add_profile(name: str, api_key: str, base_url: str | None = None) -> None:
|
|
134
|
+
config = load_config()
|
|
135
|
+
if name in config.get("profiles", {}):
|
|
136
|
+
raise ConfigError(f"Profile '{name}' already exists")
|
|
137
|
+
config.setdefault("profiles", {})[name] = {
|
|
138
|
+
"api_key": api_key,
|
|
139
|
+
"base_url": base_url or DEFAULT_BASE_URL,
|
|
140
|
+
}
|
|
141
|
+
if not config.get("default_profile"):
|
|
142
|
+
config["default_profile"] = name
|
|
143
|
+
save_config(config)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def remove_profile(name: str) -> None:
|
|
147
|
+
config = load_config()
|
|
148
|
+
if name not in config.get("profiles", {}):
|
|
149
|
+
raise ConfigError(f"Profile '{name}' not found")
|
|
150
|
+
del config["profiles"][name]
|
|
151
|
+
if config.get("default_profile") == name:
|
|
152
|
+
config["default_profile"] = None
|
|
153
|
+
save_config(config)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_profile(name: str) -> dict:
|
|
157
|
+
config = load_config()
|
|
158
|
+
if name not in config.get("profiles", {}):
|
|
159
|
+
raise ConfigError(f"Profile '{name}' not found")
|
|
160
|
+
return config["profiles"][name]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def set_default_profile(name: str) -> None:
|
|
164
|
+
config = load_config()
|
|
165
|
+
if name not in config.get("profiles", {}):
|
|
166
|
+
raise ConfigError(f"Profile '{name}' not found")
|
|
167
|
+
config["default_profile"] = name
|
|
168
|
+
save_config(config)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def mask_key(key: str) -> str:
|
|
172
|
+
if len(key) <= 8:
|
|
173
|
+
return key[:3] + "***"
|
|
174
|
+
return key[:3] + "***" + key[-3:]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def list_profiles() -> list[dict]:
|
|
178
|
+
config = load_config()
|
|
179
|
+
default = config.get("default_profile")
|
|
180
|
+
result = []
|
|
181
|
+
for name, profile in config.get("profiles", {}).items():
|
|
182
|
+
result.append({
|
|
183
|
+
"name": name,
|
|
184
|
+
"api_key": mask_key(profile["api_key"]),
|
|
185
|
+
"base_url": profile.get("base_url", DEFAULT_BASE_URL),
|
|
186
|
+
"is_default": name == default,
|
|
187
|
+
})
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# New project storage (projects.yml) — populated by `ermes login`
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def load_projects_file() -> dict[str, Any]:
|
|
197
|
+
path = _projects_path()
|
|
198
|
+
if not path.exists():
|
|
199
|
+
return {}
|
|
200
|
+
with open(path) as f:
|
|
201
|
+
data = yaml.safe_load(f)
|
|
202
|
+
return data if isinstance(data, dict) else {}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def save_projects_file(data: dict[str, Any]) -> None:
|
|
206
|
+
path = _projects_path()
|
|
207
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
with _open_private(path) as f:
|
|
209
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def clear_projects_file() -> None:
|
|
213
|
+
path = _projects_path()
|
|
214
|
+
if path.exists():
|
|
215
|
+
path.unlink()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_projects() -> dict[int, dict[str, Any]]:
|
|
219
|
+
data = load_projects_file()
|
|
220
|
+
raw = data.get("projects") or {}
|
|
221
|
+
# YAML keys may come back as strings; normalize to int.
|
|
222
|
+
return {int(k): v for k, v in raw.items()}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get_active_project_id() -> int | None:
|
|
226
|
+
data = load_projects_file()
|
|
227
|
+
aid = data.get("active_project")
|
|
228
|
+
return int(aid) if aid is not None else None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def set_active_project_id(project_id: int) -> None:
|
|
232
|
+
data = load_projects_file()
|
|
233
|
+
projects = data.get("projects") or {}
|
|
234
|
+
if str(project_id) not in projects and project_id not in projects:
|
|
235
|
+
raise ConfigError(
|
|
236
|
+
f"Project {project_id} not in projects.yml — run `ermes login` to refresh"
|
|
237
|
+
)
|
|
238
|
+
data["active_project"] = int(project_id)
|
|
239
|
+
save_projects_file(data)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def get_active_project() -> dict[str, Any] | None:
|
|
243
|
+
aid = get_active_project_id()
|
|
244
|
+
if aid is None:
|
|
245
|
+
return None
|
|
246
|
+
return get_projects().get(aid)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def get_effective_project(
|
|
250
|
+
profile_name: str | None = None,
|
|
251
|
+
) -> tuple[int | None, dict[str, Any] | None, str]:
|
|
252
|
+
"""The project commands will ACTUALLY use, and where that came from.
|
|
253
|
+
|
|
254
|
+
MUST mirror every tier of `resolve_profile()` — an earlier cut only handled
|
|
255
|
+
ERMES_PROJECT and active_project, so `whoami` printed one project while the
|
|
256
|
+
data commands used a legacy config.yml key. Tiers that resolve through
|
|
257
|
+
config.yml or ERMES_API_KEY carry no project identity and return
|
|
258
|
+
`(None, None, source)`. Returns `(id, project, source)`.
|
|
259
|
+
"""
|
|
260
|
+
if profile_name:
|
|
261
|
+
return None, None, f"--profile {profile_name!r} (legacy config.yml, no project identity)"
|
|
262
|
+
|
|
263
|
+
if os.environ.get("ERMES_API_KEY"):
|
|
264
|
+
return None, None, "ERMES_API_KEY (no project identity)"
|
|
265
|
+
|
|
266
|
+
env_project = os.environ.get("ERMES_PROJECT")
|
|
267
|
+
if env_project:
|
|
268
|
+
projects = get_projects()
|
|
269
|
+
if env_project.isdigit():
|
|
270
|
+
pid = int(env_project)
|
|
271
|
+
if pid in projects:
|
|
272
|
+
return pid, projects[pid], "ERMES_PROJECT"
|
|
273
|
+
else:
|
|
274
|
+
for pid, proj in projects.items():
|
|
275
|
+
if proj.get("name", "").lower() == env_project.lower():
|
|
276
|
+
return pid, proj, "ERMES_PROJECT"
|
|
277
|
+
# Unresolvable override: say so rather than silently falling back to the active project,
|
|
278
|
+
# which would display a project the very next command is NOT going to use.
|
|
279
|
+
return None, None, f"ERMES_PROJECT={env_project!r} (NOT FOUND)"
|
|
280
|
+
|
|
281
|
+
active_id, active = get_active_project_id(), get_active_project()
|
|
282
|
+
if active is not None:
|
|
283
|
+
return active_id, active, "projects.yml"
|
|
284
|
+
|
|
285
|
+
env_profile = os.environ.get("ERMES_PROFILE")
|
|
286
|
+
if env_profile:
|
|
287
|
+
return (
|
|
288
|
+
None,
|
|
289
|
+
None,
|
|
290
|
+
f"ERMES_PROFILE={env_profile!r} (legacy config.yml, no project identity)",
|
|
291
|
+
)
|
|
292
|
+
return active_id, active, "projects.yml"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def get_base_url() -> str:
|
|
296
|
+
data = load_projects_file()
|
|
297
|
+
return data.get("base_url") or DEFAULT_BASE_URL
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def get_auth0_email() -> str | None:
|
|
301
|
+
data = load_projects_file()
|
|
302
|
+
email = data.get("auth0_email")
|
|
303
|
+
return str(email) if email else None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
# Profile resolution — what the CLI / MCP actually consume at runtime
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def resolve_profile(profile_name: str | None = None) -> dict:
|
|
312
|
+
"""Resolve to a `{base_url, api_key}` dict.
|
|
313
|
+
|
|
314
|
+
Resolution order (1.0):
|
|
315
|
+
1. Explicit `profile_name` arg (always wins; reads legacy config.yml)
|
|
316
|
+
2. `ERMES_API_KEY` env (+ optional `ERMES_BASE_URL`) — CI / scripts, no login
|
|
317
|
+
3. `ERMES_PROJECT` env (matches projects.yml by name or id)
|
|
318
|
+
4. projects.yml `active_project` (the post-`ermes login` happy path)
|
|
319
|
+
5. `ERMES_PROFILE` env (legacy, deprecated — ranked below the project tiers)
|
|
320
|
+
6. legacy config.yml `default_profile`
|
|
321
|
+
|
|
322
|
+
A project without a cached key (login no longer mints keys for every project)
|
|
323
|
+
is provisioned on first use via `POST /api/admin/projects/:id/cli-key`.
|
|
324
|
+
Raises `ConfigError` with an actionable message if nothing resolves.
|
|
325
|
+
"""
|
|
326
|
+
if profile_name:
|
|
327
|
+
return get_profile(profile_name)
|
|
328
|
+
|
|
329
|
+
env_key = os.environ.get("ERMES_API_KEY")
|
|
330
|
+
if env_key:
|
|
331
|
+
return {
|
|
332
|
+
"base_url": os.environ.get("ERMES_BASE_URL") or get_base_url(),
|
|
333
|
+
"api_key": validate_api_key(env_key, "ERMES_API_KEY"),
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
env_profile = os.environ.get("ERMES_PROFILE")
|
|
337
|
+
if env_profile and not _warned_profile:
|
|
338
|
+
_warned_profile.append(True)
|
|
339
|
+
print(
|
|
340
|
+
"ermes: ERMES_PROFILE is deprecated and now ranks below ERMES_PROJECT and the "
|
|
341
|
+
"active project; use ERMES_PROJECT=<name-or-id> or ERMES_API_KEY.",
|
|
342
|
+
file=sys.stderr,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
env_project = os.environ.get("ERMES_PROJECT")
|
|
346
|
+
if env_project:
|
|
347
|
+
return _resolve_from_projects(env_project)
|
|
348
|
+
|
|
349
|
+
active_id = get_active_project_id()
|
|
350
|
+
if active_id is not None and active_id in get_projects():
|
|
351
|
+
return {"base_url": get_base_url(), "api_key": _project_key(active_id)}
|
|
352
|
+
|
|
353
|
+
if env_profile:
|
|
354
|
+
return get_profile(env_profile)
|
|
355
|
+
|
|
356
|
+
config = load_config()
|
|
357
|
+
default = config.get("default_profile")
|
|
358
|
+
if default:
|
|
359
|
+
return get_profile(default)
|
|
360
|
+
|
|
361
|
+
raise ConfigError(
|
|
362
|
+
"No active profile. Run `ermes login` to authenticate via Auth0 and "
|
|
363
|
+
"auto-discover your projects, or set ERMES_API_KEY for a scripted session."
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _project_key(project_id: int) -> str:
|
|
368
|
+
"""Cached per-admin key for the project, provisioning it on first use."""
|
|
369
|
+
proj = get_projects().get(project_id) or {}
|
|
370
|
+
if proj.get("api_key"):
|
|
371
|
+
return str(proj["api_key"])
|
|
372
|
+
from ermes_cli.commands.auth_cmds import ensure_project_key # lazy: avoids an import cycle
|
|
373
|
+
|
|
374
|
+
return ensure_project_key(project_id)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _resolve_from_projects(name_or_id: str) -> dict:
|
|
378
|
+
"""Look up a project in projects.yml by name OR numeric id."""
|
|
379
|
+
projects = get_projects()
|
|
380
|
+
if name_or_id.isdigit():
|
|
381
|
+
pid = int(name_or_id)
|
|
382
|
+
if pid in projects:
|
|
383
|
+
return {"base_url": get_base_url(), "api_key": _project_key(pid)}
|
|
384
|
+
raise ConfigError(
|
|
385
|
+
f"ERMES_PROJECT={name_or_id}: project id not found in projects.yml"
|
|
386
|
+
)
|
|
387
|
+
for pid, proj in projects.items():
|
|
388
|
+
if proj.get("name", "").lower() == name_or_id.lower():
|
|
389
|
+
return {"base_url": get_base_url(), "api_key": _project_key(pid)}
|
|
390
|
+
raise ConfigError(
|
|
391
|
+
f"ERMES_PROJECT={name_or_id!r} not found in projects.yml — "
|
|
392
|
+
"run `ermes projects refresh`"
|
|
393
|
+
)
|
ermes_cli/errors.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared exception hierarchy for ermes-cli.
|
|
2
|
+
|
|
3
|
+
Lives in its own module so `client.py` and `write_mode.py` can both import
|
|
4
|
+
from it without a circular dependency.
|
|
5
|
+
|
|
6
|
+
Hierarchy:
|
|
7
|
+
ErmesAPIError # base — any failure interacting with the API
|
|
8
|
+
WriteBlockedError # client-side guard fired before the request
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ErmesAPIError(Exception):
|
|
15
|
+
"""Anything that prevents an Ermes API call from succeeding cleanly.
|
|
16
|
+
|
|
17
|
+
`status_code` is the HTTP status when the failure came from the server.
|
|
18
|
+
For pre-flight failures (no network call made) it's None.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class WriteBlockedError(ErmesAPIError):
|
|
27
|
+
"""Raised by the write-mode guard before a mutating call hits the network.
|
|
28
|
+
|
|
29
|
+
Inherits from ErmesAPIError so the existing `try/except ErmesAPIError`
|
|
30
|
+
blocks in every command file catch it without modification.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str):
|
|
34
|
+
super().__init__(message, status_code=None)
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# cli/ermes_cli/export_utils.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from zoneinfo import ZoneInfo
|
|
10
|
+
except ImportError:
|
|
11
|
+
ZoneInfo = None # type: ignore
|
|
12
|
+
|
|
13
|
+
# Pre-compiled patterns
|
|
14
|
+
_LINK_PATTERN = re.compile(r'href=[\'"]?([^\'" >]+)', re.IGNORECASE)
|
|
15
|
+
_HTML_TAG_PATTERN = re.compile(r'<[^>]+>')
|
|
16
|
+
_WHITESPACE_PATTERN = re.compile(r'\s+')
|
|
17
|
+
_EMOJI_PATTERN = re.compile(
|
|
18
|
+
"[" +
|
|
19
|
+
"\U0001F600-\U0001F64F"
|
|
20
|
+
"\U0001F300-\U0001F5FF"
|
|
21
|
+
"\U0001F680-\U0001F6FF"
|
|
22
|
+
"\U0001F900-\U0001F9FF"
|
|
23
|
+
"\U0001FA70-\U0001FAFF"
|
|
24
|
+
"\U00002702-\U000027B0"
|
|
25
|
+
"\U000024C2-\U0001F251"
|
|
26
|
+
"]+",
|
|
27
|
+
flags=re.UNICODE,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _has_emoji(text: str) -> bool:
|
|
32
|
+
return bool(text and _EMOJI_PATTERN.search(text))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _count_links(html: str) -> int:
|
|
36
|
+
return len(_LINK_PATTERN.findall(html)) if html else 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _word_count(html: str) -> int:
|
|
40
|
+
if not html:
|
|
41
|
+
return 0
|
|
42
|
+
clean = _HTML_TAG_PATTERN.sub(" ", html)
|
|
43
|
+
clean = _WHITESPACE_PATTERN.sub(" ", clean).strip()
|
|
44
|
+
return len(clean.split()) if clean else 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_iso_datetime(value: str | None) -> datetime | None:
|
|
48
|
+
"""Parse an ISO datetime/date string and normalize naive values to UTC."""
|
|
49
|
+
if not value:
|
|
50
|
+
return None
|
|
51
|
+
try:
|
|
52
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
53
|
+
except (ValueError, TypeError):
|
|
54
|
+
return None
|
|
55
|
+
if dt.tzinfo is None:
|
|
56
|
+
return dt.replace(tzinfo=timezone.utc)
|
|
57
|
+
return dt
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _summarize_lists(items: Any) -> list[dict]:
|
|
61
|
+
if not isinstance(items, list):
|
|
62
|
+
return []
|
|
63
|
+
results = []
|
|
64
|
+
for it in items:
|
|
65
|
+
if not isinstance(it, dict):
|
|
66
|
+
continue
|
|
67
|
+
id_ = it.get("id")
|
|
68
|
+
name = it.get("name")
|
|
69
|
+
type_ = it.get("type")
|
|
70
|
+
if id_ is None or name is None or type_ is None:
|
|
71
|
+
continue
|
|
72
|
+
results.append({"id": id_, "name": name, "type": type_, "users_count": it.get("users_count")})
|
|
73
|
+
return results
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _extract_time_metrics(send_at: str | None, project_timezone: str | None) -> dict | None:
|
|
77
|
+
"""Build time metrics from send_at timestamp."""
|
|
78
|
+
dt = _parse_iso_datetime(send_at)
|
|
79
|
+
if dt is None:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
metrics_dt = dt
|
|
83
|
+
|
|
84
|
+
if project_timezone and ZoneInfo is not None:
|
|
85
|
+
try:
|
|
86
|
+
local_dt = dt.astimezone(ZoneInfo(project_timezone))
|
|
87
|
+
metrics_dt = local_dt
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
metrics: dict[str, Any] = {
|
|
92
|
+
"day_of_week": metrics_dt.strftime("%A"),
|
|
93
|
+
"hour_of_day": dt.hour,
|
|
94
|
+
"month": metrics_dt.strftime("%B"),
|
|
95
|
+
"year": metrics_dt.year,
|
|
96
|
+
}
|
|
97
|
+
if metrics_dt is not dt:
|
|
98
|
+
metrics["local_hour_of_day"] = metrics_dt.hour
|
|
99
|
+
return metrics
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_template_fields(templates: Any) -> dict[str, Any]:
|
|
103
|
+
"""Extract locale, from, subject, preheader, mjml, and content_analysis from templates."""
|
|
104
|
+
fields: dict[str, Any] = {}
|
|
105
|
+
if not isinstance(templates, list) or not templates:
|
|
106
|
+
return fields
|
|
107
|
+
|
|
108
|
+
tmpl = templates[0]
|
|
109
|
+
if not isinstance(tmpl, dict):
|
|
110
|
+
return fields
|
|
111
|
+
|
|
112
|
+
locale = tmpl.get("locale")
|
|
113
|
+
if locale:
|
|
114
|
+
fields["locale"] = locale
|
|
115
|
+
|
|
116
|
+
data = tmpl.get("data")
|
|
117
|
+
if not isinstance(data, dict):
|
|
118
|
+
return fields
|
|
119
|
+
|
|
120
|
+
from_data = data.get("from")
|
|
121
|
+
if isinstance(from_data, dict):
|
|
122
|
+
fields["from"] = {"name": from_data.get("name"), "address": from_data.get("address")}
|
|
123
|
+
|
|
124
|
+
subject = data.get("subject", "")
|
|
125
|
+
preheader = data.get("preheader", "")
|
|
126
|
+
fields["subject"] = subject
|
|
127
|
+
fields["preheader"] = preheader
|
|
128
|
+
fields["mjml"] = data.get("mjml", "")
|
|
129
|
+
|
|
130
|
+
html_content = data.get("html", "")
|
|
131
|
+
content_to_analyze = html_content or data.get("mjml", "")
|
|
132
|
+
fields["content_analysis"] = {
|
|
133
|
+
"subject_length": len(subject),
|
|
134
|
+
"preheader_length": len(preheader),
|
|
135
|
+
"subject_has_emoji": _has_emoji(subject),
|
|
136
|
+
"preheader_has_emoji": _has_emoji(preheader),
|
|
137
|
+
"link_count": _count_links(html_content),
|
|
138
|
+
"word_count": _word_count(content_to_analyze),
|
|
139
|
+
}
|
|
140
|
+
return fields
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _summarize_dict(source: Any, keys: list[str]) -> dict | None:
|
|
144
|
+
"""Extract a subset of keys from a dict, or return None if source is not a dict."""
|
|
145
|
+
if not isinstance(source, dict):
|
|
146
|
+
return None
|
|
147
|
+
return {k: source.get(k) for k in keys}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def extract_campaign_record(
|
|
151
|
+
campaign: dict, project_timezone: str | None = None
|
|
152
|
+
) -> dict | None:
|
|
153
|
+
"""Extract enriched record from a raw campaign API response.
|
|
154
|
+
|
|
155
|
+
Returns None if the campaign has no delivery data or zero sends.
|
|
156
|
+
"""
|
|
157
|
+
delivery = campaign.get("delivery")
|
|
158
|
+
if not isinstance(delivery, dict):
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
sent = delivery.get("sent", 0)
|
|
162
|
+
if sent <= 0:
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
opens = delivery.get("opens", 0)
|
|
166
|
+
clicks = delivery.get("clicks", 0)
|
|
167
|
+
|
|
168
|
+
record: dict[str, Any] = {
|
|
169
|
+
"id": campaign.get("id"),
|
|
170
|
+
"name": campaign.get("name"),
|
|
171
|
+
"type": campaign.get("type"),
|
|
172
|
+
"channel": campaign.get("channel"),
|
|
173
|
+
"state": campaign.get("state"),
|
|
174
|
+
"send_at": campaign.get("send_at"),
|
|
175
|
+
"created_at": campaign.get("created_at"),
|
|
176
|
+
"updated_at": campaign.get("updated_at"),
|
|
177
|
+
"deleted_at": campaign.get("deleted_at"),
|
|
178
|
+
"delivery": {"sent": sent, "opens": opens, "clicks": clicks},
|
|
179
|
+
"analytics": {
|
|
180
|
+
"open_rate": round((opens / sent) * 100, 2),
|
|
181
|
+
"click_rate": round((clicks / sent) * 100, 2),
|
|
182
|
+
"click_to_open_rate": round((clicks / opens) * 100, 2) if opens > 0 else 0,
|
|
183
|
+
},
|
|
184
|
+
"tags": campaign.get("tags", []),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
provider = _summarize_dict(campaign.get("provider"), ["id", "type", "name", "group"])
|
|
188
|
+
if provider:
|
|
189
|
+
record["provider"] = provider
|
|
190
|
+
|
|
191
|
+
subscription = _summarize_dict(campaign.get("subscription"), ["id", "name", "channel"])
|
|
192
|
+
if subscription:
|
|
193
|
+
record["subscription"] = subscription
|
|
194
|
+
|
|
195
|
+
lists_summary = _summarize_lists(campaign.get("lists"))
|
|
196
|
+
if lists_summary:
|
|
197
|
+
record["lists"] = lists_summary
|
|
198
|
+
exclusion_summary = _summarize_lists(campaign.get("exclusion_lists"))
|
|
199
|
+
if exclusion_summary:
|
|
200
|
+
record["exclusion_lists"] = exclusion_summary
|
|
201
|
+
|
|
202
|
+
time_metrics = _extract_time_metrics(campaign.get("send_at"), project_timezone)
|
|
203
|
+
if time_metrics:
|
|
204
|
+
record["time_metrics"] = time_metrics
|
|
205
|
+
|
|
206
|
+
record.update(_extract_template_fields(campaign.get("templates")))
|
|
207
|
+
|
|
208
|
+
return record
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def is_within_date_range(
|
|
212
|
+
date_str: str | None,
|
|
213
|
+
*,
|
|
214
|
+
days: int | None = None,
|
|
215
|
+
after: str | None = None,
|
|
216
|
+
before: str | None = None,
|
|
217
|
+
) -> bool:
|
|
218
|
+
"""Check if a date string falls within the given range.
|
|
219
|
+
|
|
220
|
+
Supports three modes:
|
|
221
|
+
- days: last N days from now
|
|
222
|
+
- after/before: explicit ISO date boundaries
|
|
223
|
+
- combined: after + before for a window
|
|
224
|
+
"""
|
|
225
|
+
dt = _parse_iso_datetime(date_str)
|
|
226
|
+
if dt is None:
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
if days is not None:
|
|
230
|
+
threshold = datetime.now(timezone.utc) - timedelta(days=days)
|
|
231
|
+
if dt < threshold:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
if after is not None:
|
|
235
|
+
after_dt = _parse_iso_datetime(after)
|
|
236
|
+
if after_dt is None:
|
|
237
|
+
return False
|
|
238
|
+
if dt < after_dt:
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
if before is not None:
|
|
242
|
+
before_dt = _parse_iso_datetime(before)
|
|
243
|
+
if before_dt is None:
|
|
244
|
+
return False
|
|
245
|
+
if dt > before_dt:
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def sanitize_filename(subject: str, date_str: str) -> str:
|
|
252
|
+
"""Create a sanitized filename from subject and date."""
|
|
253
|
+
date_obj = _parse_iso_datetime(date_str)
|
|
254
|
+
date_formatted = date_obj.strftime("%Y-%m-%d") if date_obj else "unknown-date"
|
|
255
|
+
|
|
256
|
+
clean = re.sub(r"[^\w\s-]", "", subject)
|
|
257
|
+
clean = re.sub(r"[-\s]+", "-", clean)
|
|
258
|
+
clean = clean.strip("-").lower()
|
|
259
|
+
if len(clean) > 50:
|
|
260
|
+
clean = clean[:50].rstrip("-")
|
|
261
|
+
|
|
262
|
+
return f"blast_{date_formatted}_{clean}.html" if clean else f"blast_{date_formatted}.html"
|
|
263
|
+
|