This commit is contained in:
adii1823 2021-10-28 17:42:09 +05:30
parent 0a0ceaf7d7
commit e00e011bac
41 changed files with 1653 additions and 0 deletions

22
ch15/pip_install.txt Normal file
View File

@ -0,0 +1,22 @@
# pip_install.txt
$ pip install --no-cache -v -v -v requests==2.26.0
...
1 location(s) to search for versions of requests:
* https://pypi.org/simple/requests/
...
Found link https://.../requests-0.2.0.tar.gz..., version: 0.2.0
...
Found link https://.../requests-2.26.0-py2.py3-none-any.whl...,
version: 2.26.0
Found link https://.../requests-2.26.0.tar.gz..., version:
2.26.0
...
Collecting requests==2.26.0
...
Downloading requests-2.26.0-py2.py3-none-any.whl (62 kB)
...
Installing collected packages: urllib3, idna, charset-normalizer,
certifi, requests
...
Successfully installed certifi-2021.5.30 charset-normalizer-2.0.4
idna-3.2 requests-2.26.0 urllib3-1.26.6

10
ch15/pypirc Normal file
View File

@ -0,0 +1,10 @@
# This file contains an example of the format of your ~/.pypirc
# file
[testpypi]
username = __token__
password = pypi-...
[pypi]
username = __token__
password = pypi-...

View File

@ -0,0 +1,2 @@
build
twine

View File

@ -0,0 +1,78 @@
#
# This file is autogenerated by pip-compile with python 3.9
# To update, run:
#
# pip-compile build.in
#
bleach==4.1.0
# via readme-renderer
build==0.6.0.post1
# via -r build.in
certifi==2021.5.30
# via requests
cffi==1.14.6
# via cryptography
charset-normalizer==2.0.5
# via requests
colorama==0.4.4
# via twine
cryptography==3.4.8
# via secretstorage
docutils==0.17.1
# via readme-renderer
idna==3.2
# via requests
importlib-metadata==4.8.1
# via
# keyring
# twine
jeepney==0.7.1
# via
# keyring
# secretstorage
keyring==23.2.1
# via twine
packaging==21.0
# via
# bleach
# build
pep517==0.11.0
# via build
pkginfo==1.7.1
# via twine
pycparser==2.20
# via cffi
pygments==2.10.0
# via readme-renderer
pyparsing==2.4.7
# via packaging
readme-renderer==29.0
# via twine
requests==2.26.0
# via
# requests-toolbelt
# twine
requests-toolbelt==0.9.1
# via twine
rfc3986==1.5.0
# via twine
secretstorage==3.3.1
# via keyring
six==1.16.0
# via
# bleach
# readme-renderer
tomli==1.2.1
# via
# build
# pep517
tqdm==4.62.2
# via twine
twine==3.4.2
# via -r build.in
urllib3==1.26.6
# via requests
webencodings==0.5.1
# via bleach
zipp==3.5.0
# via importlib-metadata

View File

@ -0,0 +1,3 @@
platformdirs>=2.0
pydantic>=1.8.2,<2.0
requests~=2.0

View File

@ -0,0 +1,22 @@
#
# This file is autogenerated by pip-compile with python 3.9
# To update, run:
#
# pip-compile main.in
#
certifi==2021.5.30
# via requests
charset-normalizer==2.0.5
# via requests
idna==3.2
# via requests
platformdirs==2.3.0
# via -r main.in
pydantic==1.8.2
# via -r main.in
requests==2.26.0
# via -r main.in
typing-extensions==3.10.0.2
# via pydantic
urllib3==1.26.6
# via requests

View File

@ -0,0 +1,3 @@
# Simple skeleton project
This is a skeleton of a project that you can copy and flesh out to create your own project.

View File

@ -0,0 +1,5 @@
"""This is just a placeholder package, replace it with your own
package"""
print("Hello world")

View File

@ -0,0 +1,3 @@
[build-system]
requires = ["setuptools>=51.0.0", "wheel"]
build-backend = "setuptools.build_meta"

View File

@ -0,0 +1,16 @@
[metadata]
name = your-project-name
author = Your Name
author_email = your.email@example.com
version = 0.0.0
description = A short description of your project
long_description = file: README.md
long_description_content_type = text/markdown
url = https://example.com/your/project
[options]
packages = find:
[options.extras_require]
test =
pytest

View File

@ -0,0 +1,3 @@
import setuptools
setuptools.setup()

View File

View File

@ -0,0 +1,5 @@
# Change log
## Version 1.0.0
Initial published version.

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Heinrich Kruger, Fabrizio Romano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,3 @@
# train-project/MANIFEST.in
include CHANGELOG.md
include train_schedule/icon.png

View File

@ -0,0 +1,63 @@
# Train Schedule
A train schedule application to demonstrate packaging and distribution of a Python project for
Chapter 15 of "Learn Python Programming, 3d Edition", by Fabrizio Romano and Heinrich Kruger. At the
same time, it acts as an example of an application built around the trains API project from Chapter
14 of the same book.
## Usage
The application provides both command-line and graphical interfaces.
### Graphical interface
To launch the train schedule GUI, run:
$ train-schedule
This will allow you to select a station from a drop-down list and show you listings of trains
arriving at and departing from the selected station.
The Train API URL can be configured via the `Edit > Preferences` menu option.
### Command-line interface
The command-line interface can be invoked via:
$ train-schedule-cli
If no command-line arguments are provided, a brief usage message will be printed. More detailed help
can be obtained by passing `-h` or `--help` as a command-line argument.
#### Configuration
The command-line and graphical interfaces share the same configuration file. To view the current
configuration, you can run:
$ train-schedule-cli config
To update the Train API URL, use:
$ train-schedule-cli config --api-url URL
Where `URL` is the new URL to use.
#### Listing stations
To get a list of available stations, run
$ train-schedule-cli stations
#### Listing arrivals
To see a list of trains arriving at a station, run:
$ train-schedule-cli --arrivals station-id
Where `station-id` is the numeric ID of the station.
#### Listing departures
To see departures from a station you can use:
$ train-schedule-cli --departures station-id

View File

@ -0,0 +1,11 @@
# train-project/pyproject.toml
[build-system]
requires = ["setuptools>=51.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.black]
line-length = 66
[tool.isort]
profile = 'black'
line_length = 66

View File

@ -0,0 +1,50 @@
# train-project/setup.cfg
[metadata]
name = train-schedule
author = Heinrich Kruger, Fabrizio Romano
author_email = heinrich@example.com, fabrizio@example.com
version = 1.0.0
description = A train app to demonstrate Python packaging
long_description = file: README.md, CHANGELOG.md
long_description_content_type = text/markdown
url = https://github.com/PacktPublishing/Learn-Python-Programming-3E
project_urls =
Learn Python Programming Book = https://www.packtpub.com/product/learn-python-programming-third-edition/9781801815093
license = MIT License
license_files = LICENSE
classifiers =
Intended Audience :: End Users/Desktop
License :: OSI Approved :: MIT License
Operating System :: MacOS
Operating System :: Microsoft :: Windows
Operating System :: POSIX :: Linux
Programming Language :: Python :: 3
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
keywords = trains, packaging example
[options]
packages = find:
include_package_data = True
install_requires =
platformdirs>=2.0
pydantic>=1.8.2,<2.0
requests~=2.0
python_requires = >=3.8
[options.extras_require]
dev =
black
flake8
isort
pdbpp
[options.entry_points]
console_scripts =
train-schedule-cli = train_schedule.cli:main
gui_scripts =
train-schedule = train_schedule.gui:main
[flake8]
max-line-length = 66

View File

@ -0,0 +1,4 @@
# train-project/setup.py
import setuptools
setuptools.setup()

View File

@ -0,0 +1,23 @@
# train-project/train_schedule/__init__.py
from .metadata import get_metadata
_metadata = get_metadata()
# Extract some useful attributes from the package metadata
APP_NAME = _metadata["Name"]
APP_TITLE = APP_NAME.title()
VERSION = _metadata["Version"]
AUTHOR = _metadata["Author"]
DESCRIPTION = _metadata["Summary"]
LICENSE = _metadata["License"]
# Define text to be displayed in the GUI about dialog or via the
# CLI --about option
ABOUT_TEXT = f"""{APP_TITLE}
{DESCRIPTION}
Version: {VERSION}
Authors: {AUTHOR}
License: {LICENSE}
Copyright: © 2021 {AUTHOR}"""

View File

@ -0,0 +1,34 @@
# train-project/train_schedule/__main__.py
"""Enable using `python -m train_schedule` to launch the app.
If any command-line arguments are provided, we launch the command
line interface. If no command-line arguments are present, launch
the GUI instead.
"""
import sys
from .cli import main as cli_main
from .gui import main as gui_main
if __name__ == "__main__":
if len(sys.argv) > 1:
cli_main()
else:
gui_main()
"""
$ python -m train_schedule stations
0: Rome, Italy (ROM)
1: Paris, France (PAR)
2: London, UK (LDN)
3: Kyiv, Ukraine (KYV)
4: Stockholm, Sweden (STK)
5: Warsaw, Poland (WSW)
6: Moskow, Russia (MSK)
7: Amsterdam, Netherlands (AMD)
8: Edinburgh, Scotland (EDB)
9: Budapest, Hungary (BDP)
10: Bucharest, Romania (BCR)
11: Sofia, Bulgaria (SFA)
"""

View File

@ -0,0 +1,84 @@
# train-project/train_schedule/api/__init__.py
from urllib.parse import urljoin
import requests
from .schemas import StationList, TrainList
class APIError(Exception):
"""An exception for errors coming from the API"""
pass
class TrainAPIClient:
"""This is our interface to the trains API.
The API client is in charge of our communication with the
trains API. It knows which HTTP calls to make, to which API
endpoints to get the data we need for our application."""
STATIONS_PATH = "/stations"
STATION_ARRIVALS_PATH = "/stations/{station_id}/arrivals"
STATION_DEPARTURES_PATH = "/stations/{station_id}/departures"
def __init__(self, config):
self.config = config
self._session = requests.Session()
def get_stations(self):
"""Get a list of stations from the API"""
url = self._make_url(self.STATIONS_PATH)
return StationList.parse_obj(self._get(url))
def get_arrivals(self, station_id):
"""Get a list of trains arriving at a particular
station"""
url = self._make_url(self.STATIONS_PATH)
url = self._make_url(
self.STATION_ARRIVALS_PATH, station_id=station_id
)
return TrainList.parse_obj(self._get(url))
def get_departures(self, station_id):
"""Get a list of trains departing from a particular
station"""
url = self._make_url(
self.STATION_DEPARTURES_PATH, station_id=station_id
)
return TrainList.parse_obj(self._get(url))
def _make_url(self, path, **kwargs):
"""Construct a URL for an API request.
Join the configured API url with the endpoint path,
formatted with any arguments that may need to be
substituted in."""
if not self.config.api_url:
raise APIError("No API URL configured")
return urljoin(self.config.api_url, path.format(**kwargs))
def _get(self, url):
"""Make an HTTP GET request.
This method takes care of all the details of making a GET
request to the api. It will handle any request errors (and
raise an API error instead). If we get a successful
response it will decode the JSON response.
"""
try:
response = self._session.get(url)
response.raise_for_status()
except requests.exceptions.HTTPError as err:
try:
msg = err.response.json()["detail"]
except requests.exceptions.InvalidJSONError:
msg = err.response.text()
raise APIError(msg) from err
except requests.exceptions.RequestException as err:
raise APIError(f"Error connecting to {url}") from err
else:
return response.json()

View File

@ -0,0 +1,73 @@
# train-project/train_schedule/api/schemas.py
"""Pydantic schemas for objects received from the API"""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel
class ModelListMixin:
"""A mixin class to proxy container type dunder methods to the
__root__ attribute of a pydantic model with a custom root
type.
See
https://docs.python.org/3/reference/datamodel.html#emulating-container-types
and
https://pydantic-docs.helpmanual.io/usage/models/#custom-root-types
"""
def __iter__(self):
return iter(self.__root__)
def __len__(self):
return len(self.__root__)
def __reversed__(self):
return reversed(self.__root__)
def __contains__(self, item):
return item in self.__root__
def __getitem__(self, item):
return self.__root__[item]
class Station(BaseModel):
"""A class to represent a station.
This should match the API response schema for a station"""
id: int
code: str
country: str
city: str
class StationList(ModelListMixin, BaseModel):
"""A list of stations."""
__root__: List[Station]
class Train(BaseModel):
"""A class to represent trains
This should match the API response schema for a train"""
id: int
id: int
name: str
station_from: Optional[Station] = None
station_to: Optional[Station] = None
departs_at: datetime
arrives_at: datetime
first_class: int
second_class: int
seats_per_car: int
class TrainList(ModelListMixin, BaseModel):
"""A list of trains"""
__root__: List[Train]

View File

@ -0,0 +1,150 @@
# train-project/train_schedule/cli.py
"""This module defines the command line interface"""
import argparse
from . import (
ABOUT_TEXT,
APP_NAME,
APP_TITLE,
DESCRIPTION,
VERSION,
)
from .api import TrainAPIClient
from .config import load_config, save_config
from .views import formatters
def main():
"""Launch the CLI"""
arg_parser = get_arg_parser()
args = arg_parser.parse_args()
if args.version:
print_version()
elif args.about:
print_about()
elif args.command == "config":
configuration(api_url=args.api_url)
elif args.command == "stations":
list_stations()
elif args.command == "arrivals":
list_arrivals(station_id=args.station_id)
elif args.command == "departures":
list_departures(station_id=args.station_id)
else:
arg_parser.print_usage()
def get_arg_parser():
"""Configure command-line arguments"""
parser = argparse.ArgumentParser(description=DESCRIPTION)
parser.add_argument(
"-v",
"--version",
help="Print version information and exit",
action="store_true",
)
parser.add_argument(
"-a",
"--about",
help=f"Print information about {APP_NAME} and exit",
action="store_true",
)
subcommands = parser.add_subparsers(
title="Commands",
dest="command",
description=(
"Use '%(prog)s COMMAND -h' to get help on a command"
),
)
config_parser = subcommands.add_parser(
"config",
description="View or update configuration",
help="View or update configuration",
)
config_parser.add_argument(
"-u",
"--api-url",
help="Update the Train API URL",
)
subcommands.add_parser(
"stations",
description="List available stations",
help="List available stations",
)
station_id_args = argparse.ArgumentParser(add_help=False)
station_id_args.add_argument(
"station_id",
help="The station ID",
type=int,
)
subcommands.add_parser(
"arrivals",
description="List arrivals for a station",
help="List arrivals for a station",
parents=[station_id_args],
)
subcommands.add_parser(
"departures",
description="List departures for a station",
help="List departures for a station",
parents=[station_id_args],
)
return parser
def print_version():
"""Print the app version"""
print(f"{APP_TITLE} {VERSION}")
def print_about():
"""Print about message"""
print(ABOUT_TEXT)
def configuration(api_url):
"""Show or update the configuration"""
config = load_config()
if api_url is not None:
config.api_url = api_url
save_config(config)
else:
print(config)
def list_stations():
"""Print a list of stations"""
api_client = TrainAPIClient(load_config())
stations = api_client.get_stations()
for station in stations:
print(formatters.format_station(station, show_id=True))
def list_arrivals(station_id):
"""Print a listing of trains arriving at a station"""
api_client = TrainAPIClient(load_config())
arrivals = api_client.get_arrivals(station_id)
for train in arrivals:
print(formatters.format_train(train, show_to=False))
def list_departures(station_id):
"""Print a listing of trains departing from a station"""
api_client = TrainAPIClient(load_config())
departures = api_client.get_departures(station_id)
for train in departures:
print(formatters.format_train(train, show_from=False))

View File

@ -0,0 +1,61 @@
# train-project/train_schedule/config.py
from pathlib import Path
from platformdirs import user_config_dir
from pydantic import BaseSettings
from . import APP_NAME
CONFIG_FILENAME = "config.json"
class Config(BaseSettings):
"""The app configuration"""
api_url: str = ""
def load_config():
"""Load the app configuration
Load the configuration from the user config file. If there's
an error reading from the file (e.g. if it doesn't exist) use
the default config values."""
cfg_path = get_config_path()
try:
return Config.parse_file(cfg_path)
except IOError:
return Config()
def save_config(config):
"""Save the configuration
Save the current configuration to the user config file. Only
write config values which have explicitly been set, and are
different from the defaults."""
cfg_path = get_config_path()
ensure_dir_exists(cfg_path.parent)
cfg = config.json(exclude_defaults=True, exclude_unset=True)
with open(cfg_path, "w") as stream:
stream.write(cfg)
def get_config_path():
"""Get the path to the config file
Get the (platform specific) path to the user configuration
file for our application"""
config_dir = Path(user_config_dir(APP_NAME)).resolve()
return config_dir / CONFIG_FILENAME
def ensure_dir_exists(path):
"""Make sure the config directory exists
If the directory doesn't exist create it (and all its ancestor
directories)"""
if not path.is_dir():
path.mkdir(parents=True)

View File

@ -0,0 +1,158 @@
# train-project/train_schedule/gui.py
from .api import TrainAPIClient
from .config import load_config, save_config
from .models.stations import StationsModel
from .models.trains import ArrivalsModel, DeparturesModel
from .views.about import AboutDialog
from .views.config import ConfigDialog
from .views.main import MainWindow, show_error
def main():
"""Launch the GUI"""
train_app = TrainApp()
train_app.run()
class TrainApp:
"""This class is the main controller of the train schedule
application.
It is responsible for creating the models and the views and
handling/coordinating interaction between them.
"""
def __init__(self):
self.config = load_config()
self.api_client = TrainAPIClient(self.config)
# Create models
self.stations_model = StationsModel(
datasource=self.api_client
)
self.arrivals_model = ArrivalsModel(
datasource=self.api_client,
)
self.departures_model = DeparturesModel(
datasource=self.api_client,
)
# Bind callbacks to handle model events
self.stations_model.updated.bind(self.stations_updated)
self.arrivals_model.updated.bind(self.arrivals_updated)
self.departures_model.updated.bind(
self.departures_updated
)
# Create the main window
self.mainwindow = MainWindow()
# Handy aliases to refer to some main window attributes
self.root = self.mainwindow.root
self.icon = self.mainwindow.icon
self.station_chooser = self.mainwindow.station_chooser
self.arrivals_view = self.mainwindow.arrivals_view
self.departures_view = self.mainwindow.departures_view
# Register callbacks to handle UI events
self.mainwindow.bind("<Visibility>", self.main_visible)
self.mainwindow.bind("<<OpenAboutDialog>>", self.about)
self.mainwindow.bind(
"<<OpenPreferencesDialog>>", self.configure
)
self.mainwindow.bind("<<RefreshData>>", self.refresh)
self.station_chooser.bind(
"<<ComboboxSelected>>", self.station_selected
)
def run(self):
self.mainwindow.run()
def main_visible(self, event=None):
"""Event handler to perform actions that should take
place when the main window is first displayed. We will
immediately unbind the window "<Visibility>" event, so
that this handler does not get called again after the
window is hidden (e.g. minimised) and displayed again.
"""
self.mainwindow.unbind("<Visibility>")
# If we have an API url configured, call `refresh` to
# fetch data from the API. Otherwise, show the `configure`
# dialog to prompt the user to enter an API URL.
if self.config.api_url:
self.refresh()
else:
self.configure()
def station_selected(self, event=None):
"""Event handler to invoke when a station is selected."""
self.update_trains()
def refresh(self, event=None):
"""Handler to refresh our data"""
# We only need to update the stations here. We'll update
# the trains after stations are updated (when we get
# a `updated` event from the stations model).
self.update_stations()
def about(self, event=None):
"""Show the about dialog"""
about_dialog = AboutDialog(self.root, self.icon)
about_dialog.run()
def configure(self, event=None):
"""Show the configuration dialog"""
config_dialog = ConfigDialog(self.root, self.config)
config_dialog.bind(
"<<ConfigUpdated>>", self.config_updated
)
config_dialog.run()
def config_updated(self, event=None):
"""Handler for when the configuration is updated.
Whenever the config is updated, we need to save the config
and refresh our data"""
with show_error():
save_config(self.config)
self.refresh()
def stations_updated(self, stations):
"""Handler for when the stations model is updated.
We need to update the station chooser with the new
station list and then update the trains"""
self.station_chooser.set_stations(stations)
self.update_trains()
def arrivals_updated(self, trains):
"""Handler for when the arrivals model is updated.
We need to update the arrivals view with the new trains
list"""
self.arrivals_view.set_trains(trains)
def departures_updated(self, trains):
"""Handler for when the departures model is updated.
We need to update the departures view with the new trains
list"""
self.departures_view.set_trains(trains)
def update_stations(self):
"""Tell the stations model to update itself"""
with show_error():
self.stations_model.update()
def update_trains(self):
"""Update the arrivals and departures models.
Get the currently selected station and update the arrivals
and departures models with that station"""
station_id = self.station_chooser.get_selected()
with show_error():
station = self.stations_model.get_station(station_id)
self.arrivals_model.set_station(station)
self.departures_model.set_station(station)

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -0,0 +1,22 @@
# train-project/train_schedule/metadata.py
"""Helper module to access package metadata"""
from importlib.metadata import PackageNotFoundError, metadata
def get_metadata():
try:
# Attempt to extract metadata from the installed package
meta = metadata(__package__)
except PackageNotFoundError:
# If the package has not been installed, we get
# a `PackageNotFoundError` exception. In this case,
# we fall back to dummy values
meta = {
"Name": __package__.replace("_", "-"),
"Summary": "description",
"Author": "author",
"Version": "version",
"License": "license",
}
return meta

View File

@ -0,0 +1 @@
# train-project/train_schedule/models/__init__.py

View File

@ -0,0 +1,22 @@
# train-project/train_schedule/models/event.py
class Event:
"""This class implements a callback mechanism for our models.
A model can define an event by creating an `Event` instance
attribute. Any objects that need to respond to event, can
register callbacks using the `bind` method. When the model
calls `emit` on the event, the callbacks will be invoked with
any arguments passed to the `emit` method.
"""
def __init__(self):
self.callbacks = []
def bind(self, listener):
"""Bind a callback to the event"""
self.callbacks.append(listener)
def emit(self, *args, **kwargs):
"""Emit the event"""
for callback in self.callbacks:
callback(*args, **kwargs)

View File

@ -0,0 +1,27 @@
# train-project/train_schedule/models/stations.py
from .event import Event
class StationsModel:
"""The StationsModel is in charge of keeping track of all the
stations."""
def __init__(self, datasource):
self._datasource = datasource
self._stations = {}
self.updated = Event()
def update(self):
"""Update the stations.
Request updated station data from the data source and emit
an `updated` event when the data is updated"""
self._stations = {
station.id: station
for station in self._datasource.get_stations()
}
self.updated.emit(stations=self._stations.values())
def get_station(self, station_id):
"""Retrieve a station by its `station_id`"""
return self._stations.get(station_id)

View File

@ -0,0 +1,61 @@
# train-project/train_schedule/models/trains.py
from .event import Event
class TrainsModelBase:
"""Base class for models that handle trains."""
def __init__(self, datasource):
self._datasource = datasource
self._station = None
self._trains = {}
self.updated = Event()
def set_station(self, station):
"""Set the station whose trains we are responsible for"""
self._station = station
self.update()
def update(self):
"""Update the train data.
If we have a station set, fetch trains for that station,
otherwise clear the train data. Emit an `updated` event
when the data is updated"""
if self._station is not None:
self._trains = {
train.id: train for train in self._fetch_trains()
}
else:
self._trains = {}
self.updated.emit(trains=self._trains.values())
def _fetch_trains(self):
"""Fetch trains from the datasource.
This must be implemented by subclasses"""
raise NotImplementedError
def get_train(self, train_id):
"""Get a train by its `train_id`"""
return self._trains[train_id]
class ArrivalsModel(TrainsModelBase):
"""The ArrivalsModel is in charge of keeping track of trains
arriving at a station"""
def _fetch_trains(self):
"""Fetch arrivals for our station from the datasource"""
return self._datasource.get_arrivals(self._station.id)
class DeparturesModel(TrainsModelBase):
"""The DeparturesModel is in charge of keeping track of trains
departing from a station"""
def _fetch_trains(self):
"""Fetch departures for our station from the datasource"""
return self._datasource.get_departures(self._station.id)

View File

@ -0,0 +1,14 @@
# train-project/train_schedule/resources.py
"""Helper module to access package data resources"""
from importlib import resources
def load_binary_resource(name):
"""Load binary data from the named package data file
This is analogous to doing
with open(name, "rb") as stream:
return stream.read()
"""
return resources.read_binary(__package__, name)

View File

@ -0,0 +1 @@
# train-project/train_schedule/views/__init__.py

View File

@ -0,0 +1,45 @@
# train-project/train_schedule/views/about.py
import tkinter as tk
from tkinter import ttk
from .. import ABOUT_TEXT, APP_TITLE
from .dialog import Dialog
class AboutDialog(Dialog):
"""A dialog to show information about the app"""
Title = f"About {APP_TITLE}"
def __init__(self, parent, icon):
self.icon = icon
super().__init__(parent, self.Title, resizable=False)
def create_widgets(self, body):
"""Create widgets to display the app icon and about
text"""
text_label = ttk.Label(
body,
image=self.icon,
text=ABOUT_TEXT,
compound=tk.LEFT,
)
text_label.grid(row=0, column=0, sticky=tk.NSEW)
body.rowconfigure(0, weight=1)
body.columnconfigure(0, weight=1)
def create_buttons(self, buttonbox):
"""Create an OK button to close the dialog"""
button = ttk.Button(
buttonbox,
text="OK",
command=self.dismiss,
default=tk.ACTIVE,
)
self.dialog.bind("<Return>", lambda e: button.invoke())
button.grid(row=0, column=0, sticky=tk.NS)
buttonbox.columnconfigure(0, weight=1)
self.initial_focus = button

View File

@ -0,0 +1,86 @@
# train-project/train_schedule/views/config.py
import tkinter as tk
from tkinter import ttk
from .. import APP_TITLE
from .dialog import Dialog
class ConfigDialog(Dialog):
"""A configuration dialog"""
Title = f"{APP_TITLE} Configuration"
def __init__(self, parent, config):
self.config = config
super().__init__(parent, title=self.Title, resizable=True)
def create_widgets(self, body):
"""Create widgets to allow the user to set configuration
values"""
self._make_config_vars()
url_frame = ttk.LabelFrame(
body,
text="Train API URL",
padding=(5, 5, 5, 5),
)
url_entry = ttk.Entry(
url_frame,
width=40,
textvariable=self._config_vars["api_url"],
)
url_entry.grid(row=0, column=0, sticky=tk.NSEW)
url_frame.columnconfigure(0, weight=1)
url_frame.grid(row=0, column=0, sticky=tk.NSEW)
body.columnconfigure(0, weight=1)
self.initial_focus = url_entry
def create_buttons(self, buttonbox):
"""Create OK and cancel buttons"""
ok_button = ttk.Button(
buttonbox,
text="OK",
command=self.ok,
default=tk.ACTIVE,
)
self.dialog.bind("<Return>", lambda e: ok_button.invoke())
cancel_button = ttk.Button(
buttonbox, text="Cancel", command=self.dismiss
)
cancel_button.grid(
row=0, column=0, sticky=(tk.N, tk.S, tk.E)
)
ok_button.grid(row=0, column=1, sticky=tk.NSEW)
buttonbox.columnconfigure(0, weight=1)
def _make_config_vars(self):
"""Create Tk variables for all the configuration values"""
self._config_vars = {
"api_url": tk.StringVar(
self.dialog, self.config.api_url, "api_url"
),
}
def _update_config(self):
"""Update the configuration from the Tk variables and emit
a `<<ConfigUpdated>>` event if the config has changed"""
changed = False
for key, var in self._config_vars.items():
orig = getattr(self.config, key)
setattr(self.config, key, var.get())
changed |= orig != getattr(self.config, key)
if changed:
self.dialog.event_generate("<<ConfigUpdated>>")
def ok(self):
"""Handler for when the OK button is clicked"""
self._update_config()
self.dismiss()

View File

@ -0,0 +1,96 @@
# train-project/train_schedule/views/dialog.py
import tkinter as tk
from tkinter import ttk
class Dialog:
"""Base class for dialogs.
Subclasses should override the `create_widgets` method to
populate the body of the dialog and the `create_buttons` to
populate the button area at the bottom of the dialog.
The `initial_focus` attribute can be set to the widget that
should get focus when the dialog is displayed.
To display the dialog, call the `run` method.
To close and destroy the dialog, call `dismiss`.
"""
def __init__(self, parent, title, resizable=False):
self.dialog = tk.Toplevel(
parent, class_=self.__class__.__name__
)
self._title = title
self._resizable = resizable
self.dialog.withdraw()
self.dialog.transient(parent)
self._set_title()
body = ttk.Frame(self.dialog, padding=(5, 5, 5, 5))
buttonbox = ttk.Frame(self.dialog, padding=(5, 5, 5, 5))
self._layout_widgets(body, buttonbox)
self.initial_focus = body
self.create_widgets(body)
self.create_buttons(buttonbox)
self.dialog.protocol("WM_DELETE_WINDOW", self.dismiss)
self.dialog.bind("<Escape>", self.dismiss)
def _set_title(self):
"""Set the dialog title.
This is called automatically when the dialog is created"""
self.dialog.title(self._title)
self.dialog.iconname(self._title)
def _layout_widgets(self, body, buttonbox):
"""Lay out the frames containing the dialog body and
buttons"""
body.grid(row=0, column=0, sticky=tk.NSEW)
buttonbox.grid(row=1, column=0, sticky=tk.NSEW)
self.dialog.rowconfigure(0, weight=1)
self.dialog.rowconfigure(1, weight=0)
self.dialog.columnconfigure(0, weight=1)
def create_widgets(self, body):
"""Create the widgets for the dialog body
Subclasses should override this method."""
pass
def create_buttons(self, buttonbox):
"""Create the buttons to show at the bottom of the dialog
Subclasses should override this method."""
pass
def dismiss(self, event=None):
"""Close and destroy the dialog"""
self.dialog.grab_release()
self.dialog.destroy()
def run(self):
"""Run the dialog"""
self.dialog.deiconify()
self.initial_focus.focus_set()
self.dialog.wait_visibility()
self.dialog.resizable(
width=self._resizable, height=self._resizable
)
self.dialog.grab_set()
self.dialog.wait_window()
def bind(self, *args, **kwargs):
"""Proxy for the underlying window's bind method"""
self.dialog.bind(*args, **kwargs)
def unbind(self, *args, **kwargs):
"""Proxy for the underlying window's unbind method"""
self.dialog.unbind(*args, **kwargs)

View File

@ -0,0 +1,66 @@
# train-project/train_schedule/views/formatters.py
"""Helper functions to format data for display."""
def format_station(station, show_id=False):
"""Format station data for display"""
if station is None:
return "Unknown"
else:
fmt_str = "{city}, {country} ({code})"
if show_id:
fmt_str = "{id}: " + fmt_str
return fmt_str.format(**station.dict())
def format_datetime(time_value):
"""Format a datetime according to the current locale"""
return time_value.strftime("%c")
TrainFormatters = {
"station_from": format_station,
"station_to": format_station,
"departs_at": format_datetime,
"arrives_at": format_datetime,
}
"""Lookup table for functions to format various attributes of
a train"""
TrainAttributeNames = {
"station_from": "From",
"station_to": "To",
"departs_at": "Departs",
"arrives_at": "Arrives",
"first_class": "1st class cars",
"second_class": "2nd class cars",
"seats_per_car": "Seats/car",
}
"""Lookup table for display names of train attributes"""
def format_train_attr(train, attr):
"""Format a train attribute"""
value = getattr(train, attr)
formatter = TrainFormatters.get(attr)
if formatter:
value = formatter(value)
return value
def format_train(train, show_from=True, show_to=True):
"""Format train data for display"""
columns = TrainAttributeNames.copy()
if not show_from:
del columns["station_from"]
if not show_to:
del columns["station_to"]
return "\t".join(
f"{heading}: {format_train_attr(train, attr)}"
for attr, heading in columns.items()
)

View File

@ -0,0 +1,192 @@
# train-project/train_schedule/views/main.py
import tkinter as tk
from contextlib import contextmanager
from tkinter import messagebox, ttk
from .. import APP_TITLE
from ..resources import load_binary_resource
from .stations import StationChooser
from .trains import TrainsView
ICON_FILENAME = "icon.png"
@contextmanager
def show_error():
"""A simple context manager to catch any exceptions and
display them as error messages in an error dialog"""
try:
yield
except Exception as error:
messagebox.showerror(title="Error", message=error)
class MainWindow:
"""The main window for our app"""
def __init__(self):
self.root = tk.Tk()
self._set_title()
self._set_icon()
self._make_menus()
content_frame = self._make_content()
self._layout_widgets(content_frame)
def _set_title(self):
"""Set the window title"""
self.title = APP_TITLE
self.root.title(self.title)
self.root.iconname(self.title)
def _set_icon(self):
"""Set the window icon"""
self.icon = tk.PhotoImage(
data=load_binary_resource(ICON_FILENAME)
)
self.root.iconphoto(True, self.icon)
def _make_menus(self):
"""Create the menubar"""
self.root.option_add("*tearOff", False)
self.menubar = tk.Menu(self.root)
self._make_app_menu()
self._make_edit_menu()
self._make_help_menu()
self.root["menu"] = self.menubar
def _make_app_menu(self):
"""Create the main application menu"""
app_menu = tk.Menu(self.menubar)
app_menu.add_command(
label="Refresh",
command=lambda: self.root.event_generate(
"<<RefreshData>>"
),
underline=0,
)
app_menu.add_command(
label="Quit",
command=self.quit,
underline=0,
)
self.menubar.add_cascade(
menu=app_menu, label=self.title, underline=0
)
def _make_edit_menu(self):
"""Create the 'Edit' menu"""
edit_menu = tk.Menu(self.menubar)
edit_menu.add_command(
label="Preferences...",
command=lambda: self.root.event_generate(
"<<OpenPreferencesDialog>>"
),
underline=0,
)
self.menubar.add_cascade(
menu=edit_menu, label="Edit", underline=0
)
def _make_help_menu(self):
"""Create the 'Help' menu"""
help_menu = tk.Menu(self.menubar)
help_menu.add_command(
label="About...",
command=lambda: self.root.event_generate(
"<<OpenAboutDialog>>"
),
underline=0,
)
self.menubar.add_cascade(
menu=help_menu, label="Help", underline=0
)
def _make_content(self):
"""Create the widgets to populate the body of the
window"""
content_frame = ttk.Frame(self.root, padding=(5, 5, 5, 5))
station_frame = self._make_station_chooser(content_frame)
station_frame.grid(row=0, column=0, sticky=tk.NSEW)
notebook = ttk.Notebook(
content_frame, padding=(0, 5, 0, 0)
)
self.arrivals_view = self._make_train_tab(
notebook, "Arrivals", show_from=True, show_to=False
)
self.departures_view = self._make_train_tab(
notebook, "Departures", show_from=False, show_to=True
)
notebook.grid(row=1, column=0, sticky=tk.NSEW)
content_frame.rowconfigure(1, weight=1)
content_frame.columnconfigure(0, weight=1)
return content_frame
def _make_station_chooser(self, content_frame):
"""Create the station chooser dropdown"""
station_frame = ttk.LabelFrame(
content_frame, text="Station", padding=(5, 5, 5, 5)
)
self.station_chooser = StationChooser(station_frame)
self.station_chooser.combobox.grid(
row=0, column=0, sticky=tk.NSEW
)
station_frame.columnconfigure(0, weight=1)
return station_frame
def _make_train_tab(self, notebook, name, show_from, show_to):
"""Create the widgets to display either arrivals or
departures"""
frame = ttk.Frame(notebook, padding=(5, 5, 5, 5))
notebook.add(frame, text=name)
train_view = TrainsView(
frame, show_from=show_from, show_to=show_to
)
scrollbar = ttk.Scrollbar(
frame,
orient=tk.VERTICAL,
command=train_view.treeview.yview,
)
train_view.treeview.configure(
yscrollcommand=scrollbar.set
)
train_view.treeview.grid(row=0, column=0, sticky=tk.NSEW)
scrollbar.grid(row=0, column=1, sticky=tk.NS)
frame.rowconfigure(0, weight=1)
frame.columnconfigure(0, weight=1)
return train_view
def _layout_widgets(self, content_frame):
"""Lay out the main frame of the window"""
content_frame.grid(row=0, column=0, sticky=tk.NSEW)
self.root.rowconfigure(0, weight=1)
self.root.columnconfigure(0, weight=1)
def bind(self, *args, **kwargs):
"""Proxy for the underlying window's bind method"""
self.root.bind(*args, *kwargs)
def unbind(self, *args, **kwargs):
"""Proxy for the underlying window's unbind method"""
self.root.unbind(*args, *kwargs)
def run(self):
"""Run the main loop"""
self.root.mainloop()
def quit(self, event=None):
"""Destroy the window and quit the app"""
self.root.destroy()

View File

@ -0,0 +1,60 @@
# train-project/train_schedule/views/stations.py
import tkinter as tk
from tkinter import ttk
from . import formatters
class StationChooser:
"""Display stations in a dropdown selector"""
def __init__(self, parent):
self._station_var = tk.StringVar(value="", name="station")
self.combobox = ttk.Combobox(
parent,
textvariable=self._station_var,
state=["readonly"],
)
# We keep a list of the station_ids in the same order as
# the stations in our combobox. This will allow us to look
# up the ID of the currently selected station.
self._station_ids = []
def set_stations(self, stations):
"""Set the stations that can be chosen"""
# We need to keep our list of station_ids in sync with the
# combobox. To do this, we clear the _station_ids list and
# repopulate the list with the new station IDs.
self._station_ids.clear()
values = []
for station in stations:
values.append(formatters.format_station(station))
self._station_ids.append(station.id)
self.combobox["values"] = values
def get_selected(self):
"""Get the ID of the selected station"""
# Get the position of the currently selected item
selection_idx = self.combobox.current()
if selection_idx == -1:
# Nothing is selected
selected_id = None
else:
# Look up the ID of the selected item in our
# _station_ids list.
selected_id = self._station_ids[selection_idx]
return selected_id
def bind(self, *args, **kwargs):
"""Proxy for the underlying combobox's bind method"""
self.combobox.bind(*args, **kwargs)
def unbind(self, *args, **kwargs):
"""Proxy for the underlying combobox's unbind method"""
self.combobox.unbind(*args, **kwargs)

View File

@ -0,0 +1,53 @@
# train-project/train_schedule/views/trains.py
import tkinter as tk
from tkinter import ttk
from . import formatters
class TrainsView:
"""Display information about trains in a table"""
def __init__(self, parent, show_from=True, show_to=True):
self._columns = formatters.TrainAttributeNames.copy()
if not show_from:
del self._columns["station_from"]
if not show_to:
del self._columns["station_to"]
self.treeview = ttk.Treeview(
parent,
columns=list(self._columns.keys()),
show="headings",
selectmode=tk.BROWSE,
)
for column_id, heading in self._columns.items():
self.treeview.heading(column_id, text=heading)
def set_trains(self, trains):
"""Set the trains to be displayed"""
self.clear()
for train in trains:
self.add_train(train)
def clear(self):
"""Remove all trains from the view"""
children = self.treeview.get_children("")
self.treeview.delete(*children)
def add_train(self, train):
"""Add a train to the view"""
self.treeview.insert(
"",
tk.END,
str(train.id),
values=tuple(self._format_columns(train)),
)
def _format_columns(self, train):
"""Format the data to be displayed in the table columns"""
for col_name in self._columns:
yield formatters.format_train_attr(train, col_name)