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 |
|---|---|
|
Reducing nodes to scalars; text and name keys |
|
Release identity, including commit hashes |
|
URL spellings that address one resource |
SPDX identifiers and expressions |
|
DOIs, ORCIDs, RORs, EDAM terms, dates |
|
|
Author matching, the hardest case |
|
Dependency notations across ecosystems |
|
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:
StrategyShape-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'#
- compare(anchor, source)[source]#
Compare two sets of values by key equality.
- Parameters:
- Returns:
ComparisonResult – The outcome. Order is never significant: values are compared as sets, and a duplicate key collapses to its first occurrence.
- Return type:
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:
objectWhat 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:
- class rs_metadata.normalize.Conflict(subject, anchor, source)[source]#
Bases:
objectTwo 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:
- Parameters:
- class rs_metadata.normalize.PersonRecord(orcid, family, given, name, display)[source]#
Bases:
objectA person or organization reduced to what can be matched across formats.
- Variables:
- Parameters:
- class rs_metadata.normalize.Requirement(name, constraint, display)[source]#
Bases:
objectOne dependency, reduced to a comparable form.
- Variables:
- Parameters:
- class rs_metadata.normalize.Strategy[source]#
Bases:
objectBase strategy: comparison by normalized key equality.
Subclasses normally override only
key(). Overridecompare()when the concept has structure that key equality cannot express.- name = 'text'#
- compare(anchor, source)[source]#
Compare two sets of values by key equality.
- Parameters:
- Returns:
ComparisonResult – The outcome. Order is never significant: values are compared as sets, and a duplicate key collapses to its first occurrence.
- Return type:
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
Noneentries dropped.- Return type:
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 –
Truewhen the two constrain versions identically.- Return type:
Notes
A plain string comparison reports
>=1.24and>=1.24.0as 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
Noneif 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
PropertyValuenode 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
Noneif 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
Noneif 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
strategykey.- 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:
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:
- Returns:
bool – Whether the two could name the same person.
- Return type:
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:
20240101is a plausible CalVer release and a plausible abbreviated hash. Requiring either the full 40 characters or at least onea–fresolves 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:
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-DDdate 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
Noneif 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 BandB OR Amean the same thing, but reordering around mixedAND/ORprecedence 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.jsoncarries 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
Noneif 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
Noneif 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
Noneif 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/nameandgithub:owner/nameforms. Off by default because a bareowner/nameis only unambiguous in an npm context.
- Returns:
str or None – A comparison key: host and path, without the scheme for
httpandhttps.Noneif empty.- Return type:
str | None
Notes
The scheme is dropped only for
httpandhttps, which address the same resource. Other schemes are meaningful and are kept, so amailto: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, orNoneif empty.- Return type:
str | None
Notes
Trailing zero components are dropped because they carry no information:
1.2.0and1.2name 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.0and 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.20is not1.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:
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
Dependsentry, or a mapping withnameandversion— the form npm and CodeMeta are flattened into by their adapters.- Returns:
Requirement or None – The parsed requirement, or
Noneif 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.jsonhas 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:
left (PersonRecord) – Records to compare.
right (PersonRecord) – Records to compare.
- Returns:
bool – Whether they denote the same agent.
- Return type:
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/Organizationnode, a CFF author entry, a packaging{"name": ..., "email": ...}table, or a bare string.- Returns:
PersonRecord – The comparable form.
- Return type:
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,orcidoridentifier:>>> 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:
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:
- Returns:
str or None – The chosen string, stripped, or
Nonewhen 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
PropertyValueyields its value, and a licenseCreativeWorkits 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
Noneif 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.0constantly. 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
CreativeWorknode 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 FamilyorFamily, Givenorder.- Returns:
tuple –
(given, family), either of which may beNone.- Return type:
Notes
A heuristic by nature: formats that offer only one name field give us no better information.
nameparserdoes 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, Givenorder 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')