Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
33 changes: 33 additions & 0 deletions docs/runbooks/configured_adapter_environment.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 42 additions & 2 deletions src/alpha_research/data/providers/configured_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
89 changes: 89 additions & 0 deletions tests/unit/test_configured_transport_env.py
Original file line number Diff line number Diff line change
@@ -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
Loading