Skip to content

Commit 75458fc

Browse files
tkuhnclaude
andauthored
Bot identity, pubinfo tags, and migrate-pipeline fixes (v0.2.0) (#4)
* fix(migrate): drop dangling references and report them as errors A published nanopub is immutable, so baking in a reference to a term that does not exist (a dangling foreign key, e.g. left behind when a duplicate compound was deduplicated upstream) is worse than omitting it: the link can never resolve. Previously such references were kept with their old ids and logged as a warning. Now split_references drops dangling references from the minted graph, and _resolve_all does the same in the superseding pass so a superseding nanopub never reintroduces a broken link. The term still mints with its remaining triples. migrate.py logs each dropped reference at ERROR (with the offending target) plus a batch summary telling the curator to fix the source and re-run; the run still completes (non-fatal), so a known-bad source link does not block publishing the rest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnygThDJDwSVZg3kYujJKz * feat(utils): bind readable prefixes and lead with this:/sub: serialize_nanopub now binds a curated set of vocabulary prefixes (schema, pehterms, biochementity, obo, skos, prov, dcterms) before serializing, so namespaces render as meaningful prefixes instead of rdflib's auto-numbered ns1:/ns2:. The header is ordered with this: and sub: first (the nanopub and its sub-graphs anchor every file), then the rest alphabetically, so the .trig reads top-down. Callers can extend or override via a new `prefixes` argument. Purely a rendering change: binding happens at write time, after signing, so trusty artifact codes and signatures are unaffected (verified is_valid). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnygThDJDwSVZg3kYujJKz * feat(introduction): self-signed bot identity introduction + CLI Add a generic, identity-agnostic way to bootstrap a publishing bot's cryptographic identity, mirroring the nanopub-community declaredBy pattern: - introduction.py: build_introduction() builds the self-signed key-declaration nanopub. The agent is a fresh URI minted inside the introduction itself, typed npx:Bot/npx:SoftwareAgent, frbr:owner-ed by a human ORCID, with its RSA public key declared. The agent self-URI only exists post-sign, so it (and npx:signedBy) use the ~~~ARTIFACTCODE~~~ placeholder, resolved at sign time like scheme A. Tagged nt:wasCreatedFromTemplate so Nanodash renders it nicely. - cli/bootstrap_identity.py: pubmate-bootstrap-identity. Generates (or reuses) an RSA keypair and writes the signed introduction to --output-dir. Signs offline by default: does NOT publish and never touches a secret store, so the private key (the bot's identity) can be generated locally and reviewed before anything goes out. Refuses to overwrite an existing key; chmod 600 on the private key. - utils.py: bind foaf/frbr/nt prefixes so introductions render readably. Tests cover validity, self-signing, agent typing/ownership, the key declaration, owner attribution, and the template tag (incl. opt-out). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnygThDJDwSVZg3kYujJKz * fix(label): prefer the untagged label for a nanopub's rdfs:label A term often carries an untagged label plus localized variants (e.g. "Lead" and "Lood"@NL-BE). The nanopub's own rdfs:label (pubinfo) was taken from the first rdfs:label rdflib happened to yield, so it sometimes picked a localized one. Add preferred_label() (untagged > English > any, ties broken by text for determinism) and use it for both the defining (term_input_from_assertion) and superseding (migrate._label) labels. The assertion keeps every label variant — only the nanopub's headline label changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnygThDJDwSVZg3kYujJKz * fix(bnode): short deterministic blank-node labels Source assertions use anonymous blank nodes ([ a pehterms:ContextAlias; … ]), whose rdflib ids are long (e.g. ndf301ab1d67…b1) and regenerated each parse. nanopub-py skolemizes each blank node to a sub:_<id> URI, so those long, non-reproducible ids leaked into the published nanopub (sub:_ndf301ab1d67…b1). Add relabel_blank_nodes(): rename blank nodes to short, content-ordered labels (b1, b2, …) before each builder wraps the assertion, so they serialize as sub:_b1. Wired into the defining and superseding builders. Pure relabeling (the graph stays isomorphic; nanopub-py already skolemized these), and the labels are assigned by a content key rather than the source's random ids, so the same input always yields the same labels — and therefore reproducible artifact codes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnygThDJDwSVZg3kYujJKz * feat(migrate): --default-suggester for batch attribution Add pubmate-migrate --default-suggester <orcid>, attributing prov:wasAttributedTo on every term that carries none — the case when migrating an existing batch all contributed by one person. Threads through to both the defining nanopubs (via the minter's existing default_suggester_orcid) and the superseding ones. The suggester goes in provenance, distinct from the bot signer; per-term wasAttributedTo (when present) still wins. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnygThDJDwSVZg3kYujJKz * fix(signing): read key files from --private-key/--public-key paths resolve_signing passed the CLI key-file path strings straight to nanopub.Profile, which only reads a key file when given a pathlib.Path — a str is taken as the raw key material, so signing crashed with "RSA key format is not supported". This hit every real-key signing path (pubmate-migrate and pubmate-mint-publish); only the testsuite-key path was exercised before. Pass Path objects so the files are read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NnygThDJDwSVZg3kYujJKz * feat(pubinfo): tag minted nanopubs with hasNanopubType + wasCreatedFromTemplate Add generic support for two Nanodash pubinfo hints on the defining and supersession builders: npx:hasNanopubType (repeatable, the kind of thing the nanopub asserts) and nt:wasCreatedFromTemplate (the assertion template it follows, so Nanodash renders it with the matching form). Shared via a new add_pubinfo_tags() helper; threaded through pubmate-migrate as --nanopub-type/--template. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump version to 0.2.0 New features since v0.1.1: bootstrap-identity / introduction CLIs, pubinfo hasNanopubType + wasCreatedFromTemplate tags, readable prefixes, and the dangling-reference / blank-node / label fixes in the migrate pipeline. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bdd8158 commit 75458fc

20 files changed

Lines changed: 691 additions & 44 deletions

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pubmate"
3-
version = "0.1.1"
3+
version = "0.2.0"
44
description = "Add your description here"
55
readme = "README.md"
66
requires-python = ">=3.12"
@@ -21,6 +21,7 @@ dev = [
2121
]
2222

2323
[project.scripts]
24+
pubmate-bootstrap-identity = "pubmate.cli.bootstrap_identity:cli"
2425
pubmate-cleanrdf = "pubmate.cli.cleanrdf:cli"
2526
pubmate-extract-assertions = "pubmate.cli.extract_assertions:cli"
2627
pubmate-migrate = "pubmate.cli.migrate:cli"

src/pubmate/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from pubmate.defining import DEFAULT_LICENSE, DefiningNanopubBuilder
22
from pubmate.idmap import IdMap, IdMapEntry
3+
from pubmate.introduction import build_introduction
34
from pubmate.migrate import MigrationResult, MintedSupersession, migrate_terms
45
from pubmate.mint import IdentifierGenerator
56
from pubmate.minting import MintBatch, MintedTerm, SequentialMinter, TermInput
@@ -23,6 +24,7 @@
2324
"SequentialMinter",
2425
"SupersessionBuilder",
2526
"TermInput",
27+
"build_introduction",
2628
"migrate_terms",
2729
"order_terms",
2830
"referenced_terms",

src/pubmate/_nanopub_build.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,18 @@
77

88
from __future__ import annotations
99

10-
from typing import Any, Optional
10+
from typing import Any, Iterable, Optional
1111

1212
import nanopub
1313
import rdflib
1414
from rdflib.namespace import DCTERMS, RDFS
1515

16+
from nanopub.namespaces import NPX
17+
18+
#: The nanopub-template ontology; ``nt:wasCreatedFromTemplate`` lets tools like
19+
#: Nanodash render a nanopub with the form/labels of the template it follows.
20+
NTEMPLATE = rdflib.Namespace("https://w3id.org/np/o/ntemplate/")
21+
1622
# Sentinel distinguishing "argument not given" (fall back to a default) from
1723
# "explicitly given None" (suppress that piece of pubinfo).
1824
UNSET: Any = object()
@@ -21,6 +27,69 @@
2127
EPHEMERAL_ORCID = "https://orcid.org/0000-0000-0000-0000"
2228

2329

30+
def _bnode_sort_key(graph: rdflib.Graph, bnode: rdflib.BNode) -> tuple:
31+
"""A content-based key for a blank node, so relabeling is order-stable.
32+
33+
Keys on the bnode's outgoing ``(predicate, object)`` pairs (nested blank
34+
objects contribute only their predicate), so the ordering does not depend on
35+
the source's random bnode ids. Bnodes with identical content sort equal —
36+
which is fine, since swapping their labels yields an isomorphic graph.
37+
"""
38+
items = [
39+
(str(p), "" if isinstance(o, rdflib.BNode) else str(o))
40+
for p, o in graph.predicate_objects(bnode)
41+
]
42+
return tuple(sorted(items))
43+
44+
45+
def relabel_blank_nodes(graph: rdflib.Graph, *, prefix: str = "b") -> rdflib.Graph:
46+
"""Rename blank nodes to short, deterministic labels (``b1``, ``b2``, …).
47+
48+
nanopub-py turns each blank node into a ``sub:_<label>`` URI. With the
49+
source's long, per-parse-random anonymous-node ids that yields unreadable and
50+
non-reproducible suffixes (e.g. ``sub:_ndf301ab1d67…b1``). Relabeling to short
51+
content-ordered labels makes them ``sub:_b1`` / ``sub:_b2`` — readable and
52+
stable across runs. Returns the graph unchanged if it has no blank nodes.
53+
"""
54+
bnodes = {n for triple in graph for n in triple if isinstance(n, rdflib.BNode)}
55+
if not bnodes:
56+
return graph
57+
ordered = sorted(bnodes, key=lambda b: _bnode_sort_key(graph, b))
58+
mapping: dict = {b: rdflib.BNode(f"{prefix}{i + 1}") for i, b in enumerate(ordered)}
59+
out = rdflib.Graph()
60+
for s, p, o in graph:
61+
out.add((mapping.get(s, s), p, mapping.get(o, o)))
62+
return out
63+
64+
65+
def preferred_label(
66+
graph: rdflib.Graph,
67+
subject: rdflib.term.Node,
68+
predicate: rdflib.URIRef = RDFS.label,
69+
) -> Optional[str]:
70+
"""Pick the "regular" label for a term among several language variants.
71+
72+
A term often carries an untagged label plus localized ones (e.g. ``"Lead"``
73+
and ``"Lood"@nl-be``). For the nanopub's own ``rdfs:label`` we want the
74+
canonical one, so prefer an **untagged** literal, then English, then any
75+
other (ties broken by text for determinism). Returns ``None`` if there is no
76+
literal label.
77+
"""
78+
labels = [o for o in graph.objects(subject, predicate) if isinstance(o, rdflib.Literal)]
79+
if not labels:
80+
return None
81+
82+
def rank(lit: rdflib.Literal) -> int:
83+
lang = (lit.language or "").lower()
84+
if not lang:
85+
return 0
86+
if lang == "en" or lang.startswith("en-"):
87+
return 1
88+
return 2
89+
90+
return str(min(labels, key=lambda lit: (rank(lit), str(lit))))
91+
92+
2493
def ephemeral_profile() -> nanopub.Profile:
2594
"""An in-memory profile whose generated keys are never written to disk.
2695
@@ -68,3 +137,24 @@ def add_label_and_license(
68137
np.pubinfo.add((np_ref, RDFS.label, rdflib.Literal(label)))
69138
if license:
70139
np.pubinfo.add((np_ref, DCTERMS.license, rdflib.URIRef(license)))
140+
141+
142+
def add_pubinfo_tags(
143+
np: nanopub.Nanopub,
144+
*,
145+
np_ref: rdflib.term.Identifier,
146+
nanopub_types: Optional[Iterable[str]] = None,
147+
template: Optional[str] = None,
148+
) -> None:
149+
"""Add optional ``npx:hasNanopubType`` and ``nt:wasCreatedFromTemplate`` tags.
150+
151+
Both are pubinfo hints consumers use to categorize and render the nanopub:
152+
``hasNanopubType`` declares the kind(s) of thing it asserts (e.g. a
153+
``BioChemEntity``); ``wasCreatedFromTemplate`` points at the assertion
154+
template it follows so Nanodash shows it with the matching form.
155+
"""
156+
if nanopub_types:
157+
for nanopub_type in nanopub_types:
158+
np.pubinfo.add((np_ref, NPX.hasNanopubType, rdflib.URIRef(nanopub_type)))
159+
if template:
160+
np.pubinfo.add((np_ref, NTEMPLATE.wasCreatedFromTemplate, rdflib.URIRef(template)))

src/pubmate/cli/_signing.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import logging
11+
import pathlib
1112
from dataclasses import dataclass
1213
from typing import Optional
1314

@@ -60,11 +61,13 @@ def resolve_signing(
6061
return Signing(profile=generator.profile, test_server=True)
6162

6263
if private_key and public_key:
64+
# Pass Path objects: nanopub.Profile only *reads* a key file when it is a
65+
# pathlib.Path; a str is taken as the raw key material.
6366
profile = nanopub.Profile(
6467
orcid_id=orcid_id,
6568
name=name,
66-
private_key=private_key,
67-
public_key=public_key,
69+
private_key=pathlib.Path(private_key),
70+
public_key=pathlib.Path(public_key),
6871
introduction_nanopub_uri=intro_nanopub_uri,
6972
)
7073
return Signing(profile=profile, test_server=test_server)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""CLI: bootstrap a publishing bot's cryptographic identity.
2+
3+
Generates (or reuses) an RSA keypair and builds a **self-signed introduction**
4+
nanopublication that declares the bot agent and binds it to that key. By default
5+
it stops at signing — it does **not** publish and does **not** touch any secret
6+
store. That is deliberate: the private key is the bot's identity, so generating
7+
it locally/offline and reviewing the introduction before anything goes out (then
8+
adding the key as a CI secret by hand, once) is far safer than minting an
9+
identity inside automation.
10+
11+
Typical flow::
12+
13+
pubmate-bootstrap-identity \\
14+
--bot-name "Biochementity bot" --bot-id biochementity-bot \\
15+
--owner-orcid https://orcid.org/0000-0002-1267-0234 --owner-name "Tobias Kuhn" \\
16+
--generate-keys --output-dir ./bot-identity
17+
18+
Review ``bot-identity/introduction.trig``; if it looks right, publish it once
19+
(``--publish``) and add ``bot-identity/id_rsa`` as the repo's signing secret.
20+
"""
21+
22+
import logging
23+
import pathlib
24+
import stat
25+
26+
import click
27+
28+
from nanopub import generate_keyfiles
29+
from pubmate.introduction import build_introduction
30+
from pubmate.utils import serialize_nanopub
31+
32+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
33+
logger = logging.getLogger(__name__)
34+
35+
36+
def _code(np_uri: str) -> str:
37+
return np_uri.rstrip("/").rsplit("/", 1)[-1]
38+
39+
40+
@click.command()
41+
@click.option("--bot-name", required=True, help="foaf:name for the agent, e.g. \"Biochementity bot\".")
42+
@click.option("--owner-orcid", required=True, help="ORCID URI of the human owner (frbr:owner + attribution).")
43+
@click.option("--bot-id", default="bot", show_default=True, help="Local URI segment for the agent (…/RA<code>/<bot-id>).")
44+
@click.option("--owner-name", help="Optional foaf:name for the owner ORCID.")
45+
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=pathlib.Path))
46+
@click.option("--generate-keys", is_flag=True, help="Generate a fresh RSA keypair into --output-dir (id_rsa / id_rsa.pub).")
47+
@click.option("--private-key", type=click.Path(exists=True, dir_okay=False), help="Existing private key (instead of --generate-keys).")
48+
@click.option("--public-key", type=click.Path(exists=True, dir_okay=False), help="Existing public key (with --private-key).")
49+
@click.option("--license", "license_", default=None, help="Override pubinfo license URI (default CC BY 4.0).")
50+
@click.option("--test-server", is_flag=True, help="Mark the introduction for the nanopub test server.")
51+
@click.option("--publish", is_flag=True, help="Publish the signed introduction to the network (default: sign only).")
52+
def cli(
53+
bot_name: str,
54+
owner_orcid: str,
55+
bot_id: str,
56+
owner_name: str | None,
57+
output_dir: pathlib.Path,
58+
generate_keys: bool,
59+
private_key: str | None,
60+
public_key: str | None,
61+
license_: str | None,
62+
test_server: bool,
63+
publish: bool,
64+
) -> None:
65+
"""Generate a bot keypair + self-signed introduction nanopublication.
66+
67+
Signs offline by default and writes everything under --output-dir; nothing is
68+
published or stored as a secret unless you ask (--publish), so you can review
69+
the introduction first.
70+
"""
71+
output_dir.mkdir(parents=True, exist_ok=True)
72+
73+
if generate_keys:
74+
if private_key or public_key:
75+
raise click.UsageError("Pass either --generate-keys or --private-key/--public-key, not both.")
76+
priv_path = output_dir / "id_rsa"
77+
if priv_path.exists():
78+
raise click.UsageError(f"{priv_path} already exists; refusing to overwrite an identity key.")
79+
generate_keyfiles(output_dir)
80+
priv_path.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600
81+
priv, pub = priv_path, output_dir / "id_rsa.pub"
82+
logger.info("Generated keypair: %s (private, chmod 600) / %s", priv, pub)
83+
elif private_key and public_key:
84+
priv, pub = pathlib.Path(private_key), pathlib.Path(public_key)
85+
else:
86+
raise click.UsageError("Provide --generate-keys, or both --private-key and --public-key.")
87+
88+
kwargs = {} if license_ is None else {"license": license_}
89+
np = build_introduction(
90+
private_key=priv, public_key=pub, bot_name=bot_name,
91+
owner_orcid=owner_orcid, bot_local_name=bot_id, owner_name=owner_name,
92+
test_server=test_server, **kwargs,
93+
)
94+
np.sign()
95+
agent_uri = f"{np.metadata.np_uri}/{bot_id}"
96+
intro_path = output_dir / "introduction.trig"
97+
intro_path.write_text(serialize_nanopub(np), encoding="utf-8")
98+
99+
if publish:
100+
np.publish()
101+
logger.info("Published introduction: %s", np.metadata.np_uri)
102+
logger.info("Wrote introduction -> %s (artifact code %s)", intro_path, _code(np.metadata.np_uri))
103+
click.echo(f"\nBot agent URI : {agent_uri}")
104+
click.echo(f"Introduction : {np.metadata.np_uri}")
105+
click.echo(f"is_valid : {np.is_valid}")
106+
if not publish:
107+
click.echo(
108+
"\nNot published. Next: review introduction.trig, then publish it once "
109+
"(--publish) and add id_rsa as your signing secret (e.g. gh secret set …)."
110+
)
111+
112+
113+
if __name__ == "__main__":
114+
cli()

src/pubmate/cli/migrate.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ def _code(np_uri: str) -> str:
4747
@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).")
4848
@click.option("--orcid-id")
4949
@click.option("--name")
50+
@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.")
51+
@click.option("--nanopub-type", "nanopub_types", multiple=True, help="URI tagged in pubinfo as npx:hasNanopubType on every nanopub (repeatable).")
52+
@click.option("--template", help="Assertion-template URI tagged in pubinfo as nt:wasCreatedFromTemplate on every nanopub (for Nanodash rendering).")
5053
@click.option("--private-key", type=click.Path(exists=True, dir_okay=False))
5154
@click.option("--public-key", type=click.Path(exists=True, dir_okay=False))
5255
@click.option("--intro-nanopub-uri")
@@ -63,6 +66,9 @@ def cli(
6366
id_map_file: pathlib.Path | None,
6467
orcid_id: str | None,
6568
name: str | None,
69+
default_suggester: str | None,
70+
nanopub_types: tuple[str, ...],
71+
template: str | None,
6672
private_key: str | None,
6773
public_key: str | None,
6874
intro_nanopub_uri: str | None,
@@ -85,9 +91,15 @@ def cli(
8591
intro_nanopub_uri=intro_nanopub_uri, use_testsuite_keys=use_testsuite_keys,
8692
testsuite_key=testsuite_key, testsuite_ref=testsuite_ref, test_server=test_server, dry_run=dry_run,
8793
)
88-
builder = DefiningNanopubBuilder(namespace, profile=signing.profile, test_server=signing.test_server)
89-
minter = SequentialMinter(builder)
90-
supersession = SupersessionBuilder(profile=signing.profile, test_server=signing.test_server)
94+
builder = DefiningNanopubBuilder(
95+
namespace, profile=signing.profile, test_server=signing.test_server,
96+
nanopub_types=nanopub_types, template=template,
97+
)
98+
minter = SequentialMinter(builder, default_suggester_orcid=default_suggester)
99+
supersession = SupersessionBuilder(
100+
profile=signing.profile, test_server=signing.test_server,
101+
nanopub_types=nanopub_types, template=template,
102+
)
91103

92104
files = sorted(assertion_folder.glob(pattern))
93105
if not files:

src/pubmate/defining.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from __future__ import annotations
1616

17-
from typing import Any, Iterable, Optional, Tuple
17+
from typing import Any, Iterable, Optional, Sequence, Tuple
1818

1919
import nanopub
2020
import rdflib
@@ -25,8 +25,10 @@
2525
from pubmate._nanopub_build import (
2626
UNSET as _UNSET,
2727
add_label_and_license,
28+
add_pubinfo_tags,
2829
build_conf,
2930
ephemeral_profile,
31+
relabel_blank_nodes,
3032
)
3133

3234
# A sensible default license for openly published nanopubs (CC BY 4.0). Override
@@ -58,6 +60,8 @@ def __init__(
5860
license: Optional[str] = DEFAULT_LICENSE,
5961
test_server: bool = False,
6062
add_prov_generated_time: bool = True,
63+
nanopub_types: Optional[Sequence[str]] = None,
64+
template: Optional[str] = None,
6165
):
6266
if not namespace:
6367
raise ValueError(
@@ -67,6 +71,10 @@ def __init__(
6771
self.license = license
6872
self.test_server = test_server
6973
self.add_prov_generated_time = add_prov_generated_time
74+
#: ``npx:hasNanopubType`` value(s) tagged on every nanopub's pubinfo.
75+
self.nanopub_types = tuple(nanopub_types) if nanopub_types else ()
76+
#: ``nt:wasCreatedFromTemplate`` value tagged on every nanopub's pubinfo.
77+
self.template = template
7078

7179
self._keyless = profile is None
7280
self.profile = profile if profile is not None else ephemeral_profile()
@@ -132,7 +140,7 @@ def build(
132140
derived_from=derived_from,
133141
)
134142

135-
np = nanopub.Nanopub(conf=conf, assertion=assertion)
143+
np = nanopub.Nanopub(conf=conf, assertion=relabel_blank_nodes(assertion))
136144
np_ref = np.metadata.namespace[""]
137145

138146
if introduces is _UNSET:
@@ -142,5 +150,6 @@ def build(
142150

143151
effective_license = self.license if license is _UNSET else license
144152
add_label_and_license(np, np_ref=np_ref, label=label, license=effective_license)
153+
add_pubinfo_tags(np, np_ref=np_ref, nanopub_types=self.nanopub_types, template=self.template)
145154

146155
return np

0 commit comments

Comments
 (0)