Compare commits
12 Commits
734cc0a525
...
renovate/g
| Author | SHA1 | Date | |
|---|---|---|---|
| abfed5c06d | |||
| 540e57ec81 | |||
| d1e1043896 | |||
| 6521078b6a | |||
| 2f0a24aceb | |||
| 77bf95817d | |||
| 29a72ab005 | |||
| 33bb464102 | |||
| d4e793224b | |||
| bf8f301ded | |||
| a97930848b | |||
| c868e8a7fa |
10
.github/workflows/build.yaml
vendored
10
.github/workflows/build.yaml
vendored
@@ -18,10 +18,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
@@ -38,10 +38,10 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ !contains(github.ref, '-alpha') && !contains(github.ref, '-beta') && !contains(github.ref, '-rc') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
76
CHANGELOG.md
76
CHANGELOG.md
@@ -5,6 +5,82 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.5.0] - 2026-01-26
|
||||
|
||||
### Added
|
||||
|
||||
#### Column Label Support
|
||||
- **`add_column`**: New optional `label` parameter for setting display name
|
||||
- **`modify_column`**: New optional `label` parameter for updating display name
|
||||
|
||||
Labels are human-readable names shown in Grist column headers, separate from the `column_id` used in formulas and API calls. If not provided, Grist defaults the label to the column ID.
|
||||
|
||||
#### Usage
|
||||
```python
|
||||
# Create column with display label
|
||||
add_column(document="crm", table="Contacts", column_id="first_name", column_type="Text", label="First Name")
|
||||
|
||||
# Update existing column's label
|
||||
modify_column(document="crm", table="Contacts", column_id="first_name", label="Given Name")
|
||||
```
|
||||
|
||||
## [1.4.1] - 2026-01-14
|
||||
|
||||
### Added
|
||||
|
||||
#### Reference Column Filter Support
|
||||
- **Filter normalization**: `get_records` now automatically normalizes filter values to array format
|
||||
- Fixes 400 errors when filtering on `Ref:*` (reference/foreign key) columns
|
||||
- Single values are wrapped in arrays before sending to Grist API
|
||||
|
||||
#### Usage
|
||||
```python
|
||||
# Before: Failed with 400 Bad Request
|
||||
get_records(document="accounting", table="TransactionLines", filter={"Transaction": 44})
|
||||
|
||||
# After: Works - filter normalized to {"Transaction": [44]}
|
||||
get_records(document="accounting", table="TransactionLines", filter={"Transaction": 44})
|
||||
|
||||
# Multiple values also supported
|
||||
get_records(document="accounting", table="TransactionLines", filter={"Transaction": [44, 45, 46]})
|
||||
```
|
||||
|
||||
### Fixed
|
||||
- Shell script shebangs updated to `#!/usr/bin/env bash` for portability across environments
|
||||
|
||||
## [1.4.0] - 2026-01-12
|
||||
|
||||
### Added
|
||||
|
||||
#### Attachment Download via Proxy
|
||||
- **`GET /api/v1/attachments/{id}`**: New HTTP endpoint for downloading attachments
|
||||
- Returns binary content with appropriate `Content-Type` and `Content-Disposition` headers
|
||||
- Requires read permission in session token
|
||||
- Complements the existing upload endpoint for complete attachment workflows
|
||||
|
||||
#### Usage
|
||||
```bash
|
||||
# Get session token with read permission
|
||||
TOKEN=$(curl -s ... | jq -r '.token')
|
||||
|
||||
# Download attachment
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
https://example.com/api/v1/attachments/42 \
|
||||
-o downloaded.pdf
|
||||
```
|
||||
|
||||
```python
|
||||
# Python example
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
f'{base_url}/api/v1/attachments/42',
|
||||
headers={'Authorization': f'Bearer {token}'}
|
||||
)
|
||||
with open('downloaded.pdf', 'wb') as f:
|
||||
f.write(response.content)
|
||||
```
|
||||
|
||||
## [1.3.0] - 2026-01-03
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Stage 1: Builder
|
||||
FROM python:3.14-slim AS builder
|
||||
FROM python:3.14-slim@sha256:fb83750094b46fd6b8adaa80f66e2302ecbe45d513f6cece637a841e1025b4ca AS builder
|
||||
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 /uv /usr/local/bin/uv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN uv sync --frozen --no-dev
|
||||
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM python:3.14-slim
|
||||
FROM python:3.14-slim@sha256:fb83750094b46fd6b8adaa80f66e2302ecbe45d513f6cece637a841e1025b4ca
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd --create-home --shell /bin/bash appuser
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Production environment
|
||||
services:
|
||||
grist-mcp:
|
||||
image: ghcr.io/xe138/grist-mcp-server:latest
|
||||
image: ghcr.io/xe138/grist-mcp-server:latest@sha256:2ef22bfac6cfbcbbfc513f61eaea3414b3a531d79e9d1d39bf6757cc9e27ea9a
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
volumes:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "grist-mcp"
|
||||
version = "1.3.0"
|
||||
version = "1.4.1"
|
||||
description = "MCP server for AI agents to interact with Grist documents"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# scripts/get-test-instance-id.sh
|
||||
# Generate a unique instance ID from git branch for parallel test isolation
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
# scripts/run-integration-tests.sh
|
||||
# Run integration tests with branch isolation and dynamic port discovery
|
||||
set -e
|
||||
|
||||
@@ -203,11 +203,14 @@ class GristClient:
|
||||
column_id: str,
|
||||
column_type: str,
|
||||
formula: str | None = None,
|
||||
label: str | None = None,
|
||||
) -> str:
|
||||
"""Add a column to a table. Returns column ID."""
|
||||
fields = {"type": column_type}
|
||||
if formula:
|
||||
fields["formula"] = formula
|
||||
if label:
|
||||
fields["label"] = label
|
||||
|
||||
payload = {"columns": [{"id": column_id, "fields": fields}]}
|
||||
data = await self._request("POST", f"/tables/{table}/columns", json=payload)
|
||||
@@ -219,13 +222,16 @@ class GristClient:
|
||||
column_id: str,
|
||||
type: str | None = None,
|
||||
formula: str | None = None,
|
||||
label: str | None = None,
|
||||
) -> None:
|
||||
"""Modify a column's type or formula."""
|
||||
"""Modify a column's type, formula, or label."""
|
||||
fields = {}
|
||||
if type is not None:
|
||||
fields["type"] = type
|
||||
if formula is not None:
|
||||
fields["formula"] = formula
|
||||
if label is not None:
|
||||
fields["label"] = label
|
||||
|
||||
payload = {"columns": [{"id": column_id, "fields": fields}]}
|
||||
await self._request("PATCH", f"/tables/{table}/columns", json=payload)
|
||||
|
||||
@@ -186,13 +186,14 @@ def create_server(
|
||||
"column_id": {"type": "string"},
|
||||
"column_type": {"type": "string"},
|
||||
"formula": {"type": "string"},
|
||||
"label": {"type": "string", "description": "Display label for the column"},
|
||||
},
|
||||
"required": ["document", "table", "column_id", "column_type"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="modify_column",
|
||||
description="Modify a column's type or formula",
|
||||
description="Modify a column's type, formula, or label",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -201,6 +202,7 @@ def create_server(
|
||||
"column_id": {"type": "string"},
|
||||
"type": {"type": "string"},
|
||||
"formula": {"type": "string"},
|
||||
"label": {"type": "string", "description": "Display label for the column"},
|
||||
},
|
||||
"required": ["document", "table", "column_id"],
|
||||
},
|
||||
@@ -311,6 +313,7 @@ def create_server(
|
||||
_current_agent, auth, arguments["document"], arguments["table"],
|
||||
arguments["column_id"], arguments["column_type"],
|
||||
formula=arguments.get("formula"),
|
||||
label=arguments.get("label"),
|
||||
)
|
||||
elif name == "modify_column":
|
||||
result = await _modify_column(
|
||||
@@ -318,6 +321,7 @@ def create_server(
|
||||
arguments["column_id"],
|
||||
type=arguments.get("type"),
|
||||
formula=arguments.get("formula"),
|
||||
label=arguments.get("label"),
|
||||
)
|
||||
elif name == "delete_column":
|
||||
result = await _delete_column(
|
||||
|
||||
37
src/grist_mcp/tools/filters.py
Normal file
37
src/grist_mcp/tools/filters.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Filter normalization for Grist API queries."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def normalize_filter_value(value: Any) -> list:
|
||||
"""Ensure a filter value is a list.
|
||||
|
||||
Grist API expects filter values to be arrays.
|
||||
|
||||
Args:
|
||||
value: Single value or list of values.
|
||||
|
||||
Returns:
|
||||
Value wrapped in list, or original list if already a list.
|
||||
"""
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
|
||||
|
||||
def normalize_filter(filter: dict | None) -> dict | None:
|
||||
"""Normalize filter values to array format for Grist API.
|
||||
|
||||
Grist expects all filter values to be arrays. This function
|
||||
wraps single values in lists.
|
||||
|
||||
Args:
|
||||
filter: Filter dict with column names as keys.
|
||||
|
||||
Returns:
|
||||
Normalized filter dict, or None if input was None.
|
||||
"""
|
||||
if not filter:
|
||||
return filter
|
||||
|
||||
return {key: normalize_filter_value(value) for key, value in filter.items()}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from grist_mcp.auth import Agent, Authenticator, Permission
|
||||
from grist_mcp.grist_client import GristClient
|
||||
from grist_mcp.tools.filters import normalize_filter
|
||||
|
||||
|
||||
async def list_tables(
|
||||
@@ -56,7 +57,10 @@ async def get_records(
|
||||
doc = auth.get_document(document)
|
||||
client = GristClient(doc)
|
||||
|
||||
records = await client.get_records(table, filter=filter, sort=sort, limit=limit)
|
||||
# Normalize filter values to array format for Grist API
|
||||
normalized_filter = normalize_filter(filter)
|
||||
|
||||
records = await client.get_records(table, filter=normalized_filter, sort=sort, limit=limit)
|
||||
return {"records": records}
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ async def add_column(
|
||||
column_id: str,
|
||||
column_type: str,
|
||||
formula: str | None = None,
|
||||
label: str | None = None,
|
||||
client: GristClient | None = None,
|
||||
) -> dict:
|
||||
"""Add a column to a table."""
|
||||
@@ -40,7 +41,9 @@ async def add_column(
|
||||
doc = auth.get_document(document)
|
||||
client = GristClient(doc)
|
||||
|
||||
created_id = await client.add_column(table, column_id, column_type, formula=formula)
|
||||
created_id = await client.add_column(
|
||||
table, column_id, column_type, formula=formula, label=label
|
||||
)
|
||||
return {"column_id": created_id}
|
||||
|
||||
|
||||
@@ -52,16 +55,17 @@ async def modify_column(
|
||||
column_id: str,
|
||||
type: str | None = None,
|
||||
formula: str | None = None,
|
||||
label: str | None = None,
|
||||
client: GristClient | None = None,
|
||||
) -> dict:
|
||||
"""Modify a column's type or formula."""
|
||||
"""Modify a column's type, formula, or label."""
|
||||
auth.authorize(agent, document, Permission.SCHEMA)
|
||||
|
||||
if client is None:
|
||||
doc = auth.get_document(document)
|
||||
client = GristClient(doc)
|
||||
|
||||
await client.modify_column(table, column_id, type=type, formula=formula)
|
||||
await client.modify_column(table, column_id, type=type, formula=formula, label=label)
|
||||
return {"modified": True}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.14-slim
|
||||
FROM python:3.14-slim@sha256:fb83750094b46fd6b8adaa80f66e2302ecbe45d513f6cece637a841e1025b4ca
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -35,6 +35,18 @@ MOCK_TABLES = {
|
||||
{"id": 2, "fields": {"Title": "Deploy", "Done": False}},
|
||||
],
|
||||
},
|
||||
"Orders": {
|
||||
"columns": [
|
||||
{"id": "OrderNum", "fields": {"type": "Int"}},
|
||||
{"id": "Customer", "fields": {"type": "Ref:People"}},
|
||||
{"id": "Amount", "fields": {"type": "Numeric"}},
|
||||
],
|
||||
"records": [
|
||||
{"id": 1, "fields": {"OrderNum": 1001, "Customer": 1, "Amount": 100.0}},
|
||||
{"id": 2, "fields": {"OrderNum": 1002, "Customer": 2, "Amount": 200.0}},
|
||||
{"id": 3, "fields": {"OrderNum": 1003, "Customer": 1, "Amount": 150.0}},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Track requests for test assertions
|
||||
@@ -93,12 +105,40 @@ async def get_records(request):
|
||||
"""GET /api/docs/{doc_id}/tables/{table_id}/records"""
|
||||
doc_id = request.path_params["doc_id"]
|
||||
table_id = request.path_params["table_id"]
|
||||
log_request("GET", f"/api/docs/{doc_id}/tables/{table_id}/records")
|
||||
filter_param = request.query_params.get("filter")
|
||||
log_request("GET", f"/api/docs/{doc_id}/tables/{table_id}/records?filter={filter_param}")
|
||||
|
||||
if table_id not in MOCK_TABLES:
|
||||
return JSONResponse({"error": "Table not found"}, status_code=404)
|
||||
|
||||
return JSONResponse({"records": MOCK_TABLES[table_id]["records"]})
|
||||
records = MOCK_TABLES[table_id]["records"]
|
||||
|
||||
# Apply filtering if provided
|
||||
if filter_param:
|
||||
try:
|
||||
filters = json.loads(filter_param)
|
||||
# Validate filter format: all values must be arrays (Grist API requirement)
|
||||
for key, values in filters.items():
|
||||
if not isinstance(values, list):
|
||||
return JSONResponse(
|
||||
{"error": f"Filter values must be arrays, got {type(values).__name__} for '{key}'"},
|
||||
status_code=400
|
||||
)
|
||||
# Apply filters: record matches if field value is in the filter list
|
||||
filtered_records = []
|
||||
for record in records:
|
||||
match = True
|
||||
for key, allowed_values in filters.items():
|
||||
if record["fields"].get(key) not in allowed_values:
|
||||
match = False
|
||||
break
|
||||
if match:
|
||||
filtered_records.append(record)
|
||||
records = filtered_records
|
||||
except json.JSONDecodeError:
|
||||
return JSONResponse({"error": "Invalid filter JSON"}, status_code=400)
|
||||
|
||||
return JSONResponse({"records": records})
|
||||
|
||||
|
||||
async def add_records(request):
|
||||
|
||||
@@ -90,6 +90,36 @@ async def test_all_tools(services_ready):
|
||||
log = get_mock_request_log()
|
||||
assert any("/records" in entry["path"] and entry["method"] == "GET" for entry in log)
|
||||
|
||||
# Test get_records with Ref column filter
|
||||
# This tests that single values are normalized to arrays for the Grist API
|
||||
clear_mock_request_log()
|
||||
result = await client.call_tool(
|
||||
"get_records",
|
||||
{"document": "test-doc", "table": "Orders", "filter": {"Customer": 1}}
|
||||
)
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "records" in data
|
||||
# Should return only orders for Customer 1 (orders 1 and 3)
|
||||
assert len(data["records"]) == 2
|
||||
for record in data["records"]:
|
||||
assert record["Customer"] == 1
|
||||
log = get_mock_request_log()
|
||||
# Verify the filter was sent as array format
|
||||
filter_requests = [e for e in log if "/records" in e["path"] and "filter=" in e["path"]]
|
||||
assert len(filter_requests) >= 1
|
||||
# The filter value should be [1] not 1
|
||||
assert "[1]" in filter_requests[0]["path"]
|
||||
|
||||
# Test get_records with multiple filter values
|
||||
clear_mock_request_log()
|
||||
result = await client.call_tool(
|
||||
"get_records",
|
||||
{"document": "test-doc", "table": "Orders", "filter": {"Customer": [1, 2]}}
|
||||
)
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "records" in data
|
||||
assert len(data["records"]) == 3 # All 3 orders (customers 1 and 2)
|
||||
|
||||
# Test sql_query
|
||||
clear_mock_request_log()
|
||||
result = await client.call_tool(
|
||||
|
||||
89
tests/unit/test_filters.py
Normal file
89
tests/unit/test_filters.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Unit tests for filter normalization."""
|
||||
|
||||
import pytest
|
||||
|
||||
from grist_mcp.tools.filters import normalize_filter, normalize_filter_value
|
||||
|
||||
|
||||
class TestNormalizeFilterValue:
|
||||
"""Tests for normalize_filter_value function."""
|
||||
|
||||
def test_int_becomes_list(self):
|
||||
assert normalize_filter_value(5) == [5]
|
||||
|
||||
def test_string_becomes_list(self):
|
||||
assert normalize_filter_value("foo") == ["foo"]
|
||||
|
||||
def test_float_becomes_list(self):
|
||||
assert normalize_filter_value(3.14) == [3.14]
|
||||
|
||||
def test_list_unchanged(self):
|
||||
assert normalize_filter_value([1, 2, 3]) == [1, 2, 3]
|
||||
|
||||
def test_empty_list_unchanged(self):
|
||||
assert normalize_filter_value([]) == []
|
||||
|
||||
def test_single_item_list_unchanged(self):
|
||||
assert normalize_filter_value([42]) == [42]
|
||||
|
||||
def test_mixed_type_list_unchanged(self):
|
||||
assert normalize_filter_value([1, "foo", 3.14]) == [1, "foo", 3.14]
|
||||
|
||||
|
||||
class TestNormalizeFilter:
|
||||
"""Tests for normalize_filter function."""
|
||||
|
||||
def test_none_returns_none(self):
|
||||
assert normalize_filter(None) is None
|
||||
|
||||
def test_empty_dict_returns_empty_dict(self):
|
||||
assert normalize_filter({}) == {}
|
||||
|
||||
def test_single_int_value_wrapped(self):
|
||||
result = normalize_filter({"Transaction": 44})
|
||||
assert result == {"Transaction": [44]}
|
||||
|
||||
def test_single_string_value_wrapped(self):
|
||||
result = normalize_filter({"Status": "active"})
|
||||
assert result == {"Status": ["active"]}
|
||||
|
||||
def test_list_value_unchanged(self):
|
||||
result = normalize_filter({"Transaction": [44, 45, 46]})
|
||||
assert result == {"Transaction": [44, 45, 46]}
|
||||
|
||||
def test_mixed_columns_all_normalized(self):
|
||||
"""Both ref and non-ref columns are normalized to arrays."""
|
||||
result = normalize_filter({
|
||||
"Transaction": 44, # Ref column (int)
|
||||
"Debit": 500, # Non-ref column (int)
|
||||
"Memo": "test", # Non-ref column (str)
|
||||
})
|
||||
assert result == {
|
||||
"Transaction": [44],
|
||||
"Debit": [500],
|
||||
"Memo": ["test"],
|
||||
}
|
||||
|
||||
def test_multiple_values_list_unchanged(self):
|
||||
"""Filter with multiple values passes through."""
|
||||
result = normalize_filter({
|
||||
"Status": ["pending", "active"],
|
||||
"Priority": [1, 2, 3],
|
||||
})
|
||||
assert result == {
|
||||
"Status": ["pending", "active"],
|
||||
"Priority": [1, 2, 3],
|
||||
}
|
||||
|
||||
def test_mixed_single_and_list_values(self):
|
||||
"""Mix of single values and lists."""
|
||||
result = normalize_filter({
|
||||
"Transaction": 44, # Single int
|
||||
"Status": ["open", "closed"], # List
|
||||
"Amount": 100.50, # Single float
|
||||
})
|
||||
assert result == {
|
||||
"Transaction": [44],
|
||||
"Status": ["open", "closed"],
|
||||
"Amount": [100.50],
|
||||
}
|
||||
@@ -155,6 +155,27 @@ async def test_add_column(client, httpx_mock: HTTPXMock):
|
||||
col_id = await client.add_column("Table1", "NewCol", "Text", formula=None)
|
||||
|
||||
assert col_id == "NewCol"
|
||||
request = httpx_mock.get_request()
|
||||
import json
|
||||
payload = json.loads(request.content)
|
||||
assert payload == {"columns": [{"id": "NewCol", "fields": {"type": "Text"}}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_column_with_label(client, httpx_mock: HTTPXMock):
|
||||
httpx_mock.add_response(
|
||||
url="https://grist.example.com/api/docs/abc123/tables/Table1/columns",
|
||||
method="POST",
|
||||
json={"columns": [{"id": "first_name"}]},
|
||||
)
|
||||
|
||||
col_id = await client.add_column("Table1", "first_name", "Text", label="First Name")
|
||||
|
||||
assert col_id == "first_name"
|
||||
request = httpx_mock.get_request()
|
||||
import json
|
||||
payload = json.loads(request.content)
|
||||
assert payload == {"columns": [{"id": "first_name", "fields": {"type": "Text", "label": "First Name"}}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -169,6 +190,22 @@ async def test_modify_column(client, httpx_mock: HTTPXMock):
|
||||
await client.modify_column("Table1", "Amount", type="Int", formula="$Price * $Qty")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_column_with_label(client, httpx_mock: HTTPXMock):
|
||||
httpx_mock.add_response(
|
||||
url="https://grist.example.com/api/docs/abc123/tables/Table1/columns",
|
||||
method="PATCH",
|
||||
json={},
|
||||
)
|
||||
|
||||
await client.modify_column("Table1", "Col1", label="Column One")
|
||||
|
||||
request = httpx_mock.get_request()
|
||||
import json
|
||||
payload = json.loads(request.content)
|
||||
assert payload == {"columns": [{"id": "Col1", "fields": {"label": "Column One"}}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_column(client, httpx_mock: HTTPXMock):
|
||||
httpx_mock.add_response(
|
||||
|
||||
@@ -75,6 +75,45 @@ async def test_get_records(agent, auth, mock_client):
|
||||
assert result == {"records": [{"id": 1, "Name": "Alice"}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_records_normalizes_filter(agent, auth, mock_client):
|
||||
"""Test that filter values are normalized to array format for Grist API."""
|
||||
mock_client.get_records.return_value = [{"id": 1, "Customer": 5}]
|
||||
|
||||
await get_records(
|
||||
agent, auth, "budget", "Orders",
|
||||
filter={"Customer": 5, "Status": "active"},
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
# Verify filter was normalized: single values wrapped in lists
|
||||
mock_client.get_records.assert_called_once_with(
|
||||
"Orders",
|
||||
filter={"Customer": [5], "Status": ["active"]},
|
||||
sort=None,
|
||||
limit=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_records_preserves_list_filter(agent, auth, mock_client):
|
||||
"""Test that filter values already in list format are preserved."""
|
||||
mock_client.get_records.return_value = []
|
||||
|
||||
await get_records(
|
||||
agent, auth, "budget", "Orders",
|
||||
filter={"Customer": [5, 6, 7]},
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
mock_client.get_records.assert_called_once_with(
|
||||
"Orders",
|
||||
filter={"Customer": [5, 6, 7]},
|
||||
sort=None,
|
||||
limit=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sql_query(agent, auth, mock_client):
|
||||
result = await sql_query(agent, auth, "budget", "SELECT * FROM Table1", client=mock_client)
|
||||
|
||||
@@ -81,6 +81,25 @@ async def test_add_column(auth, mock_client):
|
||||
)
|
||||
|
||||
assert result == {"column_id": "NewCol"}
|
||||
mock_client.add_column.assert_called_once_with(
|
||||
"Table1", "NewCol", "Text", formula=None, label=None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_column_with_label(auth, mock_client):
|
||||
agent = auth.authenticate("schema-token")
|
||||
|
||||
result = await add_column(
|
||||
agent, auth, "budget", "Table1", "first_name", "Text",
|
||||
label="First Name",
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
assert result == {"column_id": "NewCol"}
|
||||
mock_client.add_column.assert_called_once_with(
|
||||
"Table1", "first_name", "Text", formula=None, label="First Name"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -95,6 +114,25 @@ async def test_modify_column(auth, mock_client):
|
||||
)
|
||||
|
||||
assert result == {"modified": True}
|
||||
mock_client.modify_column.assert_called_once_with(
|
||||
"Table1", "Col1", type="Int", formula="$A + $B", label=None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_column_with_label(auth, mock_client):
|
||||
agent = auth.authenticate("schema-token")
|
||||
|
||||
result = await modify_column(
|
||||
agent, auth, "budget", "Table1", "Col1",
|
||||
label="Column One",
|
||||
client=mock_client,
|
||||
)
|
||||
|
||||
assert result == {"modified": True}
|
||||
mock_client.modify_column.assert_called_once_with(
|
||||
"Table1", "Col1", type=None, formula=None, label="Column One"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user