Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 78 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,62 +33,102 @@ On Mac/Linux:
export MARTA_API_KEY=<your_api_key_here>
```

Optionally, you can also set the cache timeout (default 30 seconds):
Create a client and use `get_buses()` or `get_trains()` for the existing object
interface. `get_buses()` now reads MARTA's GTFS-Realtime vehicle feed and
adapts each vehicle position into a `Bus` object.

Windows:
> **Deprecation warning:** MARTA removed its legacy bus JSON endpoints. The
> `get_buses()` arguments `time_point`, `direction`, and `api_key`, plus the
> `Bus` attributes `adherence`, `block_id`, `block_abbr`, `direction`, and
> `timepoint`, are retained only for compatibility. GTFS-Realtime does not
> provide those values, so the attributes are `None` and the unsupported
> filters raise `ValueError`. New integrations should use
> `get_bus_vehicle_positions()` for the source protobuf feed.

```
set MARTA_CACHE_EXPIRE=15
```

Mac/Linux:

```
export MARTA_CACHE_EXPIRE=15
```
from marta import MARTA

There are two primary API wrapper functions, `get_buses()` and `get_trains()`. Each method takes keyword arguments to filter results.

```
from marta.api import get_buses, get_trains
marta = MARTA()

# Get all buses
buses = get_buses()
buses = marta.get_buses()

# Get buses by route
buses = get_buses(route=1)
buses = marta.get_buses(route=1)

# Get buses by route and stop_id
buses = get_buses(route=1, stop_id=900800)
buses = marta.get_buses(route=1, stop_id=900800)

# Get buses by route and vehicle_id
buses = get_buses(route=1, bus_id=1405)

# Get buses by route and timepoint
buses = get_buses(route=1, timepoint="West End Station")
buses = marta.get_buses(route=1, vehicle_id=1405)

# Get buses by route, stop_id and vehicle_id
buses = get_buses(route=1, stop_id=900800, bus_id=1405)
buses = marta.get_buses(route=1, stop_id=900800, vehicle_id=1405)

# Get all trains
trains = get_trains()
trains = marta.get_trains()

# Get trains by line
trains = get_trains(line='red')
trains = marta.get_trains(line='red')

# Get trains by station
trains = get_trains(station='Midtown Station')
trains = marta.get_trains(station='Midtown Station')

# Get trains by destination
trains = get_trains(destination='Doraville')
trains = marta.get_trains(destination='Doraville')

# Get trains by line, station, and destination
trains = get_trains(line='blue', station='Five Points Station', destination='Indian Creek')
trains = marta.get_trains(line='blue', station='Five Points Station', destination='Indian Creek')

# Get trains by line, station, and direction
trains = get_trains(line='gold', station='Five Points Station', direction='north')
trains = marta.get_trains(line='gold', station='Five Points Station', direction='north')
```

## Package layout

```text
marta/
api.py # Client composing the services
services/
bus_service.py # Bus queries
train_service.py # Train queries
entities/
vehicle.py # Shared vehicle base
bus.py # Bus data object
train.py # Train data object
```

Import services from `marta.services` and data objects from `marta.entities`.
Existing imports from `marta`, `marta.vehicles`, `marta.bus_service`, and
`marta.train_service` remain supported through re-exports.

## Services

`MARTA` composes a `MartaBusService` at `marta.buses` and a `MartaTrainService`
at `marta.trains`. The existing top-level query methods delegate to these services
and keep their existing arguments and return types.

```python
from marta import MARTA, MartaBusService, MartaTrainService

marta = MARTA()
buses = marta.buses.get_buses(route=1)
positions = marta.buses.get_vehicle_positions()
trip_updates = marta.buses.get_trip_updates()
trains = marta.trains.get_trains(line='blue')

# Services can also be used independently.
buses = MartaBusService().get_buses(route=1)
trains = MartaTrainService(api_key='your-key').get_trains(station='Midtown Station')
```

Bus queries do not require an API key. The train service reads `MARTA_API_KEY`
when constructed unless a key is supplied, and accepts an `api_key` override on
individual calls. Both services share the library's existing in-memory cache,
request timeouts, and response validation. Constructing a service makes no requests.
Bus trip updates remain raw protobuf; this refactor does not add Python bus arrival
or departure projections.

## Results

The library includes very basic objects to represent Trains and Buses. Below are dictionary representations of each.
Expand All @@ -98,17 +138,18 @@ The `get_buses()` and `get_trains()` functions return lists of `Bus` and `Train`
### Bus Objects

```
{'adherence': '-3',
'block_abbr': '3-3',
'block_id': '346',
'direction': 'Westbound',
'last_updated': datetime.datetime(2017, 1, 26, 8, 10, 24),
'latitude': '33.7545535',
'longitude': '-84.4686002',
{'adherence': None,
'block_abbr': None,
'block_id': None,
'direction': None,
'direction_id': 0,
'last_updated': datetime.datetime(2026, 9, 4, 17, 0, tzinfo=datetime.timezone.utc),
'latitude': 33.7545535,
'longitude': -84.4686002,
'route': 3,
'stop_id': '903320',
'timepoint': 'Hamilton E. Holmes Station',
'trip_id': '5408210',
'timepoint': None,
'trip_id': 'trip-1',
'vehicle': '2417'}
```

Expand Down
5 changes: 4 additions & 1 deletion marta/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
from .api import MARTA
from .api import MARTA
from .services import MartaBusService, MartaTrainService

__all__ = ['MARTA', 'MartaBusService', 'MartaTrainService']
155 changes: 155 additions & 0 deletions marta/_shared.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Shared transport, authentication, and filtering for MARTA services."""

import json
from functools import wraps
from os import getenv
from typing import List, Union
from urllib.parse import urlparse
import requests_cache
from google.transit import gtfs_realtime_pb2

from .exceptions import APIKeyError, InvalidDirectionError

_CACHE_EXPIRE = int(getenv('MARTA_CACHE_EXPIRE', 30))
_BASE_URL = 'https://developer.itsmarta.com'
_TRAIN_URL = (
'https://developerservices.itsmarta.com:18096/itsmarta/'
'railrealtimearrivals/developerservices/traindata'
)
_BUS_VEHICLE_POSITIONS_URL = (
'https://gtfs-rt.itsmarta.com/TMGTFSRealTimeWebService/'
'vehicle/vehiclepositions.pb'
)
_BUS_TRIP_UPDATES_URL = (
'https://gtfs-rt.itsmarta.com/TMGTFSRealTimeWebService/'
'tripupdate/tripupdates.pb'
)
_REQUEST_TIMEOUT = (3.05, 10)
_MAX_RESPONSE_BYTES = 5 * 1024 * 1024
_JSON_CONTENT_TYPES = {'application/json', 'text/json'}
_PROTOBUF_CONTENT_TYPES = {
'application/octet-stream',
'application/protocol-buffer',
'application/protobuf',
'application/x-protobuf',
}

# Keep caching private to this library and in memory. Importing marta must not
# patch requests globally or persist request URLs containing credentials.
CACHE = requests_cache.CachedSession(
backend='memory',
expire_after=_CACHE_EXPIRE,
ignored_parameters=['apikey', 'apiKey'],
)

def require_api_key(api_key):
if api_key is None:
raise APIKeyError("API key is required but not provided.")

def get_bus_direction(user_direction) -> str:
direction = user_direction.lower()
if direction.startswith('n'):
return 'Northbound'
elif direction.startswith('s'):
return 'Southbound'
elif direction.startswith('e'):
return 'Eastbound'
elif direction.startswith('w'):
return 'Westbound'
else:
return None

def get_train_direction(user_direction) -> str:
train_direction = user_direction.lower()
if train_direction.startswith('n'):
return 'N'
elif train_direction.startswith('s'):
return 'S'
elif train_direction.startswith('e'):
return 'E'
elif train_direction.startswith('w'):
return 'W'
else:
return None

def _read_response(response, allowed_content_types) -> bytes:
response.raise_for_status()

content_type = response.headers.get('Content-Type', '').split(';', 1)[0].lower()
if content_type and content_type not in allowed_content_types:
raise ValueError(f'Unexpected MARTA API content type: {content_type}')

content_length = response.headers.get('Content-Length')
if content_length and int(content_length) > _MAX_RESPONSE_BYTES:
raise ValueError('MARTA API response exceeds the maximum allowed size')

chunks = []
received = 0
for chunk in response.iter_content(chunk_size=64 * 1024):
received += len(chunk)
if received > _MAX_RESPONSE_BYTES:
raise ValueError('MARTA API response exceeds the maximum allowed size')
chunks.append(chunk)
return b''.join(chunks)


def _get_json_data(
url: str,
api_key: str,
api_key_parameter: str,
) -> List[dict]:
response = CACHE.get(
url,
params={api_key_parameter: api_key},
timeout=_REQUEST_TIMEOUT,
stream=True,
)
if response.status_code in (401, 403):
raise APIKeyError('Your MARTA API key was rejected.')
content = _read_response(response, _JSON_CONTENT_TYPES)
data = json.loads(content.decode(response.encoding or 'utf-8'))
if not isinstance(data, list) or any(not isinstance(item, dict) for item in data):
raise ValueError('MARTA API response must be a list of objects')
return data


def _get_data(endpoint: str, api_key: str) -> List[dict]:
"""Fetch a legacy MARTA JSON endpoint."""
return _get_json_data(
url=f'{_BASE_URL}{endpoint}',
api_key=api_key,
api_key_parameter='apikey',
)


def _get_gtfs_realtime_feed(url: str) -> gtfs_realtime_pb2.FeedMessage:
response = CACHE.get(
url,
timeout=_REQUEST_TIMEOUT,
stream=True,
allow_redirects=False,
)
if response.is_redirect or response.is_permanent_redirect:
redirect_url = response.headers.get('Location', '')
if urlparse(redirect_url).scheme != 'https':
raise ValueError('MARTA API attempted an insecure redirect')
raise ValueError('MARTA API returned an unexpected redirect')
content = _read_response(response, _PROTOBUF_CONTENT_TYPES)
feed = gtfs_realtime_pb2.FeedMessage()
feed.ParseFromString(content)
return feed

def _filter_response(response: dict, filters: dict) -> List[dict]:
valid_items = []
for item in response:
valid = True
for filter_key, filter_value in filters.items():
if filter_value: # ignore if the filter value doesn't exist
if not item.get(filter_key): # don't penalize if item doesn't have a filter_key
pass
elif str(item[filter_key]).lower() != str(filter_value).lower():
# lower all values to avoid case issues
valid = False
if valid:
valid_items.append(item)
return valid_items
Loading