Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for constraints, install wheels before pyodide packages #181

Merged
merged 22 commits into from
Feb 1, 2025
Merged
Show file tree
Hide file tree
Changes from 18 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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Changed

- `micropip.install` now installs wheels from PyPI or custom indexes before built-in
Pyodide packages, reversing the previous behavior.
[#181](https://github.com/pyodide/micropip/pull/181)
Copy link
Member

Choose a reason for hiding this comment

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

Is there a user-facing change related to this? I think the order of installation is an implementation detail that is not visible to the user. What a user sees whether the whole set of packages (with dependencies) are installed or not.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I don't know: seems like In interactive package management, which can be modified by about five different flags now, I think "reversing what we used to do," warrants at least a note. I am not sure if it's breaking, but things folk did before might not work now, and they might not need workarounds (like the double-install thing).

Copy link
Member

@ryanking13 ryanking13 Jan 29, 2025

Choose a reason for hiding this comment

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

I see. Makes sense. Let me check the code more carefully and review.

I think the current wording could be a bit misleading to users though. When the same package exists in both the pyodide lock file and PyPI, the lockfile currently takes precedence (in terms of which package to choose, not the order of installation). This PR is not related to it, but maybe the user will think that this PR is changing that behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right: today, in the transaction phase once a dependency chain hits a pyodide package, further dependency resolution was skipped because the lockfile would take care of the rest. This was a performance win, for sure. In the install stage, they were all mixed together in wheel_tasks, and there was really no way to make any claims about what would happen.

With this change, the transaction will keep going all the way down for every novel dependency. In the install phase, the PyPI wheels get installed (in parallel) before calling loadPackage, which won't re-install (and re-download) a pyodide wheel.

This does mean there will be an extra add_dependency_inner for each depends item, but I couldn't figure out a way to do it that would be remotely correct.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

... but that's a lot to say in a CHANGELOG, maybe there's a terser way?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

tried some more things on 92baa16

Copy link
Member

Choose a reason for hiding this comment

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

With this change, the transaction will keep going all the way down for every novel dependency. In the install phase, the PyPI wheels get installed (in parallel) before calling loadPackage, which won't re-install (and re-download) a pyodide wheel.

Hmm, I still don't quite get what which won't re-install means. By default, during the dependency resolution step, micropip will check if the package is available in the lockfile, and select the one from the lockfile instead of the one from the PyPI.

So, in the installation step, we expect no overlap between transaction.pyodide_packages and transaction.wheels. Does this PR change this behavior?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note the download of two pytest wheels:

image

After this PR, since the wheel would already be installed, it would look more like this:

image

Copy link
Member

Choose a reason for hiding this comment

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

Got it. I didn't consider putting multiple packages in install(...). Thanks.


### Added

- Added support for constraining resolved requirements via
`micropip.install(..., constraints=[...])`. and `micropip.set_constraints([...])`
[#181](https://github.com/pyodide/micropip/pull/181)

### Fixed

- Fix a bug that prevented non-standard relative urls to be treated as such
Expand Down
63 changes: 62 additions & 1 deletion docs/project/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,79 @@ You can pass multiple packages to `micropip.install`:
await micropip.install(["pkg1", "pkg2"])
```

You can specify additional constraints:
A dependency can be refined as per the [PEP-508] spec:

[pep-508]: https://peps.python.org/pep-0508

```python
await micropip.install("snowballstemmer==2.2.0")
await micropip.install("snowballstemmer>=2.2.0")
await micropip.install("snowballstemmer @ https://.../snowballstemmer.*.whl")
await micropip.install("snowballstemmer[all]")
```

### Disabling dependency resolution

micropip does dependency resolution by default, but you can disable it,
this is useful if you want to install a package that has a dependency
which is not a pure Python package, but it is not mandatory for your use case:

```python
await micropip.install("pkg", deps=False)
```

### Constraining indirect dependencies

Dependency resolution can be further customized with optional `constraints`:
these modify both _direct_ and _indirect_ dependency resolutions, while direct URLs
in either a requirement or constraint will bypass any other specifiers.

As described in the [`pip` documentation][pip-constraints], each constraint:

[pip-constraints]: https://pip.pypa.io/en/stable/user_guide/#constraints-files

- _must_ provide a name
- _must_ provide exactly one of
- a set of version specifiers
- a URL
- _must not_ request any `[extras]`

Multiple constraints of the same canonical name are merged.

Invalid constraints will be silently discarded, or logged if `verbose` is provided.

```python
await micropip.install(
"pkg",
constraints=[
"other-pkg==0.1.1",
"some-other-pkg<2",
"some-other-pkg<3", # merged with the above
"yet-another-pkg@https://example.com/yet_another_pkg-0.1.2-py3-none-any.whl",
# silently discarded # why?
"yet-another-pkg >=1", # previously defined by URL
"yet_another_pkg-0.1.2-py3-none-any.whl", # missing name
"something-completely[different] ==0.1.1", # extras
"package-with-no-version", # missing version or URL
"other-pkg ==0.0.1 ; python_version < '3'", # not applicable
]
)
```

Over-constrained requirements will fail to resolve, leaving the environment unmodified.

```python
await micropip.install("pkg ==1", constraints=["pkg ==2"])
# ValueError: Can't find a pure Python 3 wheel for 'pkg==1,==2'.
```

### Setting default constraints

`micropip.set_constraints` replaces any default constraints for all subsequent
calls to `micropip.install` that don't specify `constraints`:

```python
micropip.set_constraints(["other-pkg ==0.1.1"])
await micropip.install("pkg") # uses defaults, if needed
await micropip.install("another-pkg", constraints=[]) # ignores defaults
```
1 change: 1 addition & 0 deletions micropip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

install = _package_manager_singleton.install
set_index_urls = _package_manager_singleton.set_index_urls
set_constraints = _package_manager_singleton.set_constraints
list = _package_manager_singleton.list
freeze = _package_manager_singleton.freeze
add_mock_package = _package_manager_singleton.add_mock_package
Expand Down
90 changes: 89 additions & 1 deletion micropip/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from pathlib import Path
from sysconfig import get_config_var, get_platform

from packaging.requirements import Requirement
from packaging.requirements import InvalidRequirement, Requirement
from packaging.tags import Tag
from packaging.tags import sys_tags as sys_tags_orig
from packaging.utils import BuildTag, InvalidWheelFilename, canonicalize_name
Expand Down Expand Up @@ -268,3 +268,91 @@ def fix_package_dependencies(
(get_dist_info(dist) / "PYODIDE_REQUIRES").write_text(
json.dumps(sorted(x for x in depends))
)


def validate_constraints(
constraints: list[str] | None,
environment: dict[str, str] | None = None,
) -> tuple[dict[str, Requirement], dict[str, list[str]]]:
"""Build a validated ``Requirement`` dictionary from raw constraint strings.

Parameters
----------
constraints (list):
A list of PEP-508 dependency specs, expected to contain both a package
name and at least one specifier.

environment (optional dict):
The markers for the current environment, such as OS, Python implementation.
If ``None``, the current execution environment will be used.

Returns
-------
A 2-tuple of:
- a dictionary of ``Requirement`` objects, keyed by canonical name
- a dictionary of message strings, keyed by constraint
"""
reqs: dict[str, Requirement] = {}
all_messages: dict[str, list[str]] = {}

for raw_constraint in constraints or []:
messages: list[str] = []

try:
req = Requirement(raw_constraint)
req.name = canonicalize_name(req.name)
except InvalidRequirement as err:
all_messages[raw_constraint] = [f"failed to parse: {err}"]
continue

if req.extras:
messages.append("may not provide [extras]")

if not (req.url or req.specifier):
messages.append("no version or URL")

if req.marker and not req.marker.evaluate(environment):
messages.append(f"not applicable: {req.marker}")

if messages:
all_messages[raw_constraint] = messages
elif req.name in reqs:
all_messages[raw_constraint] = [
f"updated existing constraint for {req.name}"
]
reqs[req.name] = constrain_requirement(req, reqs)
else:
reqs[req.name] = req

return reqs, all_messages


def constrain_requirement(
requirement: Requirement, constrained_requirements: dict[str, Requirement]
) -> Requirement:
"""Modify or replace a requirement based on a set of constraints.

Parameters
----------
requirement (Requirement):
A ``Requirement`` to constrain.

constrained_requirements (dict):
A dictionary of ``Requirement`` objects, keyed by canonical name.

Returns
-------
A constrained ``Requirement``.
"""
# URLs cannot be merged
if requirement.url:
return requirement

constrained = constrained_requirements.get(canonicalize_name(requirement.name))

if constrained:
if constrained.url:
return constrained
requirement.specifier = requirement.specifier & constrained.specifier

return requirement
37 changes: 15 additions & 22 deletions micropip/install.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import asyncio
import importlib
from collections.abc import Coroutine
from pathlib import Path
from typing import Any

from packaging.markers import default_environment

Expand All @@ -20,6 +18,7 @@
credentials: str | None = None,
pre: bool = False,
*,
constraints: list[str] | None = None,
verbose: bool | int | None = None,
) -> None:
with setup_logging().ctx_level(verbose) as logger:
Expand Down Expand Up @@ -49,6 +48,7 @@
fetch_kwargs=fetch_kwargs,
verbose=verbose,
index_urls=index_urls,
constraints=constraints,
)
await transaction.gather_requirements(requirements)

Expand All @@ -59,40 +59,33 @@
f"See: {FAQ_URLS['cant_find_wheel']}\n"
)

package_names = [pkg.name for pkg in transaction.pyodide_packages] + [
pkg.name for pkg in transaction.wheels
]
pyodide_packages, wheels = transaction.pyodide_packages, transaction.wheels

package_names = [pkg.name for pkg in wheels + pyodide_packages]

logger.debug(
"Installing packages %r and wheels %r ",
transaction.pyodide_packages,
[w.filename for w in transaction.wheels],
)

if package_names:
logger.info("Installing collected packages: %s", ", ".join(package_names))

wheel_promises: list[Coroutine[Any, Any, None] | asyncio.Task[Any]] = []
# Install PyPI packages
# detect whether the wheel metadata is from PyPI or from custom location
# wheel metadata from PyPI has SHA256 checksum digest.
await asyncio.gather(*(wheel.install(wheel_base) for wheel in wheels))

# Install built-in packages
pyodide_packages = transaction.pyodide_packages
if len(pyodide_packages):
if pyodide_packages:
# Note: branch never happens in out-of-browser testing because in
# that case REPODATA_PACKAGES is empty.
wheel_promises.append(
asyncio.ensure_future(
loadPackage(to_js([name for [name, _, _] in pyodide_packages]))
)
await asyncio.ensure_future(

Check warning on line 84 in micropip/install.py

View check run for this annotation

Codecov / codecov/patch

micropip/install.py#L84

Added line #L84 was not covered by tests
loadPackage(to_js([name for [name, _, _] in pyodide_packages]))
)

# Now install PyPI packages
# detect whether the wheel metadata is from PyPI or from custom location
# wheel metadata from PyPI has SHA256 checksum digest.
wheel_promises.extend(wheel.install(wheel_base) for wheel in transaction.wheels)

await asyncio.gather(*wheel_promises)

packages = [
f"{pkg.name}-{pkg.version}" for pkg in transaction.pyodide_packages
] + [f"{pkg.name}-{pkg.version}" for pkg in transaction.wheels]
packages = [f"{pkg.name}-{pkg.version}" for pkg in pyodide_packages + wheels]

if packages:
logger.info("Successfully installed %s", ", ".join(packages))
Expand Down
27 changes: 27 additions & 0 deletions micropip/package_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

self.repodata_packages: dict[str, dict[str, Any]] = REPODATA_PACKAGES
self.repodata_info: dict[str, str] = REPODATA_INFO
self.constraints: list[str] = []

pass

Expand All @@ -38,6 +39,7 @@
pre: bool = False,
index_urls: list[str] | str | None = None,
*,
constraints: list[str] | None = None,
verbose: bool | int | None = None,
):
"""Install the given package and all of its dependencies.
Expand Down Expand Up @@ -122,6 +124,14 @@
- If a list of URLs is provided, micropip will try each URL in order until
it finds a package. If no package is found, an error will be raised.

constraints :

A list of requirements with versions/URLs which will be used only if
needed by any ``requirements``.

Unlike ``requirements``, the package name _must_ be provided in the
PEP-508 format e.g. ``pkgname@https://...``.

verbose :
Print more information about the process. By default, micropip does not
change logger level. Setting ``verbose=True`` will print similar
Expand All @@ -130,13 +140,17 @@
if index_urls is None:
index_urls = self.index_urls

if constraints is None:
constraints = self.constraints

return await install(
requirements,
index_urls,
keep_going,
deps,
credentials,
pre,
constraints=constraints,
verbose=verbose,
)

Expand Down Expand Up @@ -309,3 +323,16 @@
urls = [urls]

self.index_urls = urls[:]

def set_constraints(self, constraints: List[str]): # noqa: UP006
"""
Set the default constraints to use when looking up packages.

Parameters
----------
constraints
A list of PEP-508 requirements, each of which must include a name and
version, but no ``[extras]``.
"""

self.constraints = constraints[:]

Check warning on line 338 in micropip/package_manager.py

View check run for this annotation

Codecov / codecov/patch

micropip/package_manager.py#L338

Added line #L338 was not covered by tests
Loading
Loading