diff --git a/dope/__init__.py b/dope/__init__.py index 905d635..95f1ff1 100644 --- a/dope/__init__.py +++ b/dope/__init__.py @@ -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"] diff --git a/dope/core/settings.py b/dope/core/settings.py index e756f2c..ee62116 100644 --- a/dope/core/settings.py +++ b/dope/core/settings.py @@ -1,3 +1,4 @@ +from functools import lru_cache from pathlib import Path from platformdirs import user_cache_dir @@ -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 diff --git a/dope/core/utils.py b/dope/core/utils.py index 9957df8..4ca5aeb 100644 --- a/dope/core/utils.py +++ b/dope/core/utils.py @@ -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) diff --git a/dope/llms/model_factory.py b/dope/llms/model_factory.py index d51dfb2..b0d88dc 100644 --- a/dope/llms/model_factory.py +++ b/dope/llms/model_factory.py @@ -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: diff --git a/dope/services/changer/changer_agents.py b/dope/services/changer/changer_agents.py index 26033b7..01a8697 100644 --- a/dope/services/changer/changer_agents.py +++ b/dope/services/changer/changer_agents.py @@ -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 @@ -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( diff --git a/dope/services/describer/describer_agents.py b/dope/services/describer/describer_agents.py index 44d272b..7e8e92c 100644 --- a/dope/services/describer/describer_agents.py +++ b/dope/services/describer/describer_agents.py @@ -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 @@ -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( @@ -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) diff --git a/dope/services/scoper/scoper_agents.py b/dope/services/scoper/scoper_agents.py index a778a06..4750e02 100644 --- a/dope/services/scoper/scoper_agents.py +++ b/dope/services/scoper/scoper_agents.py @@ -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 ( @@ -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) @@ -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]) @@ -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) diff --git a/dope/services/suggester/suggester_agents.py b/dope/services/suggester/suggester_agents.py index 18dec15..3616cd0 100644 --- a/dope/services/suggester/suggester_agents.py +++ b/dope/services/suggester/suggester_agents.py @@ -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 @@ -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") diff --git a/tests/unit/settings_test.py b/tests/unit/settings_test.py new file mode 100644 index 0000000..ea5c3da --- /dev/null +++ b/tests/unit/settings_test.py @@ -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