diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index 4a64274..e554aec 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -23,7 +23,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei | **user_agent** | Parameterised HTTP user-agent string builder | `user_agent(project_name, version, repository_url)` — builds `{project_name}-python-sdk/{version} (…)` string including platform info, current test, and GitHub Actions run URL | | **gui** | NiceGUI page helpers, auth decorators, and nav builder | `GUINamespace` (configurable page decorator namespace), `gui` (default singleton), `page_public/authenticated/admin/internal/internal_admin` decorators, `get_gui_user`, `require_gui_user`, `BaseNavBuilder`, `NavItem`, `NavGroup`, `gui_get_nav_groups(*, context=None)`, `BasePageBuilder`, `gui_register_pages(*, context=None)`, `gui_run(*, context=None, …)`; constants `WINDOW_SIZE`, `BROWSER_RECONNECT_TIMEOUT`, `RESPONSE_TIMEOUT` | | **console** | Themed terminal output | Module-level `console` object (Rich `Console`) with colour theme and `_get_console()` factory | -| **foundry** | Project context injection | `FoundryContext`, `FoundryContext.from_package()`, `set_context()`, `get_context()` — centralised project-specific values (name, version, environment, env files, URLs, `python_version`, runtime mode flags `is_container`, `is_cli`, `is_test`, `is_library`) derived from package metadata and environment variables | +| **foundry** | Project context injection | `FoundryContext`, `FoundryContext.from_package()`, `set_context()`, `get_context()` — centralised project-specific values (name, version, `version_full`, `version_with_vcs_ref`, environment, env files, URLs, `python_version`, runtime mode flags `is_container`, `is_cli`, `is_test`, `is_library`) derived from package metadata and environment variables | | **di** | Dependency injection | `locate_subclasses(cls, *, context=None)`, `locate_implementations(cls, *, context=None)`, `load_modules(*, context=None)`, `discover_plugin_packages`, `clear_caches`, `PLUGIN_ENTRY_POINT_GROUP` for plugin and subclass discovery | | **health** | Service health checks | `Health` model and `HealthStatus` enum for tree-structured health status | | **settings** | Pydantic settings loading | `OpaqueSettings`, `load_settings`, `strip_to_none_before_validator`, `UNHIDE_SENSITIVE_INFO` for env-based settings with secret masking and user-friendly validation errors | @@ -41,7 +41,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei application startup makes the context available everywhere in the library without threading values through call sites. Tests pass an explicit context override and never touch global state. - **Key Features**: - - `FoundryContext(BaseModel)` — frozen; fields: `name`, `version`, `version_full`, `environment`, + - `FoundryContext(BaseModel)` — frozen; fields: `name`, `version`, `version_full`, `version_with_vcs_ref`, `environment`, `env_file: list[Path]`, `repository_url`, `documentation_url`, `python_version` (Python runtime version string, e.g. `"3.11.9"`), plus four runtime mode bool flags: `is_container`, `is_cli`, `is_test`, `is_library` (all default `False`). diff --git a/src/aignostics_foundry_core/foundry.py b/src/aignostics_foundry_core/foundry.py index 1990582..ae2ffd8 100644 --- a/src/aignostics_foundry_core/foundry.py +++ b/src/aignostics_foundry_core/foundry.py @@ -56,6 +56,15 @@ class FoundryContext(BaseModel): Falls back to reading the current branch or commit SHA from ``.git/HEAD`` when ``VCS_REF`` is absent and :attr:`project_path` is set. """ + version_with_vcs_ref: str + """Compact version string with VCS ref and commit SHA only. + + Derived from :attr:`version` with ``+[-]`` appended + when ``VCS_REF`` and/or ``COMMIT_SHA`` environment variables are present. + Unlike :attr:`version_full`, this field omits CI build metadata + (``CI_RUN_ID``, ``CI_RUN_NUMBER``, ``BUILDER``, ``BUILD_DATE``). + Falls back to :attr:`version` when neither variable is set. + """ environment: str env_file: list[Path] = Field(default_factory=_empty_path_list) env_prefix: str = "" @@ -107,6 +116,7 @@ def from_package(cls, package_name: str) -> FoundryContext: 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_prefix=f"{name_upper}_", @@ -197,6 +207,24 @@ def _build_version_full(version: str, vcs_ref: str) -> str: return result +def _build_version_with_vcs_ref(version: str, vcs_ref: str) -> str: + """Append VCS ref and commit SHA to *version*, omitting CI build metadata. + + Args: + version: The base version string (e.g. ``"1.2.3"``). + vcs_ref: The VCS ref string (branch name, short SHA, or ``"unknown"``). + + Returns: + The version string with optional ``+[-]`` suffix, + or the bare *version* when both values are ``"unknown"``. + """ + commit_sha = os.getenv("COMMIT_SHA", "unknown") + parts = [p for p in [vcs_ref, commit_sha] if p != "unknown"] + if not parts: + return version + return version + "+" + "-".join(parts) + + def _detect_environment(name_upper: str) -> str: """Return the deployment environment from environment variables.""" for env_var in [f"{name_upper}_ENVIRONMENT", "ENV", "VERCEL_ENV", "RAILWAY_ENVIRONMENT"]: diff --git a/src/aignostics_foundry_core/user_agent.py b/src/aignostics_foundry_core/user_agent.py index 6b43b6f..dd7ad22 100644 --- a/src/aignostics_foundry_core/user_agent.py +++ b/src/aignostics_foundry_core/user_agent.py @@ -2,21 +2,28 @@ import os import platform +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from aignostics_foundry_core.foundry import FoundryContext +from aignostics_foundry_core.foundry import get_context -def user_agent(project_name: str, version: str, repository_url: str) -> str: + +def user_agent(*, context: "FoundryContext | None" = None) -> str: """Generate a user agent string for HTTP requests. - Format: {project_name}-python-sdk/{version} ({platform}; +{repository_url}; {optional_parts}) + Format: {name}-python-sdk/{version_full} ({platform}; +{repository_url}; {optional_parts}) Args: - project_name: The name of the project (e.g. "bridge"). - version: The version string (e.g. "1.2.3"). - repository_url: The URL of the project repository. + context: The :class:`~aignostics_foundry_core.foundry.FoundryContext` to use. + When ``None``, falls back to the process-level context installed via + :func:`~aignostics_foundry_core.foundry.set_context`. Returns: str: The user agent string. """ + ctx = context or get_context() + current_test = os.getenv("PYTEST_CURRENT_TEST") # Set if running under pytest github_run_id = os.getenv("GITHUB_RUN_ID") # Set if running in GitHub Actions github_repository = os.getenv("GITHUB_REPOSITORY") # Set if running in GitHub Actions @@ -32,8 +39,9 @@ def user_agent(project_name: str, version: str, repository_url: str) -> str: optional_suffix = "; " + "; ".join(optional_parts) if optional_parts else "" - # Format: {project}-python-sdk/{version} ({platform}; +{repository_url}; {optional_parts}) - base_info = f"{project_name}-python-sdk/{version}" - system_info = f"{platform.platform()}; +{repository_url}{optional_suffix}" + # Format: {project}-python-sdk/{version_full} ({platform}; +{repository_url}; {optional_parts}) + # TODO(oliverm): Find a way to not hard code python-sdk here. This was taken as such from Bridge. + base_info = f"{ctx.name}-python-sdk/{ctx.version_full}" + system_info = f"{platform.platform()}; +{ctx.repository_url}{optional_suffix}" return f"{base_info} ({system_info})" diff --git a/tests/aignostics_foundry_core/api/auth_test.py b/tests/aignostics_foundry_core/api/auth_test.py index 14345e3..411beb7 100644 --- a/tests/aignostics_foundry_core/api/auth_test.py +++ b/tests/aignostics_foundry_core/api/auth_test.py @@ -20,7 +20,7 @@ require_internal_admin, ) from aignostics_foundry_core.foundry import reset_context, set_context -from tests.conftest import make_context +from tests.conftest import TEST_PROJECT_PREFIX, make_context _INTERNAL_ORG_ID = "org_internal_123" _OTHER_ORG_ID = "org_other_456" @@ -36,7 +36,7 @@ def _auth_context() -> Generator[None, None, None]: # pyright: ignore[reportUnu Yields: None """ - set_context(make_context("foundry", "FOUNDRY_")) + set_context(make_context()) yield reset_context() @@ -105,8 +105,8 @@ def test_auth_settings_role_claim_value(self) -> None: def test_auth_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: """AuthSettings reads env vars from the prefix supplied by FoundryContext.""" - set_context(make_context("proj", "PROJ_")) - monkeypatch.setenv("PROJ_AUTH_AUTH0_ROLE_CLAIM", "https://custom/role") + set_context(make_context()) + monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", "https://custom/role") settings = AuthSettings() assert settings.auth0_role_claim == "https://custom/role" @@ -266,7 +266,7 @@ async def test_wrong_org_raises_forbidden_error(self, monkeypatch: pytest.Monkey async def test_internal_org_member_passes(self, monkeypatch: pytest.MonkeyPatch) -> None: """require_internal returns None without raising when user is in the internal org.""" - monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) + monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) request = MagicMock() user = {"sub": _USER_SUB, "org_id": _INTERNAL_ORG_ID, "exp": int(time.time()) + 3600} fake_client = MagicMock() @@ -304,8 +304,8 @@ async def test_wrong_org_raises_forbidden_error(self, monkeypatch: pytest.Monkey async def test_correct_org_wrong_role_raises_forbidden_error(self, monkeypatch: pytest.MonkeyPatch) -> None: """require_internal_admin raises ForbiddenError when user is in internal org but lacks admin role.""" - monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) - monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False) + monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) + monkeypatch.delenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", raising=False) request = MagicMock() user = { "sub": _USER_SUB, @@ -322,8 +322,8 @@ async def test_correct_org_wrong_role_raises_forbidden_error(self, monkeypatch: async def test_internal_admin_passes(self, monkeypatch: pytest.MonkeyPatch) -> None: """require_internal_admin returns None without raising when user is internal org admin.""" - monkeypatch.setenv("FOUNDRY_AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) - monkeypatch.delenv("FOUNDRY_AUTH_AUTH0_ROLE_CLAIM", raising=False) + monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}AUTH_INTERNAL_ORG_ID", _INTERNAL_ORG_ID) + monkeypatch.delenv(f"{TEST_PROJECT_PREFIX}AUTH_AUTH0_ROLE_CLAIM", raising=False) request = MagicMock() user = { "sub": _USER_SUB, diff --git a/tests/aignostics_foundry_core/boot_test.py b/tests/aignostics_foundry_core/boot_test.py index 3cc4b9b..a05ccae 100644 --- a/tests/aignostics_foundry_core/boot_test.py +++ b/tests/aignostics_foundry_core/boot_test.py @@ -12,9 +12,8 @@ import aignostics_foundry_core.boot as boot_mod from aignostics_foundry_core.foundry import set_context -from tests.conftest import make_context +from tests.conftest import TEST_PROJECT_NAME, TEST_PROJECT_PREFIX, make_context -_PROJECT = "testapp" _OTHER_PROJECT = "otherapp" @@ -27,7 +26,7 @@ def test_boot_can_be_called(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(boot_mod, "truststore", None) monkeypatch.setattr(boot_mod, "certifi", None) - boot_mod.boot(context=make_context(_PROJECT), sentry_integrations=None) # must not raise + boot_mod.boot(context=make_context(), sentry_integrations=None) # must not raise @pytest.mark.unit @@ -41,8 +40,8 @@ def test_boot_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(boot_mod, "truststore", None) monkeypatch.setattr(boot_mod, "certifi", None) - boot_mod.boot(context=make_context(_PROJECT), sentry_integrations=None) - boot_mod.boot(context=make_context(_PROJECT), sentry_integrations=None) + boot_mod.boot(context=make_context(), sentry_integrations=None) + boot_mod.boot(context=make_context(), sentry_integrations=None) assert mock_logging.call_count == 1 @@ -55,14 +54,14 @@ def test_parse_env_args_injects_matching_vars(monkeypatch: pytest.MonkeyPatch) - monkeypatch.setattr(boot_mod, "sentry_initialize", MagicMock(return_value=False)) monkeypatch.setattr(boot_mod, "truststore", None) monkeypatch.setattr(boot_mod, "certifi", None) - monkeypatch.delitem(os.environ, "TESTAPP_FOO", raising=False) - monkeypatch.setattr(sys, "argv", ["script.py", "--env", "TESTAPP_FOO=bar"]) + monkeypatch.delitem(os.environ, f"{TEST_PROJECT_PREFIX}FOO", raising=False) + monkeypatch.setattr(sys, "argv", ["script.py", "--env", f"{TEST_PROJECT_PREFIX}FOO=bar"]) - boot_mod.boot(context=make_context(_PROJECT), sentry_integrations=None) + boot_mod.boot(context=make_context(), sentry_integrations=None) - assert os.environ.get("TESTAPP_FOO") == "bar" + assert os.environ.get(f"{TEST_PROJECT_PREFIX}FOO") == "bar" assert "--env" not in sys.argv - assert "TESTAPP_FOO=bar" not in sys.argv + assert f"{TEST_PROJECT_PREFIX}FOO=bar" not in sys.argv @pytest.mark.unit @@ -79,7 +78,7 @@ def test_boot_amends_ssl_trust_chain(monkeypatch: pytest.MonkeyPatch) -> None: mock_paths = types.SimpleNamespace(cafile=None) monkeypatch.setattr(ssl, "get_default_verify_paths", lambda: mock_paths) - boot_mod.boot(context=make_context(_PROJECT), sentry_integrations=None) + boot_mod.boot(context=make_context(), sentry_integrations=None) assert "SSL_CERT_FILE" in os.environ @@ -94,11 +93,11 @@ def test_boot_uses_global_context_when_none_provided(monkeypatch: pytest.MonkeyP monkeypatch.setattr(boot_mod, "truststore", None) monkeypatch.setattr(boot_mod, "certifi", None) - set_context(make_context(_PROJECT)) + set_context(make_context()) boot_mod.boot(sentry_integrations=None) call_ctx = mock_logging.call_args.kwargs["context"] - assert call_ctx.name == _PROJECT + assert call_ctx.name == TEST_PROJECT_NAME @pytest.mark.unit @@ -111,9 +110,9 @@ def test_boot_explicit_context_overrides_global(monkeypatch: pytest.MonkeyPatch) monkeypatch.setattr(boot_mod, "truststore", None) monkeypatch.setattr(boot_mod, "certifi", None) - set_context(make_context(_OTHER_PROJECT)) - explicit_ctx = make_context(_PROJECT) + set_context(make_context()) + explicit_ctx = make_context(_OTHER_PROJECT) boot_mod.boot(context=explicit_ctx, sentry_integrations=None) call_ctx = mock_sentry.call_args.kwargs["context"] - assert call_ctx.name == _PROJECT + assert call_ctx.name == _OTHER_PROJECT diff --git a/tests/aignostics_foundry_core/cli_test.py b/tests/aignostics_foundry_core/cli_test.py index 43c2ed6..5684cd9 100644 --- a/tests/aignostics_foundry_core/cli_test.py +++ b/tests/aignostics_foundry_core/cli_test.py @@ -9,7 +9,6 @@ from tests.conftest import make_context _LOCATE_IMPLEMENTATIONS_PATH = "aignostics_foundry_core.cli.locate_implementations" -_PROJECT_NAME = "myproj" _MY_EPILOG = "My epilog" @@ -42,14 +41,14 @@ class TestPrepareCli: def test_sets_epilog(self) -> None: """prepare_cli sets the epilog on the CLI app.""" cli = typer.Typer() - prepare_cli(cli, _MY_EPILOG, context=make_context(_PROJECT_NAME)) + prepare_cli(cli, _MY_EPILOG, context=make_context()) assert cli.info.epilog == _MY_EPILOG def test_adds_no_args_is_help_callback(self) -> None: """prepare_cli installs the no_args_is_help workaround callback.""" cli = typer.Typer() - prepare_cli(cli, _MY_EPILOG, context=make_context(_PROJECT_NAME)) + prepare_cli(cli, _MY_EPILOG, context=make_context()) assert hasattr(cli, "no_args_callback_added") assert cli.no_args_callback_added is True # type: ignore[attr-defined] @@ -60,7 +59,7 @@ def test_prepare_cli_propagates_epilog_to_sub_typer(self) -> None: sub = typer.Typer() cli.add_typer(sub) with patch(_LOCATE_IMPLEMENTATIONS_PATH, return_value=[]): - prepare_cli(cli, _MY_EPILOG, context=make_context(_PROJECT_NAME)) + prepare_cli(cli, _MY_EPILOG, context=make_context()) assert sub.info.epilog == _MY_EPILOG @@ -70,7 +69,7 @@ def test_prepare_cli_installs_callback_on_sub_typer(self) -> None: sub = typer.Typer() cli.add_typer(sub) with patch(_LOCATE_IMPLEMENTATIONS_PATH, return_value=[]): - prepare_cli(cli, _MY_EPILOG, context=make_context(_PROJECT_NAME)) + prepare_cli(cli, _MY_EPILOG, context=make_context()) assert hasattr(sub, "no_args_callback_added") @@ -79,7 +78,7 @@ def test_prepare_cli_adds_discovered_subcommands(self) -> None: cli = typer.Typer() sub_cli = typer.Typer() with patch(_LOCATE_IMPLEMENTATIONS_PATH, return_value=[sub_cli]): - prepare_cli(cli, "epilog", context=make_context(_PROJECT_NAME)) + prepare_cli(cli, "epilog", context=make_context()) registered = [g.typer_instance for g in cli.registered_groups] assert sub_cli in registered @@ -88,7 +87,7 @@ def test_prepare_cli_skips_self_in_discovery(self) -> None: """prepare_cli does not add cli to itself when it appears in discovered results.""" cli = typer.Typer() with patch(_LOCATE_IMPLEMENTATIONS_PATH, return_value=[cli]): - prepare_cli(cli, "epilog", context=make_context(_PROJECT_NAME)) + prepare_cli(cli, "epilog", context=make_context()) registered = [g.typer_instance for g in cli.registered_groups] assert cli not in registered diff --git a/tests/aignostics_foundry_core/console_test.py b/tests/aignostics_foundry_core/console_test.py index 9935447..fa26610 100644 --- a/tests/aignostics_foundry_core/console_test.py +++ b/tests/aignostics_foundry_core/console_test.py @@ -8,10 +8,10 @@ from rich.console import Console from aignostics_foundry_core.console import console -from aignostics_foundry_core.foundry import FoundryContext, reset_context, set_context +from aignostics_foundry_core.foundry import reset_context, set_context +from tests.conftest import make_context EXPECTED_THEME_KEYS = ["success", "info", "warning", "error", "debug", "logging.level.info"] -CUSTOM_ENV_PREFIX = "TESTPROJ_" CUSTOM_WIDTH = "120" EXPECTED_CUSTOM_WIDTH = 120 CONSOLE_MODULE = "aignostics_foundry_core.console" @@ -48,15 +48,9 @@ def test_console_default_width(self, monkeypatch: pytest.MonkeyPatch) -> None: @pytest.mark.sequential def test_console_width_uses_env_prefix_from_context(self, monkeypatch: pytest.MonkeyPatch) -> None: """Console width is read from {env_prefix}CONSOLE_WIDTH when a context is set.""" - ctx = FoundryContext( - name="testproj", - version="0.0.0", - version_full="0.0.0", - environment="test", - env_prefix=CUSTOM_ENV_PREFIX, - ) + ctx = make_context() set_context(ctx) - monkeypatch.setenv(f"{CUSTOM_ENV_PREFIX}CONSOLE_WIDTH", CUSTOM_WIDTH) + monkeypatch.setenv(f"{ctx.env_prefix}CONSOLE_WIDTH", CUSTOM_WIDTH) reloaded = importlib.reload(sys.modules[CONSOLE_MODULE]) assert reloaded.console.width == EXPECTED_CUSTOM_WIDTH diff --git a/tests/aignostics_foundry_core/foundry_test.py b/tests/aignostics_foundry_core/foundry_test.py index 03433a6..f7a3bf7 100644 --- a/tests/aignostics_foundry_core/foundry_test.py +++ b/tests/aignostics_foundry_core/foundry_test.py @@ -12,6 +12,7 @@ from pydantic import ValidationError from aignostics_foundry_core.foundry import FoundryContext, get_context, reset_context, set_context +from tests.conftest import make_context # Constants (SonarQube S1192) PACKAGE_NAME = "aignostics_foundry_core" @@ -355,7 +356,7 @@ def test_from_package_is_test_when_pytest_running_env_set(monkeypatch: pytest.Mo @pytest.mark.unit def test_foundry_context_mode_flags_default_to_false() -> None: """FoundryContext constructed directly has all four mode flags as False.""" - ctx = FoundryContext(name="test", version="0.0.0", version_full="0.0.0", environment="test") + ctx = make_context() assert ctx.is_container is False assert ctx.is_cli is False assert ctx.is_test is False @@ -399,7 +400,7 @@ def test_context_raises_before_set_context() -> None: def test_set_context_replaces_previous_context() -> None: """Calling set_context() twice makes get_context() return the second context.""" ctx1 = FoundryContext.from_package(PACKAGE_NAME) - ctx2 = FoundryContext(name="other", version="0.0.0", version_full="0.0.0", environment="test") + ctx2 = make_context() set_context(ctx1) set_context(ctx2) assert get_context() is ctx2 @@ -419,3 +420,74 @@ def test_reset_context_causes_get_context_to_raise() -> None: def test_reset_context_is_idempotent_when_no_context_set() -> None: """reset_context() does not raise when no context has been installed.""" reset_context() # no prior set_context() — must not raise + + +# --------------------------------------------------------------------------- +# from_package — version_with_vcs_ref +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_from_package_version_with_vcs_ref_equals_version_when_no_vcs_info( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """version_with_vcs_ref equals version when VCS_REF and COMMIT_SHA are absent.""" + + def _find_spec_none(name: str, package: str | None = None) -> None: + return None + + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_none) + monkeypatch.setenv("BUILDER", BUILDER_UNKNOWN) + for var in ["VCS_REF", "COMMIT_SHA", "BUILD_DATE", "CI_RUN_ID", "CI_RUN_NUMBER"]: + monkeypatch.delenv(var, raising=False) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.version_with_vcs_ref == ctx.version + + +@pytest.mark.unit +def test_from_package_version_with_vcs_ref_includes_vcs_ref(monkeypatch: pytest.MonkeyPatch) -> None: + """version_with_vcs_ref contains VCS_REF and starts with '{version}+' when VCS_REF is set.""" + monkeypatch.setenv("VCS_REF", VCS_REF_VALUE) + monkeypatch.delenv("COMMIT_SHA", raising=False) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert ctx.version_with_vcs_ref.startswith(ctx.version + "+") + assert VCS_REF_VALUE in ctx.version_with_vcs_ref + + +@pytest.mark.unit +def test_from_package_version_with_vcs_ref_includes_commit_sha(monkeypatch: pytest.MonkeyPatch) -> None: + """version_with_vcs_ref contains COMMIT_SHA when COMMIT_SHA is set.""" + + def _find_spec_none(name: str, package: str | None = None) -> None: + return None + + monkeypatch.setattr(importlib.util, "find_spec", _find_spec_none) + monkeypatch.delenv("VCS_REF", raising=False) + monkeypatch.setenv("COMMIT_SHA", COMMIT_SHA_VALUE) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert COMMIT_SHA_VALUE in ctx.version_with_vcs_ref + + +@pytest.mark.unit +def test_from_package_version_with_vcs_ref_joins_vcs_ref_and_commit_sha_with_dash( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """version_with_vcs_ref joins VCS_REF and COMMIT_SHA with '-' when both are set.""" + monkeypatch.setenv("VCS_REF", VCS_REF_VALUE) + monkeypatch.setenv("COMMIT_SHA", COMMIT_SHA_VALUE) + ctx = FoundryContext.from_package(PACKAGE_NAME) + assert f"{VCS_REF_VALUE}-{COMMIT_SHA_VALUE}" in ctx.version_with_vcs_ref + + +@pytest.mark.unit +def test_from_package_version_with_vcs_ref_excludes_ci_metadata(monkeypatch: pytest.MonkeyPatch) -> None: + """version_with_vcs_ref does not contain CI build metadata even when those env vars are set.""" + monkeypatch.setenv("VCS_REF", VCS_REF_VALUE) + monkeypatch.setenv("COMMIT_SHA", COMMIT_SHA_VALUE) + monkeypatch.setenv("CI_RUN_ID", CI_RUN_ID_VALUE) + monkeypatch.setenv("CI_RUN_NUMBER", CI_RUN_NUMBER_VALUE) + monkeypatch.setenv("BUILD_DATE", BUILD_DATE_VALUE) + monkeypatch.setenv("BUILDER", "gh-actions") + ctx = FoundryContext.from_package(PACKAGE_NAME) + for fragment in ["---", "run.", "build.", "builder.", "built."]: + assert fragment not in ctx.version_with_vcs_ref diff --git a/tests/aignostics_foundry_core/gui/gui_test.py b/tests/aignostics_foundry_core/gui/gui_test.py index 7661a69..47cd9ab 100644 --- a/tests/aignostics_foundry_core/gui/gui_test.py +++ b/tests/aignostics_foundry_core/gui/gui_test.py @@ -23,7 +23,7 @@ NavItem, gui_get_nav_groups, ) -from tests.conftest import make_context +from tests.conftest import TEST_PROJECT_NAME, make_context _PATCH_GET_GUI_USER = "aignostics_foundry_core.gui.auth.get_gui_user" _PATH_NAV_LOCATE = "aignostics_foundry_core.gui.nav.locate_subclasses" @@ -33,7 +33,6 @@ _INTERNAL_ORG = "org_internal" _OTHER_ORG = "org_other" _ROLE_CLAIM = "https://example.com/role" -_PROJECT_NAME = "myproject" _FIXED_PORT = 9000 _DOCS_PATH = "/docs" _USER_SUB = "auth0|x" @@ -91,7 +90,7 @@ class TestGuiGetNavGroups: def test_returns_empty_list_when_no_builders(self) -> None: """gui_get_nav_groups returns [] when no NavBuilders are discovered.""" with patch(_PATH_NAV_LOCATE, return_value=[]): - result = gui_get_nav_groups(context=make_context("myproject")) + result = gui_get_nav_groups(context=make_context()) assert result == [] def test_collects_group_from_single_builder(self) -> None: @@ -108,7 +107,7 @@ def get_nav_items() -> list[NavItem]: return items with patch(_PATH_NAV_LOCATE, return_value=[FakeBuilder]): - result = gui_get_nav_groups(context=make_context("myproject")) + result = gui_get_nav_groups(context=make_context()) assert len(result) == 1 assert result[0].name == "Fake" @@ -144,7 +143,7 @@ def get_nav_position() -> int: return 100 with patch(_PATH_NAV_LOCATE, return_value=[LowPriority, HighPriority]): - result = gui_get_nav_groups(context=make_context("myproject")) + result = gui_get_nav_groups(context=make_context()) assert [g.name for g in result] == ["High", "Low"] @@ -161,7 +160,7 @@ def get_nav_items() -> list[NavItem]: return [] with patch(_PATH_NAV_LOCATE, return_value=[EmptyBuilder]): - result = gui_get_nav_groups(context=make_context(_PROJECT_NAME)) + result = gui_get_nav_groups(context=make_context()) assert result == [] @@ -191,7 +190,7 @@ def get_nav_position() -> int: return 100 with patch(_PATH_NAV_LOCATE, return_value=[DefaultPositionBuilder, ExplicitPositionBuilder]): - result = gui_get_nav_groups(context=make_context(_PROJECT_NAME)) + result = gui_get_nav_groups(context=make_context()) assert [g.name for g in result] == ["Explicit", "Default"] @@ -250,7 +249,7 @@ def test_calls_register_pages_on_each_builder(self) -> None: builder_b = MagicMock(spec=BasePageBuilder) with patch(_PATH_CORE_LOCATE, return_value=[builder_a, builder_b]): - gui_register_pages(context=make_context("myproject")) + gui_register_pages(context=make_context()) builder_a.register_pages.assert_called_once() builder_b.register_pages.assert_called_once() @@ -258,7 +257,7 @@ def test_calls_register_pages_on_each_builder(self) -> None: def test_no_error_when_no_builders_found(self) -> None: """gui_register_pages silently succeeds when no builders are discovered.""" with patch(_PATH_CORE_LOCATE, return_value=[]): - gui_register_pages(context=make_context(_PROJECT_NAME)) # must not raise + gui_register_pages(context=make_context()) # must not raise # --------------------------------------------------------------------------- @@ -300,13 +299,13 @@ def _call_gui_run(self, nicegui_mock: MagicMock, **kwargs: object) -> None: patch.dict(sys.modules, {"nicegui": nicegui_mock, "starlette.responses": MagicMock()}), patch(_PATH_CORE_LOCATE, return_value=[]), ): - gui_run(context=make_context(_PROJECT_NAME), **kwargs) # type: ignore[arg-type] + gui_run(context=make_context(), **kwargs) # type: ignore[arg-type] def test_ui_run_called_with_project_name_as_title(self) -> None: """When title is empty, ui.run receives project_name as title.""" nicegui_mock, _, ui_mock = _make_nicegui_app_mock() self._call_gui_run(nicegui_mock, title="") - assert ui_mock.run.call_args.kwargs["title"] == _PROJECT_NAME + assert ui_mock.run.call_args.kwargs["title"] == TEST_PROJECT_NAME def test_ui_run_called_with_explicit_title(self) -> None: """Explicit title is passed through to ui.run.""" @@ -432,7 +431,7 @@ def test_config_state_copied(self) -> None: def test_gui_register_pages_called(self) -> None: """locate_subclasses is invoked with BasePageBuilder and the configured context.""" nicegui_mock, _, _ = _make_nicegui_app_mock() - ctx = make_context(_PROJECT_NAME) + ctx = make_context() with ( patch.dict(sys.modules, {"nicegui": nicegui_mock, "starlette.responses": MagicMock()}), patch(_PATH_CORE_LOCATE, return_value=[]) as locate_mock, @@ -453,7 +452,7 @@ class TestGetGuiUser: @pytest.fixture(autouse=True) def _gui_context(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] """Install a minimal context so AuthSettings can be loaded.""" - set_context(make_context(_PROJECT_NAME, "MYPROJECT_")) + set_context(make_context()) yield reset_context() @@ -535,7 +534,7 @@ class TestRequireGuiUser: @pytest.fixture(autouse=True) def _gui_context(self) -> Generator[None, None, None]: # pyright: ignore[reportUnusedFunction] """Install a minimal context so AuthSettings can be loaded.""" - set_context(make_context(_PROJECT_NAME, "MYPROJECT_")) + set_context(make_context()) yield reset_context() diff --git a/tests/aignostics_foundry_core/log_test.py b/tests/aignostics_foundry_core/log_test.py index a639dc8..0e429b0 100644 --- a/tests/aignostics_foundry_core/log_test.py +++ b/tests/aignostics_foundry_core/log_test.py @@ -1,4 +1,4 @@ -"""Tests for aignostics_foundry_core.log.""" +"""Tests for aignostics_foundry_core.log module.""" import logging as stdlib_logging import sys @@ -8,10 +8,8 @@ from pydantic import ValidationError from aignostics_foundry_core.log import InterceptHandler, LogSettings, logging_initialize -from tests.conftest import make_context +from tests.conftest import TEST_PROJECT_PREFIX, make_context -_PROJECT = "testfoundry" -_VERSION = "0.0.1" _MARKER_MESSAGE = "log_test_unique_marker_4f2a" _STDLIB_MESSAGE = "stdlib_redirect_unique_marker_9b3c" _FILE_HANDLER_MARKER = "file_handler_unique_marker_7e9b" @@ -29,7 +27,7 @@ class TestLoggingInitialize: def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "aignostics_foundry_core.log.get_context", - lambda: make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_"), + make_context, ) def test_logging_initialize_adds_stderr_handler(self, capsys: pytest.CaptureFixture[str]) -> None: @@ -45,7 +43,7 @@ def test_logging_initialize_skips_stderr_when_disabled( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """When stderr is disabled via env var, no output is written to stderr.""" - monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_STDERR_ENABLED", "false") + monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}LOG_STDERR_ENABLED", "false") logging_initialize() from loguru import logger @@ -65,8 +63,8 @@ def test_logging_initialize_file_handler_writes_to_file( ) -> None: """File handler writes log output to the configured file when file_enabled.""" log_file = tmp_path / "test.log" - monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_FILE_ENABLED", "true") - monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_FILE_NAME", str(log_file)) + monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}LOG_FILE_ENABLED", "true") + monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}LOG_FILE_NAME", str(log_file)) logging_initialize() from loguru import logger @@ -108,7 +106,7 @@ def test_logging_initialize_uses_context_project_name( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """logging_initialize reads env-var prefix from context.name.""" - monkeypatch.setenv(f"{_PROJECT.upper()}_LOG_LEVEL", "DEBUG") + monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}LOG_LEVEL", "DEBUG") logging_initialize() from loguru import logger @@ -121,9 +119,9 @@ def test_logging_initialize_respects_env_prefix_from_context( """LogSettings reads env vars from the prefix of the active get_context().""" monkeypatch.setattr( "aignostics_foundry_core.log.get_context", - lambda: make_context("myproject", env_prefix="MYPROJECT_"), + make_context, ) - monkeypatch.setenv("MYPROJECT_LOG_STDERR_ENABLED", "false") + monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}LOG_STDERR_ENABLED", "false") logging_initialize() from loguru import logger @@ -139,7 +137,7 @@ class TestLogSettings: def _stub_get_context(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "aignostics_foundry_core.log.get_context", - lambda: make_context(_PROJECT, env_prefix=f"{_PROJECT.upper()}_"), + make_context, ) @pytest.mark.unit @@ -147,9 +145,9 @@ def test_log_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPa """LogSettings reads env vars using the env_prefix from the active FoundryContext.""" monkeypatch.setattr( "aignostics_foundry_core.log.get_context", - lambda: make_context("proj", env_prefix="PROJ_"), + make_context, ) - monkeypatch.setenv("PROJ_LOG_STDERR_ENABLED", "false") + monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}LOG_STDERR_ENABLED", "false") settings = LogSettings() # pyright: ignore[reportCallIssue] assert settings.stderr_enabled is False diff --git a/tests/aignostics_foundry_core/process_test.py b/tests/aignostics_foundry_core/process_test.py index 47518ee..386147b 100644 --- a/tests/aignostics_foundry_core/process_test.py +++ b/tests/aignostics_foundry_core/process_test.py @@ -5,18 +5,14 @@ import pytest -from aignostics_foundry_core.foundry import FoundryContext from aignostics_foundry_core.process import get_process_info - -_CTX_NAME = "test" -_CTX_VERSION = "0.0.0" -_CTX_ENVIRONMENT = "test" +from tests.conftest import make_context @pytest.mark.unit def test_get_process_info_returns_current_process() -> None: """get_process_info() returns info for the currently running process.""" - ctx = FoundryContext(name=_CTX_NAME, version=_CTX_VERSION, version_full=_CTX_VERSION, environment=_CTX_ENVIRONMENT) + ctx = make_context() info = get_process_info(context=ctx) assert info.pid == os.getpid() @@ -24,7 +20,7 @@ def test_get_process_info_returns_current_process() -> None: @pytest.mark.unit def test_process_info_has_parent() -> None: """ProcessInfo.parent has a non-empty name and positive pid.""" - ctx = FoundryContext(name=_CTX_NAME, version=_CTX_VERSION, version_full=_CTX_VERSION, environment=_CTX_ENVIRONMENT) + ctx = make_context() info = get_process_info(context=ctx) assert info.parent.name is not None assert len(info.parent.name) > 0 @@ -35,13 +31,7 @@ def test_process_info_has_parent() -> None: @pytest.mark.unit def test_get_process_info_project_root_from_context(tmp_path: Path) -> None: """project_root equals str(ctx.project_path) when project_path is set.""" - ctx = FoundryContext( - name=_CTX_NAME, - version=_CTX_VERSION, - version_full=_CTX_VERSION, - environment=_CTX_ENVIRONMENT, - project_path=tmp_path, - ) + ctx = make_context(project_path=tmp_path) info = get_process_info(context=ctx) assert info.project_root == str(tmp_path) @@ -49,12 +39,6 @@ def test_get_process_info_project_root_from_context(tmp_path: Path) -> None: @pytest.mark.unit def test_get_process_info_project_root_none_when_path_not_set() -> None: """project_root is None when context has project_path=None.""" - ctx = FoundryContext( - name=_CTX_NAME, - version=_CTX_VERSION, - version_full=_CTX_VERSION, - environment=_CTX_ENVIRONMENT, - project_path=None, - ) + ctx = make_context(project_path=None) info = get_process_info(context=ctx) assert info.project_root is None diff --git a/tests/aignostics_foundry_core/sentry_test.py b/tests/aignostics_foundry_core/sentry_test.py index 07755ae..3cc103a 100644 --- a/tests/aignostics_foundry_core/sentry_test.py +++ b/tests/aignostics_foundry_core/sentry_test.py @@ -6,17 +6,14 @@ import pytest from pydantic import ValidationError -from aignostics_foundry_core.foundry import FoundryContext, reset_context, set_context +from aignostics_foundry_core.foundry import reset_context, set_context from aignostics_foundry_core.sentry import SentrySettings, sentry_initialize, set_sentry_user +from tests.conftest import TEST_PROJECT_NAME, TEST_PROJECT_PREFIX, make_context _VALID_DSN = "https://abc123def456@o99999.ingest.de.sentry.io/1234567" -_PROJECT = "testproject" -_VERSION = "1.0.0" -_ENVIRONMENT = "test" -_ENV_PREFIX = "TESTPROJECT_" _SENTRY_SET_USER = "sentry_sdk.set_user" _AUTH0_USER = "auth0|x" -_SENTRY_PREFIX = "TESTPROJECT_SENTRY_" +_SENTRY_PREFIX = f"{TEST_PROJECT_PREFIX}SENTRY_" _SENTRY_SDK_INIT = "sentry_sdk.init" _SENTRY_SDK_SET_CONTEXT = "sentry_sdk.set_context" _SENTRY_SDK_IGNORE_LOGGER = "sentry_sdk.integrations.logging.ignore_logger" @@ -28,15 +25,7 @@ class TestSentryInitialize: @pytest.fixture(autouse=True) def _context(self) -> Generator[None, None, None]: - set_context( - FoundryContext( - name=_PROJECT, - version=_VERSION, - version_full=_VERSION, - environment=_ENVIRONMENT, - env_prefix=_ENV_PREFIX, - ) - ) + set_context(make_context()) yield reset_context() @@ -66,7 +55,7 @@ def test_sentry_initialize_returns_true_and_calls_init_when_enabled(self, monkey result = sentry_initialize(integrations=None) assert result is True mock_init.assert_called_once() - assert mock_init.call_args.kwargs["release"] == f"{_PROJECT}@{_VERSION}" + assert mock_init.call_args.kwargs["release"] == f"{TEST_PROJECT_NAME}@0.0.0" def test_sentry_initialize_returns_false_when_enabled_but_dsn_none(self, monkeypatch: pytest.MonkeyPatch) -> None: """Returns False when enabled but no DSN is configured.""" @@ -79,13 +68,7 @@ def test_sentry_initialize_uses_context_project_name(self, monkeypatch: pytest.M """sentry_sdk.init release tag uses the context project name.""" monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) - ctx = FoundryContext( - name="ctxproject", - version=_VERSION, - version_full=_VERSION, - environment=_ENVIRONMENT, - env_prefix=_ENV_PREFIX, - ) + ctx = make_context() with ( patch(_SENTRY_SDK_INIT) as mock_init, patch(_SENTRY_SDK_SET_CONTEXT), @@ -93,19 +76,13 @@ def test_sentry_initialize_uses_context_project_name(self, monkeypatch: pytest.M ): result = sentry_initialize(integrations=None, context=ctx) assert result is True - assert mock_init.call_args.kwargs["release"].startswith("ctxproject@") + assert mock_init.call_args.kwargs["release"].startswith(f"{TEST_PROJECT_NAME}@") def test_sentry_initialize_uses_context_environment(self, monkeypatch: pytest.MonkeyPatch) -> None: """sentry_sdk.init environment arg matches context.environment.""" monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) - ctx = FoundryContext( - name=_PROJECT, - version=_VERSION, - version_full=_VERSION, - environment="staging", - env_prefix=_ENV_PREFIX, - ) + ctx = make_context(environment="staging") with ( patch(_SENTRY_SDK_INIT) as mock_init, patch(_SENTRY_SDK_SET_CONTEXT), @@ -118,14 +95,7 @@ def test_sentry_initialize_uses_sentry_context_flags(self, monkeypatch: pytest.M """sentry_sdk.set_context receives runtime mode flags from the context.""" monkeypatch.setenv(f"{_SENTRY_PREFIX}ENABLED", "true") monkeypatch.setenv(f"{_SENTRY_PREFIX}DSN", _VALID_DSN) - ctx = FoundryContext( - name=_PROJECT, - version=_VERSION, - version_full=_VERSION, - environment=_ENVIRONMENT, - env_prefix=_ENV_PREFIX, - is_test=True, - ) + ctx = make_context(is_test=True) with ( patch(_SENTRY_SDK_INIT), patch(_SENTRY_SDK_SET_CONTEXT) as mock_set_ctx, @@ -142,15 +112,7 @@ class TestSentrySettingsDsnValidation: @pytest.fixture(autouse=True) def _context(self) -> Generator[None, None, None]: - set_context( - FoundryContext( - name=_PROJECT, - version=_VERSION, - version_full=_VERSION, - environment=_ENVIRONMENT, - env_prefix=_ENV_PREFIX, - ) - ) + set_context(make_context()) yield reset_context() @@ -176,15 +138,7 @@ class TestSentrySettings: @pytest.fixture(autouse=True) def _context(self) -> Generator[None, None, None]: - set_context( - FoundryContext( - name=_PROJECT, - version=_VERSION, - version_full=_VERSION, - environment=_ENVIRONMENT, - env_prefix=_ENV_PREFIX, - ) - ) + set_context(make_context()) yield reset_context() @@ -218,16 +172,8 @@ def test_sentry_settings_default_disabled(self) -> None: def test_sentry_settings_uses_context_env_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: """SentrySettings reads env vars from the prefix supplied by FoundryContext.""" - set_context( - FoundryContext( - name=_PROJECT, - version=_VERSION, - version_full=_VERSION, - environment=_ENVIRONMENT, - env_prefix="PROJ_", - ) - ) - monkeypatch.setenv("PROJ_SENTRY_ENABLED", "true") + set_context(make_context()) + monkeypatch.setenv(f"{TEST_PROJECT_PREFIX}SENTRY_ENABLED", "true") settings = SentrySettings() # pyright: ignore[reportCallIssue] assert settings.enabled is True diff --git a/tests/aignostics_foundry_core/user_agent_test.py b/tests/aignostics_foundry_core/user_agent_test.py index 4d4f03a..c099873 100644 --- a/tests/aignostics_foundry_core/user_agent_test.py +++ b/tests/aignostics_foundry_core/user_agent_test.py @@ -2,11 +2,15 @@ import pytest +from aignostics_foundry_core.foundry import FoundryContext from aignostics_foundry_core.user_agent import user_agent +from tests.conftest import make_context + +CTX_NAME = "myproject" +CTX_VERSION = "1.2.3" +CTX_REPOSITORY_URL = "https://github.com/example/myproject" +CTX_VERSION_FULL = "1.2.3+main-abc1234" -PROJECT_NAME = "myproject" -VERSION = "1.2.3" -REPOSITORY_URL = "https://github.com/example/myproject" GITHUB_REPOSITORY = "example/myproject" GITHUB_RUN_ID = "987654321" @@ -16,14 +20,15 @@ class TestUserAgent: @pytest.mark.unit def test_user_agent_contains_project_and_version(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Return value starts with '{project_name}-python-sdk/{version}'.""" + """Return value starts with '{name}-python-sdk/{version_full}'.""" monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) monkeypatch.delenv("GITHUB_RUN_ID", raising=False) monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) - result = user_agent(PROJECT_NAME, VERSION, REPOSITORY_URL) + ctx = make_context(name=CTX_NAME, version=CTX_VERSION, repository_url=CTX_REPOSITORY_URL) + result = user_agent(context=ctx) - assert result.startswith(f"{PROJECT_NAME}-python-sdk/{VERSION}") + assert result.startswith(f"{CTX_NAME}-python-sdk/{CTX_VERSION}") @pytest.mark.unit def test_user_agent_contains_repository_url(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -32,9 +37,30 @@ def test_user_agent_contains_repository_url(self, monkeypatch: pytest.MonkeyPatc monkeypatch.delenv("GITHUB_RUN_ID", raising=False) monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) - result = user_agent(PROJECT_NAME, VERSION, REPOSITORY_URL) + ctx = make_context(name=CTX_NAME, version=CTX_VERSION, repository_url=CTX_REPOSITORY_URL) + result = user_agent(context=ctx) + + assert CTX_REPOSITORY_URL in result + + @pytest.mark.unit + def test_user_agent_uses_version_full(self, monkeypatch: pytest.MonkeyPatch) -> None: + """When version_full differs from version, the result contains version_full not the base version.""" + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + monkeypatch.delenv("GITHUB_RUN_ID", raising=False) + monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) + + ctx = FoundryContext( + name=CTX_NAME, + version=CTX_VERSION, + version_full=CTX_VERSION_FULL, + version_with_vcs_ref=CTX_VERSION, + environment="test", + repository_url=CTX_REPOSITORY_URL, + ) + result = user_agent(context=ctx) - assert REPOSITORY_URL in result + assert CTX_VERSION_FULL in result + assert f"{CTX_NAME}-python-sdk/{CTX_VERSION} " not in result @pytest.mark.unit def test_user_agent_includes_pytest_test_name(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -44,7 +70,8 @@ def test_user_agent_includes_pytest_test_name(self, monkeypatch: pytest.MonkeyPa monkeypatch.delenv("GITHUB_RUN_ID", raising=False) monkeypatch.delenv("GITHUB_REPOSITORY", raising=False) - result = user_agent(PROJECT_NAME, VERSION, REPOSITORY_URL) + ctx = make_context(name=CTX_NAME, version=CTX_VERSION, repository_url=CTX_REPOSITORY_URL) + result = user_agent(context=ctx) assert test_name in result @@ -55,7 +82,8 @@ def test_user_agent_includes_github_run_url(self, monkeypatch: pytest.MonkeyPatc monkeypatch.setenv("GITHUB_RUN_ID", GITHUB_RUN_ID) monkeypatch.setenv("GITHUB_REPOSITORY", GITHUB_REPOSITORY) - result = user_agent(PROJECT_NAME, VERSION, REPOSITORY_URL) + ctx = make_context(name=CTX_NAME, version=CTX_VERSION, repository_url=CTX_REPOSITORY_URL) + result = user_agent(context=ctx) expected_url = f"https://github.com/{GITHUB_REPOSITORY}/actions/runs/{GITHUB_RUN_ID}" assert expected_url in result diff --git a/tests/conftest.py b/tests/conftest.py index 84d69a6..810de4d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import logging import os +from pathlib import Path import psutil import pytest @@ -13,6 +14,10 @@ logger = logging.getLogger(__name__) +TEST_PROJECT_NAME = "test_project" +TEST_PROJECT_PREFIX = "TEST_PROJECT_" + + def pytest_xdist_auto_num_workers(config: pytest.Config) -> int: """Set the number of workers for xdist to a factor of the (logical) CPU cores. @@ -55,11 +60,14 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: session.exitstatus = 0 -def make_context( - name: str, - env_prefix: str = "", +def make_context( # noqa: PLR0913 + name: str = TEST_PROJECT_NAME, + *, + env_prefix: str = TEST_PROJECT_PREFIX, version: str = "0.0.0", environment: str = "test", + project_path: Path | None = None, + repository_url: str = "", **kwargs: bool, ) -> FoundryContext: """Create a minimal FoundryContext for testing. @@ -69,6 +77,8 @@ def make_context( env_prefix: The environment variable prefix (e.g. ``"MYPROJECT_"``). version: The version string (defaults to ``"0.0.0"``). environment: The deployment environment (defaults to ``"test"``). + project_path: Optional path to the project root. + repository_url: The project repository URL (defaults to ``""``). **kwargs: Optional boolean flags forwarded to :class:`FoundryContext` (``is_test``, ``is_cli``, ``is_container``, ``is_library``). """ @@ -76,7 +86,10 @@ def make_context( name=name, version=version, version_full=version, + version_with_vcs_ref=version, environment=environment, env_prefix=env_prefix, + project_path=project_path, + repository_url=repository_url, **kwargs, # type: ignore[arg-type] )