1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
|
"""Catalog and config schema helpers (no I/O)."""
from __future__ import annotations
import re
from collections.abc import Mapping
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.parse import urlparse
PACKAGE_FIELD_KEYS = frozenset({"url", "revision", "subdirectory"})
CATALOG_TOP_KEYS = frozenset({"packages"})
CONFIG_TOP_KEYS = frozenset({"source", "sources", "policy", "runtime"})
SOURCE_KEYS = frozenset({"type", "url", "revision", "path", "refresh"})
SOURCE_ITEM_KEYS = SOURCE_KEYS | {"name"}
SOURCE_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")
POLICY_KEYS = frozenset({"allowed_schemes", "require_pinned_revision"})
RUNTIME_KEYS = frozenset({"python"})
def first_unknown_key(data: Mapping[str, Any], allowed: frozenset[str]) -> str | None:
extra = set(data) - allowed
if not extra:
return None
return sorted(extra)[0]
def optional_str(value: object) -> str | None:
"""Treat missing or blank strings as omitted (ADR-0010 empty revision)."""
if value is None:
return None
if not isinstance(value, str):
msg = f"Expected a string, got {type(value).__name__}"
raise TypeError(msg)
stripped = value.strip()
return stripped or None
def subdirectory_escapes_repo(subdirectory: str) -> bool:
"""True if subdirectory is absolute or walks above the repo root."""
if not subdirectory or subdirectory.strip() != subdirectory:
# leading/trailing space is still the path; strip for check only
subdirectory = subdirectory.strip()
posix = PurePosixPath(subdirectory.replace("\\", "/"))
if posix.is_absolute() or posix.anchor:
return True
depth = 0
for part in posix.parts:
if part in ("", "."):
continue
if part == "..":
depth -= 1
if depth < 0:
return True
else:
depth += 1
return False
def url_scheme(url: str) -> str:
return urlparse(url).scheme.lower()
_AUTHORING_TO_STORED = {
"ssh": "git+ssh",
"https": "git+https",
"git+ssh": "git+ssh",
"git+https": "git+https",
"git+file": "git+file",
}
_SCP_STYLE = re.compile(r"^(?P<user>[^@\s]+)@(?P<host>[^:/\s]+):(?P<path>.+)$")
def _scp_to_git_ssh(match: re.Match[str]) -> str:
path = match.group("path").replace("\\", "/").lstrip("/")
return f"git+ssh://{match.group('user')}@{match.group('host')}/{path}"
def normalize_authoring_git_url(url: str) -> str:
"""Clone-style URL → pip direct URL stored in the catalog (ADR-0015)."""
cleaned = url.strip()
if "://" not in cleaned:
scp = _SCP_STYLE.match(cleaned)
if scp is not None:
return _scp_to_git_ssh(scp)
scheme = url_scheme(cleaned)
stored = _AUTHORING_TO_STORED.get(scheme)
if stored is None:
return cleaned
if scheme == "git+file":
return normalize_git_file_url(cleaned)
if scheme in {"git+ssh", "git+https"}:
return cleaned.replace("\\", "/")
sep = cleaned.find("://")
rest = cleaned[sep:] if sep != -1 else f"://{cleaned}"
return f"{stored}{rest}".replace("\\", "/")
def toml_posix_path(path: Path | str) -> str:
"""Single TOML path spelling: POSIX separators (``D:/foo`` or ``/home/foo``)."""
return str(path).replace("\\", "/")
_LOCALHOST_PREFIX = "localhost/"
def normalize_git_file_url(url: str) -> str:
"""Canonical ``git+file`` URL for pip (POSIX separators, required netloc).
Windows drive letters use ``git+file://D:/repo`` (pip rejects
``git+file:///D:/repo``). POSIX paths use ``git+file://localhost/abs/path``
because ``packaging`` rejects ``git+file`` URLs with an empty host.
"""
cleaned = url.replace("\\", "/")
if not cleaned.lower().startswith("git+file:"):
return cleaned
path = cleaned.split(":", 1)[1].lstrip("/")
if len(path) >= 2 and path[1] == ":":
return f"git+file://{path}"
if path.lower().startswith(_LOCALHOST_PREFIX):
path = path[len(_LOCALHOST_PREFIX) :]
return f"git+file://localhost/{path}"
def to_git_remote_url(url: str) -> str:
"""Translate a catalog/package VCS URL into a URL ``git clone`` accepts."""
cleaned = normalize_git_file_url(url)
if cleaned.lower().startswith("git+file:"):
remainder = cleaned.split(":", 1)[1]
if remainder.lower().startswith("//localhost/"):
return "file:///" + remainder[len("//localhost/") :]
return "file:" + remainder
if cleaned.lower().startswith("git+"):
return cleaned[4:]
return cleaned
|