thoughtleaders-cli 0.8.0__py3-none-any.whl → 0.8.1__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.
- {thoughtleaders_cli-0.8.0.dist-info → thoughtleaders_cli-0.8.1.dist-info}/METADATA +1 -1
- {thoughtleaders_cli-0.8.0.dist-info → thoughtleaders_cli-0.8.1.dist-info}/RECORD +10 -9
- tl_cli/__init__.py +1 -1
- tl_cli/_plugin/.claude-plugin/plugin.json +1 -1
- tl_cli/commands/db.py +39 -1
- tl_cli/commands/schema.py +9 -0
- tl_cli/query_history.py +94 -0
- {thoughtleaders_cli-0.8.0.dist-info → thoughtleaders_cli-0.8.1.dist-info}/WHEEL +0 -0
- {thoughtleaders_cli-0.8.0.dist-info → thoughtleaders_cli-0.8.1.dist-info}/entry_points.txt +0 -0
- {thoughtleaders_cli-0.8.0.dist-info → thoughtleaders_cli-0.8.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: thoughtleaders-cli
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence
|
|
5
5
|
Project-URL: Homepage, https://thoughtleaders.io
|
|
6
6
|
Project-URL: Repository, https://github.com/ThoughtLeaders-io/thoughtleaders-cli
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
tl_cli/__init__.py,sha256=
|
|
1
|
+
tl_cli/__init__.py,sha256=3cLM1cLW88E02sVZNV24Xh_GnreB0RbNrhvhwp2GlJU,112
|
|
2
2
|
tl_cli/_completions.py,sha256=kOyEUqC26vbYvyXWi513WX8fF73qQLR5WWuRSe_wqyk,164
|
|
3
3
|
tl_cli/_typer_utils.py,sha256=ZiZsCVmEznPvBw-dYbr3tu3zWZ0iN6kjoQmK3gMqD28,860
|
|
4
4
|
tl_cli/config.py,sha256=UV_OYTXuQnAIqbi_oVCXx0hhIdZWR678RRapVv51UwQ,1859
|
|
5
5
|
tl_cli/filters.py,sha256=Yjj04lONGGmtCAIYN5i6KD12mAnUKEZ7i9heeWtdj7o,2952
|
|
6
6
|
tl_cli/hints.py,sha256=cT8kuDtkAZqwXkc2RV0Yg_abofK-g9UiXwTTBunX78U,1557
|
|
7
7
|
tl_cli/main.py,sha256=A_8b2SQjBKATxrjO7AGC5Ab1QWlP35gGo4TWzYZtlOM,5806
|
|
8
|
+
tl_cli/query_history.py,sha256=tj9AxI2ADoC8a92Oacoyuljtqc_PPzA6o2X0Adhs2N8,3336
|
|
8
9
|
tl_cli/self_update.py,sha256=zLPCSzPQTHRAcTZTBBopRxAs5Bg-B7Ag_7PxfN94QOI,18800
|
|
9
10
|
tl_cli/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
11
|
tl_cli/auth/commands.py,sha256=CkCaKFb-xUwhCeIL92EC4-odiaSpLI1bmgg5tDCrclM,7387
|
|
@@ -22,7 +23,7 @@ tl_cli/commands/bulk_import.py,sha256=d4y1k_lD52LPJcCqXxEmyHIqcIwomZgbjqs1_QxPje
|
|
|
22
23
|
tl_cli/commands/changelog.py,sha256=D1PtDdHpawTlWqUHjKzVmv9yXLSU915UVmI3dZzEwyA,4241
|
|
23
24
|
tl_cli/commands/channels.py,sha256=gs6jHaxi6WjB3D8jnJETFVvkTXpqzlWR-W4JkBeG3oY,20406
|
|
24
25
|
tl_cli/commands/credits.py,sha256=2xCht2e420LmaFBKNdKoMz8GlTh31qSWSlJAnVzoZic,7308
|
|
25
|
-
tl_cli/commands/db.py,sha256=
|
|
26
|
+
tl_cli/commands/db.py,sha256=JaiXLMB_ROkLS-ca6QHyMt-z2lrZlDlj6xqyralIKjQ,7209
|
|
26
27
|
tl_cli/commands/deals.py,sha256=ZK9yneInsC6DXoCPS65oyLoVR0eRW1xdRlEN7oRp1pc,2174
|
|
27
28
|
tl_cli/commands/describe.py,sha256=3lURv4NllM5qPeMEBbejrIxiMzsyTwpJIz211-wuGCU,12362
|
|
28
29
|
tl_cli/commands/doctor.py,sha256=KUKglwhMc7B26XXy_3M0LkHu7wqfFO5T0YPHO1SH1VY,9024
|
|
@@ -30,7 +31,7 @@ tl_cli/commands/matches.py,sha256=K5o6B8FLECp7825dU4W3X8n-wuXvGJz57xpQPXeXQ-0,28
|
|
|
30
31
|
tl_cli/commands/proposals.py,sha256=IXx9DsR7lPWRM4QvKB65t7xKNAK3H6ax9LIlIj8ydEs,2945
|
|
31
32
|
tl_cli/commands/recommender.py,sha256=BZfviKAPhpZah0zaSyH5RDa23fG8QAkUnG5FDhOdi4I,21324
|
|
32
33
|
tl_cli/commands/reports.py,sha256=7CL2atr0Y4XSE8U8kbuZ5-M-m3E9PKNZ0N_0Fbd_OkQ,26388
|
|
33
|
-
tl_cli/commands/schema.py,sha256=
|
|
34
|
+
tl_cli/commands/schema.py,sha256=rmfg5pvKHa5kQi59YBIHCMzBNawmYPi2tBMcZP7Vrxw,6432
|
|
34
35
|
tl_cli/commands/setup.py,sha256=ypimHsp45yYSWwTLywfYtPC2P_45X4arW0K3SFuwEXk,28398
|
|
35
36
|
tl_cli/commands/snapshots.py,sha256=exlxFwMcM1BCiDTcE9dZfaSdRO-sxaTxtWzJmEoUVWI,3434
|
|
36
37
|
tl_cli/commands/sponsorships.py,sha256=PdWCm9E4mrs_qlHtckMp99JmuRP94hthlqoWMi0k4tc,10471
|
|
@@ -39,7 +40,7 @@ tl_cli/commands/whoami.py,sha256=aUXwBRwh1vAGrvz8CKGfHYtEOKJCIDfwrGesKAwYZMk,786
|
|
|
39
40
|
tl_cli/output/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
40
41
|
tl_cli/output/formatter.py,sha256=zWwcg4yovMXLaduxu8skpDjPVLTaGZAmwtYwjpZDg1w,22766
|
|
41
42
|
tl_cli/_plugin/.claude-plugin/marketplace.json,sha256=l56PMmyjfGXNGlV30wRyOAe74B6gJNCVNCxgsBbSNxc,446
|
|
42
|
-
tl_cli/_plugin/.claude-plugin/plugin.json,sha256=
|
|
43
|
+
tl_cli/_plugin/.claude-plugin/plugin.json,sha256=00UrTvRCuztsF2A3nu5I4KUCmjp4FswlBfBBfCEAPiQ,466
|
|
43
44
|
tl_cli/_plugin/agents/tl-analyst.md,sha256=ANAQFO_Qrx2MYOOa2yTsJjhJUYJg7DQUWaHbCeL_2II,6752
|
|
44
45
|
tl_cli/_plugin/agents/youtube-comment-classifier.md,sha256=S5lr_htA98FIX0su8FJ2ntiHfbdK8OB2NQKC4lTnQcw,2178
|
|
45
46
|
tl_cli/_plugin/hooks/hooks.json,sha256=FSWibw1xAjA-suFV3fR8btIb2kQ82LQ08otTr-NpmFw,835
|
|
@@ -91,8 +92,8 @@ tl_cli/_plugin/skills/tl-top-partnerships/SKILL.md,sha256=jOMMr40XRnAZv-oRLeyKJT
|
|
|
91
92
|
tl_cli/_plugin/skills/tl-top-partnerships/scripts/top_partnerships.py,sha256=OqhoyvFe1zrtrStChcam7oPYgFnSbGHC3WAgLhLIC3w,13738
|
|
92
93
|
tl_cli/_plugin/skills/tl-views-guarantee/SKILL.md,sha256=IH7q1WJDWri9TWJMiga1FMGJO_GKSbWwaDS6CVNZ9c0,9270
|
|
93
94
|
tl_cli/_plugin/skills/tl-views-guarantee/scripts/vg.py,sha256=Qp5poinHEqh9374anq0bLtlxj2YL6ipBicaT960-Cws,15825
|
|
94
|
-
thoughtleaders_cli-0.8.
|
|
95
|
-
thoughtleaders_cli-0.8.
|
|
96
|
-
thoughtleaders_cli-0.8.
|
|
97
|
-
thoughtleaders_cli-0.8.
|
|
98
|
-
thoughtleaders_cli-0.8.
|
|
95
|
+
thoughtleaders_cli-0.8.1.dist-info/METADATA,sha256=WChLIRKUDDGwu_3Gagdg_ltJywinX7vGY85KBYjEzF0,18921
|
|
96
|
+
thoughtleaders_cli-0.8.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
97
|
+
thoughtleaders_cli-0.8.1.dist-info/entry_points.txt,sha256=umZp-1BkGkHDG0bNZXpTXrjwW0HGf9IDFN40eAWuuvg,39
|
|
98
|
+
thoughtleaders_cli-0.8.1.dist-info/licenses/LICENSE,sha256=RUfdfLsn6jygiyrnnVUHt6r4IPwr2rbDm9Kixgtu8fo,1071
|
|
99
|
+
thoughtleaders_cli-0.8.1.dist-info/RECORD,,
|
tl_cli/__init__.py
CHANGED
tl_cli/commands/db.py
CHANGED
|
@@ -4,14 +4,23 @@ import json
|
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
|
+
from rich.console import Console
|
|
7
8
|
from tl_cli._typer_utils import AlphaSortedTyperGroup
|
|
8
9
|
|
|
9
10
|
from tl_cli.client.errors import ApiError, handle_api_error
|
|
10
11
|
from tl_cli.client.http import get_client
|
|
11
12
|
from tl_cli.output.formatter import detect_format, output, output_pricing_estimate
|
|
13
|
+
from tl_cli.query_history import query_hash, record_and_check
|
|
12
14
|
|
|
13
15
|
app = typer.Typer(cls=AlphaSortedTyperGroup, help="Raw read-only queries against PostgreSQL, Firebolt, or Elasticsearch (full-access only)")
|
|
14
16
|
|
|
17
|
+
_err = Console(stderr=True)
|
|
18
|
+
|
|
19
|
+
_NO_REPEAT_WARNING_OPTION = typer.Option(
|
|
20
|
+
False, "--no-repeat-warning",
|
|
21
|
+
help="Suppress the warning shown when the same query is re-run repeatedly (also: TL_NO_REPEAT_WARNING=1).",
|
|
22
|
+
)
|
|
23
|
+
|
|
15
24
|
|
|
16
25
|
def _read_query(query: str | None) -> str:
|
|
17
26
|
if query is not None and query != "-":
|
|
@@ -21,6 +30,24 @@ def _read_query(query: str | None) -> str:
|
|
|
21
30
|
return sys.stdin.read()
|
|
22
31
|
|
|
23
32
|
|
|
33
|
+
def _warn_if_repeat(engine: str, query: str, pricing: bool, suppressed: bool) -> None:
|
|
34
|
+
"""Nudge (on stderr) when this exact query keeps being re-run.
|
|
35
|
+
|
|
36
|
+
Detection is local and best-effort; the query always executes.
|
|
37
|
+
"""
|
|
38
|
+
if suppressed:
|
|
39
|
+
return
|
|
40
|
+
count = record_and_check(query_hash(engine, query, pricing))
|
|
41
|
+
if count is None:
|
|
42
|
+
return
|
|
43
|
+
_err.print(
|
|
44
|
+
f"[yellow]Repeat query:[/yellow] this exact query has now run {count} times "
|
|
45
|
+
"in the last 5 minutes. Each run is billed and the results are unlikely to "
|
|
46
|
+
"differ — reuse the earlier output (e.g. save it with `--json > file.json`). "
|
|
47
|
+
"Pass --no-repeat-warning if the repeats are deliberate."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
24
51
|
def _run(path: str, body: dict, fmt: str, title: str, pricing: bool = False) -> None:
|
|
25
52
|
client = get_client()
|
|
26
53
|
try:
|
|
@@ -53,16 +80,23 @@ def pg_cmd(
|
|
|
53
80
|
False, "--pricing",
|
|
54
81
|
help="Estimate the query's credit cost via EXPLAIN without running it (flat 1 credit).",
|
|
55
82
|
),
|
|
83
|
+
no_repeat_warning: bool = _NO_REPEAT_WARNING_OPTION,
|
|
56
84
|
) -> None:
|
|
57
85
|
"""Run a raw PostgreSQL SELECT query.
|
|
58
86
|
|
|
87
|
+
Column names follow the current schema (e.g. subscribers, projected_views,
|
|
88
|
+
scheduled_date — not the older reach / impression / send_date). Run
|
|
89
|
+
`tl schema pg <table>` to see the exact columns your role can query.
|
|
90
|
+
|
|
59
91
|
Examples:
|
|
60
|
-
tl db pg "SELECT
|
|
92
|
+
tl db pg "SELECT channel_name, subscribers, projected_views FROM thoughtleaders_channel ORDER BY subscribers DESC LIMIT 10"
|
|
93
|
+
tl db pg "SELECT id, scheduled_date, publish_status FROM thoughtleaders_adlink LIMIT 20"
|
|
61
94
|
cat query.sql | tl db pg -
|
|
62
95
|
tl db pg "SELECT * FROM thoughtleaders_channel LIMIT 100" --pricing
|
|
63
96
|
"""
|
|
64
97
|
fmt = detect_format(json_output, csv_output, md_output, toon_output)
|
|
65
98
|
sql = _read_query(query)
|
|
99
|
+
_warn_if_repeat("pg", sql, pricing, no_repeat_warning)
|
|
66
100
|
body: dict = {"query": sql}
|
|
67
101
|
if pricing:
|
|
68
102
|
body["pricing"] = True
|
|
@@ -80,6 +114,7 @@ def fb_cmd(
|
|
|
80
114
|
False, "--pricing",
|
|
81
115
|
help="Estimate the query's credit cost without running it (flat 1 credit).",
|
|
82
116
|
),
|
|
117
|
+
no_repeat_warning: bool = _NO_REPEAT_WARNING_OPTION,
|
|
83
118
|
) -> None:
|
|
84
119
|
"""Run a raw Firebolt SELECT query.
|
|
85
120
|
|
|
@@ -91,6 +126,7 @@ def fb_cmd(
|
|
|
91
126
|
"""
|
|
92
127
|
fmt = detect_format(json_output, csv_output, md_output, toon_output)
|
|
93
128
|
sql = _read_query(query)
|
|
129
|
+
_warn_if_repeat("fb", sql, pricing, no_repeat_warning)
|
|
94
130
|
body: dict = {"query": sql}
|
|
95
131
|
if pricing:
|
|
96
132
|
body["pricing"] = True
|
|
@@ -112,6 +148,7 @@ def es_cmd(
|
|
|
112
148
|
False, "--highlight",
|
|
113
149
|
help="Keep ES highlight fragments in each result row. Only meaningful when the query body includes a `highlight` clause; otherwise no-op.",
|
|
114
150
|
),
|
|
151
|
+
no_repeat_warning: bool = _NO_REPEAT_WARNING_OPTION,
|
|
115
152
|
) -> None:
|
|
116
153
|
"""Run a raw Elasticsearch search query.
|
|
117
154
|
|
|
@@ -129,6 +166,7 @@ def es_cmd(
|
|
|
129
166
|
except json.JSONDecodeError as exc:
|
|
130
167
|
raise typer.BadParameter(f"Query is not valid JSON: {exc}") from exc
|
|
131
168
|
|
|
169
|
+
_warn_if_repeat("es", raw, pricing, no_repeat_warning)
|
|
132
170
|
body: dict = {"query": body_query}
|
|
133
171
|
if pricing:
|
|
134
172
|
body["pricing"] = True
|
tl_cli/commands/schema.py
CHANGED
|
@@ -75,6 +75,15 @@ def _try_render_yaml_tree(content: str) -> bool:
|
|
|
75
75
|
if fcomment:
|
|
76
76
|
line.append(f' — {fcomment}', style='dim')
|
|
77
77
|
tree.add(line)
|
|
78
|
+
|
|
79
|
+
# Curated "common traps" for this table (old → new field names, join
|
|
80
|
+
# hints), surfaced as a sub-branch so it's visible during discovery.
|
|
81
|
+
common_traps = fields.get('__common_traps')
|
|
82
|
+
if isinstance(common_traps, list) and common_traps:
|
|
83
|
+
traps_branch = tree.add(Text('common traps', style='bold red'))
|
|
84
|
+
for trap in common_traps:
|
|
85
|
+
traps_branch.add(Text(str(trap), style='dim'))
|
|
86
|
+
|
|
78
87
|
console.print(tree)
|
|
79
88
|
console.print()
|
|
80
89
|
|
tl_cli/query_history.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Local repeat-query detection for `tl db` commands.
|
|
2
|
+
|
|
3
|
+
Agents sometimes re-run the exact same raw query in a tight loop — losing
|
|
4
|
+
the result, or polling data that will not change — and every run is billed.
|
|
5
|
+
This module keeps a small local log of recent query hashes and decides when
|
|
6
|
+
a repeat warning is due. Everything is best-effort by design: state lives
|
|
7
|
+
in a JSON file next to the CLI config, concurrent runs may race it, and any
|
|
8
|
+
I/O or parse failure resets it — a heuristic warning must never break a
|
|
9
|
+
query.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from tl_cli.config import CONFIG_DIR
|
|
18
|
+
|
|
19
|
+
HISTORY_FILE = CONFIG_DIR / "recent_queries.json"
|
|
20
|
+
|
|
21
|
+
# A repeat is the same normalized query seen REPEAT_THRESHOLD+ times inside
|
|
22
|
+
# WINDOW_S. Warnings re-arm after WARN_INTERVAL_S so a long-running loop is
|
|
23
|
+
# nudged about once a minute rather than on every single run.
|
|
24
|
+
WINDOW_S = 300
|
|
25
|
+
REPEAT_THRESHOLD = 3
|
|
26
|
+
WARN_INTERVAL_S = 60
|
|
27
|
+
|
|
28
|
+
ENV_SUPPRESS = "TL_NO_REPEAT_WARNING"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def query_hash(engine: str, query: str, pricing: bool = False) -> str:
|
|
32
|
+
"""Stable digest for an (engine, query) pair.
|
|
33
|
+
|
|
34
|
+
Whitespace runs and letter case don't change what a query does, so they
|
|
35
|
+
don't change the digest. The engine and the --pricing flag do: the same
|
|
36
|
+
text against pg vs fb, or a dry-run vs a real run, are different actions.
|
|
37
|
+
"""
|
|
38
|
+
normalized = " ".join(query.split()).strip().rstrip(";").lower()
|
|
39
|
+
basis = f"{engine}\x00{'pricing' if pricing else 'run'}\x00{normalized}"
|
|
40
|
+
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def record_and_check(digest: str) -> int | None:
|
|
44
|
+
"""Record one run of ``digest``; return the run count when a warning is due.
|
|
45
|
+
|
|
46
|
+
Returns None below the threshold, while a warning from the last
|
|
47
|
+
WARN_INTERVAL_S is still fresh, or when TL_NO_REPEAT_WARNING is set.
|
|
48
|
+
"""
|
|
49
|
+
if os.environ.get(ENV_SUPPRESS):
|
|
50
|
+
return None
|
|
51
|
+
now = time.time()
|
|
52
|
+
state = _load()
|
|
53
|
+
entry = state.get(digest)
|
|
54
|
+
entry = entry if isinstance(entry, dict) else {}
|
|
55
|
+
runs = _recent_runs(entry, now)
|
|
56
|
+
runs.append(now)
|
|
57
|
+
warned_at = entry.get("warned_at")
|
|
58
|
+
warning_fresh = isinstance(warned_at, int | float) and now - warned_at < WARN_INTERVAL_S
|
|
59
|
+
due = len(runs) >= REPEAT_THRESHOLD and not warning_fresh
|
|
60
|
+
state[digest] = {"runs": runs, "warned_at": now if due else warned_at}
|
|
61
|
+
_save(state, now)
|
|
62
|
+
return len(runs) if due else None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _recent_runs(entry: dict, now: float) -> list[float]:
|
|
66
|
+
raw = entry.get("runs")
|
|
67
|
+
if not isinstance(raw, list):
|
|
68
|
+
return []
|
|
69
|
+
return [t for t in raw if isinstance(t, int | float) and now - t < WINDOW_S]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load() -> dict:
|
|
73
|
+
try:
|
|
74
|
+
with open(HISTORY_FILE, encoding="utf-8") as f:
|
|
75
|
+
data = json.load(f)
|
|
76
|
+
return data if isinstance(data, dict) else {}
|
|
77
|
+
except (OSError, ValueError):
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _save(state: dict, now: float) -> None:
|
|
82
|
+
pruned = {}
|
|
83
|
+
for digest, entry in state.items():
|
|
84
|
+
if not isinstance(entry, dict):
|
|
85
|
+
continue
|
|
86
|
+
runs = _recent_runs(entry, now)
|
|
87
|
+
if runs:
|
|
88
|
+
pruned[digest] = {"runs": runs, "warned_at": entry.get("warned_at")}
|
|
89
|
+
try:
|
|
90
|
+
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
|
|
92
|
+
json.dump(pruned, f)
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|