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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,13 @@ jobs:
run: mypy src/findata

- name: Pytest
if: matrix.python-version != '3.12'
run: pytest -v

- name: Pytest + coverage
if: matrix.python-version == '3.12'
run: pytest -v --cov=findata --cov-report=term-missing --cov-fail-under=60

build:
runs-on: ubuntu-latest
needs: test
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Nightly integration

on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install
run: |
python -m pip install --upgrade pip
pip install -e '.[dev]'

- name: Run integration tests
id: integration-tests
run: pytest -m integration -v 2>&1 | tee pytest-integration.log

- name: Upload failure log
if: steps.integration-tests.outcome == 'failure'
Comment on lines +30 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)(integration\.yml|.*workflow.*\.ya?ml|README|.*\.md)$' || true

echo
echo "== workflow excerpt =="
if [ -f .github/workflows/integration.yml ]; then
  nl -ba .github/workflows/integration.yml | sed -n '1,100p'
fi

echo
echo "== search for related workflows/pytest logs =="
rg -n "Run integration tests|Upload failure log|pytest-integration\.log|steps\.integration-tests\.outcome|failure\(\)|set -o pipefail|pytest -m integration" .github/workflows . || true

echo
echo "== deterministic pipeline/status probes =="
python3 - <<'PY'
import subprocess, shlex, tempfile, os

cases = [
    ("normal_exit", "echo ok | tee /tmp/pytest-integration.log"),
    ("pytest_exit_pipefail_missing", "false | true"),
    ("pytest_exit_pipefail_set", "set -o pipefail; false | true"),
]
original = os.environ.get("SHELL")
os.environ["SHELL"] = "/bin/bash"
for name, cmd in cases:
    rc = subprocess.run(["/bin/bash", "-c", cmd], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    print(f"{name}: cmd={cmd!r} returncode={rc.returncode} stdout={rc.stdout.strip()!r} stderr={rc.stderr.strip()!r}")
os.environ["SHELL"] = original or ""

from pathlib import Path
if Path("/tmp/pytest-integration.log").exists():
    print("tmp log contains:", Path("/tmp/pytest-integration.log").read_text().strip())
PY

echo
echo "== bash status docs excerpt from local bash if available =="
if command -v bash >/dev/null 2>&1; then
  bash --version | head -1
  bash -c 'false | true; echo pipe=$?'
  bash -c 'set -o pipefail; false | true; echo pipefail=$?'
fi

Repository: robertoecf/OpenFinData

Length of output: 694


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow excerpt =="
if [ -f .github/workflows/integration.yml ]; then
  sed -n '1,120p' .github/workflows/integration.yml
fi

echo
echo "== search for related workflows/pytest logs =="
rg -n "Run integration tests|Upload failure log|pytest-integration\.log|steps\.integration-tests\.outcome|failure\(\)|set -o pipefail|pytest -m integration" .github/workflows . || true

echo
echo "== deterministic pipeline/status probes =="
python3 - <<'PY'
import subprocess, os

cases = {
    "normal_exit": "echo ok | tee /tmp/pytest-integration.log",
    "pytest_exit_pipefail_missing": "false | true",
    "pytest_exit_pipefail_set": "set -o pipefail; false | true",
}
for name, cmd in cases.items():
    rc = subprocess.run(["/bin/bash", "-c", cmd], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    print(f"{name}: cmd={cmd!r} returncode={rc.returncode} stdout={rc.stdout.strip()!r} stderr={rc.stderr.strip()!r}")
PY

echo
echo "== bash status probes =="
bash -c 'false | true; echo pipe=$?'
bash -c 'set -o pipefail; false | true; echo pipefail=$?'

Repository: robertoecf/OpenFinData

Length of output: 2263


Preserve pytest failures before uploading the failure log.

The run command exits with tee’s status, so a failed pytest can make the step succeed without pipefail. Add Failure() or check() to the upload condition when the job can be canceled, because steps.integration-tests.outcome == 'failure' can have no value if the job is canceled.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/integration.yml around lines 30 - 33, Update the
integration test workflow step running `pytest -m integration` to preserve
pytest’s nonzero exit status through the `tee` pipeline by enabling `pipefail`
or explicitly checking the pytest result. Broaden the `Upload failure log`
condition to also run when the job is canceled, using the appropriate
`failure()`/`cancelled()` handling alongside `steps.integration-tests.outcome ==
'failure'`.

uses: actions/upload-artifact@v4
with:
name: pytest-integration-log
path: pytest-integration.log
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ adheres to [Semantic Versioning](https://semver.org/).

### Added

- **Test hardening and nightly integration checks.** Added offline unit/API
coverage for HTTP 429 retries, rate-limit helpers, previously thin sources
(BCB PTAX/Focus, IBGE, CVM companies/financials, Tesouro bonds, B3 quotes via
mocked yfinance), thin REST smoke for those routes, and CLI smoke
(`--help`/`--version`/`bcb series`/`bcb get`). Coverage gate
(`--cov-fail-under=60`) runs on the Python 3.12 CI leg. Live
`@pytest.mark.integration` tests run on a scheduled nightly workflow
(and `workflow_dispatch`), not on the default PR CI.
- **Asset-classification resolver** — `findata.resolver.resolve_asset()`,
`GET /resolver/resolve`, and the `resolve_asset` MCP tool. Turns any
Brazilian asset identifier (ticker/CNPJ/ISIN/name) into a classification
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,15 @@ Instalados via `bash scripts/git/install-hooks.sh`, que aponta
- **pre-push** — rede de segurança completa:
- `ruff format --check` + `ruff check` no repo inteiro (`src`, `tests`, `scripts`).
- `mypy --strict` em `src/findata`.
- `pytest -q` (unit + API; integration fica só na CI).
- `pytest -q` (unit + API; integration fica no workflow noturno/agendado).

Pra desinstalar: `git config --unset core.hooksPath`.

## Testes

```bash
pytest # padrão — unit + API (sem rede)
pytest -m integration # bate nos endpoints públicos reais
pytest -m integration # manual; também roda no workflow noturno/agendado
pytest -m "" # tudo
```

Expand Down
10 changes: 10 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ python_version = "3.11"
strict = true
ignore_missing_imports = true

[tool.coverage.run]
source = ["findata"]
branch = true
omit = ["*/tests/*"]

[tool.coverage.report]
show_missing = true
skip_covered = true
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]

[[tool.mypy.overrides]]
module = ["yfinance", "yfinance.*", "fastapi_mcp", "fastapi_mcp.*"]
ignore_missing_imports = true
Expand Down
2 changes: 1 addition & 1 deletion scripts/git/guardrails.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Split of responsibility:
# - Ruff → formatting + base lint + AI guardrails (complexity, max-args, magic numbers).
# - Mypy → strict type checking.
# - Pytest → unit-test fast path (integration tests run on CI).
# - Pytest → unit-test fast path (integration tests run on the scheduled CI workflow).
# - ggshield (opt-in) → secret leak detection.

set -euo pipefail
Expand Down
7 changes: 0 additions & 7 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,3 @@
def _clear_cache() -> None:
"""Ensure a clean HTTP cache between tests."""
http_client.clear_cache()


@pytest.fixture
async def _shutdown_http_client() -> None:
"""Close the shared httpx client after the test."""
yield
await http_client.close_client()
21 changes: 0 additions & 21 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,29 +118,8 @@ def test_chart_explorer_asset(client: TestClient) -> None:
assert "LightweightCharts" in r.text
assert "attributionLogo: false" in r.text
assert "bcbSeriesEndpoint(432, 24)" in r.text
assert "selic-meta-vs-ibov" in r.text
assert "bcbSeriesEndpoint(4189, 120)" in r.text
assert "Selic Meta vs Ibovespa" in r.text
assert 'get("preset")' in r.text
assert 'priceScaleId: "left"' in r.text
assert "Escalas: Selic à esquerda, Ibovespa à direita" in r.text
assert "MAX_POINTS = 5000" in r.text
assert "REQUEST_TIMEOUT_MS = 15000" in r.text
assert "new URL(rawEndpoint, window.location.origin)" in r.text
assert "/tesouro/bonds/history" not in r.text
assert 'options.type === "candlestick" || (!options.field && hasOhlc(firstRecord))' in r.text
assert "timestampFromDate" in r.text
assert "isValidDateParts" in r.text
assert "parseCompactPeriod" in r.text
assert "parseUnixTimestamp" in r.text
assert "allowShortSeconds" in r.text
assert "parseUnixTimestamp(text, { allowShortSeconds: true })" in r.text
assert "unixTimestamp !== null" in r.text
assert "dedupeByTime(normalizedTime.data)" in r.text
assert "normalizeMixedTimes" in r.text
assert "if (time !== null)" in r.text
assert "normalizedTime.hasIntraday ? a.time - b.time : a.time.localeCompare(b.time)" in r.text
assert "timeVisible: normalized.hasIntraday" in r.text
assert "Yahoo Finance" not in r.text


Expand Down
133 changes: 133 additions & 0 deletions tests/test_api_sources_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Thin API route smoke tests with all upstream traffic mocked."""

from __future__ import annotations

import re

import httpx
import pytest
import respx
from fastapi.testclient import TestClient

from findata.api.app import app
from findata.http_client import clear_cache
from findata.sources.cvm import companies
from findata.sources.tesouro import bonds


@pytest.fixture(autouse=True)
def _reset_module_caches() -> None:
clear_cache()
companies._companies_cache.invalidate()
bonds._bonds_cache.invalidate()


def _client() -> TestClient:
return TestClient(app)


@respx.mock
def test_ptax_usd_route() -> None:
respx.get(re.compile(r"https://olinda\.bcb\.gov\.br/.*/CotacaoDolarDia.*")).mock(
return_value=httpx.Response(
200,
json={
"value": [
{
"cotacaoCompra": 4.91,
"cotacaoVenda": 4.92,
"dataHoraCotacao": "2024-01-02 13:10:00.000",
}
]
},
)
)

response = _client().get("/bcb/ptax/usd", params={"date": "2024-01-02"})

assert response.status_code == 200
assert response.json()[0]["cotacao_compra"] == 4.91


@respx.mock
def test_focus_annual_route() -> None:
respx.get(re.compile(r"https://olinda\.bcb\.gov\.br/.*/ExpectativasMercadoAnuais.*")).mock(
return_value=httpx.Response(
200,
json={
"value": [
{
"Indicador": "IPCA",
"Data": "2024-01-02",
"DataReferencia": "2024",
"Media": 3.9,
"Mediana": 3.8,
}
]
},
)
)

response = _client().get("/bcb/focus/annual", params={"indicator": "IPCA"})

assert response.status_code == 200
assert response.json()[0]["indicador"] == "IPCA"


@respx.mock
def test_ibge_indicator_route() -> None:
respx.get(re.compile(r"https://servicodados\.ibge\.gov\.br/api/v3/agregados/7060/.*")).mock(
return_value=httpx.Response(
200,
json=[
{
"variavel": "IPCA - Variação mensal",
"resultados": [
{
"classificacoes": [],
"series": [
{
"localidade": {"nome": "Brasil"},
"serie": {"202401": "0.42"},
}
],
}
],
}
],
)
)

response = _client().get("/ibge/indicators/ipca_mensal", params={"periods": 1})

assert response.status_code == 200
assert response.json()[0]["periodo"] == "202401"


@respx.mock
def test_tesouro_bonds_route() -> None:
csv_data = (
b"Tipo Titulo;Data Vencimento;Data Base;Taxa Compra Manha;Taxa Venda Manha;"
b"PU Compra Manha;PU Venda Manha;PU Base Manha\n"
b"Tesouro Selic;01/03/2029;02/01/2024;0,10;0,11;100,00;99,00;99,50\n"
)
respx.get(bonds.TESOURO_CSV_URL).mock(return_value=httpx.Response(200, content=csv_data))

response = _client().get("/tesouro/bonds", params={"tipo": "Selic"})

assert response.status_code == 200
assert response.json()[0]["tipo"] == "Tesouro Selic"


@respx.mock
def test_cvm_companies_route() -> None:
csv_data = (
"CNPJ_CIA;DENOM_SOCIAL;DENOM_COMERC;CD_CVM;SIT;SETOR_ATIV;CATEG_REG;CONTROLE_ACIONARIO\n"
"00.000.000/0001-00;Companhia Teste SA;Teste;1234;ATIVO;Financeiro;A;PRIVADO\n"
).encode("iso-8859-1")
respx.get(companies.COMPANIES_URL).mock(return_value=httpx.Response(200, content=csv_data))

response = _client().get("/cvm/companies", params={"only_active": "true"})

assert response.status_code == 200
assert response.json()[0]["nome_social"] == "Companhia Teste SA"
4 changes: 2 additions & 2 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import pytest
import respx

from findata.auth import MissingCredentialsError, OAuth2ClientCredentials, OAuth2Token
from findata.auth import AuthError, MissingCredentialsError, OAuth2ClientCredentials, OAuth2Token


def test_token_is_expired_with_safety_margin() -> None:
Expand Down Expand Up @@ -74,7 +74,7 @@ async def test_oauth_failed_token_request_raises() -> None:
)
flow = _ANBIMA("cid", "wrong")
async with httpx.AsyncClient() as http:
with pytest.raises(Exception): # noqa: B017 — AuthError or subclass is fine
with pytest.raises(AuthError):
await flow.get_token(http)


Expand Down
Loading
Loading