Profile checks#
Each check family is a function over a ProfileContext, so any of them runs against a bare dictionary with no files and no pipeline. Why the rules are what they are: Design decisions.
The state every profile check shares.
Each check family is a plain function taking a ProfileContext and
returning diagnostics. That keeps the families independent of each other and
independently testable: a check can be exercised on a bare dictionary, with no
files on disk and no pipeline around it.
- class rs_metadata.profile.context.ProfileContext(parsed, schema=<factory>, policy=<factory>)[source]#
Bases:
objectOne anchor record under validation, plus the profile it is judged by.
- Parameters:
- Variables:
Examples
Build one from a bare document, without touching the filesystem:
>>> ctx = ProfileContext.for_document({"name": "MyTool"}) >>> ctx.document["name"] 'MyTool' >>> "license" in ctx.policy["mandatory"] True
values()normalizes CodeMeta’s scalar-or-array cardinality, pairing each value with the path it came from:>>> ctx = ProfileContext.for_document({"license": "MIT"}) >>> ctx.values("license") [('MIT', ('license',))] >>> ctx = ProfileContext.for_document({"license": ["MIT", "Apache-2.0"]}) >>> ctx.values("license") [('MIT', ('license', 0)), ('Apache-2.0', ('license', 1))]
An absent property yields nothing, so checks iterate without guarding:
>>> ProfileContext.for_document({}).values("license") []
- parsed: ParsedSource#
- classmethod for_document(document, file='codemeta.json')[source]#
Build a context from a document alone, for tests and examples.
- Parameters:
- Returns:
ProfileContext – A context with an empty source map, so diagnostics carry no line numbers — which is exactly how they behave when a source map could not be built.
- Return type:
- values(name)[source]#
Every value of a property, paired with its path.
- Parameters:
name (str) – Property name as written in the document.
- Returns:
list of tuple –
(value, path)pairs. Empty when the property is absent.- Return type:
Notes
CodeMeta permits a single value to be written bare or as a one-element array. Both are handled here so no check has to.
- node_values(node, path)[source]#
Every real property of a nested node, flattened over arrays.
- Parameters:
node (dict) – A nested node, such as a
Personinsideauthor.path (DocPath) – Where that node sits in the document.
- Returns:
list of tuple –
(name, value, path)triples. JSON-LD keywords are skipped:@idand@typeare not CodeMeta properties and are checked by the modules that understand them.- Return type:
Notes
The nested counterpart of
values(), and the reason bothtypesandformatscan walk into nodes without each keeping its own copy of the array-versus-scalar path arithmetic.Examples
>>> ctx = ProfileContext.for_document({}) >>> ctx.node_values({"@type": "Person", "familyName": "Doe"}, ("author",)) [('familyName', 'Doe', ('author', 'familyName'))]
An array-valued property yields one entry per item, each with its own index in the path:
>>> node = {"email": ["a@x.org", "b@x.org"]} >>> for entry in ctx.node_values(node, ("author",)): ... print(entry) ('email', 'a@x.org', ('author', 'email', 0)) ('email', 'b@x.org', ('author', 'email', 1))
- title(name)[source]#
The schema’s human-readable title for a property.
Falls back to the property name, so a message is never blank.
Examples
>>> ProfileContext.for_document({}).title("codeRepository") 'Code repository' >>> ProfileContext.for_document({}).title("notAProperty") 'notAProperty'
- diagnostic(code, severity, message, *, path=(), value=None, suggestion=None, prop=None)[source]#
Build a diagnostic against this record.
Resolves
pathto a line and column through the record’s source map, and defaults the reported property to the path’s first component.- Parameters:
code (str) – A registered diagnostic code.
severity (Severity) – How much the finding matters.
message (str) – A complete sentence naming the file and what is wrong.
path (DocPath, optional) – Where in the document the finding applies.
value (Any, optional) – The observed value, echoed back in the report.
suggestion (str, optional) – Concrete remediation.
prop (str, optional) – Override the reported property name.
- Returns:
Diagnostic – The finding.
- Return type:
Examples
>>> ctx = ProfileContext.for_document({"version": "1.0"}) >>> finding = ctx.diagnostic( ... "profile.invalid-value", ... Severity.ERROR, ... "codemeta.json: version is wrong.", ... path=("version",), ... value="1.0", ... ) >>> finding.property, finding.severity.value ('version', 'error') >>> finding.location.file, finding.location.path ('codemeta.json', 'version')
- rs_metadata.profile.context.flatten(name, raw, prefix)[source]#
Pair each value of a property with the path that locates it.
- Parameters:
name (str) – Property name as written.
raw (Any) – Its value: a scalar, a node, or an array of either.
prefix (DocPath) – Path of the object the property belongs to. Empty at the document root.
- Returns:
list of tuple –
(value, path)pairs, one per item whenrawis an array.- Return type:
Notes
CodeMeta permits a single value to be written bare or as a one-element array, and every check has to cope with both. Doing that arithmetic once is what keeps a finding’s reported line number right without each check re-deriving it.
Examples
>>> flatten("name", "MyTool", ()) [('MyTool', ('name',))] >>> flatten("keywords", ["a", "b"], ()) [('a', ('keywords', 0)), ('b', ('keywords', 1))]
Inside a node, the path carries on from where the node sits:
>>> flatten("familyName", "Doe", ("author", 0)) [('Doe', ('author', 0, 'familyName'))]
Running every profile check over one record.
Kept out of __init__ so the package’s namespace is only re-exports, and so
the ordering and early-exit policy have an obvious home.
- rs_metadata.profile.runner.CHECKS = (<function check_context>, <function check_feature_list>, <function check_schema>, <function check_types>, <function check_formats>, <function check_unknown_properties>, <function check_licenses>, <function check_identifiers>, <function check_agents>, <function check_edam>, <function check_vocabularies>, <function check_placeholders>, <function check_recommendations>)#
Every check, in the order their findings read best.
Order matters for the reader, not for correctness: structural problems come before value-level ones, because a record with no
@contexthas bigger problems than a non-canonical license URL. Diagnostics are re-sorted by severity before rendering, so this is a tiebreak rather than a guarantee.
- rs_metadata.profile.runner.validate(parsed)[source]#
Validate an anchor record against the LUMC profile.
- Parameters:
parsed (ParsedSource) – The parsed
codemeta.json, carrying its source map so findings can report line numbers.- Returns:
list of Diagnostic – Every finding, in check order. The caller sorts by severity before rendering.
- Return type:
See also
rs_metadata.core.validateThe full pipeline, including consistency.
Examples
>>> from rs_metadata.concepts import ParsedSource >>> record = { ... "@context": ["https://w3id.org/codemeta/3.1"], ... "@type": "SoftwareSourceCode", ... "name": "MyTool", ... "description": "A tool.", ... "version": "1.0.0", ... "identifier": "https://doi.org/10.5281/zenodo.1", ... "author": [{"@type": "Person", ... "@id": "https://orcid.org/0000-0002-1825-0097", ... "givenName": "Josiah", "familyName": "Carberry"}], ... "license": "https://spdx.org/licenses/Apache-2.0", ... "codeRepository": "https://github.com/lumc/mytool", ... "programmingLanguage": ["Python"], ... "applicationCategory": "Command-line tool", ... "schema:featureList": ["http://edamontology.org/operation_0292"], ... } >>> source = ParsedSource( ... adapter_id="codemeta", file="codemeta.json", format="CodeMeta", ... document=record, ... ) >>> findings = validate(source)
A record satisfying the profile raises no errors:
>>> [d for d in findings if d.severity.value == "error"] []
Recommendations are still reported, as one aggregated warning:
>>> [d.code for d in findings if d.severity.value == "warning"] ['recommendation.missing']
Break one thing and it is named precisely:
>>> del record["license"] >>> next(d.message for d in validate(source) ... if d.code == "profile.required-field") 'codemeta.json is missing the mandatory property "license" (license).'
Value-type conformance, driven by CodeMeta’s own property table.
This is the single place value types are enforced. The types themselves are
never written down here — they are derived from upstream by
scripts/vendor_reference_data.py from CodeMeta’s property table and
schema.org, and read through type_entry(), so a change
upstream arrives by regenerating the data rather than by editing Python.
The rule is mechanical, which is what makes it safe to apply to every property at once:
A property whose declared range includes
Textmay be written as a bare string. A property whose range does not must be a node or an absolute URL.
The second half is the one that matters. JSON-LD does not reject a wrong type;
it silently coerces. A bare "Apache-2.0" where CreativeWork or URL is
expected expands to a relative IRI, which resolves against the document base
into a meaningless URL or is dropped outright. Nothing errors anywhere in the
chain — the value simply stops being recognizable to anything that ingests the
file. That is the same failure as the unprefixed featureList, and it is why
these are errors rather than warnings.
- rs_metadata.profile.types.check_types(ctx)[source]#
Check every value against the type CodeMeta declares for its property.
- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of Diagnostic – One error per non-conformant value, each naming the required form and showing a corrected value.
- Return type:
Notes
Only the kind of value is checked here — bare string versus node versus URL. Whether a node has the right keys is the schema’s job, and whether a conformant value is a good one is the job of the license, vocabulary and identifier checks.
Nested nodes are checked one level down for the properties CodeMeta types differently by parent, which today is
identifierinside aPerson.Examples
A range including
Textaccepts a bare string:>>> check_types(ProfileContext.for_document({"developmentStatus": "active"})) [] >>> check_types(ProfileContext.for_document({"keywords": ["genomics"]})) []
A range without it does not, and the message shows the fix:
>>> ctx = ProfileContext.for_document({"referencePublication": "Doe 2026"}) >>> finding = check_types(ctx)[0] >>> finding.code, finding.severity.value ('profile.invalid-type', 'error') >>> "a ScholarlyArticle node" in finding.message True
An absolute URL satisfies a range containing
URL:>>> check_types(ProfileContext.for_document( ... {"codeRepository": "https://github.com/org/tool"})) []
but not one without it — a dependency has to be a node:
>>> ctx = ProfileContext.for_document({"softwareRequirements": ["numpy>=1.24"]}) >>> print(check_types(ctx)[0].suggestion) Write it as {"@type": "SoftwareSourceCode", "name": "package-name", ...}.
Nodes are accepted wherever a node is expected:
>>> check_types(ProfileContext.for_document({"softwareRequirements": [ ... {"@type": "SoftwareSourceCode", "name": "numpy", "version": ">=1.24"}]})) [] >>> check_types(ProfileContext.for_document({"author": [ ... {"@type": "Person", "familyName": "Doe"}]})) []
Properties CodeMeta does not define are left to the unknown-property check rather than also drawing a type complaint:
>>> check_types(ProfileContext.for_document({"somethingInvented": "x"})) []
licenseis absent here too:licensesreports it, because it can resolve the text against SPDX and name the exact URL to use.>>> check_types(ProfileContext.for_document({"license": "Apache-2.0"})) []
- rs_metadata.profile.types.example_for(name)[source]#
A ready-to-paste node showing the accepted form of a property.
- Parameters:
name (str) – CodeMeta property name.
- Returns:
str or None – Compact JSON for the first node type in the property’s range, or
Nonewhen the range is URL-only and there is nothing to build.- Return type:
str | None
Examples
>>> example_for("softwareRequirements") '{"@type": "SoftwareSourceCode", "name": "package-name", "version": ">=1.0"}'
A URL-only property has no node form to show:
>>> example_for("codeRepository") is None True
- rs_metadata.profile.types.reports_on(name, value)[source]#
Whether a dedicated value-type check will report on this value.
Used by
schema_checksto stay quiet where a better message is coming, so one mistake produces one diagnostic. Sharing this predicate rather than re-deriving it is what keeps the two in step.- Parameters:
name (str) – CodeMeta property name.
value (Any) – The property’s value, as written — a scalar, a node, or an array.
- Returns:
bool –
Trueonly when the value actually contains something a type check will complain about.- Return type:
Examples
A bare string where a node is required is reported:
>>> reports_on("license", "Apache-2.0") True >>> reports_on("author", ["Jane Doe"]) True
Conformant values are not:
>>> reports_on("author", [{"@type": "Person", "familyName": "Doe"}]) False >>> reports_on("developmentStatus", "active") False
Neither is a structural problem, which stays the schema’s to report — an empty array has no value to be the wrong type:
>>> reports_on("author", []) False
- rs_metadata.profile.types.required_form(name)[source]#
Describe, in prose, what a property’s value has to be.
- Parameters:
name (str) – CodeMeta property name.
- Returns:
str – A phrase naming the accepted forms, for use in a diagnostic.
- Return type:
Examples
>>> required_form("license") 'a CreativeWork node or an absolute URL' >>> required_form("author") 'an Organization or Person node' >>> required_form("codeRepository") 'an absolute URL' >>> required_form("softwareRequirements") 'a SoftwareSourceCode node'
Lexical form of literal values, for the datatypes CodeMeta declares.
types answers whether a value is the right kind of thing — a node, a
URL, a literal. This module answers the other half: granted that a bare value
belongs here, is what was written actually a value of that datatype? It covers
dates, datetimes, numbers, integers, booleans and URLs, and lends
valid_url() to types and identifiers so that every place
expecting a URL agrees on what one is.
Which properties are literal-typed is derived, like everything else, from the vendored table. The lexical rules themselves are not: schema.org states them only in prose (“a date value in ISO 8601 date format”) and ships no machine-readable form, so there is nothing to derive. What is implemented here is the standard each datatype name denotes — a fixed algorithm for six datatypes, not a per-property table that could drift.
Validity is decided by construction, not by pattern. This is not a detail:
the vendored CFF schema’s date pattern accepts
2026-02-30;this profile’s own former pattern accepted it too, and additionally rejected
2026, which is a valid ISO 8601 date.
A regex over digits cannot know that February has 28 days. Building a
datetime.datetime can, so that is what decides.
- rs_metadata.profile.formats.check_formats(ctx)[source]#
Check that literal values are well-formed for their declared datatype.
- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of Diagnostic – One error per malformed literal, naming the expected form.
- Return type:
Examples
A real date passes:
>>> check_formats(ProfileContext.for_document({"datePublished": "2026-06-01"})) []
A date that never happened does not, however well-formed it looks:
>>> ctx = ProfileContext.for_document({"datePublished": "2026-02-30"}) >>> finding = check_formats(ctx)[0] >>> finding.code, finding.severity.value ('profile.invalid-value', 'error') >>> "ISO 8601 date such as 2026-06-01" in finding.suggestion True
Neither does an ambiguous spelling, which is the mistake worth catching — nothing downstream can tell 1 June from 6 January:
>>> [d.severity.value for d in check_formats( ... ProfileContext.for_document({"dateCreated": "01/06/2026"}))] ['error']
Reduced precision is valid ISO 8601 and passes:
>>> check_formats(ProfileContext.for_document({"datePublished": "2026"})) []
Properties whose range includes
Textare not format-checked, so a version string is left alone:>>> check_formats(ProfileContext.for_document({"version": "1.0.0-rc1"})) []
Nested nodes are checked too, which is where CodeMeta puts
startDate:>>> ctx = ProfileContext.for_document({"author": [{ ... "@type": "Role", "startDate": "not-a-date"}]}) >>> [d.property for d in check_formats(ctx)] ['startDate']
- rs_metadata.profile.formats.format_checked(name)[source]#
Whether a property’s values have a checkable lexical form.
- Parameters:
name (str) – CodeMeta property name.
- Returns:
bool –
Trueonly when every type in the property’s range is a datatype this module knows how to check.- Return type:
Notes
A range containing
Textis never checked:Textaccepts anything, so there is no form to be wrong. A range naming a node type belongs totypes, which decides kind.Examples
>>> format_checked("datePublished"), format_checked("copyrightYear") (True, True)
versionisNumber or Text, and theTextbranch means1.0.0is a perfectly good value:>>> format_checked("version") False
Node- and URL-typed properties are
types’ business:>>> format_checked("author"), format_checked("codeRepository") (False, False)
- rs_metadata.profile.formats.reports_on(name, value)[source]#
Whether
check_formats()will report on this value.Used by
schema_checksto stay quiet where a better message is coming, so one mistake produces one diagnostic. The schema’s pattern can only say the value did not match it; this module says what a date should look like, and does not mistake2026-02-30for one.Examples
>>> reports_on("dateCreated", "01/06/2026") True >>> reports_on("datePublished", "2026-06-01") False
A property with no checkable form is never claimed:
>>> reports_on("version", "1.0.0-rc1") False
- rs_metadata.profile.formats.url_problem(text)[source]#
What is wrong with a URL, phrased for the reader, or
None.- Parameters:
text (str) – The value as written.
- Returns:
str or None – A phrase completing “… but “, or
Nonewhen the URL is well formed.- Return type:
str | None
Notes
Deliberately permissive about which scheme is used.
git://,ssh://andmailto:are all legitimate, and rejecting an unfamiliar scheme would be a false alarm about a real identifier. Only a near-miss ofhttp/httpsis treated as a mistake, because nothing else explains it.Examples
A well-formed URL has no problem:
>>> url_problem("https://github.com/lumc/tool") is None True
Non-web schemes are left alone:
>>> url_problem("git+https://github.com/x") is None True >>> url_problem("mailto:someone@example.org") is None True
A relative reference is not a URL at all:
>>> url_problem("github.com/lumc/tool") 'it has no scheme, so nothing can resolve it'
Whitespace is always a mistake:
>>> url_problem("https://github.com/a b") 'it contains whitespace'
A scheme that needs a host and has none:
>>> url_problem("https://") 'it has no host'
file:conventionally has an empty authority, so it is not missing a host:>>> url_problem("file:///data/reference.fa") is None True
And the typos a scheme check exists to catch:
>>> url_problem("htp://github.com/lumc/tool") "its scheme 'htp' is not a real scheme; did you mean 'http'?" >>> url_problem("hpts://github.com/x") "its scheme 'hpts' is not a real scheme; did you mean 'https'?"
A real scheme that merely resembles one is left alone:
>>> url_problem("ftps://files.example.org/x") is None True
- rs_metadata.profile.formats.valid_literal(value, allowed)[source]#
Whether a value is a well-formed literal of any type in a range.
- Parameters:
- Returns:
bool – Whether the value satisfies at least one of them.
- Return type:
Notes
A year written as
"2026"is accepted where aNumberis expected. JSON-LD treats a quoted literal and a bare one alike, and generators differ on which they emit, so rejecting the quoted form would be a false alarm about something no consumer would notice.Examples
>>> valid_literal("2026-06-01", ["Date"]) True >>> valid_literal("2026-02-30", ["Date"]) False
A range offering two datatypes accepts either:
>>> valid_literal("2026-06-01", ["Date", "Datetime"]) True >>> valid_literal("2026-06-01T10:30:00", ["Date", "Datetime"]) True
Numbers are accepted quoted or bare:
>>> valid_literal(2026, ["Number"]), valid_literal("2026", ["Number"]) (True, True) >>> valid_literal(True, ["Boolean"]), valid_literal("true", ["Boolean"]) (True, True)
A node where a literal belongs is not a literal at all:
>>> valid_literal({"@type": "Organization"}, ["Date"]) False
- rs_metadata.profile.formats.valid_url(text)[source]#
Whether a string is a usable absolute URL.
Examples
>>> valid_url("https://github.com/lumc/tool") True >>> valid_url("htp://github.com/x"), valid_url("https://") (False, False)
JSON-LD vocabulary checks: the context, prefixes and property names.
These catch the failures where a file looks right and is silently wrong. A record with no context is not CodeMeta at all; a property written without a prefix it needs expands to nothing and is discarded by every consumer, while remaining perfectly visible in the file to whoever wrote it.
- rs_metadata.profile.jsonld.check_context(ctx)[source]#
Check that
@contextdeclares a recognized CodeMeta vocabulary.- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of Diagnostic – Findings about the context.
- Return type:
Notes
Without a context, property names in the file have no globally defined meaning:
namecould denote anything. This is reported as its own diagnostic rather than as a generic missing field.Every
3.xcontext is current: 3.0 and 3.1 resolve to byte-identical documents, so warning about 3.0 would fire on files the common generators produce while telling the reader nothing.Anything that is not a CodeMeta 3.x context is an error, and
validate()stops after it. Earlier CodeMeta renamed properties, so every later check would judge the record against the wrong dictionary and report valid terms as unknown.Examples
A well-formed context produces nothing:
>>> ctx = ProfileContext.for_document( ... {"@context": ["https://w3id.org/codemeta/3.1"]} ... ) >>> check_context(ctx) []
A missing context is an error, and not a profile.required-field one:
>>> [d.code for d in check_context(ProfileContext.for_document({}))] ['profile.invalid-context']
So is a context that references something other than CodeMeta:
>>> ctx = ProfileContext.for_document({"@context": "https://example.org/x"}) >>> [d.code for d in check_context(ctx)] ['profile.invalid-context']
A 3.0 context is the current vocabulary, so it produces nothing:
>>> check_context(ProfileContext.for_document( ... {"@context": "https://w3id.org/codemeta/3.0"})) []
So is an earlier CodeMeta, which this profile does not build on:
>>> ctx = ProfileContext.for_document( ... {"@context": "https://doi.org/10.5063/schema/codemeta-2.0"} ... ) >>> [d.code for d in check_context(ctx)] ['profile.invalid-context']
- rs_metadata.profile.jsonld.check_feature_list(ctx)[source]#
Check how the operations property is spelled.
- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of Diagnostic – Findings about the spelling of
schema:featureList.- Return type:
Notes
CodeMeta 3.1 does not define
featureListin its own vocabulary. Written bare it expands to nothing and JSON-LD processors discard it silently, so the operations look present in the file and are invisible to every consumer. This is the failure this check exists for.The absolute-IRI spellings are correct JSON-LD and are accepted with a note, since every worked example uses the prefixed form.
Examples
The canonical spelling produces nothing:
>>> ctx = ProfileContext.for_document({"schema:featureList": ["x"]}) >>> check_feature_list(ctx) []
The unprefixed spelling is an error, because the data is silently lost:
>>> ctx = ProfileContext.for_document({"featureList": ["x"]}) >>> [(d.code, d.severity.value) for d in check_feature_list(ctx)] [('profile.unprefixed-term', 'error')]
An absolute IRI works, and is merely non-canonical:
>>> ctx = ProfileContext.for_document({"https://schema.org/featureList": ["x"]}) >>> [(d.code, d.severity.value) for d in check_feature_list(ctx)] [('profile.non-canonical-value', 'warning')]
- rs_metadata.profile.jsonld.check_unknown_properties(ctx)[source]#
Flag properties CodeMeta does not define, and undeclared prefixes.
- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of Diagnostic – Findings about property names.
- Return type:
Notes
The profile deliberately allows extra properties, so an unrecognized term is a warning: consumers ignore what they do not understand, which is not fatal. It is reported at all because it is nearly always a typo, and a typo means a mandatory field is quietly missing.
An undeclared prefix is different and is an error: the property cannot be expanded to an IRI at all, so it is malformed rather than merely unknown.
Examples
A CodeMeta property is fine, and so is an absolute IRI:
>>> check_unknown_properties(ProfileContext.for_document({"name": "x"})) [] >>> ctx = ProfileContext.for_document({"https://example.org/terms/x": "y"}) >>> check_unknown_properties(ctx) []
A typo is caught, with the correction suggested:
>>> ctx = ProfileContext.for_document({"programingLanguage": ["Python"]}) >>> finding = check_unknown_properties(ctx)[0] >>> finding.code, finding.severity.value ('profile.unknown-property', 'warning') >>> finding.suggestion 'Did you mean "programmingLanguage"?'
An editor’s $schema pointer is not a CodeMeta property, but it describes the file rather than the software, and is how editors are told which schema to complete against:
>>> check_unknown_properties(ProfileContext.for_document({"$schema": "x"})) []
A prefixed term is accepted once its prefix is declared:
>>> undeclared = ProfileContext.for_document({"lumc:internalId": "RSE-1"}) >>> [d.code for d in check_unknown_properties(undeclared)] ['profile.invalid-context'] >>> declared = ProfileContext.for_document({ ... "@context": [ ... "https://w3id.org/codemeta/3.1", ... {"lumc": "https://lumc.nl/terms/"}, ... ], ... "lumc:internalId": "RSE-1", ... }) >>> check_unknown_properties(declared) []
EDAM terms and the controlled vocabularies CodeMeta properties draw on.
EDAM terms are checked for shape and for branch, never for existence: confirming a term exists would need a network lookup, while an operation term sitting in the topics list is detectable offline and is a real mistake.
developmentStatus must use
repostatus.org, so anything else is a warning. applicationCategory merely
prefers bio.tools tool types and explicitly permits free text, so a
non-vocabulary value is only informational.
- rs_metadata.profile.terminology.check_edam(ctx)[source]#
Check EDAM terms in the operations and topics properties.
- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of Diagnostic – Findings about EDAM usage.
- Return type:
Notes
Three things are checked, and one is deliberately not:
a value that looks like EDAM but is malformed is an error;
an
operation_term in the topics list, or atopic_term in the operations list, is an error — the two describe different things;https://edamontology.org/...is a warning, because EDAM’s own identifiers usehttpand thehttpsform will not match them;whether the term exists is not checked, because that needs a network lookup and CI must be deterministic.
Free text is explicitly permitted, so a property with no EDAM terms at all is only an informational nudge.
Examples
A well-formed operation term produces nothing:
>>> ctx = ProfileContext.for_document( ... {"schema:featureList": ["http://edamontology.org/operation_0292"]} ... ) >>> check_edam(ctx) []
A topic in the operations list is an error, and says where it belongs:
>>> ctx = ProfileContext.for_document( ... {"schema:featureList": ["http://edamontology.org/topic_0622"]} ... ) >>> finding = check_edam(ctx)[0] >>> finding.code, finding.severity.value ('profile.invalid-value', 'error') >>> finding.suggestion 'Move this term to "applicationSubCategory".'
The https spelling will not match EDAM’s own identifiers:
>>> ctx = ProfileContext.for_document( ... {"schema:featureList": ["https://edamontology.org/operation_0292"]} ... ) >>> finding = check_edam(ctx)[0] >>> finding.code 'profile.non-canonical-value' >>> finding.suggestion 'Use "http://edamontology.org/operation_0292".'
A malformed term is an error. The nudge follows it because the property still contains no usable EDAM term:
>>> ctx = ProfileContext.for_document( ... {"schema:featureList": ["http://edamontology.org/operation_92"]} ... ) >>> [d.code for d in check_edam(ctx)] ['profile.invalid-value', 'recommendation.incomplete']
Free text is allowed, with a nudge towards EDAM:
>>> ctx = ProfileContext.for_document( ... {"schema:featureList": ["Aligning reads to a reference"]} ... ) >>> [(d.code, d.severity.value) for d in check_edam(ctx)] [('recommendation.incomplete', 'info')]
- rs_metadata.profile.terminology.check_vocabularies(ctx)[source]#
Check properties that draw on a controlled vocabulary.
- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of Diagnostic – Findings about vocabulary usage.
- Return type:
Notes
Strictness follows the vocabulary’s own
closedflag in the vendored data: repostatus.org is a closed vocabulary fordevelopmentStatus, while bio.tools tool types are merely preferred forapplicationCategory.Examples
A repostatus term is accepted:
>>> ctx = ProfileContext.for_document({"developmentStatus": "active"}) >>> check_vocabularies(ctx) []
The vocabulary is closed, so anything else is an error and the message lists every accepted term:
>>> ctx = ProfileContext.for_document( ... {"developmentStatus": "actively maintained"} ... ) >>> finding = check_vocabularies(ctx)[0] >>> finding.code, finding.severity.value ('profile.invalid-value', 'error') >>> "abandoned, active" in finding.suggestion True
A bio.tools tool type is accepted:
>>> ctx = ProfileContext.for_document( ... {"applicationCategory": "Command-line tool"} ... ) >>> check_vocabularies(ctx) []
That includes the repostatus.org URL: the property is typed as Text, so the bare term is the only member of the vocabulary.
>>> ctx = ProfileContext.for_document( ... {"developmentStatus": "https://www.repostatus.org/#active"} ... ) >>> [d.severity.value for d in check_vocabularies(ctx)] ['error']
Free text in applicationCategory is conformant CodeMeta but not what this profile expects, so it is a warning rather than an error:
>>> ctx = ProfileContext.for_document({"applicationCategory": "Notebook"}) >>> [(d.code, d.severity.value) for d in check_vocabularies(ctx)] [('profile.invalid-value', 'warning')]
Checks on people, organizations and persistent identifiers.
ORCID and ROR both carry check digits, so a mistyped identifier is an error without asking whether the person or institution exists: the string cannot be a valid identifier either way.
- rs_metadata.profile.identifiers.agent_label(value)[source]#
Name an agent for use in a message.
- Parameters:
value (dict) – A
PersonorOrganizationnode.- Returns:
str – The best available human-readable name.
- Return type:
Examples
>>> agent_label({"givenName": "Jane", "familyName": "Doe"}) 'Jane Doe' >>> agent_label({"@type": "Organization", "name": "LUMC"}) 'LUMC' >>> agent_label({"familyName": "Doe"}) 'Doe'
A node with nothing to name it still yields a usable phrase, so the message never reads “… given for .” :
>>> agent_label({}) 'an unnamed agent'
- rs_metadata.profile.identifiers.check_agents(ctx)[source]#
Check authors, maintainers and their affiliations.
- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of Diagnostic – Findings about agent identifiers.
- Return type:
Notes
Verifies ORCID and ROR check digits, detects one ORCID used for two different people, and notes authors and affiliations that carry no identifier at all.
The all-zero placeholder ORCID is deliberately not reported here even though its check digit fails;
placeholdersgives it a message that explains the real problem.Examples
A well-formed author produces nothing:
>>> author = { ... "@type": "Person", ... "@id": "https://orcid.org/0000-0002-1825-0097", ... "givenName": "Josiah", ... "familyName": "Carberry", ... "affiliation": { ... "@type": "Organization", ... "@id": "https://ror.org/05xvt9f17", ... "name": "Leiden University Medical Center", ... }, ... } >>> check_agents(ProfileContext.for_document({"author": [author]})) []
A mistyped ORCID fails its check digit, and that is an error:
>>> broken = dict(author, **{"@id": "https://orcid.org/0000-0002-1825-0098"}) >>> finding = check_agents(ProfileContext.for_document({"author": [broken]}))[0] >>> finding.code, finding.severity.value ('profile.invalid-value', 'error') >>> "check digit" in finding.message True
So does a mistyped ROR:
>>> bad_ror = dict(author) >>> bad_ror["affiliation"] = dict(author["affiliation"], ... **{"@id": "https://ror.org/05xvt9f18"}) >>> ctx = ProfileContext.for_document({"author": [bad_ror]}) >>> [d.code for d in check_agents(ctx)] ['profile.invalid-value']
One ORCID for two people is a warning:
>>> other = dict(author, givenName="Someone", familyName="Else") >>> ctx = ProfileContext.for_document({"author": [author, other]}) >>> [(d.code, d.severity.value) for d in check_agents(ctx)] [('profile.invalid-value', 'warning')]
An author with no ORCID gets an informational nudge, but an organization does not — organizations do not have ORCIDs:
>>> anonymous = {"@type": "Person", "givenName": "Jane", "familyName": "Doe"} >>> ctx = ProfileContext.for_document({"author": [anonymous]}) >>> [d.code for d in check_agents(ctx)] ['recommendation.incomplete'] >>> org = {"@type": "Organization", "@id": "https://ror.org/05xvt9f17", ... "name": "LUMC"} >>> check_agents(ProfileContext.for_document({"author": [org]})) []
- rs_metadata.profile.identifiers.check_identifiers(ctx)[source]#
Check the record’s persistent identifiers.
- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of Diagnostic – At most one informational finding.
- Return type:
Notes
Only the absence of a DOI is reported, and only as information. It explicitly accepts a Git tag URL for software that will never be formally published, so a missing DOI cannot be an error — but a DOI is what makes a release citable, so it is worth saying.
Examples
A DOI in any spelling satisfies the check:
>>> ctx = ProfileContext.for_document( ... {"identifier": "https://doi.org/10.5281/zenodo.1"} ... ) >>> check_identifiers(ctx) []
A Git tag URL is accepted, with a note:
>>> ctx = ProfileContext.for_document( ... {"identifier": "https://github.com/org/tool/tree/v1.0.0"} ... ) >>> [(d.code, d.severity.value) for d in check_identifiers(ctx)] [('recommendation.incomplete', 'info')]
A DOI among several identifiers is enough:
>>> ctx = ProfileContext.for_document({ ... "identifier": ["https://bio.tools/x", "10.5281/zenodo.1"] ... }) >>> check_identifiers(ctx) []
Absence is left to the mandatory-field check:
>>> check_identifiers(ProfileContext.for_document({})) []
License checks against the vendored SPDX list.
The SPDX list ships inside the package, so an identifier is resolved against the real thing, including deprecations.
A license value must be an SPDX license URL or a CreativeWork node.
The first half is CodeMeta’s own typing: license is CreativeWork or URL,
so a bare "Apache-2.0" expands to a relative IRI and stops naming anything.
The second half is this profile being stricter than CodeMeta, which permits any
URL — a bare link says where to read the license but not which one it is, so
nothing can map it to terms. An institutional license is still expressible, as
a CreativeWork carrying its name and URL; every message that relies on the
profile rule rather than on CodeMeta says so.
- rs_metadata.profile.licenses.check_licenses(ctx)[source]#
Check every license value against the SPDX list.
- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of Diagnostic – Findings about license values.
- Return type:
Notes
CodeMeta types
licenseas CreativeWork or URL, so a bare identifier is not a conformant value: it expands to a relative IRI and no consumer can tell which license it names. This check owns that error rather thantypes, because it can resolve the text against the SPDX list and name the exact URL to use.Requiring an SPDX URL specifically is a profile rule, stricter than CodeMeta: a bare link says where to read the license but not which one it is. The message says so, so nobody hunts for a spec line that does not exist.
Examples
The canonical form produces nothing:
>>> ctx = ProfileContext.for_document( ... {"license": "https://spdx.org/licenses/Apache-2.0"} ... ) >>> check_licenses(ctx) []
A bare identifier is understood, and the exact URL is named:
>>> ctx = ProfileContext.for_document({"license": "Apache-2.0"}) >>> finding = check_licenses(ctx)[0] >>> finding.code, finding.severity.value ('profile.invalid-type', 'error') >>> finding.suggestion 'Use "https://spdx.org/licenses/Apache-2.0".'
An SPDX expression cannot survive as one value, so the array form is named:
>>> ctx = ProfileContext.for_document({"license": "MIT OR Apache-2.0"}) >>> "List each license as its own SPDX URL" in check_licenses(ctx)[0].suggestion True
A URL that claims SPDX but names no real license is an error:
>>> ctx = ProfileContext.for_document( ... {"license": "https://spdx.org/licenses/Apache-2.0-only"} ... ) >>> [(d.code, d.severity.value) for d in check_licenses(ctx)] [('profile.invalid-value', 'error')]
So is a URL that identifies no license at all:
>>> ctx = ProfileContext.for_document( ... {"license": "https://opensource.org/licenses/MIT"} ... ) >>> [(d.code, d.severity.value) for d in check_licenses(ctx)] [('profile.invalid-value', 'error')]
A
CreativeWorkis how a custom or institutional license is expressed:>>> ctx = ProfileContext.for_document({"license": { ... "@type": "CreativeWork", "name": "LUMC internal license", ... "url": "https://example.org/lumc-license", ... }}) >>> check_licenses(ctx) []
but one with nothing to resolve earns a warning:
>>> ctx = ProfileContext.for_document({"license": { ... "@type": "CreativeWork", "name": "LUMC internal license"}}) >>> [(d.code, d.severity.value) for d in check_licenses(ctx)] [('profile.invalid-value', 'warning')]
A deprecated identifier is flagged separately:
>>> ctx = ProfileContext.for_document( ... {"license": "https://spdx.org/licenses/GPL-3.0"} ... ) >>> [d.code for d in check_licenses(ctx)] ['profile.deprecated-value']
- rs_metadata.profile.licenses.spdx_suggestion(candidate)[source]#
Suggest SPDX identifiers close to what the author wrote.
- Parameters:
candidate (str) – The unrecognized license string.
- Returns:
str – A “did you mean” sentence naming up to three candidates, or generic advice when nothing is close enough to be worth guessing at.
- Return type:
Examples
A near miss gets concrete suggestions. The exact set depends on the vendored SPDX list, so only the leading candidate is asserted here:
>>> suggestion = spdx_suggestion("Apache-2.0-only") >>> suggestion.startswith("Did you mean https://spdx.org/licenses/Apache-2.0") True
Something with no close match gets the general advice instead of a bad guess:
>>> spdx_suggestion("Our institutional license").startswith("Use an SPDX") True
Detection of template values that were never filled in.
A placeholder is worse than a missing field. A missing field is visibly absent; a placeholder asserts a specific identifier that does not exist, and registries harvest it as though it did — attributing software to an ORCID nobody holds, or pointing a citation at a DOI that resolves to nothing.
Every pattern here appears verbatim in a published template or in this project’s own examples, which is exactly why people leave them behind.
- rs_metadata.profile.placeholders.PLACEHOLDER_DOI_PREFIX: str = '10.0000/'#
The DOI prefix conventionally used in examples. 10.0000 is not an assigned registrant prefix.
- rs_metadata.profile.placeholders.PLACEHOLDER_HOSTS = frozenset({'example.com', 'example.net', 'example.org'})#
Reserved by RFC 2606 for documentation. These can never be a real repository, so they are errors.
- rs_metadata.profile.placeholders.PLACEHOLDER_ORCID: str = '0000-0000-0000-0000'#
The all-zero ORCID used in author examples everywhere. Its check digit is wrong, but that is not the useful thing to tell someone about it.
- rs_metadata.profile.placeholders.check_placeholders(ctx)[source]#
Report template values that were copied but never replaced.
- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of Diagnostic – One finding per placeholder found.
- Return type:
Notes
Severity is calibrated to how certain the detection is. A reserved
example.comhost, a10.0000/DOI, the all-zero ORCID and EDAM’s zero terms cannot be real, so they are errors. Agithub.com/example/repository is only a warning, because an account could in principle really be namedexample.Examples
The guide’s placeholder DOI cannot be real:
>>> ctx = ProfileContext.for_document( ... {"identifier": "https://doi.org/10.0000/example.mytool.1"} ... ) >>> [(d.code, d.severity.value) for d in check_placeholders(ctx)] [('profile.placeholder-value', 'error')]
Nor can a reserved example domain:
>>> ctx = ProfileContext.for_document( ... {"codeRepository": "https://example.com/mytool"} ... ) >>> [d.severity.value for d in check_placeholders(ctx)] ['error']
A github.com/example/ repository is a hint, not proof:
>>> ctx = ProfileContext.for_document( ... {"codeRepository": "https://github.com/example/mytool"} ... ) >>> [d.severity.value for d in check_placeholders(ctx)] ['warning']
The all-zero ORCID is caught wherever an agent appears:
>>> ctx = ProfileContext.for_document({ ... "author": [{"@id": "https://orcid.org/0000-0000-0000-0000", ... "familyName": "Doe"}] ... }) >>> [d.code for d in check_placeholders(ctx)] ['profile.placeholder-value']
So is EDAM’s zero operation, even when the property is misspelled — the reader should see every problem at once rather than a second round after renaming it:
>>> ctx = ProfileContext.for_document( ... {"featureList": ["http://edamontology.org/operation_0000"]} ... ) >>> [d.code for d in check_placeholders(ctx)] ['profile.placeholder-value']
Real values produce nothing:
>>> ctx = ProfileContext.for_document({ ... "identifier": "https://doi.org/10.5281/zenodo.0000000", ... "codeRepository": "https://github.com/lumc/rs-metadata", ... }) >>> check_placeholders(ctx) []
- rs_metadata.profile.placeholders.has_placeholder_host(value)[source]#
Whether a URL points at an RFC 2606 reserved example domain.
- Parameters:
value (str) – A URL.
- Returns:
bool – Whether its host is a reserved example domain.
- Return type:
Examples
>>> has_placeholder_host("https://example.com/mytool") True >>> has_placeholder_host("https://www.example.org/mytool") True
A real host is not a placeholder, even if the path mentions examples:
>>> has_placeholder_host("https://github.com/org/example-tool") False
Nor is a domain that merely contains the word:
>>> has_placeholder_host("https://example.lumc.nl/tool") False
The recommended-field check.
Separate from the mandatory-field check because the two exist for different reasons and behave differently: a missing mandatory field is an error about one field, while missing recommendations are a single piece of advice about the record as a whole.
- rs_metadata.profile.recommendations.check_recommendations(ctx)[source]#
Report absent recommended fields as one finding, not fifteen.
- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of Diagnostic – At most one warning, naming every absent field.
- Return type:
Notes
Aggregation is deliberate. The profile recommends fifteen fields, and a minimal-but-valid record is missing most of them; emitting one warning each would bury the errors that actually need attention and train people to ignore warnings altogether.
Recommendations never fail a build unless the run is configured with
--fail-on warning.Examples
A record missing recommendations gets exactly one finding:
>>> ctx = ProfileContext.for_document({"name": "MyTool"}) >>> findings = check_recommendations(ctx) >>> len(findings) 1 >>> findings[0].code, findings[0].severity.value ('recommendation.missing', 'warning')
The message names them all, so the advice is actionable:
>>> "datePublished" in findings[0].message and "keywords" in findings[0].message True
A record with every recommendation produces nothing:
>>> complete = {name: "x" for name in ctx.policy["recommended"]} >>> check_recommendations(ProfileContext.for_document(complete)) []
- rs_metadata.profile.recommendations.missing_recommendations(ctx)[source]#
List the recommended fields the record does not have.
- Parameters:
ctx (ProfileContext) – The record under validation.
- Returns:
list of str – Absent recommended fields, in the profile’s own order.
- Return type:
Examples
>>> ctx = ProfileContext.for_document({"keywords": ["genomics"]}) >>> "keywords" in missing_recommendations(ctx) False >>> "datePublished" in missing_recommendations(ctx) True