Skip to content

Commit 435e0b3

Browse files
authored
Support pixi as environment manager (#459)
* intial pixi commit * linting * update ci pixi settings
1 parent 2e07ce3 commit 435e0b3

File tree

12 files changed

+259
-6
lines changed

12 files changed

+259
-6
lines changed

.github/workflows/tests.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,12 @@ jobs:
7575
auto-activate-base: true
7676
activate-environment: ""
7777

78+
- name: Set up Pixi
79+
uses: prefix-dev/[email protected]
80+
with:
81+
cache: false
82+
run-install: false
83+
7884
- name: Cache conda packages
7985
uses: actions/cache@v4
8086
env:
@@ -117,6 +123,8 @@ jobs:
117123
pipenv --version
118124
which virtualenv
119125
virtualenv --version
126+
which pixi
127+
pixi --version
120128
which python
121129
python --version
122130
python -c "import platform; print(f'Python architecture: {platform.architecture()}')"

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ ipython_config.py
126126
# https://pdm.fming.dev/#use-with-ide
127127
.pdm.toml
128128

129+
# pixi
130+
# pixi.lock should be committed to version control for reproducibility
131+
# .pixi/ contains the environments and should not be committed
132+
.pixi/
133+
129134
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
130135
__pypackages__/
131136

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ create_environment:
2222

2323
## Install Python Dependencies
2424
requirements:
25-
$(PYTHON_INTERPRETER) -m pip install -r dev-requirements.txt
25+
$(PYTHON_INTERPRETER) -m pip install -U -r dev-requirements.txt
2626

2727
## Format the code using isort and black
2828
format:

ccds-help.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,13 @@
147147
"more_information": "[Docs](https://docs.astral.sh/uv/)"
148148
}
149149
},
150+
{
151+
"choice": "pixi",
152+
"help": {
153+
"description": "A fast package manager built on top of the conda ecosystem with lock files for reproducible environments. Supports both pixi.toml and pyproject.toml. Requires pixi to be installed as a system binary (see docs for installation instructions).",
154+
"more_information": "[Docs](https://pixi.sh/)"
155+
}
156+
},
150157
{
151158
"choice": "none",
152159
"help": {
@@ -173,7 +180,7 @@
173180
{
174181
"choice": "pyproject.toml",
175182
"help": {
176-
"description": "Modern configuration file for Python projects.",
183+
"description": "Modern configuration file for Python projects. Also supported by pixi when using tool.pixi sections.",
177184
"more_information": "[Docs](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/)"
178185
}
179186
},
@@ -190,6 +197,13 @@
190197
"description": "Format used by Pipenv",
191198
"more_information": "[Docs](https://pipenv.pypa.io/en/latest/pipfile.html)"
192199
}
200+
},
201+
{
202+
"choice": "pixi.toml",
203+
"help": {
204+
"description": "Configuration file used by pixi for managing dependencies and environments.",
205+
"more_information": "[Docs](https://pixi.sh/latest/reference/pixi_manifest/)"
206+
}
193207
}
194208
]
195209
},

ccds.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616
"conda",
1717
"pipenv",
1818
"uv",
19+
"pixi",
1920
"none"
2021
],
2122
"dependency_file": [
2223
"requirements.txt",
2324
"pyproject.toml",
2425
"environment.yml",
25-
"Pipfile"
26+
"Pipfile",
27+
"pixi.toml"
2628
],
2729
"pydata_packages": [
2830
"none",

ccds/hook_utils/dependencies.py

Lines changed: 134 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,60 @@ def write_python_version(python_version):
7373
f.write(tomlkit.dumps(doc))
7474

7575

76+
def _generate_pixi_dependencies_config(
77+
packages, pip_only_packages, repo_name, module_name, python_version, description
78+
):
79+
"""Generate pixi dependencies configuration data.
80+
81+
Returns:
82+
tuple: (conda_dependencies, pypi_dependencies, project_config)
83+
"""
84+
# Project configuration
85+
project_config = {
86+
"name": repo_name,
87+
"description": description
88+
or "A data science project created with cookiecutter-data-science",
89+
"version": "0.1.0",
90+
"channels": ["conda-forge"],
91+
"platforms": ["linux-64", "osx-64", "osx-arm64", "win-64"],
92+
}
93+
94+
# Conda dependencies
95+
conda_dependencies = {"python": f"~={python_version}.0"}
96+
97+
# Filter out pip and pip-only packages from conda dependencies
98+
conda_packages = [
99+
p for p in sorted(packages) if p not in pip_only_packages and p != "pip"
100+
]
101+
for p in conda_packages:
102+
conda_dependencies[p] = "*"
103+
104+
# PyPI dependencies
105+
has_pip_packages = any(p in pip_only_packages for p in packages)
106+
pypi_dependencies = {}
107+
108+
if has_pip_packages:
109+
# Add pip to conda dependencies when we have PyPI packages
110+
conda_dependencies["pip"] = "*"
111+
for p in sorted(packages):
112+
if p in pip_only_packages:
113+
pypi_dependencies[p] = "*"
114+
115+
# Always add the module as editable PyPI dependency
116+
pypi_dependencies[module_name] = {"path": ".", "editable": True}
117+
118+
return conda_dependencies, pypi_dependencies, project_config
119+
120+
76121
def write_dependencies(
77-
dependencies, packages, pip_only_packages, repo_name, module_name, python_version
122+
dependencies,
123+
packages,
124+
pip_only_packages,
125+
repo_name,
126+
module_name,
127+
python_version,
128+
environment_manager=None,
129+
description=None,
78130
):
79131
if dependencies == "requirements.txt":
80132
with open(dependencies, "w") as f:
@@ -88,8 +140,43 @@ def write_dependencies(
88140
elif dependencies == "pyproject.toml":
89141
with open(dependencies, "r") as f:
90142
doc = tomlkit.parse(f.read())
91-
doc["project"].add("dependencies", sorted(packages))
92-
doc["project"]["dependencies"].multiline(True)
143+
144+
# If using pixi, add pixi-specific configuration
145+
if environment_manager == "pixi":
146+
# Add pixi project configuration
147+
if "tool" not in doc:
148+
doc["tool"] = tomlkit.table()
149+
if "pixi" not in doc["tool"]:
150+
doc["tool"]["pixi"] = tomlkit.table()
151+
152+
# Generate pixi configuration using helper function
153+
conda_deps, pypi_deps, project_config = _generate_pixi_dependencies_config(
154+
packages,
155+
pip_only_packages,
156+
repo_name,
157+
module_name,
158+
python_version,
159+
description,
160+
)
161+
162+
# Add project configuration
163+
doc["tool"]["pixi"]["project"] = tomlkit.table()
164+
for key, value in project_config.items():
165+
doc["tool"]["pixi"]["project"][key] = value
166+
167+
# Add conda dependencies
168+
doc["tool"]["pixi"]["dependencies"] = tomlkit.table()
169+
for dep, version in conda_deps.items():
170+
doc["tool"]["pixi"]["dependencies"][dep] = version
171+
172+
# Add PyPI dependencies
173+
doc["tool"]["pixi"]["pypi-dependencies"] = tomlkit.table()
174+
for dep, version in pypi_deps.items():
175+
doc["tool"]["pixi"]["pypi-dependencies"][dep] = version
176+
else:
177+
# Standard pyproject.toml dependencies
178+
doc["project"].add("dependencies", sorted(packages))
179+
doc["project"]["dependencies"].multiline(True)
93180

94181
with open(dependencies, "w") as f:
95182
f.write(tomlkit.dumps(doc))
@@ -122,3 +209,47 @@ def write_dependencies(
122209
lines += ["", "[requires]", f'python_version = "{python_version}"']
123210

124211
f.write("\n".join(lines))
212+
213+
elif dependencies == "pixi.toml":
214+
# Generate pixi configuration using helper function
215+
conda_deps, pypi_deps, project_config = _generate_pixi_dependencies_config(
216+
packages,
217+
pip_only_packages,
218+
repo_name,
219+
module_name,
220+
python_version,
221+
description,
222+
)
223+
224+
with open(dependencies, "w") as f:
225+
lines = ["[project]"]
226+
227+
# Add project configuration
228+
for key, value in project_config.items():
229+
if isinstance(value, str):
230+
lines.append(f'{key} = "{value}"')
231+
elif isinstance(value, list):
232+
lines.append(f"{key} = {value}")
233+
else:
234+
lines.append(f'{key} = "{value}"')
235+
236+
lines.append("")
237+
lines.append("[dependencies]")
238+
239+
# Add conda dependencies
240+
for dep, version in conda_deps.items():
241+
lines.append(f'{dep} = "{version}"')
242+
243+
# Add PyPI dependencies if any
244+
if pypi_deps:
245+
lines.append("")
246+
lines.append("[pypi-dependencies]")
247+
for dep, config in pypi_deps.items():
248+
if isinstance(config, dict):
249+
# Handle editable local package
250+
lines.append(f'{dep} = {{ path = ".", editable = true }}')
251+
else:
252+
lines.append(f'{dep} = "{config}"')
253+
254+
f.write("\n".join(lines))
255+
f.write("\n")

hooks/post_gen_project.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@
9292
repo_name="{{ cookiecutter.repo_name }}",
9393
module_name="{{ cookiecutter.module_name }}",
9494
python_version="{{ cookiecutter.python_version_number }}",
95+
environment_manager="{{ cookiecutter.environment_manager }}",
96+
description="{{ cookiecutter.description }}",
9597
)
9698

9799
write_python_version("{{ cookiecutter.python_version_number }}")

tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ def _is_valid(config):
5252
config["environment_manager"] != "conda"
5353
):
5454
return False
55+
# pixi is the only valid env manager for pixi.toml
56+
if (config["dependency_file"] == "pixi.toml") and (
57+
config["environment_manager"] != "pixi"
58+
):
59+
return False
60+
# pixi supports both pixi.toml and pyproject.toml
61+
if (config["environment_manager"] == "pixi") and (
62+
config["dependency_file"] not in ["pixi.toml", "pyproject.toml"]
63+
):
64+
return False
5565
return True
5666

5767
# remove invalid configs

tests/pixi_harness.sh

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/bin/bash
2+
set -ex
3+
4+
PROJECT_NAME=$(basename $1)
5+
CCDS_ROOT=$(dirname $0)
6+
MODULE_NAME=$2
7+
8+
# Check if pixi is installed
9+
if ! command -v pixi &> /dev/null; then
10+
echo "pixi is not installed. Please install pixi first:"
11+
echo " macOS/Linux: curl -fsSL https://pixi.sh/install.sh | sh"
12+
echo " Windows: powershell -ExecutionPolicy ByPass -c \"irm -useb https://pixi.sh/install.ps1 | iex\""
13+
exit 1
14+
fi
15+
16+
# Configure exit / teardown behavior
17+
function finish {
18+
# Cleanup pixi environment
19+
if [ -d ".pixi" ]; then
20+
rm -rf .pixi
21+
fi
22+
if [ -f "pixi.lock" ]; then
23+
rm -f pixi.lock
24+
fi
25+
}
26+
trap finish EXIT
27+
28+
# Source the steps in the test
29+
source $CCDS_ROOT/test_functions.sh
30+
31+
# Navigate to the generated project and run make commands
32+
cd $1
33+
34+
make
35+
make create_environment
36+
make requirements
37+
38+
# Run pixi-specific tests with simpler commands
39+
pixi run python --version
40+
echo "Testing basic import..."
41+
pixi run python -c "import $MODULE_NAME"
42+
43+
# Test config importable if scaffolded
44+
if [ -f "$MODULE_NAME/config.py" ]; then
45+
echo "Testing config import..."
46+
pixi run python -c "from $MODULE_NAME import config"
47+
fi
48+
49+
# Run linting and formatting through pixi
50+
pixi run make lint
51+
pixi run make format
52+
53+
# Custom pixi test function to avoid issues with test_functions.sh
54+
# Check that python is available in pixi environment
55+
echo "Testing pixi python availability..."
56+
if pixi run python -c "import sys" > /dev/null 2>&1; then
57+
echo "Python is available in pixi environment"
58+
else
59+
echo "ERROR: Python not available in pixi environment"
60+
exit 1
61+
fi
62+
63+
echo "All done!"

tests/test_creation.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ def verify_makefile_commands(root, config):
171171
harness_path = test_path / "pipenv_harness.sh"
172172
elif config["environment_manager"] == "uv":
173173
harness_path = test_path / "uv_harness.sh"
174+
elif config["environment_manager"] == "pixi":
175+
harness_path = test_path / "pixi_harness.sh"
174176
elif config["environment_manager"] == "none":
175177
return True
176178
else:

0 commit comments

Comments
 (0)