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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 31 additions & 5 deletions src/aignostics_foundry_core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 22 additions & 2 deletions src/aignostics_foundry_core/foundry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 45 additions & 0 deletions tests/aignostics_foundry_core/database_settings_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for DatabaseSettings."""

from collections.abc import Generator
from pathlib import Path

import pytest

Expand All @@ -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
Expand Down Expand Up @@ -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()
73 changes: 73 additions & 0 deletions tests/aignostics_foundry_core/foundry_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."""
Expand Down
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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``).
"""
Expand All @@ -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]
)
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading