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: object

One anchor record under validation, plus the profile it is judged by.

Parameters:
  • parsed (ParsedSource) – The parsed codemeta.json, carrying its source map so diagnostics can report line numbers.

  • schema (dict[str, Any])

  • policy (dict[str, list[str]])

Variables:
  • document (dict) – The parsed record.

  • schema (dict) – The LUMC profile JSON Schema.

  • policy (dict) – Mandatory, recommended and alias field lists, read from the schema so documentation, validator and schema cannot drift apart.

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#
schema: dict[str, Any]#
policy: dict[str, list[str]]#
classmethod for_document(document, file='codemeta.json')[source]#

Build a context from a document alone, for tests and examples.

Parameters:
  • document (dict) – A CodeMeta record.

  • file (str, optional) – Filename to attribute diagnostics to.

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:

ProfileContext

property document: dict[str, Any]#

The parsed CodeMeta record.

property file: str#

The record’s repository-relative path, used in every message.

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:

list[tuple[Any, tuple[Any, …]]]

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 Person inside author.

  • path (DocPath) – Where that node sits in the document.

Returns:

list of tuple – (name, value, path) triples. JSON-LD keywords are skipped: @id and @type are not CodeMeta properties and are checked by the modules that understand them.

Return type:

list[tuple[str, Any, tuple[Any, …]]]

Notes

The nested counterpart of values(), and the reason both types and formats can 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'
Parameters:

name (str)

Return type:

str

description(name)[source]#

The schema’s description for a property, used as remediation text.

Parameters:

name (str)

Return type:

str | None

diagnostic(code, severity, message, *, path=(), value=None, suggestion=None, prop=None)[source]#

Build a diagnostic against this record.

Resolves path to 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:

Diagnostic

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 when raw is an array.

Return type:

list[tuple[Any, tuple[Any, …]]]

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 @context has 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:

list[Diagnostic]

See also

rs_metadata.core.validate

The 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 Text may 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:

list[Diagnostic]

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 identifier inside a Person.

Examples

A range including Text accepts 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"}))
[]

license is absent here too: licenses reports 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 None when 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_checks to 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 – True only when the value actually contains something a type check will complain about.

Return type:

bool

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:

str

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:

list[Diagnostic]

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 Text are 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 – True only when every type in the property’s range is a datatype this module knows how to check.

Return type:

bool

Notes

A range containing Text is never checked: Text accepts anything, so there is no form to be wrong. A range naming a node type belongs to types, which decides kind.

Examples

>>> format_checked("datePublished"), format_checked("copyrightYear")
(True, True)

version is Number or Text, and the Text branch means 1.0.0 is 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_checks to 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 mistake 2026-02-30 for 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
Parameters:
Return type:

bool

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 None when the URL is well formed.

Return type:

str | None

Notes

Deliberately permissive about which scheme is used. git://, ssh:// and mailto: are all legitimate, and rejecting an unfamiliar scheme would be a false alarm about a real identifier. Only a near-miss of http/https is 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:
  • value (Any) – The value as written — a JSON string, number, or boolean.

  • allowed (list of str) – The datatypes the property permits.

Returns:

bool – Whether the value satisfies at least one of them.

Return type:

bool

Notes

A year written as "2026" is accepted where a Number is 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)
Parameters:

text (str)

Return type:

bool

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 @context declares a recognized CodeMeta vocabulary.

Parameters:

ctx (ProfileContext) – The record under validation.

Returns:

list of Diagnostic – Findings about the context.

Return type:

list[Diagnostic]

Notes

Without a context, property names in the file have no globally defined meaning: name could denote anything. This is reported as its own diagnostic rather than as a generic missing field.

Every 3.x context 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:

list[Diagnostic]

Notes

CodeMeta 3.1 does not define featureList in 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:

list[Diagnostic]

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:

list[Diagnostic]

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 a topic_ term in the operations list, is an error — the two describe different things;

  • https://edamontology.org/... is a warning, because EDAM’s own identifiers use http and the https form 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:

list[Diagnostic]

Notes

Strictness follows the vocabulary’s own closed flag in the vendored data: repostatus.org is a closed vocabulary for developmentStatus, while bio.tools tool types are merely preferred for applicationCategory.

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 Person or Organization node.

Returns:

str – The best available human-readable name.

Return type:

str

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:

list[Diagnostic]

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; placeholders gives 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:

list[Diagnostic]

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:

list[Diagnostic]

Notes

CodeMeta types license as 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 than types, 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 CreativeWork is 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:

str

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:

list[Diagnostic]

Notes

Severity is calibrated to how certain the detection is. A reserved example.com host, a 10.0000/ DOI, the all-zero ORCID and EDAM’s zero terms cannot be real, so they are errors. A github.com/example/ repository is only a warning, because an account could in principle really be named example.

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:

bool

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:

list[Diagnostic]

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:

list[str]

Examples

>>> ctx = ProfileContext.for_document({"keywords": ["genomics"]})
>>> "keywords" in missing_recommendations(ctx)
False
>>> "datePublished" in missing_recommendations(ctx)
True