|
18 | 18 |
|
19 | 19 | _MAX_CORE_META_BYTES = 5 * 1024 * 1024 # 5 MB — generous upper bound for core.package.json |
20 | 20 |
|
| 21 | +#: GitHub API endpoint used to resolve wildcard versions to concrete release tags |
| 22 | +_GITHUB_TAGS_API_URL = "https://api.github.com/repos/jupyterlab/jupyterlab/tags" |
| 23 | + |
| 24 | +#: Upper bound on tag-list pages fetched when resolving a wildcard (100 tags per page) |
| 25 | +_MAX_GITHUB_TAG_PAGES = 10 |
| 26 | + |
21 | 27 |
|
22 | 28 | def _home_dir() -> Path: |
23 | 29 | home = os.environ.get("HOME") |
@@ -56,26 +62,73 @@ def get_core_meta( |
56 | 62 | "@jupyterlab/builder.\n \033[0m", |
57 | 63 | ) |
58 | 64 | requested_version = "main" |
| 65 | + else: |
| 66 | + # Accept both "vX.Y.Z" and "X.Y.Z" for an explicitly requested version. |
| 67 | + requested_version = _normalize_version(requested_version) |
59 | 68 |
|
60 | 69 | cache_root = _home_dir() / ".cache" / "jupyterlab_builder" / "core" |
61 | 70 | cached_file = _get_cached_core_meta_file(cache_root, requested_version) |
62 | 71 | if cached_file is not None: |
63 | 72 | return str(cached_file) |
64 | 73 |
|
65 | | - # Try to retrieve core meta from npm first |
| 74 | + # Try to retrieve core meta from npm first, then fall back to GitHub. If the |
| 75 | + # requested version cannot be found in either source, raise an error. |
66 | 76 | try: |
67 | 77 | npm_version = _resolve_npm_version(requested_version) |
68 | 78 | npm_cache_file = cache_root / npm_version / "core.package.json" |
69 | 79 | if npm_cache_file.exists(): |
70 | 80 | return str(npm_cache_file) |
71 | 81 | _download_npm_core_meta(npm_version, npm_cache_file) |
72 | 82 | return str(npm_cache_file) |
73 | | - except urllib.error.URLError: |
74 | | - pass # Fallback to GitHub below |
75 | | - |
76 | | - github_cache_file = cache_root / requested_version / "core.package.json" |
77 | | - _download_github_core_meta(requested_version, github_cache_file) |
78 | | - return str(github_cache_file) |
| 83 | + except urllib.error.URLError as npm_error: |
| 84 | + try: |
| 85 | + # Wildcards (e.g. "4.5.x") have no single git ref, so resolve them |
| 86 | + # to a concrete tag from the GitHub tag list before downloading. |
| 87 | + github_version = ( |
| 88 | + _resolve_wildcard_github_version(requested_version) |
| 89 | + if _is_wildcard_version(requested_version) |
| 90 | + else requested_version |
| 91 | + ) |
| 92 | + github_cache_file = cache_root / github_version / "core.package.json" |
| 93 | + if github_cache_file.exists(): |
| 94 | + return str(github_cache_file) |
| 95 | + _download_github_core_meta(_github_ref(github_version), github_cache_file) |
| 96 | + except urllib.error.URLError as github_error: |
| 97 | + msg = ( |
| 98 | + f"Could not resolve @jupyterlab/core-meta for requested version " |
| 99 | + f"{requested_version!r}: not found on the npm registry " |
| 100 | + f"({npm_error}) or in the jupyterlab/jupyterlab GitHub repository " |
| 101 | + f"({github_error}). Verify that the version exists " |
| 102 | + f"(both '4.5.7' and 'v4.5.7' are accepted)." |
| 103 | + ) |
| 104 | + raise RuntimeError(msg) from github_error |
| 105 | + return str(github_cache_file) |
| 106 | + |
| 107 | + |
| 108 | +def _normalize_version(version: str) -> str: |
| 109 | + """Strip a leading 'v' from a numeric version so 'vX.Y.Z' and 'X.Y.Z' are equivalent.""" |
| 110 | + return version[1:] if re.match(r"v\d", version) else version |
| 111 | + |
| 112 | + |
| 113 | +def _github_ref(version: str) -> str: |
| 114 | + """Map a resolved version to its jupyterlab/jupyterlab git ref. |
| 115 | +
|
| 116 | + Numeric releases are published as git tags. Stable releases are tagged like |
| 117 | + 'v4.5.7', while npm-style prereleases such as '4.6.0-alpha.4' correspond to |
| 118 | + the PEP 440 tag form JupyterLab uses, 'v4.6.0a4'. Branch names such as |
| 119 | + 'main' (and other non-numeric refs) are used verbatim. |
| 120 | + """ |
| 121 | + if not re.match(r"\d", version): |
| 122 | + return version |
| 123 | + release, separator, prerelease = version.partition("-") |
| 124 | + if separator: |
| 125 | + # Translate npm prerelease identifiers (alpha.4 / beta.1 / rc.2) to the |
| 126 | + # PEP 440 form JupyterLab tags use (a4 / b1 / rc2). |
| 127 | + prerelease = re.sub(r"alpha\.?", "a", prerelease) |
| 128 | + prerelease = re.sub(r"beta\.?", "b", prerelease) |
| 129 | + prerelease = re.sub(r"rc\.", "rc", prerelease) |
| 130 | + version = release + prerelease |
| 131 | + return f"v{version}" |
79 | 132 |
|
80 | 133 |
|
81 | 134 | def _is_wildcard_version(version: str) -> bool: |
@@ -126,14 +179,53 @@ def _resolve_wildcard_npm_version(version: str) -> str: |
126 | 179 | msg = f"No published @jupyterlab/core-meta versions match range '{version}'" |
127 | 180 | raise urllib.error.URLError(msg) |
128 | 181 |
|
129 | | - def semver_key(v: str) -> tuple[tuple[int, ...], int, tuple[int, ...]]: |
130 | | - release, _, prerelease = v.partition("-") |
131 | | - numeric = tuple(int(p) for p in release.split(".") if p.isdigit()) |
132 | | - # Stable releases sort higher than pre-releases; within pre-releases, |
133 | | - pre_numeric = tuple(int(p) for p in prerelease.split(".") if p.isdigit()) |
134 | | - return (numeric, 0 if prerelease else 1, pre_numeric) |
| 182 | + return max(matching, key=_semver_key) |
| 183 | + |
| 184 | + |
| 185 | +def _semver_key(v: str) -> tuple[tuple[int, ...], int, tuple[int, ...]]: |
| 186 | + release, _, prerelease = v.partition("-") |
| 187 | + numeric = tuple(int(p) for p in release.split(".") if p.isdigit()) |
| 188 | + # Stable releases sort higher than pre-releases; within pre-releases, |
| 189 | + # order by the numeric identifiers (e.g. alpha.3 < alpha.4). |
| 190 | + pre_numeric = tuple(int(p) for p in prerelease.split(".") if p.isdigit()) |
| 191 | + return (numeric, 0 if prerelease else 1, pre_numeric) |
| 192 | + |
| 193 | + |
| 194 | +def _resolve_wildcard_github_version(version: str) -> str: |
| 195 | + """Resolve a wildcard range like '4.5.x' to the highest matching git tag. |
| 196 | +
|
| 197 | + JupyterLab publishes stable releases as git tags (e.g. 'v4.5.9') that may |
| 198 | + predate @jupyterlab/core-meta on npm, so wildcards that npm cannot satisfy |
| 199 | + are resolved here against the jupyterlab/jupyterlab tag list. |
| 200 | +
|
| 201 | + Raises urllib.error.URLError if no matching tag is found. |
| 202 | + """ |
| 203 | + escaped = re.escape(version) |
| 204 | + wildcard_pattern = re.sub(r"x", r"\\d+", escaped, flags=re.IGNORECASE) |
| 205 | + pattern = re.compile("^" + wildcard_pattern + r"$") |
| 206 | + |
| 207 | + matching: list[str] = [] |
| 208 | + for page in range(1, _MAX_GITHUB_TAG_PAGES + 1): |
| 209 | + data = _http_get(f"{_GITHUB_TAGS_API_URL}?per_page=100&page={page}") |
| 210 | + tags = json.loads(data) |
| 211 | + if not tags: |
| 212 | + break |
| 213 | + page_matches = [ |
| 214 | + normalized |
| 215 | + for tag in tags |
| 216 | + if (normalized := _normalize_version(tag.get("name", ""))) and pattern.match(normalized) |
| 217 | + ] |
| 218 | + # Tags are returned newest-first, so once matches stop appearing |
| 219 | + # (after they have started) the rest are older and can be skipped. |
| 220 | + if matching and not page_matches: |
| 221 | + break |
| 222 | + matching.extend(page_matches) |
| 223 | + |
| 224 | + if not matching: |
| 225 | + msg = f"No jupyterlab/jupyterlab git tags match range '{version}'" |
| 226 | + raise urllib.error.URLError(msg) |
135 | 227 |
|
136 | | - return max(matching, key=semver_key) |
| 228 | + return max(matching, key=_semver_key) |
137 | 229 |
|
138 | 230 |
|
139 | 231 | def _get_cached_core_meta_file(cache_root: Path, version: str) -> Path | None: |
|
0 commit comments