├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── build_station_index.py ├── cli_tool_csv_in_Excel.png ├── pytest.ini ├── requirements.txt ├── setup.cfg ├── setup.py ├── stations_mini.csv ├── tests └── test_trainline.py ├── trainline.sh ├── trainline ├── __init__.py └── stations_mini.csv └── trainline_cli.py /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * trainline version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # VSCode 107 | .vscode/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.6 4 | before_script: 5 | - pip install -r requirements.txt 6 | - pip install python-coveralls 7 | - pip install pytest-cov 8 | install: 9 | - pip install . 10 | script: 11 | - pytest 12 | after_success: coveralls 13 | deploy: 14 | provider: pypi 15 | user: thibdct 16 | password: 17 | secure: j6jAWXRAV+iEUF1oBikfr2IZG5m8PraY4vgEY6k2lgK8LPyeIrbNuWLVQ0HSynUbmGH+tU915SdXjwO9/ASSLgwPMSPV2sTioyqHnxMMDDKhaRpNC3LezWrx+JKt3bklCo5tT7m4DlA+vavo3arQZ5JRtAjJSrd/ebcgWgXmp4yBulGuTnejck1ROaQ0OmBgIFN3XgfuaWv7nJ25ynbny1/IwB1xO6WEdgDGBGToc033/Ad/1+OUBH3ld1iiZSS4tGJtJLZTNbR7AtB5pCZXri3HSDERKDAfwG6LWRDhgw02lxkXyfuShGQXV5yQIw8dgT74q5gSeascf6Akrx4xcxm7Wt6TgIVWJQh/V+/FZPq0BSDqzc2RXpFKJe2ATHOkOKwyw6K5bIodhnTb6cVT/3+T6e2qttli/HubsyEYTQxoGTODkowb9bKUXt8fNhjZu03bcVOToAG1V3ffX/oc/77q4wVAu1rX6iag6TE9Kk4IRCkQ6MlKfJKvRl4ZNs+xAcv3EgjxSLd17llTgEcqtATK2oHtpto95zQkvt0cefxL6CuBxPJKi8qdg5nguF/oUpwxGXQGI82aPzGweB6wPs9SQxSe18uNiiAxXhoxfwOa7DMFiixitAaswo+pUdVFJo6fQaooZM4Twew4XIzB+28YU7NGCmO45tCiLz/WDGw= 18 | on: 19 | tags: true 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.5-alpine AS build-env 2 | 3 | # You can build the docker image with the command : 4 | # docker build --no-cache -t trainline . 5 | 6 | # You can create a container with : 7 | # docker run -it --rm trainline --departure="Toulouse" --arrival="Bordeaux" --next=12hours 8 | 9 | RUN pip install -U --no-cache-dir --target /app trainline \ 10 | && find /app | grep -E "(__pycache__|\.pyc|\.pyo$)" | xargs rm -rf 11 | 12 | FROM gcr.io/distroless/python3 13 | 14 | COPY --from=build-env /app /app 15 | 16 | ENV PYTHONPATH=/app 17 | ENV LC_ALL=C.UTF-8 18 | ENV LANG=C.UTF-8 19 | 20 | ENTRYPOINT ["python", "/app/bin/trainline_cli.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018, Thibault Ducret 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. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.py 2 | include *.txt 3 | include MANIFEST.in 4 | recursive-include tests * 5 | recursive-exclude * __pycache__ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Trainline 2 | 3 | [![Travis](https://img.shields.io/travis/tducret/trainline-python.svg)](https://travis-ci.org/tducret/trainline-python) 4 | [![Coveralls github](https://img.shields.io/coveralls/github/tducret/trainline-python.svg)](https://coveralls.io/github/tducret/trainline-python) 5 | [![PyPI](https://img.shields.io/pypi/v/trainline.svg)](https://pypi.org/project/trainline/) 6 | [![Docker Image size](https://img.shields.io/microbadger/image-size/thibdct/trainline.svg)](https://hub.docker.com/r/thibdct/trainline/) 7 | ![License](https://img.shields.io/github/license/tducret/trainline-python.svg) 8 | 9 | ## Description 10 | 11 | Non-official Python wrapper and CLI tool for Trainline 12 | 13 | I wrote a French blog post about it [here](https://www.tducret.com/scraping/2018/09/05/trouvez-le-billet-de-train-le-moins-cher-grace-a-ce-module-python.html) 14 | 15 | 🎁 I added [a tiny Docker image](#docker) to use the tool very easily 16 | 17 | # Requirements 18 | 19 | - Python 3 20 | - pip3 21 | 22 | ## Installation 23 | 24 | ```bash 25 | pip3 install -U trainline 26 | ``` 27 | 28 | ## CLI tool usage 29 | 30 | ```bash 31 | trainline_cli.py --help 32 | ``` 33 | 34 | Examples : 35 | 36 | ```bash 37 | trainline_cli.py --departure="Toulouse" --arrival="Bordeaux" --next=12hours 38 | trainline_cli.py --departure="Paris" --arrival="Marseille" --next=1day 39 | ``` 40 | 41 | or the shorter call : 42 | 43 | ```bash 44 | trainline_cli.py -d Toulouse -a Bordeaux -n 12h 45 | trainline_cli.py -d Paris -a Marseille -n 1d 46 | ``` 47 | 48 | Example output : 49 | 50 | ```bash 51 | departure_date;arrival_date;duration;number_of_segments;price;currency;transportation_mean;bicycle_reservation 52 | 15/10/2018 08:19;15/10/2018 10:26;02h07;1;36,0;EUR;train;30,0 53 | 15/10/2018 08:19;15/10/2018 10:26;02h07;1;37,5;EUR;train;30,0 54 | 15/10/2018 08:19;15/10/2018 10:26;02h07;1;95,5;EUR;train;30,0 55 | [...] 56 | ``` 57 | 58 | You can then open it with your favorite spreadsheet editor (and play with the filters) : 59 | 60 | ![snapshot trainline_cli.py output in Excel](cli_tool_csv_in_Excel.png) 61 | 62 | ## Package usage 63 | 64 | ```python 65 | # -*- coding: utf-8 -*- 66 | import trainline 67 | 68 | results = trainline.search( 69 | departure_station="Toulouse", 70 | arrival_station="Bordeaux", 71 | from_date="15/10/2018 08:00", 72 | to_date="15/10/2018 21:00") 73 | 74 | print(results.csv()) 75 | ``` 76 | 77 | Example output : 78 | 79 | ```bash 80 | departure_date;arrival_date;duration;number_of_segments;price;currency;transportation_mean;bicycle_reservation 81 | 15/10/2018 08:00;15/10/2018 10:55;02h55;1;5,0;EUR;coach;unavailable 82 | 15/10/2018 08:00;15/10/2018 10:50;02h50;1;4,99;EUR;coach;unavailable 83 | 15/10/2018 08:19;15/10/2018 10:26;02h07;1;20,5;EUR;train;10,0 84 | [...] 85 | ``` 86 | 87 | ```python 88 | # -*- coding: utf-8 -*- 89 | import trainline 90 | 91 | Pierre = trainline.Passenger(birthdate="01/01/1980", cards=[trainline.AVANTAGE_FAMILLE]) 92 | Sophie = trainline.Passenger(birthdate="01/02/1981") 93 | Enzo = trainline.Passenger(birthdate="01/03/2012") 94 | Nicolas = trainline.Passenger(birthdate="01/01/1996", cards=[trainline.AVANTAGE_JEUNE]) 95 | Nicolas.add_special_card(trainline.TGVMAX, "YourCardNumber") 96 | 97 | results = trainline.search( 98 | passengers=[Pierre, Sophie, Enzo, Nicolas], 99 | departure_station="Toulouse", 100 | arrival_station="Bordeaux", 101 | from_date="15/10/2018 08:00", 102 | to_date="15/10/2018 21:00", 103 | bicycle_with_or_without_reservation=True) 104 | 105 | print(results.csv()) 106 | ``` 107 | 108 | Example output : 109 | 110 | ```bash 111 | departure_date;arrival_date;duration;number_of_segments;price;currency;transportation_mean;bicycle_reservation 112 | 15/10/2018 08:19;15/10/2018 10:26;02h07;1;36,0;EUR;train;30,0 113 | 15/10/2018 08:19;15/10/2018 10:26;02h07;1;37,5;EUR;train;30,0 114 | 15/10/2018 08:19;15/10/2018 10:26;02h07;1;95,5;EUR;train;30,0 115 | [...] 116 | ``` 117 | 118 | # Docker 119 | 120 | You can use the `trainline` tool with the [Docker image](https://hub.docker.com/r/thibdct/trainline/) 121 | 122 | You may execute : 123 | 124 | `docker run -it --rm thibdct/trainline --departure="Toulouse" --arrival="Bordeaux" --next=12hours` 125 | 126 | > The Docker image is built on top of [Google Distroless image](https://github.com/GoogleContainerTools/distroless), so it is tiny :) 127 | 128 | ## 🤘 The easy way 🤘 129 | 130 | I also built a bash wrapper to execute the Docker container easily. 131 | 132 | Install it with : 133 | 134 | ```bash 135 | curl -s https://raw.githubusercontent.com/tducret/trainline-python/master/trainline.sh \ 136 | > /usr/local/bin/trainline && chmod +x /usr/local/bin/trainline 137 | ``` 138 | *You may replace `/usr/local/bin` with another folder that is in your $PATH* 139 | 140 | Check that it works : 141 | 142 | ```bash 143 | trainline --help 144 | trainline --departure="Toulouse" --arrival="Bordeaux" --next=12hours 145 | ``` 146 | 147 | You can upgrade the app with : 148 | 149 | ```bash 150 | trainline --upgrade 151 | ``` 152 | 153 | and even uninstall with : 154 | 155 | ```bash 156 | trainline --uninstall 157 | ``` 158 | 159 | # TODO 160 | 161 | - [ ] Create a sort function in Folders class (to get the cheapest trips first for example) 162 | - [ ] Add filter for class (first, second), for max_duration 163 | - [X] Implement `get_station_id` 164 | - [X] Implement the use of passengers during search 165 | - [X] Calculate total price with bicycle reservation if search 'with_bicyle' (and export it in csv) 166 | - [X] Calculate total price for all the passengers (and export it in csv) => may need to create a class for Folder 167 | - [X] Create the CLI tool and update README 168 | -------------------------------------------------------------------------------- /build_station_index.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """ Little program to build a mini index station_id:station_name 5 | from the official Trainline stations.csv""" 6 | 7 | import pandas as pd 8 | import io 9 | import requests 10 | 11 | _STATIONS_CSV_FILE = "https://raw.githubusercontent.com/\ 12 | trainline-eu/stations/master/stations.csv" 13 | 14 | csv_content = requests.get(_STATIONS_CSV_FILE).content 15 | df = pd.read_csv(io.StringIO(csv_content.decode('utf-8')), 16 | sep=';', index_col=0, low_memory=False) 17 | df = df[df.is_suggestable == 't'] 18 | df_mini = df.name.str.lower() 19 | df_mini.to_csv("stations_mini.csv", sep=';', encoding='utf-8', header=False) 20 | -------------------------------------------------------------------------------- /cli_tool_csv_in_Excel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tducret/trainline-python/e4f88ff2c82c53367dd24a6798a83cb8d43314e4/cli_tool_csv_in_Excel.png -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --doctest-modules -vs --cov trainline --ignore build_station_index.py -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.6.0 2 | click>=6.7 3 | pytz>=2018.5 -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [aliases] 5 | # Define setup.py command aliases here 6 | test = pytest -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """The setup script.""" 5 | 6 | from setuptools import setup 7 | try: # For pip >= 10 8 | from pip._internal.req import parse_requirements 9 | except ImportError: # For pip <= 9 10 | from pip.req import parse_requirements 11 | 12 | 13 | __version__ = '0.1.2' # Should match with __init.py__ 14 | _PACKAGE_NAME = 'trainline' 15 | _GITHUB_URL = 'https://github.com/tducret/trainline-python' 16 | _KEYWORDS = ['api', 'trainline', 'parsing', 'train', 'sncf', 17 | 'python-wrapper', 'scraping', 'scraper', 'parser'] 18 | _SCRIPTS = ['trainline_cli.py'] 19 | _PACKAGE_DATA = ['stations_mini.csv'] 20 | 21 | install_reqs = parse_requirements('requirements.txt', session='hack') 22 | try: 23 | requirements = [str(ir.req) for ir in install_reqs] 24 | except: 25 | requirements = [str(ir.requirement) for ir in install_reqs] 26 | 27 | setup( 28 | name=_PACKAGE_NAME, 29 | packages=[_PACKAGE_NAME], 30 | package_data={_PACKAGE_NAME: _PACKAGE_DATA, }, 31 | scripts=_SCRIPTS, 32 | version=__version__, 33 | license="MIT license", 34 | platforms='Posix; MacOS X', 35 | description="Non-official Python wrapper and CLI tool for Trainline", 36 | long_description="Non-official Python wrapper and CLI tool for Trainline", 37 | author="Thibault Ducret", 38 | author_email='hello@tducret.com', 39 | url=_GITHUB_URL, 40 | download_url='{github_url}/tarball/{version}'.format( 41 | github_url=_GITHUB_URL, 42 | version=__version__), 43 | keywords=_KEYWORDS, 44 | setup_requires=requirements, 45 | install_requires=requirements, 46 | classifiers=[ 47 | 'License :: OSI Approved :: MIT License', 48 | 'Programming Language :: Python :: 3', 49 | ], 50 | python_requires='>=3', 51 | tests_require=['pytest'], 52 | ) 53 | 54 | # ------------------------------------------ 55 | # To upload a new version on pypi 56 | # ------------------------------------------ 57 | # Make sure everything was pushed (with a git status) 58 | # (or git commit --am "Comment" and git push) 59 | # export VERSION=0.1.2; git tag $VERSION -m "Fix pip install issue #13 + TGV max #17"; git push --tags 60 | 61 | # If you need to delete a tag 62 | # git push --delete origin $VERSION; git tag -d $VERSION 63 | -------------------------------------------------------------------------------- /tests/test_trainline.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for `trainline` package.""" 5 | 6 | # To be tested with : python3 -m pytest -vs tests/test_trainline.py 7 | 8 | import pytest 9 | import trainline 10 | from trainline import Trainline, Trip, Passenger, Segment, ComfortClass, Folder 11 | from datetime import date, timedelta 12 | 13 | 14 | TOULOUSE_STATION_ID = "5311" 15 | BORDEAUX_STATION_ID = "828" 16 | 17 | _DEFAULT_COMFORT_CLASS_DICT = { 18 | "id": "ae9ba138a7c211e88f35afa2c1b6c287", 19 | "name": "pao.default", 20 | "description": "Un siège standard.", 21 | "title": "Normal", 22 | "options": {}, 23 | "segment_id": "ae8b939ca7c211e8967edcf1e2aa0fd7", 24 | "condition_id": "ae9b9fbca7c211e893c6790139ba5461", 25 | } 26 | 27 | _DEFAULT_SEGMENT_DICT = { 28 | "id": "ae8b939ca7c211e8967edcf1e2aa0fd7", 29 | "departure_date": "2018-10-15T08:49:00+02:00", 30 | "departure_station_id": TOULOUSE_STATION_ID, 31 | "arrival_date": "2018-10-15T10:58:00+02:00", 32 | "arrival_station_id": BORDEAUX_STATION_ID, 33 | "transportation_mean": "train", 34 | "carrier": "sncf", 35 | "train_number": "8202", 36 | "travel_class": "first", 37 | "trip_id": "f721ce4ca2cb11e88152d3a9f56d4f85", 38 | "comfort_class_ids": ["ae9ba138a7c211e88f35afa2c1b6c287"], 39 | "comfort_classes": [ComfortClass(mydict=_DEFAULT_COMFORT_CLASS_DICT)] 40 | } 41 | 42 | _DEFAULT_TRIP_DICT = { 43 | "id": "f721ce4ca2cb11e88152d3a9f56d4f85", 44 | "departure_date": "2018-10-15T08:49:00+02:00", 45 | "departure_station_id": TOULOUSE_STATION_ID, 46 | "arrival_date": "2018-10-15T10:58:00+02:00", 47 | "arrival_station_id": BORDEAUX_STATION_ID, 48 | "price": 66.00, 49 | "currency": "EUR", 50 | "segment_ids": ["f721c960a2cb11e89a42408805033f41"], 51 | "segments": [Segment(mydict=_DEFAULT_SEGMENT_DICT)], 52 | } 53 | 54 | _DEFAULT_FOLDER_DICT = { 55 | "id": "f721d0a4a2cb11e880abfc0416222638", 56 | "departure_date": "2018-10-15T08:49:00+02:00", 57 | "departure_station_id": TOULOUSE_STATION_ID, 58 | "arrival_date": "2018-10-15T10:58:00+02:00", 59 | "arrival_station_id": BORDEAUX_STATION_ID, 60 | "price": 66.00, 61 | "currency": "EUR", 62 | "trip_ids": ["f721ce4ca2cb11e88152d3a9f56d4f85"], 63 | "trips": [Trip(mydict=_DEFAULT_TRIP_DICT)], 64 | } 65 | 66 | 67 | # Get the date of tomorrow for search tests, 68 | # otherwise they will become obsolete in the future 69 | tommorow_obj = date.today() + timedelta(days=1) 70 | _TOMORROW = tommorow_obj.strftime("%d/%m/%Y") 71 | 72 | 73 | def test_class_Trainline(): 74 | t = Trainline() 75 | assert t is not None 76 | 77 | 78 | def test_class_ComfortClass(): 79 | cc = ComfortClass(mydict=_DEFAULT_COMFORT_CLASS_DICT) 80 | assert cc.id == "ae9ba138a7c211e88f35afa2c1b6c287" 81 | print() 82 | print(cc) 83 | 84 | 85 | def test_class_Segment(): 86 | seg = Segment(mydict=_DEFAULT_SEGMENT_DICT) 87 | assert seg.id == "ae8b939ca7c211e8967edcf1e2aa0fd7" 88 | print() 89 | print(seg) 90 | 91 | 92 | def test_class_Folder(): 93 | folder = Folder(mydict=_DEFAULT_FOLDER_DICT) 94 | assert folder.id == "f721d0a4a2cb11e880abfc0416222638" 95 | print() 96 | print(folder) 97 | 98 | 99 | def test_class_Trip(): 100 | trip = Trip(mydict=_DEFAULT_TRIP_DICT) 101 | assert trip.id == "f721ce4ca2cb11e88152d3a9f56d4f85" 102 | print() 103 | print(trip) 104 | 105 | 106 | def test_class_Trip_errors(): 107 | with pytest.raises(TypeError): 108 | modified_trip_dict = _DEFAULT_TRIP_DICT.copy() 109 | modified_trip_dict["departure_station_id"] = 1234 # should be a string 110 | Trip(mydict=modified_trip_dict) 111 | 112 | with pytest.raises(TypeError): 113 | modified_trip_dict = _DEFAULT_TRIP_DICT.copy() 114 | modified_trip_dict["price"] = "not_a_float" 115 | Trip(mydict=modified_trip_dict) 116 | 117 | with pytest.raises(TypeError): 118 | modified_trip_dict = _DEFAULT_TRIP_DICT.copy() 119 | modified_trip_dict["departure_date"] = "not_a_date" 120 | Trip(mydict=modified_trip_dict) 121 | 122 | with pytest.raises(TypeError): 123 | modified_trip_dict = _DEFAULT_TRIP_DICT.copy() 124 | modified_trip_dict["id"] = 12345 # string expected 125 | Trip(mydict=modified_trip_dict) 126 | 127 | with pytest.raises(TypeError): 128 | modified_trip_dict = _DEFAULT_TRIP_DICT.copy() 129 | modified_trip_dict.pop("id") # delete a required parameter 130 | Trip(mydict=modified_trip_dict) 131 | 132 | with pytest.raises(ValueError): 133 | modified_trip_dict = _DEFAULT_TRIP_DICT.copy() 134 | modified_trip_dict["price"] = -1.50 # negative price impossible 135 | Trip(mydict=modified_trip_dict) 136 | 137 | 138 | def test_class_Passenger(): 139 | p1 = Passenger(birthdate="01/01/1980") 140 | print() 141 | print(p1) 142 | assert p1.birthdate == "01/01/1980" 143 | assert p1.cards == [] 144 | age_p1 = date.today().year - 2018 + 38 145 | assert p1.age == age_p1 146 | id_p1 = p1.id 147 | assert len(id_p1) == 36 148 | assert p1.get_dict() == { 149 | "id": id_p1, 150 | "age": age_p1, 151 | "cards": [], 152 | "label": id_p1 153 | } 154 | 155 | p2 = Passenger( 156 | birthdate="01/01/2006", 157 | cards=[trainline.AVANTAGE_JEUNE, trainline.AVANTAGE_WEEK_END] 158 | ) 159 | print(p2) 160 | assert p2.birthdate == "01/01/2006" 161 | assert p2.cards == [trainline.AVANTAGE_JEUNE, trainline.AVANTAGE_WEEK_END] 162 | age_p2 = date.today().year - 2018 + 12 163 | assert p2.age == age_p2 164 | id_p2 = p2.id 165 | assert len(id_p2) == 36 166 | assert p2.get_dict() == { 167 | "id": id_p2, 168 | "age": age_p2, 169 | "cards": [{"reference": trainline.AVANTAGE_JEUNE}, 170 | {"reference": trainline.AVANTAGE_WEEK_END}], 171 | "label": id_p2 172 | } 173 | 174 | 175 | def test_class_Passenger_errors(): 176 | with pytest.raises(KeyError): 177 | Passenger(birthdate="01/03/2012", cards=["Unknown"]) 178 | 179 | with pytest.raises(TypeError): 180 | Passenger(birthdate="not_a_date") 181 | 182 | with pytest.raises(TypeError): 183 | Passenger() 184 | 185 | 186 | def test_get_station_id(): 187 | station_id = trainline.get_station_id(station_name="Toulouse Matabiau") 188 | assert station_id == TOULOUSE_STATION_ID 189 | 190 | station_id = trainline.get_station_id(station_name="Bordeaux St-Jean") 191 | assert station_id == BORDEAUX_STATION_ID 192 | 193 | 194 | def test_get_station_id_errors(): 195 | with pytest.raises(KeyError): 196 | trainline.get_station_id(station_name="Unknown station") 197 | 198 | 199 | def test_internal_search(): 200 | t = Trainline() 201 | ret = t.search( 202 | departure_station_id=TOULOUSE_STATION_ID, 203 | arrival_station_id=BORDEAUX_STATION_ID, 204 | departure_date="2018-10-15T10:48:00+00:00", 205 | passenger_list=[Passenger(birthdate="01/01/1980").get_dict()]) 206 | assert ret.status_code == 200 207 | 208 | 209 | def test_basic_search(): 210 | from_date = "{} 18:00".format(_TOMORROW) 211 | to_date = "{} 23:00".format(_TOMORROW) 212 | departure_station = "Toulouse Matabiau" 213 | arrival_station = "Bordeaux St-Jean" 214 | 215 | results = trainline.search( 216 | departure_station=departure_station, 217 | arrival_station=arrival_station, 218 | from_date=from_date, 219 | to_date=to_date) 220 | print() 221 | print("Search trips for {} to {}, between {} and {}".format( 222 | departure_station, arrival_station, from_date, to_date)) 223 | print("{} results".format(len(results))) 224 | assert len(results) > 0 225 | 226 | display_trips(results) 227 | 228 | 229 | def test_search_only_bus(): 230 | from_date = "{} 09:00".format(_TOMORROW) 231 | to_date = "{} 15:00".format(_TOMORROW) 232 | departure_station = "Toulouse Matabiau" 233 | arrival_station = "Bordeaux St-Jean" 234 | 235 | results = trainline.search( 236 | departure_station=departure_station, 237 | arrival_station=arrival_station, 238 | from_date=from_date, 239 | to_date=to_date, 240 | transportation_mean="coach") 241 | print() 242 | print("Search BUS trips for {} to {}, between {} and {}".format( 243 | departure_station, arrival_station, from_date, to_date)) 244 | print("{} results".format(len(results))) 245 | assert len(results) > 0 246 | 247 | display_trips(results) 248 | 249 | for folder in results: 250 | for trip in folder.trips: 251 | for segment in trip.segments: 252 | assert(segment.transportation_mean == "coach") 253 | 254 | 255 | def test_basic_search_with_bicyle(): 256 | from_date = "{} 08:00".format(_TOMORROW) 257 | to_date = "{} 12:00".format(_TOMORROW) 258 | departure_station = "Toulouse Matabiau" 259 | arrival_station = "Narbonne" 260 | 261 | results = trainline.search( 262 | departure_station=departure_station, 263 | arrival_station=arrival_station, 264 | from_date=from_date, 265 | to_date=to_date, 266 | bicycle_with_or_without_reservation=True) 267 | print() 268 | print("Search trips for {} to {}, between {} and {}".format( 269 | departure_station, arrival_station, from_date, to_date)) 270 | print("{} results".format(len(results))) 271 | assert len(results) > 0 272 | 273 | display_trips(results) 274 | 275 | 276 | def test_basic_search_with_bicyle_without_reservation(): 277 | from_date = "{} 07:00".format(_TOMORROW) 278 | to_date = "{} 18:00".format(_TOMORROW) 279 | departure_station = "Capdenac" 280 | arrival_station = "Figeac" 281 | 282 | results = trainline.search( 283 | departure_station=departure_station, 284 | arrival_station=arrival_station, 285 | from_date=from_date, 286 | to_date=to_date, 287 | bicycle_without_reservation_only=True) 288 | print() 289 | print("Search trips for {} to {}, between {} and {}".format( 290 | departure_station, arrival_station, from_date, to_date)) 291 | print("{} results".format(len(results))) 292 | assert len(results) > 0 293 | 294 | display_trips(results) 295 | #print(results.csv()) 296 | 297 | 298 | def test_basic_search_with_bicyle_with_reservation(): 299 | from_date = "{} 10:00".format(_TOMORROW) 300 | to_date = "{} 17:00".format(_TOMORROW) 301 | departure_station = "Toulouse Matabiau" 302 | arrival_station = "Bordeaux" 303 | 304 | results = trainline.search( 305 | departure_station=departure_station, 306 | arrival_station=arrival_station, 307 | from_date=from_date, 308 | to_date=to_date, 309 | bicycle_with_reservation_only=True) 310 | print() 311 | print("Search trips for {} to {}, between {} and {}".format( 312 | departure_station, arrival_station, from_date, to_date)) 313 | print("{} results".format(len(results))) 314 | assert len(results) > 0 315 | 316 | display_trips(results) 317 | 318 | csv_header = results.csv().split("\n")[0] 319 | assert csv_header == "departure_date;arrival_date;duration;\ 320 | number_of_segments;price;currency;transportation_mean;bicycle_reservation" 321 | 322 | # Check that the result trips starts at the proper date (tomorrow) 323 | first_result = results.csv().split("\n")[1] 324 | assert _TOMORROW in first_result.split(";")[0] 325 | 326 | last_result = results.csv().split("\n")[-2] 327 | assert _TOMORROW in last_result.split(";")[0] 328 | 329 | 330 | def display_trips(folder_list): 331 | print(folder_list.csv()) 332 | 333 | 334 | def test_with_benerail(): 335 | # Added this test to check that "benerail.default" comfort class 336 | # is handled properly ("options" field is missing in this case) 337 | # that was causing the issue #1 338 | from_date = "{} 08:00".format(_TOMORROW) 339 | to_date = "{} 10:00".format(_TOMORROW) 340 | departure_station = "Paris" 341 | arrival_station = "Antwerpen-Centraal" 342 | 343 | results = trainline.search( 344 | departure_station=departure_station, 345 | arrival_station=arrival_station, 346 | from_date=from_date, 347 | to_date=to_date) 348 | print() 349 | print("Search trips for {} to {}, between {} and {}".format( 350 | departure_station, arrival_station, from_date, to_date)) 351 | print("{} results".format(len(results))) 352 | assert len(results) > 0 353 | 354 | display_trips(results) 355 | 356 | def test_basic_search_with_card(): 357 | from_date = "{} 10:00".format(_TOMORROW) 358 | to_date = "{} 12:00".format(_TOMORROW) 359 | departure_station = "Toulouse Matabiau" 360 | arrival_station = "Bordeaux St-Jean" 361 | 362 | p1 = Passenger(birthdate="01/01/1980", cards=[trainline.AVANTAGE_FAMILLE]) 363 | 364 | results = trainline.search( 365 | departure_station=departure_station, 366 | arrival_station=arrival_station, 367 | from_date=from_date, 368 | to_date=to_date 369 | ) 370 | print() 371 | print("Search trips for {} to {}, between {} and {}".format( 372 | departure_station, arrival_station, from_date, to_date)) 373 | print("{} results".format(len(results))) 374 | assert len(results) > 0 375 | 376 | display_trips(results) 377 | -------------------------------------------------------------------------------- /trainline.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # A wrapper script for invoking a docker container 4 | # Based on https://spin.atomicobject.com/2015/11/30/command-line-tools-docker/ 5 | 6 | DOCKER_IMAGE="thibdct/trainline" 7 | 8 | error(){ 9 | error_code=$1 10 | echo "ERROR: $2" >&2 11 | exit $1 12 | } 13 | check_cmd_in_path(){ 14 | cmd=$1 15 | which $cmd > /dev/null 2>&1 || error 1 "$cmd not found!" 16 | } 17 | upgrade(){ 18 | docker pull $DOCKER_IMAGE 19 | exit 1 20 | } 21 | uninstall(){ 22 | read -p "Are you sure to uninstall (y/n)? " -n 1 -r 23 | echo 24 | if [[ $REPLY =~ ^[Yy]$ ]] 25 | then 26 | docker rmi $DOCKER_IMAGE 27 | rm $0 28 | fi 29 | exit 1 30 | } 31 | 32 | # Checks for dependencies 33 | check_cmd_in_path docker 34 | 35 | case $1 in 36 | --uninstall) 37 | uninstall 38 | ;; 39 | --upgrade) 40 | upgrade 41 | ;; 42 | esac 43 | 44 | # Run our containerized command 45 | exec docker run -it --rm $DOCKER_IMAGE "$@" -------------------------------------------------------------------------------- /trainline/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Top-level package for Trainline.""" 5 | 6 | import requests 7 | from requests import ConnectionError 8 | import json 9 | from datetime import datetime, timedelta, date 10 | import pytz 11 | import time 12 | import uuid 13 | import os 14 | import copy 15 | import re 16 | 17 | __author__ = """Thibault Ducret""" 18 | __email__ = 'hello@tducret.com' 19 | __version__ = '0.1.2' 20 | 21 | _SEARCH_URL = "https://www.trainline.eu/api/v5_1/search" 22 | _LOGIN_URL = "https://www.trainline.fr/api/v5_1/account/signin" 23 | _DEFAULT_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S%z' 24 | _BIRTHDATE_FORMAT = '%d/%m/%Y' 25 | _READABLE_DATE_FORMAT = "%d/%m/%Y %H:%M" 26 | _DEFAULT_SEARCH_TIMEZONE = 'Europe/Paris' 27 | _MAX_SERVER_RETRY = 3 # If a request is rejected, retry X times 28 | _TIME_AFTER_FAILED_REQUEST = 10 # and wait Y seconds after a rejected request 29 | 30 | ENFANT_PLUS = "SNCF.CarteEnfantPlus" 31 | JEUNE = "SNCF.Carte1225" 32 | WEEK_END = "SNCF.CarteEscapades" 33 | SENIOR = "SNCF.CarteSenior" 34 | AVANTAGE_FAMILLE = "SNCF.AvantageFamille" 35 | AVANTAGE_JEUNE = "SNCF.AvantageJeune" 36 | AVANTAGE_SENIOR = "SNCF.AvantageSenior" 37 | AVANTAGE_WEEK_END = "SNCF.AvantageWeekEnd" 38 | TGVMAX = {"reference": "SNCF.HappyCard", "number": None} 39 | _AVAILABLE_CARDS = [ENFANT_PLUS, JEUNE, WEEK_END, SENIOR, AVANTAGE_FAMILLE, 40 | AVANTAGE_JEUNE, AVANTAGE_WEEK_END] 41 | _SPECIAL_CARDS = [TGVMAX] 42 | 43 | _DEFAULT_PASSENGER_BIRTHDATE = "01/01/1980" 44 | 45 | _SCRIPT_PATH = os.path.dirname(os.path.realpath(__file__)) 46 | _STATIONS_CSV = os.path.join(_SCRIPT_PATH, "stations_mini.csv") 47 | 48 | 49 | class Client(object): 50 | """ Do the requests with the servers """ 51 | 52 | def __init__(self, token=None): 53 | self.session = requests.session() 54 | self.headers = { 55 | 'Accept': 'application/json', 56 | 'User-Agent': 'CaptainTrain/1574360965(web) (Ember 3.5.1)', 57 | 'Accept-Language': 'fr', 58 | 'Content-Type': 'application/json; charset=UTF-8', 59 | 'Host': 'www.trainline.eu', 60 | } 61 | if token: 62 | self.headers['authorization'] = 'Token token="'+token+'"' 63 | 64 | def _get(self, url, expected_status_code=200, headers = None): 65 | if not headers: 66 | ret = self.session.get(url=url, headers=self.headers) 67 | else: 68 | ret = self.session.get(url=url, headers=headers) 69 | if (ret.status_code != expected_status_code): 70 | raise ConnectionError( 71 | 'Status code {status} for url {url}\n{content}'.format( 72 | status=ret.status_code, url=url, content=ret.text)) 73 | return ret 74 | 75 | def _post(self, url, post_data, expected_status_code=200): 76 | trials = 0 77 | while trials <= _MAX_SERVER_RETRY: 78 | trials += 1 79 | ret = self.session.post(url=url, 80 | headers=self.headers, 81 | data=post_data) 82 | if (ret.status_code == expected_status_code): 83 | break 84 | else: 85 | time.sleep(_TIME_AFTER_FAILED_REQUEST) 86 | 87 | if (ret.status_code != expected_status_code): 88 | raise ConnectionError( 89 | 'Status code {status} for url {url}\n{content}'.format( 90 | status=ret.status_code, url=url, content=ret.text)) 91 | return ret 92 | 93 | 94 | class Trainline(object): 95 | """ Class to... """ 96 | 97 | def __init__(self, email_account=None, password_account=None): 98 | if not email_account or not password_account: 99 | self.token_session = None 100 | self.account_passengers = None 101 | self.account_cards = None 102 | 103 | else: 104 | infos_account_session = self._connection(email_account, password_account) 105 | self.token_session = infos_account_session['token'] 106 | self.account_passengers = infos_account_session['passengers'] 107 | self.account_cards = infos_account_session['cards'] 108 | 109 | def search(self, departure_station_id, arrival_station_id, departure_date, 110 | passenger_list): 111 | """ Search on Trainline """ 112 | data = { 113 | "local_currency": "EUR", 114 | "search": { 115 | "arrival_station_id": arrival_station_id, 116 | "departure_date": departure_date, 117 | "departure_station_id": departure_station_id, 118 | "systems": [ 119 | "sncf", 120 | "db", 121 | "idtgv", 122 | "ouigo", 123 | "trenitalia", 124 | "ntv", 125 | "hkx", 126 | "renfe", 127 | "cff", 128 | "benerail", 129 | "ocebo", 130 | "westbahn", 131 | "leoexpress", 132 | "locomore", 133 | "busbud", 134 | "flixbus", 135 | "distribusion", 136 | "cityairporttrain", 137 | "obb", 138 | "timetable" 139 | ] 140 | } 141 | } 142 | 143 | # If we are connected to an account, you can only use passengers that are created in your account 144 | if self.token_session: 145 | card_ids = [] 146 | passenger_ids = [] 147 | for passenger in passenger_list: 148 | for card in passenger['cards']: 149 | card_ids.append(card['id']) 150 | passenger_ids.append(passenger['id']) 151 | data['search']["passenger_ids"] = passenger_ids 152 | data['search']["card_ids"] = card_ids 153 | post_data = json.dumps(data) 154 | c = Client(token=self.token_session) 155 | else: 156 | data['search']["passengers"] = passenger_list 157 | post_data = json.dumps(data) 158 | c = Client() 159 | 160 | ret = c._post(url=_SEARCH_URL, post_data=post_data) 161 | return ret 162 | 163 | def _connection(self, email_account, password_account): 164 | c = Client() 165 | data_login = {"id":"1","email":email_account,"password":password_account, 166 | "facebook_id":None,"facebook_token": None,"google_code": None,"concur_auth_code": None,"concur_new_email": None,"concur_migration_type": None,"source": None,"correlation_key": None,"auth_token": None, "user_id": None} 167 | post_data_login = json.dumps(data_login) 168 | ret_login = c._post(url=_LOGIN_URL, post_data=post_data_login) 169 | dict_ret_login = dict_str_to_dict(ret_login.text) 170 | token = dict_ret_login['meta']['token'] 171 | passengers = dict_ret_login['passengers'] 172 | cards = dict_ret_login['cards'] 173 | return {'token' : token, 'passengers' : passengers, 'cards': cards} 174 | 175 | class Folder(object): 176 | """ Class to represent a folder, composed of the trips of each passenger 177 | ex : Folder Paris-Toulouse : 65€, which contains 2 trips : 178 | - Trip Paris-Toulouse passenger1 : 45€ + 179 | - Trip Paris-Toulouse passenger2 : 20€ 180 | """ 181 | 182 | def __init__(self, mydict): 183 | expected = { 184 | "id": str, 185 | "departure_date": str, 186 | "departure_station_id": str, 187 | "arrival_date": str, 188 | "arrival_station_id": str, 189 | "price": float, 190 | "currency": str, 191 | "trip_ids": list, 192 | "trips": list, 193 | } 194 | 195 | for expected_param, expected_type in expected.items(): 196 | param_value = mydict.get(expected_param) 197 | if type(param_value) is not expected_type: 198 | raise TypeError("Type {} expected for {}, {} received".format( 199 | expected_type, expected_param, type(param_value))) 200 | setattr(self, expected_param, param_value) 201 | 202 | # Remove ':' in the +02:00 offset (=> +0200). It caused problem with 203 | # Python 3.6 version of strptime 204 | self.departure_date = _fix_date_offset_format(self.departure_date) 205 | self.arrival_date = _fix_date_offset_format(self.arrival_date) 206 | 207 | self.departure_date_obj = _str_datetime_to_datetime_obj( 208 | str_datetime=self.departure_date) 209 | self.arrival_date_obj = _str_datetime_to_datetime_obj( 210 | str_datetime=self.arrival_date) 211 | 212 | if len(self.trips) > 0: 213 | trip = self.trips[0] # Choose trips[0] by default because every 214 | # trip of the folder has the same transportation mean and number 215 | # of segments 216 | self.transportation_mean = trip.transportation_mean 217 | self.segment_nb = len(trip.segments) 218 | 219 | if trip.bicycle_price is None: 220 | self.bicycle_reservation = "unavailable" 221 | else: 222 | self.bicycle_reservation = trip.bicycle_price 223 | 224 | if self.price < 0: 225 | raise ValueError("price cannot be < 0, {} received".format( 226 | self.price)) 227 | 228 | def __str__(self): 229 | return repr(self) 230 | 231 | def __repr__(self): 232 | return ("[Folder] {} → {} : {} {} ({} trips) [id : {}]".format( 233 | self.departure_date, self.arrival_date, self.price, self.currency, 234 | len(self.trip_ids), self.id)) 235 | 236 | def _main_characteristics(self): 237 | return ("{} → {} : {} {} ({} trips)".format( 238 | self.departure_date, self.arrival_date, self.price, self.currency, 239 | len(self.trip_ids))) 240 | 241 | # __hash__ and __eq__ methods are defined to allow to remove duplicates 242 | # in the results with list(set(folder_list)) 243 | def __eq__(self, other): 244 | # If 2 folders have the same route and price, we consider that 245 | # they are the same, even if they don't have the same ids 246 | return self._main_characteristics() == other._main_characteristics() 247 | 248 | def __hash__(self): 249 | return hash((self._main_characteristics())) 250 | 251 | 252 | class Trip(object): 253 | """ Class to represent a trip, composed of one or more segments """ 254 | 255 | def __init__(self, mydict): 256 | expected = { 257 | "id": str, 258 | "departure_date": str, 259 | "departure_station_id": str, 260 | "arrival_date": str, 261 | "arrival_station_id": str, 262 | "price": float, 263 | "currency": str, 264 | "segment_ids": list, 265 | "segments": list, 266 | } 267 | 268 | for expected_param, expected_type in expected.items(): 269 | param_value = mydict.get(expected_param) 270 | if type(param_value) is not expected_type: 271 | raise TypeError("Type {} expected for {}, {} received".format( 272 | expected_type, expected_param, type(param_value))) 273 | setattr(self, expected_param, param_value) 274 | 275 | # Remove ':' in the +02:00 offset (=> +0200). It caused problem with 276 | # Python 3.6 version of strptime 277 | self.departure_date = _fix_date_offset_format(self.departure_date) 278 | self.arrival_date = _fix_date_offset_format(self.arrival_date) 279 | 280 | self.departure_date_obj = _str_datetime_to_datetime_obj( 281 | str_datetime=self.departure_date) 282 | self.arrival_date_obj = _str_datetime_to_datetime_obj( 283 | str_datetime=self.arrival_date) 284 | 285 | transportation_mean = [] 286 | for segment in self.segments: 287 | transportation_mean.append(segment.transportation_mean) 288 | transportation_mean = list(set(transportation_mean)) # no duplicates 289 | self.transportation_mean = "+".join(transportation_mean) 290 | 291 | if self.price < 0: 292 | raise ValueError("price cannot be < 0, {} received".format( 293 | self.price)) 294 | 295 | self.bicycle_price = 0 # Default 296 | for segment in self.segments: 297 | if segment.bicycle_price is not None: 298 | self.bicycle_price += segment.bicycle_price 299 | else: 300 | self.bicycle_price = None 301 | # Do not calculate price if at least one segment has no price 302 | break 303 | 304 | def __str__(self): 305 | return repr(self) 306 | 307 | def __repr__(self): 308 | return ("[Trip] {} → {} : {} {} ({} segments) [id : {}]".format( 309 | self.departure_date, self.arrival_date, self.price, self.currency, 310 | len(self.segment_ids), self.id)) 311 | 312 | # __hash__ and __eq__ methods are defined to allow to remove duplicates 313 | # in the results with list(set(trip_list)) 314 | def __eq__(self, other): 315 | return self.id == other.id 316 | 317 | def __hash__(self): 318 | return hash((self.id)) 319 | 320 | 321 | class Folders(object): 322 | """ Class to represent a list of folders """ 323 | 324 | def __init__(self, folder_list): 325 | self.folders = folder_list 326 | 327 | def csv(self): 328 | csv_str = "departure_date;arrival_date;duration;number_of_segments;\ 329 | price;currency;transportation_mean;bicycle_reservation\n" 330 | for folder in self.folders: 331 | trip_duration = folder.arrival_date_obj - folder.departure_date_obj 332 | csv_str += "{dep};{arr};{dur};{seg};{price};{curr};\ 333 | {tr};{bicy}\n".format( 334 | dep=folder.departure_date_obj.strftime(_READABLE_DATE_FORMAT), 335 | arr=folder.arrival_date_obj.strftime(_READABLE_DATE_FORMAT), 336 | dur=_strfdelta(trip_duration, "{hours:02d}h{minutes:02d}"), 337 | seg=folder.segment_nb, 338 | price=str(folder.price).replace(".", ","), # For French Excel 339 | curr=folder.currency, 340 | tr=folder.transportation_mean, 341 | bicy=str(folder.bicycle_reservation).replace(".", ","), 342 | ) 343 | return csv_str 344 | 345 | def __len__(self): 346 | return len(self.folders) 347 | 348 | def __getitem__(self, key): 349 | """ Method to access the object as a list 350 | (ex : trips[1]) """ 351 | return self.folders[key] 352 | 353 | 354 | class Passenger(object): 355 | """ Class to represent a passenger """ 356 | 357 | def __init__(self, birthdate, firstname=None, lastname=None, cards=None, trainline_session=None): 358 | self.birthdate = birthdate 359 | self.firstname = firstname 360 | self.lastname = lastname 361 | self.birthdate_obj = _str_date_to_date_obj( 362 | str_date=self.birthdate, 363 | date_format=_BIRTHDATE_FORMAT) 364 | self.age = self._calculate_age() 365 | 366 | self.id = self._gen_id() 367 | 368 | if not (all(el is None for el in [firstname, lastname]) or all(el is not None for el in [firstname, lastname, trainline_session])): 369 | raise KeyError("Firstname, lastname AND trainline_session are required to enable TGVMax research") 370 | 371 | if firstname: 372 | passengers = trainline_session.account_passengers 373 | #We check every registered passenger of the account to find the passenger corresponding the the firstname, the lastname and the birthdate given 374 | for passenger in passengers: 375 | birthdate_format_request = self.birthdate_obj.strftime('%Y-%m-%dT00:00:00+00:00') 376 | if (passenger['first_name'].lower() == firstname.lower() and passenger['last_name'].lower() == lastname.lower() and passenger['birthdate'] == birthdate_format_request): 377 | self.id = passenger['id'] 378 | own_cards = [] 379 | for card_item in trainline_session.account_cards: 380 | if (card_item['id'] in passenger['card_ids']): 381 | own_cards.append(card_item) 382 | self.cards = own_cards 383 | break 384 | try: self.cards 385 | except NameError: 386 | raise KeyError("No passenger in your business trainline account named {} {}".format(firstname, lastname)) 387 | 388 | else: 389 | cards = cards or [] 390 | for card in cards: 391 | if card not in _AVAILABLE_CARDS: 392 | raise KeyError("Card '{}' unknown, [{}] available".format( 393 | card, ",".join(_AVAILABLE_CARDS))) 394 | self.cards = cards 395 | 396 | def _gen_id(self): 397 | """ Returns a unique passenger id in the proper format 398 | hhhhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh""" 399 | return str(uuid.uuid4()) # uuid4 = make a random UUID 400 | 401 | def _calculate_age(self): 402 | """ Returns the age (in years) from the birthdate """ 403 | born = self.birthdate_obj 404 | today = date.today() 405 | age = today.year - born.year - \ 406 | ((today.month, today.day) < (born.month, born.day)) 407 | return age 408 | 409 | def get_dict(self): 410 | cards_dicts = [] 411 | for card in self.cards: 412 | if type(card) is dict: 413 | cards_dicts.append(card) 414 | continue 415 | cards_dicts.append({"reference": card}) 416 | 417 | passenger_dict = { 418 | "id": self.id, 419 | "age": self.age, 420 | "cards": cards_dicts, 421 | "label": self.id 422 | } 423 | return passenger_dict 424 | 425 | def __str__(self): 426 | return (repr(self)) 427 | 428 | def __repr__(self): 429 | return ("[Passenger] birthdate={}, cards=[{}]".format( 430 | self.birthdate, 431 | ",".join(self.cards))) 432 | 433 | def add_special_card(self, card, number=None): 434 | ##USELESS, since that when we use a business trainline account to be connected with, we take all cards of the passenger, 435 | # and it is not possible to use special_card (TGV_MAX) without account... 436 | raise KeyError("The function add_special_card is no more available, to connect a special card, please create a business " 437 | "trainline account (free), and pass your email address and your password creating your Trainline object. Verify " 438 | "that you have a passenger created in your business trainline account, with expected associated cards and pass " 439 | " his name creating a Passenger.") 440 | if card not in _SPECIAL_CARDS: 441 | raise KeyError("Card '{}' unknown, [{}] available".format( 442 | card, ",".join([d['reference'] for d in _SPECIAL_CARDS]))) 443 | c = copy.deepcopy(card) 444 | c['number'] = number 445 | self.cards.append(c) 446 | 447 | 448 | class Segment(object): 449 | """ Class to represent a segment 450 | (a trip is composed of one or more segment) """ 451 | 452 | def __init__(self, mydict): 453 | expected = { 454 | "id": str, 455 | "departure_date": str, 456 | "departure_station_id": str, 457 | "arrival_date": str, 458 | "arrival_station_id": str, 459 | "transportation_mean": str, 460 | "carrier": str, 461 | "train_number": str, 462 | "travel_class": str, 463 | "trip_id": str, 464 | "comfort_class_ids": list, 465 | "comfort_classes": list, 466 | } 467 | 468 | for expected_param, expected_type in expected.items(): 469 | param_value = mydict.get(expected_param) 470 | if type(param_value) is not expected_type: 471 | raise TypeError("Type {} expected for {}, {} received".format( 472 | expected_type, expected_param, type(param_value))) 473 | setattr(self, expected_param, param_value) 474 | 475 | # Remove ':' in the +02:00 offset (=> +0200). It caused problem with 476 | # Python 3.6 version of strptime 477 | self.departure_date = _fix_date_offset_format(self.departure_date) 478 | self.arrival_date = _fix_date_offset_format(self.arrival_date) 479 | 480 | self.departure_date_obj = _str_datetime_to_datetime_obj( 481 | str_datetime=self.departure_date) 482 | self.arrival_date_obj = _str_datetime_to_datetime_obj( 483 | str_datetime=self.arrival_date) 484 | 485 | self.bicycle_with_reservation = \ 486 | self._check_extra_value("bicycle_with_reservation") 487 | self.bicycle_without_reservation = \ 488 | self._check_extra_value("bicycle_without_reservation") 489 | 490 | self.bicycle_price = None 491 | 492 | for comfort_class in self.comfort_classes: 493 | if comfort_class.bicycle_price is not None: 494 | self.bicycle_price = comfort_class.bicycle_price 495 | 496 | def __str__(self): 497 | return repr(self) 498 | 499 | def __repr__(self): 500 | return ("[Segment] {} → {} : {} ({}) \ 501 | ({} comfort_class) [id : {}]".format( 502 | self.departure_date, self.arrival_date, 503 | self.transportation_mean, self.carrier, 504 | len(self.comfort_class_ids), self.id)) 505 | 506 | # __hash__ and __eq__ methods are defined to allow to remove duplicates 507 | # in the results with list(set(segment_list)) 508 | def __eq__(self, other): 509 | return self.id == other.id 510 | 511 | def __hash__(self): 512 | return hash((self.id)) 513 | 514 | def _check_extra_value(self, value): 515 | """ Returns True if the segment has an extra 516 | with the specified value """ 517 | res = False 518 | for comfort_class in self.comfort_classes: 519 | for extra in comfort_class.extras: 520 | if extra.get("value", "") == value: 521 | res = True 522 | break 523 | return res 524 | 525 | 526 | class ComfortClass(object): 527 | """ Class to represent a comfort_class 528 | (a trip is composed of one or more segment, 529 | each one composed of one or more comfort_class) """ 530 | 531 | def __init__(self, mydict): 532 | expected = { 533 | "id": str, 534 | "name": str, 535 | "description": str, 536 | "title": str, 537 | "segment_id": str, 538 | "condition_id": str, 539 | } 540 | 541 | for expected_param, expected_type in expected.items(): 542 | param_value = mydict.get(expected_param) 543 | if type(param_value) is not expected_type: 544 | raise TypeError("Type {} expected for {}, {} received".format( 545 | expected_type, expected_param, type(param_value))) 546 | setattr(self, expected_param, param_value) 547 | 548 | self.options = mydict.get("options") 549 | if self.options is None: 550 | # No options field with "benerail.default" comfort class 551 | self.options = {} 552 | 553 | self.extras = self.options.get("extras") 554 | if self.extras is None: 555 | self.extras = [] 556 | 557 | self.bicycle_price = None # Default value 558 | for extra in self.extras: 559 | if ((extra.get("value", "") == "bicycle_with_reservation") or 560 | (extra.get("value", "") == "bicycle_without_reservation")): 561 | self.bicycle_price = float(extra.get("cents")) / 100 562 | break 563 | 564 | def __str__(self): 565 | return repr(self) 566 | 567 | def __repr__(self): 568 | return ("[ComfortClass] {} ({}) ({} extras) [id : {}]".format( 569 | self.name, 570 | self.title, 571 | self.description, 572 | len(self.extras), 573 | self.id)) 574 | 575 | # __hash__ and __eq__ methods are defined to allow to remove duplicates 576 | # in the results with list(set(comfort_class_list)) 577 | def __eq__(self, other): 578 | return self.id == other.id 579 | 580 | def __hash__(self): 581 | return hash((self.id)) 582 | 583 | 584 | def _str_datetime_to_datetime_obj(str_datetime, 585 | date_format=_DEFAULT_DATE_FORMAT): 586 | """ Check the expected format of the string date and returns a datetime 587 | object """ 588 | try: 589 | datetime_obj = datetime.strptime(str_datetime, date_format) 590 | except: 591 | raise TypeError("date must match the format {}, received : {}".format( 592 | date_format, str_datetime)) 593 | if datetime_obj.tzinfo is None: 594 | tz = pytz.timezone(_DEFAULT_SEARCH_TIMEZONE) 595 | datetime_obj = tz.localize(datetime_obj) 596 | return datetime_obj 597 | 598 | 599 | def _str_date_to_date_obj(str_date, date_format=_BIRTHDATE_FORMAT): 600 | """ Check the expected format of the string date and returns a datetime 601 | object """ 602 | try: 603 | date_obj = datetime.strptime(str_date, date_format) 604 | except: 605 | raise TypeError("date must match the format {}, received : {}".format( 606 | date_format, str_date)) 607 | return date_obj 608 | 609 | 610 | def _fix_date_offset_format(date_str): 611 | """ Remove ':' in the UTC offset, for example : 612 | >>> print(_fix_date_offset_format("2018-10-15T08:49:00+02:00")) 613 | 2018-10-15T08:49:00+0200 614 | """ 615 | return date_str[:-3] + date_str[-2:] 616 | 617 | 618 | def get_station_id(station_name): 619 | """ Returns the Trainline station id (mandatory for search) based on the 620 | stations csv file content, and the station_name passed in parameter """ 621 | global _STATION_DB 622 | 623 | if '_STATION_DB' not in globals(): 624 | _STATION_DB = _station_to_dict(_STATIONS_CSV) 625 | 626 | station_id = None 627 | for st_id, st_name in _STATION_DB.items(): 628 | if st_name == station_name.lower().strip(): 629 | station_id = st_id 630 | break 631 | 632 | if station_id is None: 633 | raise KeyError("'{}' station has not been found".format(station_name)) 634 | 635 | return station_id 636 | 637 | 638 | def search(departure_station, arrival_station, 639 | from_date, to_date, 640 | passengers=None, 641 | transportation_mean=None, 642 | bicycle_without_reservation_only=None, 643 | bicycle_with_reservation_only=None, 644 | bicycle_with_or_without_reservation=None, 645 | max_price=None, 646 | trainline_session=None): 647 | if not trainline_session: 648 | t = Trainline() 649 | else: 650 | t = trainline_session 651 | 652 | departure_station_id = get_station_id(departure_station) 653 | arrival_station_id = get_station_id(arrival_station) 654 | 655 | from_date_obj = _str_datetime_to_datetime_obj( 656 | str_datetime=from_date, date_format=_READABLE_DATE_FORMAT) 657 | 658 | to_date_obj = _str_datetime_to_datetime_obj( 659 | str_datetime=to_date, date_format=_READABLE_DATE_FORMAT) 660 | 661 | passenger_list = [] 662 | passengers = passengers or [ 663 | Passenger(birthdate=_DEFAULT_PASSENGER_BIRTHDATE)] 664 | 665 | for passenger in passengers: 666 | passenger_list.append(passenger.get_dict()) 667 | 668 | folder_list = [] 669 | 670 | search_date = from_date_obj 671 | 672 | while True: 673 | 674 | last_search_date = search_date 675 | departure_date = search_date.strftime(_DEFAULT_DATE_FORMAT) 676 | 677 | ret = t.search( 678 | departure_station_id=departure_station_id, 679 | arrival_station_id=arrival_station_id, 680 | departure_date=departure_date, 681 | passenger_list=passenger_list) 682 | j = json.loads(ret.text) 683 | folders = _get_folders(search_results_obj=j) 684 | folder_list += folders 685 | 686 | # Check the departure date of the last trip found 687 | # If it is after the 'to_date', we can stop searching 688 | if folders[-1].departure_date_obj > to_date_obj: 689 | break 690 | else: 691 | search_date = folders[-1].departure_date_obj 692 | # If we get a date earlier than the last search date, 693 | # it means that we may be searching during the night, 694 | # so we must increment the search_date till we have a 695 | # trip posterior to 'to_date' 696 | # Probably the next day in this case 697 | if search_date <= last_search_date: 698 | search_date = last_search_date + timedelta(hours=4) 699 | folder_list = list(set(folder_list)) # Remove duplicate trips in the list 700 | 701 | # Filter the list 702 | bicycle_w_or_wout_reservation = bicycle_with_or_without_reservation 703 | _filter_folders_list = _filter_folders( 704 | folder_list=folder_list, 705 | from_date_obj=from_date_obj, 706 | to_date_obj=to_date_obj, 707 | transportation_mean=transportation_mean, 708 | bicycle_without_reservation_only=bicycle_without_reservation_only, 709 | bicycle_with_reservation_only=bicycle_with_reservation_only, 710 | bicycle_with_or_without_reservation=bicycle_w_or_wout_reservation, 711 | max_price=max_price) 712 | 713 | # Sort by date 714 | _filter_folders_list = sorted(_filter_folders_list, 715 | key=lambda folder: folder.departure_date_obj) 716 | 717 | folder_list_obj = Folders(_filter_folders_list) 718 | return folder_list_obj 719 | 720 | 721 | def _convert_date_format(origin_date_str, 722 | origin_date_format, target_date_format): 723 | """ Convert a date string to another format, for example : 724 | >>> print(_convert_date_format(origin_date_str="01/01/2002 08:00",\ 725 | origin_date_format="%d/%m/%Y %H:%M", target_date_format="%Y-%m-%dT%H:%M:%S%z")) 726 | 2002-01-01T08:00:00+0100 727 | """ 728 | date_obj = _str_datetime_to_datetime_obj(str_datetime=origin_date_str, 729 | date_format=origin_date_format) 730 | return date_obj.strftime(target_date_format) 731 | 732 | 733 | def _get_folders(search_results_obj): 734 | """ Get folders from the json object of search results """ 735 | trip_obj_list = _get_trips(search_results_obj) 736 | folders = search_results_obj.get("folders") 737 | folder_obj_list = [] 738 | for folder in folders: 739 | dict_folder = { 740 | "id": folder.get("id"), 741 | "departure_date": folder.get("departure_date"), 742 | "departure_station_id": folder.get("departure_station_id"), 743 | "arrival_date": folder.get("arrival_date"), 744 | "arrival_station_id": folder.get("arrival_station_id"), 745 | "price": float(folder.get("cents")) / 100, 746 | "currency": folder.get("currency"), 747 | "trip_ids": folder.get("trip_ids"), 748 | } 749 | trips = [] 750 | for trip_id in dict_folder["trip_ids"]: 751 | trip_found = _get_trip_from_id( 752 | trip_obj_list=trip_obj_list, 753 | trip_id=trip_id) 754 | if trip_found: 755 | trips.append(trip_found) 756 | else: 757 | # Remove the id if the object is invalid or not found 758 | dict_folder["trip_ids"].remove(trip_id) 759 | dict_folder["trips"] = trips 760 | 761 | folder_obj = Folder(dict_folder) 762 | folder_obj_list.append(folder_obj) 763 | return folder_obj_list 764 | 765 | 766 | def _get_trips(search_results_obj): 767 | """ Get trips from the json object of search results """ 768 | segment_obj_list = _get_segments(search_results_obj) 769 | trips = search_results_obj.get("trips") 770 | trip_obj_list = [] 771 | for trip in trips: 772 | dict_trip = { 773 | "id": trip.get("id"), 774 | "departure_date": trip.get("departure_date"), 775 | "departure_station_id": trip.get("departure_station_id"), 776 | "arrival_date": trip.get("arrival_date"), 777 | "arrival_station_id": trip.get("arrival_station_id"), 778 | "price": float(trip.get("cents")) / 100, 779 | "currency": trip.get("currency"), 780 | "segment_ids": trip.get("segment_ids"), 781 | } 782 | segments = [] 783 | for segment_id in dict_trip["segment_ids"]: 784 | segment_found = _get_segment_from_id( 785 | segment_obj_list=segment_obj_list, 786 | segment_id=segment_id) 787 | if segment_found: 788 | segments.append(segment_found) 789 | else: 790 | # Remove the id if the object is invalid or not found 791 | dict_trip["segment_ids"].remove(segment_id) 792 | dict_trip["segments"] = segments 793 | 794 | trip_obj = Trip(dict_trip) 795 | trip_obj_list.append(trip_obj) 796 | return trip_obj_list 797 | 798 | 799 | def _get_trip_from_id(trip_obj_list, trip_id): 800 | """ Get a trip from a list, based on a trip id """ 801 | found_trip_obj = None 802 | for trip_obj in trip_obj_list: 803 | if trip_obj.id == trip_id: 804 | found_trip_obj = trip_obj 805 | break 806 | return found_trip_obj 807 | 808 | 809 | def _get_segments(search_results_obj): 810 | """ Get segments from the json object of search results """ 811 | comfort_class_obj_list = _get_comfort_classes(search_results_obj) 812 | segments = search_results_obj.get("segments") 813 | segment_obj_list = [] 814 | for segment in segments: 815 | comfort_class_ids = segment.get("comfort_class_ids") 816 | if comfort_class_ids is None: 817 | comfort_class_ids = [] 818 | dict_segment = { 819 | "id": segment.get("id"), 820 | "departure_date": segment.get("departure_date"), 821 | "departure_station_id": segment.get("departure_station_id"), 822 | "arrival_date": segment.get("arrival_date"), 823 | "arrival_station_id": segment.get("arrival_station_id"), 824 | "transportation_mean": segment.get("transportation_mean"), 825 | "carrier": segment.get("carrier"), 826 | "train_number": segment.get("train_number"), 827 | "travel_class": segment.get("travel_class"), 828 | "trip_id": segment.get("trip_id"), 829 | "comfort_class_ids": comfort_class_ids, 830 | } 831 | comfort_classes = [] 832 | for comfort_class_id in dict_segment["comfort_class_ids"]: 833 | comfort_class_found = _get_comfort_class_from_id( 834 | comfort_class_obj_list=comfort_class_obj_list, 835 | comfort_class_id=comfort_class_id) 836 | if comfort_class_found: 837 | comfort_classes.append(comfort_class_found) 838 | else: 839 | # Remove the id if the object is invalid or not found 840 | dict_segment["comfort_class_ids"].remove(comfort_class_id) 841 | dict_segment["comfort_classes"] = comfort_classes 842 | try: 843 | segment_obj = Segment(dict_segment) 844 | segment_obj_list.append(segment_obj) 845 | except TypeError: 846 | pass 847 | # Do not add a segment if it is not contain all the required fields 848 | return segment_obj_list 849 | 850 | 851 | def _get_segment_from_id(segment_obj_list, segment_id): 852 | """ Get a segment from a list, based on a segment id """ 853 | found_segment_obj = None 854 | for segment_obj in segment_obj_list: 855 | if segment_obj.id == segment_id: 856 | found_segment_obj = segment_obj 857 | break 858 | return found_segment_obj 859 | 860 | 861 | def _get_comfort_classes(search_results_obj): 862 | """ Get comfort classes from the json object of search results """ 863 | comfort_classes = search_results_obj.get("comfort_classes") 864 | if comfort_classes is None: 865 | comfort_classes = [] 866 | comfort_class_obj_list = [] 867 | for comfort_class in comfort_classes: 868 | description = comfort_class.get("description") 869 | if description is None: 870 | description = "" 871 | title = comfort_class.get("title") 872 | if title is None: 873 | title = "" 874 | dict_comfort_class = { 875 | "id": comfort_class.get("id"), 876 | "name": comfort_class.get("name"), 877 | "description": description, 878 | "title": title, 879 | "options": comfort_class.get("options"), 880 | "segment_id": comfort_class.get("segment_id"), 881 | "condition_id": comfort_class.get("condition_id"), 882 | } 883 | comfort_class_obj = ComfortClass(dict_comfort_class) 884 | comfort_class_obj_list.append(comfort_class_obj) 885 | return comfort_class_obj_list 886 | 887 | 888 | def _get_comfort_class_from_id(comfort_class_obj_list, comfort_class_id): 889 | """ Get a comfort_class from a list, based on a comfort_class id """ 890 | found_comfort_class_obj = None 891 | for comfort_class_obj in comfort_class_obj_list: 892 | if comfort_class_obj.id == comfort_class_id: 893 | found_comfort_class_obj = comfort_class_obj 894 | break 895 | return found_comfort_class_obj 896 | 897 | 898 | def _filter_folders(folder_list, from_date_obj=None, to_date_obj=None, 899 | min_price=0.0, max_price=None, transportation_mean=None, 900 | min_segment_nb=1, max_segment_nb=None, 901 | bicycle_without_reservation_only=None, 902 | bicycle_with_reservation_only=None, 903 | bicycle_with_or_without_reservation=None): 904 | """ Filter a list of folders, based on different attributes, such as 905 | from_date or min_price. Returns the filtered list """ 906 | filtered_folder_list = [] 907 | for folder in folder_list: 908 | to_be_filtered = False 909 | 910 | # Price 911 | if folder.price < min_price: 912 | to_be_filtered = True 913 | if max_price is not None: 914 | if folder.price > max_price: 915 | to_be_filtered = True 916 | 917 | # Date 918 | if from_date_obj: 919 | if folder.departure_date_obj < from_date_obj: 920 | to_be_filtered = True 921 | if to_date_obj: 922 | if folder.departure_date_obj > to_date_obj: 923 | to_be_filtered = True 924 | 925 | for trip in folder.trips: # Check every trip 926 | 927 | # Transportation mean 928 | if transportation_mean: 929 | for segment in trip.segments: 930 | if segment.transportation_mean != transportation_mean: 931 | to_be_filtered = True 932 | break 933 | 934 | # Number of segments 935 | if min_segment_nb: 936 | if len(trip.segments) < min_segment_nb: 937 | to_be_filtered = True 938 | if max_segment_nb: 939 | if len(trip.segments) > max_segment_nb: 940 | to_be_filtered = True 941 | 942 | # Bicycle 943 | # All segments of the trip must respect the bicycle conditions 944 | if bicycle_with_reservation_only: 945 | for segment in trip.segments: 946 | if segment.bicycle_with_reservation != \ 947 | bicycle_with_reservation_only: 948 | to_be_filtered = True 949 | break 950 | 951 | if bicycle_without_reservation_only: 952 | for segment in trip.segments: 953 | if segment.bicycle_without_reservation != \ 954 | bicycle_without_reservation_only: 955 | to_be_filtered = True 956 | break 957 | 958 | if bicycle_with_or_without_reservation: 959 | for segment in trip.segments: 960 | condition = (segment.bicycle_with_reservation or 961 | segment.bicycle_without_reservation) 962 | if condition != bicycle_with_or_without_reservation: 963 | to_be_filtered = True 964 | break 965 | 966 | # Add to list if it has not been filtered 967 | if not to_be_filtered: 968 | filtered_folder_list.append(folder) 969 | return filtered_folder_list 970 | 971 | 972 | def _strfdelta(tdelta, fmt): 973 | """ Format a timedelta object """ 974 | # Thanks to https://stackoverflow.com/questions/8906926 975 | d = {"days": tdelta.days} 976 | d["hours"], rem = divmod(tdelta.seconds, 3600) 977 | d["minutes"], d["seconds"] = divmod(rem, 60) 978 | return fmt.format(**d) 979 | 980 | 981 | def _read_file(filename): 982 | """ Returns the file content as as string """ 983 | with open(filename, 'r', encoding='utf8') as f: 984 | read_data = f.read() 985 | return read_data 986 | 987 | def dict_str_to_dict(dict_str): 988 | """ Returns the dictionnary string from result of a request (with null, false, true) as a dictionnary object """ 989 | dict_str_inter = re.sub('null', 'None', dict_str) 990 | dict_str_inter = re.sub('false', 'False', dict_str_inter) 991 | dict_str_inter = re.sub('true', 'True', dict_str_inter) 992 | dictionnary = eval(dict_str_inter) 993 | return dictionnary 994 | 995 | def _station_to_dict(filename, csv_delimiter=';'): 996 | """ Returns the stations csv database as a dict : """ 997 | csv_content = _read_file(filename) 998 | station_dict = {} 999 | for line in csv_content.split("\n"): 1000 | station_id = line.split(csv_delimiter)[0] 1001 | station_name = csv_delimiter.join(line.split(csv_delimiter)[1:]) 1002 | station_dict[station_id] = station_name 1003 | return station_dict 1004 | -------------------------------------------------------------------------------- /trainline_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """CLI tool for trainline.""" 5 | import click 6 | import trainline 7 | from datetime import datetime, timedelta 8 | 9 | # Usage : trainline_cli.py --help 10 | 11 | 12 | @click.command() 13 | @click.option( 14 | '--departure', '-d', 15 | envvar="PARAM1", 16 | type=str, 17 | help='departure station (example : Toulouse)', 18 | required=True, 19 | ) 20 | @click.option( 21 | '--arrival', '-a', 22 | type=str, 23 | help='arrival station (example : Bordeaux)', 24 | required=True, 25 | ) 26 | @click.option( 27 | '--next', '-n', 28 | type=str, 29 | help='period of search from now \ 30 | (example : 1day, 2days, 3d, 1hour, 2hours, 3h)', 31 | default='3hours', 32 | show_default=True, 33 | ) 34 | @click.option( 35 | '--transport', '-t', 36 | type=click.Choice(['train', 'coach', 'any']), 37 | help='get only results for the selected transportation mean', 38 | default='train', 39 | show_default=True, 40 | ) 41 | @click.option( 42 | '--verbose', '-v', 43 | is_flag=True, 44 | help='verbose mode', 45 | ) 46 | def main(departure, arrival, next, transport, verbose): 47 | """ Search trips with Trainline and returns it in csv """ 48 | 49 | # Get current datetime > from_date 50 | from_date_obj = datetime.now() 51 | 52 | # Decode duration (ex : 1day => timedelta(days=1)) 53 | delta = _decode_next_param(next) 54 | 55 | # Calculate the end date > to_date 56 | to_date_obj = from_date_obj + delta 57 | 58 | # Convert the datetime objects to strings 59 | from_date = from_date_obj.strftime("%d/%m/%Y %H:%M") 60 | to_date = to_date_obj.strftime("%d/%m/%Y %H:%M") 61 | 62 | if transport == "any": 63 | transport = None 64 | 65 | if verbose: 66 | print() 67 | print("Search trips from {} to {}, between {} and {}\n".format( 68 | departure, arrival, from_date, to_date)) 69 | 70 | results = trainline.search( 71 | departure_station=departure, 72 | arrival_station=arrival, 73 | from_date=from_date, 74 | to_date=to_date, 75 | transportation_mean=transport) 76 | 77 | print(results.csv()) 78 | 79 | if verbose: 80 | print() 81 | print("{} results".format(len(results))) 82 | 83 | 84 | def _decode_next_param(next_param): 85 | """ From a 'next' string, returns a timedelta object 86 | >>> print(_decode_next_param("1day")) 87 | 1 day, 0:00:00 88 | >>> print(_decode_next_param("2d")) 89 | 2 days, 0:00:00 90 | >>> print(_decode_next_param("3hours")) 91 | 3:00:00 92 | >>> print(_decode_next_param("4h")) 93 | 4:00:00 94 | """ 95 | if "d" in next_param: 96 | delta = timedelta(days=int(next_param.split("d")[0])) 97 | elif "h" in next_param: 98 | delta = timedelta(hours=int(next_param.split("h")[0])) 99 | else: 100 | delta = timedelta(hours=3) 101 | return delta 102 | 103 | if __name__ == "__main__": 104 | main() 105 | --------------------------------------------------------------------------------