Skip to content

Commit c9221a0

Browse files
Add support for constraints, install wheels before pyodide packages (#181)
* strawman api/docs changes for #175 * update PR number * apply constraints in transaction * add minimal test * test set_constraints * add set_constraints * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * more tests * handle more emfs cases * address review comments * update changelog * update docs * handle env markers and additive constraints * continue gathering requreiments from pyodide packages, reverse pydodide/wheel install order * fix pr number in changelog * fix spelling * tighten up utils, docstrings * skip an await if not needed, rework changelog entry * try more url checking --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 83983da commit c9221a0

File tree

10 files changed

+397
-39
lines changed

10 files changed

+397
-39
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919

2020
- `micropip` now vendors `pypa/packaging` for better reliability.
2121
[#178](https://github.com/pyodide/micropip/pull/178)
22+
- `micropip.install` adds optional `constraints`, similar to `pip install -c`,
23+
which refine the version or direct URLs of requested packages and their
24+
dependencies. This includes built-in packages, which are now installed after
25+
any requested or constrained external packages.
26+
[#181](https://github.com/pyodide/micropip/pull/181)
27+
- `micropip.set_constraints` sets default constraints for later
28+
calls to `micropip.install` that do not specify constraints.
29+
[#181](https://github.com/pyodide/micropip/pull/181)
2230

2331
## [0.8.0] - 2024/12/15
2432

docs/project/usage.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,79 @@ You can pass multiple packages to `micropip.install`:
7171
await micropip.install(["pkg1", "pkg2"])
7272
```
7373

74-
You can specify additional constraints:
74+
A dependency can be refined as per the [PEP-508] spec:
75+
76+
[pep-508]: https://peps.python.org/pep-0508
7577

7678
```python
7779
await micropip.install("snowballstemmer==2.2.0")
7880
await micropip.install("snowballstemmer>=2.2.0")
81+
await micropip.install("snowballstemmer @ https://.../snowballstemmer.*.whl")
7982
await micropip.install("snowballstemmer[all]")
8083
```
8184

85+
### Disabling dependency resolution
86+
8287
micropip does dependency resolution by default, but you can disable it,
8388
this is useful if you want to install a package that has a dependency
8489
which is not a pure Python package, but it is not mandatory for your use case:
8590

8691
```python
8792
await micropip.install("pkg", deps=False)
8893
```
94+
95+
### Constraining indirect dependencies
96+
97+
Dependency resolution can be further customized with optional `constraints`:
98+
these modify both _direct_ and _indirect_ dependency resolutions, while direct URLs
99+
in either a requirement or constraint will bypass any other specifiers.
100+
101+
As described in the [`pip` documentation][pip-constraints], each constraint:
102+
103+
[pip-constraints]: https://pip.pypa.io/en/stable/user_guide/#constraints-files
104+
105+
- _must_ provide a name
106+
- _must_ provide exactly one of
107+
- a set of version specifiers
108+
- a URL
109+
- _must not_ request any `[extras]`
110+
111+
Multiple constraints of the same canonical name are merged.
112+
113+
Invalid constraints will be silently discarded, or logged if `verbose` is provided.
114+
115+
```python
116+
await micropip.install(
117+
"pkg",
118+
constraints=[
119+
"other-pkg==0.1.1",
120+
"some-other-pkg<2",
121+
"some-other-pkg<3", # merged with the above
122+
"yet-another-pkg@https://example.com/yet_another_pkg-0.1.2-py3-none-any.whl",
123+
# silently discarded # why?
124+
"yet-another-pkg >=1", # previously defined by URL
125+
"yet_another_pkg-0.1.2-py3-none-any.whl", # missing name
126+
"something-completely[different] ==0.1.1", # extras
127+
"package-with-no-version", # missing version or URL
128+
"other-pkg ==0.0.1 ; python_version < '3'", # not applicable
129+
]
130+
)
131+
```
132+
133+
Over-constrained requirements will fail to resolve, leaving the environment unmodified.
134+
135+
```python
136+
await micropip.install("pkg ==1", constraints=["pkg ==2"])
137+
# ValueError: Can't find a pure Python 3 wheel for 'pkg==1,==2'.
138+
```
139+
140+
### Setting default constraints
141+
142+
`micropip.set_constraints` replaces any default constraints for all subsequent
143+
calls to `micropip.install` that don't specify `constraints`:
144+
145+
```python
146+
micropip.set_constraints(["other-pkg ==0.1.1"])
147+
await micropip.install("pkg") # uses defaults, if needed
148+
await micropip.install("another-pkg", constraints=[]) # ignores defaults
149+
```

micropip/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
install = _package_manager_singleton.install
1111
set_index_urls = _package_manager_singleton.set_index_urls
12+
set_constraints = _package_manager_singleton.set_constraints
1213
list = _package_manager_singleton.list
1314
freeze = _package_manager_singleton.freeze
1415
add_mock_package = _package_manager_singleton.add_mock_package

micropip/_utils.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
from sysconfig import get_config_var, get_platform
66

77
from ._compat import REPODATA_PACKAGES
8-
from ._vendored.packaging.src.packaging.requirements import Requirement
8+
from ._vendored.packaging.src.packaging.requirements import (
9+
InvalidRequirement,
10+
Requirement,
11+
)
912
from ._vendored.packaging.src.packaging.tags import Tag
1013
from ._vendored.packaging.src.packaging.tags import sys_tags as sys_tags_orig
1114
from ._vendored.packaging.src.packaging.utils import (
@@ -273,3 +276,91 @@ def fix_package_dependencies(
273276
(get_dist_info(dist) / "PYODIDE_REQUIRES").write_text(
274277
json.dumps(sorted(x for x in depends))
275278
)
279+
280+
281+
def validate_constraints(
282+
constraints: list[str] | None,
283+
environment: dict[str, str] | None = None,
284+
) -> tuple[dict[str, Requirement], dict[str, list[str]]]:
285+
"""Build a validated ``Requirement`` dictionary from raw constraint strings.
286+
287+
Parameters
288+
----------
289+
constraints (list):
290+
A list of PEP-508 dependency specs, expected to contain both a package
291+
name and at least one specifier.
292+
293+
environment (optional dict):
294+
The markers for the current environment, such as OS, Python implementation.
295+
If ``None``, the current execution environment will be used.
296+
297+
Returns
298+
-------
299+
A 2-tuple of:
300+
- a dictionary of ``Requirement`` objects, keyed by canonical name
301+
- a dictionary of message strings, keyed by constraint
302+
"""
303+
reqs: dict[str, Requirement] = {}
304+
all_messages: dict[str, list[str]] = {}
305+
306+
for raw_constraint in constraints or []:
307+
messages: list[str] = []
308+
309+
try:
310+
req = Requirement(raw_constraint)
311+
req.name = canonicalize_name(req.name)
312+
except InvalidRequirement as err:
313+
all_messages[raw_constraint] = [f"failed to parse: {err}"]
314+
continue
315+
316+
if req.extras:
317+
messages.append("may not provide [extras]")
318+
319+
if not (req.url or len(req.specifier)):
320+
messages.append("no version or URL")
321+
322+
if req.marker and not req.marker.evaluate(environment):
323+
messages.append(f"not applicable: {req.marker}")
324+
325+
if messages:
326+
all_messages[raw_constraint] = messages
327+
elif req.name in reqs:
328+
all_messages[raw_constraint] = [
329+
f"updated existing constraint for {req.name}"
330+
]
331+
reqs[req.name] = constrain_requirement(req, reqs)
332+
else:
333+
reqs[req.name] = req
334+
335+
return reqs, all_messages
336+
337+
338+
def constrain_requirement(
339+
requirement: Requirement, constrained_requirements: dict[str, Requirement]
340+
) -> Requirement:
341+
"""Modify or replace a requirement based on a set of constraints.
342+
343+
Parameters
344+
----------
345+
requirement (Requirement):
346+
A ``Requirement`` to constrain.
347+
348+
constrained_requirements (dict):
349+
A dictionary of ``Requirement`` objects, keyed by canonical name.
350+
351+
Returns
352+
-------
353+
A constrained ``Requirement``.
354+
"""
355+
# URLs cannot be merged
356+
if requirement.url:
357+
return requirement
358+
359+
constrained = constrained_requirements.get(canonicalize_name(requirement.name))
360+
361+
if constrained:
362+
if constrained.url:
363+
return constrained
364+
requirement.specifier = requirement.specifier & constrained.specifier
365+
366+
return requirement

micropip/install.py

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import asyncio
22
import importlib
3-
from collections.abc import Coroutine
43
from pathlib import Path
5-
from typing import Any
64

75
from ._compat import loadPackage, to_js
86
from ._vendored.packaging.src.packaging.markers import default_environment
@@ -19,6 +17,7 @@ async def install(
1917
credentials: str | None = None,
2018
pre: bool = False,
2119
*,
20+
constraints: list[str] | None = None,
2221
verbose: bool | int | None = None,
2322
) -> None:
2423
with setup_logging().ctx_level(verbose) as logger:
@@ -48,6 +47,7 @@ async def install(
4847
fetch_kwargs=fetch_kwargs,
4948
verbose=verbose,
5049
index_urls=index_urls,
50+
constraints=constraints,
5151
)
5252
await transaction.gather_requirements(requirements)
5353

@@ -58,40 +58,33 @@ async def install(
5858
f"See: {FAQ_URLS['cant_find_wheel']}\n"
5959
)
6060

61-
package_names = [pkg.name for pkg in transaction.pyodide_packages] + [
62-
pkg.name for pkg in transaction.wheels
63-
]
61+
pyodide_packages, wheels = transaction.pyodide_packages, transaction.wheels
62+
63+
package_names = [pkg.name for pkg in wheels + pyodide_packages]
6464

6565
logger.debug(
6666
"Installing packages %r and wheels %r ",
6767
transaction.pyodide_packages,
6868
[w.filename for w in transaction.wheels],
6969
)
70+
7071
if package_names:
7172
logger.info("Installing collected packages: %s", ", ".join(package_names))
7273

73-
wheel_promises: list[Coroutine[Any, Any, None] | asyncio.Task[Any]] = []
74+
# Install PyPI packages
75+
# detect whether the wheel metadata is from PyPI or from custom location
76+
# wheel metadata from PyPI has SHA256 checksum digest.
77+
await asyncio.gather(*(wheel.install(wheel_base) for wheel in wheels))
78+
7479
# Install built-in packages
75-
pyodide_packages = transaction.pyodide_packages
76-
if len(pyodide_packages):
80+
if pyodide_packages:
7781
# Note: branch never happens in out-of-browser testing because in
7882
# that case REPODATA_PACKAGES is empty.
79-
wheel_promises.append(
80-
asyncio.ensure_future(
81-
loadPackage(to_js([name for [name, _, _] in pyodide_packages]))
82-
)
83+
await asyncio.ensure_future(
84+
loadPackage(to_js([name for [name, _, _] in pyodide_packages]))
8385
)
8486

85-
# Now install PyPI packages
86-
# detect whether the wheel metadata is from PyPI or from custom location
87-
# wheel metadata from PyPI has SHA256 checksum digest.
88-
wheel_promises.extend(wheel.install(wheel_base) for wheel in transaction.wheels)
89-
90-
await asyncio.gather(*wheel_promises)
91-
92-
packages = [
93-
f"{pkg.name}-{pkg.version}" for pkg in transaction.pyodide_packages
94-
] + [f"{pkg.name}-{pkg.version}" for pkg in transaction.wheels]
87+
packages = [f"{pkg.name}-{pkg.version}" for pkg in pyodide_packages + wheels]
9588

9689
if packages:
9790
logger.info("Successfully installed %s", ", ".join(packages))

micropip/package_manager.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def __init__(self) -> None:
2626

2727
self.repodata_packages: dict[str, dict[str, Any]] = REPODATA_PACKAGES
2828
self.repodata_info: dict[str, str] = REPODATA_INFO
29+
self.constraints: list[str] = []
2930

3031
pass
3132

@@ -38,6 +39,7 @@ async def install(
3839
pre: bool = False,
3940
index_urls: list[str] | str | None = None,
4041
*,
42+
constraints: list[str] | None = None,
4143
verbose: bool | int | None = None,
4244
):
4345
"""Install the given package and all of its dependencies.
@@ -122,6 +124,14 @@ async def install(
122124
- If a list of URLs is provided, micropip will try each URL in order until
123125
it finds a package. If no package is found, an error will be raised.
124126
127+
constraints :
128+
129+
A list of requirements with versions/URLs which will be used only if
130+
needed by any ``requirements``.
131+
132+
Unlike ``requirements``, the package name _must_ be provided in the
133+
PEP-508 format e.g. ``pkgname@https://...``.
134+
125135
verbose :
126136
Print more information about the process. By default, micropip does not
127137
change logger level. Setting ``verbose=True`` will print similar
@@ -130,13 +140,17 @@ async def install(
130140
if index_urls is None:
131141
index_urls = self.index_urls
132142

143+
if constraints is None:
144+
constraints = self.constraints
145+
133146
return await install(
134147
requirements,
135148
index_urls,
136149
keep_going,
137150
deps,
138151
credentials,
139152
pre,
153+
constraints=constraints,
140154
verbose=verbose,
141155
)
142156

@@ -309,3 +323,16 @@ def set_index_urls(self, urls: List[str] | str): # noqa: UP006
309323
urls = [urls]
310324

311325
self.index_urls = urls[:]
326+
327+
def set_constraints(self, constraints: List[str]): # noqa: UP006
328+
"""
329+
Set the default constraints to use when looking up packages.
330+
331+
Parameters
332+
----------
333+
constraints
334+
A list of PEP-508 requirements, each of which must include a name and
335+
version, but no ``[extras]``.
336+
"""
337+
338+
self.constraints = constraints[:]

0 commit comments

Comments
 (0)