Source code for rs_metadata.normalize.uris

"""URL normalization.

One repository is written half a dozen ways across a research project's
metadata: with and without ``https``, with and without ``www.``, with a
trailing slash, with a ``.git`` suffix, as an SSH remote, or as an npm
shorthand. All of them address the same resource.
"""

from __future__ import annotations

import re
from typing import Any
from urllib.parse import urlsplit

from .scalars import normalize_text

__all__ = ["normalize_uri"]

_SSH_URL_RE = re.compile(r"^(?:ssh://)?git@([^:/]+)[:/](.+)$")
_NPM_SHORTHAND_RE = re.compile(
    r"^(?:(github|gitlab|bitbucket):)?([\w.\-]+)/([\w.\-]+)$", re.IGNORECASE
)

#: npm's shorthand hosts. Expansion is opt-in because a bare ``owner/name``
#: is only unambiguous in an npm context; elsewhere it may be a real path.
_HOSTS = {"github": "github.com", "gitlab": "gitlab.com", "bitbucket": "bitbucket.org"}


[docs] def normalize_uri(raw: Any, *, expand_shorthand: bool = False) -> str | None: """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. 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' """ if raw is None: return None text = str(raw).strip() if not text: return None text = re.sub(r"^git\+", "", text) ssh = _SSH_URL_RE.match(text) if ssh: text = f"https://{ssh.group(1)}/{ssh.group(2)}" elif expand_shorthand: shorthand = _NPM_SHORTHAND_RE.match(text) if shorthand and "://" not in text: host = _HOSTS[(shorthand.group(1) or "github").lower()] text = f"https://{host}/{shorthand.group(2)}/{shorthand.group(3)}" parts = urlsplit(text) if not parts.scheme: return normalize_text(text) host = re.sub(r"^www\.", "", parts.netloc.casefold()) host = re.sub(r":(?:80|443)$", "", host) path = re.sub(r"\.git$", "", parts.path.rstrip("/")) scheme = parts.scheme.casefold() prefix = "" if scheme in {"http", "https"} else f"{scheme}:" query = f"?{parts.query}" if parts.query else "" return f"{prefix}{host}{path}{query}" or None