mta-subway 1.0.0__tar.gz

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,23 @@
1
+ # Virtual environments
2
+ venv/
3
+ .venv/
4
+ env/
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *.egg-info/
10
+ dist/
11
+ build/
12
+
13
+ # Node
14
+ node_modules/
15
+ dist/
16
+
17
+ # IDE
18
+ .idea/
19
+ .vscode/
20
+ *.swp
21
+
22
+ # OS
23
+ .DS_Store
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.4
2
+ Name: mta-subway
3
+ Version: 1.0.0
4
+ Summary: Real-time NYC subway arrivals CLI and MCP server
5
+ Project-URL: Homepage, https://github.com/reichertjalex/mta-subway
6
+ Project-URL: Repository, https://github.com/reichertjalex/mta-subway
7
+ Author: Alex Reichert
8
+ License-Expression: MIT
9
+ Keywords: cli,gtfs,mcp,mta,nyc,subway,transit
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
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 :: Utilities
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: click>=8.1.0
21
+ Requires-Dist: mcp>=1.0.0
22
+ Requires-Dist: nyct-gtfs>=1.3.0
23
+ Requires-Dist: pydantic>=2.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # MTA API v3
27
+
28
+ Real-time NYC subway arrivals API with async concurrent feed fetching.
29
+
30
+ ## Key Differences from v2
31
+
32
+ - **Concurrent fetching**: All 8 MTA feeds fetched in parallel using `asyncio.to_thread()`
33
+ - **Async cache**: Uses `asyncio.Lock` to prevent concurrent refresh storms
34
+ - **Lazy refresh**: Cache updates on first request after expiry
35
+
36
+ ## Performance
37
+
38
+ - Sequential fetch (v2): ~2-4 seconds
39
+ - Concurrent fetch (v3): ~500ms-1s
40
+
41
+ ## Quick Start
42
+
43
+ ```bash
44
+ python -m venv venv
45
+ source venv/bin/activate
46
+ pip install -r requirements.txt
47
+ uvicorn app.main:app --reload
48
+ ```
49
+
50
+ ## API Endpoints
51
+
52
+ Same as v2 - see `/docs` for OpenAPI documentation.
53
+
54
+ ## Docker
55
+
56
+ ```bash
57
+ docker build -t mta-api-v3 .
58
+ docker run -p 8000:8000 mta-api-v3
59
+ ```
60
+
61
+ ## Deploy to Fly.io
62
+
63
+ [Fly.io](https://fly.io) is recommended for v3 since it supports persistent servers with the async caching model.
64
+
65
+ ### First-time setup
66
+
67
+ ```bash
68
+ # Install flyctl
69
+ curl -L https://fly.io/install.sh | sh
70
+
71
+ # Login
72
+ fly auth login
73
+
74
+ # Launch app (creates fly.toml)
75
+ cd mta-api-v3
76
+ fly launch --name mta-api-v3 --region ewr --no-deploy
77
+
78
+ # Deploy
79
+ fly deploy
80
+ ```
81
+
82
+ ### fly.toml
83
+
84
+ If you need to create it manually:
85
+
86
+ ```toml
87
+ app = "mta-api-v3"
88
+ primary_region = "ewr" # Newark, close to MTA servers
89
+
90
+ [build]
91
+ dockerfile = "Dockerfile"
92
+
93
+ [http_service]
94
+ internal_port = 8000
95
+ force_https = true
96
+
97
+ [[http_service.checks]]
98
+ path = "/"
99
+ interval = "30s"
100
+ timeout = "5s"
101
+ ```
102
+
103
+ ### Useful commands
104
+
105
+ ```bash
106
+ fly status # Check app status
107
+ fly logs # View logs
108
+ fly ssh console # SSH into the container
109
+ fly scale count 2 # Scale to 2 instances
110
+ fly secrets set KEY=val # Set environment variables
111
+ ```
112
+
113
+ ## CLI
114
+
115
+ Query subway data from the command line. Output is JSON by default.
116
+
117
+ ```bash
118
+ # Setup
119
+ source venv/bin/activate
120
+
121
+ # List all stations
122
+ python -m mta.cli stations
123
+
124
+ # Search stations by name
125
+ python -m mta.cli stations -q "Times Sq"
126
+
127
+ # Get station by ID (with arrivals)
128
+ python -m mta.cli stations 127
129
+
130
+ # Get multiple stations
131
+ python -m mta.cli stations 127,A32
132
+
133
+ # Search by location
134
+ python -m mta.cli stations --lat 40.7580 --lon -73.9855
135
+
136
+ # List all routes
137
+ python -m mta.cli routes
138
+
139
+ # Get stations on a route
140
+ python -m mta.cli routes A
141
+
142
+ # Find next trains from a station
143
+ python -m mta.cli find "Times Sq"
144
+
145
+ # Find specific route from a station
146
+ python -m mta.cli find "Times Sq" -r A
147
+
148
+ # Human-readable output (--pretty goes before the command)
149
+ python -m mta.cli --pretty stations 127
150
+ ```
151
+
152
+ ## MCP Server
153
+
154
+ Run as a Model Context Protocol server for AI assistants:
155
+
156
+ ```bash
157
+ python -m mta.mcp
158
+ ```
159
+
160
+ ### Available Tools
161
+
162
+ | Tool | Description |
163
+ | -------------- | ----------------------------------- |
164
+ | `get_stations` | Search stations by name or location |
165
+ | `get_station` | Get station(s) by ID with arrivals |
166
+ | `get_routes` | List all active subway routes |
167
+ | `get_route` | Get all stations on a route |
168
+ | `find_train` | Find next trains from a station |
169
+
170
+ ### Claude Desktop Configuration
171
+
172
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
173
+
174
+ ```json
175
+ {
176
+ "mcpServers": {
177
+ "mta-subway": {
178
+ "command": "python",
179
+ "args": ["-m", "mta.mcp"],
180
+ "cwd": "/path/to/mta-api-v3"
181
+ }
182
+ }
183
+ }
184
+ ```
@@ -0,0 +1,159 @@
1
+ # MTA API v3
2
+
3
+ Real-time NYC subway arrivals API with async concurrent feed fetching.
4
+
5
+ ## Key Differences from v2
6
+
7
+ - **Concurrent fetching**: All 8 MTA feeds fetched in parallel using `asyncio.to_thread()`
8
+ - **Async cache**: Uses `asyncio.Lock` to prevent concurrent refresh storms
9
+ - **Lazy refresh**: Cache updates on first request after expiry
10
+
11
+ ## Performance
12
+
13
+ - Sequential fetch (v2): ~2-4 seconds
14
+ - Concurrent fetch (v3): ~500ms-1s
15
+
16
+ ## Quick Start
17
+
18
+ ```bash
19
+ python -m venv venv
20
+ source venv/bin/activate
21
+ pip install -r requirements.txt
22
+ uvicorn app.main:app --reload
23
+ ```
24
+
25
+ ## API Endpoints
26
+
27
+ Same as v2 - see `/docs` for OpenAPI documentation.
28
+
29
+ ## Docker
30
+
31
+ ```bash
32
+ docker build -t mta-api-v3 .
33
+ docker run -p 8000:8000 mta-api-v3
34
+ ```
35
+
36
+ ## Deploy to Fly.io
37
+
38
+ [Fly.io](https://fly.io) is recommended for v3 since it supports persistent servers with the async caching model.
39
+
40
+ ### First-time setup
41
+
42
+ ```bash
43
+ # Install flyctl
44
+ curl -L https://fly.io/install.sh | sh
45
+
46
+ # Login
47
+ fly auth login
48
+
49
+ # Launch app (creates fly.toml)
50
+ cd mta-api-v3
51
+ fly launch --name mta-api-v3 --region ewr --no-deploy
52
+
53
+ # Deploy
54
+ fly deploy
55
+ ```
56
+
57
+ ### fly.toml
58
+
59
+ If you need to create it manually:
60
+
61
+ ```toml
62
+ app = "mta-api-v3"
63
+ primary_region = "ewr" # Newark, close to MTA servers
64
+
65
+ [build]
66
+ dockerfile = "Dockerfile"
67
+
68
+ [http_service]
69
+ internal_port = 8000
70
+ force_https = true
71
+
72
+ [[http_service.checks]]
73
+ path = "/"
74
+ interval = "30s"
75
+ timeout = "5s"
76
+ ```
77
+
78
+ ### Useful commands
79
+
80
+ ```bash
81
+ fly status # Check app status
82
+ fly logs # View logs
83
+ fly ssh console # SSH into the container
84
+ fly scale count 2 # Scale to 2 instances
85
+ fly secrets set KEY=val # Set environment variables
86
+ ```
87
+
88
+ ## CLI
89
+
90
+ Query subway data from the command line. Output is JSON by default.
91
+
92
+ ```bash
93
+ # Setup
94
+ source venv/bin/activate
95
+
96
+ # List all stations
97
+ python -m mta.cli stations
98
+
99
+ # Search stations by name
100
+ python -m mta.cli stations -q "Times Sq"
101
+
102
+ # Get station by ID (with arrivals)
103
+ python -m mta.cli stations 127
104
+
105
+ # Get multiple stations
106
+ python -m mta.cli stations 127,A32
107
+
108
+ # Search by location
109
+ python -m mta.cli stations --lat 40.7580 --lon -73.9855
110
+
111
+ # List all routes
112
+ python -m mta.cli routes
113
+
114
+ # Get stations on a route
115
+ python -m mta.cli routes A
116
+
117
+ # Find next trains from a station
118
+ python -m mta.cli find "Times Sq"
119
+
120
+ # Find specific route from a station
121
+ python -m mta.cli find "Times Sq" -r A
122
+
123
+ # Human-readable output (--pretty goes before the command)
124
+ python -m mta.cli --pretty stations 127
125
+ ```
126
+
127
+ ## MCP Server
128
+
129
+ Run as a Model Context Protocol server for AI assistants:
130
+
131
+ ```bash
132
+ python -m mta.mcp
133
+ ```
134
+
135
+ ### Available Tools
136
+
137
+ | Tool | Description |
138
+ | -------------- | ----------------------------------- |
139
+ | `get_stations` | Search stations by name or location |
140
+ | `get_station` | Get station(s) by ID with arrivals |
141
+ | `get_routes` | List all active subway routes |
142
+ | `get_route` | Get all stations on a route |
143
+ | `find_train` | Find next trains from a station |
144
+
145
+ ### Claude Desktop Configuration
146
+
147
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
148
+
149
+ ```json
150
+ {
151
+ "mcpServers": {
152
+ "mta-subway": {
153
+ "command": "python",
154
+ "args": ["-m", "mta.mcp"],
155
+ "cwd": "/path/to/mta-api-v3"
156
+ }
157
+ }
158
+ }
159
+ ```
File without changes
@@ -0,0 +1,61 @@
1
+ """Async TTL cache with lock to prevent concurrent refreshes."""
2
+
3
+ import asyncio
4
+ from datetime import datetime, timedelta
5
+ from typing import TypeVar, Generic, Callable, Awaitable
6
+ from zoneinfo import ZoneInfo
7
+
8
+ TZ = ZoneInfo("America/New_York")
9
+ T = TypeVar("T")
10
+
11
+
12
+ class AsyncTTLCache(Generic[T]):
13
+ """Thread-safe async cache with TTL expiration."""
14
+
15
+ def __init__(self, ttl_seconds: int = 60):
16
+ self.ttl_seconds = ttl_seconds
17
+ self._value: T | None = None
18
+ self._last_update: datetime | None = None
19
+ self._lock = asyncio.Lock()
20
+
21
+ def is_expired(self) -> bool:
22
+ """Check if cache has expired."""
23
+ if self._last_update is None:
24
+ return True
25
+ age = datetime.now(TZ) - self._last_update
26
+ return age.total_seconds() > self.ttl_seconds
27
+
28
+ async def get(self) -> T | None:
29
+ """Get cached value (may be stale)."""
30
+ return self._value
31
+
32
+ async def set(self, value: T) -> None:
33
+ """Set cached value."""
34
+ async with self._lock:
35
+ self._value = value
36
+ self._last_update = datetime.now(TZ)
37
+
38
+ async def get_or_refresh(
39
+ self, refresh_fn: Callable[[], Awaitable[T]]
40
+ ) -> T | None:
41
+ """Get cached value, refreshing if expired.
42
+
43
+ Uses lock to prevent multiple concurrent refreshes.
44
+ """
45
+ if not self.is_expired():
46
+ return self._value
47
+
48
+ async with self._lock:
49
+ # Double-check after acquiring lock
50
+ if not self.is_expired():
51
+ return self._value
52
+
53
+ # Refresh
54
+ self._value = await refresh_fn()
55
+ self._last_update = datetime.now(TZ)
56
+ return self._value
57
+
58
+ @property
59
+ def last_update(self) -> datetime | None:
60
+ """Get timestamp of last update."""
61
+ return self._last_update
@@ -0,0 +1,126 @@
1
+ """Async FastAPI application for MTA API v3."""
2
+
3
+ import logging
4
+ import os
5
+ from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
+
8
+ from fastapi import FastAPI, Query, HTTPException
9
+
10
+ # Configure logging
11
+ logging.basicConfig(
12
+ level=logging.INFO,
13
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
14
+ )
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+
17
+ from app.mta_service import AsyncMTAService
18
+ from app.models import StationsResponse, RoutesResponse
19
+
20
+ # Configuration from environment
21
+ STATIONS_FILE = Path(os.getenv("STATIONS_FILE", "data/stations.json"))
22
+ CACHE_SECONDS = int(os.getenv("CACHE_SECONDS", "60"))
23
+ MAX_TRAINS = int(os.getenv("MAX_TRAINS", "10"))
24
+ MAX_MINUTES = int(os.getenv("MAX_MINUTES", "30"))
25
+
26
+ # Global service instance
27
+ mta_service: AsyncMTAService | None = None
28
+
29
+
30
+ @asynccontextmanager
31
+ async def lifespan(app: FastAPI):
32
+ """Initialize async MTA service on startup."""
33
+ global mta_service
34
+ mta_service = AsyncMTAService(
35
+ stations_file=STATIONS_FILE,
36
+ cache_seconds=CACHE_SECONDS,
37
+ max_trains=MAX_TRAINS,
38
+ max_minutes=MAX_MINUTES,
39
+ )
40
+ yield
41
+
42
+
43
+ app = FastAPI(
44
+ title="MTA API v3",
45
+ description="Real-time NYC subway arrivals (async)",
46
+ version="3.0.0",
47
+ lifespan=lifespan,
48
+ )
49
+
50
+ app.add_middleware(
51
+ CORSMiddleware,
52
+ allow_origins=["*"],
53
+ allow_credentials=True,
54
+ allow_methods=["*"],
55
+ allow_headers=["*"],
56
+ )
57
+
58
+
59
+ @app.get("/")
60
+ async def root():
61
+ """API info."""
62
+ return {
63
+ "title": "MTA API v3",
64
+ "description": "Real-time NYC subway arrivals (async with concurrent fetching)",
65
+ "docs": "/docs",
66
+ }
67
+
68
+
69
+ @app.get("/api/stations", response_model=StationsResponse)
70
+ async def get_stations(
71
+ query: str | None = Query(None, description="Search by station name"),
72
+ latitude: float | None = Query(None, description="Latitude for location search"),
73
+ longitude: float | None = Query(None, description="Longitude for location search"),
74
+ limit: int = Query(10, ge=1, le=100, description="Max results"),
75
+ ):
76
+ """Get stations by name or location."""
77
+ if mta_service is None:
78
+ raise HTTPException(status_code=503, detail="Service not initialized")
79
+
80
+ if latitude is not None and longitude is not None:
81
+ data = await mta_service.get_by_location(latitude, longitude, limit)
82
+ elif query:
83
+ data = await mta_service.get_stations(query, limit)
84
+ else:
85
+ data = await mta_service.get_stations(limit=limit)
86
+
87
+ return StationsResponse(data=data, updated=mta_service.last_update())
88
+
89
+
90
+ @app.get("/api/stations/{station_id}", response_model=StationsResponse)
91
+ async def get_stations_by_id(station_id: str):
92
+ """Get station(s) by ID (comma-separated)."""
93
+ if mta_service is None:
94
+ raise HTTPException(status_code=503, detail="Service not initialized")
95
+
96
+ ids = [s.strip() for s in station_id.split(",")]
97
+ data = await mta_service.get_by_ids(ids)
98
+
99
+ if not data:
100
+ raise HTTPException(status_code=404, detail="Station(s) not found")
101
+
102
+ return StationsResponse(data=data, updated=mta_service.last_update())
103
+
104
+
105
+ @app.get("/api/routes", response_model=RoutesResponse)
106
+ async def get_routes():
107
+ """Get all active subway routes."""
108
+ if mta_service is None:
109
+ raise HTTPException(status_code=503, detail="Service not initialized")
110
+
111
+ routes = await mta_service.get_routes()
112
+ return RoutesResponse(data=routes, updated=mta_service.last_update())
113
+
114
+
115
+ @app.get("/api/routes/{route}", response_model=StationsResponse)
116
+ async def get_stations_by_route(route: str):
117
+ """Get all stations on a route."""
118
+ if mta_service is None:
119
+ raise HTTPException(status_code=503, detail="Service not initialized")
120
+
121
+ data = await mta_service.get_by_route(route)
122
+
123
+ if not data:
124
+ raise HTTPException(status_code=404, detail=f"Route {route} not found")
125
+
126
+ return StationsResponse(data=data, updated=mta_service.last_update())
@@ -0,0 +1,38 @@
1
+ """Pydantic models for API responses."""
2
+
3
+ from datetime import datetime
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class Train(BaseModel):
8
+ """A train arrival."""
9
+
10
+ route: str
11
+ time: datetime
12
+
13
+
14
+ class Station(BaseModel):
15
+ """A subway station with train arrivals."""
16
+
17
+ id: str
18
+ name: str
19
+ location: tuple[float, float]
20
+ routes: list[str]
21
+ N: list[Train] # Northbound/Uptown trains
22
+ S: list[Train] # Southbound/Downtown trains
23
+ stops: dict[str, tuple[float, float]]
24
+ last_update: datetime | None
25
+
26
+
27
+ class StationsResponse(BaseModel):
28
+ """API response containing stations."""
29
+
30
+ data: list[Station]
31
+ updated: datetime | None
32
+
33
+
34
+ class RoutesResponse(BaseModel):
35
+ """API response containing route list."""
36
+
37
+ data: list[str]
38
+ updated: datetime | None