diff --git a/README.md b/README.md index 37df591..02adf42 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ score and an evidence trail, never a false certainty. | `matilde_verify_citation` | Verify one reference (any of: doi, title, authors, year, url) → verdict + score + per-axis detail | | `matilde_verify_bibliography` | Verify a whole reference list → per-item verdicts + a summary + the items that need attention | | `matilde_check_retraction` | Quick retraction-only check by DOI | +| `matilde_openneuro_dataset_info` | Metadata for an OpenNeuro dataset (title, authors, modalities, subjects, tasks, size) | +| `matilde_openneuro_search` | List OpenNeuro dataset IDs to discover brain-imaging datasets | +| `matilde_openneuro_list_files` | List a dataset's files with sizes and direct download URLs | No API keys required — Crossref, OpenAlex, and DataCite are free and unauthenticated. Optionally set `MATILDE_CONTACT_EMAIL` to join the providers' @@ -104,10 +107,12 @@ MATILDE_LIVE=1 python3 -m pytest tests/test_citations_integration.py # live A Matilde grows outward from citations toward a full scientific research assistant: +- **Neuroscience / OpenNeuro** — *discovery shipped* ✅: search datasets, read metadata, + list/download files over the public GraphQL API + S3 (stdlib-only, no datalad needed). + *Next:* BIDS-compliance validation, then heavier analysis (fMRIPrep, NiMARE + meta-analysis) behind the Docker layer. - **v2 — claim-support grounding**: GROBID PDF→TEI + SciFact/SemanticCite passage-level "does the source actually support this claim?" -- **Neuroscience / OpenNeuro**: pull and reason over [OpenNeuro](https://openneuro.org) - / BIDS datasets (openneuro-py, DataLad, NiMARE); validate, analyze, replicate. - **Meta-science**: statistical re-checking of published results (statcheck, GRIM/SPRITE, p-curve) to flag reporting inconsistencies. - **Manuscript writing → LaTeX**: draft in Google Docs (multiplayer), convert via @@ -124,6 +129,8 @@ privacy model, sanitization gate, and promotion flow. | Path | What it is | |------|-----------| | `engine/citations.py` | The verifiable-citations engine (the core; the part we may open-source standalone) | +| `engine/openneuro.py` | Read-only OpenNeuro/BIDS client — discovery, metadata, files (stdlib-only) | +| `engine/parsing.py` · `engine/cli.py` | BibTeX/DOI ingestion + the `matilde` verify CLI | | `hermes-plugin/` | Hermes tool definitions exposing the engine to the agent | | `hermes-skill/SKILL.md` | The agent's research methodology | | `docker/SOUL.Matilde.md` | The research-assistant identity | diff --git a/engine/cli.py b/engine/cli.py index 1c4a99f..2928906 100644 --- a/engine/cli.py +++ b/engine/cli.py @@ -6,7 +6,7 @@ python3 -m engine.cli dois.txt # verify a list of DOIs python3 -m engine.cli --doi 10.1038/171737a0 # verify one DOI python3 -m engine.cli refs.bib --json # machine-readable output - python3 -m engine.cli refs.bib --email you@uni.edu # polite-pool contact + python3 -m engine.cli refs.bib --email you@example.org # polite-pool contact Exit codes: 0 = all references verified / only warnings; 1 = at least one ``not_found`` or ``retracted`` reference (useful as a pre-commit / CI gate on a diff --git a/engine/openneuro.py b/engine/openneuro.py new file mode 100644 index 0000000..040075e --- /dev/null +++ b/engine/openneuro.py @@ -0,0 +1,216 @@ +"""OpenNeuro client — discover, inspect, and pull BIDS neuroimaging datasets. + +This is Matilde's first scientific-data capability. It is deliberately +**read-only and stdlib-only**: it talks to the OpenNeuro GraphQL API and the +public, no-auth S3 mirror over plain HTTP — no ``datalad``, ``git-annex``, +``openneuro-py``, or AWS SDK required. Validated live against ``ds000246``. + +What it does today (realistic first capability): + - ``list_datasets`` — enumerate dataset accession IDs + - ``get_dataset`` — metadata: name, authors, modalities, subjects, tasks, size + - ``list_files`` — files in the latest snapshot, with direct S3 URLs + - ``download_file`` — fetch one file to disk + +What is intentionally **out of scope** (heavy / aspirational — needs system deps +and compute): cloning full datasets with DataLad/git-annex, running BIDS-App +pipelines like fMRIPrep, and large-scale meta-analysis with NiMARE. Those belong +behind the Docker image layer, not this lightweight client. + +All network I/O is injected (``gql`` / ``http_get``) so every path is unit-testable +offline; production defaults using urllib are provided. +""" +from __future__ import annotations + +import dataclasses +import json +import os +import urllib.request +from dataclasses import dataclass, field +from typing import Any, Callable, List, Optional + +OPENNEURO_GRAPHQL = "https://openneuro.org/crn/graphql" + +GqlFn = Callable[..., dict] +HttpGetFn = Callable[..., bytes] + + +class OpenNeuroError(Exception): + """Raised when an OpenNeuro request fails or returns nothing usable.""" + + +@dataclass +class Dataset: + id: str + name: str = "" + authors: List[str] = field(default_factory=list) + modalities: List[str] = field(default_factory=list) + subjects: List[str] = field(default_factory=list) + tasks: List[str] = field(default_factory=list) + size: Optional[int] = None + latest_tag: str = "" + created: str = "" + public: Optional[bool] = None + + def to_dict(self) -> dict: + return dataclasses.asdict(self) + + +# --------------------------------------------------------------------------- +# GraphQL queries +# --------------------------------------------------------------------------- + +_DATASET_QUERY = """ +query ($id: ID!) { + dataset(id: $id) { + id + created + public + latestSnapshot { + tag + created + size + description { Name Authors } + summary { subjects modalities tasks } + } + } +} +""" + +_DATASETS_QUERY = """ +query ($first: Int!) { + datasets(first: $first) { + edges { node { id } } + } +} +""" + +_SNAPSHOT_FILES_QUERY = """ +query ($datasetId: ID!, $tag: String!) { + snapshot(datasetId: $datasetId, tag: $tag) { + id + files { filename size urls } + } +} +""" + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def get_dataset(dataset_id: str, gql: Optional[GqlFn] = None) -> Dataset: + """Return metadata for *dataset_id* (e.g. ``"ds000246"``).""" + gql = gql or default_gql + data = gql(_DATASET_QUERY, {"id": dataset_id}) + node = (data or {}).get("dataset") + if not node: + raise OpenNeuroError(f"OpenNeuro dataset {dataset_id!r} not found.") + snap = node.get("latestSnapshot") or {} + desc = snap.get("description") or {} + summary = snap.get("summary") or {} + return Dataset( + id=node.get("id", dataset_id), + name=desc.get("Name", "") or "", + authors=list(desc.get("Authors") or []), + modalities=list(summary.get("modalities") or []), + subjects=list(summary.get("subjects") or []), + tasks=list(summary.get("tasks") or []), + size=snap.get("size"), + latest_tag=snap.get("tag", "") or "", + created=node.get("created", "") or "", + public=node.get("public"), + ) + + +def list_datasets(limit: int = 20, gql: Optional[GqlFn] = None) -> List[str]: + """Return up to *limit* OpenNeuro dataset accession IDs. + + Note: this lists datasets (most recent first as OpenNeuro orders them); it is + not a full-text search. Use ``get_dataset`` to inspect each candidate's + modalities/tasks and filter client-side. + """ + gql = gql or default_gql + data = gql(_DATASETS_QUERY, {"first": int(limit)}) + edges = ((data or {}).get("datasets") or {}).get("edges") or [] + return [e["node"]["id"] for e in edges if e.get("node", {}).get("id")] + + +def list_files(dataset_id: str, tag: Optional[str] = None, + gql: Optional[GqlFn] = None) -> List[dict]: + """Return the files in a dataset snapshot: ``[{filename, size, url}, ...]``. + + If *tag* is omitted, the latest snapshot tag is resolved automatically. + """ + gql = gql or default_gql + if not tag: + tag = get_dataset(dataset_id, gql=gql).latest_tag + if not tag: + raise OpenNeuroError(f"No snapshot tag available for {dataset_id!r}.") + data = gql(_SNAPSHOT_FILES_QUERY, {"datasetId": dataset_id, "tag": tag}) + snap = (data or {}).get("snapshot") + if not snap: + raise OpenNeuroError(f"Snapshot {dataset_id}:{tag} not found.") + out = [] + for f in snap.get("files") or []: + urls = f.get("urls") or [] + out.append({"filename": f.get("filename", ""), "size": f.get("size"), + "url": urls[0] if urls else ""}) + return out + + +def download_file(dataset_id: str, filename: str, dest_path: str, + tag: Optional[str] = None, gql: Optional[GqlFn] = None, + http_get: Optional[HttpGetFn] = None) -> str: + """Download a single file from a dataset snapshot to *dest_path*. + + Returns the path written. Raises ``OpenNeuroError`` if the file is not in the + snapshot. Intended for small files (metadata, README, single NIfTI); pulling + whole datasets is out of scope for this lightweight client — use DataLad. + """ + gql = gql or default_gql + http_get = http_get or default_http_get + files = list_files(dataset_id, tag=tag, gql=gql) + match = next((f for f in files if f["filename"] == filename), None) + if match is None: + raise OpenNeuroError( + f"{filename!r} not found in {dataset_id} (snapshot has {len(files)} files).") + if not match["url"]: + raise OpenNeuroError(f"No download URL for {filename!r}.") + content = http_get(match["url"]) + parent = os.path.dirname(os.path.abspath(dest_path)) + os.makedirs(parent, exist_ok=True) + with open(dest_path, "wb") as fh: + fh.write(content) + return dest_path + + +# --------------------------------------------------------------------------- +# Production I/O defaults (stdlib only) +# --------------------------------------------------------------------------- + +def _user_agent() -> str: + contact = os.environ.get("MATILDE_CONTACT_EMAIL", "").strip() + base = "Matilde/0.1 (https://github.com/NimbleCoAI/Matilde)" + return f"{base} mailto:{contact}" if contact else base + + +def default_gql(query: str, variables: Optional[dict] = None, + timeout: float = 30.0) -> dict: + """POST a GraphQL query to OpenNeuro and return the ``data`` object.""" + body = json.dumps({"query": query, "variables": variables or {}}).encode("utf-8") + req = urllib.request.Request( + OPENNEURO_GRAPHQL, data=body, + headers={"Content-Type": "application/json", "Accept": "application/json", + "User-Agent": _user_agent()}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + payload = json.loads(resp.read().decode("utf-8")) + if payload.get("errors"): + raise OpenNeuroError(f"GraphQL error: {payload['errors']}") + return payload.get("data") or {} + + +def default_http_get(url: str, timeout: float = 120.0) -> bytes: + """GET *url* and return raw bytes (for S3 file downloads).""" + req = urllib.request.Request(url, headers={"User-Agent": _user_agent()}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read() diff --git a/hermes-plugin/__init__.py b/hermes-plugin/__init__.py index d1e4f19..98c1773 100644 --- a/hermes-plugin/__init__.py +++ b/hermes-plugin/__init__.py @@ -34,10 +34,16 @@ VERIFY_CITATION_SCHEMA = _tools_mod.VERIFY_CITATION_SCHEMA VERIFY_BIBLIOGRAPHY_SCHEMA = _tools_mod.VERIFY_BIBLIOGRAPHY_SCHEMA CHECK_RETRACTION_SCHEMA = _tools_mod.CHECK_RETRACTION_SCHEMA +OPENNEURO_INFO_SCHEMA = _tools_mod.OPENNEURO_INFO_SCHEMA +OPENNEURO_SEARCH_SCHEMA = _tools_mod.OPENNEURO_SEARCH_SCHEMA +OPENNEURO_FILES_SCHEMA = _tools_mod.OPENNEURO_FILES_SCHEMA _check_available = _tools_mod._check_available _handle_verify_citation = _tools_mod._handle_verify_citation _handle_verify_bibliography = _tools_mod._handle_verify_bibliography _handle_check_retraction = _tools_mod._handle_check_retraction +_handle_openneuro_dataset_info = _tools_mod._handle_openneuro_dataset_info +_handle_openneuro_search = _tools_mod._handle_openneuro_search +_handle_openneuro_list_files = _tools_mod._handle_openneuro_list_files # --------------------------------------------------------------------------- # Tool registry — (tool_name, schema, handler, emoji) @@ -46,6 +52,9 @@ ("matilde_verify_citation", VERIFY_CITATION_SCHEMA, _handle_verify_citation, "✓"), ("matilde_verify_bibliography", VERIFY_BIBLIOGRAPHY_SCHEMA, _handle_verify_bibliography, "📚"), ("matilde_check_retraction", CHECK_RETRACTION_SCHEMA, _handle_check_retraction, "⚠"), + ("matilde_openneuro_dataset_info", OPENNEURO_INFO_SCHEMA, _handle_openneuro_dataset_info, "🧠"), + ("matilde_openneuro_search", OPENNEURO_SEARCH_SCHEMA, _handle_openneuro_search, "🔎"), + ("matilde_openneuro_list_files", OPENNEURO_FILES_SCHEMA, _handle_openneuro_list_files, "🗂"), ) diff --git a/hermes-plugin/plugin.yaml b/hermes-plugin/plugin.yaml index e827d7e..d4a535e 100644 --- a/hermes-plugin/plugin.yaml +++ b/hermes-plugin/plugin.yaml @@ -8,17 +8,20 @@ name: "matilde" version: "0.1.0" -description: "Matilde — verifiable academic citations: existence, metadata-match, retraction, and URL-liveness checking against Crossref, OpenAlex, and DataCite." +description: "Matilde — academic research assistant: verifiable citations (Crossref/OpenAlex/DataCite + retraction) and OpenNeuro/BIDS dataset discovery." author: "NimbleCoAI" kind: standalone -# No credentials required — Crossref, OpenAlex, and DataCite are free and -# unauthenticated. MATILDE_CONTACT_EMAIL is optional (joins providers' polite -# pools) and is therefore NOT listed as required env. +# No credentials required — Crossref, OpenAlex, DataCite, and OpenNeuro's public +# API/S3 are all free and unauthenticated. MATILDE_CONTACT_EMAIL is optional +# (joins providers' polite pools) and is therefore NOT listed as required env. provides_tools: - "matilde_verify_citation" - "matilde_verify_bibliography" - "matilde_check_retraction" + - "matilde_openneuro_dataset_info" + - "matilde_openneuro_search" + - "matilde_openneuro_list_files" diff --git a/hermes-plugin/tools.py b/hermes-plugin/tools.py index 7f542f0..53b9a2b 100644 --- a/hermes-plugin/tools.py +++ b/hermes-plugin/tools.py @@ -256,3 +256,122 @@ def _handle_check_retraction(args: dict, **kwargs: Any) -> str: ) except Exception as exc: return _tool_error(f"check_retraction failed: {type(exc).__name__}: {exc}") + + +# --------------------------------------------------------------------------- +# Tool: OpenNeuro dataset metadata +# --------------------------------------------------------------------------- + +OPENNEURO_INFO_SCHEMA = { + "name": "matilde_openneuro_dataset_info", + "description": ( + "Get metadata for an OpenNeuro neuroimaging dataset by accession ID " + "(e.g. 'ds000246'): human title, authors, imaging modalities (meg/mri/" + "eeg/ieeg/ecog), subjects, tasks, total size, and latest snapshot tag. " + "OpenNeuro hosts public BIDS-formatted brain-imaging datasets." + ), + "parameters": { + "type": "object", + "properties": { + "dataset_id": {"type": "string", "description": "OpenNeuro accession ID, e.g. 'ds000246'."}, + }, + "required": ["dataset_id"], + }, +} + + +def _handle_openneuro_dataset_info(args: dict, **kwargs: Any) -> str: + dsid = str(args.get("dataset_id", "")).strip() + if not dsid: + return _tool_error("'dataset_id' is required (e.g. 'ds000246').") + try: + from engine.openneuro import get_dataset, OpenNeuroError + try: + ds = get_dataset(dsid) + except OpenNeuroError as exc: + return _tool_error(str(exc), dataset_id=dsid) + payload = ds.to_dict() + payload["message"] = (f"{ds.id}: '{ds.name}' — modalities {ds.modalities}, " + f"{len(ds.subjects)} subject(s), {ds.size} bytes " + f"(snapshot {ds.latest_tag}).") + return _tool_result(payload) + except Exception as exc: + return _tool_error(f"openneuro_dataset_info failed: {type(exc).__name__}: {exc}") + + +# --------------------------------------------------------------------------- +# Tool: OpenNeuro dataset listing +# --------------------------------------------------------------------------- + +OPENNEURO_SEARCH_SCHEMA = { + "name": "matilde_openneuro_search", + "description": ( + "List OpenNeuro dataset accession IDs (most recent first) to discover " + "datasets. This lists rather than full-text-searches; call " + "matilde_openneuro_dataset_info on candidates to inspect modalities/tasks " + "and filter. Use to find brain-imaging datasets to analyze or replicate." + ), + "parameters": { + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "How many dataset IDs to return (default 20)."}, + }, + "required": [], + }, +} + + +def _handle_openneuro_search(args: dict, **kwargs: Any) -> str: + try: + from engine.openneuro import list_datasets + limit = args.get("limit") or 20 + try: + limit = max(1, min(int(limit), 100)) + except (TypeError, ValueError): + limit = 20 + ids = list_datasets(limit=limit) + return _tool_result(count=len(ids), dataset_ids=ids, + message=f"Listed {len(ids)} OpenNeuro dataset(s).") + except Exception as exc: + return _tool_error(f"openneuro_search failed: {type(exc).__name__}: {exc}") + + +# --------------------------------------------------------------------------- +# Tool: OpenNeuro file listing +# --------------------------------------------------------------------------- + +OPENNEURO_FILES_SCHEMA = { + "name": "matilde_openneuro_list_files", + "description": ( + "List the files in an OpenNeuro dataset's latest snapshot — filename, size, " + "and a direct download URL for each. Use to inspect a dataset's structure " + "(BIDS layout) or to get a URL for a specific file before downloading." + ), + "parameters": { + "type": "object", + "properties": { + "dataset_id": {"type": "string", "description": "OpenNeuro accession ID, e.g. 'ds000246'."}, + "tag": {"type": "string", "description": "Snapshot tag (optional; defaults to the latest)."}, + }, + "required": ["dataset_id"], + }, +} + + +def _handle_openneuro_list_files(args: dict, **kwargs: Any) -> str: + dsid = str(args.get("dataset_id", "")).strip() + if not dsid: + return _tool_error("'dataset_id' is required (e.g. 'ds000246').") + try: + from engine.openneuro import list_files, OpenNeuroError + tag = str(args.get("tag", "")).strip() or None + try: + files = list_files(dsid, tag=tag) + except OpenNeuroError as exc: + return _tool_error(str(exc), dataset_id=dsid) + return _tool_result( + dataset_id=dsid, count=len(files), files=files, + message=f"{dsid} has {len(files)} file(s) in its snapshot.", + ) + except Exception as exc: + return _tool_error(f"openneuro_list_files failed: {type(exc).__name__}: {exc}") diff --git a/tests/test_openneuro.py b/tests/test_openneuro.py new file mode 100644 index 0000000..379a3fb --- /dev/null +++ b/tests/test_openneuro.py @@ -0,0 +1,166 @@ +"""Tests for the OpenNeuro client (discovery / metadata / files / download). + +GraphQL and HTTP are injected, so these run offline. The canned responses mirror +the REAL OpenNeuro GraphQL shapes, validated live against ds000246. +""" +from __future__ import annotations + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))) + +from engine.openneuro import ( # noqa: E402 + Dataset, + OpenNeuroError, + download_file, + get_dataset, + list_datasets, + list_files, +) + +# Real GraphQL response shape for dataset(id:"ds000246"). +DATASET_RESP = { + "dataset": { + "id": "ds000246", + "created": "2018-03-30T10:34:05.130Z", + "public": True, + "latestSnapshot": { + "tag": "1.0.1", + "created": "2024-04-23T10:17:01.000Z", + "size": 2457671893, + "description": { + "Name": "MEG-BIDS Brainstorm data sample", + "Authors": ["Elizabeth Bock", "Francois Tadel"], + }, + "summary": {"subjects": ["0001"], "modalities": ["meg", "mri"], "tasks": ["AEF", "noise"]}, + }, + } +} + +DATASETS_RESP = { + "datasets": {"edges": [ + {"node": {"id": "ds000001"}}, + {"node": {"id": "ds000002"}}, + {"node": {"id": "ds000003"}}, + ]} +} + +SNAPSHOT_FILES_RESP = { + "snapshot": { + "id": "ds000246:1.0.1", + "files": [ + {"filename": "dataset_description.json", "size": 958, + "urls": ["https://s3.amazonaws.com/openneuro.org/ds000246/dataset_description.json?versionId=abc"]}, + {"filename": "README", "size": 5990, + "urls": ["https://s3.amazonaws.com/openneuro.org/ds000246/README?versionId=def"]}, + ], + } +} + + +def make_gql(routes): + """Return a gql(query, variables)->data that picks a response by substring.""" + def _gql(query, variables=None): + for needle, resp in routes.items(): + if needle in query: + if isinstance(resp, Exception): + raise resp + return resp + raise AssertionError(f"no canned response for query: {query[:60]}") + return _gql + + +# --------------------------------------------------------------------------- +# get_dataset +# --------------------------------------------------------------------------- + +def test_get_dataset_parses_metadata(): + ds = get_dataset("ds000246", gql=make_gql({"dataset(": DATASET_RESP})) + assert isinstance(ds, Dataset) + assert ds.id == "ds000246" + assert ds.name == "MEG-BIDS Brainstorm data sample" + assert ds.modalities == ["meg", "mri"] + assert ds.subjects == ["0001"] + assert ds.tasks == ["AEF", "noise"] + assert ds.latest_tag == "1.0.1" + assert ds.size == 2457671893 + assert "Elizabeth Bock" in ds.authors + + +def test_get_dataset_missing_raises(): + gql = make_gql({"dataset(": {"dataset": None}}) + with pytest.raises(OpenNeuroError): + get_dataset("ds999999", gql=gql) + + +def test_get_dataset_to_dict_is_json_safe(): + import json + ds = get_dataset("ds000246", gql=make_gql({"dataset(": DATASET_RESP})) + json.dumps(ds.to_dict()) + + +# --------------------------------------------------------------------------- +# list_datasets +# --------------------------------------------------------------------------- + +def test_list_datasets_returns_ids(): + ids = list_datasets(limit=3, gql=make_gql({"datasets(": DATASETS_RESP})) + assert ids == ["ds000001", "ds000002", "ds000003"] + + +# --------------------------------------------------------------------------- +# list_files +# --------------------------------------------------------------------------- + +def test_list_files_returns_name_size_url(): + files = list_files("ds000246", gql=make_gql({"snapshot(": SNAPSHOT_FILES_RESP, + "dataset(": DATASET_RESP})) + assert files[0]["filename"] == "dataset_description.json" + assert files[0]["size"] == 958 + assert files[0]["url"].startswith("https://s3.amazonaws.com/openneuro.org/") + + +def test_list_files_uses_latest_tag_when_none_given(): + # When tag is omitted, the client must resolve the latest tag from the dataset. + seen = {} + + def gql(query, variables=None): + if "dataset(" in query and "snapshot" not in query: + return DATASET_RESP + if "snapshot(" in query: + seen["vars"] = variables + return SNAPSHOT_FILES_RESP + raise AssertionError(query[:40]) + + list_files("ds000246", gql=gql) + assert seen["vars"]["tag"] == "1.0.1" + + +# --------------------------------------------------------------------------- +# download_file +# --------------------------------------------------------------------------- + +def test_download_file_writes_bytes(tmp_path): + dest = tmp_path / "dataset_description.json" + captured = {} + + def http_get(url): + captured["url"] = url + return b'{"Name": "MEG-BIDS Brainstorm data sample"}' + + gql = make_gql({"snapshot(": SNAPSHOT_FILES_RESP, "dataset(": DATASET_RESP}) + path = download_file("ds000246", "dataset_description.json", str(dest), + gql=gql, http_get=http_get) + assert os.path.exists(path) + assert b"Brainstorm" in open(path, "rb").read() + assert captured["url"].startswith("https://s3.amazonaws.com/openneuro.org/") + + +def test_download_file_unknown_filename_raises(tmp_path): + gql = make_gql({"snapshot(": SNAPSHOT_FILES_RESP, "dataset(": DATASET_RESP}) + with pytest.raises(OpenNeuroError): + download_file("ds000246", "no_such_file.nii.gz", str(tmp_path / "x"), + gql=gql, http_get=lambda u: b"") diff --git a/tests/test_openneuro_integration.py b/tests/test_openneuro_integration.py new file mode 100644 index 0000000..f2a3ad3 --- /dev/null +++ b/tests/test_openneuro_integration.py @@ -0,0 +1,41 @@ +"""Live OpenNeuro integration tests — hit the real GraphQL API + S3. + +Skipped unless ``MATILDE_LIVE=1``. Guards the GraphQL schema assumptions against +API drift, using the stable public dataset ds000246. +""" +from __future__ import annotations + +import os +import sys + +import pytest + +sys.path.insert(0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))) + +from engine.openneuro import get_dataset, list_datasets, list_files # noqa: E402 + +LIVE = os.environ.get("MATILDE_LIVE") == "1" +pytestmark = pytest.mark.skipif(not LIVE, reason="set MATILDE_LIVE=1 to run live API tests") + + +def test_live_get_dataset_metadata(): + ds = get_dataset("ds000246") + assert ds.id == "ds000246" + assert "meg" in [m.lower() for m in ds.modalities] + assert ds.name # has a human title + assert ds.size and ds.size > 0 + assert ds.latest_tag # has a snapshot tag + + +def test_live_list_datasets(): + ids = list_datasets(limit=3) + assert len(ids) == 3 + assert all(i.startswith("ds") for i in ids) + + +def test_live_list_files_has_description(): + files = list_files("ds000246") + names = [f["filename"] for f in files] + assert "dataset_description.json" in names + desc = next(f for f in files if f["filename"] == "dataset_description.json") + assert desc["url"].startswith("https://s3.amazonaws.com/openneuro.org/") diff --git a/tests/test_plugin_tools.py b/tests/test_plugin_tools.py index 18b568a..a7e4b5b 100644 --- a/tests/test_plugin_tools.py +++ b/tests/test_plugin_tools.py @@ -24,13 +24,16 @@ def _load_plugin(): return mod -def test_plugin_loads_and_registers_three_tools(): +def test_plugin_registers_expected_tools(): plugin = _load_plugin() names = [t[0] for t in plugin._TOOLS] assert names == [ "matilde_verify_citation", "matilde_verify_bibliography", "matilde_check_retraction", + "matilde_openneuro_dataset_info", + "matilde_openneuro_search", + "matilde_openneuro_list_files", ] @@ -43,7 +46,7 @@ def register_tool(self, **kw): calls.append(kw) plugin.register(Ctx()) - assert len(calls) == 3 + assert len(calls) == len(plugin._TOOLS) assert all(c["toolset"] == "matilde" for c in calls) assert all(callable(c["handler"]) and callable(c["check_fn"]) for c in calls) # schema name must match the registered tool name @@ -74,6 +77,28 @@ def test_check_retraction_requires_doi(): assert out["success"] is False +def test_openneuro_dataset_info_envelope_includes_message_and_fields(monkeypatch): + plugin = _load_plugin() + import engine.openneuro as on + + def fake_get_dataset(dsid, gql=None): + return on.Dataset(id=dsid, name="Demo MEG", modalities=["meg"], + subjects=["0001"], size=123, latest_tag="1.0.0") + + monkeypatch.setattr(on, "get_dataset", fake_get_dataset) + out = json.loads(plugin._handle_openneuro_dataset_info({"dataset_id": "ds000246"})) + assert out["success"] is True + assert out["id"] == "ds000246" + assert out["modalities"] == ["meg"] + assert "message" in out and "Demo MEG" in out["message"] + + +def test_openneuro_dataset_info_requires_id(): + plugin = _load_plugin() + out = json.loads(plugin._handle_openneuro_dataset_info({})) + assert out["success"] is False + + def test_reference_from_args_coerces_string_authors_and_year(): plugin = _load_plugin() ref = plugin._tools_mod._reference_from_args(