diff --git a/pyproject.toml b/pyproject.toml index 58c54a3..e3ef6f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dependencies = [ "psutil>=6", "pydantic>=2,<3", "pydantic-settings>=2,<3", + "python-dotenv>=1,<2", "rich>=14,<15", "sentry-sdk>=2,<3", "sqlalchemy[asyncio]>=2,<3", diff --git a/src/aignostics_foundry_core/database.py b/src/aignostics_foundry_core/database.py index 9da40df..ffc2582 100644 --- a/src/aignostics_foundry_core/database.py +++ b/src/aignostics_foundry_core/database.py @@ -15,6 +15,7 @@ import multiprocessing.util import urllib.parse from collections.abc import AsyncGenerator +from pathlib import Path from typing import Any from loguru import logger @@ -50,20 +51,45 @@ class DatabaseSettings(OpaqueSettings): pool_timeout: float = 30.0 name: str | None = None - def __init__(self, _env_prefix: str | None = None, **kwargs: Any) -> None: # noqa: ANN401 - """Initialise settings, deriving env prefix from the active FoundryContext when not given. + def __init__( + self, + _env_prefix: str | None = None, + _env_file: list[Path] | None = None, + **kwargs: Any, # noqa: ANN401 + ) -> None: + """Initialise settings, deriving env prefix and env files from the active FoundryContext when not given. Args: _env_prefix: Optional explicit environment variable prefix (e.g. ``"MYAPP_DB_"``). When ``None``, the prefix is derived from the active FoundryContext as ``f"{get_context().env_prefix}DB_"``. + _env_file: Optional explicit list of ``.env`` files to read settings from. + When ``None`` and a :class:`~aignostics_foundry_core.foundry.FoundryContext` is + active, the context's :attr:`~aignostics_foundry_core.foundry.FoundryContext.env_file` + list is used so that all settings sources remain consistent. When ``None`` and no + context is available (but ``_env_prefix`` is provided explicitly), env-file loading + is skipped. **kwargs: Forwarded to :class:`~pydantic_settings.BaseSettings`. + + Raises: + RuntimeError: If both ``_env_prefix`` is absent and no active context is installed. """ - if _env_prefix is None: + if _env_prefix is None or _env_file is None: from aignostics_foundry_core.foundry import get_context # noqa: PLC0415 - _env_prefix = f"{get_context().env_prefix}DB_" - super().__init__(_env_prefix=_env_prefix, **kwargs) # pyright: ignore[reportCallIssue] + try: + ctx = get_context() + if _env_prefix is None: + _env_prefix = f"{ctx.env_prefix}DB_" + if _env_file is None: + _env_file = ctx.env_file + except RuntimeError: + if _env_prefix is None: + raise + if _env_file is not None: + super().__init__(_env_prefix=_env_prefix, _env_file=_env_file, **kwargs) # pyright: ignore[reportCallIssue] + else: + super().__init__(_env_prefix=_env_prefix, **kwargs) # pyright: ignore[reportCallIssue] def get_url(self) -> str: """Return the database URL string, optionally substituting the database name. diff --git a/src/aignostics_foundry_core/foundry.py b/src/aignostics_foundry_core/foundry.py index b68a661..4ccf5c8 100644 --- a/src/aignostics_foundry_core/foundry.py +++ b/src/aignostics_foundry_core/foundry.py @@ -27,6 +27,7 @@ from importlib import metadata from pathlib import Path +from dotenv import dotenv_values from pydantic import BaseModel, Field from aignostics_foundry_core.database import DatabaseSettings @@ -122,14 +123,20 @@ def from_package(cls, package_name: str) -> FoundryContext: project_path = _find_project_path(package_name) vcs_ref = os.environ.get("VCS_REF") or (project_path and _get_vcs_ref_from_git(project_path)) or "unknown" env_prefix = f"{name_upper}_" - database = DatabaseSettings(_env_prefix=f"{env_prefix}DB_") if os.environ.get(f"{env_prefix}DB_URL") else None + env_files = _build_env_file_list(name, name_upper, environment) + db_url_key = f"{env_prefix}DB_URL" + database = ( + DatabaseSettings(_env_prefix=f"{env_prefix}DB_", _env_file=env_files) + if os.environ.get(db_url_key) or _any_env_file_has(db_url_key, env_files) + else None + ) return cls( name=name, version=version, version_full=_build_version_full(version, vcs_ref), version_with_vcs_ref=_build_version_with_vcs_ref(version, vcs_ref), environment=environment, - env_file=_build_env_file_list(name, name_upper, environment), + env_file=env_files, env_prefix=env_prefix, repository_url=repository_url, documentation_url=documentation_url, @@ -264,6 +271,19 @@ def _build_env_file_list(name: str, name_upper: str, environment: str) -> list[P return paths +def _any_env_file_has(key: str, env_files: list[Path]) -> bool: + """Return True if *key* appears in any of the given env files. + + Args: + key: The environment variable key to look for. + env_files: Ordered list of env file paths to search. + + Returns: + True if *key* is found in any readable env file, False otherwise. + """ + return any(key in dotenv_values(f) for f in env_files if f.is_file()) + + def _extract_urls(package_name: str) -> tuple[str, str]: """Return ``(repository_url, documentation_url)`` from package metadata.""" pkg_metadata = metadata.metadata(package_name) diff --git a/tests/aignostics_foundry_core/database_settings_test.py b/tests/aignostics_foundry_core/database_settings_test.py index 20f8a52..ed6b93b 100644 --- a/tests/aignostics_foundry_core/database_settings_test.py +++ b/tests/aignostics_foundry_core/database_settings_test.py @@ -1,6 +1,7 @@ """Tests for DatabaseSettings.""" from collections.abc import Generator +from pathlib import Path import pytest @@ -11,6 +12,9 @@ # Constants (SonarQube S1192) POSTGRES_URL = "postgresql+asyncpg://user:pass@localhost:5432/postgres" SQLITE_URL = "sqlite+aiosqlite:///test.db" +WRONG_SQLITE_URL = "sqlite+aiosqlite:///wrong.db" +MYAPP_ENV_PREFIX = "MYAPP_" +MYAPP_DB_URL_KEY = "MYAPP_DB_URL" CUSTOM_PREFIX = "CUSTOM_DB_" CUSTOM_PREFIX_URL_ENV = "CUSTOM_DB_URL" DEFAULT_POOL_SIZE = 10 @@ -141,3 +145,44 @@ def test_url_is_masked_in_repr() -> None: representation = repr(settings) assert "pass" not in representation assert "**" in representation or "SecretStr" in representation + + +# --------------------------------------------------------------------------- +# env-file resolution via context (integration) +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +def test_database_settings_reads_url_from_env_file_via_context(tmp_path: Path) -> None: + """DatabaseSettings() with no args reads URL from context env_file when context is set.""" + env_file = tmp_path / ".env" + env_file.write_text(f"{MYAPP_DB_URL_KEY}={SQLITE_URL}\n") + + ctx = make_context(env_prefix=MYAPP_ENV_PREFIX, env_file=[env_file]) + set_context(ctx) + + settings = DatabaseSettings() + assert settings.get_url() == SQLITE_URL + + +@pytest.mark.integration +def test_database_settings_explicit_env_file_overrides_context(tmp_path: Path) -> None: + """An explicit _env_file passed to DatabaseSettings() takes precedence over the context env_file.""" + context_env_file = tmp_path / "context.env" + context_env_file.write_text(f"{MYAPP_DB_URL_KEY}={WRONG_SQLITE_URL}\n") + + explicit_env_file = tmp_path / "explicit.env" + explicit_env_file.write_text(f"{MYAPP_DB_URL_KEY}={SQLITE_URL}\n") + + ctx = make_context(env_prefix=MYAPP_ENV_PREFIX, env_file=[context_env_file]) + set_context(ctx) + + settings = DatabaseSettings(_env_file=[explicit_env_file]) + assert settings.get_url() == SQLITE_URL + + +@pytest.mark.integration +def test_database_settings_no_context_raises_without_prefix() -> None: + """DatabaseSettings() raises RuntimeError when no context is installed and no prefix is given.""" + with pytest.raises(RuntimeError, match="get_context\\(\\) called before set_context"): + DatabaseSettings() diff --git a/tests/aignostics_foundry_core/foundry_test.py b/tests/aignostics_foundry_core/foundry_test.py index e424aaf..b04c8b0 100644 --- a/tests/aignostics_foundry_core/foundry_test.py +++ b/tests/aignostics_foundry_core/foundry_test.py @@ -23,6 +23,7 @@ SQLITE_URL = "sqlite+aiosqlite:///test.db" DB_URL_ENV_KEY = f"{PACKAGE_NAME.upper()}_DB_URL" DB_POOL_SIZE_ENV_KEY = f"{PACKAGE_NAME.upper()}_DB_POOL_SIZE" +ENV_FILE_ENV_KEY = f"{PACKAGE_NAME.upper()}_ENV_FILE" ERROR_MSG_FRAGMENT = "set_context" VCS_REF_VALUE = "abc123" VCS_REF_OVERRIDE = "ci-override-ref" @@ -675,6 +676,78 @@ def test_from_package_called_twice_is_safe() -> None: assert result.returncode == 0, result.stderr +@pytest.mark.integration +def test_from_package_sets_database_when_db_url_only_in_env_file(tmp_path: Path) -> None: + """from_package() populates database when DB_URL is only in a .env file, not in OS env.""" + env_file = tmp_path / ".env" + env_file.write_text(f"{DB_URL_ENV_KEY}={SQLITE_URL}\n") + + script = textwrap.dedent(f""" + from aignostics_foundry_core.foundry import FoundryContext + ctx = FoundryContext.from_package("{PACKAGE_NAME}") + assert ctx.database is not None, "database should not be None" + assert ctx.database.get_url() == "{SQLITE_URL}", f"expected {SQLITE_URL!r}, got {{ctx.database.get_url()!r}}" + """) + env = os.environ.copy() + env.pop(DB_URL_ENV_KEY, None) + env[ENV_FILE_ENV_KEY] = str(env_file) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, env=env, check=False) + assert result.returncode == 0, result.stderr + + +@pytest.mark.integration +def test_from_package_database_is_none_when_db_url_absent_from_env_file(tmp_path: Path) -> None: + """from_package() sets database=None when DB_URL is absent from both OS env and .env file.""" + env_file = tmp_path / ".env" + env_file.write_text("SOME_OTHER_KEY=value\n") + + script = textwrap.dedent(f""" + from aignostics_foundry_core.foundry import FoundryContext + ctx = FoundryContext.from_package("{PACKAGE_NAME}") + assert ctx.database is None, "database should be None when DB_URL is absent" + """) + env = os.environ.copy() + env.pop(DB_URL_ENV_KEY, None) + env[ENV_FILE_ENV_KEY] = str(env_file) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, env=env, check=False) + assert result.returncode == 0, result.stderr + + +@pytest.mark.integration +def test_from_package_reads_pool_settings_from_env_file(tmp_path: Path) -> None: + """from_package() reads pool_size from .env file when DB_URL is also in the .env file.""" + env_file = tmp_path / ".env" + env_file.write_text(f"{DB_URL_ENV_KEY}={SQLITE_URL}\n{DB_POOL_SIZE_ENV_KEY}=3\n") + + script = textwrap.dedent(f""" + from aignostics_foundry_core.foundry import FoundryContext + ctx = FoundryContext.from_package("{PACKAGE_NAME}") + assert ctx.database is not None, "database should not be None" + assert ctx.database.pool_size == 3, f"expected pool_size=3, got {{ctx.database.pool_size}}" + """) + env = os.environ.copy() + env.pop(DB_URL_ENV_KEY, None) + env.pop(DB_POOL_SIZE_ENV_KEY, None) + env[ENV_FILE_ENV_KEY] = str(env_file) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, env=env, check=False) + assert result.returncode == 0, result.stderr + + +@pytest.mark.integration +def test_from_package_sets_database_when_db_url_in_os_env_and_env_file_absent(tmp_path: Path) -> None: + """from_package() populates database when DB_URL is in OS env and no .env file is set — regression.""" + script = textwrap.dedent(f""" + from aignostics_foundry_core.foundry import FoundryContext + ctx = FoundryContext.from_package("{PACKAGE_NAME}") + assert ctx.database is not None, "database should not be None when DB_URL is in OS env" + """) + env = os.environ.copy() + env[DB_URL_ENV_KEY] = SQLITE_URL + env.pop(ENV_FILE_ENV_KEY, None) + result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, env=env, check=False) + assert result.returncode == 0, result.stderr + + @pytest.mark.integration def test_make_context_without_prior_from_package() -> None: """Constructing FoundryContext directly (no from_package()) has database=None.""" diff --git a/tests/conftest.py b/tests/conftest.py index 897536e..a1c702a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -70,6 +70,7 @@ def make_context( # noqa: PLR0913 project_path: Path | None = None, repository_url: str = "", database: DatabaseSettings | None = None, + env_file: list[Path] | None = None, **kwargs: bool, ) -> FoundryContext: """Create a minimal FoundryContext for testing. @@ -83,6 +84,7 @@ def make_context( # noqa: PLR0913 repository_url: The project repository URL (defaults to ``""``). database: Optional :class:`~aignostics_foundry_core.database.DatabaseSettings` instance to attach to the context. + env_file: Optional list of ``.env`` file paths to attach to the context. **kwargs: Optional boolean flags forwarded to :class:`FoundryContext` (``is_test``, ``is_cli``, ``is_container``, ``is_library``). """ @@ -96,5 +98,6 @@ def make_context( # noqa: PLR0913 project_path=project_path, repository_url=repository_url, database=database, + env_file=env_file or [], **kwargs, # type: ignore[arg-type] ) diff --git a/uv.lock b/uv.lock index c831c45..0ae7162 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,7 @@ dependencies = [ { name = "psutil" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "python-dotenv" }, { name = "rich" }, { name = "sentry-sdk" }, { name = "sqlalchemy", extra = ["asyncio"] }, @@ -69,6 +70,7 @@ requires-dist = [ { name = "psutil", specifier = ">=6" }, { name = "pydantic", specifier = ">=2,<3" }, { name = "pydantic-settings", specifier = ">=2,<3" }, + { name = "python-dotenv", specifier = ">=1,<2" }, { name = "rich", specifier = ">=14,<15" }, { name = "sentry-sdk", specifier = ">=2,<3" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2,<3" },