Skip to content

Commit 9b58d74

Browse files
ctruedenclaude
andcommitted
Port more of the builder+tool subsystem
Co-authored-by: Claude <noreply@anthropic.com>
1 parent a9a92f0 commit 9b58d74

13 files changed

Lines changed: 584 additions & 301 deletions

File tree

src/appose/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,8 @@ def task_listener(event):
220220
from .environment import Environment
221221
from .shm import NDArray, SharedMemory # noqa: F401
222222

223+
__version__ = "0.7.3.dev0"
224+
223225

224226
def base(directory: Path) -> SimpleBuilder:
225227
"""

src/appose/builder/mamba.py

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,19 +78,91 @@ def build(self) -> Environment:
7878
Raises:
7979
BuildException: If the build fails
8080
"""
81+
from ..tool.mamba import Mamba
82+
8183
env_dir = self._env_dir()
8284

85+
# Check for incompatible existing environments
86+
if (env_dir / ".pixi").is_dir():
87+
raise BuildException(
88+
self,
89+
f"Cannot use MambaBuilder: environment already managed by Pixi at {env_dir}",
90+
)
91+
if (env_dir / "pyvenv.cfg").exists():
92+
raise BuildException(
93+
self,
94+
f"Cannot use MambaBuilder: environment already managed by uv/venv at {env_dir}",
95+
)
96+
97+
# Create Mamba tool instance early so it's available for wrapping
98+
mamba = Mamba()
99+
83100
# Is this env_dir an already-existing conda directory?
84101
is_conda_dir = (env_dir / "conda-meta").is_dir()
85102
if is_conda_dir:
86-
# Environment already exists, just wrap it.
87-
return self._create_environment(env_dir)
88-
89-
# TODO: Implement actual Mamba environment building for new environments
90-
raise NotImplementedError(
91-
"MambaBuilder.build() is not yet fully implemented. "
92-
"Currently only supports wrapping existing environments."
103+
# Environment already exists, just wrap it
104+
return self._create_environment(env_dir, mamba)
105+
106+
# Building a new environment - config content is required
107+
if self.source_content is None:
108+
raise BuildException(
109+
self, "No source specified for MambaBuilder. Use .file() or .content()"
110+
)
111+
112+
# Infer scheme if not explicitly set
113+
if self.scheme is None:
114+
self.scheme = self._scheme().name()
115+
116+
if self.scheme != "environment.yml":
117+
raise BuildException(
118+
self,
119+
f"MambaBuilder only supports environment.yml scheme, got: {self.scheme}",
120+
)
121+
122+
# Set up progress/output consumers
123+
mamba.set_output_consumer(
124+
lambda msg: [sub(msg) for sub in self.output_subscribers]
125+
)
126+
mamba.set_error_consumer(
127+
lambda msg: [sub(msg) for sub in self.error_subscribers]
93128
)
129+
mamba.set_download_progress_consumer(
130+
lambda cur, max: [
131+
sub("Downloading micromamba", cur, max)
132+
for sub in self.progress_subscribers
133+
]
134+
)
135+
136+
# Pass along intended build configuration
137+
mamba.set_env_vars(self.env_vars_dict)
138+
mamba.set_flags(self.flags_list)
139+
140+
# Check for unsupported features
141+
if self.channels_list:
142+
raise BuildException(
143+
self,
144+
"MambaBuilder does not yet support programmatic channel configuration. "
145+
"Please specify channels in your environment.yml file.",
146+
)
147+
148+
try:
149+
mamba.install()
150+
151+
# Two-step build: create empty env, write config, then update
152+
# Step 1: Create empty environment
153+
mamba.create(env_dir)
154+
155+
# Step 2: Write environment.yml to envDir
156+
env_yaml = env_dir / "environment.yml"
157+
env_yaml.write_text(self.source_content, encoding="utf-8")
158+
159+
# Step 3: Update environment from yml
160+
mamba.update(env_dir, env_yaml)
161+
162+
return self._create_environment(env_dir, mamba)
163+
164+
except (IOError, KeyboardInterrupt) as e:
165+
raise BuildException(self, cause=e)
94166

95167
def wrap(self, env_dir: str | Path) -> Environment:
96168
"""
@@ -120,20 +192,23 @@ def wrap(self, env_dir: str | Path) -> Environment:
120192
self.base(env_path)
121193
return self.build()
122194

123-
def _create_environment(self, env_dir: Path) -> Environment:
195+
def _create_environment(self, env_dir: Path, mamba) -> Environment:
124196
"""
125197
Creates an Environment for the given Mamba/conda directory.
126198
127199
Args:
128200
env_dir: The Mamba/conda environment directory
201+
mamba: The Mamba tool instance
129202
130203
Returns:
131204
Environment configured for this Mamba/conda installation
132205
"""
133-
base = str(env_dir.absolute())
134-
# micromamba command - will be found via system PATH or installed micromamba
135-
launch_args = ["micromamba", "run", "-p", base]
136-
bin_paths = [str(env_dir / "bin")]
206+
# Convert to absolute path for consistency
207+
env_dir_abs = env_dir.absolute()
208+
base = str(env_dir_abs)
209+
# Use the installed micromamba command (full path)
210+
launch_args = [mamba.command, "run", "-p", base]
211+
bin_paths = [str(env_dir_abs / "bin")]
137212

138213
return self._create_env(base, bin_paths, launch_args)
139214

src/appose/builder/pixi.py

Lines changed: 148 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -105,35 +105,148 @@ def build(self) -> Environment:
105105
Raises:
106106
BuildException: If the build fails
107107
"""
108+
from ..tool.pixi import Pixi
109+
108110
env_dir = self._env_dir()
109111

110-
# Check if this is already a pixi project.
111-
is_pixi_dir = (
112-
(env_dir / "pixi.toml").is_file()
113-
or (env_dir / "pyproject.toml").is_file()
114-
or (env_dir / ".pixi").is_dir()
112+
# Check for incompatible existing environments
113+
if (env_dir / "conda-meta").exists() and not (env_dir / ".pixi").exists():
114+
raise BuildException(
115+
self,
116+
f"Cannot use PixiBuilder: environment already managed by Mamba/Conda at {env_dir}",
117+
)
118+
if (env_dir / "pyvenv.cfg").exists():
119+
raise BuildException(
120+
self,
121+
f"Cannot use PixiBuilder: environment already managed by uv/venv at {env_dir}",
122+
)
123+
124+
pixi = Pixi()
125+
126+
# Set up progress/output consumers
127+
pixi.set_output_consumer(
128+
lambda msg: [sub(msg) for sub in self.output_subscribers]
115129
)
116-
117-
if (
118-
is_pixi_dir
119-
and self.source_content is None
120-
and not self.conda_packages
121-
and not self.pypi_packages
122-
):
123-
# Environment already exists, just use it.
124-
return self._create_environment(env_dir)
125-
126-
# Handle source-based build (file or content).
127-
if self.source_content is not None:
128-
if is_pixi_dir:
129-
# Already initialized, just use it.
130-
return self._create_environment(env_dir)
131-
132-
# TODO: Implement actual Pixi environment building for new environments
133-
raise NotImplementedError(
134-
"PixiBuilder.build() is not yet fully implemented. "
135-
"Currently only supports wrapping existing environments."
130+
pixi.set_error_consumer(
131+
lambda msg: [sub(msg) for sub in self.error_subscribers]
136132
)
133+
pixi.set_download_progress_consumer(
134+
lambda cur, max: [
135+
sub("Downloading pixi", cur, max) for sub in self.progress_subscribers
136+
]
137+
)
138+
139+
# Pass along intended build configuration
140+
pixi.set_env_vars(self.env_vars_dict)
141+
pixi.set_flags(self.flags_list)
142+
143+
try:
144+
pixi.install()
145+
146+
# Check if this is already a pixi project
147+
is_pixi_dir = (
148+
(env_dir / "pixi.toml").is_file()
149+
or (env_dir / "pyproject.toml").is_file()
150+
or (env_dir / ".pixi").is_dir()
151+
)
152+
153+
if (
154+
is_pixi_dir
155+
and self.source_content is None
156+
and not self.conda_packages
157+
and not self.pypi_packages
158+
):
159+
# Environment already exists, just use it
160+
return self._create_environment(env_dir, pixi)
161+
162+
# Handle source-based build (file or content)
163+
if self.source_content is not None:
164+
# Infer scheme if not explicitly set
165+
if self.scheme is None:
166+
self.scheme = self._scheme().name()
167+
168+
if not env_dir.exists():
169+
env_dir.mkdir(parents=True, exist_ok=True)
170+
171+
if self.scheme == "pixi.toml":
172+
# Write pixi.toml to envDir
173+
pixi_toml_file = env_dir / "pixi.toml"
174+
pixi_toml_file.write_text(self.source_content, encoding="utf-8")
175+
elif self.scheme == "pyproject.toml":
176+
# Write pyproject.toml to envDir (Pixi natively supports it)
177+
pyproject_toml_file = env_dir / "pyproject.toml"
178+
pyproject_toml_file.write_text(
179+
self.source_content, encoding="utf-8"
180+
)
181+
elif self.scheme == "environment.yml":
182+
# Write environment.yml and import
183+
environment_yaml_file = env_dir / "environment.yml"
184+
environment_yaml_file.write_text(
185+
self.source_content, encoding="utf-8"
186+
)
187+
# Only run init --import if pixi.toml doesn't exist yet
188+
# (importing creates pixi.toml, so this avoids "pixi.toml already exists" error)
189+
if not (env_dir / "pixi.toml").exists():
190+
pixi.exec(
191+
"init",
192+
"--import",
193+
str(environment_yaml_file.absolute()),
194+
str(env_dir.absolute()),
195+
)
196+
197+
# Add any programmatic channels to augment source file
198+
if self.channels_list:
199+
pixi.add_channels(env_dir, *self.channels_list)
200+
else:
201+
# Programmatic package building
202+
if is_pixi_dir:
203+
# Already initialized, just use it
204+
return self._create_environment(env_dir, pixi)
205+
206+
if not env_dir.exists():
207+
env_dir.mkdir(parents=True, exist_ok=True)
208+
209+
pixi.init(env_dir)
210+
211+
# Fail fast for vacuous environments
212+
if not self.conda_packages and not self.pypi_packages:
213+
raise BuildException(
214+
self,
215+
"Cannot build empty environment programmatically. "
216+
"Either provide a source file via Appose.pixi(source), or add packages via .conda() or .pypi().",
217+
)
218+
219+
# Add channels
220+
if self.channels_list:
221+
pixi.add_channels(env_dir, *self.channels_list)
222+
223+
# Add conda packages
224+
if self.conda_packages:
225+
pixi.add_conda_packages(env_dir, *self.conda_packages)
226+
227+
# Add PyPI packages
228+
if self.pypi_packages:
229+
pixi.add_pypi_packages(env_dir, *self.pypi_packages)
230+
231+
# Verify that appose was included when building programmatically
232+
prog_build = bool(self.conda_packages) or bool(self.pypi_packages)
233+
if prog_build:
234+
import re
235+
236+
has_appose = any(
237+
re.match(r"^appose\b", pkg) for pkg in self.conda_packages
238+
) or any(re.match(r"^appose\b", pkg) for pkg in self.pypi_packages)
239+
if not has_appose:
240+
raise BuildException(
241+
self,
242+
"Appose package must be explicitly included when building programmatically. "
243+
'Add .conda("appose") or .pypi("appose") to your builder.',
244+
)
245+
246+
return self._create_environment(env_dir, pixi)
247+
248+
except (IOError, KeyboardInterrupt) as e:
249+
raise BuildException(self, cause=e)
137250

138251
def wrap(self, env_dir: str | Path) -> Environment:
139252
"""
@@ -172,31 +285,34 @@ def wrap(self, env_dir: str | Path) -> Environment:
172285
self.base(env_path)
173286
return self.build()
174287

175-
def _create_environment(self, env_dir: Path) -> Environment:
288+
def _create_environment(self, env_dir: Path, pixi) -> Environment:
176289
"""
177290
Creates an Environment for the given Pixi directory.
178291
179292
Args:
180293
env_dir: The Pixi environment directory
294+
pixi: The Pixi tool instance
181295
182296
Returns:
183297
Environment configured for this Pixi installation
184298
"""
185-
base = str(env_dir.absolute())
299+
# Convert to absolute path for consistency
300+
env_dir_abs = env_dir.absolute()
301+
base = str(env_dir_abs)
186302

187303
# Check which manifest file exists (pyproject.toml takes precedence)
188-
manifest_file = env_dir / "pyproject.toml"
304+
manifest_file = env_dir_abs / "pyproject.toml"
189305
if not manifest_file.exists():
190-
manifest_file = env_dir / "pixi.toml"
306+
manifest_file = env_dir_abs / "pixi.toml"
191307

192-
# pixi command - will be found via system PATH or installed pixi
308+
# Use the installed pixi command (full path)
193309
launch_args = [
194-
"pixi",
310+
pixi.command,
195311
"run",
196312
"--manifest-path",
197313
str(manifest_file.absolute()),
198314
]
199-
bin_paths = [str(env_dir / ".pixi" / "envs" / "default" / "bin")]
315+
bin_paths = [str(env_dir_abs / ".pixi" / "envs" / "default" / "bin")]
200316

201317
return self._create_env(base, bin_paths, launch_args)
202318

0 commit comments

Comments
 (0)