Comparing values#

Whether two values from different files mean the same thing. Shared by every format, so a rule fixed once is fixed everywhere.

Normalization and comparison strategies.

A crosswalk says which concepts correspond. This package decides when two representations of a concept are the same thing, which is a separate and much harder question: https://spdx.org/licenses/Apache-2.0 and Apache-2.0 are the same license, https://doi.org/10.5281/zenodo.1 and 10.5281/zenodo.1 are the same DOI, and J. Doe and Jane Doe are probably the same person.

Getting this wrong in the lenient direction hides real problems; getting it wrong in the strict direction produces false alarms, and a validator that cries wolf gets removed from CI. Where a judgement is genuinely ambiguous the strategy reports not comparable rather than guessing.

One module per kind of value, because the rules have nothing in common:

Module

Concern

scalars

Reducing nodes to scalars; text and name keys

versions

Release identity, including commit hashes

uris

URL spellings that address one resource

licenses

SPDX identifiers and expressions

identifiers

DOIs, ORCIDs, RORs, EDAM terms, dates

people

Author matching, the hardest case

requirements

Dependency notations across ecosystems

strategies

The strategy registry the engine drives

Examples

>>> normalize_license("apache-2.0") == normalize_license(
...     "https://spdx.org/licenses/Apache-2.0"
... )
True
>>> get_strategy("version").compare(["1.2.0"], ["v1.2"]).identical
True
class rs_metadata.normalize.BestEffortStrategy[source]#

Bases: Strategy

Shape-aware, meaning-agnostic comparison.

Examples

Scalars compare on normalized text, so incidental spacing and case do not register as a disagreement:

>>> strategy = BestEffortStrategy()
>>> strategy.compare(["Some  Value"], ["some value"]).identical
True

A node is reduced the way JSON-LD intends, so the bare and node forms of one value agree:

>>> strategy.compare(
...     ["https://example.org/x"],
...     [{"@id": "https://example.org/x", "name": "X"}],
... ).identical
True

Dates agree on the calendar day, because one file records a day and another a timestamp:

>>> strategy.compare(["2026-06-01"], ["2026-06-01T09:31:07Z"]).identical
True

A value it cannot reduce is declined rather than guessed at, which the report shows as considered but not judged:

>>> strategy.compare([{"unexpected": ["shape"]}], [{"other": 1}]).comparable
False
name = 'best-effort'#
key(value)[source]#

Comparison key for one value, or None if not comparable.

Parameters:

value (Any)

Return type:

str | None

compare(anchor, source)[source]#

Compare two sets of values by key equality.

Parameters:
  • anchor (list) – Values from codemeta.json, the canonical record.

  • source (list) – Values from the companion file.

Returns:

ComparisonResult – The outcome. Order is never significant: values are compared as sets, and a duplicate key collapses to its first occurrence.

Return type:

ComparisonResult

Examples

>>> strategy = get_strategy("text")
>>> strategy.compare(["genomics"], ["Genomics"]).identical
True

Order does not matter:

>>> strategy.compare(["a", "b"], ["b", "a"]).identical
True

Differences are reported per side:

>>> result = strategy.compare(["a", "b"], ["b", "c"])
>>> result.shared, result.only_in_anchor, result.only_in_source
(1, ['a'], ['c'])
class rs_metadata.normalize.ComparisonResult(shared=0, only_in_anchor=<factory>, only_in_source=<factory>, conflicts=<factory>, uncomparable_anchor=<factory>, uncomparable_source=<factory>)[source]#

Bases: object

What a strategy found when comparing two sets of values.

Deliberately richer than a boolean: the consistency engine turns the same result into different diagnostics depending on whether the mapping asked for equality, a subset or an overlap, and the report echoes the specific values involved.

Variables:
  • shared (int) – Number of values matched on both sides.

  • only_in_source (only_in_anchor,) – Display forms of the unmatched values on each side.

  • conflicts (list of Conflict) – Same subject, disagreeing values.

  • uncomparable_source (uncomparable_anchor,) – Values that yielded no comparison key at all.

Parameters:
shared: int = 0#
only_in_anchor: list[str]#
only_in_source: list[str]#
conflicts: list[Conflict]#
uncomparable_anchor: list[str]#
uncomparable_source: list[str]#
property comparable: bool#

Whether anything at all could be compared.

Examples

>>> ComparisonResult(shared=1).comparable
True
>>> ComparisonResult(uncomparable_source=["dynamic"]).comparable
False
property identical: bool#

Whether the two sides agree completely.

Examples

>>> ComparisonResult(shared=2).identical
True
>>> ComparisonResult(shared=1, only_in_source=["extra"]).identical
False
class rs_metadata.normalize.Conflict(subject, anchor, source)[source]#

Bases: object

Two values that address the same thing but disagree about it.

Distinct from a value present on one side only: a conflict is a contradiction, whereas a one-sided value is merely incompleteness.

Variables:
  • subject (str) – What the two values are about, such as a package name.

  • source (anchor,) – The disagreeing values, as written.

Parameters:
subject: str#
anchor: str#
source: str#
class rs_metadata.normalize.PersonRecord(orcid, family, given, name, display)[source]#

Bases: object

A person or organization reduced to what can be matched across formats.

Variables:
  • orcid (str or None) – Bare ORCID, if the entry carries one.

  • given (family,) – Normalized name parts.

  • name (str or None) – Normalized full name, used for organizations and as a fallback.

  • display (str) – Human-readable rendering for the report.

Parameters:
  • orcid (str | None)

  • family (str | None)

  • given (str | None)

  • name (str | None)

  • display (str)

orcid: str | None#
family: str | None#
given: str | None#
name: str | None#
display: str#
class rs_metadata.normalize.Requirement(name, constraint, display)[source]#

Bases: object

One dependency, reduced to a comparable form.

Variables:
  • name (str) – Package name, normalized per PEP 503.

  • constraint (str) – Version constraint with whitespace removed, or "" when the requirement is unpinned.

  • display (str) – The requirement as written, for the report.

Parameters:
name: str#
constraint: str#
display: str#
class rs_metadata.normalize.Strategy[source]#

Bases: object

Base strategy: comparison by normalized key equality.

Subclasses normally override only key(). Override compare() when the concept has structure that key equality cannot express.

name = 'text'#
key(value)[source]#

Comparison key for one value, or None if not comparable.

Parameters:

value (Any)

Return type:

str | None

display(value)[source]#

Human-readable rendering of one value, for the report.

Parameters:

value (Any)

Return type:

str

compare(anchor, source)[source]#

Compare two sets of values by key equality.

Parameters:
  • anchor (list) – Values from codemeta.json, the canonical record.

  • source (list) – Values from the companion file.

Returns:

ComparisonResult – The outcome. Order is never significant: values are compared as sets, and a duplicate key collapses to its first occurrence.

Return type:

ComparisonResult

Examples

>>> strategy = get_strategy("text")
>>> strategy.compare(["genomics"], ["Genomics"]).identical
True

Order does not matter:

>>> strategy.compare(["a", "b"], ["b", "a"]).identical
True

Differences are reported per side:

>>> result = strategy.compare(["a", "b"], ["b", "c"])
>>> result.shared, result.only_in_anchor, result.only_in_source
(1, ['a'], ['c'])
rs_metadata.normalize.as_list(value)[source]#

Lift CodeMeta’s scalar-or-array cardinality into a list.

CodeMeta permits a single value to be written either bare or as a one-element array; the two are identical in meaning, so everything downstream works on lists only.

Parameters:

value (Any) – A scalar, a list, or None.

Returns:

list – The values, with None entries dropped.

Return type:

list[Any]

Examples

>>> as_list("Python")
['Python']
>>> as_list(["Python", "C++"])
['Python', 'C++']

Absence yields an empty list rather than [None]:

>>> as_list(None)
[]
>>> as_list([None, "Python"])
['Python']
rs_metadata.normalize.constraints_equivalent(left, right)[source]#

Whether two version constraints say the same thing.

Parameters:
  • left (str) – Normalized constraint strings, as carried on Requirement.

  • right (str) – Normalized constraint strings, as carried on Requirement.

Returns:

bool – True when the two constrain versions identically.

Return type:

bool

Notes

A plain string comparison reports >=1.24 and >=1.24.0 as a conflict, which is a false alarm: they admit exactly the same releases. Comparing parsed versions instead makes the two equal, because PEP 440 says they are.

Falls back to the string comparison whenever a version does not parse. Ecosystems outside Python put things here that PEP 440 does not describe — R writes 1.2-3, and some records pin a commit hash — and guessing at those would trade one false alarm for another.

Examples

Trailing zeros do not change what a constraint admits:

>>> constraints_equivalent(">=1.24", ">=1.24.0")
True
>>> constraints_equivalent(">=1.24", ">=1.24.0.0")
True

Genuinely different constraints stay different:

>>> constraints_equivalent(">=1.24", ">=1.25")
False
>>> constraints_equivalent(">=1.24", "<1.24")
False

Multiple comparisons are compared as a set, so order does not matter:

>>> constraints_equivalent(">=1.0,<2.0", "<2.0,>=1.0")
True

Anything PEP 440 cannot read is compared as text, unchanged:

>>> constraints_equivalent(">=1.2-3", ">=1.2-3")
True
>>> constraints_equivalent("==a1b2c3d", "==e4f5g6h")
False
rs_metadata.normalize.extract_doi(raw)[source]#

Return the bare DOI behind any of its usual spellings.

Parameters:

raw (Any) – A DOI, a resolver URL, a doi: URI, or a node carrying one.

Returns:

str or None – The bare DOI, case-folded, or None if the value is not a DOI.

Return type:

str | None

Notes

Matching is anchored, not searched. A DOI-shaped substring inside an unrelated URL is not a DOI, and treating it as one would silently invent an identifier the repository never claimed.

DOIs are case-insensitive by specification, so the result is case-folded.

Examples

Every spelling yields the same identifier:

>>> spellings = [
...     "10.5281/zenodo.1234567",
...     "https://doi.org/10.5281/zenodo.1234567",
...     "http://dx.doi.org/10.5281/zenodo.1234567",
...     "doi:10.5281/zenodo.1234567",
...     "10.5281/ZENODO.1234567",
... ]
>>> {extract_doi(spelling) for spelling in spellings}
{'10.5281/zenodo.1234567'}

A PropertyValue node is reduced first:

>>> extract_doi({"@type": "PropertyValue", "propertyID": "doi",
...              "value": "10.5281/zenodo.1"})
'10.5281/zenodo.1'

A DOI-shaped path inside another URL is not a DOI:

>>> extract_doi("https://github.com/org/10.1234/repo") is None
True

Neither is a plain URL:

>>> extract_doi("https://bio.tools/mytool") is None
True
rs_metadata.normalize.extract_orcid(raw)[source]#

Return the bare 16-digit ORCID from any of its usual spellings.

Parameters:

raw (Any) – An ORCID URL or a bare ORCID, or a node carrying one.

Returns:

str or None – The dashed 16-character ORCID, or None if the value is not one.

Return type:

str | None

Examples

>>> spellings = [
...     "https://orcid.org/0000-0002-1825-0097",
...     "http://orcid.org/0000-0002-1825-0097",
...     "0000-0002-1825-0097",
... ]
>>> {extract_orcid(spelling) for spelling in spellings}
{'0000-0002-1825-0097'}

Extraction says nothing about validity — that is orcid_checksum_valid()’s job:

>>> extract_orcid("https://orcid.org/0000-0000-0000-0000")
'0000-0000-0000-0000'

A value of the wrong shape is not an ORCID:

>>> extract_orcid("https://ror.org/05xvt9f17") is None
True
rs_metadata.normalize.extract_ror(raw)[source]#

Return the bare ROR identifier from any of its usual spellings.

Parameters:

raw (Any) – A ROR URL or a bare ROR, or a node carrying one.

Returns:

str or None – The nine-character ROR, lower-cased, or None if not one.

Return type:

str | None

Examples

>>> extract_ror("https://ror.org/05xvt9f17")
'05xvt9f17'
>>> extract_ror("05xvt9f17")
'05xvt9f17'
>>> extract_ror("https://orcid.org/0000-0002-1825-0097") is None
True
rs_metadata.normalize.get_strategy(name)[source]#

Look up a comparison strategy by the name a mapping file uses.

Parameters:

name (str) – Strategy name, as written in a mapping file’s strategy key.

Returns:

Strategy – The registered strategy.

Raises:

KeyError – If no strategy is registered under that name. The message lists the available names, since this can only be reached through a typo in a mapping file.

Return type:

Strategy

Examples

>>> get_strategy("version").name
'version'

An unknown name fails loudly rather than silently comparing as text:

>>> get_strategy("nonexistent")
Traceback (most recent call last):
    ...
KeyError: "Unknown comparison strategy 'nonexistent'. Available...version."
rs_metadata.normalize.given_names_compatible(left, right)[source]#

Treat initials as compatible with the given names they abbreviate.

Parameters:
  • left (str or None) – Normalized given names. A missing side is compatible with anything, since absence is not disagreement.

  • right (str or None) – Normalized given names. A missing side is compatible with anything, since absence is not disagreement.

Returns:

bool – Whether the two could name the same person.

Return type:

bool

Examples

An initial matches the name it abbreviates:

>>> given_names_compatible("josiah", "j.")
True
>>> given_names_compatible("j", "josiah")
True

Two different names do not, however similar:

>>> given_names_compatible("jane", "john")
False

A middle name present on one side only is not a disagreement:

>>> given_names_compatible("jane m", "jane")
True

Neither is a missing given name:

>>> given_names_compatible(None, "jane")
True
rs_metadata.normalize.looks_like_commit(text)[source]#

Distinguish a Git commit hash from a calendar version.

A bare run of hex digits is ambiguous: 20240101 is a plausible CalVer release and a plausible abbreviated hash. Requiring either the full 40 characters or at least one a–f resolves it in favor of the interpretation that is almost always right.

Parameters:

text (str) – A version string.

Returns:

bool – Whether to treat it as a commit hash.

Return type:

bool

Examples

>>> looks_like_commit("a3f8c2d1b4e7f0291a3f8c2d1b4e7f0291a3f8c2")
True
>>> looks_like_commit("a3f8c2d")
True

A CalVer release is not mistaken for a hash, even though it is valid hex:

>>> looks_like_commit("20240101")
False

Nor is anything too short to be one, or not hex at all:

>>> looks_like_commit("1.2.0")
False
>>> looks_like_commit("abc")
False
rs_metadata.normalize.normalize_date(raw)[source]#

Reduce a date or timestamp to its ISO calendar date.

Parameters:

raw (Any) – An ISO 8601 date or timestamp, or a node carrying one.

Returns:

str or None – The YYYY-MM-DD date part, or a text key if the value is not a date at all.

Return type:

str | None

Examples

A timestamp and the bare date name the same day:

>>> normalize_date("2026-06-01T09:30:00Z")
'2026-06-01'
>>> normalize_date("2026-06-01")
'2026-06-01'

Anything that is not a date falls back to text comparison rather than being discarded:

>>> normalize_date("Spring 2026")
'spring 2026'
rs_metadata.normalize.normalize_license(raw)[source]#

Normalize a license value, including simple SPDX expressions.

Parameters:

raw (Any) – An SPDX identifier or URL, an SPDX expression, or free text.

Returns:

str or None – A comparison key, or None if empty.

Return type:

str | None

Notes

Falls back to text comparison for licenses that are not SPDX at all, so a custom or institutional license still compares consistently across files rather than being dropped.

Expression operands are canonicalised and operators uppercased, but operands are not reordered: A OR B and B OR A mean the same thing, but reordering around mixed AND/OR precedence would not be safe. Multi-license records normally use a CodeMeta array, where the set comparison already makes order irrelevant.

Examples

Every spelling of one license collapses together:

>>> spellings = [
...     "Apache-2.0",
...     "apache-2.0",
...     "https://spdx.org/licenses/Apache-2.0",
...     "http://spdx.org/licenses/Apache-2.0",
... ]
>>> {normalize_license(spelling) for spelling in spellings}
{'Apache-2.0'}

Different licenses stay different:

>>> normalize_license("MIT") == normalize_license("Apache-2.0")
False

Expressions are canonicalised:

>>> normalize_license("mit OR apache-2.0")
'MIT OR Apache-2.0'
>>> normalize_license("GPL-2.0-only WITH Classpath-exception-2.0")
'GPL-2.0-only WITH Classpath-exception-2.0'

Unrecognized licenses fall back to text, so they still compare:

>>> normalize_license("Our License") == normalize_license("  our license  ")
True
rs_metadata.normalize.normalize_loose_name(raw)[source]#

Compare software names across the display/package-name divide.

codemeta.json carries the name as it should appear in a citation (MyTool) while packaging files carry a distribution name (my-tool). Treating those as a mismatch would fire on almost every well-maintained repository, so separators are dropped entirely here.

Parameters:

raw (Any) – A software name in any convention.

Returns:

str or None – The comparison key, or None if empty.

Return type:

str | None

Examples

>>> normalize_loose_name("MyTool") == normalize_loose_name("my-tool")
True
>>> normalize_loose_name("Read_Aligner") == normalize_loose_name("read.aligner")
True

Different software still compares different:

>>> normalize_loose_name("my-tool") == normalize_loose_name("my-tool-2")
False
rs_metadata.normalize.normalize_package_name(raw)[source]#

Normalize a distribution name the way PEP 503 does.

Used where the identity of a package matters, such as matching dependencies across ecosystems. For comparing a software name across the display-name/package-name divide, use normalize_loose_name().

Parameters:

raw (Any) – A package name.

Returns:

str or None – The normalized name, or None if empty.

Return type:

str | None

Examples

Runs of -, _ and . collapse to a single dash, and case is discarded — every Python packaging tool treats these as one package:

>>> normalize_package_name("My_Tool") == normalize_package_name("my-tool")
True
>>> normalize_package_name("ruamel.yaml")
'ruamel-yaml'

Separators are collapsed but not removed, so distinct packages stay distinct:

>>> normalize_package_name("my-tool") == normalize_package_name("mytool")
False
rs_metadata.normalize.normalize_text(raw)[source]#

Case-fold, collapse whitespace and drop trailing sentence punctuation.

Parameters:

raw (Any) – Any value; non-strings are rendered with str().

Returns:

str or None – The comparison key, or None if nothing remains.

Return type:

str | None

Examples

Case, internal whitespace and a trailing full stop are all cosmetic:

>>> normalize_text("  A  Tool.  ") == normalize_text("a tool")
True
>>> normalize_text("Read Alignment")
'read alignment'

Genuinely different text stays different:

>>> normalize_text("genomics") == normalize_text("proteomics")
False

Empty and whitespace-only values are absence, not the empty string:

>>> normalize_text("   ") is None
True
>>> normalize_text(None) is None
True
rs_metadata.normalize.normalize_uri(raw, *, expand_shorthand=False)[source]#

Normalize a URL so cosmetic differences do not read as disagreement.

Parameters:
  • raw (Any) – A URL, an SSH remote, or (with expand_shorthand) an npm shorthand.

  • expand_shorthand (bool, optional) – Expand npm’s owner/name and github:owner/name forms. Off by default because a bare owner/name is only unambiguous in an npm context.

Returns:

str or None – A comparison key: host and path, without the scheme for http and https. None if empty.

Return type:

str | None

Notes

The scheme is dropped only for http and https, which address the same resource. Other schemes are meaningful and are kept, so a mailto: can never collide with a web address.

Examples

Every spelling of one repository collapses to the same key:

>>> spellings = [
...     "https://github.com/org/tool",
...     "http://github.com/org/tool",
...     "https://github.com/org/tool/",
...     "https://github.com/org/tool.git",
...     "https://www.github.com/org/tool",
...     "git+https://github.com/org/tool.git",
...     "git@github.com:org/tool.git",
...     "https://github.com:443/org/tool",
... ]
>>> {normalize_uri(spelling) for spelling in spellings}
{'github.com/org/tool'}

Different repositories stay different:

>>> normalize_uri("https://github.com/a/tool") == normalize_uri(
...     "https://github.com/b/tool"
... )
False

npm shorthand is expanded only when asked:

>>> normalize_uri("org/tool", expand_shorthand=True)
'github.com/org/tool'
>>> normalize_uri("github:org/tool", expand_shorthand=True)
'github.com/org/tool'
>>> normalize_uri("org/tool")
'org/tool'

Non-web schemes keep their scheme:

>>> normalize_uri("mailto:rse@example.org")
'mailto:rse@example.org'
rs_metadata.normalize.normalize_version(raw)[source]#

Normalize a version so equivalent spellings compare equal.

Parameters:

raw (Any) – A version string or number.

Returns:

str or None – A comparison key, prefixed sha: for commit hashes, or None if empty.

Return type:

str | None

Notes

Trailing zero components are dropped because they carry no information: 1.2.0 and 1.2 name one release. Pre-release and build suffixes are kept, lower-cased with punctuation removed, because they do distinguish releases.

Examples

A leading v, a trailing .0 and R’s dash are all cosmetic:

>>> normalize_version("1.2.0") == normalize_version("v1.2.0")
True
>>> normalize_version("1.2.0") == normalize_version("1.2")
True
>>> normalize_version("1.2-3") == normalize_version("1.2.3")
True

Genuinely different releases stay different — note that 1.20 is not 1.2, which is the trap behind an unquoted YAML version:

>>> normalize_version("1.2") == normalize_version("1.20")
False
>>> normalize_version("1.0.0") == normalize_version("1.0.0-rc1")
False

A short commit hash matches the full hash it abbreviates:

>>> full = "a3f8c2d1b4e7f0291a3f8c2d1b4e7f0291a3f8c2"
>>> normalize_version(full) == normalize_version(full[:7])
True
>>> normalize_version(full)
'sha:a3f8c2d'

Empty input is absence:

>>> normalize_version("") is None
True
rs_metadata.normalize.orcid_checksum_valid(orcid)[source]#

Verify an ORCID’s ISO 7064 MOD 11-2 check digit.

Parameters:

orcid (str) – A bare ORCID, with or without dashes.

Returns:

bool – Whether the final character is the correct check digit.

Return type:

bool

Notes

The check digit is part of the ORCID specification, so a failure means the identifier is mistyped or invented rather than merely unknown. That is what makes it safe to verify offline and report as an error.

Examples

ORCID publishes 0000-0002-1825-0097 (Josiah Carberry) for documentation, and it verifies:

>>> orcid_checksum_valid("0000-0002-1825-0097")
True

Changing one digit breaks it, which is the point:

>>> orcid_checksum_valid("0000-0002-1825-0098")
False

The all-zero placeholder used in documentation examples is not a valid ORCID at all:

>>> orcid_checksum_valid("0000-0000-0000-0000")
False

Malformed input is rejected rather than raising:

>>> orcid_checksum_valid("0000-0002-1825-009")
False
>>> orcid_checksum_valid("000A-0002-1825-0097")
False
rs_metadata.normalize.parse_requirement(value)[source]#

Parse a dependency from any supported dependency notation.

Parameters:

value (Any) – A PEP 508 string, an R Depends entry, or a mapping with name and version — the form npm and CodeMeta are flattened into by their adapters.

Returns:

Requirement or None – The parsed requirement, or None if the value carries no package name.

Return type:

Requirement | None

Notes

PEP 508 environment markers are discarded: a dependency that applies only on old Python is still the same dependency, and codemeta.json has nowhere to record the marker.

Examples

The four notations reduce to the same requirement:

>>> for value in ["numpy>=1.24", "numpy >= 1.24", "numpy (>= 1.24)",
...               {"name": "numpy", "version": ">=1.24"}]:
...     requirement = parse_requirement(value)
...     print(requirement.name, requirement.constraint)
numpy >=1.24
numpy >=1.24
numpy >=1.24
numpy >=1.24

Extras and environment markers are stripped:

>>> parse_requirement("numpy[extra]>=1.24").constraint
'>=1.24'
>>> parse_requirement('numpy>=1.24; python_version < "3.11"').constraint
'>=1.24'

An unpinned requirement has an empty constraint, which the comparison engine treats as “no opinion” rather than as a disagreement:

>>> parse_requirement("matplotlib").constraint
''

Names are normalized per PEP 503:

>>> parse_requirement("Ruamel.YAML>=0.18").name
'ruamel-yaml'

A value with no package name is not a requirement:

>>> parse_requirement({"version": ">=1.0"}) is None
True
>>> parse_requirement("") is None
True
rs_metadata.normalize.people_match(left, right)[source]#

Decide whether two entries denote the same person or organization.

Parameters:
Returns:

bool – Whether they denote the same agent.

Return type:

bool

Notes

Resolution order: ORCIDs when both sides have one, then family name with compatible given names, then normalized full name. ORCIDs are decisive in both directions, so a name coincidence cannot merge two real people.

Examples

A shared ORCID outranks differing names — someone’s surname may change between releases:

>>> a = person_record({"@id": "https://orcid.org/0000-0002-1825-0097",
...                    "givenName": "Josiah", "familyName": "Carberry"})
>>> b = person_record({"orcid": "0000-0002-1825-0097",
...                    "given-names": "J.", "family-names": "Karberri"})
>>> people_match(a, b)
True

Differing ORCIDs outrank identical names:

>>> a = person_record({"@id": "https://orcid.org/0000-0002-1825-0097",
...                    "givenName": "Jane", "familyName": "Doe"})
>>> b = person_record({"orcid": "0000-0001-5109-3700",
...                    "given-names": "Jane", "family-names": "Doe"})
>>> people_match(a, b)
False

Without ORCIDs, names decide, and an initial is accepted:

>>> people_match(person_record({"givenName": "Josiah", "familyName": "Carberry"}),
...              person_record({"given-names": "J.", "family-names": "Carberry"}))
True
>>> people_match(person_record({"givenName": "Jane", "familyName": "Doe"}),
...              person_record({"given-names": "John", "family-names": "Doe"}))
False

Organizations match on name:

>>> people_match(person_record({"@type": "Organization", "name": "LUMC"}),
...              person_record({"name": "lumc"}))
True
rs_metadata.normalize.person_record(value)[source]#

Reduce an author entry from any supported format to a comparable record.

Parameters:

value (Any) – A CodeMeta Person/Organization node, a CFF author entry, a packaging {"name": ..., "email": ...} table, or a bare string.

Returns:

PersonRecord – The comparable form.

Return type:

PersonRecord

Examples

Both naming conventions reduce identically, which is what lets a CodeMeta author be compared with a CFF one:

>>> codemeta = person_record({"givenName": "Jane", "familyName": "Doe"})
>>> cff = person_record({"given-names": "Jane", "family-names": "Doe"})
>>> (codemeta.given, codemeta.family) == (cff.given, cff.family)
True

An ORCID is picked up from @id, orcid or identifier:

>>> person_record({"@id": "https://orcid.org/0000-0002-1825-0097",
...                "familyName": "Carberry"}).orcid
'0000-0002-1825-0097'
>>> person_record({"orcid": "0000-0002-1825-0097"}).orcid
'0000-0002-1825-0097'

A single display string is split:

>>> record = person_record("Jan van der Berg")
>>> record.given, record.family
('jan', 'van der berg')

An organization has a name but no name parts:

>>> record = person_record({"@type": "Organization", "name": "LUMC"})
>>> record.name, record.family
('lumc', None)
rs_metadata.normalize.ror_checksum_valid(ror)[source]#

Verify a ROR identifier’s ISO 7064 MOD 97-10 check digits.

Parameters:

ror (str) – A bare nine-character ROR identifier.

Returns:

bool – Whether the final two digits are the correct checksum.

Return type:

bool

Notes

The first seven characters are Crockford base32; decoded as an integer, multiplied by 100, the checksum is 98 - (value mod 97).

Examples

LUMC’s own ROR verifies:

>>> ror_checksum_valid("05xvt9f17")
True

Changing the check digits breaks it:

>>> ror_checksum_valid("05xvt9f18")
False

So does the wrong length, or characters outside the alphabet:

>>> ror_checksum_valid("05xvt9f1")
False
>>> ror_checksum_valid("05xvt9fxx")
False
rs_metadata.normalize.scalarize(value, prefer=('@id', 'url', 'value', 'identifier', 'name'))[source]#

Reduce a value or JSON-LD node to a single comparable string.

Parameters:
  • value (Any) – A scalar, or a JSON-LD node such as a Person, CreativeWork or PropertyValue.

  • prefer (tuple of str, optional) – Key preference order. Use IDENTITY_FIRST (the default) when the IRI identifies the thing, LABEL_FIRST when the label does.

Returns:

str or None – The chosen string, stripped, or None when the value carries nothing comparable.

Return type:

str | None

Examples

Plain scalars pass through, stripped:

>>> scalarize("  Apache-2.0  ")
'Apache-2.0'
>>> scalarize(2)
'2'

A node is reduced by the preference order, which is why the same node can compare as an identifier or as a label:

>>> language = {"@type": "ComputerLanguage", "name": "Python",
...             "url": "https://python.org"}
>>> scalarize(language)
'https://python.org'
>>> scalarize(language, LABEL_FIRST)
'Python'

A PropertyValue yields its value, and a license CreativeWork its URL:

>>> scalarize({"@type": "PropertyValue", "propertyID": "doi",
...            "value": "10.5281/zenodo.1"})
'10.5281/zenodo.1'
>>> scalarize({"@type": "CreativeWork", "name": "Apache License 2.0",
...            "url": "https://spdx.org/licenses/Apache-2.0"})
'https://spdx.org/licenses/Apache-2.0'

Values carrying nothing comparable become None, which callers treat as “not comparable” rather than as an empty value:

>>> scalarize({}) is None
True
>>> scalarize("   ") is None
True
>>> scalarize(True) is None
True
rs_metadata.normalize.spdx_identifier(raw)[source]#

Resolve a value to a canonical SPDX identifier.

Parameters:

raw (Any) – An SPDX identifier, an spdx.org/licenses/ URL, or a node carrying one.

Returns:

str or None – The identifier in SPDX’s own capitalisation, or None if the value is not a recognized SPDX identifier.

Return type:

str | None

Notes

SPDX identifiers are case-sensitive by specification, but people write apache-2.0 constantly. Recognizing the value and reporting the canonical spelling is more useful than rejecting it.

LicenseRef- identifiers name a license outside the SPDX list; they are returned as written, since there is nothing to canonicalise against.

Examples

URL and bare forms resolve alike, and casing is corrected:

>>> spdx_identifier("https://spdx.org/licenses/Apache-2.0")
'Apache-2.0'
>>> spdx_identifier("apache-2.0")
'Apache-2.0'
>>> spdx_identifier("https://spdx.org/licenses/Apache-2.0.html")
'Apache-2.0'

A CreativeWork node is reduced to its URL first:

>>> spdx_identifier({"@type": "CreativeWork", "name": "Apache License 2.0",
...                  "url": "https://spdx.org/licenses/Apache-2.0"})
'Apache-2.0'

A URL that claims SPDX but names no real license is not resolved — the profile reports that as an error:

>>> spdx_identifier("https://spdx.org/licenses/Apache-2.0-only") is None
True

Neither is free text:

>>> spdx_identifier("Our institutional license") is None
True
rs_metadata.normalize.split_full_name(full)[source]#

Split a single name string into given and family parts.

Parameters:

full (str) – A name as one string, in either Given Family or Family, Given order.

Returns:

tuple – (given, family), either of which may be None.

Return type:

tuple[str | None, str | None]

Notes

A heuristic by nature: formats that offer only one name field give us no better information. nameparser does the splitting, which is why particles land with the family name — an earlier version kept its own list of prefixes, which handled neither the locale-dependent capitalization nor the compound forms.

Examples

>>> split_full_name("Jane Doe")
('Jane', 'Doe')

Particles stay with the family name, across locales:

>>> split_full_name("Jan van der Berg")
('Jan', 'van der Berg')
>>> split_full_name("Ludwig von Mises")
('Ludwig', 'von Mises')
>>> split_full_name("Maria de la Cruz")
('Maria', 'de la Cruz')

Family, Given order is understood:

>>> split_full_name("Doe, Jane")
('Jane', 'Doe')

A mononym is treated as a family name, since that is what citation styles do with it:

>>> split_full_name("Cher")
(None, 'Cher')