Skip to content

Commit 417e742

Browse files
committed
inital commit
0 parents  commit 417e742

File tree

10 files changed

+376
-0
lines changed

10 files changed

+376
-0
lines changed

.github/workflows/linting.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Linting
2+
3+
on:
4+
push:
5+
branches: ["main"]
6+
pull_request:
7+
branches: [main]
8+
9+
workflow_dispatch:
10+
11+
permissions:
12+
contents: read
13+
14+
concurrency:
15+
group: "lintint"
16+
cancel-in-progress: false
17+
18+
jobs:
19+
check:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Checkout
23+
uses: actions/checkout@v4
24+
25+
- name: Set up Python
26+
uses: actions/setup-python@v5
27+
with:
28+
python-version: "3.9"
29+
30+
- name: Install deps
31+
run: |
32+
pip install pre-commit
33+
34+
- name: Check pre-commit lint checks
35+
run: pre-commit

.github/workflows/pyright.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Pyright
2+
3+
on:
4+
push:
5+
branches: ["main"]
6+
7+
workflow_dispatch:
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: "pyright"
14+
cancel-in-progress: false
15+
16+
jobs:
17+
pyright:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v4
22+
- uses: jakebailey/pyright-action@v1
23+
with:
24+
version: 1.1.403

.github/workflows/release.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: "Release"
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
jobs:
9+
tagged-release:
10+
name: "ReleaseBuild"
11+
runs-on: "ubuntu-latest"
12+
13+
permissions:
14+
# required for all workflows
15+
security-events: read
16+
actions: write
17+
contents: write
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- name: "Build"
23+
run: |
24+
docker build -t bruttazz/apod-api:$tag .
25+
working-directory: "."
26+
27+
- name: "Publish"
28+
id: publish
29+
run: |
30+
docker login -u bruttazz -p "${{ secrets.DOCKER_HUB_TOKEN }}"
31+
docker push bruttazz/apod-api:$tag
32+
working-directory: "."
33+
34+
- name: Create Release
35+
env:
36+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37+
tag: ${{ github.ref_name }}
38+
run: |
39+
gh release create "$tag" \
40+
--title="$tag" \
41+
--generate-notes

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
*.db
2+
__pycache__
3+
.venv

.pre-commit-config.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
repos:
2+
- repo: https://github.com/PyCQA/isort
3+
rev: 6.0.1
4+
hooks:
5+
- id: isort
6+
args: ["--profile=black"]
7+
8+
- repo: https://github.com/psf/black
9+
rev: 24.3.0
10+
hooks:
11+
- id: black
12+
language_version: python3.13
13+
14+
- repo: https://github.com/RobertCraigie/pyright-python
15+
rev: v1.1.403
16+
hooks:
17+
- id: pyright
18+
19+
- repo: https://github.com/pre-commit/pre-commit-hooks
20+
rev: v5.0.0
21+
hooks:
22+
- id: check-ast
23+
- id: check-yaml
24+
- id: end-of-file-fixer
25+
- id: trailing-whitespace

Dockerfile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
FROM python:3.13-alpine
2+
3+
COPY requirements.txt .
4+
RUN pip3 install -r requirements.txt
5+
6+
WORKDIR /opt/app
7+
COPY server.py server.py
8+
9+
ENTRYPOINT ["python3", "-m", "uvicorn", "server:app", "--host", "0.0.0.0"]
10+
CMD ["--port", "8000", "--workers", "2"]

Makefile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
PORT ?= 8000
2+
HOST ?= 0.0.0.0
3+
WORKERS ?= 4
4+
5+
PYTHON ?= .venv/bin/python
6+
7+
help: ## Show all Makefile targets.
8+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' Makefile | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[33m%-30s\033[0m %s\n", $$1, $$2}'
9+
10+
.venv: requirements.txt
11+
- python3 -m venv .venv
12+
- $(PYTHON) -m pip install -r requirements.txt
13+
14+
run: .venv ## Run server
15+
@$(PYTHON) -m uvicorn server:app --host $(HOST) --port $(PORT) --workers $(WORKERS) $(FLAGS)
16+
17+
dev: .venv ## Run dev server
18+
@$(MAKE) run WORKERS=1 FLAGS='--reload'

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
**A dead simple API that scrap, cache and serve APOD (Astronomy Picture of the Day) info from [nasa](https://apod.nasa.gov/apod/) / [star.ucl](http://www.star.ucl.ac.uk/~apod/apod/)!**
2+
3+
Inspired from: [@apod@reentry.codl.fr](https://reentry.codl.fr/@apod)
4+
5+
Usage:
6+
```sh
7+
curl http://127.0.0.1:8000/api/v1/apod/ | jq
8+
{
9+
"img": "<http url>",
10+
"description": "",
11+
"title": "",
12+
"date": "YYYY-MM-DD",
13+
}
14+
```
15+
16+
17+
Env Vars:
18+
- `APOD_SITE_URL` (defaults: `http://www.star.ucl.ac.uk/~apod/apod/`, compatable with `https://apod.nasa.gov/apod/` (but the site is dead today))
19+
20+
21+
All rights reserved to respective sites: [star.ucl](http://www.star.ucl.ac.uk/~apod/apod/) / [nasa](https://apod.nasa.gov/apod/)

requirements.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
sqlalchemy[asyncio]>=2.0.44
2+
aiosqlite>=0.21.0
3+
httpx>=0.28.1
4+
selectolax>=0.4.0
5+
uvicorn>=0.37.0

server.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# type: ignore[reportMissingImports]
2+
3+
import json
4+
import logging
5+
import os
6+
from datetime import datetime, timedelta, timezone
7+
from typing import Awaitable, Callable, Literal
8+
from urllib.parse import urljoin
9+
10+
import httpx
11+
from selectolax.parser import HTMLParser
12+
from sqlalchemy import DateTime, String, select
13+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
14+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
15+
16+
logger = logging.getLogger("uvicorn")
17+
18+
APOD_HTML = os.getenv("APOD_SITE_URL", "http://www.star.ucl.ac.uk/~apod/apod/")
19+
DB_URL = "sqlite+aiosqlite:///cache.db"
20+
CACHE_EXPIRY = timedelta(hours=12)
21+
22+
APP_ROUTE = "api/v1/apod"
23+
24+
Scope = dict[str, str]
25+
Receive = Callable[[], Awaitable[dict]]
26+
Send = Callable[[dict], Awaitable[None]]
27+
28+
ApodData = dict[Literal["img", "description", "title"], str]
29+
30+
31+
class CacheDbBase(DeclarativeBase):
32+
pass
33+
34+
35+
class Cache(CacheDbBase):
36+
__tablename__ = "cache"
37+
38+
id: Mapped[int] = mapped_column(primary_key=True)
39+
date: Mapped[datetime] = mapped_column(DateTime, nullable=False)
40+
img: Mapped[str] = mapped_column(String(125), nullable=True)
41+
description: Mapped[str] = mapped_column(String(500), nullable=True)
42+
title: Mapped[str] = mapped_column(String(200), nullable=True)
43+
expire_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
44+
45+
46+
async def get_apod_data() -> ApodData:
47+
"""Scrap data from APOD site!"""
48+
async with httpx.AsyncClient() as client:
49+
resp = await client.get(APOD_HTML)
50+
51+
out_dict: ApodData = {
52+
"img": "",
53+
"description": "",
54+
"title": "",
55+
}
56+
tree = HTMLParser(resp.text)
57+
58+
# template based parsing (can easily break)
59+
first_img = tree.css("img")[0] if tree.css("img") else None
60+
if first_img:
61+
out_dict["img"] = urljoin(APOD_HTML, first_img.attributes.get("src", ""))
62+
63+
centers = tree.css("center")
64+
second_center = centers[1] if len(centers) >= 2 else None
65+
if second_center:
66+
title_part = second_center.css("b")[0] if second_center.css("b") else None
67+
if title_part:
68+
txt = title_part.text()
69+
txt = " ".join(txt.split())
70+
out_dict["title"] = txt
71+
72+
current = second_center
73+
while current.next:
74+
current = current.next
75+
if current.tag == "p":
76+
txt = current.text()
77+
txt = " ".join(txt.split())
78+
out_dict["description"] = txt
79+
break
80+
81+
return out_dict
82+
83+
84+
class App:
85+
def __init__(self):
86+
self.db_engine = create_async_engine(DB_URL, echo=False)
87+
self.session = async_sessionmaker(self.db_engine, expire_on_commit=False)
88+
89+
async def get_response(self) -> dict[str, str]:
90+
out_data = {
91+
"img": "",
92+
"description": "",
93+
"title": "",
94+
"date": "",
95+
}
96+
async with self.session() as session:
97+
result = await session.execute(select(Cache).limit(1))
98+
dat = result.scalar_one_or_none()
99+
if dat is not None:
100+
if dat.expire_at < datetime.now():
101+
resp = await get_apod_data()
102+
_time = datetime.now(tz=timezone.utc)
103+
dat.img = resp["img"]
104+
dat.description = resp["description"]
105+
dat.title = resp["title"]
106+
dat.date = _time
107+
dat.expire_at = _time + CACHE_EXPIRY
108+
await session.commit()
109+
else:
110+
resp = await get_apod_data()
111+
_time = datetime.now(tz=timezone.utc)
112+
dat = Cache(
113+
img=resp["img"],
114+
title=resp["title"],
115+
description=resp["description"],
116+
date=_time,
117+
expire_at=_time + CACHE_EXPIRY,
118+
)
119+
session.add(dat)
120+
await session.commit()
121+
out_data["img"] = dat.img
122+
out_data["description"] = dat.description
123+
out_data["title"] = dat.title
124+
out_data["date"] = dat.date.strftime("%Y-%m-%d")
125+
126+
return out_data
127+
128+
async def lifespan_handle(self, scope: Scope, receive: Receive, send: Send):
129+
while True:
130+
msg = await receive()
131+
match msg["type"]:
132+
case "lifespan.startup":
133+
await self.on_startup()
134+
await send({"type": "lifespan.startup.complete"})
135+
case "lifespan.shutdown":
136+
await self.on_shutdown()
137+
await send({"type": "lifespan.shutdown.complete"})
138+
case _:
139+
raise ValueError(f"unknown asgi msg type: {msg['type']}!")
140+
141+
async def on_startup(self):
142+
async with self.db_engine.begin() as conn:
143+
await conn.run_sync(CacheDbBase.metadata.drop_all)
144+
await conn.run_sync(CacheDbBase.metadata.create_all)
145+
logger.info("[lifetime] Startup Completed!")
146+
147+
async def on_shutdown(self):
148+
await self.db_engine.dispose()
149+
logger.info("[lifetime] Shutdown Completed!")
150+
151+
async def serve_req(self, scope: Scope, receive: Receive, send: Send):
152+
if scope["method"] not in ["GET"]:
153+
return await self._req_error(405, "Method Not Allowed.", send)
154+
if scope["path"].strip("/") != APP_ROUTE:
155+
return await self._req_error(404, "Not Found.", send)
156+
try:
157+
resp = await self.get_response()
158+
await self._req_error(200, json.dumps(resp), send, "application/json")
159+
except Exception as exp:
160+
logger.error(f"GET Error: {exp}")
161+
logger.exception(exp)
162+
await self._req_error(500, "Internal Server Error", send)
163+
164+
async def __call__(self, scope: Scope, receive: Receive, send: Send):
165+
match scope["type"]:
166+
case "lifespan":
167+
await self.lifespan_handle(scope, receive, send)
168+
case "http":
169+
await self.serve_req(scope, receive, send)
170+
case _:
171+
await self._req_error(400, "Bad Request.", send)
172+
173+
async def _req_error(
174+
self, status: int, msg: str, send: Send, content_type: str = "text/plain"
175+
):
176+
await send(
177+
{
178+
"type": "http.response.start",
179+
"status": status,
180+
"headers": [
181+
(b"Content-Type", content_type.encode("utf-8")),
182+
(b"x-server", b"apod-api"),
183+
],
184+
}
185+
)
186+
await send(
187+
{
188+
"type": "http.response.body",
189+
"body": msg.encode("utf-8"),
190+
}
191+
)
192+
193+
194+
app = App()

0 commit comments

Comments
 (0)