diff --git a/client_discovery/config.py b/client_discovery/config.py index bf8a8ba..f830469 100644 --- a/client_discovery/config.py +++ b/client_discovery/config.py @@ -3,6 +3,7 @@ import logging import os from pathlib import Path +from typing import Any from dotenv import load_dotenv logger = logging.getLogger(__name__) @@ -15,9 +16,9 @@ _dotenv_loaded = False -def load_and_validate_config() -> dict[str, any]: +def load_and_validate_config() -> dict[str, Any]: """ - Locates and loads environment variables, validates them, and returns + Locates and loads environment variables and returns a dictionary of configuration parameters. """ global _dotenv_loaded @@ -27,13 +28,6 @@ def load_and_validate_config() -> dict[str, any]: _dotenv_loaded = True gemini_api_key = os.environ.get("GEMINI_API_KEY") - testing = os.environ.get("TESTING") == "true" - - if not gemini_api_key: - if testing: - logger.warning("GEMINI_API_KEY is not set.") - else: - raise ValueError("GEMINI_API_KEY is required but missing from the environment.") # Optional variables jules_api_key = os.environ.get("JULES_API_KEY") @@ -86,5 +80,14 @@ def load_and_validate_config() -> dict[str, any]: } +def require_gemini_api_key(config: dict[str, Any] | None = None) -> str: + """Returns a configured Gemini API key or raises if missing/empty.""" + resolved_config = config if config is not None else load_and_validate_config() + gemini_api_key = str(resolved_config.get("GEMINI_API_KEY") or "").strip() + if not gemini_api_key: + raise ValueError("GEMINI_API_KEY is required but missing from the environment.") + return gemini_api_key + + # Provide load_config alias load_config = load_and_validate_config diff --git a/client_discovery/service.py b/client_discovery/service.py index c7158a6..20b489f 100644 --- a/client_discovery/service.py +++ b/client_discovery/service.py @@ -2,12 +2,12 @@ from dataclasses import asdict import logging -import os from pathlib import Path from unittest.mock import Mock from character import root_agent from google.adk.agents.llm_agent import LlmAgent +from client_discovery.config import load_config, require_gemini_api_key from client_discovery.core import ( generate_documents, @@ -40,8 +40,10 @@ def build_intake_response(questionnaire: str) -> dict[str, object]: # Resolve strategic analysis strategic_analysis = None - gemini_api_key = os.environ.get("GEMINI_API_KEY") - if not gemini_api_key: + config = load_config() + try: + require_gemini_api_key(config) + except ValueError: strategic_analysis = "DIA Agent Error: GEMINI_API_KEY is missing or invalid." else: try: @@ -65,7 +67,7 @@ def build_intake_response(questionnaire: str) -> dict[str, object]: draft = gemini_resp.text - jules_api_key = os.environ.get("JULES_API_KEY") + jules_api_key = str(config.get("JULES_API_KEY") or "").strip() if not jules_api_key: strategic_analysis = draft else: diff --git a/tests/test_adversarial_coverage.py b/tests/test_adversarial_coverage.py index bbe3090..aeec693 100644 --- a/tests/test_adversarial_coverage.py +++ b/tests/test_adversarial_coverage.py @@ -36,7 +36,7 @@ def test_obs_empty_password_auth_disabled(): "OBS_PORT": "4455", "OBS_PASSWORD": "", # Empty password when auth is disabled } - with patch.dict(os.environ, env): + with patch.dict(os.environ, env, clear=True): creds = _resolve_obs_credentials() assert creds is not None assert creds[2] == "" @@ -53,7 +53,7 @@ def test_obs_out_of_range_port(): "OBS_PORT": "-100", "OBS_PASSWORD": "secretpassword", } - with patch.dict(os.environ, env): + with patch.dict(os.environ, env, clear=True): creds = _resolve_obs_credentials() assert creds is None @@ -67,7 +67,7 @@ def test_jules_whitespace_api_key(): env = { "JULES_API_KEY": " ", } - with patch.dict(os.environ, env): + with patch.dict(os.environ, env, clear=True): # The API call will run because JULES_API_KEY is not empty, but it's invalid. # It should fail and return the original draft content. draft = "Original Draft content" @@ -115,7 +115,7 @@ def test_obs_screenshot_connection_failure_graceful_handling(): "OBS_PORT": "4455", "OBS_PASSWORD": "wrong_password", } - with patch.dict(os.environ, env), patch("obsws_python.ReqClient") as mock_req: + with patch.dict(os.environ, env, clear=True), patch("obsws_python.ReqClient") as mock_req: mock_req.side_effect = Exception("Connection refused") res = trigger_obs_screenshot() assert "failed" in res.lower() @@ -131,7 +131,7 @@ def test_obs_replay_buffer_connection_failure_graceful_handling(): "OBS_PORT": "4455", "OBS_PASSWORD": "wrong_password", } - with patch.dict(os.environ, env), patch("obsws_python.ReqClient") as mock_req: + with patch.dict(os.environ, env, clear=True), patch("obsws_python.ReqClient") as mock_req: mock_req.side_effect = Exception("Auth failure") res = save_obs_replay_buffer() assert "failed" in res.lower() diff --git a/tests/test_config.py b/tests/test_config.py index a08d25f..296376b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,7 +9,7 @@ from unittest.mock import patch import pytest -from client_discovery.config import load_and_validate_config +from client_discovery.config import load_and_validate_config, require_gemini_api_key @pytest.fixture(autouse=True) def reset_dotenv_loaded(): @@ -41,9 +41,8 @@ def test_load_config_missing_gemini_key_in_production(): "TESTING": "false", } with patch.dict(os.environ, env, clear=True), patch("client_discovery.config.load_dotenv"): - with pytest.raises(ValueError) as excinfo: - load_and_validate_config() - assert "GEMINI_API_KEY is required" in str(excinfo.value) + config = load_and_validate_config() + assert config["GEMINI_API_KEY"] is None def test_load_config_missing_gemini_key_in_testing(): env = { @@ -52,7 +51,18 @@ def test_load_config_missing_gemini_key_in_testing(): with patch.dict(os.environ, env, clear=True), patch("client_discovery.config.load_dotenv"), patch("client_discovery.config.logger") as mock_logger: config = load_and_validate_config() assert config["GEMINI_API_KEY"] is None - mock_logger.warning.assert_any_call("GEMINI_API_KEY is not set.") + mock_logger.warning.assert_any_call( + "JULES_API_KEY is not set. Jules refinement will fall back to returning original drafts." + ) + + +def test_require_gemini_api_key_raises_when_missing(): + env = {"TESTING": "false"} + with patch.dict(os.environ, env, clear=True), patch("client_discovery.config.load_dotenv"): + config = load_and_validate_config() + with pytest.raises(ValueError) as excinfo: + require_gemini_api_key(config) + assert "GEMINI_API_KEY is required" in str(excinfo.value) def test_load_config_missing_optional_keys(): env = { diff --git a/tests/test_core.py b/tests/test_core.py index fe004b0..a36b30e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -105,7 +105,7 @@ def test_score_opportunity_full_integration_tier(): def test_refine_with_jules_success(): env = {"JULES_API_KEY": "valid_key"} - with patch.dict(os.environ, env), patch("requests.post") as mock_post: + with patch.dict(os.environ, env, clear=True), patch("requests.post") as mock_post: mock_response = mock_post.return_value mock_response.status_code = 200 mock_response.json.return_value = {"refined_content": "Refined Draft"} @@ -123,7 +123,7 @@ def test_refine_with_jules_success(): def test_refine_with_jules_missing_key(): env = {"JULES_API_KEY": ""} - with patch.dict(os.environ, env), patch("requests.post") as mock_post: + with patch.dict(os.environ, env, clear=True), patch("requests.post") as mock_post: result = refine_with_jules("Original Draft", "CEO") assert result == "Original Draft" mock_post.assert_not_called() @@ -131,7 +131,7 @@ def test_refine_with_jules_missing_key(): def test_refine_with_jules_status_error(): env = {"JULES_API_KEY": "valid_key"} - with patch.dict(os.environ, env), patch("requests.post") as mock_post: + with patch.dict(os.environ, env, clear=True), patch("requests.post") as mock_post: mock_response = mock_post.return_value mock_response.status_code = 500 @@ -141,7 +141,7 @@ def test_refine_with_jules_status_error(): def test_refine_with_jules_timeout(): env = {"JULES_API_KEY": "valid_key"} - with patch.dict(os.environ, env), patch("requests.post") as mock_post: + with patch.dict(os.environ, env, clear=True), patch("requests.post") as mock_post: mock_post.side_effect = requests.exceptions.Timeout("Connection timed out") result = refine_with_jules("Original Draft", "CEO") @@ -150,7 +150,7 @@ def test_refine_with_jules_timeout(): def test_refine_with_jules_request_exception(): env = {"JULES_API_KEY": "valid_key"} - with patch.dict(os.environ, env), patch("requests.post") as mock_post: + with patch.dict(os.environ, env, clear=True), patch("requests.post") as mock_post: mock_post.side_effect = requests.exceptions.RequestException("Request error") result = refine_with_jules("Original Draft", "CEO") @@ -159,7 +159,7 @@ def test_refine_with_jules_request_exception(): def test_refine_with_jules_malformed_json(): env = {"JULES_API_KEY": "valid_key"} - with patch.dict(os.environ, env), patch("requests.post") as mock_post: + with patch.dict(os.environ, env, clear=True), patch("requests.post") as mock_post: mock_response = mock_post.return_value mock_response.status_code = 200 mock_response.json.side_effect = ValueError("Malformed JSON") @@ -170,7 +170,7 @@ def test_refine_with_jules_malformed_json(): def test_refine_with_jules_missing_refined_content_key(): env = {"JULES_API_KEY": "valid_key"} - with patch.dict(os.environ, env), patch("requests.post") as mock_post: + with patch.dict(os.environ, env, clear=True), patch("requests.post") as mock_post: mock_response = mock_post.return_value mock_response.status_code = 200 mock_response.json.return_value = {"something_else": "here"} @@ -181,14 +181,14 @@ def test_refine_with_jules_missing_refined_content_key(): def test_trigger_obs_screenshot_missing_credentials(): env = {"OBS_HOST": "", "OBS_PORT": "", "OBS_PASSWORD": ""} - with patch.dict(os.environ, env): + with patch.dict(os.environ, env, clear=True): res = trigger_obs_screenshot() assert "skipped" in res.lower() or "missing" in res.lower() def test_save_obs_replay_buffer_missing_credentials(): env = {"OBS_HOST": "", "OBS_PORT": "", "OBS_PASSWORD": ""} - with patch.dict(os.environ, env): + with patch.dict(os.environ, env, clear=True): res = save_obs_replay_buffer() assert "skipped" in res.lower() or "missing" in res.lower()