Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 6 additions & 16 deletions dope/__init__.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,15 @@
from dope.core.settings import Settings
from dope.core.settings import Settings, get_settings
from dope.core.utils import (
load_settings_from_yaml,
locate_global_config,
locate_local_config_file,
)
from dope.models.constants import CONFIG_FILENAME

# Locate config file for reference (doesn't load settings yet)
config_filepath = locate_local_config_file(CONFIG_FILENAME) or locate_global_config(CONFIG_FILENAME)

# Always create settings object - agent will be None if no config
settings = Settings()
if config_filepath:
try:
settings = Settings(**load_settings_from_yaml(config_filepath))
except Exception as e:
# Config exists but is invalid - this is an error
import sys
# DEPRECATED: Module-level settings object for backward compatibility
# Use get_settings() instead to get cached settings
settings = None

from rich import print as rprint

rprint(f"[red]❌ Config file invalid: {config_filepath}[/red]")
rprint(f"[yellow]Error: {e}[/yellow]")
rprint("[blue]Run 'dope config init --force' to recreate[/blue]")
sys.exit(1)
__all__ = ["Settings", "get_settings", "settings", "config_filepath"]
53 changes: 53 additions & 0 deletions dope/core/settings.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from functools import lru_cache
from pathlib import Path

from platformdirs import user_cache_dir
Expand Down Expand Up @@ -50,3 +51,55 @@ class Settings(BaseSettings):
git: CodeRepoSettings = CodeRepoSettings()
agent: AgentSettings | None = None
model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__")


@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Get cached application settings.

This function loads settings from configuration files (local or global) on first call,
then returns the cached instance on subsequent calls. This pattern:
- Avoids circular import issues by deferring imports until function call
- Prevents repeated file I/O and YAML parsing
- Makes testing easier (can clear cache with get_settings.cache_clear())
- Provides single source of truth for settings access

Returns:
Settings: The cached settings instance.

Raises:
SystemExit: If configuration file exists but is invalid.

Example:
>>> settings = get_settings()
>>> settings.agent.provider
Provider.OPENAI
"""
from dope.core.utils import ( # Delayed import to avoid circular dependency
load_settings_from_yaml,
locate_global_config,
locate_local_config_file,
)
from dope.models.constants import CONFIG_FILENAME

config_filepath = locate_local_config_file(CONFIG_FILENAME) or locate_global_config(
CONFIG_FILENAME
)

# Always create settings object - agent will be None if no config
settings = Settings()
if config_filepath:
try:
settings = Settings(**load_settings_from_yaml(config_filepath))
except Exception as e:
# Config exists but is invalid - this is an error
import sys

from rich import print as rprint

rprint(f"[red]❌ Config file invalid: {config_filepath}[/red]")
rprint(f"[yellow]Error: {e}[/yellow]")
rprint("[blue]Run 'dope config init --force' to recreate[/blue]")
sys.exit(1)

return settings
5 changes: 3 additions & 2 deletions dope/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ def require_config():

from rich import print as rprint

from dope import settings # pylint: disable=cyclic-import
from dope.core.settings import get_settings

if settings is None:
settings = get_settings()
if settings.agent is None:
rprint("[red]❌ No configuration found[/red]")
rprint("[blue]💡 Run 'dope config init' to set up[/blue]")
sys.exit(1)
Expand Down
3 changes: 2 additions & 1 deletion dope/llms/model_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
from pydantic_ai.providers.azure import AzureProvider
from pydantic_ai.providers.openai import OpenAIProvider

from dope import settings
from dope.core.settings import get_settings
from dope.models.enums import Provider


@lru_cache
def _get_openai_provider(provider):
settings = get_settings()
if not settings.agent:
raise ValueError("Agent settings not configured")
if provider == Provider.AZURE:
Expand Down
3 changes: 2 additions & 1 deletion dope/services/changer/changer_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

from pydantic_ai import Agent, RunContext

from dope import settings
from dope.consumers.git_consumer import GitConsumer
from dope.core.settings import get_settings
from dope.llms.model_factory import get_model
from dope.services.changer.prompts import CHANGE_DOC_PROMPT

Expand All @@ -20,6 +20,7 @@ class Deps:
@lru_cache(maxsize=1)
def get_changer_agent() -> Agent[Deps, str]:
"""Get the changer agent (lazy-initialized and cached)."""
settings = get_settings()
if settings.agent is None:
raise RuntimeError("Agent configuration not found. Run 'dope config init' first.")
agent = Agent(
Expand Down
4 changes: 3 additions & 1 deletion dope/services/describer/describer_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

from pydantic_ai import Agent, RunContext

from dope import settings
from dope.consumers.git_consumer import GitConsumer
from dope.core.settings import get_settings
from dope.llms.model_factory import get_model
from dope.models.domain.code import CodeChanges
from dope.models.domain.doc import DocSummary
Expand All @@ -22,6 +22,7 @@ class Deps:
@lru_cache(maxsize=1)
def get_code_change_agent() -> Agent[Deps, CodeChanges]:
"""Get the code change agent (lazy-initialized and cached)."""
settings = get_settings()
if settings.agent is None:
raise RuntimeError("Agent configuration not found. Run 'dope config init' first.")
agent = Agent(
Expand Down Expand Up @@ -56,6 +57,7 @@ def get_code_file_content(_ctx: RunContext[Deps], code_filepath: str) -> str:
@lru_cache(maxsize=1)
def get_doc_summarization_agent() -> Agent[None, DocSummary]:
"""Get the doc summarization agent (lazy-initialized and cached)."""
settings = get_settings()
if settings.agent is None:
raise RuntimeError("Agent configuration not found. Run 'dope config init' first.")
agent = Agent(model=get_model(settings.agent.provider, "gpt-4.1-mini"), output_type=DocSummary)
Expand Down
5 changes: 4 additions & 1 deletion dope/services/scoper/scoper_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from pydantic_ai import Agent

from dope import settings
from dope.core.settings import get_settings
from dope.llms.model_factory import get_model
from dope.models.domain.scope_template import AlignedScope, ProjectTier
from dope.services.scoper.prompts import (
Expand All @@ -15,6 +15,7 @@
@lru_cache(maxsize=1)
def get_project_complexity_agent() -> Agent[None, ProjectTier]:
"""Get the project complexity agent (lazy-initialized and cached)."""
settings = get_settings()
if settings.agent is None:
raise RuntimeError("Agent configuration not found. Run 'dope config init' first.")
agent = Agent(model=get_model(settings.agent.provider, "gpt-4.1-mini"), output_type=ProjectTier)
Expand All @@ -29,6 +30,7 @@ def _add_complexity_prompt() -> str:
@lru_cache(maxsize=1)
def get_scope_creator_agent() -> Agent[None, dict[str, str]]:
"""Get the scope creator agent (lazy-initialized and cached)."""
settings = get_settings()
if settings.agent is None:
raise RuntimeError("Agent configuration not found. Run 'dope config init' first.")
agent = Agent(model=get_model(settings.agent.provider, "gpt-4.1"), output_type=dict[str, str])
Expand All @@ -43,6 +45,7 @@ def _add_scope_creator_prompt() -> str:
@lru_cache(maxsize=1)
def get_doc_aligner_agent() -> Agent[None, AlignedScope]:
"""Get the doc aligner agent (lazy-initialized and cached)."""
settings = get_settings()
if settings.agent is None:
raise RuntimeError("Agent configuration not found. Run 'dope config init' first.")
agent = Agent(model=get_model(settings.agent.provider, "gpt-4.1"), output_type=AlignedScope)
Expand Down
3 changes: 2 additions & 1 deletion dope/services/suggester/suggester_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from pydantic_ai import Agent

from dope import settings
from dope.core.settings import get_settings
from dope.llms.model_factory import get_model
from dope.models.domain.doc import DocSuggestions
from dope.services.suggester.prompts import SYSTEM_PROMPT
Expand All @@ -11,6 +11,7 @@
@lru_cache(maxsize=1)
def get_suggester_agent() -> Agent[None, DocSuggestions]:
"""Get the suggester agent (lazy-initialized and cached)."""
settings = get_settings()
if settings.agent is None:
raise RuntimeError("Agent configuration not found. Run 'dope config init' first.")
model = get_model(settings.agent.provider, "o4-mini")
Expand Down
91 changes: 91 additions & 0 deletions tests/unit/settings_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""Tests for settings module and get_settings() caching behavior."""

from pathlib import Path

import pytest

from dope.core.settings import Settings, get_settings


def test_get_settings_returns_settings_instance():
"""Test that get_settings() returns a Settings instance."""
settings = get_settings()
assert isinstance(settings, Settings)


def test_get_settings_caches_result():
"""Test that get_settings() returns the same instance on repeated calls."""
settings1 = get_settings()
settings2 = get_settings()
assert settings1 is settings2


def test_get_settings_cache_can_be_cleared():
"""Test that cache can be cleared to reload settings."""
settings1 = get_settings()
get_settings.cache_clear()
settings2 = get_settings()
# After cache clear, should get a new instance
assert settings1 is not settings2
assert isinstance(settings2, Settings)


def test_settings_has_required_attributes():
"""Test that settings has expected attributes."""
settings = get_settings()
assert hasattr(settings, "state_directory")
assert hasattr(settings, "docs")
assert hasattr(settings, "git")
assert hasattr(settings, "agent")


def test_settings_state_directory_is_path():
"""Test that state_directory is a Path object."""
settings = get_settings()
assert isinstance(settings.state_directory, Path)


def test_settings_docs_settings():
"""Test that docs settings are properly initialized."""
settings = get_settings()
assert hasattr(settings.docs, "doc_filetypes")
assert hasattr(settings.docs, "exclude_dirs")
assert hasattr(settings.docs, "docs_root")


def test_settings_git_settings():
"""Test that git settings are properly initialized."""
settings = get_settings()
assert hasattr(settings.git, "default_branch")
assert hasattr(settings.git, "code_repo_root")


def test_settings_agent_can_be_none():
"""Test that agent settings can be None when not configured."""
# This test may fail if there's a valid config file
# Cache clear to ensure fresh load
get_settings.cache_clear()
settings = get_settings()
# Agent might be None or configured depending on test environment
assert settings.agent is None or hasattr(settings.agent, "provider")


def test_settings_immutability_not_enforced():
"""Test that settings can be modified (not frozen)."""
settings = get_settings()
# Should be able to modify settings
original_branch = settings.git.default_branch
settings.git.default_branch = "test-branch"
assert settings.git.default_branch == "test-branch"
# Restore original
settings.git.default_branch = original_branch


def test_multiple_imports_same_cached_instance():
"""Test that multiple imports get the same cached instance."""
from dope.core.settings import get_settings as get_settings_import1
from dope.core.settings import get_settings as get_settings_import2

settings1 = get_settings_import1()
settings2 = get_settings_import2()
assert settings1 is settings2