gtfs-cli 0.1.0__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.
- gtfs_cli/__init__.py +0 -0
- gtfs_cli/__main__.py +3 -0
- gtfs_cli/commands/__init__.py +0 -0
- gtfs_cli/commands/fetch.py +171 -0
- gtfs_cli/main.py +17 -0
- gtfs_cli-0.1.0.dist-info/METADATA +107 -0
- gtfs_cli-0.1.0.dist-info/RECORD +10 -0
- gtfs_cli-0.1.0.dist-info/WHEEL +4 -0
- gtfs_cli-0.1.0.dist-info/entry_points.txt +2 -0
- gtfs_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
gtfs_cli/__init__.py
ADDED
|
File without changes
|
gtfs_cli/__main__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import signal
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from google.protobuf.json_format import MessageToDict, MessageToJson
|
|
12
|
+
from google.transit import gtfs_realtime_pb2
|
|
13
|
+
|
|
14
|
+
logging.basicConfig(
|
|
15
|
+
level=logging.ERROR,
|
|
16
|
+
format="%(asctime)s %(levelname)s %(message)s",
|
|
17
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
18
|
+
stream=sys.stderr,
|
|
19
|
+
)
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_url(source: str) -> bool:
|
|
24
|
+
return source.startswith("http://") or source.startswith("https://")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fetch_from_url(url: str, timeout: float, client=None) -> bytes:
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
if client is not None:
|
|
31
|
+
response = client.get(url)
|
|
32
|
+
else:
|
|
33
|
+
response = httpx.get(url, timeout=timeout, follow_redirects=True)
|
|
34
|
+
response.raise_for_status()
|
|
35
|
+
return response.content
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_from_file(path: str) -> bytes:
|
|
39
|
+
file_path = Path(path)
|
|
40
|
+
if not file_path.exists():
|
|
41
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
42
|
+
return file_path.read_bytes()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_feed(data: bytes) -> gtfs_realtime_pb2.FeedMessage:
|
|
46
|
+
feed = gtfs_realtime_pb2.FeedMessage()
|
|
47
|
+
feed.ParseFromString(data)
|
|
48
|
+
return feed
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _remaining_sleep(next_wake: float) -> float:
|
|
52
|
+
"""Seconds until next_wake on the monotonic clock. Never negative."""
|
|
53
|
+
return max(0.0, next_wake - time.monotonic())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _backoff_delay(consecutive_failures: int, cap: float = 60.0) -> float:
|
|
57
|
+
"""Exponential backoff: 1s, 2s, 4s, …, capped at `cap` seconds."""
|
|
58
|
+
return min(2.0 ** (consecutive_failures - 1), cap)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _feed_to_ndjson_line(feed: gtfs_realtime_pb2.FeedMessage) -> str:
|
|
62
|
+
"""Convert a FeedMessage to a single-line JSON string (for NDJSON output)."""
|
|
63
|
+
d = MessageToDict(feed, preserving_proto_field_name=True)
|
|
64
|
+
return json.dumps(d, separators=(",", ":"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fetch(
|
|
68
|
+
source: str = typer.Argument(
|
|
69
|
+
help="URL or local file path to a GTFS-RT protobuf feed.",
|
|
70
|
+
),
|
|
71
|
+
timeout: float = typer.Option(
|
|
72
|
+
30.0,
|
|
73
|
+
help="HTTP request timeout in seconds (only applies to URL sources).",
|
|
74
|
+
),
|
|
75
|
+
watch: Optional[float] = typer.Option(
|
|
76
|
+
None,
|
|
77
|
+
help="Continuously fetch at this interval (seconds). Outputs NDJSON. URL sources only.",
|
|
78
|
+
),
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Fetch GTFS-RT data and output as JSON.
|
|
81
|
+
|
|
82
|
+
SOURCE is either an HTTP(S) URL or a local file path. Auto-detected.
|
|
83
|
+
|
|
84
|
+
Examples:
|
|
85
|
+
|
|
86
|
+
gtfs-cli fetch "https://gtfsrt.ttc.ca/trips/update?format=binary"
|
|
87
|
+
|
|
88
|
+
gtfs-cli fetch trips.pb
|
|
89
|
+
|
|
90
|
+
gtfs-cli fetch feed.pb | jq '.entity[] | .alert'
|
|
91
|
+
|
|
92
|
+
gtfs-cli fetch --watch 30 "https://gtfsrt.ttc.ca/trips/update?format=binary"
|
|
93
|
+
|
|
94
|
+
gtfs-cli fetch --watch 30 "https://gtfsrt.ttc.ca/trips/update?format=binary" | jq --unbuffered '.entity | length'
|
|
95
|
+
"""
|
|
96
|
+
if watch is not None:
|
|
97
|
+
if not _is_url(source):
|
|
98
|
+
print(
|
|
99
|
+
"Error: --watch requires a URL source (watching a local file is not supported).",
|
|
100
|
+
file=sys.stderr,
|
|
101
|
+
)
|
|
102
|
+
raise typer.Exit(code=1)
|
|
103
|
+
_watch_loop(source, timeout, watch)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
if _is_url(source):
|
|
108
|
+
data = _fetch_from_url(source, timeout)
|
|
109
|
+
else:
|
|
110
|
+
data = _read_from_file(source)
|
|
111
|
+
except FileNotFoundError as e:
|
|
112
|
+
print(str(e), file=sys.stderr)
|
|
113
|
+
raise typer.Exit(code=1)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(f"Error fetching source: {e}", file=sys.stderr)
|
|
116
|
+
raise typer.Exit(code=1)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
feed = _parse_feed(data)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
print(f"Error parsing protobuf: {e}", file=sys.stderr)
|
|
122
|
+
raise typer.Exit(code=1)
|
|
123
|
+
|
|
124
|
+
json_output = MessageToJson(feed, preserving_proto_field_name=True)
|
|
125
|
+
print(json_output)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _watch_loop(
|
|
129
|
+
url: str,
|
|
130
|
+
timeout: float,
|
|
131
|
+
interval: float,
|
|
132
|
+
_stop_event: threading.Event | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Continuously fetch a GTFS-RT feed and output NDJSON lines."""
|
|
135
|
+
import httpx
|
|
136
|
+
|
|
137
|
+
stop_event = _stop_event if _stop_event is not None else threading.Event()
|
|
138
|
+
|
|
139
|
+
def _sigterm_handler(signum, frame):
|
|
140
|
+
stop_event.set()
|
|
141
|
+
|
|
142
|
+
old_handler = signal.signal(signal.SIGTERM, _sigterm_handler)
|
|
143
|
+
consecutive_failures = 0
|
|
144
|
+
try:
|
|
145
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
146
|
+
while not stop_event.is_set():
|
|
147
|
+
next_wake = time.monotonic() + interval
|
|
148
|
+
try:
|
|
149
|
+
data = _fetch_from_url(url, timeout, client=client)
|
|
150
|
+
except (httpx.HTTPStatusError, httpx.RequestError) as e:
|
|
151
|
+
logger.error("HTTP error: %s", e)
|
|
152
|
+
consecutive_failures += 1
|
|
153
|
+
stop_event.wait(_backoff_delay(consecutive_failures))
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
consecutive_failures = 0
|
|
157
|
+
try:
|
|
158
|
+
feed = _parse_feed(data)
|
|
159
|
+
print(_feed_to_ndjson_line(feed))
|
|
160
|
+
sys.stdout.flush()
|
|
161
|
+
except BrokenPipeError:
|
|
162
|
+
stop_event.set()
|
|
163
|
+
break
|
|
164
|
+
except Exception as e:
|
|
165
|
+
logger.error("Parse error: %s", e)
|
|
166
|
+
|
|
167
|
+
stop_event.wait(_remaining_sleep(next_wake))
|
|
168
|
+
except KeyboardInterrupt:
|
|
169
|
+
pass
|
|
170
|
+
finally:
|
|
171
|
+
signal.signal(signal.SIGTERM, old_handler)
|
gtfs_cli/main.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from gtfs_cli.commands.fetch import fetch
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(
|
|
6
|
+
name="gtfs-cli",
|
|
7
|
+
help="CLI tool to fetch, archive, process and explore GTFS-RT data.",
|
|
8
|
+
no_args_is_help=True,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.callback()
|
|
13
|
+
def callback() -> None:
|
|
14
|
+
"""CLI tool to fetch, archive, process and explore GTFS-RT data."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
app.command()(fetch)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gtfs-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool to fetch, archive, process and explore GTFS-RT data
|
|
5
|
+
Project-URL: Homepage, https://github.com/VMois/gtfs-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/VMois/gtfs-cli
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/VMois/gtfs-cli/issues
|
|
8
|
+
Author: Vladyslav Moisieienkov
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cli,gtfs,gtfs-rt,realtime,transit
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: gtfs-realtime-bindings>=1.0.0
|
|
21
|
+
Requires-Dist: httpx>=0.28.0
|
|
22
|
+
Requires-Dist: protobuf>=5.0.0
|
|
23
|
+
Requires-Dist: rich>=13.0.0
|
|
24
|
+
Requires-Dist: typer>=0.15.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# gtfs-cli
|
|
28
|
+
|
|
29
|
+
CLI tool to fetch, archive, process and explore [GTFS-RT](https://gtfs.org/documentation/realtime/reference/) (General Transit Feed Specification — Realtime) data. GTFS-RT feeds provide live transit information: trip updates, vehicle positions, and service alerts in protobuf format.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv tool install gtfs-cli
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
After installation, the `gtfs-cli` command is available globally.
|
|
38
|
+
|
|
39
|
+
## Commands
|
|
40
|
+
|
|
41
|
+
### `fetch`
|
|
42
|
+
|
|
43
|
+
Fetch a GTFS-RT feed from a URL or local file and output it as JSON.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Fetch live trip updates
|
|
47
|
+
gtfs-cli fetch "https://gtfsrt.ttc.ca/trips/update?format=binary"
|
|
48
|
+
|
|
49
|
+
# Fetch vehicle positions
|
|
50
|
+
gtfs-cli fetch "https://gtfsrt.ttc.ca/vehicles/position?format=binary"
|
|
51
|
+
|
|
52
|
+
# Fetch service alerts
|
|
53
|
+
gtfs-cli fetch "https://gtfsrt.ttc.ca/alerts/all?format=binary"
|
|
54
|
+
|
|
55
|
+
# Inspect a previously saved .pb file
|
|
56
|
+
gtfs-cli fetch trips.pb
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Filtering with jq:**
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# List all active alerts
|
|
63
|
+
gtfs-cli fetch "https://gtfsrt.ttc.ca/alerts/all?format=binary" | jq '.entity[] | .alert'
|
|
64
|
+
|
|
65
|
+
# Count entities in a feed
|
|
66
|
+
gtfs-cli fetch "https://gtfsrt.ttc.ca/trips/update?format=binary" | jq '.entity | length'
|
|
67
|
+
|
|
68
|
+
# Extract all trip IDs
|
|
69
|
+
gtfs-cli fetch "https://gtfsrt.ttc.ca/trips/update?format=binary" \
|
|
70
|
+
| jq '[.entity[].trip_update.trip.trip_id]'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Watch mode** — continuously poll a feed and stream NDJSON (one JSON object per line):
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Poll every 30 seconds
|
|
77
|
+
gtfs-cli fetch --watch 30 "https://gtfsrt.ttc.ca/trips/update?format=binary"
|
|
78
|
+
|
|
79
|
+
# Count entities on each snapshot
|
|
80
|
+
gtfs-cli fetch --watch 30 "https://gtfsrt.ttc.ca/trips/update?format=binary" \
|
|
81
|
+
| jq --unbuffered '.entity | length'
|
|
82
|
+
|
|
83
|
+
# Save a long-running collection to a file
|
|
84
|
+
gtfs-cli fetch --watch 30 "https://gtfsrt.ttc.ca/trips/update?format=binary" \
|
|
85
|
+
>> snapshots.ndjson
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Watch mode handles transient failures gracefully: HTTP and network errors are retried with exponential backoff (1s → 2s → 4s … capped at 60s). Stop with `Ctrl+C` or `SIGTERM`.
|
|
89
|
+
|
|
90
|
+
For all available options, run:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
gtfs-cli fetch --help
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Install dependencies
|
|
100
|
+
uv sync
|
|
101
|
+
|
|
102
|
+
# Run a command
|
|
103
|
+
uv run gtfs-cli fetch "https://gtfsrt.ttc.ca/trips/update?format=binary"
|
|
104
|
+
|
|
105
|
+
# Run tests
|
|
106
|
+
uv run pytest tests/ -v
|
|
107
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
gtfs_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
gtfs_cli/__main__.py,sha256=auepT9f8trIQ_vdQ899K1qs8KbgXUI6CrPH6aGH3bcw,37
|
|
3
|
+
gtfs_cli/main.py,sha256=BLLkhrGLtzKyZx42f_2-EFWor1mJ7SfRwgkYs1brdPQ,336
|
|
4
|
+
gtfs_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
gtfs_cli/commands/fetch.py,sha256=7-IXi4ia_XF3IyWYC54DQrfqTAfIMziHiC1vW0pObzI,5338
|
|
6
|
+
gtfs_cli-0.1.0.dist-info/METADATA,sha256=vkqwyBOVA3AvNZBmwcfarDG-JBMmxH0B98Jww6XXo3s,3206
|
|
7
|
+
gtfs_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
gtfs_cli-0.1.0.dist-info/entry_points.txt,sha256=9EdFtQDl4DPYW2XiSAp7St3JGNLhla9LDzbrzwLPGog,47
|
|
9
|
+
gtfs_cli-0.1.0.dist-info/licenses/LICENSE,sha256=-cKvf5H86Rx8wbM12rVgGvJhgTbrsDcCBSLyS8PnoDs,1079
|
|
10
|
+
gtfs_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vladyslav Moisieienkov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|