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.
Files changed (65) hide show
  1. xp_libs_analytics/__init__.py +18 -0
  2. xp_libs_analytics/engine/__init__.py +1 -0
  3. xp_libs_analytics/engine/backend.py +379 -0
  4. xp_libs_analytics/engine/planner.py +450 -0
  5. xp_libs_analytics/engine/worker.py +121 -0
  6. xp_libs_analytics/errors/__init__.py +43 -0
  7. xp_libs_analytics/errors/catalog.json +75 -0
  8. xp_libs_analytics/errors/codes.py +81 -0
  9. xp_libs_analytics/ingest/__init__.py +309 -0
  10. xp_libs_analytics/interfaces.py +70 -0
  11. xp_libs_analytics/matching/__init__.py +1 -0
  12. xp_libs_analytics/matching/compiler.py +344 -0
  13. xp_libs_analytics/matching/diagnostics.py +115 -0
  14. xp_libs_analytics/matching/models.py +98 -0
  15. xp_libs_analytics/matching/normalize.py +16 -0
  16. xp_libs_analytics/matching/patterns.py +71 -0
  17. xp_libs_analytics/mcp/__init__.py +1 -0
  18. xp_libs_analytics/mcp/server.py +248 -0
  19. xp_libs_analytics/models/__init__.py +654 -0
  20. xp_libs_analytics/parser/__init__.py +142 -0
  21. xp_libs_analytics/policy/__init__.py +69 -0
  22. xp_libs_analytics/py.typed +0 -0
  23. xp_libs_analytics/registry.py +204 -0
  24. xp_libs_analytics/sdk/__init__.py +259 -0
  25. xp_libs_analytics/skills/generic-analytics/SKILL.md +236 -0
  26. xp_libs_analytics/skills/generic-analytics/examples/README.md +69 -0
  27. xp_libs_analytics/skills/generic-analytics/examples/queries/01_count_all.yaml +8 -0
  28. xp_libs_analytics/skills/generic-analytics/examples/queries/02_filter_and_count.yaml +15 -0
  29. xp_libs_analytics/skills/generic-analytics/examples/queries/03_group_stats_and_topk.yaml +21 -0
  30. xp_libs_analytics/skills/generic-analytics/examples/queries/04_nested_or_predicates.yaml +17 -0
  31. xp_libs_analytics/skills/generic-analytics/examples/queries/05_is_missing.yaml +11 -0
  32. xp_libs_analytics/skills/generic-analytics/examples/queries/06_missing_vs_null.yaml +17 -0
  33. xp_libs_analytics/skills/generic-analytics/examples/queries/07_conditional_aggregation.yaml +19 -0
  34. xp_libs_analytics/skills/generic-analytics/examples/queries/08_ratio_if.yaml +16 -0
  35. xp_libs_analytics/skills/generic-analytics/examples/queries/09_tumbling_window.yaml +23 -0
  36. xp_libs_analytics/skills/generic-analytics/examples/queries/10_sliding_window.yaml +32 -0
  37. xp_libs_analytics/skills/generic-analytics/examples/queries/11_explicit_cast.yaml +16 -0
  38. xp_libs_analytics/skills/generic-analytics/examples/queries/12_text_range_distinct.yaml +23 -0
  39. xp_libs_analytics/skills/generic-analytics/examples/queries/13_summary_statistics.yaml +15 -0
  40. xp_libs_analytics/skills/generic-analytics/examples/queries/14_nested_and_not.yaml +20 -0
  41. xp_libs_analytics/skills/generic-analytics/examples/queries/15_conditional_sum.yaml +13 -0
  42. xp_libs_analytics/skills/generic-analytics/examples/queries/16_mixed_types_cast.yaml +17 -0
  43. xp_libs_analytics/skills/generic-analytics/examples/queries/17_relative_last_hour.yaml +15 -0
  44. xp_libs_analytics/skills/generic-analytics/examples/queries/18_string_exact.yaml +11 -0
  45. xp_libs_analytics/skills/generic-analytics/examples/queries/19_string_normalization.yaml +15 -0
  46. xp_libs_analytics/skills/generic-analytics/examples/queries/20_prefix_suffix.yaml +16 -0
  47. xp_libs_analytics/skills/generic-analytics/examples/queries/21_whole_word.yaml +13 -0
  48. xp_libs_analytics/skills/generic-analytics/examples/queries/22_ilike.yaml +11 -0
  49. xp_libs_analytics/skills/generic-analytics/examples/queries/23_rust_regex.yaml +11 -0
  50. xp_libs_analytics/skills/generic-analytics/examples/queries/24_fuzzy.yaml +17 -0
  51. xp_libs_analytics/skills/generic-analytics/examples/queries/25_multi_field_matching.yaml +24 -0
  52. xp_libs_analytics/skills/generic-analytics/examples/queries/26_conditional_fuzzy.yaml +20 -0
  53. xp_libs_analytics/skills/generic-analytics/examples/queries/27_temporal_fuzzy.yaml +38 -0
  54. xp_libs_analytics/skills/generic-analytics/examples/queries/28_safe_matching_diagnostics.yaml +22 -0
  55. xp_libs_analytics/skills/generic-analytics/references/errors.md +55 -0
  56. xp_libs_analytics/skills/generic-analytics/references/query-language-full.md +306 -0
  57. xp_libs_analytics/skills/generic-analytics/references/query-language.md +39 -0
  58. xp_libs_analytics/skills/generic-analytics/references/string-matching-full.md +122 -0
  59. xp_libs_analytics/skills/generic-analytics/references/string-matching.md +11 -0
  60. xp_libs_analytics-0.4.0.dist-info/METADATA +199 -0
  61. xp_libs_analytics-0.4.0.dist-info/RECORD +65 -0
  62. xp_libs_analytics-0.4.0.dist-info/WHEEL +4 -0
  63. xp_libs_analytics-0.4.0.dist-info/entry_points.txt +2 -0
  64. xp_libs_analytics-0.4.0.dist-info/licenses/LICENSE +22 -0
  65. 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()