Skip to content
Merged
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
67 changes: 67 additions & 0 deletions .github/workflows/cli-checks.yml
Original file line number Diff line number Diff line change
@@ -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
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
151 changes: 151 additions & 0 deletions tests/test_live.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading