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.
- caltrain_api/__init__.py +68 -0
- caltrain_api/_feedcache.py +164 -0
- caltrain_api/api.py +519 -0
- caltrain_api/corridor.py +224 -0
- caltrain_api/describe.py +126 -0
- caltrain_api/errors.py +37 -0
- caltrain_api/models.py +323 -0
- caltrain_api/py.typed +0 -0
- caltrain_api/realtime.py +315 -0
- caltrain_api/schedule.py +746 -0
- caltrain_api-1.0.1.dist-info/METADATA +116 -0
- caltrain_api-1.0.1.dist-info/RECORD +14 -0
- caltrain_api-1.0.1.dist-info/WHEEL +4 -0
- caltrain_api-1.0.1.dist-info/licenses/LICENSE +21 -0
caltrain_api/__init__.py
ADDED
|
@@ -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
|