klaviyo-cli 0.1.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.
- klaviyo_cli/__init__.py +3 -0
- klaviyo_cli/_util.py +186 -0
- klaviyo_cli/cli.py +48 -0
- klaviyo_cli/commands/__init__.py +7 -0
- klaviyo_cli/commands/campaigns.py +565 -0
- klaviyo_cli/commands/flows.py +321 -0
- klaviyo_cli/commands/metrics.py +258 -0
- klaviyo_cli/commands/raw.py +34 -0
- klaviyo_cli/commands/segments.py +276 -0
- klaviyo_cli/commands/sms.py +110 -0
- klaviyo_cli/config.py +49 -0
- klaviyo_cli/embed.py +108 -0
- klaviyo_cli/transport.py +96 -0
- klaviyo_cli-0.1.0.dist-info/METADATA +152 -0
- klaviyo_cli-0.1.0.dist-info/RECORD +18 -0
- klaviyo_cli-0.1.0.dist-info/WHEEL +4 -0
- klaviyo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- klaviyo_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
klaviyo_cli/__init__.py
ADDED
klaviyo_cli/_util.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Shared helpers for command modules: timezone parsing, date ranges, output."""
|
|
2
|
+
|
|
3
|
+
import json as json_module
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from zoneinfo import ZoneInfo
|
|
9
|
+
|
|
10
|
+
from .transport import KLAVIYO_BASE
|
|
11
|
+
|
|
12
|
+
TZ_MAP = {
|
|
13
|
+
"EST": "America/New_York",
|
|
14
|
+
"EDT": "America/New_York",
|
|
15
|
+
"CST": "America/Chicago",
|
|
16
|
+
"CDT": "America/Chicago",
|
|
17
|
+
"MST": "America/Denver",
|
|
18
|
+
"MDT": "America/Denver",
|
|
19
|
+
"PST": "America/Los_Angeles",
|
|
20
|
+
"PDT": "America/Los_Angeles",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_send_time(date_str: str, time_str: str) -> str:
|
|
25
|
+
"""Parse MM-DD-YYYY + 'HH:MM AM/PM TZ' into an ISO 8601 string with timezone."""
|
|
26
|
+
# Normalize date from MM-DD-YYYY → YYYY-MM-DD
|
|
27
|
+
parts = date_str.strip().split("-")
|
|
28
|
+
if len(parts) == 3 and len(parts[2]) == 4:
|
|
29
|
+
# MM-DD-YYYY
|
|
30
|
+
month, day, year = parts
|
|
31
|
+
iso_date = f"{year}-{month}-{day}"
|
|
32
|
+
else:
|
|
33
|
+
# Assume already YYYY-MM-DD
|
|
34
|
+
iso_date = date_str.strip()
|
|
35
|
+
|
|
36
|
+
time_parts = time_str.strip().split()
|
|
37
|
+
tz_name = "America/New_York" # default
|
|
38
|
+
|
|
39
|
+
if len(time_parts) == 3:
|
|
40
|
+
# "3:00 PM EST"
|
|
41
|
+
t, ampm, tz_abbr = time_parts
|
|
42
|
+
t_str = f"{t} {ampm}"
|
|
43
|
+
fmt = "%I:%M %p"
|
|
44
|
+
tz_name = TZ_MAP.get(tz_abbr.upper(), tz_abbr)
|
|
45
|
+
elif len(time_parts) == 2:
|
|
46
|
+
if time_parts[1].upper() in ("AM", "PM"):
|
|
47
|
+
# "3:00 PM"
|
|
48
|
+
t_str = time_str.strip()
|
|
49
|
+
fmt = "%I:%M %p"
|
|
50
|
+
else:
|
|
51
|
+
# "15:00 CST"
|
|
52
|
+
t_str = time_parts[0]
|
|
53
|
+
fmt = "%H:%M"
|
|
54
|
+
tz_name = TZ_MAP.get(time_parts[1].upper(), time_parts[1])
|
|
55
|
+
else:
|
|
56
|
+
# "15:00"
|
|
57
|
+
t_str = time_parts[0]
|
|
58
|
+
fmt = "%H:%M"
|
|
59
|
+
|
|
60
|
+
dt = datetime.strptime(f"{iso_date} {t_str}", f"%Y-%m-%d {fmt}")
|
|
61
|
+
dt = dt.replace(tzinfo=ZoneInfo(tz_name))
|
|
62
|
+
return dt.isoformat()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_date_range(days: int, since: str | None, until: str | None):
|
|
66
|
+
"""Resolve a (start, end, label) tuple from --days / --since / --until.
|
|
67
|
+
|
|
68
|
+
If either --since or --until is provided, the explicit bounds win. Otherwise
|
|
69
|
+
falls back to (now - days) .. now. Dates are parsed as YYYY-MM-DD in UTC; the
|
|
70
|
+
end date is inclusive (23:59:59).
|
|
71
|
+
"""
|
|
72
|
+
from datetime import datetime, timedelta, timezone
|
|
73
|
+
|
|
74
|
+
end = datetime.now(timezone.utc)
|
|
75
|
+
start = end - timedelta(days=days)
|
|
76
|
+
label = f"last {days} days"
|
|
77
|
+
|
|
78
|
+
if since or until:
|
|
79
|
+
if since:
|
|
80
|
+
try:
|
|
81
|
+
start = datetime.strptime(since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
82
|
+
except ValueError:
|
|
83
|
+
raise click.ClickException(f"Invalid --since date {since!r} — expected YYYY-MM-DD")
|
|
84
|
+
if until:
|
|
85
|
+
try:
|
|
86
|
+
parsed = datetime.strptime(until, "%Y-%m-%d")
|
|
87
|
+
end = parsed.replace(hour=23, minute=59, second=59, tzinfo=timezone.utc)
|
|
88
|
+
except ValueError:
|
|
89
|
+
raise click.ClickException(f"Invalid --until date {until!r} — expected YYYY-MM-DD")
|
|
90
|
+
label = f"{start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}"
|
|
91
|
+
|
|
92
|
+
return start, end, label
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def output(data, use_json: bool = False):
|
|
96
|
+
"""Print data — raw JSON if use_json, otherwise assume caller formatted it."""
|
|
97
|
+
if use_json:
|
|
98
|
+
print(json_module.dumps(data, indent=2, default=str))
|
|
99
|
+
else:
|
|
100
|
+
if isinstance(data, str):
|
|
101
|
+
print(data)
|
|
102
|
+
else:
|
|
103
|
+
# Fallback for unformatted data — callers should format before calling
|
|
104
|
+
print(json_module.dumps(data, indent=2, default=str))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Segment definition helpers
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
#
|
|
111
|
+
# Klaviyo segment conditions reference metrics by opaque ID (e.g. "profile-metric"
|
|
112
|
+
# conditions carry a metric_id like "XJcga2" rather than a name like "Opened
|
|
113
|
+
# Email"). Reading or building any metric-based segment requires the ID->name
|
|
114
|
+
# map, so these helpers back both list-metrics and the segment commands.
|
|
115
|
+
|
|
116
|
+
_WINDOW_UNIT = {"day": "d", "week": "w", "month": "mo", "hour": "h", "year": "y"}
|
|
117
|
+
_OP_SYMBOL = {"greater-than": ">", "greater-than-or-equal": ">=",
|
|
118
|
+
"less-than": "<", "less-than-or-equal": "<=", "equals": "="}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _resolve_metrics(call) -> dict:
|
|
122
|
+
"""Return {metric_id: {"name": str, "integration": str}} for all metrics."""
|
|
123
|
+
out: dict = {}
|
|
124
|
+
path = "/api/metrics/"
|
|
125
|
+
while path:
|
|
126
|
+
data = call("GET", path)
|
|
127
|
+
for m in data.get("data", []):
|
|
128
|
+
attrs = m.get("attributes", {})
|
|
129
|
+
out[m["id"]] = {
|
|
130
|
+
"name": attrs.get("name", "?"),
|
|
131
|
+
"integration": (attrs.get("integration") or {}).get("name", ""),
|
|
132
|
+
}
|
|
133
|
+
next_link = data.get("links", {}).get("next")
|
|
134
|
+
path = next_link.replace(KLAVIYO_BASE, "") if next_link else None
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _format_condition(cond: dict, metric_map: dict) -> str:
|
|
139
|
+
"""Render a single segment condition to a human-readable string."""
|
|
140
|
+
t = cond.get("type")
|
|
141
|
+
if t == "profile-marketing-consent":
|
|
142
|
+
ch = (cond.get("consent") or {}).get("channel", "?")
|
|
143
|
+
return f"can receive {ch} marketing"
|
|
144
|
+
if t == "profile-property":
|
|
145
|
+
prop = cond.get("property", "?")
|
|
146
|
+
m = re.match(r"properties\['(.+)'\]", prop)
|
|
147
|
+
prop = m.group(1) if m else prop
|
|
148
|
+
f = cond.get("filter") or {}
|
|
149
|
+
ft = f.get("type")
|
|
150
|
+
if ft == "existence":
|
|
151
|
+
return f"{prop} is {f.get('operator', '?')}"
|
|
152
|
+
if ft == "boolean":
|
|
153
|
+
return f"{prop} = {f.get('value')}"
|
|
154
|
+
val = f.get("value")
|
|
155
|
+
return f"{prop} {f.get('operator', '?')}{'' if val is None else ' ' + str(val)}"
|
|
156
|
+
if t == "profile-metric":
|
|
157
|
+
mid = cond.get("metric_id")
|
|
158
|
+
name = metric_map.get(mid, {}).get("name", mid)
|
|
159
|
+
mf = cond.get("measurement_filter") or {}
|
|
160
|
+
op = _OP_SYMBOL.get(mf.get("operator"), mf.get("operator", "?"))
|
|
161
|
+
val = mf.get("value")
|
|
162
|
+
tf = cond.get("timeframe_filter") or {}
|
|
163
|
+
window = ""
|
|
164
|
+
if tf.get("operator") == "in-the-last":
|
|
165
|
+
unit = _WINDOW_UNIT.get(tf.get("unit"), tf.get("unit", ""))
|
|
166
|
+
window = f" in last {tf.get('quantity')}{unit}"
|
|
167
|
+
return f"{name} {op}{val}{window}"
|
|
168
|
+
return t or "?"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _render_definition(definition: dict, metric_map: dict, oneline: bool = False):
|
|
172
|
+
"""Render condition_groups. Groups are AND'd; conditions within OR'd.
|
|
173
|
+
|
|
174
|
+
Returns a list of per-group strings, or a single AND-joined string when
|
|
175
|
+
oneline=True.
|
|
176
|
+
"""
|
|
177
|
+
groups = (definition or {}).get("condition_groups", [])
|
|
178
|
+
rendered = [" OR ".join(_format_condition(c, metric_map) for c in g.get("conditions", []))
|
|
179
|
+
for g in groups]
|
|
180
|
+
if oneline:
|
|
181
|
+
return " AND ".join(f"({g})" if " OR " in g else g for g in rendered)
|
|
182
|
+
return rendered
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _norm_name(s: str) -> str:
|
|
186
|
+
return re.sub(r"\s+", " ", (s or "").strip().lower())
|
klaviyo_cli/cli.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""CLI group: `klaviyo` command. Commands live in klaviyo_cli/commands/."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import entry_points
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .config import resolve_transport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_context(profile: str | None) -> dict:
|
|
11
|
+
"""Build ctx.obj auth pieces. Transport resolves lazily on first call."""
|
|
12
|
+
transport = None
|
|
13
|
+
|
|
14
|
+
def call(method, path, body=None, revision=None):
|
|
15
|
+
nonlocal transport
|
|
16
|
+
if transport is None:
|
|
17
|
+
transport = resolve_transport(profile)
|
|
18
|
+
return transport.call(method, path, body=body, revision=revision)
|
|
19
|
+
|
|
20
|
+
return {"call": call, "label": profile or "default"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group()
|
|
24
|
+
@click.option("--json", "use_json", is_flag=True, help="Output raw JSON")
|
|
25
|
+
@click.option("-p", "--profile", envvar="KLAVIYO_PROFILE", default=None,
|
|
26
|
+
help="Named account profile from ~/.config/klaviyo-cli/config.toml")
|
|
27
|
+
@click.version_option(package_name="klaviyo-cli")
|
|
28
|
+
@click.pass_context
|
|
29
|
+
def main(ctx, use_json, profile):
|
|
30
|
+
"""Unofficial Klaviyo CLI: campaigns, segments, flows, metrics, scheduling.
|
|
31
|
+
|
|
32
|
+
Auth: set KLAVIYO_API_KEY, or use --profile with a config file.
|
|
33
|
+
Not affiliated with or endorsed by Klaviyo, Inc.
|
|
34
|
+
"""
|
|
35
|
+
ctx.obj = {"json": use_json, **build_context(profile)}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def entry():
|
|
39
|
+
"""Console-script entry. Host packages (klaviyo_cli.hosts entry points)
|
|
40
|
+
may supply a replacement group (e.g. an agency wrapper with per-client auth)."""
|
|
41
|
+
for ep in entry_points(group="klaviyo_cli.hosts"):
|
|
42
|
+
group = ep.load()()
|
|
43
|
+
if group is not None:
|
|
44
|
+
return group()
|
|
45
|
+
return main()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
from . import commands # noqa: E402,F401 (registers subcommands on `main`)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Import command modules so they register on the main group."""
|
|
2
|
+
from . import campaigns # noqa: F401
|
|
3
|
+
from . import segments # noqa: F401
|
|
4
|
+
from . import flows # noqa: F401
|
|
5
|
+
from . import metrics # noqa: F401
|
|
6
|
+
from . import sms # noqa: F401
|
|
7
|
+
from . import raw # noqa: F401
|