Skip to content

Commit 324828a

Browse files
Jannis Leideljezdez
authored andcommitted
Add CondaWorkspaces project spec for conda.toml
Adds a new ProjectSpec for conda-workspaces, a conda subcommand plugin (`conda workspace`, `conda task`) whose on-disk format is a conda-native sibling of pixi.toml. Matches `conda.toml` with `[workspace]` and `pyproject.toml` with `[tool.conda.workspace]`. Re-uses two helpers from pixi.py rather than copying: * `extract_feature` becomes `extract_tasks`, parameterised by run-cmd and env-flag. Default args keep `Pixi.parse()` behaviour identical; `extract_feature` is kept as a compat shim. * `envs_from_lock` accepts both `version: 1` (conda.lock) and `version: 6` (pixi.lock); the rest of the rattler-lock schema is identical (see conda-workspaces' lockfile.py). A `pyproject.toml` with only `[tool.pixi.workspace]` stays with the existing Pixi spec; multiple specs already coexist on one project, so the conda-workspaces internal `tool.conda > tool.pixi` precedence is not encoded here. Tests cover positive/negative match (conda.toml, pyproject.toml, tasks-only, pixi-only), parse (envs, tasks, lockfile), the create roundtrip, registry membership, and a regression that Pixi's task cmd is unchanged after the refactor. Spec_doc points at https://conda-incubator.github.io/conda-workspaces/reference/conda-toml-spec/
1 parent 88c0dda commit 324828a

5 files changed

Lines changed: 410 additions & 15 deletions

File tree

src/projspec/proj/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
)
1616
from projspec.proj.conda_package import CondaRecipe, RattlerRecipe
1717
from projspec.proj.conda_project import CondaProject
18+
from projspec.proj.conda_workspaces import CondaWorkspaces
1819
from projspec.proj.data_dir import Data
1920
from projspec.proj.datapackage import DataPackage, DVCRepo
2021
from projspec.proj.dataworkflows import (
@@ -78,6 +79,7 @@
7879
# Conda
7980
"CondaRecipe",
8081
"CondaProject",
82+
"CondaWorkspaces",
8183
# Data
8284
"Data",
8385
"DataPackage",
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import os
2+
3+
import toml
4+
5+
from projspec.proj import ParseFailed, ProjectSpec
6+
from projspec.proj.pixi import envs_from_lock, extract_tasks
7+
from projspec.utils import AttrDict, PickleableTomlDecoder
8+
9+
10+
class CondaWorkspaces(ProjectSpec):
11+
"""A workspace using conda-workspaces (``conda.toml``).
12+
13+
conda-workspaces brings multi-environment workspace management and
14+
task execution to the conda CLI as a plugin (``conda workspace`` /
15+
``conda task``). The manifest is a conda-native sibling of
16+
pixi.toml; the lockfile (``conda.lock``) is rattler-lock v6 with
17+
a ``version: 1`` byte identifying it as conda-workspaces-owned.
18+
"""
19+
20+
icon = "🧰"
21+
spec_doc = (
22+
"https://conda-incubator.github.io/conda-workspaces/reference/conda-toml-spec/"
23+
)
24+
25+
def _load_meta(self) -> dict:
26+
"""Merge metadata from ``pyproject.toml`` (``[tool.conda]``) and ``conda.toml``.
27+
28+
``conda.toml`` wins on overlap, mirroring conda-workspaces' own
29+
precedence rule.
30+
"""
31+
meta = self.proj.pyproject.get("tool", {}).get("conda", {})
32+
if "conda.toml" in self.proj.basenames:
33+
try:
34+
with self.proj.fs.open(self.proj.basenames["conda.toml"], "rb") as f:
35+
meta = {
36+
**meta,
37+
**toml.loads(
38+
f.read().decode(), decoder=PickleableTomlDecoder()
39+
),
40+
}
41+
except (OSError, ValueError, UnicodeDecodeError, FileNotFoundError):
42+
pass
43+
return meta
44+
45+
def match(self) -> bool:
46+
# A bare conda.toml without [workspace] is permitted by the spec
47+
# as a tasks-only manifest; it does not constitute a workspace
48+
# on its own, so do not match it here.
49+
return bool(self._load_meta().get("workspace"))
50+
51+
def parse(self) -> None:
52+
from projspec.artifact.python_env import CondaEnv, LockFile
53+
from projspec.content.environment import Environment, Precision, Stack
54+
55+
meta = self._load_meta()
56+
if not meta.get("workspace"):
57+
raise ParseFailed
58+
59+
arts = AttrDict()
60+
conts = AttrDict()
61+
procs = AttrDict()
62+
commands = AttrDict()
63+
64+
run_cmd = ("conda", "task", "run")
65+
env_flag = "-e"
66+
67+
extract_tasks(meta, procs, commands, self.proj, run_cmd=run_cmd, env_flag=env_flag)
68+
if "environments" in meta and "feature" in meta:
69+
for env_name, details in meta["environments"].items():
70+
feat: dict = {}
71+
feats = set(
72+
details if isinstance(details, list) else details["features"]
73+
)
74+
for fname in feats:
75+
feat.update(meta["feature"].get(fname, {}))
76+
if isinstance(details, list) or not details.get("no-default-feature"):
77+
feat.update(meta)
78+
extract_tasks(
79+
feat,
80+
procs,
81+
commands,
82+
self.proj,
83+
env=env_name,
84+
run_cmd=run_cmd,
85+
env_flag=env_flag,
86+
)
87+
88+
if procs:
89+
arts["process"] = procs
90+
if commands:
91+
conts["commands"] = commands
92+
93+
# `envs-dir` is in the conda.toml spec but conda-workspaces' own
94+
# parser does not yet read it from TOML (uses the model default).
95+
# Reading it here keeps projspec aligned with the published spec.
96+
envs_dir = meta.get("workspace", {}).get("envs-dir", ".conda/envs")
97+
if "conda.lock" in self.proj.basenames:
98+
conts["environments"] = AttrDict()
99+
arts["conda_env"] = AttrDict()
100+
with self.proj.fs.open(self.proj.basenames["conda.lock"], "rb") as f:
101+
lock_envs = envs_from_lock(f)
102+
for env_name, details in lock_envs.items():
103+
arts["conda_env"][env_name] = CondaEnv(
104+
proj=self.proj,
105+
fn=f"{self.proj.url}/{envs_dir}/{env_name}",
106+
cmd=["conda", "workspace", "install", "-e", env_name],
107+
)
108+
conts["environments"][env_name] = Environment(
109+
proj=self.proj,
110+
packages=details["packages"],
111+
stack=Stack.CONDA,
112+
precision=Precision.LOCK,
113+
channels=details["channels"],
114+
)
115+
116+
arts["lock_file"] = LockFile(
117+
proj=self.proj,
118+
fn=f"{self.proj.url}/conda.lock",
119+
cmd=["conda", "workspace", "lock"],
120+
)
121+
122+
self._artifacts = arts
123+
self._contents = conts
124+
125+
@staticmethod
126+
def _create(path: str) -> None:
127+
name = os.path.basename(path)
128+
with open(f"{path}/conda.toml", "wt") as f:
129+
f.write(
130+
f"""[workspace]
131+
name = "{name}"
132+
channels = ["conda-forge"]
133+
platforms = ["osx-arm64", "linux-64", "win-64"]
134+
version = "0.1.0"
135+
136+
[tasks]
137+
hello = "echo 'hello world'"
138+
139+
[dependencies]
140+
python = ">=3.10"
141+
"""
142+
)

src/projspec/proj/pixi.py

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -182,30 +182,40 @@ def _create(path: str) -> None:
182182
)
183183

184184

185-
def extract_feature(
185+
def extract_tasks(
186186
meta: dict,
187187
procs: AttrDict,
188188
commands: AttrDict,
189-
pixi: Pixi,
189+
proj,
190190
env: str | None = None,
191+
run_cmd: tuple[str, ...] = ("pixi", "run"),
192+
env_flag: str = "--environment",
191193
):
192-
"""Consolidate metadata from 'features' to create commands and processes"""
194+
"""Consolidate metadata from 'features' to create commands and processes.
195+
196+
Shared by ``Pixi`` and ``CondaWorkspaces``: both formats share the
197+
same ``[tasks]`` / ``[target.<platform>.tasks]`` shape, only the
198+
runner CLI strings differ. ``run_cmd`` is the prefix for the
199+
runnable form (``pixi run`` vs ``conda task run``); ``env_flag`` is
200+
the flag used to target an environment (``--environment`` vs
201+
``-e``).
202+
"""
193203
from projspec.artifact.process import Process
194204
from projspec.content.executable import Command
195205

196206
for name, task in meta.get("tasks", {}).items():
197207
if env:
198208
name = f"{name}.{env}"
199-
cmd = ["pixi", "run", name]
209+
cmd = [*run_cmd, name]
200210
if env:
201-
cmd.extend(["--environment", env])
202-
art = Process(proj=pixi.proj, cmd=cmd)
211+
cmd.extend([env_flag, env])
212+
art = Process(proj=proj, cmd=cmd)
203213
procs[name] = art
204214
# tasks without a command are aliases
205215
cmd = task.get("cmd", "") if isinstance(task, dict) else task
206-
# NB: these may have dependencies on other tasks and envs, but pixi
207-
# manages those.
208-
commands[name] = Command(proj=pixi.proj, cmd=cmd)
216+
# NB: these may have dependencies on other tasks and envs, but the
217+
# runner manages those.
218+
commands[name] = Command(proj=proj, cmd=cmd)
209219
for platform, v in meta.get("target", {}).items():
210220
for name, task in v.get("tasks", {}).items():
211221
if env:
@@ -215,20 +225,36 @@ def extract_feature(
215225
if isinstance(task, dict)
216226
else task
217227
)
218-
commands[name] = Command(proj=pixi.proj, cmd=cmd)
228+
commands[name] = Command(proj=proj, cmd=cmd)
219229
if platform == this_platform():
220230
# only commands on the current platform can be executed
221-
cmd = ["pixi", "run", name]
231+
cmd = [*run_cmd, name]
222232
if env:
223-
cmd.extend(["--environment", env])
224-
art = Process(proj=pixi.proj, cmd=cmd)
233+
cmd.extend([env_flag, env])
234+
art = Process(proj=proj, cmd=cmd)
225235
procs[name] = art
226236
commands[name].artifacts.add(art)
227237

228238

239+
def extract_feature(
240+
meta: dict,
241+
procs: AttrDict,
242+
commands: AttrDict,
243+
pixi: "Pixi",
244+
env: str | None = None,
245+
):
246+
"""Backwards-compatible wrapper around :func:`extract_tasks` for ``Pixi``."""
247+
extract_tasks(meta, procs, commands, pixi.proj, env=env)
248+
249+
229250
def envs_from_lock(infile) -> dict:
230-
"""Extract the environments info from a pixi (yaml) lock file"""
231-
# Developed for pixi format format 6
251+
"""Extract the environments info from a rattler-lock-shaped YAML lock file.
252+
253+
Developed for pixi lockfile format 6 (``pixi.lock``). Also accepts
254+
conda-workspaces' ``conda.lock``, which is the same schema with an
255+
on-disk ``version: 1`` byte (see
256+
https://conda-incubator.github.io/conda-workspaces/reference/conda-toml-spec/#lockfile-relationship).
257+
"""
232258
import yaml
233259

234260
data = yaml.safe_load(infile)

0 commit comments

Comments
 (0)