data-platform-mcp 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.
- data_platform_mcp/__init__.py +12 -0
- data_platform_mcp/__main__.py +4 -0
- data_platform_mcp/clients.py +74 -0
- data_platform_mcp/config.py +443 -0
- data_platform_mcp/diagnostics.py +328 -0
- data_platform_mcp/errors.py +96 -0
- data_platform_mcp/formatting.py +42 -0
- data_platform_mcp/observability.py +232 -0
- data_platform_mcp/provisioning.py +44 -0
- data_platform_mcp/registration.py +35 -0
- data_platform_mcp/scripts/setup-service-account.sh +168 -0
- data_platform_mcp/server.py +154 -0
- data_platform_mcp/tools/__init__.py +1 -0
- data_platform_mcp/tools/discovery_tools.py +310 -0
- data_platform_mcp/tools/environment_tools.py +43 -0
- data_platform_mcp/tools/query_tools.py +182 -0
- data_platform_mcp-0.1.0.dist-info/METADATA +421 -0
- data_platform_mcp-0.1.0.dist-info/RECORD +22 -0
- data_platform_mcp-0.1.0.dist-info/WHEEL +5 -0
- data_platform_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- data_platform_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- data_platform_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Read-only BigQuery MCP server."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
# Read from the installed distribution rather than hardcoding a literal, so
|
|
7
|
+
# `--version` cannot drift from what pyproject.toml declares. That leaves
|
|
8
|
+
# two places to bump for a release -- pyproject.toml and server.json -- and
|
|
9
|
+
# the release workflow refuses a tag where those disagree.
|
|
10
|
+
__version__ = version("data-platform-mcp")
|
|
11
|
+
except PackageNotFoundError: # a source tree that was never installed
|
|
12
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""BigQuery clients, built once per environment and reused.
|
|
2
|
+
|
|
3
|
+
A ``bigquery.Client`` performs credential discovery on construction, so building
|
|
4
|
+
one per tool call adds latency to every query for no benefit.
|
|
5
|
+
|
|
6
|
+
**Impersonation is what makes "read-only" a property of the identity.** The
|
|
7
|
+
SELECT-only guard and the ``readOnlyHint`` annotations are promises about this
|
|
8
|
+
code; pointing the server at a service account holding only
|
|
9
|
+
``roles/bigquery.jobUser`` and a dataset-scoped ``roles/bigquery.dataViewer``
|
|
10
|
+
makes it a fact about the credentials, enforced by IAM whatever the code does
|
|
11
|
+
and whatever the operator's own roles allow. It also moves the dataset
|
|
12
|
+
allowlist from an ``if`` statement in this process to a grant Google enforces.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
|
|
19
|
+
from .config import Environment
|
|
20
|
+
|
|
21
|
+
_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache(maxsize=8)
|
|
25
|
+
def get_credentials(impersonate: str = ""):
|
|
26
|
+
"""Credentials for an environment: ADC, optionally impersonating.
|
|
27
|
+
|
|
28
|
+
The caller running this needs ``roles/iam.serviceAccountTokenCreator`` on
|
|
29
|
+
the target account; without it, impersonation fails at token refresh with a
|
|
30
|
+
403 that names neither the account nor the role, which errors.py translates.
|
|
31
|
+
"""
|
|
32
|
+
import google.auth
|
|
33
|
+
|
|
34
|
+
source, _ = google.auth.default(scopes=_SCOPES)
|
|
35
|
+
if not impersonate:
|
|
36
|
+
return source
|
|
37
|
+
|
|
38
|
+
from google.auth import impersonated_credentials
|
|
39
|
+
|
|
40
|
+
return impersonated_credentials.Credentials(
|
|
41
|
+
source_credentials=source,
|
|
42
|
+
target_principal=impersonate,
|
|
43
|
+
target_scopes=_SCOPES,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@lru_cache(maxsize=8)
|
|
48
|
+
def _client_for(project: str, location: str, impersonate: str):
|
|
49
|
+
from google.cloud import bigquery
|
|
50
|
+
|
|
51
|
+
return bigquery.Client(
|
|
52
|
+
project=project,
|
|
53
|
+
location=location,
|
|
54
|
+
credentials=get_credentials(impersonate),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_bigquery_client(env: Environment):
|
|
59
|
+
"""Return the cached client for this environment."""
|
|
60
|
+
return _client_for(env.project, env.location, env.impersonate)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def reset_clients() -> None:
|
|
64
|
+
"""Drop cached clients and credentials. Used by tests and after a config change.
|
|
65
|
+
|
|
66
|
+
Tolerates either function having been replaced: this runs in test teardown,
|
|
67
|
+
where a test may still have ``get_credentials`` monkeypatched to a plain
|
|
68
|
+
function with no cache. Failing there would report a fixture-ordering
|
|
69
|
+
detail as a test error and hide whatever the test actually found.
|
|
70
|
+
"""
|
|
71
|
+
for cached in (_client_for, get_credentials):
|
|
72
|
+
clear = getattr(cached, "cache_clear", None)
|
|
73
|
+
if clear is not None:
|
|
74
|
+
clear()
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Runtime settings: a registry of named BigQuery environments.
|
|
2
|
+
|
|
3
|
+
One server process answers questions about several targets -- a warehouse and a
|
|
4
|
+
staging copy, or two regions of the same project -- so every tool takes an
|
|
5
|
+
optional ``environment`` argument that is resolved here. Omitting it uses the
|
|
6
|
+
configured default.
|
|
7
|
+
|
|
8
|
+
Configuration comes from, in order of precedence:
|
|
9
|
+
|
|
10
|
+
1. ``BQ_MCP_ENVIRONMENTS``, a JSON object mapping name to settings.
|
|
11
|
+
2. A TOML file at ``~/.config/data-platform-mcp/config.toml`` (or wherever
|
|
12
|
+
``BQ_MCP_CONFIG`` points), which is far kinder than a JSON blob squeezed
|
|
13
|
+
into an environment variable and can be committed and shared by a team.
|
|
14
|
+
3. The original single-project variables (``BQ_PROJECT`` and friends), exposed
|
|
15
|
+
as one environment named ``default``. An existing setup keeps working
|
|
16
|
+
unchanged, which is the point.
|
|
17
|
+
|
|
18
|
+
Two properties matter more than the values:
|
|
19
|
+
|
|
20
|
+
**Nothing is read at import time.** Configuration used to be read at module
|
|
21
|
+
scope, so an unset project killed the process before it could answer
|
|
22
|
+
``initialize`` -- which a client reports only as "server failed to start", with
|
|
23
|
+
no tools and no message the user can act on.
|
|
24
|
+
|
|
25
|
+
**An unknown environment name is an error, never a fallback.** Silently
|
|
26
|
+
resolving a typo to the default would answer a question about one warehouse
|
|
27
|
+
from another, which no reader of the answer could detect.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import tomllib
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from functools import lru_cache
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from .formatting import human_bytes
|
|
40
|
+
|
|
41
|
+
GIB = 1024**3
|
|
42
|
+
|
|
43
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "data-platform-mcp" / "config.toml"
|
|
44
|
+
|
|
45
|
+
# Hard cap on bytes scanned per query. A query estimated above this never runs,
|
|
46
|
+
# even with confirmation.
|
|
47
|
+
DEFAULT_MAX_BYTES_BILLED = 5 * GIB
|
|
48
|
+
|
|
49
|
+
# Soft "this is costly" threshold. Above it, run_query reports the estimate and
|
|
50
|
+
# asks the caller to come back with confirm_expensive=True.
|
|
51
|
+
DEFAULT_WARN_BYTES = 1 * GIB
|
|
52
|
+
|
|
53
|
+
# On-demand price used only to render a friendly cost estimate. BigQuery US
|
|
54
|
+
# on-demand analysis is ~$6.25 per TiB scanned.
|
|
55
|
+
DEFAULT_COST_PER_TIB_USD = 6.25
|
|
56
|
+
|
|
57
|
+
# Default row cap returned to the model so responses stay small.
|
|
58
|
+
DEFAULT_ROW_LIMIT = 200
|
|
59
|
+
|
|
60
|
+
DEFAULT_LOCATION = "US"
|
|
61
|
+
|
|
62
|
+
_PROJECT_ENV_VARS = ("BQ_PROJECT", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT")
|
|
63
|
+
|
|
64
|
+
# Spoken shorthands an agent picks up from a prompt ("check prod"). Listed in
|
|
65
|
+
# both directions so an environment can be named either way, and only applied
|
|
66
|
+
# when they do not collide with a real environment name.
|
|
67
|
+
_BUILTIN_ALIASES = {
|
|
68
|
+
"prod": "production",
|
|
69
|
+
"prd": "production",
|
|
70
|
+
"live": "production",
|
|
71
|
+
"production": "prod",
|
|
72
|
+
"stg": "staging",
|
|
73
|
+
"stage": "staging",
|
|
74
|
+
"staging": "stage",
|
|
75
|
+
"dev": "development",
|
|
76
|
+
"development": "dev",
|
|
77
|
+
"test": "testing",
|
|
78
|
+
"testing": "test",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# With no explicit default, prefer a target where a mistake is cheap over one
|
|
82
|
+
# where it is not, rather than whichever key happened to be listed first.
|
|
83
|
+
_PREFERRED_DEFAULTS = ("staging", "development", "dev", "test", "testing")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class Environment:
|
|
88
|
+
"""One resolvable BigQuery target and the limits applied to it."""
|
|
89
|
+
|
|
90
|
+
name: str
|
|
91
|
+
project: str
|
|
92
|
+
location: str = DEFAULT_LOCATION
|
|
93
|
+
impersonate: str = ""
|
|
94
|
+
dataset_allowlist: frozenset[str] = frozenset()
|
|
95
|
+
max_bytes_billed: int = DEFAULT_MAX_BYTES_BILLED
|
|
96
|
+
warn_bytes: int = DEFAULT_WARN_BYTES
|
|
97
|
+
row_limit: int = DEFAULT_ROW_LIMIT
|
|
98
|
+
cost_per_tib_usd: float = DEFAULT_COST_PER_TIB_USD
|
|
99
|
+
aliases: tuple[str, ...] = field(default_factory=tuple)
|
|
100
|
+
|
|
101
|
+
def dataset_allowed(self, dataset_id: str) -> bool:
|
|
102
|
+
return not self.dataset_allowlist or dataset_id in self.dataset_allowlist
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Settings:
|
|
107
|
+
environments: tuple[Environment, ...]
|
|
108
|
+
default_environment: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _env_int(name: str, default: int) -> int:
|
|
112
|
+
"""Read a positive int from the environment, ignoring unusable values.
|
|
113
|
+
|
|
114
|
+
A typo in a limit must not stop the server from starting; falling back to
|
|
115
|
+
the documented default and carrying on is the safer failure.
|
|
116
|
+
"""
|
|
117
|
+
raw = os.environ.get(name, "").strip()
|
|
118
|
+
if not raw:
|
|
119
|
+
return default
|
|
120
|
+
try:
|
|
121
|
+
value = int(raw)
|
|
122
|
+
except ValueError:
|
|
123
|
+
return default
|
|
124
|
+
return value if value > 0 else default
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _env_float(name: str, default: float) -> float:
|
|
128
|
+
raw = os.environ.get(name, "").strip()
|
|
129
|
+
if not raw:
|
|
130
|
+
return default
|
|
131
|
+
try:
|
|
132
|
+
value = float(raw)
|
|
133
|
+
except ValueError:
|
|
134
|
+
return default
|
|
135
|
+
return value if value > 0 else default
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _as_allowlist(value) -> frozenset[str]:
|
|
139
|
+
"""Accept either a comma-separated string or a list of dataset ids."""
|
|
140
|
+
if not value:
|
|
141
|
+
return frozenset()
|
|
142
|
+
if isinstance(value, str):
|
|
143
|
+
value = value.split(",")
|
|
144
|
+
return frozenset(str(v).strip() for v in value if str(v).strip())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _adc_project() -> str:
|
|
148
|
+
try:
|
|
149
|
+
import google.auth
|
|
150
|
+
|
|
151
|
+
_, project = google.auth.default()
|
|
152
|
+
return str(project) if project else ""
|
|
153
|
+
except Exception:
|
|
154
|
+
return ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _resolve_project() -> str:
|
|
158
|
+
for var in _PROJECT_ENV_VARS:
|
|
159
|
+
value = os.environ.get(var, "").strip()
|
|
160
|
+
if value:
|
|
161
|
+
return value
|
|
162
|
+
# Fall back to the project associated with Application Default Credentials,
|
|
163
|
+
# so a plain `gcloud auth application-default login` is enough to start.
|
|
164
|
+
return _adc_project()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _positive_int(spec: dict, key: str, default: int) -> int:
|
|
168
|
+
try:
|
|
169
|
+
value = int(spec.get(key, default))
|
|
170
|
+
except (TypeError, ValueError):
|
|
171
|
+
return default
|
|
172
|
+
return value if value > 0 else default
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _build_environment(name: str, spec, defaults: dict) -> Environment:
|
|
176
|
+
"""Turn one registry entry into an Environment.
|
|
177
|
+
|
|
178
|
+
A bare string is accepted as a project id, because ``{"prod": "my-project"}``
|
|
179
|
+
is what people write first and there is no reason to reject it.
|
|
180
|
+
"""
|
|
181
|
+
key = str(name).strip().lower()
|
|
182
|
+
if isinstance(spec, str):
|
|
183
|
+
spec = {"project": spec}
|
|
184
|
+
if not isinstance(spec, dict):
|
|
185
|
+
raise RuntimeError(
|
|
186
|
+
f"Environment '{name}' must be an object or a project-id string."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
project = str(spec.get("project", "")).strip()
|
|
190
|
+
if not project:
|
|
191
|
+
raise RuntimeError(f"Environment '{name}' is missing a 'project'.")
|
|
192
|
+
|
|
193
|
+
aliases = spec.get("aliases") or []
|
|
194
|
+
if isinstance(aliases, str):
|
|
195
|
+
aliases = [aliases]
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
cost = float(spec.get("cost_per_tib_usd", defaults["cost_per_tib_usd"]))
|
|
199
|
+
except (TypeError, ValueError):
|
|
200
|
+
cost = defaults["cost_per_tib_usd"]
|
|
201
|
+
|
|
202
|
+
return Environment(
|
|
203
|
+
name=key,
|
|
204
|
+
project=project,
|
|
205
|
+
location=str(spec.get("location") or defaults["location"]).strip(),
|
|
206
|
+
impersonate=str(spec.get("impersonate") or defaults["impersonate"]).strip(),
|
|
207
|
+
dataset_allowlist=(
|
|
208
|
+
_as_allowlist(spec["dataset_allowlist"])
|
|
209
|
+
if "dataset_allowlist" in spec
|
|
210
|
+
else defaults["dataset_allowlist"]
|
|
211
|
+
),
|
|
212
|
+
max_bytes_billed=_positive_int(
|
|
213
|
+
spec, "max_bytes_billed", defaults["max_bytes_billed"]
|
|
214
|
+
),
|
|
215
|
+
warn_bytes=_positive_int(spec, "warn_bytes", defaults["warn_bytes"]),
|
|
216
|
+
row_limit=_positive_int(spec, "row_limit", defaults["row_limit"]),
|
|
217
|
+
cost_per_tib_usd=cost if cost > 0 else defaults["cost_per_tib_usd"],
|
|
218
|
+
aliases=tuple(str(a).strip().lower() for a in aliases if str(a).strip()),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _global_defaults() -> dict:
|
|
223
|
+
"""Values an environment inherits when it does not set its own.
|
|
224
|
+
|
|
225
|
+
These are the original single-project variables, which keeps a pre-existing
|
|
226
|
+
configuration working when environments are introduced around it.
|
|
227
|
+
"""
|
|
228
|
+
return {
|
|
229
|
+
"location": os.environ.get("BQ_LOCATION", "").strip() or DEFAULT_LOCATION,
|
|
230
|
+
"impersonate": os.environ.get("BQ_IMPERSONATE_SERVICE_ACCOUNT", "").strip(),
|
|
231
|
+
"dataset_allowlist": _as_allowlist(os.environ.get("BQ_DATASET_ALLOWLIST", "")),
|
|
232
|
+
"max_bytes_billed": _env_int("BQ_MAX_BYTES_BILLED", DEFAULT_MAX_BYTES_BILLED),
|
|
233
|
+
"warn_bytes": _env_int("BQ_WARN_BYTES", DEFAULT_WARN_BYTES),
|
|
234
|
+
"row_limit": _env_int("BQ_ROW_LIMIT", DEFAULT_ROW_LIMIT),
|
|
235
|
+
"cost_per_tib_usd": _env_float("BQ_COST_PER_TIB_USD", DEFAULT_COST_PER_TIB_USD),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _parse_registry(table: dict) -> tuple[Environment, ...]:
|
|
240
|
+
if not isinstance(table, dict) or not table:
|
|
241
|
+
raise RuntimeError(
|
|
242
|
+
"The environment registry must be a non-empty mapping of name -> "
|
|
243
|
+
'settings, e.g. {"staging": {"project": "..."}}'
|
|
244
|
+
)
|
|
245
|
+
defaults = _global_defaults()
|
|
246
|
+
environments = [
|
|
247
|
+
_build_environment(name, spec, defaults)
|
|
248
|
+
for name, spec in table.items()
|
|
249
|
+
if str(name).strip()
|
|
250
|
+
]
|
|
251
|
+
if not environments:
|
|
252
|
+
raise RuntimeError("The environment registry defined no usable environments.")
|
|
253
|
+
return tuple(environments)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def config_path() -> Path:
|
|
257
|
+
"""Path of the config file, whether or not it exists."""
|
|
258
|
+
override = os.environ.get("BQ_MCP_CONFIG", "").strip()
|
|
259
|
+
return Path(override).expanduser() if override else DEFAULT_CONFIG_PATH
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _load_config_file() -> dict:
|
|
263
|
+
path = config_path()
|
|
264
|
+
try:
|
|
265
|
+
raw = path.read_bytes()
|
|
266
|
+
except FileNotFoundError:
|
|
267
|
+
return {}
|
|
268
|
+
except OSError as exc:
|
|
269
|
+
raise RuntimeError(f"Could not read config file {path}: {exc}") from exc
|
|
270
|
+
try:
|
|
271
|
+
parsed = tomllib.loads(raw.decode("utf-8"))
|
|
272
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
|
273
|
+
raise RuntimeError(f"Config file {path} is not valid TOML: {exc}") from exc
|
|
274
|
+
if not isinstance(parsed, dict):
|
|
275
|
+
raise RuntimeError(f"Config file {path} must define a TOML table.")
|
|
276
|
+
return parsed
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@lru_cache(maxsize=1)
|
|
280
|
+
def get_settings() -> Settings:
|
|
281
|
+
file_config = _load_config_file()
|
|
282
|
+
|
|
283
|
+
raw_registry = os.environ.get("BQ_MCP_ENVIRONMENTS", "").strip()
|
|
284
|
+
if raw_registry:
|
|
285
|
+
try:
|
|
286
|
+
parsed = json.loads(raw_registry)
|
|
287
|
+
except json.JSONDecodeError as exc:
|
|
288
|
+
raise RuntimeError(
|
|
289
|
+
f"BQ_MCP_ENVIRONMENTS is not valid JSON: {exc}"
|
|
290
|
+
) from exc
|
|
291
|
+
environments = _parse_registry(parsed)
|
|
292
|
+
elif file_config.get("environments"):
|
|
293
|
+
table = file_config["environments"]
|
|
294
|
+
if not isinstance(table, dict):
|
|
295
|
+
raise RuntimeError(
|
|
296
|
+
"The [environments] section of the config file must be a table "
|
|
297
|
+
"of named environments, e.g. [environments.warehouse]."
|
|
298
|
+
)
|
|
299
|
+
environments = _parse_registry(table)
|
|
300
|
+
else:
|
|
301
|
+
# Single-environment mode: exactly the previous behaviour.
|
|
302
|
+
defaults = _global_defaults()
|
|
303
|
+
environments = (
|
|
304
|
+
Environment(
|
|
305
|
+
name="default",
|
|
306
|
+
project=_resolve_project(),
|
|
307
|
+
location=defaults["location"],
|
|
308
|
+
impersonate=defaults["impersonate"],
|
|
309
|
+
dataset_allowlist=defaults["dataset_allowlist"],
|
|
310
|
+
max_bytes_billed=defaults["max_bytes_billed"],
|
|
311
|
+
warn_bytes=defaults["warn_bytes"],
|
|
312
|
+
row_limit=defaults["row_limit"],
|
|
313
|
+
cost_per_tib_usd=defaults["cost_per_tib_usd"],
|
|
314
|
+
),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
requested = os.environ.get("BQ_MCP_DEFAULT_ENVIRONMENT", "").strip().lower()
|
|
318
|
+
if not requested:
|
|
319
|
+
requested = str(file_config.get("default_environment", "")).strip().lower()
|
|
320
|
+
|
|
321
|
+
known = {e.name for e in environments}
|
|
322
|
+
if requested and requested not in known:
|
|
323
|
+
raise RuntimeError(
|
|
324
|
+
f"BQ_MCP_DEFAULT_ENVIRONMENT='{requested}' is not one of the "
|
|
325
|
+
f"configured environments: {sorted(known)}"
|
|
326
|
+
)
|
|
327
|
+
if requested:
|
|
328
|
+
default_environment = requested
|
|
329
|
+
else:
|
|
330
|
+
default_environment = next(
|
|
331
|
+
(name for name in _PREFERRED_DEFAULTS if name in known),
|
|
332
|
+
environments[0].name,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return Settings(
|
|
336
|
+
environments=environments, default_environment=default_environment
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@lru_cache(maxsize=1)
|
|
341
|
+
def _lookup_table() -> dict[str, Environment]:
|
|
342
|
+
"""Every accepted spelling of an environment, mapped to it."""
|
|
343
|
+
table: dict[str, Environment] = {}
|
|
344
|
+
for env in get_settings().environments:
|
|
345
|
+
table[env.name] = env
|
|
346
|
+
for alias in env.aliases:
|
|
347
|
+
table.setdefault(alias, env)
|
|
348
|
+
# Let the agent name the project id directly.
|
|
349
|
+
if env.project:
|
|
350
|
+
table.setdefault(env.project.lower(), env)
|
|
351
|
+
for alias, canonical in _BUILTIN_ALIASES.items():
|
|
352
|
+
if canonical in table:
|
|
353
|
+
table.setdefault(alias, table[canonical])
|
|
354
|
+
return table
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def list_environments() -> tuple[Environment, ...]:
|
|
358
|
+
return get_settings().environments
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def resolve_environment(name: str = "") -> Environment:
|
|
362
|
+
"""Resolve a name, alias or project id to an Environment.
|
|
363
|
+
|
|
364
|
+
An empty name yields the default. An unknown one raises a ValueError naming
|
|
365
|
+
the valid options rather than falling back, so a typo can never send a
|
|
366
|
+
question about one warehouse to another.
|
|
367
|
+
"""
|
|
368
|
+
settings = get_settings()
|
|
369
|
+
key = (name or "").strip().lower()
|
|
370
|
+
table = _lookup_table()
|
|
371
|
+
|
|
372
|
+
if not key:
|
|
373
|
+
env = table.get(settings.default_environment)
|
|
374
|
+
if env is None: # pragma: no cover - guarded by get_settings()
|
|
375
|
+
raise RuntimeError("No environments are configured.")
|
|
376
|
+
return env
|
|
377
|
+
|
|
378
|
+
env = table.get(key)
|
|
379
|
+
if env is None:
|
|
380
|
+
valid = sorted({e.name for e in settings.environments})
|
|
381
|
+
raise ValueError(
|
|
382
|
+
f"Unknown environment '{name}'. Configured environments: "
|
|
383
|
+
f"{', '.join(valid)}."
|
|
384
|
+
)
|
|
385
|
+
return env
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def require_environment(name: str = "") -> Environment:
|
|
389
|
+
"""Resolve an environment and insist it has a usable project.
|
|
390
|
+
|
|
391
|
+
Called at the top of every tool rather than at import, so the failure is a
|
|
392
|
+
message the agent can relay instead of a dead process.
|
|
393
|
+
"""
|
|
394
|
+
env = resolve_environment(name)
|
|
395
|
+
if not env.project:
|
|
396
|
+
raise RuntimeError(
|
|
397
|
+
f"Environment '{env.name}' has no BigQuery project configured. Ask "
|
|
398
|
+
"the user to set BQ_PROJECT to the GCP project whose datasets they "
|
|
399
|
+
"want to query, e.g.\n"
|
|
400
|
+
" BQ_PROJECT=my-gcp-project\n"
|
|
401
|
+
"Alternatively, `gcloud auth application-default set-quota-project "
|
|
402
|
+
"PROJECT_ID` associates a project with their credentials."
|
|
403
|
+
)
|
|
404
|
+
return env
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def describe_environments() -> str:
|
|
408
|
+
"""One line per environment, for the server instructions."""
|
|
409
|
+
try:
|
|
410
|
+
settings = get_settings()
|
|
411
|
+
except Exception:
|
|
412
|
+
return ""
|
|
413
|
+
lines = []
|
|
414
|
+
for env in settings.environments:
|
|
415
|
+
if not env.project:
|
|
416
|
+
continue
|
|
417
|
+
marker = " (default)" if env.name == settings.default_environment else ""
|
|
418
|
+
detail = f"project {env.project}, location {env.location}"
|
|
419
|
+
if env.dataset_allowlist:
|
|
420
|
+
detail += f", datasets {', '.join(sorted(env.dataset_allowlist))}"
|
|
421
|
+
lines.append(f"- {env.name}{marker}: {detail}")
|
|
422
|
+
return "\n".join(lines)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def config_summary_lines(env: Environment) -> list[str]:
|
|
426
|
+
"""The effective limits for one environment, for `doctor` to print.
|
|
427
|
+
|
|
428
|
+
Reporting these matters because they are silently overridable: someone
|
|
429
|
+
debugging "why did that query get refused" needs the caps actually in
|
|
430
|
+
force, not the documented defaults.
|
|
431
|
+
"""
|
|
432
|
+
lines = [
|
|
433
|
+
f"warn above: {human_bytes(env.warn_bytes)}",
|
|
434
|
+
f"hard cap: {human_bytes(env.max_bytes_billed)}",
|
|
435
|
+
f"default rows: {env.row_limit}",
|
|
436
|
+
f"price per TiB: ${env.cost_per_tib_usd}",
|
|
437
|
+
f"impersonate: {env.impersonate or '(none — your own credentials)'}",
|
|
438
|
+
]
|
|
439
|
+
if env.dataset_allowlist:
|
|
440
|
+
lines.append("allowlist: " + ", ".join(sorted(env.dataset_allowlist)))
|
|
441
|
+
else:
|
|
442
|
+
lines.append("allowlist: (none — all datasets readable)")
|
|
443
|
+
return lines
|