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 ADDED
File without changes
gtfs_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from gtfs_cli.main import app
2
+
3
+ app()
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gtfs-cli = gtfs_cli.main:app
@@ -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.