chore: polish for public release
Some checks are pending
Test / test (push) Waiting to run

- Real contact in SEC User-Agent (required by EDGAR)
- Fix Dockerfile cache dir (was missing, build would fail)
- Pin flask>=3.0,<4
- Harden clean_ereader to strip single-quoted attributes
- Remove unused templates (archive.html, more.html)
- Add pytest suite (10 tests) and CI workflow
This commit is contained in:
Jarian Cottingham 2026-08-20 21:11:29 +00:00
parent 873a6b6717
commit fa7e3eb545
9 changed files with 149 additions and 66 deletions

25
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest
- name: Run tests
run: python -m pytest tests/ -v

3
.gitignore vendored
View File

@ -1,5 +1,6 @@
__pycache__/ __pycache__/
*.pyc *.pyc
cache/
.env .env
*.log *.log
cache/*
!cache/.gitkeep

View File

@ -40,6 +40,13 @@ docker run -p 5001:5001 10k-viewer
Enter a stock ticker (e.g. `AAPL`, `MSFT`, `GOOGL`) to fetch and browse the latest 10-K filing data. Supports any company with a valid CIK identifier. Enter a stock ticker (e.g. `AAPL`, `MSFT`, `GOOGL`) to fetch and browse the latest 10-K filing data. Supports any company with a valid CIK identifier.
## Tests
```bash
pip install -r requirements.txt pytest
python -m pytest tests/
```
## License ## License
MIT MIT

10
app.py
View File

@ -9,8 +9,9 @@ app = Flask(__name__)
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
log = logging.getLogger("10k") log = logging.getLogger("10k")
UA = "10k-viewer/1.0 (test@test.com)" UA = "10k-viewer/1.0 (Jarian Cottingham jarianc@proton.me)"
CACHE_DIR = Path(__file__).parent / "cache" CACHE_DIR = Path(__file__).parent / "cache"
CACHE_DIR.mkdir(parents=True, exist_ok=True)
MAX_RETRIES = 1 MAX_RETRIES = 1
PAGE_SIZE = 10 PAGE_SIZE = 10
@ -155,9 +156,9 @@ def clean_ereader(html):
t = html.decode("utf-8", errors="replace") t = html.decode("utf-8", errors="replace")
t = re.sub(r'<script[^>]*>.*?</script>', '', t, flags=re.DOTALL|re.I) t = re.sub(r'<script[^>]*>.*?</script>', '', t, flags=re.DOTALL|re.I)
t = re.sub(r'<style[^>]*>.*?</style>', '', t, flags=re.DOTALL|re.I) t = re.sub(r'<style[^>]*>.*?</style>', '', t, flags=re.DOTALL|re.I)
t = re.sub(r'\s+style="[^"]*"', '', t, flags=re.I) t = re.sub(r"""\s+style=["'][^"']*["']""", '', t, flags=re.I)
t = re.sub(r'\s+on\w+="[^"]*"', '', t, flags=re.I) t = re.sub(r"""\s+on\w+=["'][^"']*["']""", '', t, flags=re.I)
t = re.sub(r'\s+(class|id)="[^"]*"', '', t, flags=re.I) t = re.sub(r"""\s+(class|id)=["'][^"']*["']""", '', t, flags=re.I)
t = re.sub(r'<!--.*?-->', '', t, flags=re.DOTALL) t = re.sub(r'<!--.*?-->', '', t, flags=re.DOTALL)
s = '<style>body{font-family:Georgia,serif!important;font-size:18px!important;line-height:1.6!important;max-width:700px;margin:0 auto;padding:20px;color:#000;background:#fff}table{border-collapse:collapse;width:100%;margin:10px 0;font-size:14px}th,td{border:1px solid #000;padding:4px 8px;text-align:left}th{font-weight:bold;background:#eee}a{color:#000}h1,h2,h3,h4,h5,h6{font-family:Georgia,serif!important;margin:16px 0 8px}p{margin:8px 0}</style>' s = '<style>body{font-family:Georgia,serif!important;font-size:18px!important;line-height:1.6!important;max-width:700px;margin:0 auto;padding:20px;color:#000;background:#fff}table{border-collapse:collapse;width:100%;margin:10px 0;font-size:14px}th,td{border:1px solid #000;padding:4px 8px;text-align:left}th{font-weight:bold;background:#eee}a{color:#000}h1,h2,h3,h4,h5,h6{font-family:Georgia,serif!important;margin:16px 0 8px}p{margin:8px 0}</style>'
if '<head>' in t.lower(): if '<head>' in t.lower():
@ -229,5 +230,4 @@ def view(ticker, filename):
if __name__ == "__main__": if __name__ == "__main__":
CACHE_DIR.mkdir(parents=True, exist_ok=True)
app.run(host="0.0.0.0", port=5001) app.run(host="0.0.0.0", port=5001)

0
cache/.gitkeep vendored Normal file
View File

View File

@ -1 +1 @@
flask flask>=3.0,<4

View File

@ -1,26 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{ ticker }} - Archive Status</title>
<style>
body { font-family: Georgia, serif; font-size: 18px; max-width: 600px; margin: 0 auto; padding: 20px; }
h1 { font-size: 1.4em; }
.back { font-size: 0.85em; }
.stats { margin: 20px 0; padding: 15px; background: #f5f5f5; }
.ok { color: #080; }
.err { color: #c00; }
</style>
</head>
<body>
<p class="back"><a href="/{{ ticker }}">&larr; Back to {{ ticker }}</a> | <a href="/">All companies</a></p>
<h1>Archive Status</h1>
<div class="stats">
<p class="ok">{{ archived }} of {{ total }} filings archived</p>
{% if failed > 0 %}
<p class="err">{{ failed }} failed (SEC may have rate-limited)</p>
{% endif %}
</div>
<p>Files saved to local cache for offline reading.</p>
</body>
</html>

View File

@ -1,33 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{ ticker }} - More Filings</title>
<style>
body { font-family: Georgia, serif; font-size: 18px; max-width: 600px; margin: 0 auto; padding: 20px; }
h1 { font-size: 1.4em; }
ul { list-style: none; padding: 0; }
li { padding: 8px 0; border-bottom: 1px solid #ccc; }
a { color: #000; }
small { color: #555; }
.back { font-size: 0.85em; }
.form-tag { display: inline-block; padding: 1px 5px; font-size: 0.75em; margin-left: 5px; }
.form-10k { background: #000; color: #fff; }
.form-10q { background: #666; color: #fff; }
</style>
</head>
<body>
<p class="back"><a href="/{{ ticker }}?form={{ form_filter }}">&larr; Back to filings</a> | <a href="/">All companies</a></p>
<h1>{{ ticker }} - More Filings</h1>
<ul>
{% for f in filings %}
<li>
<a href="{{ f.sec_url }}" target="_blank">{{ f.filing_date }}</a>
<span class="form-tag {% if f.form == '10-K' %}form-10k{% else %}form-10q{% endif %}">{{ f.form }}</span>
</li>
{% else %}
<li>No more filings.</li>
{% endfor %}
</ul>
</body>
</html>

109
tests/test_app.py Normal file
View File

@ -0,0 +1,109 @@
"""Tests for the 10-K viewer Flask app."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import app as viewer # noqa: E402
SAMPLE_SUBS = {
"filings": {
"recent": {
"form": ["10-K", "8-K", "10-Q", "10-K"],
"filingDate": ["2024-11-01", "2024-10-15", "2024-08-01", "2023-11-02"],
"primaryDocument": [
"aapl-20240928.htm",
"aapl-20241015.htm",
"aapl-20240628.htm",
"aapl-20230930.htm",
],
"accessionNumber": [
"0000320193-24-000123",
"0000320193-24-000100",
"0000320193-24-000090",
"0000320193-23-000050",
],
}
}
}
@pytest.fixture
def client():
viewer._filings_cache.clear()
viewer._tickers = None
viewer.app.config["TESTING"] = True
with viewer.app.test_client() as c:
yield c
def test_parse_filings_filters_forms():
r = viewer.parse_filings(SAMPLE_SUBS, "0000320193", {"10-K"})
assert len(r) == 2
assert all(f["form"] == "10-K" for f in r)
def test_parse_filings_builds_url_and_file():
r = viewer.parse_filings(SAMPLE_SUBS, "0000320193", {"10-K", "10-Q"})
first = r[0]
assert first["file"] == "2024-11-01_000032019324000123.html"
assert (
first["url"]
== "https://www.sec.gov/Archives/edgar/data/0000320193/000032019324000123/aapl-20240928.htm"
)
def test_parse_filings_no_matching_forms():
assert viewer.parse_filings(SAMPLE_SUBS, "0000320193", {"20-F"}) == []
def test_clean_ereader_strips_scripts_and_handlers():
html = b"<html><head><script>evil()</script></head><body><div onclick='x()' class='c' id='i'>hi</div></body></html>"
out = viewer.clean_ereader(html).decode("utf-8")
assert "<script>" not in out
assert "onclick" not in out
assert "class=" not in out
assert "id=" not in out
assert "Georgia" in out
def test_get_tickers_fallback():
viewer._tickers = None
t = viewer.get_tickers()
assert t["AAPL"]["cik"] == "0000320193"
def test_index_default_lists_companies(client):
resp = client.get("/")
assert resp.status_code == 200
assert "AAPL" in resp.get_data(as_text=True)
def test_index_search_filters(client):
resp = client.get("/?q=apple")
assert resp.status_code == 200
body = resp.get_data(as_text=True)
assert "AAPL" in body
assert "MSFT" not in body
def test_company_renders_filings(client, monkeypatch):
monkeypatch.setattr(viewer, "get_filings", lambda cik, forms: [
{"form": "10-K", "date": "2024-11-01", "file": "f1.html", "url": "https://x/f1.html"},
])
resp = client.get("/AAPL")
assert resp.status_code == 200
assert "Apple Inc." in resp.get_data(as_text=True)
assert "f1.html" in resp.get_data(as_text=True)
def test_company_unknown_ticker_404(client):
assert client.get("/NOPE").status_code == 404
def test_view_unknown_ticker_404(client):
assert client.get("/NOPE/view/f1.html").status_code == 404