Skip to content

Commit ef9436a

Browse files
ctruedenclaude
andcommitted
Track builder state to skip redundant rebuilds
Write builder configuration to appose.json after each successful build, and compare it on subsequent builds to skip unnecessary reinstallation. Each builder (Pixi, Mamba, Uv) contributes its specific fields via _add_state_fields, with base fields (content, scheme, channels, flags, envVars) provided by BaseBuilder. Programmatic pixi builds now wipe envDir before reinitializing to avoid conflicts with stale state. As per apposed/appose-java@8401825 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 86c84b8 commit ef9436a

5 files changed

Lines changed: 115 additions & 75 deletions

File tree

src/appose/builder/__init__.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from __future__ import annotations
1414

15+
import json
1516
import os
1617
import shutil
1718
import sys
@@ -508,6 +509,32 @@ def flags(self, *flags: str) -> BaseBuilder:
508509

509510
# -- Helper methods --
510511

512+
def _add_state_fields(self, state: dict) -> None:
513+
"""Populate state dict for appose.json comparison. Subclasses override, calling super first."""
514+
state["content"] = self._content
515+
state["scheme"] = self._scheme.name() if self._scheme is not None else None
516+
state["channels"] = list(self._channels)
517+
state["flags"] = list(self._flags)
518+
state["envVars"] = dict(sorted(self._env_vars.items()))
519+
520+
def _build_state_string(self) -> str:
521+
"""Build deterministic JSON string of current builder state."""
522+
state: dict = {"builder": self.env_type()}
523+
self._add_state_fields(state)
524+
return json.dumps(state, separators=(",", ":"))
525+
526+
def _is_up_to_date(self, env_dir: Path) -> bool:
527+
"""Returns True if appose.json matches current builder state."""
528+
appose_json = env_dir / "appose.json"
529+
if not appose_json.is_file():
530+
return False
531+
return appose_json.read_text(encoding="utf-8") == self._build_state_string()
532+
533+
def _write_appose_state_file(self, env_dir: Path) -> None:
534+
"""Write current builder state to appose.json after a successful build."""
535+
appose_json = env_dir / "appose.json"
536+
appose_json.write_text(self._build_state_string(), encoding="utf-8")
537+
511538
def _resolve_env_dir(self) -> Path:
512539
"""Determine the environment directory path."""
513540
if self._env_dir:

src/appose/builder/mamba.py

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import shutil
1112
from pathlib import Path
1213

1314
from . import BaseBuilder, BuildException, Builder, BuilderFactory
@@ -50,31 +51,19 @@ def build(self) -> Environment:
5051
f"Cannot use MambaBuilder: environment already managed by uv/venv at {env_dir}",
5152
)
5253

53-
# Create Mamba tool instance early so it's available for wrapping
54-
mamba = Mamba()
55-
56-
# Is this env_dir an already-existing conda directory?
57-
is_conda_dir = (env_dir / "conda-meta").is_dir()
58-
if is_conda_dir:
59-
# Environment already exists, just wrap it
60-
return self._create_environment(mamba, env_dir)
61-
62-
# Building a new environment - config content is required
63-
if self._content is None:
64-
raise BuildException(
65-
self, "No source specified for MambaBuilder. Use .file() or .content()"
66-
)
67-
68-
# Infer scheme if not explicitly set
69-
if self._scheme is None:
54+
# Infer scheme from content if not explicitly set.
55+
if self._content is not None and self._scheme is None:
7056
self._scheme = scheme_from_content(self._content)
7157

72-
if self._scheme.name() != "environment.yml":
58+
if self._scheme is not None and self._scheme.name() != "environment.yml":
7359
raise BuildException(
7460
self,
75-
f"MambaBuilder only supports environment.yml scheme, got: {self.scheme}",
61+
f"MambaBuilder only supports environment.yml scheme, got: {self._scheme.name()}",
7662
)
7763

64+
# Create Mamba tool instance early so it's available for wrapping
65+
mamba = Mamba()
66+
7867
# Set up progress/output consumers
7968
mamba.set_output_consumer(
8069
lambda msg: [sub(msg) for sub in self._output_subscribers]
@@ -104,6 +93,24 @@ def build(self) -> Environment:
10493
try:
10594
mamba.install()
10695

96+
# If the env state matches our current configuration,
97+
# skip all package management and return immediately.
98+
if self._is_up_to_date(env_dir):
99+
return self._create_environment(mamba, env_dir)
100+
101+
if self._content is None:
102+
# No source specified; wrap externally-managed conda env if present.
103+
if (env_dir / "conda-meta").is_dir():
104+
return self._create_environment(mamba, env_dir)
105+
raise BuildException(
106+
self,
107+
"No source specified for MambaBuilder. Use .file() or .content()",
108+
)
109+
110+
# Wipe existing env directory to avoid conflicts with stale state.
111+
if env_dir.exists():
112+
shutil.rmtree(env_dir)
113+
107114
# Two-step build: create empty env, write config, then update
108115
# Step 1: Create empty environment
109116
mamba.create(env_dir)
@@ -115,6 +122,7 @@ def build(self) -> Environment:
115122
# Step 3: Update environment from yml
116123
mamba.update(env_dir, env_yaml)
117124

125+
self._write_appose_state_file(env_dir)
118126
return self._create_environment(mamba, env_dir)
119127

120128
except (IOError, KeyboardInterrupt) as e:

src/appose/builder/pixi.py

Lines changed: 44 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import shutil
1112
from pathlib import Path
1213

1314
from . import BaseBuilder, BuildException, Builder, BuilderFactory
@@ -75,6 +76,12 @@ def pypi(self, *packages: str) -> PixiBuilder:
7576
def env_type(self) -> str:
7677
return "pixi"
7778

79+
def _add_state_fields(self, state: dict) -> None:
80+
super()._add_state_fields(state)
81+
state["condaPackages"] = list(self._conda_packages)
82+
state["pypiPackages"] = list(self._pypi_packages)
83+
state["pixiEnvironment"] = self._pixi_environment
84+
7885
def build(self) -> Environment:
7986
"""
8087
Build the Pixi environment.
@@ -134,21 +141,10 @@ def build(self) -> Environment:
134141
try:
135142
pixi.install()
136143

137-
# Check if this is already a pixi project
138-
is_pixi_dir = (
139-
(env_dir / "pixi.toml").is_file()
140-
or (env_dir / "pyproject.toml").is_file()
141-
or (env_dir / ".pixi").is_dir()
142-
)
143-
144-
if (
145-
is_pixi_dir
146-
and self._content is None
147-
and not self._conda_packages
148-
and not self._pypi_packages
149-
):
150-
# Environment already exists, just use it
151-
return self._create_environment(pixi, env_dir)
144+
# If the env state matches our current configuration,
145+
# skip all package management and return immediately.
146+
if self._is_up_to_date(env_dir):
147+
return self._build_pixi_environment(pixi, env_dir)
152148

153149
# Handle source-based build (file or content)
154150
if self._content is not None:
@@ -181,13 +177,10 @@ def build(self) -> Environment:
181177
if self._channels:
182178
pixi.add_channels(env_dir, *self._channels)
183179
else:
184-
# Programmatic package building
185-
if is_pixi_dir:
186-
# Already initialized, just use it
187-
return self._create_environment(pixi, env_dir)
188-
189-
if not env_dir.exists():
190-
env_dir.mkdir(parents=True, exist_ok=True)
180+
# Programmatic package building: wipe and reinitialize to avoid stale state.
181+
if env_dir.exists():
182+
shutil.rmtree(env_dir)
183+
env_dir.mkdir(parents=True, exist_ok=True)
191184

192185
pixi.init(env_dir)
193186

@@ -212,21 +205,21 @@ def build(self) -> Environment:
212205
pixi.add_pypi_packages(env_dir, *self._pypi_packages)
213206

214207
# Verify that appose was included when building programmatically
215-
prog_build = bool(self._conda_packages) or bool(self._pypi_packages)
216-
if prog_build:
217-
import re
218-
219-
has_appose = any(
220-
re.match(r"^appose\b", pkg) for pkg in self._conda_packages
221-
) or any(re.match(r"^appose\b", pkg) for pkg in self._pypi_packages)
222-
if not has_appose:
223-
raise BuildException(
224-
self,
225-
"Appose package must be explicitly included when building programmatically. "
226-
'Add .conda("appose") or .pypi("appose") to your builder.',
227-
)
208+
import re
228209

229-
return self._create_environment(pixi, env_dir)
210+
has_appose = any(
211+
re.match(r"^appose\b", pkg) for pkg in self._conda_packages
212+
) or any(re.match(r"^appose\b", pkg) for pkg in self._pypi_packages)
213+
if not has_appose:
214+
raise BuildException(
215+
self,
216+
"Appose package must be explicitly included when building programmatically. "
217+
'Add .conda("appose") or .pypi("appose") to your builder.',
218+
)
219+
220+
self._run_pixi_install(pixi, env_dir)
221+
self._write_appose_state_file(env_dir)
222+
return self._build_pixi_environment(pixi, env_dir)
230223

231224
except (IOError, KeyboardInterrupt) as e:
232225
raise BuildException(self, cause=e)
@@ -268,13 +261,25 @@ def wrap(self, env_dir: str | Path) -> Environment:
268261
self.base(env_path)
269262
return self.build()
270263

271-
def _create_environment(self, pixi: Pixi, env_dir: Path) -> Environment:
264+
def _run_pixi_install(self, pixi: Pixi, env_dir: Path) -> None:
265+
"""Run pixi install for the given environment directory."""
266+
env_dir_abs = env_dir.absolute()
267+
manifest_file = env_dir_abs / "pyproject.toml"
268+
if not manifest_file.exists():
269+
manifest_file = env_dir_abs / "pixi.toml"
270+
271+
install_cmd = ["install", "--manifest-path", str(manifest_file.absolute())]
272+
if self._pixi_environment is not None:
273+
install_cmd.extend(["--environment", self._pixi_environment])
274+
pixi.exec(*install_cmd)
275+
276+
def _build_pixi_environment(self, pixi: Pixi, env_dir: Path) -> Environment:
272277
"""
273-
Create an Environment for the given Pixi directory.
278+
Construct an Environment object for the given Pixi directory.
274279
275280
Args:
276-
env_dir: The Pixi environment directory
277281
pixi: The Pixi tool instance
282+
env_dir: The Pixi environment directory
278283
279284
Returns:
280285
Environment configured for this Pixi installation
@@ -287,12 +292,6 @@ def _create_environment(self, pixi: Pixi, env_dir: Path) -> Environment:
287292
if not manifest_file.exists():
288293
manifest_file = env_dir_abs / "pixi.toml"
289294

290-
# Ensure the pixi environment is fully installed.
291-
install_cmd = ["install", "--manifest-path", str(manifest_file.absolute())]
292-
if self._pixi_environment is not None:
293-
install_cmd.extend(["--environment", self._pixi_environment])
294-
pixi.exec(*install_cmd)
295-
296295
base = str(env_dir_abs)
297296
env_name = self._pixi_environment or "default"
298297

src/appose/builder/uv.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ def include(self, *packages: str) -> UvBuilder:
5858
def env_type(self) -> str:
5959
return "uv"
6060

61+
def _add_state_fields(self, state: dict) -> None:
62+
super()._add_state_fields(state)
63+
state["pythonVersion"] = self._python_version
64+
state["packages"] = list(self._packages)
65+
6166
def build(self) -> Environment:
6267
"""
6368
Build the uv environment.
@@ -120,13 +125,16 @@ def build(self) -> Environment:
120125
try:
121126
uv.install()
122127

123-
# Check if this is already a uv virtual environment
124-
is_uv_venv = (env_dir / "pyvenv.cfg").is_file()
125-
126-
if is_uv_venv and self._content is None and not self._packages:
127-
# Environment already exists and no new config/packages, just use it
128+
# If the env state matches our current configuration,
129+
# skip all package management and return immediately.
130+
if self._is_up_to_date(env_dir):
128131
return self._create_environment(env_dir)
129132

133+
# Determine whether the venv already exists.
134+
is_venv_built = (env_dir / "pyvenv.cfg").is_file() or (
135+
env_dir / ".venv"
136+
).is_dir()
137+
130138
# Handle source-based build (file or content)
131139
if self._content is not None:
132140
if self._scheme.name() == "pyproject.toml":
@@ -144,7 +152,7 @@ def build(self) -> Environment:
144152
else:
145153
# Handle requirements.txt - traditional venv + pip install
146154
# Create virtual environment if it doesn't exist
147-
if not is_uv_venv:
155+
if not is_venv_built:
148156
uv.create_venv(env_dir, self._python_version)
149157

150158
# Write requirements.txt to envDir
@@ -155,7 +163,7 @@ def build(self) -> Environment:
155163
uv.pip_install_from_requirements(env_dir, str(reqs_file.absolute()))
156164
else:
157165
# Programmatic package building
158-
if not is_uv_venv:
166+
if not is_venv_built:
159167
# Create virtual environment
160168
uv.create_venv(env_dir, self._python_version)
161169

@@ -167,6 +175,7 @@ def build(self) -> Environment:
167175
all_packages.append("appose")
168176
uv.pip_install(env_dir, *all_packages)
169177

178+
self._write_appose_state_file(env_dir)
170179
return self._create_environment(env_dir)
171180

172181
except (IOError, KeyboardInterrupt) as e:

tests/builder/test_wrap.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,7 @@ def test_wrap_uv():
8787
"uv environment should have no special launcher"
8888
)
8989
finally:
90-
if pyvenv_cfg.exists():
91-
pyvenv_cfg.unlink()
92-
if uv_dir.exists():
93-
uv_dir.rmdir()
90+
delete_recursively(uv_dir)
9491

9592

9693
def test_wrap_custom():

0 commit comments

Comments
 (0)