"""
Loads and validates the [dependencies]/[development] sections of duck.toml.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
try:
import tomllib
except ImportError:
import tomli as tomllib
from duck.cli.commands.sync.exceptions import ConfigNotFoundError, ConfigParseError
# Default filename Duck Sync looks for at the project root
CONFIG_FILENAME = "duck.toml"
[docs]
@dataclass
class DependencyGroup:
"""
A named collection of python and system dependencies.
Args:
python:
Python packages installable via pip/uv.
system:
System level packages resolved through the OS package manager.
requirements:
Path to a requirements.txt, relative to duck.toml.
Installed alongside the python list rather than replacing it.
python_args:
Extra CLI args appended to every python install
command in this group, e.g. ["--no-cache-dir"].
python_package_args:
Per-package extra args, keyed by package
name, merged in after python_args.
system_args:
Extra CLI args appended to every system install
command in this group, e.g. ["--no-install-recommends"].
system_package_args:
Per-package extra args for system installs,
keyed by the generic name written in the system list.
"""
python: list[str] = field(default_factory=list)
system: list[str] = field(default_factory=list)
requirements: str = ""
python_args: list[str] = field(default_factory=list)
python_package_args: dict[str, list[str]] = field(default_factory=dict)
system_args: list[str] = field(default_factory=list)
system_package_args: dict[str, list[str]] = field(default_factory=dict)
[docs]
@dataclass
class DuckSyncConfig:
"""
Fully parsed representation of a project's duck.toml file.
Args:
dependencies:
Required runtime dependencies.
development:
Optional dependencies only needed for local development.
overrides:
Per package name overrides for system package names, keyed
by backend name (apt, brew, dnf, pacman, choco).
base_dir:
Directory duck.toml lives in, used to resolve relative
paths such as requirements.
use_sudo:
Whether system installs should be prefixed with sudo.
None means auto-detect: skip sudo when already running as root.
native:
Whether native configuration is present in duck.toml.
"""
dependencies: DependencyGroup = field(default_factory=DependencyGroup)
development: DependencyGroup = field(default_factory=DependencyGroup)
overrides: dict[str, dict[str, str]] = field(default_factory=dict)
base_dir: Path = field(default_factory=Path.cwd)
use_sudo: bool | None = None
native: bool = False
[docs]
def find_config(start: Path | None = None) -> Path:
"""
Walks upward from start looking for duck.toml.
Args:
start:
Directory to begin searching from. Defaults to the cwd.
Returns:
Path to the discovered duck.toml.
Raises:
ConfigNotFoundError:
If no duck.toml is found up to the filesystem root.
"""
current = (start or Path.cwd()).resolve()
for directory in [current, *current.parents]:
candidate = directory / CONFIG_FILENAME
if candidate.is_file():
return candidate
raise ConfigNotFoundError(
f"No {CONFIG_FILENAME} found in '{current}' or any parent directory."
)
[docs]
def load_config(path: Path | None = None) -> DuckSyncConfig:
"""
Reads and validates a duck.toml file into a DuckSyncConfig.
Args:
path:
Explicit path to a duck.toml file. Auto discovered when omitted.
Returns:
The parsed DuckSyncConfig.
Raises:
ConfigParseError:
If the file exists but is malformed.
"""
config_path = path or find_config()
try:
raw = tomllib.loads(config_path.read_text(encoding="utf-8"))
except tomllib.TOMLDecodeError as error:
raise ConfigParseError(
f"Invalid TOML in '{config_path}': {error}"
) from error
dependencies_raw = raw.get("dependencies", {})
development_raw = raw.get("development", {})
sync_raw = raw.get("sync", {})
return DuckSyncConfig(
dependencies=parse_dependency_group(dependencies_raw),
development=parse_dependency_group(development_raw),
overrides=raw.get("overrides", {}),
base_dir=config_path.parent,
use_sudo=sync_raw.get("use_sudo"),
native="native" in raw,
)
[docs]
def parse_dependency_group(raw: dict) -> DependencyGroup:
"""
Builds a DependencyGroup from a [dependencies] or [development] table.
Args:
raw:
The raw dict for that TOML table.
Returns:
The parsed DependencyGroup.
"""
return DependencyGroup(
python=list(raw.get("python", [])),
system=list(raw.get("system", [])),
requirements=raw.get("requirements", ""),
python_args=list(raw.get("python_args", [])),
python_package_args=raw.get("python_package_args", {}),
system_args=list(raw.get("system_args", [])),
system_package_args=raw.get("system_package_args", {}),
)
[docs]
def merge_dependencies(
existing: list[str],
additional: list[str],
) -> list[str]:
"""
Merges dependency lists while preserving their original order.
Args:
existing:
Dependencies already declared by the user.
additional:
Dependencies automatically supplied by another configuration.
Returns:
Combined dependency list with duplicates removed.
"""
return list(dict.fromkeys([*existing, *additional]))