Skip to content

Commit d517f4f

Browse files
authored
Raise an error when a requested core version can't be found (and accept v-prefixed versions) (#121)
* update-logic-to-fetch-core-meta * update-docs * add-tests * handle-npm-style-versions * fix-ci * debug-ci * fix-github-tags-from-wildcard-versions
1 parent ecb9a24 commit d517f4f

5 files changed

Lines changed: 285 additions & 19 deletions

File tree

jupyter_builder/core_path.py

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818

1919
_MAX_CORE_META_BYTES = 5 * 1024 * 1024 # 5 MB — generous upper bound for core.package.json
2020

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+
2127

2228
def _home_dir() -> Path:
2329
home = os.environ.get("HOME")
@@ -56,26 +62,73 @@ def get_core_meta(
5662
"@jupyterlab/builder.\n \033[0m",
5763
)
5864
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)
5968

6069
cache_root = _home_dir() / ".cache" / "jupyterlab_builder" / "core"
6170
cached_file = _get_cached_core_meta_file(cache_root, requested_version)
6271
if cached_file is not None:
6372
return str(cached_file)
6473

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.
6676
try:
6777
npm_version = _resolve_npm_version(requested_version)
6878
npm_cache_file = cache_root / npm_version / "core.package.json"
6979
if npm_cache_file.exists():
7080
return str(npm_cache_file)
7181
_download_npm_core_meta(npm_version, npm_cache_file)
7282
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}"
79132

80133

81134
def _is_wildcard_version(version: str) -> bool:
@@ -126,14 +179,53 @@ def _resolve_wildcard_npm_version(version: str) -> str:
126179
msg = f"No published @jupyterlab/core-meta versions match range '{version}'"
127180
raise urllib.error.URLError(msg)
128181

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)
135227

136-
return max(matching, key=semver_key)
228+
return max(matching, key=_semver_key)
137229

138230

139231
def _get_cached_core_meta_file(cache_root: Path, version: str) -> Path | None:

jupyter_builder/extension_commands/build.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ class BuildLabExtensionApp(BaseExtensionApp):
4242
"",
4343
config=True,
4444
help=(
45-
"Version of JupyterLab core to use when building (ignored if core-package-file is set)"
45+
"Version of JupyterLab core to use when building, e.g. 'X.Y.Z' or 'vX.Y.Z' "
46+
"(ignored if core-package-file is set). Building fails if the version cannot "
47+
"be resolved."
4648
),
4749
)
4850

jupyter_builder/extension_commands/watch.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ class WatchLabExtensionApp(BaseExtensionApp):
3434
"",
3535
config=True,
3636
help=(
37-
"Version of JupyterLab core to use when watching (ignored if core-package-file is set)"
37+
"Version of JupyterLab core to use when watching, e.g. 'X.Y.Z' or 'vX.Y.Z' "
38+
"(ignored if core-package-file is set). Watching fails if the version cannot "
39+
"be resolved."
3840
),
3941
)
4042

tests/test_core_path.py

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,106 @@ def test_get_core_meta_falls_back_to_github_when_npm_fails(tmp_path, monkeypatch
126126
ext_path.mkdir()
127127
monkeypatch.setenv("HOME", str(tmp_path))
128128

129+
# JupyterLab releases are published as git tags like "v4.5.7", so a numeric
130+
# version requested from GitHub must be looked up with the "v" prefix.
129131
github_url = (
130132
"https://raw.githubusercontent.com/"
131-
"jupyterlab/jupyterlab/4.6.0-alpha.4/"
133+
"jupyterlab/jupyterlab/v4.5.7/"
134+
"jupyterlab/staging/package.json"
135+
)
136+
calls = []
137+
138+
def fake_urlopen(req_or_url, **_kwargs):
139+
url = getattr(req_or_url, "full_url", req_or_url)
140+
calls.append(url)
141+
if url == f"{core_path.JPBLD_NPM_URL}/@jupyterlab/core-meta/4.5.7":
142+
msg = "Not Found"
143+
raise urllib.error.URLError(msg)
144+
if url == github_url:
145+
return io.BytesIO(b'{"dependencies": {}}')
146+
msg = f"Unexpected URL {url}"
147+
raise AssertionError(msg)
148+
149+
monkeypatch.setattr(core_path.urllib.request, "urlopen", fake_urlopen)
150+
151+
location = core_path.get_core_meta(version="4.5.7", ext_path=ext_path)
152+
153+
assert calls == [
154+
f"{core_path.JPBLD_NPM_URL}/@jupyterlab/core-meta/4.5.7",
155+
github_url,
156+
]
157+
assert location == str(
158+
tmp_path / ".cache" / "jupyterlab_builder" / "core" / "4.5.7" / "core.package.json",
159+
)
160+
161+
162+
def test_get_core_meta_accepts_v_prefixed_version(tmp_path, monkeypatch):
163+
"""A 'v'-prefixed version is normalized so it resolves identically to the bare form."""
164+
ext_path = tmp_path / "ext"
165+
ext_path.mkdir()
166+
monkeypatch.setenv("HOME", str(tmp_path))
167+
168+
github_url = (
169+
"https://raw.githubusercontent.com/"
170+
"jupyterlab/jupyterlab/v4.5.7/"
171+
"jupyterlab/staging/package.json"
172+
)
173+
calls = []
174+
175+
def fake_urlopen(req_or_url, **_kwargs):
176+
url = getattr(req_or_url, "full_url", req_or_url)
177+
calls.append(url)
178+
if url == f"{core_path.JPBLD_NPM_URL}/@jupyterlab/core-meta/4.5.7":
179+
msg = "Not Found"
180+
raise urllib.error.URLError(msg)
181+
if url == github_url:
182+
return io.BytesIO(b'{"dependencies": {}}')
183+
msg = f"Unexpected URL {url}"
184+
raise AssertionError(msg)
185+
186+
monkeypatch.setattr(core_path.urllib.request, "urlopen", fake_urlopen)
187+
188+
location = core_path.get_core_meta(version="v4.5.7", ext_path=ext_path)
189+
190+
# Both the npm lookup and the cache directory use the normalized "4.5.7".
191+
assert calls == [
192+
f"{core_path.JPBLD_NPM_URL}/@jupyterlab/core-meta/4.5.7",
193+
github_url,
194+
]
195+
assert location == str(
196+
tmp_path / ".cache" / "jupyterlab_builder" / "core" / "4.5.7" / "core.package.json",
197+
)
198+
199+
200+
@pytest.mark.parametrize(
201+
("version", "expected_ref"),
202+
[
203+
("4.5.7", "v4.5.7"),
204+
("4.6.0-alpha.4", "v4.6.0a4"),
205+
("4.6.0-beta.1", "v4.6.0b1"),
206+
("4.6.0-rc.2", "v4.6.0rc2"),
207+
("main", "main"),
208+
("some-branch", "some-branch"),
209+
],
210+
)
211+
def test_github_ref_maps_versions_to_git_tags(version, expected_ref):
212+
assert core_path._github_ref(version) == expected_ref
213+
214+
215+
@pytest.mark.parametrize("version", ["4.5.7", "v4.5.7"])
216+
def test_normalize_version_accepts_both_forms(version):
217+
assert core_path._normalize_version(version) == "4.5.7"
218+
219+
220+
def test_get_core_meta_falls_back_to_github_for_prerelease(tmp_path, monkeypatch):
221+
"""An npm-style prerelease that npm lacks is fetched from the matching git tag."""
222+
ext_path = tmp_path / "ext"
223+
ext_path.mkdir()
224+
monkeypatch.setenv("HOME", str(tmp_path))
225+
226+
github_url = (
227+
"https://raw.githubusercontent.com/"
228+
"jupyterlab/jupyterlab/v4.6.0a4/"
132229
"jupyterlab/staging/package.json"
133230
)
134231
calls = []
@@ -157,6 +254,78 @@ def fake_urlopen(req_or_url, **_kwargs):
157254
)
158255

159256

257+
def test_get_core_meta_wildcard_resolves_from_github_when_npm_has_no_match(tmp_path, monkeypatch):
258+
"""A wildcard that npm cannot satisfy is resolved against GitHub release tags."""
259+
ext_path = tmp_path / "ext"
260+
ext_path.mkdir()
261+
monkeypatch.setenv("HOME", str(tmp_path))
262+
263+
tags_page1 = f"{core_path._GITHUB_TAGS_API_URL}?per_page=100&page=1"
264+
tags_page2 = f"{core_path._GITHUB_TAGS_API_URL}?per_page=100&page=2"
265+
# 4.5.10 must win over 4.5.2 — confirms numeric (not lexical) ordering.
266+
github_url = (
267+
"https://raw.githubusercontent.com/"
268+
"jupyterlab/jupyterlab/v4.5.10/"
269+
"jupyterlab/staging/package.json"
270+
)
271+
calls = []
272+
273+
def fake_urlopen(req_or_url, **_kwargs):
274+
url = getattr(req_or_url, "full_url", req_or_url)
275+
calls.append(url)
276+
if url == f"{core_path.JPBLD_NPM_URL}/@jupyterlab/core-meta":
277+
# npm publishes no 4.5.x, so the npm wildcard lookup finds no match.
278+
return io.BytesIO(json.dumps({"versions": {"4.6.0": {}}}).encode())
279+
if url == tags_page1:
280+
return io.BytesIO(
281+
json.dumps(
282+
[
283+
{"name": "v4.6.0"},
284+
{"name": "v4.5.2"},
285+
{"name": "v4.5.10"},
286+
{"name": "v4.5.1"},
287+
],
288+
).encode(),
289+
)
290+
if url == tags_page2:
291+
# Older tags with no 4.5.x match — resolution stops here.
292+
return io.BytesIO(json.dumps([{"name": "v4.4.9"}]).encode())
293+
if url == github_url:
294+
return io.BytesIO(b'{"dependencies": {}}')
295+
msg = f"Unexpected URL {url}"
296+
raise AssertionError(msg)
297+
298+
monkeypatch.setattr(core_path.urllib.request, "urlopen", fake_urlopen)
299+
300+
location = core_path.get_core_meta(version="4.5.x", ext_path=ext_path)
301+
302+
assert calls == [
303+
f"{core_path.JPBLD_NPM_URL}/@jupyterlab/core-meta",
304+
tags_page1,
305+
tags_page2,
306+
github_url,
307+
]
308+
assert location == str(
309+
tmp_path / ".cache" / "jupyterlab_builder" / "core" / "4.5.10" / "core.package.json",
310+
)
311+
312+
313+
def test_get_core_meta_raises_when_requested_version_is_unresolvable(tmp_path, monkeypatch):
314+
"""An explicitly requested version that exists nowhere must fail loudly."""
315+
ext_path = tmp_path / "ext"
316+
ext_path.mkdir()
317+
monkeypatch.setenv("HOME", str(tmp_path))
318+
319+
def fake_urlopen(*_args, **_kwargs):
320+
msg = "Not Found"
321+
raise urllib.error.URLError(msg)
322+
323+
monkeypatch.setattr(core_path.urllib.request, "urlopen", fake_urlopen)
324+
325+
with pytest.raises(RuntimeError, match="Could not resolve @jupyterlab/core-meta"):
326+
core_path.get_core_meta(version="9.9.9", ext_path=ext_path)
327+
328+
160329
def test_get_core_meta_wildcard_version_resolves_from_npm_and_downloads_core_meta(
161330
tmp_path,
162331
monkeypatch,

tests/test_tpl.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,10 +292,11 @@ def test_builder_version_mismatch(tmp_path):
292292
text=True,
293293
)
294294
# Check if the expected error message is in the output
295+
output = excinfo.value.stderr
295296
assert re.search(
296297
(
297-
r"ValueError: Extensions require a devDependency on @jupyterlab/builder@\^.+?, "
298+
r"ValueError: Extensions require a devDependency on @jupyterlab/builder@\^[^,]+, "
298299
r"you have a dependency on 4\.0\.0"
299300
),
300-
excinfo.value.stderr,
301+
output,
301302
), "Expected version mismatch error message not found in output!"

0 commit comments

Comments
 (0)