Source code for rs_metadata.normalize.scalars

"""Reducing JSON-LD values to comparable scalars, and normalizing text.

Every other module in this package builds on these two operations: pull a
single string out of whatever shape a metadata format wrote the value in, then
reduce that string to a form where cosmetic differences have been discarded.
"""

from __future__ import annotations

import re
from typing import Any

__all__ = [
    "IDENTITY_FIRST",
    "LABEL_FIRST",
    "as_list",
    "normalize_loose_name",
    "normalize_package_name",
    "normalize_text",
    "scalarize",
]

#: Preference order when reducing a JSON-LD node to a comparable scalar for an
#: *identifier-like* property, where the IRI is what identifies the thing.
IDENTITY_FIRST = ("@id", "url", "value", "identifier", "name")

#: Preference order for a *descriptive* property, where the human-readable
#: label is what matters. Using IDENTITY_FIRST here would compare a
#: ComputerLanguage node by its homepage URL rather than by "Python".
LABEL_FIRST = ("name", "value", "@id", "url", "identifier")

_WHITESPACE = re.compile(r"\s+")


[docs] def scalarize(value: Any, prefer: tuple[str, ...] = IDENTITY_FIRST) -> str | None: """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 :data:`IDENTITY_FIRST` (the default) when the IRI identifies the thing, :data:`LABEL_FIRST` when the label does. Returns ------- str or None The chosen string, stripped, or ``None`` when the value carries nothing comparable. 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 """ if isinstance(value, dict): for key in prefer: candidate = value.get(key) if isinstance(candidate, str) and candidate.strip(): return candidate.strip() if isinstance(candidate, dict): nested = scalarize(candidate, prefer) if nested: return nested if isinstance(candidate, list) and candidate: nested = scalarize(candidate[0], prefer) if nested: return nested return None if isinstance(value, bool): # A boolean is never a metadata value here, and str(True) would # silently become the comparable string "True". return None if isinstance(value, (str, int, float)): text = str(value).strip() return text or None return None
[docs] def as_list(value: Any) -> list[Any]: """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. 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'] """ if value is None: return [] if isinstance(value, list): return [item for item in value if item is not None] return [value]
[docs] def normalize_text(raw: Any) -> str | None: """Case-fold, collapse whitespace and drop trailing sentence punctuation. Parameters ---------- raw : Any Any value; non-strings are rendered with :func:`str`. Returns ------- str or None The comparison key, or ``None`` if nothing remains. 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 """ if raw is None: return None text = _WHITESPACE.sub(" ", str(raw)).strip() text = text.strip(" .;,") return text.casefold() or None
[docs] def normalize_package_name(raw: Any) -> str | None: """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 :func:`normalize_loose_name`. Parameters ---------- raw : Any A package name. Returns ------- str or None The normalized name, or ``None`` if empty. 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 """ if raw is None: return None return re.sub(r"[-_.]+", "-", str(raw).strip()).casefold() or None
[docs] def normalize_loose_name(raw: Any) -> str | None: """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. 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 """ if raw is None: return None return re.sub(r"[-_.\s]+", "", str(raw).strip()).casefold() or None