Skip to content

Commit

Permalink
Feature/api rate control (#6)
Browse files Browse the repository at this point in the history
* Make init method more concise

* Add API rate limiting as specified in irail docs

* Add logging to api calls

* Add a dev container for development

* Add a unit test to test api call

* bum version

* Update pipeline to seperate tests from release

* updated README

* Add etag caching

* Delete .gitlab-ci.yml

---------

Co-authored-by: Jorim Tielemans <tielemans.jorim@gmail.com>
  • Loading branch information
jcoetsie and tjorim authored Jan 1, 2025
1 parent 4ece354 commit 29f5674
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 40 deletions.
14 changes: 14 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "Python Dev Container",
"image": "mcr.microsoft.com/vscode/devcontainers/python:3.9",
"features": {},
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance"
]
}
},
"postCreateCommand": "pip install -r requirements.txt"
}
21 changes: 0 additions & 21 deletions .gitlab-ci.yml

This file was deleted.

69 changes: 68 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,70 @@
# pyRail

A Python wrapper for the iRail API
A Python wrapper for the iRail API.

## Overview

pyRail is a Python library that provides a convenient interface for interacting with the iRail API. It supports various endpoints such as stations, liveboard, vehicle, connections, and disturbances. The library also includes features like caching and rate limiting to optimize API usage.

## Installation

To install pyRail, use pip:

```sh
pip install pyrail
```

## Usage
Here is an example of how to use pyRail:

```python
from pyrail.irail import iRail

# Create an instance of the iRail class
api = iRail(format='json', lang='en')

# Make a request to the 'stations' endpoint
response = api.do_request('stations')

# Print the response
print(response)
```

## Features

- Supports multiple endpoints: stations, liveboard, vehicle, connections, disturbances
- Caching and conditional GET requests using ETag
- Rate limiting to handle API rate limits

## Configuration

You can configure the format and language for the API requests:

```python
api = iRail(format='json', lang='en')
```

Supported formats: json, xml, jsonp

Supported languages: nl, fr, en, de

## Logging

You can set the logging level at runtime to get detailed logs:

```python
api.set_logging_level(logging.DEBUG)
```

## Contributing
Contributions are welcome! Please open an issue or submit a pull request.

## Contributors
- @tjorim
- @jcoetsie

## License

This project is licensed under the MIT License. See the LICENSE file for details.


87 changes: 74 additions & 13 deletions pyrail/irail.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import logging
import requests
import time
from threading import Lock

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)

session = requests.Session()

Expand All @@ -16,13 +22,15 @@

class iRail:

def __init__(self, format=None, lang=None):
if format is None:
format = 'json'
def __init__(self, format='json', lang='en'):
self.format = format
if lang is None:
lang = 'en'
self.lang = lang
self.tokens = 3
self.burst_tokens = 5
self.last_request_time = time.time()
self.lock = Lock()
self.etag_cache = {}
logger.info("iRail instance created")

@property
def format(self):
Expand All @@ -46,28 +54,81 @@ def lang(self, value):
else:
self.__lang = 'en'

def _refill_tokens(self):
logger.debug("Refilling tokens")
current_time = time.time()
elapsed = current_time - self.last_request_time
self.last_request_time = current_time

# Refill tokens based on elapsed time
self.tokens += elapsed * 3 # 3 tokens per second
if self.tokens > 3:
self.tokens = 3

# Refill burst tokens
self.burst_tokens += elapsed * 3 # 3 burst tokens per second
if self.burst_tokens > 5:
self.burst_tokens = 5

def do_request(self, method, args=None):
logger.info(f"Starting request to endpoint: {method}")
with self.lock:
self._refill_tokens()

if self.tokens < 1:
if self.burst_tokens >= 1:
self.burst_tokens -= 1
else:
logger.warning("Rate limiting, waiting for tokens")
time.sleep(1 - (time.time() - self.last_request_time))
self._refill_tokens()
self.tokens -= 1
else:
self.tokens -= 1

if method in methods:
url = base_url.format(method)
params = {'format': self.format, 'lang': self.lang}
if args:
params.update(args)
headers = {}

# Add If-None-Match header if we have a cached ETag
if method in self.etag_cache:
logger.debug(f"Adding If-None-Match header with value: {self.etag_cache[method]}")
headers['If-None-Match'] = self.etag_cache[method]

try:
response = session.get(url, params=params, headers=headers)
try:
json_data = response.json()
return json_data
except ValueError:
return -1
if response.status_code == 429:
logger.warning("Rate limited, waiting for retry-after header")
retry_after = int(response.headers.get("Retry-After", 1))
time.sleep(retry_after)
return self.do_request(method, args)
if response.status_code == 200:
# Cache the ETag from the response
if 'Etag' in response.headers:
self.etag_cache[method] = response.headers['Etag']
try:
json_data = response.json()
return json_data
except ValueError:
return -1
elif response.status_code == 304:
logger.info("Data not modified, using cached data")
return None
else:
logger.error(f"Request failed with status code: {response.status_code}")
return None
except requests.exceptions.RequestException as e:
print(e)
logger.error(f"Request failed: {e}")
try:
session.get('https://1.1.1.1/', timeout=1)
except requests.exceptions.ConnectionError:
print("Your internet connection doesn't seem to be working.")
logger.error("Internet connection failed")
return -1
else:
print("The iRail API doesn't seem to be working.")
logger.error("iRail API failed")
return -1

def get_stations(self):
Expand Down
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from pyrail.irail import irail
29 changes: 24 additions & 5 deletions tests/test_irail.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
from pyrail import iRail
import unittest
from unittest.mock import patch, MagicMock
from pyrail.irail import iRail

def test_irail_station():
class TestiRailAPI(unittest.TestCase):

irail_instance = iRail()
response = irail_instance.get_stations()
print(response)
@patch('requests.Session.get')
def test_successful_request(self, mock_get):
# Mock the response to simulate a successful request
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {'data': 'some_data'}
mock_get.return_value = mock_response

irail_instance = iRail()

# Call the method that triggers the API request
response = irail_instance.do_request('stations')

# Check that the request was successful
self.assertEqual(mock_get.call_count, 1, "Expected one call to the requests.Session.get method")
self.assertEqual(response, {'data': 'some_data'}, "Expected response data to match the mocked response")


if __name__ == '__main__':
unittest.main()

0 comments on commit 29f5674

Please sign in to comment.