- 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:
parent
873a6b6717
commit
fa7e3eb545
25
.github/workflows/test.yml
vendored
Normal file
25
.github/workflows/test.yml
vendored
Normal 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
3
.gitignore
vendored
@ -1,5 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
cache/
|
||||
.env
|
||||
*.log
|
||||
cache/*
|
||||
!cache/.gitkeep
|
||||
|
||||
@ -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.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt pytest
|
||||
python -m pytest tests/
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
10
app.py
10
app.py
@ -9,8 +9,9 @@ app = Flask(__name__)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
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.mkdir(parents=True, exist_ok=True)
|
||||
MAX_RETRIES = 1
|
||||
PAGE_SIZE = 10
|
||||
|
||||
@ -155,9 +156,9 @@ def clean_ereader(html):
|
||||
t = html.decode("utf-8", errors="replace")
|
||||
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'\s+style="[^"]*"', '', 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+style=["'][^"']*["']""", '', 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'<!--.*?-->', '', 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>'
|
||||
if '<head>' in t.lower():
|
||||
@ -229,5 +230,4 @@ def view(ticker, filename):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
app.run(host="0.0.0.0", port=5001)
|
||||
|
||||
0
cache/.gitkeep
vendored
Normal file
0
cache/.gitkeep
vendored
Normal file
@ -1 +1 @@
|
||||
flask
|
||||
flask>=3.0,<4
|
||||
|
||||
@ -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 }}">← 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>
|
||||
@ -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 }}">← 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
109
tests/test_app.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user