diff --git a/.env.example b/.env.example index 07d8c6b..be60f22 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,8 @@ ALPHA_RESEARCH_ENV=local ALPHA_RESEARCH_TRACKING_URI=./mlruns ALPHA_RESEARCH_LOG_LEVEL=INFO + +# Optional configured adapter values. Keep real secrets in local .env only. +ALPHA_RESEARCH_PROVIDER_USER_AGENT=alpha-research-local/0.1 +ALPHA_RESEARCH_SAMPLE_API_KEY=replace-me +ALPHA_RESEARCH_LOCAL_MARKET_PATH=./tests/fixtures/market.csv diff --git a/README.md b/README.md index 89baae7..f937318 100644 --- a/README.md +++ b/README.md @@ -114,5 +114,6 @@ python .\scripts\run_release_smoke.py --root . --mode live-public Это не набор взаимозаменяемых костылей. У каждого режима свой capability contract: можно ли строить release bundle, требуется ли external proof и допустим ли synthetic ingest. Для локального воспроизводимого прогона есть отдельный runbook: `docs/runbooks/reproducible_local_runbook.md`. +Для безопасной настройки configured adapters есть runbook: `docs/runbooks/configured_adapter_environment.md`. Для отдельного тяжелого smoke-прогона есть workflow `.github/workflows/release_smoke.yml`. Для честной сверки реализации со спецификацией есть `docs/status/spec_coverage_map.yaml` и `docs/status/spec_gap_audit.md`. diff --git a/docs/runbooks/configured_adapter_environment.md b/docs/runbooks/configured_adapter_environment.md new file mode 100644 index 0000000..c3a1dc3 --- /dev/null +++ b/docs/runbooks/configured_adapter_environment.md @@ -0,0 +1,33 @@ +# Configured adapter environment runbook + +Configured adapters can read local paths, API keys, and user-agent values from +environment variables declared in `configs/data_sources.yaml`. This keeps the +repository reproducible while allowing private live-provider settings locally. + +## Safe local pattern + +1. Copy `.env.example` to `.env`. +2. Replace placeholder values only in `.env`. +3. Keep `.env` untracked. +4. Prefer configured-local smoke for pull requests. +5. Use live-public smoke only when the pull request explicitly changes external + provider behavior. + +## Adapter fields + +| Field | Meaning | Failure mode | +| --- | --- | --- | +| `local_path` | Explicit repository-relative or absolute fixture path. | Permanent error when the file is missing. | +| `local_path_env` | Environment variable that points to a local fixture path. | Permanent error when both `local_path` and the env value are missing. | +| `api_key_env` | Environment variable that stores a live-provider credential. | Omitted from request when missing unless the provider requires it. | +| `api_key_header` | Header name used for the resolved API key. | Ignored when no API key is available. | +| `api_key_query_param` | Query parameter used for the resolved API key. | Ignored when no API key is available. | +| `user_agent_env` | Environment variable for a provider-specific user agent. | Falls back to `Mozilla/5.0` when missing. | + +## Review checklist + +- `.env.example` contains placeholders only. +- No real provider key appears in diffs or test fixtures. +- Tests monkeypatch environment variables instead of depending on local shell + state. +- Error messages name the adapter and the missing setting. diff --git a/src/alpha_research/data/providers/configured_transport.py b/src/alpha_research/data/providers/configured_transport.py index 6176d2c..d341c91 100644 --- a/src/alpha_research/data/providers/configured_transport.py +++ b/src/alpha_research/data/providers/configured_transport.py @@ -29,6 +29,28 @@ class ConfiguredAdapterPermanentError(ConfiguredAdapterError): pass +@dataclass(frozen=True) +class AdapterEnvironmentDiagnostics: + adapter_name: str + local_path_env: str | None + local_path_env_present: bool + api_key_env: str | None + api_key_env_present: bool + user_agent_env: str | None + user_agent_env_present: bool + + def as_dict(self) -> dict[str, bool | str | None]: + return { + "adapter_name": self.adapter_name, + "local_path_env": self.local_path_env, + "local_path_env_present": self.local_path_env_present, + "api_key_env": self.api_key_env, + "api_key_env_present": self.api_key_env_present, + "user_agent_env": self.user_agent_env, + "user_agent_env_present": self.user_agent_env_present, + } + + @dataclass(frozen=True) class ResponseCache: root: Path @@ -59,17 +81,35 @@ def resolve_env(name: str | None) -> str | None: return value.strip() if isinstance(value, str) and value.strip() else None +def adapter_environment_diagnostics(adapter: AdapterConfig) -> AdapterEnvironmentDiagnostics: + return AdapterEnvironmentDiagnostics( + adapter_name=adapter.adapter_name, + local_path_env=adapter.local_path_env, + local_path_env_present=resolve_env(adapter.local_path_env) is not None, + api_key_env=adapter.api_key_env, + api_key_env_present=resolve_env(adapter.api_key_env) is not None, + user_agent_env=adapter.user_agent_env, + user_agent_env_present=resolve_env(adapter.user_agent_env) is not None, + ) + + def resolve_local_path(adapter: AdapterConfig, root: Path) -> Path: local_path = adapter.local_path or resolve_env(adapter.local_path_env) if not local_path: + diagnostics = adapter_environment_diagnostics(adapter) raise ConfiguredAdapterPermanentError( - f"Для adapter `{adapter.adapter_name}` не указан local_path и не задан env `{adapter.local_path_env}`." + f"Для adapter `{adapter.adapter_name}` не указан local_path и не задан env `{adapter.local_path_env}`. " + f"diagnostics={diagnostics.as_dict()}" ) candidate = Path(local_path) if not candidate.is_absolute(): candidate = (root / candidate).resolve() if not candidate.exists(): - raise ConfiguredAdapterPermanentError(f"Локальный файл для adapter `{adapter.adapter_name}` не найден: {candidate}") + source = "local_path" if adapter.local_path else f"env `{adapter.local_path_env}`" + raise ConfiguredAdapterPermanentError( + f"Локальный файл для adapter `{adapter.adapter_name}` не найден: {candidate} " + f"(source={source})." + ) return candidate diff --git a/tests/unit/test_configured_transport_env.py b/tests/unit/test_configured_transport_env.py new file mode 100644 index 0000000..dd18032 --- /dev/null +++ b/tests/unit/test_configured_transport_env.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from alpha_research.config.models import AdapterConfig +from alpha_research.data.providers.configured_transport import ( + ConfiguredAdapterPermanentError, + adapter_environment_diagnostics, + provider_headers, + resolve_env, + resolve_local_path, +) + + +def test_resolve_env_strips_blank_values(monkeypatch) -> None: + monkeypatch.setenv("ALPHA_RESEARCH_TEST_VALUE", " usable-value ") + monkeypatch.setenv("ALPHA_RESEARCH_BLANK_VALUE", " ") + + assert resolve_env("ALPHA_RESEARCH_TEST_VALUE") == "usable-value" + assert resolve_env("ALPHA_RESEARCH_BLANK_VALUE") is None + assert resolve_env(None) is None + + +def test_adapter_environment_diagnostics_reports_presence_without_values(monkeypatch) -> None: + monkeypatch.setenv("ALPHA_RESEARCH_API_KEY", "secret-value") + monkeypatch.setenv("ALPHA_RESEARCH_USER_AGENT", "alpha-research-test") + + adapter = AdapterConfig( + adapter_name="diagnostic_adapter", + adapter_type="http", + api_key_env="ALPHA_RESEARCH_API_KEY", + local_path_env="ALPHA_RESEARCH_LOCAL_PATH", + user_agent_env="ALPHA_RESEARCH_USER_AGENT", + ) + + diagnostics = adapter_environment_diagnostics(adapter) + + assert diagnostics.api_key_env_present is True + assert diagnostics.user_agent_env_present is True + assert diagnostics.local_path_env_present is False + assert diagnostics.as_dict()["api_key_env"] == "ALPHA_RESEARCH_API_KEY" + + +def test_provider_headers_uses_default_user_agent_when_env_missing(monkeypatch) -> None: + monkeypatch.delenv("ALPHA_RESEARCH_USER_AGENT", raising=False) + adapter = AdapterConfig( + adapter_name="headers_adapter", + adapter_type="http", + user_agent_env="ALPHA_RESEARCH_USER_AGENT", + ) + + assert provider_headers(adapter)["User-Agent"] == "Mozilla/5.0" + + +def test_resolve_local_path_error_names_missing_env(tmp_path, monkeypatch) -> None: + monkeypatch.delenv("ALPHA_RESEARCH_LOCAL_PATH", raising=False) + adapter = AdapterConfig( + adapter_name="local_fixture_adapter", + adapter_type="local_file_market_daily", + local_path_env="ALPHA_RESEARCH_LOCAL_PATH", + ) + + try: + resolve_local_path(adapter, tmp_path) + except ConfiguredAdapterPermanentError as exc: + message = str(exc) + else: + raise AssertionError("Expected missing local path to raise a permanent adapter error") + + assert "local_fixture_adapter" in message + assert "ALPHA_RESEARCH_LOCAL_PATH" in message + assert "local_path_env_present" in message + + +def test_resolve_local_path_error_names_missing_file_source(tmp_path) -> None: + adapter = AdapterConfig( + adapter_name="missing_file_adapter", + adapter_type="local_file_market_daily", + local_path="fixtures/missing.csv", + ) + + try: + resolve_local_path(adapter, tmp_path) + except ConfiguredAdapterPermanentError as exc: + message = str(exc) + else: + raise AssertionError("Expected missing fixture file to raise a permanent adapter error") + + assert "missing_file_adapter" in message + assert "fixtures" in message + assert "source=local_path" in message