Skip to content

Commit 075289c

Browse files
authored
ENH Support alternative index urls (#74)
1 parent 930819f commit 075289c

13 files changed

Lines changed: 268 additions & 35 deletions

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010

1111
- Added `verbose` parameter to micropip.install and micropip.uninstall
1212
[#60](https://github.com/pyodide/micropip/pull/60)
13-
13+
- Added `index_urls` parameter to micropip.install to support installing
14+
from custom package indexes.
15+
[#74](https://github.com/pyodide/micropip/pull/74)
16+
- Added `micropip.set_index_urls` to support installing from custom package
17+
indexes.
18+
[#74](https://github.com/pyodide/micropip/pull/74)
1419
### Fixed
1520

1621
- Fixed `micropip.add_mock_package` to work with Pyodide>=0.23.0

micropip/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from ._commands.freeze import freeze
2+
from ._commands.index_urls import set_index_urls
23
from ._commands.install import install
34
from ._commands.list import _list as list
45
from ._commands.mock_package import (
@@ -21,5 +22,6 @@
2122
"list_mock_packages",
2223
"remove_mock_package",
2324
"uninstall",
25+
"set_index_urls",
2426
"__version__",
2527
]

micropip/_commands/index_urls.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from .. import package_index
2+
3+
4+
def set_index_urls(urls: list[str] | str) -> None:
5+
"""
6+
Set the index URLs to use when looking up packages.
7+
8+
- The index URL should support the
9+
`JSON API <https://warehouse.pypa.io/api-reference/json/>`__ .
10+
11+
- The index URL may contain the placeholder {package_name} which will be
12+
replaced with the package name when looking up a package. If it does not
13+
contain the placeholder, the package name will be appended to the URL.
14+
15+
- If a list of URLs is provided, micropip will try each URL in order until
16+
it finds a package. If no package is found, an error will be raised.
17+
18+
Parameters
19+
----------
20+
urls
21+
A list of URLs or a single URL to use as the package index.
22+
"""
23+
24+
if isinstance(urls, str):
25+
urls = [urls]
26+
27+
package_index.INDEX_URLS = urls

micropip/_commands/install.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ async def install(
1616
deps: bool = True,
1717
credentials: str | None = None,
1818
pre: bool = False,
19+
index_urls: list[str] | str | None = None,
1920
*,
2021
verbose: bool | int = False,
2122
) -> None:
@@ -86,6 +87,21 @@ async def install(
8687
If ``True``, include pre-release and development versions. By default,
8788
micropip only finds stable versions.
8889
90+
index_urls :
91+
92+
A list of URLs or a single URL to use as the package index when looking
93+
up packages. If None, *https://pypi.org/pypi/{package_name}/json* is used.
94+
95+
- The index URL should support the
96+
`JSON API <https://warehouse.pypa.io/api-reference/json/>`__ .
97+
98+
- The index URL may contain the placeholder {package_name} which will be
99+
replaced with the package name when looking up a package. If it does not
100+
contain the placeholder, the package name will be appended to the URL.
101+
102+
- If a list of URLs is provided, micropip will try each URL in order until
103+
it finds a package. If no package is found, an error will be raised.
104+
89105
verbose :
90106
Print more information about the process.
91107
By default, micropip is silent. Setting ``verbose=True`` will print
@@ -117,6 +133,7 @@ async def install(
117133
pre=pre,
118134
fetch_kwargs=fetch_kwargs,
119135
verbose=verbose,
136+
index_urls=index_urls,
120137
)
121138
await transaction.gather_requirements(requirements)
122139

micropip/package_index.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
import string
13
import sys
24
from collections import defaultdict
35
from collections.abc import Generator
@@ -7,8 +9,14 @@
79
from packaging.utils import InvalidWheelFilename
810
from packaging.version import InvalidVersion, Version
911

12+
from ._compat import fetch_string
1013
from ._utils import is_package_compatible, parse_version
1114

15+
DEFAULT_INDEX_URLS = ["https://pypi.org/pypi/{package_name}/json"]
16+
INDEX_URLS = DEFAULT_INDEX_URLS
17+
18+
_formatter = string.Formatter()
19+
1220

1321
# TODO: Merge this class with WheelInfo
1422
@dataclass
@@ -182,3 +190,60 @@ def _fast_check_incompatibility(filename: str) -> bool:
182190
return False
183191

184192
return True
193+
194+
195+
def _contain_placeholder(url: str, placeholder: str = "package_name") -> bool:
196+
fields = [parsed[1] for parsed in _formatter.parse(url)]
197+
198+
return placeholder in fields
199+
200+
201+
async def query_package(
202+
name: str,
203+
fetch_kwargs: dict[str, str] | None = None,
204+
index_urls: list[str] | str | None = None,
205+
) -> ProjectInfo:
206+
"""
207+
Query for a package from package indexes.
208+
209+
Parameters
210+
----------
211+
name
212+
Name of the package to search for.
213+
fetch_kwargs
214+
Keyword arguments to pass to the fetch function.
215+
index_urls
216+
A list of URLs or a single URL to use as the package index.
217+
If None, the default index URL is used.
218+
219+
If a list of URLs is provided, it will be tried in order until
220+
it finds a package. If no package is found, an error will be raised.
221+
"""
222+
global INDEX_URLS
223+
224+
if not fetch_kwargs:
225+
fetch_kwargs = {}
226+
227+
if index_urls is None:
228+
index_urls = INDEX_URLS
229+
elif isinstance(index_urls, str):
230+
index_urls = [index_urls]
231+
232+
for url in index_urls:
233+
if _contain_placeholder(url):
234+
url = url.format(package_name=name)
235+
else:
236+
url = f"{url}/{name}/"
237+
238+
try:
239+
metadata = await fetch_string(url, fetch_kwargs)
240+
except OSError:
241+
continue
242+
243+
return ProjectInfo.from_json_api(json.loads(metadata))
244+
else:
245+
raise ValueError(
246+
f"Can't fetch metadata for '{name}'."
247+
"Please make sure you have entered a correct package name "
248+
"and correctly specified index_urls (if you changed them)."
249+
)

micropip/transaction.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616
from packaging.utils import canonicalize_name
1717
from packaging.version import Version
1818

19+
from . import package_index
1920
from ._compat import (
2021
REPODATA_PACKAGES,
2122
fetch_bytes,
22-
fetch_string,
2323
get_dynlibs,
2424
loadDynlib,
2525
loadedPackages,
@@ -177,6 +177,7 @@ class Transaction:
177177
deps: bool
178178
pre: bool
179179
fetch_kwargs: dict[str, str]
180+
index_urls: list[str] | str | None
180181

181182
locked: dict[str, PackageMetadata] = field(default_factory=dict)
182183
wheels: list[WheelInfo] = field(default_factory=list)
@@ -295,7 +296,9 @@ def eval_marker(e: dict[str, str]) -> bool:
295296
)
296297
return
297298

298-
metadata: ProjectInfo = await _get_pypi_json(req.name, self.fetch_kwargs)
299+
metadata = await package_index.query_package(
300+
req.name, self.fetch_kwargs, index_urls=self.index_urls
301+
)
299302

300303
try:
301304
wheel = find_wheel(metadata, req)
@@ -405,18 +408,6 @@ def find_wheel(metadata: ProjectInfo, req: Requirement) -> WheelInfo:
405408
)
406409

407410

408-
async def _get_pypi_json(pkgname: str, fetch_kwargs: dict[str, str]) -> ProjectInfo:
409-
url = f"https://pypi.org/pypi/{pkgname}/json"
410-
try:
411-
metadata = await fetch_string(url, fetch_kwargs)
412-
except OSError as e:
413-
raise ValueError(
414-
f"Can't fetch metadata for '{pkgname}' from PyPI. "
415-
"Please make sure you have entered a correct package name."
416-
) from e
417-
return ProjectInfo.from_json_api(json.loads(metadata))
418-
419-
420411
def _generate_package_hash(data: IO[bytes]) -> str:
421412
sha256_hash = hashlib.sha256()
422413
data.seek(0)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dynamic = ["version"]
1616
dependencies = ["packaging>=23.0"]
1717
[project.optional-dependencies]
1818
test = [
19+
"pytest-httpserver",
1920
"pytest-pyodide",
2021
"pytest-cov",
2122
"build",

tests/conftest.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import functools
2+
import gzip
13
import io
24
import sys
35
import zipfile
@@ -14,6 +16,12 @@
1416
PLATFORM = f"emscripten_{EMSCRIPTEN_VER.replace('.', '_')}_wasm32"
1517
CPVER = f"cp{sys.version_info.major}{sys.version_info.minor}"
1618

19+
TEST_PYPI_RESPONSE_DIR = Path(__file__).parent / "test_data" / "pypi_response"
20+
21+
22+
def _read_pypi_response(file: Path) -> bytes:
23+
return gzip.decompress(file.read_bytes())
24+
1725

1826
def _build(build_dir, dist_dir):
1927
import build
@@ -186,7 +194,7 @@ def add_pkg_version(
186194
self.metadata_map[filename] = metadata
187195
self.top_level_map[filename] = top_level
188196

189-
async def _get_pypi_json(self, pkgname, kwargs):
197+
async def query_package(self, pkgname, kwargs, index_urls=None):
190198
from micropip.package_index import ProjectInfo
191199

192200
try:
@@ -229,9 +237,43 @@ def write_file(filename, contents):
229237
@pytest.fixture
230238
def mock_fetch(monkeypatch, mock_importlib):
231239
pytest.importorskip("packaging")
232-
from micropip import transaction
240+
from micropip import package_index, transaction
233241

234242
result = mock_fetch_cls()
235-
monkeypatch.setattr(transaction, "_get_pypi_json", result._get_pypi_json)
243+
monkeypatch.setattr(package_index, "query_package", result.query_package)
236244
monkeypatch.setattr(transaction, "fetch_bytes", result._fetch_bytes)
237245
return result
246+
247+
248+
def _mock_package_index_gen(
249+
httpserver,
250+
pkgs=("black", "pytest", "numpy", "pytz", "snowballstemmer"),
251+
content_type="application/json",
252+
suffix="_json.json.gz",
253+
):
254+
# Run a mock server that serves as a package index
255+
import secrets
256+
257+
base = secrets.token_hex(16)
258+
259+
for pkg in pkgs:
260+
data = _read_pypi_response(TEST_PYPI_RESPONSE_DIR / f"{pkg}{suffix}")
261+
httpserver.expect_request(f"/{base}/{pkg}/").respond_with_data(
262+
data,
263+
content_type=content_type,
264+
headers={"Access-Control-Allow-Origin": "*"},
265+
)
266+
267+
index_url = httpserver.url_for(base)
268+
269+
return index_url
270+
271+
272+
@pytest.fixture
273+
def mock_package_index_json_api(httpserver):
274+
return functools.partial(
275+
_mock_package_index_gen,
276+
httpserver=httpserver,
277+
suffix="_json.json.gz",
278+
content_type="application/json",
279+
)
645 Bytes
Binary file not shown.
393 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)