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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.8.0
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=1LIQDuzeCXPfeG6VDqnZBiedaT4NAAxugkO_jWADk-s,112
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=PS2HxB3gxp9j-plQtqOxnWgyVTJgnjcuZSJJ_-C10uY,5453
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=GCBEE4fDatQhVasLKKr7bkGhELRZ0scYm_hUCbDYmuA,5985
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=Ozb2Tc982THa78GZNvX3xvg62HnOH6HiwmZ-j8h5rt4,466
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.0.dist-info/METADATA,sha256=woPiWi6h1T9aCyjiMOGZDDq3ckFsI0yogHLQxFrubVY,18921
95
- thoughtleaders_cli-0.8.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
96
- thoughtleaders_cli-0.8.0.dist-info/entry_points.txt,sha256=umZp-1BkGkHDG0bNZXpTXrjwW0HGf9IDFN40eAWuuvg,39
97
- thoughtleaders_cli-0.8.0.dist-info/licenses/LICENSE,sha256=RUfdfLsn6jygiyrnnVUHt6r4IPwr2rbDm9Kixgtu8fo,1071
98
- thoughtleaders_cli-0.8.0.dist-info/RECORD,,
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
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.8.0"
3
+ __version__ = "0.8.1"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
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 id, name FROM thoughtleaders_brand LIMIT 10 OFFSET 0"
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
 
@@ -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