Source code for conda_workspaces.manifests
"""Manifest detection and parser registry (workspaces and tasks).
Search order (same for workspaces and tasks)
--------------------------------------------
1. ``conda.toml`` -- conda-native manifest format
2. ``pixi.toml`` -- pixi-native format (compatibility)
3. ``pyproject.toml`` -- pixi or conda tables embedded
The first file that exists *and* contains the relevant configuration wins.
"""
from __future__ import annotations
import os
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING
from ..exceptions import (
NoTaskFileError,
WorkspaceNotFoundError,
WorkspaceParseError,
)
from .pixi_toml import PixiTomlParser
from .pyproject_toml import PyprojectTomlParser
from .toml import CondaTomlParser
if TYPE_CHECKING:
from ..models import Task, WorkspaceConfig
from .base import ManifestParser
_PARSERS: list[ManifestParser] = [
CondaTomlParser(),
PixiTomlParser(),
PyprojectTomlParser(),
]
_SEARCH_FILES: list[str] = [
"conda.toml",
"pixi.toml",
"pyproject.toml",
]
PARSER_BY_FILENAME: dict[str, ManifestParser] = {
fname: parser for parser in _PARSERS for fname in parser.filenames
}
[docs]
def walk_manifests(
start_dir: Path,
predicate: str,
) -> Path | None:
"""Walk up from *start_dir* looking for a manifest matching *predicate*.
*predicate* is a method name on ``ManifestParser`` — either
``"has_workspace"`` or ``"has_tasks"``. Returns the first
matching file path, or ``None`` if none is found.
"""
current = start_dir.resolve()
while True:
for fname in _SEARCH_FILES:
candidate = current / fname
if candidate.is_file():
parser = PARSER_BY_FILENAME.get(fname)
if parser is not None and getattr(parser, predicate)(candidate):
return candidate
parent = current.parent
if parent == current:
break
current = parent
return None
[docs]
def detect_workspace_file(
start_dir: str | Path | None = None,
) -> Path:
"""Walk up from *start_dir* to find a workspace manifest.
Returns the path to the first matching file.
Raises ``WorkspaceNotFoundError`` if none is found.
"""
if start_dir is None:
start_dir = Path.cwd()
else:
start_dir = Path(start_dir)
result = walk_manifests(start_dir, "has_workspace")
if result is None:
raise WorkspaceNotFoundError(start_dir)
return result
[docs]
def find_parser(path: Path) -> ManifestParser:
"""Return the parser that can handle *path*.
Raises ``WorkspaceParseError`` if no parser matches.
"""
for parser in _PARSERS:
if parser.can_handle(path):
return parser
raise WorkspaceParseError(path, f"No parser available for '{path.name}'")
[docs]
@lru_cache(maxsize=4)
def cached_parse(path_str: str) -> WorkspaceConfig:
"""Parse a workspace config from *path_str* (cached by path string)."""
path = Path(path_str)
parser = find_parser(path)
return parser.parse(path)
[docs]
def detect_and_parse(
start_dir: str | Path | None = None,
) -> tuple[Path, WorkspaceConfig]:
"""Detect the workspace manifest and parse it.
Returns ``(manifest_path, workspace_config)``.
"""
path = detect_workspace_file(start_dir)
config = cached_parse(str(path))
return path, config
[docs]
def detect_task_file(start_dir: Path | None = None) -> Path | None:
"""Walk up from *start_dir* looking for a file that contains tasks.
Returns the first match according to ``_SEARCH_FILES``, or ``None``.
"""
if start_dir is None:
start_dir = Path.cwd()
return walk_manifests(Path(start_dir), "has_tasks")
[docs]
@lru_cache(maxsize=4)
def cached_task_parse(path_str: str) -> dict[str, Task]:
"""Parse tasks from a manifest file (cached by path string)."""
p = Path(path_str)
parser = find_parser(p)
return parser.parse_tasks(p)
[docs]
@lru_cache(maxsize=1)
def cached_user_task_parse(path_str: str) -> dict[str, Task]:
"""Parse tasks from the user-level ``tasks.toml`` (conda.toml format)."""
return CondaTomlParser().parse_tasks(Path(path_str))
[docs]
def user_task_file() -> Path | None:
"""Return the user-level task file path, or ``None``."""
candidates: list[Path] = []
xdg = os.environ.get("XDG_CONFIG_HOME", "")
if xdg:
candidates.append(Path(xdg) / "conda" / "tasks.toml")
candidates.append(Path.home() / ".config" / "conda" / "tasks.toml")
candidates.append(Path.home() / ".conda" / "tasks.toml")
for c in candidates:
if c.is_file():
return c
return None
[docs]
def detect_and_parse_tasks(
file_path: Path | None = None,
start_dir: Path | None = None,
) -> tuple[Path, dict[str, Task], set[str]]:
"""Detect task files and parse them, merging user-level tasks.
Returns ``(resolved_path, {task_name: Task}, user_only_names)`` where
*user_only_names* is the set of task names that came from the user-level
file and were not overridden by the project.
Raises ``NoTaskFileError`` when neither a project nor a user task file
is found.
"""
project_path: Path | None = None
if file_path is not None:
project_path = file_path.resolve()
else:
project_path = detect_task_file(start_dir)
user_path = user_task_file()
if project_path is None and user_path is None:
raise NoTaskFileError(str(start_dir or Path.cwd()))
user_tasks: dict[str, Task] = {}
if user_path is not None:
user_tasks = cached_user_task_parse(str(user_path))
if project_path is not None:
project_tasks = cached_task_parse(str(project_path))
merged = {**user_tasks, **project_tasks}
user_only = set(user_tasks) - set(project_tasks)
return project_path, merged, user_only
assert user_path is not None
return user_path, dict(user_tasks), set(user_tasks)