xp-libs-analytics 0.4.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.
- xp_libs_analytics/__init__.py +18 -0
- xp_libs_analytics/engine/__init__.py +1 -0
- xp_libs_analytics/engine/backend.py +379 -0
- xp_libs_analytics/engine/planner.py +450 -0
- xp_libs_analytics/engine/worker.py +121 -0
- xp_libs_analytics/errors/__init__.py +43 -0
- xp_libs_analytics/errors/catalog.json +75 -0
- xp_libs_analytics/errors/codes.py +81 -0
- xp_libs_analytics/ingest/__init__.py +309 -0
- xp_libs_analytics/interfaces.py +70 -0
- xp_libs_analytics/matching/__init__.py +1 -0
- xp_libs_analytics/matching/compiler.py +344 -0
- xp_libs_analytics/matching/diagnostics.py +115 -0
- xp_libs_analytics/matching/models.py +98 -0
- xp_libs_analytics/matching/normalize.py +16 -0
- xp_libs_analytics/matching/patterns.py +71 -0
- xp_libs_analytics/mcp/__init__.py +1 -0
- xp_libs_analytics/mcp/server.py +248 -0
- xp_libs_analytics/models/__init__.py +654 -0
- xp_libs_analytics/parser/__init__.py +142 -0
- xp_libs_analytics/policy/__init__.py +69 -0
- xp_libs_analytics/py.typed +0 -0
- xp_libs_analytics/registry.py +204 -0
- xp_libs_analytics/sdk/__init__.py +259 -0
- xp_libs_analytics/skills/generic-analytics/SKILL.md +236 -0
- xp_libs_analytics/skills/generic-analytics/examples/README.md +69 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/01_count_all.yaml +8 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/02_filter_and_count.yaml +15 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/03_group_stats_and_topk.yaml +21 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/04_nested_or_predicates.yaml +17 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/05_is_missing.yaml +11 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/06_missing_vs_null.yaml +17 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/07_conditional_aggregation.yaml +19 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/08_ratio_if.yaml +16 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/09_tumbling_window.yaml +23 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/10_sliding_window.yaml +32 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/11_explicit_cast.yaml +16 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/12_text_range_distinct.yaml +23 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/13_summary_statistics.yaml +15 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/14_nested_and_not.yaml +20 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/15_conditional_sum.yaml +13 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/16_mixed_types_cast.yaml +17 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/17_relative_last_hour.yaml +15 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/18_string_exact.yaml +11 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/19_string_normalization.yaml +15 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/20_prefix_suffix.yaml +16 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/21_whole_word.yaml +13 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/22_ilike.yaml +11 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/23_rust_regex.yaml +11 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/24_fuzzy.yaml +17 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/25_multi_field_matching.yaml +24 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/26_conditional_fuzzy.yaml +20 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/27_temporal_fuzzy.yaml +38 -0
- xp_libs_analytics/skills/generic-analytics/examples/queries/28_safe_matching_diagnostics.yaml +22 -0
- xp_libs_analytics/skills/generic-analytics/references/errors.md +55 -0
- xp_libs_analytics/skills/generic-analytics/references/query-language-full.md +306 -0
- xp_libs_analytics/skills/generic-analytics/references/query-language.md +39 -0
- xp_libs_analytics/skills/generic-analytics/references/string-matching-full.md +122 -0
- xp_libs_analytics/skills/generic-analytics/references/string-matching.md +11 -0
- xp_libs_analytics-0.4.0.dist-info/METADATA +199 -0
- xp_libs_analytics-0.4.0.dist-info/RECORD +65 -0
- xp_libs_analytics-0.4.0.dist-info/WHEEL +4 -0
- xp_libs_analytics-0.4.0.dist-info/entry_points.txt +2 -0
- xp_libs_analytics-0.4.0.dist-info/licenses/LICENSE +22 -0
- xp_libs_analytics-0.4.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +175 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Read-only resources and YAML-only tool API for standalone FastMCP 4."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from importlib.resources.abc import Traversable
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from xp_libs_analytics import AnalyticsEngine, AnalyticsError, Limits, Principal
|
|
14
|
+
from xp_libs_analytics.ingest import DatasetStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_server(
|
|
18
|
+
engine: AnalyticsEngine | None = None,
|
|
19
|
+
*,
|
|
20
|
+
uploads: bool = False,
|
|
21
|
+
principal_resolver: Callable[[], Principal] | None = None,
|
|
22
|
+
context_resolver: Callable[[], AnalyticsEngine] | None = None,
|
|
23
|
+
) -> Any:
|
|
24
|
+
from fastmcp import FastMCP
|
|
25
|
+
from fastmcp.server.dependencies import get_context
|
|
26
|
+
from fastmcp.server.middleware import Middleware
|
|
27
|
+
from fastmcp.tools.base import ToolResult
|
|
28
|
+
|
|
29
|
+
server = FastMCP(
|
|
30
|
+
"Generic Analytics",
|
|
31
|
+
instructions=(
|
|
32
|
+
"Read analytics://skill/generic-analytics. Discover analytics_list_datasets. "
|
|
33
|
+
"Author restricted YAML; "
|
|
34
|
+
"describe and validate before querying."
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
limits = engine.limits if engine else Limits()
|
|
38
|
+
store = engine.store if engine else DatasetStore(limits)
|
|
39
|
+
# Default server is stdio/local only. Remote hosts MUST provide an authenticated
|
|
40
|
+
# principal resolver and configure FastMCP's transport authentication.
|
|
41
|
+
if context_resolver is not None and (engine is not None or principal_resolver is not None):
|
|
42
|
+
raise ValueError("Supply a context resolver or an engine/principal resolver")
|
|
43
|
+
if engine is not None and principal_resolver is not None:
|
|
44
|
+
raise ValueError("Use a context resolver to bind authenticated hosted contexts")
|
|
45
|
+
sessions: dict[str, AnalyticsEngine] = {}
|
|
46
|
+
session_lock = threading.Lock()
|
|
47
|
+
launcher_principal = Principal(id="launcher_" + uuid4().hex)
|
|
48
|
+
|
|
49
|
+
def service() -> AnalyticsEngine:
|
|
50
|
+
context = get_context()
|
|
51
|
+
if (
|
|
52
|
+
principal_resolver is None
|
|
53
|
+
and context_resolver is None
|
|
54
|
+
and context.transport in ("sse", "streamable-http")
|
|
55
|
+
):
|
|
56
|
+
raise AnalyticsError("ACCESS_DENIED")
|
|
57
|
+
if context_resolver is not None:
|
|
58
|
+
return context_resolver()
|
|
59
|
+
if engine is not None:
|
|
60
|
+
return engine
|
|
61
|
+
# Modern MCP connections are per-request. Local stdio inherits the
|
|
62
|
+
# launcher's authority; remote identity must come from authenticated hosting.
|
|
63
|
+
principal = principal_resolver() if principal_resolver else launcher_principal
|
|
64
|
+
key = principal.id
|
|
65
|
+
with session_lock:
|
|
66
|
+
if key not in sessions:
|
|
67
|
+
if len(sessions) >= 128:
|
|
68
|
+
raise AnalyticsError("RESOURCE_CONCURRENCY_LIMIT")
|
|
69
|
+
sessions[key] = AnalyticsEngine(
|
|
70
|
+
limits=limits, store=store, principal=principal, isolated=True
|
|
71
|
+
)
|
|
72
|
+
return sessions[key]._authorized_view(principal)
|
|
73
|
+
|
|
74
|
+
def reply(value: dict[str, Any]) -> ToolResult:
|
|
75
|
+
return ToolResult(structured_content=value, is_error=value.get("status") == "ERROR")
|
|
76
|
+
|
|
77
|
+
def guarded(fn: Any) -> Any:
|
|
78
|
+
@wraps(fn)
|
|
79
|
+
def invoke(*args: Any, **kwargs: Any) -> ToolResult:
|
|
80
|
+
try:
|
|
81
|
+
return cast(ToolResult, fn(*args, **kwargs))
|
|
82
|
+
except AnalyticsError as exc:
|
|
83
|
+
return reply(exc.envelope())
|
|
84
|
+
except Exception:
|
|
85
|
+
return reply(AnalyticsError("INTERNAL_ERROR").envelope())
|
|
86
|
+
|
|
87
|
+
return invoke
|
|
88
|
+
|
|
89
|
+
tool_args = {
|
|
90
|
+
"analytics_capabilities": {},
|
|
91
|
+
"analytics_list_datasets": {},
|
|
92
|
+
"analytics_load_json": {"name": str, "data": str},
|
|
93
|
+
"analytics_describe": {"name": str},
|
|
94
|
+
"analytics_validate_query": {"query_yaml": str},
|
|
95
|
+
"analytics_explain_query": {"query_yaml": str},
|
|
96
|
+
"analytics_query": {"query_yaml": str},
|
|
97
|
+
"analytics_unregister_dataset": {"name": str},
|
|
98
|
+
"analytics_error_info": {"code": str},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
class ErrorBoundary(Middleware):
|
|
102
|
+
async def on_call_tool(self, context: Any, call_next: Any) -> Any:
|
|
103
|
+
try:
|
|
104
|
+
expected = tool_args.get(context.message.name)
|
|
105
|
+
arguments = context.message.arguments or {}
|
|
106
|
+
if (
|
|
107
|
+
expected is None
|
|
108
|
+
or set(arguments) != set(expected)
|
|
109
|
+
or any(type(arguments[k]) is not typ for k, typ in expected.items())
|
|
110
|
+
):
|
|
111
|
+
raise AnalyticsError("MCP_INVALID_TOOL_CALL")
|
|
112
|
+
return await call_next(context)
|
|
113
|
+
except AnalyticsError as exc:
|
|
114
|
+
return reply(exc.envelope())
|
|
115
|
+
except Exception:
|
|
116
|
+
return reply(AnalyticsError("INTERNAL_ERROR").envelope())
|
|
117
|
+
|
|
118
|
+
server.add_middleware(ErrorBoundary())
|
|
119
|
+
|
|
120
|
+
@server.tool
|
|
121
|
+
@guarded
|
|
122
|
+
def analytics_capabilities() -> ToolResult:
|
|
123
|
+
"""Discover YAML AST schema, snapshot operations and host-owned limits."""
|
|
124
|
+
return reply(service().capabilities())
|
|
125
|
+
|
|
126
|
+
@server.tool
|
|
127
|
+
@guarded
|
|
128
|
+
def analytics_list_datasets() -> ToolResult:
|
|
129
|
+
"""Discover logical dataset names and authorized metadata in this context."""
|
|
130
|
+
return reply({"datasets": service().list_datasets()})
|
|
131
|
+
|
|
132
|
+
@server.tool
|
|
133
|
+
@guarded
|
|
134
|
+
def analytics_load_json(name: str, data: str) -> ToolResult:
|
|
135
|
+
"""Bind bounded JSON/NDJSON to a unique logical name if uploads are enabled."""
|
|
136
|
+
current = service()
|
|
137
|
+
if not uploads or "upload" not in current.principal.scopes:
|
|
138
|
+
raise AnalyticsError("ACCESS_UPLOAD_DISABLED")
|
|
139
|
+
current.register_records(name, data)
|
|
140
|
+
return reply(current.describe(name))
|
|
141
|
+
|
|
142
|
+
@server.tool
|
|
143
|
+
@guarded
|
|
144
|
+
def analytics_describe(name: str) -> ToolResult:
|
|
145
|
+
"""Discover authorized JSON Pointer fields and quality; returns no sample values."""
|
|
146
|
+
return reply(service().describe(name))
|
|
147
|
+
|
|
148
|
+
@server.tool
|
|
149
|
+
@guarded
|
|
150
|
+
def analytics_validate_query(query_yaml: str) -> ToolResult:
|
|
151
|
+
"""Validate one restricted YAML string through typing, authorization and budgets."""
|
|
152
|
+
return reply(service().validate_query(query_yaml))
|
|
153
|
+
|
|
154
|
+
@server.tool
|
|
155
|
+
@guarded
|
|
156
|
+
def analytics_explain_query(query_yaml: str) -> ToolResult:
|
|
157
|
+
"""Return an approved logical plan and conservative cost without backend execution."""
|
|
158
|
+
return reply(service().explain_query(query_yaml))
|
|
159
|
+
|
|
160
|
+
@server.tool
|
|
161
|
+
@guarded
|
|
162
|
+
def analytics_query(query_yaml: str) -> ToolResult:
|
|
163
|
+
"""Execute one authorized bounded snapshot query authored as restricted YAML text."""
|
|
164
|
+
return reply(service().query_yaml(query_yaml))
|
|
165
|
+
|
|
166
|
+
@server.tool
|
|
167
|
+
@guarded
|
|
168
|
+
def analytics_unregister_dataset(name: str) -> ToolResult:
|
|
169
|
+
"""Remove a caller-context binding; running queries retain pinned snapshots."""
|
|
170
|
+
service().unregister_dataset(name)
|
|
171
|
+
return reply({"status": "OK"})
|
|
172
|
+
|
|
173
|
+
@server.tool
|
|
174
|
+
@guarded
|
|
175
|
+
def analytics_error_info(code: str) -> ToolResult:
|
|
176
|
+
"""Look up the exact canonical error message and repair example."""
|
|
177
|
+
return reply(AnalyticsEngine.error_info(code))
|
|
178
|
+
|
|
179
|
+
bundle = files("xp_libs_analytics").joinpath("skills/generic-analytics")
|
|
180
|
+
# Editable installs have resources at the repository root; wheels bundle them.
|
|
181
|
+
if not bundle.joinpath("SKILL.md").is_file():
|
|
182
|
+
from pathlib import Path
|
|
183
|
+
|
|
184
|
+
bundle = Path(__file__).resolve().parents[3] / "skills/generic-analytics"
|
|
185
|
+
examples: Traversable = Path(__file__).resolve().parents[3] / "examples/queries"
|
|
186
|
+
else:
|
|
187
|
+
examples = bundle.joinpath("examples/queries")
|
|
188
|
+
|
|
189
|
+
@server.resource("analytics://skill/generic-analytics")
|
|
190
|
+
def skill() -> str:
|
|
191
|
+
return bundle.joinpath("SKILL.md").read_text(encoding="utf-8")
|
|
192
|
+
|
|
193
|
+
@server.resource("analytics://query-language/v1")
|
|
194
|
+
def language() -> str:
|
|
195
|
+
full = bundle.joinpath("references/query-language-full.md")
|
|
196
|
+
if full.is_file():
|
|
197
|
+
return full.read_text(encoding="utf-8")
|
|
198
|
+
from pathlib import Path
|
|
199
|
+
|
|
200
|
+
return (Path(__file__).resolve().parents[3] / "docs/query-language.md").read_text(
|
|
201
|
+
encoding="utf-8"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
allowlist = {p.name: p for p in examples.iterdir() if p.name.endswith(".yaml")}
|
|
205
|
+
|
|
206
|
+
@server.resource("analytics://examples/index")
|
|
207
|
+
def example_index() -> str:
|
|
208
|
+
return json.dumps(sorted(allowlist))
|
|
209
|
+
|
|
210
|
+
@server.resource("analytics://string-matching/v1")
|
|
211
|
+
def matching_language() -> str:
|
|
212
|
+
full = bundle.joinpath("references/string-matching-full.md")
|
|
213
|
+
if full.is_file():
|
|
214
|
+
return full.read_text(encoding="utf-8")
|
|
215
|
+
from pathlib import Path
|
|
216
|
+
|
|
217
|
+
return (Path(__file__).resolve().parents[3] / "docs/string-matching.md").read_text(
|
|
218
|
+
encoding="utf-8"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
for name, path in allowlist.items():
|
|
222
|
+
|
|
223
|
+
def register_example(name: str, text: str) -> None:
|
|
224
|
+
@server.resource("analytics://examples/" + name)
|
|
225
|
+
def example() -> str:
|
|
226
|
+
return text
|
|
227
|
+
|
|
228
|
+
register_example(name, path.read_text(encoding="utf-8"))
|
|
229
|
+
return server
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main() -> None:
|
|
233
|
+
parser = argparse.ArgumentParser(
|
|
234
|
+
description="Bounded YAML analytics MCP (stdio; authority inherited from launcher)"
|
|
235
|
+
)
|
|
236
|
+
parser.add_argument(
|
|
237
|
+
"--allow-uploads", action="store_true", help="Enable bounded local JSON uploads"
|
|
238
|
+
)
|
|
239
|
+
args = parser.parse_args()
|
|
240
|
+
try:
|
|
241
|
+
server = create_server(uploads=args.allow_uploads)
|
|
242
|
+
except ImportError:
|
|
243
|
+
raise SystemExit(json.dumps(AnalyticsError("MCP_UNAVAILABLE").envelope())) from None
|
|
244
|
+
server.run(transport="stdio", show_banner=False)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
main()
|