Files
fundi-scraper-api/api/datasource.py
T

174 lines
6.4 KiB
Python

"""Loads the scraped events JSON file(s) and keeps them in memory.
Files are treated as read-only. Call ``reload()`` to pick up a fresh
scrape without restarting the server.
Two files are read:
- the main data file (upcoming events, produced by a plain
``scraper.py`` run) - required, resolved from DATA_FILE.
- the archive file (past events, produced by ``scraper.py --archive``)
- optional, resolved from ARCHIVE_DATA_FILE. Missing archive file
just means no archived events are available yet.
"""
import json
import os
from datetime import date
from pathlib import Path
from threading import Lock
from typing import List, Optional
from models import Event
# Defaults: the fundi-*-output.json files sitting in the repo root, one
# directory above this file. Override with the DATA_FILE / ARCHIVE_DATA_FILE
# env vars.
_DEFAULT_DATA_FILE = Path(__file__).resolve().parent.parent / "fundi-scraped-output.json"
_DEFAULT_ARCHIVE_DATA_FILE = Path(__file__).resolve().parent.parent / "fundi-archive-output.json"
class EventStore:
def __init__(self, path: Optional[os.PathLike] = None, archive_path: Optional[os.PathLike] = None):
self.path = Path(path or os.environ.get("DATA_FILE") or _DEFAULT_DATA_FILE)
self.archive_path = Path(archive_path or os.environ.get("ARCHIVE_DATA_FILE") or _DEFAULT_ARCHIVE_DATA_FILE)
self._lock = Lock()
self._events: List[Event] = []
self._current_count = 0
self._archive_count = 0
self.reload()
@staticmethod
def _load_file(path: Path) -> List[Event]:
if not path.exists():
return []
with path.open(encoding="utf-8") as fh:
raw = json.load(fh)
return [Event.model_validate(item) for item in raw]
def reload(self) -> int:
"""Re-read the data file(s) from disk. Returns the number of events loaded."""
if not self.path.exists():
raise FileNotFoundError(f"Data file not found: {self.path}")
current = self._load_file(self.path)
archive = self._load_file(self.archive_path)
# Merge, de-duplicating on (date, name) in case an event that was
# scraped while upcoming was later also picked up by an archive
# scrape - the current-file copy wins.
seen = {(event.event_date, event.event_name) for event in current}
merged = list(current)
for event in archive:
key = (event.event_date, event.event_name)
if key not in seen:
merged.append(event)
seen.add(key)
with self._lock:
self._events = merged
self._current_count = len(current)
self._archive_count = len(merged) - len(current)
return len(merged)
def all(self) -> List[Event]:
with self._lock:
return list(self._events)
def get(self, index: int) -> Optional[Event]:
with self._lock:
if 0 <= index < len(self._events):
return self._events[index]
return None
@property
def current_count(self) -> int:
with self._lock:
return self._current_count
@property
def archive_count(self) -> int:
with self._lock:
return self._archive_count
def parse_event_date(value: str) -> Optional[date]:
"""Parse the scraper's ``dd.mm.yy`` date strings. Returns None if unparseable."""
try:
day, month, year = (int(part) for part in value.strip().split("."))
except (ValueError, AttributeError):
return None
if year < 100:
year += 2000
try:
return date(year, month, day)
except ValueError:
return None
def iter_artist_names(event: Event):
for group in event.event_artists:
for artist in group:
if artist.artist_name:
yield artist.artist_name
def sort_events(events: List[Event], sort: Optional[str]) -> List[Event]:
"""Sort events per the ``?sort=`` query param.
"asc"/"desc" order chronologically by ``event_date``; events with an
unparseable date always sort last, regardless of direction. "az"/"za"
order alphabetically (case-insensitive) by ``event_name``. Any other
value (including None) leaves the events in their existing order.
"""
if sort in ("asc", "desc"):
with_parsed = [(e, parse_event_date(e.event_date)) for e in events]
dated = sorted(
(item for item in with_parsed if item[1] is not None),
key=lambda item: item[1],
reverse=(sort == "desc"),
)
undated = [e for e, parsed in with_parsed if parsed is None]
return [e for e, _ in dated] + undated
if sort in ("az", "za"):
return sorted(events, key=lambda e: e.event_name.lower(), reverse=(sort == "za"))
return events
def sort_artist_schedules(items, sort: Optional[str]):
"""Sort (artist_name, dates) pairs per the ``?sort=`` query param.
"asc"/"desc" order by each artist's *latest* (most recent) date, since
an artist has many dates and not just one to sort on; artists with no
parseable date always sort last, regardless of direction. "az"/"za"
order alphabetically (case-insensitive) by name. None (or any other
value) defaults to "az", matching the plain-list behavior before sort
support existed.
"""
items = list(items)
if sort in ("asc", "desc"):
def _latest(dates) -> Optional[date]:
parsed = [d for d in (parse_event_date(x) for x in dates) if d is not None]
return max(parsed) if parsed else None
with_latest = [(name, dates, _latest(dates)) for name, dates in items]
dated = sorted(
(item for item in with_latest if item[2] is not None),
key=lambda item: item[2],
reverse=(sort == "desc"),
)
undated = [(name, dates) for name, dates, latest in with_latest if latest is None]
return [(name, dates) for name, dates, _ in dated] + undated
return sorted(items, key=lambda item: item[0].lower(), reverse=(sort == "za"))
def sort_date_strings(dates) -> List[str]:
"""Sort raw ``dd.mm.yy`` date strings chronologically, de-duplicated.
Unparseable strings sort last (alphabetically among themselves).
"""
unique = set(dates)
dated = sorted(
(d for d in unique if parse_event_date(d) is not None), key=parse_event_date
)
undated = sorted(d for d in unique if parse_event_date(d) is None)
return dated + undated