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.
Files changed (54) hide show
  1. fairspec_mcp_server/__init__.py +2 -0
  2. fairspec_mcp_server/_test/__init__.py +0 -0
  3. fairspec_mcp_server/_test/server_unit.py +93 -0
  4. fairspec_mcp_server/_test/start_unit.py +29 -0
  5. fairspec_mcp_server/helpers/__init__.py +0 -0
  6. fairspec_mcp_server/helpers/limit.py +34 -0
  7. fairspec_mcp_server/helpers/tool.py +38 -0
  8. fairspec_mcp_server/models/__init__.py +0 -0
  9. fairspec_mcp_server/models/tool.py +15 -0
  10. fairspec_mcp_server/server.py +41 -0
  11. fairspec_mcp_server/tools/__init__.py +0 -0
  12. fairspec_mcp_server/tools/_shared/__init__.py +0 -0
  13. fairspec_mcp_server/tools/_shared/schemas.py +20 -0
  14. fairspec_mcp_server/tools/data/__init__.py +0 -0
  15. fairspec_mcp_server/tools/data/_test/__init__.py +0 -0
  16. fairspec_mcp_server/tools/data/_test/validate_unit.py +26 -0
  17. fairspec_mcp_server/tools/data/validate.py +22 -0
  18. fairspec_mcp_server/tools/data_schema/__init__.py +0 -0
  19. fairspec_mcp_server/tools/data_schema/_test/__init__.py +0 -0
  20. fairspec_mcp_server/tools/data_schema/_test/infer_unit.py +20 -0
  21. fairspec_mcp_server/tools/data_schema/_test/validate_unit.py +31 -0
  22. fairspec_mcp_server/tools/data_schema/infer.py +19 -0
  23. fairspec_mcp_server/tools/data_schema/validate.py +25 -0
  24. fairspec_mcp_server/tools/dataset/__init__.py +0 -0
  25. fairspec_mcp_server/tools/dataset/_test/__init__.py +0 -0
  26. fairspec_mcp_server/tools/dataset/_test/infer_unit.py +23 -0
  27. fairspec_mcp_server/tools/dataset/_test/query_unit.py +39 -0
  28. fairspec_mcp_server/tools/dataset/_test/validate_unit.py +53 -0
  29. fairspec_mcp_server/tools/dataset/infer.py +17 -0
  30. fairspec_mcp_server/tools/dataset/query.py +38 -0
  31. fairspec_mcp_server/tools/dataset/validate.py +25 -0
  32. fairspec_mcp_server/tools/file/__init__.py +0 -0
  33. fairspec_mcp_server/tools/file/_test/__init__.py +0 -0
  34. fairspec_mcp_server/tools/file/_test/preview_unit.py +33 -0
  35. fairspec_mcp_server/tools/file/preview.py +27 -0
  36. fairspec_mcp_server/tools/file_dialect/__init__.py +0 -0
  37. fairspec_mcp_server/tools/file_dialect/_test/__init__.py +0 -0
  38. fairspec_mcp_server/tools/file_dialect/_test/infer_unit.py +29 -0
  39. fairspec_mcp_server/tools/file_dialect/infer.py +28 -0
  40. fairspec_mcp_server/tools/table/__init__.py +0 -0
  41. fairspec_mcp_server/tools/table/_test/__init__.py +0 -0
  42. fairspec_mcp_server/tools/table/_test/query_unit.py +85 -0
  43. fairspec_mcp_server/tools/table/_test/validate_unit.py +98 -0
  44. fairspec_mcp_server/tools/table/query.py +39 -0
  45. fairspec_mcp_server/tools/table/validate.py +59 -0
  46. fairspec_mcp_server/tools/table_schema/__init__.py +0 -0
  47. fairspec_mcp_server/tools/table_schema/_test/__init__.py +0 -0
  48. fairspec_mcp_server/tools/table_schema/_test/infer_unit.py +42 -0
  49. fairspec_mcp_server/tools/table_schema/_test/validate_unit.py +33 -0
  50. fairspec_mcp_server/tools/table_schema/infer.py +28 -0
  51. fairspec_mcp_server/tools/table_schema/validate.py +27 -0
  52. fairspec_mcp_server-0.3.0.dist-info/METADATA +20 -0
  53. fairspec_mcp_server-0.3.0.dist-info/RECORD +54 -0
  54. fairspec_mcp_server-0.3.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,2 @@
1
+ from .server import server as server
2
+ from .server import start_server as start_server
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
@@ -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