ch15
This commit is contained in:
parent
0a0ceaf7d7
commit
e00e011bac
22
ch15/pip_install.txt
Normal file
22
ch15/pip_install.txt
Normal 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
10
ch15/pypirc
Normal 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-...
|
||||
2
ch15/requirements/build.in
Normal file
2
ch15/requirements/build.in
Normal file
@ -0,0 +1,2 @@
|
||||
build
|
||||
twine
|
||||
78
ch15/requirements/build.txt
Normal file
78
ch15/requirements/build.txt
Normal 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
|
||||
3
ch15/requirements/main.in
Normal file
3
ch15/requirements/main.in
Normal file
@ -0,0 +1,3 @@
|
||||
platformdirs>=2.0
|
||||
pydantic>=1.8.2,<2.0
|
||||
requests~=2.0
|
||||
22
ch15/requirements/main.txt
Normal file
22
ch15/requirements/main.txt
Normal 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
|
||||
3
ch15/skeleton-project/README.md
Normal file
3
ch15/skeleton-project/README.md
Normal 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.
|
||||
5
ch15/skeleton-project/example/__init__.py
Normal file
5
ch15/skeleton-project/example/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""This is just a placeholder package, replace it with your own
|
||||
package"""
|
||||
|
||||
|
||||
print("Hello world")
|
||||
3
ch15/skeleton-project/pyproject.toml
Normal file
3
ch15/skeleton-project/pyproject.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=51.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
16
ch15/skeleton-project/setup.cfg
Normal file
16
ch15/skeleton-project/setup.cfg
Normal 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
|
||||
3
ch15/skeleton-project/setup.py
Normal file
3
ch15/skeleton-project/setup.py
Normal file
@ -0,0 +1,3 @@
|
||||
import setuptools
|
||||
|
||||
setuptools.setup()
|
||||
0
ch15/skeleton-project/tests/__init__.py
Normal file
0
ch15/skeleton-project/tests/__init__.py
Normal file
5
ch15/train-project/CHANGELOG.md
Normal file
5
ch15/train-project/CHANGELOG.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Change log
|
||||
|
||||
## Version 1.0.0
|
||||
|
||||
Initial published version.
|
||||
21
ch15/train-project/LICENSE
Normal file
21
ch15/train-project/LICENSE
Normal 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.
|
||||
3
ch15/train-project/MANIFEST.in
Normal file
3
ch15/train-project/MANIFEST.in
Normal file
@ -0,0 +1,3 @@
|
||||
# train-project/MANIFEST.in
|
||||
include CHANGELOG.md
|
||||
include train_schedule/icon.png
|
||||
63
ch15/train-project/README.md
Normal file
63
ch15/train-project/README.md
Normal 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
|
||||
11
ch15/train-project/pyproject.toml
Normal file
11
ch15/train-project/pyproject.toml
Normal 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
|
||||
50
ch15/train-project/setup.cfg
Normal file
50
ch15/train-project/setup.cfg
Normal 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
|
||||
4
ch15/train-project/setup.py
Normal file
4
ch15/train-project/setup.py
Normal file
@ -0,0 +1,4 @@
|
||||
# train-project/setup.py
|
||||
import setuptools
|
||||
|
||||
setuptools.setup()
|
||||
23
ch15/train-project/train_schedule/__init__.py
Normal file
23
ch15/train-project/train_schedule/__init__.py
Normal 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}"""
|
||||
34
ch15/train-project/train_schedule/__main__.py
Normal file
34
ch15/train-project/train_schedule/__main__.py
Normal 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)
|
||||
"""
|
||||
84
ch15/train-project/train_schedule/api/__init__.py
Normal file
84
ch15/train-project/train_schedule/api/__init__.py
Normal 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()
|
||||
73
ch15/train-project/train_schedule/api/schemas.py
Normal file
73
ch15/train-project/train_schedule/api/schemas.py
Normal 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]
|
||||
150
ch15/train-project/train_schedule/cli.py
Normal file
150
ch15/train-project/train_schedule/cli.py
Normal 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))
|
||||
61
ch15/train-project/train_schedule/config.py
Normal file
61
ch15/train-project/train_schedule/config.py
Normal 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)
|
||||
158
ch15/train-project/train_schedule/gui.py
Normal file
158
ch15/train-project/train_schedule/gui.py
Normal 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)
|
||||
BIN
ch15/train-project/train_schedule/icon.png
Normal file
BIN
ch15/train-project/train_schedule/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
22
ch15/train-project/train_schedule/metadata.py
Normal file
22
ch15/train-project/train_schedule/metadata.py
Normal 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
|
||||
1
ch15/train-project/train_schedule/models/__init__.py
Normal file
1
ch15/train-project/train_schedule/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# train-project/train_schedule/models/__init__.py
|
||||
22
ch15/train-project/train_schedule/models/event.py
Normal file
22
ch15/train-project/train_schedule/models/event.py
Normal 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)
|
||||
27
ch15/train-project/train_schedule/models/stations.py
Normal file
27
ch15/train-project/train_schedule/models/stations.py
Normal 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)
|
||||
61
ch15/train-project/train_schedule/models/trains.py
Normal file
61
ch15/train-project/train_schedule/models/trains.py
Normal 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)
|
||||
14
ch15/train-project/train_schedule/resources.py
Normal file
14
ch15/train-project/train_schedule/resources.py
Normal 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)
|
||||
1
ch15/train-project/train_schedule/views/__init__.py
Normal file
1
ch15/train-project/train_schedule/views/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# train-project/train_schedule/views/__init__.py
|
||||
45
ch15/train-project/train_schedule/views/about.py
Normal file
45
ch15/train-project/train_schedule/views/about.py
Normal 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
|
||||
86
ch15/train-project/train_schedule/views/config.py
Normal file
86
ch15/train-project/train_schedule/views/config.py
Normal 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()
|
||||
96
ch15/train-project/train_schedule/views/dialog.py
Normal file
96
ch15/train-project/train_schedule/views/dialog.py
Normal 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)
|
||||
66
ch15/train-project/train_schedule/views/formatters.py
Normal file
66
ch15/train-project/train_schedule/views/formatters.py
Normal 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()
|
||||
)
|
||||
192
ch15/train-project/train_schedule/views/main.py
Normal file
192
ch15/train-project/train_schedule/views/main.py
Normal 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()
|
||||
60
ch15/train-project/train_schedule/views/stations.py
Normal file
60
ch15/train-project/train_schedule/views/stations.py
Normal 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)
|
||||
53
ch15/train-project/train_schedule/views/trains.py
Normal file
53
ch15/train-project/train_schedule/views/trains.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user