diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a289b78f..ab04655b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -10,16 +10,16 @@ repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.2.0
hooks:
- - id: check-added-large-files # prevents giant files from being committed
- - id: check-yaml # checks yaml files for parseable syntax
- - id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline
- - id: trailing-whitespace # trims trailing whitespace
+ - id: check-added-large-files # Prevents giant files from being committed
+ - id: check-yaml # Checks yaml files for parseable syntax
+ - id: end-of-file-fixer # Ensures that a file is either empty, or ends with one newline
+ - id: trailing-whitespace # Trims trailing whitespace
exclude: startlist_test.py # Test data has intentional EOL whitespace
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.1.1
hooks:
- - id: mypy
+ - id: mypy # Run mypy to check typeing
additional_dependencies:
- types-python-dateutil
- types-requests
@@ -31,3 +31,14 @@ repos:
- id: rst-directive-colons # Detect mistake of rst directive not ending with double colon or space before the double colon
- id: rst-inline-touching-normal # Detect mistake of inline code touching normal text in rst
- id: text-unicode-replacement-char # Forbid files which have a UTF-8 Unicode replacement character
+
+ - repo: https://github.com/psf/black
+ rev: "23.3.0"
+ hooks:
+ - id: black # Run "black" for standardized code formatting
+
+ - repo: https://github.com/PyCQA/isort
+ rev: "5.12.0"
+ hooks:
+ - id: isort # Sort imports
+ args: ["--profile", "black", "--filter-files"]
diff --git a/about.py b/about.py
index 3db38c06..a2c06339 100644
--- a/about.py
+++ b/about.py
@@ -14,9 +14,9 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''
+"""
Display an "about" dialog
-'''
+"""
import textwrap
from tkinter import Text, Tk, Toplevel, font, ttk
@@ -25,10 +25,10 @@
def about(root: Tk) -> None:
- '''Displays a modal dialog containing the application "about" info'''
+ """Displays a modal dialog containing the application "about" info"""
dlg = Toplevel(root)
- dlg.resizable(False, False) # don't allow resizing
+ dlg.resizable(False, False) # don't allow resizing
def dismiss():
dlg.grab_release()
@@ -42,23 +42,25 @@ def dismiss():
dlg.title("About - Wahoo! Results")
dlg.configure(padx=8, pady=8)
- dlg.protocol("WM_DELETE_WINDOW", dismiss) # intercept close button
- dlg.transient(root) # dialog window is related to main
- dlg.wait_visibility() # can't grab until window appears, so we wait
- dlg.grab_set() # ensure all input goes to our window
+ dlg.protocol("WM_DELETE_WINDOW", dismiss) # intercept close button
+ dlg.transient(root) # dialog window is related to main
+ dlg.wait_visibility() # can't grab until window appears, so we wait
+ dlg.grab_set() # ensure all input goes to our window
geo = dlg.geometry()
# parse the geometry string of the form "WxH+X+Y" to get the width and height as integers
- width, height = [int(x) for x in geo.split('+')[0].split('x')]
+ width, height = [int(x) for x in geo.split("+")[0].split("x")]
# center the dialog on the screen
left = root.winfo_screenwidth() // 2 - width // 2
top = root.winfo_screenheight() // 2 - height // 2
- dlg.geometry(f'+{left}+{top}')
+ dlg.geometry(f"+{left}+{top}")
+
+ dlg.wait_window() # block until window is destroyed
- dlg.wait_window() # block until window is destroyed
def _set_contents(txt: Text) -> None:
- contents = textwrap.dedent(f'''\
+ contents = textwrap.dedent(
+ f"""\
Wahoo! Results
Copyright (c) 2022 - John Strunk
@@ -68,28 +70,37 @@ def _set_contents(txt: Text) -> None:
https://github.com/JohnStrunk/wahoo-results
Version: {WAHOO_RESULTS_VERSION}
- ''')
+ """
+ )
- txtfont = font.nametofont('TkTextFont')
+ txtfont = font.nametofont("TkTextFont")
txtsize = txtfont.actual()["size"]
# Add contents and set default style
- lines = contents.split('\n')
+ lines = contents.split("\n")
height = len(lines)
width = 0
for line in lines:
width = max(width, len(line))
- txt.configure(state="normal", background=txt.master["background"], relief="flat",
- width=width, height=height)
+ txt.configure(
+ state="normal",
+ background=txt.master["background"],
+ relief="flat",
+ width=width,
+ height=height,
+ )
txt.insert("1.0", contents, ("all"))
- txt.tag_configure("all", foreground="black", font=f"TktextFont {txtsize}", justify="center")
+ txt.tag_configure(
+ "all", foreground="black", font=f"TktextFont {txtsize}", justify="center"
+ )
# Set the style for the application title text line
- txt.tag_add('title', '1.0', '2.0')
+ txt.tag_add("title", "1.0", "2.0")
txt.tag_configure("title", font=f"TkTextFont {txtsize} bold underline")
txt.configure(state="disabled")
txt.see("1.0")
+
if __name__ == "__main__":
about(Tk())
diff --git a/build.py b/build.py
index 7ec06d40..6cde1b2e 100644
--- a/build.py
+++ b/build.py
@@ -14,14 +14,15 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''Python script to build Wahoo! Results executable'''
+"""Python script to build Wahoo! Results executable"""
import os
-import semver #type: ignore
import shutil
import subprocess
+
import PyInstaller.__main__
import PyInstaller.utils.win32.versioninfo as vinfo
+import semver # type: ignore
import wh_version
@@ -29,17 +30,21 @@
# Remove any previous build artifacts
try:
- shutil.rmtree('build')
+ shutil.rmtree("build")
except FileNotFoundError:
pass
# Determine current git tag
-git_ref = subprocess.check_output('git describe --tags --match "v*" --long', shell=True).decode("utf-8").rstrip()
+git_ref = (
+ subprocess.check_output('git describe --tags --match "v*" --long', shell=True)
+ .decode("utf-8")
+ .rstrip()
+)
wr_version = wh_version.git_semver(git_ref)
print(f"Building Wahoo Results, version: {wr_version}")
vdict = semver.parse(wr_version)
-with open ('version.py', 'w' ) as f:
+with open("version.py", "w") as f:
f.write("'''Version information'''\n\n")
f.write(f'WAHOO_RESULTS_VERSION = "{wr_version}"\n')
@@ -48,12 +53,12 @@
if dsn is not None:
f.write(f'SENTRY_DSN = "{dsn}"\n')
else:
- f.write('SENTRY_DSN = None\n')
+ f.write("SENTRY_DSN = None\n")
# Segment API key
segment = os.getenv("SEGMENT_WRITE_KEY")
if segment is None:
- segment = 'unknown'
+ segment = "unknown"
f.write(f'SEGMENT_WRITE_KEY = "{segment}"\n')
# ipinfo.io
@@ -61,7 +66,7 @@
if ipinfo is not None:
f.write(f'IPINFO_TOKEN = "{ipinfo}"\n')
else:
- f.write('IPINFO_TOKEN = None\n')
+ f.write("IPINFO_TOKEN = None\n")
f.flush()
f.close()
@@ -69,37 +74,46 @@
# Create file info to embed in executable
v = vinfo.VSVersionInfo(
ffi=vinfo.FixedFileInfo(
- filevers=(vdict['major'], vdict['minor'], vdict['patch'], 0),
- prodvers=(vdict['major'], vdict['minor'], vdict['patch'], 0),
- mask=0x3f,
+ filevers=(vdict["major"], vdict["minor"], vdict["patch"], 0),
+ prodvers=(vdict["major"], vdict["minor"], vdict["patch"], 0),
+ mask=0x3F,
flags=0x0,
OS=0x4,
fileType=0x1,
subtype=0x0,
),
kids=[
- vinfo.StringFileInfo([
- vinfo.StringTable('040904e4', [
- # https://docs.microsoft.com/en-us/windows/win32/menurc/versioninfo-resource
- # Required fields:
- vinfo.StringStruct('CompanyName', 'John D. Strunk'),
- vinfo.StringStruct('FileDescription', 'Wahoo! Results'),
- vinfo.StringStruct('FileVersion', wr_version),
- vinfo.StringStruct('InternalName', 'wahoo_results'),
- vinfo.StringStruct('ProductName', 'Wahoo! Results'),
- vinfo.StringStruct('ProductVersion', wr_version),
- vinfo.StringStruct('OriginalFilename', 'wahoo-results.exe'),
- # Optional fields
- vinfo.StringStruct('LegalCopyright', '(c) John D. Strunk - AGPL-3.0-or-later'),
- ])
- ]),
- vinfo.VarFileInfo([
- # 1033 -> Engligh; 1252 -> charsetID
- vinfo.VarStruct('Translation', [1033, 1252])
- ])
- ]
+ vinfo.StringFileInfo(
+ [
+ vinfo.StringTable(
+ "040904e4",
+ [
+ # https://docs.microsoft.com/en-us/windows/win32/menurc/versioninfo-resource
+ # Required fields:
+ vinfo.StringStruct("CompanyName", "John D. Strunk"),
+ vinfo.StringStruct("FileDescription", "Wahoo! Results"),
+ vinfo.StringStruct("FileVersion", wr_version),
+ vinfo.StringStruct("InternalName", "wahoo_results"),
+ vinfo.StringStruct("ProductName", "Wahoo! Results"),
+ vinfo.StringStruct("ProductVersion", wr_version),
+ vinfo.StringStruct("OriginalFilename", "wahoo-results.exe"),
+ # Optional fields
+ vinfo.StringStruct(
+ "LegalCopyright", "(c) John D. Strunk - AGPL-3.0-or-later"
+ ),
+ ],
+ )
+ ]
+ ),
+ vinfo.VarFileInfo(
+ [
+ # 1033 -> Engligh; 1252 -> charsetID
+ vinfo.VarStruct("Translation", [1033, 1252])
+ ]
+ ),
+ ],
)
-with open('wahoo-results.fileinfo', 'w') as f:
+with open("wahoo-results.fileinfo", "w") as f:
f.write(str(v))
f.flush()
f.close()
@@ -107,8 +121,4 @@
print("Invoking PyInstaller to generate executable...\n")
# Build it
-PyInstaller.__main__.run([
- "--distpath=.",
- "--workpath=build",
- 'wahoo-results.spec'
-])
+PyInstaller.__main__.run(["--distpath=.", "--workpath=build", "wahoo-results.spec"])
diff --git a/docs/conf.py b/docs/conf.py
index 168ededc..9f4b0b29 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -14,14 +14,13 @@
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
-import sphinx_rtd_theme #type: ignore
-
+import sphinx_rtd_theme # type: ignore
# -- Project information -----------------------------------------------------
-project = 'Wahoo! Results'
-copyright = '2020-2023, John D. Strunk'
-author = 'John D. Strunk'
+project = "Wahoo! Results"
+copyright = "2020-2023, John D. Strunk"
+author = "John D. Strunk"
# -- General configuration ---------------------------------------------------
@@ -30,21 +29,21 @@
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
- # https://plantweb.readthedocs.io/#sphinx-directives
- # https://plantweb.readthedocs.io/examples.html
- 'plantweb.directive',
- 'sphinx_rtd_theme',
- # https://sphinx-tabs.readthedocs.io/en/latest/
- 'sphinx_tabs.tabs',
+ # https://plantweb.readthedocs.io/#sphinx-directives
+ # https://plantweb.readthedocs.io/examples.html
+ "plantweb.directive",
+ "sphinx_rtd_theme",
+ # https://sphinx-tabs.readthedocs.io/en/latest/
+ "sphinx_tabs.tabs",
]
# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
-exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '.venv', 'README.md']
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", ".venv", "README.md"]
# -- Options for HTML output -------------------------------------------------
@@ -52,17 +51,17 @@
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
-html_theme = 'sphinx_rtd_theme'
+html_theme = "sphinx_rtd_theme"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
# Logo at top of sidebar
-html_logo = 'media/wr-card2-150.png'
+html_logo = "media/wr-card2-150.png"
# favicon
-html_favicon = 'media/wr-icon.ico'
+html_favicon = "media/wr-icon.ico"
-master_doc = 'index'
+master_doc = "index"
diff --git a/imagecast.py b/imagecast.py
index 97a4277c..ecd34614 100644
--- a/imagecast.py
+++ b/imagecast.py
@@ -19,18 +19,18 @@
images to Chromecast devices.
"""
-from dataclasses import dataclass
-from http.server import BaseHTTPRequestHandler, HTTPServer
import threading
import time
+from dataclasses import dataclass
+from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any, Callable, Dict, List, Optional
from uuid import UUID
-from PIL import Image # type: ignore
-import pychromecast # type: ignore
-from pychromecast.error import NotConnected # type: ignore
+import pychromecast # type: ignore
import sentry_sdk
import zeroconf
+from PIL import Image # type: ignore
+from pychromecast.error import NotConnected # type: ignore
# Resolution of images for the Chromecast
IMAGE_SIZE = (1280, 720)
@@ -38,20 +38,25 @@
# Chromecast image refresh interval (seconds)
_REFRESH_INTERVAL = 15 * 60
+
@dataclass
class DeviceStatus:
- '''The status of a Chromecast device'''
- uuid: UUID # UUID for the device
- name: str # Friendly name for the device
+ """The status of a Chromecast device"""
+
+ uuid: UUID # UUID for the device
+ name: str # Friendly name for the device
enabled: bool # Whether the device is enabled
+
DiscoveryCallbackFn = Callable[[], None]
-class ImageCast: # pylint: disable=too-many-instance-attributes
+
+class ImageCast: # pylint: disable=too-many-instance-attributes
"""
The ImageCast class encapsulates everything necessary to cast images to a
set of Chromecast devices.
"""
+
_server_port: int # port for the web server
# _devices maps the chromecast uuid to a map of:
# "cast" -> its chromecast object
@@ -65,13 +70,13 @@ class ImageCast: # pylint: disable=too-many-instance-attributes
zconf: Optional[zeroconf.Zeroconf]
def __init__(self, server_port: int) -> None:
- '''
+ """
Create an instance to communicate with a set of Chromecast devices.
Parameters:
- server_port: The port on the local machine that will host the
embedded web server for the Chromecast(s) to connect to.
- '''
+ """
self._server_port = server_port
self.devices = {}
self.image = None
@@ -82,19 +87,19 @@ def __init__(self, server_port: int) -> None:
self.browser = None
def start(self):
- '''
+ """
Start the background processes. This must be called before publishing
images.
- '''
+ """
self._start_webserver()
self._start_listener()
self._start_refresh()
def stop(self) -> None:
- '''
+ """
Shut down the background processes and disconnect from the
Chromecast(s).
- '''
+ """
for state in self.devices.values():
if state["enabled"]:
self._disconnect(state["cast"])
@@ -108,41 +113,49 @@ def _disconnect(cls, cast: pychromecast.Chromecast) -> None:
pass
def set_discovery_callback(self, func: DiscoveryCallbackFn) -> None:
- '''
+ """
Sets the callback function that will be called when the list of
discovered Chromecasts changes.
- '''
+ """
self.callback_fn = func
def enable(self, uuid: UUID, enabled: bool) -> None:
- '''
+ """
Set whether to include or exclude a specific Chromecast device from
receiving the published images.
- '''
- with sentry_sdk.start_transaction(op="enable_cc", name="Enable/disable Chromecast"):
+ """
+ with sentry_sdk.start_transaction(
+ op="enable_cc", name="Enable/disable Chromecast"
+ ):
if self.devices[uuid] is not None:
previous = self.devices[uuid]["enabled"]
self.devices[uuid]["enabled"] = enabled
- if enabled and not previous : # enabling: send the latest image
+ if enabled and not previous: # enabling: send the latest image
self._publish_one(self.devices[uuid]["cast"])
- elif previous and not enabled: # disabling: disconnect
+ elif previous and not enabled: # disabling: disconnect
self._disconnect(self.devices[uuid]["cast"])
def get_devices(self) -> List[DeviceStatus]:
- '''
+ """
Get the current list of known Chromecast devices and whether they are
currently enabled.
- '''
+ """
devs: List[DeviceStatus] = []
for uuid, state in self.devices.items():
- devs.append(DeviceStatus(uuid, state["cast"].cast_info.friendly_name, state["enabled"]))
+ devs.append(
+ DeviceStatus(
+ uuid, state["cast"].cast_info.friendly_name, state["enabled"]
+ )
+ )
return devs
def publish(self, image: Image.Image) -> None:
- '''
+ """
Publish a new image to the currently enabled Chromecast devices.
- '''
- with sentry_sdk.start_transaction(op="publish_image", name="Publish image") as txn:
+ """
+ with sentry_sdk.start_transaction(
+ op="publish_image", name="Publish image"
+ ) as txn:
num = len([x for x in self.devices.values() if x["enabled"]])
txn.set_tag("enabled_cc", num)
self.image = image
@@ -171,9 +184,11 @@ def _publish_one(self, cast: pychromecast.Chromecast) -> None:
def _start_webserver(self) -> None:
parent = self
+
class WSHandler(BaseHTTPRequestHandler):
"""Handle web requests coming from the CCs"""
- def do_GET(self): # pylint: disable=invalid-name
+
+ def do_GET(self): # pylint: disable=invalid-name
"""Respond to CC w/ the current image"""
with sentry_sdk.start_transaction(op="http", name="GET"):
self.send_response(200)
@@ -181,12 +196,14 @@ def do_GET(self): # pylint: disable=invalid-name
self.end_headers()
if parent.image is not None:
parent.image.save(self.wfile, "PNG", optimize=True)
+
def log_message(self, format, *args): # pylint: disable=redefined-builtin
pass # Don't log anything
def _webserver_run():
web_server = HTTPServer(("", self._server_port), WSHandler)
web_server.serve_forever()
+
self._webserver_thread = threading.Thread(target=_webserver_run, daemon=True)
self._webserver_thread.start()
@@ -198,15 +215,19 @@ def _refresh_run():
time.sleep(_REFRESH_INTERVAL)
if self.image is not None:
self.publish(self.image)
+
self._refresh_thread = threading.Thread(target=_refresh_run, daemon=True)
self._refresh_thread.start()
def _start_listener(self) -> None:
parent = self
+
class Listener(pychromecast.discovery.AbstractCastListener):
"""Receive chromecast discovery updates"""
+
def add_cast(self, uuid: UUID, service):
self.update_cast(uuid, service)
+
def remove_cast(self, uuid: UUID, service, cast_info):
try:
del parent.devices[uuid]
@@ -216,13 +237,17 @@ def remove_cast(self, uuid: UUID, service, cast_info):
pass
if parent.callback_fn is not None:
parent.callback_fn()
+
def update_cast(self, uuid: UUID, service) -> None:
- with sentry_sdk.start_transaction(op="cc_update",
- name="Chromecast update recieved"):
+ with sentry_sdk.start_transaction(
+ op="cc_update", name="Chromecast update recieved"
+ ):
if parent.browser is None:
return
svcs = parent.browser.services
- cast = pychromecast.get_chromecast_from_cast_info(svcs[uuid], parent.zconf)
+ cast = pychromecast.get_chromecast_from_cast_info(
+ svcs[uuid], parent.zconf
+ )
cast.wait(timeout=2)
# We only care about devices that we can cast to (i.e., not
# audio devices)
@@ -230,21 +255,17 @@ def update_cast(self, uuid: UUID, service) -> None:
cast.disconnect(blocking=False)
return
if uuid not in parent.devices:
- parent.devices[uuid] = {
- "cast": cast,
- "enabled": False
- }
+ parent.devices[uuid] = {"cast": cast, "enabled": False}
else:
cast.disconnect(blocking=False)
if parent.callback_fn is not None:
parent.callback_fn()
+
self.zconf = zeroconf.Zeroconf()
self.browser = pychromecast.discovery.CastBrowser(Listener(), self.zconf)
self.browser.start_discovery()
-
-
def _main():
"""Simple test of the ImageCast class."""
image = Image.open("file.png")
@@ -266,5 +287,6 @@ def callback():
time.sleep(60)
imgcast.stop()
+
if __name__ == "__main__":
_main()
diff --git a/main_window.py b/main_window.py
index 68481b10..3c95d196 100644
--- a/main_window.py
+++ b/main_window.py
@@ -14,18 +14,19 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''Main window'''
+"""Main window"""
import os
import sys
from tkinter import FALSE, HORIZONTAL, Menu, StringVar, TclError, Tk, Widget, ttk
-import ttkwidgets #type: ignore
-import ttkwidgets.font #type: ignore
+
+import ttkwidgets # type: ignore
+import ttkwidgets.font # type: ignore
from PIL import ImageTk
+import widgets
from model import Model
from tooltip import ToolTip
-import widgets
_PADDING = 2
_TXT_X_PAD = 5
@@ -34,7 +35,8 @@
class View(ttk.Frame):
- '''Main window view definition'''
+ """Main window view definition"""
+
def __init__(self, root: Tk, vm: Model):
super().__init__(root)
self._root = root
@@ -44,12 +46,14 @@ def __init__(self, root: Tk, vm: Model):
# Fix the window to 1/2 the size of the screen so that it's manageable
root.resizable(False, False)
root.geometry(f"{1366//2}x{768//2}")
- bundle_dir = getattr(sys, '_MEIPASS', os.path.abspath(os.path.dirname(__file__)))
- icon_file = os.path.abspath(os.path.join(bundle_dir, 'media', 'wr-icon.ico'))
+ bundle_dir = getattr(
+ sys, "_MEIPASS", os.path.abspath(os.path.dirname(__file__))
+ )
+ icon_file = os.path.abspath(os.path.join(bundle_dir, "media", "wr-icon.ico"))
root.iconphoto(True, ImageTk.PhotoImage(file=icon_file)) # type: ignore
try:
root.iconbitmap(icon_file)
- except TclError: # On linux, we can't set a Windows icon file
+ except TclError: # On linux, we can't set a Windows icon file
pass
# Insert ourselves into the main window
self.pack(side="top", fill="both", expand=True)
@@ -61,18 +65,28 @@ def __init__(self, root: Tk, vm: Model):
self.columnconfigure(0, weight=1)
book = ttk.Notebook(self)
book.grid(column=0, row=0, sticky="news")
- book.add(_configTab(book, self._vm), text="Configuration", underline=0, sticky="news")
- book.add(_dirsTab(book, self._vm), text="Directories", underline=0, sticky="news")
+ book.add(
+ _configTab(book, self._vm), text="Configuration", underline=0, sticky="news"
+ )
+ book.add(
+ _dirsTab(book, self._vm), text="Directories", underline=0, sticky="news"
+ )
book.add(_runTab(book, self._vm), text="Run", underline=0, sticky="news")
book.enable_traversal() # So that Alt- switches tabs
statusbar = ttk.Frame(self, padding=_PADDING)
statusbar.grid(column=0, row=1, sticky="news")
statusbar.columnconfigure(0, weight=1)
- ttk.Label(statusbar, textvariable=self._vm.version, justify="right",
- relief="sunken").grid(column=1, row=0, sticky="news")
- statustext = ttk.Label(statusbar, textvariable=self._vm.statustext,
- justify="left", relief="sunken", foreground="blue")
+ ttk.Label(
+ statusbar, textvariable=self._vm.version, justify="right", relief="sunken"
+ ).grid(column=1, row=0, sticky="news")
+ statustext = ttk.Label(
+ statusbar,
+ textvariable=self._vm.statustext,
+ justify="left",
+ relief="sunken",
+ foreground="blue",
+ )
statustext.grid(column=0, row=0, sticky="news")
statustext.bind("", lambda *_: self._vm.statusclick.run())
@@ -85,24 +99,34 @@ def __init__(self, root: Tk, vm: Model):
style.configure("TLabelframe", padding=_PADDING)
def _build_menu(self) -> None:
- '''Creates the dropdown menus'''
- self._root.option_add('*tearOff', FALSE) # We don't use tear-off menus
+ """Creates the dropdown menus"""
+ self._root.option_add("*tearOff", FALSE) # We don't use tear-off menus
menubar = Menu(self)
- self._root['menu'] = menubar
+ self._root["menu"] = menubar
# File menu
file_menu = Menu(menubar)
- menubar.add_cascade(menu=file_menu, label='File', underline=0)
- file_menu.add_command(label='Save scoreboard...', underline=0,
- command=self._vm.menu_save_scoreboard.run)
- file_menu.add_command(label='Export template...', underline=0,
- command=self._vm.menu_export_template.run)
+ menubar.add_cascade(menu=file_menu, label="File", underline=0)
+ file_menu.add_command(
+ label="Save scoreboard...",
+ underline=0,
+ command=self._vm.menu_save_scoreboard.run,
+ )
+ file_menu.add_command(
+ label="Export template...",
+ underline=0,
+ command=self._vm.menu_export_template.run,
+ )
file_menu.add_separator()
- file_menu.add_command(label='Exit', underline=1, command=self._vm.menu_exit.run)
+ file_menu.add_command(label="Exit", underline=1, command=self._vm.menu_exit.run)
# Help menu
help_menu = Menu(menubar, name="help")
- menubar.add_cascade(menu=help_menu, label='Help', underline=0)
- help_menu.add_command(label='Documentation', underline=0, command=self._vm.menu_docs.run)
- help_menu.add_command(label='About', underline=0, command=self._vm.menu_about.run)
+ menubar.add_cascade(menu=help_menu, label="Help", underline=0)
+ help_menu.add_command(
+ label="Documentation", underline=0, command=self._vm.menu_docs.run
+ )
+ help_menu.add_command(
+ label="About", underline=0, command=self._vm.menu_about.run
+ )
class _configTab(ttk.Frame):
@@ -116,41 +140,64 @@ def __init__(self, parent: Widget, vm: Model) -> None:
self._options_frame(self).grid(column=1, row=0, sticky="news")
self._preview(self).grid(column=1, row=1, sticky="news")
- def _appearance(self, parent: Widget) -> Widget: # pylint: disable=too-many-statements
+ def _appearance(
+ self, parent: Widget
+ ) -> Widget: # pylint: disable=too-many-statements
mainframe = ttk.LabelFrame(parent, text="Appearance")
txt_frame = ttk.Frame(mainframe)
txt_frame.pack(side="top", fill="x")
txt_frame.columnconfigure(1, weight=1) # col 1 gets any extra space
- ttk.Label(txt_frame, text="Main font:", anchor="e").grid(column=0, row=0, sticky="news")
- main_dd = ttkwidgets.font.FontFamilyDropdown(txt_frame, self._vm.font_normal.set)
+ ttk.Label(txt_frame, text="Main font:", anchor="e").grid(
+ column=0, row=0, sticky="news"
+ )
+ main_dd = ttkwidgets.font.FontFamilyDropdown(
+ txt_frame, self._vm.font_normal.set
+ )
main_dd.grid(column=1, row=0, sticky="news", pady=_PADDING)
ToolTip(main_dd, "Main font used for scoreboard text")
# Update dropdown if textvar is changed
- self._vm.font_normal.trace_add("write",
- lambda *_: main_dd.set(self._vm.font_normal.get()))
+ self._vm.font_normal.trace_add(
+ "write", lambda *_: main_dd.set(self._vm.font_normal.get())
+ )
# Set initial value
main_dd.set(self._vm.font_normal.get())
- ttk.Label(txt_frame, text="Time font:", anchor="e").grid(column=0, row=1, sticky="news")
- time_dd = ttkwidgets.font.FontFamilyDropdown(txt_frame, self._vm.font_time.set)
+ ttk.Label(txt_frame, text="Time font:", anchor="e").grid(
+ column=0, row=1, sticky="news"
+ )
+ time_dd = ttkwidgets.font.FontFamilyDropdown(txt_frame, self._vm.font_time.set)
time_dd.grid(column=1, row=1, sticky="news", pady=_PADDING)
- ToolTip(time_dd, "Font for displaying the times - Recommended: fixed-width font")
+ ToolTip(
+ time_dd, "Font for displaying the times - Recommended: fixed-width font"
+ )
# Update dropdown if textvar is changed
- self._vm.font_time.trace_add("write",
- lambda *_: time_dd.set(self._vm.font_time.get()))
+ self._vm.font_time.trace_add(
+ "write", lambda *_: time_dd.set(self._vm.font_time.get())
+ )
# Set initial value
time_dd.set(self._vm.font_time.get())
- ttk.Label(txt_frame, text="Title:", anchor="e").grid(column=0, row=2, sticky="news")
+ ttk.Label(txt_frame, text="Title:", anchor="e").grid(
+ column=0, row=2, sticky="news"
+ )
hentry = ttk.Entry(txt_frame, textvariable=self._vm.title)
hentry.grid(column=1, row=2, sticky="news", pady=_PADDING)
ToolTip(hentry, "Title text to display")
- ttk.Label(txt_frame, text="Text spacing:", anchor="e").grid(column=0, row=3, sticky="news")
- spspin = ttk.Spinbox(txt_frame, from_=0.8, to=2.0, increment=0.05, width=4, format="%0.2f",
- textvariable=self._vm.text_spacing)
+ ttk.Label(txt_frame, text="Text spacing:", anchor="e").grid(
+ column=0, row=3, sticky="news"
+ )
+ spspin = ttk.Spinbox(
+ txt_frame,
+ from_=0.8,
+ to=2.0,
+ increment=0.05,
+ width=4,
+ format="%0.2f",
+ textvariable=self._vm.text_spacing,
+ )
spspin.grid(column=1, row=3, sticky="nws", pady=_PADDING)
ToolTip(spspin, "Vertical space between text lines")
@@ -161,77 +208,138 @@ def _appearance(self, parent: Widget) -> Widget: # pylint: disable=too-many-sta
colorframe.columnconfigure(1, weight=1)
colorframe.columnconfigure(3, weight=1)
# 1st col
- ttk.Label(colorframe, text="Heading:", anchor="e").grid(column=0, row=0, sticky="news")
- widgets.ColorButton2(colorframe, color_var=self._vm.color_title).grid(column=1,
- row=0, sticky="nws", pady=_PADDING)
- ttk.Label(colorframe, text="Event:", anchor="e").grid(column=0, row=1, sticky="news")
- widgets.ColorButton2(colorframe, color_var=self._vm.color_event).grid(column=1,
- row=1, sticky="nws", pady=_PADDING)
- ttk.Label(colorframe, text="Odd rows:", anchor="e").grid(column=0, row=2, sticky="news")
- widgets.ColorButton2(colorframe, color_var=self._vm.color_odd).grid(column=1,
- row=2, sticky="nws", pady=_PADDING)
- ttk.Label(colorframe, text="Even rows:", anchor="e").grid(column=0, row=3, sticky="news")
- widgets.ColorButton2(colorframe, color_var=self._vm.color_even).grid(column=1,
- row=3, sticky="nws", pady=_PADDING)
+ ttk.Label(colorframe, text="Heading:", anchor="e").grid(
+ column=0, row=0, sticky="news"
+ )
+ widgets.ColorButton2(colorframe, color_var=self._vm.color_title).grid(
+ column=1, row=0, sticky="nws", pady=_PADDING
+ )
+ ttk.Label(colorframe, text="Event:", anchor="e").grid(
+ column=0, row=1, sticky="news"
+ )
+ widgets.ColorButton2(colorframe, color_var=self._vm.color_event).grid(
+ column=1, row=1, sticky="nws", pady=_PADDING
+ )
+ ttk.Label(colorframe, text="Odd rows:", anchor="e").grid(
+ column=0, row=2, sticky="news"
+ )
+ widgets.ColorButton2(colorframe, color_var=self._vm.color_odd).grid(
+ column=1, row=2, sticky="nws", pady=_PADDING
+ )
+ ttk.Label(colorframe, text="Even rows:", anchor="e").grid(
+ column=0, row=3, sticky="news"
+ )
+ widgets.ColorButton2(colorframe, color_var=self._vm.color_even).grid(
+ column=1, row=3, sticky="nws", pady=_PADDING
+ )
# 2nd col
- ttk.Label(colorframe, text="1st place:", anchor="e").grid(column=2, row=0, sticky="news")
- widgets.ColorButton2(colorframe, color_var=self._vm.color_first).grid(column=3,
- row=0, sticky="nws", pady=_PADDING)
- ttk.Label(colorframe, text="2nd place:", anchor="e").grid(column=2, row=1, sticky="news")
- widgets.ColorButton2(colorframe, color_var=self._vm.color_second).grid(column=3,
- row=1, sticky="nws", pady=_PADDING)
- ttk.Label(colorframe, text="3rd place:", anchor="e").grid(column=2, row=2, sticky="news")
- widgets.ColorButton2(colorframe, color_var=self._vm.color_third).grid(column=3,
- row=2, sticky="nws", pady=_PADDING)
- ttk.Label(colorframe, text="Background:", anchor="e").grid(column=2, row=3, sticky="news")
- widgets.ColorButton2(colorframe, color_var=self._vm.color_bg).grid(column=3,
- row=3, sticky="nws", pady=_PADDING)
+ ttk.Label(colorframe, text="1st place:", anchor="e").grid(
+ column=2, row=0, sticky="news"
+ )
+ widgets.ColorButton2(colorframe, color_var=self._vm.color_first).grid(
+ column=3, row=0, sticky="nws", pady=_PADDING
+ )
+ ttk.Label(colorframe, text="2nd place:", anchor="e").grid(
+ column=2, row=1, sticky="news"
+ )
+ widgets.ColorButton2(colorframe, color_var=self._vm.color_second).grid(
+ column=3, row=1, sticky="nws", pady=_PADDING
+ )
+ ttk.Label(colorframe, text="3rd place:", anchor="e").grid(
+ column=2, row=2, sticky="news"
+ )
+ widgets.ColorButton2(colorframe, color_var=self._vm.color_third).grid(
+ column=3, row=2, sticky="nws", pady=_PADDING
+ )
+ ttk.Label(colorframe, text="Background:", anchor="e").grid(
+ column=2, row=3, sticky="news"
+ )
+ widgets.ColorButton2(colorframe, color_var=self._vm.color_bg).grid(
+ column=3, row=3, sticky="nws", pady=_PADDING
+ )
ttk.Separator(mainframe, orient=HORIZONTAL).pack(side="top", fill="x", pady=10)
bgframelabels = ttk.Frame(mainframe)
bgframelabels.pack(side="top", fill="x")
- ttk.Label(bgframelabels, text="Background image:", anchor="e").pack(side="left",
- fill="both")
+ ttk.Label(bgframelabels, text="Background image:", anchor="e").pack(
+ side="left", fill="both"
+ )
self._bg_img_label = StringVar()
- ttk.Label(bgframelabels, textvariable=self._bg_img_label, anchor="w",
- relief="sunken").pack(side="left", fill="both", expand=1)
- self._vm.image_bg.trace_add("write", lambda *_:
- self._bg_img_label.set(os.path.basename(self._vm.image_bg.get())[-20:]))
+ ttk.Label(
+ bgframelabels, textvariable=self._bg_img_label, anchor="w", relief="sunken"
+ ).pack(side="left", fill="both", expand=1)
+ self._vm.image_bg.trace_add(
+ "write",
+ lambda *_: self._bg_img_label.set(
+ os.path.basename(self._vm.image_bg.get())[-20:]
+ ),
+ )
self._vm.image_bg.set(self._vm.image_bg.get())
ToolTip(bgframelabels, "Scoreboard background image - Recommended: 1280x720")
bgframebtns = ttk.Frame(mainframe)
bgframebtns.pack(side="top", fill="x")
- ttk.Button(bgframebtns, text="Import...", command=self._vm.bg_import.run).pack(side="left",
- fill="both", expand=1, padx=_PADDING, pady=_PADDING)
- ttk.Button(bgframebtns, text="Clear", command=self._vm.bg_clear.run).pack(side="left",
- fill="both", expand=1, padx=_PADDING, pady=_PADDING)
+ ttk.Button(bgframebtns, text="Import...", command=self._vm.bg_import.run).pack(
+ side="left", fill="both", expand=1, padx=_PADDING, pady=_PADDING
+ )
+ ttk.Button(bgframebtns, text="Clear", command=self._vm.bg_clear.run).pack(
+ side="left", fill="both", expand=1, padx=_PADDING, pady=_PADDING
+ )
return mainframe
def _options_frame(self, parent: Widget) -> Widget:
opt_frame = ttk.LabelFrame(parent, text="Options")
- ttk.Label(opt_frame, text="Lanes:", anchor="e").grid(column=0, row=0, sticky="news")
- lspin = ttk.Spinbox(opt_frame, from_=6, to=10, increment=1, width=3,
- textvariable=self._vm.num_lanes)
+ ttk.Label(opt_frame, text="Lanes:", anchor="e").grid(
+ column=0, row=0, sticky="news"
+ )
+ lspin = ttk.Spinbox(
+ opt_frame,
+ from_=6,
+ to=10,
+ increment=1,
+ width=3,
+ textvariable=self._vm.num_lanes,
+ )
lspin.grid(column=1, row=0, sticky="news", pady=_PADDING)
ToolTip(lspin, "Number of lanes to display")
- ttk.Label(opt_frame, text="Minimum times:", anchor="e").grid(column=0, row=1, sticky="news")
- tspin = ttk.Spinbox(opt_frame, from_=1, to=3, increment=1, width=3,
- textvariable=self._vm.min_times)
+ ttk.Label(opt_frame, text="Minimum times:", anchor="e").grid(
+ column=0, row=1, sticky="news"
+ )
+ tspin = ttk.Spinbox(
+ opt_frame,
+ from_=1,
+ to=3,
+ increment=1,
+ width=3,
+ textvariable=self._vm.min_times,
+ )
tspin.grid(column=1, row=1, sticky="news", pady=_PADDING)
- ToolTip(tspin, 'Lanes with fewer than this number of raw times will' +
- ' display dashes instead of a time')
-
- ttk.Label(opt_frame, text="Time threshold:", anchor="e").grid(column=0, row=2,
- sticky="news")
- thresh = ttk.Spinbox(opt_frame, from_=0.01, to=9.99, increment=0.1, width=4,
- textvariable=self._vm.time_threshold)
+ ToolTip(
+ tspin,
+ "Lanes with fewer than this number of raw times will"
+ + " display dashes instead of a time",
+ )
+
+ ttk.Label(opt_frame, text="Time threshold:", anchor="e").grid(
+ column=0, row=2, sticky="news"
+ )
+ thresh = ttk.Spinbox(
+ opt_frame,
+ from_=0.01,
+ to=9.99,
+ increment=0.1,
+ width=4,
+ textvariable=self._vm.time_threshold,
+ )
thresh.grid(column=1, row=2, sticky="news", pady=_PADDING)
- ToolTip(thresh, "Lanes with any raw times that differ from the final" +
- " time more than this amount will display dashes")
+ ToolTip(
+ thresh,
+ "Lanes with any raw times that differ from the final"
+ + " time more than this amount will display dashes",
+ )
return opt_frame
@@ -242,6 +350,7 @@ def _preview(self, parent: Widget) -> Widget:
ToolTip(frame, "Mockup of how the scoreboard will look")
return frame
+
class _dirsTab(ttk.Frame):
def __init__(self, parent: Widget, vm: Model) -> None:
super().__init__(parent)
@@ -253,32 +362,42 @@ def __init__(self, parent: Widget, vm: Model) -> None:
self._race_results(self).grid(column=1, row=0, sticky="news", padx=1, pady=1)
def _start_list(self, parent: Widget) -> Widget:
- frame = ttk.LabelFrame(parent, text='Start lists')
+ frame = ttk.LabelFrame(parent, text="Start lists")
dirsel = widgets.DirSelection(frame, self._vm.dir_startlist)
dirsel.grid(column=0, row=0, sticky="news", padx=1, pady=1)
ToolTip(dirsel, "Directory where start list (*.scb) files are located")
sltv = widgets.StartListTreeView(frame, self._vm.startlist_contents)
sltv.grid(column=0, row=1, sticky="news", padx=1, pady=1)
ToolTip(sltv, "List of events found in the start list directory")
- expbtn = ttk.Button(frame, padding=(8, 0), text="Export events to Dolphin...",
- command=self._vm.dolphin_export.run)
+ expbtn = ttk.Button(
+ frame,
+ padding=(8, 0),
+ text="Export events to Dolphin...",
+ command=self._vm.dolphin_export.run,
+ )
expbtn.grid(column=0, row=2, padx=1, pady=1)
- ToolTip(expbtn, 'Create "dolphin_events.csv" event list for import' +
- ' into CTS Dolphin software')
+ ToolTip(
+ expbtn,
+ 'Create "dolphin_events.csv" event list for import'
+ + " into CTS Dolphin software",
+ )
frame.columnconfigure(0, weight=1)
frame.rowconfigure(1, weight=1)
return frame
def _race_results(self, parent: Widget) -> Widget:
- frame = ttk.LabelFrame(parent, text='Race results')
- widgets.DirSelection(frame, self._vm.dir_results).grid(column=0, row=0,
- sticky="news", padx=1, pady=1)
- widgets.RaceResultTreeView(frame, self._vm.results_contents).grid(column=0,
- row=1, sticky="news", padx=1, pady=1)
+ frame = ttk.LabelFrame(parent, text="Race results")
+ widgets.DirSelection(frame, self._vm.dir_results).grid(
+ column=0, row=0, sticky="news", padx=1, pady=1
+ )
+ widgets.RaceResultTreeView(frame, self._vm.results_contents).grid(
+ column=0, row=1, sticky="news", padx=1, pady=1
+ )
frame.columnconfigure(0, weight=1)
frame.rowconfigure(1, weight=1)
return frame
+
class _runTab(ttk.Frame):
def __init__(self, parent: Widget, vm: Model) -> None:
super().__init__(parent)
diff --git a/model.py b/model.py
index 23c681fb..059742ae 100644
--- a/model.py
+++ b/model.py
@@ -14,32 +14,36 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''Data model'''
+"""Data model"""
-from configparser import ConfigParser
import queue
+import uuid
+from configparser import ConfigParser
from tkinter import BooleanVar, DoubleVar, IntVar, StringVar, Tk, Variable
from typing import Callable, Generic, List, Optional, Set, TypeVar
-import uuid
+
import PIL.Image as PILImage
+
+from imagecast import DeviceStatus
from racetimes import RaceTimes
from startlist import StartList
-from imagecast import DeviceStatus
CallbackFn = Callable[[], None]
_INI_HEADING = "wahoo-results"
-_T = TypeVar('_T')
+_T = TypeVar("_T")
+
class GVar(Variable, Generic[_T]):
- '''
+ """
Create a generic variable in the flavor of StringVar, IntVar, etc.
- master: the master widget.
- value: the initial value for the variable
- '''
- def __init__(self, value:_T, master=None):
+ """
+
+ def __init__(self, value: _T, master=None):
super().__init__(master=master, value=0)
self._value = value
@@ -48,55 +52,67 @@ def get(self) -> _T:
_x = super().get()
return self._value
- def set(self, value:_T) -> None:
+ def set(self, value: _T) -> None:
"""Sets the variable to a new value."""
self._value = value
super().set(super().get() + 1)
+
class ChromecastStatusVar(GVar[List[DeviceStatus]]):
"""Holds a list of Chromecast devices and whether they are enabled"""
+
class ImageVar(GVar[PILImage.Image]):
"""Value holder for PhotoImage variables."""
+
class CallbackList:
- '''A list of callback functions'''
+ """A list of callback functions"""
+
_callbacks: Set[CallbackFn]
+
def __init__(self):
self._callbacks = set()
+
def run(self) -> None:
- '''Invoke all registered callback functions'''
+ """Invoke all registered callback functions"""
for func in self._callbacks:
func()
+
def add(self, callback) -> None:
- '''Add a callback function to the set'''
+ """Add a callback function to the set"""
self._callbacks.add(callback)
+
def remove(self, callback) -> None:
- '''Remove a callback function from the set'''
+ """Remove a callback function from the set"""
self._callbacks.discard(callback)
+
class StartListVar(GVar[List[StartList]]):
- '''An ordered list of start lists'''
+ """An ordered list of start lists"""
+
class RaceResultListVar(GVar[List[RaceTimes]]):
"""Holds an ordered list of race results."""
+
class RaceResultVar(GVar[Optional[RaceTimes]]):
- '''A race result'''
+ """A race result"""
+
-class Model: # pylint: disable=too-many-instance-attributes,too-few-public-methods
- '''Defines the state variables (model) for the main UI'''
+class Model: # pylint: disable=too-many-instance-attributes,too-few-public-methods
+ """Defines the state variables (model) for the main UI"""
## Colors from USA-S visual identity standards
- PANTONE282_DKBLUE = "#041e42" # Primary
- PANTONE200_RED = "#ba0c2f" # Primary
- BLACK = "#000000" # Secondary
- PANTONE428_LTGRAY = "#c1c6c8" # Secondary
+ PANTONE282_DKBLUE = "#041e42" # Primary
+ PANTONE200_RED = "#ba0c2f" # Primary
+ BLACK = "#000000" # Secondary
+ PANTONE428_LTGRAY = "#c1c6c8" # Secondary
PANTONE877METALIC_MDGRAY = "#8a8d8f" # Secondary
- PANTONE281_MDBLUE = "#00205b" # Tertiary
- PANTONE306_LTBLUE = "#00b3e4" # Tertiary
- PANTONE871METALICGOLD = "#85754e" # Tertiary
- PANTONE4505FLATGOLD = "#b1953a" # Tertiary
+ PANTONE281_MDBLUE = "#00205b" # Tertiary
+ PANTONE306_LTBLUE = "#00b3e4" # Tertiary
+ PANTONE871METALICGOLD = "#85754e" # Tertiary
+ PANTONE4505FLATGOLD = "#b1953a" # Tertiary
_ENQUEUE_EVENT = "<>"
@@ -156,7 +172,7 @@ def __init__(self, root: Tk):
self.statusclick = CallbackList()
def load(self, filename: str) -> None:
- '''Load user's preferences'''
+ """Load user's preferences"""
config = ConfigParser()
config.read(filename, encoding="utf-8")
if _INI_HEADING not in config:
@@ -195,7 +211,7 @@ def load(self, filename: str) -> None:
self.analytics.set(data.getboolean("analytics", True))
def save(self, filename: str) -> None:
- '''Save user's preferences'''
+ """Save user's preferences"""
config = ConfigParser()
config[_INI_HEADING] = {
"font_normal": self.font_normal.get(),
@@ -223,7 +239,7 @@ def save(self, filename: str) -> None:
config.write(file)
def enqueue(self, func: Callable[[], None]) -> None:
- '''Enqueue a function to be executed by the tkinter main thread'''
+ """Enqueue a function to be executed by the tkinter main thread"""
self._event_queue.put(func)
self.root.event_generate(self._ENQUEUE_EVENT, when="tail")
diff --git a/racetimes.py b/racetimes.py
index 904851b9..ffeb1fc3 100644
--- a/racetimes.py
+++ b/racetimes.py
@@ -14,30 +14,33 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''The times (raw and calculated) from a race'''
+"""The times (raw and calculated) from a race"""
-from abc import ABC, abstractmethod
import copy
-from dataclasses import dataclass
-from datetime import datetime
-from decimal import Decimal, ROUND_DOWN
import io
import os
import re
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from datetime import datetime
+from decimal import ROUND_DOWN, Decimal
from typing import List, Optional
from startlist import StartList
RawTime = Decimal
+
@dataclass
class Time:
- '''Class to represent a result time.'''
+ """Class to represent a result time."""
+
value: RawTime # The measured (or calculated) time to the hundredths
is_valid: bool # True if the time is valid/consistent/within bounds
+
def _truncate_hundredths(time: RawTime) -> RawTime:
- '''
+ """
Truncates a Time to two decimal places.
>>> _truncate_hundredths(RawTime('100.00'))
@@ -48,12 +51,12 @@ def _truncate_hundredths(time: RawTime) -> RawTime:
Decimal('10.98')
>>> _truncate_hundredths(RawTime('100.123'))
Decimal('100.12')
- '''
+ """
return time.quantize(Decimal("0.01"), rounding=ROUND_DOWN)
class RaceTimes(ABC):
- '''
+ """
Abstract class representing the times from a race.
This should be instantiated from a derived class based on a particular
@@ -62,9 +65,10 @@ class RaceTimes(ABC):
set_names() with a StartList. Once the StartList has been added, the
RaceTimes object should be sufficient to render the scoreboard information
for the heat.
- '''
+ """
+
def __init__(self, min_times: int, threshold: RawTime):
- '''
+ """
Parameters:
- min_times: The minimum number of times required for the lane time to
be considered "valid"
@@ -72,52 +76,54 @@ def __init__(self, min_times: int, threshold: RawTime):
final time and each measured time in order for the final time to be
considered valid. Any individual times outside teh threshold are
also marked invalid.
- '''
+ """
self.min_times = min_times
self.threshold = threshold
self._startlist = StartList()
self._has_names = False
def set_names(self, start_list: StartList) -> None:
- '''Set the names/teams for the race'''
+ """Set the names/teams for the race"""
self._startlist = copy.deepcopy(start_list)
self._has_names = True
def name(self, lane: int) -> str:
- '''The Swimmer's name'''
+ """The Swimmer's name"""
return self._startlist.name(self.heat, lane)
def team(self, lane: int) -> str:
- '''The Swimmer's team'''
+ """The Swimmer's team"""
return self._startlist.team(self.heat, lane)
@property
def event_name(self) -> str:
- '''The event description'''
+ """The event description"""
return self._startlist.event_name
def is_noshow(self, lane: int) -> bool:
- '''True if a swimmer should be in the lane, but no time was recorded'''
- return not self._startlist.is_empty_lane(self.heat, lane) \
+ """True if a swimmer should be in the lane, but no time was recorded"""
+ return (
+ not self._startlist.is_empty_lane(self.heat, lane)
and self.final_time(lane).value == 0
+ )
@abstractmethod
def raw_times(self, lane: int) -> List[Optional[RawTime]]:
- '''
+ """
Retrieve the measured times from the specified lane.
The returned List will always be of length 3, but one or more
elements may be None if no time was reported.
- '''
+ """
return [None, None, None]
def times(self, lane: int) -> List[Optional[Time]]:
- '''
+ """
Retrieve the measured times and their validity for the specified lane.
The returned List will always be of length 3, but one or more
elements may be None if no time was reported.
- '''
+ """
final = self.final_time(lane)
times: List[Optional[Time]] = []
for time in self.raw_times(lane):
@@ -129,13 +135,13 @@ def times(self, lane: int) -> List[Optional[Time]]:
return times
def final_time(self, lane: int) -> Time:
- '''Retrieve the calculated final time for a lane'''
+ """Retrieve the calculated final time for a lane"""
times: List[RawTime] = []
for time in self.raw_times(lane):
if time is not None:
times.append(time)
- if len(times) == 3: # 3 times -> median
+ if len(times) == 3: # 3 times -> median
times.sort()
final = times[1]
elif len(times) == 2: # 2 times -> average
@@ -156,7 +162,7 @@ def final_time(self, lane: int) -> Time:
return Time(final, valid)
def place(self, lane: int) -> Optional[int]:
- '''
+ """
Returns the finishing place within the heat for a given lane.
- A lane whose time is considered not valid will not be assigned a
@@ -165,7 +171,7 @@ def place(self, lane: int) -> Optional[int]:
subsequent place will not be awarded. For example, 2 lanes tie for
2nd: both will receive a place of "2", and no lanes will receive a
"3". The next will be awarded "4".
- '''
+ """
this_lane = self.final_time(lane)
if not this_lane.is_valid:
# Invalid times don't get a place
@@ -180,42 +186,50 @@ def place(self, lane: int) -> Optional[int]:
@property
def has_names(self) -> bool:
- '''Whether this race has name/team information'''
+ """Whether this race has name/team information"""
return self._has_names
@property
@abstractmethod
def event(self) -> int:
- '''Event number for this race'''
+ """Event number for this race"""
return 0
@property
@abstractmethod
def heat(self) -> int:
- '''Heat number for this race'''
+ """Heat number for this race"""
return 0
@property
@abstractmethod
def time_recorded(self) -> datetime:
- '''The time when this race result was recorded'''
+ """The time when this race result was recorded"""
return datetime.now()
@property
@abstractmethod
def meet_id(self) -> str:
- '''Identifier for the meet to which this result belongs'''
+ """Identifier for the meet to which this result belongs"""
return "0"
+
class DO4(RaceTimes):
- '''
+ """
Implementation of RaceTimes class for CTS Dolphin w/ Splits (.do4 files).
- '''
- def __init__(self, stream: io.TextIOBase, min_times: int, threshold: RawTime, # pylint: disable=too-many-arguments
- when: datetime, meet_id: str):
- '''
+ """
+
+ def __init__(
+ self,
+ stream: io.TextIOBase,
+ min_times: int,
+ threshold: RawTime, # pylint: disable=too-many-arguments
+ when: datetime,
+ meet_id: str,
+ ):
+ """
Parse a text stream in D04 format into a RaceTimes object
- '''
+ """
super().__init__(min_times, threshold)
header = stream.readline()
match = re.match(r"^(\d+);(\d+);\w+;\w+$", header)
@@ -235,7 +249,7 @@ def __init__(self, stream: io.TextIOBase, min_times: int, threshold: RawTime, #
if not match:
raise ValueError("Unable to parse times")
lane_times: List[Optional[RawTime]] = []
- for index in range(1,4):
+ for index in range(1, 4):
match_txt = match.group(index)
time = RawTime(0)
if match_txt != "":
@@ -247,7 +261,7 @@ def __init__(self, stream: io.TextIOBase, min_times: int, threshold: RawTime, #
self._lanes.append(lane_times)
def raw_times(self, lane: int) -> List[Optional[RawTime]]:
- return self._lanes[lane-1]
+ return self._lanes[lane - 1]
@property
def event(self) -> int:
@@ -259,15 +273,16 @@ def heat(self) -> int:
@property
def time_recorded(self) -> datetime:
- '''The time when this race result was recorded'''
+ """The time when this race result was recorded"""
return self._time_recorded
@property
def meet_id(self) -> str:
return self._meet_id
+
def from_do4(filename: str, min_times: int, threshold: RawTime) -> RaceTimes:
- '''Create a RaceTimes from a D04 race result file'''
+ """Create a RaceTimes from a D04 race result file"""
with open(filename, "r", encoding="cp1252") as file:
meet_id = "???"
meet_match = re.match(r"^(\d+)-", os.path.basename(filename))
diff --git a/racetimes_test.py b/racetimes_test.py
index e3831bc1..d2da7403 100644
--- a/racetimes_test.py
+++ b/racetimes_test.py
@@ -16,21 +16,25 @@
"""Tests for RaceTimes"""
-from datetime import datetime
import io
import textwrap
+from datetime import datetime
+
import pytest
-from racetimes import RaceTimes, DO4, Time, RawTime
+from racetimes import DO4, RaceTimes, RawTime, Time
from startlist import StartList
now = datetime.now()
meet_seven = "007"
+
@pytest.fixture
def do4_mising_one_time():
"""A D04 that is missing a time in lane 3"""
- return io.StringIO(textwrap.dedent("""\
+ return io.StringIO(
+ textwrap.dedent(
+ """\
69;1;1;All
Lane1;143.37;143.37;143.39
Lane2;135.15;135.39;135.20
@@ -42,12 +46,17 @@ def do4_mising_one_time():
Lane8;0;0;0
Lane9;0;0;0
Lane10;0;0;0
- 731146ABD1866BB3"""))
+ 731146ABD1866BB3"""
+ )
+ )
+
@pytest.fixture
def do4_big_delta():
"""A D04 that has an outlier in 1"""
- return io.StringIO(textwrap.dedent("""\
+ return io.StringIO(
+ textwrap.dedent(
+ """\
1;1;1;All
Lane1;160.72;130.63;130.61
Lane2;138.56;138.69;138.58
@@ -59,12 +68,17 @@ def do4_big_delta():
Lane8;0;0;0
Lane9;0;0;0
Lane10;0;0;0
- 2CBB478C916F0ADA"""))
+ 2CBB478C916F0ADA"""
+ )
+ )
+
@pytest.fixture
def do4_one_time():
"""Lane w/ only 1 time"""
- return io.StringIO(textwrap.dedent("""\
+ return io.StringIO(
+ textwrap.dedent(
+ """\
57;1;1;All
Lane1;;55.92;
Lane2;54.86;55.18;54.98
@@ -76,17 +90,21 @@ def do4_one_time():
Lane8;0;0;0
Lane9;0;0;0
Lane10;0;0;0
- 9EF6F5121A02D2D5"""))
+ 9EF6F5121A02D2D5"""
+ )
+ )
+
def test_can_parse_header(do4_mising_one_time) -> None:
"""Ensure we can parse the event/heat header"""
- race:RaceTimes = DO4(do4_mising_one_time, 2, RawTime(0.30), now, meet_seven)
+ race: RaceTimes = DO4(do4_mising_one_time, 2, RawTime(0.30), now, meet_seven)
assert race.event == 69
assert race.heat == 1
+
def test_resolve_times(do4_mising_one_time) -> None:
"""Ensure we can calculate final times correctly"""
- race:RaceTimes = DO4(do4_mising_one_time, 2, RawTime("0.30"), now, meet_seven)
+ race: RaceTimes = DO4(do4_mising_one_time, 2, RawTime("0.30"), now, meet_seven)
assert len(race.times(1)) == 3
assert race.final_time(1).is_valid
@@ -96,17 +114,19 @@ def test_resolve_times(do4_mising_one_time) -> None:
assert race.final_time(3).is_valid
assert race.final_time(3).value == RawTime("128.14")
+
def test_toofew_times(do4_mising_one_time) -> None:
"""Final time is invalid if too few raw times"""
- race:RaceTimes = DO4(do4_mising_one_time, 3, RawTime("0.30"), now, meet_seven)
+ race: RaceTimes = DO4(do4_mising_one_time, 3, RawTime("0.30"), now, meet_seven)
assert len(race.times(3)) == 3
assert not race.final_time(3).is_valid
assert race.final_time(3).value == RawTime("128.14")
+
def test_largedelta_times(do4_big_delta) -> None:
"""Final time is invalid if too few raw times"""
- race:RaceTimes = DO4(do4_big_delta, 2, RawTime("0.30"), now, meet_seven)
+ race: RaceTimes = DO4(do4_big_delta, 2, RawTime("0.30"), now, meet_seven)
assert len(race.times(1)) == 3
assert not race.final_time(1).is_valid
assert race.final_time(1).value == RawTime("130.63")
@@ -116,9 +136,10 @@ def test_largedelta_times(do4_big_delta) -> None:
assert times[1] == Time(RawTime("130.63"), True)
assert times[2] == Time(RawTime("130.61"), True)
+
def test_one_zero_times(do4_one_time) -> None:
"""Ensure we can calculate final times correctly"""
- race:RaceTimes = DO4(do4_one_time, 2, RawTime("0.30"), now, meet_seven)
+ race: RaceTimes = DO4(do4_one_time, 2, RawTime("0.30"), now, meet_seven)
assert race.times(1) == [None, Time(RawTime("55.92"), True), None]
assert not race.final_time(1).is_valid
@@ -128,9 +149,12 @@ def test_one_zero_times(do4_one_time) -> None:
assert not race.final_time(7).is_valid
assert race.final_time(7).value == RawTime("0")
+
def test_places() -> None:
"""Ensure we can calculate places correctly"""
- data = io.StringIO(textwrap.dedent("""\
+ data = io.StringIO(
+ textwrap.dedent(
+ """\
1;1;1;All
Lane1;55.92;55.92;
Lane2;54.86;54.86;
@@ -142,8 +166,10 @@ def test_places() -> None:
Lane8;0;0;0
Lane9;0;0;0
Lane10;0;0;0
- 9EF6F5121A02D2D5"""))
- race:RaceTimes = DO4(data, 2, RawTime("0.30"), now, meet_seven)
+ 9EF6F5121A02D2D5"""
+ )
+ )
+ race: RaceTimes = DO4(data, 2, RawTime("0.30"), now, meet_seven)
assert race.place(4) == 1 # Lane 4 is 1st
assert race.place(3) == 2 # Lane 3 is 2nd
assert race.place(2) == 3 # Lane 2 tied for 3rd
@@ -152,42 +178,49 @@ def test_places() -> None:
assert race.place(5) is None # Lane 5 has invalid time
assert race.place(8) is None # Lane 8 is empty
+
class MockStartList(StartList):
- '''Mock StartList'''
+ """Mock StartList"""
+
@property
def event_name(self) -> str:
- '''Get the event name (description)'''
+ """Get the event name (description)"""
return "Mock event name"
+
def name(self, _heat: int, _lane: int) -> str:
- '''Retrieve the Swimmer's name for a heat/lane'''
+ """Retrieve the Swimmer's name for a heat/lane"""
return f"Swimmer{_heat}:{_lane}"
+
def team(self, _heat: int, _lane: int) -> str:
- '''Retrieve the Swimmer's team for a heat/lane'''
+ """Retrieve the Swimmer's team for a heat/lane"""
return f"Team{_heat}:{_lane}"
+
def test_names(do4_one_time) -> None:
- race:RaceTimes = DO4(do4_one_time, 2, RawTime("0.30"), now, meet_seven)
+ race: RaceTimes = DO4(do4_one_time, 2, RawTime("0.30"), now, meet_seven)
race.set_names(MockStartList())
assert race.event_name == "Mock event name"
assert race.name(4) == "Swimmer1:4"
assert race.team(6) == "Team1:6"
+
def test_default_names(do4_one_time) -> None:
- race:RaceTimes = DO4(do4_one_time, 2, RawTime("0.30"), now, meet_seven)
+ race: RaceTimes = DO4(do4_one_time, 2, RawTime("0.30"), now, meet_seven)
assert race.event_name == ""
assert race.name(4) == ""
assert race.team(6) == ""
+
def test_noshow(do4_one_time) -> None:
- race:RaceTimes = DO4(do4_one_time, 2, RawTime("0.30"), now, meet_seven)
+ race: RaceTimes = DO4(do4_one_time, 2, RawTime("0.30"), now, meet_seven)
# We haven't loaded any names, so lanes w/o times should not be NS
- assert not race.is_noshow(1) # invalid, but not NS
+ assert not race.is_noshow(1) # invalid, but not NS
assert not race.is_noshow(2)
assert not race.is_noshow(9)
race.set_names(MockStartList())
- assert not race.is_noshow(1) # invalid, but not NS
+ assert not race.is_noshow(1) # invalid, but not NS
assert not race.is_noshow(2)
- assert race.is_noshow(9) # all lanes have names
+ assert race.is_noshow(9) # all lanes have names
diff --git a/scoreboard.py b/scoreboard.py
index bcada722..19fc63c2 100644
--- a/scoreboard.py
+++ b/scoreboard.py
@@ -14,22 +14,24 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''
+"""
Generates an image of the scoreboard from a RaceTimes object.
-'''
+"""
from typing import Optional, Tuple
-from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
-from matplotlib import font_manager # type: ignore
+
import sentry_sdk
+from matplotlib import font_manager # type: ignore
+from PIL import Image, ImageDraw, ImageFont, UnidentifiedImageError
from model import Model
from racetimes import RaceTimes, RawTime
-from startlist import format_name, NameMode
+from startlist import NameMode, format_name
+
def waiting_screen(size: Tuple[int, int], model: Model) -> Image.Image:
- '''Generate a "waiting" image to display on the scoreboard.'''
+ """Generate a "waiting" image to display on the scoreboard."""
img = Image.new(mode="RGBA", size=size, color=model.color_bg.get())
- center = (int(size[0]*0.5), int(size[1]*0.8))
+ center = (int(size[0] * 0.5), int(size[1] * 0.8))
normal = fontname_to_file(model.font_normal.get())
font_size = 72
fnt = ImageFont.truetype(normal, font_size)
@@ -38,8 +40,9 @@ def waiting_screen(size: Tuple[int, int], model: Model) -> Image.Image:
draw.text(center, "Waiting for results...", font=fnt, fill=color, anchor="ms")
return img
-class ScoreboardImage: #pylint: disable=too-many-instance-attributes
- '''
+
+class ScoreboardImage: # pylint: disable=too-many-instance-attributes
+ """
Generate a scoreboard image from a RaceTimes object.
Parameters:
@@ -48,20 +51,25 @@ class ScoreboardImage: #pylint: disable=too-many-instance-attributes
- race: The RaceTimes object containing the race result (and optionally
the swimmer names/teams)
- model: The model state that contains the rendering preferences
- '''
+ """
_BORDER_FRACTION = 0.05 # Fraction of image left as a border around all sides
- _EVENT_SIZE = 'E:MMM'
- _HEAT_SIZE = 'H:MM'
+ _EVENT_SIZE = "E:MMM"
+ _HEAT_SIZE = "H:MM"
_img: Image.Image # The rendered image
_lanes: int # The number of lanes to display
_line_height: int # Height of a line of text (baseline to baseline), in px
_text_height: int # Height of actual text, in px
_normal_font: ImageFont.FreeTypeFont # Font for normal text
- _time_font: ImageFont.FreeTypeFont # Font for printing times
+ _time_font: ImageFont.FreeTypeFont # Font for printing times
- def __init__(self, size: Tuple[int, int], race: RaceTimes, model: Model,
- background: bool = True):
+ def __init__(
+ self,
+ size: Tuple[int, int],
+ race: RaceTimes,
+ model: Model,
+ background: bool = True,
+ ):
with sentry_sdk.start_span(op="render_image", description="Render image"):
self._race = race
self._model = model
@@ -81,18 +89,18 @@ def __init__(self, size: Tuple[int, int], race: RaceTimes, model: Model,
@property
def image(self) -> Image.Image:
- '''The image of the scoreboard'''
+ """The image of the scoreboard"""
return self._img
@property
def size(self):
- '''Get the size of the image'''
+ """Get the size of the image"""
return self._img.size
def _add_bg_image(self) -> None:
bg_image_filename = self._model.image_bg.get()
if bg_image_filename == "":
- return # bg image not defined
+ return # bg image not defined
try:
bg_image = Image.open(bg_image_filename)
# Ensure the size matches
@@ -130,28 +138,48 @@ def _draw_header(self) -> None:
width = edge_r - edge_l
# Line1 - E: 999 Heading text
- draw.text((edge_l, self._baseline(1)), f"E:{self._race.event}", font=self._time_font,
- anchor="ls", fill=self._model.color_event.get())
+ draw.text(
+ (edge_l, self._baseline(1)),
+ f"E:{self._race.event}",
+ font=self._time_font,
+ anchor="ls",
+ fill=self._model.color_event.get(),
+ )
hstart = edge_l + draw.textsize(self._EVENT_SIZE, self._normal_font)[0]
hwidth = width - hstart
head_txt = self._model.title.get()
while draw.textsize(head_txt, self._normal_font)[0] > hwidth:
head_txt = head_txt[:-1]
- draw.text((edge_r, self._baseline(1)), head_txt, font=self._normal_font,
- anchor="rs", fill=self._model.color_title.get())
+ draw.text(
+ (edge_r, self._baseline(1)),
+ head_txt,
+ font=self._normal_font,
+ anchor="rs",
+ fill=self._model.color_title.get(),
+ )
# Line2 - H: 99 Event description
- draw.text((edge_l, self._baseline(2)), f"H:{self._race.heat}",
- font=self._time_font, anchor="ls", fill=self._model.color_event.get())
+ draw.text(
+ (edge_l, self._baseline(2)),
+ f"H:{self._race.heat}",
+ font=self._time_font,
+ anchor="ls",
+ fill=self._model.color_event.get(),
+ )
dstart = edge_l + draw.textsize(self._HEAT_SIZE, self._normal_font)[0]
dwidth = width - dstart
desc_txt = self._race.event_name
while draw.textsize(desc_txt, self._normal_font)[0] > dwidth:
desc_txt = desc_txt[:-1]
- draw.text((edge_r, self._baseline(2)), desc_txt, font=self._normal_font,
- anchor="rs", fill=self._model.color_event.get())
+ draw.text(
+ (edge_r, self._baseline(2)),
+ desc_txt,
+ font=self._normal_font,
+ anchor="rs",
+ fill=self._model.color_event.get(),
+ )
- def _draw_lanes(self) -> None: # pylint: disable=too-many-locals
+ def _draw_lanes(self) -> None: # pylint: disable=too-many-locals
draw = ImageDraw.Draw(self._img)
edge_l = int(self.size[0] * self._BORDER_FRACTION)
edge_r = int(self.size[0] * (1 - self._BORDER_FRACTION))
@@ -164,18 +192,42 @@ def _draw_lanes(self) -> None: # pylint: disable=too-many-locals
# Lane title
baseline = self._baseline(3)
title_color = self._model.color_event.get()
- draw.text((edge_l, baseline), "L", font=self._normal_font, anchor="ls", fill=title_color)
- draw.text((edge_l + idx_width + pl_width, baseline), "Name",
- font=self._normal_font, anchor="ls", fill=title_color)
- draw.text((edge_r, baseline), "Time", font=self._normal_font, anchor="rs", fill=title_color)
+ draw.text(
+ (edge_l, baseline),
+ "L",
+ font=self._normal_font,
+ anchor="ls",
+ fill=title_color,
+ )
+ draw.text(
+ (edge_l + idx_width + pl_width, baseline),
+ "Name",
+ font=self._normal_font,
+ anchor="ls",
+ fill=title_color,
+ )
+ draw.text(
+ (edge_r, baseline),
+ "Time",
+ font=self._normal_font,
+ anchor="rs",
+ fill=title_color,
+ )
# Lane data
for i in range(1, self._lanes + 1):
- color = self._model.color_odd.get() if i % 2 else self._model.color_even.get()
+ color = (
+ self._model.color_odd.get() if i % 2 else self._model.color_even.get()
+ )
line_num = 3 + i
# Lane
- draw.text((edge_l + idx_width/2, self._baseline(line_num)),
- f"{i}", font=self._normal_font, anchor="ms", fill=color)
+ draw.text(
+ (edge_l + idx_width / 2, self._baseline(line_num)),
+ f"{i}",
+ font=self._normal_font,
+ anchor="ms",
+ fill=color,
+ )
# Place
pl_num = self._race.place(i)
pl_color = color
@@ -186,18 +238,33 @@ def _draw_lanes(self) -> None: # pylint: disable=too-many-locals
if pl_num == 3:
pl_color = self._model.color_third.get()
ptxt = format_place(pl_num)
- draw.text((edge_l + idx_width + pl_width/2, self._baseline(line_num)),
- ptxt, font=self._normal_font, anchor="ms", fill=pl_color)
+ draw.text(
+ (edge_l + idx_width + pl_width / 2, self._baseline(line_num)),
+ ptxt,
+ font=self._normal_font,
+ anchor="ms",
+ fill=pl_color,
+ )
# Name
name_variants = format_name(NameMode.NONE, self._race.name(i))
while draw.textsize(name_variants[0], self._normal_font)[0] > name_width:
name_variants.pop(0)
name = name_variants[0]
- draw.text((edge_l + idx_width + pl_width, self._baseline(line_num)),
- f"{name}", font=self._normal_font, anchor="ls", fill=color)
+ draw.text(
+ (edge_l + idx_width + pl_width, self._baseline(line_num)),
+ f"{name}",
+ font=self._normal_font,
+ anchor="ls",
+ fill=color,
+ )
# Time
- draw.text((edge_r, self._baseline(line_num)), self._time_text(i),
- font=self._time_font, anchor="rs", fill=color)
+ draw.text(
+ (edge_r, self._baseline(line_num)),
+ self._time_text(i),
+ font=self._time_font,
+ anchor="rs",
+ fill=color,
+ )
def _time_text(self, lane: int) -> str:
if self._race.is_noshow(lane):
@@ -210,16 +277,19 @@ def _time_text(self, lane: int) -> str:
return format_time(final_time.value)
def _baseline(self, line: int) -> int:
- '''
+ """
Return the y-coordinate for the baseline of the n-th line of text from
the top.
- '''
- return int(self._img.size[1] * self._BORDER_FRACTION # skip top border
- + line * self._line_height # move down to proper line
- - (self._line_height - self._text_height)/2) # up 1/2 the inter-line space
+ """
+ return int(
+ self._img.size[1] * self._BORDER_FRACTION # skip top border
+ + line * self._line_height # move down to proper line
+ - (self._line_height - self._text_height) / 2
+ ) # up 1/2 the inter-line space
+
def format_time(seconds: RawTime) -> str:
- '''
+ """
>>> format_time(RawTime('1.2'))
'01.20'
>>> format_time(RawTime('9.87'))
@@ -228,22 +298,24 @@ def format_time(seconds: RawTime) -> str:
'50.00'
>>> format_time(RawTime('120.0'))
'2:00.00'
- '''
- sixty = RawTime('60')
+ """
+ sixty = RawTime("60")
minutes = seconds // sixty
seconds = seconds % sixty
if minutes == 0:
return f"{seconds:05.2f}"
return f"{minutes}:{seconds:05.2f}"
+
def fontname_to_file(name: str) -> str:
- '''Convert a font name (Roboto) to its corresponding file name'''
+ """Convert a font name (Roboto) to its corresponding file name"""
properties = font_manager.FontProperties(family=name, weight="bold")
filename = font_manager.findfont(properties)
return filename
+
def format_place(place: Optional[int]) -> str:
- '''
+ """
Turn a numerical place into the printable string representation.
>>> format_place(None)
@@ -258,7 +330,7 @@ def format_place(place: Optional[int]) -> str:
'3rd'
>>> format_place(6)
'6th'
- '''
+ """
if place is None:
return ""
if place == 0:
diff --git a/startlist.py b/startlist.py
index 94cbbe32..d42f47c2 100644
--- a/startlist.py
+++ b/startlist.py
@@ -14,52 +14,56 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''Manipulation of CTS Start Lists'''
+"""Manipulation of CTS Start Lists"""
-from abc import ABC
-from enum import Enum, auto, unique
import io
import os
import re
+from abc import ABC
+from enum import Enum, auto, unique
from typing import Dict, List
+
class StartList(ABC):
- '''Represents the start list for an event'''
+ """Represents the start list for an event"""
+
@property
def heats(self) -> int:
- '''The number of heats in the event'''
+ """The number of heats in the event"""
return 0
@property
def event_name(self) -> str:
- '''Get the event name (description)'''
+ """Get the event name (description)"""
return ""
@property
def event_num(self) -> int:
- '''Get the event number'''
+ """Get the event number"""
return 0
def name(self, _heat: int, _lane: int) -> str:
- '''Retrieve the Swimmer's name for a heat/lane'''
+ """Retrieve the Swimmer's name for a heat/lane"""
return ""
def team(self, _heat: int, _lane: int) -> str:
- '''Retrieve the Swimmer's team for a heat/lane'''
+ """Retrieve the Swimmer's team for a heat/lane"""
return ""
def is_empty_lane(self, heat: int, lane: int) -> bool:
- '''Returns true if the specified heat/lane has no name or team'''
+ """Returns true if the specified heat/lane has no name or team"""
return self.name(heat, lane) == "" and self.team(heat, lane) == ""
+
class CTSStartList(StartList):
- '''Implementation of StartList based on the CTS file format'''
+ """Implementation of StartList based on the CTS file format"""
+
_event_name: str
_event_num: int
- _heats: List[List[Dict[str,str]]]
+ _heats: List[List[Dict[str, str]]]
def __init__(self, stream: io.TextIOBase):
- '''
+ """
Construct a StartList from a text stream (file)
Example:
@@ -68,7 +72,7 @@ def __init__(self, stream: io.TextIOBase):
slist = StartList(file)
except ValueError as err: # Parse error
...
- '''
+ """
super().__init__()
# The following assumes event numbers are always numeric. I believe
# this is ok given that we are parsing SCB format start lists. MM
@@ -85,7 +89,7 @@ def __init__(self, stream: io.TextIOBase):
lines = stream.readlines()
if len(lines) % 10:
raise ValueError("Length is not a multiple of 10")
- heats = (len(lines))//10
+ heats = (len(lines)) // 10
# Reverse the lines because we're going to pop() them later and we
# want to read them in order.
@@ -104,42 +108,46 @@ def __init__(self, stream: io.TextIOBase):
match = re.match(r"^(.{20})--(.{16})$", line)
if not match:
raise ValueError(f"Unable to parse line: '{line}'")
- heat.append({
- "name": match.group(1).strip(),
- "team": match.group(2).strip(),
- })
+ heat.append(
+ {
+ "name": match.group(1).strip(),
+ "team": match.group(2).strip(),
+ }
+ )
self._heats.append(heat)
@property
def heats(self) -> int:
- '''Get the number of heats in the event'''
+ """Get the number of heats in the event"""
return len(self._heats)
@property
def event_name(self) -> str:
- '''Get the event name (description)'''
+ """Get the event name (description)"""
return self._event_name
@property
def event_num(self) -> int:
- '''Get the event number'''
+ """Get the event number"""
return self._event_num
def name(self, heat: int, lane: int) -> str:
- '''Retrieve the Swimmer's name for a heat/lane'''
+ """Retrieve the Swimmer's name for a heat/lane"""
if heat > len(self._heats) or heat < 1 or lane > 10 or lane < 1:
return ""
- return self._heats[heat-1][lane-1]["name"]
+ return self._heats[heat - 1][lane - 1]["name"]
def team(self, heat: int, lane: int) -> str:
- '''Retrieve the Swimmer's team for a heat/lane'''
+ """Retrieve the Swimmer's team for a heat/lane"""
if heat > len(self._heats) or heat < 1 or lane > 10 or lane < 1:
return ""
- return self._heats[heat-1][lane-1]["team"]
+ return self._heats[heat - 1][lane - 1]["team"]
+
@unique
class NameMode(Enum):
"""Formatting options for swimmer names"""
+
NONE = auto()
"""Verbatim as in the start list file"""
FIRST = auto()
@@ -155,7 +163,8 @@ class NameMode(Enum):
LAST = auto()
"""Format name as: Last"""
-#pylint: disable=too-many-return-statements
+
+# pylint: disable=too-many-return-statements
def arrange_name(how: NameMode, name: str) -> str:
"""
Change the format of a name from a start list.
@@ -193,7 +202,9 @@ def arrange_name(how: NameMode, name: str) -> str:
# - The middle (initial) is any remaining non-whitespace in the name
# - The CTS start list names are placed into a 20-character field, so we
# need to be able to properly parse w/ ws at the end (or not).
- match = re.match(r'^(?P(?P[^,])[^,]*)(,\s+(?P(?P\w)\w*)(\s+(?P\w+))?)?', name)
+ match = re.match(
+ r"^(?P(?P[^,])[^,]*)(,\s+(?P(?P\w)\w*)(\s+(?P\w+))?)?", name
+ )
if not match:
return name.strip()
if how == NameMode.FIRST:
@@ -211,6 +222,7 @@ def arrange_name(how: NameMode, name: str) -> str:
# default is NameMode.NONE
return name.strip()
+
def format_name(how: NameMode, name: str) -> List[str]:
"""
Returns a name formatted according to "how", along w/ shorter variants in
@@ -241,6 +253,7 @@ def format_name(how: NameMode, name: str) -> List[str]:
variants = _shorter_strings(arrange_name(how, name))
return [arrange_name(how, name)] + variants
+
def _shorter_strings(string: str) -> List[str]:
"""
>>> _shorter_strings("foobar")
@@ -251,20 +264,23 @@ def _shorter_strings(string: str) -> List[str]:
return [shortened] + _shorter_strings(shortened)
return []
+
def from_scb(filename: str) -> StartList:
- '''Create a StartList from a CTS startlist (.SCB) file'''
+ """Create a StartList from a CTS startlist (.SCB) file"""
with open(filename, "r", encoding="cp1252") as file:
return CTSStartList(file)
+
def events_to_csv(startlists: List[StartList]) -> List[str]:
- '''Convert a list of StartLists to a CSV for the CTS Dolphin'''
+ """Convert a list of StartLists to a CSV for the CTS Dolphin"""
csv = []
for slist in startlists:
csv.append(f"{slist.event_num},{slist.event_name},{slist.heats},1,A\n")
return csv
+
def load_all_scb(directory: str) -> List[StartList]:
- '''Load all the start list .scb files from a directory'''
+ """Load all the start list .scb files from a directory"""
files = os.scandir(directory)
startlists: List[StartList] = []
for file in files:
diff --git a/startlist_test.py b/startlist_test.py
index 8ccc39cb..96f295b6 100644
--- a/startlist_test.py
+++ b/startlist_test.py
@@ -14,16 +14,18 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''Tests for StartList class'''
+"""Tests for StartList class"""
import io
import textwrap
+
import pytest
from startlist import CTSStartList, StartList
+
def test_startlist():
- '''Instantiating a bare StartList generates an empty start list'''
+ """Instantiating a bare StartList generates an empty start list"""
slist = StartList()
assert slist.event_name == ""
assert slist.event_num == 0
@@ -32,9 +34,13 @@ def test_startlist():
assert slist.team(1, 2) == ""
assert slist.is_empty_lane(7, 4)
+
def test_two_heats():
- '''General test on 2 heats worth of data'''
- slist = CTSStartList(io.StringIO(textwrap.dedent("""\
+ """General test on 2 heats worth of data"""
+ slist = CTSStartList(
+ io.StringIO(
+ textwrap.dedent(
+ """\
#18 BOYS 10&U 50 FLY
--
--
@@ -55,7 +61,10 @@ def test_two_heats():
--
--
--
- -- """)))
+ -- """
+ )
+ )
+ )
assert slist.event_name == "BOYS 10&U 50 FLY"
assert not slist.is_empty_lane(1, 4)
@@ -73,9 +82,13 @@ def test_two_heats():
assert slist.name(2, 6) == "AAAAA, B"
assert slist.team(2, 6) == "X"
+
def test_six_heats():
- '''General test on 6 heats worth of data'''
- slist = CTSStartList(io.StringIO(textwrap.dedent("""\
+ """General test on 6 heats worth of data"""
+ slist = CTSStartList(
+ io.StringIO(
+ textwrap.dedent(
+ """\
#34 GIRLS 15&O 50 BACK
--
--
@@ -136,7 +149,10 @@ def test_six_heats():
WASHINGTON, TRACEY 1--RED
MCDANIEL, SUZANNE 1 --GREEN
--
- -- """)))
+ -- """
+ )
+ )
+ )
assert slist.event_num == 34
assert slist.event_name == "GIRLS 15&O 50 BACK"
assert slist.heats == 6
@@ -144,10 +160,14 @@ def test_six_heats():
assert not slist.is_empty_lane(6, 8)
assert slist.is_empty_lane(6, 10)
+
def test_invalid_header():
- '''Exception should be thrown when header can't be parsed'''
+ """Exception should be thrown when header can't be parsed"""
with pytest.raises(ValueError) as verr:
- CTSStartList(io.StringIO(textwrap.dedent("""\
+ CTSStartList(
+ io.StringIO(
+ textwrap.dedent(
+ """\
#AA BOYS 10&U 50 FLY
--
--
@@ -158,13 +178,20 @@ def test_invalid_header():
--
--
--
- -- """)))
- assert verr.match(r'Unable to parse header')
+ -- """
+ )
+ )
+ )
+ assert verr.match(r"Unable to parse header")
+
def test_invalid_num_lanes():
- '''Exception should be thrown when there are not 10 lanes per heat'''
+ """Exception should be thrown when there are not 10 lanes per heat"""
with pytest.raises(ValueError) as verr:
- CTSStartList(io.StringIO(textwrap.dedent("""\
+ CTSStartList(
+ io.StringIO(
+ textwrap.dedent(
+ """\
#1 BOYS 10&U 50 FLY
--
--
@@ -174,13 +201,20 @@ def test_invalid_num_lanes():
BIGBIGBIGLY, NAMENAM--LONGLONGLONGLONG
--
--
- -- """)))
- assert verr.match(r'Length is not a multiple of 10')
+ -- """
+ )
+ )
+ )
+ assert verr.match(r"Length is not a multiple of 10")
+
def test_invalid_line():
- '''Exception should be thrown when a line can't be parsed as an entry'''
+ """Exception should be thrown when a line can't be parsed as an entry"""
with pytest.raises(ValueError) as verr:
- CTSStartList(io.StringIO(textwrap.dedent("""\
+ CTSStartList(
+ io.StringIO(
+ textwrap.dedent(
+ """\
#1 BOYS 10&U 50 FLY
--
INVALID LINE
@@ -191,5 +225,8 @@ def test_invalid_line():
--
--
--
- -- """)))
- assert verr.match(r'Unable to parse line')
+ -- """
+ )
+ )
+ )
+ assert verr.match(r"Unable to parse line")
diff --git a/template.py b/template.py
index d3203847..3e850273 100644
--- a/template.py
+++ b/template.py
@@ -14,17 +14,19 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''
+"""
A scoreboard template for previews and theming
-'''
+"""
from datetime import datetime
from typing import List, Optional
+
from racetimes import RaceTimes, RawTime
from startlist import StartList
+
def get_template() -> RaceTimes:
- '''
+ """
Returns template data to create a scoreboard mockup
>>> from scoreboard import format_time
@@ -43,11 +45,12 @@ def get_template() -> RaceTimes:
'BRADY, JUNE A'
>>> t.team(3)
'TEAM'
- '''
+ """
race = _TemplateRace(2, RawTime("0.30"))
race.set_names(_TemplateStartList())
return race
+
class _TemplateRace(RaceTimes):
@property
def event(self) -> int:
@@ -59,9 +62,9 @@ def heat(self) -> int:
def raw_times(self, lane: int) -> List[Optional[RawTime]]:
if lane == 2:
- return [None, None, None] # no-show
+ return [None, None, None] # no-show
if lane == 3:
- return [RawTime("1"), None, None] # Too few times -> invalid
+ return [RawTime("1"), None, None] # Too few times -> invalid
time = RawTime("59.99") + RawTime("60") * RawTime("99")
time = time + lane - 10
return [time, time, time]
@@ -74,6 +77,7 @@ def time_recorded(self) -> datetime:
def meet_id(self) -> str:
return "000"
+
class _TemplateStartList(StartList):
@property
def event_name(self) -> str:
@@ -92,8 +96,8 @@ def name(self, _heat: int, lane: int) -> str:
"Clark, Leslie J",
"Jensen, Kelli N",
"Parsons, Marsha L",
- )
- return names[lane-1].upper()
+ )
+ return names[lane - 1].upper()
def team(self, _heat: int, _lane: int) -> str:
return "TEAM"
diff --git a/tooltip.py b/tooltip.py
index 0dcd157c..e18cdcb6 100644
--- a/tooltip.py
+++ b/tooltip.py
@@ -11,13 +11,15 @@
import tkinter as tk
-class ToolTip: # pylint: disable=too-few-public-methods
+
+class ToolTip: # pylint: disable=too-few-public-methods
"""
create a tooltip for a given widget
"""
- def __init__(self, widget, text='widget info'):
- self.waittime = 500 #miliseconds
- self.wraplength = 225 #pixels
+
+ def __init__(self, widget, text="widget info"):
+ self.waittime = 500 # miliseconds
+ self.wraplength = 225 # pixels
self.widget = widget
self.text = text
self.widget.bind("", self._enter)
@@ -54,32 +56,43 @@ def _showtip(self, _=None):
# Leaves only the label and removes the app window
self._tip_win.wm_overrideredirect(True)
self._tip_win.wm_geometry(f"+{x}+{y}")
- label = tk.Label(self._tip_win, text=self.text, justify='left',
- background="#ffffff", relief='solid', borderwidth=1,
- wraplength = self.wraplength)
+ label = tk.Label(
+ self._tip_win,
+ text=self.text,
+ justify="left",
+ background="#ffffff",
+ relief="solid",
+ borderwidth=1,
+ wraplength=self.wraplength,
+ )
label.pack(ipadx=1)
def _hidetip(self):
tip_win = self._tip_win
- self._tip_win= None
+ self._tip_win = None
if tip_win:
tip_win.destroy()
+
# testing ...
-if __name__ == '__main__':
+if __name__ == "__main__":
root = tk.Tk()
btn1 = tk.Button(root, text="button 1")
btn1.pack(padx=10, pady=5)
- button1_ttp = ToolTip(btn1, \
- 'Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, '
- 'consectetur, adipisci velit. Neque porro quisquam est qui dolorem ipsum '
- 'quia dolor sit amet, consectetur, adipisci velit. Neque porro quisquam '
- 'est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.')
+ button1_ttp = ToolTip(
+ btn1,
+ "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, "
+ "consectetur, adipisci velit. Neque porro quisquam est qui dolorem ipsum "
+ "quia dolor sit amet, consectetur, adipisci velit. Neque porro quisquam "
+ "est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.",
+ )
btn2 = tk.Button(root, text="button 2")
btn2.pack(padx=10, pady=5)
- button2_ttp = ToolTip(btn2, \
- "First thing's first, I'm the realest. Drop this and let the whole world "
- "feel it. And I'm still in the Murda Bizness. I could hold you down, like "
- "I'm givin' lessons in physics. You should want a bad Vic like this.")
+ button2_ttp = ToolTip(
+ btn2,
+ "First thing's first, I'm the realest. Drop this and let the whole world "
+ "feel it. And I'm still in the Murda Bizness. I could hold you down, like "
+ "I'm givin' lessons in physics. You should want a bad Vic like this.",
+ )
root.mainloop()
diff --git a/version.py b/version.py
index 4074e3bb..5a9b751f 100644
--- a/version.py
+++ b/version.py
@@ -1,4 +1,4 @@
-'''Version information'''
+"""Version information"""
WAHOO_RESULTS_VERSION = "unreleased"
SENTRY_DSN = None
diff --git a/wahoo_results.py b/wahoo_results.py
index a6817b2d..106b50ed 100755
--- a/wahoo_results.py
+++ b/wahoo_results.py
@@ -15,72 +15,87 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''Wahoo Results!'''
+"""Wahoo Results!"""
import copy
import os
import platform
import re
import sys
+import webbrowser
from time import sleep
from tkinter import Tk, filedialog, messagebox
from typing import List, Optional
-import webbrowser
+
import sentry_sdk
from sentry_sdk.integrations.socket import SocketIntegration
from sentry_sdk.integrations.threading import ThreadingIntegration
-from watchdog.observers import Observer #type: ignore
-from about import about
+from watchdog.observers import Observer # type: ignore
-import main_window
import imagecast
+import main_window
+import wh_analytics
+import wh_version
+from about import about
from model import Model
from racetimes import RaceTimes, RawTime, from_do4
from scoreboard import ScoreboardImage, waiting_screen
from startlist import events_to_csv, from_scb, load_all_scb
from template import get_template
-from watcher import DO4Watcher, SCBWatcher
from version import SENTRY_DSN, WAHOO_RESULTS_VERSION
-import wh_version
-import wh_analytics
+from watcher import DO4Watcher, SCBWatcher
CONFIG_FILE = "wahoo-results.ini"
+
def setup_exit(root: Tk, model: Model) -> None:
- '''Set up handlers for application exit'''
+ """Set up handlers for application exit"""
+
def exit_fn() -> None:
try:
model.save(CONFIG_FILE)
except PermissionError as err:
- messagebox.showerror(title="Error saving configuration",
+ messagebox.showerror(
+ title="Error saving configuration",
message=f'Unable to write configuration file "{err.filename}". {err.strerror}',
- detail="Please ensure the working directory is writable.")
+ detail="Please ensure the working directory is writable.",
+ )
root.destroy()
+
# Close box exits app
root.protocol("WM_DELETE_WINDOW", exit_fn)
# Exit menu item exits app
model.menu_exit.add(exit_fn)
+
def setup_template(model: Model) -> None:
- '''Setup handler for exporting scoreboard template'''
+ """Setup handler for exporting scoreboard template"""
+
def do_export() -> None:
- filename = filedialog.asksaveasfilename(confirmoverwrite=True,
- defaultextension=".png", filetypes=[("image", "*.png")],
- initialfile="template")
+ filename = filedialog.asksaveasfilename(
+ confirmoverwrite=True,
+ defaultextension=".png",
+ filetypes=[("image", "*.png")],
+ initialfile="template",
+ )
if len(filename) == 0:
return
- template = ScoreboardImage(imagecast.IMAGE_SIZE,
- get_template(), model, True)
+ template = ScoreboardImage(imagecast.IMAGE_SIZE, get_template(), model, True)
template.image.save(filename)
model.menu_export_template.add(do_export)
+
def setup_save(model: Model) -> None:
- '''Setup handler for saving current scoreboard image'''
+ """Setup handler for saving current scoreboard image"""
+
def do_save() -> None:
- filename = filedialog.asksaveasfilename(confirmoverwrite=True,
- defaultextension=".png", filetypes=[("image", "*.png")],
- initialfile="scoreboard")
+ filename = filedialog.asksaveasfilename(
+ confirmoverwrite=True,
+ defaultextension=".png",
+ filetypes=[("image", "*.png")],
+ initialfile="scoreboard",
+ )
if len(filename) == 0:
return
sb_image = model.scoreboard.get()
@@ -88,11 +103,14 @@ def do_save() -> None:
model.menu_save_scoreboard.add(do_save)
+
def setup_appearance(model: Model) -> None:
- '''Link model changes to the scoreboard preview'''
+ """Link model changes to the scoreboard preview"""
+
def update_preview() -> None:
preview = ScoreboardImage(imagecast.IMAGE_SIZE, get_template(), model)
model.appearance_preview.set(preview.image)
+
for element in [
model.font_normal,
model.font_time,
@@ -108,34 +126,40 @@ def update_preview() -> None:
model.color_third,
model.color_bg,
model.num_lanes,
- ]: element.trace_add("write", lambda *_: update_preview())
+ ]:
+ element.trace_add("write", lambda *_: update_preview())
update_preview()
def handle_bg_import() -> None:
- image = filedialog.askopenfilename(filetypes=[("image", "*.gif *.jpg *.jpeg *.png")])
+ image = filedialog.askopenfilename(
+ filetypes=[("image", "*.gif *.jpg *.jpeg *.png")]
+ )
if len(image) == 0:
return
image = os.path.normpath(image)
model.image_bg.set(image)
+
model.bg_import.add(handle_bg_import)
model.bg_clear.add(lambda: model.image_bg.set(""))
+
def setup_scb_watcher(model: Model, observer: Observer) -> None:
- '''Set up file system watcher for startlists'''
+ """Set up file system watcher for startlists"""
+
def process_startlists() -> None:
- '''
+ """
Load all the startlists from the current directory and update the UI
with their information.
- '''
+ """
directory = model.dir_startlist.get()
startlists = load_all_scb(directory)
model.startlist_contents.set(startlists)
def scb_dir_updated() -> None:
- '''
+ """
When the startlist directory is changed, update the watched to look at
the new directory and trigger processing of the startlists.
- '''
+ """
path = model.dir_startlist.get()
if not os.path.exists(path):
return
@@ -149,13 +173,14 @@ def scb_dir_updated() -> None:
model.dir_startlist.trace_add("write", lambda *_: scb_dir_updated())
scb_dir_updated()
+
def summarize_racedir(directory: str) -> List[RaceTimes]:
- '''Summarize all race results in a directory'''
+ """Summarize all race results in a directory"""
files = os.scandir(directory)
contents: List[RaceTimes] = []
for file in files:
if file.name.endswith(".do4"):
- match = re.match(r'^(\d+)-', file.name)
+ match = re.match(r"^(\d+)-", file.name)
if match is None:
continue
try:
@@ -169,15 +194,17 @@ def summarize_racedir(directory: str) -> List[RaceTimes]:
pass
return contents
+
def load_result(model: Model, filename: str) -> Optional[RaceTimes]:
- '''Load a result file and corresponding startlist'''
+ """Load a result file and corresponding startlist"""
racetime: Optional[RaceTimes] = None
# Retry mechanism since we get errors if we try to read while it's
# still being written.
for tries in range(1, 6):
try:
- racetime = from_do4(filename, model.min_times.get(),
- RawTime(model.time_threshold.get()))
+ racetime = from_do4(
+ filename, model.min_times.get(), RawTime(model.time_threshold.get())
+ )
except ValueError:
sleep(0.05 * tries)
except OSError:
@@ -194,21 +221,24 @@ def load_result(model: Model, filename: str) -> Optional[RaceTimes]:
pass
return racetime
+
def setup_do4_watcher(model: Model, observer: Observer) -> None:
- '''Set up watches for files/directories and connect to model'''
+ """Set up watches for files/directories and connect to model"""
+
def process_racedir() -> None:
- '''
+ """
Load all the race results and update the UI
- '''
- with sentry_sdk.start_span(op="update_race_ui",
- description="Update race summaries in UI") as span:
+ """
+ with sentry_sdk.start_span(
+ op="update_race_ui", description="Update race summaries in UI"
+ ) as span:
directory = model.dir_results.get()
contents = summarize_racedir(directory)
span.set_tag("race_files", len(contents))
model.results_contents.set(contents)
def process_new_result(file: str) -> None:
- '''Process a new race result that has been detected'''
+ """Process a new race result that has been detected"""
with sentry_sdk.start_transaction(op="new_result", name="New race result"):
racetime = load_result(model, file)
if racetime is None:
@@ -221,50 +251,63 @@ def process_new_result(file: str) -> None:
process_racedir() # update the UI
def do4_dir_updated() -> None:
- '''
+ """
When the raceresult directory is changed, update the watch to look at
the new directory and trigger processing of the results.
- '''
+ """
path = model.dir_results.get()
if not os.path.exists(path):
return
observer.unschedule_all()
+
def async_process(file: str) -> None:
model.enqueue(lambda: process_new_result(file))
+
observer.schedule(DO4Watcher(async_process), path)
process_racedir()
model.dir_results.trace_add("write", lambda *_: do4_dir_updated())
do4_dir_updated()
+
def check_for_update(model: Model) -> None:
- '''Notifies if there's a newer released version'''
+ """Notifies if there's a newer released version"""
current_version = model.version.get()
latest_version = wh_version.latest()
- if (latest_version is not None and
- not wh_version.is_latest_version(latest_version, current_version)):
- model.statustext.set(f"New version available. Click to download: {latest_version.tag}")
+ if latest_version is not None and not wh_version.is_latest_version(
+ latest_version, current_version
+ ):
+ model.statustext.set(
+ f"New version available. Click to download: {latest_version.tag}"
+ )
model.statusclick.add(lambda: webbrowser.open(latest_version.url))
+
def setup_run(model: Model, icast: imagecast.ImageCast) -> None:
- '''Link Chromecast discovery/management to the UI'''
+ """Link Chromecast discovery/management to the UI"""
+
def cast_discovery() -> None:
dev_list = copy.deepcopy(icast.get_devices())
model.enqueue(lambda: model.cc_status.set(dev_list))
+
def update_cc_list() -> None:
dev_list = model.cc_status.get()
for dev in dev_list:
icast.enable(dev.uuid, dev.enabled)
+
model.cc_status.trace_add("write", lambda *_: update_cc_list())
icast.set_discovery_callback(cast_discovery)
# Link Chromecast contents to the UI preview
- model.scoreboard.trace_add("write", lambda *_: icast.publish(model.scoreboard.get()))
+ model.scoreboard.trace_add(
+ "write", lambda *_: icast.publish(model.scoreboard.get())
+ )
+
def initialize_sentry(model: Model) -> None:
- '''Initialize sentry.io crash reporting'''
+ """Initialize sentry.io crash reporting"""
execution_environment = "source"
- if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
+ if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
execution_environment = "executable"
# Initialize Sentry crash reporting
@@ -275,10 +318,7 @@ def initialize_sentry(model: Model) -> None:
environment=execution_environment,
release=f"wahoo-results@{WAHOO_RESULTS_VERSION}",
include_local_variables=True,
- integrations=[
- SocketIntegration(),
- ThreadingIntegration(propagate_hub=True)
- ],
+ integrations=[SocketIntegration(), ThreadingIntegration(propagate_hub=True)],
debug=False,
)
uname = platform.uname()
@@ -286,13 +326,16 @@ def initialize_sentry(model: Model) -> None:
sentry_sdk.set_tag("os_release", uname.release)
sentry_sdk.set_tag("os_version", uname.version)
sentry_sdk.set_tag("os_machine", uname.machine)
- sentry_sdk.set_user({
- "id": model.client_id.get(),
- "ip_address": "{{auto}}",
- })
+ sentry_sdk.set_user(
+ {
+ "id": model.client_id.get(),
+ "ip_address": "{{auto}}",
+ }
+ )
+
def main() -> None: # pylint: disable=too-many-statements
- '''Main program'''
+ """Main program"""
root = Tk()
model = Model(root)
@@ -306,9 +349,12 @@ def main() -> None: # pylint: disable=too-many-statements
screen_size = (root.winfo_screenwidth(), root.winfo_screenheight())
wh_analytics.application_start(model, screen_size)
- sentry_sdk.set_context("display", {
- "size": f"{screen_size[0]}x{screen_size[1]}",
- })
+ sentry_sdk.set_context(
+ "display",
+ {
+ "size": f"{screen_size[0]}x{screen_size[1]}",
+ },
+ )
main_window.View(root, model)
@@ -317,12 +363,14 @@ def main() -> None: # pylint: disable=too-many-statements
setup_template(model)
def docs_fn() -> None:
- query_params = "&".join([
- "utm_source=wahoo_results",
- "utm_medium=menu",
- "utm_campaign=docs_link",
- f"ajs_uid={model.client_id.get()}",
- ])
+ query_params = "&".join(
+ [
+ "utm_source=wahoo_results",
+ "utm_medium=menu",
+ "utm_campaign=docs_link",
+ f"ajs_uid={model.client_id.get()}",
+ ]
+ )
webbrowser.open("https://wahoo-results.readthedocs.io/?" + query_params)
model.menu_docs.add(docs_fn)
@@ -351,6 +399,7 @@ def write_dolphin_csv():
file.writelines(csv)
num_events = len(csv)
wh_analytics.wrote_dolphin_csv(num_events)
+
model.dolphin_export.add(write_dolphin_csv)
# Connections for the run tab
@@ -364,15 +413,20 @@ def write_dolphin_csv():
# Analytics triggers
model.menu_docs.add(wh_analytics.documentation_link)
model.statusclick.add(wh_analytics.update_link)
- model.dir_startlist.trace_add("write", lambda *_: wh_analytics.set_cts_directory(True))
- model.dir_results.trace_add("write", lambda *_: wh_analytics.set_do4_directory(True))
+ model.dir_startlist.trace_add(
+ "write", lambda *_: wh_analytics.set_cts_directory(True)
+ )
+ model.dir_results.trace_add(
+ "write", lambda *_: wh_analytics.set_do4_directory(True)
+ )
# Allow the root window to build, then close the splash screen if it's up
# and we're running in exe mode
try:
root.update()
- #pylint: disable=import-error,import-outside-toplevel
- import pyi_splash #type: ignore
+ # pylint: disable=import-error,import-outside-toplevel
+ import pyi_splash # type: ignore
+
if pyi_splash.is_alive():
pyi_splash.close()
except ModuleNotFoundError:
@@ -391,5 +445,6 @@ def write_dolphin_csv():
wh_analytics.application_stop(model)
hub.end_session()
+
if __name__ == "__main__":
main()
diff --git a/watcher.py b/watcher.py
index 109f1e95..475148d5 100644
--- a/watcher.py
+++ b/watcher.py
@@ -14,16 +14,18 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''Monitor the startlist directory'''
+"""Monitor the startlist directory"""
from typing import Callable
-import watchdog.events #type: ignore
+
+import watchdog.events # type: ignore
CallbackFn = Callable[[], None]
CreatedCallbackFn = Callable[[str], None]
+
class SCBWatcher(watchdog.events.PatternMatchingEventHandler):
- '''Monitors a directory for changes to CTS Startlist files'''
+ """Monitors a directory for changes to CTS Startlist files"""
def __init__(self, callback: CallbackFn):
super().__init__(patterns=["*.scb"], ignore_directories=True)
@@ -32,8 +34,9 @@ def __init__(self, callback: CallbackFn):
def on_any_event(self, event: watchdog.events.FileSystemEvent):
self._callback()
+
class DO4Watcher(watchdog.events.PatternMatchingEventHandler):
- '''Monitors a directory for new .do4 race result files'''
+ """Monitors a directory for new .do4 race result files"""
def __init__(self, callback: CreatedCallbackFn):
super().__init__(patterns=["*.do4"], ignore_directories=True)
diff --git a/wh_analytics.py b/wh_analytics.py
index 97c854b0..b20eebf1 100644
--- a/wh_analytics.py
+++ b/wh_analytics.py
@@ -14,25 +14,26 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''Application usage analytics'''
+"""Application usage analytics"""
import locale
import platform
-from pprint import pprint
import socket
import time
+from pprint import pprint
from typing import Any, Dict, Optional, Tuple
-import requests
-from segment import analytics # type: ignore
-import sentry_sdk
import ipinfo # type: ignore
+import requests
+import sentry_sdk
+from segment import analytics # type: ignore
-from model import Model
import version
+from model import Model
_CONTEXT: Dict[str, Any] = {}
+
def application_start(model: Model, screen_size: Tuple[int, int]) -> None:
"""Event for application startup"""
analytics.write_key = version.SEGMENT_WRITE_KEY
@@ -43,72 +44,95 @@ def application_start(model: Model, screen_size: Tuple[int, int]) -> None:
"race_count": 0,
"race_count_with_names": 0,
"session_start": time.time(),
- "user_id": model.client_id.get()
+ "user_id": model.client_id.get(),
}
analytics.identify(
- user_id = _CONTEXT["user_id"],
- context = _CONTEXT["context"],
- traits = _CONTEXT["context"]["traits"],
+ user_id=_CONTEXT["user_id"],
+ context=_CONTEXT["context"],
+ traits=_CONTEXT["context"]["traits"],
)
_send_event("Scoreboard started")
+
def application_stop(model: Model) -> None:
"""Event for application shutdown"""
- _send_event("Scoreboard stopped", {
- "runtime": time.time() - _CONTEXT["session_start"],
- "race_count": _CONTEXT["race_count"],
- "race_count_with_names": _CONTEXT["race_count_with_names"],
- "lane_count": model.num_lanes.get(),
- "time_threshold": model.time_threshold.get(),
- "min_times": model.min_times.get(),
- "bg_image": model.image_bg.get() != "",
- "normal_font": model.font_normal.get(),
- "time_font": model.font_time.get(),
- })
+ _send_event(
+ "Scoreboard stopped",
+ {
+ "runtime": time.time() - _CONTEXT["session_start"],
+ "race_count": _CONTEXT["race_count"],
+ "race_count_with_names": _CONTEXT["race_count_with_names"],
+ "lane_count": model.num_lanes.get(),
+ "time_threshold": model.time_threshold.get(),
+ "min_times": model.min_times.get(),
+ "bg_image": model.image_bg.get() != "",
+ "normal_font": model.font_normal.get(),
+ "time_font": model.font_time.get(),
+ },
+ )
analytics.shutdown()
+
def results_received(has_names: bool, chromecasts: int) -> None:
"""Event for race results"""
_CONTEXT["race_count"] += 1
if has_names:
_CONTEXT["race_count_with_names"] += 1
- _send_event("Results received", {
- "has_names": has_names,
- "chromecast_count": chromecasts
- })
+ _send_event(
+ "Results received", {"has_names": has_names, "chromecast_count": chromecasts}
+ )
+
def documentation_link() -> None:
"""Follow link to docs event"""
_send_event("Documentation click")
+
def update_link() -> None:
"""Follow link to dl latest version event"""
_send_event("DownloadUpdate click")
+
def set_cts_directory(changed: bool) -> None:
"""Set CTS start list directory"""
- _send_event("Browse CTS directory",{
- "directory_changed": changed,
- })
+ _send_event(
+ "Browse CTS directory",
+ {
+ "directory_changed": changed,
+ },
+ )
+
def wrote_dolphin_csv(num_events: int) -> None:
"""Dolphin CSV event list written"""
- _send_event("Write Dolphin CSV", {
- "event_count": num_events,
- })
+ _send_event(
+ "Write Dolphin CSV",
+ {
+ "event_count": num_events,
+ },
+ )
+
def set_do4_directory(changed: bool) -> None:
"""Set directory to watch for do4 files"""
- _send_event("Browse D04 directory",{
- "directory_changed": changed,
- })
+ _send_event(
+ "Browse D04 directory",
+ {
+ "directory_changed": changed,
+ },
+ )
+
def cc_toggle(enable: bool) -> None:
"""Enable/disable Chromecast"""
- _send_event("Set Chromecast state",{
- "enable": enable,
- })
+ _send_event(
+ "Set Chromecast state",
+ {
+ "enable": enable,
+ },
+ )
+
def _send_event(name: str, kvparams: Optional[Dict[str, Any]] = None) -> None:
with sentry_sdk.start_span(op="analytics", description="Process analytics event"):
@@ -116,16 +140,19 @@ def _send_event(name: str, kvparams: Optional[Dict[str, Any]] = None) -> None:
return
if kvparams is None:
kvparams = {}
- analytics.track(_CONTEXT["user_id"], name, kvparams, context=_CONTEXT["context"])
- if analytics.write_key == "unknown": # dev environment
+ analytics.track(
+ _CONTEXT["user_id"], name, kvparams, context=_CONTEXT["context"]
+ )
+ if analytics.write_key == "unknown": # dev environment
print(f"Event: {name}")
pprint(kvparams)
+
def _setup_context(screen_size: Tuple[int, int]) -> Dict[str, Any]:
uname = platform.uname()
# https://segment.com/docs/connections/spec/identify/#traits
traits: Dict[str, Any] = {}
- if hasattr(socket, "gethostname"):
+ if hasattr(socket, "gethostname"):
traits["name"] = socket.gethostname()
# https://segment.com/docs/connections/spec/common/#context
diff --git a/wh_version.py b/wh_version.py
index 4f7fdbeb..e80b61f2 100644
--- a/wh_version.py
+++ b/wh_version.py
@@ -14,35 +14,37 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''Version information'''
-from typing import List, Optional
+"""Version information"""
import datetime
import re
+from typing import List, Optional
import dateutil.parser
import dateutil.tz
import requests
-import semver #type: ignore
+import semver # type: ignore
-#pylint: disable=too-few-public-methods
+
+# pylint: disable=too-few-public-methods
class ReleaseInfo:
"""
ReleaseInfo describes a single release from a GitHub repository.
"""
- tag: str # The git tag of the release
- url: str # The url to the release page
- draft: bool # Whether the release is a draft
- prerelease: bool # Whether the release is a prerelease
+
+ tag: str # The git tag of the release
+ url: str # The url to the release page
+ draft: bool # Whether the release is a draft
+ prerelease: bool # Whether the release is a prerelease
published: datetime.datetime # When the release was published
- semver: str # The version corresponding to the tag
+ semver: str # The version corresponding to the tag
def __init__(self, release_json):
- self.tag = release_json['tag_name']
- self.url = release_json['html_url']
- self.draft = release_json['draft']
- self.prerelease = release_json['prerelease']
- self.published = dateutil.parser.isoparse(release_json['published_at'])
- match = re.match(r'^v?(.*)$', self.tag)
+ self.tag = release_json["tag_name"]
+ self.url = release_json["html_url"]
+ self.draft = release_json["draft"]
+ self.prerelease = release_json["prerelease"]
+ self.published = dateutil.parser.isoparse(release_json["published_at"])
+ match = re.match(r"^v?(.*)$", self.tag)
self.semver = ""
if match is not None:
self.semver = match.group(1)
@@ -53,16 +55,19 @@ def releases(user_repo: str) -> List[ReleaseInfo]:
Retrieves the list of releases for the provided repo. user_repo should be
of the form "user/repo" (i.e., "JohnStrunk/wahoo-results")
"""
- url = f'https://api.github.com/repos/{user_repo}/releases'
+ url = f"https://api.github.com/repos/{user_repo}/releases"
# The timeout may be too fast, but it's going to hold up displaying the
# settings screen. Better to miss an update than hang for too long.
- resp = requests.get(url, headers={'Accept': 'application/vnd.github.v3+json'}, timeout=2)
+ resp = requests.get(
+ url, headers={"Accept": "application/vnd.github.v3+json"}, timeout=2
+ )
if not resp.ok:
return []
body = resp.json()
return list(map(ReleaseInfo, body))
+
def highest_semver(rlist: List[ReleaseInfo]) -> ReleaseInfo:
"""
Takes a list of releases and returns the one with the highest semantic
@@ -71,10 +76,14 @@ def highest_semver(rlist: List[ReleaseInfo]) -> ReleaseInfo:
"""
highest = rlist[0]
for release in rlist:
- if semver.compare(release.semver, highest.semver) > 0 and not release.prerelease:
+ if (
+ semver.compare(release.semver, highest.semver) > 0
+ and not release.prerelease
+ ):
highest = release
return highest
+
def git_semver(wrv: str) -> str:
"""
Returns a legal semver description of the Wahoo Results version
@@ -89,7 +98,7 @@ def git_semver(wrv: str) -> str:
'1.2.3-pre4.dev.5+badbeef'
"""
# groups: tag (w/o v), commits, hash (w/ g)
- components = re.match(r'^v?(.+)-(\d+)-g([0-9a-f]+)$', wrv)
+ components = re.match(r"^v?(.+)-(\d+)-g([0-9a-f]+)$", wrv)
if components is None:
return "0.0.1"
version = components.group(1)
@@ -98,7 +107,7 @@ def git_semver(wrv: str) -> str:
if not semver.VersionInfo.isvalid(version):
return "0.0.1"
version_info = semver.VersionInfo.parse(version)
- if commits > 0: # it's a dev version, so modify the version information
+ if commits > 0: # it's a dev version, so modify the version information
pre = ""
if version_info.prerelease is not None:
pre += version_info.prerelease + "."
@@ -108,6 +117,7 @@ def git_semver(wrv: str) -> str:
version_info = version_info.replace(prerelease=pre, build=sha)
return str(version_info)
+
def latest() -> Optional[ReleaseInfo]:
"""Retrieves the latest release info"""
rlist = releases("JohnStrunk/wahoo-results")
@@ -115,7 +125,8 @@ def latest() -> Optional[ReleaseInfo]:
return None
return highest_semver(rlist)
-def is_latest_version(latest_version: Optional[ReleaseInfo], wrv:str) -> bool:
+
+def is_latest_version(latest_version: Optional[ReleaseInfo], wrv: str) -> bool:
"""
Returns true if the running version is the most recent
diff --git a/widgets.py b/widgets.py
index 19e99508..c212e14f 100644
--- a/widgets.py
+++ b/widgets.py
@@ -14,32 +14,50 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-'''
+"""
TKinter code to display a button that presents a colorpicker.
-'''
+"""
import os
-from tkinter import VERTICAL, Canvas, StringVar, TclError, \
- Widget, colorchooser, filedialog, ttk
+from tkinter import (
+ VERTICAL,
+ Canvas,
+ StringVar,
+ TclError,
+ Widget,
+ colorchooser,
+ filedialog,
+ ttk,
+)
from typing import Any, Optional
-from PIL import ImageTk #type: ignore
import PIL.Image as PILImage
+from PIL import ImageTk # type: ignore
-from model import ChromecastStatusVar, ImageVar, RaceResultListVar, RaceResultVar, StartListVar
-from racetimes import RawTime
import scoreboard
+from model import (
+ ChromecastStatusVar,
+ ImageVar,
+ RaceResultListVar,
+ RaceResultVar,
+ StartListVar,
+)
+from racetimes import RawTime
TkContainer = Any
+
def swatch(width: int, height: int, color: str) -> ImageTk.PhotoImage:
- '''Generate a color swatch'''
+ """Generate a color swatch"""
img = PILImage.new("RGBA", (width, height), color)
return ImageTk.PhotoImage(img)
+
class ColorButton2(ttk.Button): # pylint: disable=too-many-ancestors
- '''Displays a button that allows choosing a color.'''
+ """Displays a button that allows choosing a color."""
+
SWATCH_SIZE = 12
+
def __init__(self, parent: Widget, color_var: StringVar):
if color_var.get() == "":
color_var.set("#000000")
@@ -48,12 +66,14 @@ def __init__(self, parent: Widget, color_var: StringVar):
# padx=9, command=self._btn_cb)
super().__init__(parent, command=self._btn_cb, image=self._img, padding=0)
self._color_var = color_var
+
def _on_change(_a, _b, _c):
try:
self._img = swatch(self.SWATCH_SIZE, self.SWATCH_SIZE, color_var.get())
self.configure(image=self._img)
- except TclError: # configuring an invalid color throws
+ except TclError: # configuring an invalid color throws
pass
+
self._color_var.trace_add("write", _on_change)
def _btn_cb(self) -> None:
@@ -61,10 +81,13 @@ def _btn_cb(self) -> None:
if rgb is not None:
self._color_var.set(rgb)
+
class Preview(Canvas):
"""A widget that displays a scoreboard preview image"""
+
WIDTH = 320
HEIGHT = 180
+
def __init__(self, parent: Widget, image_var: ImageVar):
super().__init__(parent, width=self.WIDTH, height=self.HEIGHT)
self._pimage: Optional[ImageTk.PhotoImage] = None
@@ -72,7 +95,7 @@ def __init__(self, parent: Widget, image_var: ImageVar):
image_var.trace_add("write", lambda *_: self._set_image(self._image_var.get()))
def _set_image(self, image: PILImage.Image) -> None:
- '''Set the preview image'''
+ """Set the preview image"""
self.delete("all")
scaled = image.resize((self.WIDTH, self.HEIGHT))
# Note: In order for the image to display on the canvas, we need to
@@ -81,23 +104,27 @@ def _set_image(self, image: PILImage.Image) -> None:
self._pimage = ImageTk.PhotoImage(scaled)
self.create_image(0, 0, image=self._pimage, anchor="nw")
+
class StartListTreeView(ttk.Frame):
- '''Widget to display a set of startlists'''
+ """Widget to display a set of startlists"""
+
def __init__(self, parent: Widget, startlist: StartListVar):
super().__init__(parent)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
- self.tview = ttk.Treeview(self, columns = ['event', 'desc', 'heats'])
+ self.tview = ttk.Treeview(self, columns=["event", "desc", "heats"])
self.tview.grid(column=0, row=0, sticky="news")
self.scroll = ttk.Scrollbar(self, orient=VERTICAL, command=self.tview.yview)
self.scroll.grid(column=1, row=0, sticky="news")
- self.tview.configure(selectmode='none', show='headings', yscrollcommand=self.scroll.set)
- self.tview.column('event', anchor='w', minwidth=40, width=40)
- self.tview.heading('event', anchor='w', text='Event')
- self.tview.column('desc', anchor='w', minwidth=220, width=220)
- self.tview.heading('desc', anchor='w', text='Description')
- self.tview.column('heats', anchor='w', minwidth=40, width=40)
- self.tview.heading('heats', anchor='w', text='Heats')
+ self.tview.configure(
+ selectmode="none", show="headings", yscrollcommand=self.scroll.set
+ )
+ self.tview.column("event", anchor="w", minwidth=40, width=40)
+ self.tview.heading("event", anchor="w", text="Event")
+ self.tview.column("desc", anchor="w", minwidth=220, width=220)
+ self.tview.heading("desc", anchor="w", text="Description")
+ self.tview.column("heats", anchor="w", minwidth=40, width=40)
+ self.tview.heading("heats", anchor="w", text="Heats")
self.startlist = startlist
startlist.trace_add("write", lambda *_: self._update_contents())
@@ -105,11 +132,17 @@ def _update_contents(self):
self.tview.delete(*self.tview.get_children())
local_list = self.startlist.get()
for entry in local_list:
- self.tview.insert('', 'end', id=str(entry.event_num), values=[str(entry.event_num),
- entry.event_name, str(entry.heats)])
+ self.tview.insert(
+ "",
+ "end",
+ id=str(entry.event_num),
+ values=[str(entry.event_num), entry.event_name, str(entry.heats)],
+ )
+
class DirSelection(ttk.Frame):
- '''Directory selector widget'''
+ """Directory selector widget"""
+
def __init__(self, parent: Widget, directory: StringVar):
super().__init__(parent)
self.dir = directory
@@ -117,13 +150,15 @@ def __init__(self, parent: Widget, directory: StringVar):
self.btn = ttk.Button(self, text="Browse...", command=self._handle_browse)
self.btn.grid(column=0, row=0, sticky="news")
self.dir_label = StringVar()
- ttk.Label(self, textvariable=self.dir_label, relief="sunken").grid(column=1,
- row=0, sticky="news")
- self.dir.trace_add("write", lambda *_:
- self.dir_label.set(os.path.basename(self.dir.get())[-20:]))
+ ttk.Label(self, textvariable=self.dir_label, relief="sunken").grid(
+ column=1, row=0, sticky="news"
+ )
+ self.dir.trace_add(
+ "write",
+ lambda *_: self.dir_label.set(os.path.basename(self.dir.get())[-20:]),
+ )
self.dir.set(self.dir.get())
-
def _handle_browse(self) -> None:
directory = filedialog.askdirectory(initialdir=self.dir.get())
if len(directory) == 0:
@@ -131,25 +166,29 @@ def _handle_browse(self) -> None:
directory = os.path.normpath(directory)
self.dir.set(directory)
+
class RaceResultTreeView(ttk.Frame):
- '''Widget that displays a table of completed races'''
+ """Widget that displays a table of completed races"""
+
def __init__(self, parent: Widget, racelist: RaceResultListVar):
super().__init__(parent)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
- self.tview = ttk.Treeview(self, columns = ['meet', 'event', 'heat', 'time'])
+ self.tview = ttk.Treeview(self, columns=["meet", "event", "heat", "time"])
self.tview.grid(column=0, row=0, sticky="news")
self.scroll = ttk.Scrollbar(self, orient=VERTICAL, command=self.tview.yview)
self.scroll.grid(column=1, row=0, sticky="news")
- self.tview.configure(selectmode='none', show='headings', yscrollcommand=self.scroll.set)
- self.tview.column('meet', anchor='w', minwidth=50, width=50)
- self.tview.heading('meet', anchor='w', text='Meet')
- self.tview.column('event', anchor='w', minwidth=50, width=50)
- self.tview.heading('event', anchor='w', text='Event')
- self.tview.column('heat', anchor='w', minwidth=50, width=50)
- self.tview.heading('heat', anchor='w', text='Heat')
- self.tview.column('time', anchor='w', minwidth=140, width=140)
- self.tview.heading('time', anchor='w', text='Time')
+ self.tview.configure(
+ selectmode="none", show="headings", yscrollcommand=self.scroll.set
+ )
+ self.tview.column("meet", anchor="w", minwidth=50, width=50)
+ self.tview.heading("meet", anchor="w", text="Meet")
+ self.tview.column("event", anchor="w", minwidth=50, width=50)
+ self.tview.heading("event", anchor="w", text="Event")
+ self.tview.column("heat", anchor="w", minwidth=50, width=50)
+ self.tview.heading("heat", anchor="w", text="Heat")
+ self.tview.column("time", anchor="w", minwidth=140, width=140)
+ self.tview.heading("time", anchor="w", text="Time")
self.racelist = racelist
racelist.trace_add("write", lambda *_: self._update_contents())
@@ -161,29 +200,37 @@ def _update_contents(self):
local_list.sort(key=lambda e: e.time_recorded, reverse=True)
for entry in local_list:
timetext = entry.time_recorded.strftime("%Y-%m-%d %H:%M:%S")
- self.tview.insert('', 'end', id=timetext, values=[str(entry.meet_id),
- entry.event, entry.heat, timetext])
+ self.tview.insert(
+ "",
+ "end",
+ id=timetext,
+ values=[str(entry.meet_id), entry.event, entry.heat, timetext],
+ )
+
class ChromcastSelector(ttk.Frame):
- '''Widget that allows enabling/disabling a set of Chromecast devices'''
+ """Widget that allows enabling/disabling a set of Chromecast devices"""
+
def __init__(self, parent: Widget, statusvar: ChromecastStatusVar) -> None:
super().__init__(parent)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
- self.tview = ttk.Treeview(self, columns = ['enabled', 'cc_name'])
+ self.tview = ttk.Treeview(self, columns=["enabled", "cc_name"])
self.tview.grid(column=0, row=0, sticky="news")
self.scroll = ttk.Scrollbar(self, orient=VERTICAL, command=self.tview.yview)
self.scroll.grid(column=1, row=0, sticky="news")
- self.tview.configure(selectmode='none', show='headings', yscrollcommand=self.scroll.set)
- self.tview.column('enabled', anchor='center', width=30)
- self.tview.heading('enabled', anchor='center', text='Enabled')
- self.tview.column('cc_name', anchor='w', minwidth=100)
- self.tview.heading('cc_name', anchor='w', text='Chromecast name')
+ self.tview.configure(
+ selectmode="none", show="headings", yscrollcommand=self.scroll.set
+ )
+ self.tview.column("enabled", anchor="center", width=30)
+ self.tview.heading("enabled", anchor="center", text="Enabled")
+ self.tview.column("cc_name", anchor="w", minwidth=100)
+ self.tview.heading("cc_name", anchor="w", text="Chromecast name")
self.devstatus = statusvar
self.devstatus.trace_add("write", lambda *_: self._update_contents())
# Needs to be the ButtonRelease event because the Button event happens
# before the focus is actually set/changed.
- self.tview.bind('', self._item_clicked)
+ self.tview.bind("", self._item_clicked)
self._update_contents()
def _update_contents(self) -> None:
@@ -193,7 +240,9 @@ def _update_contents(self) -> None:
local_list.sort(key=lambda d: (d.name))
for dev in local_list:
txt_status = "Yes" if dev.enabled else "No"
- self.tview.insert('', 'end', id=str(dev.uuid), values=[txt_status, dev.name])
+ self.tview.insert(
+ "", "end", id=str(dev.uuid), values=[txt_status, dev.name]
+ )
def _item_clicked(self, _event) -> None:
item = self.tview.focus()
@@ -205,29 +254,35 @@ def _item_clicked(self, _event) -> None:
dev.enabled = not dev.enabled
self.devstatus.set(local_list)
+
class RaceResultView(ttk.LabelFrame):
- '''Widget that displays a RaceResult'''
+ """Widget that displays a RaceResult"""
+
def __init__(self, parent: Widget, resultvar: RaceResultVar) -> None:
super().__init__(parent, text="Latest result")
self._resultvar = resultvar
self._resultvar.trace_add("write", lambda *_: self._update())
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
- self.tview = ttk.Treeview(self, columns=["lane", "t1", "t2", "t3", "final"],
- selectmode="none", show="headings")
+ self.tview = ttk.Treeview(
+ self,
+ columns=["lane", "t1", "t2", "t3", "final"],
+ selectmode="none",
+ show="headings",
+ )
self.tview.grid(column=0, row=0, sticky="news")
# Column configuration
time_width = 70
- self.tview.heading('lane', anchor='e', text='Lane')
- self.tview.column('lane', anchor='e', width=40)
- self.tview.heading('t1', anchor='e', text='Timer #1')
- self.tview.column('t1', anchor='e', width=time_width)
- self.tview.heading('t2', anchor='e', text='Timer #2')
- self.tview.column('t2', anchor='e', width=time_width)
- self.tview.heading('t3', anchor='e', text='Timer #3')
- self.tview.column('t3', anchor='e', width=time_width)
- self.tview.heading('final', anchor='e', text='Final')
- self.tview.column('final', anchor='e', width=time_width)
+ self.tview.heading("lane", anchor="e", text="Lane")
+ self.tview.column("lane", anchor="e", width=40)
+ self.tview.heading("t1", anchor="e", text="Timer #1")
+ self.tview.column("t1", anchor="e", width=time_width)
+ self.tview.heading("t2", anchor="e", text="Timer #2")
+ self.tview.column("t2", anchor="e", width=time_width)
+ self.tview.heading("t3", anchor="e", text="Timer #3")
+ self.tview.column("t3", anchor="e", width=time_width)
+ self.tview.heading("final", anchor="e", text="Final")
+ self.tview.column("final", anchor="e", width=time_width)
self._update()
def _update(self) -> None:
@@ -235,15 +290,22 @@ def _update(self) -> None:
result = self._resultvar.get()
for lane in range(1, 11):
if result is None:
- self.tview.insert('', 'end', id=str(lane),
- values=[str(lane), "", "", "", ""])
+ self.tview.insert(
+ "", "end", id=str(lane), values=[str(lane), "", "", "", ""]
+ )
else:
rawtimes = result.raw_times(lane)
- timestr = [scoreboard.format_time(t) if t is not None else "" for t in rawtimes]
+ timestr = [
+ scoreboard.format_time(t) if t is not None else "" for t in rawtimes
+ ]
final = result.final_time(lane)
if final.value == RawTime("0"):
finalstr = ""
else:
finalstr = scoreboard.format_time(final.value)
- self.tview.insert('', 'end', id=str(lane),
- values=[str(lane), timestr[0], timestr[1], timestr[2], finalstr])
+ self.tview.insert(
+ "",
+ "end",
+ id=str(lane),
+ values=[str(lane), timestr[0], timestr[1], timestr[2], finalstr],
+ )