focus-mcp 0.2.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.
- focus_mcp/__init__.py +10 -0
- focus_mcp/__main__.py +6 -0
- focus_mcp/config.py +81 -0
- focus_mcp/data_loading.py +266 -0
- focus_mcp/paths.py +22 -0
- focus_mcp/queries.py +215 -0
- focus_mcp/resources/queries/focus_use_cases.yaml +1481 -0
- focus_mcp/resources/queries/focus_use_cases_adjustments.yaml +306 -0
- focus_mcp/resources/specifications/attributes.yaml +54 -0
- focus_mcp/resources/specifications/columns.yaml +513 -0
- focus_mcp/server.py +880 -0
- focus_mcp/spec_loader.py +268 -0
- focus_mcp/storage_backends.py +178 -0
- focus_mcp-0.2.0.dist-info/METADATA +412 -0
- focus_mcp-0.2.0.dist-info/RECORD +18 -0
- focus_mcp-0.2.0.dist-info/WHEEL +4 -0
- focus_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- focus_mcp-0.2.0.dist-info/licenses/LICENSE +175 -0
focus_mcp/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""FOCUS MCP server: query FOCUS billing data over the Model Context Protocol."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("focus-mcp")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree with no install
|
|
8
|
+
__version__ = "0.0.0.dev0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
focus_mcp/__main__.py
ADDED
focus_mcp/config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
FOCUS MCP Server Configuration - Environment-based settings.
|
|
4
|
+
|
|
5
|
+
This module centralizes all configuration for the FOCUS MCP server,
|
|
6
|
+
providing a single point of control for data paths, FOCUS version,
|
|
7
|
+
and other runtime settings.
|
|
8
|
+
|
|
9
|
+
Configuration is environment-driven to support different deployment
|
|
10
|
+
scenarios (development, staging, production) without code changes.
|
|
11
|
+
All settings have sensible defaults for quick local development.
|
|
12
|
+
|
|
13
|
+
Environment Variables:
|
|
14
|
+
FOCUS_DATA_LOCATION: Path to FOCUS data (local path, s3:// or gs:// URI)
|
|
15
|
+
Default: "data/focus-export"
|
|
16
|
+
Relative local paths are resolved against the working
|
|
17
|
+
directory at startup and reported as absolute.
|
|
18
|
+
|
|
19
|
+
AWS_REGION: AWS region for S3 access
|
|
20
|
+
Default: "us-east-1"
|
|
21
|
+
|
|
22
|
+
AWS_PROFILE: Optional AWS profile for authentication
|
|
23
|
+
Uses credential chain if not specified
|
|
24
|
+
|
|
25
|
+
GCS_HMAC_KEY_ID: GCS HMAC access key for gs:// locations
|
|
26
|
+
(create with: gcloud storage hmac create <service-account>)
|
|
27
|
+
|
|
28
|
+
GCS_HMAC_SECRET: GCS HMAC secret paired with GCS_HMAC_KEY_ID.
|
|
29
|
+
If unset, Application Default Credentials are used
|
|
30
|
+
when gcsfs is installed (gcs extra).
|
|
31
|
+
|
|
32
|
+
FOCUS_VERSION: FOCUS specification version
|
|
33
|
+
Default: "1.0"
|
|
34
|
+
|
|
35
|
+
The configuration supports both absolute and relative paths for data,
|
|
36
|
+
with relative paths resolved from the server's working directory.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import os
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
# Data source configuration
|
|
43
|
+
# Supports local paths, S3 locations, and GCS locations
|
|
44
|
+
# Examples:
|
|
45
|
+
# Local: "/path/to/focus/data" or "data/focus-export"
|
|
46
|
+
# S3: "s3://bucket-name/path/to/focus/data"
|
|
47
|
+
# GCS: "gs://bucket-name/path/to/focus/data"
|
|
48
|
+
#
|
|
49
|
+
# For S3, authentication is handled automatically via AWS credential chain:
|
|
50
|
+
# - IAM roles (EC2, ECS, Lambda)
|
|
51
|
+
# - Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
|
|
52
|
+
# - AWS CLI profiles (AWS_PROFILE)
|
|
53
|
+
# - Instance metadata service
|
|
54
|
+
#
|
|
55
|
+
# For GCS, authentication is tiered (see storage_backends.py):
|
|
56
|
+
# - HMAC env vars (GCS_HMAC_KEY_ID, GCS_HMAC_SECRET) if set
|
|
57
|
+
# - Application Default Credentials via gcsfs (gcs extra) otherwise
|
|
58
|
+
# - Keyless access as last resort (public buckets only)
|
|
59
|
+
def _resolve_location(location: str) -> str:
|
|
60
|
+
"""Make local paths absolute; leave remote URIs exactly as given.
|
|
61
|
+
|
|
62
|
+
A relative path resolved against an MCP client's working directory is not
|
|
63
|
+
something a user can debug from an error message. Remote URIs must survive
|
|
64
|
+
verbatim: Path() would collapse the double slash in "s3://bucket".
|
|
65
|
+
"""
|
|
66
|
+
if "://" in location:
|
|
67
|
+
return location
|
|
68
|
+
return str(Path(location).expanduser().resolve())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
DATA_LOCATION = _resolve_location(os.getenv("FOCUS_DATA_LOCATION", "data/focus-export"))
|
|
72
|
+
|
|
73
|
+
# AWS Configuration (optional)
|
|
74
|
+
# Region for S3 access - defaults to us-east-1 if not specified
|
|
75
|
+
AWS_REGION = os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", "us-east-1"))
|
|
76
|
+
|
|
77
|
+
# FOCUS specification version
|
|
78
|
+
# Determines which queries are available based on their focus_versions field
|
|
79
|
+
# Queries are filtered from the unified YAML file at runtime
|
|
80
|
+
# This allows supporting multiple FOCUS versions with version-specific query sets
|
|
81
|
+
FOCUS_VERSION = os.getenv("FOCUS_VERSION", "1.0")
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Data Loading Module - How focus_data_table sources its Parquet files.
|
|
4
|
+
|
|
5
|
+
Two strategies, picked at view-creation time:
|
|
6
|
+
|
|
7
|
+
1. Manifest-driven, for AWS BCM Data Exports. AWS writes
|
|
8
|
+
{location}/metadata/<PARTITION>/<export>-Manifest.json once per
|
|
9
|
+
billing period and rewrites it on every delivery; its dataFiles array
|
|
10
|
+
lists the full URIs of exactly the current delivery. Globbing such an
|
|
11
|
+
export is wrong: the prefix also holds blanked OVERWRITE_REPORT
|
|
12
|
+
chunks (zero-byte Parquet that crashes read_parquet), superseded
|
|
13
|
+
CREATE_NEW_REPORT executions, and partition folders orphaned when AWS
|
|
14
|
+
switches the casing of billing_period= (which also makes DuckDB's
|
|
15
|
+
Hive inference throw). None of those appear in a manifest, so reading
|
|
16
|
+
the manifests needs no special-casing of any of them.
|
|
17
|
+
2. Glob fallback, for everything else - bundled sample data, BigQuery
|
|
18
|
+
FOCUS exports written to GCS, arbitrary directories - which has no
|
|
19
|
+
metadata/ prefix. An export whose manifests cannot be read is an
|
|
20
|
+
error rather than a reason to glob it: the manifests are proof that
|
|
21
|
+
globbing would double-count.
|
|
22
|
+
|
|
23
|
+
Both strategies run over the connection the storage backend prepared, so
|
|
24
|
+
they behave the same on local paths, s3:// and gs://.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import re
|
|
29
|
+
|
|
30
|
+
import duckdb
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Exactly one directory level: partition-level manifests only. Manifests
|
|
35
|
+
# of individual CREATE_NEW_REPORT executions live a level deeper, and the
|
|
36
|
+
# sibling <export>-Manifest-FOCUS.json does not end in "Manifest.json".
|
|
37
|
+
MANIFEST_GLOB = "{location}/metadata/*/*Manifest.json"
|
|
38
|
+
|
|
39
|
+
# AWS documents BILLING_PERIOD= but has delivered billing_period= too,
|
|
40
|
+
# so both the discovery filter and the derived column ignore casing.
|
|
41
|
+
PARTITION_DIR_PATTERN = re.compile(r"billing_period=\d{4}-\d{2}", re.IGNORECASE)
|
|
42
|
+
BILLING_PERIOD_EXPR = (
|
|
43
|
+
r"regexp_extract(filename, '(?i)billing_period=(\d{4}-\d{2})', 1)"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# dataFiles holds the URIs of the bucket the export was delivered to. A
|
|
47
|
+
# copy of that export (aws s3 sync into a directory, a mirror in another
|
|
48
|
+
# bucket) keeps AWS's data/<PARTITION>/[<execution>/]<file> layout but not
|
|
49
|
+
# those URIs, so only the layout tail is trusted.
|
|
50
|
+
DATA_FILE_TAIL_PATTERN = re.compile(
|
|
51
|
+
r"(?:^|/)(data/billing_period=\d{4}-\d{2}/.+)$", re.IGNORECASE
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _sql_literal(value: str) -> str:
|
|
56
|
+
"""Quote a string for inlining: views cannot carry bound parameters."""
|
|
57
|
+
escaped = value.replace("'", "''")
|
|
58
|
+
return f"'{escaped}'"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_partition_manifest(path: str) -> bool:
|
|
62
|
+
"""Whether the manifest sits directly in a billing-period directory."""
|
|
63
|
+
segments = path.split("/")
|
|
64
|
+
return len(segments) >= 2 and bool(
|
|
65
|
+
PARTITION_DIR_PATTERN.fullmatch(segments[-2])
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def discover_manifests(
|
|
70
|
+
conn: duckdb.DuckDBPyConnection, location: str
|
|
71
|
+
) -> list[str]:
|
|
72
|
+
"""Return the partition-level export manifests under the location.
|
|
73
|
+
|
|
74
|
+
A location that is not an export simply globs to nothing. An error
|
|
75
|
+
means the metadata/ prefix could not be listed, which must not be
|
|
76
|
+
read as "no manifests here": that silently downgrades an export to
|
|
77
|
+
the glob, so it propagates instead.
|
|
78
|
+
"""
|
|
79
|
+
pattern = MANIFEST_GLOB.format(location=location)
|
|
80
|
+
rows = conn.execute("SELECT file FROM glob(?)", [pattern]).fetchall()
|
|
81
|
+
return sorted(p for (p,) in rows if _is_partition_manifest(p))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def read_manifest_data_files(
|
|
85
|
+
conn: duckdb.DuckDBPyConnection, manifest: str
|
|
86
|
+
) -> list[str]:
|
|
87
|
+
"""Return the dataFiles URIs of one manifest, [] if it can't be read."""
|
|
88
|
+
try:
|
|
89
|
+
rows = conn.execute(
|
|
90
|
+
"SELECT dataFiles FROM read_json_auto(?)", [manifest]
|
|
91
|
+
).fetchall()
|
|
92
|
+
except duckdb.Error as e:
|
|
93
|
+
logger.warning("Skipping unreadable manifest %s: %s", manifest, e)
|
|
94
|
+
return []
|
|
95
|
+
# dataFiles is a flat array of URIs; anything else isn't a file path
|
|
96
|
+
return [
|
|
97
|
+
entry
|
|
98
|
+
for (data_files,) in rows
|
|
99
|
+
for entry in (data_files or [])
|
|
100
|
+
if isinstance(entry, str)
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def rebase_data_file(location: str, uri: str) -> str:
|
|
105
|
+
"""Point a dataFiles URI at the location the export is read from.
|
|
106
|
+
|
|
107
|
+
Reading the export where it was delivered rebases it onto itself; a
|
|
108
|
+
copy of it is read from the copy instead of from the original bucket.
|
|
109
|
+
"""
|
|
110
|
+
match = DATA_FILE_TAIL_PATTERN.search(uri)
|
|
111
|
+
if not match:
|
|
112
|
+
# Layout AWS does not write: the manifest's own URI is all we have
|
|
113
|
+
logger.warning("Unexpected data file path in manifest: %s", uri)
|
|
114
|
+
return uri
|
|
115
|
+
return f"{location}/{match.group(1)}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def collect_data_files(
|
|
119
|
+
conn: duckdb.DuckDBPyConnection, manifests: list[str], location: str
|
|
120
|
+
) -> list[str]:
|
|
121
|
+
"""Union the manifests' dataFiles, deduped, in discovery order."""
|
|
122
|
+
files: list[str] = []
|
|
123
|
+
for manifest in manifests:
|
|
124
|
+
files.extend(
|
|
125
|
+
rebase_data_file(location, uri)
|
|
126
|
+
for uri in read_manifest_data_files(conn, manifest)
|
|
127
|
+
)
|
|
128
|
+
return list(dict.fromkeys(files))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def manifest_view_sql(data_files: list[str]) -> str:
|
|
132
|
+
"""View over an explicit file list, with billing_period derived.
|
|
133
|
+
|
|
134
|
+
union_by_name absorbs schema drift between billing periods (FOCUS 1.0
|
|
135
|
+
months next to 1.2 months); Hive inference stays off because the file
|
|
136
|
+
list already excludes the orphaned partitions it would trip over.
|
|
137
|
+
"""
|
|
138
|
+
file_list = ", ".join(_sql_literal(f) for f in data_files)
|
|
139
|
+
return f"""
|
|
140
|
+
CREATE OR REPLACE VIEW focus_data_table AS
|
|
141
|
+
SELECT * EXCLUDE (filename), {BILLING_PERIOD_EXPR} AS billing_period
|
|
142
|
+
FROM read_parquet(
|
|
143
|
+
[{file_list}],
|
|
144
|
+
union_by_name=true,
|
|
145
|
+
hive_partitioning=false,
|
|
146
|
+
filename=true
|
|
147
|
+
)
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def glob_view_sql(location: str, hive_partitioning: bool) -> str:
|
|
152
|
+
"""View over every Parquet file under the location.
|
|
153
|
+
|
|
154
|
+
union_by_name absorbs schema drift between billing periods here for
|
|
155
|
+
the same reason it does in manifest_view_sql.
|
|
156
|
+
"""
|
|
157
|
+
pattern = _sql_literal(f"{location}/**/*.parquet")
|
|
158
|
+
if hive_partitioning:
|
|
159
|
+
return f"""
|
|
160
|
+
CREATE OR REPLACE VIEW focus_data_table AS
|
|
161
|
+
SELECT * FROM read_parquet(
|
|
162
|
+
{pattern}, union_by_name=true, hive_partitioning=true
|
|
163
|
+
)
|
|
164
|
+
"""
|
|
165
|
+
# Retry path: inference is off, so billing_period is derived from the
|
|
166
|
+
# path instead of being dropped from the view.
|
|
167
|
+
return f"""
|
|
168
|
+
CREATE OR REPLACE VIEW focus_data_table AS
|
|
169
|
+
SELECT * EXCLUDE (filename), {BILLING_PERIOD_EXPR} AS billing_period
|
|
170
|
+
FROM read_parquet(
|
|
171
|
+
{pattern},
|
|
172
|
+
union_by_name=true,
|
|
173
|
+
hive_partitioning=false,
|
|
174
|
+
filename=true
|
|
175
|
+
)
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_readable(conn: duckdb.DuckDBPyConnection, path: str) -> bool:
|
|
180
|
+
"""Whether DuckDB can still see a file a manifest points at."""
|
|
181
|
+
try:
|
|
182
|
+
return bool(
|
|
183
|
+
conn.execute("SELECT count(*) FROM glob(?)", [path]).fetchone()[0]
|
|
184
|
+
)
|
|
185
|
+
except duckdb.Error:
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _create_manifest_view(
|
|
190
|
+
conn: duckdb.DuckDBPyConnection, location: str, manifests: list[str]
|
|
191
|
+
) -> str:
|
|
192
|
+
"""Create the view from the export's own manifests."""
|
|
193
|
+
data_files = collect_data_files(conn, manifests, location)
|
|
194
|
+
if not data_files:
|
|
195
|
+
# The manifests are proof this is an export, and globbing an
|
|
196
|
+
# export double-counts it, so there is nothing to fall back to.
|
|
197
|
+
raise RuntimeError(
|
|
198
|
+
f"Found {len(manifests)} export manifest(s) under {location} but "
|
|
199
|
+
"no data file in any of them; the export cannot be loaded "
|
|
200
|
+
"without readable manifests"
|
|
201
|
+
)
|
|
202
|
+
logger.info(
|
|
203
|
+
"Loading FOCUS data from %d export manifest(s): %d file(s)",
|
|
204
|
+
len(manifests),
|
|
205
|
+
len(data_files),
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
conn.execute(manifest_view_sql(data_files))
|
|
209
|
+
except duckdb.IOException as e:
|
|
210
|
+
# A manifest outlives its data when a period is expired or cleaned
|
|
211
|
+
# up by hand. Serve the periods that are left rather than nothing.
|
|
212
|
+
present = [f for f in data_files if _is_readable(conn, f)]
|
|
213
|
+
if not present or len(present) == len(data_files):
|
|
214
|
+
raise
|
|
215
|
+
logger.warning(
|
|
216
|
+
"%d of %d manifested file(s) under %s cannot be read (%s); "
|
|
217
|
+
"loading the other %d",
|
|
218
|
+
len(data_files) - len(present),
|
|
219
|
+
len(data_files),
|
|
220
|
+
location,
|
|
221
|
+
e,
|
|
222
|
+
len(present),
|
|
223
|
+
)
|
|
224
|
+
conn.execute(manifest_view_sql(present))
|
|
225
|
+
return "manifest-partial"
|
|
226
|
+
return "manifest"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _create_glob_view(conn: duckdb.DuckDBPyConnection, location: str) -> str:
|
|
230
|
+
"""Create the view from every Parquet file under the location."""
|
|
231
|
+
logger.info(
|
|
232
|
+
"No export manifests under %s; loading every Parquet file "
|
|
233
|
+
"under the location",
|
|
234
|
+
location,
|
|
235
|
+
)
|
|
236
|
+
try:
|
|
237
|
+
conn.execute(glob_view_sql(location, hive_partitioning=True))
|
|
238
|
+
except duckdb.BinderException as e:
|
|
239
|
+
if "hive partition" not in str(e).lower():
|
|
240
|
+
raise
|
|
241
|
+
# Mixed partition-key casings, no manifests to disambiguate them
|
|
242
|
+
logger.warning(
|
|
243
|
+
"Hive partition mismatch under %s (%s); loading without "
|
|
244
|
+
"partition inference",
|
|
245
|
+
location,
|
|
246
|
+
e,
|
|
247
|
+
)
|
|
248
|
+
conn.execute(glob_view_sql(location, hive_partitioning=False))
|
|
249
|
+
return "glob-no-hive"
|
|
250
|
+
return "glob"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def create_focus_view(conn: duckdb.DuckDBPyConnection, location: str) -> str:
|
|
254
|
+
"""Create the focus_data_table view over the data at the location.
|
|
255
|
+
|
|
256
|
+
Returns the name of the strategy used, which callers surface: a glob
|
|
257
|
+
over what turns out to be an export is the failure mode this module
|
|
258
|
+
exists to avoid, and it is invisible otherwise.
|
|
259
|
+
|
|
260
|
+
Errors propagate: the caller decides whether to attach a backend hint
|
|
261
|
+
to them.
|
|
262
|
+
"""
|
|
263
|
+
manifests = discover_manifests(conn, location)
|
|
264
|
+
if manifests:
|
|
265
|
+
return _create_manifest_view(conn, location, manifests)
|
|
266
|
+
return _create_glob_view(conn, location)
|
focus_mcp/paths.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Locate data files that ship inside the focus_mcp package.
|
|
2
|
+
|
|
3
|
+
Resources are addressed through importlib.resources rather than __file__ or a
|
|
4
|
+
relative path, so they resolve identically whether the server was started from
|
|
5
|
+
a checkout, a wheel installed by uvx, or the Docker image.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resource_path(*parts: str) -> Path:
|
|
13
|
+
"""Return the absolute path to a file under focus_mcp/resources/.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
parts: Path components below the resources directory, for example
|
|
17
|
+
("specifications", "columns.yaml").
|
|
18
|
+
|
|
19
|
+
The package is always installed unzipped, so this yields a real filesystem
|
|
20
|
+
path that DuckDB and PyYAML can open directly.
|
|
21
|
+
"""
|
|
22
|
+
return Path(str(files("focus_mcp").joinpath("resources", *parts)))
|
focus_mcp/queries.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
FOCUS Query Library - Dynamic query loader for FOCUS billing analytics.
|
|
4
|
+
|
|
5
|
+
This module provides a structured way to load and manage predefined analytical
|
|
6
|
+
queries for FOCUS (FinOps Open Cost and Usage Specification) billing data.
|
|
7
|
+
Queries are loaded from a comprehensive JSON file containing all use cases
|
|
8
|
+
from focus.finops.org, with automatic filtering based on the configured
|
|
9
|
+
FOCUS version.
|
|
10
|
+
|
|
11
|
+
The query library provides:
|
|
12
|
+
- Version-specific query filtering (v1.0, v1.1, v1.2)
|
|
13
|
+
- Comprehensive metadata including columns and parameters
|
|
14
|
+
- Parameter descriptions with types and examples
|
|
15
|
+
- Column identification for query validation
|
|
16
|
+
- Source attribution for all queries
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
import yaml
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import List, Dict, Optional
|
|
23
|
+
from . import config
|
|
24
|
+
from .paths import resource_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Query:
|
|
29
|
+
"""
|
|
30
|
+
Represents a single FOCUS analytical query with comprehensive metadata.
|
|
31
|
+
|
|
32
|
+
This class encapsulates all information needed to execute, understand,
|
|
33
|
+
and validate a FOCUS billing query, including the SQL text and metadata.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
name: Human-readable name of the query
|
|
37
|
+
description: Description of what the query analyzes
|
|
38
|
+
query: The SQL text with ? placeholders for parameters
|
|
39
|
+
focus_versions: FOCUS specification versions this query supports
|
|
40
|
+
citation: Source URL from focus.finops.org
|
|
41
|
+
slug: URL-friendly identifier
|
|
42
|
+
"""
|
|
43
|
+
name: str
|
|
44
|
+
description: str
|
|
45
|
+
query: str
|
|
46
|
+
focus_versions: List[str] = field(default_factory=list)
|
|
47
|
+
citation: str = ""
|
|
48
|
+
slug: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class QueryLoader:
|
|
52
|
+
"""
|
|
53
|
+
Manages loading and accessing FOCUS analytical queries from JSON.
|
|
54
|
+
|
|
55
|
+
This class loads all FOCUS use cases from a comprehensive JSON file
|
|
56
|
+
and filters them based on the configured FOCUS version. It provides
|
|
57
|
+
rich metadata for each query including columns, parameters, and descriptions.
|
|
58
|
+
|
|
59
|
+
The loader automatically filters queries at initialization based on
|
|
60
|
+
the FOCUS_VERSION environment variable and provides methods to access
|
|
61
|
+
queries by ID, slug, or iterate through all available queries.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self):
|
|
65
|
+
"""Initialize the query loader and load version-specific queries."""
|
|
66
|
+
self.queries: Dict[str, Query] = {}
|
|
67
|
+
self.adjustments: Dict[str, dict] = {} # Store raw adjustments with comments
|
|
68
|
+
self._load_queries()
|
|
69
|
+
|
|
70
|
+
def _load_queries(self):
|
|
71
|
+
"""
|
|
72
|
+
Load all queries from the YAML file and filter by FOCUS version.
|
|
73
|
+
|
|
74
|
+
The loading process:
|
|
75
|
+
1. Loads the comprehensive YAML file with all use cases
|
|
76
|
+
2. Loads adjustments from focus_query_adjustments.yaml
|
|
77
|
+
3. Applies adjustments by overriding fields
|
|
78
|
+
4. Filters queries based on configured FOCUS_VERSION
|
|
79
|
+
5. Converts YAML data to Query objects with full metadata
|
|
80
|
+
6. Indexes queries by slug for flexible access
|
|
81
|
+
|
|
82
|
+
This approach provides version-specific query sets while maintaining
|
|
83
|
+
all metadata from the focus.finops.org website.
|
|
84
|
+
"""
|
|
85
|
+
# Find the YAML files that ship inside the package
|
|
86
|
+
yaml_file = resource_path("queries", "focus_use_cases.yaml")
|
|
87
|
+
adjustments_file = resource_path("queries", "focus_use_cases_adjustments.yaml")
|
|
88
|
+
|
|
89
|
+
if not yaml_file.exists():
|
|
90
|
+
print(f"Warning: Query file {yaml_file} does not exist", file=sys.stderr)
|
|
91
|
+
print("Run 'python scrape_to_yaml.py' to generate it", file=sys.stderr)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
with open(yaml_file, 'r', encoding='utf-8') as f:
|
|
96
|
+
all_queries = yaml.safe_load(f)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
print(f"Error loading queries from {yaml_file}: {e}", file=sys.stderr)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# Load adjustments if file exists
|
|
102
|
+
if adjustments_file.exists():
|
|
103
|
+
try:
|
|
104
|
+
with open(adjustments_file, 'r', encoding='utf-8') as f:
|
|
105
|
+
self.adjustments = yaml.safe_load(f) or {}
|
|
106
|
+
print(f"Loaded {len(self.adjustments)} query adjustments", file=sys.stderr)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
print(f"Error loading adjustments from {adjustments_file}: {e}", file=sys.stderr)
|
|
109
|
+
self.adjustments = {}
|
|
110
|
+
|
|
111
|
+
# Normalize the configured version (e.g., "1.0" -> "v1.0")
|
|
112
|
+
configured_version = f"v{config.FOCUS_VERSION}"
|
|
113
|
+
|
|
114
|
+
# Process each query
|
|
115
|
+
for key, query_data in all_queries.items():
|
|
116
|
+
# Apply adjustments if they exist for this query
|
|
117
|
+
if key in self.adjustments:
|
|
118
|
+
adjustment = self.adjustments[key]
|
|
119
|
+
# Override fields from adjustment (except fix_comment)
|
|
120
|
+
for field, value in adjustment.items():
|
|
121
|
+
if field != 'fix_comment':
|
|
122
|
+
query_data[field] = value
|
|
123
|
+
|
|
124
|
+
# Filter by FOCUS version
|
|
125
|
+
focus_versions = query_data.get('focus_versions', [])
|
|
126
|
+
if configured_version not in focus_versions:
|
|
127
|
+
continue # Skip queries not compatible with configured version
|
|
128
|
+
|
|
129
|
+
# Create Query object with all metadata
|
|
130
|
+
query = Query(
|
|
131
|
+
name=query_data.get('title', ''),
|
|
132
|
+
description=query_data.get('description', ''),
|
|
133
|
+
query=query_data.get('sql', ''),
|
|
134
|
+
focus_versions=focus_versions,
|
|
135
|
+
citation=query_data.get('source_url', ''),
|
|
136
|
+
slug=query_data.get('slug', key)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Index by slug (key)
|
|
140
|
+
self.queries[key] = query
|
|
141
|
+
|
|
142
|
+
print(f"Loaded {len(self.queries)} queries for FOCUS {config.FOCUS_VERSION}", file=sys.stderr)
|
|
143
|
+
|
|
144
|
+
def get_query(self, query_identifier: str) -> Optional[Query]:
|
|
145
|
+
"""
|
|
146
|
+
Retrieve a specific query by its slug identifier.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
query_identifier: The query slug (underscore-separated key)
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Query object if found, None otherwise
|
|
153
|
+
"""
|
|
154
|
+
return self.queries.get(query_identifier)
|
|
155
|
+
|
|
156
|
+
def list_queries(self) -> List[Dict[str, str]]:
|
|
157
|
+
"""
|
|
158
|
+
List all available queries with basic metadata.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
List of dictionaries with query metadata for display
|
|
162
|
+
"""
|
|
163
|
+
return [
|
|
164
|
+
{
|
|
165
|
+
'slug': query.slug,
|
|
166
|
+
'name': query.name,
|
|
167
|
+
'description': query.description or 'No description available',
|
|
168
|
+
'parameter_count': query.query.count('?'),
|
|
169
|
+
'versions': ', '.join(query.focus_versions)
|
|
170
|
+
}
|
|
171
|
+
for query in self.queries.values()
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
def get_query_info(self, query: Query) -> str:
|
|
175
|
+
"""
|
|
176
|
+
Generate comprehensive information about a query for the LLM.
|
|
177
|
+
|
|
178
|
+
This method creates a detailed description of the query that helps
|
|
179
|
+
the LLM understand what parameters are needed and how to use the query.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
query: The Query object to describe
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Formatted string with complete query information
|
|
186
|
+
"""
|
|
187
|
+
info = []
|
|
188
|
+
info.append(f"Query: {query.name}")
|
|
189
|
+
|
|
190
|
+
if query.description:
|
|
191
|
+
info.append(f"Description: {query.description}")
|
|
192
|
+
|
|
193
|
+
info.append(f"\nFOCUS Versions: {', '.join(query.focus_versions)}")
|
|
194
|
+
|
|
195
|
+
# Just show parameter count from SQL
|
|
196
|
+
param_count = query.query.count('?')
|
|
197
|
+
if param_count > 0:
|
|
198
|
+
info.append(f"\nParameters Required: {param_count}")
|
|
199
|
+
|
|
200
|
+
# Add SQL preview
|
|
201
|
+
sql_preview = query.query[:200] + "..." if len(query.query) > 200 else query.query
|
|
202
|
+
info.append("\nSQL Preview:")
|
|
203
|
+
info.append(sql_preview)
|
|
204
|
+
|
|
205
|
+
# Add source
|
|
206
|
+
if query.citation:
|
|
207
|
+
info.append(f"\nSource: {query.citation}")
|
|
208
|
+
|
|
209
|
+
return "\n".join(info)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# Global query loader instance
|
|
213
|
+
# Initialized at module import to pre-load all available queries
|
|
214
|
+
# This singleton pattern ensures queries are loaded once and cached
|
|
215
|
+
focus_queries = QueryLoader()
|