thoughtleaders-cli 0.8.0__tar.gz → 0.8.1__tar.gz
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 → thoughtleaders_cli-0.8.1}/.claude-plugin/plugin.json +1 -1
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/PKG-INFO +1 -1
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/pyproject.toml +1 -1
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/__init__.py +1 -1
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/db.py +39 -1
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/schema.py +9 -0
- thoughtleaders_cli-0.8.1/src/tl_cli/query_history.py +94 -0
- thoughtleaders_cli-0.8.1/tests/test_db_repeat_warning.py +121 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/uv.lock +1 -1
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.claude-plugin/marketplace.json +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.github/dependabot.yml +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.github/workflows/ci.yml +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.github/workflows/cli-integration.yml +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.github/workflows/python-publish.yml +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/.gitignore +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/AGENTS.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/API.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/CLAUDE.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/LICENSE +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/README.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/agents/tl-analyst.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/agents/youtube-comment-classifier.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/hooks/hooks.json +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/hooks/scripts/load-tl-skill.mjs +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/hooks/scripts/post-usage.sh +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/hooks/scripts/pre-check.sh +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/SKILL.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/references/business-glossary.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/references/elasticsearch-schema.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/references/firebolt-schema.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/references/postgres-schema.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/.gitignore +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/SKILL.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/references/scoring.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/report.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/score.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-keyword-research/SKILL.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-keyword-research/references/context-classifier.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-keyword-research/scripts/fetch_context.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-keyword-research/scripts/probe.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-keyword-research/scripts/search_channels.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/SKILL.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/columns_brands.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/columns_channels.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/columns_content.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/report_glossary.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/sortable_columns.json +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/sponsorship_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/widgets.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-top-partnerships/SKILL.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-views-guarantee/SKILL.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-views-guarantee/scripts/vg.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/_completions.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/_typer_utils.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/auth/__init__.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/auth/commands.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/auth/login.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/auth/pkce.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/auth/token_store.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/client/__init__.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/client/errors.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/client/http.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/__init__.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/_comments_common.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/balance.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/brands.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/bulk_import.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/changelog.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/channels.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/credits.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/deals.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/describe.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/doctor.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/matches.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/proposals.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/recommender.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/reports.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/setup.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/snapshots.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/sponsorships.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/uploads.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/whoami.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/config.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/filters.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/hints.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/main.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/output/__init__.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/output/formatter.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/self_update.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/__init__.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_auth.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_brands_winner_channels.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_channels_lookalike.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_describe.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_filters.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_http_auth.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_output.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_reports.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_setup.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests/test_sponsorships.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/AGENTS.md +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/conftest.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/test_balance.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/test_db_es.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/test_db_fb.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/test_db_pg.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/test_schema.py +0 -0
- {thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/tests_cli/test_whoami.py +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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Tests for the local repeat-query warning (`tl db` commands)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from tl_cli import query_history
|
|
6
|
+
from tl_cli.commands import db
|
|
7
|
+
from tl_cli.query_history import query_hash, record_and_check
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _use_tmp_history(monkeypatch, tmp_path):
|
|
11
|
+
monkeypatch.setattr(query_history, "HISTORY_FILE", tmp_path / "recent_queries.json")
|
|
12
|
+
monkeypatch.delenv(query_history.ENV_SUPPRESS, raising=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestQueryHash:
|
|
16
|
+
def test_whitespace_and_case_collapse(self):
|
|
17
|
+
assert query_hash("pg", "SELECT 1\n FROM x;") == query_hash("pg", "select 1 from x")
|
|
18
|
+
|
|
19
|
+
def test_engines_do_not_collide(self):
|
|
20
|
+
assert query_hash("pg", "SELECT 1") != query_hash("fb", "SELECT 1")
|
|
21
|
+
|
|
22
|
+
def test_pricing_dry_run_is_distinct(self):
|
|
23
|
+
assert query_hash("pg", "SELECT 1") != query_hash("pg", "SELECT 1", pricing=True)
|
|
24
|
+
|
|
25
|
+
def test_different_literals_are_different_queries(self):
|
|
26
|
+
# Pagination (OFFSET 0/100/200) must never look like a repeat.
|
|
27
|
+
assert query_hash("pg", "SELECT 1 OFFSET 0") != query_hash("pg", "SELECT 1 OFFSET 100")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestRecordAndCheck:
|
|
31
|
+
def test_warns_on_third_run_only(self, monkeypatch, tmp_path):
|
|
32
|
+
_use_tmp_history(monkeypatch, tmp_path)
|
|
33
|
+
digest = query_hash("pg", "SELECT 1")
|
|
34
|
+
assert record_and_check(digest) is None
|
|
35
|
+
assert record_and_check(digest) is None
|
|
36
|
+
assert record_and_check(digest) == 3
|
|
37
|
+
|
|
38
|
+
def test_warn_once_per_interval(self, monkeypatch, tmp_path):
|
|
39
|
+
_use_tmp_history(monkeypatch, tmp_path)
|
|
40
|
+
digest = query_hash("pg", "SELECT 1")
|
|
41
|
+
for _ in range(3):
|
|
42
|
+
record_and_check(digest)
|
|
43
|
+
# 4th and 5th runs arrive while the warning is still fresh.
|
|
44
|
+
assert record_and_check(digest) is None
|
|
45
|
+
assert record_and_check(digest) is None
|
|
46
|
+
|
|
47
|
+
def test_rewarns_after_interval_elapses(self, monkeypatch, tmp_path):
|
|
48
|
+
_use_tmp_history(monkeypatch, tmp_path)
|
|
49
|
+
digest = query_hash("pg", "SELECT 1")
|
|
50
|
+
now = 1_000_000.0
|
|
51
|
+
monkeypatch.setattr(query_history.time, "time", lambda: now)
|
|
52
|
+
for _ in range(4):
|
|
53
|
+
record_and_check(digest)
|
|
54
|
+
monkeypatch.setattr(
|
|
55
|
+
query_history.time, "time", lambda: now + query_history.WARN_INTERVAL_S + 1
|
|
56
|
+
)
|
|
57
|
+
assert record_and_check(digest) == 5
|
|
58
|
+
|
|
59
|
+
def test_runs_age_out_of_window(self, monkeypatch, tmp_path):
|
|
60
|
+
_use_tmp_history(monkeypatch, tmp_path)
|
|
61
|
+
digest = query_hash("pg", "SELECT 1")
|
|
62
|
+
now = 1_000_000.0
|
|
63
|
+
monkeypatch.setattr(query_history.time, "time", lambda: now)
|
|
64
|
+
record_and_check(digest)
|
|
65
|
+
record_and_check(digest)
|
|
66
|
+
monkeypatch.setattr(
|
|
67
|
+
query_history.time, "time", lambda: now + query_history.WINDOW_S + 1
|
|
68
|
+
)
|
|
69
|
+
# Both earlier runs expired: this is a fresh run 1, not run 3.
|
|
70
|
+
assert record_and_check(digest) is None
|
|
71
|
+
|
|
72
|
+
def test_env_var_suppresses(self, monkeypatch, tmp_path):
|
|
73
|
+
_use_tmp_history(monkeypatch, tmp_path)
|
|
74
|
+
monkeypatch.setenv(query_history.ENV_SUPPRESS, "1")
|
|
75
|
+
digest = query_hash("pg", "SELECT 1")
|
|
76
|
+
for _ in range(5):
|
|
77
|
+
assert record_and_check(digest) is None
|
|
78
|
+
|
|
79
|
+
def test_corrupt_state_file_is_tolerated(self, monkeypatch, tmp_path):
|
|
80
|
+
_use_tmp_history(monkeypatch, tmp_path)
|
|
81
|
+
(tmp_path / "recent_queries.json").write_text("{not json", encoding="utf-8")
|
|
82
|
+
digest = query_hash("pg", "SELECT 1")
|
|
83
|
+
assert record_and_check(digest) is None
|
|
84
|
+
# State was reset and is valid JSON again.
|
|
85
|
+
state = json.loads((tmp_path / "recent_queries.json").read_text(encoding="utf-8"))
|
|
86
|
+
assert digest in state
|
|
87
|
+
|
|
88
|
+
def test_other_hashes_pruned_when_stale(self, monkeypatch, tmp_path):
|
|
89
|
+
_use_tmp_history(monkeypatch, tmp_path)
|
|
90
|
+
now = 1_000_000.0
|
|
91
|
+
monkeypatch.setattr(query_history.time, "time", lambda: now)
|
|
92
|
+
record_and_check(query_hash("pg", "SELECT 1"))
|
|
93
|
+
monkeypatch.setattr(
|
|
94
|
+
query_history.time, "time", lambda: now + query_history.WINDOW_S + 1
|
|
95
|
+
)
|
|
96
|
+
record_and_check(query_hash("pg", "SELECT 2"))
|
|
97
|
+
state = json.loads((tmp_path / "recent_queries.json").read_text(encoding="utf-8"))
|
|
98
|
+
assert list(state) == [query_hash("pg", "SELECT 2")]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TestWarnIfRepeat:
|
|
102
|
+
def test_prints_warning_on_third_run(self, monkeypatch, tmp_path, capsys):
|
|
103
|
+
_use_tmp_history(monkeypatch, tmp_path)
|
|
104
|
+
for _ in range(3):
|
|
105
|
+
db._warn_if_repeat("pg", "SELECT 1", False, False)
|
|
106
|
+
err = capsys.readouterr().err
|
|
107
|
+
assert "Repeat query" in err
|
|
108
|
+
assert "3 times" in err
|
|
109
|
+
assert "--no-repeat-warning" in err
|
|
110
|
+
|
|
111
|
+
def test_flag_suppresses(self, monkeypatch, tmp_path, capsys):
|
|
112
|
+
_use_tmp_history(monkeypatch, tmp_path)
|
|
113
|
+
for _ in range(5):
|
|
114
|
+
db._warn_if_repeat("pg", "SELECT 1", False, True)
|
|
115
|
+
assert "Repeat query" not in capsys.readouterr().err
|
|
116
|
+
|
|
117
|
+
def test_silent_below_threshold(self, monkeypatch, tmp_path, capsys):
|
|
118
|
+
_use_tmp_history(monkeypatch, tmp_path)
|
|
119
|
+
db._warn_if_repeat("pg", "SELECT 1", False, False)
|
|
120
|
+
db._warn_if_repeat("pg", "SELECT 2", False, False)
|
|
121
|
+
assert "Repeat query" not in capsys.readouterr().err
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/references/business-glossary.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/references/elasticsearch-schema.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/references/firebolt-schema.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl/references/postgres-schema.md
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/.gitignore
RENAMED
|
File without changes
|
{thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-channel-authenticity/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-keyword-research/scripts/probe.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-save-report/references/widgets.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/skills/tl-views-guarantee/scripts/vg.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{thoughtleaders_cli-0.8.0 → thoughtleaders_cli-0.8.1}/src/tl_cli/commands/_comments_common.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|