Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions micropip/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from ._commands.freeze import freeze
from ._commands.index_urls import set_index_urls
from ._commands.install import install
from ._commands.list import _list as list
from ._commands.mock_package import (
Expand All @@ -21,5 +22,6 @@
"list_mock_packages",
"remove_mock_package",
"uninstall",
"set_index_urls",
"__version__",
]
23 changes: 23 additions & 0 deletions micropip/_commands/index_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from .. import package_index


def set_index_urls(urls: list[str] | str) -> None:
"""
Set the index URLs to use when looking up packages.

The URLs should contain the placeholder {package_name} which will be
replaced with the package name when looking up a package.

Parameters
----------
urls
A list of URLs or a single URL to use as the package index.
"""

if isinstance(urls, str):
urls = [urls]

for url in urls:
package_index._check_index_url(url)

package_index.INDEX_URLS = urls
9 changes: 9 additions & 0 deletions micropip/_commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ async def install(
deps: bool = True,
credentials: str | None = None,
pre: bool = False,
index_urls: list[str] | str | None = None,
*,
verbose: bool | int = False,
) -> None:
Expand Down Expand Up @@ -86,6 +87,13 @@ async def install(
If ``True``, include pre-release and development versions. By default,
micropip only finds stable versions.

index_urls :

A list of URLs or a single URL to use as the package index.
By default, micropip uses the PyPI. The URLs should contain
Comment thread
ryanking13 marked this conversation as resolved.
Outdated
the placeholder {package_name} which will be replaced with
the package name when looking up a package.

verbose :
Print more information about the process.
By default, micropip is silent. Setting ``verbose=True`` will print
Expand Down Expand Up @@ -117,6 +125,7 @@ async def install(
pre=pre,
fetch_kwargs=fetch_kwargs,
verbose=verbose,
index_urls=index_urls,
)
await transaction.gather_requirements(requirements)

Expand Down
64 changes: 64 additions & 0 deletions micropip/package_index.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import json
import string
import sys
from collections import defaultdict
from collections.abc import Generator
Expand All @@ -7,8 +9,14 @@
from packaging.utils import InvalidWheelFilename
from packaging.version import InvalidVersion, Version

from ._compat import fetch_string
from ._utils import is_package_compatible, parse_version

DEFAULT_INDEX_URLS = ["https://pypi.org/pypi/{package_name}/json"]
INDEX_URLS = DEFAULT_INDEX_URLS

_formatter = string.Formatter()


# TODO: Merge this class with WheelInfo
@dataclass
Expand Down Expand Up @@ -182,3 +190,59 @@ def _fast_check_incompatibility(filename: str) -> bool:
return False

return True


def _check_index_url(url: str) -> None:
fields = [parsed[1] for parsed in _formatter.parse(url)]

if "package_name" not in fields:
raise ValueError(
f"Invalid index URL: {url!r}. "
"Please make sure it contains the placeholder {package_name}."
)


async def search_packages(
pkg: str,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be search_package if it searches just 1 package? Also, it might be more user friendly to name the first argument name.

It's not "search" technically though. Search is returning top k results for a query, possibly with some pre-processing or fuzzing of the query. While this return a single response with which is the exact match. Maybe query_package? But I'm open to other name suggestions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! I wasn't aware of the difference between search and query :) I will go with query_package.

fetch_kwargs: dict[str, str] | None = None,
index_urls: list[str] | str | None = None,
) -> ProjectInfo:
"""
Search for packages from given index URLs.

Parameters
----------
pkg
Name of the package to search for.
fetch_kwargs
Keyword arguments to pass to the fetch function.
index_urls
A list of URLs or a single URL to use as the package index.
If None, the default index URLs are used.
"""
global INDEX_URLS

if not fetch_kwargs:
fetch_kwargs = {}

if index_urls is None:
index_urls = INDEX_URLS
elif isinstance(index_urls, str):
index_urls = [index_urls]

for url in index_urls:
_check_index_url(url)

url = url.format(package_name=pkg)

try:
metadata = await fetch_string(url, fetch_kwargs)
except OSError:
continue

return ProjectInfo.from_json_api(json.loads(metadata))
else:
raise ValueError(
f"Can't fetch metadata for '{pkg}'."
"Please make sure you have entered a correct package name."
Comment thread
ryanking13 marked this conversation as resolved.
Outdated
)
19 changes: 5 additions & 14 deletions micropip/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
from packaging.utils import canonicalize_name
from packaging.version import Version

from . import package_index
from ._compat import (
REPODATA_PACKAGES,
fetch_bytes,
fetch_string,
get_dynlibs,
loadDynlib,
loadedPackages,
Expand Down Expand Up @@ -177,6 +177,7 @@ class Transaction:
deps: bool
pre: bool
fetch_kwargs: dict[str, str]
index_urls: list[str] | str | None

locked: dict[str, PackageMetadata] = field(default_factory=dict)
wheels: list[WheelInfo] = field(default_factory=list)
Expand Down Expand Up @@ -295,7 +296,9 @@ def eval_marker(e: dict[str, str]) -> bool:
)
return

metadata: ProjectInfo = await _get_pypi_json(req.name, self.fetch_kwargs)
metadata = await package_index.search_packages(
req.name, self.fetch_kwargs, index_urls=self.index_urls
)

try:
wheel = find_wheel(metadata, req)
Expand Down Expand Up @@ -405,18 +408,6 @@ def find_wheel(metadata: ProjectInfo, req: Requirement) -> WheelInfo:
)


async def _get_pypi_json(pkgname: str, fetch_kwargs: dict[str, str]) -> ProjectInfo:
url = f"https://pypi.org/pypi/{pkgname}/json"
try:
metadata = await fetch_string(url, fetch_kwargs)
except OSError as e:
raise ValueError(
f"Can't fetch metadata for '{pkgname}' from PyPI. "
"Please make sure you have entered a correct package name."
) from e
return ProjectInfo.from_json_api(json.loads(metadata))


def _generate_package_hash(data: IO[bytes]) -> str:
sha256_hash = hashlib.sha256()
data.seek(0)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dynamic = ["version"]
dependencies = ["packaging>=23.0"]
[project.optional-dependencies]
test = [
"pytest-httpserver",
"pytest-pyodide",
"pytest-cov",
"build",
Expand Down
49 changes: 46 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import functools
import gzip
import io
import sys
import zipfile
Expand All @@ -14,6 +16,12 @@
PLATFORM = f"emscripten_{EMSCRIPTEN_VER.replace('.', '_')}_wasm32"
CPVER = f"cp{sys.version_info.major}{sys.version_info.minor}"

TEST_PYPI_RESPONSE_DIR = Path(__file__).parent / "test_data" / "pypi_response"


def _read_pypi_response(file: Path) -> bytes:
return gzip.decompress(file.read_bytes())


def _build(build_dir, dist_dir):
import build
Expand Down Expand Up @@ -186,7 +194,7 @@ def add_pkg_version(
self.metadata_map[filename] = metadata
self.top_level_map[filename] = top_level

async def _get_pypi_json(self, pkgname, kwargs):
async def search_packages(self, pkgname, kwargs, index_urls=None):
from micropip.package_index import ProjectInfo

try:
Expand Down Expand Up @@ -229,9 +237,44 @@ def write_file(filename, contents):
@pytest.fixture
def mock_fetch(monkeypatch, mock_importlib):
pytest.importorskip("packaging")
from micropip import transaction
from micropip import package_index, transaction

result = mock_fetch_cls()
monkeypatch.setattr(transaction, "_get_pypi_json", result._get_pypi_json)
monkeypatch.setattr(package_index, "search_packages", result.search_packages)
monkeypatch.setattr(transaction, "fetch_bytes", result._fetch_bytes)
return result


def _mock_package_index_gen(
httpserver,
pkgs=("black", "pytest", "numpy", "pytz", "snowballstemmer"),
content_type="application/json",
suffix="_json.json.gz",
):
# Run a mock server that serves as a package index
import secrets

base = secrets.token_hex(16)

for pkg in pkgs:
data = _read_pypi_response(TEST_PYPI_RESPONSE_DIR / f"{pkg}{suffix}")
httpserver.expect_request(f"/{base}/{pkg}/").respond_with_data(
data,
content_type=content_type,
headers={"Access-Control-Allow-Origin": "*"},
)

base_url = httpserver.url_for(base)
index_url = base_url + "/{package_name}/"

return index_url


@pytest.fixture
def mock_package_index_json_api(httpserver):
return functools.partial(
_mock_package_index_gen,
httpserver=httpserver,
suffix="_json.json.gz",
content_type="application/json",
)
Binary file not shown.
Binary file not shown.
31 changes: 31 additions & 0 deletions tests/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,3 +368,34 @@ async def run_test(selenium, url, name, version):
assert f"Successfully installed {name}-{version}" in captured

run_test(selenium_standalone_micropip, wheel_url, name, version)


@pytest.mark.asyncio
async def test_custom_index_urls(mock_package_index_json_api, monkeypatch):
from io import BytesIO

mock_server_fake_package = mock_package_index_json_api(
pkgs=["fake-pkg-micropip-test"]
)

_wheel_url = ""

async def _mock_fetch_bytes(url, *args):
nonlocal _wheel_url
_wheel_url = url
return BytesIO(b"fake wheel")

from micropip import transaction

monkeypatch.setattr(transaction, "fetch_bytes", _mock_fetch_bytes)

try:
await micropip.install(
"fake-pkg-micropip-test", index_urls=[mock_server_fake_package]
)
except Exception:
# We just check that the custom index url was used
# install will fail because the package is not real, but it doesn't matter.
pass

assert "fake_pkg_micropip_test-1.0.0-py2.py3-none-any.whl" in _wheel_url
Loading