Skip to content

Commit eca85cb

Browse files
authored
Merge a13bf6c into 316a7d3
2 parents 316a7d3 + a13bf6c commit eca85cb

23 files changed

+1502
-52
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,5 +88,6 @@ ENV/
8888
# Rope project settings
8989
.ropeproject
9090

91-
# other
91+
# pixi environments
9292
.pixi
93+
*.egg-info

README.md

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -420,16 +420,17 @@ See [example](example/) for more information or check the output of `unidep -h`
420420
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
421421
```bash
422422
usage: unidep [-h]
423-
{merge,install,install-all,conda-lock,pip-compile,pip,conda,version} ...
423+
{merge,install,install-all,conda-lock,pixi-lock,pip-compile,pip,conda,version} ...
424424

425425
Unified Conda and Pip requirements management.
426426

427427
positional arguments:
428-
{merge,install,install-all,conda-lock,pip-compile,pip,conda,version}
428+
{merge,install,install-all,conda-lock,pixi-lock,pip-compile,pip,conda,version}
429429
Subcommands
430430
merge Combine multiple (or a single) `requirements.yaml` or
431431
`pyproject.toml` files into a single Conda installable
432-
`environment.yaml` file.
432+
`environment.yaml` file or Pixi installable
433+
`pixi.toml` file.
433434
install Automatically install all dependencies from one or
434435
more `requirements.yaml` or `pyproject.toml` files.
435436
This command first installs dependencies with Conda,
@@ -448,6 +449,11 @@ positional arguments:
448449
lock.yml` files for each `requirements.yaml` or
449450
`pyproject.toml` file consistent with the global lock
450451
file.
452+
pixi-lock Generate a global `pixi.lock` file for a collection of
453+
`requirements.yaml` or `pyproject.toml` files.
454+
Additionally, create individual `pixi.lock` files for
455+
each `requirements.yaml` or `pyproject.toml` file
456+
consistent with the global lock file.
451457
pip-compile Generate a fully pinned `requirements.txt` file from
452458
one or more `requirements.yaml` or `pyproject.toml`
453459
files using `pip-compile` from `pip-tools`. This
@@ -482,30 +488,33 @@ See `unidep merge -h` for more information:
482488
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
483489
```bash
484490
usage: unidep merge [-h] [-o OUTPUT] [-n NAME] [--stdout]
485-
[--selector {sel,comment}] [-d DIRECTORY] [--depth DEPTH]
486-
[-v]
491+
[--selector {sel,comment}] [--pixi] [-d DIRECTORY]
492+
[--depth DEPTH] [-v]
487493
[-p {linux-64,linux-aarch64,linux-ppc64le,osx-64,osx-arm64,win-64}]
488494
[--skip-dependency SKIP_DEPENDENCY]
489495
[--ignore-pin IGNORE_PIN] [--overwrite-pin OVERWRITE_PIN]
490496

491497
Combine multiple (or a single) `requirements.yaml` or `pyproject.toml` files
492-
into a single Conda installable `environment.yaml` file. Example usage:
493-
`unidep merge --directory . --depth 1 --output environment.yaml` to search for
494-
`requirements.yaml` or `pyproject.toml` files in the current directory and its
495-
subdirectories and create `environment.yaml`. These are the defaults, so you
496-
can also just run `unidep merge`.
498+
into a single Conda installable `environment.yaml` file or Pixi installable
499+
`pixi.toml` file. Example usage: `unidep merge --directory . --depth 1
500+
--output environment.yaml` to search for `requirements.yaml` or
501+
`pyproject.toml` files in the current directory and its subdirectories and
502+
create `environment.yaml`. These are the defaults, so you can also just run
503+
`unidep merge`.
497504

498505
options:
499506
-h, --help show this help message and exit
500507
-o, --output OUTPUT Output file for the conda environment, by default
501-
`environment.yaml`
508+
`environment.yaml` or `pixi.toml` if `--pixi` is used
502509
-n, --name NAME Name of the conda environment, by default `myenv`
503510
--stdout Output to stdout instead of a file
504511
--selector {sel,comment}
505512
The selector to use for the environment markers, if
506513
`sel` then `- numpy # [linux]` becomes `sel(linux):
507514
numpy`, if `comment` then it remains `- numpy #
508515
[linux]`, by default `sel`
516+
--pixi Generate a `pixi.toml` file instead of
517+
`environment.yaml`
509518
-d, --directory DIRECTORY
510519
Base directory to scan for `requirements.yaml` or
511520
`pyproject.toml` file(s), by default `.`

example/environment.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ dependencies:
1818
- pytest
1919
- pytest-cov
2020
- pip:
21-
- unidep
21+
- unidep; sys_platform == 'linux' and platform_machine == 'x86_64'
22+
- unidep; sys_platform == 'darwin'
2223
- markdown-code-runner
2324
- numthreads
2425
- yaml2bib; sys_platform == 'linux' and platform_machine == 'x86_64'

example/hatch_project/requirements.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ channels:
33
- conda-forge
44
dependencies:
55
- conda: adaptive-scheduler # [linux64]
6-
- pip: unidep
6+
- pip: unidep # [linux64]
77
- numpy >=1.21
88
- hpc05 # [linux64]
99
- pandas >=1,<3

pixi_create_sub_lock_file.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Create a subset of a lock file with a subset of packages."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import json
7+
import os
8+
import tempfile
9+
from collections import defaultdict
10+
11+
from rattler import (
12+
Environment,
13+
GenericVirtualPackage,
14+
LockFile,
15+
Platform,
16+
Version,
17+
solve_with_sparse_repodata,
18+
)
19+
from rattler.channel import Channel, ChannelConfig
20+
from rattler.match_spec import MatchSpec
21+
from rattler.repo_data import SparseRepoData
22+
23+
24+
def create_repodata_from_pixi_lock(lock_file_path: str) -> dict[str, dict]:
25+
"""Create repodata from a pixi lock file."""
26+
lock_file = LockFile.from_path(lock_file_path)
27+
env = lock_file.default_environment()
28+
repodata = {}
29+
for platform in env.platforms():
30+
subdir = str(platform)
31+
packages = env.conda_repodata_records_for_platform(platform)
32+
if not packages:
33+
continue
34+
35+
repodata[subdir] = {
36+
"info": {
37+
"subdir": subdir,
38+
"base_url": f"https://conda.anaconda.org/conda-forge/{subdir}",
39+
},
40+
"packages": {
41+
f"{pkg.name.normalized}-{pkg.version}-{pkg.build}.conda": {
42+
"build": pkg.build,
43+
"build_number": pkg.build_number,
44+
"depends": pkg.depends,
45+
"constrains": pkg.constrains,
46+
"license": pkg.license,
47+
"license_family": pkg.license_family,
48+
"md5": pkg.md5.hex() if pkg.md5 else None,
49+
"name": pkg.name.normalized,
50+
"sha256": pkg.sha256.hex() if pkg.sha256 else None,
51+
"size": pkg.size,
52+
"subdir": pkg.subdir,
53+
"timestamp": int(pkg.timestamp.timestamp() * 1000)
54+
if pkg.timestamp
55+
else None,
56+
"version": str(pkg.version),
57+
}
58+
for pkg in packages
59+
},
60+
"repodata_version": 2,
61+
}
62+
return repodata
63+
64+
65+
def _version_requirement_to_lowest_version(version: str | None) -> str | None:
66+
if version is None:
67+
return None
68+
if version.startswith(">="):
69+
version = version[2:]
70+
if version.startswith("=="):
71+
version = version[2:]
72+
version = version.split(",")[0]
73+
return version # noqa: RET504
74+
75+
76+
def all_virtual_packages(env: Environment) -> dict[Platform, set[str]]:
77+
"""Get all virtual packages from an environment."""
78+
virtual_packages = defaultdict(set)
79+
for platform, packages in env.packages_by_platform().items():
80+
for package in packages:
81+
if not package.is_conda:
82+
continue
83+
repo_record = package.as_conda()
84+
for dep in repo_record.depends:
85+
spec = MatchSpec(dep)
86+
if spec.name.normalized.startswith("__"):
87+
version = _version_requirement_to_lowest_version(spec.version)
88+
virtual_package = GenericVirtualPackage(
89+
spec.name,
90+
version=Version(version or "0"),
91+
build_string=spec.build or "*",
92+
)
93+
virtual_packages[platform].add(virtual_package)
94+
return virtual_packages
95+
96+
97+
async def create_subset_lock_file(
98+
original_lock_file_path: str,
99+
required_packages: list[str],
100+
platform: Platform,
101+
) -> LockFile:
102+
"""Create a new lock file with a subset of packages from original lock file."""
103+
original_lock_file = LockFile.from_path(original_lock_file_path)
104+
env = original_lock_file.default_environment()
105+
conda_records = env.conda_repodata_records_for_platform(platform)
106+
if conda_records is None:
107+
msg = f"No conda records found for platform {platform}"
108+
raise ValueError(msg)
109+
repodata = create_repodata_from_pixi_lock(original_lock_file_path)
110+
platform_repodata = repodata.get(str(platform))
111+
if platform_repodata is None:
112+
msg = f"No repodata found for platform {platform}"
113+
raise ValueError(msg)
114+
115+
with tempfile.NamedTemporaryFile(
116+
mode="w",
117+
delete=False,
118+
suffix=".json",
119+
) as temp_file:
120+
json.dump(platform_repodata, temp_file)
121+
temp_file_path = temp_file.name
122+
print(f"Temporary repodata file: {temp_file_path}")
123+
dummy_channel = Channel("dummy", ChannelConfig())
124+
sparse_repo_data = SparseRepoData(dummy_channel, str(platform), temp_file_path)
125+
specs = [MatchSpec(pkg) for pkg in required_packages]
126+
virtual_packages = all_virtual_packages(env)[platform]
127+
128+
solved_records = await solve_with_sparse_repodata(
129+
specs=specs,
130+
sparse_repodata=[sparse_repo_data],
131+
locked_packages=conda_records,
132+
virtual_packages=virtual_packages,
133+
)
134+
new_env = Environment("new_env", {platform: solved_records})
135+
new_lock_file = LockFile({"new_env": new_env})
136+
os.unlink(temp_file_path) # noqa: PTH108
137+
return new_lock_file
138+
139+
140+
async def main() -> None:
141+
"""Example usage of create_subset_lock_file."""
142+
original_lock_file_path = "pixi.lock"
143+
required_packages = ["tornado", "scipy", "ipykernel", "adaptive"]
144+
platform = Platform("linux-64")
145+
new_lock_file = await create_subset_lock_file(
146+
original_lock_file_path,
147+
required_packages,
148+
platform,
149+
)
150+
new_lock_file.to_path("new_lock_file.lock")
151+
152+
153+
if __name__ == "__main__":
154+
asyncio.run(main())

tests/simple_monorepo/common-requirements.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ channels:
55
- conda-forge
66
dependencies:
77
- conda: python_abi
8+
platforms:
9+
- osx-64
10+
- osx-arm64

tests/simple_monorepo/pixi.lock

Lines changed: 97 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)