caltrain-api 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,68 @@
1
+ """Caltrain schedules and train positions.
2
+
3
+ Public API -- everything importable from ``caltrain_api`` directly is covered
4
+ by semantic versioning; submodule internals are not.
5
+ """
6
+
7
+ __version__ = "1.0.1"
8
+
9
+ from .api import CaltrainAPI
10
+ from .corridor import Corridor
11
+ from .describe import DescriptionPolicy
12
+ from .errors import (
13
+ CaltrainError,
14
+ OffCorridorError,
15
+ RateLimitedError,
16
+ RealtimeUnavailableError,
17
+ ScheduleUnavailableError,
18
+ UnknownStationError,
19
+ UnknownTrainError,
20
+ )
21
+ from .models import (
22
+ Alert,
23
+ Departure,
24
+ Journey,
25
+ Direction,
26
+ PositionSource,
27
+ Relation,
28
+ ServiceType,
29
+ Station,
30
+ Stop,
31
+ TrackLocation,
32
+ Train,
33
+ TrainPosition,
34
+ TrainStatus,
35
+ )
36
+ from .realtime import RealtimeSnapshot, RealtimeSource, SiriRealtimeSource, TrainObservation
37
+ from .schedule import Schedule
38
+
39
+ __all__ = [
40
+ "Alert",
41
+ "CaltrainAPI",
42
+ "CaltrainError",
43
+ "Corridor",
44
+ "Departure",
45
+ "Journey",
46
+ "DescriptionPolicy",
47
+ "Direction",
48
+ "OffCorridorError",
49
+ "PositionSource",
50
+ "RateLimitedError",
51
+ "RealtimeSnapshot",
52
+ "RealtimeSource",
53
+ "RealtimeUnavailableError",
54
+ "Relation",
55
+ "Schedule",
56
+ "ScheduleUnavailableError",
57
+ "ServiceType",
58
+ "SiriRealtimeSource",
59
+ "Station",
60
+ "Stop",
61
+ "TrackLocation",
62
+ "Train",
63
+ "TrainObservation",
64
+ "TrainPosition",
65
+ "TrainStatus",
66
+ "UnknownStationError",
67
+ "UnknownTrainError",
68
+ ]
@@ -0,0 +1,164 @@
1
+ """On-disk cache for the static GTFS zip (internal).
2
+
3
+ Layout under the cache dir:
4
+ caltrain-gtfs.zip the feed, replaced atomically
5
+ caltrain-gtfs.json metadata: url, downloaded_at, checked_at, etag, last_modified
6
+
7
+ Downloads are conditional (If-None-Match / If-Modified-Since), and a new zip
8
+ replaces the cached one only after it parses successfully, so a bad download
9
+ never clobbers a good cache.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ import tempfile
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from datetime import date, datetime, timedelta
21
+ from pathlib import Path
22
+ from typing import TypeVar
23
+
24
+ import httpx
25
+
26
+ from . import __version__
27
+ from .errors import ScheduleUnavailableError
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+ ZIP_NAME = "caltrain-gtfs.zip"
32
+ META_NAME = "caltrain-gtfs.json"
33
+ USER_AGENT = f"caltrain-api/{__version__}"
34
+
35
+ T = TypeVar("T")
36
+
37
+
38
+ @dataclass
39
+ class FeedMeta:
40
+ url: str
41
+ downloaded_at: datetime
42
+ checked_at: datetime
43
+ etag: str | None = None
44
+ last_modified: str | None = None
45
+
46
+ def to_json(self) -> str:
47
+ return json.dumps({
48
+ "url": self.url,
49
+ "downloaded_at": self.downloaded_at.isoformat(),
50
+ "checked_at": self.checked_at.isoformat(),
51
+ "etag": self.etag,
52
+ "last_modified": self.last_modified,
53
+ }, indent=2)
54
+
55
+ @classmethod
56
+ def from_json(cls, text: str) -> FeedMeta:
57
+ d = json.loads(text)
58
+ return cls(url=d["url"], downloaded_at=datetime.fromisoformat(d["downloaded_at"]),
59
+ checked_at=datetime.fromisoformat(d["checked_at"]),
60
+ etag=d.get("etag"), last_modified=d.get("last_modified"))
61
+
62
+
63
+ class FeedCache:
64
+ def __init__(
65
+ self,
66
+ cache_dir: Path,
67
+ url: str,
68
+ max_age: timedelta,
69
+ http_client: httpx.Client | None,
70
+ clock: Callable[[], datetime],
71
+ ):
72
+ self.cache_dir = cache_dir
73
+ self.url = url
74
+ self.max_age = max_age
75
+ self.http_client = http_client
76
+ self.clock = clock
77
+
78
+ @property
79
+ def zip_path(self) -> Path:
80
+ return self.cache_dir / ZIP_NAME
81
+
82
+ @property
83
+ def meta_path(self) -> Path:
84
+ return self.cache_dir / META_NAME
85
+
86
+ def read_meta(self) -> FeedMeta | None:
87
+ try:
88
+ return FeedMeta.from_json(self.meta_path.read_text())
89
+ except (OSError, ValueError, KeyError) as exc:
90
+ if self.meta_path.exists():
91
+ log.warning("Ignoring unreadable GTFS cache metadata %s: %s", self.meta_path, exc)
92
+ return None
93
+
94
+ def is_stale(self, valid_until: date | None) -> bool:
95
+ """True if the cache should be re-checked against the server."""
96
+ meta = self.read_meta()
97
+ now = self.clock()
98
+ if meta is None or not self.zip_path.exists():
99
+ return True
100
+ if meta.url != self.url:
101
+ log.info("GTFS URL changed; cache is stale")
102
+ return True
103
+ if now - meta.checked_at > self.max_age:
104
+ return True
105
+ return valid_until is not None and now.date() > valid_until
106
+
107
+ def _write_atomic(self, path: Path, data: bytes) -> None:
108
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
109
+ fd, tmp = tempfile.mkstemp(dir=self.cache_dir, prefix=path.name, suffix=".tmp")
110
+ try:
111
+ with os.fdopen(fd, "wb") as f:
112
+ f.write(data)
113
+ os.replace(tmp, path)
114
+ except BaseException:
115
+ Path(tmp).unlink(missing_ok=True)
116
+ raise
117
+
118
+ def download(self, parse: Callable[[bytes], T], *, conditional: bool) -> T | None:
119
+ """Fetch the feed. Returns ``parse(data)`` for new content, or None if
120
+ the server says the cached copy is current (HTTP 304).
121
+
122
+ The data is parsed *before* it replaces the cache.
123
+
124
+ Raises:
125
+ ScheduleUnavailableError: network/HTTP failure or unparseable feed.
126
+ """
127
+ meta = self.read_meta()
128
+ headers = {"User-Agent": USER_AGENT}
129
+ if conditional and meta is not None and self.zip_path.exists() and meta.url == self.url:
130
+ if meta.etag:
131
+ headers["If-None-Match"] = meta.etag
132
+ if meta.last_modified:
133
+ headers["If-Modified-Since"] = meta.last_modified
134
+
135
+ log.info("Downloading GTFS from %s", self.url)
136
+ client = self.http_client or httpx.Client(timeout=60, follow_redirects=True)
137
+ try:
138
+ resp = client.get(self.url, headers=headers)
139
+ except httpx.HTTPError as exc:
140
+ raise ScheduleUnavailableError(f"GTFS download failed: {type(exc).__name__}: {exc}") from exc
141
+ finally:
142
+ if self.http_client is None:
143
+ client.close()
144
+
145
+ now = self.clock()
146
+ if resp.status_code == 304 and meta is not None:
147
+ log.info("GTFS unchanged on server (304)")
148
+ meta.checked_at = now
149
+ self._write_atomic(self.meta_path, meta.to_json().encode())
150
+ return None
151
+ if resp.status_code != 200:
152
+ raise ScheduleUnavailableError(f"GTFS download failed: HTTP {resp.status_code}")
153
+
154
+ try:
155
+ parsed = parse(resp.content)
156
+ except Exception as exc:
157
+ raise ScheduleUnavailableError(f"downloaded GTFS is unusable: {exc}") from exc
158
+
159
+ self._write_atomic(self.zip_path, resp.content)
160
+ new_meta = FeedMeta(url=self.url, downloaded_at=now, checked_at=now,
161
+ etag=resp.headers.get("ETag"), last_modified=resp.headers.get("Last-Modified"))
162
+ self._write_atomic(self.meta_path, new_meta.to_json().encode())
163
+ log.info("Cached GTFS (%d bytes) at %s", len(resp.content), self.zip_path)
164
+ return parsed