spice-mcp 0.1.3__py3-none-any.whl → 0.1.5__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,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- import re
5
4
  import time
6
5
  from collections.abc import Mapping, Sequence
7
6
  from typing import Any
@@ -24,6 +23,10 @@ from ...polars_utils import collect_preview
24
23
  from ..http_client import HttpClient, HttpClientConfig
25
24
  from . import extract, urls
26
25
 
26
+ # Use wrapper to avoid FastMCP detecting overloads in extract.query()
27
+ # Note: We still import extract for _determine_input_type and other non-overloaded functions
28
+ from .query_wrapper import execute_query as _execute_dune_query
29
+
27
30
 
28
31
  class DuneAdapter(QueryExecutor, CatalogExplorer):
29
32
  """Thin façade around the vendored extract module."""
@@ -43,11 +46,10 @@ class DuneAdapter(QueryExecutor, CatalogExplorer):
43
46
  self._ensure_api_key()
44
47
  start = time.time()
45
48
  q = request.query
46
- if isinstance(q, str):
47
- q_rewritten = _maybe_rewrite_show_sql(q)
48
- if q_rewritten is not None:
49
- q = q_rewritten
50
- result = extract.query(
49
+ # Use native SHOW statements directly - they're faster than information_schema queries
50
+ # See issue #10: https://github.com/Evan-Kim2028/spice-mcp/issues/10
51
+ # Removed rewrite to avoid performance issues with information_schema queries
52
+ result = _execute_dune_query(
51
53
  query_or_execution=q,
52
54
  verbose=False,
53
55
  refresh=request.refresh,
@@ -123,9 +125,10 @@ class DuneAdapter(QueryExecutor, CatalogExplorer):
123
125
  pass
124
126
 
125
127
  url = urls.get_query_results_url(query_id, parameters=params, csv=False)
128
+ from .user_agent import get_user_agent
126
129
  headers = {
127
130
  "X-Dune-API-Key": self._api_key(),
128
- "User-Agent": extract.get_user_agent(),
131
+ "User-Agent": get_user_agent(),
129
132
  }
130
133
  try:
131
134
  resp = self._http.request("GET", url, headers=headers)
@@ -195,9 +198,11 @@ class DuneAdapter(QueryExecutor, CatalogExplorer):
195
198
  # Internal helpers --------------------------------------------------------------
196
199
  def _run_sql(self, sql: str, *, limit: int | None = None) -> pl.DataFrame:
197
200
  self._ensure_api_key()
198
- sql_eff = _maybe_rewrite_show_sql(sql) or sql
199
- df = extract.query(
200
- sql_eff,
201
+ # Use native SHOW statements directly - they're faster than information_schema queries
202
+ # See issue #10: https://github.com/Evan-Kim2028/spice-mcp/issues/10
203
+ sql_eff = sql
204
+ df = _execute_dune_query(
205
+ query_or_execution=sql_eff,
201
206
  verbose=False,
202
207
  performance="medium",
203
208
  timeout_seconds=self.config.default_timeout_seconds,
@@ -228,28 +233,12 @@ def _build_preview(lf: pl.LazyFrame, columns: list[str], rowcount: int) -> Resul
228
233
 
229
234
 
230
235
  def _maybe_rewrite_show_sql(sql: str) -> str | None:
231
- """Rewrite certain SHOW statements to information_schema SELECTs for portability.
232
-
233
- This allows running discovery-style commands through the parameterized raw SQL
234
- template which expects SELECT statements.
236
+ """DEPRECATED: This function is no longer used.
237
+
238
+ Native SHOW statements are now used directly as they're faster than
239
+ information_schema queries in Dune. See issue #10 for details.
240
+
241
+ This function is kept for backward compatibility but is not called.
235
242
  """
236
- s = sql.strip()
237
- m = re.match(r"^SHOW\s+SCHEMAS\s+LIKE\s+'([^']+)'\s*;?$", s, flags=re.IGNORECASE)
238
- if m:
239
- pat = m.group(1)
240
- return (
241
- "SELECT schema_name AS Schema FROM information_schema.schemata "
242
- f"WHERE schema_name LIKE '{pat}'"
243
- )
244
- if re.match(r"^SHOW\s+SCHEMAS\s*;?$", s, flags=re.IGNORECASE):
245
- return "SELECT schema_name AS Schema FROM information_schema.schemata"
246
-
247
- m = re.match(r"^SHOW\s+TABLES\s+FROM\s+([A-Za-z0-9_\.]+)\s*;?$", s, flags=re.IGNORECASE)
248
- if m:
249
- schema = m.group(1)
250
- return (
251
- "SELECT table_name AS Table FROM information_schema.tables "
252
- f"WHERE table_schema = '{schema}'"
253
- )
254
-
243
+ # Function body kept for reference but not executed
255
244
  return None
@@ -1,9 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import io
4
+ import os
4
5
  import time
5
6
  from typing import TYPE_CHECKING, overload
6
- import os
7
7
 
8
8
  from ..http_client import HttpClient
9
9
  from . import cache as _cache
@@ -289,6 +289,14 @@ def query(
289
289
 
290
290
  # execute or retrieve query
291
291
  if query_id:
292
+ # Check if this is a parameterized query (raw SQL via template or parameterized query)
293
+ # For parameterized queries, results don't exist until execution, so skip GET attempt
294
+ is_parameterized = (
295
+ parameters is not None
296
+ and len(parameters) > 0
297
+ and ('query' in parameters or any(k != 'query' for k in parameters))
298
+ )
299
+
292
300
  if cache and load_from_cache and not refresh:
293
301
  cache_result, cache_execution = _cache.load_from_cache(
294
302
  execute_kwargs, result_kwargs, output_kwargs
@@ -301,7 +309,8 @@ def query(
301
309
  age = get_query_latest_age(**execute_kwargs, verbose=verbose) # type: ignore
302
310
  if age is None or age > max_age:
303
311
  refresh = True
304
- if not refresh:
312
+ # Skip GET results attempt for parameterized queries - they need execution first
313
+ if not refresh and not is_parameterized:
305
314
  df = get_results(**execute_kwargs, **result_kwargs)
306
315
  if df is not None:
307
316
  return process_result(df, execution, **output_kwargs)
@@ -334,9 +343,44 @@ def query(
334
343
  return execution
335
344
 
336
345
 
337
- @overload
338
- @overload
339
- @overload
346
+ if TYPE_CHECKING:
347
+ @overload
348
+ def _process_result(
349
+ df: pl.DataFrame,
350
+ execution: Execution | None,
351
+ execute_kwargs: ExecuteKwargs,
352
+ result_kwargs: RetrievalKwargs,
353
+ cache: bool,
354
+ save_to_cache: bool,
355
+ cache_dir: str | None,
356
+ include_execution: Literal[False],
357
+ ) -> pl.DataFrame: ...
358
+
359
+ @overload
360
+ def _process_result(
361
+ df: pl.DataFrame,
362
+ execution: Execution | None,
363
+ execute_kwargs: ExecuteKwargs,
364
+ result_kwargs: RetrievalKwargs,
365
+ cache: bool,
366
+ save_to_cache: bool,
367
+ cache_dir: str | None,
368
+ include_execution: Literal[True],
369
+ ) -> tuple[pl.DataFrame, Execution]: ...
370
+
371
+ @overload
372
+ def _process_result(
373
+ df: pl.DataFrame,
374
+ execution: Execution | None,
375
+ execute_kwargs: ExecuteKwargs,
376
+ result_kwargs: RetrievalKwargs,
377
+ cache: bool,
378
+ save_to_cache: bool,
379
+ cache_dir: str | None,
380
+ include_execution: bool,
381
+ ) -> pl.DataFrame | tuple[pl.DataFrame, Execution]: ...
382
+
383
+
340
384
  def _process_result(
341
385
  df: pl.DataFrame,
342
386
  execution: Execution | None,
@@ -0,0 +1,86 @@
1
+ """Wrapper for extract.query() to avoid FastMCP overload detection.
2
+
3
+ This module provides a clean interface to extract.query() without exposing
4
+ the @overload decorators that FastMCP detects during runtime validation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping, Sequence
10
+ from typing import Any
11
+
12
+ from ..http_client import HttpClient
13
+ from .types import Execution, Performance, Query
14
+
15
+
16
+ def execute_query(
17
+ query_or_execution: Query | Execution,
18
+ *,
19
+ verbose: bool = True,
20
+ refresh: bool = False,
21
+ max_age: float | None = None,
22
+ parameters: Mapping[str, Any] | None = None,
23
+ api_key: str | None = None,
24
+ performance: Performance = "medium",
25
+ poll: bool = True,
26
+ poll_interval: float = 1.0,
27
+ timeout_seconds: float | None = None,
28
+ limit: int | None = None,
29
+ offset: int | None = None,
30
+ sample_count: int | None = None,
31
+ sort_by: str | None = None,
32
+ columns: Sequence[str] | None = None,
33
+ extras: Mapping[str, Any] | None = None,
34
+ types: Sequence[type] | Mapping[str, type] | None = None,
35
+ all_types: Sequence[type] | Mapping[str, type] | None = None,
36
+ cache: bool = True,
37
+ cache_dir: str | None = None,
38
+ save_to_cache: bool = True,
39
+ load_from_cache: bool = True,
40
+ include_execution: bool = False,
41
+ http_client: HttpClient | None = None,
42
+ ) -> Any:
43
+ """
44
+ Execute a Dune query without exposing overloads to FastMCP.
45
+
46
+ This is a wrapper around extract.query() that has a single, non-overloaded
47
+ signature. FastMCP won't detect overloads when inspecting this function.
48
+ """
49
+ # Import here to avoid FastMCP seeing overloads during module import
50
+ from . import extract
51
+
52
+ # Call the actual query function - FastMCP won't trace through this wrapper
53
+ try:
54
+ return extract.query(
55
+ query_or_execution=query_or_execution,
56
+ verbose=verbose,
57
+ refresh=refresh,
58
+ max_age=max_age,
59
+ parameters=parameters,
60
+ api_key=api_key,
61
+ performance=performance,
62
+ poll=poll,
63
+ poll_interval=poll_interval,
64
+ timeout_seconds=timeout_seconds,
65
+ limit=limit,
66
+ offset=offset,
67
+ sample_count=sample_count,
68
+ sort_by=sort_by,
69
+ columns=columns,
70
+ extras=extras,
71
+ types=types,
72
+ all_types=all_types,
73
+ cache=cache,
74
+ cache_dir=cache_dir,
75
+ save_to_cache=save_to_cache,
76
+ load_from_cache=load_from_cache,
77
+ include_execution=include_execution,
78
+ http_client=http_client,
79
+ )
80
+ except NotImplementedError as exc:
81
+ # Provide additional context to help diagnose overload issues
82
+ raise RuntimeError(
83
+ "Underlying extract.query() raised NotImplementedError. "
84
+ "This suggests we're calling an overloaded stub."
85
+ ) from exc
86
+
@@ -0,0 +1,9 @@
1
+ """User agent utility to avoid importing overloaded functions."""
2
+
3
+ ADAPTER_VERSION = "0.1.4"
4
+
5
+
6
+ def get_user_agent() -> str:
7
+ """Get user agent string for HTTP requests."""
8
+ return f"spice-mcp/{ADAPTER_VERSION}"
9
+
@@ -133,11 +133,43 @@ class SpellbookExplorer(CatalogExplorer):
133
133
  if not schema_yml.exists():
134
134
  schema_yml = sql_file.parent.parent / "schema.yml"
135
135
 
136
+ # Parse dbt config to get actual Dune table name
137
+ config = self._parse_dbt_config(sql_file)
138
+
139
+ # Ignore templated dbt config values like "{{ target.schema }}"
140
+ def _is_templated(val: Any) -> bool:
141
+ try:
142
+ s = str(val)
143
+ except Exception:
144
+ return False
145
+ return "{{" in s and "}}" in s
146
+
147
+ raw_schema = config.get("schema")
148
+ raw_alias = config.get("alias")
149
+
150
+ dune_schema = (
151
+ raw_schema.strip() if isinstance(raw_schema, str) else raw_schema
152
+ )
153
+ dune_alias = (
154
+ raw_alias.strip() if isinstance(raw_alias, str) else raw_alias
155
+ )
156
+
157
+ # Fall back to original names when values are templated or empty
158
+ if not dune_schema or _is_templated(dune_schema):
159
+ dune_schema = schema_name
160
+ if not dune_alias or _is_templated(dune_alias):
161
+ dune_alias = model_name
162
+
163
+ dune_table = f"{dune_schema}.{dune_alias}"
164
+
136
165
  models[schema_name].append({
137
166
  "name": model_name,
138
167
  "file": sql_file,
139
168
  "schema_yml": schema_yml if schema_yml.exists() else None,
140
169
  "schema": schema_name,
170
+ "dune_schema": dune_schema,
171
+ "dune_alias": dune_alias,
172
+ "dune_table": dune_table,
141
173
  })
142
174
 
143
175
  self._models_cache = models
@@ -264,6 +296,58 @@ class SpellbookExplorer(CatalogExplorer):
264
296
 
265
297
  return []
266
298
 
299
+ def _parse_dbt_config(self, sql_file: Path) -> dict[str, str]:
300
+ """
301
+ Parse dbt config block from SQL file to extract schema and alias.
302
+
303
+ Looks for patterns like:
304
+ {{ config(schema='sui_walrus', alias='base_table') }}
305
+ {{ config(schema="sui_walrus", alias="base_table") }}
306
+
307
+ Returns dict with 'schema' and 'alias' keys, or empty dict if not found.
308
+ """
309
+ try:
310
+ with open(sql_file, encoding="utf-8") as f:
311
+ sql = f.read()
312
+
313
+ # Match dbt config block: {{ config(...) }}
314
+ # Use non-greedy match to get first config block
315
+ config_match = re.search(
316
+ r"{{\s*config\s*\((.*?)\)\s*}}",
317
+ sql,
318
+ re.IGNORECASE | re.DOTALL,
319
+ )
320
+
321
+ if not config_match:
322
+ return {}
323
+
324
+ config_content = config_match.group(1)
325
+ result: dict[str, str] = {}
326
+
327
+ # Extract schema parameter (supports single and double quotes)
328
+ schema_match = re.search(
329
+ r"schema\s*=\s*['\"]([^'\"]+)['\"]",
330
+ config_content,
331
+ re.IGNORECASE,
332
+ )
333
+ if schema_match:
334
+ result["schema"] = schema_match.group(1)
335
+
336
+ # Extract alias parameter (supports single and double quotes)
337
+ alias_match = re.search(
338
+ r"alias\s*=\s*['\"]([^'\"]+)['\"]",
339
+ config_content,
340
+ re.IGNORECASE,
341
+ )
342
+ if alias_match:
343
+ result["alias"] = alias_match.group(1)
344
+
345
+ return result
346
+ except Exception:
347
+ # On any error (file read, parsing, etc.), return empty dict
348
+ # This allows fallback to using schema_name and model_name
349
+ return {}
350
+
267
351
  def _parse_sql_columns(self, sql_file: Path) -> list[TableColumn]:
268
352
  """Parse SQL file to extract column names from SELECT statements."""
269
353
  try:
@@ -310,4 +394,3 @@ class SpellbookExplorer(CatalogExplorer):
310
394
  pass
311
395
 
312
396
  return []
313
-