diff --git a/.github/workflows/cli-checks.yml b/.github/workflows/cli-checks.yml new file mode 100644 index 0000000..0010475 --- /dev/null +++ b/.github/workflows/cli-checks.yml @@ -0,0 +1,67 @@ +name: CLI checks + +on: + pull_request: + push: + branches: [main] + schedule: + # 03:17 in Madrid in winter; 04:17 in summer. + - cron: '17 2 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + regression: + name: Regression (Python ${{ matrix.python }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python: ['3.11', '3.14'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + version: '0.11.21' + python-version: ${{ matrix.python }} + enable-cache: true + - name: Install locked dependencies + run: uv sync --locked --all-groups + - name: Run regression tests + run: uv run --no-sync pytest -q + - name: Lint and compile + run: | + uv run --no-sync ruff check . + uv run --no-sync python -m compileall -q src tests + - name: Check installed CLI entry point + run: | + uv run --no-sync cnmv --help + uv run --no-sync cnmv filing list --help + uv run --no-sync cnmv filing download --help + uv run --no-sync cnmv filing compare --help + + live-cnmv: + name: Live CNMV (list, download, compare) + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + version: '0.11.21' + python-version: '3.11' + enable-cache: true + - name: Install locked dependencies + run: uv sync --locked --all-groups + - name: Exercise real CNMV filings + env: + CNMV_LIVE_TESTS: '1' + run: uv run --no-sync pytest -v -s tests/test_live.py diff --git a/README.md b/README.md index 3778120..8b7962a 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,36 @@ uv run pytest uv run python -m compileall -q src tests ``` -Tests use local fixtures and mocked HTTP responses; they do not access the -network. +The default test suite uses local fixtures and mocked HTTP responses; it does +not access the network. + +### Automated CLI checks + +The [CLI checks workflow](https://github.com/JaviChulvi/cnmv-cli/actions/workflows/cli-checks.yml) runs the +regression suite, lint, compilation, and installed-command help checks on Python +3.11 and 3.14 for every pull request, push to `main`, and nightly at **02:17 UTC** +(03:17 Madrid winter time / 04:17 summer time). It also supports **Run workflow** +in the repository's Actions tab. + +Nightly and manual runs include a separate **Live CNMV** job. It lists filings +for `A08001851`, selects the latest two distinct periods with consolidated XHTML +documents, downloads one and verifies its hash and size, then compares the pair +using a temporary Chroma database. The job validates JSON output and provenance +and logs the selected periods, registration numbers, URLs, hashes, and change +count. It retries a reported upstream request failure once; persistent failures +fail the job. A live failure can also indicate a CNMV outage or page change. + +To run the same live check locally: + +```console +CNMV_LIVE_TESTS=1 uv run pytest -v -s tests/test_live.py +``` + +No API keys or repository secrets are required. Results and failing commands +appear in Actions; enable GitHub Actions notifications in your GitHub account +if you want email alerts. Scheduled runs begin once the workflow is on `main`; +GitHub may delay them and disables schedules in public repositories after 60 +days without repository activity. This MVP intentionally does not implement XBRL extraction, financial metrics, LLM-generated narrative, or external services. diff --git a/tests/test_live.py b/tests/test_live.py new file mode 100644 index 0000000..953bd50 --- /dev/null +++ b/tests/test_live.py @@ -0,0 +1,151 @@ +"""Opt-in checks of the installed CLI against the official CNMV website.""" + +import hashlib +import json +import math +import os +import subprocess +import time +from datetime import UTC, datetime + +import pytest + +pytestmark = pytest.mark.skipif( + os.environ.get("CNMV_LIVE_TESTS") != "1", + reason="set CNMV_LIVE_TESTS=1 to contact the real CNMV website", +) + + +def run_cli(*arguments: str, timeout: int = 120) -> str: + print(f"Running cnmv {' '.join(arguments)}", flush=True) + for attempt in range(2): + result = subprocess.run( + ["cnmv", *arguments], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + if ( + attempt == 0 + and result.returncode != 0 + and "CNMV request failed" in result.stderr + ): + print(f"Retrying one upstream failure: {result.stderr}", flush=True) + time.sleep(3) + continue + break + assert result.returncode == 0, result.stderr or result.stdout + return result.stdout + + +def test_live_list_download_and_compare(tmp_path) -> None: + filings = [ + json.loads(line) + for line in run_cli("filing", "list", "--nif", "A08001851").splitlines() + ] + assert filings, "CNMV returned no filings for the smoke-test issuer" + for filing in filings: + assert { + "registration_number", + "period_end", + "publication_date", + "auditor", + "documents", + } <= filing.keys() + + selected = [] + periods = set() + for filing in sorted( + filings, + key=lambda item: ( + datetime.strptime(item["period_end"], "%d/%m/%Y").replace(tzinfo=UTC), + datetime.strptime(item["publication_date"], "%d/%m/%Y").replace(tzinfo=UTC), + ), + reverse=True, + ): + if filing["period_end"] in periods: + continue + for document in filing["documents"]: + if document["role"] == "consolidated_xhtml": + selected.append( + { + "registration_number": filing["registration_number"], + "period_end": filing["period_end"], + **document, + } + ) + periods.add(filing["period_end"]) + break + if len(selected) == 2: + break + assert len(selected) == 2, ( + "CNMV did not expose two distinct consolidated XHTML periods" + ) + newer, older = selected + print(json.dumps({"older": older, "newer": newer}), flush=True) + + output = tmp_path / "downloads" / "newer.xhtml" + downloaded = json.loads( + run_cli( + "filing", + "download", + "--url", + newer["url"], + "--output", + str(output), + timeout=360, + ) + ) + content = output.read_bytes() + assert downloaded["byte_count"] == len(content) > 0 + assert downloaded["sha256"] == hashlib.sha256(content).hexdigest() + assert downloaded["output_path"] == str(output) + assert downloaded["content_type"].split(";", 1)[0].lower() in { + "application/xhtml+xml", + "text/html", + } + + database = tmp_path / "chroma" + comparison = json.loads( + run_cli( + "filing", + "compare", + "--older-url", + older["url"], + "--newer-url", + newer["url"], + "--database", + str(database), + timeout=900, + ) + ) + for label in ("older", "newer"): + provenance = comparison[label] + assert provenance["url"] + assert len(provenance["sha256"]) == 64 + int(provenance["sha256"], 16) + assert provenance["byte_count"] > 0 + assert comparison["newer"] == { + "url": downloaded["final_url"], + "sha256": downloaded["sha256"], + "byte_count": downloaded["byte_count"], + } + assert isinstance(comparison["change_count"], int) + assert comparison["change_count"] >= len(comparison["changes"]) + assert len(comparison["changes"]) <= 100 + for change in comparison["changes"]: + assert change["new_text"] + assert change["closest_old_text"] + assert math.isfinite(change["distance"]) + assert any(database.iterdir()), "Comparison did not persist its database" + print( + json.dumps( + { + "older": comparison["older"], + "newer": comparison["newer"], + "change_count": comparison["change_count"], + } + ), + flush=True, + )