"""Step definitions for settings feature tests.""" from __future__ import annotations import os from pathlib import Path from behave import given, then, when from behave.runner import Context from cleveragents.config.settings import Settings @given('the environment variable "{key}" is set to "{value}"') @when('the environment variable "{key}" is set to "{value}"') def step_set_env_var(context, key, value): """Set an environment variable.""" os.environ[key] = value if not hasattr(context, "env_vars_to_clean"): context.env_vars_to_clean = [] context.env_vars_to_clean.append(key) @given("no environment variables are set") def step_clear_env_vars(context): """Clear all CleverAgents-related environment variables.""" keys_to_remove = [ "CLEVERAGENTS_LOG_LEVEL", "CLEVERAGENTS_LOG_DIR", "CLEVERAGENTS_DATA_DIR", "CLEVERAGENTS_ENV", "CLEVERAGENTS_DEFAULT_PROVIDER", "CLEVERAGENTS_DEFAULT_MODEL", "CLEVERAGENTS_OPENROUTER_ORGANIZATION", # Database URL env vars: clear so scenarios testing the default or an # explicit override start from a clean slate, regardless of what # before_scenario injected for test isolation. "CLEVERAGENTS_DATABASE_URL", "CLEVERAGENTS_TEST_DATABASE_URL", # CLEVERAGENTS_HOME affects database_url resolution; must be cleared # alongside the URL vars so Settings() uses the true default path. "CLEVERAGENTS_HOME", "DATABASE_URL", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "OPENROUTER_ORGANIZATION", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENAI_API_KEY", "GEMINI_API_KEY", "AZURE_OPENAI_API_KEY", "AZURE_API_KEY", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_DEPLOYMENT", "AZURE_OPENAI_API_VERSION", "DEEPSEEK_API_KEY", "COHERE_API_KEY", "PERPLEXITY_API_KEY", "GROQ_API_KEY", "TOGETHER_API_KEY", "HF_TOKEN", ] for key in keys_to_remove: os.environ.pop(key, None) @when("I load the settings") def step_load_settings(context): """Load settings from environment.""" # Clear the singleton to force reload Settings._instance = None context.settings = Settings() @when("I load the settings with defaults") def step_load_settings_defaults(context): """Load settings with defaults.""" # Clear the singleton to force reload Settings._instance = None context.settings = Settings() @when('I construct settings with default provider "{provider}"') def step_construct_settings_default_provider(context, provider): """Construct a settings instance with an explicit default provider value.""" Settings._instance = None context.settings = Settings.model_construct(default_provider=provider) @when('I construct settings with default model "{model}"') def step_construct_settings_default_model(context, model): """Construct a settings instance with an explicit default model value.""" Settings._instance = None context.settings = Settings.model_construct(default_model=model) @then('the log level should be "{expected}"') def step_check_log_level(context, expected): """Check the log level.""" assert context.settings.log_level == expected @then('the log directory should be "{expected}"') def step_check_log_dir(context, expected): """Check the log directory.""" assert str(context.settings.log_dir) == expected @then('the log directory should contain "{substring}"') def step_check_log_dir_contains(context, substring): """Check the log directory contains a substring.""" assert substring in str(context.settings.log_dir) @then("the data directory should equal the home cleveragents path") def step_check_data_dir_home_cleveragents(context): """Check that data_dir defaults to Path.home() / '.cleveragents'.""" expected = Path.home() / ".cleveragents" assert context.settings.data_dir == expected, ( f"Expected data_dir={expected!r}, got {context.settings.data_dir!r}" ) @then('the data directory should be "{expected}"') def step_check_data_dir(context, expected): """Check the data directory.""" assert str(context.settings.data_dir) == expected @then('the data directory should contain "{substring}"') def step_check_data_dir_contains(context, substring): """Check the data directory contains a substring.""" assert substring in str(context.settings.data_dir) @then('the environment should be "{expected}"') def step_check_environment(context, expected): """Check the environment.""" assert context.settings.env == expected @when('I set the environment alias to "{value}"') def step_set_environment_alias(context, value): """Update the environment alias property.""" assert hasattr(context, "settings"), ( "Settings must be loaded before updating the environment alias." ) context.settings.environment = value @then('the environment alias should be "{expected}"') def step_check_environment_alias(context, expected): """Validate the environment alias getter.""" assert context.settings.environment == expected @then('the database URL should be "{expected}"') def step_check_database_url(context, expected): """Check the database URL.""" assert context.settings.database_url == expected @then("the database URL should be a SQLite database") def step_check_sqlite_database(context): """Check that the database URL is SQLite.""" assert context.settings.database_url.startswith("sqlite://") @then("Anthropic should be configured") def step_check_anthropic_configured(context): """Check that Anthropic is configured.""" assert context.settings.anthropic_api_key is not None @then("no providers should be configured") def step_check_no_providers(context): """Check that no providers are configured.""" assert not context.settings.has_provider_configured() @given('the data directory is "{directory}"') def step_set_data_directory(context, directory): """Set the data directory for testing.""" context.test_data_dir = directory @given('the storage base path is "{directory}"') def step_set_storage_base_path(context, directory): """Record the storage base directory used for tests.""" context.storage_base_dir = directory @when('I compute the storage path for "{storage_type}"') def step_compute_storage_path(context, storage_type): """Compute the storage path from the configured base.""" base_dir = getattr(context, "storage_base_dir", None) assert base_dir is not None, "Storage base path must be set before computing it." settings = Settings.model_construct(storage_base_path=Path(base_dir)) context.storage_path = settings.get_storage_base_path(storage_type) @when("I compute the default storage path") def step_compute_default_storage_path(context): """Compute the storage path when no type is supplied.""" base_dir = getattr(context, "storage_base_dir", None) assert base_dir is not None, "Storage base path must be set before computing it." settings = Settings.model_construct(storage_base_path=Path(base_dir)) context.storage_path = settings.get_storage_base_path() @when('I get the storage base path for "{storage_type}"') def step_get_storage_path(context, storage_type): """Get the storage base path.""" settings = Settings(data_dir=Path(context.test_data_dir)) context.storage_path = settings.get_storage_base_path(storage_type) @then('the storage path should be "{expected}"') def step_check_storage_path(context, expected): """Check the storage path.""" assert str(context.storage_path) == expected @given('the vector store path override is "{path_value}"') def step_set_vector_store_override(context, path_value): """Record the vector store path override for later resolution.""" context.vector_store_override = path_value @when("I resolve the vector store path") def step_resolve_vector_store_path(context): """Resolve the configured vector store path to an absolute location.""" override = getattr(context, "vector_store_override", None) assert override is not None, "Vector store path override must be provided first." Settings._instance = None settings = Settings.model_construct(vector_store_path=Path(override)) context.resolved_vector_store_path = settings.resolve_vector_store_path() @then( "the resolved vector store path should equal the override under the current working directory" ) def step_verify_resolved_vector_store_path(context): """Ensure the vector store path is resolved relative to the current working directory.""" override = getattr(context, "vector_store_override", None) assert override is not None, "Vector store path override must be provided first." expected = (Path.cwd() / Path(override)).resolve() assert context.resolved_vector_store_path == expected @given('the environment is set to "{env}"') def step_set_environment(context, env): """Set the environment.""" os.environ["CLEVERAGENTS_ENV"] = env if not hasattr(context, "env_vars_to_clean"): context.env_vars_to_clean = [] context.env_vars_to_clean.append("CLEVERAGENTS_ENV") @when("I check if running in production") def step_check_is_production(context): """Check if running in production.""" Settings._instance = None settings = Settings() context.is_production = settings.is_production() @when( 'I evaluate production mode with env "{env_value}", debug "{debug_flag}", and reload "{reload_flag}"' ) def step_evaluate_production_mode_flags(context, env_value, debug_flag, reload_flag): """Evaluate production helpers for the supplied flags.""" Settings._instance = None settings = Settings.model_construct( env=env_value, debug_enabled=debug_flag.lower() == "true", server_reload=reload_flag.lower() == "true", ) context.is_production = settings.is_production() context.is_production_mode = settings.is_production_mode @then("is_production should be {expected}") def step_verify_is_production(context, expected): """Verify the is_production value.""" expected_bool = expected == "True" assert context.is_production == expected_bool @then("is_production_mode should be {expected}") def step_verify_is_production_mode(context, expected): """Verify the is_production_mode alias.""" expected_bool = expected == "True" assert context.is_production_mode == expected_bool @given('the {provider} API key is set to "{key}"') def step_set_provider_key(context, provider, key): """Set a provider API key.""" env_key = f"{provider.upper().replace(' ', '_')}_API_KEY" os.environ[env_key] = key if not hasattr(context, "env_vars_to_clean"): context.env_vars_to_clean = [] context.env_vars_to_clean.append(env_key) @when('I instantiate settings with explicit openai API key "{value}"') def step_instantiate_settings_with_openai(context, value): """Instantiate settings with a constructor-specified OpenAI key.""" Settings._instance = None context.settings = Settings.model_construct(openai_api_key=value) @when('I check if "{provider}" provider is configured') def step_check_provider_configured(context, provider): """Check if a provider is configured.""" Settings._instance = None settings = Settings() context.provider_configured = settings.has_provider_configured( provider.lower().replace(" ", "") ) @when('I check if "{provider}" provider is configured using a constructed instance') def step_check_provider_configured_constructed(context, provider): """Check provider configuration using a lightweight constructed settings instance.""" Settings._instance = None settings = Settings.model_construct() context.provider_configured = settings.has_provider_configured( provider.lower().replace(" ", "") ) @when('I clear the explicit "{provider}" key and recheck configuration') def step_clear_explicit_key_and_recheck(context, provider): """Clear the in-memory provider key and re-evaluate configuration.""" Settings._instance = None settings = Settings() attr = provider.lower().replace(" ", "_") + "_api_key" setattr(settings, attr, None) context.provider_configured = settings.has_provider_configured(provider) @then('has_provider_configured should be {expected} for "{provider}"') def step_verify_provider_configured(context, expected, provider): """Verify provider configuration status.""" expected_bool = expected == "True" assert context.provider_configured == expected_bool @then("has_provider_configured should be {expected}") def step_verify_any_provider_configured(context, expected): """Verify any provider configuration status.""" expected_bool = expected == "True" assert context.provider_configured == expected_bool @given("no API keys are set") def step_clear_api_keys(context): """Clear all API keys.""" api_keys = [ "OPENAI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY", "GOOGLE_GENAI_API_KEY", "GEMINI_API_KEY", "AZURE_OPENAI_API_KEY", "AZURE_API_KEY", "DEEPSEEK_API_KEY", "COHERE_API_KEY", "PERPLEXITY_API_KEY", "GROQ_API_KEY", "TOGETHER_API_KEY", "HF_TOKEN", ] for key in api_keys: os.environ.pop(key, None) @given('a temporary provider "{provider}" uses env "{env_var}"') def step_define_temporary_provider(context, provider, env_var): """Temporarily register a provider mapping for provider lookup tests.""" normalized = provider.lower().replace(" ", "_") if not normalized.endswith("_api_key"): normalized = f"{normalized}_api_key" original_map = Settings._PROVIDER_ENV_MAP original_override = Settings._apply_external_env_overrides Settings._PROVIDER_ENV_MAP = dict(Settings._PROVIDER_ENV_MAP) Settings._PROVIDER_ENV_MAP[normalized] = (env_var,) Settings._apply_external_env_overrides = lambda self: None if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] def cleanup(): Settings._PROVIDER_ENV_MAP = original_map Settings._apply_external_env_overrides = original_override context._cleanup_handlers.append(cleanup) @when("I check if any provider is configured") def step_check_any_provider(context): """Check if any provider is configured.""" Settings._instance = None settings = Settings() context.provider_configured = settings.has_provider_configured() @given('the database URL is set to "{url}"') def step_set_database_url(context, url): """Set the database URL.""" os.environ["DATABASE_URL"] = url if not hasattr(context, "env_vars_to_clean"): context.env_vars_to_clean = [] context.env_vars_to_clean.append("DATABASE_URL") @given("no database URL is set") def step_clear_database_url(context): """Clear the database URL.""" os.environ.pop("DATABASE_URL", None) @when("I get the database URL") def step_get_database_url(context): """Get the database URL.""" Settings._instance = None if hasattr(context, "test_data_dir"): settings = Settings(data_dir=Path(context.test_data_dir)) else: settings = Settings() context.database_url = settings.get_database_url() @when('I derive the test database URL from "{url}"') def step_derive_test_database_url(context, url): """Derive the fallback test database URL when none is provided.""" Settings._instance = None settings = Settings.model_construct(database_url=url, test_database_url=None) context.derived_database_url = settings.get_database_url(test=True) @then('the derived test database URL should be "{expected}"') def step_check_derived_test_database_url(context, expected): """Verify the derived test database URL.""" assert context.derived_database_url == expected @when("I resolve provider defaults") def step_resolve_provider_defaults(context): """Resolve provider defaults from the loaded settings.""" assert hasattr(context, "settings"), "Load settings before resolving defaults." context.provider_defaults = context.settings.resolve_provider_defaults() @then( 'the provider defaults should report provider "{provider}" from source "{source}"' ) def step_verify_provider_default_provider(context, provider, source): defaults = getattr(context, "provider_defaults", None) assert defaults is not None, "Provider defaults must be resolved first." assert (defaults.provider or "") == provider assert defaults.provider_source == source @then('the provider defaults should report model "{model}" from source "{source}"') def step_verify_provider_default_model(context, model, source): defaults = getattr(context, "provider_defaults", None) assert defaults is not None, "Provider defaults must be resolved first." assert (defaults.model or "") == model assert defaults.model_source == source @when('I validate provider configuration for "{provider}"') def step_validate_provider_configuration(context, provider): """Validate configuration for a specific provider name.""" assert hasattr(context, "settings"), "Load settings before validation." target = None if provider.lower() in {"none", "default", ""} else provider context.provider_validation = context.settings.validate_provider_configuration( target ) @then("provider validation should fail") def step_provider_validation_fail(context): passed, _ = getattr(context, "provider_validation", (False, [])) assert not passed, "Expected provider validation to fail." @then("provider validation should pass with no errors") def step_provider_validation_pass(context): passed, errors = getattr(context, "provider_validation", (False, [])) assert passed, "Expected provider validation to pass." assert not errors, f"Expected no validation errors, got: {errors}" @then('provider validation errors should include "{message}"') def step_provider_validation_errors_include(context, message): _, errors = getattr(context, "provider_validation", (False, [])) assert any(message in err for err in errors), ( f"Expected '{message}' in validation errors {errors}" ) @when('I normalize the provider name "{raw}"') def step_normalize_provider_name(context, raw): """Normalize a provider name through the settings helper.""" Settings._instance = None settings = Settings.model_construct() context.normalized_provider_name = settings._normalize_provider_name(raw) @then("the normalized provider name should be absent") def step_normalized_provider_absent(context): """Assert that the normalization result is None.""" assert hasattr(context, "normalized_provider_name"), ( "Provider normalization must be performed before assertions." ) assert context.normalized_provider_name is None @then('the normalized provider name should be "{expected}"') def step_normalized_provider_value(context, expected): """Assert that the normalization result equals the expected value.""" assert hasattr(context, "normalized_provider_name"), ( "Provider normalization must be performed before assertions." ) assert context.normalized_provider_name == expected @when("I get the settings instance") def step_get_settings(context): """Get the settings instance.""" context.settings1 = Settings.get_settings() @when("I get the settings instance again") def step_get_settings_again(context): """Get the settings instance again.""" context.settings2 = Settings.get_settings() @then("both instances should be the same object") def step_verify_singleton(context): """Verify both instances are the same.""" assert context.settings1 is context.settings2 @then('the {provider} API key should be "{expected}"') def step_check_provider_api_key(context, provider, expected): """Check a provider API key value.""" provider_key = provider.lower().replace(" ", "_") + "_api_key" assert getattr(context.settings, provider_key) == expected @when("I build the LangSmith config") def step_build_langsmith_config(context): """Build the LangSmith configuration payload.""" Settings._instance = None settings = Settings() context.langsmith_config = settings.build_langsmith_config() @then("the LangSmith config should be absent") def step_assert_langsmith_config_absent(context): """Ensure LangSmith config is not generated when disabled.""" assert context.langsmith_config is None @given("LangSmith is enabled without credentials") def step_enable_langsmith_without_credentials(context): """Create a settings object with LangSmith enabled but missing key/project.""" Settings._instance = None context.settings = Settings.model_construct( langsmith_enabled=True, langsmith_api_key=None, langsmith_project=None, ) @when("I validate the LangSmith configuration") def step_validate_langsmith_configuration(context): """Run validation against the current LangSmith settings.""" assert hasattr(context, "settings"), "Settings must be initialized first." context.langsmith_validation = context.settings.validate_langsmith_configuration() @then("LangSmith validation should fail with {error_count:d} errors") def step_assert_langsmith_validation_errors(context, error_count): """Assert the validation result and number of reported errors.""" enabled, errors = context.langsmith_validation assert enabled is False assert len(errors) == error_count @given( 'LangSmith is configured with API key "{api_key}", project "{project}", and endpoint "{endpoint}"' ) def step_configure_langsmith_with_endpoint(context, api_key, project, endpoint): """Construct LangSmith settings with explicit endpoint details.""" Settings._instance = None context.settings = Settings.model_construct( langsmith_enabled=True, langsmith_api_key=api_key, langsmith_project=project, langsmith_endpoint=endpoint, ) @when("I check if LangSmith is enabled") def step_check_langsmith_enabled(context): """Evaluate whether LangSmith is enabled for the current settings.""" assert hasattr(context, "settings"), "Settings must be initialized first." context.langsmith_enabled_flag = context.settings.is_langsmith_enabled @then('the "{env_var}" environment variable should equal "{expected}"') def step_verify_environment_variable(context, env_var, expected): """Verify that an environment variable has the expected value.""" assert os.environ.get(env_var) == expected if not hasattr(context, "env_vars_to_clean"): context.env_vars_to_clean = [] if env_var not in context.env_vars_to_clean: context.env_vars_to_clean.append(env_var) @given("I load singleton settings via get_settings") @when("I load singleton settings via get_settings") def step_load_singleton_settings_via_get_settings(context: Context) -> None: """Load settings through the singleton accessor and store the instance.""" settings = Settings.get_settings() if not hasattr(context, "singleton_instances"): context.singleton_instances = [] context.singleton_instances.append(settings) context.settings = settings @when("I reset singleton settings") def step_reset_singleton_settings(context: Context) -> None: """Reset the singleton cache for Settings. Uses the ``reset()`` classmethod when available, falling back to direct ``_instance = None`` assignment to guard against Pydantic metaclass attribute-resolution edge cases during test runs. """ reset_fn = Settings.__dict__.get("reset") if reset_fn is not None: # Unwrap the classmethod descriptor and call. reset_fn.__func__(Settings) else: Settings._instance = None # type: ignore[assignment] @then("singleton settings instances should be different") def step_singleton_instances_should_be_different(context: Context) -> None: """Assert reset causes a fresh singleton instance to be constructed.""" instances = getattr(context, "singleton_instances", []) assert len(instances) >= 2, "Expected at least two singleton instances." assert instances[0] is not instances[-1] @then('singleton environment should be "{expected}"') def step_singleton_environment_should_be(context: Context, expected: str) -> None: """Assert the latest singleton reflects the current environment value.""" assert context.settings.environment == expected @given('"{key}" is not set') def step_env_var_not_set(context: Context, key: str) -> None: """Ensure a specific environment variable is not set.""" os.environ.pop(key, None) if not hasattr(context, "env_vars_to_clean"): context.env_vars_to_clean = [] # Track it so teardown knows to clean it if it gets set later context.env_vars_to_clean.append(key) @then('the database URL should point to "~/.cleveragents/cleveragents.db"') def step_database_url_points_to_home(context: Context) -> None: """Verify the database URL resolves to the spec-required home directory path.""" expected_path = Path.home() / ".cleveragents" / "cleveragents.db" url = context.settings.database_url assert str(expected_path) in url, ( f"Expected database URL to contain '{expected_path}', got: {url}" ) @then('the database URL should start with "{prefix}"') def step_database_url_starts_with(context: Context, prefix: str) -> None: """Verify the database URL starts with the given prefix.""" url = context.settings.database_url assert url.startswith(prefix), ( f"Expected database URL to start with '{prefix}', got: {url}" ) @then('the database URL should contain "{substring}"') def step_database_url_contains(context: Context, substring: str) -> None: """Verify the database URL contains the given substring.""" url = context.settings.database_url assert substring in url, ( f"Expected database URL to contain '{substring}', got: {url}" ) @then("the database URL should be an absolute path") def step_database_url_is_absolute(context: Context) -> None: """Verify the database URL uses an absolute path (not relative).""" url = context.settings.database_url # For SQLite URLs: sqlite:////absolute/path or sqlite:///relative # Absolute paths have 4 slashes: sqlite:////home/... # Relative paths have 3 slashes: sqlite:///relative assert url.startswith("sqlite:////") or ( url.startswith("sqlite:///") and Path(url[len("sqlite:///") :]).is_absolute() ), f"Expected database URL to use an absolute path, got: {url}"