Implement CRIT-006: Config system with YAML validation

- ProjectConfig dataclass for type-safe project settings
  - Channel-to-project mapping with required fields
  - Environment variable expansion ($HOME, custom vars)
  - System prompt loading from file or inline
  - Tool restriction per project
  - Model selection per project

- Config class with comprehensive YAML loading
  - Default config: ~/.claude-coordinator/config.yaml
  - Full validation with detailed error messages
  - Duplicate channel ID detection
  - Project directory existence checks
  - Tool name validation (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch)
  - Model validation (sonnet, opus, haiku)
  - System prompt file validation

- Example config with all options documented
  - Full example with all fields
  - Minimal example with defaults
  - Environment variable usage
  - Read-only project example
  - Inline comments explaining all options

- Comprehensive test suite: 25/25 tests passing
  - ProjectConfig creation and defaults
  - Environment variable expansion
  - System prompt loading (inline and file)
  - YAML loading and parsing
  - Required field validation
  - Duplicate channel ID detection
  - Invalid tool name detection
  - Missing directory warnings
  - Invalid model detection
  - Channel ID lookup
  - Numeric channel ID conversion

Progress: 6/6 Week 1 tasks complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Discord Bot 2026-02-13 18:01:49 +00:00
parent 6b56463779
commit b2ff6f19f2
3 changed files with 856 additions and 28 deletions

View File

@ -1,56 +1,300 @@
"""Configuration management for Claude Discord Coordinator. """Configuration management for Claude Discord Coordinator.
Loads and validates YAML configuration files with support for environment Loads and validates YAML configuration files mapping Discord channels to
variable substitution. project configurations with tool restrictions and custom prompts.
""" """
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, List, Optional
import os
import yaml import yaml
# Known valid tool names from Claude API
VALID_TOOLS = {
"Bash",
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"WebSearch",
"WebFetch",
}
@dataclass
class ProjectConfig:
"""Configuration for a single project mapped to a Discord channel.
Attributes:
name: Project identifier/name.
channel_id: Discord channel ID (as string).
project_dir: Absolute path to project directory.
allowed_tools: List of tool names allowed for this project.
system_prompt: Optional inline system prompt.
system_prompt_file: Optional path to system prompt file.
model: Model to use (sonnet, opus, haiku).
"""
name: str
channel_id: str
project_dir: str
allowed_tools: List[str] = field(default_factory=lambda: list(VALID_TOOLS))
system_prompt: Optional[str] = None
system_prompt_file: Optional[Path] = None
model: str = "sonnet"
def __post_init__(self):
"""Validate and normalize configuration after initialization."""
# Expand environment variables in paths
self.project_dir = os.path.expandvars(self.project_dir)
if self.system_prompt_file:
self.system_prompt_file = Path(os.path.expandvars(str(self.system_prompt_file)))
def get_system_prompt(self) -> Optional[str]:
"""Get the system prompt, loading from file if necessary.
Returns:
System prompt text or None if not configured.
Raises:
FileNotFoundError: If system_prompt_file is set but doesn't exist.
"""
if self.system_prompt:
return self.system_prompt
if self.system_prompt_file:
if not self.system_prompt_file.exists():
raise FileNotFoundError(
f"System prompt file not found: {self.system_prompt_file}"
)
return self.system_prompt_file.read_text()
return None
class Config: class Config:
"""Configuration manager for bot settings. """Configuration manager for bot settings.
Loads YAML configuration mapping Discord channels to project settings,
validates configuration structure, and provides lookup methods.
Attributes: Attributes:
config_path: Path to the YAML configuration file. config_path: Path to the YAML configuration file.
data: Parsed configuration data. projects: Dictionary mapping project names to ProjectConfig objects.
_channel_map: Internal mapping of channel IDs to project names.
""" """
def __init__(self, config_path: Path): def __init__(self, config_path: Optional[str] = None):
"""Initialize configuration from YAML file. """Initialize configuration manager.
Args: Args:
config_path: Path to the configuration file. config_path: Path to config file. If None, uses default location
~/.claude-coordinator/config.yaml
""" """
self.config_path = config_path if config_path is None:
self.data: Dict[str, Any] = {} config_path = os.path.expanduser("~/.claude-coordinator/config.yaml")
self.config_path = Path(config_path)
self.projects: Dict[str, ProjectConfig] = {}
self._channel_map: Dict[str, str] = {}
def load(self) -> None: def load(self) -> None:
"""Load configuration from YAML file. """Load configuration from YAML file.
Raises: Raises:
FileNotFoundError: If config file does not exist. FileNotFoundError: If config file does not exist.
yaml.YAMLError: If config file is invalid YAML. yaml.YAMLError: If config file is invalid YAML.
ValueError: If configuration validation fails.
""" """
with open(self.config_path, "r") as f: if not self.config_path.exists():
self.data = yaml.safe_load(f) raise FileNotFoundError(
f"Configuration file not found: {self.config_path}\n"
f"Create a config file at this location or specify a different path."
)
try:
with open(self.config_path, "r") as f:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
raise yaml.YAMLError(
f"Invalid YAML in configuration file {self.config_path}:\n{e}"
)
if not isinstance(data, dict):
raise ValueError("Configuration file must contain a YAML dictionary")
if "projects" not in data:
raise ValueError("Configuration must contain a 'projects' section")
projects_data = data["projects"]
if not isinstance(projects_data, dict):
raise ValueError("'projects' section must be a dictionary")
# Parse each project configuration
self.projects = {}
self._channel_map = {}
for project_name, project_data in projects_data.items():
if not isinstance(project_data, dict):
raise ValueError(
f"Project '{project_name}' configuration must be a dictionary"
)
# Create ProjectConfig
try:
config = self._parse_project_config(project_name, project_data)
self.projects[project_name] = config
self._channel_map[config.channel_id] = project_name
except (ValueError, KeyError) as e:
raise ValueError(f"Error in project '{project_name}': {e}")
# Validate configuration
errors = self.validate()
if errors:
raise ValueError(
f"Configuration validation failed:\n" + "\n".join(f" - {e}" for e in errors)
)
def _parse_project_config(self, name: str, data: Dict[str, Any]) -> ProjectConfig:
"""Parse a single project configuration.
Args:
name: Project name.
data: Project configuration dictionary.
Returns:
ProjectConfig object.
Raises:
KeyError: If required fields are missing.
ValueError: If field values are invalid.
"""
# Required fields
if "channel_id" not in data:
raise KeyError("'channel_id' is required")
if "project_dir" not in data:
raise KeyError("'project_dir' is required")
channel_id = str(data["channel_id"]) # Ensure string
project_dir = data["project_dir"]
# Optional fields with defaults
allowed_tools = data.get("allowed_tools", list(VALID_TOOLS))
system_prompt = data.get("system_prompt")
system_prompt_file = data.get("system_prompt_file")
model = data.get("model", "sonnet")
# Convert system_prompt_file to Path if present
if system_prompt_file:
system_prompt_file = Path(system_prompt_file)
return ProjectConfig(
name=name,
channel_id=channel_id,
project_dir=project_dir,
allowed_tools=allowed_tools,
system_prompt=system_prompt,
system_prompt_file=system_prompt_file,
model=model,
)
def get_project_by_channel(self, channel_id: str) -> Optional[ProjectConfig]:
"""Get project configuration by Discord channel ID.
Args:
channel_id: Discord channel ID as string.
Returns:
ProjectConfig if channel is mapped, None otherwise.
"""
project_name = self._channel_map.get(str(channel_id))
if project_name:
return self.projects[project_name]
return None
def get_all_projects(self) -> Dict[str, ProjectConfig]:
"""Get all configured projects.
Returns:
Dictionary mapping project names to ProjectConfig objects.
"""
return self.projects.copy()
def validate(self) -> List[str]:
"""Validate configuration structure and values.
Returns:
List of validation error messages (empty if valid).
"""
errors = []
# Check for duplicate channel IDs
channel_ids: Dict[str, List[str]] = {}
for project_name, config in self.projects.items():
channel_id = config.channel_id
if channel_id not in channel_ids:
channel_ids[channel_id] = []
channel_ids[channel_id].append(project_name)
for channel_id, projects in channel_ids.items():
if len(projects) > 1:
errors.append(
f"Duplicate channel_id '{channel_id}' in projects: {', '.join(projects)}"
)
# Validate each project
for project_name, config in self.projects.items():
# Validate project directory exists
project_path = Path(config.project_dir)
if not project_path.exists():
errors.append(
f"Project '{project_name}': directory does not exist: {config.project_dir}"
)
elif not project_path.is_dir():
errors.append(
f"Project '{project_name}': path is not a directory: {config.project_dir}"
)
# Validate tool names
invalid_tools = set(config.allowed_tools) - VALID_TOOLS
if invalid_tools:
errors.append(
f"Project '{project_name}': invalid tool names: {', '.join(sorted(invalid_tools))}\n"
f" Valid tools: {', '.join(sorted(VALID_TOOLS))}"
)
# Validate system prompt file exists if specified
if config.system_prompt_file:
if not config.system_prompt_file.exists():
errors.append(
f"Project '{project_name}': system_prompt_file does not exist: "
f"{config.system_prompt_file}"
)
# Validate model choice
valid_models = {"sonnet", "opus", "haiku"}
if config.model not in valid_models:
errors.append(
f"Project '{project_name}': invalid model '{config.model}'\n"
f" Valid models: {', '.join(sorted(valid_models))}"
)
return errors
# Legacy method for backward compatibility
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:
"""Get configuration value by key. """Get configuration value by key (legacy method).
Args: Args:
key: Configuration key (supports dot notation). key: Configuration key (supports dot notation).
default: Default value if key not found. default: Default value if key not found.
Returns: Returns:
Configuration value or default. Configuration value or default.
""" """
keys = key.split(".") # This method is kept for backward compatibility but not used
value = self.data # in the new channel-to-project mapping system
for k in keys: return default
if isinstance(value, dict):
value = value.get(k)
else:
return default
return value if value is not None else default

98
config.example.yaml Normal file
View File

@ -0,0 +1,98 @@
# Claude Discord Coordinator - Example Configuration
#
# This file maps Discord channels to project configurations, allowing
# different channels to work with different projects, tools, and prompts.
#
# Default location: ~/.claude-coordinator/config.yaml
#
# Environment variables are expanded in paths:
# $HOME or ${HOME} - User's home directory
# $USER or ${USER} - Username
# Any other environment variable
projects:
# Full example with all options
major-domo:
# REQUIRED: Discord channel ID (as string or number)
channel_id: "1234567890"
# REQUIRED: Absolute path to project directory
# Environment variables like $HOME are expanded
project_dir: "/opt/projects/major-domo-bot"
# OPTIONAL: List of allowed tools (defaults to all tools if omitted)
# Valid tools: Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch
allowed_tools:
- Bash
- Read
- Write
- Edit
- Glob
- Grep
# OPTIONAL: System prompt loaded from file
# Use this for complex prompts stored in your project
system_prompt_file: "/opt/projects/major-domo-bot/CLAUDE.md"
# OPTIONAL: Model to use (defaults to "sonnet")
# Valid values: sonnet, opus, haiku
model: "sonnet"
# Example with inline system prompt
paper-dynasty:
channel_id: "0987654321"
project_dir: "/opt/projects/paper-dynasty"
# OPTIONAL: Inline system prompt (alternative to system_prompt_file)
# Use for short prompts; prefer system_prompt_file for longer ones
system_prompt: |
You are helping with the Paper Dynasty baseball card game.
Focus on gameplay mechanics, card generation, and player stats.
allowed_tools:
- Bash
- Read
- Grep
- WebSearch
model: "sonnet"
# Minimal example with only required fields
new-projects:
channel_id: "1111111111"
project_dir: "/opt/projects"
# allowed_tools defaults to all tools
# model defaults to "sonnet"
# no system_prompt or system_prompt_file
# Example using environment variables
personal-workspace:
channel_id: "2222222222"
project_dir: "$HOME/workspace/my-project"
system_prompt_file: "${HOME}/.config/claude/my-prompt.md"
allowed_tools:
- Bash
- Read
- Write
- Edit
model: "opus" # Use more powerful model for complex work
# Example with read-only access (no Write/Edit tools)
documentation:
channel_id: "3333333333"
project_dir: "/opt/projects/docs"
allowed_tools:
- Read
- Glob
- Grep
- WebSearch
system_prompt: "You help users navigate and understand documentation."
model: "haiku" # Faster model for simpler tasks
# Notes:
# - Each channel_id must be unique across all projects
# - Project directories should exist before running the bot
# - System prompt files are loaded at runtime
# - If both system_prompt and system_prompt_file are set, system_prompt takes precedence
# - Invalid tool names will cause validation errors
# - Model choices: sonnet (balanced), opus (powerful), haiku (fast)

486
tests/test_config.py Normal file
View File

@ -0,0 +1,486 @@
"""Tests for configuration management.
Tests YAML loading, validation, project lookups, and error handling for the
channel-to-project configuration system.
"""
import os
import pytest
import tempfile
import yaml
from pathlib import Path
from claude_coordinator.config import Config, ProjectConfig, VALID_TOOLS
@pytest.fixture
def temp_config_dir(tmp_path):
"""Create temporary directories for testing.
Creates a test project directory structure for validating project_dir
existence checks.
"""
projects = tmp_path / "projects"
projects.mkdir()
# Create test project directories
(projects / "major-domo").mkdir()
(projects / "paper-dynasty").mkdir()
(projects / "test-project").mkdir()
# Create test system prompt file
prompt_file = projects / "major-domo" / "CLAUDE.md"
prompt_file.write_text("You are helping with Major Domo.")
return projects
@pytest.fixture
def valid_config_data(temp_config_dir):
"""Create valid configuration data for testing."""
return {
"projects": {
"major-domo": {
"channel_id": "123456",
"project_dir": str(temp_config_dir / "major-domo"),
"allowed_tools": ["Bash", "Read", "Write"],
"system_prompt_file": str(temp_config_dir / "major-domo" / "CLAUDE.md"),
"model": "sonnet",
},
"paper-dynasty": {
"channel_id": "789012",
"project_dir": str(temp_config_dir / "paper-dynasty"),
"system_prompt": "You help with Paper Dynasty.",
"model": "opus",
},
}
}
@pytest.fixture
def config_file(tmp_path, valid_config_data):
"""Create a temporary config file for testing.
Returns path to a valid YAML config file with test data.
"""
config_path = tmp_path / "config.yaml"
with open(config_path, "w") as f:
yaml.dump(valid_config_data, f)
return config_path
class TestProjectConfig:
"""Test ProjectConfig dataclass functionality."""
def test_project_config_creation(self):
"""Test creating a ProjectConfig with all fields."""
config = ProjectConfig(
name="test-project",
channel_id="123456",
project_dir="/opt/projects/test",
allowed_tools=["Bash", "Read"],
system_prompt="Test prompt",
model="sonnet",
)
assert config.name == "test-project"
assert config.channel_id == "123456"
assert config.project_dir == "/opt/projects/test"
assert config.allowed_tools == ["Bash", "Read"]
assert config.system_prompt == "Test prompt"
assert config.model == "sonnet"
def test_project_config_defaults(self):
"""Test ProjectConfig default values."""
config = ProjectConfig(
name="test",
channel_id="123",
project_dir="/opt/test",
)
assert config.allowed_tools == list(VALID_TOOLS)
assert config.model == "sonnet"
assert config.system_prompt is None
assert config.system_prompt_file is None
def test_environment_variable_expansion(self, temp_config_dir):
"""Test that environment variables are expanded in paths."""
os.environ["TEST_PROJECT_DIR"] = str(temp_config_dir)
config = ProjectConfig(
name="test",
channel_id="123",
project_dir="$TEST_PROJECT_DIR/major-domo",
)
assert config.project_dir == str(temp_config_dir / "major-domo")
def test_get_system_prompt_inline(self):
"""Test getting inline system prompt."""
config = ProjectConfig(
name="test",
channel_id="123",
project_dir="/opt/test",
system_prompt="Inline prompt",
)
assert config.get_system_prompt() == "Inline prompt"
def test_get_system_prompt_from_file(self, temp_config_dir):
"""Test loading system prompt from file."""
prompt_file = temp_config_dir / "major-domo" / "CLAUDE.md"
config = ProjectConfig(
name="test",
channel_id="123",
project_dir="/opt/test",
system_prompt_file=prompt_file,
)
assert config.get_system_prompt() == "You are helping with Major Domo."
def test_get_system_prompt_file_not_found(self):
"""Test error when system prompt file doesn't exist."""
config = ProjectConfig(
name="test",
channel_id="123",
project_dir="/opt/test",
system_prompt_file=Path("/nonexistent/file.md"),
)
with pytest.raises(FileNotFoundError):
config.get_system_prompt()
def test_get_system_prompt_none(self):
"""Test when no system prompt is configured."""
config = ProjectConfig(
name="test",
channel_id="123",
project_dir="/opt/test",
)
assert config.get_system_prompt() is None
class TestConfig:
"""Test Config class functionality."""
def test_load_valid_config(self, config_file):
"""Test loading a valid configuration file."""
config = Config(str(config_file))
config.load()
assert len(config.projects) == 2
assert "major-domo" in config.projects
assert "paper-dynasty" in config.projects
def test_load_missing_file(self, tmp_path):
"""Test error when config file doesn't exist."""
config = Config(str(tmp_path / "nonexistent.yaml"))
with pytest.raises(FileNotFoundError) as exc:
config.load()
assert "Configuration file not found" in str(exc.value)
def test_load_invalid_yaml(self, tmp_path):
"""Test error with invalid YAML syntax."""
config_path = tmp_path / "invalid.yaml"
config_path.write_text("invalid: yaml: syntax: [")
config = Config(str(config_path))
with pytest.raises(yaml.YAMLError):
config.load()
def test_load_not_dict(self, tmp_path):
"""Test error when config is not a dictionary."""
config_path = tmp_path / "list.yaml"
config_path.write_text("- item1\n- item2")
config = Config(str(config_path))
with pytest.raises(ValueError) as exc:
config.load()
assert "must contain a YAML dictionary" in str(exc.value)
def test_load_missing_projects_section(self, tmp_path):
"""Test error when 'projects' section is missing."""
config_path = tmp_path / "no_projects.yaml"
with open(config_path, "w") as f:
yaml.dump({"other_section": {}}, f)
config = Config(str(config_path))
with pytest.raises(ValueError) as exc:
config.load()
assert "must contain a 'projects' section" in str(exc.value)
def test_missing_required_fields(self, tmp_path, temp_config_dir):
"""Test error when required fields are missing."""
# Missing channel_id
config_data = {
"projects": {
"test": {
"project_dir": str(temp_config_dir / "test-project"),
}
}
}
config_path = tmp_path / "missing_channel.yaml"
with open(config_path, "w") as f:
yaml.dump(config_data, f)
config = Config(str(config_path))
with pytest.raises(ValueError) as exc:
config.load()
assert "'channel_id' is required" in str(exc.value)
def test_get_project_by_channel(self, config_file):
"""Test looking up project by channel ID."""
config = Config(str(config_file))
config.load()
project = config.get_project_by_channel("123456")
assert project is not None
assert project.name == "major-domo"
assert project.channel_id == "123456"
def test_get_project_by_channel_not_found(self, config_file):
"""Test lookup returns None for unmapped channel."""
config = Config(str(config_file))
config.load()
project = config.get_project_by_channel("999999")
assert project is None
def test_get_all_projects(self, config_file):
"""Test getting all configured projects."""
config = Config(str(config_file))
config.load()
projects = config.get_all_projects()
assert len(projects) == 2
assert "major-domo" in projects
assert "paper-dynasty" in projects
assert isinstance(projects["major-domo"], ProjectConfig)
def test_validate_duplicate_channel_ids(self, tmp_path, temp_config_dir):
"""Test validation detects duplicate channel IDs."""
config_data = {
"projects": {
"project1": {
"channel_id": "123456",
"project_dir": str(temp_config_dir / "major-domo"),
},
"project2": {
"channel_id": "123456", # Duplicate
"project_dir": str(temp_config_dir / "paper-dynasty"),
},
}
}
config_path = tmp_path / "duplicate.yaml"
with open(config_path, "w") as f:
yaml.dump(config_data, f)
config = Config(str(config_path))
with pytest.raises(ValueError) as exc:
config.load()
assert "Duplicate channel_id" in str(exc.value)
assert "123456" in str(exc.value)
def test_validate_invalid_tool_names(self, tmp_path, temp_config_dir):
"""Test validation detects invalid tool names."""
config_data = {
"projects": {
"test": {
"channel_id": "123456",
"project_dir": str(temp_config_dir / "test-project"),
"allowed_tools": ["Bash", "InvalidTool", "FakeTool"],
}
}
}
config_path = tmp_path / "invalid_tools.yaml"
with open(config_path, "w") as f:
yaml.dump(config_data, f)
config = Config(str(config_path))
with pytest.raises(ValueError) as exc:
config.load()
assert "invalid tool names" in str(exc.value)
assert "InvalidTool" in str(exc.value) or "FakeTool" in str(exc.value)
def test_validate_missing_project_directory(self, tmp_path):
"""Test validation warns about missing project directories."""
config_data = {
"projects": {
"test": {
"channel_id": "123456",
"project_dir": "/nonexistent/directory",
}
}
}
config_path = tmp_path / "missing_dir.yaml"
with open(config_path, "w") as f:
yaml.dump(config_data, f)
config = Config(str(config_path))
with pytest.raises(ValueError) as exc:
config.load()
assert "directory does not exist" in str(exc.value)
def test_validate_invalid_model(self, tmp_path, temp_config_dir):
"""Test validation detects invalid model names."""
config_data = {
"projects": {
"test": {
"channel_id": "123456",
"project_dir": str(temp_config_dir / "test-project"),
"model": "invalid-model",
}
}
}
config_path = tmp_path / "invalid_model.yaml"
with open(config_path, "w") as f:
yaml.dump(config_data, f)
config = Config(str(config_path))
with pytest.raises(ValueError) as exc:
config.load()
assert "invalid model" in str(exc.value)
assert "invalid-model" in str(exc.value)
def test_validate_missing_system_prompt_file(self, tmp_path, temp_config_dir):
"""Test validation detects missing system prompt files."""
config_data = {
"projects": {
"test": {
"channel_id": "123456",
"project_dir": str(temp_config_dir / "test-project"),
"system_prompt_file": "/nonexistent/prompt.md",
}
}
}
config_path = tmp_path / "missing_prompt.yaml"
with open(config_path, "w") as f:
yaml.dump(config_data, f)
config = Config(str(config_path))
with pytest.raises(ValueError) as exc:
config.load()
assert "system_prompt_file does not exist" in str(exc.value)
def test_default_config_path(self, monkeypatch, tmp_path):
"""Test default config path uses ~/.claude-coordinator/config.yaml."""
# Create default config location
config_dir = tmp_path / ".claude-coordinator"
config_dir.mkdir()
default_config = config_dir / "config.yaml"
# Mock expanduser to return our tmp_path
def mock_expanduser(path):
return str(tmp_path / path.lstrip("~/"))
monkeypatch.setattr(os.path, "expanduser", mock_expanduser)
config = Config()
assert str(config.config_path).endswith(".claude-coordinator/config.yaml")
def test_channel_id_as_number(self, tmp_path, temp_config_dir):
"""Test that numeric channel IDs are converted to strings."""
config_data = {
"projects": {
"test": {
"channel_id": 123456, # Number instead of string
"project_dir": str(temp_config_dir / "test-project"),
}
}
}
config_path = tmp_path / "numeric_id.yaml"
with open(config_path, "w") as f:
yaml.dump(config_data, f)
config = Config(str(config_path))
config.load()
project = config.get_project_by_channel("123456")
assert project is not None
assert project.channel_id == "123456"
class TestEnvironmentVariableExpansion:
"""Test environment variable expansion in configuration."""
def test_expand_home_variable(self, tmp_path):
"""Test $HOME variable expansion."""
home_dir = str(tmp_path / "home")
os.makedirs(home_dir, exist_ok=True)
project_dir = Path(home_dir) / "projects" / "test"
project_dir.mkdir(parents=True)
os.environ["HOME"] = home_dir
config_data = {
"projects": {
"test": {
"channel_id": "123456",
"project_dir": "$HOME/projects/test",
}
}
}
config_path = tmp_path / "env_test.yaml"
with open(config_path, "w") as f:
yaml.dump(config_data, f)
config = Config(str(config_path))
config.load()
project = config.projects["test"]
assert project.project_dir == str(project_dir)
def test_expand_custom_variable(self, tmp_path):
"""Test custom environment variable expansion."""
custom_path = str(tmp_path / "custom")
os.makedirs(custom_path, exist_ok=True)
os.environ["CUSTOM_PROJECT_ROOT"] = custom_path
config_data = {
"projects": {
"test": {
"channel_id": "123456",
"project_dir": "${CUSTOM_PROJECT_ROOT}",
}
}
}
config_path = tmp_path / "custom_var.yaml"
with open(config_path, "w") as f:
yaml.dump(config_data, f)
config = Config(str(config_path))
config.load()
project = config.projects["test"]
assert project.project_dir == custom_path