diff --git a/README.md b/README.md index 3f8de3b..28bb225 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ CLI entrypoints provided by this project: - `pubmate-yamlconcat` - `pubmate-mint` - `pubmate-cleanrdf` +- `pubmate-download-nanopubs` - `pubmate-extract-assertions` - `pubmate-validate-defining` - `pubmate-mint-publish` @@ -131,6 +132,39 @@ pubmate-mint-publish \ --intro-nanopub-uri https://w3id.org/np/RA... ``` +### 6) Download published nanopubs from a query endpoint + +Use `pubmate-download-nanopubs` when a query endpoint returns the nanopublication +URIs you want to mirror locally. The endpoint should return SPARQL results JSON; +by default Pubmate reads nanopub URIs from the `np` binding and downloads each one +as `.trig`. + +You can pass a complete endpoint URL: + +```bash +pubmate-download-nanopubs \ + "https://query.knowledgepixels.com/api/RA.../your-query?ontology=https%3A%2F%2Fw3id.org%2Fyourspace%2Fvocabulary" \ + --output-dir published \ + --manifest build/nanopub-network-published.tsv +``` + +Or split filters into repeatable query parameters: + +```bash +pubmate-download-nanopubs \ + "https://query.knowledgepixels.com/api/RA.../your-query" \ + --query-param "ontology=https://w3id.org/yourspace/vocabulary" \ + --query-param "ontologyNamespace=https://w3id.org/yourspace/vocabulary" \ + --output-dir published \ + --manifest build/nanopub-network-published.tsv +``` + +Useful options: +- `--np-column`: read nanopub URIs from a binding other than `np`. +- `--min-count`: fail if the endpoint returns fewer nanopubs than expected. +- `--replace`: replace existing `.trig` files in `--output-dir` with exactly the endpoint result set. +- `--no-validate`: skip parsing each downloaded TriG file as a nanopublication. + ## Real-Life Publishing Checklist Before real publish: @@ -139,6 +173,7 @@ Before real publish: 3. Run `pubmate-validate-defining`, then `pubmate-mint-publish --dry-run`. 4. Publish a small subset first (e.g., a temporary small assertion folder). 5. Then publish the full batch. +6. Use `pubmate-download-nanopubs --replace` to rebuild the local `published` folder from the network when needed. ## Troubleshooting @@ -148,3 +183,5 @@ Before real publish: - verify assertion folder contains `.ttl` files. - URI prefix differences: - published nanopub URIs may use `purl.org` or `w3id.org` prefixes depending on server behavior. +- Download endpoint returns no nanopubs: + - verify the endpoint returns SPARQL results JSON and that nanopub URIs are in the binding named by `--np-column` (`np` by default). diff --git a/pyproject.toml b/pyproject.toml index de00926..a10ef11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pubmate" -version = "0.2.2" +version = "0.2.3" description = "Helper tool aiding with publishing nanopublications departing from a linkml schema" readme = "README.md" requires-python = ">=3.12" @@ -23,6 +23,7 @@ dev = [ [project.scripts] pubmate-bootstrap-identity = "pubmate.cli.bootstrap_identity:cli" pubmate-cleanrdf = "pubmate.cli.cleanrdf:cli" +pubmate-download-nanopubs = "pubmate.cli.download_nanopubs:cli" pubmate-extract-assertions = "pubmate.cli.extract_assertions:cli" pubmate-migrate = "pubmate.cli.migrate:cli" pubmate-mint = "pubmate.cli.mint:cli" diff --git a/src/pubmate/_nanopub_build.py b/src/pubmate/_nanopub_build.py index f1b3de7..cdd0480 100644 --- a/src/pubmate/_nanopub_build.py +++ b/src/pubmate/_nanopub_build.py @@ -35,10 +35,7 @@ def _bnode_sort_key(graph: rdflib.Graph, bnode: rdflib.BNode) -> tuple: the source's random bnode ids. Bnodes with identical content sort equal — which is fine, since swapping their labels yields an isomorphic graph. """ - items = [ - (str(p), "" if isinstance(o, rdflib.BNode) else str(o)) - for p, o in graph.predicate_objects(bnode) - ] + items = [(str(p), "" if isinstance(o, rdflib.BNode) else str(o)) for p, o in graph.predicate_objects(bnode)] return tuple(sorted(items)) diff --git a/src/pubmate/cli/bootstrap_identity.py b/src/pubmate/cli/bootstrap_identity.py index 806ab08..205aebe 100644 --- a/src/pubmate/cli/bootstrap_identity.py +++ b/src/pubmate/cli/bootstrap_identity.py @@ -38,14 +38,24 @@ def _code(np_uri: str) -> str: @click.command() -@click.option("--bot-name", required=True, help="foaf:name for the agent, e.g. \"Biochementity bot\".") +@click.option("--bot-name", required=True, help='foaf:name for the agent, e.g. "Biochementity bot".') @click.option("--owner-orcid", required=True, help="ORCID URI of the human owner (frbr:owner + attribution).") -@click.option("--bot-id", default="bot", show_default=True, help="Local URI segment for the agent (…/RA/).") +@click.option( + "--bot-id", default="bot", show_default=True, help="Local URI segment for the agent (…/RA/)." +) @click.option("--owner-name", help="Optional foaf:name for the owner ORCID.") @click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=pathlib.Path)) -@click.option("--generate-keys", is_flag=True, help="Generate a fresh RSA keypair into --output-dir (id_rsa / id_rsa.pub).") -@click.option("--private-key", type=click.Path(exists=True, dir_okay=False), help="Existing private key (instead of --generate-keys).") -@click.option("--public-key", type=click.Path(exists=True, dir_okay=False), help="Existing public key (with --private-key).") +@click.option( + "--generate-keys", is_flag=True, help="Generate a fresh RSA keypair into --output-dir (id_rsa / id_rsa.pub)." +) +@click.option( + "--private-key", + type=click.Path(exists=True, dir_okay=False), + help="Existing private key (instead of --generate-keys).", +) +@click.option( + "--public-key", type=click.Path(exists=True, dir_okay=False), help="Existing public key (with --private-key)." +) @click.option("--license", "license_", default=None, help="Override pubinfo license URI (default CC BY 4.0).") @click.option("--test-server", is_flag=True, help="Mark the introduction for the nanopub test server.") @click.option("--publish", is_flag=True, help="Publish the signed introduction to the network (default: sign only).") @@ -87,9 +97,14 @@ def cli( kwargs = {} if license_ is None else {"license": license_} np = build_introduction( - private_key=priv, public_key=pub, bot_name=bot_name, - owner_orcid=owner_orcid, bot_local_name=bot_id, owner_name=owner_name, - test_server=test_server, **kwargs, + private_key=priv, + public_key=pub, + bot_name=bot_name, + owner_orcid=owner_orcid, + bot_local_name=bot_id, + owner_name=owner_name, + test_server=test_server, + **kwargs, ) np.sign() agent_uri = f"{np.metadata.np_uri}/{bot_id}" diff --git a/src/pubmate/cli/download_nanopubs.py b/src/pubmate/cli/download_nanopubs.py new file mode 100644 index 0000000..ac1424b --- /dev/null +++ b/src/pubmate/cli/download_nanopubs.py @@ -0,0 +1,102 @@ +import logging +import pathlib + +import click + +from pubmate.download import ( + download_nanopubs, + endpoint_url_with_params, + nanopub_uris_from_query, + sync_nanopubs, + write_manifest, +) + +logging.basicConfig(level=logging.INFO, format="::%(levelname)s:: %(message)s") +logger = logging.getLogger(__name__) + + +@click.command() +@click.argument("endpoint_url") +@click.option( + "--query-param", + "query_params", + multiple=True, + help="Additional endpoint query parameter as key=value. Repeatable.", +) +@click.option( + "--output-dir", + required=True, + type=click.Path(file_okay=False, path_type=pathlib.Path), + help="Where to write downloaded .trig nanopublications.", +) +@click.option( + "--manifest", + type=click.Path(dir_okay=False, path_type=pathlib.Path), + help="Optional TSV manifest to write.", +) +@click.option("--np-column", default="np", show_default=True, help="SPARQL JSON result column containing nanopub URIs.") +@click.option("--timeout", default=60, show_default=True, type=int, help="HTTP timeout in seconds.") +@click.option("--retries", default=3, show_default=True, type=int, help="Number of retries per HTTP request.") +@click.option( + "--min-count", + default=1, + show_default=True, + type=int, + help="Fail if the endpoint returns fewer nanopublications than this.", +) +@click.option( + "--replace/--no-replace", + default=False, + show_default=True, + help="Replace existing .trig files in --output-dir with exactly the endpoint result set.", +) +@click.option( + "--validate/--no-validate", + default=True, + show_default=True, + help="Parse each downloaded TriG file and require it to declare an np:Nanopublication.", +) +def cli( + endpoint_url: str, + query_params: tuple[str, ...], + output_dir: pathlib.Path, + manifest: pathlib.Path | None, + np_column: str, + timeout: int, + retries: int, + min_count: int, + replace: bool, + validate: bool, +) -> None: + """Download nanopublication .trig files listed by a query endpoint. + + ENDPOINT_URL is expected to return SPARQL results JSON. Pubmate reads + nanopublication URIs from the ``np`` binding by default, then downloads each + URI as ``.trig`` into ``--output-dir``. + """ + try: + query_url = endpoint_url_with_params(endpoint_url, query_params) + except ValueError as error: + raise click.ClickException(str(error)) from error + np_uris = nanopub_uris_from_query(query_url, np_column=np_column, timeout=timeout, retries=retries) + if len(np_uris) < min_count: + raise click.ClickException(f"Expected at least {min_count} nanopub(s), got {len(np_uris)}.") + + downloader = sync_nanopubs if replace else download_nanopubs + downloaded = downloader( + np_uris, + output_dir=output_dir, + timeout=timeout, + retries=retries, + validate=validate, + ) + + if manifest is not None: + write_manifest(manifest, downloaded) + logger.info("Wrote manifest (%d entries) -> %s", len(downloaded), manifest) + + logger.info("Downloaded %d nanopublication(s) -> %s", len(downloaded), output_dir) + + +if __name__ == "__main__": + cli() diff --git a/src/pubmate/cli/migrate.py b/src/pubmate/cli/migrate.py index f8e749a..4768ce3 100644 --- a/src/pubmate/cli/migrate.py +++ b/src/pubmate/cli/migrate.py @@ -41,21 +41,45 @@ def _code(np_uri: str) -> str: @click.command() -@click.option("--assertion-folder", "-a", required=True, type=click.Path(exists=True, file_okay=False, path_type=pathlib.Path)) +@click.option( + "--assertion-folder", "-a", required=True, type=click.Path(exists=True, file_okay=False, path_type=pathlib.Path) +) @click.option("--namespace", default="https://w3id.org/peh/biochementities/", show_default=True) -@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=pathlib.Path), help="Where to write all minted .trig nanopubs (defining + superseding).") -@click.option("--id-map-file", type=click.Path(dir_okay=False, path_type=pathlib.Path), help="TSV id-map to write/merge (old_id -> thing_uri, np_uri).") +@click.option( + "--output-dir", + required=True, + type=click.Path(file_okay=False, path_type=pathlib.Path), + help="Where to write all minted .trig nanopubs (defining + superseding).", +) +@click.option( + "--id-map-file", + type=click.Path(dir_okay=False, path_type=pathlib.Path), + help="TSV id-map to write/merge (old_id -> thing_uri, np_uri).", +) @click.option("--orcid-id") @click.option("--name") -@click.option("--default-suggester", help="ORCID attributed as the suggester (prov:wasAttributedTo) for any term that carries none — e.g. the contributor of an existing batch being migrated.") -@click.option("--nanopub-type", "nanopub_types", multiple=True, help="URI tagged in pubinfo as npx:hasNanopubType on every nanopub (repeatable).") -@click.option("--template", help="Assertion-template URI tagged in pubinfo as nt:wasCreatedFromTemplate on every nanopub (for Nanodash rendering).") +@click.option( + "--default-suggester", + help="ORCID attributed as the suggester (prov:wasAttributedTo) for any term that carries none — e.g. the contributor of an existing batch being migrated.", +) +@click.option( + "--nanopub-type", + "nanopub_types", + multiple=True, + help="URI tagged in pubinfo as npx:hasNanopubType on every nanopub (repeatable).", +) +@click.option( + "--template", + help="Assertion-template URI tagged in pubinfo as nt:wasCreatedFromTemplate on every nanopub (for Nanodash rendering).", +) @click.option("--part-of", help="URI each term links to via dcterms:isPartOf in its assertion (e.g. the vocabulary).") @click.option("--private-key", type=click.Path(exists=True, dir_okay=False)) @click.option("--public-key", type=click.Path(exists=True, dir_okay=False)) @click.option("--intro-nanopub-uri") @click.option("--test-server", is_flag=True, help="Publish to the nanopub test server (with --private-key).") -@click.option("--use-testsuite-keys", is_flag=True, help="Sign with nanopub-testsuite-connector key material (test server).") +@click.option( + "--use-testsuite-keys", is_flag=True, help="Sign with nanopub-testsuite-connector key material (test server)." +) @click.option("--testsuite-key", default="rsa-key1", show_default=True, hidden=True) @click.option("--testsuite-ref", default="main", show_default=True, hidden=True) @click.option("--dry-run", is_flag=True, help="Sign only (offline); do not publish to the network.") @@ -89,18 +113,30 @@ def cli( skipped, so the migration is resumable. """ signing = resolve_signing( - orcid_id=orcid_id, name=name, private_key=private_key, public_key=public_key, - intro_nanopub_uri=intro_nanopub_uri, use_testsuite_keys=use_testsuite_keys, - testsuite_key=testsuite_key, testsuite_ref=testsuite_ref, test_server=test_server, dry_run=dry_run, + orcid_id=orcid_id, + name=name, + private_key=private_key, + public_key=public_key, + intro_nanopub_uri=intro_nanopub_uri, + use_testsuite_keys=use_testsuite_keys, + testsuite_key=testsuite_key, + testsuite_ref=testsuite_ref, + test_server=test_server, + dry_run=dry_run, ) builder = DefiningNanopubBuilder( - namespace, profile=signing.profile, test_server=signing.test_server, - nanopub_types=nanopub_types, template=template, + namespace, + profile=signing.profile, + test_server=signing.test_server, + nanopub_types=nanopub_types, + template=template, ) minter = SequentialMinter(builder, default_suggester_orcid=default_suggester) supersession = SupersessionBuilder( - profile=signing.profile, test_server=signing.test_server, - nanopub_types=nanopub_types, template=template, + profile=signing.profile, + test_server=signing.test_server, + nanopub_types=nanopub_types, + template=template, ) files = sorted(assertion_folder.glob(pattern)) @@ -114,11 +150,18 @@ def cli( graph.parse(path, format="turtle") assertions[_term_subject(graph, namespace, path)] = graph - existing = IdMap.from_tsv(id_map_file.read_text(encoding="utf-8")) if id_map_file and id_map_file.exists() else IdMap() + existing = ( + IdMap.from_tsv(id_map_file.read_text(encoding="utf-8")) if id_map_file and id_map_file.exists() else IdMap() + ) result = migrate_terms( - assertions, namespace=namespace, minter=minter, supersession_builder=supersession, - existing=existing, dry_run=dry_run, part_of=part_of, + assertions, + namespace=namespace, + minter=minter, + supersession_builder=supersession, + existing=existing, + dry_run=dry_run, + part_of=part_of, ) output_dir.mkdir(parents=True, exist_ok=True) diff --git a/src/pubmate/cli/mint_publish.py b/src/pubmate/cli/mint_publish.py index 2265a5d..d68877a 100644 --- a/src/pubmate/cli/mint_publish.py +++ b/src/pubmate/cli/mint_publish.py @@ -17,21 +17,42 @@ @click.command() -@click.option("--assertion-folder", "-a", required=True, type=click.Path(exists=True, file_okay=False, path_type=pathlib.Path)) +@click.option( + "--assertion-folder", "-a", required=True, type=click.Path(exists=True, file_okay=False, path_type=pathlib.Path) +) @click.option("--namespace", default="https://w3id.org/peh/biochementities/", show_default=True) -@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=pathlib.Path), help="Where to write the minted .trig nanopubs.") -@click.option("--id-map-file", type=click.Path(dir_okay=False, path_type=pathlib.Path), help="TSV id-map to write/merge (old_id -> thing_uri, np_uri).") +@click.option( + "--output-dir", + required=True, + type=click.Path(file_okay=False, path_type=pathlib.Path), + help="Where to write the minted .trig nanopubs.", +) +@click.option( + "--id-map-file", + type=click.Path(dir_okay=False, path_type=pathlib.Path), + help="TSV id-map to write/merge (old_id -> thing_uri, np_uri).", +) @click.option("--default-suggester", help="Fallback suggester ORCID for terms without their own.") @click.option("--part-of", help="URI each term links to via dcterms:isPartOf in its assertion (e.g. the vocabulary).") -@click.option("--nanopub-type", "nanopub_types", multiple=True, help="URI tagged in pubinfo as npx:hasNanopubType on every nanopub (repeatable).") -@click.option("--template", help="Assertion-template URI tagged in pubinfo as nt:wasCreatedFromTemplate on every nanopub (for Nanodash rendering).") +@click.option( + "--nanopub-type", + "nanopub_types", + multiple=True, + help="URI tagged in pubinfo as npx:hasNanopubType on every nanopub (repeatable).", +) +@click.option( + "--template", + help="Assertion-template URI tagged in pubinfo as nt:wasCreatedFromTemplate on every nanopub (for Nanodash rendering).", +) @click.option("--orcid-id") @click.option("--name") @click.option("--private-key", type=click.Path(exists=True, dir_okay=False)) @click.option("--public-key", type=click.Path(exists=True, dir_okay=False)) @click.option("--intro-nanopub-uri") @click.option("--test-server", is_flag=True, help="Publish to the nanopub test server (with --private-key).") -@click.option("--use-testsuite-keys", is_flag=True, help="Sign with nanopub-testsuite-connector key material (test server).") +@click.option( + "--use-testsuite-keys", is_flag=True, help="Sign with nanopub-testsuite-connector key material (test server)." +) @click.option("--testsuite-key", default="rsa-key1", show_default=True, hidden=True) @click.option("--testsuite-ref", default="main", show_default=True, hidden=True) @click.option("--dry-run", is_flag=True, help="Sign only (offline); do not publish to the network.") @@ -85,12 +106,18 @@ def cli( dry_run=dry_run, ) builder = DefiningNanopubBuilder( - namespace, profile=signing.profile, test_server=signing.test_server, - nanopub_types=nanopub_types, template=template, + namespace, + profile=signing.profile, + test_server=signing.test_server, + nanopub_types=nanopub_types, + template=template, ) supersession_builder = SupersessionBuilder( - profile=signing.profile, test_server=signing.test_server, - license=builder.license, nanopub_types=nanopub_types, template=template, + profile=signing.profile, + test_server=signing.test_server, + license=builder.license, + nanopub_types=nanopub_types, + template=template, ) files = sorted(assertion_folder.glob(pattern)) @@ -112,7 +139,9 @@ def cli( ) ) - existing = IdMap.from_tsv(id_map_file.read_text(encoding="utf-8")) if id_map_file and id_map_file.exists() else IdMap() + existing = ( + IdMap.from_tsv(id_map_file.read_text(encoding="utf-8")) if id_map_file and id_map_file.exists() else IdMap() + ) minter = SequentialMinter(builder, default_suggester_orcid=default_suggester) result = publish_incremental( @@ -139,8 +168,11 @@ def cli( logger.info( "Minted %d, superseded %d, skipped %d term(s)%s -> %s", - len(result.minted.terms), len(result.superseded), len(result.skipped), - " (dry-run)" if dry_run else "", output_dir, + len(result.minted.terms), + len(result.superseded), + len(result.skipped), + " (dry-run)" if dry_run else "", + output_dir, ) diff --git a/src/pubmate/defining.py b/src/pubmate/defining.py index 8bb6b02..5f56e0d 100644 --- a/src/pubmate/defining.py +++ b/src/pubmate/defining.py @@ -64,9 +64,7 @@ def __init__( template: Optional[str] = None, ): if not namespace: - raise ValueError( - "namespace is required (e.g. 'https://example.org/terms/')." - ) + raise ValueError("namespace is required (e.g. 'https://example.org/terms/').") self.namespace = namespace self.license = license self.test_server = test_server diff --git a/src/pubmate/download.py b/src/pubmate/download.py new file mode 100644 index 0000000..a0d0a69 --- /dev/null +++ b/src/pubmate/download.py @@ -0,0 +1,168 @@ +"""Download nanopublication TriG files from query API results.""" + +from __future__ import annotations + +import json +import pathlib +import shutil +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Iterable, Sequence +from dataclasses import dataclass + +import rdflib +from rdflib.namespace import RDF + +NP = rdflib.Namespace("http://www.nanopub.org/nschema#") + + +@dataclass(frozen=True) +class DownloadedNanopub: + artifact_code: str + np_uri: str + path: pathlib.Path + + +def endpoint_url_with_params(endpoint_url: str, query_params: Sequence[str] = ()) -> str: + """Return ``endpoint_url`` with additional ``key=value`` query parameters.""" + if not query_params: + return endpoint_url + + parsed = urllib.parse.urlsplit(endpoint_url) + params = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + for param in query_params: + if "=" not in param: + raise ValueError(f"Expected query parameter as key=value, got: {param}") + key, value = param.split("=", 1) + if not key: + raise ValueError(f"Expected non-empty query parameter key in: {param}") + params.append((key, value)) + + query = urllib.parse.urlencode(params) + return urllib.parse.urlunsplit(parsed._replace(query=query)) + + +def fetch_url(url: str, *, accept: str, timeout: int, retries: int) -> bytes: + """Fetch a URL with retry/backoff and an Accept header.""" + last_error: Exception | None = None + for attempt in range(retries + 1): + request = urllib.request.Request(url, headers={"Accept": accept}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read() + except (TimeoutError, urllib.error.URLError) as error: + last_error = error + if attempt == retries: + break + time.sleep(min(2**attempt, 10)) + raise RuntimeError(f"Failed to fetch {url}: {last_error}") from last_error + + +def nanopub_uris_from_query( + query_url: str, + *, + np_column: str = "np", + timeout: int, + retries: int, +) -> list[str]: + """Fetch a SPARQL JSON endpoint result and extract nanopublication URIs.""" + payload = fetch_url( + query_url, + accept="application/sparql-results+json", + timeout=timeout, + retries=retries, + ) + data = json.loads(payload) + bindings = data.get("results", {}).get("bindings", []) + uris = { + row[np_column]["value"] + for row in bindings + if row.get(np_column, {}).get("type") == "uri" and row[np_column].get("value") + } + if not uris: + raise RuntimeError(f"The query returned no nanopublication URIs in the '{np_column}' column.") + return sorted(uris) + + +def artifact_code(np_uri: str) -> str: + """Extract the trusty artifact code from a nanopublication URI.""" + code = np_uri.rstrip("/").rsplit("/", 1)[-1] + if not code.startswith("RA"): + raise ValueError(f"Unexpected nanopublication URI without RA artifact code: {np_uri}") + return code + + +def ensure_nanopublication_trig(content: bytes, *, source_url: str) -> None: + """Raise if ``content`` does not parse as a nanopublication TriG document.""" + dataset = rdflib.Dataset() + try: + dataset.parse(data=content.decode("utf-8"), format="trig") + except Exception as error: # rdflib raises several parser-specific errors. + raise RuntimeError(f"Downloaded content is not valid TriG: {source_url}") from error + + if not any((None, RDF.type, NP.Nanopublication) in graph for graph in dataset.graphs()): + raise RuntimeError(f"Downloaded content does not look like a nanopublication: {source_url}") + + +def download_nanopubs( + np_uris: Iterable[str], + *, + output_dir: pathlib.Path, + timeout: int, + retries: int, + validate: bool = True, +) -> list[DownloadedNanopub]: + """Download each nanopublication URI as ``.trig``.""" + output_dir.mkdir(parents=True, exist_ok=True) + downloaded: list[DownloadedNanopub] = [] + for np_uri in np_uris: + code = artifact_code(np_uri) + trig_url = f"{np_uri}.trig" + content = fetch_url(trig_url, accept="application/trig", timeout=timeout, retries=retries) + if validate: + ensure_nanopublication_trig(content, source_url=trig_url) + path = output_dir / f"{code}.trig" + path.write_bytes(content) + downloaded.append(DownloadedNanopub(code, np_uri, path)) + return downloaded + + +def sync_nanopubs( + np_uris: Iterable[str], + *, + output_dir: pathlib.Path, + timeout: int, + retries: int, + validate: bool = True, +) -> list[DownloadedNanopub]: + """Replace ``output_dir``'s ``*.trig`` files with the downloaded set atomically.""" + output_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="pubmate-nanopub-download-") as temp_dir: + staging_dir = pathlib.Path(temp_dir) + staged = download_nanopubs( + np_uris, + output_dir=staging_dir, + timeout=timeout, + retries=retries, + validate=validate, + ) + for path in output_dir.glob("*.trig"): + path.unlink() + downloaded: list[DownloadedNanopub] = [] + for item in staged: + dest = output_dir / item.path.name + shutil.copy2(item.path, dest) + downloaded.append(DownloadedNanopub(item.artifact_code, item.np_uri, dest)) + return downloaded + + +def write_manifest(path: pathlib.Path, rows: Sequence[DownloadedNanopub]) -> None: + """Write a TSV manifest of downloaded nanopublications.""" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as handle: + handle.write("artifact_code\tnp_uri\tpath\n") + for row in rows: + handle.write(f"{row.artifact_code}\t{row.np_uri}\t{row.path}\n") diff --git a/src/pubmate/incremental.py b/src/pubmate/incremental.py index 8c9f409..996987d 100644 --- a/src/pubmate/incremental.py +++ b/src/pubmate/incremental.py @@ -118,7 +118,8 @@ def publish_incremental( if entry.old_id != term.term_id: logger.info( "Resolved submitted id %s to existing term %s via its thing URI.", - term.term_id, entry.old_id, + term.term_id, + entry.old_id, ) if entry.fingerprint == "": diff --git a/src/pubmate/introduction.py b/src/pubmate/introduction.py index dfae52f..1c5b8b0 100644 --- a/src/pubmate/introduction.py +++ b/src/pubmate/introduction.py @@ -99,9 +99,7 @@ def build_introduction( # The agent signs its own introduction: the signing identity *is* the agent # URI (placeholder form), so npx:signedBy resolves to it after signing. - profile = nanopub.Profile( - orcid_id=str(agent), name=bot_name, private_key=private_key, public_key=public_key - ) + profile = nanopub.Profile(orcid_id=str(agent), name=bot_name, private_key=private_key, public_key=public_key) conf = nanopub.NanopubConf( profile=profile, use_test_server=test_server, diff --git a/src/pubmate/migrate.py b/src/pubmate/migrate.py index 450b984..ac5eca6 100644 --- a/src/pubmate/migrate.py +++ b/src/pubmate/migrate.py @@ -137,7 +137,10 @@ def migrate_terms( continue g = assertions[old] split = split_references( - g, namespace=namespace, subject=subjects[old], resolved_uris=resolved_thing, + g, + namespace=namespace, + subject=subjects[old], + resolved_uris=resolved_thing, batch_targets=batch_ids, ) if split.dangling: @@ -145,10 +148,15 @@ def migrate_terms( logger.error( "%s: %d reference(s) to terms absent from the batch (dangling foreign " "key, likely a stale/deduplicated id); dropped from the minted nanopub: %s", - old, len(split.dangling), [str(o) for _, _, o in split.dangling], + old, + len(split.dangling), + [str(o) for _, _, o in split.dangling], ) term = term_input_from_assertion( - split.kept, namespace=namespace, thing_uri=minter.builder.thing_uri, part_of=part_of, + split.kept, + namespace=namespace, + thing_uri=minter.builder.thing_uri, + part_of=part_of, ) minted = minter.mint(term, dry_run=dry_run) result.defining.terms.append(minted) @@ -178,11 +186,17 @@ def migrate_terms( minted = minted_by_term[old] new_subject = rdflib.URIRef(minted.thing_uri) full = resolve_references( - assertions[old], namespace=namespace, subject=subjects[old], - new_subject=new_subject, thing_uris=resolved_thing, part_of=part_of, + assertions[old], + namespace=namespace, + subject=subjects[old], + new_subject=new_subject, + thing_uris=resolved_thing, + part_of=part_of, ) sup_np = supersession_builder.build( - full, supersedes_np_uri=minted.np_uri, label=_label(full, new_subject), + full, + supersedes_np_uri=minted.np_uri, + label=_label(full, new_subject), suggester_orcid=minter.default_suggester_orcid, ) sup_uri = sign_and_publish(sup_np, dry_run=dry_run) diff --git a/src/pubmate/minting.py b/src/pubmate/minting.py index 50912b3..6fec60b 100644 --- a/src/pubmate/minting.py +++ b/src/pubmate/minting.py @@ -67,9 +67,7 @@ def term_input_from_assertion( The original term URI becomes the returned ``TermInput.term_id`` (the id-map key) unless ``term_id`` is given. """ - subjects = { - s for s in assertion.subjects() if isinstance(s, rdflib.URIRef) and str(s).startswith(namespace) - } + subjects = {s for s in assertion.subjects() if isinstance(s, rdflib.URIRef) and str(s).startswith(namespace)} if len(subjects) != 1: raise ValueError( f"expected exactly one subject in namespace {namespace!r}, found {len(subjects)}: {sorted(map(str, subjects))}" @@ -221,8 +219,6 @@ def mint_all( continue minted = self.mint(term, dry_run=dry_run) if output_dir is not None: - (output_dir / f"{term.term_id}.trig").write_text( - serialize_nanopub(minted.nanopub), encoding="utf-8" - ) + (output_dir / f"{term.term_id}.trig").write_text(serialize_nanopub(minted.nanopub), encoding="utf-8") batch.terms.append(minted) return batch diff --git a/src/pubmate/references.py b/src/pubmate/references.py index 5e6d6fc..74536d2 100644 --- a/src/pubmate/references.py +++ b/src/pubmate/references.py @@ -91,9 +91,7 @@ def order_terms(references: Mapping[str, Set[str]]) -> Ordering: """ remaining: Set[str] = set(references) # Restrict each term's refs to in-batch targets, ignore self-references. - refs: Dict[str, Set[str]] = { - t: {r for r in references[t] if r in remaining and r != t} for t in remaining - } + refs: Dict[str, Set[str]] = {t: {r for r in references[t] if r in remaining and r != t} for t in remaining} placed: Set[str] = set() order: List[str] = [] diff --git a/tests/test_cli_download_nanopubs.py b/tests/test_cli_download_nanopubs.py new file mode 100644 index 0000000..c891cfc --- /dev/null +++ b/tests/test_cli_download_nanopubs.py @@ -0,0 +1,120 @@ +import json + +from click.testing import CliRunner + +from pubmate.cli.download_nanopubs import cli + + +def _trig(code: str) -> bytes: + return f""" +@prefix np: . +@prefix this: . + +this:Head {{ + a np:Nanopublication . +}} +""".encode() + + +def test_download_nanopubs_cli_writes_trig_and_manifest(tmp_path, monkeypatch) -> None: + calls: list[tuple[str, str]] = [] + query_payload = { + "results": { + "bindings": [ + {"np": {"type": "uri", "value": "https://w3id.org/np/RAone"}}, + {"np": {"type": "uri", "value": "https://w3id.org/np/RAtwo"}}, + ] + } + } + + def fake_fetch_url(url: str, *, accept: str, timeout: int, retries: int) -> bytes: + calls.append((url, accept)) + if accept == "application/sparql-results+json": + assert timeout == 5 + assert retries == 1 + assert url == "https://query.example/api/run?ontology=https%3A%2F%2Fexample.org%2Fvocab&limit=2" + return json.dumps(query_payload).encode() + code = url.removesuffix(".trig").rsplit("/", 1)[-1] + return _trig(code) + + monkeypatch.setattr("pubmate.download.fetch_url", fake_fetch_url) + out = tmp_path / "published" + manifest = tmp_path / "build" / "manifest.tsv" + + result = CliRunner().invoke( + cli, + [ + "https://query.example/api/run", + "--query-param", + "ontology=https://example.org/vocab", + "--query-param", + "limit=2", + "--output-dir", + str(out), + "--manifest", + str(manifest), + "--timeout", + "5", + "--retries", + "1", + ], + ) + + assert result.exit_code == 0, result.output + assert sorted(path.name for path in out.glob("*.trig")) == ["RAone.trig", "RAtwo.trig"] + assert manifest.read_text(encoding="utf-8").splitlines() == [ + "artifact_code\tnp_uri\tpath", + f"RAone\thttps://w3id.org/np/RAone\t{out / 'RAone.trig'}", + f"RAtwo\thttps://w3id.org/np/RAtwo\t{out / 'RAtwo.trig'}", + ] + assert calls == [ + ( + "https://query.example/api/run?ontology=https%3A%2F%2Fexample.org%2Fvocab&limit=2", + "application/sparql-results+json", + ), + ("https://w3id.org/np/RAone.trig", "application/trig"), + ("https://w3id.org/np/RAtwo.trig", "application/trig"), + ] + + +def test_download_nanopubs_cli_replace_removes_stale_trig(tmp_path, monkeypatch) -> None: + query_payload = { + "results": { + "bindings": [ + {"np": {"type": "uri", "value": "https://w3id.org/np/RAfresh"}}, + ] + } + } + + def fake_fetch_url(url: str, *, accept: str, timeout: int, retries: int) -> bytes: + if accept == "application/sparql-results+json": + return json.dumps(query_payload).encode() + return _trig("RAfresh") + + monkeypatch.setattr("pubmate.download.fetch_url", fake_fetch_url) + out = tmp_path / "published" + out.mkdir() + (out / "RAstale.trig").write_text("stale", encoding="utf-8") + (out / "keep.ttl").write_text("keep", encoding="utf-8") + + result = CliRunner().invoke(cli, ["https://query.example/api/run", "--output-dir", str(out), "--replace"]) + + assert result.exit_code == 0, result.output + assert sorted(path.name for path in out.iterdir()) == ["RAfresh.trig", "keep.ttl"] + + +def test_download_nanopubs_cli_honors_min_count(tmp_path, monkeypatch) -> None: + def fake_fetch_url(url: str, *, accept: str, timeout: int, retries: int) -> bytes: + return json.dumps( + {"results": {"bindings": [{"np": {"type": "uri", "value": "https://w3id.org/np/RAone"}}]}} + ).encode() + + monkeypatch.setattr("pubmate.download.fetch_url", fake_fetch_url) + + result = CliRunner().invoke( + cli, + ["https://query.example/api/run", "--output-dir", str(tmp_path / "published"), "--min-count", "2"], + ) + + assert result.exit_code != 0 + assert "Expected at least 2 nanopub" in result.output diff --git a/tests/test_cli_migrate.py b/tests/test_cli_migrate.py index ad01772..c217296 100644 --- a/tests/test_cli_migrate.py +++ b/tests/test_cli_migrate.py @@ -61,10 +61,7 @@ def test_migrate_resumes_from_existing_idmap(tmp_path): out = tmp_path / "out" idmap = tmp_path / "id-map.tsv" # Pre-seed so the single term is treated as already minted. - seed = IdMap.from_tsv( - "old_id\tthing_uri\tnp_uri\n" - + f"{TERMS['a']}\t{NS}RAseed\thttps://w3id.org/np/RAseed" - ) + seed = IdMap.from_tsv("old_id\tthing_uri\tnp_uri\n" + f"{TERMS['a']}\t{NS}RAseed\thttps://w3id.org/np/RAseed") seed.write_tsv(idmap) result = CliRunner().invoke( diff --git a/tests/test_cli_mint_publish.py b/tests/test_cli_mint_publish.py index 43a3d0c..ac5f7e0 100644 --- a/tests/test_cli_mint_publish.py +++ b/tests/test_cli_mint_publish.py @@ -87,8 +87,19 @@ def test_mint_publish_adds_part_of_type_and_template(tmp_path) -> None: result = CliRunner().invoke( cli, - ["-a", str(assertions), "--output-dir", str(out), "--dry-run", - "--part-of", vocab, "--nanopub-type", type_uri, "--template", template_uri], + [ + "-a", + str(assertions), + "--output-dir", + str(out), + "--dry-run", + "--part-of", + vocab, + "--nanopub-type", + type_uri, + "--template", + template_uri, + ], ) assert result.exit_code == 0, result.output np = rdflib.Dataset() @@ -105,9 +116,7 @@ def test_mint_publish_without_keys_requires_dry_run(tmp_path) -> None: assertions.mkdir() _assertion_graph(assertions / "caffeine.ttl") - result = CliRunner().invoke( - cli, ["-a", str(assertions), "--output-dir", str(tmp_path / "published")] - ) + result = CliRunner().invoke(cli, ["-a", str(assertions), "--output-dir", str(tmp_path / "published")]) assert result.exit_code != 0 assert "signing keys" in result.output.lower() @@ -120,9 +129,11 @@ def test_mint_publish_skips_already_minted(tmp_path) -> None: out = tmp_path / "published" idmap = tmp_path / "id-map.tsv" # Pre-seed the id-map so the term is treated as already minted. - seed = IdMap.from_tsv("\t".join(("old_id", "thing_uri", "np_uri")) + "\n" + "\t".join( - (OLD_ID, f"{NAMESPACE}RAseed", "https://w3id.org/np/RAseed") - )) + seed = IdMap.from_tsv( + "\t".join(("old_id", "thing_uri", "np_uri")) + + "\n" + + "\t".join((OLD_ID, f"{NAMESPACE}RAseed", "https://w3id.org/np/RAseed")) + ) seed.write_tsv(idmap) result = CliRunner().invoke( diff --git a/tests/test_cli_yamlconcat.py b/tests/test_cli_yamlconcat.py index df832df..37e6832 100644 --- a/tests/test_cli_yamlconcat.py +++ b/tests/test_cli_yamlconcat.py @@ -58,9 +58,7 @@ def test_inherit_default_does_not_cross_files(tmp_path) -> None: without_default.write_text("terms:\n - name: Beta\n", encoding="utf-8") out = tmp_path / "combined.yaml" - result = _run( - [str(out), str(with_default), str(without_default), "--target", "terms", "--inherit", "suggester"] - ) + result = _run([str(out), str(with_default), str(without_default), "--target", "terms", "--inherit", "suggester"]) assert result.exit_code == 0, result.output combined = yaml.safe_load(out.read_text(encoding="utf-8")) diff --git a/tests/test_defining.py b/tests/test_defining.py index cce05e9..141ab01 100644 --- a/tests/test_defining.py +++ b/tests/test_defining.py @@ -139,6 +139,7 @@ def test_blank_nodes_get_short_deterministic_labels(): def test_blank_node_relabel_is_isomorphic_and_stable(): """Differently-labeled isomorphic graphs relabel to the same b1 graph.""" from pubmate._nanopub_build import relabel_blank_nodes + schema = rdflib.Namespace("http://schema.org/") subj = rdflib.URIRef(NAMESPACE + "x") diff --git a/tests/test_download.py b/tests/test_download.py new file mode 100644 index 0000000..be991eb --- /dev/null +++ b/tests/test_download.py @@ -0,0 +1,55 @@ +import json + +import pytest + +from pubmate.download import artifact_code, endpoint_url_with_params, nanopub_uris_from_query + + +def test_endpoint_url_with_params_preserves_existing_filters() -> None: + url = endpoint_url_with_params( + "https://query.example/api/run?ontology=https%3A%2F%2Fexample.org%2Fvocab", + ("space=https://example.org/space", "limit=10"), + ) + + assert url == ( + "https://query.example/api/run?" + "ontology=https%3A%2F%2Fexample.org%2Fvocab&" + "space=https%3A%2F%2Fexample.org%2Fspace&limit=10" + ) + + +def test_endpoint_url_with_params_requires_key_value() -> None: + with pytest.raises(ValueError, match="key=value"): + endpoint_url_with_params("https://query.example/api/run", ("ontology",)) + + +def test_nanopub_uris_from_query_extracts_sorted_unique_column(monkeypatch) -> None: + payload = { + "results": { + "bindings": [ + {"np": {"type": "uri", "value": "https://w3id.org/np/RAb"}}, + {"np": {"type": "uri", "value": "https://w3id.org/np/RAa"}}, + {"np": {"type": "uri", "value": "https://w3id.org/np/RAb"}}, + {"label": {"type": "literal", "value": "ignored"}}, + ] + } + } + + def fake_fetch_url(url: str, *, accept: str, timeout: int, retries: int) -> bytes: + assert url == "https://query.example/api/run" + assert accept == "application/sparql-results+json" + assert timeout == 10 + assert retries == 2 + return json.dumps(payload).encode() + + monkeypatch.setattr("pubmate.download.fetch_url", fake_fetch_url) + + assert nanopub_uris_from_query("https://query.example/api/run", timeout=10, retries=2) == [ + "https://w3id.org/np/RAa", + "https://w3id.org/np/RAb", + ] + + +def test_artifact_code_rejects_non_trusty_uri() -> None: + with pytest.raises(ValueError, match="without RA artifact code"): + artifact_code("https://example.org/not-a-nanopub") diff --git a/tests/test_incremental.py b/tests/test_incremental.py index de4d37b..8b4b72d 100644 --- a/tests/test_incremental.py +++ b/tests/test_incremental.py @@ -44,8 +44,11 @@ def _term_ref(term_uri: str, label: str, ref_uri: str | None = None) -> TermInpu def _run(terms, existing=None): return publish_incremental( - terms, minter=_minter(), supersession_builder=_sup(), - existing=existing or IdMap(), dry_run=True, + terms, + minter=_minter(), + supersession_builder=_sup(), + existing=existing or IdMap(), + dry_run=True, ) @@ -85,9 +88,9 @@ def test_drifted_term_is_superseded_keeping_thing_uri(): assert rdflib.URIRef(before.thing_uri) in set(sup.nanopub.assertion.subjects()) after = drifted.id_map["alpha"] - assert after.thing_uri == before.thing_uri # identity preserved - assert after.np_uri == sup.np_uri != before.np_uri # version advanced - assert after.fingerprint != before.fingerprint # fingerprint updated + assert after.thing_uri == before.thing_uri # identity preserved + assert after.np_uri == sup.np_uri != before.np_uri # version advanced + assert after.fingerprint != before.fingerprint # fingerprint updated def test_legacy_entry_without_fingerprint_is_backfilled_not_superseded(): @@ -108,7 +111,10 @@ def test_supersede_resolves_interterm_reference_to_new_thing_uri(): minter1, sup1 = _wrapped(TPL_OLD) first = publish_incremental( [_term_ref(A, "Alpha", ref_uri=B), _term_ref(B, "Beta")], - minter=minter1, supersession_builder=sup1, existing=IdMap(), dry_run=True, + minter=minter1, + supersession_builder=sup1, + existing=IdMap(), + dry_run=True, ) thing_a = first.id_map[A].thing_uri thing_b = first.id_map[B].thing_uri @@ -117,7 +123,10 @@ def test_supersede_resolves_interterm_reference_to_new_thing_uri(): minter2, sup2 = _wrapped(TPL_NEW) result = publish_incremental( [_term_ref(A, "Alpha", ref_uri=B), _term_ref(B, "Beta")], - minter=minter2, supersession_builder=sup2, existing=first.id_map, dry_run=True, + minter=minter2, + supersession_builder=sup2, + existing=first.id_map, + dry_run=True, ) sup_a = next(s for s in result.superseded if s.term_id == A) objs = set(sup_a.nanopub.assertion.objects(rdflib.URIRef(thing_a), REL)) @@ -180,9 +189,9 @@ def test_supersede_keeps_references_already_given_as_thing_uris(): def test_mixed_batch_mints_skips_and_supersedes(): first = _run([_term("keep", "Keep"), _term("edit", "Edit")]) batch = [ - _term("keep", "Keep"), # unchanged + _term("keep", "Keep"), # unchanged _term("edit", "Edit (edited)"), # drifted - _term("new", "New"), # new + _term("new", "New"), # new ] result = _run(batch, existing=first.id_map) assert [m.term_id for m in result.minted.terms] == ["new"] diff --git a/tests/test_introduction.py b/tests/test_introduction.py index e839fae..0564cbf 100644 --- a/tests/test_introduction.py +++ b/tests/test_introduction.py @@ -16,8 +16,12 @@ def _keys(): def _build(**over): priv, pub = _keys() kwargs = dict( - private_key=priv, public_key=pub, bot_name="Biochementity bot", - owner_orcid=OWNER, bot_local_name="biochementity-bot", owner_name="Tobias Kuhn", + private_key=priv, + public_key=pub, + bot_name="Biochementity bot", + owner_orcid=OWNER, + bot_local_name="biochementity-bot", + owner_name="Tobias Kuhn", ) kwargs.update(over) return build_introduction(**kwargs) diff --git a/tests/test_migrate.py b/tests/test_migrate.py index 4ba0139..0a0c651 100644 --- a/tests/test_migrate.py +++ b/tests/test_migrate.py @@ -44,11 +44,13 @@ def _no_old_id_objects(graph, old_uris): def test_acyclic_chain_resolves_all_inline(): - assertions = _batch({ - "a": {"metabolite_of": ["b"]}, - "b": {"metabolite_of": ["c"]}, - "c": {}, - }) + assertions = _batch( + { + "a": {"metabolite_of": ["b"]}, + "b": {"metabolite_of": ["c"]}, + "c": {}, + } + ) minter, sup = _minter_and_supersession() result = migrate_terms(assertions, namespace=NS, minter=minter, supersession_builder=sup, dry_run=True) @@ -68,10 +70,12 @@ def test_acyclic_chain_resolves_all_inline(): def test_two_cycle_defers_one_and_supersedes(): - assertions = _batch({ - "a": {"isomer_of": ["b"]}, - "b": {"isomer_of": ["a"]}, - }) + assertions = _batch( + { + "a": {"isomer_of": ["b"]}, + "b": {"isomer_of": ["a"]}, + } + ) minter, sup = _minter_and_supersession() result = migrate_terms(assertions, namespace=NS, minter=minter, supersession_builder=sup, dry_run=True) @@ -105,8 +109,12 @@ def test_rerun_with_existing_idmap_mints_nothing(): minter2, sup2 = _minter_and_supersession() second = migrate_terms( - assertions, namespace=NS, minter=minter2, supersession_builder=sup2, - existing=first.id_map, dry_run=True, + assertions, + namespace=NS, + minter=minter2, + supersession_builder=sup2, + existing=first.id_map, + dry_run=True, ) assert second.defining.terms == [] assert second.superseding == [] @@ -120,8 +128,12 @@ def test_part_of_link_on_defining_and_superseding(): assertions = _batch({"a": {"isomer_of": ["b"]}, "b": {"isomer_of": ["a"]}}) # cycle -> 1 supersession minter, sup = _minter_and_supersession() result = migrate_terms( - assertions, namespace=NS, minter=minter, supersession_builder=sup, - dry_run=True, part_of=str(vocab), + assertions, + namespace=NS, + minter=minter, + supersession_builder=sup, + dry_run=True, + part_of=str(vocab), ) new = result.id_map.thing_uri_map # every defining nanopub links its term to the vocabulary @@ -149,7 +161,12 @@ def test_migration_records_source_fingerprints(): assertions = _batch({"a": {"metabolite_of": ["b"]}, "b": {}}) minter, sup = _minter_and_supersession() result = migrate_terms( - assertions, namespace=NS, minter=minter, supersession_builder=sup, dry_run=True, part_of=VOCAB, + assertions, + namespace=NS, + minter=minter, + supersession_builder=sup, + dry_run=True, + part_of=VOCAB, ) fps = result.id_map.fingerprint_map assert all(fps.values()) # every migrated term carries a fingerprint @@ -164,13 +181,21 @@ def test_incremental_over_migrated_sources_skips_unchanged(): assertions = _batch({"a": {"metabolite_of": ["b"]}, "b": {}}) minter, sup = _wrapped_minter(TPL_OLD) migrated = migrate_terms( - assertions, namespace=NS, minter=minter, supersession_builder=sup, dry_run=True, part_of=VOCAB, + assertions, + namespace=NS, + minter=minter, + supersession_builder=sup, + dry_run=True, + part_of=VOCAB, ) # Re-run the incremental publisher over the SAME sources with the SAME wrapper. minter2, sup2 = _wrapped_minter(TPL_OLD) result = publish_incremental( - _terms_for(assertions, minter2.builder), minter=minter2, supersession_builder=sup2, - existing=migrated.id_map, dry_run=True, + _terms_for(assertions, minter2.builder), + minter=minter2, + supersession_builder=sup2, + existing=migrated.id_map, + dry_run=True, ) assert result.minted.terms == [] assert result.superseded == [] @@ -181,13 +206,21 @@ def test_incremental_supersedes_migrated_terms_on_template_change(): assertions = _batch({"a": {"metabolite_of": ["b"]}, "b": {}}) minter, sup = _wrapped_minter(TPL_OLD) migrated = migrate_terms( - assertions, namespace=NS, minter=minter, supersession_builder=sup, dry_run=True, part_of=VOCAB, + assertions, + namespace=NS, + minter=minter, + supersession_builder=sup, + dry_run=True, + part_of=VOCAB, ) # Change only the template; every migrated term must be superseded, keeping its thing URI. minter2, sup2 = _wrapped_minter(TPL_NEW) result = publish_incremental( - _terms_for(assertions, minter2.builder), minter=minter2, supersession_builder=sup2, - existing=migrated.id_map, dry_run=True, + _terms_for(assertions, minter2.builder), + minter=minter2, + supersession_builder=sup2, + existing=migrated.id_map, + dry_run=True, ) assert result.minted.terms == [] assert {s.term_id for s in result.superseded} == set(assertions) @@ -202,7 +235,11 @@ def test_default_suggester_attributes_defining_and_superseding(): builder = DefiningNanopubBuilder(NS) minter = SequentialMinter(builder, default_suggester_orcid=str(GERTJAN)) result = migrate_terms( - assertions, namespace=NS, minter=minter, supersession_builder=SupersessionBuilder(), dry_run=True, + assertions, + namespace=NS, + minter=minter, + supersession_builder=SupersessionBuilder(), + dry_run=True, ) # every defining nanopub's assertion is attributed to the default suggester (in provenance) for t in result.defining.terms: diff --git a/tests/test_minting.py b/tests/test_minting.py index 363e326..cf6f936 100644 --- a/tests/test_minting.py +++ b/tests/test_minting.py @@ -91,6 +91,7 @@ def test_mint_all_writes_trig_files(tmp_path): def test_term_input_prefers_untagged_label_over_localized(): from pubmate.minting import term_input_from_assertion + g = rdflib.Graph() s = rdflib.URIRef(NAMESPACE + "lead") g.add((s, RDF.type, RDFS.Class)) @@ -102,6 +103,7 @@ def test_term_input_prefers_untagged_label_over_localized(): def test_term_input_adds_part_of_link(): from pubmate.minting import term_input_from_assertion + DCTERMS = rdflib.Namespace("http://purl.org/dc/terms/") thing = rdflib.URIRef(NAMESPACE + "X") vocab = "https://w3id.org/spaces/biochementity/r/vocabulary" @@ -117,6 +119,7 @@ def test_term_input_adds_part_of_link(): def test_preferred_label_falls_back_english_then_any(): from pubmate._nanopub_build import preferred_label + s = rdflib.URIRef(NAMESPACE + "x") # only localized + english -> english wins over nl-be g = rdflib.Graph() diff --git a/tests/test_references.py b/tests/test_references.py index a97a948..c6edf8d 100644 --- a/tests/test_references.py +++ b/tests/test_references.py @@ -30,6 +30,7 @@ def _assertion(subject_id, *, refs=(), externals=()): # -- reference detection --------------------------------------------------- + def test_referenced_terms_picks_only_in_namespace_other_terms(): g, s = _assertion("a", refs=["b", "c"], externals=["https://identifiers.org/cas:1-2-3"]) assert referenced_terms(g, namespace=NS, subject=s) == {TERMS["b"].toPython(), TERMS["c"].toPython()} @@ -39,14 +40,15 @@ def test_iter_term_references_excludes_self_and_top_parent(): g = rdflib.Graph() s = TERMS["a"] g.add((s, RDFS.subClassOf, PEH.BioChemEntity)) # top parent, external ns - g.add((s, PEH.isIsomerOf, s)) # self-reference - g.add((s, PEH.isMetaboliteOf, TERMS["b"])) # real inter-term ref + g.add((s, PEH.isIsomerOf, s)) # self-reference + g.add((s, PEH.isMetaboliteOf, TERMS["b"])) # real inter-term ref refs = list(iter_term_references(g, namespace=NS, subject=s)) assert refs == [(s, PEH.isMetaboliteOf, TERMS["b"])] # -- ordering -------------------------------------------------------------- + def test_order_acyclic_places_dependencies_first(): # a -> b -> c ; expect c, b, a refs = {"a": {"b"}, "b": {"c"}, "c": set()} @@ -90,6 +92,7 @@ def test_order_is_deterministic(): # -- splitting ------------------------------------------------------------- + def test_split_rewrites_resolved_and_holds_back_deferred(): g, s = _assertion("a", refs=["b", "c"]) # only b is minted so far; both b and c are in the batch @@ -111,9 +114,7 @@ def test_split_rewrites_resolved_and_holds_back_deferred(): def test_split_with_all_resolved_has_no_deferred(): g, s = _assertion("a", refs=["b"]) resolved = {TERMS["b"].toPython(): NS + "RAbbb"} - split = split_references( - g, namespace=NS, subject=s, resolved_uris=resolved, batch_targets={TERMS["b"].toPython()} - ) + split = split_references(g, namespace=NS, subject=s, resolved_uris=resolved, batch_targets={TERMS["b"].toPython()}) assert split.deferred == [] assert split.dangling == [] assert (s, PEH.isMetaboliteOf, rdflib.URIRef(NS + "RAbbb")) in split.kept @@ -122,9 +123,7 @@ def test_split_with_all_resolved_has_no_deferred(): def test_split_drops_dangling_reference(): # c is referenced but neither resolved nor in the batch -> dangling, dropped g, s = _assertion("a", refs=["c"]) - split = split_references( - g, namespace=NS, subject=s, resolved_uris={}, batch_targets={TERMS["a"].toPython()} - ) + split = split_references(g, namespace=NS, subject=s, resolved_uris={}, batch_targets={TERMS["a"].toPython()}) assert split.deferred == [] assert split.dangling == [(s, PEH.isMetaboliteOf, TERMS["c"])] # dropped from the minted graph (a published nanopub must not carry an diff --git a/tests/test_signing.py b/tests/test_signing.py index f16d9a5..de632ad 100644 --- a/tests/test_signing.py +++ b/tests/test_signing.py @@ -1,4 +1,5 @@ """Regression tests for resolve_signing key-file handling.""" + import nanopub from pubmate.cli._signing import resolve_signing @@ -11,10 +12,16 @@ def test_resolve_signing_reads_key_files(tmp_path): priv, pub = tmp_path / "id_rsa", tmp_path / "id_rsa.pub" signing = resolve_signing( - orcid_id="https://orcid.org/0000-0002-1825-0097", name="Test", - private_key=str(priv), public_key=str(pub), intro_nanopub_uri=None, - use_testsuite_keys=False, testsuite_key="rsa-key1", testsuite_ref="main", - test_server=False, dry_run=True, + orcid_id="https://orcid.org/0000-0002-1825-0097", + name="Test", + private_key=str(priv), + public_key=str(pub), + intro_nanopub_uri=None, + use_testsuite_keys=False, + testsuite_key="rsa-key1", + testsuite_ref="main", + test_server=False, + dry_run=True, ) # the profile holds the key *content*, not the path string assert signing.profile is not None diff --git a/tests/test_utils.py b/tests/test_utils.py index aed5025..0af2b1a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -12,9 +12,7 @@ def _nanopub(): builder = DefiningNanopubBuilder(NAMESPACE) - assertion = builder.make_assertion( - [(RDF.type, RDFS.Class), (RDFS.label, Literal("Caffeine"))] - ) + assertion = builder.make_assertion([(RDF.type, RDFS.Class), (RDFS.label, Literal("Caffeine"))]) return builder.build(assertion, suggester_orcid=SUGGESTER, label="Definition of Caffeine")