feat: replace CordraPy with http requests

This commit is contained in:
daniel
2025-06-29 14:47:43 +02:00
parent cc06e1eace
commit 53cbb53178
6 changed files with 111 additions and 102 deletions

View File

@@ -10,11 +10,9 @@ license = "MIT"
requires-python = ">=3.11"
dependencies = [
"mcp>=1.2.0",
"httpx>=0.25.0",
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
"cordrapy",
"setuptools>=80.9.0",
"requests>=2.25.0",
]
[project.optional-dependencies]
@@ -56,8 +54,6 @@ asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "-v"
[tool.uv.sources]
cordrapy = { git = "https://github.com/usnistgov/CordraPy.git" }
[dependency-groups]
dev = [

View File

@@ -1,12 +1,15 @@
"""Cordra client wrapper using CordraPy."""
"""Cordra client wrapper using HTTP requests."""
import logging
from typing import Any
import cordra
import requests
from pydantic import BaseModel, Field
from .config import CordraConfig
logger = logging.getLogger(__name__)
class DigitalObject(BaseModel):
"""Model for a Cordra digital object."""
@@ -30,7 +33,7 @@ class CordraNotFoundError(CordraClientError):
class CordraClient:
"""Client for interacting with Cordra repository."""
"""Client for interacting with Cordra repository using HTTP requests."""
def __init__(self, config: CordraConfig) -> None:
"""Initialize the Cordra client.
@@ -39,6 +42,14 @@ class CordraClient:
config: Configuration settings for the Cordra connection
"""
self.config = config
self.session = requests.Session()
self.session.verify = config.verify_ssl
# Set up authentication
if config.username and config.password:
self.session.auth = (config.username, config.password)
elif config.username or config.password:
logger.warning("Only username or password provided, not both. Authentication may fail.")
async def get_object(self, object_id: str) -> DigitalObject:
"""Retrieve a digital object by its ID.
@@ -54,28 +65,34 @@ class CordraClient:
CordraClientError: For other API errors
"""
try:
cordra_obj: dict[str, Any] = cordra.CordraObject.read(
host=self.config.cordra_url, #type: ignore
obj_id=object_id,
username=self.config.username,
password=self.config.password,
verify=self.config.verify_ssl,
full=True # Get full object details including metadata, paylods, etc.
)
# Build URL: cordra_base_url/objects/prefix/postfix
url = f"{self.config.cordra_url}/objects/{object_id}"
# Add full=true parameter to get complete object details
params = {"full": "true"}
response = self.session.get(url, params=params, timeout=self.config.timeout)
if response.status_code == 404:
raise CordraNotFoundError(f"Object not found: {object_id}")
response.raise_for_status()
cordra_obj = response.json()
return DigitalObject(
id=object_id,
type=cordra_obj['type'],
content=cordra_obj['content'],
type=cordra_obj.get('type', ''),
content=cordra_obj.get('content', cordra_obj),
metadata=cordra_obj.get('metadata'),
acl=cordra_obj.get('acl'),
payloads=cordra_obj.get('payloads'),
)
except CordraNotFoundError:
raise
except requests.RequestException as e:
raise CordraClientError(f"Failed to retrieve object {object_id}: {e}") from e
except Exception as e:
error_msg = str(e).lower()
if 'not found' in error_msg or '404' in error_msg:
raise CordraNotFoundError(f"Object not found: {object_id}") from e
raise CordraClientError(f"Failed to retrieve object {object_id}: {e}") from e
async def find(self, query: str) -> list[dict[str, Any]]:
@@ -91,19 +108,22 @@ class CordraClient:
CordraClientError: If there's an API error
"""
try:
# Use CordraPy to find objects
# TODO - need to handle pagination, but the CordraPy API does not support it.
response: dict[str, Any] = cordra.CordraObject.find(
self.config.cordra_url, # type: ignore
query
)
# Use HTTP GET request to search endpoint
url = f"{self.config.cordra_url}/search"
params = {"query": query}
response = self.session.get(url, params=params, timeout=self.config.timeout)
response.raise_for_status()
search_result = response.json()
# Extract the results array from the response
if isinstance(response, dict) and 'results' in response:
return response['results']
if isinstance(search_result, dict) and 'results' in search_result:
return search_result['results']
else:
return []
except Exception as e:
except requests.RequestException as e:
raise CordraClientError(f"Failed to search with query '{query}': {e}") from e
except Exception as e:
raise CordraClientError(f"Failed to search with query '{query}': {e}") from e

View File

@@ -59,8 +59,8 @@ async def list_cordra_schemas() -> str:
# Extract the names from the schema objects
schema_names = []
for schema in schemas:
if isinstance(schema, dict) and 'name' in schema:
schema_names.append(schema['name'])
if isinstance(schema, dict) and 'content' in schema and 'name' in schema['content']:
schema_names.append(schema['content']['name'])
result = {
"schemas": schema_names,

View File

@@ -112,10 +112,13 @@ class TestCordraClient:
client = CordraClient(config)
assert client.config == config
@patch('mcp_cordra.client.cordra.CordraObject.read')
async def test_get_object_success(self, mock_read, client, mock_cordra_object):
@patch('mcp_cordra.client.requests.Session.get')
async def test_get_object_success(self, mock_get, client, mock_cordra_object):
"""Test successful object retrieval."""
mock_read.return_value = mock_cordra_object
mock_response = mock_get.return_value
mock_response.status_code = 200
mock_response.json.return_value = mock_cordra_object
mock_response.raise_for_status.return_value = None
result = await client.get_object("test/123")
@@ -127,39 +130,38 @@ class TestCordraClient:
assert result.acl == {"read": ["public"], "write": ["admin"]}
assert result.payloads and len(result.payloads) == 2
mock_read.assert_called_once_with(
host="https://test.example.com",
obj_id="test/123",
username="testuser",
password="testpass",
verify=False,
full=True
mock_get.assert_called_once_with(
"https://test.example.com/objects/test/123",
params={"full": "true"},
timeout=30
)
@patch('mcp_cordra.client.cordra.CordraObject.read')
async def test_get_object_not_found(self, mock_read, client):
@patch('mcp_cordra.client.requests.Session.get')
async def test_get_object_not_found(self, mock_get, client):
"""Test object not found exception."""
mock_read.side_effect = Exception("Object not found")
mock_response = mock_get.return_value
mock_response.status_code = 404
with pytest.raises(CordraNotFoundError) as exc_info:
await client.get_object("test/nonexistent")
assert "Object not found: test/nonexistent" in str(exc_info.value)
@patch('mcp_cordra.client.cordra.CordraObject.read')
async def test_get_object_general_error(self, mock_read, client):
@patch('mcp_cordra.client.requests.Session.get')
async def test_get_object_general_error(self, mock_get, client):
"""Test general error handling."""
mock_read.side_effect = Exception("Connection failed")
from requests import RequestException
mock_get.side_effect = RequestException("Connection failed")
with pytest.raises(CordraClientError) as exc_info:
await client.get_object("test/123")
assert "Failed to retrieve object test/123" in str(exc_info.value)
@patch('mcp_cordra.client.cordra.CordraObject.find')
async def test_find_success(self, mock_find, client):
@patch('mcp_cordra.client.requests.Session.get')
async def test_find_success(self, mock_get, client):
"""Test successful find operation."""
mock_response = {
mock_response_data = {
"results": [
{"name": "User", "identifier": "test/user-schema"},
{"name": "Project", "identifier": "test/project-schema"},
@@ -167,7 +169,10 @@ class TestCordraClient:
],
"size": 3
}
mock_find.return_value = mock_response
mock_response = mock_get.return_value
mock_response.status_code = 200
mock_response.json.return_value = mock_response_data
mock_response.raise_for_status.return_value = None
result = await client.find("type:Schema")
@@ -176,39 +181,48 @@ class TestCordraClient:
assert result[1]["name"] == "Project"
assert result[2]["name"] == "Document"
mock_find.assert_called_once_with(
client.config.cordra_url,
"type:Schema"
mock_get.assert_called_once_with(
"https://test.example.com/search",
params={"query": "type:Schema"},
timeout=30
)
@patch('mcp_cordra.client.cordra.CordraObject.find')
async def test_find_empty_results(self, mock_find, client):
@patch('mcp_cordra.client.requests.Session.get')
async def test_find_empty_results(self, mock_get, client):
"""Test find with empty results."""
mock_response = {"results": [], "size": 0}
mock_find.return_value = mock_response
mock_response_data = {"results": [], "size": 0}
mock_response = mock_get.return_value
mock_response.status_code = 200
mock_response.json.return_value = mock_response_data
mock_response.raise_for_status.return_value = None
result = await client.find("type:NonExistent")
assert result == []
mock_find.assert_called_once_with(
client.config.cordra_url,
"type:NonExistent"
mock_get.assert_called_once_with(
"https://test.example.com/search",
params={"query": "type:NonExistent"},
timeout=30
)
@patch('mcp_cordra.client.cordra.CordraObject.find')
async def test_find_no_results_key(self, mock_find, client):
@patch('mcp_cordra.client.requests.Session.get')
async def test_find_no_results_key(self, mock_get, client):
"""Test find with response missing results key."""
mock_response = {"size": 0} # No results key
mock_find.return_value = mock_response
mock_response_data = {"size": 0} # No results key
mock_response = mock_get.return_value
mock_response.status_code = 200
mock_response.json.return_value = mock_response_data
mock_response.raise_for_status.return_value = None
result = await client.find("type:Schema")
assert result == []
@patch('mcp_cordra.client.cordra.CordraObject.find')
async def test_find_error(self, mock_find, client):
@patch('mcp_cordra.client.requests.Session.get')
async def test_find_error(self, mock_get, client):
"""Test find error handling."""
mock_find.side_effect = Exception("Search failed")
from requests import RequestException
mock_get.side_effect = RequestException("Search failed")
with pytest.raises(CordraClientError) as exc_info:
await client.find("invalid:query")

View File

@@ -152,10 +152,10 @@ class TestListCordraSchemas:
async def test_list_schemas_success(self, mock_client):
"""Test successful schema listing."""
mock_schemas = [
{"name": "User", "identifier": "test/user-schema"},
{"name": "Project", "identifier": "test/project-schema"},
{"name": "Document", "identifier": "test/doc-schema"},
{"name": "CaptureEvent", "identifier": "test/capture-schema"}
{"content": {"name": "User"}, "identifier": "test/user-schema"},
{"content": {"name": "Project"}, "identifier": "test/project-schema"},
{"content": {"name": "Document"}, "identifier": "test/doc-schema"},
{"content": {"name": "CaptureEvent"}, "identifier": "test/capture-schema"}
]
mock_client.find = AsyncMock(return_value=mock_schemas)
@@ -193,10 +193,10 @@ class TestListCordraSchemas:
async def test_list_schemas_missing_name_field(self, mock_client):
"""Test schema listing with objects missing name field."""
mock_schemas = [
{"name": "User", "identifier": "test/user-schema"},
{"identifier": "test/no-name-schema"}, # Missing name field
{"name": "Project", "identifier": "test/project-schema"},
{"other": "field"} # No name or identifier
{"content": {"name": "User"}, "identifier": "test/user-schema"},
{"content": {}, "identifier": "test/no-name-schema"}, # Missing name field
{"content": {"name": "Project"}, "identifier": "test/project-schema"},
{"content": {"other": "field"}} # No name or identifier
]
mock_client.find = AsyncMock(return_value=mock_schemas)
@@ -226,7 +226,7 @@ class TestListCordraSchemas:
async def test_list_schemas_json_format(self, mock_client):
"""Test that the returned JSON is properly formatted."""
mock_schemas = [
{"name": "TestSchema", "identifier": "test/schema"}
{"content": {"name": "TestSchema"}, "identifier": "test/schema"}
]
mock_client.find = AsyncMock(return_value=mock_schemas)

25
uv.lock generated
View File

@@ -112,14 +112,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "cordrapy"
version = "0.3.2"
source = { git = "https://github.com/usnistgov/CordraPy.git#7d73f3f58461a2dc7540970fe7defaa361b61740" }
dependencies = [
{ name = "requests" },
]
[[package]]
name = "coverage"
version = "7.9.1"
@@ -309,12 +301,10 @@ name = "mcp-cordra"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "cordrapy" },
{ name = "httpx" },
{ name = "mcp" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "setuptools" },
{ name = "requests" },
]
[package.optional-dependencies]
@@ -334,8 +324,6 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cordrapy", git = "https://github.com/usnistgov/CordraPy.git" },
{ name = "httpx", specifier = ">=0.25.0" },
{ name = "loguru", marker = "extra == 'dev'", specifier = ">=0.7.0" },
{ name = "mcp", specifier = ">=1.2.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.6.0" },
@@ -344,8 +332,8 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
{ name = "requests", specifier = ">=2.25.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
{ name = "setuptools", specifier = ">=80.9.0" },
]
provides-extras = ["dev"]
@@ -711,15 +699,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/d0/6902c0d017259439d6fd2fd9393cea1cfe30169940118b007d5e0ea7e954/ruff-0.12.1-py3-none-win_arm64.whl", hash = "sha256:78ad09a022c64c13cc6077707f036bab0fac8cd7088772dcd1e5be21c5002efc", size = 10691209 },
]
[[package]]
name = "setuptools"
version = "80.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 },
]
[[package]]
name = "sniffio"
version = "1.3.1"