fairspec-mcp-server 0.3.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.
- fairspec_mcp_server/__init__.py +2 -0
- fairspec_mcp_server/_test/__init__.py +0 -0
- fairspec_mcp_server/_test/server_unit.py +93 -0
- fairspec_mcp_server/_test/start_unit.py +29 -0
- fairspec_mcp_server/helpers/__init__.py +0 -0
- fairspec_mcp_server/helpers/limit.py +34 -0
- fairspec_mcp_server/helpers/tool.py +38 -0
- fairspec_mcp_server/models/__init__.py +0 -0
- fairspec_mcp_server/models/tool.py +15 -0
- fairspec_mcp_server/server.py +41 -0
- fairspec_mcp_server/tools/__init__.py +0 -0
- fairspec_mcp_server/tools/_shared/__init__.py +0 -0
- fairspec_mcp_server/tools/_shared/schemas.py +20 -0
- fairspec_mcp_server/tools/data/__init__.py +0 -0
- fairspec_mcp_server/tools/data/_test/__init__.py +0 -0
- fairspec_mcp_server/tools/data/_test/validate_unit.py +26 -0
- fairspec_mcp_server/tools/data/validate.py +22 -0
- fairspec_mcp_server/tools/data_schema/__init__.py +0 -0
- fairspec_mcp_server/tools/data_schema/_test/__init__.py +0 -0
- fairspec_mcp_server/tools/data_schema/_test/infer_unit.py +20 -0
- fairspec_mcp_server/tools/data_schema/_test/validate_unit.py +31 -0
- fairspec_mcp_server/tools/data_schema/infer.py +19 -0
- fairspec_mcp_server/tools/data_schema/validate.py +25 -0
- fairspec_mcp_server/tools/dataset/__init__.py +0 -0
- fairspec_mcp_server/tools/dataset/_test/__init__.py +0 -0
- fairspec_mcp_server/tools/dataset/_test/infer_unit.py +23 -0
- fairspec_mcp_server/tools/dataset/_test/query_unit.py +39 -0
- fairspec_mcp_server/tools/dataset/_test/validate_unit.py +53 -0
- fairspec_mcp_server/tools/dataset/infer.py +17 -0
- fairspec_mcp_server/tools/dataset/query.py +38 -0
- fairspec_mcp_server/tools/dataset/validate.py +25 -0
- fairspec_mcp_server/tools/file/__init__.py +0 -0
- fairspec_mcp_server/tools/file/_test/__init__.py +0 -0
- fairspec_mcp_server/tools/file/_test/preview_unit.py +33 -0
- fairspec_mcp_server/tools/file/preview.py +27 -0
- fairspec_mcp_server/tools/file_dialect/__init__.py +0 -0
- fairspec_mcp_server/tools/file_dialect/_test/__init__.py +0 -0
- fairspec_mcp_server/tools/file_dialect/_test/infer_unit.py +29 -0
- fairspec_mcp_server/tools/file_dialect/infer.py +28 -0
- fairspec_mcp_server/tools/table/__init__.py +0 -0
- fairspec_mcp_server/tools/table/_test/__init__.py +0 -0
- fairspec_mcp_server/tools/table/_test/query_unit.py +85 -0
- fairspec_mcp_server/tools/table/_test/validate_unit.py +98 -0
- fairspec_mcp_server/tools/table/query.py +39 -0
- fairspec_mcp_server/tools/table/validate.py +59 -0
- fairspec_mcp_server/tools/table_schema/__init__.py +0 -0
- fairspec_mcp_server/tools/table_schema/_test/__init__.py +0 -0
- fairspec_mcp_server/tools/table_schema/_test/infer_unit.py +42 -0
- fairspec_mcp_server/tools/table_schema/_test/validate_unit.py +33 -0
- fairspec_mcp_server/tools/table_schema/infer.py +28 -0
- fairspec_mcp_server/tools/table_schema/validate.py +27 -0
- fairspec_mcp_server-0.3.0.dist-info/METADATA +20 -0
- fairspec_mcp_server-0.3.0.dist-info/RECORD +54 -0
- fairspec_mcp_server-0.3.0.dist-info/WHEEL +4 -0
|
File without changes
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from fairspec_library import write_temp_file
|
|
3
|
+
from mcp import Client
|
|
4
|
+
|
|
5
|
+
from ..server import server
|
|
6
|
+
|
|
7
|
+
pytestmark = pytest.mark.anyio
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def anyio_backend():
|
|
12
|
+
return "asyncio"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestServer:
|
|
16
|
+
async def test_should_list_tools_with_typescript_names(self):
|
|
17
|
+
async with Client(server) as client:
|
|
18
|
+
result = await client.list_tools()
|
|
19
|
+
|
|
20
|
+
assert sorted(tool.name for tool in result.tools) == sorted(
|
|
21
|
+
[
|
|
22
|
+
"inferDataSchemaTool",
|
|
23
|
+
"inferDatasetTool",
|
|
24
|
+
"inferFileDialectTool",
|
|
25
|
+
"inferTableSchemaTool",
|
|
26
|
+
"previewFileTool",
|
|
27
|
+
"queryDatasetTool",
|
|
28
|
+
"queryTableTool",
|
|
29
|
+
"validateDataSchemaTool",
|
|
30
|
+
"validateDatasetTool",
|
|
31
|
+
"validateDataTool",
|
|
32
|
+
"validateTableSchemaTool",
|
|
33
|
+
"validateTableTool",
|
|
34
|
+
]
|
|
35
|
+
)
|
|
36
|
+
for tool in result.tools:
|
|
37
|
+
assert tool.description
|
|
38
|
+
assert tool.output_schema
|
|
39
|
+
|
|
40
|
+
async def test_should_call_tool_with_structured_output(self, capfd):
|
|
41
|
+
path = write_temp_file("id,name\n1,alice\n2,bob\n", format="csv")
|
|
42
|
+
|
|
43
|
+
async with Client(server) as client:
|
|
44
|
+
result = await client.call_tool(
|
|
45
|
+
"inferTableSchemaTool", {"resource": {"data": path}}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
assert not result.is_error
|
|
49
|
+
assert capfd.readouterr().out == ""
|
|
50
|
+
assert result.structured_content == {
|
|
51
|
+
"tableSchema": {
|
|
52
|
+
"properties": {"id": {"type": "integer"}, "name": {"type": "string"}}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async def test_should_report_tool_failure_as_error(self):
|
|
57
|
+
async with Client(server) as client:
|
|
58
|
+
result = await client.call_tool(
|
|
59
|
+
"queryTableTool", {"resource": {}, "query": "SELECT * FROM self"}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
assert result.is_error
|
|
63
|
+
|
|
64
|
+
async def test_should_report_real_message_of_failed_query(self):
|
|
65
|
+
path = write_temp_file("id,name\n1,alice\n", format="csv")
|
|
66
|
+
|
|
67
|
+
async with Client(server) as client:
|
|
68
|
+
result = await client.call_tool(
|
|
69
|
+
"queryTableTool",
|
|
70
|
+
{"resource": {"data": path}, "query": "SELEC nonsense"},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert result.is_error
|
|
74
|
+
assert "sql" in str(result.content).lower()
|
|
75
|
+
|
|
76
|
+
async def test_should_report_real_message_of_missing_file(self):
|
|
77
|
+
async with Client(server) as client:
|
|
78
|
+
result = await client.call_tool(
|
|
79
|
+
"validateDatasetTool", {"source": "missing/dataset.json"}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
assert result.is_error
|
|
83
|
+
assert "missing" in str(result.content)
|
|
84
|
+
|
|
85
|
+
async def test_should_reject_response_over_budget(self):
|
|
86
|
+
async with Client(server) as client:
|
|
87
|
+
result = await client.call_tool(
|
|
88
|
+
"inferDatasetTool",
|
|
89
|
+
{"dataset": {"resources": [{"data": [{"text": "x" * 60_000}]}]}},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
assert result.is_error
|
|
93
|
+
assert "exceeds 50000 characters" in str(result.content)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from unittest.mock import patch
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from fairspec_library import get_host_policy, set_host_policy
|
|
5
|
+
|
|
6
|
+
from ..server import start_server
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(autouse=True)
|
|
10
|
+
def reset_host_policy(monkeypatch):
|
|
11
|
+
monkeypatch.delenv("FAIRSPEC_HOST_POLICY", raising=False)
|
|
12
|
+
yield
|
|
13
|
+
set_host_policy(None)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestStartServer:
|
|
17
|
+
def test_should_block_non_public_hosts_by_default(self):
|
|
18
|
+
with patch("fairspec_mcp_server.server.server.run") as run:
|
|
19
|
+
start_server()
|
|
20
|
+
|
|
21
|
+
run.assert_called_once_with("stdio")
|
|
22
|
+
assert get_host_policy() == "public"
|
|
23
|
+
|
|
24
|
+
def test_should_keep_host_policy_from_env(self, monkeypatch):
|
|
25
|
+
monkeypatch.setenv("FAIRSPEC_HOST_POLICY", "any")
|
|
26
|
+
with patch("fairspec_mcp_server.server.server.run"):
|
|
27
|
+
start_server()
|
|
28
|
+
|
|
29
|
+
assert get_host_policy() == "any"
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
|
|
3
|
+
from fairspec_library import Report
|
|
4
|
+
from pydantic_core import to_json
|
|
5
|
+
|
|
6
|
+
DEFAULT_LIMIT = 100
|
|
7
|
+
MAX_LIMIT = 1000
|
|
8
|
+
MAX_RESPONSE_CHARS = 50_000
|
|
9
|
+
|
|
10
|
+
# room for the object a trimmed list is wrapped in
|
|
11
|
+
MAX_ITEMS_CHARS = MAX_RESPONSE_CHARS - 1_000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def limit_items[T](items: Sequence[T], *, limit: int) -> tuple[list[T], bool]:
|
|
15
|
+
"""Cut a list to `limit` items, then to as many as fit the response budget.
|
|
16
|
+
|
|
17
|
+
Pass one item more than the limit to learn whether the list was truncated.
|
|
18
|
+
"""
|
|
19
|
+
chars = 0
|
|
20
|
+
count = 0
|
|
21
|
+
for item in items[:limit]:
|
|
22
|
+
chars += len(to_json(item, by_alias=True, exclude_none=True)) + 1
|
|
23
|
+
if chars > MAX_ITEMS_CHARS:
|
|
24
|
+
break
|
|
25
|
+
count += 1
|
|
26
|
+
|
|
27
|
+
return list(items[:count]), count < len(items)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def limit_report(report: Report, *, max_errors: int | None) -> tuple[Report, bool]:
|
|
31
|
+
"""Cut a report's errors to `max_errors`, then to the response budget."""
|
|
32
|
+
limit = max_errors if max_errors is not None else DEFAULT_LIMIT
|
|
33
|
+
errors, truncated = limit_items(report.errors, limit=limit)
|
|
34
|
+
return Report(valid=report.valid, errors=errors), truncated
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
|
|
4
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
5
|
+
from mcp.shared.exceptions import MCPError
|
|
6
|
+
from mcp.types import CallToolResult, TextContent
|
|
7
|
+
|
|
8
|
+
from ..models.tool import ToolResult
|
|
9
|
+
from .limit import MAX_RESPONSE_CHARS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def guard_tool[**P](tool: Callable[P, ToolResult]) -> Callable[P, CallToolResult]:
|
|
13
|
+
"""Pass a tool's failure to the client with its message, and cap its response size."""
|
|
14
|
+
|
|
15
|
+
# MCPServer reads the signature through `__wrapped__`, so the schemas stay the tool's
|
|
16
|
+
@functools.wraps(tool)
|
|
17
|
+
def guarded_tool(*args: P.args, **kwargs: P.kwargs) -> CallToolResult:
|
|
18
|
+
try:
|
|
19
|
+
result = tool(*args, **kwargs)
|
|
20
|
+
except (ToolError, MCPError):
|
|
21
|
+
raise
|
|
22
|
+
except Exception as exc:
|
|
23
|
+
# The SDK hides the message of anything but a ToolError
|
|
24
|
+
raise ToolError(str(exc)) from exc
|
|
25
|
+
|
|
26
|
+
text = result.model_dump_json(by_alias=True)
|
|
27
|
+
if len(text) > MAX_RESPONSE_CHARS:
|
|
28
|
+
raise ToolError(
|
|
29
|
+
f"The response exceeds {MAX_RESPONSE_CHARS} characters; narrow the input"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# The SDK would indent the text copy, doubling its size against the budget
|
|
33
|
+
return CallToolResult(
|
|
34
|
+
content=[TextContent(type="text", text=text)],
|
|
35
|
+
structured_content=result.model_dump(mode="json", by_alias=True),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return guarded_tool
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from fairspec_library import FairspecModel
|
|
2
|
+
from pydantic import SerializationInfo, SerializerFunctionWrapHandler, model_serializer
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ToolResult(FairspecModel):
|
|
6
|
+
"""The structured output of a tool, serialized without unset optional fields."""
|
|
7
|
+
|
|
8
|
+
# mcp dumps tool output without exclude_none, so every unset field would reach the client as null
|
|
9
|
+
@model_serializer(mode="wrap")
|
|
10
|
+
def serialize_tool_result(
|
|
11
|
+
self, handler: SerializerFunctionWrapHandler, info: SerializationInfo
|
|
12
|
+
):
|
|
13
|
+
if info.exclude_none:
|
|
14
|
+
return handler(self)
|
|
15
|
+
return self.model_dump(mode=info.mode, by_alias=info.by_alias, exclude_none=True)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
|
|
4
|
+
from fairspec_library import set_host_policy
|
|
5
|
+
from mcp.server.mcpserver import MCPServer
|
|
6
|
+
|
|
7
|
+
from .helpers.tool import guard_tool
|
|
8
|
+
from .tools.data.validate import validate_data_tool
|
|
9
|
+
from .tools.data_schema.infer import infer_data_schema_tool
|
|
10
|
+
from .tools.data_schema.validate import validate_data_schema_tool
|
|
11
|
+
from .tools.dataset.infer import infer_dataset_tool
|
|
12
|
+
from .tools.dataset.query import query_dataset_tool
|
|
13
|
+
from .tools.dataset.validate import validate_dataset_tool
|
|
14
|
+
from .tools.file.preview import preview_file_tool
|
|
15
|
+
from .tools.file_dialect.infer import infer_file_dialect_tool
|
|
16
|
+
from .tools.table.query import query_table_tool
|
|
17
|
+
from .tools.table.validate import validate_table_tool
|
|
18
|
+
from .tools.table_schema.infer import infer_table_schema_tool
|
|
19
|
+
from .tools.table_schema.validate import validate_table_schema_tool
|
|
20
|
+
|
|
21
|
+
server = MCPServer(name="fairspec", version=version("fairspec-mcp-server"))
|
|
22
|
+
|
|
23
|
+
server.add_tool(guard_tool(infer_data_schema_tool), name="inferDataSchemaTool")
|
|
24
|
+
server.add_tool(guard_tool(infer_dataset_tool), name="inferDatasetTool")
|
|
25
|
+
server.add_tool(guard_tool(infer_file_dialect_tool), name="inferFileDialectTool")
|
|
26
|
+
server.add_tool(guard_tool(infer_table_schema_tool), name="inferTableSchemaTool")
|
|
27
|
+
server.add_tool(guard_tool(preview_file_tool), name="previewFileTool")
|
|
28
|
+
server.add_tool(guard_tool(query_dataset_tool), name="queryDatasetTool")
|
|
29
|
+
server.add_tool(guard_tool(query_table_tool), name="queryTableTool")
|
|
30
|
+
server.add_tool(guard_tool(validate_data_schema_tool), name="validateDataSchemaTool")
|
|
31
|
+
server.add_tool(guard_tool(validate_dataset_tool), name="validateDatasetTool")
|
|
32
|
+
server.add_tool(guard_tool(validate_data_tool), name="validateDataTool")
|
|
33
|
+
server.add_tool(guard_tool(validate_table_schema_tool), name="validateTableSchemaTool")
|
|
34
|
+
server.add_tool(guard_tool(validate_table_tool), name="validateTableTool")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def start_server() -> None:
|
|
38
|
+
"""Serve over stdio; remote fetches reach only public hosts unless FAIRSPEC_HOST_POLICY is set."""
|
|
39
|
+
if not os.environ.get("FAIRSPEC_HOST_POLICY"):
|
|
40
|
+
set_host_policy("public")
|
|
41
|
+
server.run("stdio")
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from ...helpers.limit import DEFAULT_LIMIT, MAX_LIMIT
|
|
6
|
+
|
|
7
|
+
# Tool parameters carry their wire name (`maxErrors`): the SDK passes an alias as the
|
|
8
|
+
# keyword, so a snake_case parameter cannot be aliased to match the TypeScript server
|
|
9
|
+
MaxErrors = Annotated[
|
|
10
|
+
int | None,
|
|
11
|
+
Field(
|
|
12
|
+
ge=1,
|
|
13
|
+
le=MAX_LIMIT,
|
|
14
|
+
description=f"Maximum number of errors to return (default {DEFAULT_LIMIT})",
|
|
15
|
+
),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
Truncated = Annotated[
|
|
19
|
+
bool, Field(description="Whether errors were left out of the report")
|
|
20
|
+
]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from fairspec_library import Resource
|
|
2
|
+
|
|
3
|
+
from ..validate import validate_data_tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestValidateDataTool:
|
|
7
|
+
def test_should_validate_data_against_schema(self):
|
|
8
|
+
resource = Resource(
|
|
9
|
+
data={"name": "alice"},
|
|
10
|
+
dataSchema={"type": "object", "properties": {"name": {"type": "string"}}},
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
result = validate_data_tool(resource)
|
|
14
|
+
|
|
15
|
+
assert result.report.valid is True
|
|
16
|
+
|
|
17
|
+
def test_should_detect_invalid_data(self):
|
|
18
|
+
resource = Resource(
|
|
19
|
+
data={"name": 1},
|
|
20
|
+
dataSchema={"type": "object", "properties": {"name": {"type": "string"}}},
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
result = validate_data_tool(resource)
|
|
24
|
+
|
|
25
|
+
assert result.report.valid is False
|
|
26
|
+
assert len(result.report.errors) > 0
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fairspec_library import Report, Resource, validate_data
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from ...helpers.limit import limit_report
|
|
7
|
+
from ...models.tool import ToolResult
|
|
8
|
+
from .._shared.schemas import MaxErrors, Truncated
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ValidateDataResult(ToolResult):
|
|
12
|
+
report: Report
|
|
13
|
+
truncated: Truncated
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_data_tool(
|
|
17
|
+
resource: Annotated[Resource, Field(description="The data resource to validate")],
|
|
18
|
+
maxErrors: MaxErrors = None,
|
|
19
|
+
) -> ValidateDataResult:
|
|
20
|
+
"""Validate data against a data schema (JSON Schema). Returns a validation report with errors if any."""
|
|
21
|
+
report, truncated = limit_report(validate_data(resource), max_errors=maxErrors)
|
|
22
|
+
return ValidateDataResult(report=report, truncated=truncated)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from fairspec_library import Resource
|
|
2
|
+
|
|
3
|
+
from ..infer import infer_data_schema_tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestInferDataSchemaTool:
|
|
7
|
+
def test_should_infer_schema_from_inline_data(self):
|
|
8
|
+
resource = Resource(data={"name": "alice", "age": 25})
|
|
9
|
+
|
|
10
|
+
result = infer_data_schema_tool(resource)
|
|
11
|
+
|
|
12
|
+
assert result.dataSchema is not None
|
|
13
|
+
assert result.dataSchema["type"] == "object"
|
|
14
|
+
assert set(result.dataSchema["properties"]) == {"name", "age"}
|
|
15
|
+
|
|
16
|
+
def test_should_return_none_for_resource_without_data(self):
|
|
17
|
+
result = infer_data_schema_tool(Resource())
|
|
18
|
+
|
|
19
|
+
assert result.dataSchema is None
|
|
20
|
+
assert result.model_dump(mode="json", by_alias=True) == {}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from fairspec_library import Report, write_temp_file
|
|
4
|
+
|
|
5
|
+
from ..validate import validate_data_schema_tool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestValidateDataSchemaTool:
|
|
9
|
+
def test_should_validate_valid_descriptor(self):
|
|
10
|
+
result = validate_data_schema_tool({"type": "object"})
|
|
11
|
+
|
|
12
|
+
assert result.report.valid is True
|
|
13
|
+
assert result.report.errors == []
|
|
14
|
+
|
|
15
|
+
def test_should_detect_invalid_descriptor(self):
|
|
16
|
+
result = validate_data_schema_tool({"type": 123})
|
|
17
|
+
|
|
18
|
+
assert result.report.valid is False
|
|
19
|
+
assert len(result.report.errors) > 0
|
|
20
|
+
|
|
21
|
+
def test_should_validate_descriptor_from_path(self):
|
|
22
|
+
path = write_temp_file(json.dumps({"type": "object"}), format="json")
|
|
23
|
+
|
|
24
|
+
result = validate_data_schema_tool(path)
|
|
25
|
+
|
|
26
|
+
assert result.report.valid is True
|
|
27
|
+
|
|
28
|
+
def test_should_return_plain_report(self):
|
|
29
|
+
result = validate_data_schema_tool({"type": "object"})
|
|
30
|
+
|
|
31
|
+
assert type(result.report) is Report
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fairspec_library import DataSchema, Resource, infer_data_schema
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from ...models.tool import ToolResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InferDataSchemaResult(ToolResult):
|
|
10
|
+
dataSchema: DataSchema | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def infer_data_schema_tool(
|
|
14
|
+
resource: Annotated[
|
|
15
|
+
Resource, Field(description="The data resource to infer schema from")
|
|
16
|
+
],
|
|
17
|
+
) -> InferDataSchemaResult:
|
|
18
|
+
"""Infer a data schema (JSON Schema) from data. Analyzes the data structure to determine types and properties."""
|
|
19
|
+
return InferDataSchemaResult(dataSchema=infer_data_schema(resource))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fairspec_library import Descriptor, Report, validate_data_schema
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from ...helpers.limit import limit_report
|
|
7
|
+
from ...models.tool import ToolResult
|
|
8
|
+
from .._shared.schemas import MaxErrors, Truncated
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ValidateDataSchemaResult(ToolResult):
|
|
12
|
+
report: Report
|
|
13
|
+
truncated: Truncated
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_data_schema_tool(
|
|
17
|
+
source: Annotated[
|
|
18
|
+
Descriptor | str,
|
|
19
|
+
Field(description="The data schema to validate (descriptor object or file path)"),
|
|
20
|
+
],
|
|
21
|
+
maxErrors: MaxErrors = None,
|
|
22
|
+
) -> ValidateDataSchemaResult:
|
|
23
|
+
"""Validate a data schema (JSON Schema) against the Fairspec Data Schema specification. Returns validation report with errors if any."""
|
|
24
|
+
report, truncated = limit_report(validate_data_schema(source), max_errors=maxErrors)
|
|
25
|
+
return ValidateDataSchemaResult(report=report, truncated=truncated)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from fairspec_library import Dataset, Resource, write_temp_file
|
|
2
|
+
|
|
3
|
+
from ..infer import infer_dataset_tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestInferDatasetTool:
|
|
7
|
+
def test_should_infer_resource_metadata(self):
|
|
8
|
+
path = write_temp_file("id,name\n1,alice\n2,bob\n", format="csv")
|
|
9
|
+
dataset = Dataset(resources=[Resource(data=path)])
|
|
10
|
+
|
|
11
|
+
result = infer_dataset_tool(dataset)
|
|
12
|
+
|
|
13
|
+
assert result.dataset.resources
|
|
14
|
+
resource = result.dataset.resources[0]
|
|
15
|
+
assert resource.fileDialect is not None
|
|
16
|
+
assert resource.tableSchema is not None
|
|
17
|
+
|
|
18
|
+
def test_should_preserve_dataset_metadata(self):
|
|
19
|
+
dataset = Dataset(version="1.0.0", resources=[])
|
|
20
|
+
|
|
21
|
+
result = infer_dataset_tool(dataset)
|
|
22
|
+
|
|
23
|
+
assert result.dataset.version == "1.0.0"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from fairspec_library import Dataset
|
|
2
|
+
|
|
3
|
+
from ..query import query_dataset_tool
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestQueryDatasetTool:
|
|
7
|
+
def test_should_join_two_resources_by_name(self):
|
|
8
|
+
source = Dataset.model_validate(
|
|
9
|
+
{
|
|
10
|
+
"resources": [
|
|
11
|
+
{
|
|
12
|
+
"name": "users",
|
|
13
|
+
"data": [{"id": 1, "name": "alice"}, {"id": 2, "name": "bob"}],
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "posts",
|
|
17
|
+
"data": [{"id": 1, "user_id": 2}, {"id": 2, "user_id": 2}],
|
|
18
|
+
},
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
result = query_dataset_tool(
|
|
24
|
+
source,
|
|
25
|
+
"SELECT users.name, COUNT(*) AS n FROM posts "
|
|
26
|
+
"JOIN users ON posts.user_id = users.id GROUP BY users.name",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
assert result.records == [{"name": "bob", "n": 2}]
|
|
30
|
+
|
|
31
|
+
def test_should_return_at_most_limit_records(self):
|
|
32
|
+
source = Dataset.model_validate(
|
|
33
|
+
{"resources": [{"name": "items", "data": [{"id": i} for i in range(10)]}]}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
result = query_dataset_tool(source, "SELECT * FROM items", limit=5)
|
|
37
|
+
|
|
38
|
+
assert len(result.records) == 5
|
|
39
|
+
assert result.truncated is True
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from fairspec_library import Dataset, Resource, TableSchema, write_temp_file
|
|
4
|
+
|
|
5
|
+
from ..validate import validate_dataset_tool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestValidateDatasetTool:
|
|
9
|
+
def test_should_validate_valid_dataset(self):
|
|
10
|
+
dataset = Dataset(
|
|
11
|
+
resources=[
|
|
12
|
+
Resource(
|
|
13
|
+
name="people",
|
|
14
|
+
data=[{"id": 1}, {"id": 2}],
|
|
15
|
+
tableSchema=TableSchema.model_validate(
|
|
16
|
+
{"properties": {"id": {"type": "integer"}}}
|
|
17
|
+
),
|
|
18
|
+
)
|
|
19
|
+
]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
result = validate_dataset_tool(dataset)
|
|
23
|
+
|
|
24
|
+
assert result.report.valid is True
|
|
25
|
+
assert result.report.errors == []
|
|
26
|
+
|
|
27
|
+
def test_should_detect_invalid_resource_data(self):
|
|
28
|
+
dataset = Dataset(
|
|
29
|
+
resources=[
|
|
30
|
+
Resource(
|
|
31
|
+
name="people",
|
|
32
|
+
data=[{"id": "one"}],
|
|
33
|
+
tableSchema=TableSchema.model_validate(
|
|
34
|
+
{"properties": {"id": {"type": "integer"}}}
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
result = validate_dataset_tool(dataset)
|
|
41
|
+
|
|
42
|
+
assert result.report.valid is False
|
|
43
|
+
assert len(result.report.errors) > 0
|
|
44
|
+
|
|
45
|
+
def test_should_validate_dataset_from_path(self):
|
|
46
|
+
path = write_temp_file(
|
|
47
|
+
json.dumps({"resources": [{"name": "people", "data": [{"id": 1}]}]}),
|
|
48
|
+
format="json",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
result = validate_dataset_tool(path)
|
|
52
|
+
|
|
53
|
+
assert result.report.valid is True
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fairspec_library import Dataset, infer_dataset
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from ...models.tool import ToolResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InferDatasetResult(ToolResult):
|
|
10
|
+
dataset: Dataset
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def infer_dataset_tool(
|
|
14
|
+
dataset: Annotated[Dataset, Field(description="The dataset to infer metadata for")],
|
|
15
|
+
) -> InferDatasetResult:
|
|
16
|
+
"""Infer metadata for all resources in a dataset. Analyzes each resource to determine schemas and dialects."""
|
|
17
|
+
return InferDatasetResult(dataset=infer_dataset(dataset))
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
import polars as pl
|
|
5
|
+
from fairspec_library import Dataset, query_dataset
|
|
6
|
+
from pydantic import Field, JsonValue
|
|
7
|
+
|
|
8
|
+
from ...helpers.limit import DEFAULT_LIMIT, MAX_LIMIT, limit_items
|
|
9
|
+
from ...models.tool import ToolResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class QueryDatasetResult(ToolResult):
|
|
13
|
+
records: list[dict[str, JsonValue]]
|
|
14
|
+
truncated: Annotated[bool, Field(description="Whether records were left out")]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def query_dataset_tool(
|
|
18
|
+
source: Annotated[
|
|
19
|
+
Dataset | str,
|
|
20
|
+
Field(description="The dataset to query (dataset object or file path)"),
|
|
21
|
+
],
|
|
22
|
+
query: Annotated[
|
|
23
|
+
str, Field(description="SQL query to execute (use resource names as table names)")
|
|
24
|
+
],
|
|
25
|
+
limit: Annotated[
|
|
26
|
+
int,
|
|
27
|
+
Field(
|
|
28
|
+
ge=1,
|
|
29
|
+
le=MAX_LIMIT,
|
|
30
|
+
description=f"Maximum number of records to return (default {DEFAULT_LIMIT})",
|
|
31
|
+
),
|
|
32
|
+
] = DEFAULT_LIMIT,
|
|
33
|
+
) -> QueryDatasetResult:
|
|
34
|
+
"""Query a dataset using SQL. Each table resource is available under its name, so a query can join several resources. Returns the results as an array of records. Returns at most `limit` records; `truncated` is true when more were left out."""
|
|
35
|
+
frame = query_dataset(source, query).head(limit + 1).collect()
|
|
36
|
+
assert isinstance(frame, pl.DataFrame)
|
|
37
|
+
records, truncated = limit_items(json.loads(frame.write_json()), limit=limit)
|
|
38
|
+
return QueryDatasetResult(records=records, truncated=truncated)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fairspec_library import Dataset, Report, validate_dataset
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
|
|
6
|
+
from ...helpers.limit import limit_report
|
|
7
|
+
from ...models.tool import ToolResult
|
|
8
|
+
from .._shared.schemas import MaxErrors, Truncated
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ValidateDatasetResult(ToolResult):
|
|
12
|
+
report: Report
|
|
13
|
+
truncated: Truncated
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_dataset_tool(
|
|
17
|
+
source: Annotated[
|
|
18
|
+
Dataset | str,
|
|
19
|
+
Field(description="The dataset to validate (dataset object or file path)"),
|
|
20
|
+
],
|
|
21
|
+
maxErrors: MaxErrors = None,
|
|
22
|
+
) -> ValidateDatasetResult:
|
|
23
|
+
"""Validate a dataset including all its resources and foreign keys. Returns a validation report with errors if any."""
|
|
24
|
+
report, truncated = limit_report(validate_dataset(source), max_errors=maxErrors)
|
|
25
|
+
return ValidateDatasetResult(report=report, truncated=truncated)
|
|
File without changes
|
|
File without changes
|