Tkinter-Python-GUI-编程-全-

Tkinter Python GUI 编程(全)

原文:zh.annas-archive.org/md5/58fd70e00e744a0db971c3fca67cd80e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

写一本书远不止是应用语法和标点符号规则。同样,开发应用程序也需要更多编程语言和库 API 的知识。仅仅掌握语法规则和函数调用本身并不足以设计出能够使用户能够执行工作、保护宝贵数据并产生完美输出的应用程序。作为程序员,我们还需要能够将用户请求和期望转化为有效的界面设计,并选择最佳技术来实现它们。我们需要能够组织大型代码库,测试它们,并以保持其可管理性和避免粗心错误的方式维护它们。

本书的目标远不止是一本特定 GUI 工具包的参考手册。当我们走过一个虚构的工作场所场景时,您将体验到在一个小型办公室环境中作为应用程序程序员的感受。除了学习 Tkinter 和一些其他有用的库外,您还将学习许多您需要从编写短脚本过渡到编写功能齐全的图形应用程序的技能。完成本书后,您应该有信心能够为工作环境开发一个简单但实用的数据导向应用程序。

本书面向的对象

这本书是为那些已经学习了 Python 基础知识但尚未编写过多复杂脚本的新手而写的。我们将一步步引导您设计和创建一个更大的应用程序,并介绍一些有助于您作为程序员进步的技能。

它也针对那些已经使用 Python 进行数据科学、Web 开发或系统管理,但现在想扩展到创建 GUI 应用程序的人。我们将介绍创建本地 GUI 应用程序所需的知识和技能。

最后,这本书也可能对那些只想学习 Tkinter 的资深 Python 程序员有所帮助,因为书中详细介绍了使用 Tkinter 库的细微之处。

本书涵盖的内容

第一章Tkinter 简介,向您介绍了 Tkinter 库的基础知识,并引导您创建一个基本的 Tkinter 应用程序。它还将介绍 IDLE 作为 Tkinter 应用程序的示例。

第二章设计 GUI 应用程序,讲述了将一组用户需求转化为可实施设计的过程。

第三章使用 Tkinter 和 Ttk 小部件创建基本表单,展示了如何创建一个基本的数据输入应用程序,该应用程序将输入的数据追加到 CSV 文件中。

第四章使用类组织我们的代码,将向您介绍通用的面向对象编程技术,以及 Tkinter 特定类用法,这将使我们的 GUI 程序更易于维护和理解。

第五章通过验证和自动化减少用户错误,展示了如何在我们的表单输入中自动填充和验证数据。

第六章为应用程序的扩展规划,使你熟悉如何智能地将单个文件脚本拆分为多个文件,如何构建可以导入的 Python 模块,以及如何将大型代码库的关注点分离以使其更易于管理。

第七章使用 Menu 和 Tkinter 对话框创建菜单,概述了使用 Tkinter 创建主菜单的过程。它还将展示如何使用几种内置对话框类型来实现常见的菜单功能。

第八章使用 Treeview 和 Notebook 导航记录,详细介绍了使用 Ttk Treeview 和 Notebook 构建数据记录导航系统,以及将我们的应用程序从仅追加模式转换为全读写更新功能。

第九章使用样式和主题改进外观,告诉你如何更改应用程序的颜色、字体和小部件样式,以及如何使用它们使应用程序更易用和吸引人。

第十章维护跨平台兼容性,概述了 Python 和 Tkinter 技术,以保持你的应用程序在 Windows、macOS 和 Linux 系统上平稳运行。

第十一章使用 unittest 创建自动化测试,讨论了如何通过自动化单元测试和集成测试验证你的代码。

第十二章使用 SQL 改进数据存储,带你了解如何将我们的应用程序从 CSV 平面文件存储转换为 SQL 数据库存储。你还将了解所有关于 SQL 和关系数据模型的内容。

第十三章连接到云,涵盖了如何处理网络资源,如 HTTP 服务器、REST 服务和 SFTP 服务器。你将学习如何与这些服务交互以下载和上传数据和文件。

第十四章使用 Thread 和 Queue 进行异步编程,解释了如何使用异步和多线程编程在长时间运行过程中保持应用程序的响应性。

第十五章使用 Canvas 小部件可视化数据,教你如何使用 Tkinter Canvas 小部件创建可视化和动画。你还将学习如何集成 Matplotlib 图表并构建一个简单的游戏。

第十六章使用 setuptools 和 cxFreeze 进行打包,探讨了如何准备你的 Python 应用程序以作为 Python 包或独立可执行文件进行分发。

为了最大限度地利用本书

本书假设你已了解 Python 3 的基础知识。你应该知道如何使用内置类型和函数编写和运行简单的脚本,如何定义自己的函数,以及如何从标准库中导入模块。

您可以在运行当前版本的 Microsoft Windows、Apple macOS 或 GNU/Linux 发行版的计算机上阅读本书。请确保您已安装 Python 3 和 Tcl/Tk(第一章Tkinter 简介包含 Windows、macOS 和 Linux 的安装说明),并且您有一个您感到舒适的代码编辑环境(我们建议使用 IDLE,因为它与 Python 一起提供并使用 Tkinter。我们不推荐使用 Jupyter、Spyder 或类似的环境,这些环境针对的是分析 Python 而不是应用开发)。在后面的章节中,您将需要访问互联网,以便您可以安装 Python 包和 PostgreSQL 数据库。

下载示例代码文件

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Python-GUI-Programming-with-Tkinter-2E。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781801815925_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“将代码保存在solve_the_worlds_problems.py中,并在终端提示符下键入python solve_the_worlds_problems.py来执行它。”

代码块设置如下:

import tkinter as tk
root = tk.TK()
def solve():
  raise NotImplemented("Sorry!")
tk.Button(
  root, text="Solve the world's problems", command=solve
).pack()
root.mainloop() 

当我们希望您注意代码块中的特定部分,特别是指明现有代码的更改时,相关的行或项目将以粗体显示:

import tkinter as tk
**from** **tkinter** **import** **messagebox**
root = tk.TK()
def solve():
  **messagebox.showinfo(****'The answer?'****,** **'Bananas?'****)**
tk.Button(
  root, text="Solve the world's problems", command=solve
).pack()
root.mainloop() 

注意,本书中使用的所有 Python 代码都使用 2 个空格缩进,而不是传统的 4 个空格缩进。

任何命令行输入或输出都使用$表示提示符,如下所示:

$ mkdir Bananas
$ cp plantains.txt Bananas/ 

旨在 Python 外壳或 REPL 的命令行输入以>>>提示符打印,如下所示:

>>> print('This should be run in a Python shell')
'This should be run in a Python shell' 

从外壳期望的输出将打印在没有提示符的行上。

粗体:表示新术语、重要单词或您在屏幕上看到的单词,例如在菜单或对话框中。例如:“从管理面板中选择系统信息。”

警告或重要注意事项看起来像这样。

小贴士和技巧看起来像这样。

执行 Python 和 pip

当我们需要指导读者在本书中执行 Python 脚本时,我们将指示如下命令行:

$ python myscript.py 

根据您的操作系统或 Python 配置,python命令可能执行的是 Python 2.x 而不是 Python 3.x。您可以通过运行以下命令来验证这一点:

$ python --version
Python 3.9.7 

如果此命令在您的系统上输出 Python 2 而不是 Python 3,您需要更改任何 python 命令,以便您的代码在 Python 3 中执行。通常,这意味着使用 python3 命令,如下所示:

$ python3 myscript.py 

同样的注意事项适用于用于从 Python 包索引安装库的 pip 命令。您可能需要使用 pip3 命令来安装库到您的 Python 3 环境中,例如:

$ pip3 install --user requests 

联系我们

我们始终欢迎读者的反馈。

一般反馈:请发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍的标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送电子邮件给我们。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告此错误。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 联系我们,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问 packtpub.com

分享您的想法

读完 Python GUI Programming with Tkinter, Second Edition 后,我们很乐意听到您的想法!请点击此处直接跳转到该书的 Amazon 评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

第一章:Tkinter 简介

欢迎来到 Python 程序员!如果你已经学会了 Python 的基础知识,并想开始设计强大的 GUI 应用程序,这本书就是为你准备的。

到现在为止,你无疑已经体验到了 Python 的强大和简单。也许你编写过网络服务、执行过数据分析或管理过服务器。也许你编写过一款游戏、自动化常规任务,或者只是简单地玩弄代码。但现在你准备好处理 GUI 了。

在对网络、移动和服务器端编程如此重视的今天,简单桌面 GUI 应用程序的开发似乎越来越像一门失传的艺术;许多经验丰富的开发者从未学会如何创建这样的应用程序。多么遗憾啊!桌面计算机在工作和家庭计算中仍然扮演着至关重要的角色,为这个无处不在的平台构建简单、功能性的应用程序应该是每个软件开发人员工具箱的一部分。幸运的是,对于 Python 程序员来说,这种能力通过 Tkinter 轻而易举地就能实现。

在本章中,你将涵盖以下主题:

  • Introducing Tkinter and Tk 中,你将了解 Tkinter,这是一个内置在 Python 标准库中的快速、有趣、易于学习的 GUI 库;以及 IDLE,这是一个用 Tkinter 编写的编辑器和开发环境。

  • An overview of basic Tkinter 中,你将通过一个 "Hello World" 程序学习 Tkinter 的基础知识,并创建一个调查应用程序。

Tkinter 和 Tk 介绍

Tk 窗口部件库起源于 工具命令语言Tcl)编程语言。Tcl 和 Tk 是由 John Ousterhout 在 1980 年代后期在伯克利大学担任教授时创建的,作为一种更简单的编程大学正在使用的工程工具的方法。由于其速度和相对简单性,Tcl/Tk 在学术、工程和 Unix 程序员中迅速流行起来。与 Python 本身一样,Tcl/Tk 最初起源于 Unix 平台,后来才迁移到 macOS 和 Windows。Tk 的实用意图和 Unix 根仍然影响着其设计,与其它工具包相比,其简单性仍然是一个主要优势。

Tkinter 是 Python 对 Tk GUI 库的接口,自 1994 年 Python 版本 1.1 发布以来一直是 Python 标准库的一部分,使其成为 Python 的 事实上的 GUI 库。Tkinter 的文档以及进一步学习的链接可以在标准库文档中找到:docs.python.org/3/library/tkinter.html

选择 Tkinter

想要构建图形用户界面的 Python 程序员有多个工具包选项可供选择;不幸的是,Tkinter 经常被贬低或忽视,被视为一个过时的选项。公平地说,它并不是一种可以用时髦的词汇和夸大的宣传来描述的技术。然而,Tkinter 不仅适用于各种应用,而且还有一些不容忽视的优点:

  • Tkinter 在标准库中:除少数例外,Tkinter 在 Python 可用的任何地方都可用。无需安装 pip、创建虚拟环境、编译二进制文件或在网上搜索安装包。对于需要快速完成的项目,这是一个明显的优势。

  • Tkinter 是稳定的:虽然 Tkinter 的开发没有停止,但它的发展缓慢且渐进。API 已经稳定多年,变化主要在于新增功能和错误修复。您的 Tkinter 代码在未来几年或几十年内可能无需修改即可运行。

  • Tkinter 只是一个 GUI 工具包:与其他一些 GUI 库不同,Tkinter 没有自己的线程库、网络堆栈或文件系统 API。它依赖于常规 Python 库来处理这些事情,因此它非常适合将 GUI 应用于现有的 Python 代码。

  • Tkinter 简单且直接了当:Tkinter 非常基础且直接;它可以在过程式和面向对象的 GUI 设计中有效使用。要使用 Tkinter,您不需要学习数百个小部件类、标记或模板语言、新的编程范式、客户端-服务器技术或不同的编程语言。

当然,Tkinter 并不完美,它也有一些缺点:

  • Tkinter 的默认外观和感觉过时了:Tkinter 的默认外观已经落后于当前趋势,它还保留了一些来自 20 世纪 90 年代 Unix 世界的遗迹。虽然它缺少动画小部件、渐变或可缩放图形等细节,但得益于 Tk 本身的更新和主题小部件库的添加,它在过去几年里已经取得了很大的进步。本书将教会我们如何修复或避免 Tkinter 的一些更古老的默认设置。

  • Tkinter 缺少更复杂的小部件:Tkinter 缺少像富文本编辑器、3D 图形嵌入、HTML 查看器或专用输入小部件等高级小部件。正如我们将在本书后面看到的那样,Tkinter 通过自定义和组合其简单的小部件,使我们能够创建复杂的小部件。

Tkinter 可能不适合游戏 UI 或光滑的商业应用程序;然而,对于数据驱动应用程序、简单实用程序、配置对话框和其他业务逻辑应用程序,Tkinter 提供了所需的一切,甚至更多。在这本书中,我们将通过开发一个工作环境中的数据输入应用程序来开展工作,Tkinter 可以出色地处理这项任务。

安装 Tkinter

Tkinter 包含在 Windows 和 macOS 的 Python 标准库中。因此,如果您使用官方安装程序在这些平台上安装了 Python,您不需要做任何事情来安装 Tkinter。

然而,我们将在这本书中专门关注 Python 3.9;因此,您需要确保您已安装此版本或更高版本。

在 Windows 上安装 Python 3.9

您可以通过以下步骤从 python.org 网站获取 Windows 的 Python 3 安装程序:

  1. 前往 www.python.org/downloads/windows

  2. 选择最新的 Python 3 版本。在撰写本文时,最新版本是 3.9.2。

  3. 文件 部分下,选择适合您系统架构的 Windows 可执行安装程序(x86 用于 32 位 Windows,x86-64 用于 64 位 Windows;如果您不确定,x86 适用于任何一种)。

  4. 启动下载的安装程序。

  5. 点击 自定义安装。确保已选中 tcl/tk 和 IDLE 选项(默认情况下应该已选中)。

  6. 使用所有默认选项继续安装向导。

在 macOS 上安装 Python 3

到本文撰写时,macOS 内置了 Python 2.7。然而,Python 2 于 2020 年正式弃用,本书中的代码将无法与它兼容,因此 macOS 用户需要安装 Python 3 才能遵循本书。

按照以下步骤在 macOS 上安装 Python3:

  1. 前往 www.python.org/downloads/mac-osx/

  2. 选择最新的 Python 3 版本。在撰写本文时,最新版本是 3.9.2。

  3. 文件 部分下,选择并下载 macOS 64 位/32 位安装程序。

  4. 启动您下载的 .pkg 文件,并按照安装向导的步骤操作,选择默认选项。

在 Linux 上安装 Python 3 和 Tkinter

大多数 Linux 发行版都包含 Python 2 和 Python 3;然而,Tkinter 并非总是与它捆绑在一起或默认安装。要找出 Tkinter 是否已安装,打开终端并尝试以下命令:

$ python3 -m tkinter 

这应该会打开一个简单的窗口,显示有关 Tkinter 的信息。如果您收到 ModuleNotFoundError 错误,您需要使用您的包管理器安装您发行版的 Python 3 Tkinter 包。在大多数主要发行版中,包括 Debian、Ubuntu、Fedora 和 openSUSE,此包称为 python3-tk

介绍 IDLE

IDLE 是一个集成开发环境,它包含在官方 Python 软件发行版中,适用于 Windows 和 macOS(在大多数 Linux 发行版中也可以轻松获得,通常作为 idleidle3)。

IDLE 使用 Python 和 Tkinter 编写,它不仅为我们提供了 Python 的编辑环境,而且还是 Tkinter 作用的一个很好的示例。因此,尽管 IDLE 的基本功能可能不被经验丰富的 Python 程序员视为专业级别,并且尽管您可能已经有一个用于编写 Python 代码的首选环境,但我鼓励您在阅读本书的过程中花些时间使用 IDLE。

IDLE 有两种主要模式:shell 模式和编辑模式。我们将在本节中探讨这些模式。

使用 IDLE 的 shell 模式

当您启动 IDLE 时,您将进入 shell 模式,这是一个简单的 Python 读取-评估-打印循环REPL),类似于在终端窗口中键入 python 时获得的结果。

您可以在此屏幕截图中看到 IDLE 的 shell 模式:

图 1.1:IDLE 的 shell 模式

IDLE 的 shell 有一些很好的功能,您在命令行 REPL 中得不到,如语法高亮和自动补全。REPL 对于 Python 开发过程至关重要,因为它允许您实时测试代码并检查类和 API,而无需编写完整的脚本。我们将在后面的章节中使用 shell 模式来探索模块的功能和行为。如果您没有打开 shell 窗口,可以通过在 IDLE 菜单中点击运行|Python Shell来打开一个。

使用 IDLE 的编辑模式

编辑模式用于创建 Python 脚本文件,您可以在以后运行它们。当本书告诉您创建一个新文件时,这是您将使用的模式。要打开编辑模式中的新文件,只需在菜单中导航到文件|新建文件,或在键盘上按 Ctrl + N。

此图像显示了 IDLE 的文件编辑器:

图 1.2:IDLE 的文件编辑器

您可以在编辑模式下按 F5 键运行脚本而无需离开 IDLE;IDLE 将打开一个 shell 模式窗口来执行脚本并显示输出。

IDLE 作为 Tkinter 的示例

在我们开始使用 Tkinter 编程之前,让我们快速查看您可以使用它做什么,通过检查一些 IDLE 的 UI。从主菜单导航到选项|配置 IDLE以打开 IDLE 的配置设置。在这里,您可以更改 IDLE 的字体、颜色和主题、键盘快捷键和默认行为,如此截图所示:

图 1.3:IDLE 配置设置

考虑以下组成此用户界面的组件:

  • 有下拉菜单,允许您在大型选项集中进行选择。

  • 有可勾选的按钮,允许您在小型选项集中进行选择。

  • 有许多可点击的按钮来执行操作。

  • 有一个文本窗口可以显示多色文本。

  • 有标签化的框架,其中包含组件组。

  • 屏幕顶部有标签页,用于选择不同的配置部分。

在 Tkinter(如大多数 GUI 库一样),这些组件都被称为小部件;我们将在本书中遇到这些小部件以及更多内容,并学习如何像这里一样使用它们。然而,我们首先从一些更简单的东西开始。

创建 Tkinter 的 "Hello World"

在任何编程语言或库中,有一个伟大的传统是创建一个 "Hello World" 程序:即,显示 Hello World 并退出的程序。让我们逐步创建一个 Tkinter 的 "Hello World" 应用程序,并在过程中讨论它的各个部分。

首先,在 IDLE 或您喜欢的编辑器中创建一个名为 hello_tkinter.py 的新文件,并输入以下代码:

"""Hello World application for Tkinter"""
import tkinter as tk 

第一行被称为文档字符串,每个 Python 脚本都应该以一个开始。至少,它应该给出程序的名字,但也可以包括有关如何使用它、谁编写了它以及它需要什么的详细信息。

第二行将tkinter模块导入到我们的程序中。尽管 Tkinter 在标准库中,但我们必须导入它才能使用其任何类或函数。

有时,你可能看到这种导入方式被写成 from tkinter import *。这种做法被称为通配符导入,它会导致所有对象都被引入到全局命名空间中。虽然它在教程中因其简单性而流行,但在实际代码中却是个坏主意,因为我们的变量名可能与tkinter模块中的所有名称发生冲突,这可能导致微妙的错误。

为了避免这种情况,我们将tkinter放在它自己的命名空间中;然而,为了使代码简洁,我们将别名tkintertk。这个约定将在整本书中使用。

每个 Tkinter 程序必须恰好有一个根窗口,它代表我们应用程序的最高级窗口,也是应用程序本身。让我们创建我们的root窗口,如下所示:

root = Tk() 

root窗口是Tk类的一个实例。我们通过调用Tk()来创建它,就像我们在这里做的那样。在我们可以创建任何其他 Tkinter 对象之前,这个对象必须存在,当它被销毁时,应用程序会退出。

现在,让我们创建一个窗口小部件:

label = Label(root, text="Hello World") 

这是一个Label小部件,它只是一个可以显示一些文本的面板。任何 Tkinter 小部件的第一个参数总是父小部件(有时称为主小部件);在这种情况下,我们传递了根窗口的引用。父小部件是我们Label将被放置其上的一小部件,因此这个Label将直接位于应用程序的根窗口上。Tkinter GUI 中的小部件按层次排列,每个小部件都包含在另一个小部件中,直到根窗口。

我们还传递了一个关键字参数,text。这个参数当然定义了将被放置在小部件上的文本。对于大多数 Tkinter 小部件,大多数配置都是使用这样的关键字参数完成的。

现在我们已经创建了一个小部件,我们需要将其实际放置在 GUI 上:

label.pack() 

Label小部件的pack()方法被称为几何管理器方法。它的任务是确定小部件将如何附加到其父小部件,并在那里绘制它。如果没有这个调用,你的小部件将存在,但你不会在任何窗口上看到它。pack()是三种几何管理器之一,我们将在下一节中了解更多关于它的内容。

我们程序的最后一行看起来是这样的:

root.mainloop() 

这行代码启动了应用程序的事件循环。事件循环是一个无限循环,它持续处理程序执行期间发生的任何事件。事件可以是按键、鼠标点击或其他用户生成活动。这个循环一直运行到程序退出,因此在这行代码之后的任何代码都不会运行,直到主窗口关闭。因此,这行代码通常是任何 Tkinter 程序中的最后一行。

通过按 F5 在 IDLE 中运行程序,或者在终端中输入以下命令:

$ python hello_tkinter.py 

你应该会看到一个非常小的窗口弹出,上面显示Hello World,如图所示:

图 1.4

图 1.4:我们的“Hello World”应用程序

root.mainloop()调用之前,你可以随意修改这个脚本,添加更多的小部件。你可以添加更多的Label对象,或者尝试一些Button(创建可点击的按钮)或Entry(创建文本字段)小部件。就像Label一样,这些小部件使用父对象(使用root)和text参数初始化。别忘了在每个小部件上调用pack()来将它们放置在根窗口上。

本书所有章节的示例代码可以从github.com/PacktPublishing/Python-GUI-Programming-with-Tkinter-2E下载。你可能现在想下载这些代码,以便跟随学习。

当你准备好后,继续到下一节,我们将创建一个更有趣的应用程序。

Tkinter 基本概述

虽然看到第一个 GUI 窗口在屏幕上弹出可能非常令人兴奋,但“Hello World”并不是一个特别有趣的应用程序。让我们重新开始,在构建一个稍微大一点的应用程序的同时,更深入地了解 Tkinter。由于下一章将带你去一个虚构的农业实验室工作,研究水果植物,让我们创建一个小程序来评估你对香蕉的看法。

使用 Tkinter 小部件构建 GUI

在你的编辑器中创建一个名为banana_survey.py的新文件,并开始导入tkinter,如下所示:

# banana_survey.py
"""A banana preferences survey written in Python with Tkinter"""
import tkinter as tk 

hello_tkinter.py一样,在创建任何小部件或其他 Tkinter 对象之前,我们需要创建一个root窗口:

root = tk.Tk() 

再次强调,我们把这个对象命名为rootroot窗口可以以各种方式配置;例如,我们可以给它一个窗口标题或设置其大小,如下所示:

# set the title
root.title('Banana interest survey')
# set the root window size
root.geometry('640x480+300+300')
root.resizable(False, False) 

title()方法设置我们的窗口标题(即显示在任务管理器和窗口装饰中的名称),而geometry()设置窗口大小。在这种情况下,我们告诉根窗口大小为 640×480 像素。+300+300设置窗口在屏幕上的位置——在这种情况下,顶部和左侧各 300 像素(位置部分是可选的,如果你只关心大小)。请注意,geometry()的参数是一个字符串。在 Tcl/Tk 中,每个参数都被视为字符串。由于 Tkinter 只是一个包装器,它将参数传递给 Tcl/Tk,我们经常会发现字符串被用来配置 Tkinter 对象——即使我们可能期望使用整数或浮点数。

resizable()方法设置我们的窗口是否可以水平和垂直调整大小。True表示窗口可以调整大小,False表示其尺寸是固定的。在这种情况下,我们希望防止窗口调整大小,这样我们就不必担心使布局适应窗口大小的变化。

现在,让我们开始向我们的调查添加小部件。我们已经遇到了Label小部件,所以让我们添加一个:

title = tk.Label(
  root,
  text='Please take the survey',
  font=('Arial 16 bold'),
  bg='brown',
  fg='#FF0'
) 

正如我们在“Hello World”示例中看到的那样,传递给任何 Tkinter 小部件的第一个参数是小部件,新小部件将放置在这个小部件上。在这种情况下,我们将把这个Label小部件放置在root窗口上。小部件的其他参数被指定为关键字参数。在这里,我们指定了以下内容:

  • text,这是标签将显示的文本。

  • font,指定用于显示文本的字体家族、大小和粗细。请注意,字体设置被指定为一个简单的字符串,就像我们的geometry设置一样。

  • bg,用于设置小部件的背景颜色。在这里,我们使用了一个颜色名称;Tkinter 识别了大量的颜色名称,类似于 CSS 或 X11 中使用的颜色名称。

  • fg,用于设置小部件的前景色(文本)颜色。在这种情况下,我们指定了一个简短的十六进制字符串,其中三个字符分别代表红色、绿色和蓝色值。我们也可以使用六位十六进制字符串(例如,#FFE812)来对颜色进行更精细的控制。

在第九章“使用样式和主题改进外观”中,我们将学习更复杂的方法来设置字体和颜色,但这对现在来说已经足够好了。

Tkinter 有许多用于数据输入的交互式小部件,当然,最简单的是Entry小部件:

name_label = tk.Label(root, text='What is your name?')
name_inp = tk.Entry(root) 

Entry小部件只是一个简单的文本输入框,设计用于单行文本。Tkinter 中的大多数输入小部件都不包含任何类型的标签,所以我们添加了一个标签,以便让我们的用户清楚输入框的用途。

那个例外是Checkbutton小部件,我们将在下一节创建它:

eater_inp = tk.Checkbutton(
  root,
  text='Check this box if you eat bananas'
) 

Checkbutton创建一个复选框输入;它包括一个位于框旁边的标签,我们可以使用text参数设置其文本。

对于输入数字,Tkinter 提供了 Spinbox 小部件。让我们添加一个:

num_label = tk.Label(
  root,
  text='How many bananas do you eat per day?'
)
num_inp = tk.Spinbox(root, from_=0, to=1000, increment=1) 

SpinboxEntry 类似,但具有可以递增和递减框内数字的箭头按钮。我们在这里使用了几个参数来配置它:

  • from_to 参数分别设置按钮递减或递增的最小和最大值。请注意,from_ 在末尾有一个额外的下划线;这并不是一个错误!由于 from 是 Python 的一个关键字(用于导入模块),它不能用作变量名,因此 Tkinter 的作者选择使用 from_ 代替。

  • increment 参数设置箭头按钮增加或减少的数值。

Tkinter 有几个小部件允许您从预设的选择值中进行选择;其中最简单的一个是 Listbox,它看起来像这样:

color_label = tk.Label(
  root,
  text='What is the best color for a banana?'
)
color_inp = tk.Listbox(root, height=1)  # Only show selected item
# add choices
color_choices = (
  'Any', 'Green', 'Green-Yellow',
  'Yellow', 'Brown Spotted', 'Black'
  )
for choice in color_choices:
  color_inp.insert(tk.END, choice) 

Listbox 接受一个 height 参数,指定可见的行数;默认情况下,框足够大,可以显示所有选项。我们将其更改为 1,以便只显示当前选定的选项。其他选项可以使用箭头键访问。

要向框中添加选项,我们需要调用其 insert() 方法并逐个添加每个选项。我们在这里使用 for 循环来完成,以节省重复的编码。insert 的第一个参数指定我们想要插入选项的位置;请注意,我们使用了 tkinter 提供的一个特殊 常量tk.END。这是 Tkinter 中定义的许多特殊常量之一。在这种情况下,tk.END 表示小部件的末尾,因此我们插入的每个选择都将放置在末尾。

另一种让用户在少量选项之间进行选择的方法是 Radiobutton 小部件;这些与 Checkbutton 小部件类似,但类似于(非常、非常古老的)汽车收音机中的机械预设按钮,它们一次只允许选择一个。让我们创建几个 Radiobutton 小部件:

plantain_label = tk.Label(root, text='Do you eat plantains?')
plantain_frame = tk.Frame(root)
plantain_yes_inp = tk.Radiobutton(plantain_frame, text='Yes')
plantain_no_inp = tk.Radiobutton(plantain_frame, text='Ewww, no!') 

注意我们在 plantain_frame 上做了什么:我们创建了一个 Frame 对象,并将其用作每个 Radiobutton 小部件的父小部件。Frame 只是一个空白的面板,上面没有任何内容,它对于分层组织布局非常有用。在这本书中,我们将经常使用 Frame 小部件来将一组小部件组合在一起。

Entry 小部件适用于单行字符串,但对于多行字符串呢?对于这些,Tkinter 提供了 Text 小部件,我们创建它的方式如下:

banana_haiku_label = tk.Label(
  root, 
  text='Write a haiku about bananas'
)
banana_haiku_inp = tk.Text(root, height=3) 

Text 小部件的功能远不止多行文本,我们将在 第九章通过样式和主题改进外观 中探索其一些更高级的功能。不过,现在我们只使用它来显示文本。

我们的 GUI 如果没有用于调查的提交按钮就不完整了,这个按钮由 Button 类提供,如下所示:

submit_btn = tk.Button(root, text='Submit Survey') 

我们将使用这个按钮来提交调查并显示一些输出。我们可以使用哪个小部件来显示那个输出?结果证明,Label对象不仅对静态消息很有用;我们还可以在运行时使用它们来显示消息。

让我们为我们的程序输出添加一个:

output_line = tk.Label(root, text='', anchor='w', justify='left') 

在这里,我们创建了一个没有文本的Label小部件(因为我们还没有输出)。我们还使用了一些额外的参数来设置Label

  • anchor确定如果小部件比文本宽,文本将粘附在哪个小部件的侧面。Tkinter 有时使用基本方向(北、南、东和西),缩写为它们的首字母,每当它需要指定小部件的侧面时;在这种情况下,字符串'w'表示小部件的西边(或左边)。

  • justify确定当有多行代码时,文本将对齐到哪一侧。与anchor不同,它使用传统的'left''right''center'选项。

anchorjustify可能看起来是多余的,但它们的行为略有不同。在多行文本的情况下,文本可以与每行的中心对齐,但整个行集合可以锚定到小部件的西边,例如。换句话说,anchor影响相对于包含小部件的整个文本块,而justify影响相对于其他行的单个文本行。

Tkinter 有许多更多的小部件,我们将在本书的剩余部分遇到许多它们。

使用几何管理器排列我们的小部件

如果你将root.mainloop()添加到这个脚本中并直接执行,你会看到一个空白窗口。嗯,我们刚才创建的所有小部件都去哪了?嗯,你可能还记得从hello_tkinter.py中,我们需要使用像pack()这样的几何管理器来实际上将它们放置在父小部件的某个位置。

Tkinter 提供了三种几何管理器方法:

  • pack()是最古老的,它简单地按顺序将小部件添加到窗口的四个侧面之一。

  • grid()是较新的,也是首选的,它允许你在二维网格表中放置小部件。

  • place()是第三个选项,它允许你将小部件放置在特定的像素坐标上。它不建议使用,因为它对窗口大小、字体大小和屏幕分辨率的更改反应不佳,所以我们不会在这本书中使用它。

虽然pack()对于涉及少量小部件的简单布局来说确实不错,但它并不适合更复杂的布局,除非有大量的Frame小部件嵌套。因此,大多数 Tkinter 程序员依赖于更现代的grid()几何管理器。正如其名所示,grid()允许你在二维网格上布局小部件,就像电子表格文档或 HTML 表格一样。在这本书中,我们将主要关注grid()

让我们从使用grid()开始布局我们的 GUI 小部件,首先是title标签:

title.grid() 

默认情况下,对grid()的调用将小部件放置在第一列(列 0)的下一个空行中。因此,如果我们简单地对下一个小部件调用grid(),它将直接位于第一个小部件下方。然而,我们也可以通过使用rowcolumn参数来明确这一点,如下所示:

name_label.grid(row=1, column=0) 

行和列从小部件的左上角开始计数,从0开始。因此,row=1, column=0将小部件放置在第二行第一列。如果我们想要额外的列,我们只需要在小部件中放置一个,如下所示:

name_inp.grid(row=1, column=1) 

当我们向新行或新列添加小部件时,网格会自动扩展。如果一个小部件比当前列的宽度或行的长度大,那么该列或行的所有单元格都会扩展以容纳它。我们可以使用columnspanrowspan参数分别告诉小部件跨越多个列或多个行。例如,让我们的标题跨越表单的宽度可能是个不错的选择,所以让我们相应地修改它:

title.grid(columnspan=2) 

当列和行扩展时,小部件默认不会随着它们一起扩展。如果我们想让它们扩展,我们需要使用sticky参数,如下所示:

eater_inp.grid(row=2, columnspan=2, sticky='we') 

sticky告诉 Tkinter 将小部件的边缘粘附到其包含单元格的边缘,这样当单元格扩展时,小部件也会拉伸。就像我们上面学到的anchor参数一样,sticky也接受四个基本方向:n(北)、s(南)、e(东)和w(西)。在这种情况下,我们指定了西和东,这将导致当列进一步扩展时,小部件在水平方向上拉伸。

作为字符串的替代,我们还可以使用 Tkinter 的常量作为sticky参数的参数:

num_label.grid(row=3, sticky=tk.W)
num_inp.grid(row=3, column=1, sticky=(tk.W + tk.E)) 

在 Tkinter 看来,使用常量和字符串字面量之间没有真正的区别;然而,使用常量的优点是,你的编辑软件可以更容易地识别你是否使用了不存在的常量,而不是无效的字符串。

grid()方法允许我们为小部件添加填充,如下所示:

color_label.grid(row=4, columnspan=2, sticky=tk.W, pady=10)
color_inp.grid(row=5, columnspan=2, sticky=tk.W + tk.E, padx=25) 

padxpady表示外部填充——也就是说,它们将扩展包含单元格,但不会扩展小部件。另一方面,ipadxipady表示内部填充。指定这些参数将扩展小部件本身(从而扩展包含单元格)。

图片

图 1.5:内部填充(ipadx, ipady)与外部填充(padx, pady)的比较

Tkinter 不允许我们在同一个父小部件上混合几何管理器;一旦我们在任何子小部件上调用grid(),对兄弟小部件的pack()place()方法的调用将生成错误,反之亦然。

然而,我们可以在兄弟小部件的子项上使用不同的几何管理器。例如,我们可以使用pack()将子小部件放置在plantain_frame小部件上,如下所示:

plantain_yes_inp.pack(side='left', fill='x', ipadx=10, ipady=5)
plantain_no_inp.pack(side='left', fill='x', ipadx=10, ipady=5)
plantain_label.grid(row=6, columnspan=2, sticky=tk.W)
plantain_frame.grid(row=7, columnspan=2, stick=tk.W) 

plantain_labelplantain_frame小部件作为root的子项,必须使用grid()进行放置;然而,plantain_yesplantain_noplantain_frame的子项,因此如果我们愿意,可以选择在它们上使用pack()(或place())。以下图表说明了这一点:

图片

图 1.6:每个小部件的子项必须使用相同的几何管理器方法

这种为每个容器小部件选择几何管理器的能力,使我们能够在布局 GUI 时具有极大的灵活性。虽然grid()方法当然可以指定大多数布局,但在某些时候,pack()place()的语义对于我们界面的一部分来说更有意义。

虽然pack()几何管理器与grid()共享一些参数,如padxpady,但大多数参数是不同的。例如,示例中使用的side参数决定了小部件将从哪一侧填充,而fill参数决定了小部件将在哪个轴上扩展。

让我们向窗口添加最后几个小部件:

banana_haiku_label.grid(row=8, sticky=tk.W)
banana_haiku_inp.grid(row=9, columnspan=2, sticky='NSEW')
submit_btn.grid(row=99)
output_line.grid(row=100, columnspan=2, sticky='NSEW') 

注意,我们已经将Text小部件(banana_haiku_inp)固定在其容器的四面。这将导致它在网格拉伸时同时垂直和水平扩展。同时请注意,我们跳过了最后两个小部件的 99 行和 100 行。记住,未使用的行会被折叠成无,因此通过跳过行或列,我们可以为 GUI 未来的扩展留出空间。

默认情况下,Tkinter 会使我们的窗口大小刚好足够容纳我们放置在上面的所有小部件;但如果我们的窗口(或包含框架)变得比小部件所需的空间大呢?默认情况下,小部件将保持原样,固定在应用程序的左上角。如果我们想让 GUI 扩展并填充可用空间,我们必须告诉父小部件哪些列和行将扩展。我们通过使用父小部件的columnconfigure()rowconfigure()方法来完成此操作。

例如,如果我们想让我们的第二列(包含大部分输入小部件的列)扩展到未使用空间,我们可以这样做:

root.columnconfigure(1, weight=1) 

第一个参数指定了我们想要影响的列(从 0 开始计数)。关键字参数weight接受一个整数,该整数将决定列将获得多少额外空间。当只指定一个列时,任何大于 0 的值都会导致该列扩展到剩余空间中。

rowconfigure()方法的工作方式相同:

root.rowconfigure(99, weight=2)
root.rowconfigure(100, weight=1) 

这次,我们给两行分配了一个weight值,但请注意,行99被分配了2的权重,而100被分配了1的权重。在这种配置下,任何额外的垂直空间将在行99100之间分配,但行99将获得其中两倍的空间。

如您所见,通过结合使用grid()pack()子框架和一些精心规划,我们可以在 Tkinter 中相对容易地实现复杂的 GUI 布局。

使表单真正发挥作用

我们现在有一个很好的表单布局,包括一个提交按钮;那么我们如何让它真正做些什么呢?如果你过去只编写过程式代码,你可能会对 GUI 应用程序中代码的流程感到困惑。与过程式脚本不同,GUI 不能简单地从上到下执行所有代码。相反,它必须响应用户操作,如按钮点击或按键,无论何时何地发生。这些操作被称为事件。为了使程序响应用户事件,我们需要将事件绑定到函数上,我们称之为回调

在 Tkinter 中绑定事件到回调函数有几种方法;对于按钮,最简单的是配置其command属性,如下所示:

submit_btn.configure(command=on_submit) 

在创建小部件时(例如,submit_btn = Button(root, command=on_submit)),或者在创建小部件后使用其configure()方法,可以指定command参数。configure()方法允许你在创建小部件后通过传递参数来更改小部件的配置,就像创建小部件时一样。

在任何情况下,command指定了一个当按钮被点击时要调用的回调函数的引用。请注意,我们在这里函数名后不加括号;这样做会导致函数被调用,并且其返回值将被分配给command。我们在这里只想得到函数的引用。

在将回调函数传递给command之前,回调函数必须存在。因此,在调用submit_btn.configure()之前,让我们创建on_submit()函数:

def on_submit():
  """To be run when the user submits the form"""
  pass
submit_btn.configure(command=on_submit) 

当回调函数专门创建来响应特定事件时,通常以on_<event_name>的格式命名回调函数。然而,这不是必需的,也不总是合适的(例如,如果函数是多个事件的回调)。

绑定事件的一个更强大的方法是使用小部件的bind()方法,我们将在第六章规划应用扩展中更详细地讨论。

我们当前的on_submit()回调相当无聊,让我们让它变得更好。删除pass语句并添加以下代码:

def on_submit():
  """To be run when the user submits the form"""
  name = name_inp.get()
  number = num_inp.get()
  selected_idx = color_inp.curselection()
  if selected_idx:
    color = color_inp.get(selected_idx)
  else:
    color = ''
  haiku = banana_haiku_inp.get('1.0', tk.END)
  message = (
    f'Thanks for taking the survey, {name}.\n'
    f'Enjoy your {number} {color} bananas!'
  )
  output_line.configure(text=message)
  print(haiku) 

在这个函数中,我们首先要做的是从一些输入中检索值。对于许多输入,我们使用get()方法来检索小部件的当前值。请注意,即使在我们的Spinbox中,这个值也将以字符串的形式返回。

对于我们的列表小部件color,事情要复杂一些。它的get()方法需要一个索引号来选择一个选项,并返回该索引号的文本。我们可以使用小部件的curselection()方法来获取选中的索引。如果没有进行选择,选中的索引将是一个空元组。在这种情况下,我们将只设置color为空字符串。如果有选择,我们可以将值传递给get()

Text小部件获取数据又略有不同。它的get()方法需要两个值,一个用于起始位置,另一个用于结束位置。这些遵循特殊的语法(我们将在第三章使用 Tkinter 和 Ttk 小部件创建基本表单中讨论),但基本上1.0表示第一行的第一个字符,而tk.END是一个表示Text小部件末尾的常量。

从我们的CheckbuttonRadiobutton中检索数据,没有使用 Tkinter 控制变量是不可能的,我们将在下面的章节中讨论,即使用 Tkinter 控制变量处理数据

收集完数据后,我们的回调函数通过更新输出Label小部件的text属性,以包含一些输入数据的字符串结束,然后将用户的俳句打印到控制台。

要使这个脚本可运行,以这一行结束:

root.mainloop() 

这将执行脚本的事件循环,以便 Tkinter 可以开始响应用件。保存你的脚本并执行它,你应该会看到类似这样的:

图片

图 1.7:我们的香蕉调查应用程序

恭喜,你的香蕉调查应用程序工作正常!好吧,有点。让我们看看我们是否可以使它完全工作。

使用 Tkinter 控制变量处理数据

我们已经很好地掌握了 GUI 布局,但我们的 GUI 存在一些问题。从我们的小部件中检索数据有些混乱,我们甚至不知道如何获取CheckbuttonRadiobutton小部件的值。实际上,如果你尝试操作Radiobutton小部件,你会发现它们完全损坏了。看来我们遗漏了拼图中的一个大块。

我们缺少的是 Tkinter 控制变量。控制变量是特殊的 Tkinter 对象,允许我们存储数据;有四种类型的控制变量:

  • StringVar:用于存储任意长度的字符串

  • IntVar:用于存储整数

  • DoubleVar:用于存储浮点值

  • BooleanVar:用于存储布尔(True/False)值

但等等!Python 已经有一些可以存储这些类型数据以及更多数据的变量。我们为什么还需要这些类?简单地说,这些变量类具有一些常规 Python 变量所缺乏的特殊能力,例如:

  • 我们可以在控制变量和小部件之间创建双向绑定,这样如果小部件内容或变量内容发生变化,两者都将保持同步。

  • 我们可以在变量上设置跟踪。跟踪将变量事件(如读取或更新变量)绑定到回调函数。(跟踪将在第四章使用类组织我们的代码中讨论。)

  • 我们可以在小部件之间建立关系。例如,我们可以告诉我们的两个Radiobutton小部件它们是连接的。

让我们看看控制变量如何帮助我们的调查应用程序。回到顶部,查看定义名称输入的地方,然后添加一个变量:

**name_var = tk.StringVar(root)**
name_label = tk.Label(root, text='What is your name?')
name_inp = tk.Entry(root, **textvariable=name_var**) 

我们可以通过调用StringVar()来创建一个StringVar对象;注意,我们已将root窗口作为第一个参数传递。控制变量需要一个对root窗口的引用;然而,在几乎所有情况下,它们可以自动解决这个问题,因此在这里实际上指定root窗口很少是必要的。重要的是要理解,在没有 Tk 对象存在的情况下,不能创建任何控制变量对象

一旦我们有一个StringVar对象,我们可以通过将其传递给textvariable参数来将其绑定到我们的Entry小部件。通过这样做,name_inp小部件的内容和name_var变量保持同步。调用变量的get()方法将返回框的当前内容,如下所示:

print(name_var.get()) 

对于复选框,使用BooleanVar

eater_var = tk.BooleanVar()
eater_inp = tk.Checkbutton(
  root, variable=eater_var, text='Check this box if you eat bananas'
) 

这次,我们通过variable参数将变量传递给了Checkbutton。按钮小部件将使用关键字variable来绑定控制变量,而通常将文本值返回的小部件或用户输入的小部件则使用关键字textvariable

按钮小部件也接受textvariable参数,但它并不绑定按钮的值;相反,它绑定到按钮标签的文本。这个特性允许你动态更新按钮的文本。

可以使用value参数初始化变量,如下所示:

num_var = tk.IntVar(value=3)
num_label = tk.Label(text='How many bananas do you eat per day?')
num_inp = tk.Spinbox(
  root, textvariable=num_var, from_=0, to=1000, increment=1
) 

这里,我们使用IntVar()创建了一个整数变量并将其值设置为3;当我们启动表单时,num_inp小部件将被设置为3。请注意,尽管我们认为Spinbox是一个数字输入,但它使用textvariable参数来绑定其控制变量。Spinbox小部件实际上可以用于不仅仅是数字,因此其数据在内部以文本形式存储。然而,通过将其绑定到IntVarDoubleVar,检索到的值将自动转换为整数或浮点数。

如果用户能够输入字母、符号或其他无效字符,IntVarDoubleVar自动转换为整数或浮点数可能会成为一个问题。当在一个包含无效数字字符串(例如,'1.1.2''I like plantains')的小部件上调用get()方法时,将引发异常,导致我们的应用程序崩溃。在第五章通过验证和自动化减少用户错误,我们将学习如何解决这个问题。

之前,我们使用Listbox向用户显示选项列表。不幸的是,Listbox与控制变量配合得不太好,但还有一个名为OptionMenu的小部件可以做到这一点。

让我们将color_inp替换为OptionMenu小部件:

color_var = tk.StringVar(value='Any')
color_label = tk.Label(
  root, 
  text='What is the best color for a banana?'
)
color_choices = (
  'Any', 'Green', 'Green Yellow', 'Yellow', 'Brown Spotted', 'Black'
)
**color_inp = tk.OptionMenu(**
 **root, color_var, *color_choices**
**)** 

OptionMenu小部件以字符串的形式保存选项列表,因此我们需要创建一个StringVar来绑定它。注意,与ListBox小部件不同,OptionMenu允许我们在创建时指定选项。OptionMenu构造函数与其他 Tkinter 小部件构造函数也有所不同,因为它接受控制变量和选项作为位置参数,如下所示:

# Example, don't add this to the program
menu = tk.OptionMenu(parent, ctrl_var, opt1, opt2, ..., optN) 

在我们的调查代码中,我们通过使用解包操作符(*)将color_choices列表展开为位置参数来添加选项。我们也可以直接列出它们,但这样做可以使我们的代码更整洁。

我们将在第三章使用 Tkinter 和 Ttk 小部件创建基本表单中讨论一个更好的下拉列表框选项。

Radiobutton小部件与其他小部件在处理变量方面略有不同。为了有效地使用Radiobutton小部件,我们将所有分组在一起的按钮绑定到同一个控制变量上,如下所示:

plantain_var = tk.BooleanVar()
plantain_yes_inp = tk.Radiobutton(
  plantain_frame, text='Yes', value=True, variable=plantain_var
)
plantain_no_inp = tk.Radiobutton(
  plantain_frame, 
  text='Ewww, no!', 
  value=False, 
  variable=plantain_var
) 

我们可以将任何类型的控制变量绑定到Radiobutton小部件上,但我们必须确保为每个小部件提供一个与变量类型匹配的value。在这种情况下,我们使用按钮来回答True/False问题,因此BooleanVar是合适的;我们使用value参数将一个按钮设置为True,另一个设置为False。当我们调用变量的get()方法时,它将返回所选按钮的value参数。

不幸的是,并非所有 Tkinter 小部件都支持控制变量。值得注意的是,我们用于banana_haiku_inp输入的Text小部件不能绑定到变量上,而且(与Listbox不同)没有可用的替代方案。目前,我们不得不像之前那样处理Text输入小部件。

Tkinter 的Text框不支持变量,因为它不仅仅是多行文本;它可以包含图像、富文本和其他无法用简单字符串表示的对象。然而,在第四章使用类组织我们的代码中,我们将实现一个解决方案,允许我们将变量绑定到多行字符串小部件。

控制变量不仅用于绑定到输入小部件;我们还可以使用它们来更新非交互式小部件(如Label)中的字符串。例如:

output_var = tk.StringVar(value='')
output_line = tk.Label(
  root, textvariable=output_var, anchor='w', justify='left'
) 

通过将output_var控制变量绑定到这个Label小部件的textvariable参数,我们可以在运行时通过更新output_var来改变标签显示的文本。

在回调函数中使用控制变量

现在我们已经创建了所有这些变量并将它们绑定到小部件上,我们可以用它们做什么呢?跳转到回调函数on_submit(),并删除其中的代码。我们将使用控制变量重新编写它。

name值开始:

def on_submit():
  """To be run when the user submits the form"""
  name = name_var.get() 

如前所述,get()方法用于检索变量的值。get()返回的数据类型取决于变量的类型,如下所示:

  • StringVar返回一个str

  • IntVar 返回一个 int

  • DoubleVar 返回一个 float

  • BooleanVar 返回一个 bool

注意,每当调用 get() 时都会执行类型转换,因此如果小部件包含的内容与变量期望的内容不兼容,将会在此时刻引发异常。例如,如果 IntVar 绑定到一个空的 Spinboxget() 将会引发异常,因为空字符串无法转换为 int

因此,有时将 get() 放在 try/except 块中是明智的,如下所示:

 try:
    number = num_var.get()
  except tk.TclError:
    number = 10000 

与经验丰富的 Python 程序员可能预期的相反,对于无效值引发的异常不是 ValueError。转换实际上是在 Tcl/Tk 中完成的,而不是在 Python 中,因此引发的异常是 tkinter.TclError。在这里,我们已经捕获了 TclError 并通过将香蕉数量设置为 10,000 来处理它。

当 Tcl/Tk 执行我们的翻译 Python 调用遇到困难时,会引发 TclError 异常,因此为了正确处理它们,您可能需要从异常中提取实际的错误字符串。这有点丑陋,不符合 Python 风格,但 Tkinter 给我们的选择不多。

现在提取我们的 OptionMenuCheckbuttonRadiobutton 小部件的值要干净得多,正如您在这里可以看到的:

 color = color_var.get()
  banana_eater = eater_var.get()
  plantain_eater = plantain_var.get() 

对于 OptionMenuget() 返回选中的字符串。对于 Checkbutton,如果按钮被选中,则返回 True,如果没有选中,则返回 False。对于 Radiobutton 小部件,get() 返回选中小部件的 value。控制变量的好处是,我们不必知道或关心它们绑定到了哪种小部件;只需调用 get() 就足以检索用户的输入。

如前所述,Text 小部件不支持控制变量,因此我们必须以传统的方式获取其内容:

 haiku = banana_haiku_inp.get('1.0', tk.END) 

现在我们有了所有这些数据,让我们为调查者构建一个消息字符串:

 message = f'Thanks for taking the survey, {name}.\n'
  if not banana_eater:
    message += "Sorry you don't like bananas!\n"
  else:
    message += f'Enjoy your {number} {color} bananas!\n'
  if plantain_eater:
    message += 'Enjoy your plantains!'
  else:
    message += 'May you successfully avoid plantains!'
  if haiku.strip():
    message += f'\n\nYour Haiku:\n{haiku}' 

为了向用户显示我们的消息,我们需要更新 output_var 变量。这是通过使用它的 set() 方法来完成的,如下所示:

 output_var.set(message) 

set() 方法将更新控制变量,这反过来又会更新它所绑定到的 Label 小部件。这样,我们可以动态地更新显示的消息、小部件标签以及我们应用程序中的其他文本。

记得使用 set() 来更改控制变量的值!使用赋值运算符(=)只会用不同的对象覆盖控制变量对象,您将无法再使用它。例如,output_var = message 只会将名称 output_var 重新分配给字符串对象 message,而当前绑定到 output_line 的控制变量对象将变得无名称。

控制变量的重要性

希望你能看到控制变量是 Tkinter GUI 的一个强大且必要的一部分。我们将在我们的应用程序中广泛使用它们来存储和传递 Tkinter 对象之间的数据。实际上,一旦我们将一个变量绑定到小部件上,通常就不再需要保留对小部件的引用。例如,如果我们这样定义输出部分,我们的调查代码将运行得很好:

output_var = tk.StringVar(value='')
# remove the call to output_line.grid() in the layout section!
tk.Label(
  root, textvariable=output_var, anchor='w', justify='left'
).grid(row=100, columnspan=2, sticky="NSEW") 

由于我们不需要直接与输出Label交互,我们只需创建它并将所有内容放在一行中,无需保存引用。由于小部件的父级保留了对象的引用,Python 不会销毁该对象,我们可以随时使用控制变量检索其内容。当然,如果我们后来想以某种方式操作小部件(例如更改其font值),我们需要保留对其的引用。

摘要

在本章中,你学习了如何安装 Tkinter 和 IDLE,并且体验到了使用 Tkinter 开始构建 GUI 是多么简单。你学习了如何创建小部件,如何使用grid()布局管理器将它们排列在主窗口中,以及如何将它们的内联内容绑定到控制变量,如StringVarBooleanVar。你还学习了如何将事件,如按钮点击,绑定到回调函数,以及如何检索和处理小部件数据。

在下一章中,你将在 ABQ AgriLabs 开始你的新工作,并面临一个需要你的 GUI 编程技能的问题。你将学习如何分析这个问题,制定程序规范,并设计一个用户友好的应用程序,这将成为解决方案的一部分。

第二章:设计 GUI 应用程序

软件应用的开发分为三个重复的阶段:理解问题、设计解决方案和实施解决方案。这些阶段在应用程序的生命周期中重复,随着你添加新功能、改进功能并更新应用程序,直到它变得最优或过时。虽然许多程序员想要直接进入实施阶段,但放下代码编辑器,花时间完成前两个阶段将给你开发一个正确解决问题的应用程序的机会更大。

在本章中,我们将介绍你新工作场所的问题,并开始设计以下主题的解决方案:

  • 分析 ABQ AgriLabs 的问题中,我们将了解你新工作中你可以用你的编码技能帮助解决的问题。

  • 记录规范要求中,我们将创建一个程序规范,概述我们解决方案的要求。

  • 设计应用程序中,我们将开发一个实现解决方案的 GUI 应用程序的设计。

  • 评估技术选项中,我们将考虑哪个工具包和语言最适合我们的项目。

分析 ABQ AgriLabs 的问题

恭喜!你的 Python 技能让你在 ABQ AgriLabs 获得了一份优秀的数据分析师工作。到目前为止,你的工作相当简单:整理并对你每天由实验室数据录入人员发送给你的 CSV 文件进行简单的数据分析。

然而,有一个问题。你带着挫败感注意到,实验室发送的 CSV 文件质量很不一致。数据缺失,错误百出,而且经常需要花费大量时间重新录入文件。实验室主任也注意到了这个问题,并且知道你是一位熟练的 Python 程序员,她认为你可能能够帮忙。你已经接受了编写一个解决方案的任务,这个方案将允许数据录入人员以更少的错误将实验室数据录入 CSV 文件。你的应用程序需要简单,并尽可能减少错误的空间。

评估问题

电子表格通常是计算机用户跟踪数据的起点。它们的表格式布局和计算功能似乎使它们非常适合这项任务。

然而,随着数据集的增长和多个用户的添加,电子表格的缺点变得明显:它们不强制数据完整性,当处理长行稀疏或模糊数据时,它们的表格式布局可能会视觉上令人困惑,而且如果用户不小心,他们可以很容易地删除或覆盖数据。

为了改善这种情况,你提议实施一个简单的 GUI 数据录入表单,将数据追加到我们需要的 CSV 文件格式中。表单可以通过以下几种方式帮助提高数据完整性:

  • 他们可以强制输入数据的类型(例如,数字或日期)。

  • 他们可以验证录入的数据是否在预期的范围内,是否符合预期的模式,或者是否在有效的选项集中。

  • 他们可以自动填写诸如当前日期、时间和用户名等信息。

  • 他们可以确保所需的数据字段没有被留空。

通过实施一个设计良好的表单,我们可以大大减少数据录入人员的人为错误。我们从哪里开始呢?

收集关于问题的信息

要构建一个真正有效的数据录入应用程序,你需要的不仅仅是将一些录入字段放在表单上。理解数据及其周围的所有问题方面的工作流程非常重要。同时,了解你需要适应的人类和技术限制也同样重要。为了做到这一点,我们需要与几个不同的当事人进行交流:

  • 应用程序数据的发起者——在这种情况下,检查每个实验室图表的实验室技术人员。他们可以帮助我们了解数据的重要性,可能的值,以及可能需要特殊处理的数据异常情况。

  • 我们应用程序的用户——在这种情况下,数据录入人员。我们需要了解他们接收数据时的数据样子,他们录入数据的工作流程是怎样的,他们面临哪些实际或知识限制,以及我们的软件如何使他们的工作变得更容易而不是更难。

  • 应用程序数据的消费者——即所有将使用 CSV 文件的人(包括你!)他们对这个应用程序的输出有什么期望?他们希望如何处理异常情况?他们保持和分析数据的目的是什么?

  • 涉及将运行或消耗您应用程序数据的系统的支持人员。需要支持哪些技术?需要适应哪些技术限制?需要解决哪些安全问题?

当然,这些群体有时会重叠。无论如何,思考一下所有可能受数据和软件影响的工作人员的职责,并在设计应用程序时考虑他们的需求,这很重要。因此,在我们开始编码之前,我们将准备一些问题,以帮助我们收集这些细节。

访谈相关方

你首先会与实验室技术人员交谈,试图了解更多关于正在记录的数据的细节。这并不像听起来那么简单。软件在处理数据时需要绝对的黑白规则;另一方面,人们往往对他们的数据持一般性看法,并且他们通常在没有提示的情况下不考虑极限或边缘情况的精确细节。作为应用程序的设计师,你的任务是提出能够揭示你需要的信息的问题。

以下是我们可以向实验室技术人员提出的问题,以了解更多关于数据的信息:

  • 字符字段可接受的值是什么?是否有任何值被限制在离散的值集中?

  • 每个数值字段代表什么单位?

  • 数值字段真的是仅数字字段吗?它们是否可能需要字母或符号?

  • 每个数值字段可接受的数值范围是什么?

  • 不可用数据(如设备故障)如何标记?

接下来,让我们采访应用程序的用户。如果我们正在制作一个旨在减少用户错误的程序,我们必须了解这些用户以及他们的工作方式。在这个应用程序的情况下,我们的用户将是数据录入人员。我们需要询问他们关于他们的需求和工作流程的问题,以便我们可以创建一个对他们来说效果良好的应用程序。

这里有一些我们可以向数据录入人员提出的好问题:

  • 当你收到数据时,数据是如何格式化的?

  • 数据何时接收以及何时录入?最晚可能是什么时候录入?

  • 是否有可以自动填充的字段?用户是否应该能够覆盖自动值?

  • 用户的整体技术能力如何?他们是优秀的打字员,还是更喜欢鼠标驱动的界面?

  • 你喜欢当前解决方案的什么?你不喜欢什么?

  • 是否有任何用户有视觉或手动障碍需要考虑?

    倾听你的用户! 当与用户讨论应用程序设计时,他们可能会经常提出不切实际、不符合最佳实践或看似琐碎的要求或想法。例如,他们可能会要求在特定条件下按钮显示动画,特定字段为黄色,或者时间字段以小时和分钟的下拉列表形式表示。与其摒弃这些想法,不如试图理解背后的推理或促使他们提出这些想法的问题。这通常会揭示你之前没有理解的数据和工作流程的方面,并导致更好的解决方案。

一旦我们与用户交谈过,就轮到我们与数据消费者交谈了。在这种情况下,那就是你!你已经对需要和期望从数据中获得的内容有了很好的了解,但即便如此,反思并考虑你理想中希望从这个应用程序中接收数据的方式仍然很重要。例如:

  • CSV 真的是最好的输出格式,还是只是习惯上一直使用?

  • CSV 中字段的顺序重要吗?对标题值有约束(没有空格,大小写混合等)吗?

  • 应用程序应如何处理异常情况?它们在数据中应呈现什么样子?

  • 应如何在数据中表示不同的对象,如布尔值或日期值?

  • 是否有额外的数据应该被捕获以帮助你实现目标?

最后,我们需要了解我们的应用程序将与之合作的技术;也就是说,用于完成任务的计算机、网络、服务器和平台。你可以提出以下问题向 IT 支持人员询问:

  • 数据录入使用什么类型的计算机?它的速度或性能如何?

  • 它运行在什么操作系统平台上?

  • 这些系统上是否有 Python?如果有,是否安装了任何 Python 库?

  • 当前解决方案中涉及哪些其他脚本或应用程序?

  • 需要多少用户同时使用该程序?

随着开发过程的继续,不可避免地会有更多关于数据、工作流程和技术的问题出现。因此,务必与所有这些团队保持联系,并在需要时提出更多问题。

分析我们所发现的内容

您已经与所有感兴趣的各方进行了访谈,现在是时候回顾您的笔记了。您首先写下您已经知道的 ABQ 运营的基本信息:

  • 您的 ABQ 设施有三个温室,每个温室使用不同的气候,分别标记为 A、B 和 C

  • 每个温室有 20 个图块(标记为 1 到 20)

  • 目前有四种类型的种子样本,每个样本都带有六位数的标签

  • 每个图块中种植了 20 个给定样本的种子,以及它自己的环境传感器单元

数据提供者的信息

与实验室技术人员的交谈揭示了有关数据的大量信息。每天四次,在 8:00、12:00、16:00 和 20:00,每位技术人员都会检查其分配的实验室中的图块。他们使用纸质表格记录每个图块中植物和环境条件的信息,并将所有数值记录到不超过两位小数。这通常需要 45 到 90 分钟,具体取决于植物生长的进度。

每个图块都有自己的环境传感器,可以检测图块处的光、温度和湿度。不幸的是,这些设备容易发生暂时性故障,设备上的设备故障灯会指示这一点。由于故障会使环境数据可疑,他们只是在这些情况下划掉字段,并记录不记录这些数据。

他们为您提供了一份纸质表格的示例副本,看起来像这样:

图片

图 2.1:实验室技术人员填写的纸质表格

最后,技术人员会向您介绍字段的数据单位和可能的范围,您将在以下图表中记录:

字段 数据类型 备注
日期 日期 数据收集日期。通常是当前日期。
时间 时间 测量开始的时间段。可以是 8:00、12:00、16:00 或 20:00 之一。
实验室 字符 实验室 ID,可以是 A、B 或 C。
技术员 文本 记录数据的技师的姓名。
图块 整数 图块 ID,从 1 到 20。
种子样本 文本 种子样本的 ID 字符串。始终是一个包含数字 0 到 9 和字母 A 到 Z 的大写字母的六位代码。
故障 布尔值 如果环境设备记录了故障,则为真,否则为假。
湿度 小数 g/m³的绝对湿度,大约在 0.5 到 52.0 之间。
光照 小数 图块中心的光照量,单位为千勒克斯,介于 0 到 100 之间。
温度 小数 图块中的温度,单位为摄氏度;应在 4 到 40 之间。
花蕾数量 整数 图块中植物上的花蕾数量。没有最大值,但不太可能接近 1,000。
果实数量 整数 植物上的果实数量。没有最大值,但不太可能达到 1,000。
植物数量 整数 图块中的植物数量;不应超过 20。
最大高度 小数 图块中最高的植物高度,单位为厘米。没有最大值,但不太可能接近 1,000。
中位数高度 小数 图块中植物的中位数高度,单位为厘米。没有最大值,但不太可能接近 1,000。
最小高度 小数 图块中最矮的植物高度,单位为厘米。没有最大值,但不太可能接近 1,000。
备注 长文本 关于植物、数据、仪器等方面的额外观察。

来自应用程序用户的信息

与数据录入人员会话的结果是获得了关于他们工作流程和实际问题的良好信息。你了解到实验室技术人员在完成纸质表格后将其提交,数据通常立即录入,并且通常在提交当天完成。

数据录入人员目前正在使用电子表格(LibreOffice Calc)录入数据。他们喜欢能够使用复制粘贴来批量填充重复数据,如日期、时间和技术人员姓名。他们还指出,LibreOffice 的自动完成功能在文本字段中通常很有帮助,但有时会在数字字段中导致意外的数据错误。

你记录了他们如何从表格中录入数据的以下信息:

  • 日期以月/日/年格式录入,因为这是 LibreOffice 默认使用系统区域设置进行格式化的方式。

  • 时间以 24 小时制录入。

  • 技术人员以首字母和姓氏录入。

  • 在设备故障的情况下,环境数据录入为N/A

  • CSV 文件通常按图块顺序(从 1 到 20)逐个实验室创建。

总共有四位数据录入员,但任何时候只有一位在工作;在采访这些职员时,你了解到其中一位有红绿色盲,另一位由于 RSI 问题使用鼠标有困难。他们都是相当懂电脑的,并且更喜欢键盘输入而不是鼠标输入,因为这使他们能够更快地工作。

特别有一位用户对您的程序的外观提出了一些想法。他建议将实验作为一组复选框进行,并为植物数据和环境数据设置单独的弹出对话框。

来自技术支持的信息

与 IT 人员交谈后,你了解到数据录入人员只有一台 PC 工作站,他们共享这台机器。这是一台运行 Debian GNU/Linux 的较老系统,但性能足够。Python3 和 Tkinter 作为基础系统的一部分已经安装,尽管它们比你在工作站上的版本稍旧。数据录入人员将当天的 CSV 数据保存到名为 abq_data_record.csv 的文件中。当所有数据都录入完毕后,数据录入人员可以运行一个脚本来通过电子邮件发送文件并为第二天创建一个新的空文件。该脚本还会备份带有日期戳的旧文件,以便稍后可以调出来进行更正。

来自数据消费者的信息

作为主要的数据消费者,你很容易就坚持你已经知道的东西;然而,你花了时间去审查一份最近的 abq_data_record.csv 文件副本,它看起来像这样:

图片

图 2.2:abq_data_record.csv 文件

在反思这一点时,你意识到有一些改变现状的方法可以使你在进行数据分析时生活变得更轻松:

  • 如果文件能立即打上日期戳就太好了。目前,你的收件箱里满是名为 abq_data_record.csv 的文件,而且没有好的方法来区分它们。

  • 如果文件中的数据以 Python 可以更轻松解析且无歧义的方式保存,那就太有帮助了。例如,日期目前以本地月/日/年格式保存,但 ISO 格式会更好。

  • 你希望有一个字段明确指示设备故障发生的时间,而不仅仅是通过缺失的环境数据来暗示。

  • N/A 是你在处理数据时必须过滤掉的内容。如果设备故障能直接清除环境数据字段,那么文件就不会包含那样的无用数据就好了。

  • 当前的 CSV 标题很晦涩,你总是在报告脚本中需要翻译它们。有可读的标题会很好。

这些改变不仅会使你的工作变得更轻松,而且还会使数据处于比之前更可用的状态。像这些 CSV 文件这样的遗留数据格式通常充满了来自过时软件环境或过时工作流程的遗迹。提高数据的清晰度和可读性将有助于未来试图使用这些数据的人,随着实验室对数据的使用不断演变。

记录规范要求

现在你已经收集了关于受你的应用程序影响的数据、人员和技术的信息,现在是时候编写一个软件规范了。软件规范可能从非常正式、包含时间估计和截止日期的合同文件,到程序员打算构建的简单描述集合。规范的目的是为所有参与项目的人提供一个参考点,了解开发者将创建什么。它明确了要解决的问题、所需的功能以及程序应该和不应该做什么的范围。

你的场景相当非正式,你的应用程序也很简单,所以在这种情况下你不需要详细的正式规范。然而,一个基本的知识概述将确保你、你的雇主和用户都能理解你将要编写的应用程序的基本要素。

简单规范的目录

我们将从以下我们需要编写的项目概要开始编写规范:

  • 描述:这是一句或两句描述应用程序的主要目的、功能和目标的话。把它看作是程序的使命宣言。

  • 需求:这一部分是程序必须能够执行的具体事项列表,以便其具有最小功能。它可以包括功能和非功能需求。

    • 功能需求是程序必须实现的明确目标;例如,它必须执行的业务逻辑或它必须产生的输出格式。列出这些内容有助于我们了解何时我们的程序准备好投入生产使用。

    • 非功能需求通常不那么具体,关注用户期望和一般目标,例如可用性、性能或可访问性要求。尽管这些目标并不总是可衡量的,但它们有助于指导我们的开发重点。

  • 不需要的功能:这一部分是程序不需要执行的事项列表;它存在是为了阐明软件的范围,确保没有人对应用程序有非分之想。我们不需要列出应用程序不会做的每一件事;自然,我们的程序不会烤面包或洗衣服。然而,如果我们没有实现用户可能合理期望的功能,这里是一个澄清不会做什么的好地方。

  • 限制:这是程序必须在其下运行的约束列表,包括技术和人为的约束。

  • 数据字典:这是应用程序中数据字段及其参数的详细列表。数据字典可能会相当长,可能值得成为一个单独的文档。它不仅在我们开发应用程序期间有用,而且随着应用程序的扩展和数据在其他环境中被利用,它将成为应用程序产生的数据的关键参考。

编写 ABQ 数据录入程序规范

你可以用你喜欢的文字处理器编写规范,但理想情况下,规范应该被视为代码的一部分;它需要与代码一起保存,并与应用程序的任何更改保持同步。因此,我们将使用reStructuredText标记语言在我们的代码编辑器中编写规范。

对于 Python 文档、reStructuredText 或 reST,是官方的标记语言。Python 社区鼓励使用 reST 来记录 Python 项目,Python 社区中使用的许多打包和发布工具都期望 reST 格式。要深入了解 reST,请参阅附录 AreStructuredText 快速入门,或查看官方文档在https://docutils.sourceforge.io/rst.html

让我们从文档的“描述”部分开始:

======================================
 ABQ Data Entry Program specification
======================================
Description
-----------
This program facilitates entry of laboratory observations
into a CSV file. 

现在,让我们列出“需求”。记住,功能需求是客观可达到的目标,如输入和输出需求、必须完成的计算或必须存在的功能。另一方面,非功能需求是主观的或尽力而为的目标。回顾上一节的研究结果,考虑哪些需求是哪一种。你应该得出以下类似的结果:

Requirements
----------------------
Functional Requirements:
  * Allow all relevant, valid data to be entered,
    as per the data dictionary.
  * Append entered data to a CSV file:
    - The CSV file must have a filename of
    abq_data_record_CURRENTDATE.csv, where CURRENTDATE is the date
    of the laboratory observations in ISO format (Year-month-day).
    - The CSV file must include all fields
    listed in the data dictionary.
    - The CSV headers will avoid cryptic abbreviations.
  * Enforce correct datatypes per field.
Non-functional Requirements:
  * Enforce reasonable limits on data entered, per the data dict.
  * Auto-fill data to save time.
  * Suggest likely correct values.
  * Provide a smooth and efficient workflow.
  * Store data in a format easily understandable by Python. 

接下来,我们将通过“不需要的功能”部分来缩小程序的范围。记住,现在这只是一个输入表单;数据的编辑或删除将由电子表格应用程序处理。我们将如下明确这一点:

Functionality Not Required
--------------------------
The program does not need to:
  * Allow editing of data.
  * Allow deletion of data.
Users can perform both actions in LibreOffice if needed. 

对于“限制”部分,请记住,我们有一些用户有身体限制,以及硬件和操作系统限制。它应该看起来像这样:

Limitations
-----------
The program must:
  * Be efficiently operable by keyboard-only users.
  * Be accessible to color blind users.
  * Run on Debian GNU/Linux.
  * Run acceptably on a low-end PC. 

最后,我们将编写数据字典。这本质上是我们之前制作的表格,但我们将会将范围、数据类型和单位分开,以便快速参考,如下所示:

+------------+--------+----+---------------+--------------------+
|Field       | Type   |Unit| Valid Values  |Description         |
+============+========+====+===============+====================+
|Date        |Date    |    |               |Date of record      |
+------------+--------+----+---------------+--------------------+
|Time        |Time    |    | 8:00, 12:00,  |Time period         |
|            |        |    | 16:00, 20:00  |                    |
+------------+--------+----+---------------+--------------------+
|Lab         |String  |    | A - C         |Lab ID              |
+------------+--------+----+---------------+--------------------+
|Technician  |String  |    |               |Technician name     |
+------------+--------+----+---------------+--------------------+
|Plot        |Int     |    | 1 - 20        |Plot ID             |
+------------+--------+----+---------------+--------------------+
|Seed        |String  |    | 6-character   |Seed sample ID      |
|Sample      |        |    | string        |                    |
+------------+--------+----+---------------+--------------------+
|Fault       |Bool    |    | True, False   |Environmental       |
|            |        |    |               |sensor fault        |
+------------+--------+----+---------------+--------------------+
|Light       |Decimal |klx | 0 - 100       |Light at plot       |
|            |        |    |               |blank on fault      |
+------------+--------+----+---------------+--------------------+
|Humidity    |Decimal |g/m³| 0.5 - 52.0    |Abs humidity at plot|
|            |        |    |               |blank on fault      |
+------------+--------+----+---------------+--------------------+
|Temperature |Decimal |°C  | 4 - 40        |Temperature at plot |
|            |        |    |               |blank on fault      |
+------------+--------+----+---------------+--------------------+
|Blossoms    |Int     |    | 0 - 1000      |No. blossoms in plot|
+------------+--------+----+---------------+--------------------+
|Fruit       |Int     |    | 0 - 1000      |No. fruits in plot  |
+------------+--------+----+---------------+--------------------+
|Plants      |Int     |    | 0 - 20        |No. plants in plot  |
+------------+--------+----+---------------+--------------------+
|Max Height  |Decimal |cm  | 0 - 1000      |Height of tallest   |
|            |        |    |               |plant in plot       |
+------------+--------+----+---------------+--------------------+
|Min Height  |Decimal |cm  | 0 - 1000      |Height of shortest  |
|            |        |    |               |plant in plot       |
+------------+--------+----+---------------+--------------------+
|Median      |Decimal |cm  | 0 - 1000      |Median height of    |
|Height      |        |    |               |plants in plot      |
+------------+--------+----+---------------+--------------------+
|Notes       |String  |    |               |Miscellaneous notes |
+------------+--------+----+---------------+--------------------+ 

目前为止,这就是我们的规范!随着我们发现新的需求,规范很可能增长、变化或变得更加复杂,但它为我们设计应用程序的第一版提供了一个很好的起点。

设计应用程序

拿着我们的规范和明确的需求,现在是时候开始设计我们的解决方案了。我们应用程序的主要焦点是数据输入表单本身,因此我们将从这里开始这个 GUI 组件的设计。

我们将分三步为我们的表单创建一个基本设计:

  1. 确定每个数据字段的适当输入小部件类型

  2. 将相关项目分组在一起,以创建一种组织感

  3. 在其组内布局我们的小部件

决定输入控件

在没有承诺特定的 GUI 库或小部件集的情况下,我们可以通过为每个字段决定适当的输入小部件类型来开始表单设计。大多数工具包都为不同类型的数据提供了相同的基本输入类型。

我们在查看 Tkinter 时已经看到了一些这些,但让我们看看可能有哪些选项可用:

小部件类型 Tkinter 示例 用途
行输入 Entry 单行字符串
数字输入 Spinbox 整数或小数值
选择列表(下拉列表) ListboxOptionMenu 在许多不同的值之间进行选择
复选框 Checkbutton 真假值
单选按钮 Radiobutton 在几个不同的值之间进行选择
文本输入 Text 多行文本输入
日期输入 (无特定) 日期

查看我们的数据字典,我们应该为每个字段选择哪种小部件?让我们考虑一下:

  • 有几个小数字段,许多具有清晰的边界范围,如最小高度、最大高度、中等高度、湿度、温度和光照。我们需要某种类型的数字输入,可能是一个 Tkinter Spinbox

  • 此外,还有一些整数字段,如植物、花蕾和果实。同样,像Spinbox小部件这样的数字输入是正确的选择。

  • 有几个字段具有有限的可能值:时间和实验室。对于这些,我们可以选择单选按钮或某种选择列表。这完全取决于选项的数量和我们的布局方式:当有超过几个选项时,单选按钮会占用很多空间,但选择列表小部件需要额外的交互并减慢用户速度。我们将为时间字段选择选择/下拉列表,为实验室字段选择单选按钮。

  • 图表字段是一个棘手的情况。从表面上看,它看起来像是一个整数字段,但想想看:图表也可以用字母、符号或名称来标识。数字只是任意标识符的一个简单值集合。图表 ID,就像实验室 ID 一样,实际上是一个受限的值集;因此,在这里使用选择列表会更有意义。

  • 备注字段是多行文本,因此 Text 小部件在这里是合适的。

  • 有一个布尔字段,故障。在这里使用复选框类型的小部件是一个不错的选择,尤其是因为这个值通常是假的,代表一种特殊情况。

  • 对于日期字段,使用某种形式的日期输入会更好。我们目前还没有在 Tkinter 中找到这样的输入方式,但当我们编写应用程序时,我们会看看是否能够解决这个问题。

  • 剩余的行是简单的单行字符字段。我们将为这些字段使用文本输入类型的小部件。

我们最终的分析结果如下:

字段 小部件类型
日期 日期输入
时间 选择列表
实验室 单选按钮
技术员 文本输入
图表 选择列表
种子样本 文本输入
故障 复选框
湿度 数字输入
光照 数字输入
温度 数字输入
花蕾 数字输入
果实 数字输入
植物 数字输入
最大高度 数字输入
中等高度 数字输入
最小高度 数字输入
备注 文本输入

请记住,这种分析并不是一成不变的;随着我们从用户那里收到反馈,随着应用程序的使用案例的发展,或者随着我们更熟悉 Python 和 Tkinter 的功能和限制,它几乎肯定会进行修订。这只是一个起点,我们可以从中创建一个初步设计。

分组我们的字段

当人们盯着一大堆无序的输入时,往往会感到困惑。通过将输入表分成相关字段集,你可以为你的用户提供很大的便利。当然,这假设你的数据有相关的字段集,不是吗?我们的数据有分组吗?

回想一下我们在访谈中收集的一些信息:

  • 其中一位员工要求为“环境数据”和“植物数据”分别制作表格

  • 纸质表格的布局将时间、日期、实验室和技术员全部放在顶部;这些信息有助于识别数据记录会话

这样的细节告诉你很多关于用户如何思考他们的数据,这应该会指导应用程序如何呈现这些数据。

考虑所有这些,你确定了以下相关组:

  • 日期、实验室、Plot、种子样本、技术人员和时间字段是关于记录本身的标识数据或元数据。你可以将这些信息一起分组在标题为记录信息下。

  • 花朵、水果、三个高度字段和植物字段都是与 Plot 字段中的植物相关的测量值。你可以将这些信息一起分组在植物数据标题下。

  • 湿度、光照、温度和设备故障字段都是来自环境传感器的信息。你可以将这些信息分组为环境数据

  • 笔记字段可能与任何事物相关,所以它属于一个单独的类别。

大多数 GUI 库都提供了多种方式来将表单的各个部分分组在一起;想想你看到过的。以下是一些列出的例子:

小部件类型 描述
标签页(笔记本) 允许用户在多个标签页之间切换
框架/框 在表单的各个部分周围绘制框,有时带有标题
手风琴 将表单分成可以隐藏或逐个展开的各个部分

框架是分割 GUI 的最简单方式。在有很多字段的情况下,标签页或手风琴小部件可以通过隐藏用户未使用的字段来提供帮助。然而,它们需要额外的用户交互来切换页面或部分。经过一些考虑,你决定带有标题的框架将完全适合这个表单。实际上没有足够的字段来证明需要单独的页面,而且切换它们只会给数据输入过程增加更多开销。

布置表单

到目前为止,我们知道我们有 17 个输入,它们被分组如下:

  • 六个在记录信息下的字段

  • 四个在环境数据下的字段

  • 六个在植物数据下的字段

  • 一个大的笔记字段

我们想使用某种带有标题标签的框或框架来分组前面的输入。注意,前三个部分中的两个部分有多个小部件。这表明我们可以将它们排列成一个每行有三项的网格。我们应该如何在每个组内排序字段?

字段的排序看似微不足道,但对于用户来说,它可能在可用性上产生重大差异。那些必须随意在表单中跳转以匹配其工作流程的用户更有可能出错。

正如你所学的,数据是从实验室技术人员填写的纸质表格中输入的。请参考前一小节中显示的纸质表格截图 图 2.1。看起来项目大多是以我们记录的方式分组,因此我们将使用此表单上的顺序来排序我们的字段。这样,数据录入员就可以从上到下、从左到右快速浏览整个表单,而无需在屏幕上四处跳转。

记住,用户工作流程很重要!当设计一个新的应用程序来替代现有流程的一部分时,尊重既定的工作流程至关重要。虽然改善现状可能需要调整工作流程,但请务必注意,在没有充分理由的情况下,不要让别人的工作变得更难。

我们设计中的最后一个考虑因素是字段标签相对于字段的位置。在 UI 设计社区中,关于标签最佳放置位置有很多争议,但共识是以下两种选项之一最佳:

  • 字段上方的标签

  • 字段左侧的标签

你可以尝试绘制两种方案,看看你更喜欢哪一种,但在这个应用程序中,字段上方的标签可能更适合以下原因:

  • 由于字段和标签都是矩形的,因此通过堆叠它们,我们的表单将更加紧凑。

  • 由于我们不需要找到一个适用于所有标签且不会使标签与字段距离过远的标签宽度,因此布局工作会容易得多。

唯一的例外是复选框字段;复选框通常在控件右侧标注。

请花点时间用纸笔或如果你更喜欢的话,用绘图程序制作一个表单的草图。你的表单应该看起来像这样:

图片

图 2.3:表单布局

布置应用程序

当你的表单设计完成后,是时候考虑应用程序其余部分的 GUI 了:

  • 你需要一个保存按钮来触发已输入数据的存储。

  • 通常,我们会包括一个重置表单的按钮,以便用户在需要时可以重新开始。

  • 有时,我们可能需要向用户提供状态信息。例如,我们可能想让他们知道记录是否成功保存,或者特定字段中是否有错误。应用程序通常有一个 状态栏,在窗口底部显示这些类型的消息。

  • 最后,最好有一个标题来表明表单的内容。

在我们的草图添加以下内容后,我们会有如下截图所示的内容:

图片

图 2.4:应用程序布局

看起来不错!您的最后一步是将这些设计展示给用户和主管,以获取任何反馈或批准。祝您好运!

尽可能让利益相关者——您的老板、用户以及将受您的程序影响的其他人——尽可能参与到您的应用程序设计过程中。这减少了您以后不得不重新设计应用程序的可能性。

评估技术选项

在我们开始编码之前,让我们花一点时间来评估可用于实现此设计的现有技术选择。

自然,我们将使用 Python 和 Tkinter 来构建这个表单,因为这正是本书的主题。然而,在现实世界中,值得问一问 Tkinter 是否真的是应用程序技术的最佳选择。在决定用于实现应用程序的语言、库和其他技术时,许多标准都会发挥作用,包括性能、功能可用性、成本和许可、平台支持以及开发者的知识和信心。

让我们根据以下标准评估我们的 ABQ 应用程序的情况:

  • 性能:这不会是一个高性能的应用程序。没有计算密集型任务,高速不是关键。Python 和 Tkinter 在性能方面将完美无缺。

  • 功能可用性:您的应用程序需要能够显示基本表单字段,验证输入的数据,并将其写入 CSV 文件。Tkinter 可以处理这些前端需求,而 Python 可以轻松处理 CSV 文件。您对 Tkinter 缺少专门的日期输入字段有些担忧,但这可能是一些我们可以解决的难题。

  • 成本和许可:这个项目不会分发或出售,因此许可不是一个大问题。尽管如此,该项目没有预算,因此您使用的任何东西都需要免费,没有任何财务成本。Python 和 Tkinter 都是免费的,并且拥有宽松的许可,所以无论如何这都不是问题。

  • 平台支持:您将在 Windows PC 上开发应用程序,但它需要在 Debian Linux 上运行,因此 GUI 的选择应该是跨平台的。它将运行的计算机较旧且速度较慢,因此您的程序需要节省资源。Python 和 Tkinter 在这里都符合要求。

  • 开发者知识和信心:您的专长在于 Python,但在创建 GUI 方面经验不足。为了尽快交付,您需要一个与 Python 配合良好且易于学习的选项。您还希望选择一个成熟且稳定的工具,因为您没有时间跟上工具包中的新进展。Tkinter 在这里是一个很好的选择。

在这里,不要将您自己的技能、知识和对技术的舒适度排除在外!虽然做出客观的选择和认识到您对已知事物的个人偏见是好的,但同样重要的是要认识到您自信交付和维护产品的能力是您评估中的一个关键因素。

考虑到 Python 的可选方案,Tkinter 是此应用程序的一个好选择。它易于学习,轻量级,免费,在您的开发和目标平台上都很容易获得,并提供了我们数据输入表单所需的基本功能。在解决了这个问题之后,是时候更深入地研究 Tkinter,以找到我们构建此应用程序所需的内容。

Python 在 GUI 开发方面还有其他选项,包括 PyQt、Kivy 和 wxPython。与 Tkinter 相比,它们有不同的优势和劣势,但如果您发现 Tkinter 不适合某个项目,这些中的任何一个可能是一个更好的选择。

摘要

在本章中,您完成了应用程序开发的头两个阶段:理解问题和设计解决方案。您学习了如何通过访谈用户和检查数据和需求来开发应用程序规范,为您的用户创建了一个最佳表单布局,并了解了在 GUI 框架中处理不同类型输入数据的不同类型的控件。在创建规范后,您评估了 Tkinter,看看它是否是一个合适的技术。最重要的是,您了解到开发应用程序不是从代码开始的,而是从研究和规划开始的。

在下一章中,您将使用 Tkinter 和 Python 创建您设计的初步实现。您将了解一个新的控件集 Ttk,并使用它以及我们已经遇到的一些 Tkinter 控件来创建表单和应用程序。

第三章:使用 Tkinter 和 Ttk 小部件创建基本表单

好消息!您的设计方案已经经过审查并获得总监批准。现在是时候开始实施了!在本章中,我们将创建一个非常简单的应用程序,它提供了规范的核心功能,而其他功能则很少。这被称为 最小可行产品MVP。MVP 不会是生产就绪的,但它将给我们一些东西向用户展示,并帮助我们更好地理解问题和我们所工作的技术。以下是我们将要讨论的主题:

  • Ttk 小部件集 中,我们将了解一个更好的 Tkinter 小部件集,即 Ttk。

  • 实现应用程序 中,我们将使用 Python、Tkinter 和 Ttk 来构建我们的表单设计。

让我们开始编码!

Ttk 小部件集

第一章Tkinter 简介 中,我们使用默认的 Tkinter 小部件创建了一个调查应用程序。这些小部件功能齐全,并且仍然被许多 Tkinter 应用程序使用,但现代 Tkinter 应用程序往往更喜欢一个改进的小部件集,称为 Ttk。Ttk 是 Tkinter 的一个子模块,它提供了许多(但不是所有)Tkinter 小部件的主题版本。这些小部件与传统的类似,但提供了旨在使它们在 Windows、macOS 和 Linux 上看起来更现代和自然的先进样式选项。

在每个平台上,Ttk 包含模仿平台原生小部件的平台特定主题。此外,Ttk 还添加了一些额外的提供默认库中找不到的功能的小部件。

虽然本章将涵盖 Ttk 小部件的基本用法,但关于 Ttk 小部件的字体、颜色和其他样式定制的完整覆盖可以在 第九章通过样式和主题改进外观 中找到。

Ttk 已经作为 Tkinter 的一部分包含在内,因此我们不需要安装任何额外的东西。要在我们的 Tkinter 应用程序中使用 Ttk 小部件,我们需要像这样导入 ttk

from tkinter import ttk 

在本节中,我们将更深入地了解将在我们的应用程序中非常有用的小部件。记住,从我们的设计中我们知道我们需要以下类型的小部件:

  • 标签

  • 日期输入

  • 文本输入

  • 数字输入

  • 复选框

  • 单选按钮

  • 选择列表

  • 长文本输入

  • 按钮

  • 带标题的框式框架

让我们看看我们可以用来满足这些需求的小部件。

标签小部件

第一章Tkinter 简介 中,我们很好地使用了 Tkinter Label 小部件,Ttk 版本本质上相同。我们可以这样创建一个:

mylabel = ttk.Label(root, text='This is a label') 

这将产生一个看起来像这样的标签:

图 3.1:一个 Ttk 标签小部件

Ttk Label 小部件与 Tk 版本具有大多数相同的选项,其中最常见的是以下列出的:

参数 描述
text 字符串 标签的文本内容
textvariable StringVar 绑定到标签内容的变量
anchor 方向 文本相对于内边距的位置
justify left, right, 或 center 文本行相对于彼此的对齐方式
foreground 颜色字符串 文本的颜色
wraplength 整数 文本换行前的像素数
underline 整数 text 中字符的下划线索引
font 字体字符串或元组 要使用的字体

注意,标签的文本可以通过 text 直接指定,或者绑定到 StringVar,允许动态标签文本。underline 参数允许在标签文本中下划线一个字符;这有助于指示用户的快捷键,例如,激活由标签命名的控制小部件。此参数实际上不会创建快捷键;它仅具有装饰性。我们将在 第十章维护跨平台兼容性 中学习如何创建快捷键。

Entry 小部件

ttk.Entry 小部件是一个简单的单行文本输入,就像 Tkinter 版本一样。它看起来是这样的:

图片

图 3.2:Ttk Entry 小部件

我们可以使用以下代码创建一个 Entry 小部件:

myentry = ttk.Entry(root, textvariable=my_string_var, width=20) 

Ttk 的 Entry 小部件与我们之前看到的 Tkinter Entry 小部件非常相似,并支持许多相同的参数。以下是更常见的 Entry 选项的选取:

参数 描述
textvariable StringVar Tkinter 控制变量绑定。
show 字符串 用户输入时显示的字符或字符串。例如,对于密码字段很有用。
justify left, right, 或 center 输入框中文字的对齐方式。left 为默认值。
foreground 颜色字符串 文本的颜色。

在未来章节中深入探讨 Ttk 小部件的功能时,我们将学习更多关于 Entry 的选项。Entry 将用于我们所有的文本输入字段,以及我们的 Date 字段。Ttk 没有专门的 date 小部件,但在 第五章通过验证和自动化减少用户错误 中,我们将学习如何将我们的 Entry 转换为 date 字段。

Spinbox 小部件

与 Tkinter 版本一样,Ttk 的 Spinbox 在标准的 Entry 小部件中添加了增加和减少按钮,使其适合数值数据。

这里展示了 Ttk 的 Spinbox

图片

图 3.3:Ttk Spinbox 小部件

我们可以创建一个如下所示:

myspinbox = ttk.Spinbox(
  root,
  from_=0, to=100, increment=.01,
  textvariable=my_int_var,
  command=my_callback
) 

如此代码所示,Ttk 的 Spinbox 接受多个参数来控制其箭头按钮的行为,列于下表:

参数 描述
from_ 浮点数或整数 箭头减少到的最小值。
to 浮点数或整数 箭头增加到的最大值。
increment 浮点数或整数 由箭头添加或减去的值。
command Python 函数 当任意按钮被按下时执行的回调函数。
textvariable 控制变量(任何类型) 绑定到字段值的变量。
values 字符串或数字列表 按钮将滚动浏览的选项集。覆盖from_to值。

注意,这些参数并不限制输入到Spinbox中的内容;它们仅影响箭头的行为。此外,请注意,如果你只指定了from_to中的一个,另一个将自动默认为0。这可能会导致意外的行为;例如,如果你设置了from_=1但没有指定to,那么to将默认为0,你的箭头将只在10之间切换。要显式设置无限制,可以使用from_='-infinity'to='infinity'

Spinbox小部件不仅用于数字,尽管我们主要会这样使用它。正如你所见,它还可以接受values参数,这是一个字符串或数字列表,可以通过箭头按钮滚动浏览。因此,Spinbox可以绑定到任何类型的控制变量,而不仅仅是IntVarDoubleVar变量。

记住,这些参数实际上并没有限制可以输入到Spinbox小部件中的内容。它实际上只是一个带有按钮的Entry小部件,你可以输入不仅限于有效范围内的数值,还可以输入字母和符号。这样做可能会导致异常,如果你将小部件绑定到非字符串变量。在第五章通过验证和自动化减少用户错误中,我们将学习如何使Spinbox小部件仅允许输入有效的数字字符。

复选框小部件

Ttk Checkbutton小部件是一个带标签的复选框,非常适合输入布尔数据。它可以创建如下:

mycheckbutton = ttk.Checkbutton(
  root,
  variable=my_bool_var,
  textvariable=my_string_var,
  command=my_callback
) 

除了上面列出的参数外,Checkbutton小部件还可以接受其他参数,如下表所示:

参数 描述
variable 控制变量 复选框选中/未选中状态所绑定的变量
text String 标签文本
textvariable StringVar 标签文本所绑定的变量
command Python 函数 当复选框选中或未选中时执行的回调函数
onvalue Any 当复选框选中时设置variable的值
offvalue Any 当复选框未选中时设置variable的值
underline Integer text中字符的下划线索引

Checkbutton中的标签可以直接使用text参数设置,或者可以使用textvariable将其绑定到控制变量。这允许对小部件进行动态标签化,这在许多情况下都很有用。

虽然复选框非常适合布尔数据,并且默认将其绑定的变量设置为TrueFalse,但我们可以使用onvalueoffvalue参数来覆盖此行为,使其可以与任何类型的控制变量一起使用。

例如,我们可以用它与DoubleVar一起使用,如下所示:

mycheckbutton2 = ttk.Checkbutton(
  root,
  variable=my_dbl_var,
  text='Would you like Pi?',
  onvalue=3.14159,
  offvalue=0,
  underline=15
) 

Ttk Checkbutton将标签放置在框的右侧,如以下截图所示:

图片

图 3.4:带有内置标签的 Ttk Checkbutton 小部件

Radiobutton 小部件

与其 Tkinter 对应物一样,Ttk Radiobutton小部件用于在一组相互排斥的选项中进行选择。单个Radiobutton本身不是一个非常有用的小部件;相反,它们通常作为一个组创建,如下所示:

图 3.5:一对 Ttk Radiobutton 小部件

以下代码显示了如何创建这些按钮:

buttons = tk.Frame(root)
r1 = ttk.Radiobutton(
  buttons,
  variable=my_int_var,
  value=1,
  text='One'
)
r2 = ttk.Radiobutton(
  buttons,
  variable=my_int_var,
  value=2,
  text='Two'
) 

要分组Radiobutton小部件,您只需将它们都分配给相同的控制变量,然后为每个按钮添加一个不同的value。在我们的例子中,我们还将它们分组在同一个父小部件上,但这只是为了视觉原因,并不是严格必要的。

此表显示了您可以与Radiobutton一起使用的各种参数:

参数 描述
variable 控制变量 绑定到按钮选中状态的变量
value 任何 当按钮被选中时设置变量的值
command Python 函数 当按钮被点击时执行的回调函数
text 字符串 连接到单选按钮的标签
textvariable StringVar 绑定到按钮标签文本的变量
underline 整数 text中要下划线的字符索引

Combobox 小部件

第一章Tkinter 简介中,我们学习了提供不同选项之间选择的一两种方法:ListboxOptionMenu小部件。Ttk 为此目的提供了一个新小部件,ComboboxCombobox小部件是一个带有下拉列表框的Entry小部件。它不仅允许鼠标选择,还允许键盘输入。尽管在某些方面OptionMenu可能更适合我们的应用程序,但我们将利用Combobox小部件的键盘功能来构建一个更高级的下拉小部件。

我们可以这样创建一个Combobox小部件:

mycombo = ttk.Combobox(
  root, textvariable=my_string_var,
  values=['This option', 'That option', 'Another option']
) 

运行此代码将给我们一个看起来像这样的组合框:

图 3.6:一个 Ttk Combobox 小部件

注意,虽然我们可以指定一个可能的值列表来填充下拉列表框,但Combobox小部件并不限于这些值。用户可以在框中输入任何他们想要的文本,并且绑定的变量将相应地更新。默认情况下,Combobox不适合必须保持约束在固定列表中的值列表;然而,在第五章通过验证和自动化减少用户错误中,我们将学习如何解决这个问题。

此表显示了与Combobox一起使用的常见参数:

参数 描述
textvariable StringVar 绑定到Combobox内容的变量
values 字符串列表 填充下拉listbox的值
postcommand Python 函数 listbox显示之前运行的回调函数
justify leftrightcenter 框中文本的对齐方式

Text 小部件

我们已经在第一章Tkinter 简介中遇到过的Text小部件是我们将使用的唯一没有 Ttk 版本的小部件。虽然这个小部件最常用于多行文本输入,但它实际上提供了更多功能。Text小部件可以用来显示或编辑包含图像、多色文本、超链接样式可点击文本等的文本。

我们可以这样向一个应用程序添加一个:

mytext = tk.Text(
  root,
  undo=True, maxundo=100,
  spacing1=10, spacing2=2, spacing3=5,
  height=5, wrap='char'
) 

上述代码将生成类似以下的内容:

图 3.7:Tk Text 小部件

Text小部件有大量我们可以指定的参数来控制其外观和行为。其中一些更有用的参数列在这个表中:

参数 描述
height 整数 小部件的高度,以文本行数表示。
width 整数 小部件的宽度,以字符数表示。对于可变宽度字体,使用“0”字符的宽度来计算宽度。
undo 布尔值 激活或停用撤销功能。撤销和重做操作使用平台默认快捷键激活。
maxundo 整数 将存储的撤销操作的最大数量。
wrap nonecharword 指定当文本行超出小部件宽度时,文本行将如何断开和换行。
spacing1 整数 在每行文本上方填充的像素数。
spacing2 整数 在显示的换行文本行之间填充的像素数。
spacing3 整数 在每行文本下方填充的像素数。

使用标签实现Text小部件的更高级视觉配置。我们将在第九章使用样式和主题改进外观中讨论标签。

Text 小部件索引

记住,Text小部件不能绑定到控制变量;要访问、设置或清除其内容,我们需要分别使用其get()insert()delete()方法。

当使用这些方法读取或修改时,您需要传递一个或两个索引值来选择您正在操作的字符或字符范围。这些索引值是字符串,可以采用以下任何一种格式:

  • 行号和字符号由点分隔。行从 1 开始编号,字符从 0 开始,因此第一行的第一个字符是1.0,而第四行的第十二个字符是4.11。请注意,由换行符的存在确定;换行文本行在索引目的上仍然只被认为是一行。

  • 字符串字面量end,或 Tkinter 常量END,表示文本的末尾。

  • 一个数值索引加上单词linestartlineendwordstartwordend之一,表示相对于数值索引的行或单词的开始或结束。例如:

    • 6.2 wordstart将是包含第 6 行第三个字符的单词的开始

    • 2.0 lineend将是第 2 行的末尾

  • 任何前面的内容,一个加号或减号运算符,以及一定数量的字符或行。例如:

    • 2.5 wordend - 1 chars将是包含第 2 行第 6 个字符的单词结束前的字符

以下示例展示了这些索引的实际应用:

# insert a string at the beginning
mytext.insert('1.0', "I love my text widget!")
# insert a string into the current text
mytext.insert('1.2', 'REALLY ')
# get the whole string
mytext.get('1.0', tk.END)
# delete the last character.
mytext.delete('end - 2 chars') 

注意在最后一个例子中,我们删除了两个字符以删除最后一个字符。Text小部件会自动在其文本内容末尾添加一个换行符,因此我们始终需要记住在处理索引或提取的文本时考虑到这个额外的字符。

记住,这些索引应该是字符串,而不是浮点值!由于隐式类型转换,浮点值有时可能工作,但不要依赖这种行为。

按钮小部件

Ttk 的Button是一个简单的可点击的按钮,可以激活回调函数。它看起来像这样:

图片

图 3.8:一个 Ttk 按钮小部件

我们可以创建一个如下所示:

mybutton = ttk.Button(
  root,
  command=my_callback,
  text='Click Me!',
  default='active'
) 

按钮是一个相当直观的小部件,但它有一些可以用来配置它的选项。这些选项在下表中展示:

参数 描述
text 字符串 按钮上的标签文本。
textvariable StringVar 绑定到按钮标签文本的变量。
command Python 函数 当按钮被点击时执行的回调。
default normalactivedisabled 如果按钮在按下Enter键时执行。active表示它将在响应Enter时执行,normal表示只有在首先选中时才会执行,disabled表示它不会响应Enter
underline 整数 text中要下划线的字符索引。

按钮也可以配置为显示图像而不是文本。我们将在第九章使用样式和主题改进外观中了解更多。

LabelFrame 小部件

第一章Tkinter 简介中,我们使用了Frame小部件来组合我们的小部件。Ttk 为我们提供了一个更强大的选项LabelFrame,它提供了一个带有边框和标签的框架。这是一个非常实用的工具,可以为我们 GUI 中的小部件提供视觉分组。

这段代码展示了LabelFrame的一个示例:

mylabelframe = ttk.LabelFrame(
  root,
  text='Button frame'
)
b1 = ttk.Button(
  mylabelframe,
  text='Button 1'
)
b2 = ttk.Button(
  mylabelframe,
  text='Button 2'
)
b1.pack()
b2.pack() 

生成的 GUI 将看起来像这样:

图片

图 3.9:一个 Ttk LabelFrame 小部件

LabelFrame小部件为我们提供了一些配置参数,如下所示:

参数 描述
text 字符串 要显示的标签文本。
labelanchor 基本方向 文本标签的锚点位置。
labelwidget ttk.Label对象 用于标签的标签小部件。覆盖text
underline 整数 text中要下划线的字符索引。

如您所见,我们可以通过指定text参数或创建一个Label小部件并使用labelwidget参数来配置LabelFrame的标签。如果我们想利用Label小部件的一些高级功能,例如将其textvariable绑定,后者可能更可取。如果我们使用它,它将覆盖text参数。

Tkinter 和 Ttk 包含许多更多的小部件,其中一些我们将在本书的后面遇到。Python 还附带了一个名为tix的小部件库,其中包含几十个小部件。然而,tix非常过时,我们不会在本书中介绍它。尽管如此,你应该知道它的存在。

应用实现

到目前为止,我们已经学习了 Tkinter 的基本知识,研究了用户的需求,设计了我们的应用,并确定了哪些 Ttk 小部件将在我们的应用中有用。现在是时候将这些整合起来,并实际编写 ABQ 数据输入应用的第一版。回想一下我们的设计,来自第二章设计 GUI 应用,如下所示:

图片

图 3.10:ABQ 数据输入应用布局

花点时间回顾我们需要创建的小部件,然后我们将开始编码。

第一步

在您的编辑器中打开一个名为data_entry_app.py的新文件,让我们从这里开始:

# data_entry_app.py
"""The ABQ Data Entry application"""
import tkinter as tk
from tkinter import ttk
from datetime import datetime
from pathlib import Path
import csv 

我们的脚本以一个文档字符串开始,就像所有 Python 脚本应该做的那样。这个字符串至少应该给出文件所属的应用程序的名称,也可以包括有关使用、作者或其他未来维护者需要了解的项目。

接下来,我们将导入我们将在本应用中需要的 Python 模块;这些是:

  • 当然,tkinterttk用于我们的 GUI 元素

  • 来自datetime模块的datetime类,我们将用它来生成文件名的日期字符串

  • 来自pathlib模块的Path类,用于我们在保存例程中的某些文件操作

  • 我们将使用的csv模块,用于与 CSV 文件交互

接下来,让我们创建一些全局变量,应用将使用这些变量来跟踪信息:

variables = dict()
records_saved = 0 

variables字典将保存所有表单的控制变量。将它们保存在字典中将使管理它们变得更容易,并将我们的全局命名空间保持精简和整洁。records_saved变量将存储用户自打开应用以来保存的记录数。

现在是时候创建和配置根窗口了:

root = tk.Tk()
root.title('ABQ Data Entry Application')
root.columnconfigure(0, weight=1) 

我们已设置应用程序的窗口标题并配置了其布局网格,以便允许第一列扩展。根窗口将只有一个列,但通过设置这一点,它将允许表单在窗口扩展时保持居中。如果没有它,当窗口扩展时,表单将固定在窗口的左侧。

现在我们将为应用添加一个标题:

ttk.Label(
  root, text="ABQ Data Entry Application",
  font=("TkDefaultFont", 16)
).grid() 

由于我们不需要再次引用这个小部件,我们不会费心将其分配给变量。这也允许我们在同一行上调用Label上的grid(),使我们的代码更加简洁,命名空间更加清晰。我们将为应用程序中的大多数小部件这样做,除非我们有理由可能在代码的其他地方与之交互。

注意,我们使用了TkDefaultFont作为这个标签小部件的字体家族值。这是 Tkinter 中定义的一个别名,指向平台上的默认窗口字体。我们将在第九章通过样式和主题改进外观中了解更多关于字体信息。

构建数据记录表

在设置好初始应用程序窗口后,让我们开始构建实际的数据输入表单。我们将创建一个框架来包含整个数据记录表,称为drf

drf = ttk.Frame(root)
drf.grid(padx=10, sticky=(tk.E + tk.W))
drf.columnconfigure(0, weight=1) 

drf框架以一点水平填充添加到主窗口中,sticky参数确保它在包含列拉伸时也会拉伸。我们还将配置其网格以扩展第一列。

对于使用网格布局的窗口或框架,如果你想在父级拉伸时使子小部件拉伸,你需要确保容器将扩展(在父级上使用columnconfigurerowconfigure),并且子小部件将与容器一起扩展(在调用子小部件上的grid()时使用sticky)。

记录信息部分

我们表单的第一部分是记录信息部分。让我们创建并配置一个LabelFrame来存储它:

r_info = ttk.LabelFrame(drf, text='Record Information')
r_info.grid(sticky=(tk.W + tk.E))
for i in range(3):
  r_info.columnconfigure(i, weight=1) 

我们首先创建一个 Ttk LabelFrame小部件,将其数据记录表作为其父级。我们将其添加到父级的网格中,设置sticky参数,以便在窗口大小调整时它将扩展。这个表单的每一部分都将有三列输入小部件,我们希望每一列均匀扩展以填充框架的宽度。因此,我们使用了一个for循环来设置每一列的weight属性为1

现在,我们可以开始创建框架的内容,从第一个输入小部件,即日期字段开始:

variables['Date'] = tk.StringVar()
ttk.Label(r_info, text='Date').grid(row=0, column=0)
ttk.Entry(
  r_info, textvariable=variables['Date']
).grid(row=1, column=0, sticky=(tk.W + tk.E)) 

首先,我们创建了一个控制变量并将其放入variables字典中。然后我们为日期字段创建了我们的Label小部件,并将其添加到LabelFrame小部件的网格中。即使不是严格必要的,我们也将使用显式的rowcolumn值,因为我们将要放置一些稍微偏离顺序的对象。没有显式坐标,事情可能会变得混乱。

最后,我们创建了一个Entry小部件,传递了控制变量。请注意,如果我们可以使用变量来存储值,我们不会保存我们小部件的任何引用。这将使代码更加简洁。我们已经将我们的小部件添加到网格中,通过指定下一行的第一列将其放置在其标签的下方。对于EntryLabel,我们使用了sticky参数来确保小部件在 GUI 扩展时拉伸。

现在让我们添加第一行的其余部分,TimeTechnician字段:

time_values = ['8:00', '12:00', '16:00', '20:00']
variables['Time'] = tk.StringVar()
ttk.Label(r_info, text='Time').grid(row=0, column=1)
ttk.Combobox(
  r_info, textvariable=variables['Time'], values=time_values
).grid(row=1, column=1, sticky=(tk.W + tk.E))
variables['Technician'] = tk.StringVar()
ttk.Label(r_info, text='Technician').grid(row=0, column=2)
ttk.Entry(
  r_info, textvariable=variables['Technician']
).grid(row=1, column=2, sticky=(tk.W + tk.E)) 

再次,我们为每个项目创建一个变量和输入小部件。回想一下,Combobox小部件使用字符串列表作为其values参数,这将填充小部件的下拉部分。这样就处理了第一行。

在第二行,我们将从Lab输入开始:

variables['Lab'] = tk.StringVar()
ttk.Label(r_info, text='Lab').grid(row=2, column=0)
labframe = ttk.Frame(r_info)
for lab in ('A', 'B', 'C'):
  ttk.Radiobutton(
    labframe, value=lab, text=lab, variable=variables['Lab']
).pack(side=tk.LEFT, expand=True)
labframe.grid(row=3, column=0, sticky=(tk.W + tk.E)) 

就像之前一样,我们创建了控制变量和Label,但对于输入小部件,我们创建了一个Frame来容纳三个Radiobutton小部件。我们还在使用for循环创建Radiobutton小部件,以使代码更简洁和一致。

pack()几何管理器在这里很有用,因为我们可以从左到右填充,而无需显式管理列号。expand参数使得小部件在窗口大小调整时使用额外空间;这将帮助我们的按钮利用可用空间,而不会挤到窗口的左侧。

现在让我们完成第二行的剩余部分,PlotSeed Sample字段:

variables['Plot'] = tk.IntVar()
ttk.Label(r_info, text='Plot').grid(row=2, column=1)
ttk.Combobox(
  r_info,
  textvariable=variables['Plot'],
  values=list(range(1, 21))
).grid(row=3, column=1, sticky=(tk.W + tk.E))
variables['Seed Sample'] = tk.StringVar()
ttk.Label(r_info, text='Seed Sample').grid(row=2, column=2)
ttk.Entry(
  r_info,
  textvariable=variables['Seed Sample']
).grid(row=3, column=2, sticky=(tk.W + tk.E)) 

这里发生的事情与之前相同:创建一个变量,创建一个Label,创建输入小部件。注意,对于Plot值,我们使用range()生成一个列表,以使代码更简洁。

这是环境数据部分

表单的下一部分是Environment Data框架。让我们开始这个部分如下:

e_info = ttk.LabelFrame(drf, text="Environment Data")
e_info.grid(sticky=(tk.W + tk.E))
for i in range(3):
  e_info.columnconfigure(i, weight=1) 

这正是我们为最后一个LabelFrame所做的事情,只是名称有所更新。让我们开始用HumidityLightTemperature小部件填充它:

variables['Humidity'] = tk.DoubleVar()
ttk.Label(e_info, text="Humidity (g/m³)").grid(row=0, column=0)
ttk.Spinbox(
  e_info, textvariable=variables['Humidity'],
  from_=0.5, to=52.0, increment=0.01,
).grid(row=1, column=0, sticky=(tk.W + tk.E))
variables['Light'] = tk.DoubleVar()
ttk.Label(e_info, text='Light (klx)').grid(row=0, column=1)
ttk.Spinbox(
  e_info, textvariable=variables['Light'],
  from_=0, to=100, increment=0.01
).grid(row=1, column=1, sticky=(tk.W + tk.E))
variables['Temperature'] = tk.DoubleVar()
ttk.Label(e_info, text='Temperature (°C)').grid(row=0, column=2)
ttk.Spinbox(
  e_info, textvariable=variables['Temperature'],
  from_=4, to=40, increment=.01
).grid(row=1, column=2, sticky=(tk.W + tk.E)) 

好的!现在,对于本节第二行,我们只需要添加Equipment Fault复选框:

variables['Equipment Fault'] = tk.BooleanVar(value=False)
ttk.Checkbutton(
  e_info, variable=variables['Equipment Fault'],
  text='Equipment Fault'
).grid(row=2, column=0, sticky=tk.W, pady=5) 

前三个值都是浮点数,所以我们使用DoubleVar控制变量和Spinbox小部件进行输入。别忘了为Spinbox小部件填充from_toincrement值,以便箭头能够正确地工作。我们的Checkbutton使用BooleanVar控制变量,由于它内置了标签,所以不需要Label小部件。注意,因为我们开始了一个新的框架,我们的网格行和列从零开始。这是将表单拆分成更小框架的好处:我们不必跟踪不断增长的行或列号。

这是植物数据部分

我们将创建下一个框架,Plant Data,就像其他两个一样:

p_info = ttk.LabelFrame(drf, text="Plant Data")
p_info.grid(sticky=(tk.W + tk.E))
for i in range(3):
  p_info.columnconfigure(i, weight=1) 

现在,创建并配置了框架后,让我们添加第一行的输入项,PlantsBlossomsFruit

variables['Plants'] = tk.IntVar()
ttk.Label(p_info, text='Plants').grid(row=0, column=0)
ttk.Spinbox(
  p_info, textvariable=variables['Plants'],
  from_=0, to=20, increment=1
).grid(row=1, column=0, sticky=(tk.W + tk.E))
variables['Blossoms'] = tk.IntVar()
ttk.Label(p_info, text='Blossoms').grid(row=0, column=1)
ttk.Spinbox(
  p_info, textvariable=variables['Blossoms'],
  from_=0, to=1000, increment=1
).grid(row=1, column=1, sticky=(tk.W + tk.E))
variables['Fruit'] = tk.IntVar()
ttk.Label(p_info, text='Fruit').grid(row=0, column=2)
ttk.Spinbox(
  p_info, textvariable=variables['Fruit'],
  from_=0, to=1000, increment=1
).grid(row=1, column=2, sticky=(tk.W + tk.E)) 

这里没有什么真正新的东西,只是因为我们使用IntVar控制变量,我们将Spinbox的增量设置为1。这并不能真正阻止任何人输入小数(或任何任意的字符串),但至少按钮不会误导用户。在第五章通过验证和自动化减少用户错误中,我们将看到如何更彻底地执行increment

现在最后的一行输入项,Min HeightMax HeightMed Height

variables['Min Height'] = tk.DoubleVar()
ttk.Label(p_info, text='Min Height (cm)').grid(row=2, column=0)
ttk.Spinbox(
  p_info, textvariable=variables['Min Height'],
  from_=0, to=1000, increment=0.01
).grid(row=3, column=0, sticky=(tk.W + tk.E))
variables['Max Height'] = tk.DoubleVar()
ttk.Label(p_info, text='Max Height (cm)').grid(row=2, column=1)
ttk.Spinbox(
  p_info, textvariable=variables['Max Height'],
  from_=0, to=1000, increment=0.01
).grid(row=3, column=1, sticky=(tk.W + tk.E))
variables['Med Height'] = tk.DoubleVar()
ttk.Label(p_info, text='Median Height (cm)').grid(row=2, column=2)
ttk.Spinbox(
  p_info, textvariable=variables['Med Height'],
  from_=0, to=1000, increment=0.01
).grid(row=3, column=2, sticky=(tk.W + tk.E)) 

我们创建了三个更多的DoubleVar对象,三个更多的标签和三个更多的Spinbox小部件。如果这感觉有点重复,不要感到惊讶;GUI 代码往往会有点重复。在第四章使用类组织我们的代码中,我们将找到减少这种重复性的方法。

完成 GUI

这样就完成了我们的三个信息部分;现在我们需要添加“笔记”输入。我们将它直接添加到drf框架中,并添加一个标签,如下所示:

ttk.Label(drf, text="Notes").grid()
notes_inp = tk.Text(drf, width=75, height=10)
notes_inp.grid(sticky=(tk.W + tk.E)) 

由于我们无法将控制变量与Text小部件关联,我们需要保留一个常规变量引用。

当你需要保存小部件的引用时,不要忘记在单独的语句中调用grid()!由于grid()(和其他几何管理方法)返回None,如果你在一个语句中创建并定位小部件,你保存的小部件引用将只是None

我们几乎完成了表单!我们只需要添加一些按钮:

buttons = tk.Frame(drf)
buttons.grid(sticky=tk.E + tk.W)
save_button = ttk.Button(buttons, text='Save')
save_button.pack(side=tk.RIGHT)
reset_button = ttk.Button(buttons, text='Reset')
reset_button.pack(side=tk.RIGHT) 

为了使表单的网格布局更简单,我们将两个按钮打包到一个子框架中,使用pack()函数的side参数将它们保持在右侧。

这样就完成了数据记录表单;为了完成应用程序的 GUI,我们只需要添加一个带有相关变量的状态栏,如下所示:

status_variable = tk.StringVar()
ttk.Label(
  root, textvariable=status_variable
).grid(sticky=tk.W + tk.E, row=99, padx=10) 

状态栏只是一个Label小部件,我们将其放置在root窗口的网格的第99行,以确保在应用程序未来添加任何内容时它保持在底部。请注意,我们没有将状态变量添加到variables字典中;该字典保留用于存储用户输入的变量。这个变量只是用来向用户显示消息。

编写回调函数

现在我们已经完成了布局,让我们着手创建应用程序的功能。我们的表单有两个需要回调函数的按钮:“重置”和“保存”。

重置函数

重置函数的职责是将整个表单恢复到空白状态,以便用户可以输入更多数据。我们不仅需要这个函数作为“重置”按钮的回调,还需要在用户保存记录后为下一个记录准备表单。否则,用户将不得不手动删除并覆盖每个字段的每个新记录中的数据。

由于我们需要从保存回调中调用重置回调,我们需要先编写重置函数。在data_entry_app.py的末尾,开始一个新的函数,如下所示:

# data_entry_app.py
def on_reset():
  """Called when reset button is clicked, or after save""" 

函数被命名为on_reset()。回想一下第一章Tkinter 入门,按照惯例,回调函数通常命名为on_<事件名>,其中事件名指的是触发该事件的事件。由于这将由点击“重置”按钮触发,我们将它命名为on_reset()

在函数内部,我们需要将所有小部件重置为空值。但是等等!我们没有保存任何小部件的引用,除了“笔记”输入。我们需要做什么?

简单来说,我们将所有变量重置为空字符串,如下所示:

 for variable in variables.values():
    if isinstance(variable, tk.BooleanVar):
      variable.set(False)
    else:
      variable.set('')
  notes_inp.delete('1.0', tk.END) 

StringVarDoubleVarIntVar 对象可以设置为空字符串,这将导致任何绑定到它们的部件变为空白。如果尝试这样做,BooleanVar 变量将引发异常,因此我们将使用 Python 的内置 isinstance() 函数检查我们的变量是否是 BooleanVar。如果是,我们只需将其设置为 False

对于 Notes 输入,我们可以使用 Text 小部件的 delete() 方法来清除其内容。此方法接受一个起始位置和结束位置,就像 get() 方法一样。值 1.0tk.END 表示小部件的全部内容。回顾我们之前对 Text 小部件的讨论,此索引是 字符串 1.0不是 浮点值。

这就是我们在重置回调函数中需要做的所有事情。要将它绑定到按钮,请使用按钮的 configure() 方法:

reset_button.configure(command=on_reset) 

configure() 方法可以在任何 Tkinter 小部件上调用以更改其属性。它接受与部件构造函数相同的关键字参数。

保存回调

我们最后一点功能,也是最重要的,是 Save 回调。回顾我们的程序规范,我们的应用程序需要将输入的数据追加到名为 abq_data_record_CURRENTDATE.csvCSV逗号分隔值) 文件中,其中 CURRENTDATE 是 ISO 格式的日期(年-月-日)。如果文件不存在,则应创建该文件并将列标题写入第一行。因此,此函数需要执行以下操作:

  • 确定当前日期并生成文件名

  • 确定文件是否存在,如果不存在,则创建它并写入标题行

  • 从表单中提取数据并执行任何必要的清理

  • 将数据行追加到文件中

  • 增加记录已保存的数量,并通知用户记录已保存

  • 为下一条记录重置表单

让我们从以下方式开始这个函数:

def on_save():
  """Handle save button clicks"""
  global records_saved 

再次,我们使用了 on_<eventname> 命名约定。我们首先声明 records_saved 为一个全局变量。如果我们不这样做,Python 将将名称 records_saved 解释为局部变量,我们将无法更新它。

修改全局变量通常是不好的做法,但 Tkinter 在这里实际上并没有给我们太多选择:我们无法使用返回值来更新变量,因为这是一个在响应事件时调用的回调函数,而不是我们代码中的任何地方,我们都有直接访问 records_saved 的权限。在 第四章使用类组织我们的代码 中,我们将学习一种更好的方法来实现此功能而不使用全局变量;然而,现在我们仍然被困在这里。

接下来,让我们确定文件名的细节以及它是否存在:

 datestring = datetime.today().strftime("%Y-%m-%d")
  filename = f"abq_data_record_{datestring}.csv"
  newfile = not Path(filename).exists() 

datetime.today()函数返回一个表示当前日期的Date对象,其strftime()方法允许我们将日期格式化为任何我们指定的字符串。strftime()的语法起源于 C 编程,所以在某些情况下可能相当晦涩;但希望它足够清晰,%Y表示年份,%m表示月份,%d表示日期。这将返回 ISO 格式的日期;例如,2021-10-31 代表 2021 年 10 月 31 日。

拥有datestring后,我们可以用它来构建当天 CSV 文件的文件名。在下一行中,Path(filename).exists()告诉我们文件是否在当前工作目录中存在。它是通过使用文件名构造一个Path对象,然后调用其exists()方法来检查文件是否已经在文件系统中。我们将这个信息保存到一个名为newfile的变量中。

现在是时候从表单中获取数据了:

 data = dict()
  fault = variables['Equipment Fault'].get()
  for key, variable in variables.items():
    if fault and key in ('Light', 'Humidity', 'Temperature'):
      data[key] = ''
    else:
      try:
        data[key] = variable.get()
      except tk.TclError:
        status_variable.set(
          f'Error in field: {key}.  Data was not saved!'
        )
        return
  # get the Text widget contents separately
  data['Notes'] = notes_inp.get('1.0', tk.END) 

我们将数据存储在一个名为data的新字典对象中。为此,我们将遍历我们的variables字典,对每个变量调用get()。当然,如果有设备故障,我们想要跳过LightHumidityTemperature字段的值,所以我们首先获取Equipment Fault的值并检查它,然后再检索这些字段值。如果我们确实需要从变量中检索值,我们将在try块中这样做。记住,如果变量中有无效值时调用get()方法,变量将引发TclError,因此我们需要处理这个异常。在这种情况下,我们将通知用户该特定字段存在问题并立即退出函数。

最后,我们需要使用get()方法从Notes字段获取数据。

现在我们有了数据,我们需要将其写入 CSV 文件。接下来添加以下代码:

 with open(filename, 'a', newline='') as fh:
    csvwriter = csv.DictWriter(fh, fieldnames=data.keys())
    if newfile:
      csvwriter.writeheader()
    csvwriter.writerow(data) 

首先,我们使用上下文管理器(with关键字)打开文件。这样做可以确保当我们退出缩进的块时文件将被关闭。我们以追加模式打开(由open函数的a参数指示),这意味着我们写入的任何数据都将简单地添加到现有内容的末尾。注意newline参数,我们将其设置为空字符串。这是为了解决 CSV 模块在 Windows 上的一个 bug,该 bug 会在每条记录之间出现额外的空行。在其他平台上这不会造成任何伤害。

在这个块内部,我们需要创建一个名为CSV Writer 对象的东西。标准库csv模块包含几种不同类型的对象,可以将数据写入 CSV 文件。DictWriter类很方便,因为它可以接受任何顺序的值字典,并将它们写入 CSV 的正确字段,前提是第一行包含列名。我们可以通过传递data.keys()来告诉DictWriter那些标题值应该是什么,这包含了我们数据值的所有名称。

追加模式将在文件不存在时创建文件,但不会自动写入标题行。因此,我们需要检查文件是否是新的文件(使用我们之前找到的newfile值),如果是,我们将写入标题行。DictWriter对象有一个用于此目的的方法,它将导致它只写入包含所有字段名称的单行。

最后,我们可以使用DictWriter对象的writerow()方法将我们的数据字典传递给要写入的文件。当我们退出缩进块时,Python 会关闭文件并将其保存到磁盘。

这就留下了on_save()函数中的最后几行:

 records_saved += 1
  status_variable.set(
    f"{records_saved} records saved this session"
  )
  on_reset() 

首先,我们将增加records_saved变量,然后在状态栏中通知用户到目前为止已保存了多少条记录。这是良好的反馈,有助于用户知道他们的操作是成功的。最后,我们调用on_reset()来为输入下一条记录准备表单。

保存方法实现后,让我们将其绑定到我们的按钮上:

save_button.configure(command=on_save) 

最后,让我们重置表单并启动主事件循环:

on_reset()
root.mainloop() 

就这样,您的第一个 ABQ 应用程序已经完成并准备好发布了!

完成并测试

在我们将应用程序发送到世界之前,让我们先启动它并进行测试:

图 3.11:我们的第一个 ABQ 数据录入应用程序

看起来不错!而且它也真的能工作。请继续输入一些测试数据并保存它。当然,这并不是终点——我们还没有完全解决程序规格说明中的所有问题,一旦用户接触到应用程序,无疑会开始提出功能请求。但就目前而言,我们可以庆祝一个工作 MVP 的胜利。

概述

嗯,在这一章中,我们已经走了很长的路!您将设计从规格说明和一些图纸变成了一个运行中的最小可行产品(MVP),该产品已经涵盖了您所需的基本功能。您学习了关于基本 Ttk 小部件,如EntrySpinboxComboboxRadiobuttonCheckbutton,以及 Tkinter 的Text小部件。您学习了如何使用嵌套的LabelFrame小部件将这些小部件组装成一个复杂但有序的 GUI,以及如何使用回调方法保存文件。

在下一章中,我们将利用类和面向对象编程技术来清理我们的代码并扩展小部件的功能。

第四章:使用类组织我们的代码

你的数据输入表单进展顺利!你的老板和同事对你的进步感到兴奋,并且已经开始提出一些可以添加的其他功能的想法。说实话,这让你有点紧张!虽然他们看到一个看起来专业的表单,但你清楚底下的代码正变得越来越庞大和重复。你还有一些瑕疵,比如全局变量和一个非常杂乱的全球命名空间。在你开始添加更多功能之前,你希望掌握这段代码,并开始将其分解成一些可管理的块。为此,你需要创建

在本章中,我们将涵盖以下主题:

  • Python 类的入门指南 中,我们将回顾如何创建 Python 类和子类。

  • 使用 Tkinter 的类 中,我们将发现如何有效地在 Tkinter 代码中使用类。

  • 使用类重写我们的应用程序 中,我们将将这些技术应用到 ABQ 数据输入应用程序中。

Python 类的入门指南

虽然类的基本概念表面上看起来很简单,但类带来了一系列术语和概念,这些概念常常让许多初学者感到困惑。在本节中,我们将讨论使用类的优点,探讨类的不同特性,并回顾在 Python 中创建类的语法。

使用类的优点

许多初学者甚至中级 Python 程序员避免或忽视 Python 中类的使用;与函数或变量不同,类在简短的简单脚本中没有明显的用途。然而,随着我们的应用程序代码的增长,类成为组织我们的代码成可管理单元的不可或缺的工具。让我们看看类如何帮助我们构建更干净的代码。

类是 Python 的组成部分

类本质上是一个创建对象的蓝图。什么是对象?在 Python 中,一切都是对象:整数、字符串、浮点数、列表、字典、Tkinter 小部件,甚至函数都是对象。这些对象类型都是由类定义的。如果你在 Python 提示符下使用type命令,就可以很容易地看到这一点,如下所示:

>>> type('hello world')
<class 'str'>
>>> type(1)
<class 'int'>
>>> type(print)
<class 'builtin_function_or_method'> 

type函数显示了你用来构建特定对象的类。当一个对象由特定的类构建时,我们称它为该类的实例

实例对象经常可以互换使用,因为每个对象都是某个类的实例。

因为 Python 中的所有东西都是类,所以创建我们自己的类允许我们使用与内置对象相同的语法来处理自定义对象。

类使数据与函数之间的关系明确

通常,在代码中,我们有一组所有相关联的数据。例如,在一个多人游戏中,你可能会有每个玩家的分数、健康或进度等变量。操作这些变量的函数需要确保操作的是指向同一玩家的变量。类允许我们创建这些变量和操作它们的函数之间的显式关系,这样我们就可以更容易地将它们作为一个单元组织起来。

类有助于创建可重用的代码

类是减少代码冗余的强大工具。假设我们有一组在提交时具有相似行为但输入字段不同的表单。使用类继承,我们可以创建一个具有所需共同行为的基表单;然后,我们可以从这个基表单派生出单个表单类,只需要实现每个表单中独特的内容。

类创建的语法

创建一个类与创建一个函数非常相似,只是我们使用class关键字,如下所示:

class Banana:
  """A tasty tropical fruit"""
  pass 

注意,我们还包含了一个文档字符串,它被 Python 工具(如内置的help函数)用于生成有关类的文档。在 Python 中,类名传统上使用帕斯卡大小写,即每个单词的首字母大写;有时,第三方库会使用其他约定,但通常不会。

一旦我们定义了一个类,我们就可以通过调用它来创建类的实例,就像调用一个函数一样:

my_banana = Banana() 

在这种情况下,my_banana是一个Banana类的实例对象。当然,更有用的类将在类体内部定义一些内容;具体来说,我们可以定义属性方法,这些统称为成员

属性和方法

属性仅仅是变量,它们可以是类属性实例属性。类属性是在类体顶部的范围内定义的,如下所示:

class Banana:
  """A tasty tropical fruit"""
  food_group = 'fruit'
  colors = [
    'green', 'green-yellow', 'yellow',
    'brown spotted', 'black'
  ] 

类属性被类的所有实例共享,通常用于设置默认值、常量和其他只读值。

注意,与类名不同,成员名称按照惯例使用蛇形命名法,即单词之间用下划线分隔。

实例属性存储特定于类单个实例的值;要创建一个实例属性,我们需要访问一个实例。我们可以这样做:

my_banana = Banana()
my_banana.color = 'yellow' 

然而,如果我们能在类定义内部定义一些实例属性,而不是像那样外部定义,那将更加理想。为了做到这一点,我们需要在类定义内部对类的实例有一个引用。这可以通过一个实例方法来实现。

方法只是附加到类上的函数。实例方法是一种自动接收实例引用作为其第一个参数的方法。我们可以这样定义一个:

class Banana:
  def peel(self):
    self.peeled = True 

正如你所见,定义一个实例方法就是简单地在类体内定义一个函数。这个函数将接收的第一个参数是类的实例的引用;你可以称它为你喜欢的任何名字,但根据长期以来的 Python 约定,我们称它为self。在函数内部,self可以用来对实例进行操作,例如分配实例属性。

注意,实例(self)也可以访问类属性(例如,self.colors),如下所示:

 def set_color(self, color):
    """Set the color of the banana"""
    if color in self.colors:
      self.color = color
    else:
      raise ValueError(f'A banana cannot be {color}!') 

当我们使用实例方法时,我们不显式传递self;它是隐式传递的,如下所示:

my_banana = Banana()
my_banana.set_color('green')
my_banana.peel() 

self的隐式传递通常会在传递错误数量的参数时导致令人困惑的错误信息。例如,如果你调用my_banana.peel(True),你会得到一个异常,表明期望一个参数但传递了两个。从你的角度来看,你只传递了一个参数,但方法接收到了两个,因为实例引用被自动添加了。

除了实例方法之外,类还可以有类方法静态方法。与实例方法不同,这些方法没有访问类的实例,并且不能读取或写入实例属性。

类方法是在方法定义之前使用装饰器创建的,如下所示:

 **@classmethod**
  def check_color(cls, color):
    """Test a color string to see if it is valid."""
    return color in cls.colors
 **@classmethod**
  def make_greenie(cls):
    """Create a green banana object"""
    banana = cls()
    banana.set_color('green')
    return banana 

正如实例方法隐式地传递了实例的引用一样,类方法也隐式地传递了类的引用作为第一个参数。同样,你可以称那个参数为你喜欢的任何名字,但传统上我们称之为cls。类方法通常用于与类变量交互。例如,在上面的check_color()方法中,该方法需要引用类变量colors。类方法也用作生成特定配置的类实例的便利函数;例如,上面的make_greenie()方法使用其类引用创建颜色预设置为greenBanana实例。

静态方法也是一个附加到类上的函数,但它不接收任何隐式参数,方法内的代码也无法访问类或实例。就像类方法一样,我们使用装饰器来定义静态方法,如下所示:

 **@staticmethod**
  def estimate_calories(num_bananas):
    """Given `num_bananas`, estimate the number of calories"""
    return num_bananas * 105 

静态方法通常用于定义类内部使用的算法或实用函数。

类和静态方法可以直接在类本身上调用;例如,我们可以调用Banana.estimate_calories()Banana.check_color()而不实际创建Banana的实例。然而,实例方法必须在类的实例上调用。调用Banana.set_color()Banana.peel()是没有意义的,因为这些方法旨在操作实例。相反,我们应该创建一个实例,并在其上调用那些方法(例如,my_banana.peel())。

魔法属性和方法

所有 Python 对象都自动获得一组称为魔法属性的属性和一组称为魔法方法的方法,也称为特殊方法或dunder 方法,因为它们由属性或方法名周围的两个下划线指示(“dunder”是“double under”的混合词)。

魔法属性通常存储关于对象的元数据。例如,任何对象的__class__属性存储了对对象类的引用:

>>> print(my_banana.__class__)
<class '__main__.Banana'> 

魔法方法定义了 Python 对象如何响应运算符(如+%[])或内置函数(如dir()setattr())。例如,__str__()方法定义了当对象传递给str()函数(无论是显式还是隐式,例如通过传递给print())时返回的内容:

class Banana:
  # ....
  def __str__(self):
    # "Magic Attributes" contain metadata about the object
    return f'A {self.color} {self.__class__.__name__}' 

在这里,我们不仅访问实例的color属性,还使用__class__属性来检索其类,然后使用类对象的__name__属性来获取类名。

尽管它很令人困惑,但也是一个对象。它是type类的一个实例。记住,Python 中的所有东西都是对象,所有对象都是某个类的实例。

因此,当打印Banana对象时,它看起来是这样的:

>>> my_banana = Banana()
>>> my_banana.set_color('yellow')
>>> print(my_banana)
A yellow Banana 

到目前为止,最重要的魔法方法是初始化器方法,__init__()。每当调用类对象以创建实例时,该方法都会执行,我们为其定义的参数成为创建实例时可以传递的参数。例如:

 def __init__(self, color='green'):
    if not self.check_color(color):
      raise ValueError(
        f'A {self.__class__.__name__} cannot be {color}'
      )
    self.color = color 

在这里,我们创建了一个带有可选参数color的初始化器,允许我们在创建对象时设置Banana对象的颜色值。因此,我们可以这样创建一个新的Banana

>>> my_new_banana = Banana('green')
>>> print(my_new_banana)
A green Banana 

理想情况下,任何在类中使用的实例属性都应该在__init__()中创建,这样我们就可以确保它们对于类的所有实例都存在。例如,我们应该这样创建我们的peeled属性:

 def __init__(self, color='green'):
    # ...
    **self.peeled =** **False** 

如果我们没有在这里定义这个属性,它将不会存在,直到调用peel()方法。在调用该方法之前寻找my_banana.peel值的代码将引发异常。

最终,初始化器应该使对象处于一个程序可以使用它的状态。

在其他面向对象的语言中,设置类对象的那个方法被称为构造函数,它不仅初始化新对象,还返回它。有时,Python 开发者会随意将__init__()称为构造函数。然而,Python 对象的实际构造方法为__new__(),我们通常在 Python 类中保持其不变。

公共、私有和受保护的成员

类是用于抽象的强大工具——也就是说,将复杂对象或过程简化为一个简单、高级的接口,供应用程序的其他部分使用。为了帮助它们做到这一点,Python 程序员使用一些命名约定来区分公共、私有和受保护的成员:

  • 公共成员是那些打算由类外部的代码读取或调用的成员。它们使用普通的成员名称。

  • 受保护成员仅用于类内部或其子类。它们以单个下划线为前缀。

  • 私有成员仅用于类内部。它们以双下划线为前缀。

Python 实际上并不强制区分公共、受保护和私有成员;这些只是其他程序员理解并用来指示哪些部分可以外部访问,哪些是类内部实现的一部分且不打算在类外使用的规定。

Python 通过自动将它们的名称更改为 _classname__member_name 来协助强制私有成员。

例如,让我们将以下代码添加到 Banana 类中:

 __ripe_colors = ['yellow', 'brown spotted']
  def _is_ripe(self):
    """Protected method to see if the banana is ripe."""
    return self.color in self.__ripe_colors
  def can_eat(self, must_be_ripe=False):
    """Check if I can eat the banana."""
    if must_be_ripe and not self._is_ripe():
      return False
    return True 

在这里,__ripe_colors 是一个私有属性。如果你尝试访问 my_banana.__ripe_colors,Python 会抛出一个 AttributeError 异常,因为它隐式地将这个属性重命名为 my_banana._Banana__ripe_colors。方法 _is_ripe() 是一个受保护成员,但与私有成员不同,Python 不会更改它的名称。它可以作为 my_banana._is_ripe() 执行,但使用你的类的程序员会理解这个方法是为了内部使用,而不是在外部代码中依赖。相反,应该调用公共的 can_eat() 方法。

有许多原因会让你想要将成员标记为私有或受保护,但通常是因为该成员是某些内部过程的一部分,对于外部代码的使用可能是无意义、不可靠或缺乏上下文。

虽然单词“私有”和“受保护”似乎表明了一种安全特性,但这并不是它们的意图,使用它们也不会为类提供任何安全性。意图仅仅是区分类的公共接口(外部代码应该使用)和类的内部机制(应该保持不变)。

继承和子类

构建我们自己的类确实是一个强大的工具,但鉴于 Python 中的一切都是从一个类构建的对象,如果我们能够简单地修改其中一个现有类以适应我们的需求,那岂不是很好?这样我们就不必每次都从头开始了。

幸运的是,我们可以!当我们创建一个类时,Python 允许我们从现有类中派生它,如下所示:

class RedBanana(Banana):
  """Bananas of the red variety"""
  pass 

我们创建了 RedBanana 类作为 Banana子类派生类。在这种情况下,Banana 被称为 父类超类。最初,RedBananaBanana 的一个精确副本,将表现得完全相同,但我们可以通过简单地定义成员来修改它,如下所示:

class RedBanana(Banana):
  colors = ['green', 'orange', 'red', 'brown', 'black']
  botanical_name = 'red dacca'
  def set_color(self, color):
    if color not in self.colors:
      raise ValueError(f'A Red Banana cannot be {color}!') 

指定现有成员,如colorsset_color,将掩盖超类中这些成员的版本。因此,在RedBanana实例上调用set_color()将调用RedBanana版本的方法,然后,当引用self.colors时,它将咨询RedBanana版本的colors。我们还可以添加新成员,如botanical_name属性,它将仅在子类中存在。

在某些情况下,我们可能希望子类方法添加到超类方法中,但仍然执行超类方法版本中的代码。我们可以将超类代码复制到子类代码中,但有一个更好的方法:使用super()

在实例方法内部,super()给我们提供了对超类版本实例的引用,如下所示:

 def peel(self):
    super().peel()
    print('It looks like a regular banana inside!') 

在这种情况下,调用super().peel()会导致在RedBanana实例上执行Banana.peel()中的代码。然后,我们可以在子类版本的peel()中添加额外的代码。

正如你将在下一节中看到的,super()通常在__init__()方法中使用,以运行超类初始化器。这对于 Tkinter GUI 类来说尤其正确,因为它们在初始化方法中执行了很多关键的外部设置。

关于 Python 类,我们在这里讨论的还有很多,包括多重继承的概念,我们将在第五章“通过验证和自动化减少用户错误”中学习到。然而,到目前为止我们所学的已经足够我们应用到 Tkinter 代码中。让我们看看类如何在 GUI 环境中帮助我们。

使用 Tkinter 中的类

GUI 框架和面向对象代码是相辅相成的。虽然 Tkinter 比大多数框架都更允许你使用过程式编程创建 GUI,但这样做我们会失去很多组织上的优势。尽管在这本书的整个过程中,我们会找到许多在 Tkinter 代码中使用类的方法,但在这里我们将探讨三种主要的类使用方法:

  • 提升或扩展 Tkinter 类以获得更多功能

  • 创建复合小部件以节省重复输入

  • 将我们的应用程序组织成自包含的组件

提升 Tkinter 类

让我们面对现实:一些 Tkinter 对象在功能上有些不足。我们可以通过子类化 Tkinter 类并创建自己的改进版本来解决这个问题。例如,虽然我们已经看到 Tkinter 控制变量类很有用,但它们仅限于字符串、整数、双精度和布尔类型。如果我们想要这些变量的功能,但针对更复杂的对象,如字典或列表呢?我们可以通过子类化和一些 JSON 的帮助来实现。

JavaScript 对象表示法JSON)是一种标准化的格式,用于将列表、字典和其他复合对象表示为字符串。Python 标准库自带了一个json库,它允许我们将这些对象转换为字符串格式,然后再转换回来。我们将在第七章“使用菜单和 Tkinter 对话框创建菜单”中更多地使用 JSON。

打开一个名为 tkinter_classes_demo.py 的新脚本,让我们从一些导入开始,如下所示:

# tkinter_classes_demo.py
import tkinter as tk
import json 

除了 Tkinter,我们还导入了标准库中的 json 模块。此模块包含两个函数,我们将使用它们来实现我们的变量:

  • json.dumps() 接收一个 Python 对象,如列表、字典、字符串、整数或浮点数,并返回一个 JSON 格式的字符串。

  • json.loads() 接收一个 JSON 字符串并返回一个 Python 对象,如列表、字典或字符串,具体取决于 JSON 字符串中存储的内容。

通过创建一个名为 JSONVartk.StringVar 子类来开始新的变量类:

class JSONVar(tk.StringVar):
  """A Tk variable that can hold dicts and lists""" 

为了使我们的 JSONVar 正常工作,我们需要拦截任何传递给对象的 value 参数,并使用 json.dumps() 方法将其转换为 JSON 字符串。第一个需要拦截 value 参数的地方是在 __init__() 方法中,我们将像这样覆盖它:

 def __init__(self, *args, **kwargs):
    kwargs['value'] = json.dumps(kwargs.get('value')
    super().__init__(*args, **kwargs) 

在这里,我们只是从关键字参数中检索 value 参数,并使用 json.dumps() 将其转换为字符串。转换后的字符串将覆盖 value 参数,然后将其传递给超类初始化器。如果未提供 value 参数(记住,它是一个可选参数),kwargs.get() 将返回 None,这将被转换为 JSON null 值。

在覆盖你未编写的类中的方法时,始终包含 *args**kwargs 是一个好主意,以捕获任何未明确列出的参数。这样,该方法将继续允许所有与超类版本相同的参数,但你不必明确列出它们。

我们需要拦截 value 参数的下一个地方是在 set() 方法中,如下所示:

 def set(self, value, *args, **kwargs):
    string = json.dumps(value)
    super().set(string, *args, **kwargs) 

我们再次拦截了 value 参数,并在将其传递给超类版本的 set() 方法之前将其转换为 JSON 字符串。

最后,让我们修复 get() 方法:

 def get(self, *args, **kwargs):
    string = super().get(*args, **kwargs)
    return json.loads(string) 

在这里,我们做了与前两种方法相反的操作:首先,我们从超类中获取了字符串,然后使用 json.loads() 将其转换回对象。完成这些操作后,我们就准备好了!我们现在有一个可以存储和检索列表或字典的变量,就像任何其他 Tkinter 变量一样。

让我们测试一下:

root = tk.Tk()
var1 = JSONVar(root)
var1.set([1, 2, 3])
var2 = JSONVar(root, value={'a': 10, 'b': 15})
print("Var1: ", var1.get()[1])
# Should print 2
print("Var2: ", var2.get()['b'])
# Should print 15 

如您所见,子类化 Tkinter 对象为我们代码打开了全新的可能性。我们将在本章后面以及在第五章 通过验证和自动化减少用户错误 中更广泛地应用这一概念。不过,首先,让我们看看我们还可以用类与 Tkinter 代码结合的两种方法。

创建复合小部件

许多 GUI(尤其是数据输入表单)包含需要大量重复模板代码的模式。例如,输入小部件通常有一个伴随的标签来告诉用户他们需要输入什么。这通常需要几行代码来创建和配置每个对象并将它们添加到表单中。我们不仅可以节省时间,而且通过创建一个可重用的复合小部件将两者结合成一个单一类,还可以确保输出的一致性。

通过创建一个LabelInput类来组合输入小部件和标签,开始如下:

# tkinter_classes_demo.py
class LabelInput(tk.Frame):
  """A label and input combined together""" 

tk.Frame小部件,一个没有任何内容的基础小部件,是一个理想的类,可以用来创建复合小部件。在开始我们的类定义之后,接下来我们需要做的是思考我们的小部件将需要哪些数据,并确保这些数据可以通过__init__()方法传递。

对于一个基本小部件,可能的最小参数集看起来可能如下所示:

  • 父小部件

  • 标签的文本

  • 要使用的输入小部件类型

  • 要传递给输入小部件的参数字典

让我们在LabelInput类中实现这一点:

 def __init__(
    self, parent, label, inp_cls, 
    inp_args, *args, **kwargs
  ):
    super().__init__(parent, *args, **kwargs)
    self.label = tk.Label(self, text=label, anchor='w')
    self.input = inp_cls(self, **inp_args) 

在这里我们首先调用超类初始化器,以便构建Frame小部件。请注意,我们传递了parent参数,因为这将成为Frame本身的父小部件;Label和输入小部件的父小部件是self——即LabelInput对象本身。

不要混淆“父类”和“父小部件”。“父类”指的是我们的子类从中继承其成员的超类。“父小部件”指的是我们的小部件(可能属于一个无关的类)所附加的小部件。为了避免混淆,当我们在本书中讨论类继承时,我们将坚持使用超/子类术语。

在创建我们的labelinput小部件之后,我们可以根据需要将它们排列在Frame上;例如,我们可能希望标签位于输入旁边,如下所示:

 self.columnconfigure(1, weight=1)
    self.label.grid(sticky=tk.E + tk.W)
    self.input.grid(row=0, column=1, sticky=tk.E + tk.W) 

或者,我们可能更喜欢在输入小部件上方使用标签,如下所示:

 self.columnconfigure(0, weight=1)
    self.label.grid(sticky=tk.E + tk.W)
    self.input.grid(sticky=tk.E + tk.W) 

在任何情况下,如果我们使用LabelInput创建表单上的所有输入,我们就有能力仅用三行代码来改变整个表单的布局。我们还可以考虑添加一个初始化器参数,以便为每个实例单独配置布局。

让我们看看这个类在实际中的应用。由于我们的inp_args参数将被直接扩展到对inp_cls初始化器的调用中,我们可以填充任何我们希望输入小部件接收到的参数,如下所示:

# tkinter_classes_demo.py
li1 = LabelInput(root, 'Name', tk.Entry, {'bg': 'red'})
li1.grid() 

我们甚至可以将一个变量传递给绑定到小部件:

age_var = tk.IntVar(root, value=21)
li2 = LabelInput(
  root, 'Age', tk.Spinbox,
  {'textvariable': age_var, 'from_': 10, 'to': 150}
)
li2.grid() 

复合小部件为我们节省了一些代码,但更重要的是,它将我们的输入表单代码提升到对正在发生的事情的更高层次的描述。我们不必关注每个标签相对于每个小部件的放置细节,我们可以从这些更大的组件的角度来考虑表单。

构建封装的组件

创建复合小部件对于我们在应用程序中计划重用的结构很有用,但同样的概念可以有益地应用于我们应用程序的更大块,即使它们只出现一次。

这样做使我们能够将方法附加到应用程序的组件上,以构建功能自包含的单元,这些单元更容易管理。

例如,让我们创建一个MyForm类来保存一个简单的表单:

# tkinter_classes_demo.py
class MyForm(tk.Frame):
  def __init__(self, parent, data_var, *args, **kwargs):
    super().__init__(parent, *args, **kwargs)
    self.data_var = data_var 

正如我们处理复合小部件一样,我们已经从tk.Frame派生子类并定义了一个新的初始化方法。parent*args**kwargs参数将被传递给超类初始化器,但我们还将接受一个data_var参数,它将是我们新JSONVar类型的实例。我们将使用此参数将表单数据传回表单。

接下来,我们将创建一些内部控制变量以绑定到我们的表单小部件:

 self._vars = {
      'name': tk.StringVar(self),
      'age': tk.IntVar(self, value=2)
    } 

正如我们在数据输入应用程序中已经看到的,将表单数据变量保存在字典中将使以后从它们中提取数据变得简单。然而,我们不是使用全局变量,而是通过将其添加到self并使用下划线作为前缀来创建字典作为受保护的实例变量。这是因为这个字典仅用于我们表单的内部使用。

现在,让我们使用我们的LabelInput类来创建表单的实际小部件:

 LabelInput(
      self, 'Name', tk.Entry,
      {'textvariable': self._vars['name']}
    ).grid(sticky=tk.E + tk.W)
    LabelInput(
      self, 'Age', tk.Spinbox,
      {'textvariable': self._vars['age'], 'from_': 10, 'to': 150}
    ).grid(sticky=tk.E + tk.W) 

你可以看到LabelInput大大减少了我们的 GUI 构建代码!现在,让我们为我们的表单添加一个提交按钮:

 tk.Button(self, text='Submit', command=self._on_submit).grid() 

提交按钮被配置为调用名为_on_submit的保护实例方法。这展示了使用类为我们 GUI 组件提供的一个强大功能:通过将按钮绑定到实例方法,该方法将能够访问所有其他实例成员。例如,它可以访问我们的_vars字典:

 def _on_submit(self):
    data = { key: var.get() for key, var in self._vars.items() }
    self.data_var.set(data) 

如果不使用类,我们就必须依赖于全局变量,就像我们在第三章中编写的data_entry_app.py应用程序中所做的那样。相反,我们的回调方法只需要隐式传递的self对象来访问它需要的所有对象。在这种情况下,我们使用字典推导从我们的小部件中提取所有数据,然后将结果字典存储在我们的JSONVar对象中。

字典推导类似于列表推导,但它创建一个字典;语法是{ key: value for expression in iterator }。例如,如果您想创建一个包含数字及其平方的字典,您可以编写{ n: n**2 for n in range(100) }

因此,每当点击提交按钮时,data_var对象将使用当前输入小部件的内容进行更新。

派生 Tk

我们可以将组件构建的概念扩展到顶级窗口Tk对象。通过从Tk派生子类并在它们自己的类中构建其他应用程序组件,我们可以以高级方式组合应用程序的布局和行为。

让我们用我们当前的演示脚本试一试:

# tkinter_classes_demo.py
class Application(tk.Tk):
  """A simple form application"""
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs) 

记住,Tk对象不仅是我们顶级窗口,还代表了我们应用程序的核心。因此,我们将其子类命名为Application,以表明它代表了我们整个应用程序的基础。我们的初始化方法以必要的调用super().__init__()开始,并将任何参数传递给Application.__init__()方法。

接下来,我们将创建一些变量来跟踪我们应用程序中的数据:

 self.jsonvar = JSONVar(self)
    self.output_var = tk.StringVar(self) 

如你所预期,JSONVar将被传递到我们的MyForm对象中,以处理其数据。output_var只是一个StringVar,我们将用它来显示一些输出。接下来,让我们向我们的窗口添加一些小部件:

 tk.Label(self, text='Please fill the form').grid(sticky='ew')
    MyForm(self, self.jsonvar).grid(sticky='nsew')
    tk.Label(self, textvariable=self.output_var).grid(sticky='ew')
    self.columnconfigure(0, weight=1)
    self.rowconfigure(1, weight=1) 

在这里,我们为表单添加了一个简单的标题标签,一个MyForm对象,以及另一个用于显示输出的标签。我们还配置了框架,使得第一列(也是唯一的一列)可以扩展到额外空间,第二行(包含表单的那一行)可以扩展到额外的垂直空间。

由于MyForm的提交会更新我们传递给它的JSONVar对象,我们需要一种方法在变量内容更改时执行提交处理回调。我们可以通过在jsonvar上设置一个跟踪来实现这一点:

 self.jsonvar.trace_add('write', self._on_data_change) 

trace_add()方法可以用在任何一个 Tkinter 变量(或变量子类)上,在变量相关事件发生时执行回调函数。让我们花点时间更详细地考察它。

trace_add()的第一个参数指定了触发跟踪的事件;它可以是以下之一:

  • read:变量的值被读取(例如通过get()调用)。

  • write:变量的值被修改(例如通过set()调用)。

  • unset:删除变量。

  • array:这是 Tcl/Tk 的一个遗迹,在 Python 中并不真正有意义,但仍然是有效的语法。你很可能永远不会用到它。

第二个参数指定了事件的回调函数,在这种情况下,是实例方法_on_data_change(),它将在jsonvar更新时被触发。我们将这样处理它:

 def _on_data_change(self, *args, **kwargs):
    data = self.jsonvar.get()
    output = ''.join([
    f'{key} = {value}\n'
    for key, value in data.items()
    ])
    self.output_var.set(output) 

这个方法简单地遍历从jsonvar检索到的字典中的值,然后将它们组合成一个格式化的字符串。最后,将格式化的字符串传递给output_var,这将更新主窗口底部的标签以显示我们的表单值。在实际应用程序中,你可能会将检索到的数据保存到文件中,或者将它们用作批量操作的参数,例如。

在实例方法中,何时应该使用实例变量(例如,self.jsonvar),何时应该使用常规变量(例如,data)?方法中的常规变量在其作用域内是 局部 的,这意味着一旦方法返回,它们就会被销毁。此外,它们不能被类中的其他方法引用。实例变量在其实例本身的整个生命周期内保持作用域,并且可供任何其他实例方法读取或写入。在 Application 类的情况下,data 变量仅在 _on_data_change() 方法内部需要,而 jsonvar 需要在 __init__()_on_datachange() 中访问。

由于我们已经从 Tk 继承了子类,我们不应再以 root = tk.Tk() 这行开始我们的脚本。请确保删除该行,以及删除引用 root 的代码的上一行。相反,我们将这样执行我们的应用程序:

if __name__ == "__main__":
  app = Application()
  app.mainloop() 

注意,这些行、我们的类定义和我们的导入是我们唯一执行的最高级代码。这大大清理了我们的全局作用域,将代码的更详细细节限制在一个更小的范围内。

在 Python 中,if __name__ == "__main__": 是一个常见的惯用语,用于检查脚本是否被直接运行,例如,当我们在一个命令提示符中键入 python3 tkinter_classes_demo.py 时。如果我们将此文件作为模块导入到另一个 Python 脚本中,此检查将为假,并且该块内的代码将不会运行。将程序的主要执行代码放在此检查下面是一个好习惯,这样你就可以安全地在更大的应用程序中重用你的类和函数。

使用类重写我们的应用程序

现在我们已经学会了在代码中使用类的方法,让我们将其应用到我们的 ABQ 数据录入应用程序中。我们将从一个名为 data_entry_app.py 的新文件开始,并添加我们的导入语句,如下所示:

# data_entry_app.py
from datetime import datetime
from pathlib import Path
import csv
import tkinter as tk
from tkinter import ttk 

现在,让我们看看我们如何应用一些基于类的技术来重写我们应用程序代码的更简洁版本。

向 Text 小部件添加 StringVar

在创建我们的应用程序时,我们发现的一个烦恼是 Text 小部件不允许使用 StringVar 来存储其内容,这迫使我们不得不与其他所有小部件不同地处理它。这确实有一个很好的原因:Tkinter 的 Text 小部件远不止是一个多行 Entry 小部件,它可以包含富文本、图像和其他低级 StringVar 无法存储的东西。话虽如此,我们并没有使用这些功能,因此对我们来说,有一个更有限的 Text 小部件,它可以绑定到一个变量上会更好。

让我们创建一个名为 BoundText 的子类来解决这个问题;从以下代码开始:

class BoundText(tk.Text):
  """A Text widget with a bound variable.""" 

我们的类需要向 Text 类添加三件事情:

  • 它需要允许我们传入一个 StringVar,它将被绑定到。

  • 它需要在变量更新时更新小部件内容;例如,如果它从文件中加载或被另一个小部件更改。

  • 它需要在小部件更新时更新变量内容;例如,当用户在控件中键入或粘贴内容时。

传递一个变量

我们将首先覆盖初始化器,以便传递一个控制变量:

 def __init__(self, *args, textvariable=None, **kwargs):
    super().__init__(*args, **kwargs)
    self._variable = textvariable 

按照 Tkinter 的惯例,我们将使用textvariable参数传递StringVar对象。在将剩余参数传递给super().__init__()之后,我们将变量存储为类的保护成员。

接下来,如果用户提供了变量,我们将将其内容插入到小部件中(这解决了分配给变量的任何默认值):

 if self._variable:
      self.insert('1.0', self._variable.get()) 

注意,如果没有传递变量,textvariable(以及因此self._variable)将是None

将小部件与变量同步

下一步,我们需要将控制变量的修改绑定到一个将更新小部件的实例方法。

仍然在__init__()方法中工作,让我们在刚刚创建的if块内添加一个跟踪,如下所示:

 if self._variable:
      self.insert('1.0', self._variable.get())
      **self._variable.trace_add('write', self._set_content)** 

我们跟踪的回调是一个名为_set_content()的保护成员函数,它将更新小部件的内容,以变量的内容为准。让我们继续创建这个回调:

 def _set_content(self, *_):
    """Set the text contents to the variable"""
    self.delete('1.0', tk.END)
    self.insert('1.0', self._variable.get()) 

首先,请注意我们的回调函数的参数列表中包含* _。这种表示法简单地将传递给函数的任何位置参数包装在一个名为_(下划线)的变量中。单个下划线或一系列下划线是我们用来命名 Python 变量的一种传统方式,我们提供这些变量但不打算使用它们。在这种情况下,我们使用它来消耗 Tkinter 在响应事件调用此函数时传递给此函数的任何额外参数。您将在我们打算将它们绑定到 Tkinter 事件的其他回调方法中看到这种技术被使用。

在方法内部,我们将简单地使用其delete()insert()方法修改小部件的内容。

将变量与小部件同步

当小部件被修改时更新变量稍微复杂一些。我们需要找到一个事件,每当Text小部件被编辑时就会触发,以便绑定到我们的回调。我们可以使用<Key>事件,它在按键时触发,但它不会捕获基于鼠标的编辑,如粘贴操作。然而,Text小部件确实有一个<<Modified>>事件,它在第一次修改时发出。

我们可以从这里开始;在__init__()方法中if语句的末尾添加另一行,如下所示:

 if self._variable:
      self.insert('1.0', self._variable.get())
      self._variable.trace_add('write', self._set_content)
      `self.bind('<<Modified>>', self._set_var)` 

然而,<<Modified>>事件仅在第一次修改小部件时触发。之后,我们需要通过更改小部件的修改标志来重置事件。我们可以使用Text小部件的edit_modified()方法来完成此操作,该方法还允许我们检索修改标志的状态。

为了了解这将如何工作,让我们编写_set_var()回调:

 def _set_var(self, *_):
    """Set the variable to the text contents"""
    if self.edit_modified():
      content = self.get('1.0', 'end-1chars')
      self._variable.set(content)
      self.edit_modified(False) 

在这个方法中,我们首先通过调用 edit_modified() 来检查小部件是否已被修改。如果是,我们将使用小部件的 get() 方法检索内容。请注意,get 的结束索引为 end-1chars。这意味着“内容结束前的一个字符。”回想一下,Text 小部件的 get() 方法会自动将换行符追加到内容的末尾,因此通过使用此索引,我们可以消除额外的换行符。

在检索小部件的内容后,我们需要通过将 False 传递给 edit_modified() 方法来重置已修改标志。这样,它就准备好在用户下次与小部件交互时触发 <<Modified>> 事件。

创建一个更高级的 LabelInput()

我们在 创建复合小部件 下创建的 LabelInput 类似乎很有用,但如果我们想在程序中使用它,它将需要更多的完善。

让我们再次从类定义和初始化方法开始:

# data_entry_app.py
class LabelInput(tk.Frame):
  """A widget containing a label and input together."""
  def __init__(
    self, parent, label, var, input_class=ttk.Entry,
    input_args=None, label_args=None, **kwargs
  ):
    super().__init__(parent, **kwargs)
    input_args = input_args or {}
    label_args = label_args or {}
    self.variable = var
    self.variable.label_widget = self 

和以前一样,我们有父小部件、标签文本、输入类和输入参数的参数。由于我们现在想要使用的每个小部件都可以绑定一个变量,我们将接受它作为必需参数,并且如果需要,我们将添加一个可选参数来传递给标签小部件的参数字典。我们将 input_class 默认设置为 ttk.Entry,因为我们有几个这样的小部件。

注意,input_argslabel_args 参数的默认值是 None,并且如果它们是 None,我们在方法内部将它们作为字典。为什么不直接使用空字典作为默认参数呢?在 Python 中,默认参数是在函数定义首次运行时评估的。这意味着在函数签名中创建的字典对象将在每次函数运行时都是相同的对象,而不是每次都是一个新的空字典。由于我们希望每次都是一个新的空字典,所以我们将在函数体内部而不是在参数列表中创建字典。对于列表和其他可变对象也是如此。

在方法内部,我们像往常一样调用 super().__init__(),然后确保 input_argslabel_args 是字典。最后,我们将 input_var 保存到实例变量中,并将标签小部件本身保存为变量对象的属性。这样做意味着我们不需要存储 LabelInput 对象的引用;如果我们需要,我们可以通过变量对象来访问它们。

接下来,是设置标签的时候了:

 if input_class in (ttk.Checkbutton, ttk.Button):
      input_args["text"] = label
    else:
      self.label = ttk.Label(self, text=label, **label_args)
      self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) 

CheckbuttonButton 小部件内部已经集成了标签,所以我们不希望有一个单独的标签悬挂在那里。相反,我们将只设置小部件的 text 参数为传入的任何内容。(Radiobutton 对象也内置了标签,但我们会稍作不同处理,你将在下一刻看到)。对于所有其他小部件,我们将在 LabelInput 的第一行和第一列中添加一个 Label 小部件。

接下来,我们需要设置输入参数,以便输入的控制变量将以正确的参数名称传递:

 if input_class in (
      ttk.Checkbutton, ttk.Button, ttk.Radiobutton
    ):
      input_args["variable"] = self.variable
    else:
      input_args["textvariable"] = self.variable 

记得按钮类使用 variable 作为参数名称,而所有其他类使用 textvariable。通过在类内部处理这个问题,我们就不必在构建我们的表单时担心这种区别。

现在,让我们设置输入小部件。大多数小部件的设置都很简单,但对于 Radiobutton,我们需要做些不同的事情。我们需要为传递的每个可能的值创建一个 Radiobutton 小部件(使用 input_args 中的 values 键)。记住,我们通过让按钮共享相同的变量来链接它们,我们在这里也会这样做。

我们可以这样添加:

 if input_class == ttk.Radiobutton:
      self.input = tk.Frame(self)
      for v in input_args.pop('values', []):
        button = ttk.Radiobutton(
          self.input, value=v, text=v, **input_args
        )
        button.pack(
          side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x'
        ) 

首先,我们创建一个 Frame 对象来容纳按钮;然后,对于传递给 values 的每个值,我们向 Frame 布局中添加一个 Radiobutton 小部件。注意,我们调用 pop() 方法从 input_args 字典中获取 values 项。dict.pop() 几乎与 dict.get() 相同,如果给定键存在,则返回该键的值,如果不存在,则返回第二个参数。区别在于 pop() 还会从字典中删除检索到的项。我们这样做是因为 values 不是 Radiobutton 的有效参数,所以在将 input_args 传递给 Radiobutton 初始化器之前,我们需要将其删除。input_args 中剩余的项应该是小部件的有效关键字参数。

对于非 Radiobutton 小部件的情况,操作相当直接:

 else:
      self.input = input_class(self, **input_args) 

我们只需调用带有 input_args 参数的任何 input_class 类。现在我们已经创建了 self.input,我们只需将其添加到 LabelInput 布局中:

 self.input.grid(row=1, column=0, sticky=(tk.W + tk.E))
    self.columnconfigure(0, weight=1) 

最后一次调用 columnconfigure 命令是告诉 LabelWidget 小部件填充其整个宽度至列 0

当我们创建自己的小部件时(无论是自定义子类还是复合小部件),我们可以为几何布局设置一些合理的默认值。例如,我们希望所有 LabelInput 小部件都紧贴其容器的左右两侧,以便它们填充最大可用宽度。而不是每次定位 LabelInput 小部件时都必须传递 sticky=(tk.E + tk.W),让我们将其设置为默认值,如下所示:

 def grid(self, sticky=(tk.E + tk.W), **kwargs):
    """Override grid to add default sticky values"""
    super().grid(sticky=sticky, **kwargs) 

我们已经覆盖了 grid 并将参数传递给了超类版本,但为 sticky 添加了一个默认值。如果需要,我们仍然可以覆盖它,但这将节省我们很多麻烦。

我们的 LabelInput 现在相当稳健了;是时候让它派上用场了!

创建表单类

现在我们已经准备好了构建块,是时候构建我们应用程序的主要组件了。将应用程序分解成合理的组件需要考虑什么可能构成合理的职责划分。最初,我们的应用程序似乎可以分解成两个组件:数据输入表单和根应用程序本身。但哪些功能放在哪里呢?

一种合理的评估可能如下:

  • 数据输入表本身当然应该包含所有的小部件。它还应该包含保存和重置按钮,因为这些按钮与表分开没有意义。

  • 应用程序标题和状态栏属于通用级别,因为它们将适用于应用程序的所有部分。文件保存可以与表一起进行,但它还必须与一些应用程序级别的项目(如状态栏或records_saved变量)交互。这是一个棘手的选择,但我们将它暂时放在应用程序对象中。

让我们从构建我们的数据输入表类DataRecordForm开始:

# data_entry_app.py
class DataRecordForm(ttk.Frame):
  """The input form for our widgets"""
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs) 

像往常一样,我们首先通过继承Frame并调用超类初始化方法来开始。在这个阶段,我们实际上不需要添加任何自定义参数。

现在,让我们创建一个字典来保存所有我们的变量对象:

 self._vars = {
      'Date': tk.StringVar(),
      'Time': tk.StringVar(),
      'Technician': tk.StringVar(),
      'Lab': tk.StringVar(),
      'Plot': tk.IntVar(),
      'Seed Sample': tk.StringVar(),
      'Humidity': tk.DoubleVar(),
      'Light': tk.DoubleVar(),
      'Temperature': tk.DoubleVar(),
      'Equipment Fault': tk.BooleanVar(),
      'Plants': tk.IntVar(),
      'Blossoms': tk.IntVar(),
      'Fruit': tk.IntVar(),
      'Min Height': tk.DoubleVar(),
      'Max Height': tk.DoubleVar(),
      'Med Height': tk.DoubleVar(),
      'Notes': tk.StringVar()
    } 

这只是直接从我们的数据字典中提取的。请注意,多亏了我们的BoundText类,我们可以将StringVar对象分配给注释。现在,我们准备开始向我们的 GUI 添加小部件。在我们的应用程序当前版本中,我们使用类似这样的代码块为应用程序的每个部分添加了一个LabelFrame小部件:

r_info = ttk.LabelFrame(drf, text='Record Information')
r_info.grid(sticky=(tk.W + tk.E))
for i in range(3):
  r_info.columnconfigure(i, weight=1 ) 

这段代码为每个框架重复了一次,只是变量名和标签文本有所变化。为了避免这种重复,我们可以将这个过程抽象成一个实例方法。让我们创建一个可以为我们添加新的标签框架的方法;将此代码添加到__init__()定义之上:

 def _add_frame(self, label, cols=3):
    """Add a LabelFrame to the form"""
    frame = ttk.LabelFrame(self, text=label)
    frame.grid(sticky=tk.W + tk.E)
    for i in range(cols):
      frame.columnconfigure(i, weight=1)
    return frame 

这个方法只是以前面的代码以通用方式重新定义,这样我们就可以传入标签文本,以及可选的列数。回滚到我们在DataRecordForm.__init__()方法中的位置,让我们使用这个方法创建一个记录信息部分,如下所示:

 r_info = self._add_frame("Record Information") 

现在我们有了框架,让我们尝试使用LabelInput并开始构建表的第一部分,如下所示:

 LabelInput(
      r_info, "Date", var=self._vars['Date']
    ).grid(row=0, column=0)
    LabelInput(
      r_info, "Time", input_class=ttk.Combobox,
      var=self._vars['Time'],
      input_args={"values": ["8:00", "12:00", "16:00", "20:00"]}
    ).grid(row=0, column=1)
    LabelInput(
      r_info, "Technician",  var=self._vars['Technician']
    ).grid(row=0, column=2) 

如您所见,LabelInput已经为我们节省了很多冗余的杂乱。

让我们继续第二行:

 LabelInput(
      r_info, "Lab", input_class=ttk.Radiobutton,
      var=self._vars['Lab'],
      input_args={"values": ["A", "B", "C"]}
    ).grid(row=1, column=0)
    LabelInput(
      r_info, "Plot", input_class=ttk.Combobox,
      var=self._vars['Plot'],
      input_args={"values": list(range(1, 21))}
    ).grid(row=1, column=1)
    LabelInput(
      r_info, "Seed Sample",  var=self._vars['Seed Sample']
    ).grid(row=1, column=2) 

记住,为了使用与LabelInput一起的RadioButton小部件,我们需要将值列表传递给输入参数,就像我们对Combobox做的那样。完成记录信息部分后,让我们继续下一个部分,环境数据

 e_info = self._add_frame("Environment Data")
    LabelInput(
      e_info, "Humidity (g/m³)",
      input_class=ttk.Spinbox,  var=self._vars['Humidity'],
      input_args={"from_": 0.5, "to": 52.0, "increment": .01}
    ).grid(row=0, column=0)
    LabelInput(
      e_info, "Light (klx)", input_class=ttk.Spinbox,
      var=self._vars['Light'],
      input_args={"from_": 0, "to": 100, "increment": .01}
    ).grid(row=0, column=1)
    LabelInput(
      e_info, "Temperature (°C)",
      input_class=ttk.Spinbox,  var=self._vars['Temperature'],
      input_args={"from_": 4, "to": 40, "increment": .01}
    ).grid(row=0, column=2)
    LabelInput(
      e_info, "Equipment Fault",
      input_class=ttk.Checkbutton,  
      var=self._vars['Equipment Fault']
    ).grid(row=1, column=0, columnspan=3) 

同样,我们使用我们的_add_frame()方法添加并配置了一个LabelFrame,并用四个LabelInput小部件填充它。

现在,让我们添加植物数据部分:

 p_info = self._add_frame("Plant Data")
    LabelInput(
      p_info, "Plants", input_class=ttk.Spinbox,
      var=self._vars['Plants'],
      input_args={"from_": 0, "to": 20}
    ).grid(row=0, column=0)
    LabelInput(
      p_info, "Blossoms", input_class=ttk.Spinbox,
      var=self._vars['Blossoms'],
      input_args={"from_": 0, "to": 1000}
    ).grid(row=0, column=1)
    LabelInput(
      p_info, "Fruit", input_class=ttk.Spinbox,
      var=self._vars['Fruit'],
      input_args={"from_": 0, "to": 1000}
    ).grid(row=0, column=2)
    LabelInput(
      p_info, "Min Height (cm)",
      input_class=ttk.Spinbox,  var=self._vars['Min Height'],
      input_args={"from_": 0, "to": 1000, "increment": .01}
    ).grid(row=1, column=0)
    LabelInput(
      p_info, "Max Height (cm)",
      input_class=ttk.Spinbox,  var=self._vars['Max Height'],
      input_args={"from_": 0, "to": 1000, "increment": .01}
    ).grid(row=1, column=1)
    LabelInput(
      p_info, "Median Height (cm)",
      input_class=ttk.Spinbox,  var=self._vars['Med Height'],
      input_args={"from_": 0, "to": 1000, "increment": .01}
    ).grid(row=1, column=2) 

我们几乎完成了;让我们接下来添加我们的注释部分:

 LabelInput(
      self, "Notes",
      input_class=BoundText,  var=self._vars['Notes'],
      input_args={"width": 75, "height": 10}
    ).grid(sticky=tk.W, row=3, column=0) 

在这里,我们利用我们的BoundText对象来附加一个变量。否则,这看起来就像对LabelInput的所有其他调用一样。

现在,是时候添加按钮了:

 buttons = tk.Frame(self)
    buttons.grid(sticky=tk.W + tk.E, row=4)
    self.savebutton = ttk.Button(
      buttons, text="Save", command=self.master._on_save)
    self.savebutton.pack(side=tk.RIGHT)
    self.resetbutton = ttk.Button(
      buttons, text="Reset", command=self.reset)
    self.resetbutton.pack(side=tk.RIGHT) 

与之前一样,我们在Frame上添加了我们的按钮小部件。不过,这一次,我们将传递一些实例方法作为按钮的回调命令。重置按钮将获得我们在本类中定义的实例方法,但由于我们决定保存文件是应用程序对象的责任,我们将保存按钮绑定到父对象的实例方法(通过这个对象的master属性访问)。

将 GUI 对象直接绑定到其他对象的命令上不是解决对象间通信问题的好方法,但就目前而言,它将完成这项工作。在第六章为应用程序的扩展做准备,我们将学习一种更优雅的方法来完成这项工作。

这样就结束了我们的__init__()方法,但在我们完成之前,这个类还需要几个更多的方法。首先,我们需要实现reset()方法来处理表单重置;它看起来是这样的:

 def reset(self):
    """Resets the form entries"""
    for var in self._vars.values():
      if isinstance(var, tk.BooleanVar):
        var.set(False)
      else:
        var.set('') 

实际上,我们只需要将所有变量设置为空字符串。然而,对于BooleanVar对象,这样做将会引发异常,因此我们需要将其设置为False来取消选中复选框。

最后,我们需要一个方法,使得应用程序对象能够从表单中检索数据,以便它可以保存数据。按照 Tkinter 的约定,我们将这个方法命名为get()

 def get(self):
    data = dict()
    fault = self._vars['Equipment Fault'].get()
    for key, variable in self._vars.items():
      if fault and key in ('Light', 'Humidity', 'Temperature'):
        data[key] = ''
      else:
        try:
          data[key] = variable.get()
        except tk.TclError:
          message = f'Error in field: {key}.  Data was not saved!'
          raise ValueError(message)
    return data 

这里的代码与我们之前版本应用程序中on_save()函数的数据检索代码非常相似,但有几点不同。首先,我们从self._vars而不是全局变量字典中检索数据。其次,在发生错误的情况下,我们创建一个错误消息并重新抛出一个ValueError,而不是直接更新 GUI。我们必须确保调用此方法的代码能够处理ValueError异常。最后,与之前版本的应用程序中保存数据的方式不同,我们只是返回数据。

这样就完成了表单类的创建!现在剩下的只是编写一个应用程序来保持它。

创建应用程序类

我们的应用程序类将处理应用程序级别的功能,同时也是我们的顶级窗口。在 GUI 方面,它需要包含:

  • 标题标签

  • 我们DataRecordForm类的一个实例

  • 状态栏

它还需要一个方法将表单中的数据保存到 CSV 文件中。

让我们开始我们的类:

class Application(tk.Tk):
  """Application root window"""
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs) 

这里没有什么新的内容,只是现在我们正在继承Tk而不是Frame

让我们设置一些窗口参数:

 self.title("ABQ Data Entry Application")
    self.columnconfigure(0, weight=1) 

与程序的程序性版本一样,我们已经设置了窗口标题并配置了网格的第一列以扩展。现在,我们将创建标题标签:

 ttk.Label(
      self, text="ABQ Data Entry Application",
      font=("TkDefaultFont", 16)
    ).grid(row=0) 

这里没有什么真正不同的地方,只是要注意,父对象现在是self——将不再有root对象;self是这个类内部的Tk实例。

让我们创建一个记录表单:

 self.recordform = DataRecordForm(self)
    self.recordform.grid(row=1, padx=10, sticky=(tk.W + tk.E)) 

尽管DataRecordForm的大小和复杂性,将其添加到应用程序中就像添加任何其他小部件一样简单。

现在,让我们看看状态栏:

 self.status = tk.StringVar()
    ttk.Label(
      self, textvariable=self.status
    ).grid(sticky=(tk.W + tk.E), row=2, padx=10) 

再次强调,这和过程式版本非常相似,只是我们的status变量是一个实例变量。这意味着它将可以访问我们类中的任何方法。

最后,让我们创建一个受保护的实例变量来保存已保存的记录数:

 self._records_saved = 0 

完成了__init__()方法后,我们现在可以编写最后一个方法:_on_save()。这个方法将非常接近我们之前编写的过程式函数:

 def _on_save(self):
    """Handles save button clicks"""
    datestring = datetime.today().strftime("%Y-%m-%d")
    filename = "abq_data_record_{}.csv".format(datestring)
    newfile = not Path(filename).exists()
    try:
      data = self.recordform.get()
    except ValueError as e:
      self.status.set(str(e))
      return
    with open(filename, 'a', newline='') as fh:
      csvwriter = csv.DictWriter(fh, fieldnames=data.keys())
      if newfile:
        csvwriter.writeheader()
      csvwriter.writerow(data)
    self._records_saved += 1
    self.status.set(
      "{} records saved this session".format(self._records_saved))
    self.recordform.reset() 

再次强调,这个函数使用当前日期生成文件名,然后以追加模式打开文件。不过,这次我们可以通过简单地调用self.recordform.get()来获取我们的数据,这抽象了从变量获取数据的过程。记住,我们确实必须处理ValueError异常,以防表中有不良数据,我们在这里已经做到了。如果数据不良,我们只需在方法尝试保存数据之前在状态栏中显示错误并退出。如果没有异常,数据将被保存,因此我们增加_records_saved属性并更新状态。

使这个应用程序运行的最后一件事情是创建我们的Application对象实例并启动其mainloop

if __name__ == "__main__":
  app = Application()
  app.mainloop() 

注意,除了我们的类定义和模块导入之外,这两行是唯一在顶层作用域中执行的。另外,因为Application负责构建 GUI 和其他对象,所以我们可以使用if __name__ == "__main__"保护来一起在应用程序的末尾执行它和mainloop()调用。

摘要

在本章中,你学习了如何利用 Python 类的强大功能。你学习了如何创建自己的类,定义属性和方法,以及魔术方法的功能。你还学习了如何通过子类化扩展现有类的功能。

我们探讨了如何将这些技术有力地应用于 Tkinter 类,以扩展其功能,构建复合小部件,并将我们的应用程序组织成组件。

在下一章中,我们将学习 Tkinter 的验证功能,并进一步使用子类化使我们的小部件更加直观和健壮。我们还将学习如何自动化输入以节省用户时间并确保数据输入的一致性。

第五章:通过验证和自动化减少用户错误

我们的项目进展顺利:数据输入表单运行良好,代码组织得更好,用户对使用应用程序的前景感到兴奋。尽管如此,我们还没有准备好投入生产!我们的表单还没有执行承诺的任务,即防止或劝阻用户错误:数字框仍然允许字母,组合框没有限制在给定的选择中,日期只是需要手动填写的文本字段。在本章中,我们将通过以下主题来纠正这些问题:

  • 验证用户输入 中,我们将讨论一些在控件中强制正确值并如何在 Tkinter 中实现它们的策略。

  • 创建验证控件类 中,我们将使用一些自定义验证逻辑来增强 Tkinter 的控件类。

  • 在我们的 GUI 中实现验证控件 中,我们将使用我们新的控件来改进 ABQ 数据输入。

  • 自动化输入 中,我们将实现控件中的数据自动填充,以节省用户的时间和精力。

让我们开始吧!

验证用户输入

初看 Tkinter 的输入控件选择似乎有点令人失望。

它既没有提供只允许数字的真正数字输入,也没有提供真正键盘友好的、现代的下拉选择器。我们没有日期输入、电子邮件输入或其他特殊格式的输入控件。

尽管如此,这些弱点可以成为优势。因为这些控件没有任何假设,我们可以使它们以适合我们特定需求的方式表现。例如,在数字输入中,字母字符可能看起来不合适,但它们合适吗?在 Python 中,如 NaNInfinity 这样的字符串是有效的浮点值;有一个可以递增数字但也可以处理这些字符串值的框,在某些应用中可能非常有用。

当然,在我们能够根据我们的需求定制我们的控件之前,我们需要考虑我们确切想要它们做什么。让我们进行分析。

防止数据错误的策略

对于控件应该如何响应用户尝试输入错误数据的问题,没有统一的答案。在各种 GUI 工具包中找到的验证逻辑可能大相径庭;当输入错误数据时,输入控件可能会以以下任何一种方式验证用户输入:

  • 完全阻止无效按键注册

  • 接受输入,但在表单提交时返回错误或错误列表

  • 当用户离开输入字段时显示错误,可能禁用表单提交直到更正

  • 将用户锁定在输入字段中,直到输入有效数据

  • 使用最佳猜测算法静默地纠正错误数据

数据输入表单(每天由相同用户填写数百次,他们甚至可能没有注意到它)的正确行为可能与仪表控制面板(值必须绝对正确以避免灾难)或在线用户注册表单(由从未见过的小部件的用户填写一次)不同。我们需要问自己——以及我们的用户——哪种行为将最好地最小化错误。

在与数据输入人员用户讨论后,你得出以下一系列指南:

  • 在可能的情况下,应忽略无意义的按键(例如,数字字段中的字母)。

  • 在焦点移出时(当用户退出字段时),应通过描述问题的错误以某种可见方式标记包含不良数据的字段。

  • 在焦点移出时留空的必填字段应标记为错误。

  • 如果有字段存在未解决的错误,应禁用表单提交。

在继续之前,让我们将以下要求添加到我们的规范中。在“要求”部分,更新“功能要求”如下:

Functional Requirements:
  (...)
  * have inputs that:
    - ignore meaningless keystrokes
    - display an error if the value is invalid on focusout
    - display an error if a required field is empty on focusout
  * prevent saving the record when errors are present 

到目前为止一切顺利,但我们如何实现它?

Tkinter 中的验证

Tkinter 的验证系统是工具箱中不太直观的部分之一。它依赖于三个我们可以传递给任何输入小部件的配置参数:

  • validate参数确定哪种类型的事件将触发验证回调。

  • validatecommand参数:此选项接受确定数据是否有效的命令。

  • invalidcommand:此选项接受一个命令,如果validatecommand返回False,则运行该命令。

这看起来似乎很简单,但也有一些意外的曲线。让我们深入查看每个参数。

validate参数

validate参数指定了触发验证的事件类型。它可以是指定的以下字符串值之一:

触发事件
none 从不。此选项关闭验证。
focusin 用户选择或输入小部件。
focusout 用户离开小部件。
focus focusinfocusout
key 用户在小部件中按下一个键。
all 任何focusinfocusoutkey事件。

只能指定一个validate参数,所有匹配的事件将触发相同的验证回调。大多数时候,你将想要使用keyfocusout(在focusin上验证很少有用),但由于没有结合这两个事件的值,通常最好使用all,并在必要时让回调根据事件类型切换其验证逻辑。

validatecommand参数

validatecommand 参数指定了当 validate 事件被触发时将运行的回调函数。这里事情变得有点棘手。你可能认为这个参数接受一个 Python 函数或方法的名称,但事实并非如此。相反,我们需要提供一个包含对 Tcl/Tk 函数的字符串引用的元组,以及(可选的)一些 替换代码,这些代码指定了我们想要传递给函数的触发事件的详细信息。

我们如何获取一个 Tcl/Tk 函数的引用?幸运的是,这并不太难;我们只需要将一个 Python 可调用对象传递给任何 Tkinter 小部件的 register() 方法。这会返回一个字符串引用,我们可以用它来与 validatecommand 一起使用。

例如,我们可以创建一个(有点无意义的)验证命令,如下所示:

# validate_demo.py
import tkinter as tk
root = tk.Tk()
entry = tk.Entry(root)
entry.grid()
def always_good():
  return True
validate_ref = root.register(always_good)
entry.configure(
  validate='all',
  validatecommand=(validate_ref,)
)
root.mainloop() 

在这个例子中,我们通过将 always_good 函数传递给 root.register() 来检索我们的函数引用。然后我们可以将这个引用作为一个元组传递给 validatecommand。我们注册的验证回调必须返回一个布尔值,表示字段中的数据是否有效或无效。

validatecommand 回调 必须 返回一个布尔值。如果它返回任何其他内容(包括没有 return 语句时的隐式 None 值),Tkinter 将关闭小部件的验证(即,它将 validate 设置为 none)。请记住,它的目的 是指示数据是否可接受。无效数据的处理将由我们的 invalidcommand 回调完成。

当然,除非我们向函数提供一些要验证的数据,否则很难验证数据。为了使 Tkinter 将信息传递给我们的验证回调,我们可以在 validatecommand 元组中添加一个或多个替换代码。这些代码如下:

代码 传递的值
%d 指示尝试的操作的代码:0 表示删除,1 表示插入,以及 -1 表示其他事件。请注意,这作为字符串传递,而不是整数。
%P 变更后字段将具有的提议值(仅限关键事件)。
%s 当前字段中的值(仅限关键事件)。
%i 在按键事件中插入或删除的文本的索引(从 0 开始),或在非按键事件中为 -1。请注意,这作为字符串传递,而不是整数。
%S 用于插入或删除的文本(仅限关键事件)。
%v 小部件的 validate 值。
%V 触发验证的事件类型,可以是 focusinfocusoutkeyforced(表示小部件的变量已更改)。
%W Tcl/Tk 中小部件的名称,作为字符串。

我们可以使用这些代码创建一个稍微更有用的验证 Entry,如下所示:

# validate_demo.py
# Place just before root.mainloop()
entry2 = tk.Entry(root)
entry2.grid(pady=10)
def no_t_for_me(proposed):
  return 't' not in proposed
validate2_ref = root.register(no_t_for_me)
entry2.configure(
  validate='all',
  validatecommand=(validate2_ref, '%P')
) 

在这里,我们将 %P 替换代码传递到我们的 validatecommand 元组中,以便我们的回调函数将接收到小部件的提议新值(即,如果按键被接受,则小部件的值)。在这种情况下,如果提议的值包含 t 字符,我们将返回 False

注意,当 validatecommand 回调返回时,小部件的行为会根据触发验证的事件类型而改变。如果验证回调由 key 事件触发并返回 False,Tkinter 的内置行为是拒绝按键并保持内容不变。如果 focus 事件触发验证,则 False 返回值将简单地标记小部件为无效。在这两种情况下,invalidcommand 回调也将被执行。如果我们没有指定回调,Tkinter 将不会采取任何进一步的操作。

例如,运行上面的脚本;您会发现您不能在 Entry 小部件中键入 t。这是因为 key 验证返回 False,所以 Tkinter 拒绝了按键。

invalidcommand 参数

invalidcommand 参数与 validatecommand 参数完全相同,需要使用 register() 方法以及相同的替换代码。它指定了一个在 validatecommand 返回 False 时要运行的回调函数。它可以用来显示错误或可能纠正输入。

要了解这些代码组合在一起的样子,请考虑以下仅接受五个字符的 Entry 小部件的代码:

entry3 = tk.Entry(root)
entry3.grid()
entry3_error = tk.Label(root, fg='red')
entry3_error.grid()
def only_five_chars(proposed):
  return len(proposed) < 6
def only_five_chars_error(proposed):
  entry3_error.configure(
  text=f'{proposed} is too long, only 5 chars allowed.'
  )
validate3_ref = root.register(only_five_chars)
invalid3_ref = root.register(only_five_chars_error)
entry3.configure(
  validate='all',
  validatecommand=(validate3_ref, '%P'),
  invalidcommand=(invalid3_ref, '%P')
) 

在这里,我们创建了一个简单的 GUI,其中包含一个 Entry 小部件和一个 Label 小部件。我们还创建了两个函数,一个用于返回字符串长度是否小于六个字符,另一个用于配置 Label 小部件以显示错误。然后我们使用 root.register() 方法将这两个函数注册到 Tk 中,将它们传递给 Entry 小部件的 validatecommandinvalidcommand 参数。我们还包括了 %P 替换代码,以便将小部件的提议值传递给每个函数。请注意,您可以传递任意多的替换代码,并且可以按任意顺序传递,只要您的回调函数能够接受这些参数。

运行此示例并测试其行为;请注意,您不仅不能在框中键入超过五个字符,而且您还会在标签中收到警告,表明您的尝试编辑过长。

创建经过验证的小部件类

如您所见,即使在 Tkinter 小部件中添加非常简单的验证也涉及几个步骤和一些不太直观的逻辑。即使只对我们的部分小部件添加验证也可能变得冗长且难以看懂。然而,我们在上一章中学到,我们可以通过子类化 Tkinter 小部件来改进它们,以添加新的配置和功能。让我们看看我们是否可以将这种技术应用于小部件验证,通过创建 Tkinter 小部件类的验证版本。

例如,让我们再次实现我们的五字符输入,这次作为 ttk.Entry 的子类,如下所示:

# five_char_entry_class.py
class FiveCharEntry(ttk.Entry):
  """An Entry that truncates to five characters on exit."""
  def __init__(self, parent, *args, **kwargs):
    super().__init__(parent, *args, **kwargs)
    self.error = tk.StringVar()
    self.configure(
      validate='all',
      validatecommand=(self.register(self._validate), '%P'),
      invalidcommand=(self.register(self._on_invalid), '%P')
    )
  def _validate(self, proposed):
    return len(proposed) <= 5
  def _on_invalid(self, proposed):
    self.error.set(
      f'{proposed} is too long, only 5 chars allowed!'
    ) 

这次,我们通过子类化 Entry 并在方法中定义我们的验证逻辑而不是外部函数来实现验证。这简化了我们在验证方法中对小部件的访问,如果我们需要的话,并且也允许我们在它们实际定义之前在 __init__() 中引用这些方法。我们还添加了一个名为 errorStringVar 作为实例变量。如果我们的验证失败,我们可以使用这个变量来保存错误消息。

注意,我们使用 self.register() 而不是 root.register() 来注册这些函数。register() 方法不需要在 root 窗口对象上运行;它可以在任何 Tkinter 小部件上运行。由于我们无法确定使用我们类代码的人是否会调用 root 窗口,或者它是否在 __init__() 方法运行时处于作用域内,因此使用 FiveCharEntry 小部件本身来注册函数是有意义的。然而,这必须在调用 super().__init__() 之后完成,因为底层的 Tcl/Tk 对象实际上并不存在(并且不能注册函数),直到该方法运行。这就是为什么我们使用 configure() 来设置这些值,而不是将它们传递给 super().__init__() 的原因。

我们可以使用此类如下:

root = tk.Tk()
entry = FiveCharEntry(root)
error_label = ttk.Label(
  root, textvariable=entry.error, foreground='red'
)
entry.grid()
error_label.grid()
root.mainloop() 

在这里,我们创建了一个 FiveCharEntry 小部件的实例以及一个 Label 小部件来显示错误。请注意,我们将小部件的内置错误变量 entry.error 传递给标签的 textvariable 参数。当你执行这个操作时,你应该会看到标签在尝试输入超过五个字符时显示错误,如下所示:

五字符输入拒绝接受 "Banana"

图 5.1:五字符输入拒绝接受 "Banana"

创建日期字段

现在让我们尝试做一些更有用的东西:创建一个用于我们的 Date 字段的验证 DateEntry 小部件。我们的小部件将阻止任何对于日期字符串无效的按键,并在 focusout 时检查日期的有效性。如果日期无效,我们将以某种方式标记字段,并在 StringVar 中设置一个错误消息,其他小部件可以使用它来显示错误。

首先,打开一个名为 DateEntry.py 的新文件,并从以下代码开始:

# DateEntry.py
import tkinter as tk
from tkinter import ttk
from datetime import datetime
class DateEntry(ttk.Entry):
  """An Entry for ISO-style dates (Year-month-day)"""
  def __init__(self, parent, *args, **kwargs):
    super().__init__(parent, *args, **kwargs)
    self.configure(
      validate='all',
      validatecommand=(
        self.register(self._validate),
        '%S', '%i', '%V', '%d'
      ),
      invalidcommand=(self.register(self._on_invalid), '%V')
    )
    self.error = tk.StringVar() 

在导入 tkinterttk 之后,我们还导入了 datetime,这是为了验证输入的日期字符串。与之前的类一样,我们重写了 __init__() 方法来设置验证并添加一个错误变量。然而,这一次,我们将向我们的 validatecommand 方法传递更多的参数:要插入的字符(%S),插入的索引位置(%i),触发验证的事件类型(%V),以及动作类型(%d)。invalidcommand 只接收事件类型(%V)。由于我们将在所有事件上触发验证,我们需要这个值来决定如何适当地处理无效数据。

接下来,让我们创建一个名为 _toggle_error() 的方法来在小部件中打开或关闭错误状态:

 def _toggle_error(self, error=''):
    self.error.set(error)
    **self.config(foreground=****'red'****if** **error** **else****'black'****)** 

我们将使用这个方法来处理小部件在发生错误或被纠正时的行为。它首先将我们的错误变量设置为提供的字符串。如果字符串不为空,我们设置一个视觉错误指示器(在这种情况下,将文本变为红色);如果为空,我们关闭视觉指示器。

现在我们有了这些,我们可以创建我们的 _validate() 方法,如下所示:

 def _validate(self, char, index, event, action):
    # reset error state
    self._toggle_error()
    valid = True
    # ISO dates only need digits and hyphens
    if event == 'key':
      if action == '0':
        valid = True
      elif index in ('0', '1', '2', '3', '5', '6', '8', '9'):
        valid = char.isdigit()
      elif index in ('4', '7'):
        valid = char == '-'
      else:
        valid = False 

这个方法将采取“无罪直到被证明有罪”的方法来验证用户输入,所以我们首先关闭任何错误状态并将 valid 标志设置为 True。然后,我们将开始查看按键事件。if action == '0': 这行代码告诉我们用户是否正在尝试删除字符。我们总是希望允许这样做,以便用户可以编辑字段,所以它应该总是返回 True

ISO 日期的基本格式是四位数字,一个连字符,两位数字,一个连字符,再是两位数字。我们可以通过检查插入的字符是否与我们的期望匹配来测试用户是否遵循了这种格式。例如,index in ('0','1', '2', '3', '5', '6', '8', '9') 这行代码将告诉我们字符是否被插入到需要数字的一个位置,如果是,我们将检查该字符是否为数字。索引 47 的字符应该是连字符。任何其他按键都是无效的。

尽管你可能期望它们是整数,Tkinter 将动作代码和字符索引都作为字符串传递。在编写比较时请记住这一点。

虽然这是一个对于正确日期来说极其天真的启发式方法,因为它允许像 0000-97-46 或看起来正确但实际上错误的日期 2000-02-29 这样的完全无意义的日期,但它至少强制执行了基本格式,并移除了一大堆无效的按键。一个完全准确的日期部分分析器是一个独立的项目,但就目前而言,这已经足够了。

检查在焦点移出时日期的正确性更为简单且更不容易出错,正如你在这里可以看到的:

# still in DateEntry._validate()
    elif event == 'focusout':
      try:
        datetime.strptime(self.get(), '%Y-%m-%d')
      except ValueError:
        valid = False
    return valid 

由于我们现在可以访问用户打算输入的最终值,我们可以使用 datetime.strptime() 尝试使用格式 %Y-%m-%d 将字符串转换为 Python datetime 对象。如果这失败,我们知道日期是无效的。

最后,在方法末尾,我们返回我们的 valid 标志。正如你之前看到的,对于按键事件,返回 False 就足以防止字符被插入;但对于焦点事件上的错误,我们需要以某种用户可见的方式做出响应。

这将在我们的 _on_invalid() 方法中处理,如下所示:

 def _on_invalid(self, event):
    if event != 'key':
      self._toggle_error('Not a valid date') 

我们已经配置了此方法,使其仅接收事件类型,我们将使用它来忽略按键事件(它们已经被默认行为充分处理)。对于任何其他事件类型,我们将使用我们的 _toggle_error() 方法来设置我们的视觉指示器和错误字符串。

让我们在文件的末尾使用一个小测试脚本来测试 DateEntry 类:

if __name__ == '__main__':
  root = tk.Tk()
  entry = DateEntry(root)
  entry.pack()
  ttk.Label(
    textvariable=entry.error, foreground='red'
  ).pack()
  # add this so we can unfocus the DateEntry
  ttk.Entry(root).pack()
  root.mainloop() 

保存文件并运行它以尝试新的 DateEntry 类。尝试输入各种错误的日期或无效的按键,然后点击第二个 Entry 小部件以取消 DateEntry 的焦点,并注意发生了什么。

你应该看到如下内容:

一个验证的 DateEntry 小部件警告我们关于一个错误的日期字符串。

图 5.2:一个验证的 DateEntry 小部件警告我们关于一个错误的日期字符串

在我们的 GUI 中实现验证小部件

现在你已经知道了如何验证你的小部件,你有很多工作要做!我们有 17 个输入小部件,你必须为它们都编写像上一节中所示的那种验证代码,以获得我们需要的功能。在这个过程中,你需要确保小部件对错误做出一致的反应,并向应用程序提供一个一致的 API。

如果这听起来像是你想无限期推迟的事情,我无法责怪你。也许有一种方法我们可以减少我们需要编写的重复代码量。

介绍多重继承的强大功能

到目前为止,我们已经了解到 Python 允许我们通过子类化来创建新类,从超类继承特性,并且只添加或更改我们新类中不同的部分。Python 还支持使用 多重继承 来构建类,其中子类可以继承多个超类。我们可以通过创建所谓的 混合类 来利用这个特性。

混合类只包含我们想要能够“混合”到其他类中以组成新类的一组特定功能。

看看下面的示例代码:

class Fruit():
  _taste = 'sweet'
  def taste(self):
    print(f'It tastes {self._taste}')
class PeelableMixin():
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self._peeled = False
  def peel(self):
    self._peeled = True
  def taste(self):
    if not self._peeled:
      print('I will peel it first')
      self.peel()
    super().taste() 

在这个例子中,我们有一个名为 Fruit 的类,它有一个 _taste 类属性和一个 taste() 方法,该方法打印一条消息,指示水果的口感。然后我们有一个名为 PeelableMixin 的混合类。混合类添加了一个实例属性 _peeled,用来指示水果是否已被剥皮,以及一个 peel() 方法来更新 _peeled 属性。它还重写了 taste() 方法,在品尝之前检查水果是否已被剥皮。请注意,即使混合类没有从另一个类继承,其 __init__() 方法也会调用超类初始化器。我们稍后将看到这是为什么。

现在,让我们使用多重继承来创建一个新的类,如下所示:

class Plantain(PeelableMixin, Fruit):
  _taste = 'starchy'
  def peel(self):
    print('It has a tough peel!')
    super().peel() 

Plantain类是由PeelableMixinFruit类的组合创建的。当我们使用多重继承创建类时,我们指定的最右侧的类被称为基类,混入类应该在其之前指定(即位于基类的左侧)。因此,在这种情况下Fruit是基类。

让我们创建我们类的实例并调用taste(),如下所示:

plantain = Plantain()
plantain.taste() 

如你所见,生成的子类既有taste()方法也有peel()方法,但请注意,在所有类之间定义了每种方法的两个版本。当我们调用这些方法之一时,使用的是哪个版本?

在多重继承的情况下,super()所做的比仅仅代表超类要复杂一些。它使用称为方法解析顺序MRO)的东西来查找继承链,并确定定义我们正在调用的方法的最近类。解析顺序从当前类开始,然后按照从左侧到基类的超类链进行。

因此,当我们调用plantain.taste()时,会发生一系列的方法解析,如下所示:

  • plantain.taste()解析为PeelableMixin.taste()

  • 然后PeelableMixin.taste()调用self.peel()。由于self是一个Plantain对象,self.peel()解析为Plantain.peel()

  • Plaintain.peel()打印一条消息并调用super().peel()。Python 将此调用解析为具有peel()方法的左侧最接近的类,即PeelableMixin.peel()

  • 当返回结果时,PeelableMixin.taste()然后调用super().taste()。从PeelableMixin开始的下一个左侧类是Fruit,因此这解析为Fruit.taste()

  • Fruit.taste()引用类变量_taste。即使正在运行的方法在Fruit类中,但我们的对象类是Plantain,所以这里使用Plantain._taste

如果这看起来很复杂,只需记住self.method()self.attribute将始终首先在当前类中查找method()attribute,然后从左到右按照继承的类列表进行查找,直到找到方法或属性。super()对象将做同样的事情,但它会跳过当前类。

这就是我们为什么在示例中在混入类的初始化器中调用super().__init__()的原因。

如果不进行此调用,则只会调用混入类的初始化器。通过调用super().__init__(),Python 还会继续沿着 MRO 链向上调用基类初始化器。这在创建 Tkinter 类的混入类时尤其重要,因为 Tkinter 类的初始化器创建了实际的 Tcl/Tk 对象。

类的方法解析顺序存储在其__mro__属性中;如果你在使用继承的方法或属性时遇到问题,可以在 Python 壳或调试器中检查此方法。

注意,PeelableMixin不能单独使用:它只能在具有taste()方法的类结合使用时工作。这就是为什么它是一个混合类:它旨在与其他类混合以增强其他类,而不是单独使用。

不幸的是,Python 没有提供一种方法来显式地在代码中注释一个类是混合类或它必须与哪些类混合,所以请确保很好地记录你的混合类。

构建验证混合类

让我们应用我们对多重继承的知识来构建一个混合类,这将帮助我们用更少的样板代码创建验证小部件类。打开data_entry_app.py并在你的Application类定义之上添加新类:

# data_entry_app.py
class ValidatedMixin:
  """Adds a validation functionality to an input widget"""
  def __init__(self, *args, error_var=None, **kwargs):
    self.error = error_var or tk.StringVar()
    super().__init__(*args, **kwargs) 

我们像往常一样开始这个类,尽管这次我们没有子类化任何东西,因为这是一个混合类。构造函数还有一个额外的参数,称为error_var。这将允许我们传递一个用于错误消息的变量;如果没有,类将创建自己的。记住,对super().__init__()的调用将确保执行基类初始化器。

接下来,我们设置验证,如下所示:

 vcmd = self.register(self._validate)
    invcmd = self.register(self._invalid)
    self.configure(
      validate='all',
      validatecommand=(vcmd, '%P', '%s', '%S', '%V', '%i', '%d'),
      invalidcommand=(invcmd, '%P', '%s', '%S', '%V', '%i', '%d')
    ) 

如之前所做的那样,我们正在注册实例方法以进行验证和无效数据处理,然后使用configure将它们与小部件一起设置。我们将继续传递所有替换代码(除了%w,即小部件名称,因为它在类上下文中相当无用)。我们在所有条件下运行验证,因此我们可以捕获focuskey事件。

现在,我们将定义我们的错误条件处理器:

 def _toggle_error(self, on=False):
    self.configure(foreground=('red' if on else 'black')) 

这将仅在存在错误时将文本颜色更改为红色,否则为黑色。与之前的验证小部件类不同,我们不会在这个函数中设置错误字符串;相反,我们将在验证回调中这样做,因为在那样的上下文中我们将更好地了解错误是什么。

我们的验证回调将如下所示:

 def _validate(self, proposed, current, char, event, index, action):
    self.error.set('')
    self._toggle_error()
    valid = True
    # if the widget is disabled, don't validate
    state = str(self.configure('state')[-1])
    if state == tk.DISABLED:
      return valid
    if event == 'focusout':
      valid = self._focusout_validate(event=event)
    elif event == 'key':
      valid = self._key_validate(
      proposed=proposed,
      current=current,
      char=char,
      event=event,
      index=index,
      action=action
    )
    return valid 

由于这是一个混合类,我们的_validate()方法实际上不包含任何验证逻辑。相反,它将首先处理一些设置任务,例如关闭错误并清除错误消息。然后,它检查小部件是否被禁用,通过检索小部件state值中的最后一个项目来实现。如果它被禁用,小部件的值无关紧要,因此验证应该始终通过。

之后,该方法根据传入的事件类型调用特定的事件回调方法。我们现在只关心keyfocusout事件,所以任何其他事件都返回True。这些特定的事件方法将在我们的子类中定义,以确定实际使用的验证逻辑。

注意,我们使用关键字参数调用单个方法;当我们创建我们的子类时,我们将覆盖这些方法。通过使用关键字参数,我们的覆盖函数只需指定所需的关键字或从**kwargs中提取单个参数,而不必按正确的顺序获取所有参数。此外,请注意,所有参数都传递给了_key_validate(),但只有event传递给了_focusout_validate()。焦点事件不会传递任何有用的参数给其他任何参数,所以没有必要传递它们。

接下来,我们将为特定事件的验证方法添加占位符:

 def _focusout_validate(self, **kwargs):
    return True
  def _key_validate(self, **kwargs):
    return True 

这里的最终想法是,我们的子类只需要覆盖一个或两个_focusout_validate()_key_validate(),具体取决于我们对该小部件的关注点。如果我们不覆盖它们,它们将默认返回True,因此验证通过。

现在,让我们为我们的无效输入处理程序做类似的事情:

 def _invalid(self, proposed, current, char, event, index, action):
    if event == 'focusout':
      self._focusout_invalid(event=event)
    elif event == 'key':
      self._key_invalid(
        proposed=proposed,
        current=current,
        char=char,
        event=event,
        index=index,
        action=action
      )
  def _focusout_invalid(self, **kwargs):
    """Handle invalid data on a focus event"""
    self._toggle_error(True)
  def _key_invalid(self, **kwargs):
    """Handle invalid data on a key event.  
    By default we want to do nothing"""
    pass 

我们对这些方法采取相同的方法。与验证方法不同,我们的无效数据处理程序不需要返回任何内容。对于无效的key事件,我们默认不执行任何操作,对于focusout事件上的无效输入,我们切换错误状态。

我们最后想添加的是一种手动在控件上执行验证的方法。按键验证仅在输入键的上下文中才有意义,但有时我们可能想手动运行焦点退出检查,因为它们实际上检查了完整输入值。让我们通过以下公共方法来实现这一点:

 def trigger_focusout_validation(self):
    valid = self._validate('', '', '', 'focusout', '', '')
    if not valid:
      self._focusout_invalid(event='focusout')
    return valid 

在这个方法中,我们只是在复制焦点退出事件发生时的逻辑:运行验证函数,如果失败,则运行无效处理程序。这完成了ValidatedMixin。现在让我们通过将其应用于我们的某些小部件来看看它是如何工作的。

使用 ValidatedMixin 构建验证输入小部件

首先,让我们思考一下我们需要使用我们的新ValidatedMixin类实现哪些类:

  • 除了注释字段之外,我们所有的字段都是必需的(当未禁用时),因此我们需要一个基本的Entry小部件,如果没有任何输入,它会注册一个错误。

  • 我们有一个日期字段,因此我们需要一个强制有效日期字符串的Entry小部件。

  • 我们有几个用于十进制或整数输入的Spinbox小部件。我们需要确保这些小部件只接受有效的数字字符串。

  • 我们有几个Combobox小部件,它们的行为并不完全符合我们的期望。

让我们开始吧!

需求数据

让我们从需要数据的基本Entry小部件开始。我们可以将这些用于技术人员和种子样本字段。

ValidatedMixin类之后添加一个新类:

# data_entry_app.py
class RequiredEntry(ValidatedMixin, ttk.Entry):
  """An Entry that requires a value"""
  def _focusout_validate(self, event):
    valid = True
    if not self.get():
      valid = False
      self.error.set('A value is required')
    return valid 

这里没有按键验证要做,所以我们只需要创建_focusout_validate()。在那个方法中,我们只需要检查输入值是否为空。如果是,我们只需设置一个error字符串并返回False

就这么简单!

创建日期小部件

接下来,让我们将混合类应用到我们之前制作的DateEntry类上,保持相同的验证算法。在RequiredEntry类下方添加以下代码:

class DateEntry(ValidatedMixin, ttk.Entry):
  """An Entry that only accepts ISO Date strings"""
  def _key_validate(self, action, index, char, **kwargs):
    valid = True
    if action == '0':  # This is a delete action
      valid = True
    elif index in ('0', '1', '2', '3', '5', '6', '8', '9'):
      valid = char.isdigit()
    elif index in ('4', '7'):
      valid = char == '-'
    else:
      valid = False
    return valid
  def _focusout_validate(self, event):
    valid = True
    if not self.get():
      self.error.set('A value is required')
      valid = False
    try:
      datetime.strptime(self.get(), '%Y-%m-%d')
    except ValueError:
      self.error.set('Invalid date')
      valid = False
    return valid 

在这个类中,我们再次简单地重写了keyfocus验证方法,这次复制了我们之前在DateEntry小部件中使用的验证逻辑。_focusout_validate()方法也包括了RequiredEntry类的逻辑,因为日期值是必需的。

这些类都很容易创建;让我们继续做一些更复杂的事情。

一个更好的 Combobox 小部件

不同工具包或小部件集中的下拉小部件在鼠标操作方面表现相当一致,但按键响应各不相同;例如:

  • 一些不做任何事情,例如 Tkinter 的OptionMenu

  • 一些需要使用箭头键来选择项目,例如 Tkinter 的ListBox

  • 一些将列表缩小到以按下的任何键开头的第一个条目,并在后续按下时循环显示以该字母开头的条目

  • 一些将列表缩小到与输入的文本匹配的条目

我们需要考虑我们的Combobox小部件应该有什么行为。由于我们的用户习惯于使用键盘进行数据输入,并且有些人使用鼠标有困难,因此小部件需要与键盘很好地协同工作。让他们使用重复的按键来选择选项也不是很直观。在与数据输入人员交谈后,你决定采用以下行为:

  • 如果提议的文本与任何条目都不匹配,则忽略按键

  • 当提议的文本与单个条目匹配时,小部件被设置为该值

  • 删除或退格键清除整个框

让我们看看我们能否通过验证来实现这一点。在DateEntry定义之后添加另一个类:

class ValidatedCombobox(ValidatedMixin, ttk.Combobox):
  """A combobox that only takes values from its string list"""
  def _key_validate(self, proposed, action, **kwargs):
    valid = True
    if action == '0':
      self.set('')
      return True 

_key_validate()方法首先设置一个有效标志并快速检查这是否是一个删除操作。如果是,我们将值设置为空字符串并返回True。这样就处理了最后一个要求。

现在,我们将添加将提议文本与我们的值匹配的逻辑:

 values = self.cget('values')
    # Do a case-insensitive match against the entered text
    matching = [
      x for x in values
      if x.lower().startswith(proposed.lower())
    ]
    if len(matching) == 0:
      valid = False
    elif len(matching) == 1:
      self.set(matching[0])
      self.icursor(tk.END)
      valid = False
    return valid 

使用小部件的cget()方法检索其值列表的副本。然后,我们使用列表推导来将此列表缩减到仅包含以提议文本开头的条目。为了使匹配不区分大小写,我们在比较之前在列表项中的值和提议文本上都调用lower()

每个 Tkinter 小部件都支持cget()方法。它可以用来通过名称检索小部件的任何配置值。

如果匹配列表的长度为0,则没有以输入值开头的项,我们将拒绝按键。如果它是1,我们就找到了匹配项,因此我们将变量设置为该值。这是通过调用小部件的set()方法并传递匹配值来完成的。作为最后的润色,我们将使用组合框的.icursor()将光标移至字段的末尾。这并不是严格必要的,但看起来比将光标留在文本中间要好。注意,尽管值匹配成功,我们仍然将valid设置为False;由于我们正在将值自行设置为匹配项,我们希望停止任何进一步的输入到小部件。否则,建议的按键将被附加到我们设置的值的末尾,从而创建一个无效的输入。

还要注意,如果我们的匹配列表包含多个值,该方法将仅返回True,允许用户继续输入并过滤列表。

接下来,让我们添加focusout验证器:

 def _focusout_validate(self, **kwargs):
    valid = True
    if not self.get():
      valid = False
      self.error.set('A value is required')
    return valid 

我们在这里不需要做太多,因为键验证方法确保唯一可能的值是空白字段或values列表中的项,但由于所有字段都需要值,我们将从RequiredEntry复制验证逻辑。

这样我们就处理好了Combobox小部件。接下来,我们将处理Spinbox小部件。

一个范围受限的 Spinbox 小部件

数字输入看起来似乎处理起来不会太复杂,但有一些细微之处需要处理以确保其健壮性。除了限制字段为有效的数字字符串外,你还需要强制执行from_toincrement参数分别为输入的最小值、最大值和精度。

算法需要实现以下规则:

  • 删除总是允许的

  • 数字总是允许的

  • 如果from_小于 0,允许负号作为第一个字符

  • 如果increment有小数部分,则允许一个(且仅一个)点

  • 如果建议的值大于to值,则忽略按键

  • 如果建议的值需要比increment更高的精度,则忽略按键

  • focusout时,确保值是一个有效的数字字符串

  • focusout时,确保值大于from_

这有很多规则,所以让我们慢慢来,在尝试实现它们的过程中。我们首先想要做的是从标准库中导入Decimal类。在文件顶部,将以下内容添加到导入列表的末尾:

from decimal import Decimal, InvalidOperation 

Decimal类帮助我们的十进制值比内置的float类更精确,并且也使得在数字和字符串之间进行转换变得更容易。InvalidOperation是我们可以在验证逻辑中使用的特定于十进制的异常。

现在,让我们在ValidatedCombobox类下添加一个新的ValidatedSpinbox类,如下所示:

**class****ValidatedSpinbox****(ValidatedMixin, ttk.Spinbox):**
  def __init__(
    self, *args, from_='-Infinity', to='Infinity', **kwargs
  ):
    super().__init__(*args, from_=from_, to=to, **kwargs)
    increment = Decimal(str(kwargs.get('increment', '1.0')))
    self.precision = increment.normalize().as_tuple().exponent 

我们首先通过重写__init__()方法来指定一些默认值,并从初始化参数中获取from_toincrement值,以便在建立验证规则时使用。请注意,我们已经为tofrom_设置了默认值:-InfinityInfinityfloatDecimal都乐意接受这些值,并按你期望的方式处理它们。回想一下,如果我们指定一个限制,我们必须也指定另一个。添加这些默认值允许我们只指定所需的那个,并且我们的Spinbox将按预期工作。

一旦我们运行了超类初始化方法,我们将确定精度值;即我们希望小数点右边的数字位数。

要做到这一点,我们首先从关键字参数中检索increment值,如果没有指定,则使用1.0。然后我们将此值转换为Decimal对象。为什么要这样做?Spinbox小部件的参数可以是浮点数、整数或字符串。无论你如何传递它们,Tkinter 都会在Spinbox初始化器运行时将它们转换为浮点数。由于浮点数错误,确定浮点数的精度是有问题的,所以我们希望在它成为浮点数之前将其转换为 Python 的Decimal

什么是浮点数错误?浮点数试图以二进制形式表示十进制数。打开 Python 壳,并输入1.2 / 0.2。你可能会惊讶地发现答案是5.999999999999999而不是6。这是在二进制数上而不是在十进制数上进行计算的结果,并且是几乎所有编程语言中的计算错误来源。Python 为我们提供了Decimal类,它接受一个数字字符串并将其以使数学运算免受浮点数错误影响的方式存储。

注意,我们在将increment传递给Decimal之前将其转换为str。理想情况下,我们应该将increment作为字符串传递给我们的小部件以确保它被正确解释,但如果出于某种原因需要传递一个浮点数,str将首先进行一些合理的舍入。

increment转换为Decimal对象后,我们可以通过取最小有效小数位的指数来提取其精度值。我们将在验证方法中使用这个值来确保我们输入的数据没有太多小数位。

我们的小部件构造器现在已经确定,所以让我们编写验证方法。_key_validate()方法有点棘手,所以我们将分块进行讲解。

首先,我们开始这个方法:

 def _key_validate(
    self, char, index, current, proposed, action, **kwargs
  ):
    if action == '0':
      return True
    valid = True
    min_val = self.cget('from')
    max_val = self.cget('to')
    no_negative = min_val >= 0
    no_decimal = self.precision >= 0 

首先,因为删除操作应该始终有效,所以如果操作是删除,我们将立即返回True。之后,我们使用cget()获取from_to的值,并声明一些标志变量来指示是否允许负数和小数。

接下来,我们需要测试所提出的按键是否是一个有效的字符:

 if any([
      (char not in '-1234567890.'),
      (char == '-' and (no_negative or index != '0')),
      (char == '.' and (no_decimal or '.' in current))
    ]):
      return False 

有效的字符是数字、- 符号和十进制(.)。减号仅在索引 0 处有效,并且仅当允许负数时有效。小数只能出现一次,并且只有当我们的精度小于 1 时才有效。我们将所有这些条件放入一个列表中,并将其传递给内置的 any() 函数。

内置的 any() 函数接受一个表达式列表,如果列表中的任何一个表达式为真,则返回 True。还有一个 all() 函数,只有当列表中的所有表达式都为真时才返回 True。这些函数允许你压缩一长串布尔表达式。

到目前为止,我们几乎可以肯定有一个有效的 Decimal 字符串,但还不完全确定;我们可能只有 -.-. 这些字符。

这些不是有效的 Decimal 字符串,但它们是有效的 部分 输入,因此我们应该允许它们。此代码将检查这些组合并允许它们:

 if proposed in '-.':
      return True 

如果我们在此点还没有返回,则提议的文本只能是一个有效的 Decimal 字符串,因此我们将从它创建一个 Decimal 对象并进行一些最终测试:

 proposed = Decimal(proposed)
    proposed_precision = proposed.as_tuple().exponent
    if any([
      (proposed > max_val),
      (proposed_precision < self.precision)
    ]):
      return False
    return valid 

我们最后的两个测试是检查提议的文本是否大于我们的最大值,或者比我们指定的增量(我们在这里使用 < 操作符的原因是精度以负值表示小数位数)有更多的精度。最后,如果没有返回任何内容,我们将返回 valid 值。

这就处理了键验证;我们的焦点外验证器要简单得多,如下所示:

 def _focusout_validate(self, **kwargs):
    valid = True
    value = self.get()
    min_val = self.cget('from')
    max_val = self.cget('to')
    try:
      d_value = Decimal(value)
    except InvalidOperation:
      self.error.set(f'Invalid number string: {value}')
      return False
    if d_value < min_val:
      self.error.set(f'Value is too low (min {min_val})')
      valid = False
    if d_value > max_val:
      self.error.set(f'Value is too high (max {max_val})')
      valid = False
    return valid 

在我们拥有整个预期值的情况下,我们只需要确保它是一个有效的 Decimal 字符串,并且处于指定的值范围内。从理论上讲,我们的键验证应该阻止无效的十进制字符串或高值被输入,但进行检查并无害处。

完成该方法后,我们的 ValidatedSpinbox 就可以使用了。

验证 Radiobutton 小部件

验证 Radiobutton 小部件可能一开始看起来毫无意义,因为小部件本身只能处于开启或关闭状态;然而,在某些情况下,验证一组按钮可能非常有用。例如,在我们的 ABQ 数据表单中,实验室字段必须有一个值,但当前用户可以提交一个记录而不点击任何一个选项。

为了解决这个问题,我们将创建一个新的类来表示一组按钮,并将验证代码添加到这个复合小部件中。

不幸的是,我们的混合类在这里帮不上忙,因为我们的复合小部件和 Ttk Radiobutton 小部件都不支持 validatevalidatecommandinvalidcommand 参数。因此,我们将不得不在没有 Tkinter 验证系统帮助的情况下实现按钮组的验证。

首先,我们将对 ttk.Frame 进行子类化,以构建复合小部件:

# data_entry_app.py 
class ValidatedRadioGroup(ttk.Frame):
  """A validated radio button group"""
  def __init__(
    self, *args, variable=None, error_var=None,
    values=None, button_args=None, **kwargs
  ):
    super().__init__(*args, **kwargs)
    self.variable = variable or tk.StringVar()
    self.error = error_var or tk.StringVar()
    self.values = values or list()
    self.button_args = button_args or dict() 

这个类的初始化器接受多个关键字参数:

  • variable 将是控制该组值的控制变量。如果没有传入,它将由该类创建。

  • error_var 是错误字符串的控制变量。正如我们其他已验证的类一样,我们允许接受一个 StringVar 控制变量来保存错误字符串,或者如果没有传入,我们创建一个,将其保存为 self.error

  • values 将是一个包含每个按钮在组中代表的字符串值的列表。

  • button_args 将是一个关键字参数的字典,我们可以将其传递给单个 Radiobutton 小部件。这将允许我们单独从 Frame 容器传递参数给按钮。

剩余的位置参数和关键字参数被传递给超类初始化器。在将关键字值保存到实例变量之后,我们将创建按钮,如下所示:

 for v in self.values:
      button = ttk.Radiobutton(
        self, value=v, text=v,
        variable=self.variable, **self.button_args
      )
      button.pack(
        side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x'
      ) 

正如我们在 LabelInput 初始化器中所做的那样,我们正在遍历 values 列表,为每个值创建一个 Radiobutton 小部件,并将其绑定到公共控制变量。每个小部件都从小部件的左侧开始填充到 Frame 中。

为了完成初始化器,我们需要在 Frame 小部件失去焦点时触发验证回调。为此,我们可以简单地使用 bind(),如下所示:

 self.bind('<FocusOut>', self.trigger_focusout_validation) 

现在,每当小部件失去焦点时,验证回调将被调用。让我们接下来编写这个回调:

 def trigger_focusout_validation(self, *_):
    self.error.set('')
    if not self.variable.get():
      self.error.set('A value is required') 

此方法首先将错误变量设置为空字符串,然后简单地检查我们的边界变量是否包含值。如果它是空的,错误字符串将被填充。

在我们能够使用这个复合小部件与我们的应用程序一起使用之前,我们需要对 LabelInput 类进行一个小修改。记住,LabelInput 确保正确的控制变量关键字参数被传递到小部件初始化器中。我们需要确保我们的新复合小部件类得到正确的关键字(在这种情况下是 variable)。

更新 LabelInput 初始化器如下:

# data_entry_app, in LabelInput.__init__()
    if input_class in (
      ttk.Checkbutton, ttk.Button,
      ttk.Radiobutton, **ValidatedRadioGroup**
    ):
      input_args["variable"] = self.variable
    else:
      input_args["textvariable"] = self.variable 

有了这些,ValidatedRadio 小部件应该准备好使用了!

更新我们的表单以使用验证小部件

现在我们已经制作了所有小部件,是时候在我们的表单 GUI 中使用它们了。在 data_entry_app.py 中,滚动到 DataRecordForm 类的 __init__() 方法,我们将逐行更新我们的小部件。第 1 行相当直接:

 LabelInput(
      r_info, "Date", var=self._vars['Date'], input_class=**DateEntry**
    ).grid(row=0, column=0)
    LabelInput(
      r_info, "Time", input_class=**ValidatedCombobox**,
      var=self._vars['Time'],
      input_args={"values": ["8:00", "12:00", "16:00", "20:00"]}
    ).grid(row=0, column=1)
    LabelInput(
      r_info, "Technician",  var=self._vars['Technician'],
      input_class=**RequiredEntry**
    ).grid(row=0, column=2) 

这就像在每个 LabelInput 调用中用我们新的类之一替换 input_class 值一样简单。继续运行你的应用程序并尝试这些小部件。在 DateEntry 中尝试一些不同的有效和无效日期,看看 ValidatedCombobox 小部件是如何工作的(RequiredEntry 在这一点上不会做太多,因为唯一的可见指示是红色文本,如果没有文本标记为红色,那么它就是空的;我们将在下一节中解决这个问题)。

现在,让我们处理第 2 行,它包括 Lab、Plot 和 Seed Sample 输入:

 LabelInput(
      r_info, "Lab", input_class=**ValidatedRadioGroup**,
      var=self._vars['Lab'], input_args={"values": ["A", "B", "C"]}
    ).grid(row=1, column=0)
    LabelInput(
      r_info, "Plot", input_class=ValidatedCombobox,
      var=self._vars['Plot'], 
      input_args={"values": list(range(1, 21))}
    ).grid(row=1, column=1)
    LabelInput(
      r_info, "Seed Sample",  var=self._vars['Seed Sample'],
      input_class=RequiredEntry
    ).grid(row=1, column=2) 

一个敏锐的读者可能会注意到这不应该工作,因为我们的值列表包含整数,而 ValidatedCombobox 小部件的验证回调假定值是字符串(例如,我们在列表中的每个项上运行 lower(),并将项与提议的字符串进行比较)。实际上,Tkinter 在将调用转换为 Tcl/Tk 时隐式地将值列表中的项转换为字符串。当你编写包含数字的字段的验证方法时,了解这一点是很好的。

太好了!现在让我们继续到环境数据。我们只需要更新这里的数字输入到 ValidatedSpinbox 小部件:

 LabelInput(
      e_info, "Humidity (g/m³)",
      input_class=ValidatedSpinbox,  var=self._vars['Humidity'],
      input_args={"from_": 0.5, "to": 52.0, "increment": .01}
    ).grid(row=0, column=0)
    LabelInput(
      e_info, "Light (klx)", input_class=ValidatedSpinbox,
      var=self._vars['Light'],
      input_args={"from_": 0, "to": 100, "increment": .01}
    ).grid(row=0, column=1)
    LabelInput(
      e_info, "Temperature (°C)",
      input_class=ValidatedSpinbox,  var=self._vars['Temperature'],
      input_args={"from_": 4, "to": 40, "increment": .01}
    ).grid(row=0, column=2) 

在此点保存并执行脚本,并尝试使用 ValidatedSpinbox 小部件。你应该会发现无法输入大于最大值或超过两位小数的值,并且如果小于最小值,文本也会变成红色。

接下来,我们将更新植物数据的第一行,添加更多的 ValidatedSpinbox 小部件:

 LabelInput(
      p_info, "Plants", input_class=ValidatedSpinbox,
      var=self._vars['Plants'], input_args={"from_": 0, "to": 20}
    ).grid(row=0, column=0)
    LabelInput(
      p_info, "Blossoms", input_class=ValidatedSpinbox,
      var=self._vars['Blossoms'], input_args={"from_": 0, "to": 1000}
    ).grid(row=0, column=1)
    LabelInput(
      p_info, "Fruit", input_class=ValidatedSpinbox,
      var=self._vars['Fruit'], input_args={"from_": 0, "to": 1000}
    ).grid(row=0, column=2) 

保存并再次运行表单;你应该会发现这些小部件不允许你输入小数点,因为增量是默认的 (1.0)。

剩下的只是我们最后一行数字输入。在我们做这些之前,让我们解决一些表单小部件交互的问题。

实现表单小部件之间的验证交互

到目前为止,我们已经使用验证来创建可以根据用户输入到该小部件的输入进行验证的小部件。然而,有时小部件可能需要根据表单上另一个小部件的状态进行验证。在我们的表单上有两个这样的例子:

  • 我们的高度字段(最小高度、中等高度和最大高度)不应允许用户输入一个大于其他两个字段的最小高度,一个小于其他两个字段的最大高度,或者一个不在其他字段之间的中等高度。

  • 我们的设备故障复选框应该禁用环境数据的输入,因为我们不希望记录被怀疑有故障的数据。

动态更新 Spinbox 范围

为了解决我们高度字段的问题,我们将更新我们的 ValidatedSpinbox 小部件,使其范围可以动态更新。为此,我们可以使用我们在 第四章使用类组织我们的代码 中学到的变量跟踪功能。

我们的策略将是允许可选的 min_varmax_var 参数传递给 ValidatedSpinbox 类,然后对这些变量设置跟踪,以便在相应变量更改时更新 ValidatedSpinbox 对象的最小值或最大值。我们还将有一个 focus_update_var 变量,该变量将在 Spinbox 小部件失去焦点时更新其值。然后,这个变量可以作为 min_varmax_var 变量传递给第二个 ValidatedSpinbox,这样第一个小部件的值就可以改变第二个的有效范围。

让我们对 ValidatedSpinbox 进行以下更改。首先,更新 ValidatedSpinbox.__init__() 方法,如下添加我们的新关键字参数:

 def __init__(self, *args, min_var=None, max_var=None,
    focus_update_var=None, from_='-Infinity', to='Infinity', **kwargs
  ): 

我们为此功能的一些代码将需要 Spinbox 有一个变量绑定到它,所以接下来我们要确保这一点;将此代码放在 __init__() 的末尾:

 self.variable = kwargs.get('textvariable')
    if not self.variable:
      self.variable = tk.DoubleVar()
      self.configure(textvariable=self.variable) 

我们首先从关键字参数中检索 textvariable;如果它没有设置任何值,我们将创建一个 DoubleVar 并将其作为变量。我们存储对变量的引用,以便我们可以在实例方法中轻松使用它。

注意,如果稍后使用 configure() 分配变量,这种安排可能会引起问题。在我们的代码中这不会是问题,但如果你在自己的 Tkinter 程序中使用这个类,你可能想要覆盖 configure() 方法以确保变量引用保持同步。

接下来,仍然在 __init__() 中,让我们设置我们的最小值和最大值变量:

 if min_var:
      self.min_var = min_var
      self.min_var.trace_add('write', self._set_minimum)
    if max_var:
      self.max_var = max_var
      self.max_var.trace_add('write', self._set_maximum) 

如果我们传递了 min_varmax_var 参数,则值将被存储并配置一个跟踪。跟踪的回调指向一个适当命名的私有方法。

我们还需要存储对 focus_update_var 参数的引用,并将焦点离开事件绑定到一个将用于更新它的方法。为此,将以下代码添加到 __init__() 中:

 self.focus_update_var = focus_update_var
    self.bind('<FocusOut>', self._set_focus_update_var) 

bind() 方法可以在任何 Tkinter 小部件上调用,它用于将小部件事件连接到 Python 可调用函数。事件可以是按键、鼠标移动或点击、焦点事件、窗口管理事件等。

现在,我们需要添加 trace()bind() 命令的回调方法。我们将从更新 focus_update_var 的那个开始,我们将称之为 _set_focus_update_var()。如下添加:

 def _set_focus_update_var(self, event):
    value = self.get()
    if self.focus_update_var and not self.error.get():
      self.focus_update_var.set(value) 

此方法只是获取小部件的当前值,如果实例中存在 focus_update_var 参数,则将其设置为相同的值。请注意,如果小部件上当前存在错误,我们不会设置值,因为将值更新为无效内容是没有意义的。

还请注意,该方法接受一个 event 参数。我们不使用此参数,但它是必要的,因为这是一个绑定回调。当 Tkinter 调用一个绑定回调时,它会传递一个包含触发回调的事件信息的事件对象。即使你不会使用这些信息,你的函数或方法也需要能够接受这个参数。

现在,让我们创建设置最小值的回调,从以下内容开始:

 def _set_minimum(self, *_):
    current = self.get() 

这种方法的第一步是使用 self.get() 获取小部件的当前值。我们这样做的原因是因为 Spinbox 小部件有一个稍微令人烦恼的默认行为,即当 tofrom_ 值改变时,它会自动纠正其值,将过低值移动到 from_ 值,将过高值移动到 to 值。这种无声的自动纠正可能会逃过用户的注意,导致保存错误数据。

我们更希望保留超出范围的值不变,并将其标记为错误;因此,为了绕过 Spinbox 小部件,我们将保存当前值,更改配置,然后将原始值放回字段。

在将当前值存储在 current 中后,我们尝试获取 min_var 的值,并使用它设置小部件的 from_ 值,如下所示:

 try:
      new_min = self.min_var.get()
      self.config(from_=new_min)
    except (tk.TclError, ValueError):
      pass 

这里可能会出现几个问题,例如 min_var 中的空白或无效值,所有这些问题都应引发 tk.TclErrorValueError。在任何情况下,我们只是什么也不做,保留当前最小值不变。

通常来说,只是静默异常并不是一个好主意;然而,在这种情况下,如果变量有问题,我们除了忽略它外,合理地无法做任何事情。

现在,我们只需将保存的 current 值写回字段,如下所示:

 if not current:
      self.delete(0, tk.END)
    else:
      self.variable.set(current) 

如果 current 为空,我们只需删除字段的内容;否则,我们将输入的变量设置为 current

最后,我们将想要触发小部件的焦点退出验证,以查看当前值是否在新范围内可接受;我们可以通过调用我们的 trigger_focusout_validation() 方法来实现,如下所示:

 self.trigger_focusout_validation() 

_set_maximum() 方法将与这个方法相同,只是它将使用 max_var 更新 to 值。它在这里显示:

 def _set_maximum(self, *_):
    current = self.get()
    try:
      new_max = self.max_var.get()
      self.config(to=new_max)
    except (tk.TclError, ValueError):
      pass
    if not current:
      self.delete(0, tk.END)
    else:
      self.variable.set(current)
    self.trigger_focusout_validation() 

这样就完成了我们的 ValidatedSpinbox 变更。现在我们可以使用这种新功能实现植物数据中的最后一行。

首先,我们需要设置变量来存储最小和最大高度,如下所示:

 min_height_var = tk.DoubleVar(value='-infinity')
    max_height_var = tk.DoubleVar(value='infinity') 

每个变量都是一个 DoubleVar,设置为 -infinityinfinity,实际上默认没有最小值或最大值。我们的小部件直到它们实际更改(触发跟踪回调)才会受到这些变量值的影响,因此它们最初不会覆盖小部件中输入的 tofrom_ 值。

注意,这些不需要是实例变量,因为我们的小部件将存储对它们的引用。

现在,我们将创建最小高度小部件,如下所示:

 LabelInput(
      p_info, "Min Height (cm)",
      input_class=**ValidatedSpinbox**,  var=self._vars['Min Height'],
      input_args={
        "from_": 0, "to": 1000, "increment": .01,
        **"max_var"****: max_height_var,** **"focus_update_var"****: min_height_var**
      }
    ).grid(row=1, column=0) 

我们将使用 max_height_var 来更新这里的高度,并将 focus_update_var 设置为 min_height_var,以便进入最小高度小部件将更新最小高度变量。我们不想在这个字段上设置 min_var,因为它的值代表其他字段的最小值。

接下来,让我们更新最大高度小部件:

 LabelInput(
      p_info, "Max Height (cm)",
      input_class=**ValidatedSpinbox**,  var=self._vars['Max Height'],
      input_args={
        "from_": 0, "to": 1000, "increment": .01,
        **"min_var"****: min_height_var,** **"focus_update_var"****: max_height_var**
      }
    ).grid(row=1, column=1) 

这次,我们使用min_height_var变量设置部件的最小值,并将max_height_var设置为在焦点移出时更新部件的当前值。我们在这个字段上不设置max_var,因为它的值将代表最大值,不应该超出其初始限制。

最后,Median Height 字段应该更新如下:

 LabelInput(
      p_info, "Median Height (cm)",
      input_class=ValidatedSpinbox,  var=self._vars['Med Height'],
      input_args={
        "from_": 0, "to": 1000, "increment": .01,
        "min_var": min_height_var, "max_var": max_height_var
      }
    ).grid(row=1, column=2) 

在这里,我们分别从min_height_varmax_height_var变量设置字段的最低和最高值。我们不会更新 Median Height 字段的任何变量,尽管我们可以在这里添加额外的变量和代码,以确保 Min Height 不会超过它,Max Height 不会低于它。在大多数情况下,只要用户按顺序输入数据,Median Height 作为最后一个字段,这通常不会很重要。

你可能会想知道为什么我们不直接使用 Min Height 和 Max Height 的绑定变量来保存这些值。如果你尝试这样做,你会发现原因:绑定变量会在你输入时更新,这意味着你的部分值会立即成为新的最大值或最小值。我们宁愿等到用户已经确认了值再更新范围,因此我们创建了一个单独的变量,它只在焦点移出时更新。

动态禁用字段

为了在激活 EquipmentFault 复选框时禁用我们的环境数据字段,我们再次使用控制变量跟踪。然而,这一次,我们不是在部件类级别实现它,而是在我们的复合部件LabelInput中实现它。

在你的代码中定位LabelInput类,并让我们向它的__init__()方法添加一个新的关键字参数:

class LabelInput(tk.Frame):
  """A widget containing a label and input together."""
  def __init__(
    self, parent, label, var, input_class=ttk.Entry,
      input_args=None, label_args=None, **disable_var=None**,
      **kwargs
  ): 

disable_var参数将允许我们传递一个布尔控制变量,我们将监控该变量以确定我们的字段是否应该被禁用。为了使用它,我们需要将其存储在LabelInput实例中并配置一个跟踪。将以下代码添加到LabelInput.__init__()的末尾:

 if disable_var:
      self.disable_var = disable_var
      self.disable_var.trace_add('write', self._check_disable) 

跟踪链接到一个名为_check_disable()的实例方法。这个方法需要检查disable_var的值,并对LabelInput部件的输入采取适当的行动。

让我们在LabelInput类中这样实现该方法:

 def _check_disable(self, *_):
    if not hasattr(self, 'disable_var'):
      return
    if self.disable_var.get():
      self.input.configure(state=tk.DISABLED)
      self.variable.set('')
    else:
      self.input.configure(state=tk.NORMAL) 

首先,我们的方法使用hasattr来检查这个LabelInput是否有disable_var。从理论上讲,如果没有这个变量,方法甚至不应该被调用,因为没有跟踪,但为了保险起见,我们将检查并简单地返回,如果实例变量不存在。

如果我们有disable_var,我们将检查其值以确定它是否为True。如果是,我们将禁用输入小部件。要禁用输入小部件,我们需要配置其state属性。state属性决定了小部件的当前状态。在这种情况下,我们希望禁用它,因此可以将state设置为tk.DISABLED常量。这将使我们的字段变灰,使其只读。我们还希望清除禁用字段中的任何信息,以确保用户理解这些字段不会记录任何数据。因此,我们将变量设置为空字符串。

如果disable_varfalse,我们需要重新启用小部件。为此,我们只需将其状态设置为tk.NORMAL

state属性将在第九章通过样式和主题改进外观中更详细地介绍。

在编写了该方法之后,我们只需更新我们的环境数据字段,并添加一个disable_var变量。滚动到你的DataRecordForm.__init__()方法,找到我们创建这些字段的位置。我们将按如下方式更新它们:

 LabelInput(
      e_info, "Humidity (g/m³)",
      input_class=ValidatedSpinbox,  var=self._vars['Humidity'],
      input_args={"from_": 0.5, "to": 52.0, "increment": .01},
      **disable_var=self._****vars****[****'Equipment Fault'****]**
    ).grid(row=0, column=0)
    LabelInput(
      e_info, "Light (klx)", input_class=ValidatedSpinbox,
      var=self._vars['Light'],
      input_args={"from_": 0, "to": 100, "increment": .01},
      **disable_var=self._****vars****[****'Equipment Fault'****]**
    ).grid(row=0, column=1)
    LabelInput(
      e_info, "Temperature (°C)",
      input_class=ValidatedSpinbox,  **var=self._****vars****[****'Temperature'****],**
      input_args={"from_": 4, "to": 40, "increment": .01},
      **disable_var=self._****vars****[****'Equipment Fault'****]**
    ).grid(row=0, column=2) 

在每种情况下,我们都添加了disable_var参数,并将其设置为self._vars['Equipment Fault']。如果你现在运行脚本,你应该会发现勾选设备故障框将禁用并清除这三个字段,取消勾选则重新启用它们。

我们的形式现在在强制正确数据和在数据输入过程中捕获潜在错误方面做得更好,但还不是非常用户友好。让我们看看在下一节中我们能做些什么。

显示错误

如果你运行应用程序,你可能会注意到,虽然具有焦点丢失错误的字段变成了红色,但我们看不到实际的错误。这对用户体验来说有点问题,所以让我们看看我们能否解决这个问题。我们的计划是将LabelInput复合小部件更新为另一个可以显示错误字符串的Label

要实现这一点,首先定位你的LabelInput类。将以下代码添加到__init__()方法的末尾:

 self.error = getattr(self.input, 'error', tk.StringVar())
    ttk.Label(self, textvariable=self.error, **label_args).grid(
      row=2, column=0, sticky=(tk.W + tk.E)
    ) 

在这里,我们检查输入是否有错误变量,如果没有,我们创建一个。经过验证的小部件应该已经有了这样的变量,但未经过验证的小部件,如用于Notes字段的BoundText小部件,则没有,因此我们需要这个检查来确保。

接下来,我们正在创建并放置一个Label小部件,并将错误变量绑定到其textvariable参数。这将根据验证逻辑的更新来更新Label的内容。

保存应用程序,运行它,并在字段中尝试输入一些错误数据(例如,在Spinbox小部件中的一个低值)。当你聚焦到下一个字段时,你应该会在字段下方看到一个错误弹出。成功!

尽管如此,还有一个小问题需要修复。如果你在点击设备故障复选框时聚焦在环境数据字段,如湿度,那么该字段下将留下一个错误。原因是点击复选框会导致字段失去焦点,触发其验证。同时,_check_disable()方法将其值设置为无效的空字符串,验证逻辑会拒绝它。

解决方案是在我们禁用字段时清除错误字符串。在LabelInput._check_disable()方法中,更新代码如下:

 if self.disable_var.get():
      self.input.configure(state=tk.DISABLED)
      self.variable.set('')
      self.error.set('') 

再次运行应用程序,你应该会在复选框被选中时看到错误消失。

阻止错误时表单提交

阻止错误进入我们的 CSV 文件的最后一步是停止应用程序在表单有已知错误时保存记录。

记录保存发生在我们的Application对象中,因此我们需要一种方法让该对象在保存数据之前确定表单的错误状态。这意味着我们的DataRecordForm将需要一个公共方法。我们将称此方法为get_errors()

DataRecordForm类的末尾,添加以下方法:

 def get_errors(self):
    """Get a list of field errors in the form"""
    errors = {}
    for key, var in self._vars.items():
      inp = var.label_widget.input
      error = var.label_widget.error
      if hasattr(inp, 'trigger_focusout_validation'):
        inp.trigger_focusout_validation()
      if error.get():
        errors[key] = error.get()
    return errors 

我们首先定义一个空的dict对象来存储错误。我们将错误存储在字典中,格式为field: error_string,这样调用代码可以具体指定哪些字段有错误。

回想一下,我们的LabelInput类在其__init__()方法中将其自身引用附加到传入的控制变量上。现在我们可以使用这个引用来遍历我们的变量字典。对于每个变量,我们做了以下操作:

  • 我们从LabelWidget引用中检索其输入小部件和相关的error变量。

  • 如果输入定义了trigger_focusout_validation()方法,我们调用它,以确保其值已被验证。

  • 如果值无效,这将填充错误变量;所以,如果error不为空,我们将其添加到errors字典中。

  • 在我们遍历完所有字段后,我们可以返回errors字典。

现在我们有了检索表单错误的方法,我们需要在Application类的on_save()方法中利用它。定位该方法,然后在方法的开头添加以下代码:

 errors = self.recordform.get_errors()
    if errors:
      self.status.set(
        "Cannot save, error in fields: {}"
        .format(', '.join(errors.keys()))
      )
      return 

回想一下,我们的Application对象在self.recordform中存储了对表单的引用。现在我们可以通过调用其get_errors()方法来检索其错误字典。如果字典不为空,我们将通过连接所有键(即字段名称)并将它们附加到错误消息中来构造一个错误字符串。然后将其传递给status控制变量,使其在状态栏中显示。最后,我们从方法中返回,以便on_save()中的剩余逻辑不执行。

启动应用程序并尝试保存一个空白表单。你应该会在所有字段中收到错误消息,并在底部显示一条消息,告诉你哪些字段有错误,如下所示:

显示所有错误的程序。

图 5.3:显示所有错误的程序

自动化输入

防止用户输入错误数据是提高他们输出质量的一种方法;另一种方法是自动化数据输入,在这些地方值是可以预测的。利用我们对表单可能如何填写我们的理解,我们可以插入对于某些字段非常可能正确的值。

第二章设计 GUI 应用程序中记住,表单几乎总是在填写当天记录的,从 Plot 1 开始,按顺序到 Plot 20,以便每个纸质表单。

还要记住,日期、时间、实验室和技术人员值对于每个填写的表单都是相同的。这给了我们实现一些有用自动化的可能性,具体如下:

  • 当前日期可以自动插入到日期字段中

  • 如果上一个绘图不是实验室中的最后一个绘图,我们可以增加其值,同时保持时间、技术人员和实验室值不变

让我们看看我们如何为用户实现这些更改。

日期自动化

插入当前日期是一个容易开始的地方。做这件事的地方是在DataRecordForm.reset()方法中,该方法在表单初始化时以及每次记录保存时被调用,以设置表单为新记录做准备。

按照以下方式更新该方法:

 def reset(self):
    """Resets the form entries"""
    for var in self._vars.values():
      if isinstance(var, tk.BooleanVar):
        var.set(False)
      else:
        var.set('')
    current_date = datetime.today().strftime('%Y-%m-%d')
    self._vars['Date'].set(current_date)
    self._vars['Time'].label_widget.input.focus() 

在清除所有变量的值之后,我们将使用datetime.today().strftime()获取当前日期的 ISO 格式,就像我们在Application.on_save()中对日期戳所做的那样。一旦我们有了这个值,只需将其设置为Date变量即可。

作为最后的润色,我们应该更新表单的焦点到下一个需要输入的输入字段,在这种情况下,是时间字段。否则,用户将不得不手动通过已经填写好的日期字段。为此,我们通过其label_widget成员访问与Time变量关联的输入小部件,并调用小部件的focus()方法。此方法给小部件提供键盘焦点。

自动化绘图、实验室、时间和技术人员

处理绘图、实验室、时间和技术人员稍微复杂一些。我们的策略将类似于以下内容:

  • 在清除数据之前,存储绘图、实验室、时间和技术人员值。

  • 清除所有值。

  • 如果存储的绘图值小于最后一个值(20),我们将把实验室、时间和技术人员值放回字段中。我们还将增加绘图值。

  • 如果存储的绘图值最后一个值(或没有值),则保留这些字段为空。

让我们从以下方式开始添加这个逻辑到reset()方法:

 def reset(self):
    """Resets the form entries"""
    lab = self._vars['Lab'].get()
    time = self._vars['Time'].get()
    technician = self._vars['Technician'].get()
    try:
      plot = self._vars['Plot'].get()
    except tk.TclError:
      plot = ''
    plot_values = (
      self._vars['Plot'].label_widget.input.cget('values')
    ) 

reset() 方法中的任何其他操作之前,我们将获取受影响字段的值并将它们保存。注意,我们将 plot 放在一个 try/except 块中。如果绘图输入为空,它将抛出一个 TclError,因为空字符串是一个无效的整数字符串。在这种情况下,我们将绘图赋值为空字符串并继续。

我们还通过访问绘图变量的 label_widget 成员来检索可能的绘图值列表。由于我们知道每个实验室有 20 个绘图,我们在这里可以简单地硬编码一个从 1 到 20 的列表,但这种信息硬编码的方式是不好的;如果实验室中添加或删除了绘图,我们就必须遍历我们的代码以找到数字 20,并修复所有基于这个假设的地方。查询小部件本身以获取其可能的值要更好。

接下来,在清空字段并设置日期之后,让我们添加以下代码来更新字段:

 if plot not in ('', 0, plot_values[-1]):
      self._vars['Lab'].set(lab)
      self._vars['Time'].set(time)
      self._vars['Technician'].set(technician)
      next_plot_index = plot_values.index(str(plot)) + 1
      self._vars['Plot'].set(plot_values[next_plot_index])
      self._vars['Seed Sample'].label_widget.input.focus() 

这段代码检查绘图值是否为空字符串、0 或绘图值列表中的最后一个值。如果不是,我们开始填充自动化字段。首先,实验室、时间和技术人员字段被填充为我们存储的值。然后我们需要增加绘图值。

在这一点上,绘图应该是一个整数,但由于 Tkinter 隐式转换东西为字符串的习惯,最好像它不是整数一样处理它。因此,我们不是仅仅增加绘图值,而是从 plot_values 中检索其索引并增加它。然后我们可以将绘图变量的值设置为增加后的索引。

作为最后的润色,我们将表单的焦点设置到种子样本输入框,就像我们之前对时间输入框所做的那样。

我们的有效性和自动化代码已经完成,表单现在可以与我们的用户进行试验运行。与 CSV 输入相比,这确实是一个改进,并将帮助数据输入快速处理这些表单。干得好!

摘要

应用程序已经取得了很大的进步。在本章中,我们学习了 Tkinter 验证,创建了一个验证混合类,并使用它创建了EntryComboboxSpinbox小部件的验证版本。我们还学习了如何验证不支持内置验证框架的Radiobutton小部件。我们在按键和焦点事件上验证了不同类型的数据,并创建了根据相关字段值动态改变状态或更新其约束的字段。最后,我们在几个字段上自动化了输入,以减少用户所需的手动数据输入量。

在下一章中,我们将通过学习如何组织大型应用程序以简化维护来为我们的代码库的扩展做准备。更具体地说,我们将了解 MVC 模式以及如何将我们的代码结构化到多个文件中以便于维护。我们还将学习版本控制软件以及它是如何帮助我们跟踪变更的。

第六章:为我们应用程序的扩展做准备

该应用程序非常受欢迎!经过一些初步测试和定位后,数据输入人员已经使用了你新创建的表格几周了。错误和数据输入时间的减少非常显著,关于这个程序可能解决的其他问题的讨论也非常热烈。甚至总监也加入了头脑风暴,你强烈怀疑你很快就会被要求添加一些新功能。

虽然如此,但存在一个问题:应用程序已经是一个包含数百行脚本的程序,随着其增长,你对其可管理性感到担忧。你需要花些时间来组织你的代码库,为未来的扩展做准备。

在本章中,我们将学习以下主题:

  • 分离关注点中,你将了解如何使用模型-视图-控制器MVC)模式。

  • 结构化我们的应用程序目录中,你将学习如何将你的代码组织成一个 Python 包。

  • 将我们的应用程序拆分为多个文件中,你将重新组织数据输入应用程序为一个 MVC Python 包。

  • 使用版本控制软件中,你将发现如何使用 Git 版本控制系统来跟踪你的更改。

分离关注点

正确的架构设计对于任何需要扩展的项目至关重要。任何人都可以用一些支架搭建一个花园小屋,但房屋或摩天大楼则需要仔细规划和工程。软件也是如此;简单的脚本可以通过使用全局变量或直接操作类属性等捷径来逃避,但随着程序的扩展,我们的代码需要以限制在任何给定时刻需要理解复杂性的方式隔离和封装不同的功能。

我们称这个概念为关注点分离,它是通过使用描述不同应用程序组件及其交互的架构模式来实现的。

MVC 模式

在这些架构模式中,最持久的是模型-视图-控制器MVC)模式,该模式在 20 世纪 70 年代被引入。虽然这个模式在多年中已经演变并产生了各种变体,但其基本原理保持不变:保持数据、数据的展示和应用程序逻辑在独立、分离的组件中。

MVC 组件的角色和关系如图所示:

图片

图 6.1:模型、视图和控制器的角色和关系

让我们更深入地看看这些组件,并在我们当前应用程序的背景下理解它们。

什么是模型?

MVC 中的模型代表数据。这包括数据的存储,但也包括数据可以查询或操作的各种方式。理想情况下,模型不关心或不受数据展示方式(即使用什么 GUI 小部件、字段如何排序等)的影响,而是提供一个高级接口,仅对其他组件的内部工作有最小程度的关注。理论上,如果你决定完全改变程序的用户界面(例如,从 Tkinter 应用程序到 Web 应用程序),模型应该完全不受影响。

你在模型中可能会找到的功能或信息示例包括:

  • 准备和将程序数据保存到持久介质(数据文件、数据库等)

  • 从文件或数据库中检索数据到程序有用的格式

  • 一组数据字段及其数据类型和限制的权威列表

  • 验证数据与定义的数据类型和限制

  • 对存储数据进行计算

我们的应用程序中目前没有模型类;数据布局是在表单类中定义的,而Application.onsave()方法是迄今为止唯一涉及数据持久化的代码。为了在我们的应用程序中实现 MVC,我们需要将这部分逻辑拆分到一个单独的对象中,该对象将定义数据布局并处理所有 CSV 操作。

什么是视图?

视图是向用户展示数据和控件的一个接口。应用程序可能有多个视图,通常基于相同的数据。视图可能或可能没有直接访问模型;如果它们有,通常只有只读访问权限,将写请求通过控制器发送。

你在视图中可能会找到的代码示例包括:

  • GUI 布局和小部件定义

  • 表单自动化,例如字段自动完成、动态切换小部件或显示错误对话框

  • 格式化原始数据以供展示

我们的DataRecordForm类是一个视图的例子:它包含了我们应用程序用户界面的大部分代码。它还包含_vars字典,该字典目前定义了我们的数据记录结构。这个字典可以留在视图中,因为视图确实需要一种方式在将其传递给模型之前临时存储数据,但_vars不应该再定义我们的数据记录——这是模型的工作。为了实现 MVC,我们需要使视图中的数据概念依赖于模型,而不是它自己的定义。

什么是控制器?

控制器是应用程序的“中央车站”。它处理来自用户的请求,并负责在视图和模型之间路由数据。MVC 的多数变体都会改变控制器的角色(有时还会改变名称),但重要的是它作为视图和模型之间的中介。我们的控制器对象需要持有应用程序使用的视图和模型的引用,并负责管理它们之间的交互。

你在控制器中找到的代码示例包括:

  • 应用程序的启动和关闭逻辑

  • 用户界面事件的回调

  • 模型和视图实例的创建

我们的Application对象目前正作为我们应用程序的控制器,尽管它里面也有一些视图和模型逻辑。不幸的是,Tkinter 中的Tk对象结合了控制中心点和root窗口,因此不可能完全将控制器与应用程序的主要视图分开。因此,我们的Application对象将包含两者的部分,但为了实现更 MVC 的设计,我们需要将其部分展示逻辑移动到视图,部分数据逻辑移动到模型。理想情况下,我们希望Application对象主要专注于连接模型和视图之间的代码。

为什么要使设计复杂化?

初始时,这种将应用程序拆分的方式可能看起来有很多不必要的开销。我们将在不同的对象之间传递数据,并最终编写更多的代码来完成完全相同的事情。我们为什么要这样做呢?

简而言之,我们这样做是为了使扩展变得可管理。随着应用程序的增长,复杂性也会增长。将我们的组件相互隔离限制了任何单个组件需要管理的复杂性;例如,如果我们想重新设计数据记录表单的布局,我们不应该担心这样做是否会改变输出文件中的数据结构。

程序的这两个方面应该相互独立。

它还帮助我们保持一致,关于我们将某些类型的逻辑放在哪里。例如,拥有一个离散的模型对象有助于我们避免在 UI 代码中随意添加数据查询或文件访问尝试。

事实上,如果没有一些指导性的架构策略,我们的程序可能会变成一团糟的意大利面逻辑。即使不严格遵循 MVC 设计的定义,一致地遵循即使是松散的 MVC 模式,当应用程序变得更加复杂时,也会节省很多麻烦。

结构化我们的应用程序目录

正如将我们的程序分解成单独的关注点有助于我们管理每个组件的逻辑复杂性一样,将代码物理地分解成多个文件有助于我们保持每个文件的复杂性可管理。这也加强了组件之间的隔离;例如,你无法在文件之间共享全局变量,而且你知道如果你的 models.py 文件导入了 tkinter,你正在做错事。

基本目录结构

对于 Python 应用程序的目录布局没有官方标准,但有一些常见的约定可以帮助我们保持整洁,并在以后更容易打包我们的软件。让我们设置我们的目录结构。

首先,创建一个名为 ABQ_Data_Entry 的目录。这是我们的应用程序的根目录,因此每当提到应用程序根目录时,指的就是这个目录。

在应用程序根目录下创建另一个名为 abq_data_entry 的目录。注意它是小写的。这将是一个包含应用程序所有代码的 Python 包;它应该有一个相当独特且描述性的名称,以免与现有的 Python 包混淆。通常,你不会在应用程序根目录和这个主要模块之间使用不同的大小写,但这也没有什么坏处;我们在这里这样做是为了避免混淆。

Python 包和模块应该始终使用全部小写字母和下划线来分隔单词。这个约定在 PEP 8 中有详细说明,这是 Python 的官方风格指南。有关 PEP 8 的更多信息,请参阅 www.python.org/dev/peps/pep-0008

接下来,在应用程序根目录下创建一个 docs 文件夹。这个文件夹将用于存放有关应用程序的文档文件。

最后,在应用程序根目录中创建两个空文件:README.rstabq_data_entry.py。你的目录结构应该如下所示:

图 6.2:应用程序根目录的目录结构

现在,让我们将这些代码放入这些文件中。

abq_data_entry.py 文件

abq_data_entry.py 文件将是启动程序时执行的主要文件。然而,它不会包含我们程序的大部分内容。实际上,它将只包含以下代码:

from abq_data_entry.application import Application
app = Application()
app.mainloop() 

将该代码添加到文件中并保存。这个文件的唯一目的是导入我们的 Application 类,创建其实例并运行它。其余的工作将在 abq_data_entry 包内部完成。我们还没有创建这个包,所以这个文件现在还不能运行。在我们处理应用程序包之前,让我们先处理我们的文档。

README.rst 文件

从 20 世纪 70 年代开始,程序中就包含了一个简短的文本文件,称为 README,其中包含程序文档的简明摘要。对于小型程序,它可能是唯一的文档;对于大型程序,它通常包含用户或管理员在飞行前必须执行的必要指令。

README文件没有规定的内容,但作为一个基本指南,请考虑以下部分:

  • 描述:对程序及其功能的简要描述。我们可以重用我们的规范中的描述,或者类似的内容。这还可能包含主要功能的简要列表。

  • 作者信息:作者的名字和版权日期。如果您计划共享您的软件,这尤为重要,但对于内部使用的软件,对于未来的维护者来说,了解谁创建了软件以及何时创建也是有用的。

  • 要求:如果有的话,列出软件和硬件的要求。

  • 安装:安装软件的说明、其先决条件、依赖项和基本设置。

  • 配置:如何配置应用程序以及可用的选项。这通常针对命令行或配置文件选项,而不是在程序中交互式设置的选项。

  • 用法:如何启动应用程序、命令行参数以及其他用户需要了解的基本功能的相关说明。

  • 一般注意事项:用于收集用户应了解的笔记或关键信息的通用部分。

  • 错误:已知错误或应用程序的限制列表。

并非所有这些部分都适用于每个程序;例如,ABQ 数据输入目前没有任何配置选项,因此没有必要有配置部分。根据情况,您还可以添加其他部分;例如,公开分发的软件可能有常见问题解答部分,或开源软件可能有贡献部分,其中包含提交补丁的说明。

README文件使用纯 ASCII 或 Unicode 文本编写,可以是自由格式或使用标记语言。由于我们正在进行 Python 项目,我们将使用reStructuredText,这是 Python 官方文档的标记语言(这也是为什么我们的文件使用rst文件扩展名)。

关于 reStructuredText 的更多信息,请参阅附录 AreStructuredText 快速入门

GitHub 仓库中的示例代码中包含了一个README.rst文件的样本。花点时间看看它;然后,我们可以继续到docs文件夹。

填充文档文件夹

docs文件夹是放置文档的地方。这可以是任何类型的文档:用户手册、程序规范、API 参考、图表等等。

现在,我们只需复制这些内容:

  • 我们在前几章中编写的程序规范

  • 您的界面原型

  • 技术人员使用的表格副本

在某个时候,您可能需要编写用户手册,但到目前为止,程序足够简单,不需要它。

制作 Python 包

创建自己的 Python 包出奇地简单。一个 Python 包由以下三个部分组成:

  • 一个目录

  • 目录中的__init__.py文件

  • 可选地,该目录中的一个或多个 Python 文件

一旦完成这些操作,你就可以像导入标准库包一样,整体或部分导入你的包,前提是你的脚本与包目录位于同一父目录下。

注意,模块中的__init__.py文件在某种程度上类似于类的初始化方法。其中的代码会在包被导入时运行,并且任何创建或导入到其中的名称都直接在包命名空间下可用。尽管没有实际需要代码,Python 社区通常不鼓励在这个文件中放置太多代码;因此,我们将保持这个文件为空。

让我们开始构建应用程序的包。在abq_data_entry下创建以下六个空文件:

  • __init__.py

  • widgets.py

  • views.py

  • models.py

  • application.py

  • constants.py

这些 Python 文件每个都被称为模块。模块不过是一个包目录内的 Python 文件。现在你的目录结构应该看起来像这样:

图片

图 6.3:更新后的目录结构,包括包目录

到目前为止,你有一个正在工作的包,尽管里面没有实际的代码。为了测试这一点,打开一个终端或命令行窗口,切换到你的ABQ_Data_Entry目录,并启动一个 Python shell。

现在,输入以下命令:

from abq_data_entry import application 

这应该会无错误地执行。当然,它什么也不做,但我们会继续下去。

不要将这里的术语与实际的可分发 Python 包混淆,例如使用pip下载的包。我们将在第十六章使用 setuptools 和 cxFreeze 打包中学习如何制作可分发 Python 包。在这个上下文中,包只是 Python 模块的集合。

将我们的应用程序拆分为多个文件

现在我们已经整理好了目录结构,我们需要开始分解我们的应用程序脚本,并将其拆分到我们的模块文件中。我们还需要创建我们的模型类。

打开你的data_entry_app.py文件,来自第五章通过验证和自动化减少用户错误,然后我们开始吧!

创建模型模块

当你的应用程序全部关于数据时,从模型开始是个好主意。记住,模型的工作是管理应用程序数据的存储、检索和处理,通常与它的持久化存储格式(在本例中是 CSV)相关。为了完成这个任务,我们的模型应该包含关于我们数据的所有知识。

目前,我们的应用程序没有类似模型的东西;关于应用程序数据的知识散布在表单字段中,而Application对象在请求保存操作时,只是简单地将表单包含的数据直接填充到 CSV 文件中。由于我们还没有检索或更新信息,我们的应用程序实际上对 CSV 文件中的内容一无所知。

要将我们的应用程序迁移到 MVC 架构,我们需要创建一个模型类,该类既管理数据存储和检索,又代表我们数据的知识权威来源。换句话说,我们必须在这里我们的模型中编码数据字典中包含的知识。我们目前还不知道我们将如何使用这些知识,但这就是它应该属于的地方。

存储这些数据有几种方式,例如创建一个自定义字段类或一个namedtuple对象,但为了简单起见,我们现在将只使用字典,将字段名称映射到字段元数据。

字段元数据将同样以关于字段的属性字典的形式存储,它将包括:

  • 字段中存储的数据类型

  • 字段是否必需

  • 如果适用,可能的值列表

  • 如果适用,值的范围、最小值、最大值和增量

为了存储每个字段的类型,我们将定义一组常量,这将让我们以一致和明确的方式引用不同的字段类型。我们将把这个放在constants.py文件中,所以请在您的编辑器中打开该文件并添加以下代码:

# abq_data_entry/constants.py
from enum import Enum, auto
class FieldTypes(Enum):
  string = auto()
  string_list = auto()
  short_string_list = auto()
  iso_date_string = auto()
  long_string = auto()
  decimal = auto()
  integer = auto()
  boolean = auto() 

我们创建了一个名为FieldTypes的类,它只是存储了一些命名整数值,这些值将描述我们将要存储的不同类型的数据。这个类基于 Python 的Enum类,这是一个用于定义此类常量集合的有用类。这些变量的值并不重要,只要每个都是唯一的;在Enum中,我们真正感兴趣的是拥有一组不等于彼此的变量名。

我们可以手动将它们设置为字符串或顺序整数,但enum模块提供了一个auto()函数,它可以自动为类的每个常量分配一个唯一的整数值。使用这种方法更好地传达了值本身并不重要;只有名称才是关键。

现在我们有了这些常量,让我们打开models.py并开始创建我们的模型类:

# abq_data_entry/models.py, at the top
import csv
from pathlib import Path
from datetime import datetime
import os
from .constants import FieldTypes as FT
class CSVModel:
  """CSV file storage""" 

我们首先导入我们模型所需的库:csvpathlibdatetimeos以及我们的新FieldTypes常量。前三个是我们为Application中的on_save()方法所需的库。现在,模型类将处理大部分这些功能。os模块将用于检查文件权限,而FieldTypes常量将用于定义我们模型的数据字典。

注意我们导入FieldTypes的方式:from .constants import FieldTypesconstants前面的点表示这是一个相对导入。相对导入可以在 Python 包内部使用,以定位同一包中的其他模块。在这种情况下,我们处于models模块中,我们需要访问abq_data_entry包内的constants模块。单个点代表我们的当前父模块(abq_data_entry),因此在这个文件中的.constants意味着abq_data_entry包的constants模块。

相对导入区分了我们的自定义模块和 PYTHONPATH 中的模块。通过使用它们,我们不必担心任何第三方或标准库包与我们的模块名称冲突。

接下来,我们需要创建一个包含模型中所有字段字典的类成员变量。字典中的每个条目都将包含有关字段的详细信息:其数据类型、是否必需以及有效值、范围和增量。

除了字段属性外,我们还在这里记录了 CSV 字段的顺序。在 Python 3.6 及以后的版本中,字典保留了它们被定义的顺序;如果你使用的是 Python 3 的较旧版本,你需要使用 collections 标准库模块中的 OrderedDict 类来保留字段顺序。

按照这种方式添加这个字典:

 fields = {
    "Date": {'req': True, 'type': FT.iso_date_string},
    "Time": {'req': True, 'type': FT.string_list,
      'values': ['8:00', '12:00', '16:00', '20:00']},
    "Technician": {'req': True, 'type':  FT.string},
    "Lab": {'req': True, 'type': FT.short_string_list,
      'values': ['A', 'B', 'C']},
    "Plot": {'req': True, 'type': FT.string_list,
      'values': [str(x) for x in range(1, 21)]},
    "Seed Sample":  {'req': True, 'type': FT.string},
    "Humidity": {'req': True, 'type': FT.decimal,
      'min': 0.5, 'max': 52.0, 'inc': .01},
    "Light": {'req': True, 'type': FT.decimal,
      'min': 0, 'max': 100.0, 'inc': .01},
    "Temperature": {'req': True, 'type': FT.decimal,
      'min': 4, 'max': 40, 'inc': .01},
    "Equipment Fault": {'req': False, 'type': FT.boolean},
    "Plants": {'req': True, 'type': FT.integer,       'min': 0, 'max': 20},
    "Blossoms": {
      'req': True, 'type': FT.integer, 'min': 0, 'max': 1000},
    "Fruit": {'req': True, 'type': FT.integer, 
      'min': 0, 'max': 1000},
    "Min Height": {'req': True, 'type': FT.decimal,
      'min': 0, 'max': 1000, 'inc': .01},
    "Max Height": {'req': True, 'type': FT.decimal,
      'min': 0, 'max': 1000, 'inc': .01},
    "Med Height": {'req': True, 'type': FT.decimal,
      'min': 0, 'max': 1000, 'inc': .01},
    "Notes": {'req': False, 'type': FT.long_string}
  } 

这个列表直接来自我们的数据字典,我们已经在 DataRecordForm 类中看到了这些相同的值;但从现在开始,这个字典将成为这些信息的权威来源。任何需要关于模型字段信息的其他类都必须从该字典中检索。

在我们开始设计模型类的函数之前,让我们花一点时间看看我们应用程序中现有的文件保存逻辑,并考虑哪些部分属于模型。我们当前脚本中的代码看起来是这样的:

 def _on_save(self):
    errors = self.recordform.get_errors()
    if errors:
      self.status.set(
        "Cannot save, error in fields: {}"
        .format(', '.join(errors.keys()))
      )
      return
    datestring = datetime.today().strftime("%Y-%m-%d")
    filename = f"abq_data_record_{datestring}.csv"
    newfile = not Path(filename).exists()
    data = self.recordform.get()
    with open(filename, 'a') as fh:
      csvwriter = csv.DictWriter(fh, fieldnames=data.keys())
      if newfile:
        csvwriter.writeheader()
      csvwriter.writerow(data)
    self._records_saved += 1
    self.status.set(
      f"{self._records_saved} records saved this session"
    )
    self.recordform.reset() 

让我们审查这段代码,确定哪些内容放入模型,哪些保留在 Application 类中:

  • 第一段代码从 DataRecordForm 类中提取错误。由于模型将没有关于表单的了解,这部分应该保留在 Application 中。实际上,模型甚至不需要知道表单错误,因为唯一采取的操作是 UI 相关的(即显示错误)。

  • 接下来的几行定义了我们将要使用的文件名。由于这是文件存储的细节,这显然是模型关心的问题。

  • newfile 赋值行确定文件是否存在。作为数据存储介质的实现细节,这显然是模型的问题,而不是应用程序的问题。

  • 这一行 data = self.recordform.get() 从表单中获取数据。由于我们的模型对表单的存在没有了解,这部分需要保留在 Application 中。

  • 下一段代码打开文件,创建一个 csv.DictWriter 对象,并追加数据。这显然是模型关心的问题。

  • 最后一段代码将文件保存操作的结果通知用户并重置表单。这完全是用户界面相关的,所以它不属于模型。

因此,我们的模型将需要确定文件名并负责将 Application 对象接收到的数据写入其中,而应用程序将负责检查表单错误、从表单中检索数据,并将保存操作的结果通知用户。

让我们为我们的模型类创建初始化器方法。由于CSVModel代表对特定 CSV 文件的接口,我们将在__init__()中确定文件名,并在模型对象的生命周期内保持它。该方法开始如下:

# models.py, in the CSVModel class
  def __init__(self):
    datestring = datetime.today().strftime("%Y-%m-%d")
    filename = "abq_data_record_{}.csv".format(datestring)
    self.file = Path(filename) 

__init__()方法首先确定filename,从当前日期转换成Path对象,并将其存储为实例变量。

由于模型实例与文件名相关联,并代表我们对该文件的访问,如果我们没有权限向文件追加数据,那么这个模型将相对无用。因此,我们希望在开始将数据输入到我们的表单之前,初始化器会检查对文件的访问权限,并在发现任何问题时提醒我们。

要做到这一点,我们需要使用os.access()函数,如下所示:

# models.py, in CSVModel.__init__()
    file_exists = os.access(self.file, os.F_OK)
    parent_writeable = os.access(self.file.parent, os.W_OK)
    file_writeable = os.access(self.file, os.W_OK)
    if (
      (not file_exists and not parent_writeable) or
      (file_exists and not file_writeable)
    ):
      msg = f'Permission denied accessing file: {filename}'
      raise PermissionError(msg) 

os.access()函数接受两个参数:一个文件路径字符串或Path对象,以及一个表示我们想要检查的模式的常量。我们将使用两个常量:os.F_OK,它检查文件是否存在,以及os.W_OK,它检查我们是否有对该文件的写入权限。请注意,如果文件不存在(如果没有保存任何数据,这是完全可能的),检查W_OK将返回False,因此我们需要检查两种可能的情况:

  • 文件存在,但我们无法写入它

  • 文件不存在,我们无法写入其父目录

在这两种情况下,我们将无法写入文件,应该抛出异常。你可能会想知道为什么我们要抛出异常而不是显示某种错误信息(例如在状态栏中或通过控制台打印)。记住,模型类不应该对 UI 有任何假设,也不应包含任何 UI 代码。在模型中处理错误情况的方法是使用异常将消息传递回控制器,以便控制器可以采取适合我们用户界面的适当操作。

故意抛出异常的想法对初学者来说通常很奇怪;毕竟,异常是我们试图避免的东西,对吧?在小型脚本中,我们实际上是现有模块的消费者时,这是正确的;然而,当你编写自己的模块时,异常是模块通过其类和函数与使用它们的代码通信问题的正确方式。试图处理——或者更糟,压制——外部代码的不良行为,最多会破坏我们代码的模块化;最坏的情况是,它将创建难以追踪的微妙错误。

现在我们已经初始化了具有可写文件名的模型,我们需要创建一个方法来保存数据。在CSVModel类中,让我们创建一个公共方法来存储数据。为save_record()方法添加以下代码:

# models.py, in the CSVModel class
  def save_record(self, data):
    """Save a dict of data to the CSV file"""
    newfile = not self.file.exists()
    with open(self.file, 'a', newline='') as fh:
      csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys())
      if newfile:
        csvwriter.writeheader()
      csvwriter.writerow(data) 

由于模型不需要知道表单错误,并且在其初始化器中已经建立了文件名,因此这个方法只需要一个表单数据的字典作为参数。剩下的是确定我们是否处理的是新文件,并将数据写入 CSV。

注意,当将字段名称写入新的 CSV 文件时,我们使用fields字典的键,而不是依赖于传入数据中的键。请记住,CSVModel.fields现在是关于应用程序数据信息的权威来源,因此它应该确定使用的标题。

我们的模式类现在已经完成。让我们开始着手用户界面!

移动小部件

虽然我们可以将所有与 UI 相关的代码放在一个views模块中,但我们有很多自定义小部件类。将它们放在自己的单独模块中,以限制views模块的复杂性是有意义的。因此,我们将所有小部件类的代码移动到widgets.py文件中。我们将移动的小部件类包括所有实现可重用 GUI 组件的类,包括复合小部件如LabelInput。如果我们开发更多的自定义小部件,我们也将它们添加到这个文件中。

打开widgets.py,并将ValidatedMixinDateEntryRequiredEntryValidatedComboboxValidatedSpinboxValidatedRadioGroupBoundTextLabelInput的所有代码复制进去。这些都是我们迄今为止创建的所有小部件类。

widgets.py文件当然需要导入被复制进来的代码所使用的任何模块依赖项。我们需要检查我们的代码,找出我们使用的库并将它们导入。请在文件的顶部添加以下内容:

# top of widgets.py
import tkinter as tk
from tkinter import ttk
from datetime import datetime
from decimal import Decimal, InvalidOperation 

显然,我们需要tkinterttk;我们的DateEntry类使用datetime库中的datetime类,而我们的ValidatedSpinbox类则使用decimal库中的Decimal类和InvalidOperation异常。这就是widgets.py中所需的所有内容。

移动视图

接下来,我们将处理views.py文件。回想一下,视图是更大的 GUI 组件,就像我们的DataRecordForm类。实际上,目前它是我们唯一的视图,但随着我们创建更多的大型 GUI 组件,它们将被添加到这里。

打开views.py文件,并将DataRecordForm类复制进去;然后,回到顶部处理模块导入。再次强调,我们需要tkinterttk,以及datetime,因为我们的自动填充逻辑需要它们。

按照以下方式将它们添加到文件顶部:

# abq_data_entry/views.py, at the top
import tkinter as tk
from tkinter import ttk
from datetime import datetime 

尽管如此,我们还没有完成;我们的实际小部件不再在这里,所以我们需要导入它们,如下所示:

from . import widgets as w 

就像我们在models.py文件中的FieldTypes一样,我们使用相对导入导入了我们的widgets模块。我们保持小部件在其自己的命名空间中,以保持全局命名空间干净,但给它一个简短的别名w,这样我们的代码就不会过于杂乱。

这意味着,尽管如此,我们还需要遍历代码,并在所有 LabelInputRequiredEntryDateEntryValidatedComboboxValidatedRadioGroupBoundTextValidatedSpinbox 实例前添加 w.。这应该足够容易在 IDLE 或任何其他文本编辑器中通过一系列搜索和替换操作来完成。

例如,表单的第一行应如下所示:

 **w.**LabelInput(
      r_info, "Date", var=self._vars['Date'],
      input_class=w.DateEntry
    ).grid(row=0, column=0)
    **w.**LabelInput(
      r_info, "Time", input_class=w.ValidatedCombobox,
      var=self._vars['Time'],
      input_args={"values": ["8:00", "12:00", "16:00", "20:00"]}
    ).grid(row=0, column=1)
    **w.**LabelInput(
      r_info, "Technician",  var=self._vars['Technician'],
      input_class=w.RequiredEntry
    ).grid(row=0, column=2) 

在你开始更改所有这些内容之前,让我们停下来,花点时间重构一些代码中的冗余。

减少视图逻辑中的冗余

考虑我们传递给 LabelInput 小部件的参数:它们包含许多也存在于我们模型中的信息。最小值、最大值、增量以及可能的值在这里和我们的模型代码中都被定义。甚至我们选择的输入小部件的类型也与存储的数据类型直接相关:数字得到一个 ValidatedSpinbox 小部件,日期得到一个 DateEntry 小部件,依此类推。理想情况下,关于每个字段的信息来源应该只定义在一个地方,并且那个地方应该是模型。如果我们需要出于某种原因更新模型,我们的表单应该与这些更改同步。

而不是在视图中重复定义这些选项,我们需要让我们的视图能够访问来自我们模型的字段规范,以便小部件的详细信息可以从它中确定。由于我们的小部件实例是在 LabelInput 类内部定义的,我们将增强该类,使其能够自动从我们模型的字段规范格式中确定输入类和参数。

要做到这一点,打开 widgets.py 文件。我们将首先导入 FieldTypes 类,如下所示:

# at the top of widgets.py
from .constants import FieldTypes as FT 

接下来,我们需要告诉 LabelInput 类如何将字段类型转换为控件类。为此,定位 LabelInput 类,并在 __init__() 方法之上添加以下 field_types 类属性:

# widgets.py, inside LabelInput
  field_types = {
    FT.string: RequiredEntry,
    FT.string_list: ValidatedCombobox,
    FT.short_string_list: ValidatedRadioGroup,
    FT.iso_date_string: DateEntry,
    FT.long_string: BoundText,
    FT.decimal: ValidatedSpinbox,
    FT.integer: ValidatedSpinbox,
    FT.boolean: ttk.Checkbutton
  } 

这个字典将作为键将我们的模型字段类型转换为适当的控件类型。

注意,所有这些控件在我们可以创建这个字典之前都必须存在,所以请确保如果尚未放置在 widgets.py 的末尾,将 LabelInput 类定义放在 末尾

现在,我们需要更新 LabelInput.__init__() 方法以接受一个 field_spec 参数,并在提供的情况下使用它来定义输入小部件的参数。首先,初始化器的参数列表应更新如下:

# widgets.py, inside LabelInput
  def __init__(
    self, parent, label, var, input_class=None,
    input_args=None, label_args=None, **field_spec=None**,
    disable_var=None, **kwargs
  ): 

虽然 field_spec 将在很大程度上消除对 input_classinput_args 参数的需求,但我们仍将保留它们,以防我们稍后需要构建一个与模型无关的表单。

在初始化方法内部,我们需要读取字段规范并应用这些信息。在变量设置之后和标签设置之前添加以下代码:

# widgets.py, inside LabelInput.__init__():
    if field_spec:
      field_type = field_spec.get('type', FT.string)
      input_class = input_class or self.field_types.get(field_type)
      if 'min' in field_spec and 'from_' not in input_args:
        input_args['from_'] = field_spec.get('min')
      if 'max' in field_spec and 'to' not in input_args:
        input_args['to'] = field_spec.get('max')
      if 'inc' in field_spec and 'increment' not in input_args:
        input_args['increment'] = field_spec.get('inc')
      if 'values' in field_spec and 'values' not in input_args:
        input_args['values'] = field_spec.get('values') 

如果提供了field_spec,我们首先会做的是检索字段类型。这将用于使用field_types字典查找适当的控件。如果我们想为特定的LabelInput实例覆盖此查找值,可以显式传递一个input_class参数来覆盖查找值。

接下来,我们需要设置字段参数,minmaxincvalues。对于这些中的每一个,我们检查键是否存在于字段规范中,并确保相应的from_toincrementvalues参数没有使用input_args显式传递。如果是这样,我们将使用适当的值设置input_args。现在,input_classinput_args已经从字段规范中确定,初始化方法剩余部分可以继续按照之前定义的方式进行。

LabelInput重构为接受一个field_spec参数后,我们可以更新我们的视图代码以利用这个新功能。为此,我们的DataRecordForm类首先需要访问model对象,从而可以获取数据模型的字段规范。

views.py文件中,编辑DataRecordForm的初始化方法,以便我们可以传递model的一个副本:

# views.py, in DataRecordForm class
  def __init__(self, parent, model, *args, **kwargs):
    super().__init__(parent, *args, **kwargs)
    self.model= model
    fields = self.model.fields 

我们已经将model本身存储在一个实例变量中,并将fields字典提取到一个局部变量中,以减少我们使用此字典时的代码冗余。现在,我们可以遍历我们的LabelInput调用,并用单个field_spec参数替换input_argsinput_class参数。

这些更改后,第一行看起来是这样的:

# views.py, in DataRecordForm.__init__()
    w.LabelInput(
      r_info, "Date",
      field_spec=fields['Date'],
      var=self._vars['Date'],
    ).grid(row=0, column=0)
    w.LabelInput(
      r_info, "Time",
      field_spec=fields['Time'],
      var=self._vars['Time'],
    ).grid(row=0, column=1)
    w.LabelInput(
      r_info, "Technician",
      field_spec=fields['Technician'],
      var=self._vars['Technician']
    ).grid(row=0, column=2) 

按照同样的方式继续更新其他小部件,用field_spec参数替换input_classinput_args。注意,当你到达高度字段时,你仍然需要传递一个input_args字典来定义min_varmax_varfocus_update_var参数。

例如,以下是最小高度输入定义:

 w.LabelInput(
      p_info, "Min Height (cm)",
      field_spec=fields['Min Height'],
      var=self._vars['Min Height'],
      input_args={
        "max_var": max_height_var,
        "focus_update_var": min_height_var
      }) 

就这样。现在,任何对给定字段规范的更改都可以仅在模型中进行,表单将简单地做正确的事情。

使用自定义事件来移除紧密耦合

在离开DataRecordForm类之前,我们应该进行一个修改,以改善我们应用程序的关注点分离。目前,我们表单上的savebutton小部件绑定到self.master._on_save(),这指的是Application类的_on_save()方法。然而,我们绑定此命令的方式假设self.master(即DataRecordForm的父小部件)是Application。如果我们决定将我们的DataRecordForm小部件放入NotebookFrame小部件中,而不是直接在Application对象下,会发生什么?在这种情况下,self.master将改变,代码将出错。由于父小部件实际上是布局问题,我们不会期望对其的更改会影响保存按钮回调。

这种情况,即一个类过度依赖于类外部的应用程序架构,被称为紧耦合,这是我们应在代码中努力避免的。相反,我们希望在代码中实现松耦合,以便一个类的更改不会在另一个类中引起意外的错误。

我们可以解决这个问题的几种方法。我们可以将回调或Application类的引用传递给视图,以便它可以更明确地引用相关的方法。这将有效,但仍然比我们理想中想要的耦合更紧密。

一个更好的方法是利用事件。正如你所知,Tkinter 在用户以某种方式与 GUI 交互时生成事件,例如点击按钮或按键。这些事件可以使用任何 Tkinter 小部件的bind()方法显式绑定到回调函数。Tkinter 还允许我们生成自己的自定义事件,我们可以像绑定内置事件一样绑定它们。

让我们在DataRecordForm中实现一个回调方法,该方法将生成一个自定义事件,如下所示:

 def _on_save(self):
    self.event_generate('<<SaveRecord>>') 

可以在任何 Tkinter 小部件上调用event_generate()方法,以使其发出指定的事件。在这种情况下,我们调用我们的事件<<SaveRecord>>。所有自定义事件序列都必须使用双尖括号来区分它们与内置事件类型。除此之外,你可以随意命名它们。

DataRecordForm.__init__()方法中,我们将更新我们的保存按钮定义,使用此方法作为回调,如下所示:

# views.py, in DataRecordForm.__init__()
    self.savebutton = ttk.Button(
      buttons, text="Save", command=self._on_save) 

现在,我们不再直接执行Application对象的_on_save()方法,按钮将简单地使DataRecordForm发出一个消息,表明用户请求了记录保存操作。处理这个消息将是Application对象的责任。

在构建我们的应用程序菜单时,我们将在第七章使用菜单和 Tkinter 对话框创建菜单中更广泛地使用自定义事件。

创建应用程序文件

我们需要创建的最后一块是控制器和根窗口类,Application。打开application.py文件,并将Application类定义从旧的data_entry_app.py文件中复制过来。

和之前一样,我们需要添加此代码所需的模块导入。在文件顶部添加以下内容:

# abq_data_entry/application.py, at the top
import tkinter as tk
from tkinter import ttk
from . import views as v
from . import models as m 

再次,我们需要tkinterttk,当然;我们还需要views模块来使用我们的DataRecordForm,以及models模块来使用我们的CSVModel

现在,我们需要对Application.__init__()方法进行一些修改。首先,我们需要创建一个模型实例,我们可以将其传递给DataRecordForm并保存我们的数据。在初始化方法顶部创建此对象:

# application.py, inside the Application class
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self.model = m.CSVModel() 

接下来,我们需要更新对DataRecordForm的调用,既要添加命名空间,又要确保我们传递了模型实例,如下所示:

# application.py, inside Application.__init__()
    self.recordform = v.DataRecordForm(self, self.model) 

我们还需要将我们的自定义事件<<SaveRecord>>绑定到Application对象的记录保存回调。添加bind命令如下所示:

# application.py, inside Application.__init__()
    self.recordform = v.DataRecordForm(self, self.model)
    self.recordform.grid(row=1, padx=10, sticky=(tk.W + tk.E))
    self.recordform.bind('<<SaveRecord>>', self._on_save) 

最后,我们需要更新Application._on_save()中的代码以使用模型。新的方法应该看起来像这样:

 def _on_save(self, *_):
    """Handles file-save requests"""
    errors = self.recordform.get_errors()
    if errors:
      self.status.set(
        "Cannot save, error in fields: {}"
        .format(', '.join(errors.keys()))
      )
      return 
    data = self.recordform.get()
    self.model.save_record(data)
    self._records_saved += 1
    self.status.set(
      f"{self._records_saved} records saved this session"
    )
    self.recordform.reset() 

如你所见,使用我们的模型非常无缝;一旦我们检查了错误并从表单中检索了数据,我们只需将其传递给self.model.save_record()Application不需要了解数据是如何保存的细节。

注意,我们在方法定义中添加了一个*_参数。当我们使用bind将事件绑定到回调时,回调将接收到一个event对象。我们不会使用这个event参数,所以按照 Python 的惯例,我们将所有位置参数合并到一个名为_(下划线)的变量中。这样,我们的回调可以处理接收参数,但我们已经表明我们不会使用它们。

运行应用程序

应用程序现在已完全迁移到新的数据格式。要测试它,导航到应用程序根文件夹ABQ_Data_Entry,并执行以下命令:

$ python3 abq_data_entry.py 

它应该看起来和表现得就像第五章中的单个脚本一样,《通过验证和自动化减少用户错误》,并且运行时没有错误,如下面的截图所示:

应用程序,看起来依然很好!

图 6.4:ABQ 数据录入应用程序 – 在 MVC 重构后依然保持相同的外观!

成功!

使用版本控制软件

我们的代码结构良好,便于扩展,但还有一个关键问题需要解决:版本控制。你可能已经熟悉了版本控制系统VCS),有时也称为修订控制源代码管理,但如果还不熟悉,它是一个处理大型且不断变化的代码库不可或缺的工具。

当我们在应用程序上工作时,有时我们认为自己知道需要更改什么,但结果证明我们是错的。有时,我们不知道如何编写代码,需要多次尝试才能找到正确的方法。有时,我们需要恢复到很久以前更改过的代码。有时,有多个人在相同的代码片段上工作,我们需要合并他们的更改。版本控制系统正是为了解决这些问题和其他问题而创建的。

有数十种不同的版本控制系统,但它们大多数遵循本质上相同的流程:

  • 你有一个工作副本的代码,你可以对其进行更改

  • 你定期选择更改并将它们提交到一个主副本

  • 你可以在任何时刻检出(即检索到你的工作副本)代码的旧版本,然后稍后恢复到主副本

  • 你可以创建代码的分支来尝试不同的方法、新功能或大规模重构

  • 你可以稍后合并这些分支回主副本

版本控制系统(VCS)为你提供了一个安全网,让你在更改代码时无需担心会彻底破坏它:只需几个简单的命令就可以恢复到已知的工作状态。它还帮助我们记录代码的更改,并在有机会时与他人协作。

现在有数十种版本控制系统可用,但到目前为止,Git 仍然是许多年来最受欢迎的版本控制系统。让我们看看如何使用 Git 来跟踪应用程序的更改。

Git 使用快速指南

Git 是由 Linus Torvalds 创建的,旨在成为 Linux 内核项目的版本控制系统,并从此发展成为世界上最受欢迎的版本控制系统。它被源代码共享网站如 GitHub、Bitbucket、SourceForge 和 GitLab 所使用。Git 非常强大,掌握它可能需要数月或数年;幸运的是,基础可以在几分钟内掌握。

首先,你需要安装 Git;访问 git-scm.com/downloads 获取在 macOS、Windows、Linux 或其他 Unix 操作系统上安装 Git 的说明。

初始化和配置 Git 仓库

Git 安装完成后,我们需要初始化和配置我们的项目目录作为 Git 仓库。为此,打开一个命令终端,导航到应用程序的根目录(ABQ_Data_Entry),然后运行以下命令:

$ git init 

此命令会在我们的项目根目录下创建一个名为 .git 的隐藏目录,并用构成仓库的基本文件初始化它。.git 目录将包含关于我们保存的修订版本的所有数据和元数据。

在我们将任何文件添加到仓库之前,我们需要指导 Git 忽略某些类型的文件。例如,Python 在执行文件时创建字节码(.pyc)文件,而我们不想将这些文件作为代码的一部分保存。为此,在您的项目根目录中创建一个名为 .gitignore 的文件,并在其中放入以下行:

*.pyc
__pycache__/ 

您可以添加更多的目录名、文件名或通配符模式来忽略您不想保存的文件类型(例如,某些编辑器通过在文件名中添加特定字符来创建临时文件或备份副本)。

添加和提交代码

现在既然我们的仓库已经初始化,我们可以使用 git add 命令将文件和目录添加到我们的 Git 仓库中,如下所示:

$ git add abq_data_entry
$ git add abq_data_entry.py
$ git add docs
$ git add README.rst 

到目前为止,我们的文件已经 暂存,但尚未提交到仓库。由于单个应用程序的更改可能需要更改多个文件,Git 允许您暂存任意数量的文件以作为单个提交的一部分。请注意,我们可以指定目录而不是单个文件;在这种情况下,目录中当前的所有文件都将为我们的下一次提交进行暂存。

您可以通过输入命令 git status 在任何时间检查您仓库的状态以及其中的文件。现在尝试一下,你应该会得到以下输出:

On branch master
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: README.rst
new file: abq_data_entry.py
new file: abq_data_entry/~__init__.py~
new file: abq_data_entry/application.py
new file: abq_data_entry/models.py
new file: abq_data_entry/views.py
new file: abq_data_entry/widgets.py
new file: docs/Application_layout.png
new file: docs/abq_data_entry_spec.rst
new file: docs/lab-tech-paper-form.png
Untracked files:
(use "git add <file>..." to include in what will be committed)
.gitignore 

这表明 abq_data_entry/docs/ 目录下的所有文件,以及您直接指定的文件,都已准备好提交到仓库中。

让我们继续使用以下命令提交更改:

$ git commit -m "Initial commit" 

此处的 -m 标志允许您指定一个 提交信息,该信息与提交一起存储。每次您将代码提交到仓库时,您都需要编写一条信息。您应该始终使这些信息尽可能有意义,详细说明您所做的更改及其背后的原因。

查看和使用我们的提交

要查看您仓库的历史记录,运行 git log 命令,如下所示:

$ git log
commit df48707422875ff545dc30f4395f82ad2d25f103 (HEAD -> master)
Author: Alan Moore <alan@example.com>
Date: Thu Dec 21 18:12:17 2017 -0600
Initial commit 

如您所见,AuthorDate 和提交信息显示为我们上次的提交。如果我们有更多的提交,它们也会按时间顺序从新到旧地列在这里。输出第一行中您看到的长的十六进制值是 提交哈希,这是一个唯一值,用于标识提交。此值可以在其他操作中引用提交。

例如,我们可以用它来将我们的仓库重置到过去的状态。通过以下步骤尝试一下:

  1. 删除 README.rst 文件,并验证它是否已完全删除。

  2. 运行 git log 获取您上次提交的哈希值。

  3. 现在,输入命令 git reset --hard df48707,将 df48707 替换为您上次提交哈希值的前七个字符。

  4. 再次检查您的文件列表:README.rst 文件应该已经恢复。

这里发生的情况是我们修改了我们的仓库,然后告诉 Git 将仓库的状态硬重置到最后的提交。如果你不想重置你的仓库,你也可以使用 git checkout 临时切换到一个旧的提交,或者使用 git branch 创建一个新的分支,以特定的提交作为基础。正如你已看到的,这为我们提供了一个强大的安全网,用于实验;无论你对代码进行多少次修改,任何提交都只是命令之遥!

Git 有许多超出本书范围的功能。如果你想了解更多,Git 项目提供了一个免费的在线手册,网址为 git-scm.com/book,在那里你可以了解关于分支和设置远程仓库等高级功能。现在,重要的是要边做边提交更改,这样你就可以保持你的安全网并记录更改的历史。

摘要

在本章中,你学习了如何为你的简单脚本做一些重大的扩展。你学习了如何使用模型-视图-控制器模型将应用程序的责任区域划分为单独的组件。你将 ABQ 应用程序重新实现为一个 Python 包,将代码拆分为多个模块,以进一步强化关注点的分离并为后续扩展提供一个有组织的框架。最后,你为你的代码设置了一个 Git 仓库,这样你就可以使用版本控制跟踪所有更改。

在下一章中,我们将通过实现文件打开和保存、信息弹出窗口和主菜单来测试我们新项目布局的便利性。你还将学习如何为你的应用程序提供可配置的设置并将它们保存到磁盘。

第七章:使用菜单和 Tkinter 对话框创建菜单

随着应用程序功能的增长,将所有功能和输入都挤入一个单一表单变得越来越低效。相反,我们需要以保持它们可用而不使视觉呈现杂乱的方式组织对功能、信息和控制的访问。像 Tkinter 这样的 GUI 工具包为我们提供了一些工具来帮助我们处理这个问题。首先,菜单系统,通常位于应用程序窗口的顶部(或在某些平台上,在全局桌面菜单中),可以用来以压缩的层次结构组织应用程序功能。其次,对话框窗口,通常被称为对话框,提供了一种快速显示包含信息、错误或基本表单的临时窗口的方法。

在本章中,我们将通过以下主题来探讨 Tkinter 中菜单和对话框的使用和最佳实践:

  • 解决应用程序中的问题 中,我们将分析一些关于我们应用程序的报告问题,并设计一个涉及菜单和对话框的解决方案。

  • 实现 Tkinter 对话框 中,我们将探索 Tkinter 的对话框类以及如何使用它们来实现常见应用程序功能。

  • 设计应用程序菜单 中,我们将使用 Tkinter 的 Menu 小部件将我们的应用程序功能组织到一个主菜单系统中。

让我们从看看我们的应用程序需要哪些改进开始。

解决应用程序中的问题

虽然到目前为止每个人都对你的应用程序感到满意,但你的老板在与员工讨论后,给你带来了这一系列需要解决的问题:

  • 固定代码的文件名是一个问题。有时数据录入人员直到第二天才能到达表单;在这种情况下,他们需要能够手动输入他们想要附加数据的文件名。

  • 此外,数据录入人员对表单中的自动填充功能有矛盾的看法。有些人认为它非常有帮助,但其他人希望自动填充部分或完全禁用。

  • 一些用户很难注意到底部的状态栏文本,并希望当由于字段错误而无法保存数据记录时,应用程序能够更加果断。

  • 最后,实验室正在引入一些实习生到实验室工作,数据安全问题已经被提出。IT 建议简单的登录要求是可取的。它不需要有很高的安全性,只要足够“让诚实的人保持诚实”即可。

规划解决这些问题的方案

很明显,你需要实现一种方法来输入登录凭证,选择保存文件名,并切换表单的自动填充功能。你还需要使状态文本更加引人注目。首先,你考虑只是为主应用程序添加这些功能的控件并增加状态文本的大小。你快速制作了一个看起来像这样的原型:

图 7.1:我们添加新功能的第一次尝试:用于登录数据和文件名的三个 Entry 小部件,以及两个用于设置的 Checkbutton 小部件

很明显,这不是一个好的设计,而且肯定不是一种能够适应增长的设计。用户不想盲目地在框中输入文件路径和文件名,他们也不需要额外的登录字段和复选框来弄乱用户界面。增大状态字体看起来是个好主意,直到你意识到现在表单太长,很可能会被推到屏幕底部。

在思考其他 GUI 应用程序时,你会意识到这些功能通常由对话框处理,通常从菜单选项激活。考虑到菜单和对话框,你计划以下解决方案来解决问题:

  • 从菜单系统激活的文件对话框可以用来选择数据将保存到的文件。

  • 我们菜单系统中的设置菜单将处理激活或禁用自动填充。

  • 错误对话框将用于更坚定地显示问题状态消息。

  • 登录对话框可以用来输入登录信息。

在我们能够编写这个解决方案之前,我们需要更多地了解 Tkinter 中的对话框。

实现 Tkinter 对话框

Tkinter 包含许多子模块,为不同情况提供现成的对话框。这些包括:

  • messagebox,用于显示简单的消息和警告

  • filedialog,用于提示用户输入文件或文件夹路径

  • simpledialog,用于从用户请求字符串、整数或浮点值

在本节中,我们将探索这些对话框,并使用它们来解决我们应用程序的一些问题。

使用 Tkinter 的 messagebox 显示错误对话框

在 Tkinter 中显示简单对话框的最佳方式是使用tkinter.messagebox模块,它提供了各种信息显示对话框类型。

由于它是一个子模块,我们需要在可以使用它之前显式导入它,如下所示:

from tkinter import messagebox 

与创建大量小部件实例相比,messagebox模块提供了一系列便利函数,用于利用其各种对话框类型。当执行时,每个函数都会显示不同的按钮组合和预设图标,以及你指定的消息和详细文本。当用户点击对话框中的按钮或关闭对话框时,该函数将返回一个布尔值或字符串值,具体取决于哪个按钮被点击。

下表显示了messagebox模块的一些函数及其图标和返回值:

函数 图标 按钮文本/返回值
askokcancel() 问题 确定(True),取消(False
askretrycancel() 警告 重试(True),取消(False
askyesno() 问题 是(True),否(False
askyesnocancel() 问题 是(True),否(False),取消(None
showerror() 错误 确定(ok
showinfo() 信息 确定 (ok)
showwarning() 警告 确定 (ok)

每个 message box 函数都接受这个相同的参数集:

  • title 设置窗口的标题,该标题在桌面环境中的标题栏和/或任务栏中显示。

  • message 设置对话框的主要消息。它通常以标题字体显示,并且应该保持相当简短。

  • detail 设置对话框的主体文本,通常以标准窗口字体显示。

messagebox.showinfo() 的基本调用看起来可能像这样:

messagebox.showinfo(
  title='This is the title',
  message='This is the message',
  detail='This is the detail'
) 

在 Windows 10 中,它将导致一个看起来像这样的对话框:

Windows 10 上的 showinfo() 消息框

图 7.2:Windows 10 上的 showinfo() 消息框

在 macOS 上,你会看到类似这样的东西:

图 7.3:macOS 上的 showinfo() 消息框

在 Ubuntu Linux 上,对话框看起来像这样:

图 7.4:Ubuntu Linux 上的 showinfo() 消息框

注意,Tkinter messagebox 对话框是模态的,这意味着当对话框打开时,程序执行暂停,其余的 UI 不可响应。没有办法改变这一点,所以只有在程序在对话框打开时暂停执行是可以接受的情况下才使用它们。

让我们创建一个小的示例脚本,以展示 messagebox 函数的使用:

# messagebox_demo.py
import tkinter as tk
from tkinter import messagebox
see_more = messagebox.askyesno(
  title='See more?',
  message='Would you like to see another box?',
  detail='Click NO to quit'
)
if not see_more:
  exit()
messagebox.showinfo(
  title='You got it',
  message="Ok, here's another dialog.",
  detail='Hope you like it!'
) 

这将创建一个带有按钮的对话框。如果用户点击,函数返回 False 并且应用程序退出。在我们的用户想要看到更多对话框的情况下,程序将继续并显示一个信息框。

在 ABQ 数据输入中显示错误对话框

现在你已经了解了如何使用 messagebox,在应用程序中实现错误对话框应该很容易。Application._on_save() 方法已经显示状态栏中的错误;我们只需要确保同样的文本也在错误对话框中显示。

首先,打开 application.py,然后让我们这样导入 messagebox

# application.py at the top of the file
from tkinter import messagebox 

现在,定位到 Application._on_save() 方法中更新应用程序状态以显示任何错误的行(在 if errors: 块内)。就在那行之后,让我们添加一些代码来显示错误对话框,如下所示:

# application.py, inside Application._on_save()
    if errors:
      # ... after setting the status:
      message = "Cannot save record"
      detail = (
        "The following fields have errors: "
        "\n  * {}".format(
          '\n  * '.join(errors.keys())
      ))
      messagebox.showerror(
        title='Error',
        message=message,
        detail=detail
      )
      return False 

我们首先为对话框构建了消息和详细字符串,通过使用 \n *(即换行符、空格和星号)将存在错误的字段合并成一个项目符号列表。不幸的是,messagebox 对话框不支持任何类型的标记或富文本,因此像项目符号列表这样的结构需要使用常规字符手动构建。

在构建消息之后,我们调用 messagebox.showerror() 来显示它们。记住,此时应用程序将冻结,直到用户点击确定并且 showerror() 函数返回。

打开程序并点击保存;你会看到一个对话框,提示你应用程序中的错误,如下面的截图所示:

尝试保存无数据时的错误信息

图 7.5:当我们在没有数据的情况下尝试保存时,Windows 10 上的错误信息

这个错误应该对任何人来说都不难发现!

messagebox模块的对话框的一个缺点是它们不能滚动;一个长的错误信息将创建一个可能填满(或超出)屏幕的对话框。如果这是一个潜在问题,你将想要创建一个包含可滚动小部件的自定义对话框。我们将在本节的后面创建一个自定义对话框。

使用filedialog

当用户需要输入文件或目录路径时,首选的方式是显示一个包含微型文件浏览器的对话框,通常称为文件对话框。像大多数工具包一样,Tkinter 为我们提供了打开文件、保存文件和选择目录的对话框。这些都是filedialog模块的一部分。

就像messagebox一样,filedialog是 Tkinter 的一个子模块,需要显式导入才能使用。同样,像messagebox一样,它包含一组方便的函数,用于创建适合不同场景的文件对话框。

以下表格列出了函数、它们返回的内容以及可以在对话框中选择的选项:

函数 返回值 允许选择
askdirectory() 字符串形式的目录路径 仅目录
askopenfile() 文件句柄对象 仅现有文件
askopenfilename() 字符串形式的文件路径 仅现有文件
askopenfilenames() 字符串列表形式的多个文件路径 多个现有文件
asksaveasfile() 文件句柄对象 新或现有文件
asksaveasfilename() 字符串形式的文件路径 新或现有文件

正如你所见,每个文件选择对话框有两种版本:一种返回一个路径字符串,另一种返回一个打开的文件对象。

每个函数都可以接受以下参数:

  • title指定对话框窗口标题。

  • parent指定(可选)父小部件。文件对话框将显示在这个小部件之上。

  • initialdir设置文件浏览器应该开始的目录。

  • filetypes是一个元组列表,每个元组都有一个标签和匹配的模式,这些模式将用于构建通常在文件名输入下看到的“格式”或“文件类型”下拉列表。这用于过滤可见文件,只显示应用程序支持的文件。例如,[('Text', '*.txt'), ('Python', '*.py')]的值将提供仅查看.txt.py文件的能力。

asksaveasfile()asksaveasfilename()函数还接受以下两个附加参数:

  • initialfile:此参数是一个默认文件路径,用于选择。

  • defaultextension:此参数是一个文件扩展名字符串,如果用户没有包含一个,它将被自动附加到文件名上。

最后,返回文件对象的那些方法接受一个mode参数,用于指定打开文件时使用的模式;这些是 Python 内置open()函数使用的相同的一个或两个字符字符串(例如,r为只读,w为写入,等等)。

注意,asksaveasfile()默认情况下会以写入模式自动打开所选文件。这会立即清空所选文件的内容,即使您随后没有向文件写入任何内容或关闭文件句柄! 因此,除非您绝对确定所选文件应该被覆盖,否则应避免使用此函数。

在 macOS 和 Windows 上,filedialog使用操作系统的内置文件对话框,您可能很熟悉。在 Linux 上,它将使用自己的对话框,看起来像这样:

图片

图 7.6:Ubuntu Linux 上的文件对话框

我们的应用程序需要使用哪个对话框?让我们考虑我们的需求:

  • 我们需要一个允许我们选择现有文件的对话框。

  • 我们还需要能够创建一个新文件。

  • 由于打开文件是模型的责任,我们不希望 Tkinter 为我们打开它,所以我们只想获取一个要传递给模型的文件名。

这些要求明确指向asksaveasfilename()函数。让我们在我们的Application对象上创建一个方法,使用此对话框获取文件名并构建一个新的模型。

打开abq_data_entry/application.py,并在Application类上启动一个新的方法,名为_on_file_select()

# abq_data_entry/application.py, in the Application class
  def _on_file_select(self, *_):
    """Handle the file->select action"""
    filename = filedialog.asksaveasfilename(
      title='Select the target file for saving records',
      defaultextension='.csv',
      filetypes=[('CSV', '*.csv *.CSV')]
    ) 

该方法首先启动asksaveasfilename文件对话框;使用filetypes参数,现有文件的选取将限制在以.csv.CSV结尾的文件。当对话框退出时,该函数将返回所选文件的路径作为字符串传递给filename。无论如何,我们必须将此路径传递给我们的模型。

目前,模型使用的文件名是在模型的初始化方法中生成的。为了创建一个带有用户提供的文件名的新的模型,我们需要更新初始化方法,使其能够接受一个文件名作为参数。

打开abq_data_entry/model.py,并编辑CSVModel.__init__()方法,如下所示:

# abq_data_entry/models.py, in CSVModel
  def __init__(self, **filename=None**):
    **if not** **filename**:
      datestring = datetime.today().strftime("%Y-%m-%d")
      filename = "abq_data_record_{}.csv".format(datestring)
    self.file = Path(filename) 

如您所见,我们已将filename作为关键字参数添加,默认值为None。如果filename确实为空,我们将使用之前生成的文件名。这样,我们就不必修改任何使用CSVModel的现有代码,但我们有传递文件名的选项。

现在,回到Application类,让我们完成_on_file_select()方法,如下所示:

# abq_data_entry/application.py, in CSVModel._on_file_select()
    if filename:
      self.model = m.CSVModel(filename=filename) 

要使用不同的文件,我们只需要更改这些内容。目前,我们没有运行此回调的方法;我们将在下一节中解决,即设计应用程序菜单。不过,首先,让我们谈谈最后一个对话框模块,simpledialog

使用 simpledialog 创建自定义对话框

在 GUI 应用程序中,你经常会需要停止一切,在程序可以继续执行操作之前先询问用户一个值。为此,Tkinter 提供了simpledialog模块。像messagebox一样,它为我们提供了一些便利函数,这些函数显示一个模态对话框,并根据用户的交互返回一个值。然而,与simpledialog一样,对话框中包含一个Entry小部件,允许用户提供值。

与其他对话框库一样,我们必须导入simpledialog才能使用它,如下所示:

from tkinter import simpledialog as sd 

有三个便利函数可用:askstring()askinteger()askfloat()。每个函数都接受一个title参数和一个prompt参数,分别用于提供窗口标题和输入提示文本。

例如,让我们要求用户输入一个单词:

word = sd.askstring('Word', 'What is the word?') 

这将显示一个类似这样的框:

macOS 上的 askstring 对话框

图 7.7:macOS 上的 askstring 对话框

当用户点击确定时,函数将返回输入到Entry小部件中的内容作为字符串。askinteger()askfloat()的工作方式完全相同,只是在返回之前,它们会尝试将输入的值转换为整数或浮点数。Entry小部件本身不使用验证回调进行验证,但如果在提交对话框时转换输入值出现问题,Tkinter 将显示一个错误框,如图所示:

当提交非整数值时,askinteger()生成的错误

图 7.8:当提交非整数值时,askinteger()生成的错误

使用 simpledialog 创建登录对话框

我们在本章中的一项任务是向我们的应用程序添加一个Login对话框。这似乎是 simpledialog 可以帮助我们的事情,但内置的便利函数中没有一个真正适合这个目的:askstring()可以用来,但它一次只询问一个字符串,如果我们可以为用户安全起见对密码输入进行掩码那就更好了。

幸运的是,我们可以创建自己的自定义simpledialog类,包含我们想要的任何字段集。为此,我们将子类化simpledialog.Dialog类。

由于这是一个 GUI 表单,让我们将其添加到我们的abq_data_entry/views.py文件中。打开该文件,并从导入Dialog开始:

# abq_data_entry/views.py at the top
from tkinter.simpledialog import Dialog 

现在,在文件末尾,让我们开始一个新的类,称为LoginDialog,如下所示:

# abq_data_entry/views.py at the bottom
class LoginDialog(Dialog):
  """A dialog that asks for username and password"""
  def __init__(self, parent, title, error=''):
    self._pw = tk.StringVar()
    self._user = tk.StringVar()
    self._error = tk.StringVar(value=error)
    super().__init__(parent, title=title) 

Dialog初始化器期望一个parent参数,指定它将出现的窗口小部件,以及一个title参数,用于框的窗口标题。我们还添加了一个关键字参数error,它将允许我们在显示对话框时传递一个错误消息。

在初始化器内部,我们正在设置用户、密码和错误字符串的私有控制变量,然后调用超类初始化器。为了实际构建Dialog类的 GUI,我们需要覆盖一个名为body()的方法。此方法预期构建 GUI 的主体,并返回一个输入小部件的实例,该实例在对话框显示时应获得焦点。

我们的body()方法将如下所示:

 def body(self, frame):
    ttk.Label(frame, text='Login to ABQ').grid(row=0)
    if self._error.get():
      ttk.Label(frame, textvariable=self._error).grid(row=1)
    user_inp = w.LabelInput(
      frame, 'User name:', input_class=w.RequiredEntry,
      var=self._user
    )
    user_inp.grid()
    w.LabelInput(
      frame, 'Password:', input_class=w.RequiredEntry,
      input_args={'show': '*'}, var=self._pw
    ).grid()
    return user_inp.input 

此方法中的frame参数是一个由超类初始化器创建的tkinter.Frame对象,在它上面可以构建对话框的主体。我们的方法需要在框架上构建表单。在这里,我们为表单的顶部添加了一个Label小部件,然后使用我们的LabelInput类添加用户名和密码字段。对于我们的密码输入,我们使用show参数用星号隐藏密码输入。此外,请注意,我们已经保存了对用户输入类的局部引用;记住body()需要返回一个在对话框显示时将获得焦点的部件的引用。

注意,在我们的body()方法中没有定义按钮。默认情况下,Dialog创建一个确定按钮和一个取消按钮,分别连接到Dialog.ok()Dialog.cancel()回调。这对于许多情况来说是可以的,但我们可能更喜欢我们的对话框显示登录取消。为此,我们需要覆盖buttonbox()方法。此方法负责将按钮放置在表单上并将它们连接到它们的回调。

让我们像这样覆盖该方法:

 def buttonbox(self):
    box = ttk.Frame(self)
    ttk.Button(
      box, text="Login", command=self.ok, default=tk.ACTIVE
    ).grid(padx=5, pady=5)
    ttk.Button(
      box, text="Cancel", command=self.cancel
    ).grid(row=0, column=1, padx=5, pady=5)
    self.bind("<Return>", self.ok)
    self.bind("<Escape>", self.cancel)
    box.pack() 

在此方法中,我们创建了一个Frame小部件,然后添加了登录和取消按钮。每个按钮都连接到相应的回调,并添加到框架中。接下来,我们将相同的回调绑定到ReturnEscape键上,分别。这并不是严格必要的,但对于仅使用键盘的用户来说是一个很好的功能,这也是超类方法版本所做的事情。

为了使输入的数据容易为调用对话框的代码访问,我们将创建一个包含输入用户名和密码的元组,并在用户点击登录时将其作为类成员提供。

我们可以覆盖ok()方法来实现这一点,但该方法处理一些其他逻辑(如关闭对话框),我们不希望重新实现。相反,Dialog具有一个apply()方法,我们打算用我们的自定义逻辑覆盖它。

它将简单地如下所示:

 def apply(self):
    self.result = (self._user.get(), self._pw.get()) 

此函数构建一个包含输入数据的元组,并将其存储为公共成员result。使用我们的LoginDialog类的代码可以访问此属性以检索usernamepassword

在我们的类中整合 LoginDialog

方便函数askstring()askfloat()askinteger()基本上创建它们相关联的对话框类的实例,并返回其result属性。为了使用我们的自定义对话框类,我们将做同样的事情。当我们得到结果时,我们将将其传递给一个认证方法,该方法将决定凭据是否有效。如果无效,我们将重新显示带有错误的对话框,直到凭据正确或用户取消对话框。

首先,让我们编写一个认证方法。我们将把这个方法添加到Application类中,所以打开application.py文件,并将这个_simple_login()方法添加到类的末尾:

# application.py, at the end of the Application class
  @staticmethod
  def _simple_login(username, password):
    return username == 'abq' and password == 'Flowers' 

注意,我们将其实现为一个静态方法,因为它不需要访问实例或类。它将简单地接受给定的usernamepassword,并查看它们是否与硬编码的值匹配。它相应地返回TrueFalse

这可能是你在应用程序中做密码安全的最糟糕的方式;永远不要在真实的应用程序中使用这种方法。我们在这里使用它是为了说明,因为目的是理解对话框。在第十二章使用 SQL 改进数据存储中,我们将实现一个真正值得生产的认证后端。

现在,让我们创建第二个方法,这个方法将显示登录对话框并测试输入的凭据是否有效,如下所示:

# application.py, at the end of the Application class
  def _show_login(self):
    error = ''
    title = "Login to ABQ Data Entry"
    while True:
      login = v.LoginDialog(self, title, error)
      if not login.result:  # User canceled
        return False
      username, password = login.result
      if self._simple_login(username, password):
        return True
      error = 'Login Failed' # loop and redisplay 

在这个方法中,我们首先创建errortitle变量,然后进入一个无限循环。在循环内部,我们使用titleerror字符串创建LoginDialog实例。这将显示对话框,并且执行将在这里暂停,直到用户取消或提交对话框。当这种情况发生时,login被分配给对话框的实例(不是结果!)现在我们可以检查login.result以查看用户输入了什么。

如果result为空,则用户取消了操作,因此我们可以从方法中返回False。如果用户输入了某些内容,我们将result提取到其usernamepassword值中,然后将这些值传递给我们的_simple_login()方法。如果凭据检查通过,我们将返回True;如果不通过,我们将更新错误字符串并让循环再次迭代,重新显示对话框。最终结果是,这个方法将返回False(如果对话框被取消),或者True(如果认证成功)。

现在,我们需要在应用程序启动时调用这个方法。我们将在应用程序的初始化器中这样做。由于对话框必须在创建根窗口之后才能创建,所以我们必须在调用super().__init__()之后立即进行(记住,ApplicationTk的子类,所以调用super().__init__()就是创建我们的Tk实例)。

将以下代码添加到Application.__init__()中,就在调用super().__init__()之后:

# application.py, inside Application.__init__()
    self.withdraw()
    if not self._show_login():
      self.destroy()
      return
    self.deiconify() 

第一行调用了withdraw()方法,该方法隐藏了我们的主窗口。我们并不严格必须这样做,但如果没有它,当登录对话框正在显示时,我们将会有一个空白的Application窗口悬挂在那里。

在隐藏空白窗口之后,我们将调用_show_login()并测试其return值。记住,如果用户成功认证,它将返回True,如果用户取消对话框,则返回False。在后一种情况下,我们将调用self.destroy(),这将删除我们的Tk实例,并从方法中返回。实际上,这会退出应用程序。

通常您会调用Application.quit()来退出 Tkinter 程序;这个Tk对象的方法会导致主循环退出,从而程序结束。然而,在程序的这个阶段,我们还没有启动主循环,所以quit()不会做任何事情。如果我们销毁窗口并返回而不添加任何其他内容,主循环将看到根窗口已被销毁,并在第一次迭代后退出。

如果用户成功认证,我们将调用应用程序的deiconify()方法,这将恢复其可见性。然后我们继续初始化器的其余部分。

继续启动应用程序,以对您的LoginDialog类进行测试运行。它应该看起来像这样:

登录对话框

图 7.9:登录对话框

干得好!

设计应用程序菜单

大多数应用程序将功能组织成层次菜单系统,通常显示在应用程序或屏幕的顶部(取决于操作系统)。虽然不同操作系统之间此菜单的组织方式不同,但某些项目在各个平台之间相当常见。

在这些常见项目之中,我们的应用程序需要以下内容:

  • 一个包含文件操作(如打开/保存/导出)的文件菜单,以及通常有一个退出应用程序的选项。我们的用户将需要这个菜单来选择要保存的文件,以及退出程序。

  • 一个选项菜单,用户可以在此配置应用程序。我们需要这个菜单来切换设置;有时这样的菜单被称为首选项或设置,但我们现在先使用选项。

  • 一个帮助菜单,其中包含指向帮助文档的链接,或者至少是一个提供应用程序基本信息的关于对话框。我们将为此实现菜单。

苹果、微软和 GNOME 项目分别发布了 macOS、Windows 和 GNOME 桌面环境(用于 Linux 和 BSD)的指南;每一套指南都针对特定平台的菜单项布局。我们将在第十章维护跨平台兼容性中更详细地探讨这一点。

在我们能够实现我们的菜单之前,我们需要了解 Tkinter 中菜单是如何工作的。

Tkinter 菜单小部件

tkinter.Menu小部件是用于在 Tkinter 应用程序中实现菜单的基本构建块;它是一个相当简单的小部件,充当任意多个菜单项的容器。

菜单项可以是以下五种类型之一:

项目类型 描述
command 一个标签化的项,在点击时执行命令
checkbutton 一个标签化的复选按钮,可以与布尔控制变量相关联
radiobutton 一个标签化的单选按钮,可以与控制变量相关联
separator 一个系统适当的视觉分隔符,通常是一条黑色线
cascade 一个子菜单,作为第二个Menu实例实现

为了探索Menu类的工作方式,让我们从一个简单的示例脚本开始,如下所示:

# menu_demo.py
import tkinter as tk
root = tk.Tk()
root.geometry('200x150')
main_text = tk.StringVar(value='Hi')
label = tk.Label(root, textvariable=main_text)
label.pack(side='bottom') 

此应用程序设置了一个 200x150 像素的主窗口,其中包含一个Label小部件,其文本由一个字符串变量main_text控制。现在,让我们开始添加菜单组件,如下所示:

main_menu = tk.Menu(root)
root.config(menu=main_menu) 

这创建了一个Menu实例,然后通过将其分配给root窗口的menu参数,将其设置为应用程序的主菜单。

目前,菜单是空的,所以让我们添加一个项;将以下代码添加到脚本中:

main_menu.add('command', label='Quit', command=root.quit) 

在这里,我们添加了一个command命令项以退出应用程序。Menu.add()方法允许我们指定一个项目类型以及任意数量的关键字参数来创建一个新的菜单项。在command项的情况下,我们至少需要一个label参数来指定将在菜单中显示的文本,以及一个指向 Python 回调函数的command参数。

一些平台,如 macOS,不允许在顶级菜单中使用命令。我们将在第十章维护跨平台兼容性中更详细地介绍不同平台菜单之间的差异。

接下来,让我们尝试创建一个子菜单,如下所示:

text_menu = tk.Menu(main_menu, tearoff=False) 

创建一个子菜单就像创建一个菜单一样,只不过我们需要指定父菜单作为小部件的父级。注意tearoff参数;在 Tkinter 中,子菜单默认是可撕离的,这意味着它们可以被拉出来并作为独立窗口移动。

您不必禁用此选项,但这是一种相当古老的 UI 功能,在现代平台上很少使用。我们的用户可能会觉得它很困惑,所以每当创建子菜单时,我们都会禁用它。

现在我们有了子菜单对象,让我们添加一些命令,如下所示:

text_menu.add_command(
  label='Set to "Hi"',
  command=lambda: main_text.set('Hi')
)
text_menu.add_command(
  label='Set to "There"',
  command=lambda: main_text.set('There')
) 

这里使用的add_command()方法仅仅是add('command')的快捷方式,并且可以在任何Menu对象上使用。还有类似的方法用于添加其他项(如add_cascade()add_separator()等)。

现在我们已经填充了text_menu,让我们使用add_cascade()方法将我们的菜单添加回其父小部件,如下所示:

main_menu.add_cascade(label="Text", menu=text_menu) 

当将子菜单添加到其父菜单时,我们只需提供菜单的标签和菜单对象本身。

使用 Checkbutton 和 Radiobutton 项

除了命令和子菜单外,我们还可以在菜单中添加CheckbuttonRadiobutton小部件。为了演示这一点,让我们创建另一个子菜单,其中包含用于更改标签外观的选项。

首先,我们需要添加以下设置代码:

font_bold = tk.BooleanVar(value=False)
font_size = tk.IntVar(value=10)
def set_font(*args):
  size = font_size.get()
  bold = 'bold' if font_bold.get() else ''
  font_spec = f'TkDefaultFont {size} {bold}'
  label.config(font=font_spec)
font_bold.trace_add('write', set_font)
font_size.trace_add('write', set_font)
set_font() 

要在菜单中使用checkbuttonradiobutton项,我们首先需要创建控制变量来绑定它们。在这里,我们只是创建了一个布尔变量用于粗体字体切换,以及一个整数变量用于字体大小。接下来,我们创建了一个回调函数,该函数读取变量并在被调用时从它们设置Label小部件的font属性。最后,我们为这两个变量设置了跟踪,以便在值更改时调用回调,并调用了回调以初始化字体设置。

现在,我们只需要创建菜单选项来更改变量;添加以下代码:

appearance_menu = tk.Menu(main_menu, tearoff=False)
main_menu.add_cascade(label="Appearance", menu=appearance_menu)
appearance_menu.add_checkbutton(label="Bold", variable=font_bold) 

在这里,我们创建了外观选项的子菜单并添加了粗体文本的复选框。就像常规的Checkbutton小部件一样,add_checkbutton()方法使用variable参数来分配其控制变量。然而,与常规的Checkbutton小部件不同,它使用label参数而不是text参数来分配标签文本。

默认情况下,checkbutton项与BooleanVar一起使用;然而,就像Checkbutton小部件一样,你可以通过传递onvalueoffvalue参数来使用不同的控制变量类型。

为了演示radiobutton项,让我们在我们的外观子菜单中添加一个子菜单,如下所示:

size_menu = tk.Menu(appearance_menu, tearoff=False)
appearance_menu.add_cascade(label='Font size', menu=size_menu)
for size in range(8, 24, 2):
  size_menu.add_radiobutton(
    label="{} px".format(size),
    value=size, variable=font_size
  ) 

正如我们向主菜单添加了子菜单一样,我们也可以向子菜单添加子菜单。在理论上,你可以无限地嵌套子菜单,但大多数 UI 指南都建议不超过两个级别。

要为我们的尺寸菜单创建项,我们只是迭代从 8 到 24 生成的偶数列表;对于每一个,我们调用add_radiobutton(),添加一个具有该尺寸值的项。就像常规的Radiobutton小部件一样,传递给variable参数的控制变量将在按钮被选中时更新为存储在value参数中的值。

最后,让我们添加一个对mainloop()的调用:

root.mainloop() 

启动应用程序并尝试使用它。你应该得到类似以下内容:

菜单演示应用程序

图 7.10:菜单演示应用程序

现在我们已经了解了如何使用Menu小部件,让我们为我们的应用程序设计和实现一个菜单。

实现 ABQ 应用程序菜单

作为 GUI 的主要组件,我们的主菜单代码将非常适合放在views.py文件中。然而,由于随着应用程序的增长它将大大扩展,我们将将其放入自己的模块文件中。在abq_data_entry目录中创建一个名为mainmenu.py的新文件。然后以一个文档字符串和我们的导入开始文件:

# mainmenu.py
"""The Main Menu class for ABQ Data Entry"""
import tkinter as tk
from tkinter import messagebox 

接下来,让我们通过以下方式子类化tkinter.Menu来创建我们自己的主菜单类:

class MainMenu(tk.Menu):
  """The Application's main menu"""
  def __init__(self, parent, **kwargs):
    super().__init__(parent, **kwargs) 

我们将在初始化器内部构建菜单的其余部分,尽管目前这并没有做任何额外的事情。在我们开始构建菜单之前,让我们回到application.py模块,并将此类设置为应用程序的主菜单。

首先,在文件顶部导入类,如下所示:

# application.py, at the top after the import statements
from .mainmenu import MainMenu 

接下来,在Application.__init__()内部,我们需要创建我们的MainMenu类的一个实例,并将其作为应用程序的菜单。更新方法如下:

# application.py, inside Application.__init__()
    self.title("ABQ Data Entry Application")
    self.columnconfigure(0, weight=1)
    **menu = MainMenu(self)**
    **self.config(menu=menu)** 

现在,让我们回到mainmenu.py并开始构建菜单的组件。

添加帮助菜单

让我们从简单的事情开始。我们只需添加一个关于对话框来显示有关我们程序的一些信息。这通常位于帮助菜单中。

将以下代码添加到MainMenu.__init__()中:

# mainmenu.py, inside MainMenu.__init__()
    help_menu = tk.Menu(self, tearoff=False)
    help_menu.add_command(label='About…', command=self.show_about) 

在这里,我们添加了一个帮助菜单和一个关于的命令。

命令指定了一个实例方法show_about()作为其回调;因此,我们需要将该方法添加到类中,如下所示:

# mainmenu.py, inside the MainMenu class
  def show_about(self):
    """Show the about dialog"""
    about_message = 'ABQ Data Entry'
    about_detail = (
      'by Alan D Moore\n'
      'For assistance please contact the author.'
    )
    messagebox.showinfo(
      title='About', message=about_message, detail=about_detail
    ) 

此方法仅指定了一些关于应用程序的基本信息,并在messagebox对话框中显示它。当然,您可以更新about_detail变量以包含您自己的信息,或者一个更长(并且希望更有帮助)的消息。

添加文件菜单

接下来我们要创建的菜单是文件菜单。这个菜单将有两个命令,一个用于选择文件,另一个用于退出应用程序。然而,与关于对话框不同的是,我们实际上不能在菜单类本身中实现这两个命令的回调逻辑。文件选择需要调用我们在本章早期创建的Application._on_file_select()方法,而退出命令需要调用Application.quit()

由于菜单的父小部件将是Application对象,我们可以将这些命令绑定到parent._on_file_selectparent.quit,但这样会创建一个紧密耦合的情况,正如我们在第六章中讨论的,为应用程序的扩展做准备。正如我们在那一章中所做的那样,我们将使用生成的事件来将信息反馈给控制器类。

实现我们的文件菜单命令的一个可能方法是使用一个lambda函数,如下所示:

 file_menu.add_command(
      label="Select file…",
      command=lambda: self.event_generate('<<FileSelect>>')
    ) 

lambda关键字创建了一个包含单个表达式的匿名内联函数。它通常用于需要函数引用(如小部件的command参数)但不需要定义命名函数的开销的情况。在这种情况下,我们创建了一个匿名函数,它使用event_generate()MainMenu对象上生成一个自定义的<<FileSelect>>事件。

您可以在 Python 官方文档的第 6.14 节中了解更多关于 Lambda 表达式的信息,该文档可在docs.python.org/3/reference/expressions.html找到。

然而,这种方法有两个问题。

首先,每次都使用 lambda 会相当冗长且难看,并且由于我们的菜单在应用程序增长时将生成大量的自定义事件,我们希望避免大量的重复样板代码。

第二,在 Menu 对象上绑定事件在所有平台上都不起作用(特别是,在 Microsoft Windows 上不起作用)。这与 Menu 是围绕每个平台的本地菜单系统构建的事实有关。为了解决这个问题,我们需要获取对 root 窗口的引用,并将我们的事件绑定到它。

由于这会让我们的代码变得更加丑陋,因此创建一个简单的包装函数来保持我们的菜单定义整洁是有意义的。

MainMenu 类的初始化器之上添加以下 _event() 方法:

# mainmenu.py, inside MainMenu
  def _event(self, sequence):
    def callback(*_):
      root = self.master.winfo_toplevel()
      root.event_generate(sequence)
    return callback 

这个简单的方法创建了一个函数,该函数会导致 root 窗口实例生成提供的 sequence 字符串,然后返回对新定义的函数的引用。为了获取 root 窗口的引用,我们在菜单的父小部件(self.master)上调用 winfo_toplevel(),这将返回菜单父小部件的最高级窗口。你可能想知道为什么我们不直接使用 self.master,或者直接在 Menu 对象上调用 winfo_toplevel()。在第一种情况下,我们无法确定菜单的父小部件是什么,直到我们创建其实例,尤其是在我们的程序在未来发展时。虽然我们无法确定父小部件的确切类型,但我们确信它将是一个窗口上的小部件;通过调用 winfo_toplevel(),我们应该得到 root 窗口。

在第二种情况下,winfo_toplevel() 方法,当在 Menu 对象上调用时,实际上返回的是 菜单 的顶层。换句话说,在这个上下文中,self.winfo_toplevel() 将仅返回我们的 MainMenu 对象。

现在,我们可以更新我们的菜单项以使用这个包装方法,如下所示:

# mainmenu.py, inside MainMenu.__init__()
    file_menu = tk.Menu(self, tearoff=False)
    file_menu.add_command(
      label="Select file…",
      command=self._event('<<FileSelect>>')
    )
    file_menu.add_separator()
    file_menu.add_command(
      label="Quit",
      command=self._event('<<FileQuit>>')
    ) 

注意在 "Select file" 后面使用了省略号字符()。这是菜单中的一个约定,表示当执行命令时,将打开另一个窗口或对话框来获取用户信息,而不是直接运行命令。

现在代码看起来整洁多了。为了使这些命令生效,我们需要告诉我们的 Application 类监听这些事件,并在它们生成时采取适当的行动。

application.py 文件中,让我们在菜单对象设置之后,向 Application.__init__() 添加以下行:

# application.py, inside Application.__init__()
    event_callbacks = {
      '<<FileSelect>>': self._on_file_select,
      '<<FileQuit>>': lambda _: self.quit(),
    }
    for sequence, callback in event_callbacks.items():
      self.bind(sequence, callback) 

在这里,我们创建了一个 event_callbacks 字典,将事件序列与回调方法相匹配。然后,我们遍历这个字典,将每个序列绑定到其事件。

随着我们向菜单中添加更多项目,我们只需要更新字典以包含额外的绑定。请注意,我们不能直接将<<FileQuit>>动作绑定到self.quit()。这是因为使用bind()方法绑定的回调在调用时传递参数,而self.quit()不接受任何参数。我们在这里使用lambda调用只是为了从回调中过滤掉添加的参数。

添加设置菜单

下一步需要添加的是我们的选项菜单,它将允许用户指定他们是否希望在表单中自动填充日期和表数据。我们已经看到向菜单中添加复选框选项相当简单,但实际上使这些选项工作需要一些额外的管道工作。我们需要以某种方式将这些菜单选项连接到DataRecordForm实例,以便它可以适当地禁用自动化。

要做到这一点,让我们首先在Application类中创建一个字典,该字典将存储一些控制变量:

# application.py, inside Application.__init__()
# before the menu setup
  self.settings = {
    'autofill date': tk.BooleanVar(),
    'autofill sheet data': tk.BooleanVar()
  } 

接下来,我们需要确保我们的DataRecordFormMainMenu对象都可以访问这些设置;我们将通过将settings字典传递给它们的初始化方法并将它存储在每个类的实例变量上来做到这一点。

首先,在views.py中,让我们更新DataRecordForm.__init__()方法,如下所示:

# views.py, inside DataRecordForm class
  def __init__(self, parent, model, **settings**, *args, **kwargs):
    super().__init__(parent, *args, **kwargs)
    self.model = model
    **self.settings = settings** 

接下来,在mainmenu.py中,让我们更新MainMenu.__init__()方法,如下所示:

# mainmenu.py, inside MainMenu class
  def __init__(self, parent, **settings**, **kwargs):
    super().__init__(parent, **kwargs)
    **self.settings = settings** 

现在,回到Application类,我们必须更新创建这些类实例的代码,以便将settings字典传递给每个实例。按照以下方式更新Application.__init__()中的代码:

# application.py, in Application.__init__()
  # update the menu creation line:
  menu = MainMenu(self, **self.settings**)
  #...
  # update the data record form creation line:
  self.recordform = v.DataRecordForm(
    self,
    self.model,
    **self.settings**
  ) 

每个类现在都可以访问settings字典,所以让我们来使用它。首先,让我们将我们的选项菜单添加到主菜单中。

MainMenu文件中,将以下代码添加到初始化方法中以构建菜单:

# mainmenu.py, in MainMenu.__init__()
    options_menu = tk.Menu(self, tearoff=False)
    options_menu.add_checkbutton(
      label='Autofill Date',
      variable=self.settings['autofill date']
    )
    options_menu.add_checkbutton(
      label='Autofill Sheet data',
      variable=self.settings['autofill sheet data']
    ) 

简单来说,我们创建了一个名为options_menuMenu小部件,其中包含两个绑定到我们的设置变量的checkbutton项。这就是MainMenu需要的所有设置配置。

我们需要做的最后一件事是让这些设置与DataRecordForm类的reset()方法一起工作,该方法处理这些字段的自动填充。

views.py文件中,定位DataRecordForm.reset()方法,并找到设置日期变量的代码。更新如下:

# views.py, in DataRecordForm.reset()
    **if** **self.settings[****'autofill date'****].get():**
      current_date = datetime.today().strftime('%Y-%m-%d')
      self._vars['Date'].set(current_date)
      self._vars['Time'].label_widget.input.focus() 

我们在这里所做的只是将这个日期设置逻辑放在一个检查settings值的if语句下面。我们需要对我们的表数据部分做同样的事情,如下所示:

 if (
      **self.settings[****'autofill sheet data'****].get()** **and**
      plot not in ('', 0, plot_values[-1])
    ):
      self._vars['Lab'].set(lab)
      self._vars['Time'].set(time)
      # etc... 

由于这个逻辑已经在if语句下面,我们只是向检查中添加了另一个条件。现在这应该会给我们提供功能选项。

完成菜单

在我们的主菜单中,我们需要做的最后一件事是将我们创建的子菜单添加到主菜单中。在MainMenu.__init__()的末尾,添加以下行:

# mainmenu.py, at the end of MainMenu.__init__()
    self.add_cascade(label='File', menu=file_menu)
    self.add_cascade(label='Options', menu=options_menu)
    self.add_cascade(label='Help', menu=help_menu) 

子菜单将按照我们添加它们的顺序从左到右排列。通常,文件菜单是第一个,帮助菜单是最后一个,其他菜单在中间排列。我们将在第十章维护跨平台兼容性中了解更多关于如何根据平台排列菜单的信息。

运行应用程序,你应该看到一个像这样的漂亮主菜单:

ABQ 应用程序拥有一个花哨的主菜单

图 7.11:ABQ 应用程序拥有一个花哨的主菜单

通过取消选中设置并输入一些记录来尝试设置。当禁用时,它们应该禁用自动填充功能。

持久化设置

我们的设置是有效的,但有一个主要的不便:它们在会话之间不会持久化。关闭应用程序并重新启动,你会看到设置已经恢复到默认值。这不是一个主要问题,但这是我们不应该留给用户的一个粗糙边缘。理想情况下,他们的个人设置应该在每次启动应用程序时加载。

Python 为我们提供了多种在文件中持久化数据的方法。我们已经体验了 CSV,它是为表格数据设计的;还有其他格式,考虑到不同的能力而设计。以下表格显示了 Python 标准库中可用的存储数据选项的几个示例:

模块 文件类型 适合 优点 缺点
pickle 二进制 任何 Python 对象 快速、简单、文件小 不安全,文件不可读,必须读取整个文件
configparser 文本 键 -> 值对 可读性文件 无法处理序列或复杂对象,层次结构有限
json 文本 简单值和序列 广泛使用,简单,可读性高 无法处理日期,没有修改无法处理复杂对象
xml 文本 任何 Python 对象 强大、灵活、可读性高 不安全,使用复杂,语法冗长
sqlite 二进制 关系数据 快速、强大,可以表示复杂关系 需要 SQL 知识,对象必须转换为表

表 7.3:

如果这还不够,第三方库中还有更多选项可用。几乎任何一种都适合存储几个布尔值,那么我们如何选择?让我们考虑一下选项:

  • SQLXML功能强大,但对我们这里的简单需求来说过于复杂。

  • 如果我们需要调试损坏的设置文件,我们希望坚持使用文本格式,因此pickle不可用。

  • configparser 目前可以工作,但它无法处理列表、元组和字典,这可能在将来会成为一个限制。

这就留下了json,这是一个不错的选择。虽然它无法处理每种 Python 对象,但它可以处理字符串、数字和布尔值,以及列表和字典。它甚至可以扩展以处理其他类型的数据。它应该能够很好地满足我们当前的配置需求,以及我们未来的需求。

当我们说一个库“不安全”时,这意味着什么?一些数据格式被设计成具有强大的功能,如可扩展性、链接或别名,这些功能解析库必须实现。不幸的是,这些功能可能会被用于恶意目的。例如,“十亿笑声”XML 漏洞结合了三个 XML 功能来创建一个文件,当解析时,会膨胀到巨大的大小(通常会导致程序或在某些情况下操作系统崩溃)。

构建设置持久化的模型

就像任何类型的数据持久化一样,我们需要首先实现一个模型。就像我们的CSVModel类一样,设置模型需要保存和加载数据,以及权威地定义设置数据的布局。由于我们使用json,我们需要导入它。将以下内容添加到models.py的顶部:

import json 

现在,在models.py的末尾,让我们开始创建一个新的SettingsModel类,如下所示:

# models.py, at the bottom
class SettingsModel:
  """A model for saving settings"""
  fields = {
    'autofill date': {'type': 'bool', 'value': True},
    'autofill sheet data': {'type': 'bool', 'value': True}
  } 

就像我们对CSVModel所做的那样,我们在类中定义了一个类变量来指定设置文件中包含的fields。目前,它只包含我们两个布尔值。字典中的每个字段定义了字段的类型和默认值。请注意,我们在这里使用字符串而不是 Python type对象;这样做将允许我们将类型和值持久化到文本文件中。

接下来,让我们创建初始化器方法,如下所示:

# models.py, in SettingsModel
  def __init__(self):
    filename = 'abq_settings.json'
    self.filepath = Path.home() / filename 

初始化器将确定我们的设置将被保存到的文件路径;目前,我们已将名称abq_settings.json硬编码并存储在用户的家目录中。Path.home()Path类的一个类方法,它为我们提供了一个指向用户家目录的Path对象。这样,系统上的每个用户都可以有自己的设置文件。

一旦模型创建完成,我们希望从磁盘加载用户的保存选项,因此让我们添加一个调用我们称之为load()的实例方法:

# models.py, at the end of SettingsModel.__init__()
    self.load() 

现在我们需要实现load()方法。一个简单的实现可能如下所示:

 def load(self):
    with open(self.filepath, 'r') as fh:
      self.fields = json.load(fh) 

这只是简单地打开存储在self.filepath位置的文件,并用json.load()提取的内容覆盖fields变量。这是我们需要做的核心,但这种方法有两个问题:

  • 如果文件不存在会发生什么?(例如,如果用户之前从未运行过程序。)

  • 如果模型中的 JSON 数据与我们的应用程序期望的键不匹配会发生什么?(例如,如果它被篡改,或者由应用程序的旧版本创建。)

让我们创建一个更健壮的回调来处理这些问题,如下所示:

# models.py, inside the SettingsModel class
  def load(self):
    if not self.filepath.exists():
      return
    with open(self.filepath, 'r') as fh:
      raw_values = json.load(fh)
    for key in self.fields:
      if key in raw_values and 'value' in raw_values[key]:
        raw_value = raw_values[key]['value']
        self.fields[key]['value'] = raw_value 

在这个版本中,我们通过检查文件是否存在来解决第一个问题。如果文件不存在,该方法将简单地返回并什么都不做。文件不存在是完全合理的,特别是如果用户从未运行过程序或编辑过任何设置。在这种情况下,该方法将保持self.fields不变,用户最终将使用默认值。

为了解决第二个问题,我们将 JSON 数据拉入一个名为raw_values的局部变量中;然后,我们通过从raw_values中检索由我们的类定义的键来更新fields。如果 JSON 数据缺少特定的键,我们将跳过它,使fields保持其默认值。

除了加载设置外,我们的模型当然还需要保存其数据。让我们编写一个save()方法来将我们的值写入文件:

# models.py, inside the SettingsModel class
  def save(self):
    with open(self.filepath, 'w') as fh:
      json.dump(self.fields, fh) 

json.dump()函数是json.load()的逆操作:它接受一个 Python 对象和一个文件句柄,将对象转换为 JSON 字符串,并将其写入文件。将我们的设置数据保存下来就像将fields字典转换为 JSON 字符串并写入指定的文本文件一样简单。

我们模型需要的最后一个方法是外部代码设置值的方式;我们本可以直接允许外部代码直接操作fields字典,但为了保护我们的数据完整性,我们将通过方法调用来实现。

按照 Tkinter 约定,我们将此方法命名为set()

set()方法的基本实现如下:

def set(self, key, value):
  self.fields[key]['value'] = value 

这种简单的方法只接受keyvalue参数,并将它们写入fields字典。尽管如此,这也会带来一些潜在的问题:

  • 如果提供的值对于数据类型无效怎么办?

  • 如果键不在我们的fields字典中怎么办?我们应该允许外部代码直接添加新键吗?

这些情况可能会在应用程序中造成难以调试的问题,因此我们的set()方法应该防范这些场景。

让我们创建一个更健壮的版本,如下所示:

# models.py, inside the SettingsModel class
  def set(self, key, value):
    if (
      key in self.fields and
      type(value).__name__ == self.fields[key]['type']
    ):
      self.fields[key]['value'] = value
    else:
      raise ValueError("Bad key or wrong variable type") 

在这个版本中,我们检查给定的key参数是否存在于fields中,以及数据的type是否与该字段定义的类型匹配。为了将value变量的对象类型与field字典的type字符串相匹配,我们使用type(value).__name__提取了变量的数据类型作为字符串。这返回一个如bool的字符串,对于布尔变量,或者str对于字符串。有了这些检查来保护我们的值赋值,尝试写入未知键或错误的变量类型将失败。

然而,我们不会让它默默失败;如果有不良数据,我们将立即引发一个ValueError异常。为什么要引发异常?如果测试失败,这只意味着调用代码中存在错误。通过异常,我们将立即知道调用代码是否向我们的模型发送了不良请求。如果没有它,请求将默默失败,留下一个难以发现的错误。

在我们的应用程序中使用设置模型

我们的应用程序在启动时需要加载设置,然后在它们更改时自动保存。目前,应用程序的 settings 字典是手动创建的,但作为设置数据结构的权威,我们的模型应该真正地告诉它要创建什么类型的变量。

回到 Application.__init__() 方法中,找到创建我们的 settings 字典的行,并将其替换为以下代码:

# application.py, inside Application.__init__()
    self.settings_model = m.SettingsModel()
    self._load_settings() 

首先,我们创建了一个 SettingsModel 实例,将其存储为实例变量。然后,我们运行一个名为 _load_settings() 的实例方法。这个方法将负责查询 settings_model 以创建 Application.settings 字典。

在类定义的末尾,让我们创建 _load_settings() 方法:

# application.py, inside the Application class
  def _load_settings(self):
    """Load settings into our self.settings dict."""
    vartypes = {
      'bool': tk.BooleanVar,
      'str': tk.StringVar,
      'int': tk.IntVar,
      'float': tk.DoubleVar
    }
    self.settings = dict()
    for key, data in self.settings_model.fields.items():
      vartype = vartypes.get(data['type'], tk.StringVar)
      self.settings[key] = vartype(value=data['value']) 

我们的模式存储了每个变量的类型和值,但我们的应用程序需要 Tkinter 控制变量。我们需要将模型的数据表示转换为 Application 可以使用的结构。因此,这个函数首先创建一个 vartypes 字典,将我们的 type 字符串转换为控制变量类型。

虽然我们目前在我们的设置中只有布尔变量,但我们预计未来会有更多的设置,并创建一个能够处理字符串、浮点数和整数的函数。

在定义了 vartypes 字典并为 settings 创建了一个空字典之后,我们只需要迭代 self.settings_model.fields,为每个字段创建一个匹配的控制变量。请注意,vartypes.get(data['type'], tk.StringVar) 确保如果我们得到一个不在 vartypes 中列出的变量类型,我们只需为它创建一个 StringVar

在这里使用 Tkinter 变量的主要原因是为了能够通过 UI 追踪用户对值的任何更改,并立即做出响应。具体来说,我们希望在用户进行更改时保存我们的设置。为了实现这一点,将最后两行添加到方法中:

# application.py, inside Application._load_settings()
    for var in self.settings.values():
      var.trace_add('write', self._save_settings) 

这添加了一个跟踪,每当设置变量更改时都会调用 _save_settings。当然,这意味着我们需要编写一个名为 Application._save_settings() 的方法,该方法将设置保存到磁盘。

将此代码添加到 Application 的末尾:

 def _save_settings(self, *_):
    for key, variable in self.settings.items():
      self.settings_model.set(key, variable.get())
    self.settings_model.save() 

save_settings() 方法只需要从 Application.settings 获取数据到模型,然后保存。这就像迭代 self.settings 并调用我们的模型的 set() 方法逐个拉入值一样简单。一旦我们更新了值,我们就调用模型的 save() 方法。

这完成了我们的设置持久化;你应该能够运行程序并观察到设置被保存,即使你在关闭和重新打开应用程序后也是如此。你还会在你的主目录中找到一个名为 abq_settings.json 的文件(这不是保存设置文件的理想位置,但我们在第十章 维护跨平台兼容性 中将解决这个问题)。

摘要

在本章中,我们的简单表单已经迈出了很大一步,向成为一个完整的应用程序迈进。我们实现了主菜单、在执行之间保持的选项设置,以及一个关于对话框。我们增加了选择记录保存文件的能力,并通过错误对话框改善了表单错误的可见性。在这个过程中,你学习了 Tkinter 菜单、文件对话框、消息框和自定义对话框,以及标准库中持久化数据的各种选项。

在下一章中,我们将被要求使程序既能读取数据也能写入数据。我们将学习关于 Ttk 的 TreeviewNotebook 小部件,以及如何使我们的 CSVModelDataRecordForm 类能够读取和更新现有数据。

第八章:使用 Treeview 和 Notebook 导航记录

你收到了另一个关于应用程序功能的请求。现在,由于用户可以打开任意文件进行追加,他们希望能够查看这些文件的内容,并使用他们已经习惯的数据输入表单来纠正旧记录,而不是不得不切换到电子表格。简而言之,现在是时候在我们的应用程序中实现读取和更新功能了。

在本章中,我们将涵盖以下主题:

  • 在模型中实现读取和更新 中,我们将修改我们的 CSV 模型以实现读取和更新功能。

  • 探索 Ttk Treeview 中,我们将探讨 Ttk 的 Treeview 小部件。

  • 使用 Treeview 实现记录列表 中,我们将利用我们对 Treeview 小部件的了解来创建 CSV 文件中记录的交互式显示。

  • 将记录列表添加到应用程序中 中,我们将使用 Ttk 的 Notebook 小部件将我们的新记录列表视图集成到应用程序中。

在模型中实现读取和更新

我们到目前为止的设计一直围绕着只向文件追加数据的表单;添加读取和更新功能是一个基本的改变,将几乎触及应用程序的每个部分。

这可能看起来像是一项艰巨的任务,但通过一次处理一个组件,我们会发现这些变化并不那么令人难以承受。

我们首先应该做的是更新我们的文档。在 docs 文件夹中打开 abq_data_entry_spec.rst 文件,让我们从需求部分开始:

Functional Requirements:
  * Provide a UI for reading, updating, and appending 
    data to the CSV file
  * ... 

当然,我们还应该更新那些不需要的部分,如下所示:

The program does not need to:
  * Allow deletion of data. 

现在,让代码与文档匹配只是一个简单的问题。让我们开始吧!

向 CSVModel 类添加读取和更新功能

请花点时间考虑一下,CSVModel 类缺少哪些我们需要添加以实现读取和更新功能的部分:

  • 我们需要一个可以检索文件中所有记录的方法,这样我们就可以显示它们。我们将称之为 get_all_records()

  • 我们需要一个方法来通过行号从文件中获取单个记录。我们可以称这个方法为 get_record()

  • 我们需要以能够追加新记录的同时也能更新现有记录的方式保存记录。我们可以更新我们的 save_record() 方法以适应这一需求。

在你的编辑器中打开 models.py 文件,让我们一步步进行这些修改。

实现 get_all_records()

让我们在 CSVModel 中开始一个新的方法,称为 get_all_records()

# models.py, in the CSVModel class
  def get_all_records(self):
    """Read in all records from the CSV and return a list"""
    if not self.file.exists():
      return [] 

我们首先做的事情是检查模型文件是否存在(回想一下,self.file 是一个 Path 对象,因此我们可以直接调用 exists() 来查看它是否存在)。当我们的用户每天早上启动程序时,CSVModel 会生成一个指向一个可能还不存在的文件的默认文件名,因此 get_all_records() 需要优雅地处理这种情况。在这种情况下返回一个空列表是有意义的,因为没有数据时文件不存在。

如果文件确实存在,我们将以只读模式打开它并获取所有记录。我们可以这样做:

 with open(self.file, 'r') as fh:
      csvreader = csv.DictReader(fh)
      records = list(csvreader) 

虽然这种方法效率不高,但将整个文件拉入内存并转换为列表在我们的情况下是可以接受的,因为我们知道我们的最大文件应该限制在仅仅 241 行:20 个图表乘以 3 个实验室乘以 4 个检查会话,再加上一个标题行。这么多的数据对于 Python 来说是小菜一碟,即使是老工作站。然而,这种方法过于信任用户。我们至少应该做一些合理性检查,以确保用户实际上打开了一个包含正确字段的 CSV 文件,而不是其他任意文件,这可能会使程序崩溃。

让我们修改这个方法,使其能够检查文件的正确字段结构:

# models.py, inside CSVModel.get_all_records()
    with open(self.file, 'r', encoding='utf-8') as fh:
      csvreader = csv.DictReader(fh.readlines())
      missing_fields = (
        set(self.fields.keys()) - set(csvreader.fieldnames)
      )
      if len(missing_fields) > 0:
        fields_string = ', '.join(missing_fields)
        raise Exception(
          f"File is missing fields: {fields_string}"
        )
      records = list(csvreader) 

在这个版本中,我们首先通过比较CSVModel.fields字典的键与 CSV 文件中的fieldnames列表来查找任何缺失的字段。为了找到缺失的字段,我们使用了一个涉及 Python set类型的简单技巧:如果我们将两个列表都转换为set对象,我们可以从其中一个减去另一个,从而得到一个包含来自第一个列表(我们的fields键)而第二个列表(CSV 字段名)中缺失的字段的set对象。

如果missing_fields有任何项,那么这些就是 CSV 文件中缺失的字段。在这种情况下,我们将引发一个异常,详细说明哪些字段缺失。否则,我们将像在方法简单版本中那样将 CSV 数据转换为列表。

Python set对象在比较列表、元组和其他序列对象的内容时非常有用。它们提供了一个简单的方法来获取两个集合之间的信息,如差集(x中不在y中的项)或交集(xy中都有的项),并允许你比较序列而不考虑顺序。

在我们能够从方法中返回records列表之前,我们需要纠正一个问题;CSV 文件中的所有数据都存储为文本,并由 Python 作为字符串读取。大多数情况下这没问题,因为 Tkinter 会负责将字符串转换为floatint。然而,布尔值在 CSV 文件中以字符串TrueFalse的形式存储,直接将这些值强制转换为bool是不行的。字符串False是一个非空字符串,在 Python 中所有非空字符串都会评估为True

为了解决这个问题,我们首先定义一个字符串列表,这些字符串应该被解释为True

# models.py, inside CSVModel.get_all_records()
    trues = ('true', 'yes', '1') 

任何不在该列表中的值将被视为False。我们将进行不区分大小写的比较,因此我们的列表中只有小写值。

接下来,我们使用列表推导创建一个包含布尔型模型字段的列表,如下所示:

 bool_fields = [
      key for key, meta
      in self.fields.items()
      if meta['type'] == FT.boolean
    ] 

从技术上讲,我们知道“设备故障”是我们唯一的布尔字段,所以实际上,我们只需将方法硬编码来纠正该字段即可。然而,更明智的做法是设计模型,以便任何对模式的更改都将由逻辑部分自动适当地处理。如果添加或更改了字段,我们理想情况下只需要更改字段规范,其余的模型代码应该能够正确运行。

现在,让我们遍历记录并纠正每行中的所有布尔字段:

 for record in records:
      for key in bool_fields:
        record[key] = record[key].lower() in trues 

对于每条记录,我们遍历布尔字段列表,并将字段的值与我们的真值字符串列表进行比较,相应地设置项的值。

在布尔值固定后,我们可以通过返回记录列表来完成我们的函数,如下所示:

 return records 

注意,此方法返回的行是以save_record()方法保存数据时预期的格式相同的字典。对于模型来说,保持数据表示方式的一致性是一种良好的实践。在一个更健壮的模型中,你甚至可以创建一个类来表示数据行,尽管对于更简单的应用,字典通常也足够好。

实现 get_record()

get_record()方法需要接受一个行号并返回一个包含该行数据的单个字典。鉴于我们处理的是非常少量的数据,我们可以简单地利用我们刚刚编写的get_all_records()方法,并在几行代码中处理这个问题,如下所示:

# models.py, inside the CSVModel class
  def get_record(self, rownum):
    return self.get_all_records()[rownum] 

然而,请注意,可能传递一个不存在于我们的记录列表中的rownum值;在这种情况下,Python 会引发IndexError异常。

由于在模型内部处理这种情况没有有意义的方法,我们需要记住在使用此方法时让我们的控制器捕获这个异常并适当地处理它。

为 save_record()添加更新功能

为了将save_record()方法转换为可以更新记录的方法,我们首先需要提供传递行号以更新的能力。默认值将是None,这表示数据是新的行,应该将其附加到文件中。

更新后的方法签名如下所示:

# models.py, in the CSVModel class
  def save_record(self, data, rownum=None):
    """Save a dict of data to the CSV file""" 

我们现有的记录保存逻辑不需要改变,但它应该只在rownumNone时运行。

因此,在方法中首先要做的是检查rownum

 if rownum is None:
      # This is a new record
      newfile = not self.file.exists()
      with open(self.file, 'a') as fh:
        csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys())
        if newfile:
          csvwriter.writeheader()
        csvwriter.writerow(data) 

如果rownumNone,我们只是在运行现有的代码:如果文件不存在,则写入标题,然后将行追加到文件末尾。

如果rownum不是None,我们需要更新指定的行并保存文件。完成此任务有几种方法,但对于相对较小的文件,更新单行最简单的方法是:

  1. 将整个文件加载到列表中

  2. 更改列表中的行

  3. 将整个列表写回到一个干净的文件中

这可能看起来效率不高,但再次强调,我们处理的是非常少量的数据。只有在处理大量数据时(肯定不会存储在 CSV 文件中!),才需要更精细的方法。

因此,让我们添加以下代码来完成这项工作:

# models.py, inside CSVModel.save_record()
    else:
      # This is an update
      records = self.get_all_records()
      records[rownum] = data
      with open(self.file, 'w', encoding='utf-8') as fh:
        csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys())
        csvwriter.writeheader()
        csvwriter.writerows(records) 

再次利用我们的 get_all_records() 方法将 CSV 文件的内容提取到列表中。然后,我们将请求行中的字典替换为提供的数据字典。最后,我们以写入模式(w)打开文件,这将清除其内容,并用我们写入文件的内容替换它。然后我们将标题和所有记录写回空文件。

注意,我们采取的方法使得两个用户同时在一个 CSV 文件中工作变得不安全。创建允许多个用户同时编辑单个文件的软件非常困难,许多程序最初选择通过锁文件或其他保护机制来防止这种情况。在 第十二章使用 SQL 改进数据存储 中,我们将更新我们的程序,以便多个用户可以同时使用它。

此方法已完成,我们只需要在我们的模型中进行以下更改,以启用数据的更新和查看。现在,是时候向我们的 GUI 添加必要的功能了。

Ttk 树视图

为了让用户能够查看 CSV 文件的内容并选择记录进行编辑,我们需要在应用程序中实现一个新的视图,该视图能够显示表格数据。这个记录列表视图将允许我们的用户浏览文件内容,并打开记录进行查看或编辑。

我们的用户习惯于以表格形式查看这些数据,类似于表格格式,因此以类似的方式设计我们的视图是有意义的。

为了构建具有可选行的表格视图,Tkinter 给我们提供了 Ttk Treeview 小部件。为了构建我们的记录列表视图,我们需要了解 Treeview

树视图的结构

为了帮助我们探索树视图,让我们回顾一些与该小部件相关的基本术语和概念。树视图旨在显示 层次数据;也就是说,数据被组织成 节点,每个节点可以恰好有一个父节点和零个或多个子节点。以下图表显示了层次数据的示例:

图 8.1:一个小型的层次数据结构。节点 1、2 和 3 是根节点的子节点,节点 4 和 5 是节点 1 的子节点;“值”是每个节点的属性。

Treeview 小部件以表格格式显示层次数据;表格的每一行代表一个单独的节点,它称之为 。表格的每一列代表节点的某个属性。当一个节点有子节点时,这些行将显示在父节点下方,并且可以通过单击父节点来隐藏或显示。

例如,上面的 Treeview 中显示的层次结构将看起来像这样:

图 8.2:在树视图小部件中显示的浆果层次结构

树视图中每个项目都有一个唯一的 项目标识符IID),每个列都有一个 列标识符CID)。这些值是字符串,你可以手动分配它们,或者让小部件自动选择它们。

在树视图中列的顶部是 标题小部件。这些是按钮,可以显示每列的名称,并且可选地当点击时运行回调函数。

Treeview 小部件的第一列被称为 图标列,其 CID 为 #0。它不能被移除,也不能更改其 CID。通常它包含有关项目的标识信息。

构建文件浏览器

在树视图中表示我们能够表示的数据的最好例子可能是一个文件系统树:

  • 每一行可以代表一个文件或目录

  • 每个目录可以包含额外的文件或目录

  • 每行可以具有额外的数据属性,例如权限、大小或所有权信息

为了更好地理解 Treeview 小部件的工作原理,让我们创建一个简单的文件浏览器。

打开一个名为 treeview_demo.py 的新文件,并从以下模板开始:

# treeview_demo.py
import tkinter as tk
from tkinter import ttk
from pathlib import Path
root = tk.Tk()
# Code will go here
root.mainloop() 

我们首先获取当前工作目录下所有文件路径的列表。Path 有一个名为 glob() 的方法,将给我们这样一个列表。在 root = tk.Tk() 行下方添加此行:

paths = Path('.').glob('**/*') 

glob() 函数在文件路径中搜索匹配文件系统匹配表达式的文件或目录。该表达式可以包含通配符字符,如 *(表示“零个或多个字符”)和 ?(表示“单个字符”)。虽然“glob”这个名字可以追溯到非常早期的 Unix 命令,但这个相同的通配符语法现在被用于大多数现代操作系统命令行界面中。

Path('.') 创建一个引用当前工作目录的路径对象,**/* 是一种特殊的通配符语法,它可以递归地获取路径下的所有对象。给定这个通配符表达式,glob() 方法返回一个包含当前目录下每个目录和文件的 Path 对象列表。

创建和配置 Treeview

现在我们有一些要显示的数据,让我们创建一个 Treeview 小部件来显示它,如下所示:

tv = ttk.Treeview(
  root, columns=['size', 'modified'], selectmode='none'
) 

与任何 Tkinter 小部件一样,Treeview 的第一个参数是其父小部件。接下来,我们向 column 参数传递了一个字符串列表。这些是我们列的 CID 值。请注意,这些列是 除了 默认图标列之外的,所以这个 Treeview 小部件将总共有 3 列:#0sizemodified

selectmode 参数决定了用户如何在树中选取项目。selectmode 的不同选项如下所示:

行为
"none" 不能进行选择
"browse" 用户只能选择一个项目
"extended" 用户可以选择多个项目

在这种情况下,我们正在防止选择,因此将其设置为 none(注意,这将是字符串 none,而不是 None 对象)。

虽然 Tkinter 会为每个 CID 值添加一个列,但它不会自动给这些列提供标题标签。我们需要自己使用 Treeview.heading() 方法来做这件事,如下所示:

tv.heading('#0', text='Name')
tv.heading('size', text='Size', anchor='center')
tv.heading('modified', text='Modified', anchor='e') 

树形视图的 heading() 方法允许我们配置列标题小部件;它接受我们希望操作的列的 CID,后跟任意数量的关键字参数来配置标题小部件。

这些属性可以包括:

  • text:标题显示的文本。默认情况下,它是空的。

  • anchor:文本的对齐方式;可以是八个基本方向之一或 center,指定为字符串或 Tkinter 常量。

  • command:当点击标题时运行的回调函数。这可能用于按该列排序行,或选择列中的所有值,例如。

  • image:要在标题中显示的图像。

除了配置标题外,我们还可以使用 Treeview.column() 方法配置影响整个列的一些属性。

例如:

tv.column('#0', stretch=True)
tv.column('size', width=200) 

在此代码中,我们在第一列中设置了 stretch=True,这将导致它扩展以填充任何可用空间。然后我们在 size 列上设置了 width 值为 200 像素。

可以设置的列参数包括:

  • stretch:是否将此列扩展以填充可用空间。

  • width:列的像素宽度。

  • minwidth:列可以调整大小的最小宽度,以像素为单位。

  • anchor:列中文本的对齐方式。可以是八个基本方向之一或 center,指定为字符串或 Tkinter 常量。

配置好树形视图后,让我们将其添加到 GUI 中,如下所示:

tv.pack(expand=True, fill='both') 

填充 Treeview 的数据

现在我们已经完成了 GUI 部分,我们的视图需要填充数据。使用 Treeview 小部件的 insert() 方法逐行填充数据。

insert() 方法的基本调用如下:

mytreeview.insert(
  parent, 'end', iid='item1',
  text='My Item 1', values=['12', '42']
) 

第一个参数指定了插入行的 父项。这 不是 父小部件,而是插入节点所属的层次结构中父节点的 IID。对于顶级项,此值应该是一个空字符串。

下一个参数指定了项目相对于其兄弟节点在其父节点下的插入位置。它可以是数值索引或字符串 end,它将项目放置在列表的末尾。

在这些位置参数之后,insert() 接受多个关键字参数,这些参数可以包括:

  • text:这是要在图标列(CID #0)中显示的值。

  • values:这是剩余列的值列表。请注意,我们需要按顺序指定它们。

  • image:这是要在图标列的左侧显示的图像对象。

  • iid:行的 IID 字符串。如果您不指定它,将自动分配。

  • open:对于有子节点的节点,这设置行是否最初是打开的(显示子项目)或不是。

  • tags:一个标签字符串列表。当我们讨论第九章中的样式时,我们将了解更多关于标签的信息,使用样式和主题改进外观

要将我们的文件路径插入到树视图中,让我们按照以下方式遍历paths列表:

for path in paths:
  meta = path.stat()
  parent = str(path.parent)
  if parent == '.':
    parent = '' 

在调用insert()之前,我们需要从path对象中提取和准备一些数据。path.stat()方法会给我们一个包含各种文件信息的对象,我们将从中提取大小和修改时间。path.parent为我们提供了包含路径;然而,我们需要将根路径(目前是一个点)的名称更改为空字符串,这是Treeview表示根节点的方式。

现在,仍然在for循环中,我们按照以下方式添加insert()方法调用:

 tv.insert(
    parent,
    'end',
    iid=str(path),
    text=str(path.name),
    values=[meta.st_size, meta.st_mtime]
  ) 

通过使用路径字符串作为 IID,我们可以将其指定为其子对象的父节点。我们只使用路径名称(即,不带包含路径的文件或目录名称)作为我们的显示值,然后从stat()数据中检索st_sizest_mtime以填充大小和修改时间列。

运行此脚本,你应该会看到一个简单的文件树浏览器,看起来像这样:

图 8.1:我们的 Treeview 文件浏览器在 Ubuntu Linux 上运行

图 8.3:我们的 Treeview 小部件文件浏览器在 Ubuntu Linux 上运行

排序 Treeview 记录

Treeview小部件默认不提供任何排序功能,但我们可以通过向列标题添加回调函数来实现排序。

对未知深度的分层数据进行排序有点棘手;为了做到这一点,我们将编写一个递归函数。递归函数是一种调用自身的函数,它们在处理未知深度的分层数据时最常被使用。

让我们先定义我们的函数签名,如下所示:

def sort(tv, col, parent='', reverse=False): 

这个sort()函数接受一个Treeview小部件、我们想要排序的列的 CID 字符串、一个可选的父节点 IID 和一个布尔值,表示是否应该反转排序。parent的默认值是一个空字符串,表示层次结构的根。

我们将要做的第一件事是构建一个元组列表,每个元组包含我们想要排序的值和包含该值的行的 IID,如下所示:

 sort_index = list()
  for iid in tv.get_children(parent):
    sort_value = tv.set(iid, col) if col != '#0' else iid
    sort_index.append((sort_value, iid)) 

Treeview.get_children()方法检索一个 IID 字符串列表,这些字符串是给定parent IID 的直接子代。例如,在我们的文件浏览器中,调用tv.get_children('')将返回当前目录(不在任何子目录中)中所有文件和文件夹的 IID 值列表。

一旦我们有了这个列表,我们就遍历它,并开始构建一个我们可以对其进行排序的列表。为此,我们需要为每个 IID 获取排序列的内容。这有点令人困惑,这是通过 Treeview.set() 方法完成的。Treeview.set() 可以用两个或三个参数调用,前两个始终是我们想要引用的单元格的 IID 和 CID。如果第三个参数存在,set() 将将该值写入单元格。如果省略,set() 将返回该单元格的当前值。没有 Treeview.get() 方法,所以我们通过这种方式检索特定单元格的值。

然而,即使我们只想检索值,也不能在 CID #0 上调用 set()。因此,我们添加了一个检查,以防用户正在对该列进行排序,并返回 IID。在获取表格单元格的内容后,我们将它及其 IID 添加到 sort_index 列表中。

现在,我们可以对索引进行排序:

 sort_index.sort(reverse=reverse) 

由于我们的表格单元格值在每个元组中都是第一个,所以默认情况下元组将根据它进行排序。请注意,我们已传递了 reverse 值,以指示列表的排序方向。

现在我们已经有一个排序后的列表,我们需要相应地移动每个节点。接下来添加以下代码:

 for index, (_, iid) in enumerate(sort_index):
    tv.move(iid, parent, index) 

enumerate() 函数返回一个包含列表中每个项目及其在列表中索引的整数的元组。由于我们列表中的每个项目已经是一个元组,所以我们也将它展开,从而得到三个变量:index,列表项的索引数字;_,排序值(我们不再需要它,所以我们用下划线命名它);以及 iid

对于列表中的每个项目,我们调用 Treeview.move(),它接受三个参数:我们想要移动的行的 IID、我们想要移动到的父节点,以及在该节点下应该插入的索引。这将有效地根据 sort_index 列表的顺序对行进行排序。

然而,到目前为止,这仅对根节点的直接子节点进行了排序。现在是我们使用递归以对所有子节点进行排序的时候了;这只需要一行额外的代码:

 for index, (_, iid) in enumerate(sort_index):
    tv.move(iid, parent, index)
    **sort(tv, col, parent=iid, reverse=reverse)** 

for 循环的最后一行再次调用 sort() 函数,这次传入子 IID 作为父节点,并传递所有其他相同的参数。sort() 将继续递归调用自身,直到达到没有子节点的节点。在没有子节点的情况下,对 sort() 的调用将返回而不做任何事情。这样,所有包含文件的子目录都将通过它们自己的 sort() 调用单独排序。

要使用我们的 sort() 函数,我们需要将其绑定到我们的列标题上;我们可以通过再次调用 Treeview.heading() 方法来实现,如下所示:

for cid in ['#0', 'size', 'modified']:
  tv.heading(cid, command=lambda col=cid: sort(tv, col)) 

在这里,我们正在遍历每个 CID 值,调用 heading() 方法向标题添加一个 command 参数。我们以具有默认 CID 的 lambda 函数的形式这样做。

为什么使用默认参数来传递 CID?lambda函数的主体使用延迟绑定进行评估,这意味着变量的值直到主体运行时才确定。到那时,cid将是列表中的最后一个值('modified'),无论哪个列调用回调。然而,lambda函数的签名立即评估,这意味着col的默认值将是我们创建函数时cid的值。

对此函数的最后一个小修复;通常,点击标题的第二次点击将反转排序。我们可以在sort()函数内部通过调用heading()方法的第二组调用来实现这一点,这将用反转版本替换lambda函数。

sort()函数内部,添加以下代码:

 if parent == '':
    tv.heading(
      col,
      command=lambda col=col: sort(tv, col, reverse=not reverse)
    ) 

由于该函数是递归调用的,我们不希望在每次排序时调用它超过一次;因此,我们只为根节点运行此代码,由parent值是空字符串表示。在该块内部,我们重置正在排序的列上的lambda函数,这次将reverse设置为当前值的相反。

现在当你运行应用程序时,你应该能够通过点击每一列的标题以两个方向进行排序。

注意,尽管两个列包含数字,但它们是按字典顺序排序的——也就是说,就像它们是字符串一样,而不是数值。这是因为放入Treeview小部件的值隐式转换为字符串,所以Treeview.set()返回的排序值是一个字符串。要使用数值排序对这些进行排序,您需要在排序之前将它们转换回整数或浮点值。

使用 Treeview 虚拟事件

为了能够响应用户与Treeview小部件项目的交互,该小部件包括三个虚拟事件,如表中所示:

事件 生成
<<TreeviewSelect>> 当用户选择一个项目时
<<TreeviewOpen>> 当一个父项目展开以显示子项目时
<<TreeviewClose>> 当一个打开的父项目再次关闭时

例如,我们可以使用这些事件在用户打开目录时在状态栏中显示一些目录信息。首先,让我们向应用程序添加一个状态栏:

# treeview_demo.py
status = tk.StringVar()
tk.Label(root, textvariable=status).pack(side=tk.BOTTOM) 

接下来,我们将为获取有关打开目录的信息并显示的事件创建一个回调:

def show_directory_stats(*_):
  clicked_path = Path(tv.focus())
  num_children = len(list(clicked_path.iterdir()))
  status.set(
    f'Directory: {clicked_path.name}, {num_children} children'
  ) 

当用户点击一个项目以打开它时,该项目获得焦点,因此我们可以使用 treeview 的focus()方法来获取被点击项目的 IID。我们将它转换为Path,并使用Path对象的iterdir()方法计算目录中的子对象数量。然后,我们使用该信息更新status变量。

现在,我们可以将此回调绑定到适当的虚拟事件,如下所示:

tv.bind('<<TreeviewOpen>>', show_directory_stats)
tv.bind('<<TreeviewClose>>', lambda _: status.set('')) 

除了将打开事件绑定到我们的回调函数外,我们还把关闭事件绑定到一个清除状态控制变量的 lambda 函数。现在,运行演示脚本并点击一个目录。你应该会在状态栏中看到一些信息。再次点击它,信息就会消失。

使用 Treeview 实现记录列表

现在我们已经了解了如何使用 Treeview 小部件,是时候实现一个 GUI,它将允许我们浏览 CSV 文件中的记录并打开它们进行编辑。让我们花点时间来规划我们需要创建的内容:

  • 我们希望将 CSV 数据以表格结构进行布局,类似于它在电子表格中的样子。这将是一个平面表格,而不是一个层次结构。

  • 每个表格行将代表文件中的一个记录。当用户双击行或高亮显示并按 Enter 键时,我们希望记录表单以选定的记录打开。

  • 我们实际上不需要在表中显示每个字段,因为它的目的仅仅是定位记录以进行编辑。相反,我们将只显示那些对用户唯一标识记录的行。具体来说,这些是 Date(日期)、Time(时间)、Lab(实验室)和 Plot(绘图)。我们还可以显示 CSV 行号。

  • 实际上没有必要对数据进行排序,所以我们不会实现排序。目的是可视化 CSV 文件,其顺序不应该改变。

为了使所有这些工作,我们首先将实现一个使用树视图显示所有记录并允许选择记录的小部件。然后,我们将通过应用程序的其他组件并集成新的功能。让我们开始吧!

创建 RecordList 类

我们将通过从 tkinter.Frame 继承来开始构建我们的 RecordList 类,就像我们处理记录表单时做的那样:

# views.py, at the end of the file
class RecordList(tk.Frame):
  """Display for CSV file contents""" 

为了避免重复代码,我们将定义树视图的列属性和默认值作为类属性。这也会使我们在以后更容易调整它们以满足不断变化的需求。将这些属性添加到类中:

# views.py, inside the RecordList class
  column_defs = {
    '#0': {'label': 'Row', 'anchor': tk.W},
    'Date': {'label': 'Date', 'width': 150, 'stretch': True},
    'Time': {'label': 'Time'},
    'Lab': {'label': 'Lab', 'width': 40},
    'Plot': {'label': 'Plot', 'width': 80}
  }
  default_width = 100
  default_minwidth = 10
  default_anchor = tk.CENTER 

回想一下,我们将显示 Date(日期)、Time(时间)、Lab(实验室)和 Plot(绘图)。对于 #0 列,我们将显示 CSV 行号。我们还为一些列设置了 width(宽度)和 anchor(锚点)值,并将 Date 字段配置为 stretch(拉伸)。在 RecordList 类的初始化器中配置 Treeview 小部件时,我们将使用这些值。

接下来是初始化方法,让我们这样开始:

# views.py, inside the RecordList class
def __init__(self, parent, *args, **kwargs):
  super().__init__(parent, *args, **kwargs)
  self.columnconfigure(0, weight=1)
  self.rowconfigure(0, weight=1) 

在这里,在运行超类初始化器之后,我们已配置网格布局以扩展第一行和第一列。这就是我们的 Treeview 小部件将被放置的地方,因此我们希望它能够占据框架上的任何可用空间。

配置 Treeview 小部件

现在我们已经准备好创建我们的 Treeview 小部件,如下所示:

# views.py, inside the RecordList.__init__() method
  self.treeview = ttk.Treeview(
    self,
    columns=list(self.column_defs.keys())[1:],
    selectmode='browse'
  )
  self.treeview.grid(row=0, column=0, sticky='NSEW') 

在这里,我们创建了一个 Treeview 小部件并将其添加到框架的布局中。我们通过从 column_defs 字典中检索键来生成 columns 列表,并排除了第一个条目(#0)。请记住,#0 是自动创建的,不应包含在 columns 列表中。我们还选择了 browse 选择模式,以便用户只能选择 CSV 文件的单独行。这将在我们与控制器通信的方式中非常重要。

接下来,我们将通过遍历 column_defs 字典来配置 Treeview 小部件的列和标题:

 for name, definition in self.column_defs.items():
      label = definition.get('label', '')
      anchor = definition.get('anchor', self.default_anchor)
      minwidth = definition.get('minwidth', self.default_minwidth)
      width = definition.get('width', self.default_width)
      stretch = definition.get('stretch', False)
      self.treeview.heading(name, text=label, anchor=anchor)
      self.treeview.column(
        name, anchor=anchor, minwidth=minwidth,
        width=width, stretch=stretch
      ) 

对于 column_defs 中的每个条目,我们正在提取指定的配置值,然后根据适当的情况将它们传递给 Treeview.heading()Treeview.column()。如果字典中没有指定值,则将使用类的默认值。

最后,我们将设置一些绑定,以便双击或按 Enter 键在记录上会导致打开记录,如下所示:

# views.py, in RecordList.__init__()
    self.treeview.bind('<Double-1>', self._on_open_record)
    self.treeview.bind('<Return>', self._on_open_record) 

事件 <Double-1> 指的是鼠标按钮 1 的双击(即左键),而 <Return> 事件表示按下 Return 或 Enter 键(取决于您的硬件上的标签)。这两个事件都绑定到一个名为 _on_open_record() 的实例方法。让我们继续实现该方法,如下所示:

# views.py, in the RecordList class
  def _on_open_record(self, *args):
    self.event_generate('<<OpenRecord>>') 

由于打开记录是在 RecordList 类外部发生的,我们只是简单地生成一个名为 <<OpenRecord>> 的自定义事件,我们的 Application 类可以监听它。当然,Application 需要知道要切换到哪个记录,因此我们需要一种方法让它能够从表中检索当前选中的行。我们将使用 Python 类的一个特性,称为属性。类属性在外部代码中看起来像是一个常规属性,但在每次评估时都会运行一个方法来确定其值。我们当然可以使用方法,但使用属性简化了类外部的访问。要创建一个属性,我们需要编写一个只接受 self 作为参数的方法并返回一个值,然后使用 @property 装饰器。我们将我们的属性命名为 selected_id;将其添加到 RecordList 类中,如下所示:

 @property
  def selected_id(self):
    selection = self.treeview.selection()
    return int(selection[0]) if selection else None 

在这个方法中,我们首先使用 selection() 方法检索所选项目的列表。此方法始终返回一个列表,即使只选择了一个项目(即使只有一个项目可以被选择)。由于我们只想返回一个 IID,如果列表中存在,则检索项目 0,如果没有任何选择,则返回 None。请记住,我们树视图中每一行的 IID 是 CSV 行号作为字符串。我们将希望将其转换为整数,以便控制器可以轻松地使用它从模型中定位 CSV 记录。

为 Treeview 添加滚动条

由于 CSV 文件将包含数百条记录,记录列表很可能会超出应用程序窗口的高度,即使应用程序已最大化。如果发生这种情况,为用户提供一个滚动条来垂直导航列表将很有帮助。

Treeview 小部件默认没有滚动条;它可以使用键盘或鼠标滚轮控件进行滚动,但用户合理地期望在可滚动的区域(如 Treeview)上有一个滚动条,以帮助他们可视化列表的大小和他们在列表中的当前位置。

幸运的是,Ttk 为我们提供了一个可以连接到我们的 Treeview 小部件的 Scrollbar 小部件。回到初始化器,让我们添加一个:

# views.py , in RecordList.__init__()
    self.scrollbar = ttk.Scrollbar(
      self,
      orient=tk.VERTICAL,
      command=self.treeview.yview
    ) 

Scrollbar 类接受两个重要的关键字参数:

  • orient:此参数确定是水平还是垂直滚动。可以使用字符串 horizontalvertical,或者使用 Tkinter 常量 tk.HORIZONTALtk.VERTICAL

  • command:此参数为滚动条移动事件提供回调。回调将传递描述发生的滚动运动的参数。

在这种情况下,我们将回调设置为树视图的 yview() 方法,该方法用于使树视图上下滚动。(另一种选择是 xview(),它将用于水平滚动。)结果是,当滚动条移动时,位置数据被发送到 Treeview.yview(),导致树视图上下滚动。

我们还需要将我们的 Treeview 与滚动条连接起来:

 self.treeview.configure(yscrollcommand=self.scrollbar.set) 

这告诉 Treeview 在滚动时,将当前垂直位置发送到滚动条小部件的 set() 方法。如果我们不这样做,我们的滚动条将不知道我们已经滚动到列表的哪个位置,也不知道列表有多长,因此无法适当地设置条形小部件的大小或位置。

在配置了我们的 Scrollbar 小部件后,我们需要将其放置在框架上。按照惯例,它应该位于被滚动的小部件的右侧,如下所示:

 self.scrollbar.grid(row=0, column=1, sticky='NSW') 

注意我们将 sticky 设置为北、南和西。北和南确保滚动条拉伸整个小部件的高度,而西确保它紧挨着其左侧的 Treeview 小部件。

填充 Treeview

现在我们已经创建并配置了我们的 Treeview 小部件,我们需要一种方法来填充它。让我们创建一个 populate() 方法来完成这个任务:

# views.py, in the RecordList class
  def populate(self, rows):
    """Clear the treeview and write the supplied data rows to it.""" 

rows 参数将接受一个字典列表,例如模型 get_all_records() 方法返回的内容。其思路是控制器将从模型获取一个列表,然后通过此方法将其传递给 RecordList

在重新填充 Treeview 之前,我们需要清空它:

# views.py, in RecordList.populate()
    for row in self.treeview.get_children():
      self.treeview.delete(row) 

要从树视图中删除记录,我们只需调用其 delete() 方法,并传入要删除的行的 IID。在这里,我们已经使用 get_children() 获取了所有行的 IID,然后逐个传递给 delete()

现在树形视图已被清除,我们可以遍历rows列表并填充表格:

 cids = self.treeview.cget('columns')
    for rownum, rowdata in enumerate(rows):
      values = [rowdata[cid] for cid in cids]
      self.treeview.insert('', 'end', iid=str(rownum),
         text=str(rownum), values=values) 

在这里我们首先创建一个列表,列出我们实际上想要从每一行获取的所有 CID,通过检索树形视图的columns值。

接下来,我们使用enumerate()函数遍历提供的数据行以生成行号。对于每一行,我们将使用列表推导式创建一个按正确顺序排列的值列表,然后使用insert()方法将列表插入到Treeview小部件的末尾。请注意,我们只是使用行号(转换为字符串)作为行的第一列的 IID 和文本。

在这个函数中最后需要做的事情是一个小的可用性调整。为了使我们的记录列表键盘友好,我们需要最初将焦点放在第一个项目上,这样键盘用户就可以立即通过箭头键开始导航它。

Treeview小部件中执行此操作实际上需要三个方法调用:

 if len(rows) > 0:
      self.treeview.focus_set()
      self.treeview.selection_set('0')
      self.treeview.focus('0') 

首先,focus_set()方法将焦点移动到Treeview小部件。接下来,selection_set('0')选择列表中的第一个记录(注意字符串0是第一个记录的 IID)。最后,focus('0')将焦点放在 IID 为0的行上。当然,我们只有在有行的情况下才这样做;如果我们对一个空的Treeview调用这些方法,我们将引发异常。

RecordList类现在已完成。现在是时候更新应用程序的其余部分以使用它了。

将记录列表添加到应用程序

现在我们有一个能够读取和更新数据的模型,以及一个能够显示文件内容的RecordList小部件,我们需要对应用程序的其余部分进行更改以使一切协同工作。具体来说,我们必须做以下事情:

  • 我们需要更新DataRecordForm以使其适合更新现有记录以及添加新记录。

  • 我们需要更新Application窗口的布局以适应新的记录列表。

  • 我们需要创建新的Application回调来处理记录加载和应用导航。

  • 最后,我们需要更新主菜单以添加新的功能选项。

让我们开始吧!

修改记录表单以进行读取和更新

只要我们还在views.py中,就让我们向上滚动查看我们的DataRecordForm类,并调整它使其能够加载和更新现有记录。

请花点时间考虑以下我们需要做出的更改:

  • 该表单需要跟踪它正在编辑的记录,或者如果是一个新记录。

  • 用户需要一些视觉指示来了解正在编辑的记录。

  • 表单需要一种方式来加载控制器提供的记录。

让我们实现这些更改。

添加当前记录属性

为了跟踪正在编辑的当前记录,我们只需使用一个实例属性。在__init__()方法中,在第一个LabelFrame小部件创建之上,添加以下代码:

# views.py, in DataRecordForm.__init__()
    self.current_record = None 

current_record实例属性最初设置为None,我们将用它来表示没有加载记录且表单正在用于创建新记录。当我们编辑记录时,我们将此值更新为引用 CSV 数据中行的整数。我们在这里可以使用 Tkinter 变量,但在这个情况下没有真正的优势,并且我们无法使用None作为值。

添加一个标签来显示正在编辑的内容

由于表单现在可能正在编辑现有记录或新记录,因此用户能够一眼看出正在发生的事情将很有帮助。为此,让我们在表单顶部添加一个Label来显示正在编辑的当前记录,如下所示:

# views.py, in DataRecordForm.__init__()
    self.record_label = ttk.Label(self)
    self.record_label.grid(row=0, column=0) 

我们将新的Label小部件放置在row 0column 0,这将导致其他小部件向下移动一行。这不会影响由_add_frame()生成的Frame小部件,因为它们使用隐式行号,但我们的笔记输入和按钮需要移动。让我们更新这些小部件到新的位置:

# views.py, in DataRecordForm.__init__()
    w.LabelInput(
      self, "Notes", field_spec=fields['Notes'],
      var=self._vars['Notes'], input_args={"width": 85, "height": 10}
    ).grid(sticky="nsew", **row=****4**, column=0, padx=10, pady=10)
    buttons = tk.Frame(self)
    buttons.grid(sticky=tk.W + tk.E, **row=****5**) 

如果这个更改导致您的系统中的表单底部超出屏幕,请随意调整笔记字段的长度!

添加load_record()方法

DataRecordForm类中最后要添加的是一种加载新记录的方法。这个方法需要接受来自控制器的一个行号和数据字典,并使用它们来更新current_record(表单中的数据)和顶部的标签。这将是一个公共方法,因为它将由控制器调用,并且它将如下开始:

 def load_record(self, rownum, data=None):
    self.current_record = rownum
    if rownum is None:
      self.reset()
      self.record_label.config(text='New Record') 

在更新current_record属性后,我们检查rownum是否为None。回想一下,这表示我们请求一个空白表单来输入新记录。在这种情况下,我们将调用reset()方法并配置标签以显示新记录

注意,这里的if条件专门检查rownum是否为None;我们不能仅仅检查rownum的真值,因为 0 是一个有效的更新rownum

如果我们有有效的rownum,我们需要它以不同的方式行动:

 else:
      self.record_label.config(text=f'Record #{rownum}')
      for key, var in self._vars.items():
        var.set(data.get(key, ''))
        try:
          var.label_widget.input.trigger_focusout_validation()
        except AttributeError:
          pass 

在这个块中,我们首先使用我们正在编辑的行号适当地设置标签。然后,我们遍历表单的_vars字典,从传递给函数的data字典中检索匹配的值。最后,我们尝试在每个变量的输入小部件上调用trigger_focusout_validation()方法,因为 CSV 文件可能包含无效数据。如果没有这样的方法(也就是说,如果我们使用的是常规 Tkinter 小部件而不是我们验证过的小部件),我们就什么也不做。

我们的形式现在已准备好加载数据记录!

更新应用程序布局

我们已经准备好了用于加载记录的表单,并且我们有了准备显示记录的记录列表。现在,我们需要将这些全部整合到主应用程序中。不过,首先,我们需要考虑如何将这两个表单容纳到我们的 GUI 布局中。

第二章设计 GUI 应用程序中,我们列出了一些可以帮助我们分组 GUI 组件并减少 GUI 杂乱的组件选项。我们选择使用框架盒来组织我们的数据输入表单;我们能否再次这样做?

这个想法的快速原型可能看起来像这样:

图 8.2:我们的应用程序布局,使用并排框架

图 8.4:使用并排框架的应用程序布局

这可能可行,但屏幕上一次显示的信息太多,用户实际上不需要同时看到所有这些信息。记录列表主要用于导航,数据输入表单用于编辑或输入数据。如果我们一次只显示一个组件可能会更好。

将这两个大型组件组织到同一个 GUI 中的另一种选项是笔记本。这种类型的小部件可以通过使用标签在 GUI 中切换多个页面。Ttk 为我们提供了一个实现此功能的Notebook小部件;你之前在第一章Tkinter 简介中已经见过它,当时我们查看 IDLE 配置对话框。它在这里可以看到:

IDLE 配置对话框中的 Ttk 笔记本标签

图 8.5:IDLE 配置对话框中的 Ttk 笔记本标签

让我们快速看一下 Ttk 的Notebook,看看它如何在应用程序中使用。

Ttk 笔记本小部件

Notebook小部件是ttk模块的一部分,因此我们不需要添加任何额外的导入就可以使用它。创建一个Notebook小部件相当简单,如下所示:

# notebook_demo.py
import tkinter as tk
from tkinter import ttk
root = tk.Tk()
notebook = ttk.Notebook(root)
notebook.grid() 

要向小部件添加页面,我们需要创建一些子小部件。让我们创建几个带有一些信息内容的Label小部件:

banana_facts = [
  'Banana trees are of the genus Musa.',
  'Bananas are technically berries.',
  'All bananas contain small amounts of radioactive potassium.'
  'Bananas are used in paper and textile manufacturing.'
]
plantain_facts = [
  'Plantains are also of genus Musa.',
  'Plantains are starchier and less sweet than bananas',
  'Plantains are called "Cooking Bananas" since they are'
  ' rarely eaten raw.'
]
b_label = ttk.Label(notebook, text='\n\n'.join(banana_facts))
p_label = ttk.Label(notebook, text='\n\n'.join(plantain_facts)) 

在这里,我们创建了一些标签作为笔记本中的页面。通常,你的笔记本页面小部件可能是Frame对象或我们的RecordListDataRecordForm组件的子类,但任何小部件都可以使用。

我们不是使用几何管理器将这些组件放置在笔记本中,而是使用小部件的add()方法,如下所示:

notebook.add(b_label, text='Bananas', padding=20)
notebook.add(p_label, text='Plantains', padding=20) 

add()方法创建一个包含给定小部件的新页面,并将其放置在笔记本的末尾。如果我们想将页面插入到其他位置,我们也可以使用insert()方法,如下所示:

notebook.insert(1, p_label, text='Plantains', padding=20) 

此方法与之前相同,只是它将索引号作为第一个参数。页面将插入到该索引位置。

两种方法都接受多个关键字参数来配置页面及其标签,如下所示:

参数 描述
text 字符串 标签上显示的文本。默认情况下,标签是空的。
padding 整数 在页面上的小部件周围添加的像素间距。
sticky 基数值(NSEW 在笔记本页面上粘滞小部件的位置。默认为NSEW
underline 整数 text中绑定键盘遍历的字母索引。
image Tkinter Photoimage 在标签上显示的图像。见第九章使用样式和主题改进外观
compound LEFT, RIGHT, CENTER, TOP, BOTTOM 如果指定了文本和图像,则显示图像的位置相对于文本。

underline选项是我们之前在其他小部件上见过的(见第三章使用 Tkinter 和 Ttk 小部件创建基本表单);然而,在ttk.Notebook小部件中,当使用该选项时,实际上会设置键盘绑定。

让我们在我们的示例笔记本上尝试一下:

notebook.tab(0, underline=0)
notebook.tab(1, underline=0) 

tab()方法类似于小部件的config()方法,允许我们在添加标签后更改配置选项。

在这种情况下,我们为两个标签都指定了underline=0,这意味着每个标签的text字符串的第一个字母将被下划线。此外,将创建一个键绑定,以便按下 Alt 键加上下划线字母的组合可以切换到相应的标签。例如,在我们的应用程序中,我们在标签Banana中下划线字母 0,所以 Alt-B 将切换到该标签;我们还在标签Plantain中下划线字母 0,所以 Alt-P 将切换到Plantain标签。

除了这些绑定之外,我们还可以通过调用其enable_traversal()方法来启用笔记本的通用键盘遍历,如下所示:

notebook.enable_traversal() 

如果调用此方法,Control-Tab 将从左到右循环遍历标签页,而 Shift-Control-Tab 将按从右到左的顺序遍历它们。

我们的代码有时可能需要选择一个标签;为此,我们可以使用select()方法,如下所示:

notebook.select(0) 

在这种情况下,我们传递整数0,表示第一个标签。我们也可以传递包含在标签中的小部件的名称,如下所示:

notebook.select(p_label) 

这同样适用于tab()方法以及任何需要标签 ID 的方法。

Notebook小部件有一个<<NotebookTabChanged>>虚拟信号,每当用户更改标签时都会生成。您可能可以使用此功能刷新页面或显示帮助信息,例如。

现在我们已经熟悉了笔记本,让我们将其整合到我们的应用程序中。

将笔记本添加到我们的应用程序中

要将Notebook小部件添加到我们的布局中,我们需要在创建DataRecordFormRecordList小部件之前在Application.__init__()中创建一个。打开application.py文件,找到当前创建DataRecordForm对象的行,并在它们上面创建一个笔记本,如下所示:

# application.py, in Application.__init__()
    self.notebook = ttk.Notebook(self)
    self.notebook.enable_traversal()
    self.notebook.grid(row=1, padx=10, sticky='NSEW') 

注意,我们为仅使用键盘的用户启用了键盘遍历,并将小部件粘附在网格的所有边上。现在,按照以下方式更新创建记录表单的行:

 self.recordform = v.DataRecordForm(
      self, 
      self.model, 
      self.settings
    )
    self.recordform.bind('<<SaveRecord>>', self._on_save)
    **self.notebook.add(self.recordform, text=****'Entry Form'****)** 

在这里,我们只是移除了对self.recordform.grid()的调用,并用self.notebook.add()替换了它。接下来,让我们创建一个RecordList类的实例并将其添加到笔记本中:

 self.recordlist = v.RecordList(self)
    self.notebook.insert(0, self.recordlist, text='Records') 

虽然我们是在第二个添加 RecordList 小部件,但我们希望它首先显示;因此,我们使用 insert() 将其添加到标签列表的开头。这样我们就完成了页面的添加,但让我们开始添加必要的回调来使它们工作。

添加和更新应用程序回调

为了以功能方式将这些新小部件组合在一起,我们需要在 Application 对象上创建一些回调方法,以便在需要时允许应用程序将用户和数据传递到 GUI 的适当区域。具体来说,我们需要创建四个方法:

  • 我们可以使用 _show_recordlist() 方法在需要时显示记录列表

  • 一个可以用来从文件数据中重新填充记录列表的 _populate_recordlist() 方法

  • 一个可以用来切换到新的、空白的记录的 _new_record() 方法

  • 我们可以调用的 _open_record() 方法来从记录列表中加载特定的记录到表单中

我们还需要修复 Application._on_save() 方法,以确保它传递给模型所有必要的信息,以便更新现有记录和创建新记录。

让我们逐一查看每个方法,创建或更新方法,并在适当的地方绑定或调用它。

_show_recordlist() 方法

我们将要编写的第一个方法是 _show_recordlist()。正如你所见,这个方法相当简单:

# application.py, in the Application class
  def _show_recordlist(self, *_):
    self.notebook.select(self.recordlist) 

编写这样一个简单的方法几乎不值得,但通过将其作为方法,我们可以轻松地将其绑定为回调,而无需使用 lambda 函数。请注意,我们可以将其编写为 self.notebook.select(0),但传递小部件引用更明确地表达了我们的意图。如果我们决定切换标签的顺序,此方法将继续工作而无需更改。

我们还希望将此回调绑定到主菜单上。回到 Application 的初始化器中,让我们将此方法添加到我们的回调函数字典中,如下所示:

# application.py, in Application.__init__()
    event_callbacks = {
      #...
      '<<ShowRecordlist>>': self._show_recordlist
    } 

我们将在下一节中添加菜单本身的必要代码。另一个我们应该调用此方法的地方是在 __init__() 的末尾,以确保当用户打开程序时记录列表被显示。在 Application.__init__() 的末尾添加此代码:

# application.py, at the end of Application.__init__()
    self._show_recordlist() 

_populate_recordlist() 方法

_populate_recordlist() 方法需要从模型中检索数据并将其传递给记录列表的 populate() 方法。我们可以这样编写它:

 def _populate_recordlist(self):
    rows = self.model.get_all_records()
    self.recordlist.populate(rows) 

然而,请记住,如果文件中的数据有问题,CSVModel.get_all_records() 可能会引发一个 Exception。捕获这个异常并采取适当的行动是控制器的责任,所以我们将这样编写该方法:

# application.py, in the Application class
  def _populate_recordlist(self):
    try:
      rows = self.model.get_all_records()
    except Exception as e:
      messagebox.showerror(
        title='Error',
        message='Problem reading file',
        detail=str(e)
      )
    else:
      self.recordlist.populate(rows) 

在这个版本中,如果我们从 get_all_records() 获取异常,我们将在错误对话框中显示其消息。然后用户将负责处理这个问题。

现在我们有了这个方法,它应该在什么时候被调用?首先,它应该在每次我们选择一个新的文件来工作时被调用;所以,让我们在_on_file_select()的末尾添加对它的调用,如下所示:

 def _on_file_select(self, *_):
    # ...
    if filename:
      self.model = m.CSVModel(filename=filename)
      **self._populate_recordlist()** 

此外,我们还需要在打开程序时填充列表,因为它将自动加载默认文件。让我们在创建记录列表小部件后立即调用此方法,如下所示:

# application.py, in Application.__init__()
    self.recordlist = v.RecordList(self)
    self.notebook.insert(0, self.recordlist, text='Records')
    **self._populate_recordlist()** 

最后,每次我们保存记录时,这也应该更新记录列表,因为新记录已经被添加到文件中。我们需要在_on_save()中添加对方法的调用,如下所示:

# application.py, in Application._on_save()
  def _on_save(self, *_):
    #...
    self.recordform.reset()
    **self._populate_recordlist()** 

现在我们的记录列表应该与我们在工作的文件状态保持同步。

_new_record()方法

接下来,我们需要一个可以打开数据记录表单以输入新记录的方法。记住,我们的DataRecordForm.load_record()方法可以将None作为记录号和数据参数,表示我们想要处理一个新记录,所以我们只需要编写一个回调来完成这个操作。

将此方法添加到Application

# application.py, in the Application class
  def _new_record(self, *_):
    self.recordform.load_record(None)
    self.notebook.select(self.recordform) 

在调用load_record()为新的记录输入准备表单后,我们使用notebook.select()将笔记本切换到记录表单。为了使用户能够调用此方法,我们将创建一个菜单项,因此我们需要在event_callbacks字典中添加另一个条目。

Application.__init__()中,按如下方式更新字典:

# application.py, in Application.__init__()
    event_callbacks = {
      #...
      **'<<NewRecord>>'****: self._new_record**
    } 

我们将在下一节中添加必要的代码到菜单中。

_open_record()方法

接下来,我们需要编写一个回调方法,当用户从记录列表中选择一个记录时,它会打开一个现有的记录。将此方法添加到Application类:

# application.py, in the Application class
  def _open_record(self, *_):
    """Open the selected id from recordlist in the recordform"""
    rowkey = self.recordlist.selected_id
    try:
      record = self.model.get_record(rowkey)
    except Exception as e:
      messagebox.showerror(
        title='Error', message='Problem reading file', detail=str(e)
      )
    else:
      self.recordform.load_record(rowkey, record)
      self.notebook.select(self.recordform) 

记住,每当记录被双击或使用 Enter 键激活时,RecordList对象都会更新其selected_id属性。我们正在检索这个 ID 号并将其传递给模型的get_record()方法。因为get_record()会调用get_all_records(),如果文件有问题,它也可能抛出异常。因此,就像我们在_populate_recordlist()中所做的那样,我们在有问题的情况下捕获异常并向用户显示其消息。

如果没有问题,我们已经检索到了数据,我们只需要传递行号和数据字典到表单的load_record()方法。最后,我们调用notebook.select()来切换到记录表单视图。

这个回调需要在用户从记录列表中选择文件时被调用。记住,我们已经编写了RecordList对象来生成一个<<OpenRecord>>事件,每当这种情况发生时。回到应用程序的初始化方法中,我们需要设置一个绑定到这个事件。

回到Application.__init__(),在创建RecordList小部件后添加此绑定,如下所示:

# application.py, inside Application.__init__()
    self.notebook.insert(0, self.recordlist, text='Records')
    self._populate_recordlist()
    **self.recordlist.bind(****'<<OpenRecord>>'****, self._open_record)** 

现在双击或按 Enter 键将打开选定的记录。

_on_save()方法

最后,现在我们的模型可以处理更新现有记录,我们需要修改调用模型 save_record() 方法的代码,以确保我们传递了它需要更新现有记录或插入新记录所需的所有信息。回想一下,我们更新了 save_record() 以接受 rownum 参数。当此值为 None 时,添加新记录;当它是一个整数时,更新指定的行号。

Application._on_save() 中,按照以下方式更新代码:

# application.py, inside Application._on_save()
    data = self.recordform.get()
    **rownum = self.recordform.current_record**
    **self.model.save_record(data, rownum)** 

回想一下,记录表单对象的 current_record 包含正在编辑的当前行的值,如果没有正在编辑的记录,则为 None。我们可以直接将此值传递给模型的 save() 方法,确保数据被保存到正确的位置。

主菜单更改

我们需要对我们应用程序进行的最后一个更改是更新主菜单,以包含用于导航应用程序的新选项;具体来说,我们需要添加一个添加新文件的命令,以及一个返回记录列表的命令。记住,Application 对象将这些操作的回调绑定到 <<ShowRecordlist>><<NewRecord>> 事件上。

对于在应用程序中导航的命令实际上并没有一个标准的位置,因此我们将创建一个新的子菜单,称为“Go”。打开 mainmenu.py 文件,让我们在初始化方法中添加一个新的子菜单:

# mainmenu.py, inside MainMenu.__init__()
    go_menu = tk.Menu(self, tearoff=False)
    go_menu.add_command(
      label="Record List",
      command=self._event('<<ShowRecordlist>>')
    )
    go_menu.add_command(
      label="New Record",
      command=self._event('<<NewRecord>>')
    ) 

在这里,我们添加了一个新的子菜单小部件,并添加了我们的两个导航命令,再次利用 _event() 方法,它为我们提供了一个生成给定事件的方法的引用。现在,在文件和选项菜单之间添加Go菜单,如下所示:

# mainmenu.py, at the end of MainMenu.__init__()
    self.add_cascade(label='File', menu=file_menu)
    **self.add_cascade(label=****'Go'****, menu=go_menu)**
    self.add_cascade(label='Options', menu=options_menu) 

测试我们的程序

到目前为止,你应该能够运行应用程序并加载如以下截图所示的示例 CSV 文件。

使用我们新的菜单和记录列表选择现有文件进行写入

图 8.6:使用我们新的菜单和记录列表选择现有文件进行写入

确保尝试打开记录、编辑并保存它,以及插入新记录和打开不同的文件。你还应该测试以下错误条件:

  • 尝试打开一个不是 CSV 文件的文件,或者字段不正确的 CSV 文件。会发生什么?

  • 打开一个有效的 CSV 文件,选择一个记录进行编辑,然后,在点击“保存”之前,选择不同的或空文件。会发生什么?

  • 打开程序的两个副本,并将它们指向保存的 CSV 文件。尝试在两个程序之间交替执行编辑或更新操作。注意发生了什么。

考虑如何解决这些问题;在某些情况下可能无法解决,用户将不得不被告知这些限制。此外,如果可能的话,尝试在不同的操作系统上执行最后一个测试。结果是否不同?

摘要

我们已经将程序从仅能追加数据输入表单转变为能够从现有文件中加载、查看和更新数据的应用程序。在这个过程中,你学习了如何更新我们的模型,使其能够读取和更新 CSV 文件。你还探索了Treeview小部件,包括其基本用法、虚拟事件和列回调。你通过创建文件浏览工具,探索了如何使用Treeview小部件与层次化数据结构协同工作。你学习了如何使用Notebook小部件组织多表单应用程序,以及如何使用Scrollbar小部件创建滚动界面。最后,你将这些概念整合到 ABQ 数据输入应用程序中,以满足用户需求。

在我们接下来的章节中,我们将学习如何修改应用程序的外观和感觉。我们将了解如何使用小部件属性、样式和主题,以及如何处理位图图形。

第九章:通过样式和主题改进外观

虽然程序可以用黑色、白色和灰度的纯文本完美运行,但微妙地使用颜色、字体和图像可以增强甚至最实用应用程序的视觉吸引力和可用性。你的数据录入应用程序也不例外,你同事带来的这一轮请求似乎需要重新调整应用程序的外观和感觉。

具体来说,你被要求解决以下这些点:

  • 你的经理已经通知你,ABQ 的公司政策要求在公司内部的所有软件上显示公司标志。你已经提供了一个公司标志图像以包含在应用程序中。

  • 数据录入人员在与表单的可读性方面存在一些问题。他们希望表单各部分之间有更多的视觉区分,以及错误消息有更高的可见性。

  • 数据录入人员还要求你在会话期间突出显示他们添加或更新的记录,以帮助他们跟踪他们的工作。

除了用户的要求外,你还想通过在按钮和菜单中添加一些图标来使你的应用程序看起来更加专业。

在本章中,我们将学习 Tkinter 的一些功能,这些功能将帮助我们解决这些问题:

  • 在 Tkinter 中处理图像 中,我们将学习如何将图片和图标添加到我们的 Tkinter GUI 中。

  • Tkinter 小部件样式化 中,我们将学习如何调整 Tkinter 小部件的颜色和视觉样式,无论是直接调整还是使用标签。

  • 在 Tkinter 中处理字体 中,我们将学习 Tkinter 中字体使用的细节。

  • 样式化 Ttk 小部件 中,我们将学习如何使用样式和主题调整 Ttk 小部件的外观。

在 Tkinter 中处理图像

为了解决公司标志问题并使我们的应用程序看起来更加美观,我们需要了解如何在 Tkinter 中处理图像。Tkinter 通过两个类提供对图像文件的访问:PhotoImage 类和 BitmapImage 类。让我们看看这些类如何帮助我们向应用程序添加图形。

Tkinter PhotoImage

许多 Tkinter 小部件,包括 LabelButton,都接受一个 image 参数,允许我们在小部件上显示图像。这个参数要求我们创建并传递一个 PhotoImage(或 BitmapImage)对象。

创建 PhotoImage 对象相对简单:

myimage = tk.PhotoImage(file='my_image.png') 

PhotoImage 通常使用关键字参数 file 调用,该参数指向一个文件路径。或者,你可以使用 data 参数指向一个包含图像数据的 bytes 对象。在两种情况下,生成的对象现在都可以在任何接受 image 参数的地方使用,例如在 Label 小部件中:

mylabel = tk.Label(root, image=myimage) 

注意,如果我们向 Label 初始化器传递了 imagetext 参数,默认情况下只会显示图像。要显示两者,我们还需要为 compound 参数提供一个值,该参数决定了图像和文本相对于彼此的排列方式。例如:

mylabel_1 = tk.Label(root, text='Banana', image=myimage)
mylabel_2 = tk.Label(
  root,
  text='Plantain',
  image=myimage,
  compound=tk.LEFT
) 

在这种情况下,第一个标签只会显示 imagetext 不会显示。在第二个中,由于我们指定了 compound 值为 tk.LEFTimage 将显示在 text 的左侧。compound 可以是 LEFTRIGHTBOTTOMTOP(小写字符串或 Tkinter 常量),它表示图像相对于文本的位置。

PhotoImage 和变量作用域

当使用 PhotoImage 对象时,必须记住你的应用程序必须保留对将保持作用域的对象的引用,直到图像显示完毕;否则,图像将不会显示。为了理解这意味着什么,请考虑以下示例:

# image_scope_demo.py
import tkinter as tk
class App(tk.Tk):
  def __init__(self):
    super().__init__()
    smile = tk.PhotoImage(file='smile.gif')
    tk.Label(self, image=smile).pack()
App().mainloop() 

如果你运行这个示例,你会注意到没有图像被显示。这是因为持有 PhotoImage 对象的变量 smile 是一个局部变量,因此一旦初始化器返回,它就会被销毁。由于没有对 PhotoImage 对象的引用,它被丢弃,图像消失,即使我们已经将它打包到布局中。

让我们通过进行简单的更改来修复我们的脚本:

 def __init__(self):
    super().__init__()
    self.smile = tk.PhotoImage(file='smile.gif')
    tk.Label(self, image=self.smile).pack() 

在这种情况下,我们将 PhotoImage 对象存储在一个实例变量 self.smile 中。实例变量会持续存在,直到对象本身被销毁,因此图片会留在屏幕上。

使用 Pillow 扩展图像支持

Tkinter 的图像支持仅限于 GIF、PGM、PPM 和 PNG 文件。如果你只是将标志和图标添加到 GUI 中,这些格式可能足够了,但对于需要更多图形的场景,缺少像 JPEG、SVG 和 WebP 这样的常见格式变得相当受限。如果你需要支持这些格式中的任何一种,你可以使用 Pillow 库。

Pillow 不是标准库的一部分,也不是大多数 Python 发行版的一部分。要安装它,请遵循 python-pillow.org 上的说明;尽管在大多数情况下,你只需在终端中输入以下内容即可:

$ pip install -U pillow 

这将使用 Python 包索引PyPI)安装 Pillow。Pillow 为我们提供了一个名为 ImageTk 的类,我们可以使用它从广泛的各种图像文件格式创建 PhotoImage 对象。为了了解它是如何工作的,让我们构建一个带有过滤器的基于 Tkinter 的小型图像查看器。

打开一个名为 image_viewer_demo.py 的新文件,并从以下代码开始:

# image_viewer_demo.py
import tkinter as tk
from tkinter import ttk
from tkinter import filedialog
from PIL import Image, ImageTk, ImageFilter 

注意,Pillow 被导入为 PIL。实际上,Pillow 是一个已停止的项目 PIL(Python Imaging Library)的分支。为了向后兼容,它继续使用 PIL 模块名称。我们从 PIL 导入 Image 类,用于加载图像;ImageTk 类,用于将 Pillow Image 对象转换为 Tkinter 可用;以及 ImageFilter,它将为我们提供一些用于转换图像的过滤器。

接下来,让我们为这个应用程序创建主应用程序类 PictureViewer

class PictureViewer(tk.Tk):
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self.title('My Image Viewer')
    self.geometry('800x600')
    self.rowconfigure(0, weight=1)
    self.columnconfigure(0, weight=1) 

此类首先继承自Tk,就像我们在 ABQ 应用程序中所做的那样,初始化器从一些基本的窗口和网格布局配置开始。接下来,我们将创建 GUI 元素,如下所示:

 self.image_display = ttk.Label(self)
    self.image_display.grid(columnspan=3)
    ttk.Button(
      self, text='Select image', command=self._choose_file
    ).grid(row=1, column=0, sticky='w') 

到目前为止,我们只有一个用于显示图像的Label小部件和一个绑定到实例方法self._choose_file()Button小部件。让我们创建这个方法,如下所示:

 def _choose_file(self):
    filename = filedialog.askopenfilename(
      filetypes=(
        ('JPEG files', '*.jpg *.jpeg *.JPG *.JPEG'),
        ('PNG files', '*.png *.PNG'),
        ('All files', '*.*')
      ))
    if filename:
      self.image = Image.open(filename)
      self.photoimage = ImageTk.PhotoImage(self.image)
      self.image_display.config(image=self.photoimage) 

此方法首先使用我们在第七章中学习的filedialog.askopenfilename()方法请求用户输入文件名,创建菜单和 Tkinter 对话框。如果用户选择了一个文件,我们调用Image.open()方法从文件创建一个 Pillow Image对象。Image.open()是一个便利方法,它只需一个文件名或路径,就返回一个包含该文件图像数据的Image对象。接下来,我们通过将Image对象传递给ImageTk.PhotoImage()创建一个 Tkinter PhotoImage对象。最后,我们使用新的PhotoImage对象更新我们的image_display小部件。

使用这种方法,你可以在 Tkinter 中显示更广泛的各种图像格式——Pillow 对超过 40 种不同的格式提供了完整的读取支持!然而,Pillow 提供的不仅仅是图像格式转换。我们还可以用它以各种方式编辑或转换我们的图像。例如,我们可以将过滤应用于我们的 Pillow Image对象。让我们将此功能添加到演示应用程序中。

PictureViewer.__init__()中向上回溯,添加以下 GUI 代码:

 self.filtervar = tk.StringVar()
    filters =[
      'None', 'BLUR', 'CONTOUR', 'DETAIL', 'EDGE_ENHANCE',
      'EDGE_ENHANCE_MORE', 'EMBOSS', 'FIND_EDGES',
      'SHARPEN', 'SMOOTH', 'SMOOTH_MORE'
    ]
    ttk.Label(self, text='Filter: ').grid(
      row=1, column=1, sticky='e'
    )
    ttk.OptionMenu(
      self, self.filtervar, 'None', *filters
    ).grid(row=1, column=2)
    self.filtervar.trace_add('write', self._apply_filter) 

filters列表包含所有可以应用于Image对象的 Pillow 提供的过滤器对象的名称(这些可以在Pillow文档中找到)。我们将所有这些添加到OptionMenu中,以及字符串None。然后,我们将OptionMenu小部件绑定到filtervar控制变量,我们在其上添加了一个调用_apply_filter()方法的跟踪。

_apply_filter()方法如下所示:

 def _apply_filter(self, *_):
    filter_name = self.filtervar.get()
    if filter_name == 'None':
      self.filtered_image = self.image
    else:
      filter_object = getattr(ImageFilter, filter_name)
      self.filtered_image = self.image.filter(filter_object)
    self.photoimage = ImageTk.PhotoImage(self.filtered_image)
    self.image_display.config(image=self.photoimage) 

首先,这种方法从控制变量中检索过滤器名称。如果它是None,我们将self.filtered_image设置为当前的self.image对象。否则,我们使用getattr()ImageFilter模块检索过滤器对象,并使用其filter()方法将过滤器应用于我们的 Pillow Image对象。

最后,我们通过创建一个新的PhotoImage对象并更新Label小部件的配置来更新应用程序中显示的图像。

要查看此程序的实际运行效果,将最后两行添加到脚本中:

app = PictureViewer()
app.mainloop() 

你应该看到类似这样的东西:

图 9.1:图像查看器应用程序,过滤蒙娜丽莎

图 9.1:图像查看器应用程序,过滤蒙娜丽莎

现在我们已经掌握了在 Tkinter 中使用图像的方法,让我们将此知识应用到 ABQ 数据输入应用程序中。

将公司标志添加到 ABQ 数据输入

通过我们对PhotoImage的了解,将公司标志添加到我们的程序应该是简单的。我们提供了几个不同尺寸的公司标志 PNG 文件。

你可以将其中一个复制到应用程序根目录,并在Application类的初始化器中添加类似的内容:

# application.py, in Application.__init__()
    self.logo = tk.PhotoImage(file='abq_logo_32x20.png')
    ttk.Label(
      self, text="ABQ Data Entry Application",
      font=("TkDefaultFont", 16),
      image=self.logo, compound=tk.LEFT
    ).grid(row=0) 
PhotoImage object from a file path, storing it as an instance variable so it does not go out of scope. Then, we've assigned this object to the image argument of the application's title label, also adding the compound argument so that the image is displayed to the left of the text.

如果你从应用程序根目录内的终端运行应用程序,这种方法是可行的。然而,如果你从任何其他目录运行它,图像就不会出现。例如,尝试从包含你的根目录的命令行中这样做:

$ cd ABQ_Data_Entry
$ python3 abq_data_entry.py
# the image will show when you run it this way.
$ cd ..
$ python3 ABQ_Data_Entry/abq_data_entry.py
# the image will not show this way. 

为什么会是这种情况,我们能做些什么来解决这个问题?

处理图像路径问题

当你只给 Python 一个没有路径的文件名来打开时,它假设文件在当前工作目录中。这是用户运行应用程序时的目录。在上面的例子中,当我们第一次运行程序时,我们的工作目录是应用程序的根目录。图像就在那个目录中,所以 Python 找到了它。第二次我们运行它时,我们的工作目录是应用程序根目录的父目录。Python 在那个目录中寻找图像,但没有找到。

如果你知道你的文件在系统中的位置,你可以提供一个绝对路径;例如,如果你在 Windows 10 上,应用程序的根目录在你的家目录中,你可以这样做:

 self.logo = tk.PhotoImage(
      file=r'C:\Users\myuser\ABQ_Data_Entry\abq_logo_32x20.png'
    ) 

然而,问题在于如果我们把代码放在系统的任何其他地方,这个参考就会失效。记住,我们的应用程序需要在 Linux 和 Windows 上运行,所以提供这样的绝对路径在跨平台上是不可行的。

上面的路径字符串前面的r使其成为一个原始字符串。当一个字符串被标记为原始字符串时,Python 不会在字符串中解释反斜杠转义序列。这使得原始字符串在 Windows 上很有用,因为 Windows 使用反斜杠作为路径分隔符。有关解决跨平台路径问题的更多详细信息,请参阅第十章维护跨平台兼容性

一种更稳健的方法是从某个已知点提供一个相对路径。每个 Python 脚本都可以访问一个名为__file__的变量,它是一个包含脚本文件路径的字符串。我们可以使用这个变量结合pathlib模块来定位应用程序根目录内的文件。

例如,我们可以这样重写我们的PhotoImage对象的配置:

 self.logo = tk.PhotoImage(
      Path(__file__).parent.parent / 'abq_logo_32x20.png'
    ) 

由于我们处于application.py中,__file__指向ABQ_Data_Entry/abq_data_entry/application.py。我们可以使用这个参考点来找到父父目录,其中包含图像文件。这将使 Python 无论当前工作目录是什么都能成功找到图像。

这种方法在功能上是可接受的,但是每次我们需要访问图像文件时,进行这类路径操作会显得相当杂乱和笨拙。让我们运用我们在第六章为应用程序的扩展规划中学到的一些组织技巧,将图像放入它们自己的模块中。

abq_data_entry目录下创建一个新的目录,命名为images,并在其中放置一个适合我们应用程序使用的 PNG 文件(示例代码中的图像具有 8x5 的宽高比,因此在这种情况下,我们使用 32x20)。

接下来,在images文件夹内创建一个__init__.py文件,我们将添加以下代码:

# images/__init__.py
from pathlib import Path
IMAGE_DIRECTORY = Path(__file__).parent
ABQ_LOGO_16 = IMAGE_DIRECTORY / 'abq_logo-16x10.png'
ABQ_LOGO_32 = IMAGE_DIRECTORY / 'abq_logo-32x20.png'
ABQ_LOGO_64 = IMAGE_DIRECTORY / 'abq_logo-64x40.png' 

在这种情况下,__file__指向ABQ_Data_Entry/abq_data_entry/images/__init__.py,因此我们可以使用这个参考点来获取我们放在ABQ_Data_Entry/abq_data_entry/images/中的所有图像文件的路径。

现在,我们的application.py模块可以像这样导入images模块:

# application.py, at the top
from . import images 

一旦导入,我们可以轻松地引用PhotoImage对象的图像路径:

# application.py, inside Application.__init__()
    self.logo = tk.PhotoImage(file=images.ABQ_LOGO_32)
    ttk.Label(
      self, text="ABQ Data Entry Application",
      font=("TkDefaultFont", 16),
      image=self.logo, compound=tk.LEFT
    ).grid(row=0) 

现在,无论您从哪个工作目录运行脚本,您都应该看到标题看起来像这样:

图 9.2:带有公司标志的 ABQ 数据输入应用程序

图 9.2:带有公司标志的 ABQ 数据输入应用程序

设置窗口图标

目前,我们应用程序的窗口图标(在窗口装饰和操作系统的任务栏中显示的图标)是 Tkinter 标志,这是任何 Tkinter 应用程序的默认设置。对我们来说,使用公司标志图像作为这个图标更有意义。我们如何实现这一点呢?

作为Tk的子类,我们的Application对象有一个名为iconphoto()的方法,它应该根据图标文件的路径设置窗口图标。不幸的是,这个方法在不同平台上的结果有些不一致。让我们继续将其添加到初始化器中,看看会发生什么。在调用super().__init__()之后添加此代码:

# application.py, inside Application.__init__()
    self.taskbar_icon = tk.PhotoImage(file=images.ABQ_LOGO_64)
    self.iconphoto(True, self.taskbar_icon) 

第一行创建了一个新的PhotoImage对象,引用了标志的较大版本。接下来,我们执行self.iconphoto()。第一个参数指示我们是否希望这个图标在所有新窗口中都是默认的,或者它只是针对这个窗口。在这里传递True使其对所有窗口都是默认的。第二个参数是我们的PhotoImage对象。

现在,当您运行应用程序时,您应该看到 ABQ 图标被用作窗口图标;它如何使用取决于平台。例如,在 Windows 上,它出现在窗口装饰中,如下所示:

图 9.3:ABQ 标志作为任务栏图标

图 9.3:ABQ 标志作为任务栏图标

下面是iconphoto在不同平台上使用的总结:

  • 在 Linux 上,它将取决于您的桌面环境,但通常,它将在任务栏或坞站以及窗口装饰中出现

  • 在 macOS 上,它将作为坞站图标出现,但不会在全局菜单或窗口本身上显示

  • 在 Windows 10 上,它将出现在窗口装饰中,但不会出现在任务栏上

造成这种不一致的部分原因是我们应用程序是一个由 Python 执行的脚本,从操作系统的角度来看,我们正在运行的程序不是 ABQ 数据输入,而是 Python。因此,您可能会在您的平台上看到 Python 标志而不是 ABQ 标志。我们将在第十六章使用 setuptools 和 cxFreeze 打包中进一步解决这个问题。

为按钮和菜单添加图标

虽然用户或公司不需要,但您觉得在按钮和菜单项的文本旁边添加一些简单的图标可以使您的应用程序看起来更令人印象深刻。不幸的是,Tkinter 没有提供任何图标主题,也无法访问操作系统的内置图标主题。因此,为了使用图标,我们首先需要获取一些 PNG 或 GIF 图像来使用。这些可以从网上多个来源获取,或者当然,您也可以自己创建。

示例代码附带了一些来自Open-Iconic项目的图标,该项目提供了一组在 MIT 许可下发布的标准应用程序图标。您可以在useiconic.com/open找到这个项目。

假设您已经获取了一些图标文件,让我们将它们添加到images文件夹中,然后按以下方式更新images/__init__.py

SAVE_ICON = IMAGE_DIRECTORY / 'file-2x.png'
RESET_ICON = IMAGE_DIRECTORY / 'reload-2x.png'
LIST_ICON = IMAGE_DIRECTORY / 'list-2x.png'
FORM_ICON = IMAGE_DIRECTORY / 'browser-2x.png' 

在这里,我们为保存重置按钮添加了图像,以及代表 GUI 的记录列表和数据输入表部分的图像。我们现在可以开始将这些添加到我们的应用程序中;例如,让我们将它们添加到DataRecordForm框架中的按钮上。首先,将images导入到views.py中,如下所示:

# views.py, at the top
from . import images 

现在,在初始化器中,让我们用图像图标更新DataRecordForm中的按钮:

# views.py, inside DataRecordForm.__init__()
    **self.save_button_logo = tk.PhotoImage(file=images.SAVE_ICON)**
    self.savebutton = ttk.Button(
      buttons, text="Save", command=self._on_save,
      **image=self.save_button_logo, compound=tk.LEFT**
    )
    #...
    **self.reset_button_logo = tk.PhotoImage(file=images.RESET_ICON)**
    self.resetbutton = ttk.Button(
      buttons, text="Reset", command=self.reset,
      **image=self.reset_button_logo, compound=tk.LEFT**
    ) 

现在,表单应该看起来像这样:

图 9.4:现在带有图标的记录表单中的按钮

图 9.4:现在带有图标的记录表单中的按钮

记住我们还可以将图像添加到Notebook小部件的标签页上。回到application.py,找到在__init__()中创建笔记本标签的代码,并按以下方式更新它:

# application.py, inside Application.__init__()
    **self.recordform_icon = tk.PhotoImage(file=images.FORM_ICON)**
    self.recordform = v.DataRecordForm(
      self, self.model, self.settings
    )
    self.notebook.add(
      self.recordform, text='Entry Form',
      **image=self.recordform_icon, compound=tk.LEFT**
    )
    #...
    **self.recordlist_icon = tk.PhotoImage(file=images.LIST_ICON)**
    self.recordlist = v.RecordList(self)
    self.notebook.insert(
      0, self.recordlist, text='Records',
      **image=self.recordlist_icon, compound=tk.LEFT**
    ) 

这就像在笔记本的add()insert()方法调用中添加一个image参数一样简单。与按钮和标签一样,务必包含compound参数,否则只会显示图标。现在,当我们运行应用程序时,标签应该看起来像这样:

图 9.5:带有图标的笔记本标签

图 9.5:带有图标的笔记本标签

如您所见,使用图标的流程相当一致:

  1. 创建一个PhotoImage对象,确保对其的引用将保持作用域。

  2. 将对象传递给您希望它显示的小部件的image参数。

  3. 将小部件的compound参数传递给指定将显示文本和图像的小部件的布局。

而不是为每个图标创建一个单独的类属性,你可能发现将它们存储在字典对象中更有效率。例如,我们应在MainMenu类中这样做,因为我们需要很多图标。将images导入到mainmenu.py中,就像你在其他两个文件中所做的那样,并在MainMenu中创建一个新的_create_icons()实例方法,如下所示:

# mainmenu.py, in the MainMenu class
  def _create_icons(self):
    self.icons = {
      'file_open': tk.PhotoImage(file=images.SAVE_ICON),
      'record_list': tk.PhotoImage(file=images.LIST_ICON),
      'new_record': tk.PhotoImage(file=images.FORM_ICON),
    } 

在这里,我们使用实例方法创建一个PhotoImage对象的字典,并将其存储为实例属性self.icons。你可能想知道为什么我们不创建MainMenu.icons作为类属性,类似于我们为模型创建的fields字典。

原因是PhotoImage对象,就像所有 Tkinter 对象一样,必须在创建Tk实例(在我们的案例中是Application对象)之后才能创建。

类定义以及因此类属性,在 Python 开始执行主执行线程之前由 Python 执行,所以在定义此类时,将不存在Application对象。

我们可以在初始化器内部调用此方法,以确保在定义菜单之前self.icons已被填充;添加如下代码:

# mainmenu.py, inside the MainMenu class
  def __init__(self, parent, settings, **kwargs):
    super().__init__(parent, **kwargs)
    self.settings = settings
    **self._create_icons()** 

现在,每个菜单项都可以通过字典访问其PhotoImage对象,如下所示:

# mainmenu.py, inside MainMenu.__init__()
    file_menu.add_command(
      label="Select file…", command=self._event('<<FileSelect>>'),
      **image=self.icons['file_open'], compound=tk.LEFT**
    )
    #...
    go_menu.add_command(
      label="Record List", command=self._event('<<ShowRecordlist>>'),
      **image=self.icons['record_list'], compound=tk.LEFT**
    )
    go_menu.add_command(
      label="New Record", command=self._event('<<NewRecord>>'),
      **image=self.icons['new_record'], compound=tk.LEFT**
    ) 

现在,我们的菜单展示了一些看起来专业的图标,如图所示:

图 9.6:带有一些精美图标的“前往”菜单

图 9.6:带有一些精美图标的“前往”菜单

使用 BitmapImage

使用PhotoImage与 PNG 文件配合已经足够满足我们的应用需求,但 Tkinter 中还有另一个值得提及的图像选项:BitmapImageBitmapImage对象与PhotoImage类似,但仅限于与XBM(X11 位图)文件协同工作。这是一个非常古老的图像格式,仅允许单色图像。尽管是单色的,XBM 图像并未压缩,因此其大小并不小于同等大小的 PNG 文件。BitmapImage对象的唯一真正优势在于,我们可以告诉 Tkinter 以我们想要的任何颜色渲染它。

要了解这是如何工作的,让我们在我们的images模块中添加一些 XBM 文件;复制一些 XBM 文件,然后像这样将它们添加到__init__.py中:

QUIT_BMP = IMAGE_DIRECTORY / 'x-2x.xbm'
ABOUT_BMP = IMAGE_DIRECTORY / 'question-mark-2x.xbm' 

样本代码中包含了一些 XBM 文件;或者,你可以使用像 GNU Image Manipulation Program(www.gimp.org)这样的图像编辑软件将你的图像文件转换为 XBM。

现在,回到mainmenu.py,让我们将它们添加到我们的icons字典中,如下所示:

# mainmenu.py, in MainMenu._create_icons()
    self.icons = {
      #...
      **'quit': tk.BitmapImage(**
        **file=images.QUIT_BMP, foreground='red'**
      **),**
      **'about': tk.BitmapImage(**
        **file=images.ABOUT_BMP,**
        **foreground='#CC0', background='#A09'**
      **)**
    } 

如你所见,创建BitmapImage与创建PhotoImage对象相同,但可以指定图像的前景色背景色。一旦创建,将它们添加到菜单项的操作与使用PhotoImage相同,如下所示:

# mainmenu.py, inside MainMenu.__init__()
    help_menu.add_command(
      label='About…', command=self.show_about,
      **image=self.icons['about'], compound=tk.LEFT**
    )
    #...
    file_menu.add_command(
      label="Quit", command=self._event('<<FileQuit>>'),
      **image=self.icons['quit'], compound=tk.LEFT**
    ) 

现在,帮助菜单应该有一个彩色的图标,如图所示:

图 9.7:现在彩色的关于图标

图 9.7:现在彩色的关于图标

如果你希望重复使用单个文件但具有不同的颜色,或者可能需要动态更改图标颜色方案以适应主题或指示某种状态,那么BitmapImage对象可能会很有用。不过,大多数情况下,使用PhotoImage对象会更合适。

这些图像极大地改变了我们应用程序的外观,但其余部分仍然是相当单调的灰色。在接下来的几节中,我们将着手更新其颜色。

Tkinter 小部件样式

Tkinter 本质上有两个样式系统:旧的 Tkinter 小部件系统和较新的 Ttk 系统。尽管我们尽可能使用 Ttk 小部件,但仍然有一些情况下需要使用常规的 Tkinter 小部件,因此了解这两个系统都是好的。让我们首先看看较旧的 Tkinter 系统,并给我们的应用程序中的 Tkinter 小部件应用一些样式。

小部件颜色属性

如你在第一章Tkinter 简介中看到的,基本的 Tkinter 小部件允许你更改两个颜色值:前景色,主要指文本和边框的颜色,以及背景色,指小部件的其余部分。这些可以通过foregroundbackground参数,或它们的别名fgbg来设置。

例如,我们可以这样设置标签的颜色:

# tkinter_color_demo.py
import tkinter as tk
l = tk.Label(text='Hot Dog Stand!', fg='yellow', bg='red') 

颜色的值可以是颜色名称字符串或 CSS 风格的 RGB 十六进制字符串。

例如,以下代码会产生相同的效果:

l2 = tk.Label(
  text='Also Hot Dog Stand!',
  foreground='#FFFF00',
  background='#FF0000'
) 

Tkinter 识别了超过 700 种命名颜色,大致对应于 Linux 和 Unix 上使用的 X11 显示服务器所识别的颜色,或者网络设计师使用的 CSS 命名颜色。完整的列表,请参阅www.tcl.tk/man/tcl8.6/TkCmd/colors.htm

在 MainMenu 上使用小部件属性

在我们的视图中,我们并没有使用很多 Tkinter 小部件,尽可能多地使用 Ttk。我们确实使用 Tkinter 小部件的一个地方是应用程序的主菜单。我们可以使用主菜单来演示如何配置 Tkinter 小部件的颜色。

注意,在菜单系统上设置颜色和其他外观选项仅在 Linux 或 BSD 上一致地工作。在 Windows 或 macOS 上的效果不完整,因此那些平台上的读者可能会看到不完整的结果。在第十章维护跨平台兼容性中,我们将重新设计我们的菜单,以便考虑到这些兼容性差异。

tk.Menu小部件接受以下与外观相关的参数:

参数 描述
background 颜色字符串 正常条件下的背景色
foreground 颜色字符串 正常条件下的前景色(文本)颜色
borderwidth 整数 正常条件下的小部件边框宽度,以像素为单位
activebackground 颜色字符串 当小部件处于活动状态(被悬停或通过键盘选中)时的背景色
activeforeground 颜色字符串 当 widget 处于活动状态时的前景(文字)颜色
activeborderwidth 整数 当 widget 处于活动状态时,边框的宽度,以像素为单位
disabledforeground 颜色字符串 当 widget 被禁用时的前景(文字)颜色
relief Tkinter 常量之一RAISEDSUNKENFLATRIDGESOLIDGROOVE 绘制在 widget 周围的边框样式

注意,对于正常和活动状态,以及禁用状态,都有backgroundforegroundborderwidth的版本,还有一个用于禁用状态的foreground版本。根据 widget 适用的内容,许多 Tkinter widget 支持某些状态、条件或功能的额外参数;例如,具有可选文本的 widget,如Entry widget,支持highlightbackgroundhighlightforeground参数来指定文本被选中时使用的颜色。

Tcl/Tk 文档在www.tcl.tk/man/提供了关于 widget 特定选项的最完整参考,包括样式选项。

打开mainmenu.py文件,让我们在初始化方法中为我们的菜单添加一些样式:

# mainmenu.py, inside MainMenu.__init__()
    self.configure(
      background='#333',
      foreground='white',
      activebackground='#777',
      activeforeground='white',
      'relief'=tk.GROOVE
    ) 

运行应用程序,并注意菜单的外观。在 Linux 或 BSD 上,它应该看起来像这样:

图 9.8:在 Ubuntu Linux 上的样式化 Tkinter 菜单

图 9.8:在 Ubuntu Linux 上的样式化 Tkinter 菜单

注意,样式不会超出主菜单;子菜单仍然是默认的灰色背景黑色文字。为了使菜单保持一致,我们需要将这些样式应用到所有子菜单上。为了避免重复,让我们修改我们的代码,将样式存储在字典中,然后我们可以在每次调用tk.Menu时解包这些样式。按照以下方式更新代码:

# mainmenu.py, inside MainMenu.__init__()
    self.styles = {
      'background': '#333',
      'foreground': 'white',
      'activebackground': '#777',
      'activeforeground': 'white',
      'relief': tk.GROOVE
    }
    self.configure(**self.styles) 

现在,为了将样式添加到每个子菜单,我们只需要在每个子菜单初始化时添加**self.styles**,如下所示:

# mainmenu.py, inside MainMenu.__init__()
    help_menu = tk.Menu(self, tearoff=False, ****self.styles**)
    #...
    file_menu = tk.Menu(self, tearoff=False, ****self.styles**)
    #...
    options_menu = tk.Menu(self, tearoff=False, ****self.styles**)
    #...
    go_menu = tk.Menu(self, tearoff=False, ****self.styles**) 

假设你的平台支持菜单样式,你现在应该看到子菜单上也应用了样式。

使用标签对 widget 内容进行样式设置

对于像按钮和标签这样的简单小部件,前景色和背景色就足够了,但对于更复杂的 Tkinter 小部件,如Text小部件或 Ttk Treeview小部件,它们依赖于基于标签的系统来进行更详细的样式设置。在 Tkinter 中,标签是 widget 内容的一个命名区域,可以将颜色和字体设置应用于该区域。为了了解这是如何工作的,让我们构建一个粗糙但相当漂亮的 Python 终端模拟器。

打开一个名为tags_demo.py的新文件,我们首先创建一个Text小部件来存储终端的输入和输出:

# tags_demo.py
import tkinter as tk
text = tk.Text(width=50, height=20, bg='black', fg='lightgreen')
text.pack() 

在这里,我们使用了fgbg参数来设置绿色背景黑色文字的终端主题,这是程序员中流行的经典组合。然而,我们不想只有绿色文字,让我们为我们的提示符和解释器输出配置不同的颜色。

要做到这一点,我们需要定义一些标签:

text.tag_configure('prompt', foreground='magenta')
text.tag_configure('output', foreground='yellow') 

tag_configure()方法允许我们在Text小部件上声明和配置标记。我们创建了一个名为prompt的标记,用于外壳提示的洋红色文本,另一个名为output的标记,用于 Python 输出的黄色文本。注意,我们在这里并不限于单个配置参数;如果我们愿意,我们可以传递fontbackground参数。

要插入应用了给定标记的文本,我们执行以下操作:

text.insert('end', '>>> ', ('prompt',)) 

如您所记得,Text.insert()方法接受索引和字符串作为其前两个参数。注意第三个参数:这是我们想要标记插入文本的标记的元组。这个值必须是一个元组,即使你只使用一个标记;当然,你可以包括你想要的任意多个标记。

如果你将text.mainloop()添加到代码末尾并运行它,你会看到我们有一个黑色文本输入窗口和一个洋红色提示符;然而,如果你输入任何内容,你的文本将以绿色显示(小部件的默认前景色)。到目前为止,一切顺利;现在,让我们让它执行一些 Python 代码。

在调用mainloop()之前创建一个函数:

def on_return(*args):
  cmd = text.get('prompt.last', 'end').strip() 

回想一下,当我们从Text小部件检索文本时,我们必须为要检索的文本提供起始和结束索引。我们可以像这里这样在我们的索引值中使用标记名称:prompt.last告诉 Tkinter 从标记为prompt的区域结束之后开始检索文本。

接下来,让我们执行输入的命令:

 if cmd:
    try:
      output = str(eval(cmd))
    except Exception as e:
      output = str(e) 

如果cmd变量实际上包含任何内容,我们将尝试使用eval()执行它,然后将响应值的字符串存储为输出。如果引发异常,我们将异常转换为字符串并将其设置为输出。

注意,eval()只对表达式有效,所以我们的“外壳”无法处理循环、条件或其他语句。

然后,我们将像这样显示我们的输出:

 # (still in the if block)
    text.insert('end', '\n' + output, ('output',)) 

在这里,我们插入我们的output字符串,前面带有换行符并标记为output

我们将通过给用户返回一个提示符来完成函数:

 text.insert('end', '\n>>> ', ('prompt',))
  return 'break' 

注意,我们在这里也返回了字符串break。这告诉 Tkinter 忽略触发回调的原始事件。由于我们将从 Return/Enter 键盘中触发,我们希望在完成之后忽略该键盘中断。如果不这样做,键盘中断将在我们的函数返回后执行,在提示符显示后插入换行符,并使用户停留在提示符下的行。

最后,我们需要将我们的函数绑定到 Return 键:

text.bind('<Return>', on_return) 

注意,Enter/Return 键的事件始终是<Return>,即使在非 Apple 硬件上(那里的键通常标记为“Enter”)。

确保在脚本末尾添加对text.mainloop()的调用,然后启动应用程序。你应该会得到类似以下内容:

图 9.9:多彩的 Python 外壳

图 9.9:多彩的 Python 外壳

虽然这个外壳不会很快取代 IDLE,但它看起来相当不错,不是吗?

使用标签对记录列表进行样式化

虽然Treeview是一个 Ttk 小部件,但它使用标签来控制单个行的样式。我们可以利用这一功能来满足您从数据录入人员那里收到的另一个请求——具体来说,他们希望记录列表能够突出显示当前会话中更新和插入的记录。

我们首先需要让我们的RecordList对象跟踪会话期间哪些行已被更新或插入。

我们将从RecordList.__init__()开始,创建一些实例变量来存储会话期间更新或插入的行:

# views.py, inside RecordList.__init__()
    super().__init__(parent, *args, **kwargs)
    **self._inserted = list()**
    **self._updated = list()** 

当记录被插入或更新时,我们需要将其行号追加到相应的列表中。由于RecordList不知道何时更新或插入记录,我们需要创建一些公共方法,供Application对象调用以追加到列表中。在RecordList类中创建这两个方法:

# views.py, inside RecordList
  def add_updated_row(self, row):
    if row not in self._updated:
      self._updated.append(row)
  def add_inserted_row(self, row):
    if row not in self._inserted:
      self._inserted.append(row) 

每个方法都接受一个行号并将其追加到相应的列表中。为了避免重复,我们只有在行不在列表中时才这样做。现在,为了使用这些方法,我们需要更新Application._on_save()方法,使其在记录保存后、重新填充记录列表之前调用适当的更新方法。

_on_save()中,在调用self.model.save_record()之后,添加以下行:

# application.py, in Application._on_save()
    if rownum is not None:
      self.recordlist.add_updated_row(rownum) 

更新操作有一个rownum值,该值不为None,但可能是0,因此我们在这里明确检查None,而不是仅仅使用if rownum:。如果rownum不为None,我们将将其追加到更新列表中。

现在,我们需要处理插入操作:

 else:
      rownum = len(self.model.get_all_records()) -1
      self.recordlist.add_inserted_row(rownum) 

插入的记录稍微麻烦一些,因为我们没有现成的行号来记录。不过,我们知道插入的记录总是追加到文件的末尾,因此其行号应该比文件中的行数少一。

我们插入和更新的记录将保留到程序会话结束(当用户退出程序)或直到用户选择一个新的文件来工作。如果用户选择了一个新的文件,我们需要清除列表,因为我们正在处理一组全新的记录。

再次强调,由于RecordList不知道何时发生这种情况,我们需要创建一个公共方法来清除列表。将以下clear_tags()方法添加到RecordList类中:

# views.py, inside RecordList
  def clear_tags(self):
    self._inserted.clear()
    self._updated.clear() 

现在,我们需要在Application类中选择保存新文件时调用此方法,这发生在Application._on_file_select()中。在重新填充记录列表之前,添加对方法的调用:

# application.py inside Application._on_file_select()
    if filename:
      self.model = m.CSVModel(filename=filename)
      **self.recordlist.clear_tags()**
      self._populate_recordlist() 

现在我们已经正确更新了这些列表,我们需要使用它们来为列表项着色。

要做到这一点,我们首先需要配置带有适当颜色的标签。我们的数据录入人员认为,浅绿色是插入记录的合理颜色,浅蓝色是更新记录的颜色。

RecordList.__init__()的末尾添加以下代码:

# views.py, inside RecordList.__init__()    
    self.treeview.tag_configure(
      'inserted', background='lightgreen'

    self.treeview.tag_configure('updated', background='lightblue') 

正如我们在之前的Text小部件中所做的那样,我们调用TreeView对象的tag_configure()方法来将background颜色设置与我们的标签名称连接起来。为了将标签添加到我们的TreeView行中,我们需要更新populate()方法,以便在插入行时,添加适当的标签(如果有的话)。

populate()方法的for循环中,在插入行之前,我们将添加以下代码:

# views.py, inside RecordList.populate()
    for rownum, rowdata in enumerate(rows):
      values = [rowdata[cid] for cid in cids]
      if rownum in self._inserted:
        tag = 'inserted'
      elif rownum in self._updated:
        tag = 'updated'
      else:
        tag = '' 

现在,我们的treeview.insert()调用只需要修改为这个标签值:

 self.treeview.insert(
        '', 'end', iid=str(rownum),
        text=str(rownum), values=values, **tag=tag**
      ) 

运行应用程序并尝试插入和更新一些记录。

你应该得到类似这样的结果:

图 9.10:具有样式行的 treeview

图 9.10:具有样式行的 treeview。浅蓝色对应于更新的行(行 0)和浅绿色对应于插入的行(行 1)。请注意,深蓝色行只是选中的行(行 2)。

除了TextTreeview小部件外,标签也用于 Tkinter 的Canvas小部件,我们将在第十五章中了解更多关于它的内容,即《使用 Canvas 小部件可视化数据》。

在 Tkinter 中处理字体

一些我们的数据输入用户抱怨应用程序的字体有点太小,难以阅读,但其他人不喜欢你增加它的想法,因为它使得应用程序太大,不适合屏幕。为了满足所有用户的需求,我们可以添加一个配置选项,允许他们设置首选的字体大小和家族。

配置 Tkinter 字体

Tkinter 中任何显示文本的小部件都允许我们指定一个字体,通常通过其font配置属性。对于支持标签的小部件,我们还可以为每个标签指定字体设置。我们自第一章《Tkinter 简介》以来一直在使用font参数,但现在是我们深入探究 Tkinter 允许我们用字体做什么的时候了。

在 Tkinter 中指定小部件字体有三种方式:使用字符串、使用元组和使用Font对象。让我们逐一看看。

使用字符串和元组配置字体

在 Tkinter 中配置字体的最简单方法是直接使用字体指定字符串:

tk.Label(text="Format with a string", font="Times 20 italic bold") 

字符串的格式为font-family size styles,其中:

  • font-family是字体家族的名称。它只能是一个单词;不允许有空格。

  • size是一个描述大小的整数。正整数表示以点为单位的大小,负数表示以像素为单位的大小。不支持浮点值。

  • styles可以是任何有效的文本样式关键字组合。

除了字体家族之外,其他都是可选的,尽管如果你想要指定任何样式关键字,你需要指定一个大小。可用于样式的关键字包括:

  • bold用于加粗文本,或normal用于正常重量

  • italic用于斜体文本,或roman用于常规斜体

  • underline用于下划线文本

  • overstrike用于删除线文本

样式关键字的顺序无关紧要,但重量和斜体关键字是互斥的(也就是说,你不能有bold normalitalic roman)。

虽然字符串方法快速简单,但它有其缺点;首先,它无法处理名称中包含空格的字体,这在现代系统中相当常见。

要处理这样的字体,你可以使用元组格式:

tk.Label(
  text="Tuple font format",
  font=('Noto sans', 15, 'overstrike')
) 

此格式与字符串格式完全相同,不同之处在于不同的组件被写成元组中的项。

大小组件可以是一个整数或包含数字的字符串,这取决于值的来源提供了一些灵活性。

字体模块

字符串或元组方法在启动时设置少量字体更改时工作良好,但对于需要动态操作字体设置的情况,Tkinter 提供了font模块。此模块为我们提供了一些与字体相关的函数以及一个Font类,其实例可以分配给小部件并动态更改。

要使用font模块,首先必须导入:

from tkinter import font 

现在,我们可以创建一个自定义的Font对象并将其分配给一些小部件:

labelfont = font.Font(
  family='Courier', size=30,
  weight='bold', slant='roman',
  underline=False, overstrike=False
)
tk.Label(text='Using the Font class', font=labelfont).pack() 

如您所见,传递给Font初始化参数的值与字符串和元组字体规范中使用的重量和斜体值相对应。weight参数还支持使用font.NORMALfont.BOLD常量,而slant支持使用font.ITALICfont.ROMAN

一旦我们创建了一个Font对象并将其分配给一个或多个小部件,我们就可以在运行时动态地更改其某些方面。例如,我们可以创建一个按钮来切换我们字体的overstrike属性:

def toggle_overstrike():
  labelfont['overstrike'] = not labelfont['overstrike']
tk.Button(text='Toggle Overstrike', command=toggle_overstrike).pack() 

Font对象是 Python 对 Tcl/Tk 中称为命名字体的功能的接口。在 Tcl/Tk 中,命名字体只是一个与名称相关联的字体属性集合。

Tk已经预配置了几个命名的字体,如下表所示:

字体名称 默认为 用于
TkCaptionFont 系统标题字体 窗口和对话框标题栏
TkDefaultFont 系统默认字体 未指定其他项
TkFixedFont 系统固定宽度字体 Text小部件
TkHeadingFont 系统标题字体 列表和表中的列标题
TkIconFont 系统图标字体 图标标题
TkMenuFont 系统菜单字体 菜单标签
TkSmallCaptionFont 系统标题字体 子窗口、工具对话框
TkTextFont 系统输入字体 输入小部件:EntrySpinbox
TkTooltipFont 系统工具提示字体 工具提示

font模块包含一个名为names()的函数,该函数返回系统上当前命名的字体列表,包括您自己创建的字体(通过创建Font对象)。我们可以使用font.nametofont()函数从一个给定的名称生成一个Font对象。

例如,我们可以创建一个小程序来演示 Tkinter 包含的所有命名字体,如下所示:

# named_font_demo.py
import tkinter as tk
from tkinter import font
root = tk.Tk()
for name in font.names():
  font_obj = font.nametofont(name)
  tk.Label(root, text=name, font=font_obj).pack()
root.mainloop() 

在此脚本中,我们使用font.names()检索所有命名字体的列表,并通过它迭代。对于每个名称,我们使用font.nametofont()创建一个Font对象,然后创建一个显示命名字体名称并使用Font对象作为其字体的标签。

此脚本将显示您的系统上所有内置命名字体的外观。

例如,在 Ubuntu Linux 上,它们看起来像这样:

图 9.11:Ubuntu Linux 上的 Tkinter 命名字体

图 9.11:Ubuntu Linux 上的 Tkinter 命名字体

由于 Tkinter 默认使用其内置的命名字体,我们可以通过为这些默认命名字体创建Font对象并覆盖其属性来改变整个应用程序的整体外观。我们做出的更改将应用于所有没有明确字体配置的小部件。

例如,我们可以在前面的脚本中root.mainloop()之前添加一些代码,以允许我们自定义内置字体:

# named_font_demo.py
namedfont = tk.StringVar()
family = tk.StringVar()
size = tk.IntVar()
tk.OptionMenu(root, namedfont, *font.names()).pack()
tk.OptionMenu(root, family, *font.families()).pack()
tk.Spinbox(root, textvariable=size, from_=6, to=128).pack()
def setFont():
  font_obj = font.nametofont(namedfont.get())
  font_obj.configure(family=family.get(), size=size.get())
tk.Button(root, text='Change', command=setFont).pack() 

在此代码中,我们设置了三个控制变量来保存命名字体名称、字体族和大小值,然后设置了三个小部件来选择它们。第一个OptionMenu小部件使用font.names()检索所有命名字体的列表,第二个使用font.families()函数检索操作系统上可用的字体族列表(在大多数现代系统上这很可能是一个非常长的列表)。然后我们有一个Spinbox用于选择字体大小。

回调函数setFont()从所选的命名字体创建一个字体对象,然后使用所选的字体族和大小配置它。然后此函数绑定到一个按钮上。

如果现在运行此脚本,您应该能够选择任何命名字体并编辑其字体族和大小。当您点击更改时,您应该看到相关的标签根据您的选择而更改。您也可能注意到更改某些命名字体会影响您的OptionMenuSpinboxButton小部件。

例如,在 Ubuntu Linux 上,它看起来像这样:

图 9.12:Ubuntu Linux 上的命名字体编辑器

图 9.12:Ubuntu Linux 上的命名字体编辑器

在 ABQ 数据输入中为用户提供字体选项

现在我们已经了解了如何在 Tkinter 中处理字体,让我们为用户添加在应用程序中配置字体的能力。我们将允许他们选择一个大小和一个字体族,这些大小和字体族将用于应用程序中显示的所有小部件和数据。

由于用户希望在会话之间持久化此值,我们应该首先在我们的设置模型中添加font size(字体大小)和font family(字体族)的键。打开models.py并将这些添加到fields字典中,如下所示:

# models.py, inside SettingsModel
  fields = {
    # ...
    **'font size': {'type': 'int', 'value': 9}**,
    **'font family': {'type': 'str', 'value': ''}**
  } 

我们已将大小默认设置为 9 点,但字体族默认设置为空字符串。使用空字符串作为字体族值配置字体将导致 Tkinter 使用其自己的默认字体族。

记住,Application对象将读取fields字典并为每个设置设置一个控制变量,然后这些控制变量的字典将传递给我们的MainMenu对象。因此,我们的下一个任务将是为这些变量的大小和家族值创建菜单项。

打开mainmenu.py,让我们首先导入font模块:

# mainmenu.py, at the top
from tkinter import font 

现在,在MainMenu初始化方法内部,让我们为options_menu级联创建一些子菜单:

# mainmenu.py, inside MainMenu.__init__(), 
# after creating options_menu
    size_menu = tk.Menu(
      options_menu, tearoff=False, **self.styles
    )
    options_menu.add_cascade(label='Font Size', menu=size_menu)
    for size in range(6, 17, 1):
      size_menu.add_radiobutton(
        label=size, value=size,
        variable=self.settings['font size']
      )
    family_menu = tk.Menu(
      options_menu, tearoff=False, **self.styles
    )
    options_menu.add_cascade(
      label='Font Family', menu=family_menu
    )
    for family in font.families():
      family_menu.add_radiobutton(
        label=family, value=family,
        variable=self.settings['font family']
      ) 

这应该看起来很熟悉,因为我们创建了一个几乎相同的字体大小菜单,在第七章中学习 Tkinter Menu小部件时。我们允许从 6 到 16 的字体大小,这应该为用户提供足够的范围。

字体家族菜单几乎相同,除了我们从font.families()中拉取可能的值,就像我们在本章早些时候的演示脚本中所做的那样。

现在用户可以选择字体并存储他们的选择,让我们实际上让这些设置更改应用程序中的字体。要做到这一点,我们首先需要在Application类中添加一个方法,该方法将读取值并相应地更改适当的命名字体。

打开application.py;在顶部添加一个font导入语句,然后让我们将这个新的_set_font()方法添加到Application类中:

# application.py, inside the Application class
  def _set_font(self, *_):
    """Set the application's font"""
    font_size = self.settings['font size'].get()
    font_family = self.settings['font family'].get()
    font_names = (
      'TkDefaultFont', 'TkMenuFont', 'TkTextFont', 'TkFixedFont'
    )
    for font_name in font_names:
      tk_font = font.nametofont(font_name)
      tk_font.config(size=font_size, family=font_family) 

此方法首先从各自的控制变量中检索大小和家族设置。接下来,我们将遍历一个包含我们想要更改的内置命名字体的元组。TkDefaultFont将更改大多数小部件,TkMenuFont将影响主菜单,TkTextFont将更改文本输入小部件,而TkFixedFont将为我们的Text小部件设置默认值。

对于每一个,我们使用nametofont()检索一个Font对象,并使用从settings检索的值重新配置它。

此方法需要在设置最初加载后以及每次更改大小或家族值后调用。因此,让我们将以下行添加到Application._load_settings()的末尾:

# application.py, in Application._load_settings()
    self._set_font()
    self.settings['font size'].trace_add('write', self._set_font)
    self.settings['font family'].trace_add(
      'write', self._set_font
    ) 

现在,每当Application()创建新的设置控制变量时,它将设置字体并添加一个跟踪,以便在更改这些值时重新配置应用程序字体。

运行应用程序并尝试字体菜单。它应该看起来像这样:

图 9.13:将我们的 ABQ 数据输入切换为 Comic Sans

图 9.13:将我们的 ABQ 数据输入切换为 Comic Sans

Ttk 小部件的样式

我们需要解决的最后用户请求涉及我们的Ttk小部件的样式和颜色;用户要求在表单部分之间有更多的视觉区分,以及错误消息有更高的可见性。

经过一番思考和讨论,你决定按照以下方式对表单部分进行颜色编码:

  • 记录信息部分将使用米色,暗示用于纸质记录的经典曼尼拉文件夹

  • 环境数据部分将使用浅蓝色,象征着水和空气

  • 植物数据将具有浅绿色背景,象征着植物

  • 笔记输入足够独特,因此它将保持默认的灰色

为了提高错误信息的可见性,我们希望当字段有错误时,将其背景变为红色,并将错误文本本身显示为深红色。为了实现这一点,我们需要了解如何样式化 Ttk 部件。

TTk 样式分解

Ttk 部件在样式化方面比标准 Tkinter 部件有了很大的改进,它们可以以更强大和灵活的方式被样式化。这种灵活性使得 Ttk 部件能够模仿跨平台的原生 UI 控件,但这也带来了一定的代价:Ttk 样式化可能令人困惑、复杂、文档不完善,并且偶尔不一致。

为了理解 Ttk 样式,让我们从一些词汇开始,从最基本的到最复杂的:

  • Ttk 以 元素 为起点。一个元素是部件的一部分,例如边框、箭头或可以输入文本的字段。

  • 每个元素都有一组 选项,定义了颜色、大小和字体等属性。

  • 元素是通过 布局 组合成完整的部件(例如 ComboboxTreeview)。

  • 样式 是应用于部件的元素选项设置的集合。一个样式通过其名称来识别。通常,名称是 "T" 加上部件的名称,例如 TButtonTEntry,尽管有一些例外。

  • 部件也有许多 状态,这些是可开启或关闭的标志:

    • 样式可以通过将元素选项值与状态或状态组合关联的 映射 来配置。
  • 一组布局及其相关样式被称为 主题。Ttk 在不同平台上提供不同的主题集,每个平台都有一个旨在匹配其原生部件集外观的默认主题。由于每个主题可能包含具有不同样式选项的元素,因此并非每个选项都可用,也不是每个主题都有相同的效果。例如,默认 macOS 主题中的 ttk.Button 可能包含不同的元素集,与在 Windows 中使用默认主题的 ttk.Button 应用样式设置的方式不同。

如果你此时感到困惑,这是可以理解的。为了使事情更清晰,让我们深入探讨 ttk.Combobox 的结构。

探索 Ttk 部件

为了更好地了解 Ttk 部件的构建方式,请在 IDLE 中打开一个 shell 并导入 tkinterttkpprint

>>> import tkinter as tk
>>> from tkinter import ttk
>>> from pprint import pprint 

现在,创建一个 root 窗口、ComboboxStyle 对象:

>>> root = tk.Tk()
>>> cb = ttk.Combobox(root)
>>> cb.pack()
>>> style = ttk.Style() 

Style 对象可能被略微误命名;它并不指向单个 样式,而是为我们提供了一个访问点,以检查和修改当前 主题 的样式、布局和映射。

为了检查我们的 Combobox,我们首先使用 winfo_class() 方法获取其样式名称:

>>> cb_stylename = cb.winfo_class()
>>> print(cb_stylename)
TCombobox 

如预期,名称是 TCombobox,这仅仅是 T 加上小部件名称。我们可以使用这个名称来了解更多关于这个 Combobox 小部件的信息。

例如,我们可以通过将名称传递给 Style.layout() 方法来检查其布局,如下所示:

>>> cb_layout = style.layout(cb_stylename)
>>> pprint(cb_layout)
[(
  'Combobox.field',
  {
    'children': [
      (
        'Combobox.downarrow',
        {'side': 'right', 'sticky': 'ns'}
      ),
      (
        'Combobox.padding',
       {
        'children': [
          ('Combobox.textarea', {'sticky': 'nswe'})
        ],
        'expand': '1',
        'sticky': 'nswe'
      }
      )
    ],
    'sticky': 'nswe'
   }
)] 

注意,layout() 的输出可能因你的系统而异,因为布局内容取决于主题。不同的操作系统使用不同的默认主题。

返回的布局规范显示了构建此小部件所使用的元素层次结构。在这种情况下,元素是 "Combobox.field""Combobox.downarrow""Combobox.padding""Combobox.textarea"。正如你所看到的,每个元素都有与几何管理器方法中传递的类似的位置属性。

layout() 方法也可以通过传入一个新的规范作为第二个参数来替换一个样式的布局。不幸的是,由于样式是使用不可变的元组构建的,这需要替换整个布局规范——你无法仅仅调整或替换单个元素。

要查看此布局中元素可用的选项,我们可以使用 style.element_options() 方法。此方法接受一个元素名称,并返回一个可以用来更改它的选项列表。

例如:

>>> pprint(style.element_options('Combobox.downarrow'))
('background', 'relief', 'borderwidth', 'arrowcolor', 'arrowsize') 

一次又一次,这个列表可能因你的操作系统和主题设置而不同(甚至可能是空的)。

这告诉我们 Combobox 小部件的 downarrow 元素提供了 backgroundreliefborderwidtharrowcolorarrowsize 样式属性来调整其外观。要更改这些属性,我们可以使用 style.configure() 方法。

例如,让我们将箭头的颜色改为红色:

>>> style.configure('TCombobox', arrowcolor='red') 

如果你的操作系统不支持 arrowcolor 选项,请随意尝试不同的选项或切换到 alt 主题。下一节将介绍如何切换主题。

你应该看到箭头的颜色已变为红色。这就是我们为了配置静态更改所需的全部信息,但关于动态更改,比如当输入被禁用或无效时怎么办?

要进行动态更改,我们需要处理我们的小部件状态和映射。我们可以使用 state() 方法检查或更改 Combobox 的状态,如下所示:

>>> print(cb.state())
() 

state() 不带参数将返回一个包含当前设置的状态标志的元组;正如你所看到的,Combobox 小部件默认没有状态标志。我们也可以通过传递一个字符串序列来设置状态,如下所示:

>>> cb.state(['active', 'invalid'])
('!active', '!invalid')
>>> print(cb.state())
('active', 'invalid')
>>> cb.state(['!invalid'])
('invalid',)
>>> print(cb.state())
('active',) 

注意,为了关闭状态标志,我们需要在标志名称前加上一个 !。当你使用参数调用 state() 来更改值时,返回值是一个包含一组状态(或否定状态)的元组,如果应用这些状态,将撤销你刚刚设置的状态改变。因此,在这种情况下,当我们传入使 activeinvalid 状态开启的列表时,该方法返回一个将再次关闭这些状态的元组。同样,当我们传入否定 invalid 状态时,我们得到了包含 invalid 的元组。这可能在你想暂时设置小部件的状态然后返回到其之前(可能是未知)的状态的情况下很有用。

你不能为 state() 使用任何任意的字符串;它们必须是支持值之一,如下表所示:

状态 表示
active 小部件元素正被鼠标悬停
disabled 小部件的交互被关闭
focus 小部件将接收键盘事件
pressed 小部件当前正在被点击
selected 小部件已被用户选中(例如,单选按钮)
background 小部件位于非前景窗口的窗口上
readonly 小部件不允许修改
alternate 根据小部件的不同,有不同的效果
invalid 小部件包含无效数据(即验证命令返回 False
hover active 类似,但指的是整个小部件而不是一个元素

小部件如何使用这些状态中的每一个取决于小部件和主题;并非每个状态都默认配置为对每个小部件都有影响。例如,readonlyLabel 小部件没有影响,因为它一开始就是不可编辑的。

小部件状态通过使用样式映射与主题的小部件样式交互。我们可以使用 style.map() 方法检查或设置每个样式的映射。

看看 TCombobox 的默认映射:

>>> pprint(style.map(cb_stylename))
{
  'arrowcolor': [
    ('disabled', '#a3a3a3')
  ],
  'fieldbackground': [
    ('readonly', '#d9d9d9'),
    ('disabled', '#d9d9d9')
  ]
} 

如你所见,TCombobox 默认为 arrowcolorfieldbackground 选项提供了样式映射。每个样式映射是一个元组的列表,每个元组是一个或多个状态标志后面跟着元素选项的值。当所有状态标志与当前小部件的状态匹配时,该值(即元组中的最后一个字符串)生效。

默认映射在设置 disabled 标志时将箭头颜色转换为浅灰色,当设置 disabledreadonly 标志时,将字段背景转换为不同的浅灰色。

我们可以使用相同的方法设置自己的样式映射:

>>> style.map(
  'TCombobox',
  arrowcolor=[('!invalid', 'blue'), ('invalid', 'focus', 'red')]
)
{}
>>> pprint(style.map('TCombobox'))
{
  'arrowcolor': [
    ('!invalid', 'blue'), ('invalid', 'focus', 'red')
  ],
  'fieldbackground': [
    ('readonly', '#d9d9d9'), ('disabled', '#d9d9d9')
  ]
} 

在这里,我们已将 arrowcolor 属性配置为在未设置无效标志时为 blue,在同时设置了 invalidfocus 标志时为 red。请注意,虽然我们的 map() 调用完全覆盖了 arrowcolor 样式映射,但 fieldbackground 映射未受到影响。您可以单独替换每个选项的样式映射,而不会影响其他选项,尽管您为该选项指定的任何映射都会覆盖该选项的整个映射。

到目前为止,我们一直在操作 TCombobox 样式,这是所有 Combobox 小部件的默认样式。我们所做的任何更改都会影响应用程序中的每个 Combobox 小部件。如果我们只想更改特定的小部件或特定的一组小部件,我们应该怎么办?我们可以通过创建 自定义样式 来做到这一点。自定义样式必须通过在现有样式名称前添加一个名称和一个点来从现有样式派生。

例如:

>>> style.configure('Blue.TCombobox', fieldbackground='blue')
>>> cb.configure(style='Blue.TCombobox') 

Blue.TCombobox 继承了 TCombobox 的所有属性(包括我们之前配置的动态着色向下箭头),但可以通过自己的设置添加或覆盖它们,而不会影响 TCombobox。这允许您为某些小部件创建自定义样式,而不会影响相同类型的其他小部件。

我们甚至可以通过添加更多前缀来自定义我们的自定义样式;例如,样式 MyCB.Blue.TCombobox 将继承 TComboboxBlue.TCombobox 的所有样式,以及我们想要添加或覆盖的任何额外设置。

使用主题

我们可以通过更改主题来一次性改变应用中所有 Ttk 小部件的外观。请记住,主题是一组样式和布局的集合;因此,更改主题不仅会改变外观,还可能改变可用的样式选项。

Ttk 在每个操作系统平台上都提供了一组不同的主题;要查看您平台上的主题,请使用 Style.theme_names() 方法:

>>> style.theme_names()
('clam', 'alt', 'default', 'classic') 

(以下是在 Debian Linux 上可用的主题;您的可能不同。)

要查询当前主题或设置新主题,请使用 Style.theme_use() 方法:

>>> style.theme_use()
'default'
>>> style.theme_use('alt') 

如果没有参数,该方法将返回当前主题的名称。如果有参数,它将主题设置为给定的主题名称。注意,当您更改主题时,之前的样式将消失。但是,如果您切换回默认主题,您会看到您的更改被保留了。这是因为我们使用 Style.configure() 方法所做的任何更改仅影响当前正在运行的主题。

为 ABQ 数据输入添加一些颜色

现在您已经对 Ttk 主题和样式有了更深入的了解,让我们给我们的数据输入表单添加一些颜色。首先,我们将为数据记录表单中的每个 LabelFrame 小部件设置不同的背景颜色。由于我们希望配置三个相同类型的小部件,我们将需要使用自定义样式。对于每个框架,我们将创建一个自定义样式,用适当的颜色配置它,然后将它分配给框架。

首先打开views.py,让我们将以下代码添加到DataRecordForm初始化方法中:

# views.py, inside DataRecordForm.__init__()
    style = ttk.Style()
    # Frame styles
    style.configure(
      'RecordInfo.TLabelframe',
      background='khaki', padx=10, pady=10
    )
    style.configure(
      'EnvironmentInfo.TLabelframe', background='lightblue',
      padx=10, pady=10
    )
    style.configure(
      'PlantInfo.TLabelframe',
      background='lightgreen', padx=10, pady=10
    ) 

我们首先创建一个Style对象,我们可以使用它来访问和修改小部件样式。然后我们使用style.configure()方法设置基于TLabelframe的三个自定义样式,这是 Ttk Labelframe小部件的默认样式。我们已经根据我们的计划设置了颜色,并且还添加了一些样式填充。

现在,我们需要将这些样式分配给每个框架。记住,我们的LabelFrame小部件是在一个名为_add_frame()的实例方法中创建的。我们需要更新这个方法,使其接受一个style参数,我们可以将其传递给小部件。按照以下方式更新方法:

# views.py, inside the DataRecordForm class
  def _add_frame(self, label, **style=''**, cols=3):
    """Add a labelframe to the form"""
    frame = ttk.LabelFrame(self, text=label)
    **if style:**
      **frame.configure(style=style)**
    frame.grid(sticky=tk.W + tk.E)
    for i in range(cols):
      frame.columnconfigure(i, weight=1)
    return frame 

在这个版本中,我们接受一个字符串作为样式,如果传递了一个样式,我们将配置我们的LabelFrame小部件来使用它。现在,让我们更新初始化器中的_add_frame()调用,传入我们创建的自定义样式,如下所示:

# views.py, in DataRecordForm.__init__()
    r_info = self._add_frame(
      "Record Information", 'RecordInfo.TLabelframe'
    )
    #...
    e_info = self._add_frame(
      "Environment Data", 'EnvironmentInfo.TLabelframe'
    )
    #...
    p_info = self._add_frame("Plant Data", 'PlantInfo.TLabelframe') 

现在,执行应用程序,让我们看看表单。它应该看起来像这样:

图 9.14:我们尝试为记录表单框架着色的第一次尝试

图 9.14:我们尝试为记录表单框架着色的第一次尝试

如您所见,这远远不是理想的。虽然有一些颜色从小部件后面透出,但每个部分的小部件仍然是默认的灰暗颜色,甚至LabelFrame小部件的标签部分仍然是灰色。样式不会传播到子小部件,因此我们需要单独设置每个小部件以获得完整效果。

为单个表单小部件添加样式

我们可以迅速修复的第一件事是每个LabelFrame小部件的标签部分。尽管每个小部件都已分配到自定义样式,但小部件的标签元素需要显式地设置样式。我们可以通过将以下代码添加到DataRecordForm初始化器中来实现这一点:

# views.py, inside DataRecordForm.__init__()
    style.configure(
      'RecordInfo.TLabelframe.Label', background='khaki',
      padx=10, pady=10
    )
    style.configure(
      'EnvironmentInfo.TLabelframe.Label',
      background='lightblue', padx=10, pady=10
    )
    style.configure(
      'PlantInfo.TLabelframe.Label',
      background='lightgreen', padx=10, pady=10
    ) 

这正是我们创建自定义TLabelframe样式的相同方法,只不过我们添加了想要样式的单个元素名称(在这种情况下,Label)。如果你再次运行程序,你会看到现在每个框架的标签也共享框架的背景颜色。但我们还没有完成,因为我们需要所有的小部件标签都显示框架的背景颜色。

让我们考虑我们需要为哪些小部件创建自定义样式:

  • 我们需要为每个部分的Label小部件设置样式,因为我们将在记录信息、环境数据和植物数据中为这些小部件使用不同的颜色。

  • 我们需要为我们的Checkbutton设置样式,因为它使用的是其内置的标签而不是单独的标签小部件。由于目前只有一个,我们只需要为它设置一个样式。

  • 我们需要为Radiobutton小部件设置样式,因为它们也使用内置的标签。尽管如此,我们只需要一个样式,因为它们也只出现在一个表单部分中。

让我们创建这些样式:

# views.py, inside DataRecordForm.__init__()
    style.configure('RecordInfo.TLabel', background='khaki')
    style.configure('RecordInfo.TRadiobutton', background='khaki')
    style.configure('EnvironmentInfo.TLabel', background='lightblue')
    style.configure(
      'EnvironmentInfo.TCheckbutton',
      background='lightblue'
    )
    style.configure('PlantInfo.TLabel', background='lightgreen') 

现在我们已经创建了样式,我们需要将它们添加到表单中的每个小部件上。记住,LabelInput初始化器接受一个label_args字典,用于传递给其Label小部件的关键字参数,因此我们需要在那里添加标签样式。

例如,第一行应该看起来像这样:

 w.LabelInput(
      r_info, "Date",
      field_spec=fields['Date'],
      var=self._vars['Date'],
      **label_args={'style': 'RecordInfo.TLabel'}**
    ).grid(row=0, column=0)
    w.LabelInput(
      r_info, "Time",
      field_spec=fields['Time'],
      var=self._vars['Time'],
      **label_args={'style': 'RecordInfo.TLabel'}**
    ).grid(row=0, column=1)
    w.LabelInput(
      r_info, "Technician",
      field_spec=fields['Technician'],
      var=self._vars['Technician'],
      **label_args={'style': 'RecordInfo.TLabel'}**
    ).grid(row=0, column=2) 

对于Lab输入,记住我们正在使用我们的ValidatedRadioGroup小部件,它接受一个button_args字典,用于传递给单选按钮的参数。我们将必须指定一个label_args参数和一个input_args参数,以便在这些小部件上设置我们的样式,如下所示:

 w.LabelInput(
      r_info, "Lab",
      field_spec=fields['Lab'],
      var=self._vars['Lab'],
      **label_args={'style': 'RecordInfo.TLabel'}**,
      **input_args={**
        **'button_args':{'style': 'RecordInfo.TRadiobutton'}**
      **}**
    ).grid(row=1, column=0) 

继续将这些样式添加到其余的LabelInput小部件中;如果你遇到了困难,请参考书中包含的代码示例。当你完成时,应用程序应该看起来像这样:

图 9.15:带有彩色标签的应用程序

图 9.15:带有彩色标签的应用程序

这是一个显著的改进,但还不是完全到位;错误标签仍然是旧的默认颜色。让我们接下来解决这一点。

修复错误颜色

要修复错误标签,我们需要编辑我们的LabelInput小部件,使其在创建错误标签的Label小部件时,使用通过label_args字典传入的样式值。然而,我们遇到了一个复杂的问题:我们希望将错误文本设置为深红色。我们如何尊重传入样式的背景色,同时只为这个小部件定制前景色?

答案是我们可以进一步给我们的自定义样式添加前缀,以创建一个新的样式,它继承所有自定义的属性,同时添加或覆盖其自身的属性。换句话说,如果我们创建一个名为Error.RecordInfo.TLabel的样式,它将继承RecordInfo.TLabel的所有属性,同时允许我们进行额外的更改。

打开widgets.py文件,让我们看看我们是否可以在LabelInput初始化方法中实现这一点:

# widgets.py, inside LabelInput.__init__()
    error_style = 'Error.' + label_args.get('style', 'TLabel')
    ttk.Style().configure(error_style, foreground='darkred')
    self.error = getattr(self.input, 'error', tk.StringVar())
    ttk.Label(
      self, textvariable=self.error, style=error_style
    ).grid(row=2, column=0, sticky=(tk.W + tk.E)) 

在此代码中,我们从label_args字典中提取了style值,如果没有传入样式,则默认为TLabel。然后,我们通过在给定的样式前加上Error.(注意点,这很重要!)来创建一个新的样式名称。然后,我们调用Style.configure()来设置我们新样式的文本颜色为深红色。请注意,我们没有给Style对象命名;因为我们只做了一次更改,所以可以直接在创建的对象上调用configure(),然后让对象被丢弃。

现在,你应该看到错误显示小部件与背景色相匹配,但同时也以深红色显示。

在错误时对输入小部件进行样式化

将错误文本设置为深红色是对错误可见性问题的微小改进,但对于我们的色盲用户来说,这种改进至多只是微妙的,如果甚至能注意到的话。然而,我们可以利用我们对样式的知识来更进一步。而不仅仅是改变文字的颜色,让我们将输入的颜色反转,这样我们就有浅色文字在深色背景上。

要做到这一点,我们需要更新ValidatedMixin类。回想一下,我们之前实现了一个_toggle_error()方法,当控件在失去焦点时无效时,它会将前景颜色设置为红色。我们可以更新该命令以应用不同的样式到控件上,这样背景颜色也会改变。然而,有一个更好的方法。

在本章的早期,我们了解到当验证失败时,小部件会被标记为无效状态,并且 Ttk 样式可以通过样式映射将颜色和其他属性与不同的控件状态相关联。我们不需要在验证失败时明确更改样式或颜色,而是可以创建一个样式映射,在验证失败时自动更改颜色。

首先,请从ValidatedMixin类中删除对self._toggle_error()的所有调用,该类可以在_validate()方法和_focusout_invalid()方法中找到。这将使_focusout_invalid()方法为空,因此用pass替换它:

# widget.py, inside the ValidatedMixin class
  def _focusout_invalid(self, **kwargs):
    """Handle invalid data on a focus event"""
    pass 

虽然这个方法现在什么也不做,但我们保留它,因为它混合类 API 的一部分,子类可以覆盖它。实际上,你可以删除_toggle_error()方法,因为它的功能将由样式映射处理。

现在,在初始化器中,让我们为我们的控件配置一个样式和样式映射:

# widget.py, inside ValidatedMixin.__init__()
    style = ttk.Style()
    widget_class = self.winfo_class()
    validated_style = 'ValidatedInput.' + widget_class
    style.map(
      validated_style,
      foreground=[('invalid', 'white'), ('!invalid', 'black')],
      fieldbackground=[
        ('invalid', 'darkred'), 
        ('!invalid', 'white')
      ]
    )
    self.configure(style=validated_style) 

由于这是一个混合类,我们不知道我们要混合的控件的原生样式名称,所以我们使用winfo_class()方法获取它。在获取控件类之后,我们通过在类名前添加ValidatedInput来创建一个自定义样式。然后,我们调用style.map()来配置此样式在无效和非无效状态下的前景和背景颜色:无效状态将使控件在深红色背景上显示白色文字,而!invalid状态(即,如果控件没有无效标志)则是黑色文字在白色背景上。最后,我们使用self.configure()将样式应用到控件上。

如果你现在尝试这个应用程序,你可能会看到现在带有错误的字段变成了深红色,文字为白色:

图 9.16:我们新的验证样式在工作

图 9.16:我们新的验证样式在工作

也就是说,你将在 Linux 或 macOS 上看到这种情况;在 Microsoft Windows 上,字段背景将保持不变。这里发生了什么?

记住,根据我们在探索 Ttk 小部件中的早期讨论,每个平台都自带一组独特的主题,每个主题为其小部件定义了独特的布局。这些布局定义了每个小部件的各个元素以及可以为其定义的属性。这意味着某些样式属性可能在某个主题上有效,但在另一个主题上则无效。

在此情况下,Windows 的默认 Ttk 主题(vista主题)不允许更改我们输入小部件的背景颜色。我们的目标用户是 Debian Linux 上的 ABQ 数据输入,因此这不会影响他们。但如果我们能在其他平台上看到这个功能工作,那将很棒。

设置主题

一般而言,任何给定平台的默认 Ttk 主题可能是该平台上最好的选择,但外观是主观的,有时我们可能会觉得 Tkinter 处理得不对。有时,就像我们在上一节中看到的那样,我们可能需要的某些功能在默认主题中可能不起作用。有一种方法可以切换主题可能会帮助平滑一些粗糙的边缘,并让一些用户对应用程序的外观感到更加舒适。

正如我们已经看到的,查询可用主题和设置新主题相当简单。让我们创建一个配置选项来更改应用程序的主题。

构建主题选择器

主题通常不是用户需要经常更改的内容,正如我们所见,更改主题可能会撤销我们对小部件所做的样式更改。鉴于这一点,我们将通过设计一个需要重启程序才能实际更改主题的主题更改器来确保安全。

我们将首先在我们的SettingsModel类的fields字典中添加一个theme选项:

# models.py, inside the SettingsModel class
  fields = {
    #...
    **'theme': {'type': 'str', 'value': 'default'}**
    } 

每个平台都有一个主题别名为default,因此这是一个安全和合理的默认值。

接下来,我们的Application对象需要在设置加载时检查此值并应用它。将以下代码添加到Application._load_settings()方法的末尾:

 # application.py, in Application._load_settings()
    style = ttk.Style()
    theme = self.settings.get('theme').get()
    if theme in style.theme_names():
      style.theme_use(theme) 

此代码将创建一个Style对象,检索主题,然后使用theme_use()方法设置主题。如果我们给 Tkinter 一个不存在的主题,它将引发TCLError异常;为了避免这种情况,我们添加了一个if语句来确保给定的主题在theme_names()返回的列表中。

现在剩下的是创建所需的 UI 元素。就像我们处理字体选项一样,我们将为选择主题添加一个子菜单到我们的Options菜单中。

要做到这一点,请打开mainmenu.py并在顶部添加对ttk的导入语句。然后,在字体菜单之后初始化方法中添加以下代码:

# mainmenu.py, inside MainMenu.__init__()
    style = ttk.Style()
    themes_menu = tk.Menu(self, tearoff=False, **self.styles)
    for theme in style.theme_names():
      themes_menu.add_radiobutton(
        label=theme, value=theme,
        variable=self.settings['theme']
      )
    options_menu.add_cascade(label='Theme', menu=themes_menu) 

在这里,就像我们处理字体设置一样,我们只需遍历从theme_names()检索到的可用主题,并为每个主题添加一个Radiobutton项,将其绑定到我们的settings['theme']变量。

对于用户来说,可能不明显的是更改主题需要重启,所以让我们确保让他们知道这一点。

我们可以使用变量跟踪来实现这一点,如下所示:

 self.settings['theme'].trace_add(
      'write', self._on_theme_change
    ) 

每当主题更改时,此跟踪将调用self._on_theme_change()方法;让我们将此方法添加到MainMenu类的末尾:

# mainmenu.py, inside MainMenu
  @staticmethod
  def _on_theme_change(*_):
    message = "Change requires restart"
    detail = (
      "Theme changes do not take effect"
      " until application restart"
    )
    messagebox.showwarning(
      title='Warning',
      message=message,
      detail=detail
    ) 

注意,我们实际上并没有采取任何行动来更改主题;此方法仅显示警告消息框,没有其他操作。实际的设置更改由绑定到菜单复选框的控制变量处理,所以我们实际上不需要明确地做任何事情。此外,因为这个方法不需要访问实例或类,所以我们将其设为静态方法。

现在,你可以运行应用程序并尝试更改主题,然后重新启动应用程序。你应该会注意到应用程序外观的变化。例如,这里是在 Windows 上使用“clam”主题的应用程序:

图 9.17:在 Windows 上使用“clam”主题的 ABQ 数据输入

图 9.17:在 Windows 上使用“clam”主题的 ABQ 数据输入

如你所见,并非每个主题都适合我们的更改。尝试你平台上可用的不同主题。哪个主题在你的平台上看起来最好?哪些主题与我们的样式更改配合得最好?尝试它们所有,看看效果如何。

摘要

在本章中,我们对应用程序的外观和用户体验进行了全面改造,以提升美学和实用性。你学习了如何使用 PhotoImage 和 BitmapImage 在你的应用程序中使用图片和图标,以及如何使用 Pillow 扩展图像格式支持。你学习了如何为小部件分配字体,以及如何更改内置字体的设置。你学习了如何处理默认 Tkinter 小部件的颜色和字体设置,以及如何使用标签来格式化单个Treeview项和Text小部件的内容。我们探索了 Ttk 样式的复杂世界,并学习了如何基于内置默认值创建自定义样式。最后,我们将我们对样式的知识应用到 ABQ 数据输入应用程序中,使其更具美学价值和用户友好性。

在下一章中,我们将采取措施确保我们的程序在主要桌面平台上有效运行。你将学习避免跨平台陷阱的策略,特别是在 Tkinter 编程中。我们还将探索平台供应商为针对其平台开发人员提供的各种指南。

第十章:维护跨平台兼容性

关于你的应用程序的消息已经在 ABQ AgriLabs 传开,它被用作可视化和处理实验数据文件的方式。因此,它现在需要在 Windows、macOS 和 Linux 系统上同等良好地运行。幸运的是,对于你来说,Python 和 Tkinter 在这三个操作系统上都有支持,你可能会惊喜地发现你的应用程序在所有三个系统上都能无修改地运行。然而,还有一些小问题需要你解决,并保持警觉,以便你的应用程序能够在每个平台上成为良好的公民。

在本章中,我们将通过介绍以下主题来了解更多关于跨平台兼容性的内容:

  • 在《跨平台 Python 编写》中,你将学习如何保持基本的 Python 代码在多个平台上保持功能。

  • 在《跨平台 Tkinter 编写》中,你将了解影响 Tkinter 代码的特定跨平台问题。

  • 在《改进我们应用程序的跨平台兼容性》中,我们将更新我们的 ABQ 数据录入应用程序以获得更好的跨平台支持。

跨平台 Python 编写

在撰写本文时,Python 在近十种操作系统平台上都有支持,涵盖了从常见的桌面系统如 Windows 到高端商业 Unix 如 AIX,以及像 Haiku OS 这样的神秘 OS 项目。

在所有这些平台上,大多数 Python 代码无需任何重大修改即可工作,因为 Python 已被设计为将高级功能转换为每个系统上适当的低级操作。即便如此,仍然存在一些情况,其中操作系统差异无法(或尚未)抽象化,需要谨慎处理以避免特定平台的失败。

在本节中,我们将探讨一些影响跨平台 Python 的较大问题。

跨平台文件名和文件路径

文件系统可能是跨平台开发中最大的陷阱来源。尽管大多数平台共享文件和目录按层次结构排列的概念,但有一些关键差异可能会让不熟悉各种操作系统的开发者陷入困境。

路径分隔符和驱动器

当涉及到在文件系统中标识位置时,操作系统通常使用以下两种模型之一:

  • Windows/DOS:在这个模型中,每个分区或存储设备都被分配一个卷标(通常是一个字母),每个卷都有自己的文件系统树。路径由反斜杠(\)字符分隔。这个系统被 Windows、DOS 和 VMS 使用。

  • Unix:在这个模型中,有一个文件系统树,设备和分区在任意点挂载。路径由正斜杠(/)分隔。这个模型被 macOS、Linux、BSD、iOS、Android 和其他类 Unix 操作系统使用。

因此,像E:\Server\Files\python这样的路径在 Linux 或 macOS 上是没有意义的,而像/mnt/disk1/files/python这样的路径在 Windows 上同样没有意义。这可能会使得编写跨平台访问文件的代码变得相当困难,但 Python 为我们提供了一些工具来处理这些差异。

路径分隔符转换

如果你在一个 Windows 系统上使用 Unix 风格的正斜杠路径分隔符,Python 会自动将它们转换为反斜杠。这对于跨平台用途非常有用,因为在使用字符串中的反斜杠时可能会遇到问题。例如,如果你尝试在 Python 中创建字符串C:\Users,你会得到一个异常,因为\u是用于指定 Unicode 序列的转义序列,而sers\U之后的字符串)不是一个有效的 Unicode 序列。

要在字符串中使用反斜杠,你必须通过输入双反斜杠(\\)来转义它们,或者你必须使用原始字符串(通过在字符串字面量前加上r)。

注意,没有 Windows 到 Unix 路径分隔符的转换:Python 不会将反斜杠转换为 Unix 风格的正斜杠。因此,像r'\usr\bin\python'这样的路径在 macOS 或 Linux 上将无法正常工作。

os.path 模块

即使有自动路径分隔符插值,构建或硬编码路径作为字符串也是一项繁琐的工作。Python 强大的字符串操作方法使得尝试以字符串的形式处理路径变得诱人,许多程序员都尝试这样做。

结果通常是丑陋的、不可移植的代码,如下所示:

script_dir = '/'.join(some_path.split('/')[:-1]) 

虽然这种方法在大多数情况下可能有效(甚至在 Windows 上),但它容易在一些边缘情况下出错(例如,如果some_path/script.sh)。因此,Python 标准库包括了os.path模块来处理文件系统路径。

path模块似乎是一组函数和常量,有助于抽象常见的文件名和目录操作,尽管它实际上是对 Unix-like 系统的posixpath模块和 Windows 的ntpath模块的低级封装。当你导入path时,Python 会简单地检测你的操作系统并加载适当的低级库。

下表显示了对于跨平台开发者有用的某些常见的os.path函数:

函数 目的
join() 以平台适当的方式连接两个或多个路径段
expanduser() ~username快捷方式分别扩展到用户的家目录或用户名
expandvars() 扩展路径字符串中存在的任何 shell 变量
dirname() 提取路径的父目录
isfile() 确定路径是否指向一个文件
isdir() 确定路径是否指向一个目录
exists() 确定给定的路径是否存在

使用这些函数而不是直接操作路径字符串可以保证你的代码在各个平台上的一致性。

pathlib 模块

Python 标准库中较新的补充是pathlib模块。pathlib模块是对文件系统路径的一种更面向对象且稍微高级的抽象,我们在整本书中一直在使用它。与仅是一系列函数和常量的os.path不同,pathlib提供了Path对象,它代表一个文件系统位置,并提供了一系列修改路径和获取其信息的方法。

我们通常通过从其中导入Path类来使用pathlib。例如:

>>> from pathlib import Path
>>> p = Path()
>>> print(p)
.
>>> print(p.absolute())
'/home/alanm' 

Path默认为当前工作目录,但你也可以提供绝对或相对路径字符串。相对路径将相对于当前工作目录进行计算。

Path对象具有各种有用的属性和方法:

# Create a Path object for the current working directory
p = Path()
# Find the parent directory
parent = p.parent
# Check if the path /var/log exists
has_var_log = Path('/var/log').exists()
# Join Paths together, using the division operator
image_path = Path(__file__) / 'images' / 'png' 

有关这个强大库的更多信息,请参阅pathlib模块的文档:docs.python.org/3/library/pathlib.html

你应该使用os.path还是pathlib.Path?一般来说,pathlib是更好的选择,并且总体上代码更干净。然而,在某些边缘情况下,你可能需要os.path。例如,pathlib没有与expandvars()等效的功能;此外,os.path模块以函数为导向的方法在函数式编程场景中可能更有用。

大小写敏感性

平台在文件系统大小写敏感性方面也存在差异。例如,在 Linux、BSD 和 Unix 上,文件log.txtLOG.txtLoG.TxT都是不同的文件,可以在同一目录中共存。在 Windows 或 macOS(取决于你的设置),这三个名称都会指向同一个文件,并且不能在同一目录中存在三个具有这些名称的文件。

以下表格详细说明了主要操作系统的大小写敏感性:

系统 大小写敏感
Windows
macOS 默认不区分(可配置)
Linux
BSD,大多数其他 Unix 系统

大小写(不)敏感性问题通常取决于你习惯的系统:

  • 习惯于不区分大小写的系统的程序员在引用文件和路径时可能会遇到大小写使用不一致的问题。例如,你可能会将文件保存为UserSettings.json,但尝试以usersettings.JSON的形式检索它。

  • 习惯于大小写敏感系统的程序员在依赖于大小写来区分文件或目录名时可能会遇到问题。例如,你可能在同一目录下有ImportIngest.txtImportingEst.txt这两个文件。

通过以下几条基本规则可以避免这些问题:

  • 除非有充分的理由,否则请使用全小写名称作为文件和路径名称。

  • 如果你确实混合了大小写,请遵循一致的规则,这样你就不需要记住任意的使用大小写。

  • 避免使用 CamelCase 或类似依赖于大小写来表示单词分隔的命名方案。使用下划线、连字符或空格(它们在所有现代文件系统中都是有效的!)。

换句话说:将所有路径和文件名视为如果系统是大小写敏感的,但不要依赖于系统是大小写敏感的。

符号链接

符号链接是一种特殊的文件系统级构造,看起来像文件或目录,但实际上只是指向系统上另一个文件或目录的指针。它们通常用于提供文件或目录的别名,或者在不使用额外磁盘空间的情况下,使同一个文件看起来存在于多个位置。尽管它们存在于 Windows 上,但在 Linux、macOS 和其他类 Unix 系统上使用得更为普遍;因此,对于来自 Windows 环境的程序员来说,它们可能是一个混淆点。

符号链接不应与桌面快捷方式混淆,后者也存在于所有三个主要平台上。快捷方式只是桌面环境级别的元数据文件,而符号链接是在文件系统级别实现的。

文件和路径操作有时需要明确它们是在处理符号链接本身还是链接指向的文件。

例如,假设我们当前目录中有一个符号链接 secret_stuff.txt,它指向一个不存在的文件 /tmp/secret_stuff.txt。看看 os.path() 如何响应这样的文件:

>>> from os import path
>>> path.exists('secret_stuff.txt')
False
>>> path.lexists('secret_stuff.txt')
True 

正常的 path.exists() 函数会跟随链接并发现实际文件不存在。os.path 还包括一个 lexists() 函数,它会告诉我们链接是否存在,即使文件不存在。这种情况可能是一个问题;例如,你的程序可能正在尝试创建一个与损坏的符号链接同名目录。在这种情况下,os.path.exists()Path.exists() 都会返回 False,但名称冲突仍然存在,目录创建会失败。在这种情况下,检查 os.path.lexists()Path.is_symlink() 也是一个好主意。

以下表格展示了 os.path 中一些帮助处理符号链接的函数:

方法 描述
islink() 如果路径是符号链接,则返回 True
lexists() 如果路径存在,即使它是损坏的符号链接,也返回 True
realpath() 返回实际路径,解析任何符号链接到真实文件和目录

pathlib.Path 对象也具有这些与链接相关的方法:

方法 描述
is_symlink() 如果路径是符号链接,则返回 True
resolve() 返回一个路径,其中所有符号链接都已解析为真实文件和目录
lchmod() 改变符号链接的权限,而不是它指向的文件
lstat() 返回符号链接的文件系统信息,而不是它指向的文件

总结来说,我们的代码应该注意在可能引起其行为异常的情况下处理符号链接。

路径变量

大多数平台,包括 Windows、macOS 和 Linux,支持某种类型的 shell 变量,这些变量通常由系统自动设置,以指向常见的文件系统位置。os.path 模块提供了 expandvars() 函数来将这些变量展开为其实际值(pathlib 没有等效方法)。虽然这些变量在定位常见路径位置时可能很有用,但跨平台开发者应该了解它们在平台间并不一致。

在不同系统间,一些常用的变量包括以下内容:

描述 Windows macOS Linux
当前用户主目录 %HOME%, %USERPROFILE% $HOME | $HOME
临时目录 %TMP%, %TEMP% $TMPDIR
默认 shell 路径 $SHELL | $SHELL
当前工作目录 $PWD | $PWD
配置目录 %APPDATA%, %LOCALAPPDATA% $XDG_CONFIG_HOME(通常未设置)
OS 目录 %WINDIR%, %SYSTEMROOT%
程序文件目录 %PROGRAMFILES%, %PROGRAMW6432%

注意,Windows 变量可以使用本地的 %VARIABLENAME% 格式或 Unix 风格的 $VARIABLENAME 格式;macOS 和 Linux 只接受 Unix 风格格式。

使用这些变量不一定是个坏主意(它们可以帮助抽象操作系统的版本或配置之间的差异),但请注意,它们在平台间并不一致可用,甚至可能没有意义。

库和功能支持不一致

虽然可以理解许多第三方 Python 库只支持有限数量的平台,但你可能会惊讶地发现,标准库包含的模块集合根据平台略有不同。即使那些在平台上都存在的模块,其行为也可能略有不同,或者内容不一致,这取决于平台。

自然地,这些变量在跨平台应用程序中必须谨慎处理。让我们看看这些库和功能的几个例子。

Python 的平台限制库

在 Python 标准库文档的第 34 和 35 部分 (docs.python.org/3/library/index.html) 中,你可以找到仅在 Windows 或类 Unix 系统上可用的库列表。仔细阅读文档可显示,在其他部分还列出了更多平台限制库。

这是一个你可能遇到的常见平台限制库列表:

描述 可用性
ossaudiodev 开放声音系统 (OSS) 音频服务器接口 Linux, FreeBSD
winsound Windows 音频接口 Windows
msilib Windows 软件打包工具 Windows
winreg Windows 注册表工具 Windows
syslog Unix 系统日志接口 Linux, macOS, BSD
pwd, spwd Unix 密码数据库接口 Linux, macOS, BSD
resource 系统资源限制 Linux、macOS、BSD
curses 基于终端的 UI 库 Linux、macOS、BSD

在某些情况下,可以使用更高层次的跨平台库来替换这些库(例如,使用logging代替syslog),但在其他情况下,功能过于特定于平台,可能别无选择(例如winreg)。在这种情况下,在导入这些库之前,您需要进行平台检查,因为在不支持的平台上您将收到ImportError异常。

检查低级函数兼容性

即使在普遍可用的库中,有时也存在某些函数或方法,它们根据平台的不同而不可用或表现出不同的行为。os模块可能是最显著的例子。

os模块是对系统调用或命令的相对较薄的包装,尽管它试图抽象跨平台的一些大致类似调用,但许多函数过于特定于平台,无法普遍提供。

os模块的文档在docs.python.org/3/library/os.html中包含有关平台支持的完整详细信息,但这里列出了其中的一些示例:

描述 可用性
getuidgetgidgetgroupsgeteuid 获取当前进程的用户或组信息 类 Unix
setuidsetgidsetgroupsseteuid 设置当前进程的用户或组信息 类 Unix
getprioritysetpriority 获取或设置当前进程的优先级 类 Unix
chownlchown 更改文件或其符号链接的所有者 类 Unix
startfile 以双击文件的方式打开文件 Windows

尝试使用不可用的函数将导致异常,因此这些函数中没有一个应该在跨平台应用程序中使用,除非进行适当的检查或异常处理。到目前为止,os模块中大多数受平台限制的函数仅限于类 Unix 系统(Linux、macOS、BSD 等),而大多数 Windows 的类似函数可以在第三方pywin32包中找到(当然,该包仅适用于 Windows)。

通常,您需要检查您使用的库的文档,以确保它们在您打算支持的平台上都可用。当使用与操作系统功能(如窗口管理、文件系统、用户身份验证等)或仅在某些平台上可用的服务(例如 Microsoft SQL Server)交互的库时,应特别注意。

子进程模块的危险

subprocess 模块提供了在您的 Python 应用程序中启动和管理其他程序和命令的工具。对于已经熟悉其操作系统命令行界面的程序员来说,它通常提供了一种快速便捷的方式来完成文件系统操作或其他管理任务。但它也极有可能破坏跨平台兼容性!

例如,Linux 或 macOS 上的程序员可能会尝试以下方式复制文件:

import subprocess
subprocess.call(['cp', 'file1.txt', 'file2.txt']) 

这在类 Unix 操作系统上会起作用,但在 Windows 上会失败,因为 cp 不是一个有效的 Windows 命令行命令。在这种情况下,更好的选择是使用 shutil 库,它包含用于复制文件的高级函数。

为了避免这里的问题,请遵循以下指南:

  1. 在求助于 subprocess 解决问题之前,先寻找高级库。

  2. 如果您必须使用 subprocess,请仔细研究每个受支持平台上的调用命令,确保语法、输出和行为完全相同。

  3. 如果它们不是,请确保为每个平台创建不同的案例(参见下文“根据平台编写代码”部分)。

自然地,所有这些建议同样适用于任何允许您在 Python 中执行操作系统命令的第三方模块。

文本文件编码和格式

不同平台上的纯文本文件默认使用不同的字符编码和换行符。尽管大多数操作系统可以处理多种编码,但每个系统都有一个默认编码(通常由语言或本地化设置确定),如果没有指定,将使用该编码。不同平台上的文本文件也使用不同的字符代码作为换行符。

Linux 和 macOS 的现代版本使用 UTF-8 作为默认编码,并使用换行符(\n)作为行终止符。然而,Windows 10 使用 cp1252 作为其默认编码,并使用回车符和换行符的组合(\r\n)作为行终止符。大多数时候,这些差异不会造成问题,尤其是如果您只使用 Python 读取和写入文件,并且处理标准英语字符。

然而,考虑以下场景,您尝试将一个 Unicode 字符追加到文本文件中,如下所示:

with open('testfile.test', 'a') as fh:
  fh.write('\U0001F34C') 

在 Windows 或其他使用非 Unicode 默认编码的系统上,前面的代码将引发异常,如下所示:

UnicodeEncodeError: 'charmap' codec can't encode character '\U0001f34c' in position 0: character maps to <undefined> 

为了避免这个问题,您可以在打开文件时手动指定字符编码,如下所示:

with open('testfile.test', 'a', encoding='utf-8') as fh:
  fh.write('\U0001F34C') 

在使用 newline 参数打开文件时,也可以指定行终止符字符,如下所示:

with open('testfile.test', 'w', newline='\n') as fh:
  fh.write('banana') 

我们已经在 ABQ 数据录入中这样做,以解决 Windows 上 csv 模块的错误。在跨平台的情况下,指定 encodingnewline 参数以保存您不控制的数据(例如用户输入的数据)是一个好主意。

图形和控制台模式

在 Windows 上,程序将以 GUI 模式或控制台模式启动,这由可执行文件中的元数据决定。Windows 的 Python 发行版包括一个名为 Python launcher 的实用程序,它在安装期间与 Python 文件关联。Python launcher 将根据其文件扩展名在 GUI 或控制台模式下启动您的应用程序,如下所示:

  • .py 扩展名结尾的文件将使用 python.exe 在控制台模式下启动。这将导致在后台打开一个命令行窗口,程序运行期间必须保持该窗口打开。

  • .pyw 结尾的文件将使用 pythonw.exe 在 GUI 模式下启动。不会启动命令行窗口,如果从命令行运行程序,程序将不会阻塞控制台(即,提示符将立即返回,而程序仍在运行);然而,print() 将没有效果,sys.stderrsys.stdout 也不会存在。尝试访问它们将引发异常。

这种区别常常会让来自 Linux 或 macOS 的开发者感到困惑,在这些系统中,图形应用程序通常会将错误和调试信息输出到终端。即使是对于刚开始接触 GUI 应用程序的 Windows 程序员来说,GUI 应用程序没有命令行输出也可能是个问题。

为了避免问题,只需记住以下内容:

  1. 如果将代码部署到 Windows,请从代码中移除任何 sys.stdout()sys.stderr() 调用。

  2. 依靠日志记录而不是 print()sys.stderr() 调用来记录调试信息。

  3. 创建主可执行脚本的 .pyw 扩展名副本,以便 Windows 用户可以无需命令行窗口即可启动它。

虽然 macOS 不区分 GUI 和控制台应用程序(除了明显的 GUI 存在之外),但其桌面通过启动一个终端窗口来启动常规的 .py 文件,就像 Windows 一样。虽然 macOS Python 包含一个无需终端即可启动的 pythonw.exe 文件,但存在两个问题。首先,它默认不与 .pyw 扩展名关联;如果您想实现这种行为,则需要手动进行。其次,根据您如何安装 Python 3(例如,如果您使用 homebrew 安装),您的安装可能没有 pythonw

在 macOS 上设置 Python 程序以使其表现得像真正的 GUI 应用程序是有方法的,我们将在 第十六章使用 setuptools 和 cxFreeze 打包 中介绍。

编写根据平台变化的代码

如您所见,在某些情况下,您根本无法避免编写特定平台的代码,要么是因为高级库不可用,要么是因为需要在特定平台上执行的操作本质上不同。

在这种情况下,检测平台变得必要。在 Python 中,有几种方法可以做到这一点,包括os.system()函数和sys.platform属性,但标准库中的platform模块包含了确定 OS 详细信息的最有用的功能集。当调用时,platform.system()函数返回一个标识操作系统的字符串:WindowsLinuxfreebsd7Darwin(用于 macOS)。

platform模块中一些其他有用的函数包括release(),它返回 OS 的版本字符串(例如,Windows 10 上的"10",macOS High Sierra 上的"17.3.0",或 Linux 上的运行内核版本);以及architecture(),它告诉我们系统是 64 位还是 32 位。

对于代码中的简单差异,通常使用嵌套的if / else链中的此信息就足够了:

# simple_cross_platform_demo.py
import platform
import subprocess
os_name = platform.system()
if os_name in ('Darwin', 'freebsd7'):
    cmd = ['ps', '-e', '-o', "comm=''", '-c']
elif os_name == 'Linux':
    cmd = ['ps', '-e', '--format', 'comm', '--no-heading']
elif os_name == 'Windows':
    cmd = ['tasklist', '/nh', '/fo', 'CSV']
else:
    raise NotImplemented("Command unknown for OS")
processes = subprocess.check_output(cmd, text=True)
print(processes) 

此示例定义了一个基于platform.system()返回值的平台适当的命令标记列表。正确的列表被保存为cmd,然后传递给subprocess.check_output()以运行命令并获取其输出。

这对于偶尔的调用来说是可接受的,但对于更复杂的情况,将特定于平台的代码打包到后端类中是有意义的,然后我们可以根据我们的平台字符串选择这些类。例如,我们可以将上述代码重新实现如下:

# complex_cross_platform_demo/backend.py
import subprocess
import platform
class GenericProcessGetter():
  cmd = []
  def get_process_list(self):
    if self.cmd:
      return subprocess.check_output(self.cmd)
    else:
      raise NotImplementedError
class LinuxProcessGetter(GenericProcessGetter):
  cmd = ['ps', '-e', '--format', 'comm', '--no-heading']
class MacBsdProcessGetter(GenericProcessGetter):
  cmd = ['ps', '-e', '-o', "comm=''", '-c']
class WindowsProcessGetter(GenericProcessGetter):
  cmd = ['tasklist', '/nh', '/fo', 'CSV'] 

在这种方法中,我们创建了一个通用类来处理获取进程的常见逻辑,然后将其子类化以专门针对每个平台覆盖cmd属性。

现在,我们可以创建一个选择函数,当给定 OS 名称时返回适当的后端类:

# complex_cross_platform_demo/backend.py
def get_process_getter_class(os_name):
  process_getters = {
    'Linux': LinuxProcessGetter,
    'Darwin': MacBsdProcessGetter,
    'Windows': WindowsProcessGetter,
    'freebsd7': MacBsdProcessGetter
  }
  try:
    return process_getters[os_name]
  except KeyError:
    raise NotImplementedError("No backend for OS") 

现在,需要使用此类的代码可以利用此函数检索平台适当的版本。例如:

# complex_cross_platform_demo/main.py
os_name = platform.system()
process_getter = get_process_getter_class(os_name)()
print(process_getter.get_process_list()) 

现在,此脚本可以在 Linux、Windows、macOS 或 BSD 上运行以打印进程列表。可以通过创建更多的GenericProcessGetter子类并更新get_process_getter_class()轻松添加其他平台。

对于更复杂的情况,其中多个类或函数需要在平台之间以不同的方式实现,我们可以采取类似于标准库的os.path模块的方法:为每个平台实现完全独立的模块,然后根据平台导入它们,使用一个共同的别名。例如:

import platform
os_name = platform.system()
if os_name == 'Linux':
    import linux_backend as backend
elif os_name == 'Windows':
    import windows_backend as backend
elif os_name in ('Darwin', 'freebsd7'):
    import macos_bsd_backend as backend
else:
    raise NotImplementedError(f'No backend for {os_name}') 

请记住,每个后端模块理想情况下应包含相同的类和函数名称,并产生类似的输出。这样,backend就可以在代码中使用,而无需关心特定的平台。

编写跨平台 Tkinter

如您迄今为止所见,Tkinter 在各个平台上大多工作方式相同,甚至可以以最小的努力在每个平台上做正确的事情。然而,当您在多个操作系统上支持 Tkinter 应用程序时,有一些小问题需要注意。在本节中,我们将探讨更显著的不同之处。

跨平台 Tkinter 版本差异

截至 2021 年,主流平台官方 Python 3 发行版至少包含 Tcl/Tk 8.6;这是 Tcl/Tk 的最新主要版本,包含了本书中讨论的所有功能。然而,并非每个平台都包含最新的次要版本,这可能会影响错误修复和次要功能。在撰写本文时,Tcl/Tk 的最新版本是 8.6.11。

历史上,一些平台(尤其是 macOS)在发布 Tcl/Tk 最新版本方面落后。尽管在撰写本文时平台支持相当一致,但未来可能会有所差异。

要发现您系统上安装的 Tcl/Tk 的确切版本,您可以在 Python 提示符下执行以下命令:

>>> import tkinter as tk
>>> tk.Tcl().call('info', 'patchlevel') 

此代码使用 Tcl() 函数创建一个新的 Tcl 解释器,然后调用 info patchlevel 命令。以下是使用每个平台最常用的 Python 3 发行版在几个平台上运行此命令的返回结果:

平台 Python 版本 Tcl/Tk 版本
Windows 10 3.9(来自 python.org) 8.6.9
macOS High Sierra 3.9(来自 python.org) 8.6.8
Ubuntu 20.04 3.8(来自仓库) 8.6.10
Debian 10 3.7(来自仓库) 8.6.9

如您所见,这些平台中没有一个提供 Tcl/Tk 的最新版本,即使那些拥有 Python 较新版本的系统,也可能拥有较旧的 Tcl/Tk 版本。最终,如果您打算编写跨平台 Tkinter 代码,请确保您没有依赖于 Tcl/Tk 最新版本的特性。

平台间的应用程序菜单

应用程序菜单可能是能力与平台间差异最明显的区域之一。如 第七章 中所述,使用 Menu 和 Tkinter 对话框创建菜单,在设计我们的菜单时,我们应该了解主要操作系统对限制和期望。

菜单小部件功能

我们在第七章中了解到的 Menu 小部件与其他大多数 Tkinter 小部件不同,因为它依赖于底层平台的菜单功能。这允许您的应用程序拥有一个表现本地的菜单;例如,在 macOS 上,菜单出现在屏幕顶部的全局菜单区域,而在 Windows 上,它出现在任务栏下的应用程序窗口中。

由于这种设计,当与跨平台的 Menu 小部件一起工作时,存在一些限制。为了演示这一点,让我们构建一个极端的非跨平台菜单。

我们将首先创建一个带有菜单的简单 Tk 窗口,如下所示:

# non_cross_platform_menu.py
import tkinter as tk
from tkinter.messagebox import showinfo
root = tk.Tk()
root.geometry("300x200")
menu = tk.Menu(root) 

现在,我们将创建一个级联菜单:

smile = tk.PhotoImage(file='smile.gif')
smile_menu = tk.Menu(menu, tearoff=False)
smile_menu.add_command(
  image=smile,
  command=lambda: showinfo(message="Smile!")
)
smile_menu.add_command(label='test')
menu.add_cascade(image=smile, menu=smile_menu) 

smile_menu 包含两个命令,一个带有文本标签,另一个只包含图像。我们在添加菜单的级联时也使用了图像,因此它不应该有文本标签,只需一个图像即可。

在此期间,让我们添加一些颜色;在第九章通过样式和主题改进外观中,我们自定义了应用程序菜单的颜色,提到它只在 Linux 上工作。让我们看看它在其他平台上会做什么;添加以下代码:

menu.configure(foreground='white', background='navy')
smile_menu.configure(foreground='yellow', background='red') 

这样应该会使我们的主菜单显示为黑色背景上的白色文字,而级联菜单为红色背景上的黄色。

接下来,让我们在smile_menu之后的主菜单中添加一个分隔符和一条命令:

menu.add_separator()
menu.add_command(
  label='Top level command',
  command=lambda: showinfo(message='By your command!')
) 

最后,我们将在主菜单上直接创建一个Checkbutton小部件,并使用通常的样板代码来配置root并运行mainloop()方法:

boolvar = tk.BooleanVar()
menu.add_checkbutton(label="It is true", variable=boolvar)
root.config(menu=menu)
root.mainloop() 

保存此文件并执行代码。根据你的操作系统,你可能会看到一些不同的结果。

如果你使用的是 Linux(在这个例子中是 Ubuntu 20.04),它似乎大致按预期工作:

图 10.1:Ubuntu 20.04 上的菜单实验

图 10.1:Ubuntu 20.04 上的菜单实验

我们有了第一个带有笑脸 GIF 标签的级联菜单,我们的顶级菜单命令,以及我们的顶级Checkbutton(因为我们已经检查过,确实我们的菜单是工作的!)。颜色似乎也是正确的,尽管 GIF 的背景是默认的灰色,而不是我们预期的红色(GIF 本身有透明背景)。

如果你使用的是 Windows 10,你应该会看到类似这样的效果:

图 10.2:Windows 10 上的菜单实验

图 10.2:Windows 10 上的菜单实验

在顶部菜单中,我们没有微笑图标,而是只有文本(图像)。即使我们指定了一个标签,这个文本也会出现在图像应该出现的位置。幸运的是,当我们在级联菜单中使用它时,图像确实出现了。至于颜色,它们在级联菜单中按预期工作,但顶级菜单完全忽略了它们。主菜单中的命令出现并正常工作,但Checkbutton小部件没有。它的标签出现并可以点击,但复选标记本身没有出现。

最后,让我们在 macOS 上尝试这个菜单。它应该看起来像这样:

图 10.3:macOS 上的菜单实验

图 10.3:macOS 上的菜单实验

在 macOS 上,我们的菜单不是显示在程序窗口中,而是显示在屏幕顶部的全局菜单中,正如 macOS 用户所期望的那样。然而,有一些明显的问题。

首先,当我们的微笑图标出现时,它似乎被截断了。由于顶部栏的高度是固定的,Tkinter 不会为我们调整图标大小,所以大于顶部栏高度的图片会被截断。还有更大的问题:顶级命令和Checkbutton小部件都无处可寻。只有我们的级联菜单出现了。在颜色方面,顶级菜单忽略了我们的颜色,而级联菜单只尊重前景色(导致了一种相当难以阅读的灰色背景上的黄色组合)。此外,请注意,我们有一个我们没有创建的“Python”级联菜单。

在每个平台上,我们受限于菜单系统的功能,虽然看起来 Linux 上的菜单似乎可以随意设置,但其他两个操作系统在构建菜单时需要更加小心。

为了避免菜单出现任何问题,请遵循以下指南:

  • 在主菜单中避免使用命令、CheckbuttonRadiobutton项;仅使用级联菜单。

  • 不要在顶级主菜单中使用图像。

  • 不要使用颜色来设置菜单样式,至少不要在 Windows 或 macOS 上使用。

  • 如果你必须执行上述任何一点,为每个平台创建单独的菜单。

如果你为每个平台分别构建菜单,当然可以实施该平台支持的所有功能。然而,仅仅因为你在某个平台上可以使用某个功能,并不意味着你“应该”使用它。在下一节中,我们将探讨可以帮助你决定如何在每个平台上实现菜单的指南和标准。

菜单指南和标准

我们的主要平台都提供了标准,以指导开发者创建满足该系统用户期望的用户界面。虽然这些标准应考虑整个应用程序,但受其影响最明显的区域之一是应用程序菜单(或菜单栏,使用标准术语)的布局。

让我们看看每个平台可用的标准,我们将在本章创建跨平台主菜单时参考这些标准。

Windows 用户体验交互指南

微软的Windows 用户体验交互指南,可在docs.microsoft.com/en-us/windows/win32/uxguide/guidelines找到,为开发者提供了大量关于设计适合 Windows 桌面的应用程序的信息。在提供的许多关于菜单栏设计的指南中,包括对标准菜单项及其排列方式的描述。

在撰写本文时,微软刚刚发布了针对 Windows 11 和通用 Windows 平台的新指南,可在docs.microsoft.com/en-us/windows/apps/design/basics/找到。然而,这些新指南并没有提供关于菜单结构的具体指导,因此我们使用了较旧的指南。

苹果公司的人机界面指南

苹果公司的人机界面指南可在developer.apple.com/macos/human-interface-guidelines/找到,并提供了一套详细的规则,用于创建符合 macOS 的界面。

虽然菜单栏设计的基本建议与微软提供的类似,但布局建议却截然不同,并且更加具体。例如,macOS 应用程序的第一个级联菜单应该是应用程序菜单,即以应用程序命名的菜单,其中包含关于首选项等项。

Linux 和 BSD 人机界面指南

与 Windows 和 macOS 形成鲜明对比的是,Linux、BSD 和其他 X11 系统没有受到祝福的默认桌面环境或控制实体来规定 UI 标准。这些平台有十多个完整的桌面环境可供选择,每个都有其自己的目标和关于用户交互的想法。虽然有许多项目正在为这些平台创建人机界面指南HIG),但我们将遵循 Gnome 项目制定的 Gnome HIG。这组指南被 Gnome、MATE 和 XFCE 桌面使用,可在developer.gnome.org/hig/找到。Gnome 桌面是许多 Linux 发行版的默认桌面环境,包括 Red Hat、Ubuntu,以及特别值得注意的是 Debian,这是我们在 ABQ 的目标 Linux 环境。

菜单和加速键

加速键是分配给常见应用程序操作的键盘快捷键,尤其是菜单项。到目前为止,我们还没有添加任何加速键,这对仅使用键盘的用户来说是不利的。

在 Tkinter 中,可以使用bind()方法将加速键分配给小部件。我们还可以使用bind_all()方法,该方法可以在任何小部件上调用,并有效地全局绑定事件(即,即使调用bind_all()的小部件没有获得焦点)。我们的菜单项也接受一个accelerator参数,该参数可以用来指定将在菜单中显示的加速键提示字符串。

每个平台的 UI 指南定义了常见操作的标准化加速键,其中大多数在平台上是相同的,因为它们源自 20 世纪 80 年代建立的 IBM 通用用户访问CUA)标准。最显著的区别是 macOS 使用命令()键代替 Windows 和 Linux 使用的控制(Ctrl)键。

在为跨平台兼容性重写我们的应用程序菜单时,我们还将添加适合平台的加速键。

跨平台字体

第九章使用样式和主题改进外观中,我们了解到如何轻松地自定义 Tkinter 的字体以改变应用程序的外观和感觉。然而,这样做可能会导致跨平台的不一致性。

大约有 18 种字体在 macOS 和 Windows 之间共享,但它们在两个平台上并不完全相同。至于 Linux,由于许可问题,大多数发行版都不包含这 18 种字体中的任何一种。

除非你能保证特定字体在所有支持的平台上都可用,否则最好避免在样式中使用特定的字体家族名称。幸运的是,如果你恰好指定了一个不存在的字体,Tkinter 将只使用默认字体,但即使在某些情况下这也可能引起布局或可读性问题。

为了安全起见,请坚持使用 Tkinter 的命名字体,这些字体在每个平台上都自动设置为相同的默认值。

跨平台主题支持

正如我们在第九章中看到的,使用样式和主题改进外观,Ttk 提供了一系列在不同平台上不同的主题。每个平台都包含一个名为“default”的别名,它指向该平台最合理的主题。尝试设置一个不存在的主题会导致异常,因此请避免在应用程序中硬编码主题设置,并确保主题选择与Style.theme_names()的输出进行核对。

窗口缩放状态

除了最大化窗口和最小化窗口外,许多窗口环境还有一个“缩放”窗口的概念,它完全占据屏幕。在 Windows 或 macOS 上,可以通过使用根窗口的state()方法来激活 Tkinter 应用程序的缩放功能,如下所示:

from tkinter import *
root = Tk()
root.state('zoomed')
root.mainloop() 

在 Windows 或 macOS 上,这会创建一个占据整个屏幕的窗口;然而,在 Linux 或 BSD 上,它会引发异常,因为 X11 没有提供设置缩放状态的功能。

在 X11 上,可以通过以下方式打开根窗口的-zoomed属性来实现:

root.attributes('-zoomed', True) 

不幸的是,前面的代码在 Windows 和 macOS 上会引发异常。如果您需要在程序中设置此状态,您需要使用一些特定于平台的代码。

现在我们已经走过了一系列跨平台问题,让我们来看看 ABQ 数据输入,看看我们能为不同操作系统改进其行为做些什么。

提高我们应用程序的跨平台兼容性

我们的应用程序在各个平台上表现相当不错,但我们还可以做一些事情来改进它:

  • 首先,我们的应用程序将首选项存储在用户的家目录中,这在任何平台上都不是最佳选择。大多数桌面平台定义了配置文件应放置的具体位置,因此我们将修复我们的应用程序以使用这些位置来存储abq_settings.json文件。

  • 第二,我们在创建 CSV 文件时没有指定任何编码;如果用户插入了一个 Unicode 字符(比如在Notes字段中),文件保存将引发异常并在非 Unicode 平台上失败。

  • 最后,当前的菜单结构实际上并没有真正接近遵循我们所讨论的任何人类界面指南。我们将为每个平台实现独立的菜单,以确保用户有一个与其平台一致的 UI。

让我们开始吧!

正确存储首选项

每个平台定义了存储用户配置文件的适当位置:

  • Linux 和其他 X11 系统将配置文件存储在由$XDG_CONFIG_HOME环境变量定义的位置,如果没有定义,则默认为$HOME/.config

  • macOS 用户配置文件存储在$HOME/Library/Application Support/

  • Windows 用户配置文件存储在%USERPROFILE%\AppData\Local。尽管如果你的环境使用带有漫游配置文件的Active DirectoryAD),你可能更喜欢使用%HOME%\AppData\Roaming

为了在我们的应用程序中实现这一点,我们需要更新SettingsModel类。记住,我们SettingsModel类的初始化方法目前将配置文件放置在Path.home(),它返回每个平台上的用户家目录。让我们用一些特定平台的代码来更新它。

首先,打开models.py并导入platform模块,如下所示:

# models.py, at the top
import platform 

为了确定所需的目录,我们需要获取平台名称以及一些环境变量。os.environ变量是一个包含系统上设置的环境变量的字典。由于我们已经在models.py文件中导入了os,我们可以使用os.environ来检索我们需要的变量。

SettingsModel类中,我们将创建一个字典来查找正确的配置目录,如下所示:

 config_dirs = {
    "Linux": Path(
      os.environ.get('$XDG_CONFIG_HOME', Path.home() / '.config')
    ),
    "freebsd7": Path(
      os.environ.get('$XDG_CONFIG_HOME', Path.home() / '.config')
    ),
    'Darwin': Path.home() / 'Library' / 'Application Support',
    'Windows': Path.home() / 'AppData' / 'Local'
  } 

在每种情况下,我们都将一个平台名称与一个指向每个平台默认配置目录的pathlib.Path对象匹配。现在,在SettingsModel初始化器内部,我们只需要使用platform.system()的值来查找正确的目录。

按如下方式更新__init__()方法:

 def __init__(self):
    filename = 'abq_settings.json'
    **filedir = self.config_dirs.get(platform.system(), Path.home())**
    self.filepath = filedir / filename
    self.load() 

如果平台不在我们的列表中,我们只需默认使用Path.home()将配置文件放置在用户的家目录中。否则,文件应该正确放置在平台上。

现在当你运行应用程序时,你应该会发现任何之前的偏好设置都被重置为默认设置(因为我们现在正在不同的位置查找配置文件),并且如果你保存新的偏好设置,文件abq_settings.json将出现在你的平台配置目录中。

为我们的 CSV 文件指定编码

我们的应用程序目前正在使用系统的默认编码保存 CSV 文件。如果 Windows 用户尝试使用 Unicode 字符,这可能会成为一个问题。

models.py中,我们需要找到CSVModel类中的三个open()实例,并指定一个编码,如下例所示:

# models.py, in CSVModel.save_record()
    with open(
      self.filename, 'a', encoding='utf-8', newline=''
    ) as fh: 

确保更新models.py中的所有open()调用,包括SettingsModel中的调用。有了这个更改,Unicode 字符应该不再有问题。

创建适合平台的菜单

创建特定平台的菜单将比之前的修复更复杂。我们的基本方法将是创建多个菜单类,并使用选择函数返回适当的类,如前一小节所述。

在我们能够这样做之前,我们需要准备我们的MainMenu类,使其更容易被继承。

准备我们的MainMenu

目前,我们的MainMenu类的配置的大部分工作发生在__init__()中。然而,对于每个平台,我们都需要以不同的结构构建菜单,并且对于某些命令有一些不同的细节。为了使这更简单,我们将采取组合方法,其中我们将菜单创建分解为许多离散的方法,然后我们可以在每个子类中按需组合这些方法。

我们首先要做的是将其名称更改,以便更清楚地说明其作用:

class GenericMainMenu(tk.Menu):
  styles = dict() 

我们还创建了一个空的styles字典作为类属性。由于菜单样式并不是在所有平台上都得到很好的支持,这个空字典可以作为一个占位符,这样我们就可以通过简单地覆盖这个属性来在需要时应用样式。

接下来,我们将为创建每个菜单项创建单独的方法。因为这些项可能会根据平台的不同添加到不同的菜单中,所以每个方法都将接受一个menu参数,该参数将用于指定它将添加到哪个Menu对象。

让我们从创建用于创建我们的选择文件退出命令条目的方法开始:

 def _add_file_open(self, menu):
    menu.add_command(
      label='Select file…', command=self._event('<<FileSelect>>'),
      image=self.icons.get('file'), compound=tk.LEFT
  )
  def _add_quit(self, menu):
    menu.add_command(
      label='Quit', command=self._event('<<FileQuit>>'),
      image=self.icons.get('quit'), compound=tk.LEFT
    ) 

接下来,创建添加自动填充设置选项的方法:

 def _add_autofill_date(self, menu):
    menu.add_checkbutton(
      label='Autofill Date', variable=self.settings['autofill date']
    )
  def _add_autofill_sheet(self, menu):
    menu.add_checkbutton(
      label='Autofill Sheet data',
      variable=self.settings['autofill sheet data']
    ) 

对于我们具有自己子菜单的字体选项,我们将创建创建整个子菜单的方法,如下所示:

 def _add_font_size_menu(self, menu):
    font_size_menu = tk.Menu(self, tearoff=False, **self.styles)
    for size in range(6, 17, 1):
      font_size_menu.add_radiobutton(
        label=size, value=size,
        variable=self.settings['font size']
      )
    menu.add_cascade(label='Font size', menu=font_size_menu)
  def _add_font_family_menu(self, menu):
    font_family_menu = tk.Menu(self, tearoff=False, **self.styles)
    for family in font.families():
      font_family_menu.add_radiobutton(
        label=family, value=family,
        variable=self.settings['font family']
    )
    menu.add_cascade(label='Font family', menu=font_family_menu) 

注意,我们在定义级联菜单时使用了self.styles字典;尽管在这个类中它是空的,但我们希望如果定义了样式,它们将应用到所有菜单。

对于主题菜单,我们将做同样的事情,并设置显示警告消息的跟踪,如下所示:

 def _add_themes_menu(self, menu):
    style = ttk.Style()
    themes_menu = tk.Menu(self, tearoff=False, **self.styles)
    for theme in style.theme_names():
      themes_menu.add_radiobutton(
        label=theme, value=theme,
        variable=self.settings['theme']
      )
    menu.add_cascade(label='Theme', menu=themes_menu)
    self.settings['theme'].trace_add('write', self._on_theme_change) 

最后,让我们添加用于导航和“关于”命令的最后三个方法:

 def _add_go_record_list(self, menu):
    menu.add_command(
      label="Record List", command=self._event('<<ShowRecordlist>>'),
      image=self.icons.get('record_list'), compound=tk.LEFT
    )
  def _add_go_new_record(self, menu):
    menu.add_command(
      label="New Record", command=self._event('<<NewRecord>>'),
      image=self.icons.get('new_record'), compound=tk.LEFT
    )
  def _add_about(self, menu):
    menu.add_command(
      label='About…', command=self.show_about,
      image=self.icons.get('about'), compound=tk.LEFT
    ) 

现在,为了将这些方法组合成一个菜单,我们将创建一个新的方法,称为_build_menu()。此方法可以被我们的子类覆盖,而__init__()则负责处理常见的设置任务。

为了了解这将如何工作,让我们创建一个将重新创建我们的通用菜单的方法版本:

 def _build_menu(self):
    # The file menu
    self._menus['File'] = tk.Menu(self, tearoff=False, **self.styles)
    self._add_file_open(self._menus['File'])
    self._menus['File'].add_separator()
    self._add_quit(self._menus['File'])
    # The options menu
    self._menus['Options'] = 
      tk.Menu(
        self, tearoff=False, **self.styles
      )
    self._add_autofill_date(self._menus['Options'])
    self._add_autofill_sheet(self._menus['Options'])
    self._add_font_size_menu(self._menus['Options'])
    self._add_font_family_menu(self._menus['Options'])
    self._add_themes_menu(self._menus['Options'])
    # switch from recordlist to recordform
    self._menus['Go'] = tk.Menu(self, tearoff=False, **self.styles)
    self._add_go_record_list(self._menus['Go'])
    self._add_go_new_record(self._menus['Go'])
    # The help menu
    self._menus['Help'] = tk.Menu(self, tearoff=False, **self.styles)
    self.add_cascade(label='Help', menu=self._menus['Help'])
    self._add_about(self._menus['Help'])
    for label, menu in self._menus.items():
      self.add_cascade(label=label, menu=menu) 

在这里,我们正在创建我们的文件、选项、导航和帮助级联菜单,并将每个菜单传递给适当的项添加方法以设置其项。我们将这些存储在字典self._menus中,而不是作为局部变量。在方法末尾,我们遍历字典以将每个级联添加到主菜单。

现在,我们可以将这个类的初始化器简化为仅包含方法调用的骨架,如下所示:

 def __init__(self, parent, settings, **kwargs):
    super().__init__(parent, **kwargs)
    self.settings = settings
    self._create_icons()
    self._menus = dict()
    self._build_menu()
    self.configure(**self.styles) 

在调用超类初始化器后,此方法仅保存设置,创建图标和_menus字典,然后调用_build_menu()。如果设置了任何样式,则通过self.configure()将这些样式应用到主菜单。

添加加速键

在我们开始构建GenericMainMenu的子类之前,让我们使每个菜单能够添加特定于平台的加速键。这些只是可以激活我们的菜单项的键盘快捷键。我们不需要为每个菜单项都这样做,只需为一些常用命令添加即可。

要创建菜单项的关键绑定,有两个步骤:

  1. 使用bind_all()方法将键盘事件绑定到回调函数。

  2. 使用菜单项的加速器参数用键盘序列标记菜单项。

重要的是要理解我们需要做这两件事;accelerator参数不会自动设置键绑定,它只是决定了菜单项的标签。同样,bind_all()方法不会导致菜单项被标签化,它只会创建事件绑定。

为了完成这两项任务,我们将创建两个类属性字典,一个用于加速器,一个用于键绑定,如下所示:

class GenericMainMenu(tk.Menu):
  accelerators = {
    'file_open': 'Ctrl+O',
    'quit': 'Ctrl+Q',
    'record_list': 'Ctrl+L',
    'new_record': 'Ctrl+R',
  }
  keybinds = {
    '<Control-o>': '<<FileSelect>>',
    '<Control-q>': '<<FileQuit>>',
    '<Control-n>': '<<NewRecord>>',
    '<Control-l>': '<<ShowRecordlist>>'
  } 

第一个字典只是将加速器字符串与我们可以用于我们的菜单定义方法中的键匹配。要使用此字典,我们只需要更新这些方法以包括加速器;例如,像这样更新_add_file_open()方法:

# mainmenu.py, inside the GenericMainMenu class
  def _add_file_open(self, menu):
    menu.add_command(
      label='Select file…',
      command=self._event('<<FileSelect>>'),
      **accelerator=self.accelerators.get('file_open')**,
      image=self.icons.get('file'),
      compound=tk.LEFT
  ) 

请继续在_add_quit()_add_go_record_list()_add_go_new_record()中的add_command()调用中添加accelerator参数。

为了处理键绑定,我们只需要创建一个将创建键绑定的方法。将此_bind_accelerators()方法添加到类中:

# mainmenu.py, inside the GenericMainMenu class
  def _bind_accelerators(self):
    for key, sequence in self.keybinds.items():
      self.bind_all(key, self._event(sequence)) 

_bind_accelerators()方法遍历keybinds字典,并将每个键序列绑定到由_event()方法创建的函数,该函数将生成给定的事件。请注意,我们在这里使用了bind_all();与只对小部件上的事件做出响应的bind()方法不同,bind_all()方法将在任何小部件上生成事件时执行回调。因此,无论选择或聚焦的是哪个小部件,Control + Q 快捷键都会退出程序,例如。

最后的部分是从我们的初始化器中调用这个新方法。将以下内容添加到GenericMainMenu.__init__()的末尾:

 self._bind_accelerators() 

现在GenericMainMenu类已准备好进行子类化。让我们一次处理一个平台,找出需要更新的内容。

构建 Windows 菜单

在学习完Windows 用户体验交互指南后,你认为以下更改是使我们的菜单 Windows 友好的必要步骤:

  • 文件 | 退出应改为文件 | 退出,并且不应为其设置加速器。Windows 使用 Alt + F4 来关闭程序,这由 Windows 自动处理。

  • Windows 可以很好地处理菜单栏中的命令,并且指南鼓励这样做以用于常用功能。我们将直接将我们的记录列表和新记录命令移动到主菜单。不过,我们必须移除图标,因为主菜单无法处理图标。

  • 配置选项项应放在工具菜单下,与工具中的其他项(如果有)分开。我们需要创建一个工具菜单并将我们的选项移到那里。

让我们实施这些更改并创建我们的 Windows 菜单类。首先,像这样对GenericMainMenu类进行子类化:

class WindowsMainMenu(GenericMainMenu): 

我们首先将重写初始化器,以便我们可以删除 文件 | 退出 的快捷键绑定:

# mainmenu.py, inside WindowsMainMenu
  def __init__(self, *args, **kwargs):
    del(self.keybinds['<Control-q>'])
    super().__init__(*args, **kwargs) 

接下来,我们需要重写 _add_quit() 方法以重新标记它并删除加速键:

 def _add_quit(self, menu):
    menu.add_command(
      label='Exit', command=self._event('<<FileQuit>>'),
      image=self.icons.get('quit'), compound=tk.LEFT
    ) 

我们需要删除两个导航命令的图标,这样我们菜单中就不会出现 (Image) 字符串。为此,我们将接下来重写 _create_icons() 方法,如下所示:

# mainmenu.py, inside WindowsMainMenu
  def _create_icons(self):
    super()._create_icons()
    del(self.icons['new_record'])
    del(self.icons['record_list']) 

该方法的超类版本创建了 self.icons,所以我们只需运行它并删除我们不需要的图标。

现在这些问题都解决了,我们可以创建 _build_menu() 方法来组合我们的菜单,从以下三个级联开始:

 def _build_menu(self):
    # The File menu
    self._menus['File'] = tk.Menu(self, tearoff=False)
    self._add_file_open(self._menus['File'])
    self._menus['File'].add_separator()
    self._add_quit(self._menus['File'])
    # The Tools menu
    self._menus['Tools'] = tk.Menu(self, tearoff=False)
    self._add_autofill_date(self._menus['Tools'])
    self._add_autofill_sheet(self._menus['Tools'])
    self._add_font_size_menu(self._menus['Tools'])
    self._add_font_family_menu(self._menus['Tools'])
    self._add_themes_menu(self._menus['Tools'])
    # The Help menu
    self._menus['Help'] = tk.Menu(self, tearoff=False)
    self._add_about(self._menus['Help']) 

由于我们希望将导航命令条目(记录列表和新记录)直接添加到主菜单而不是级联菜单中,我们无法简单地遍历 _menus 字典来添加级联。相反,我们必须手动将条目添加到顶级菜单中,如下所示:

 self.add_cascade(label='File', menu=self._menus['File'])
    self.add_cascade(label='Tools', menu=self._menus['Tools'])
    self._add_go_record_list(self)
    self._add_go_new_record(self)
    self.add_cascade(label='Help', menu=self._menus['Help']) 

Windows 菜单现在已完整并准备好使用。让我们继续到下一个平台!

构建 Linux 菜单

我们的 GenericMainMenu 类与 Gnome HIG 非常接近,但需要做出一个修改:我们的选项菜单实际上并不合适;相反,我们需要将其项目分为两个类别:

  • 自动填充选项,因为它们改变了表单中数据输入的方式,应属于编辑菜单。

  • 字体和主题选项,因为它们只改变应用程序的外观而不改变实际数据,应属于视图菜单。

由于 Linux 也完全支持菜单颜色,我们将把颜色样式添加回这个版本的菜单。

让我们先从子类化 GenericMainMenu 并定义一些样式开始:

# mainmenu.py
class LinuxMainMenu(GenericMainMenu):
  styles = {
    'background': '#333',
    'foreground': 'white',
    'activebackground': '#777',
    'activeforeground': 'white',
    'relief': tk.GROOVE
  } 

这些菜单样式并非绝对必要,但如果我们要为 Linux 创建一个单独的菜单,那么充分利用其一些特性也是可以的!

现在,让我们从文件和编辑菜单开始 _build_menu() 方法:

 def _build_menu(self):
    self._menus['File'] = tk.Menu(self, tearoff=False, **self.styles)
    self._add_file_open(self._menus['File'])
    self._menus['File'].add_separator()
    self._add_quit(self._menus['File'])
    self._menus['Edit'] = tk.Menu(self, tearoff=False, **self.styles)
    self._add_autofill_date(self._menus['Edit'])
    self._add_autofill_sheet(self._menus['Edit']) 

注意,我们已经将 **self.styles 添加回每个 Menu() 调用以应用样式。我们将在构建下一个三个级联时做同样的事情,如下所示:

 self._menus['View'] = tk.Menu(self, tearoff=False, **self.styles)
    self._add_font_size_menu(self._menus['View'])
    self._add_font_family_menu(self._menus['View'])
    self._add_themes_menu(self._menus['View'])
    self._menus['Go'] = tk.Menu(self, tearoff=False, **self.styles)
    self._add_go_record_list(self._menus['Go'])
    self._add_go_new_record(self._menus['Go'])
    self._menus['Help'] = tk.Menu(self, tearoff=False, **self.styles)
    self._add_about(self._menus['Help']) 

最后,我们将遍历 _menus 字典并添加所有级联:

 for label, menu in self._menus.items():
      self.add_cascade(label=label, menu=menu) 

我们不需要做其他任何更改;我们的加速键和菜单的其他部分与 Gnome HIG 非常匹配。

构建 macOS 菜单

在这三个特定平台的菜单中,macOS 菜单需要最多的更改。与 Windows 和 Gnome 指南主要建议类别不同,Apple 指南非常具体地说明了哪些菜单应该创建以及哪些项目属于它们。此外,macOS 还创建并预先填充了一些这些菜单的默认命令,因此我们需要使用特殊参数来钩入这些菜单并添加我们自己的项目。

我们需要做出的更改以符合 Apple 的 HIG 如下:

  • 我们需要创建一个应用程序菜单。这是 macOS 创建的第一个菜单,位于菜单栏中苹果图标右侧。它默认创建,但我们需要将其钩入以添加一些自定义项。

  • 关于命令属于应用程序菜单;我们将将其移动到那里并删除未使用的帮助菜单。

  • 由于 macOS 将为我们提供一个退出命令,我们将移除我们的。

  • 正如我们在 Linux 菜单中所做的那样,我们的选项将在编辑和查看菜单之间分配。

  • 我们需要添加一个窗口菜单;这是 macOS 填充窗口管理和导航功能的另一个自动生成的菜单。我们的导航项将从“转到”菜单移动到这个菜单。

  • 最后,macOS 使用命令键而不是控制键来激活加速器。我们需要相应地更新我们的键绑定和加速器字典。

如前所述,我们将从创建GenericMainMenu的子类开始:

class MacOsMainMenu(GenericMainMenu):
  keybinds = {
    '<Command-o>': '<<FileSelect>>',
    '<Command-n>': '<<NewRecord>>',
    '<Command-l>': '<<ShowRecordlist>>'
  }
  accelerators = {
    'file_open': 'Cmd-O',
    'record_list': 'Cmd-L',
    'new_record': 'Cmd-R',
  } 

我们首先做的事情是重新定义keybindsaccelerators字典,移除Quit条目并将"Control"改为"Command"。请注意,当菜单显示时,Tkinter 会自动将字符串CommandCmd替换为命令键的符号(![img/B17578_10_0011.jpg]),所以当你指定加速器时,确保使用其中一个。

现在,让我们开始工作于_build_menu()方法,如下所示:

 def _build_menu(self):
    self._menus['ABQ Data Entry'] = tk.Menu(
      self, tearoff=False,
      name='apple'
    )
    self._add_about(self._menus['ABQ Data Entry'])
    self._menus['ABQ Data Entry'].add_separator() 

第一项任务是应用程序菜单。要访问这个内置菜单,我们只需要在创建Menu对象时传递一个设置为applename参数。应用程序菜单应包含我们的关于选项和退出选项,但我们只需要添加前者,因为 macOS 会自动添加退出操作。请注意,我们还在关于命令后添加了一个分隔符,这应该总是添加在关于命令之后。

应用程序菜单应该始终是你在 macOS 主菜单中添加的第一个菜单。如果你先添加其他任何内容,你的自定义应用程序菜单项将添加到它们自己的菜单中,而不是生成的应用程序菜单中。

在继续之前,我们需要对我们的关于命令进行一个修正。苹果的 HIG 指定这个命令应该读作关于<程序名称>而不是仅仅关于。因此,我们需要覆盖_add_about()来纠正这一点,如下所示:

# mainmenu.py, inside MacOSMainMenu
  def _add_about(self, menu):
    menu.add_command(
      label='About ABQ Data Entry', command=self.show_about,
      image=self.icons.get('about'), compound=tk.LEFT
    ) 

你的应用程序菜单目前会显示为"Python"而不是"ABQ 数据输入"。我们将在第十六章使用 setuptools 和 cxFreeze 打包中解决这个问题。

在创建应用程序菜单之后,让我们创建我们的文件、编辑和查看菜单,如下所示:

# mainmenu.py, inside MacOSMainMenu._build_menu()    
    self._menus['File'] = tk.Menu(self, tearoff=False)
    self.add_cascade(label='File', menu=self._menus['File'])
    self._add_file_open(self._menus['File'])
    self._menus['Edit'] = tk.Menu(self, tearoff=False)
    self._add_autofill_date(self._menus['Edit'])
    self._add_autofill_sheet(self._menus['Edit'])
    self._menus['View'] = tk.Menu(self, tearoff=False)
    self._add_font_size_menu(self._menus['View'])
    self._add_font_family_menu(self._menus['View'])
    self._add_themes_menu(self._menus['View']) 

我们实际上不需要在那里做任何不同的事情;然而,Window菜单是 macOS 创建的另一个自动生成的菜单,因此我们再次需要在创建它时使用name参数:

 self._menus['Window'] = tk.Menu(
      self, name='window', tearoff=False
    )
    self._add_go_record_list(self._menus['Window'])
    self._add_go_new_record(self._menus['Window']) 

最后,让我们遍历_menus字典并添加所有级联菜单:

 for label, menu in self._menus.items():
      self.add_cascade(label=label, menu=menu) 

尽管 macOS 会自动创建应用程序和窗口菜单,但你仍然需要显式地使用add_cascade()方法将Menu对象添加到主菜单中,否则你添加的项目将不会出现在自动创建的菜单中。

这就完成了我们的 macOS 菜单类。

创建和使用我们的选择函数

在创建了我们的类之后,让我们添加一个简单的选择器函数来为每个平台返回适当的类;将以下代码添加到 mainmenu.py 中:

# mainmenu.py, at the end
def get_main_menu_for_os(os_name):
  menus = {
    'Linux': LinuxMainMenu,
    'Darwin': MacOsMainMenu,
    'freebsd7': LinuxMainMenu,
    'Windows': WindowsMainMenu
  }
  return menus.get(os_name, GenericMainMenu) 

这个字典中的键是 platform.system() 的输出字符串,我们将它们指向适当的平台菜单类。如果我们运行在一些新的、未知系统上,我们将默认使用 GenericMainMenu 类。

现在,回到 application.py,我们将更改我们的导入语句,从 mainmenu 只导入这个函数,如下所示:

# application.py, at the top
from .mainmenu import get_main_menu_for_os
import platform 

注意,我们还导入了 platform,我们将使用它来确定正在运行的操作系统。

现在,我们不再调用 v.MainMenu()(它已不存在),而是使用以下函数:

# application.py, inside Application.__init__()
    # menu = MainMenu(self, self.settings)
    **menu_class = get_main_menu_for_os(platform.system())**
    **menu = menu_class(self, self.settings)**
    self.config(menu=menu) 

现在当您运行应用程序时,您的菜单外观将根据平台而改变。在 Windows 上,您应该看到类似如下内容:

图 10.4:Windows 电脑上的菜单系统

图 10.4:Windows 电脑上的菜单系统

在 macOS 上,您将看到类似如下内容:

图 10.5:macOS 上的菜单系统

图 10.5:macOS 上的菜单系统

在 Linux 或 BSD 上,您将看到如下截图所示的菜单:

图 10.5:Ubuntu Linux 上的菜单系统

图 10.6:Ubuntu Linux 上的菜单系统

摘要

在本章中,您学习了如何编写跨多个平台工作的 Python 软件。您学习了如何在 Python 代码中避免常见的平台陷阱,例如文件系统差异和库支持,以及如何编写能够智能适应不同操作系统需求的软件。您还了解了有助于开发者编写满足平台用户期望的软件的已发布指南,并使用这些指南为 ABQ 数据录入创建了特定平台的菜单。

在下一章中,我们将学习关于自动化测试的内容。您将学习编写确保代码正确工作的测试,无论是常规 Python 代码还是特定于 Tkinter 的代码,并利用 Python 标准库中包含的测试框架。

第十一章:使用 unittest 创建自动测试

随着你的应用程序规模和复杂性的快速扩展,你对做出更改感到紧张。万一你弄坏了什么?你怎么知道?当然,你可以手动运行程序的所有功能,使用各种输入并监视错误,但随着你添加更多功能,这种方法会变得更加困难且耗时。你真正需要的是一个快速且可靠的方法来确保每次代码更改时程序都能正常工作。

幸运的是,有一种方法:自动测试。在本章中,你将学习以下关于自动测试的内容:

  • 自动测试基础中,你将了解使用unittest在 Python 中进行自动测试的基本原理。

  • 测试 Tkinter 代码中,我们将讨论测试 Tkinter 应用程序的具体策略。

  • 为我们的应用程序编写测试中,我们将应用这些知识到 ABQ 数据录入应用程序。

自动测试基础

到目前为止,测试我们的应用程序一直是一个启动它,运行一些基本程序,并验证它是否按预期执行的过程。这种方法在非常小的脚本上可以接受,但随着我们的应用程序增长,验证应用程序行为的过程变得越来越耗时且容易出错。

使用自动测试,我们可以在几秒钟内一致地验证我们的应用程序逻辑。有几种自动测试的形式,但最常见的是单元测试集成测试。单元测试与独立的代码片段一起工作,使我们能够快速验证特定部分的行为。集成测试验证多个代码单元之间的交互。我们将编写这两种类型的测试来验证我们应用程序的行为。

简单单元测试

最基本的单元测试只是一个短程序,它在不同的条件下运行一段代码单元,并将其输出与预期结果进行比较。

考虑以下计算类:

# unittest_demo/mycalc.py
import random
class MyCalc:
  def __init__(self, a, b):
    self.a = a
    self.b = b
  def add(self):
    return self.a + self.b
  def mod_divide(self):
    if self.b == 0:
      raise ValueError("Cannot divide by zero")
    return (int(self.a / self.b), self.a % self.b)
  def rand_between(self):
    return (
      (random.random() * abs(self.a - self.b))
      + min(self.a, self.b)
    ) 

这个类初始化时带有两个数字,它可以在其后执行各种数学运算。

假设我们想编写一些代码来测试这个类是否按预期工作。一种简单的方法可能如下所示:

# unittest_demo/test_mycalc_no_unittest.py
from mycalc import MyCalc
mc1 = MyCalc(1, 100)
mc2 = MyCalc(10, 4)
try:
  assert mc1.add() == 101, "Test of add() failed."
  assert mc2.mod_divide() == (2, 2), "Test of mod_divide() failed."
except AssertionError as e:
  print("Test failed: ", e)
else:
  print("Tests succeeded!") 

这段测试代码创建了一个MyCalc对象,然后使用assert语句检查add()mod_divide()的输出是否与预期值相符。Python 中的assert关键字是一个特殊的语句,如果其后的表达式评估为False,则会引发AssertionError异常。逗号后面的消息字符串是传递给AssertionError异常初始化器的错误字符串。

换句话说,assert expression, "message"语句等同于:

if not expression:
  raise AssertionError("message") 

目前,如果你运行MyCalc的测试脚本,所有测试都通过。让我们尝试更改add()方法使其失败:

def add(self):
  return self.a - self.b 

现在,运行测试给出以下错误:

Test failed: Test of add() failed. 

这样的测试有什么价值?对于这样一个简单的函数,这似乎是多余的。但是,假设有人决定按照以下方式重构我们的mod_divide()方法:

def mod_divide(self):
  #...
  return (self.a // self.b, self.a % self.b) 

这种方法稍微复杂一些,你可能对涉及的运算符熟悉或不熟悉。然而,由于这种方法通过了我们的测试,我们有证据表明这个算法是正确的,即使我们没有完全理解代码。如果重构存在问题,我们的测试可以帮助我们快速识别问题。

测试纯数学函数相对简单;不幸的是,测试实际应用程序代码给我们带来了挑战,需要更复杂的方法。

考虑这些问题:

  • 代码单元通常依赖于在测试之前必须设置并在测试之后清理的预存在状态。

  • 代码可能存在副作用,会改变代码单元外部的对象。

  • 代码可能与慢速、不可靠或不可预测的资源交互。

  • 实际应用程序包含许多需要测试的函数和类,理想情况下我们希望一次性被提醒所有问题。按照目前编写的测试脚本,它会在第一个失败的断言处停止,所以我们只会一次被提醒一个问题。

为了解决这些问题以及其他问题,程序员依赖于测试框架,以使编写和执行自动化测试尽可能简单、高效和可靠。

unittest 模块

unittest模块是 Python 标准库的自动化测试框架。它为我们提供了一些强大的工具,使测试我们的代码变得相对容易,并且基于许多测试框架中发现的某些标准单元测试概念。这些概念包括:

  • 测试:测试是一个单独的方法,它要么完成,要么引发异常。测试通常关注一个代码单元,如函数、方法或过程。测试可以成功,表示测试成功;失败,表示代码未通过测试;或者错误,表示测试本身遇到了问题。

  • 测试用例:测试用例是一组应该一起运行的测试,包含类似的设置和清理要求,通常对应于一个类或模块。测试用例可以有固定装置,这些是在每个测试之前需要设置并在每个测试之后清理的项目,以提供一个干净、可预测的环境,使测试可以运行。

  • 测试套件:测试套件是一组测试用例,用于覆盖应用程序或模块的所有代码。

  • 模拟对象:模拟对象是代表另一个对象的实体。通常,它们用于替换外部资源,如文件、数据库或库模块。在测试期间,模拟对象会覆盖这些资源,以提供一个快速且可预测的替代品,且没有副作用。

为了深入探索这些概念,让我们使用unittest测试我们的MyCalc类。

编写测试用例

让我们为MyCalc类创建一个测试用例。创建一个名为test_mycalc.py的新文件,并输入以下代码:

# unittest_demo/test_mycalc.py
import mycalc
import unittest
class TestMyCalc(unittest.TestCase):
  def test_add(self):
    mc = mycalc.MyCalc(1, 10)
    assert mc.add() == 11
if __name__ == '__main__':
  unittest.main() 

你的测试模块和测试方法的名称都应该以test_为前缀。这样做允许unittest运行器自动找到测试模块,并区分测试用例类中的测试方法和其他方法。

如你所猜,TestCase类代表一个测试用例。为了对MyCalc进行测试用例,我们已从TestCase派生并添加了一个test_方法,该方法将测试我们类的一些方面。在test_add()方法内部,我们创建了一个MyCalc对象,然后对add()的输出进行了断言。

在文件末尾,我们添加了对unittest.main()的调用,这将导致文件中的所有测试用例被执行。

如果你在命令行中运行你的测试文件,你应该得到以下输出:

.
---------------------------------------------------------------------
Ran 1 test in 0.000s
OK 

第一行上的单个点代表我们的一个测试(test_add())。对于每个测试方法,unittest.main()将输出以下之一:

  • 一个点,表示测试通过

  • F,表示它失败了

  • E,表示测试引发了错误

最后,我们得到一个总结,包括运行了多少个测试以及花费了多长时间。OK表示所有测试都成功通过。

要查看测试失败时会发生什么,让我们修改我们的测试,使其故意失败:

def test_add(self):
  mc = mycalc.MyCalc(1, 10)
  assert mc.add() == **12** 

现在你运行测试模块时,你应该看到如下输出:

F
=====================================================================
FAIL: test_add (__main__.TestMyCalc)
---------------------------------------------------------------------Traceback (most recent call last):
File "test_mycalc.py", line 8, in test_add
assert mc.add() == 12
AssertionError
---------------------------------------------------------------------Ran 1 test in 0.000s
FAILED (failures=1) 

注意顶部单个的F,代表我们的失败测试。在所有测试运行完毕后,我们得到任何失败测试的完整跟踪记录,这样我们就可以轻松地定位失败的代码并更正它。

尽管如此,这个跟踪输出并不理想;我们可以看到mc.add()没有返回12,但我们不知道它实际上返回了什么。我们可以在我们的assert调用中添加一个注释字符串,但unittest提供了一个更好的方法:TestCase断言方法。

TestCase 断言方法

TestCase对象具有许多断言方法,提供了一种更干净、更健壮的方式来运行我们代码输出的各种测试。

例如,这里有TestCase.assertEqual()方法来测试相等性,我们可以如下使用:

 def test_add(self):
    mc = mycalc.MyCalc(1, 10)
    **self.assertEqual(mc.add(), 12)** 

当我们用此代码运行测试用例时,你可以看到跟踪记录得到了改进:

Traceback (most recent call last):
File "test_mycalc.py", line 11, in test_add
self.assertEqual(mc.add(), 12)
AssertionError: 11 != 12 

现在,我们可以看到mc.add()返回的值,这对调试非常有帮助。TestCase包含 20 多种断言方法,可以简化对各种条件(如类继承、抛出异常和序列成员资格)的测试。

一些更常用的方法列在以下表格中:

方法 测试
assertEqual(a, b) a == b
assertTrue(a) a is True
assertFalse(a) a is False
assertIn(item, sequence) item in sequence
assertRaises(exception, callable, *args) callable raises exception when called with args
assertGreater(a, b) a is greater than b
assertLess(a, b) a is less than b

可用的断言方法的完整列表可以在 unittest 文档的 docs.python.org/3/library/unittest.html#unittest.TestCase 找到。

让我们使用一个断言方法来测试当 b0 时,mod_divide() 会引发一个 ValueError 异常:

 def test_mod_divide(self):
    mc = mycalc.MyCalc(1, 0)
    self.assertRaises(ValueError, mc.mod_divide) 

当函数在调用时引发给定的异常时,assertRaises()通过。如果我们需要将任何参数传递给测试的函数,它们可以作为额外的参数指定给 assertRaises()

assertRaises() 也可以像这样用作上下文管理器:

 def test_mod_divide(self):
    mc = mycalc.MyCalc(1, 0)
    with self.assertRaises(ValueError):
      mc.mod_divide() 

这段代码实现了完全相同的功能,但更加清晰和灵活,因为它允许我们将多行代码放入代码块中。

您也可以轻松地将自己的自定义断言方法添加到测试用例中;这只是一个创建在某种条件下引发 AssertionError 异常的方法的问题。

固定设施

应该很明显,我们测试用例中的每个测试都需要访问一个 MyCalc 对象。如果我们不需要在每个测试方法中手动做这件事,那就很好了。为了帮助我们避免这项繁琐的任务,TestCase 对象提供了一个 setUp() 方法。该方法在运行每个测试用例之前运行,通过覆盖它,我们可以处理每个测试所需的任何设置。

例如,我们可以用它来创建 MyCalc 对象,如下所示:

 def setUp(self):
    self.mycalc1_0 = mycalc.MyCalc(1, 0)
    self.mycalc36_12 = mycalc.MyCalc(36, 12) 

现在,每个测试用例都可以使用这些对象来运行其测试,而不是创建它们自己的。理解到 setUp() 方法将在 每个 测试之前重新运行,因此这些对象将在测试方法之间始终重置。如果我们有需要在每次测试后清理的项目,我们也可以覆盖 tearDown() 方法,该方法在每次测试之后运行(在这种情况下,这不是必要的)。

现在我们有了 setUp() 方法,我们的 test_add() 方法可以更加简单:

 def test_add(self):
    self.assertEqual(self.mycalc1_0.add(), 1)
    self.assertEqual(self.mycalc36_12.add(), 48) 

除了实例方法 setUp()tearDown() 之外,TestCase 还具有用于对象本身设置和清理的类方法;这些是 setUpClass()tearDownClass()。这两个方法可以用于在测试用例创建和销毁时运行的较慢操作,而不是需要在每次测试之间刷新;例如,您可能可以使用它们来创建测试所需的复杂对象,而这些对象不会被任何测试所更改。

使用 Mock 和 patch

MyCalc.rand_between() 方法生成一个介于 ab 之间的随机数。因为我们无法预测其输出,所以我们不能提供一个固定值来测试它。我们如何测试这个方法?

一个简单的方法可能看起来像这样:

def test_rand_between(self):
  rv = self.mycalc1_0.rand_between()
  self.assertLessEqual(rv, 1)
  self.assertGreaterEqual(rv, 0) 

如果我们的代码是正确的,这个测试就会通过,但代码错误时它不一定失败;事实上,如果代码错误,它可能会不可预测地通过或失败,因为rand_between()的返回值是随机的。例如,如果MyCalc(1,10).rand_between()错误地返回了 2 到 11 之间的值,那么如果返回 2 到 10 之间的值,测试就会通过,只有当返回 11 时才会失败。因此,即使代码是错误的,测试套件在每次运行时只有 10%的几率会失败。

为了测试的目的,我们可以安全地假设标准库函数,如random(),是正确工作的;因此,我们的单元测试实际上应该测试我们自己的方法是否正确处理了random()提供的数值。如果我们能够临时替换random()为一个返回可预测固定值的函数,那么测试我们后续计算的准确性就会变得简单。

unittest.mock模块为我们提供了Mock类来实现这个目的。Mock对象可以用来可预测地模拟另一个类、方法或库的行为。我们可以为我们的Mock对象提供返回值、副作用、属性、方法和其他特征,以模拟另一个类、对象、函数或模块的行为,然后在运行测试之前将其放置在适当的位置。

为了看到这个效果,让我们使用Mock创建一个假的random()函数,如下所示:

from unittest.mock import Mock
  #... inside TestMyCalc
  def test_rand_between(self):
    fakerandom = Mock(return_value=.5) 

Mock对象的return_value参数允许我们在每次调用函数时硬编码一个返回值。在这里,我们的模拟对象fakerandom将表现得像一个总是返回0.5的函数。

现在,我们可以将fakerandom替换为random(),如下所示:

 #...
    orig_random = mycalc.random.random
    mycalc.random.random = fakerandom
    rv = self.mycalc1_0.rand_between()
    self.assertEqual(rv, 0.5)
    mycalc.random.random = orig_random 

我们首先保存对mycalc.random.random的引用,然后再进行替换。请注意,我们特别替换了mycalc.py中使用的random版本,这样就不会影响到其他地方的random()调用。在修补库时尽可能具体是一种最佳实践,以避免不可预见的副作用。

fakerandom模块就绪后,我们可以调用rand_between()并测试输出。因为fakerandom()总是返回0.5,所以当a1b0时,答案应该是(0.5 × 1 + 0) = 0.5。任何其他值都表明我们的算法中存在错误。在测试代码的末尾,我们将random恢复到原始的标准库函数,这样其他测试(或它们调用的类或函数)就不会意外地使用模拟。

每次需要存储或恢复原始库时都是一种不必要的麻烦,所以unittest.mock提供了一个更干净的方法,使用patch()patch()函数可以用作上下文管理器或装饰器,两种方法都可以使将Mock对象修补到我们的代码中变得更加干净。

使用patch()作为上下文管理器来交换fakerandom()看起来是这样的:

# test_mycalc.py
from unittest.mock import patch
#... inside TestMyCalc
  def test_rand_between(self):
    with patch('mycalc.random.random') as fakerandom:
      fakerandom.return_value = 0.5
      rv = self.mycalc1_0.rand_between()
      self.assertEqual(rv, 0.5) 

patch()命令接受一个导入路径字符串,并为我们提供一个新创建的Mock对象,该对象已在该路径上替换了原始对象。在上下文管理器块内部,我们可以在Mock对象上设置方法和属性,然后运行我们的实际测试。当块结束时,修补的函数将恢复到其原始版本。

patch()用作装饰器类似:

 @patch('mycalc.random.random')
  def test_rand_between2(self, fakerandom):
    fakerandom.return_value = 0.5
    rv = self.mycalc1_0.rand_between()
    self.assertEqual(rv, 0.5) 

在这种情况下,由patch()创建的Mock对象作为参数传递给我们的测试方法,并在装饰函数的持续时间内在其位置上保持修补。如果我们计划在测试方法中多次使用模拟,这种方法效果很好。

运行多个单元测试

虽然我们可以在文件末尾包含对unittest.main()的调用以运行我们的单元测试,但这种方法扩展性不好。随着我们的应用程序增长,我们将编写许多测试文件,我们希望可以分组或一次性运行。

幸运的是,unittest可以通过一条命令发现并运行项目中的所有测试:

$ python -m unittest 

只要你遵循了推荐的命名方案,即以test_前缀命名你的测试模块,在项目根目录下运行此命令就应该会运行所有测试脚本。

测试 Tkinter 代码

测试 Tkinter 代码给我们带来了一些特定的挑战。首先,Tkinter 异步处理许多回调和方法,这意味着我们不能指望某些代码的结果立即显现。此外,测试 GUI 行为通常依赖于外部因素,如窗口管理或视觉提示,这些因素是我们测试无法检测的。

在本节中,我们将学习一些工具和策略来解决这些问题,并帮助你为 Tkinter 代码编写测试。

管理异步代码

每当你与 Tkinter UI 交互时——无论是点击按钮、在字段中输入还是打开窗口等——响应不会立即执行。

相反,这些操作被放置在一个类似于待办事项列表的事件队列中,以便在代码执行继续的同时进行处理。虽然这些操作对用户来说似乎是瞬间的,但测试代码不能指望在运行下一行代码之前完成请求的操作。

为了解决这个问题,Tkinter 部件有一些方法允许我们管理事件队列:

  • wait_visibility(): 此方法导致代码等待直到部件完全绘制到屏幕上,然后执行下一行代码。

  • update_idletasks(): 此方法强制 Tkinter 处理当前在部件上挂起的任何空闲任务。空闲任务是指低优先级任务,如绘图和渲染。

  • update(): 此方法强制 Tkinter 处理部件上挂起的全部事件,包括调用回调、重绘和几何管理。它包括update_idletasks()所做的一切以及更多。

事件队列将在第十四章“使用线程和队列的异步编程”中更详细地讨论。

模拟用户操作

在自动化 GUI 测试时,我们可能想知道当用户点击某个控件或输入某个按键时会发生什么。当这些动作在 GUI 中发生时,Tkinter 会为该控件生成一个 Event 对象并将其传递给事件队列。我们可以在代码中做同样的事情,使用控件的 event_generate() 方法。

指定事件序列

正如我们在 第六章 中学到的,为应用扩展做准备,我们可以通过将事件序列字符串传递给 event_generate() 方法中的格式 <EventModifier-EventType-EventDetail> 来在控件上注册一个事件。让我们更详细地看看序列字符串。

事件序列字符串的核心部分是事件类型。它指定了我们发送的事件类型,例如按键、鼠标点击、窗口事件等。

Tkinter 大约有 30 种事件类型,但通常你只需要处理以下几种:

事件类型 表示的动作
ButtonPressButton 鼠标按钮点击
ButtonRelease 松开鼠标按钮
KeyPressKey 按下键盘上的键
KeyRelease 松开键盘上的键
FocusIn 将焦点给予一个控件,例如按钮或输入控件
FocusOut 离开一个聚焦的控件
Enter 鼠标光标进入一个控件
Leave 鼠标光标从一个控件上移开
Configure 控件配置的改变,例如,调用 config(),或者用户调整窗口大小等

事件修饰符是可选的单词,可以改变事件类型;例如,ControlAltShift 可以用来表示这些修饰键之一被按下;DoubleTriple 可以与 Button 结合使用,以表示描述的按钮的双击或三击。如果需要,可以串联多个修饰符。

事件细节,仅对键盘或鼠标事件有效,描述了哪个键或按钮被按下。例如,<Button-1> 指的是左鼠标按钮,而 <Button-3> 指的是右。对于字母和数字键,可以使用实际的字母或数字,例如 <Control-KeyPress-a>;然而,大多数符号都由一个单词(如 minuscolonsemicolon 等)描述,以避免语法冲突。

对于按钮点击和按键,事件类型在技术上不是必需的;例如,你可以使用 <Control-a> 而不是 <Control-KeyPress-a>。然而,出于清晰起见,保留它可能是个好主意。例如,<1> 是一个有效的事件,但它指的是按下左鼠标按钮还是数字键 1?你可能惊讶地发现它是鼠标按钮。

下表展示了有效事件序列的一些示例:

序列 含义
<Double-Button-3> 双击右鼠标按钮
<Alt-KeyPress-exclam> 按住 Alt 并输入感叹号
<Control-Alt-Key-m> 按住 Control 和 Alt 并按下 M 键
<KeyRelease-minus> 释放按下的减号键

除了序列之外,我们还可以向event_generate()传递其他参数,以描述事件的各个方面。其中许多是多余的,但在某些情况下,我们需要为事件提供额外信息,以便它具有任何意义;例如,鼠标按钮事件需要包括一个xy参数,以指定点击的坐标。

单括号包围的序列表示内置事件类型。双括号用于自定义事件,例如我们在主菜单和其他地方使用的事件。

管理焦点和抓取

焦点指的是当前接收键盘输入的控件或窗口。控件也可以抓取焦点,防止鼠标在其边界之外移动或按键。

Tkinter 为我们提供了这些用于管理焦点和抓取的控件方法,其中一些对于运行测试很有用:

方法 描述
focus_set() 当其窗口下次获得焦点时聚焦控件
focus_force() 立即聚焦控件及其所在的窗口
grab_set() 控件抓取应用程序的所有事件
grab_set_global() 控件抓取屏幕上的所有事件
grab_release() 控件放弃其抓取

在测试环境中,我们可以使用这些方法来确保我们生成的键盘和鼠标事件将发送到正确的控件或窗口。

大多数时候focus_set()方法就足够了,但根据您应用程序的行为和操作系统窗口环境,您可能需要更极端的强制措施,如focus_force()grab_set()

获取控件信息

Tkinter 控件有一组winfo_方法,使我们能够访问有关控件的信息。虽然可用的功能还有很多不足,但这些方法包括一些我们可以在测试中使用的信息,以提供有关给定控件状态的反馈。

以下是一些我们将发现很有用的winfo_方法:

方法 描述
winfo_height()winfo_width() 获取控件的高度和宽度
winfo_children() 获取子控件的列表
winfo_geometry() 获取控件的大小和位置
winfo_ismapped() 确定控件是否已映射(即,它已被添加到布局中,使用几何管理器)
winfo_viewable() 确定控件是否可见(即,它及其所有父控件都已映射)
winfo_x()winfo_y() 获取控件左上角的xy坐标

为我们的应用程序编写测试

让我们将我们对unittest和 Tkinter 的知识运用起来,为我们的应用程序编写一些自动化测试。要开始,我们需要创建一个测试模块。在abq_data_entry包内创建一个名为test的目录,并在其中创建传统的空__init__.py文件。我们将在该目录内创建所有的测试模块。

测试数据模型

除了需要读取和写入文件之外,我们的CSVModel类相当独立。我们需要模拟这一功能,以便测试不会干扰文件系统。由于文件操作是在测试中需要模拟的更常见的事情之一,因此mock模块提供了mock_open(),这是一个现成的Mock子类,用于替换 Python 的open()方法。当调用时,mock_open对象返回一个模拟文件句柄对象,并支持read()write()readlines()方法。

test目录中创建一个名为test_models.py的新文件。这将是我们的数据模型类测试模块。以一些模块导入开始:

# test_models.py
from .. import models
from unittest import TestCase
from unittest import mock
from pathlib import Path 

除了models模块,我们还需要TestCasemock,当然,以及Path类,因为我们的CSVModel内部使用Path对象。

现在,我们将开始对CSVModel类进行测试用例,如下所示:

class TestCSVModel(TestCase):
  def setUp(self):
    self.file1_open = mock.mock_open(
      read_data=(
        "Date,Time,Technician,Lab,Plot,Seed Sample,"
        "Humidity,Light,Temperature,Equipment Fault,"
        "Plants,Blossoms,Fruit,Min Height,Max Height,"
        "Med Height,Notes\r\n"
        "2021-06-01,8:00,J Simms,A,2,AX478,24.47,1.01,21.44,"
        "False,14,27,1,2.35,9.2,5.09,\r\n"
        "2021-06-01,8:00,J Simms,A,3,AX479,24.15,1,20.82,"
        "False,18,49,6,2.47,14.2,11.83,\r\n"
      )
    )
    self.file2_open = mock.mock_open(read_data='')
    self.model1 = models.CSVModel('file1')
    self.model2 = models.CSVModel('file2') 

在此案例的setUp()方法中,我们创建了两个模拟数据文件。第一个包含 CSV 标题和两行 CSV 数据,而第二个是空的。mock_open对象的read_data参数允许我们指定当代码尝试从它读取数据时将返回的字符串。

我们还创建了两个CSVModel对象,一个文件名为file1,另一个文件名为file2。值得一提的是,我们的模型和mock_open对象之间没有实际的联系;给定的文件名是任意的,因为我们实际上不会打开文件,而选择使用哪个mock_open对象将在我们的测试方法中使用patch()来决定。

get_all_records()中测试文件读取

要了解我们如何使用这些,让我们从对get_all_records()方法的测试开始,如下所示:

# test_models.py, inside TestCSVModel
  @mock.patch('abq_data_entry.models.Path.exists')
  def test_get_all_records(self, mock_path_exists):
    mock_path_exists.return_value = True 

由于我们的文件名实际上不存在,我们使用patch()的装饰器版本将Path.exists()替换为始终返回True的模拟函数。如果我们想测试文件不存在的情况,我们可以稍后更改此对象的return_value属性。

要针对我们的一个mock_open对象运行get_all_records()方法,我们将使用patch()的上下文管理器形式,如下所示:

 with mock.patch(
      'abq_data_entry.models.open',
      self.file1_open
    ):
      records = self.model1.get_all_records() 

在此上下文管理器块内的代码中,对open()的任何调用都将由我们的mock_open对象替换,返回的文件句柄将包含我们指定的read_data字符串。

现在我们可以开始对返回的记录进行断言:

# test_models.py, inside TestCSVModel.test_get_all_records()
    self.assertEqual(len(records), 2)
    self.assertIsInstance(records, list)
    self.assertIsInstance(records[0], dict) 

在这里,我们检查records包含两行(因为我们的读取数据包含两个 CSV 记录),它是一个list对象,并且它的第一个成员是一个dict对象(或dict的子类)。

接下来,让我们确保所有字段都已通过,并且布尔转换已成功:

 fields = (
      'Date', 'Time', 'Technician', 'Lab', 'Plot',
      'Seed Sample', 'Humidity', 'Light',
      'Temperature', 'Equipment Fault', 'Plants',
      'Blossoms', 'Fruit', 'Min Height', 'Max Height',
      'Med Height', 'Notes')
    for field in fields:
      self.assertIn(field, records[0].keys())
    self.assertFalse(records[0]['Equipment Fault']) 

通过遍历所有字段名称的元组,我们可以检查记录输出中是否包含所有我们的字段。不要害怕在测试中使用这种方式来快速检查大量内容。

Mock对象不仅可以代替另一个类或函数;它还具有自己的断言方法,可以告诉我们它是否被调用、调用了多少次以及调用的参数。

例如,我们可以检查我们的mock_open对象以确保它以预期的参数被调用:

 self.file1_open.assert_called_with(
      Path('file1'), 'r', encoding='utf-8', newline=''
    ) 

assert_called_with()接受任意数量的位置参数和关键字参数,并检查模拟对象的最后调用是否包含那些确切的参数。我们期望file1_open()以包含文件名file1Path对象、模式为rnewline设置为空字符串以及encoding值为utf-8的方式被调用。通过确认模拟函数是否以正确的参数被调用,并假设真实函数(在这种情况下是内置的open()函数)的正确性,我们可以避免测试实际的结果。

注意,对于此方法,关键字参数的传递顺序并不重要。

save_record()中测试文件保存

为了演示如何使用mock_open测试文件写入,让我们测试save_record()。首先创建一个测试方法,定义一些数据:

 @mock.patch('abq_data_entry.models.Path.exists')
  def test_save_record(self, mock_path_exists):
    record = {
      "Date": '2021-07-01', "Time": '12:00',
      "Technician": 'Test Technician', "Lab": 'C',
      "Plot": '17', "Seed Sample": 'test sample',
      "Humidity": '10', "Light": '99',
      "Temperature": '20', "Equipment Fault": False,
      "Plants": '10', "Blossoms": '200',
      "Fruit": '250', "Min Height": '40',
      "Max Height": '50', "Med Height": '55',
      "Notes": 'Test Note\r\nTest Note\r\n'
    }
    record_as_csv = (
      '2021-07-01,12:00,Test Technician,C,17,test sample,10,99,'
      '20,False,10,200,250,40,50,55,"Test Note\r\nTest Note\r\n"'
      '\r\n') 

此方法首先再次模拟Path.exists并创建一个数据字典,以及以 CSV 数据行表示的相同数据。

你可能会想通过代码生成记录或其预期的 CSV 输出,但始终最好在测试中坚持使用字面值;这样做使得测试的期望变得明确,并避免测试中的逻辑错误。

现在,对于我们的第一个测试场景,让我们通过使用file2_openmodel2来模拟向一个空但存在的文件写入:

 mock_path_exists.return_value = True
    with mock.patch('abq_data_entry.models.open', self.file2_open):
      self.model2.save_record(record, None) 

将我们的mock_path_exists.return_value设置为True以告诉我们的方法文件已经存在,然后我们用我们的第二个mock_open对象(表示一个空文件)覆盖open(),并调用CSVModel.save_record()方法。由于我们传递了一个没有行号的记录(这表示记录插入),这应该导致我们的代码尝试以追加模式打开file2并写入 CSV 格式的记录。

assert_called_with()将按以下方式测试这个假设:

 self.file2_open.assert_called_with(
        Path('file2'), 'a', encoding='utf-8', newline=''
      ) 

虽然此方法可以告诉我们file2_open是否以预期的参数被调用,但我们如何访问其实际的文件句柄,以便我们可以看到写入的内容?

结果我们只需调用我们的mock_open对象并检索模拟的文件句柄对象,如下所示:

 file2_handle = self.file2_open()
      file2_handle.write.assert_called_with(record_as_csv) 

一旦我们有了模拟的文件句柄(它本身也是一个Mock对象),我们就可以在其write()成员上运行测试方法,以找出它是否以预期的 CSV 数据被调用。在这种情况下,文件句柄的write()方法应该被调用,带有 CSV 格式的记录字符串。

让我们进行一组类似的测试,传递一个行号来模拟记录更新:

 with mock.patch('abq_data_entry.models.open', self.file1_open):
      self.model1.save_record(record, 1)
      self.file1_open.assert_called_with(
        Path('file1'), 'w', encoding='utf-8'
      ) 

检查我们的更新是否正确完成会带来问题:assert_called_with() 只检查对模拟函数的最后调用。当我们更新 CSV 文件时,整个 CSV 文件都会更新,每行有一个 write() 调用。我们不能仅仅检查最后一个调用是否正确;我们需要确保所有行的 write() 调用都是正确的。为了完成这个任务,Mock 包含一个名为 assert_has_calls() 的方法,我们可以使用它来测试对对象进行的调用历史。

要使用它,我们需要创建一个 Call 对象列表。每个 Call 对象代表对模拟对象的调用。我们使用 mock.call() 函数创建 Call 对象,如下所示:

 file1_handle = self.file1_open()
      file1_handle.write.assert_has_calls([
        mock.call(
          'Date,Time,Technician,Lab,Plot,Seed Sample,'
          'Humidity,Light,Temperature,Equipment Fault,Plants,'
          'Blossoms,Fruit,Min Height,Max Height,Med Height,Notes'
          '\r\n'),
        mock.call(
          '2021-06-01,8:00,J Simms,A,2,AX478,24.47,1.01,21.44,'
          'False,14,27,1,2.35,9.2,5.09,\r\n'),
        mock.call(
          '2021-07-01,12:00,Test Technician,C,17,test sample,'
          '10,99,20,False,10,200,250,40,50,55,'
          '"Test Note\r\nTest Note\r\n"\r\n')
        ]) 

mock.call() 的参数代表应该传递给函数调用的参数,在我们的情况下应该是单行的 CSV 数据字符串。我们传递给 assert_has_calls()Call 对象列表代表了对模拟文件句柄的 write() 方法应该进行的每个调用,按顺序assert_has_calls() 方法的 in_order 参数也可以设置为 False,在这种情况下,顺序不需要匹配。在我们的情况下,顺序很重要,因为错误的顺序会导致 CSV 文件损坏。

对模型进行更多测试

测试 CSVModel 类和 SettingsModel 类的其他方法应该与这两个方法基本相同。示例代码中包含了一些额外的测试,但请尝试自己想出一些。

测试我们的应用程序对象

我们将应用程序实现为一个 Tk 对象,它不仅作为主窗口,还作为控制器,将应用程序中其他地方定义的模型和视图连接起来。因此,正如你所期望的,patch() 将在我们的测试代码中发挥重要作用,因为我们模拟了所有其他组件以隔离 Application 对象。

test 目录下打开一个名为 test_application.py 的新文件,我们将从导入开始:

# test_application.py
from unittest import TestCase
from unittest.mock import patch
from .. import application 

现在,让我们以这种方式开始我们的测试用例类:

class TestApplication(TestCase):
  records = [
    {'Date': '2018-06-01', 'Time': '8:00', 'Technician': 'J Simms',
     'Lab': 'A', 'Plot': '1', 'Seed Sample': 'AX477',
     'Humidity': '24.09', 'Light': '1.03', 'Temperature': '22.01',
     'Equipment Fault': False,  'Plants': '9', 'Blossoms': '21',
     'Fruit': '3', 'Max Height': '8.7', 'Med Height': '2.73',
     'Min Height': '1.67', 'Notes': '\n\n',
    },
    {'Date': '2018-06-01', 'Time': '8:00', 'Technician': 'J Simms',
     'Lab': 'A', 'Plot': '2', 'Seed Sample': 'AX478',
     'Humidity': '24.47', 'Light': '1.01', 'Temperature': '21.44',
     'Equipment Fault': False, 'Plants': '14', 'Blossoms': '27',
     'Fruit': '1', 'Max Height': '9.2', 'Med Height': '5.09',
     'Min Height': '2.35', 'Notes': ''
     }
  ]
  settings = {
    'autofill date': {'type': 'bool', 'value': True},
    'autofill sheet data': {'type': 'bool', 'value': True},
    'font size': {'type': 'int', 'value': 9},
    'font family': {'type': 'str', 'value': ''},
    'theme': {'type': 'str', 'value': 'default'}
  } 

由于我们的 TestApplication 类将使用模拟数据代替数据和设置模型,我们在其中创建了一些类属性来存储 Application 预期从这些模型检索的数据样本。setUp() 方法将使用模拟替换所有外部类,配置模拟模型以返回我们的样本数据,然后创建一个 Application 实例,以便我们的测试可以使用。

注意,虽然测试记录中的布尔值是 bool 对象,但数值是字符串。实际上,CSVModel 就是这么返回数据的,因为在模型的这个点上没有进行实际的数据类型转换。

现在,让我们创建我们的 setUp() 方法,它看起来像这样:

# test_application.py, inside TestApplication class
  def setUp(self):
    with \
      patch(
        'abq_data_entry.application.m.CSVModel'
      ) as csvmodel,\
      patch(
        'abq_data_entry.application.m.SettingsModel'
      ) as settingsmodel,\
      patch(
       'abq_data_entry.application.Application._show_login'
      ) as show_login,\
      patch('abq_data_entry.application.v.DataRecordForm'),\
      patch('abq_data_entry.application.v.RecordList'),\
      patch('abq_data_entry.application.ttk.Notebook'),\
      patch('abq_data_entry.application.get_main_menu_for_os')\
    :
      show_login.return_value = True
      settingsmodel().fields = self.settings
      csvmodel().get_all_records.return_value = self.records
      self.app = application.Application() 

在这里,我们使用七个 patch() 上下文管理器创建了一个 with 块,每个类、方法或函数都有一个,包括:

  • CSV 和设置模型。这些已经被别名替换,这样我们就可以配置它们以返回适当的数据。

  • show_login() 方法,我们将返回值硬编码为 True 以确保登录始终成功。注意,如果我们打算编写这个类的完整测试覆盖率,我们还想测试这个函数,但现在我们只是模拟它。

  • 记录表和记录列表类,因为我们不希望这些类的问题导致我们的 Application 测试代码出现错误。这些类将有自己的测试用例,所以我们在这个案例中不感兴趣测试它们。我们不需要对它们进行任何配置,因此我们没有对这些模拟对象进行别名设置。

  • Notebook 类。如果不进行模拟,我们将在其 add() 方法中传递 Mock 对象,从而引发不必要的错误。我们可以假设 Tkinter 类可以正常工作,因此我们模拟这一部分。

  • get_main_menu_for_os 类,因为我们不想处理实际的菜单对象。就像记录表和记录列表一样,我们的菜单类将有自己的测试用例,所以我们最好在这里将它们排除在外。

自 Python 3.2 以来,您可以通过在每次上下文管理器调用之间使用逗号来创建包含多个上下文管理器的块。不幸的是,在 Python 3.9 或更低版本中,您不能将它们放在括号中,因此我们使用相对丑陋的转义换行方法将这个巨大的调用拆分成多行。如果您使用 Python 3.10 或更高版本,您可以在上下文管理器列表周围使用括号以获得更整洁的布局。

注意我们正在创建 settingsmodelcsvmodel 对象的实例,并在模拟对象的 返回值 上配置方法,而不是在模拟对象本身上。记住,我们的模拟正在替换 ,而不是 对象,并且是对象将包含 Application 对象将要调用的方法。因此,我们需要调用模拟类来访问 Application 将用作数据或设置模型的实际 Mock 对象。

与它所替代的实际类不同,当作为函数调用的 Mock 对象每次调用时都会返回相同的对象。因此,我们不需要保存由调用模拟类创建的对象的引用;我们只需重复调用模拟类即可访问该对象。然而,请注意,每次 Mock 类本身都会创建一个唯一的 Mock 对象。

由于 Application 是 Tk 的子类,因此在使用后安全地销毁它是我们的好主意;即使我们重新分配了它的变量名,Tcl/Tk 对象仍然会存在,并可能对我们的测试造成问题。为了解决这个问题,在 TestApplication 中创建一个 tearDown() 方法:

 def tearDown(self):
    self.app.update()
    self.app.destroy() 

注意对 app.update() 的调用。如果我们不在销毁 app 之前调用它,事件队列中可能有任务会在它消失后尝试访问它。这不会破坏我们的代码,但它会在我们的测试输出中添加错误消息。

现在我们已经处理好了我们的固定值,让我们编写一个测试:

 def test_show_recordlist(self):
    self.app._show_recordlist()
    self.app.notebook.select.assert_called_with(self.app.recordlist) 

Application._show_recordlist() 只有一行代码,它只是调用 self.notebook.select()。因为我们把 recordlist 设置为一个模拟对象,所以它的所有成员(包括 select)也都是模拟对象。因此,我们可以使用模拟断言方法来检查 select() 是否被调用以及调用的参数。

我们可以使用类似的技术来检查 _populate_recordlist(),如下所示:

 def test_populate_recordlist(self):
    self.app._populate_recordlist()
    self.app.model.get_all_records.assert_called()
    self.app.recordlist.populate.assert_called_with(self.records) 

在这种情况下,我们还在使用 assert_called() 方法来查看 CSVModel.get_all_records() 是否被调用,它应该已经被调用来填充记录列表。与 assert_called_with() 不同,assert_called() 只检查一个函数是否被调用,因此对于没有参数的函数来说是有用的。

在某些情况下,get_all_records() 可以引发一个异常,在这种情况下,我们应该显示一个错误消息框。但由于我们已经模拟了我们的数据模型,我们如何让 Mock 对象引发一个异常呢?解决方案是使用模拟的 side_effect 属性,如下所示:

 self.app.model.get_all_records.side_effect = Exception(
      'Test message'
    ) 

side_effect 可以用来模拟在模拟函数或方法中的更复杂的功能。它可以设置为一个函数,在这种情况下,模拟将在被调用时运行该函数并返回结果;它可以设置为一个可迭代对象,在这种情况下,模拟将在每次被调用时返回可迭代对象中的下一个项目;或者,正如这个例子中所示,它可以设置为一个异常,当模拟被调用时将引发该异常。

在我们能够使用它之前,我们需要按照以下方式修补 messagebox

 with patch('abq_data_entry.application.messagebox'):
      self.app._populate_recordlist()
      application.messagebox.showerror.assert_called_with(
        title='Error', message='Problem reading file',
        detail='Test message'
      ) 

这次当我们调用 _populate_recordlist() 时,我们的模拟 CSVModel 对象引发了一个异常,这应该会导致方法调用 messagebox.showerror()。由于我们已经模拟了 showerror(),我们可以使用 assert_called_with() 断言它以预期的参数被调用。

显然,测试我们的 Application 对象最困难的部分是修补所有模拟组件并确保它们足够像真实的东西以使 Application 满意。一旦我们做到了这一点,编写实际的测试就相对简单了。

测试我们的小部件

到目前为止,我们已经很好地使用 patch()Mock 和默认的 TestCase 类测试了我们的组件,但测试我们的小部件模块将带来一些新的挑战。首先,我们的小部件需要一个 Tk 实例作为它们的根窗口。我们可以在每个案例的 setUp() 方法中创建这个实例,但这会显著减慢测试速度,而且这并不是真正必要的:我们的测试不会修改根窗口,所以每个测试用例只需要一个根窗口。为了保持测试以合理的速度运行,我们可以利用 setUpClass() 方法在测试用例实例创建时只创建一个 Tk 实例一次。

其次,我们有大量的小部件要测试,每个小部件都需要自己的 TestCase 类。因此,我们需要创建大量需要相同 Tk 设置和清理的测试用例。为了解决这个问题,我们将创建一个自定义的 TestCase 基类来处理根窗口的设置和清理,然后为每个小部件测试用例子类化它。在 test 目录下打开一个新文件,命名为 test_widgets.py,并从以下代码开始:

# test_widgets.py
from .. import widgets
from unittest import TestCase
from unittest.mock import Mock
import tkinter as tk
from tkinter import ttk
class TkTestCase(TestCase):
  """A test case designed for Tkinter widgets and views"""
  @classmethod
  def setUpClass(cls):
    cls.root = tk.Tk()
    cls.root.wait_visibility()
  @classmethod
  def tearDownClass(cls):
    cls.root.update()
    cls.root.destroy() 

setUpClass() 方法创建 Tk 对象并调用 wait_visibility() 以确保在测试开始工作之前,根窗口是可见的并且完全绘制。我们还提供了一个互补的清理方法,该方法更新 Tk 实例(以完成队列中的任何事件)并销毁它。

现在,对于每个小部件测试用例,我们将子类化 TkTestCase 以确保我们为小部件有一个合适的测试环境。

ValidatedSpinbox 小部件的单元测试

ValidatedSpinbox 是我们为应用程序创建的较为复杂的小部件之一,因此它是开始编写测试的好地方。

TkTestCase 类子类化以创建 ValidatedSpinbox 的测试用例,如下所示:

class TestValidatedSpinbox(TkTestCase):
  def setUp(self):
    self.value = tk.DoubleVar()
    self.vsb = widgets.ValidatedSpinbox(
      self.root,
      textvariable=self.value,
      from_=-10, to=10, increment=1
    )
    self.vsb.pack()
    self.vsb.wait_visibility()
  def tearDown(self):
    self.vsb.destroy() 

我们的 setUp() 方法创建一个控制变量来存储小部件的值,然后创建一个具有一些基本设置的 ValidatedSpinbox 小部件实例:最小值为 -10,最大值为 10,增量值为 1。创建后,我们将其打包并等待其变得可见。对于我们的清理方法,我们只需销毁小部件。

现在,让我们开始编写测试。我们将从 _key_validate() 方法的单元测试开始:

 def test_key_validate(self):
    for x in range(10):
      x = str(x)
      p_valid = self.vsb._key_validate(x, 'end', '', x, '1')
      n_valid = self.vsb._key_validate(x, 'end', '-', '-' + x, '1')
      self.assertTrue(p_valid)
      self.assertTrue(n_valid) 

在这个测试中,我们只是从 0 迭代到 9 并测试数字的正负值与 _key_validate(),它应该对所有这些值返回 True

注意到 _key_validate() 方法接受许多位置参数,其中大部分是冗余的;可能有一个包装方法来使其更容易调用会很好,因为对这个函数的适当测试可能需要调用它几十次。

让我们称这个方法为 key_validate() 并将其添加到我们的 TestValidatedSpinbox 类中,如下所示:

 def key_validate(self, new, current=''):
    return self.vsb._key_validate(
      new,  # inserted char
      'end',  # position to insert
      current,  # current value
      current + new,  # proposed value
      '1'  # action code (1 == insert)
    ) 

这将使未来对该方法的调用更短且更不容易出错。现在让我们使用这个方法来测试一些无效的输入,如下所示:

 def test_key_validate_letters(self):
    valid = self.key_validate('a')
    self.assertFalse(valid)
  def test_key_validate_increment(self):
    valid = self.key_validate('1', '0.')
    self.assertFalse(valid)
  def test_key_validate_high(self):
    valid = self.key_validate('0', '10')
    self.assertFalse(valid)) 

在第一个例子中,我们输入字母 a;在第二个例子中,当框中已有 0. 时输入一个 1 字符(导致建议值为 0.1);在第三个例子中,当框中有 10 时输入一个 0 字符(导致建议值为 100)。所有这些场景都应该使验证方法失败,导致它返回 False

集成测试 ValidatedSpinbox 小部件

在前面的测试中,我们实际上并没有向小部件输入任何数据;我们只是直接调用键验证方法并评估其输出。这是好的单元测试,但作为对我们小部件功能性的测试,它并不令人满意,对吧?鉴于我们的自定义小部件与 Tkinter 的验证 API 深度交互,我们希望测试我们是否正确地与这个 API 接口。毕竟,那个方面的代码比我们验证方法中的实际逻辑更具挑战性。

我们可以通过创建一些模拟实际用户操作的集成测试来完成这项任务,然后检查这些操作的结果。为了干净利落地完成这项任务,我们首先需要创建一些支持方法。

首先,我们需要一种方法来模拟在窗口中输入文本。让我们在TkTestCase类中开始一个新的type_in_widget()方法来完成这个任务:

# test_widgets.py, in TkTestCase
  def type_in_widget(self, widget, string):
    widget.focus_force() 

这种方法的第一步是迫使注意力集中在小部件上;回想一下,focus_force()即使在包含窗口没有焦点的情况下也会给小部件分配焦点;我们需要使用这个方法,因为我们的测试 Tk 窗口在测试运行时很可能没有焦点。

一旦我们获得焦点,我们就需要遍历字符串中的字符,并将原始字符转换为适当的事件序列键符号。回想一下,一些字符,尤其是符号,必须表示为名称字符串,例如minuscolon。为了使这可行,我们需要一种方法在字符和它们的键符号之间进行转换。我们可以通过添加一个类属性字典来实现这一点,如下所示:

# test_widgets.py, in TkTestCase
  keysyms = {
    '-': 'minus',
    ' ': 'space',
    ':': 'colon',
  } 

更多键符号可以在www.tcl.tk/man/tcl8.4/TkCmd/keysyms.htm找到,但这些都足够了。让我们这样完成type_in_widget()方法:

# test_widgets.py, in TkTestCase.type_in_widget()
    for char in string:
      char = self.keysyms.get(char, char)
      widget.event_generate(f'<KeyPress-{char}>')
      self.root.update_idletasks() 

在这个循环中,我们首先检查我们的char值在keysyms中是否有名称字符串。然后我们在小部件上生成一个带有给定字符或键符号的KeyPress事件。请注意,我们在生成按键事件后调用self.root.update_idletasks()。这确保了生成的按键字符在生成后能够注册。

除了模拟键盘输入外,我们还需要能够模拟鼠标点击。我们可以创建一个类似的方法,click_on_widget(),来模拟鼠标按钮点击,如下所示:

 def click_on_widget(self, widget, x, y, button=1):
    widget.focus_force()
    widget.event_generate(f'<ButtonPress-{button}>', x=x, y=y)
    self.root.update_idletasks() 

此方法接受一个小部件、一个点击的xy坐标,以及可选的将被点击的鼠标按钮(默认为1,即左鼠标按钮)。就像我们处理按键方法一样,我们首先强制焦点,生成我们的事件,然后更新应用程序。鼠标点击的xy坐标指定了相对于小部件左上角的点击位置。

在这些方法就绪后,回到TestValidatedSpinbox类并编写一个新的测试:

# test_widgets.py, in TestValidatedSpinbox
  def test__key_validate_integration(self):
    self.vsb.delete(0, 'end')
    self.type_in_widget(self.vsb, '10')
    self.assertEqual(self.vsb.get(), '10') 

此方法首先清除小部件,然后使用type_in_widget()模拟一些有效输入。然后我们使用get()从控件中检索值,检查它是否与预期值匹配。请注意,在这些集成测试中,我们需要每次都清除控件,因为我们正在模拟在真实控件中的按键操作并触发该操作的所有副作用。

接下来,让我们测试一些无效输入;在测试方法中添加以下内容:

 self.vsb.delete(0, 'end')
    self.type_in_widget(self.vsb, 'abcdef')
    self.assertEqual(self.vsb.get(), '')
    self.vsb.delete(0, 'end')
    self.type_in_widget(self.vsb, '200')
    self.assertEqual(self.vsb.get(), '2') 

这次,我们模拟在控件中输入非数字或超出范围的值,并检查控件以确保它已正确拒绝无效的按键。在第一个例子中,ValidatedSpinbox应该拒绝所有按键,因为它们都是字母;在第二个例子中,只有初始的2应该被接受,因为随后的0按键会使数字超出范围。

我们可以使用我们的鼠标点击方法来测试ValidatedSpinbox小部件箭头按钮的功能。为了简化这个过程,我们可以在测试用例类中创建一个辅助方法来点击我们想要的箭头。当然,要点击特定的箭头,我们必须找出如何在小部件内定位该元素。

一种方法就是简单地估计一个硬编码的像素数。在大多数默认主题中,箭头位于框的右侧,框的高度大约为 20 像素。因此,这种方法可能可行:

# test_widgets.py, inside TestValidatedSpinbox
  def click_arrow_naive(self, arrow='inc', times=1):
    x = self.vsb.winfo_width() – 5
    y = 5 if arrow == 'inc' else 15
    for _ in range(times):
      self.click_on_widget(self.vsb, x=x, y=y) 

这种方法实际上效果相当不错,可能足以满足您的需求。然而,由于它对您的主题和屏幕分辨率做出了假设,因此它有点脆弱。对于更复杂的自定义小部件,您可能很难通过这种方式定位元素。更好的方法可能是找到小部件元素的实际坐标。

不幸的是,Tkinter 小部件没有提供一种方法来定位小部件内元素的xy坐标;然而,Ttk 元素提供了一个使用identify()方法查看给定坐标集下哪个元素的方法。使用这种方法,我们可以编写一个方法,遍历小部件以查找特定元素,并返回找到的第一个xy坐标集。

让我们将这个方法作为静态方法添加到TkTestCase类中,如下所示:

# test_widgets.py, inside TkTestCase
  @staticmethod
  def find_element(widget, element):
    widget.update_idletasks()
    x_coords = range(widget.winfo_width())
    y_coords = range(widget.winfo_height())
    for x in x_coords:
      for y in y_coords:
        if widget.identify(x, y) == element:
          return (x + 1, y + 1)
    raise Exception(f'{element} was not found in widget') 

此方法首先更新小部件的空闲任务。如果没有这个调用,所有元素可能还没有被绘制,identify()将返回一个空字符串。接下来,我们通过将小部件的宽度和高度传递给range()函数来获取小部件中所有xy坐标的列表。我们遍历这些列表,在小部件的每个像素坐标上调用widget.identify()。如果返回的元素名称与我们正在寻找的元素名称匹配,我们就返回当前坐标作为一个元组。如果我们遍历整个小部件而没有返回,我们将引发一个异常,指出未找到元素。

注意,我们对每个 xy 坐标都加上了 1;这是因为该元素返回小部件的左上角坐标。在某些情况下,点击这些角落坐标不会注册为对小部件的点击。为了确保我们实际上是在小部件内部点击,我们从角落向右和向下返回 1 像素的坐标。

当然,这里有一个问题:我们正在寻找的元素名称是什么?回想一下 第九章通过样式和主题改进外观,组成小部件的元素由主题确定,不同的主题可能有完全不同的元素。例如,如果你正在寻找增加箭头元素,Windows 上的默认主题将其称为 Spinbox.uparrow。然而,Linux 上的默认主题简单地将其称为 uparrow,而 macOS 上的默认主题甚至没有为它提供单独的元素(两个箭头都是一个名为 Spinbox.spinbutton 的单个元素)!

为了解决这个问题,我们需要强制我们的测试窗口使用特定的主题,这样我们就可以依赖名称的一致性。在 TestValidatedSpinbox.setUp() 方法中,我们将添加一些代码来强制显式主题:

# test_widgets.py, inside TestValidatedSpinbox.setUp()
    ttk.Style().theme_use('classic')
    self.vsb.update_idletasks() 

classic 主题应在所有平台上都可用,并且它使用简单的元素名称 uparrowdownarrow 作为 Spinbox 箭头元素。我们已经添加了对 update_idletasks() 的调用,以确保在测试开始之前主题更改已经在小部件中生效。

现在,我们可以为 TestValidatedSpinbox 编写一个更好的 click_arrow() 方法,该方法依赖于元素名称而不是硬编码的像素值。将此方法添加到类中:

# test_widgets.py, inside TestValidatedSpinbox
  def click_arrow(self, arrow, times=1):
    element = f'{arrow}arrow'
    x, y = self.find_element(self.vsb, element)
    for _ in range(times):
      self.click_on_widget(self.vsb, x=x, y=y) 

就像我们的原始版本一样,这个方法接受一个箭头方向和次数。我们使用箭头方向来构建元素名称,然后使用我们的 find_element() 方法在 ValidatedSpinbox 小部件内定位适当的箭头。一旦我们有了坐标,我们就可以使用我们编写的 click_on_widget() 方法来点击它。

让我们将这种方法付诸实践,并在新的测试方法中测试我们的箭头键功能:

# test_widgets.py, inside TestValidatedSpinbox
  def test_arrows(self):
    self.value.set(0)
    self.click_arrow('up', times=1)
    self.assertEqual(self.vsb.get(), '1')
    self.click_arrow('up', times=5)
    self.assertEqual(self.vsb.get(), '6')
    self.click_arrow(arrow='down', times=1)
    self.assertEqual(self.vsb.get(), '5') 

通过设置小部件的值,然后点击适当的箭头指定次数,我们可以测试箭头是否按照我们在小部件类中创建的规则完成了工作。

测试我们的混合类

我们还没有着手解决的一个额外挑战是测试我们的混合类。与我们的其他小部件类不同,我们的混合类不能独立存在:它依赖于与它结合的 Ttk 小部件中找到的方法和属性。

测试这个类的一个方法是将它与一个 Mock 对象混合,该对象模拟了任何继承的方法。这种方法有其优点,但一个更简单(如果理论纯度较低)的方法是用最简单的 Ttk 小部件子类化它,并测试生成的子类。

我们将创建一个使用后一种方法的测试用例。在 test_widgets.py 中启动它,如下所示:

# test_widgets.py
class TestValidatedMixin(TkTestCase):
  def setUp(self):
    class TestClass(widgets.ValidatedMixin, ttk.Entry):
      pass
    self.vw1 = TestClass(self.root) 

在这里,setUp() 方法仅创建了一个 ValidatedMixinttk.Entry 的基本子类,没有其他修改,然后创建了其实例。

现在,让我们编写一个针对 _validate() 方法的测试用例,如下所示:

 def test__validate(self):
    args = {
      'proposed': 'abc',
      'current': 'ab',
      'char': 'c',
      'event': 'key',
      'index': '2',
      'action': '1'
    }
    self.assertTrue(
      self.vw1._validate(**args)
    ) 

因为我们向 _validate() 发送了一个按键事件,它将请求路由到 _key_validate(),该函数默认简单地返回 True。我们需要验证当 _key_validate() 返回 False 时,_validate() 是否执行了所需操作。

我们将使用 Mock 来完成这个任务:

 fake_key_val = Mock(return_value=False)
    self.vw1._key_validate = fake_key_val
    self.assertFalse(
      self.vw1._validate(**args)
    )
    fake_key_val.assert_called_with(**args) 

通过测试返回 False 并验证 _key_validate() 是否以正确的参数被调用,我们已经证明了 _validate() 正确地将事件路由到正确的验证方法。

通过更新 args 中的 event 值,我们可以检查焦点移出事件是否也正常工作:

 args['event'] = 'focusout'
    self.assertTrue(self.vw1._validate(**args))
    fake_focusout_val = Mock(return_value=False)
    self.vw1._focusout_validate = fake_focusout_val
    self.assertFalse(self.vw1._validate(**args))
    fake_focusout_val.assert_called_with(event='focusout') 

我们在这里采取了相同的方法,只是模拟了 _focusout_validate() 函数,使其返回 False

正如你所见,一旦我们创建了测试类,测试 ValidatedMixin 就像测试任何其他小部件类一样。包含的源代码中有其他测试方法示例;这些应该足以帮助你开始创建完整的测试套件。

摘要

在本章中,你了解了自动化测试的好处以及 Python 的 unittest 库提供的能力。你学习了如何使用 Mockpatch() 来替换外部模块、类和函数,从而隔离代码单元。你还学习了控制 Tkinter 事件队列和模拟用户输入以自动化测试我们的 GUI 组件的策略,并针对 ABQ 应用程序的部分编写了单元测试和集成测试。

在下一章中,我们将升级我们的后端以使用关系数据库。在这个过程中,你将了解关系数据库设计和数据规范化的知识。你还将学习如何与 PostgreSQL 数据库服务器以及 Python 的 psycopg2 PostgreSQL 接口库一起工作。

第十二章:使用 SQL 提升数据存储

随着时间的推移,实验室出现了一个日益严重的问题:CSV 文件无处不在!冲突的副本、丢失的文件、非数据录入人员更改的记录,以及其他与 CSV 相关的挫折正在困扰着项目。不幸的是,应用程序中的密码保护并没有起到任何有意义的作用,以防止任何人编辑文件和损坏数据。很明显,当前的数据存储解决方案不起作用。需要更好的解决方案!

该设施有一个安装了 PostgreSQL 数据库的较旧的 Linux 服务器。你被要求更新你的程序,使其将数据存储在 PostgreSQL 数据库中,而不是 CSV 文件中,并针对数据库进行用户认证。这样就可以有一个权威的数据源,支持人员可以轻松地管理访问权限。此外,SQL 数据库将有助于强制执行正确的数据类型,并允许比简单的平面文件更复杂的数据关系。这将是你的应用程序的一个重大更新!

本章,你将学习以下主题:

  • PostgreSQL 中,我们将安装和配置 PostgreSQL 数据库系统。

  • 关系数据建模 中,我们将讨论如何在数据库中结构化数据以实现良好的性能和可靠性。

  • 创建 ABQ 数据库 中,我们将为 ABQ 数据录入应用程序构建一个 SQL 数据库。

  • 使用 psycopg2 连接到 PostgreSQL 中,我们将使用 psycopg2 库将我们的程序连接到 PostgreSQL。

  • 最后,在 将 SQL 集成到我们的应用程序中 中,我们将更新 ABQ 数据录入以利用新的 SQL 数据库。

本章假设你具备基本的 SQL 知识。如果你不具备,请参阅 附录 B快速 SQL 教程

PostgreSQL

Python 可以与各种关系型数据库进行交互,包括 Microsoft SQL Server、Oracle、MariaDB、MySQL 和 SQLite;在这本书中,我们将专注于 Python 世界中一个非常受欢迎的选择,即 PostgreSQL。PostgreSQL(通常发音为 post-gress,其中 "QL" 静音)是一个免费、开源、跨平台的数据库系统。它作为网络服务运行,你可以使用客户端程序或软件库与其通信。在撰写本文时,版本 13 是当前的稳定版本。

虽然 ABQ 已经提供了一个已经安装和配置好的 PostgreSQL 服务器,但你仍需要在你的工作站上下载和安装软件以进行开发。让我们看看如何让我们的工作站为 PostgreSQL 开发做好准备。

应该永远不要在测试或开发中使用共享的生产资源,如数据库和 Web 服务。始终在你的工作站或单独的服务器机器上设置这些资源的单独开发副本。

安装和配置 PostgreSQL

要下载 PostgreSQL,请访问 www.postgresql.org/download 并下载适用于您操作系统的安装包。EnterpriseDB(一家为 PostgreSQL 提供付费支持的商业实体)提供了 Windows、macOS、Linux、BSD 和 Solaris 的安装包。这些安装程序包含服务器、命令行客户端和 pgAdmin 图形客户端,全部包含在一个包中。要安装软件,请使用具有管理员权限的账户启动安装程序,并按照安装向导中的屏幕操作。在安装过程中,您将被要求为 postgres 超级用户账户设置密码;请务必记下此密码。

使用图形实用程序配置 PostgreSQL

安装完成后,您可以使用 pgAdmin 图形实用程序配置和与 PostgreSQL 交互。从您的应用程序菜单启动 pgAdmin,并按照以下步骤为自己创建一个新的管理员用户:

  1. 从左侧的 浏览器 窗格中选择 服务器。您将被提示输入超级用户密码。

  2. 一旦认证,选择 对象 | 创建 | 登录/组角色。在 常规 选项卡中输入用于数据库访问的用户名。然后访问 权限 选项卡以检查 超级用户可以登录,以及 定义 选项卡以设置密码。

  3. 在窗口底部点击 保存 按钮。

接下来,我们需要创建一个数据库。为此,请按照以下步骤操作:

  1. 从菜单中选择 对象 | 创建 | 数据库

  2. 将数据库命名为 abq,并将您的新用户账户设置为所有者。

  3. 在窗口底部点击 保存 按钮。

您的数据库现在已准备好使用。您可以通过在 浏览器 窗格中选择数据库并点击菜单中的 工具 | 查询工具 来开始输入 SQL 语句以运行数据库。

使用命令行配置 PostgreSQL

如果您更喜欢直接在命令行中工作,PostgreSQL 包含几个命令行实用程序,包括以下内容:

命令 描述
createuser 创建 PostgreSQL 用户账户
dropuser 删除 PostgreSQL 用户账户
createdb 创建 PostgreSQL 数据库
dropdb 删除 PostgreSQL 数据库
psql 命令行 SQL shell

例如,在 macOS 或 Linux 上,我们可以使用以下命令完成数据库的配置:

$ sudo -u postgres createuser -sP myusername
$ sudo -u postgres createdb -O myusername abq
$ psql -d abq -U myusername 

这三个命令创建用户、创建数据库并打开一个 SQL shell,可以在其中输入查询。请注意,我们使用 sudo 命令以 postgres 用户身份运行这些命令。请记住,这是您在安装过程中设置的超级用户账户。

尽管 EnterpriseDB 为 Linux 提供了二进制安装程序,但大多数 Linux 用户更愿意使用他们发行版提供的软件包。你可能会得到一个稍微旧一点的 PostgreSQL 版本,但对于大多数基本用例来说这不会很重要。请注意,pgAdmin 通常是一个单独的软件包的一部分,也可能是一个稍微旧一点的版本。无论如何,你应该没有困难地使用较旧版本来跟随这一章节。

关系型数据建模

我们的应用程序目前将数据存储在一个单一的 CSV 文件中;这样的文件通常被称为平面文件,因为数据已经被展平到二维。虽然这种格式对我们应用程序来说是可接受的,并且可以直接转换为 SQL 表,但一个更准确和有用的数据模型需要更多的复杂性。在本节中,我们将介绍一些数据建模的概念,这将帮助我们将 CSV 数据转换为有效的关系表。

主键

关系型数据库中的每个表都应该有一个称为主键的东西。主键是一个值,或一组值,它唯一地标识表中的记录;因此,它应该是一个值或一组值,对于表中的每一行都是唯一的且非空的。数据库中的其他表可以使用此字段来引用表中的特定行。这被称为外键关系。

我们如何确定一组数据的主键是什么?考虑这个表格:

水果 分类
香蕉 草莓
香蕉 草莓
橙子 柑橘
柠檬 柑橘

在这个表中,每一行代表一种水果类型。在这个表中,水果列是空的就没有意义,或者两行对于水果有相同的值也是不合理的。这使得该列成为主键的完美候选者。

现在考虑一个不同的表格:

水果 品种 数量
香蕉 凯文迪什 452
香蕉 红色 72
橙子 橘子 1023
橙子 红色 875

在这个表中,每一行代表一种水果的亚品种;然而,没有单个字段可以唯一地定义单一水果的单一品种。相反,需要水果品种字段。当我们需要多个字段来确定主键时,我们称之为组合主键。在这种情况下,我们的组合主键使用了水果品种字段。

使用代理主键

考虑这个员工表:

名字 姓氏 职位
鲍勃 史密斯 经理
爱丽丝 琼斯 分析师
帕特 汤普森 开发者

假设这个表使用 FirstLast 作为复合主键,并且假设数据库中的其他表使用主键引用行。不考虑两个同名同姓的人显然的问题,如果 Bob Smith 决定他更愿意被称为 Robert,或者如果 Alice Jones 结婚并取了新的姓氏,会发生什么?记住,其他表使用主键值来引用表中的行;如果我们更改主键字段的值,引用这些员工的表要么也必须更新,要么将无法在 employees 表中找到记录。

虽然使用实际数据字段来构建主键值在理论上可能是最纯粹的方法,但当您开始使用外键关联表时,会出现两个主要的缺点:

  • 您必须在需要引用您的表的每个表中重复数据。如果您有许多字段组成的复合键,这可能会变得特别繁琐。

  • 您不能更改原始表中的值,否则会破坏外键引用。

因此,数据库工程师可能会选择使用代理键。这些通常是存储在标识列中的整数或全局唯一标识符GUID)值,当记录被插入到表中时,这些值会自动添加到记录中。在 employees 表的情况下,我们可以简单地添加一个包含自动递增整数值的 ID 字段,如下所示:

ID 首名 姓氏 职位
1 Bob Smith 经理
2 Alice Jones 分析师
3 Pat Thompson 开发者

现在,其他表可以简单地引用 employees.ID=1employees.ID=2,这样 BobAlice 就可以自由更改他们的名字而不会产生后果。

使用代理键可能会破坏数据库的理论纯粹性;它还可能要求我们手动指定列的唯一性或非空约束,这些约束在它们用作主键时是隐含的。有时,尽管如此,代理键的实用优势可能会超过这些担忧。您需要评估哪种选项最适合您的应用程序及其数据。

在做出这种决定的规则之一是考虑您打算用作键的数据是描述还是定义了由行表示的项目。例如,一个名字并不定义一个人:一个人可以更改他们的名字,但仍然是同一个人。另一方面,存储在我们 CSV 文件中的检查图是由日期、时间、实验室和检查图值定义的。更改这些值中的任何一个,你就是在引用不同的检查图。

规范化

将平面数据文件分解成多个表的过程称为规范化。规范化过程被分解成一系列称为范式的级别,这些级别逐步消除重复并创建一个更精确的数据模型。尽管有许多范式,但大多数常见业务数据中遇到的问题都可以通过遵循前三个范式来解决。

将数据符合这些形式的目的在于消除冗余、冲突或未定义数据情况的可能性。让我们简要地看一下前三个范式,以及它们能防止哪些问题。

第一范式

第一范式要求每个字段只包含一个值,并且必须消除重复的列。例如,假设我们有一个看起来像这样的平面文件:

水果 多种品种
香蕉 卡文迪什、红色、苹果
橙子 柑橘、瓦伦西亚、血橙、卡拉卡拉

这个表中的Varieties字段在一个列中有多个值,所以这个表不在第一范式。我们可能会尝试这样修复它:

水果 品种 _1 品种 _2 品种 _3 品种 _4
香蕉 卡文迪什 红色 苹果
橙子 柑橘、瓦伦西亚、血橙、卡拉卡拉

这是一种改进,但它仍然不在第一范式,因为我们有重复的列。所有的Variety_列代表相同的属性(水果的品种),但被任意地拆分成了不同的列。判断是否有重复列的一种方法是,如果数据无论放入哪一列都是同样有效的;例如,Cavendish可以同样地放入Variety_2Variety_3Variety_4列。

考虑这种格式的一些问题:

  • 如果我们在多个Variety字段中有相同的数据意味着什么;例如,如果Banana行在Variety_1Variety_4中都有Cavendish?或者如果Variety_1为空,但Variety_2有值,这会表明什么?这些模糊的情况被称为异常,可能导致数据库中的冲突或混淆数据。

  • 查询这个表以查看两种水果是否共享一个品种名称会复杂到什么程度?我们必须检查每个Variety_字段与每个其他Variety_字段。如果我们需要为某种特定水果超过四种品种怎么办?我们就必须添加列,这意味着我们的查询将变得指数级复杂。

要将这个表提升到第一范式,我们需要创建一个Fruit列和一个Variety列,类似于这样:

水果 品种
香蕉 卡文迪什
香蕉 红色
香蕉 苹果
橙子 柑橘
橙子 瓦伦西亚
橙子 血橙
橙子 卡拉卡拉

注意,这改变了我们表的本质,因为它不再是每 Fruit 一行,而是每 Fruit-Variety 组合一行。换句话说,主键已从 Fruit 变为 Fruit + Variety。如果表中还有其他与 Fruit 类型相关但与 Variety 无关的字段,我们将在查看第二范式时解决该问题。

第二范式

第二范式要求满足第一范式,并且还要求每个值必须依赖于整个主键。换句话说,如果一个表有主键字段 A、B 和 C,并且列 X 的值仅依赖于列 A 的值,而不考虑 B 或 C,则该表违反了第二范式。例如,假设我们向我们的表中添加了一个 Classification 字段,如下所示:

水果 品种 分类
香蕉 卡文迪什 草莓
香蕉 红色 草莓
橙子 柑橘 柑橘
橙子 瓜拉尼 柑橘

在这个表中,FruitVariety 构成了每一行的主键。然而,Classification 只依赖于 Fruit,因为所有香蕉都是草莓,所有橙子都是柑橘。考虑这种格式的缺点:

  • 首先,我们有一个数据冗余,因为每种 Fruit 类型都会多次列出其 Classification(每次 Fruit 值重复时都会列出一次)。

  • 这种冗余可能导致异常,即相同的 Fruit 值在不同的行中有不同的 Classification 值。这是没有意义的。

为了解决这个问题,我们需要将我们的表拆分为两个表;一个包含 FruitClassification,主键为 Fruit,另一个包含 FruitVariety,这两个字段共同构成主键。

第三范式

第三范式要求满足第二范式,并且还要求表中的每个值只依赖于主键。换句话说,给定一个主键为 A 的表,以及数据字段 X 和 Y,Y 的值不能依赖于 X 的值,它只能依赖于 A。

例如,考虑这个表:

水果 主要出口国家 主要出口大陆
香蕉 厄瓜多尔 南美洲
橙子 巴西 南美洲
苹果 中国 亚洲

这个表符合第二范式,因为这两列都是相对于主键是唯一的——每种水果只能有一个主要的出口国家,以及一个主要的出口大陆。然而,Leading Export Continent 的值依赖于 Leading Export Country 的值(一个非主键字段),因为一个国家位于一个大陆,与其水果出口无关。这种格式的缺点是:

  • 存在数据冗余,因为任何出现多次的国家都会导致其大陆出现多次。

  • 再次,这种冗余可能导致异常,即同一个国家可能会列出两个不同的洲。这是没有意义的。

要将此转换为第三范式,我们需要创建一个包含大陆列和任何其他依赖于国家的列的单独的国家表。

更多的规范化形式

数据库理论家提出了其他更高的规范化形式,可以帮助进一步消除数据中的模糊性和冗余,但在这本书中,前三个应该足以组织我们的数据。请注意,对于某个应用来说,数据可能存在过度规范化的问题。决定什么构成过度规范化实际上取决于数据和用户。

例如,如果你有一个包含telephone_1telephone_2列的联系人数据库,第一范式会规定你应该将电话号码放在它们自己的表中以消除重复字段。但如果你的用户不需要超过两个字段,很少使用第二个字段,并且永远不会对数据进行复杂查询,那么使数据库和应用复杂化以符合理论上的纯模型可能并不值得。

实体-关系图

有一个有效的方法可以帮助我们规范化数据并为关系数据库做准备,那就是创建一个实体-关系图,或ERD。ERD 是一种图表化我们数据库存储信息的事物及其之间关系的方式。

这些“事物”被称为实体。实体是一个唯一可识别的对象;它对应于单个表中的一行。实体有属性,它们对应于表中的列。实体还与其他实体有关系,这对应于我们在 SQL 中定义的外键关系。

让我们考虑我们实验室场景中的实体及其属性和关系:

  • 实验室。每个实验室都有一个名字。

  • 地块。每个地块属于一个实验室,并有一个编号。每个地块中种植一个单独的种子样本。

  • 实验室技术人员,他们每个人都有一个名字。

  • 实验室检查,这些检查由实验室的技术人员在一个特定的实验室进行。每个实验室检查都有一个日期和时间。

  • 地块检查,这是在实验室检查期间在单个地块上收集的数据。每个地块检查都记录了各种植物和环境数据。

下面的图显示了这些实体及其关系:

我们 ABQ 数据的实体-关系图

图 12.1:我们 ABQ 数据的实体-关系图

在此图中,实体由矩形表示。我们有五个实体:LabPlotLab TechLab CheckPlot Check。每个实体都有属性,由椭圆形表示。实体之间的关系由菱形表示,其中的文字描述了从左到右的关系。例如,Lab Tech 执行 Lab Check,并且 Lab CheckLab 中执行。注意关系周围的小 1n 字符:这些显示了关系的基数。数据库中常见三种基数类型:

  • 一对多(1 到 n)的关系,其中左表中的一行与右表中的多行相关联。例如,一个 Lab Tech 执行多个 Lab Checks

  • 多对一(n 到 1)的关系,其中左表中的多行与右表中的同一行相关联。例如,在同一个“实验室”中执行多个“实验室检查”。

  • 多对多(n 到 n)的关系,其中左表中的多行与右表中的多行相关联。例如,如果我们需要更新我们的数据库以允许一个以上的技术人员在同一实验室检查中工作,那么一个实验室技术人员仍然会执行多个检查,但一个检查会有多个技术人员(幸运的是,我们不需要实现这一点!)。

此图表示了我们数据的一个合理规范结构。要在 SQL 中实现它,我们只需为每个实体创建一个表,为每个属性创建一个列,并为每个关系创建一个外键关系。但在我们这样做之前,让我们再考虑一件事:SQL 数据类型。

分配数据类型

标准 SQL 定义了 16 种数据类型,包括各种大小的整数和浮点数类型、固定或可变大小的 ASCII 或 Unicode 字符串类型、日期和时间类型以及单比特类型。除了实现标准类型外,几乎每个 SQL 引擎都通过添加更多类型来扩展此列表,以适应二进制数据、JSON 数据、货币值、网络地址和其他特殊类型的字符串或数字。许多数据类型似乎有点冗余,并且有几个别名可能在实现之间不同。为您的列选择数据类型可能会令人惊讶地复杂!

对于 PostgreSQL,以下图表提供了一些合理的选择:

存储的数据 推荐类型 备注
固定长度字符串 CHAR 需要长度,例如,CHAR(256)
短到中等长度的字符串 VARCHAR 需要一个最大长度参数,例如,VARCHAR(256)
长文本,自由格式 TEXT 长度无限,性能较慢。
较小的整数 SMALLINT 范围为 ±32,767。
大多数整数 INT 大约 ±2.1 亿。
较大的整数 BIGINT 大约 ±922 万亿。
小数数字 NUMERIC 可选长度和精度参数。
整数主键 SERIAL, BIGSERIAL 自动递增的整数或大整数。
布尔值 BOOLEAN 可以是 TRUE、FALSE 或 NULL。
日期和时间 TIMESTAMP WITH TIMEZONE 存储日期、时间和时区。精确到 1 µs。
日期无时间 DATE 存储日期。
时间无日期 TIME 可以带或不带时区。

这些类型可能满足大多数应用中的绝大多数需求,我们将使用这些类型的一个子集来构建我们的 ABQ 数据库。随着我们创建表格,我们将参考我们的数据字典,并为我们的列选择适当的数据类型。

请注意不要选择过于具体或限制性的数据类型。任何数据最终都可以存储在TEXT字段中;选择更具体类型的目的是主要为了能够使用特定于该类型数据的运算符、函数或排序。如果那些不是必需的,考虑一个更通用的类型。例如,电话号码和美国社会保障号码可以用纯数字表示,但这并不是将它们做成INTEGERNUMERIC字段的原因;毕竟,你不会对它们进行算术运算!

创建 ABQ 数据库

现在我们已经建模了数据,并对可用的数据类型有了感觉,是时候构建我们的数据库了。确保你已经安装了 PostgreSQL,并如本章第一部分所述创建了abq数据库,然后让我们开始编写 SQL 来创建我们的数据库结构。

在你的项目根目录下,创建一个名为sql的新目录。在sql目录内,创建一个名为create_db.sql的文件。我们将从这里开始编写我们的表定义查询。

创建我们的表格

我们创建表格的顺序很重要。任何在外键关系中引用的表格都必须在定义关系之前存在。因此,最好从你的查找表开始,沿着一对一关系的链条继续,直到所有表格都创建完成。在我们的 ERD 中,这从大致的左上角延伸到右下角。

创建查找表

我们需要创建以下三个查找表:

  • labs:这个查找表将包含我们实验室的 ID 字符串。由于实验室的名称不会改变,我们将只使用单字母名称作为主键值。

  • lab_techs:这个查找表将包含实验室技术人员的姓名。由于我们不希望使用员工姓名作为主键,我们将创建一个员工 ID 号码的列,并使用它作为主键。

  • plots:这个查找表将为每个物理地块创建一行,通过实验室和地块编号进行标识。它还将跟踪地块中种植的当前种子样本。

将创建这些表格的 SQL 查询添加到create_db.sql文件中,如下所示:

# create_db.sql
CREATE TABLE labs (id CHAR(1) PRIMARY KEY);
CREATE TABLE lab_techs (
  id SMALLINT PRIMARY KEY,
  name VARCHAR(512) UNIQUE NOT NULL
);
CREATE TABLE plots (
  lab_id CHAR(1) NOT NULL REFERENCES labs(id),
  plot SMALLINT NOT NULL,
  current_seed_sample CHAR(6),
  PRIMARY KEY(lab_id, plot),
  CONSTRAINT valid_plot CHECK (plot BETWEEN 1 AND 20)
); 

一旦创建,这三个表格看起来可能像这样:

lab_id
A
B
C

实验室表

id name
4291 J Simms
4319 P Taylor

实验室技术人员表

lab_id plot current_seed_sample
A 1 AXM477
A 2 AXM478
A 3 AXM479

地块表

虽然这些表可能看起来非常简单,但它们将有助于强制数据完整性,并使动态从数据库构建接口变得简单。例如,由于我们将从数据库中填充我们的 Labs 小部件,因此向应用程序添加一个新的实验室只是向数据库中添加一行的问题。

lab_checks

lab_checks 表的每一行代表一个技术人员在给定日期的特定时间检查实验室所有图表的一个实例。我们将使用以下 SQL 来定义它:

CREATE TABLE lab_checks(
  date DATE NOT NULL, time TIME NOT NULL,
  lab_id CHAR(1) NOT NULL REFERENCES labs(id),
  lab_tech_id SMALLINT NOT NULL REFERENCES lab_techs(id),
  PRIMARY KEY(date, time, lab_id)
); 

当创建并填充时,表将看起来像这样:

日期 时间 实验室 ID 实验室技术人员 ID
2021-10-01 8:00 A 4291

lab_checks

datetimelab_id 列共同唯一地标识一个实验室检查,因此我们将它们共同指定为主键。进行检查的实验室技术人员的 ID 是这个表中的唯一属性,并创建与 lab_techs 表的外键关系。

plot_checks

图表检查是在各个图表收集的实际数据记录。这些每个都属于一个实验室检查,因此必须使用三个键值 datetimelab_id 回指现有的实验室检查。

我们将从主键列开始:

CREATE TABLE plot_checks(
  date DATE NOT NULL,
  time TIME NOT NULL,
  lab_id CHAR(1) NOT NULL REFERENCES labs(id),
  plot SMALLINT NOT NULL, 

plot_checks 的主键基本上是 lab_check 表的主键,增加了图表编号;其键约束如下所示:

 PRIMARY KEY(date, time, lab_id, plot),
  FOREIGN KEY(date, time, lab_id)
    REFERENCES lab_checks(date, time, lab_id),
  FOREIGN KEY(lab_id, plot) REFERENCES plots(lab_id, plot), 

现在我们已经定义了主键列,我们可以添加属性列:

 seed_sample CHAR(6) NOT NULL,
  humidity NUMERIC(4, 2) CHECK (humidity BETWEEN 0.5 AND 52.0),
  light NUMERIC(5, 2) CHECK (light BETWEEN 0 AND 100),
  temperature NUMERIC(4, 2) CHECK (temperature BETWEEN 4 AND 40),
  equipment_fault BOOLEAN NOT NULL,
  blossoms SMALLINT NOT NULL CHECK (blossoms BETWEEN 0 AND 1000),
  plants SMALLINT NOT NULL CHECK (plants BETWEEN 0 AND 20),
  fruit SMALLINT NOT NULL CHECK (fruit BETWEEN 0 AND 1000),
  max_height NUMERIC(6, 2) NOT NULL
    CHECK (max_height BETWEEN 0 AND 1000),
  min_height NUMERIC(6, 2) NOT NULL
    CHECK (min_height BETWEEN 0 AND 1000),
  median_height NUMERIC(6, 2) NOT NULL
  CHECK (median_height BETWEEN min_height AND max_height),
  notes TEXT
); 

当创建并填充时,表的最初几列看起来像这样:

日期 时间 实验室 图表 种子样本 湿度 光照 (等等...)
2021-10-01 08:00:00 A 1 AXM477 24.19 0.97
2021-10-01 08:00:00 A 2 AXM478 23.62 1.03

plot_checks

注意我们使用数据类型和 CHECK 约束来复制规范的数据字典中定义的限制。使用这些,我们利用了数据库的强大功能来保护无效数据。这完成了我们对 ABQ 数据库的表定义。

创建一个视图

在我们完成数据库设计之前,我们将创建一个 视图,这将简化我们数据的访问。视图在大多数方面表现得像一张表,但它不包含实际数据;它实际上只是一个存储的 SELECT 查询。我们将创建一个名为 data_record_view 的视图,以重新排列我们的数据,以便更容易与 GUI 交互。

视图是通过使用 CREATE VIEW 命令创建的,它开始如下:

# create_db.sql
CREATE VIEW data_record_view AS ( 

接下来,在括号内,我们放入将返回我们视图中所需表数据的 SELECT 查询:

SELECT pc.date AS "Date", to_char(pc.time, 'FMHH24:MI') AS "Time",
  lt.name AS "Technician", pc.lab_id AS "Lab", pc.plot AS "Plot",
  pc.seed_sample AS "Seed Sample", pc.humidity AS "Humidity",
  pc.light AS "Light", pc.temperature AS "Temperature",
  pc.plants AS "Plants", pc.blossoms AS "Blossoms",
  pc.fruit AS "Fruit", pc.max_height AS "Max Height",
  pc.min_height AS "Min Height", pc.median_height AS "Med Height",
  pc.notes AS "Notes"
FROM plot_checks AS pc
  JOIN lab_checks AS lc ON pc.lab_id = lc.lab_id
  AND pc.date = lc.date AND pc.time = lc.time
  JOIN lab_techs AS lt ON lc.lab_tech_id = lt.id
 ); 

我们正在选择 plot_checks 表,并通过外键关系将其与 lab_checkslab_techs 表连接。请注意,我们已使用 AS 关键字对这些表进行了别名设置。这样的简短别名可以帮助使大型查询更易于阅读。我们还将每个字段别名为应用程序数据结构中使用的名称。这些名称必须用双引号括起来,以便使用空格并保留大小写。通过使列名与我们的应用程序中的数据字典键匹配,我们就不需要在应用程序代码中翻译字段名。

视图的前几列看起来像这样;将其与上面的原始 plot_checks 表进行比较:

日期 时间 技术员 实验室 图表 种子样本 湿度 光照
2021-10-01 8:00 J Simms A 1 AXM477 24.19 0.97
2021-10-01 8:00 J Simms A 2 AXM478 23.62 1.03

SQL 数据库引擎,如 PostgreSQL,在连接和转换表格数据方面非常高效。尽可能利用这种力量,让数据库为您的应用程序方便地格式化数据。

这完成了我们的数据库创建脚本。在您的 PostgreSQL 客户端中运行此脚本,并验证是否已创建了四个表和视图。要在 pgAdmin 中执行脚本,首先从 工具 | 查询工具 打开 查询工具,然后通过点击 查询编辑器 窗口上方的文件夹图标打开文件。文件打开后,点击播放按钮图标来执行它。要在命令行中运行脚本,请在终端执行以下命令:

$ cd ABQ_Data_Entry/sql
$ psql -U myuser -d abq < create_db.sql 

填充查找表

虽然所有表都已创建,但在我们可以使用它们之前,查找表需要被填充;具体来说:

  • labs 应该有 AC 的值,代表三个实验室。

  • lab_techs 需要我们四位实验室技术人员的姓名和 ID 号:J Simms(4291)、P Taylor(4319)、Q Murphy(4478)和 L Taniff(5607)。

  • plots 需要所有 60 个图表,每个实验室的编号从 1 到 20。种子样本在四个值之间旋转,例如 AXM477、AXM478、AXM479 和 AXM480。

您可以使用 pgAdmin 手动填充这些表,或使用示例代码中包含的 lookup_populate.sql 脚本。就像执行 create_db.sql 脚本一样执行它。

现在我们的数据库已准备好与应用程序一起使用。让我们让应用程序准备好与数据库一起工作!

使用 psycopg2 连接到 PostgreSQL

现在我们有一个很好的数据库可以与之一起使用,我们如何让我们的应用程序使用它?要从我们的应用程序中执行 SQL 查询,我们需要安装一个可以直接与我们的数据库通信的 Python 库。在 Python 中,每个不同的 SQL 产品都有一到多个库可用于与之集成。

对于 PostgreSQL,最流行的选择是psycopg2psycopg2库不是 Python 标准库的一部分,因此您需要在运行应用程序的任何机器上安装它。您可以在initd.org/psycopg/docs/install.html找到最新的安装说明;然而,首选的方法是使用pip

对于 Windows、macOS 和 Linux,以下命令应该有效:

$ pip install --user psycopg2-binary 

如果这不起作用,或者您宁愿从源代码安装它,请检查网站上的要求。请注意,psycopg2库是用 C 语言编写的,而不是 Python,因此需要 C 编译器和一些其他开发包才能从源代码安装。

Linux 用户通常可以从其发行版的软件包管理系统中安装psycopg2

psycopg2 基础知识

使用psycopg2的基本工作流程如下:

  1. 首先,我们使用psycopg2.connect()创建一个Connection对象。此对象代表我们与数据库引擎的连接,并用于管理我们的登录会话。

  2. 接下来,我们使用Connection对象的cursor()方法从我们的连接中创建一个Cursor对象。游标是我们与数据库引擎交互的点。

  3. 我们可以通过将 SQL 字符串传递给游标的execute()方法来运行查询。

  4. 如果我们的查询返回数据,我们可以使用游标的fetchone()fetchall()方法检索数据。

以下脚本演示了psycopg2的基本用法:

# psycopg2_demo.py
import psycopg2 as pg
from getpass import getpass
cx = pg.connect(
  host='localhost',  database='abq',
  user=input('Username: '),
  password=getpass('Password: ')
)
cur = cx.cursor()
cur.execute("""
  CREATE TABLE test
  (id SERIAL PRIMARY KEY, val TEXT)
""")
cur.execute("""
  INSERT INTO test (val)
  VALUES ('Banana'), ('Orange'), ('Apple');
""") 

我们首先导入psycopg2并将其别名为pg以简化;我们还导入了getpass以提示用户输入密码。接下来,我们使用connect()函数生成一个连接对象cx,传递所有必要的详细信息以定位数据库服务器并对其进行身份验证。这些详细信息包括服务器的主机名、数据库名称和身份验证凭证。host参数可以是运行 PostgreSQL 服务器的服务器名称、IP 地址或完全限定的域名。由于我们在本地系统上运行 PostgreSQL,所以我们在这里使用了localhost,它指向我们的本地系统。

从连接中,我们创建一个游标对象cur。最后,我们使用了游标的execute()方法来执行两个 SQL 查询。

现在,让我们从数据库中检索一些数据,如下所示:

cur.execute("SELECT * FROM test")
num_rows = cur.rowcount
data = cur.fetchall()
print(f'Got {num_rows} rows from database:')
print(data) 

您可能期望从查询中检索到的数据在execute()的返回值中找到;然而,情况并非如此。相反,我们执行查询,然后使用游标的方法和属性来检索数据和执行的相关元数据。在这种情况下,我们使用了fetchall()一次性检索所有数据行。我们还使用了游标的rowcount属性来查看从数据库返回了多少行。

PostgreSQL 是一个 事务型数据库,这意味着修改操作(如我们的 CREATEINSERT 语句)不会自动保存到磁盘。为了做到这一点,我们需要 提交 事务。在 psycopg2 中,我们可以使用连接对象的 commit() 方法来完成,如下所示:

cx.commit() 

如果我们不提交,我们在连接退出时所做的更改将不会被保存。当我们的应用程序或脚本退出时,连接会自动退出,但我们可以使用连接的 close() 方法显式退出,如下所示:

cx.close() 

在创建 Connection 对象时,您可以指定 autocommit=True,这样 psycopg2 就会在每次查询后隐式提交事务。这是一个方便的便利功能,尤其是在使用 shell 中的 PostgreSQL 时。

参数化查询

很常见的情况是我们需要在 SQL 查询中包含运行时数据,例如用户输入的数据。您可能会倾向于使用 Python 强大的字符串格式化功能来完成此操作,如下所示:

new_item = input('Enter new item: ')
cur.execute(f"INSERT INTO test (val) VALUES ('{new_item}')")
cur.execute('SELECT * FROM test')
print(cur.fetchall()) 

绝对不要这样做! 虽然一开始可能有效,但它会创建一个称为 SQL 注入漏洞 的漏洞。换句话说,它将允许程序的用户输入他们想要的任何 SQL 命令。例如,我们可以执行脚本并添加如下恶意数据:

$ python psycopg2_demo.py
Username: alanm
Password:
Got 3 rows from database:
[(1, 'Banana'), (2, 'Orange'), (3, 'Apple')]
Enter new item: '); DROP TABLE test; SELECT ('
Traceback (most recent call last):
  File "/home/alanm/psycopg2_demo.py", line 37, in <module>
    cur.execute('SELECT * FROM test')
psycopg2.errors.UndefinedTable: relation "test" does not exist
LINE 1: SELECT * FROM test 

在这个例子中,我们执行了程序并输入了一个字符串,该字符串关闭了我们的编码 SQL 语句并添加了一个 DROP TABLE 语句。然后它添加了一个部分 SELECT 语句以避免 SQL 引擎的语法错误。结果是 test 表被删除,当我们尝试从它查询数据时出现异常!

SQL 注入漏洞已经困扰应用程序数十年,并成为许多高调黑客灾难的源头。幸运的是,psycopg2 通过使用 参数化查询 给我们提供了避免这种情况的方法。前面代码的参数化版本如下所示:

new_item = input('Enter new item: ')
**cur.execute(****"INSERT INTO test (val) VALUES (%s)"****, (new_item,))**
cur.execute('SELECT * FROM test')
print(cur.fetchall()) 

要参数化一个查询,我们使用 %s 字符串来代替我们想要插入查询中的值。这些值本身作为 execute() 方法的第二个参数传入。对于多个值,参数值应该作为列表或元组传入,并将按顺序替换 %s 出现的位置。

对于复杂的查询,我们还可以给每个参数一个名称,并传入一个字典来匹配值;例如:

cur.execute(
  "INSERT INTO test (val) VALUES (%(item)s)",
  {'item': new_item}
) 

参数的名称放在百分号和 s 字符之间的括号中。名称将与参数值字典中的键匹配,并在数据库执行查询时进行替换。

这个参数字符串中的 s 被称为 格式说明符,它源自 Python 的原始字符串替换语法。它是必需的,并且应该 始终s。如果您参数化的查询导致无效格式说明符错误,那是因为您忘记了 s 或使用了不同的字符。

参数化查询负责正确转义和清理我们的数据,从而使得 SQL 注入攻击几乎不可能。例如,如果我们尝试使用参数化代码的先前黑客攻击,我们会得到以下结果:

Enter new item: '); DROP TABLE test; SELECT ('
[(1, 'Banana'), (2, 'Orange'), (3, 'Apple'), (4, "'); DROP TABLE test; SELECT ('")] 

不仅参数化查询可以保护我们免受 SQL 注入攻击,而且它们还会自动将某些 Python 类型转换为 SQL 值;例如,Python datedatetime 对象会自动转换为 SQL 识别为日期的字符串,而 None 会自动转换为 SQL NULL

注意,参数仅适用于 数据值;没有方法可以对其他查询内容进行参数化,如表名或命令。

特殊游标类

默认情况下,Cursor.fetchall() 将我们的查询结果作为元组的列表返回。如果我们有一个一列或两列的表,这可能是可以接受的,但对于像我们的 ABQ 数据库中的大表,很快就会变成一个问题,即记住哪个元组索引对应哪个字段。理想情况下,我们希望能够通过名称引用字段。

为了适应这一点,psycopg2 允许我们为我们的连接对象指定一个 游标工厂 类,允许我们使用具有自定义行为的游标对象。psycopg2 中包含的一个这样的自定义游标类是 DictCursor 类。我们这样使用它:

# psycopg2_demo.py
**from** **psycopg2.extras** **import** **DictCursor**
cx = pg.connect(
  host='localhost',  database='abq',
  user=input('Username: '),
  password=getpass('Password: '),
  **cursor_factory=DictCursor**
) 

DictCursorpsycopg2.extras 模块中找到,因此我们必须从主模块单独导入它。一旦导入,我们将它传递给 connect() 函数的 cursor_factory 参数。现在,行将以 DictRow 对象的形式返回,可以像字典一样处理:

cur.execute("SELECT * FROM test")
data = cur.fetchall()
for row in data:
    print(row['val']) 

当处理大量列时,这要方便得多。

关于 psycopg2 的更多信息可以在其官方文档中找到:www.psycopg.org/docs/

将 SQL 集成到我们的应用程序中

将我们的应用程序转换为 SQL 后端将是一项艰巨的任务。应用程序是围绕 CSV 文件假设构建的,尽管我们已经尽力分离关注点,但许多事情都需要改变。

让我们分解我们需要采取的步骤:

  • 我们需要创建一个新的模型来与 SQL 数据库进行接口。

  • 我们的 Application 类将需要使用 SQL 模型,并且可能需要根据结果调整一些行为。

  • 记录表单需要重新排序以优先考虑我们的关键字段,使用新的查找表,并使用数据库中的信息自动填充。

  • 记录列表需要调整以与新数据模型和主键一起工作。

让我们开始吧!

创建一个新的模型

我们将在 models.py 中开始导入 psycopg2DictCursor

# models.py
import psycopg2 as pg
from psycopg2.extras import DictCursor 

如前一小节所学,DictCursor 允许我们以 Python 字典而不是默认的元组形式获取结果,这在我们应用程序中更容易处理。

现在,开始一个新的模型类 SQLModel,并像这样复制 CSVModelfields 属性:

# models.py
class SQLModel:
  """Data Model for SQL data storage"""
  fields = {
    "Date": {'req': True, 'type': FT.iso_date_string},
    "Time": {'req': True, 'type': FT.string_list,
     'values': ['8:00', '12:00', '16:00', '20:00']},
    # etc. ... 

然而,我们需要对这个字典做一些修改。首先,我们的有效实验室和绘图值将从数据库中提取,而不是在这里硬编码,所以我们将它们指定为空列表,并在初始化器中填充它们。此外,技术人员字段将变成一个下拉选择,也由数据库填充,所以我们需要将其类型改为 string_list,并将 values 参数的列表留空。

这三个条目应该看起来像这样:

# models.py, in the SQLModel.fields property
    "Technician": {
      'req': True, 'type':  FT.string_list, 'values': []
    },
    "Lab": {
      'req': True, 'type': FT.short_string_list, 'values': []
    },
    "Plot": {
      'req': True, 'type': FT.string_list, 'values': []
    }, 

在我们编写初始化器之前,让我们创建一个方法来封装查询和检索数据周围的许多样板代码。我们将把这个方法命名为 query();像这样将其添加到 SQLModel 类中:

# models.py, inside SQLModel
  def query(self, query, parameters=None):
    with self.connection:
      with self.connection.cursor() as cursor:
        cursor.execute(query, parameters)

        if cursor.description is not None:
          return cursor.fetchall() 

此方法接受一个查询字符串,以及可选的参数序列。在方法内部,我们首先使用 Connection 对象打开一个上下文块。以这种方式使用连接意味着如果查询成功,psycopg2 将自动提交事务。接下来,我们生成我们的 Cursor 对象,也使用上下文管理器。通过将游标用作上下文管理器,如果 execute() 方法抛出异常,psycopg2 将自动 回滚 我们的事务。回滚是提交数据库的相反:不是保存更改,而是丢弃它们,并从上次提交(或会话的开始,如果我们还没有调用 commit())时的数据库状态开始。回滚后,异常将被重新抛出,以便我们可以在调用代码中处理它,并且,在任何情况下,当块退出时,游标都将关闭。本质上,它等同于以下内容:

 cursor = self.connection.cursor()
    try:
      cursor.execute(query, parameters)
    except (pg.Error) as e:
      self.connection.rollback()
      raise e
    finally:
      cursor.close() 

如果我们成功执行查询并且它返回数据,该方法需要返回这些数据。为了确定是否返回了数据,我们检查 cursor.description 属性。cursor.description 属性返回由我们的查询返回的表的标题列表;如果我们的查询没有返回数据(例如 INSERT 查询),它被设置为 None。重要的是要意识到,如果没有从查询返回数据,fetchall() 将引发异常,所以我们应该在执行之前检查 description

现在我们有了这个方法,我们可以轻松地从数据库中检索结果,如下所示:

 def some_method(self):
    return self.query('SELECT * FROM table') 

为了了解我们如何使用查询方法,让我们先给这个类添加一个初始化器方法:

# models.py, inside SQLModel
  def __init__(self, host, database, user, password):
    self.connection = pg.connect(
      host=host, database=database,
      user=user, password=password,
      cursor_factory=DictCursor
    )
    techs = self.query("SELECT name FROM lab_techs ORDER BY name")
    labs = self.query("SELECT id FROM labs ORDER BY id")
    plots = self.query(
      "SELECT DISTINCT plot FROM plots ORDER BY plot"
    )
    self.fields['Technician']['values'] = [
      x['name'] for x in techs
    ]
    self.fields['Lab']['values'] = [x['id'] for x in labs]
    self.fields['Plot']['values'] = [
      str(x['plot']) for x in plots
    ] 

__init__() 方法接受数据库连接详情,并使用 psycopg2.connect() 建立数据库连接,将 cursor_factory 设置为 DictCursor。然后,我们使用我们新的 query() 方法查询数据库,获取我们三个查找表中的相关列,使用列表推导来简化每个查询的结果,以形成相应的 values 列表。

接下来,我们需要编写应用程序调用来从模型检索数据的那些方法。我们将从 get_all_records() 开始,它看起来像这样:

 def get_all_records(self, all_dates=False):
    query = (
      'SELECT * FROM data_record_view '
      'WHERE NOT %(all_dates)s OR "Date" = CURRENT_DATE '
      'ORDER BY "Date" DESC, "Time", "Lab", "Plot"'
    )
    return self.query(query, {'all_dates': all_dates}) 

由于我们的用户习惯于只处理当前天的数据,我们默认只显示这些数据,但添加一个可选标志,以便我们将来需要检索所有时间的数据。要在 PostgreSQL 中检索当前日期,我们可以使用CURRENT_DATE常量,该常量始终根据服务器持有当前日期。请注意,我们使用预定义查询将all_dates值传递到查询中。

接下来,让我们创建get_record()

 def get_record(self, rowkey):
    date, time, lab, plot = rowkey
    query = (
      'SELECT * FROM data_record_view '
      'WHERE "Date" = %(date)s AND "Time" = %(time)s '
      'AND "Lab" = %(lab)s AND "Plot" = %(plot)s'
    )
    result = self.query(
      query,
      {"date": date, "time": time, "lab": lab, "plot": plot}
    )
    return result[0] if result else dict() 

此方法代表了从CSVModel类界面上的一个变化。我们不再处理行号;相反,行由其主键值来标识。在我们的记录(即绘图检查)的情况下,我们需要日期、时间、实验室和绘图来标识一个记录。为了方便,我们将以(日期时间实验室绘图)的格式将此值作为元组传递。因此,我们的方法首先将rowkey元组提取到这四个值中。

一旦我们有了这些值,我们可以使用预定义查询从我们创建的视图中检索所有记录数据。请注意,即使查询结果是一行,query()方法也将结果作为列表返回。然而,我们的应用程序期望从get_record()获取单个数据字典,因此我们的return语句在列表不为空时提取result中的第一个项目,如果为空,则返回空字典。

获取实验室检查记录非常相似:

 def get_lab_check(self, date, time, lab):
    query = (
      'SELECT date, time, lab_id, lab_tech_id, '
      'lt.name as lab_tech FROM lab_checks JOIN lab_techs lt '
      'ON lab_checks.lab_tech_id = lt.id WHERE '
      'lab_id = %(lab)s AND date = %(date)s AND time = %(time)s'
    )
    results = self.query(
      query, {'date': date, 'time': time, 'lab': lab}
    )
    return results[0] if results else dict() 

在此查询中,我们使用连接来确保我们有技术人员名称而不是仅 ID。此方法在CSVModel中不存在,因为我们尚未规范化数据;但它将在我们的save_record()方法和我们的表单自动化方法中派上用场。

保存数据

在我们的 SQL 模型中保存数据比 CSV 复杂一些,因为每个数据记录由两个不同的表中的行表示:lab_checksplot_checks表。当我们尝试保存记录时,我们需要考虑三种可能性:

  • 对于给定的日期、时间、实验室和绘图,既没有实验室检查记录也没有绘图检查记录。在这种情况下,需要创建实验室检查和绘图检查记录。

  • 对于给定的日期、时间和实验室,实验室检查存在,但对于给定的绘图,没有相应的绘图检查存在。在这种情况下,需要更新实验室检查记录(以防用户想要更正技术人员值),并且需要添加绘图检查记录。

  • 实验室检查和绘图检查都存在。在这种情况下,都需要使用提交的非主键值进行更新。

我们实现的save_record()方法需要检查这些条件,并对每个表运行适当的INSERTUPDATE查询。

我们还需要考虑用户在编辑现有记录时更新主键字段的可能性。在这种情况下,模型应该做什么?让我们考虑:

  • 从用户的角度来看,他们在应用程序中填写的每一条记录都对应一个图表检查。

  • 图表检查基于其日期、时间和实验室与实验室检查相关联。

  • 因此,如果用户更改了这些关键字段之一,他们最有可能的意图是将图表检查记录与不同的实验室检查相关联,而不是更改它已经关联的实验室检查记录。

  • 由于从 GUI 的角度来看,用户正在更新现有记录而不是添加新记录,因此,更新由更改前的日期、时间、实验室和图表值标识的图表检查,并用新输入的字段值更新这些字段是有意义的。

因此,当我们确定是否运行INSERTUPDATE查询时,我们应该根据实验室检查的输入数据来决定,但对于图表检查的关键数据

让我们通过编写我们的查询来实现这个逻辑,我们将把这些查询存储在类变量中,以使save_record()方法更加简洁。

我们将开始编写实验室检查查询:

# models.py, in SQLModel
  lc_update_query = (
    'UPDATE lab_checks SET lab_tech_id = '
    '(SELECT id FROM lab_techs WHERE name = %(Technician)s) '
    'WHERE date=%(Date)s AND time=%(Time)s AND lab=%(Lab)s'
  )
  lc_insert_query = (
    'INSERT INTO lab_checks VALUES (%(Date)s, %(Time)s, %(Lab)s, '
    '(SELECT id FROM lab_techs WHERE name LIKE %(Technician)s))'
  ) 

这些查询相当直接,但请注意我们在每种情况下都使用了子查询来填充lab_tech_id。我们的应用程序将不知道实验室技术人员的 ID 是什么,因此我们需要通过名称查找 ID。此外,请注意我们的参数名称与模型fields字典中使用的名称相匹配。这将使我们免于重新格式化从表单获取的记录数据。

图表检查查询更长,但并不更复杂:

 pc_update_query = (
    'UPDATE plot_checks SET seed_sample = %(Seed Sample)s, '
    'humidity = %(Humidity)s, light = %(Light)s, '
    'temperature = %(Temperature)s, '
    'equipment_fault = %(Equipment Fault)s, '
    'blossoms = %(Blossoms)s, plants = %(Plants)s, '
    'fruit = %(Fruit)s, max_height = %(Max Height)s, '
    'min_height = %(Min Height)s, median_height = %(Med Height)s, '
    'notes = %(Notes)s WHERE date=%(key_date)s AND time=%(key_time)s '
    'AND lab_id=%(key_lab)s AND plot=%(key_plot)s')
  pc_insert_query = (
    'INSERT INTO plot_checks VALUES (%(Date)s, %(Time)s, %(Lab)s,'
    ' %(Plot)s, %(Seed Sample)s, %(Humidity)s, %(Light)s,'
    ' %(Temperature)s, %(Equipment Fault)s, %(Blossoms)s,'
    ' %(Plants)s, %(Fruit)s, %(Max Height)s, %(Min Height)s,'
    ' %(Med Height)s, %(Notes)s)') 

注意,在UPDATE查询的WHERE子句中使用的参数名称以key_为前缀;这将允许我们根据行键中的日期、时间、实验室和图表值更新由这些值标识的记录,如前所述。

查询就绪后,我们可以开始编写save_record()方法:

# models.py, inside SQLModel
  def save_record(self, record, rowkey):
    if rowkey:
      key_date, key_time, key_lab, key_plot = rowkey
      record.update({
        "key_date": key_date,
        "key_time": key_time,
        "key_lab": key_lab,
        "key_plot": key_plot
      }) 

CSVModel.save_record()方法接受一个记录字典和一个整数值rownum,以确定哪个记录将被更新(如果是新记录,则为None)。在我们的数据库中,我们使用复合键来标识图表检查,我们期望它是一个包含日期、时间、实验室和图表的元组。因此,如果传递了rowkey,我们将提取其值并将其添加到记录字典中,以便我们可以将它们传递给查询。

接下来,我们需要确定对实验室检查表运行哪种查询:

 if self.get_lab_check(
      record['Date'], record['Time'], record['Lab']
    ):
      lc_query = self.lc_update_query
    else:
      lc_query = self.lc_insert_query 

如果存在具有输入日期、时间和实验室的现有实验室检查记录,我们将只更新它(这实际上只是将技术人员值更改为输入的值)。如果没有,我们将创建一个新记录。

接下来,让我们确定要执行哪种图表检查操作:

 if rowkey:
      pc_query = self.pc_update_query
    else:
      pc_query = self.pc_insert_query 

这次我们只需要知道是否给方法提供了一个行键元组。如果是,这应该是一个现有记录,我们只想更新它。如果不是,我们需要插入一个新的记录。

现在,我们只需运行两个查询来完成方法,将record字典作为参数列表传递:

 self.query(lc_query, record)
    self.query(pc_query, record) 

注意,psycopg2没有问题,我们可以传递一个包含未在查询中引用的额外参数的字典,所以我们不需要从record中过滤掉不需要的项目。

获取图的当前种子样本

此模型还需要一个最后的方法;由于我们的数据库知道每个图中的当前种子样本是什么,我们希望我们的表单能够自动为用户填充这个信息。我们需要一个方法,它接受labplot_id作为参数,并返回种子样本名称。

我们将其命名为get_current_seed_sample()

 def get_current_seed_sample(self, lab, plot):
    result = self.query(
      'SELECT current_seed_sample FROM plots '
      'WHERE lab_id=%(lab)s AND plot=%(plot)s',
      {'lab': lab, 'plot': plot}
    )
    return result[0]['current_seed_sample'] if result else '' 

这次,我们的return语句不仅仅是提取结果的第一行,而是从第一行中提取current_seed_sample列的值。如果没有结果,我们返回一个空字符串。

这就完成了我们的模型类;现在让我们将其集成到应用程序中。

调整 Application 类以支持 SQL 后端

在创建SQLModel实例之前,Application类需要数据库连接信息以传递给模型:服务器名称、数据库名称、用户名和密码。服务器名称和数据库名称不太可能经常更改,所以用户不需要每次都输入这些信息。相反,我们只需将它们添加为SettingsModel中的设置:

# models.py, inside SettingsModel
class SettingsModel:
  fields = {
    #...
    **'db_host'****: {****'type'****:** **'str'****,** **'value'****:** **'localhost'****},**
    **'db_name'****: {****'type'****:** **'str'****,** **'value'****:** **'abq'****}**
  } 

这些可以保存在我们的 JSON 配置文件中,该文件可以编辑以从开发模式切换到生产模式,但用于身份验证的用户名和密码需要用户输入。为此,我们可以使用我们的登录对话框。

实现 SQL 登录

登录对话框当前使用Application._simple_login()方法中硬编码的凭据进行身份验证。这远远不够理想,因此我们将使用我们的 PostgreSQL 服务器作为生产质量的身份验证后端。首先,让我们创建一个新的Application方法,命名为_database_login(),如下所示:

# application.py, inside Application
  def _database_login(self, username, password):
    db_host = self.settings['db_host'].get()
    db_name = self.settings['db_name'].get()
    try:
      self.model = m.SQLModel(
        db_host, db_name, username, password
      )
    except m.pg.OperationalError as e:
      print(e)
      return False
    return True 

此方法类似于我们的_simple_login()方法,因为Application._show_login()方法将调用它来验证用户输入的凭据。然而,与_simple_login()不同,此方法是一个实例方法,因为它需要访问设置并需要保存它创建的SQLModel实例。

该方法首先从settings字典中获取数据库主机和数据库名称,然后尝试使用它们创建一个SQLModel实例。psycopg2.OperationalError表示无法连接到数据库,最可能是因为凭据失败;在这种情况下,我们将从方法中返回False。否则,如果连接成功,我们将返回True

注意,我们将错误信息打印到控制台。由于其他问题可能导致OperationalError,记录异常或以其他方式使其可访问进行调试,而不仅仅是静默处理,这将是明智的。

要使用此登录后端,我们只需在_show_login()方法中更改一行:

# application.py, in Application
  def _show_login(self):
    #...
      **if** **self._database_login(username, password):**
        return True 

我们需要为 SQL 登录进行的最后一个更改是在Application类的初始化器中。我们需要确保在显示登录对话框之前settings字典是可用的,因为我们的数据库登录依赖于db_hostdb_name设置。只需将加载设置的行移动到__init__()的顶部,就在调用super().__init__()之后,如下所示:

# application.py, in Application
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self.settings_model = m.SettingsModel()
    self._load_settings()
    self.withdraw()
    if not self._show_login():
      self.destroy()
      return
    self.deiconify() 

更新 Application._on_save()方法

由于我们的记录键已从单个整数更改为元组,我们需要对我们的_on_save()方法做一些小的调整。感谢我们努力保持模型对象接口的完整性,这个方法的核心功能实际上运行得很好。然而,当涉及到保存已更改或更新的行的引用时,我们不能再依赖于计算行号;我们将不得不依赖于键。

Application._on_save()方法的第二部分开始,就在if errors:块之后,按照以下方式修改代码:

# application,py, in Application._on_save()
    data = self.recordform.get()
    **rowkey = self.recordform.current_record**
    **self.model.save_record(data, rowkey)**
    **if** **rowkey** **is****not****None****:**
      **self.recordlist.add_updated_row(rowkey)**
    **else:**
      **rowkey = (**
        **data[****'Date'****], data[****'Time'****], data[****'Lab'****], data[****'Plot'****]**
      **)**
      **self.recordlist.add_inserted_row(rowkey)**
    # remainder of method as before 

首先,我们将rownum变量改为rowkey,使其更能描述变量包含的内容。其次,当我们有一个新记录时,我们使用与记录一起传递的日期、时间、实验室和图表值来构造一个新的行键。请注意,现在RecordList小部件的_updated_inserted列表的内容将是元组而不是整数,因此我们还需要更新其代码。我们将在本章后面这样做。

移除基于文件的代码

在我们从Application类继续前进之前,我们需要删除一些我们不再需要的基于文件的代码。删除或注释掉以下代码:

  • __init__()中,删除创建CSVModel实例的行。我们不再想这样做。

  • 同样在__init__()中,从event_callbacks字典中删除<<FileSelect>>事件。

  • 删除self._on_file_select()方法定义。

  • 最后,在mainmenu.py中,我们可以注释掉每个菜单类中调用_add_file_open()方法的代码。

现在Application对象已准备好进行 SQL 操作,让我们来看看我们的视图代码。

调整 DataRecordForm 以适应 SQL 数据

目前我们的DataRecordForm使用行号来跟踪其记录。这不再有效,因为记录由复合主键识别。我们需要调整记录的加载方式以及记录表单的标签,以便我们可以准确地识别我们正在处理的行。我们还需要重新排序字段,以便首先输入键值,这将有助于自动填充工作得更顺畅。

此外,我们的数据库为我们提供了自动填充数据的新可能性。一旦我们知道了足够的信息来识别一个实验室检查记录,我们就可以自动填充技术人员字段;一旦我们知道我们正在处理哪个图表,我们就可以自动填充种子样本字段。

重新排序字段

我们可以对 DataRecordForm 进行的第一项更改是最简单的。我们只需要重新排列字段,以便关键字段日期、时间、实验室和绘图出现在最前面。

更新后的调用(省略了一些参数)应按如下顺序排列:

# views.py, inside DataRecordForm.__init__()
    # line 1
    w.LabelInput(
      r_info, "Date",
      #...
    ).grid(row=0, column=0)
    w.LabelInput(
      r_info, "Time",
      #...
    ).grid(row=0, column=1)
    # swap order for chapter 12
    w.LabelInput(
      r_info, "Lab",
      #...
    ).grid(row=0, column=2)
    # line 2
    w.LabelInput(
      r_info, "Plot",
      #...
    ).grid(row=1, column=0)
    w.LabelInput(
      r_info, "Technician",
      #...
    ).grid(row=1, column=1)
    w.LabelInput(
      r_info, "Seed Sample",
      #...
    ).grid(row=1, column=2) 

注意,您需要更改 grid() 方法调用中的 rowcolumn 参数,而不仅仅是 LabelInput 调用的顺序。

修复 load_record() 方法

load_record() 方法只需要进行两项调整。首先,我们将用 rowkey 替换 rownum 变量,以与 Application 类保持一致。其次,我们需要更新生成的标题文本,以便识别记录,如下所示:

#views.py, inside DataRecordForm.load_record()
    if **rowkey** is None:
      self.reset()
      self.record_label.config(text='New Record')
    else:
      **date, time, lab, plot = rowkey**
      **title =** **f'Record for Lab****{lab}, Plot****{plot}****at****{date}****{time}****'**
      self.record_label.config(text=title) 

再次强调,我们已经从键中提取了 datetimelabplot 值,并使用它们来识别用户目前正在编辑的记录。方法的其他部分可以保持不变。

改进自动填充

对于我们的记录表单,我们希望有两个自动填充回调。首先,当用户输入 labplot 值时,我们希望自动填充种子样本字段,以包含当前种植在该 plot 中的种子值。其次,当输入了 datetimelab 值,并且我们有匹配的现有实验室检查时,我们应该填充执行该检查的实验室技术人员姓名。当然,如果我们的用户希望不自动填充数据,我们就不应该执行这两件事。

让我们从种子样本回调开始:

# views.py, inside DataRecordForm
  def _populate_current_seed_sample(self, *_):
    """Auto-populate the current seed sample for Lab and Plot"""
    if not self.settings['autofill sheet data'].get():
      return
    plot = self._vars['Plot'].get()
    lab = self._vars['Lab'].get()
    if plot and lab:
      seed = self.model.get_current_seed_sample(lab, plot)
      self._vars['Seed Sample'].set(seed) 

我们首先检查用户是否希望自动填充数据。如果不是,我们就从方法中返回。如果是,我们就从表单的控制变量字典中获取 Plot 和 Lab 值。如果我们都有,我们就使用它们从模型中获取种子样本值,并在表单中相应地设置它。

我们将对技术人员值做类似处理:

# views.py, inside DataRecordForm
  def _populate_tech_for_lab_check(self, *_):
    """Populate technician based on the current lab check"""
    if not self.settings['autofill sheet data'].get():
      return
    date = self._vars['Date'].get()
    try:
      datetime.fromisoformat(date)
    except ValueError:
      return
    time = self._vars['Time'].get()
    lab = self._vars['Lab'].get()
    if all([date, time, lab]):
      check = self.model.get_lab_check(date, time, lab)
      tech = check['lab_tech'] if check else ''
      self._vars['Technician'].set(tech) 

这次,我们使用表单的 datetimelab 值来获取实验室检查记录,然后从结果中设置技术人员值(如果没有结果,则为空字符串)。请注意,我们在 date 值周围添加了错误处理;这是因为我们计划从变量跟踪触发这些方法。LabTime 都是从 Combobox 小部件中选择的,所以它们只会改变到完整值,但 Date 是文本输入字段,所以我们可能会得到部分输入的日期。如果 date 字符串无效,运行 SQL 查询(这是一个相对耗时的操作)就没有意义,所以我们使用了 datetime.fromisoformat() 来确定输入的 date 字符串是否有效。如果它无效,我们就从方法中返回,因为没有更多的事情要做。

为了完成这个功能,我们只需要添加触发器,以便在适当的变量更新时运行这些方法。将以下代码添加到 DataRecordForm.__init__() 中:

# views.py, inside DataRecordForm.__init__()
    for field in ('Lab', 'Plot'):
      self._vars[field].trace_add(
        'write', self._populate_current_seed_sample
      )
    for field in ('Date', 'Time', 'Lab'):
      self._vars[field].trace_add(
        'write', self._populate_tech_for_lab_check
      ) 

使用 for 循环,我们为确定种子样本和技术员值所涉及的每个变量添加了跟踪。现在,当输入足够的信息来确定它们的值时,这些字段应该会自动填充。

更新 SQLModel 的 RecordList 记录

我们 RecordList 对象最重要的特性之一是能够选择一个记录,以便 Application 对象可以在 DataRecordForm 视图中打开它。为此,我们必须将每个记录的键存储在其相应的 Treeview 项的 IID 值中。这对于整数行号值来说很容易,但现在出现了问题。回想一下 第八章使用 Treeview 和 Notebook 导航记录,IID 值必须是一个字符串。我们不能使用元组。

要解决这个问题,我们只需要想出一个一致的方法来将我们的行键元组连接到一个可以作为 IID 使用的字符串值。我们将创建一个字典作为实例变量,它将映射行键到 IID 值。

RecordList.__init__() 中,添加创建我们映射的这条线:

# views.py, near the beginning of RecordList.__init__()
    self.iid_map = dict() 

现在,我们需要更新 populate() 方法以利用字典而不是整数值。首先,在删除现有行之后,方法开始处,让我们清除字典中的任何当前信息,如下所示:

# views.py, in RecordList.populate()
    self.iid_map.clear() 

然后,找到此方法中填充 Treeviewfor 循环,并让我们按如下方式编辑代码:

 for rowdata in rows:
      values = [rowdata[key] for key in cids]
      rowkey = tuple([str(v) for v in values])
      if rowkey in self._inserted:
        tag = 'inserted'
      elif rowkey in self._updated:
        tag = 'updated'
      else:
        tag = ''
      iid = self.treeview.insert(
        '', 'end', values=values, tag=tag
      )
      self.iid_map[iid] = rowkey 

由于行号不再存在,我们可以删除 enumerate() 调用,只需处理行数据。碰巧的是,cids 列表中的四个列与构成键的四个列相同,并且顺序相同。因此,我们可以直接将此列表转换为 tuple 对象以创建我们的 rowkey。请注意,我们确实需要将键中的每个项转换为字符串;它们从数据库中以 Python 对象的形式(如 dateint)输出,并且我们需要将它们与 _ inserted_ updated 列表中的键匹配。从我们的 DataRecordForm 中提取的这些值都是字符串值。

一旦我们有了键,我们就检查它是否在列表之一中,并适当地设置 tag 值。然后,我们将 Treeview.insert() 的输出保存为 iid。当 insert() 被调用而没有显式 IID 值时,会自动生成一个值并由该方法返回。然后我们使用生成的 IID 值作为键将我们的 rowkey 值添加到映射字典中。

for 循环之后,此方法的最后一部分将焦点放在第一行,以便键盘用户使用。要聚焦在第一行之前,我们依赖于第一个 IID 总是 0 的这一事实。现在第一个 IID 将是一个自动生成的值,在数据加载之前我们无法预测,因此我们必须在设置选择和焦点之前检索 IID。

我们可以通过使用 Treeview.identify_row() 方法来实现这一点:

# views.py, in RecordList.populate()
    if len(rows) > 0:
      firstrow = self.treeview.identify_row(0)
      self.treeview.focus_set()
      self.treeview.selection_set(firstrow)
      self.treeview.focus(firstrow) 

identify_row() 方法接受一个行号并返回该行的 IID。一旦我们有了它,我们就可以将其传递给 selection_set()focus()

我们已经处理了将行键映射到我们的 IID;现在我们需要更新 selected_id() 属性方法,使其返回一个行键元组。按照以下方式更新该方法:

# views.py, in RecordList
  @property
  def selected_id(self):
    selection = self.treeview.selection()
    return **self.iid_map[selection[0]]** if selection else None 

就像之前一样,我们使用 self.treeview.selection() 方法检索选定的 IID。不过,这次我们需要在返回之前在映射字典中查找行键值。

RecordList 的最后更改需要在初始化器中完成。目前,我们的第一列 Row 显示 IID,理由是它是行号。现在情况已经不再是这样了,因为我们更新 insert() 调用时没有指定要显示的值,所以这一列现在是空的。因此,我们能做的最好的事情就是移除这一列。

然而,这是不可能的。#0 列是必需的,不能被移除。但是,它可以被隐藏。为了做到这一点,我们需要设置 Treeview 小部件的 show 属性,如下所示:

# views.py, inside RecordList.__init__()
    self.treeview.config(show='headings') 

show 属性基本上决定了 #0 列是否会被显示。它可以设置为 tree,在这种情况下,列将被显示,或者设置为 headings,在这种情况下,它将被隐藏。默认值是 tree,因此我们将它更改为 headings。现在只显示我们四个数据列。

我们完成了!

呼呼!这是一段相当漫长的旅程,但我们的 SQL 转换基本上已经完成。你应该能够启动应用程序,使用你的 PostgreSQL 凭据登录,并使用数据库加载和保存记录。这代表了应用程序的一个巨大改进,从简单的脚本追加文件到完整的数据库应用程序的一个重大转变。

当然,在现实世界中,我们在这里还没有完全完成。单元测试和文档都需要更新,以反映新的模型层和其他代码更改。此外,现有数据可能需要导入到数据库中,并且用户需要重新培训以适应从平面文件迁移。我们不会在书中涵盖所有这些,但如果你在真实的生产环境中进行此类更改,请记住这一点!

摘要

在本章中,你学习了如何与关系型 SQL 数据库一起工作。你安装并配置了 PostgreSQL。通过识别主键字段、选择正确的数据类型以及将数据结构规范化以减少不一致性、冗余和异常的可能性,将平面文件数据集转换为关系表。你学习了如何安装和使用 psycopg2 库在 PostgreSQL 中检索和存储数据。最后,你完成了构建 SQL 数据库以存储你的 ABQ 数据的艰巨任务,构建了一个数据库模型类以与数据库接口,并将应用程序代码转换为使用新的 SQL 后端。

在下一章中,我们将接触到云计算。我们需要使用不同的网络协议来联系一些远程服务器以交换数据。你将学习到 Python 标准库中用于处理 HTTP 的模块,以及用于连接 REST 服务和通过 SFTP 传输文件的第三方包。

第十三章:连接到云

似乎几乎每个应用程序迟早都需要与外部世界进行通信,你的 ABQ 数据录入应用程序也不例外。你已经收到了一些新的功能请求,这些请求将需要与远程服务器和服务进行交互。

首先,质量保证部门正在研究当地天气条件如何影响每个实验室的环境数据;他们请求一种按需下载和存储本地天气数据到数据库的方法。第二个请求来自你的经理,她仍然需要每天上传 CSV 文件到中央企业服务器。她希望这个过程简化并可通过鼠标点击实现。

在本章中,你将学习以下主题,以与云进行接口:

  • 使用 urllib 的 HTTP中,你将使用urllib连接到网络服务并下载数据。

  • 使用 requests 的 RESTful HTTP中,你将学习如何使用requests库与 REST 服务进行交互。

  • 使用 paramiko 的 SFTP中,你将使用paramiko通过 SSH 上传文件。

使用 urllib 的 HTTP

每次你在浏览器中打开一个网站,你都在使用超文本传输协议,即 HTTP。HTTP 是在 30 多年前被创建的,作为一种让网页浏览器下载 HTML 文档的方式,但它已经演变成为适用于各种目的的最受欢迎的客户端-服务器通信协议之一。

我们不仅在使用浏览器查看从纯文本到通过互联网流式传输的视频时使用它,应用程序还可以使用它来传输数据、启动远程过程或分配计算任务。

HTTP 事务基础

客户端和服务器之间基本 HTTP 事务是这样的:

  1. 首先,客户端创建一个请求,它将发送到服务器。请求包含以下内容:

    • 一个URL,它指定了请求的目标主机、端口和路径。

    • 一个方法,也称为动词,它告诉服务器客户端请求的操作。最常见的方法是GET,用于检索数据,以及POST,用于提交数据。

    • 一个头部,它包括键值对中的元数据;例如,提交的内容类型、内容的编码方式或授权令牌。

    • 最后,请求可能有一个有效载荷,它将包含提交给服务器的数据;例如,上传的文件或来自表单的一组键值对。

  2. 当服务器接收到请求时,它会返回一个响应。响应包含以下内容:

    • 一个包含元数据,如响应大小或内容类型的头部

    • 包含响应实际内容的有效载荷,例如 HTML、XML、JSON 或二进制数据。

在网页浏览器中,这些交互在后台进行,但我们的应用程序代码将直接处理请求和响应对象,以便与远程 HTTP 服务器进行通信。

HTTP 状态码

每个 HTTP 请求在其头部中都会包含一个状态码,它是一个 3 位数,表示请求的处理状态。这些代码在 HTTP 标准中定义,并按以下方式组织:

  • 1XX 状态码是在请求处理过程中发送的信息性消息。

  • 2XX 状态码表示请求成功;例如,200 是最常见的响应,表示请求成功。

  • 3XX 状态码表示重定向。例如,301 用于将客户端重定向到新的 URL,而 304 表示内容自上次下载以来未修改(将客户端重定向到其缓存)。

  • 4XX 状态码表示客户端请求中的错误。例如,403 错误表示禁止请求(例如未经身份验证请求受保护文档),而众所周知的 404 错误表示请求了不存在的文档。

  • 5XX 状态码表示服务器端发生错误,例如当服务器在 Web 服务中遇到错误时发出的通用 500 错误。

虽然网络浏览器用户通常只会遇到 4XX 和 5XX 错误,但当你直接通过 urllib 与 HTTP 交互时,你会遇到一些不同的状态码。

使用 urllib.request 进行基本下载

urllib.request 模块是 Python 模块,用于实现 HTTP 交互。它包含了一系列用于生成请求的函数和类,其中最基本的是 urlopen() 函数。此函数可以创建一个 GETPOST 请求,将其发送到远程服务器,并返回一个包含服务器响应的对象。

让我们来看看 urllib 是如何工作的;打开 Python 壳并执行以下命令:

>>> from urllib.request import urlopen
>>> response = urlopen('http://python.org') 

urlopen() 函数至少需要一个 URL 字符串。默认情况下,它会对 URL 发起一个 GET 请求,并返回一个封装从服务器接收到的响应的对象。

此响应对象公开了从服务器接收到的元数据或内容,我们可以在我们的应用程序中使用这些信息。响应的大部分元数据都位于头部中,我们可以使用其 getheader() 方法来提取,如下所示:

>>> response.getheader('Content-Type')
'text/html; charset=utf-8'
>>> response.getheader('Server')
'nginx' 

getheader() 方法需要一个键名,如果该键名在头部中找到,则返回其值。如果找不到该键名,则返回 None

我们还可以使用 statusreason 属性提取状态码和代码的文本说明,如下所示:

>>> response.status
200
>>> response.reason
'OK' 

记住,200 状态码表示请求成功。OK 字符串只是状态码的更易读形式。

响应对象的负载可以通过类似于文件句柄的接口来检索;例如:

>>> html = response.read()
>>> html[:15]
b'<!doctype html>' 

就像文件句柄一样,响应只能使用read()方法读取一次;与文件句柄不同,它不能使用seek()进行“回滚”,因此如果需要多次访问响应数据,则非常重要地将响应数据保存在另一个变量中。请注意,response.read()的输出是一个bytes对象,根据下载的内容,应该将其转换为适当的对象或解码。

在这种情况下,我们知道从Content-Type头信息中,内容是 UTF-8 字符串,因此我们应该使用decode()将其转换为str,如下所示:

>>> html.decode('utf-8')[:15]
'<!doctype html>' 

生成 POST 请求

urlopen()函数还可以生成POST请求。为此,我们只需要包含一个data参数,如下所示:

>>> response = urlopen('http://duckduckgo.com', data=b'q=tkinter') 

data值需要是一个 URL 编码的bytes对象。一个 URL 编码的数据字符串由用&符号分隔的键值对组成,某些保留字符被编码为 URL 安全的替代字符(例如,空格字符是%20,有时也可以是+)。

这样的字符串可以手动创建,但使用urllib.parse模块提供的urlencode()函数会更简单,如下所示:

>>> from urllib.parse import urlencode
>>> url = 'http://duckduckgo.com'
>>> data = {'q': 'tkinter, python', 'ko': '-2', 'kz': '-1'}
>>> u_data = urlencode(data)
>>> u_data
'q=tkinter%2C+python&ko=-2&kz=-1'
>>> response = urlopen(url, data=u_data.encode()) 

注意,data参数必须是bytes类型,而不是字符串,因此在urlopen()接受之前,必须在 URL 编码的字符串上调用encode()

将天气数据下载到 ABQ 数据录入

让我们尝试下载我们应用程序所需的天气数据。我们将使用的是weather.gov,该网站提供美国国内的天气数据。我们将下载的实际 URL 是w1.weather.gov/xml/current_obs/STATION.xml,其中STATION被替换为当地气象站的呼号。对于 ABQ,我们将使用位于印第安纳州布卢明顿的KBMG

QA 团队希望您记录温度(以摄氏度为单位)、相对湿度、气压(以毫巴为单位)和天空状况(字符串,如“多云”或“晴朗”)。他们还需要气象站观测天气的日期和时间。

创建天气数据模型

虽然将urlopen()调用放在Application类回调中可能足够简单,但与我们的 MVC 设计更一致的是,将我们与天气数据服务的交互封装在一个模型类中。我们的模型类将负责从网络服务获取天气数据并将其转换为其他组件可以轻松使用的格式。

打开models.py文件,让我们首先导入urlopen()

# models.py
from urllib.request import urlopen 

现在,在文件末尾,让我们开始一个新的模型类来封装我们的数据下载:

class WeatherDataModel:
  base_url = 'http://w1.weather.gov/xml/current_obs/{}.xml'
  def __init__(self, station):
    self.url = self.base_url.format(station) 

我们的初始化器将接受一个station字符串作为参数,并使用它与基本 URL 值构建天气数据的下载 URL。通过将station值设置为变量,我们可以在用户的配置文件中设置站点,允许其他 ABQ 设施的用户使用此功能。

现在,让我们开始为这个类编写一个公共方法来检索天气数据:

# models.py, inside WeatherDataModel
  def get_weather_data(self):
    response = urlopen(self.url) 

我们通过向模型的 URL 发送 GET 请求并检索响应来开始这个方法。请注意,这可能会引发异常(例如,如果由于某种原因无法访问该网站),调用此方法的代码需要处理这个异常。

假设一切顺利,我们只需要解析这个响应中的数据,并将其放入Application类可以传递给 SQL 模型的形式。为了确定我们将如何处理这个响应,让我们回到 Python shell 中检查那里的数据:

>>> url = 'http://w1.weather.gov/xml/current_obs/KBMG.xml'
>>> response = urlopen(url)
>>> print(response.read().decode())
  <?xml version="1.0" encoding="ISO-8859-1"?>
  <?xml-stylesheet href="latest_ob.xsl" type="text/xsl"?>
  <current_observation version="1.0"
  xmlns:xsd=http://www.w3.org/2001/XMLSchema
  xmlns:xsi=http://www.w3.org/2001/XMLSchema-instance
  xsi:noNamespaceSchemaLocation=
    "http://www.weather.gov/view/current_observation.xsd">
  <credit>NOAA's National Weather Service</credit>
  <credit_URL>http://weather.gov/</credit_URL>
.... 

如 URL 所示,响应的有效负载是一个 XML 文档,其中大部分我们不需要。然而,经过一些搜索后,你应该能够找到我们需要的字段,如下所示:

<observation_time_rfc822>
  Tue, 29 Jun 2021 15:53:00 -0400
</observation_time_rfc822>
<weather>Mostly Cloudy</weather>
<temp_c>32.8</temp_c>
<relative_humidity>54</relative_humidity>
<pressure_mb>1020.0</pressure_mb> 

很好,我们需要的数据就在那里,所以我们只需要将其从 XML 字符串中提取出来,转换成我们应用程序可以使用的形式。让我们花点时间来学习如何解析 XML 数据。

解析 XML 天气数据

Python 标准库包含一个xml包,它由几个用于解析或创建 XML 数据的子模块组成。在这些子模块中,xml.etree.ElementTree子模块是一个简单、轻量级的解析器,应该能满足我们的需求。

让我们将ElementTree导入到我们的models.py文件中,如下所示:

# models.py
from xml.etree import ElementTree 

现在,回到我们的get_weather_data()方法的末尾,我们将按照以下方式解析响应对象中的 XML 数据:

# models.py, inside WeatherDataModel.get_weather_data()
  xmlroot = ElementTree.fromstring(response.read()) 

fromstring() 方法接受一个 XML 字符串并返回一个Element对象。为了获取我们需要的数据,我们首先需要了解Element对象代表什么,以及如何与之交互。

XML 是数据的分层表示;一个元素代表这个层次结构中的一个节点。元素以一个标签开始,这是一个位于尖括号内的文本字符串。每个标签都有一个匹配的闭标签,它只是在标签名前加上一个前导斜杠。

在开标签和闭标签之间,一个元素可能包含其他子元素,或者它可能包含文本。元素还可以有属性,这些属性是放置在开标签的尖括号内的键值对,紧随标签名之后。

看一下以下 XML 的示例:

<star_system starname="Sol">
  <planet>Mercury</planet>
  <planet>Venus</planet>
  <planet>Earth
    <moon>Luna</moon>
  </planet>
  <planet>Mars
    <moon>Phobos</moon>
    <moon>Deimos</moon>
  </planet>
  <dwarf_planet>Ceres</dwarf_planet>
</star_system> 

这个示例是一个(不完整)的太阳系 XML 描述。根元素有一个<star_system>标签和一个starname属性。在这个根元素下,我们有四个<planet>元素和一个<dwarf_planet>元素,每个元素都包含一个表示行星名称的文本节点。一些行星节点还有子<moon>节点,每个节点都包含一个表示月球名称的文本节点。

可以说,这些数据可以有不同的结构;例如,行星名称可以在行星元素内部的子<name>节点中,或者作为<planet>标签的属性列出。虽然 XML 的语法定义良好,但 XML 文档的实际结构由其创建者决定,因此完全解析 XML 数据需要了解数据在文档中的布局方式。

如果你查看我们在 shell 中之前下载的 XML 天气数据,你会注意到它是一个相当浅的层次结构。在<current_observations>节点下,有许多子元素,其标签代表特定的数据字段,如温度、湿度、风寒等等。

要访问和提取这些子元素,Element对象为我们提供了以下各种方法:

方法 返回值
iter() 所有子节点的迭代器(递归)
find(tag) 匹配给定标签的第一个元素
findall(tag) 匹配给定标签的元素列表
getchildren() 立即子节点列表
iterfind(tag) 所有匹配给定标签的子节点的迭代器(递归)

在我们之前下载 XML 数据时,我们确定了五个包含我们想要从该文档中提取的数据的标签:<observation_time_rfc822><weather><temp_c><relative_humidity><pressure_mb>。我们希望我们的函数返回一个包含每个这些标签作为键的 Python 字典。

因此,在get_weather_data()内部,让我们创建一个包含我们想要的标签的字典,如下所示:

 weatherdata = {
    'observation_time_rfc822': None,
    'temp_c': None,
    'relative_humidity': None,
    'pressure_mb': None,
    'weather': None
  } 

现在,让我们从Element对象中获取值并将它们添加到字典中:

 for tag in weatherdata:
    element = xmlroot.find(tag)
    if element is not None:
      weatherdata[tag] = element.text
  return weatherdata 

对于我们所有的标签名,我们将使用find()方法尝试在xmlroot中定位匹配的元素。这个特定的 XML 文档不使用重复的标签(因为单个观测值多次出现,温度值、湿度值等等都没有意义),所以任何标签的第一个实例应该是唯一的。如果标签匹配,我们将返回匹配节点的Element对象;如果没有匹配,我们将返回None,所以在尝试访问其text属性之前,我们需要确保element不是None

完成所有标签后,我们可以通过返回字典来完成函数。

你可以在 Python shell 中测试这个函数;从命令行,导航到ABQ_Data_Entry目录并启动 Python shell。然后输入以下命令:

>>> from abq_data_entry.models import WeatherDataModel
>>> wdm = WeatherDataModel('KBMG')
>>> wdm.get_weather_data()
{'observation_time_rfc822': 'Mon, 09 Aug 2021 15:53:00 -0400',
'temp_c': '26.1', 'relative_humidity': '74',
'pressure_mb': '1013.7', 'weather': 'Fair'} 

你应该会得到一个包含印第安纳州布卢明顿当前天气状况的字典。

你可以在w1.weather.gov/xml/current_obs/找到美国其他城市的站码。

现在我们有了我们的天气数据模型,我们只需要构建存储数据的表和触发操作的接口。

实现天气数据存储

为了存储我们的天气数据,我们首先将在 ABQ 数据库中创建一个表来保存单个观测数据,然后构建一个SQLModel方法来存储检索到的数据。我们不需要担心编写从数据库中检索数据的代码,因为我们的实验室的 QA 团队有自己的报告工具,他们将使用这些工具来访问它。

创建 SQL 表

在应用程序的 sql 文件夹下,打开 create_db.sql 文件,并添加一个新的 CREATE TABLE 语句,如下所示:

**# create_db.sql**
CREATE TABLE local_weather (
  datetime TIMESTAMP(0) WITH TIME ZONE PRIMARY KEY,
  temperature NUMERIC(5,2),
  rel_hum NUMERIC(5, 2),
  pressure NUMERIC(7,2),
  conditions VARCHAR(32)
); 

在这个表中,我们使用记录上的 TIMESTAMP 数据类型作为主键;保存相同的带时间戳的观察结果两次是没有意义的,因此这足以成为一个合适的主键。TIMESTAMP 数据类型后面的 (0) 大小表示我们需要多少小数位来进行秒的测量。由于这些测量大约每小时进行一次,而我们只需要大约每四小时一次(当实验室检查完成时),我们不需要在时间戳中包含秒的小数部分。

注意我们正在保存时区;当可用时,始终将时区数据与时间戳一起存储!这可能看起来并不必要,尤其是当你的应用程序将在永远不会更改时区的办公场所运行时,但存在许多边缘情况,例如夏令时变更,时区的缺失可能会造成重大问题。

在你的数据库中运行这个 CREATE 查询来构建表,然后让我们继续创建我们的 SQLModel 方法。

实现 SQLModel.add_weather_data() 方法

models.py 中,让我们向 SQLModel 类添加一个新的方法,称为 add_weather_data(),它只接受一个字典作为其唯一参数。从这个方法开始创建一个 INSERT 查询,如下所示:

# models.py, inside SQLModel
  def add_weather_data(self, data):
    query = (
      'INSERT INTO local_weather VALUES '
      '(%(observation_time_rfc822)s, %(temp_c)s, '
      '%(relative_humidity)s, %(pressure_mb)s, '
      '%(weather)s)'
    ) 

这是一个简单的参数化 INSERT 查询,使用与 get_local_weather() 函数从 XML 数据中提取的字典键匹配的变量名。我们只需要将这个查询和 data 字典传递到我们的 query() 方法中。

然而,有一个问题;如果我们得到一个重复的时间戳,我们的查询将因为重复的主键而失败。我们可以在查询之前再进行一次查询来检查,但这会稍微有些多余,因为 PostgreSQL 本身会在插入新行之前检查重复的键。

当它检测到此类错误时,psycopg2 会引发 IntegrityError 异常,因此我们只需捕获这个异常,如果它被引发,就什么也不做。

要做到这一点,我们将像这样将我们的 query() 调用包装在 try/except 块中:

 try:
      self.query(query, data)
    except pg.IntegrityError:
      # already have weather for this datetime
      pass 

现在,我们的数据录入人员可以随时调用此方法,但它只会保存有新鲜观测记录时。

更新 SettingsModel 类

在离开 models.py 之前,我们需要添加一个新的应用程序设置来存储首选的天气站。在 SettingsModel.fields 字典中添加以下新条目:

# models.py, inside SettingsModel
  fields = {
    # ...
    **'weather_station'****: {****'type'****:** **'str'****,** **'value'****:** **'KBMG'****}**,
  } 

我们不会添加一个 GUI 来更改此设置,因为用户不需要更新它。这将取决于我们,或者在其他实验室站点上的系统管理员,通过编辑 abq_settings.json 文件来确保每个工作站都正确设置。

添加天气下载的 GUI 元素

Application 对象现在需要将 WeatherDataModel 中的天气下载方法连接到 SQLModel 中的数据库方法,使用一个适当的回调方法,这样主菜单类就可以调用它。

打开 application.py 文件,并在 Application 类中启动一个新的方法,命名为 _update_weather_data()

# application.py, inside Application
  def _update_weather_data(self, *_):
    weather_data_model = m.WeatherDataModel(
      self.settings['weather_station'].get()
    )
    try:
      weather_data = weather_data_model.get_weather_data() 

此方法首先使用从 settings 字典中拉取的 weather_station 值创建一个 WeatherDataModel 实例。然后,它尝试在 try 块中调用 get_weather_data()

记住,在错误场景中,urlopen() 可能会引发任何数量的异常,具体取决于 HTTP 事务中出了什么问题。应用程序实际上无法做任何事情来处理这些异常,除了通知用户并退出方法。因此,我们将捕获通用的 Exception 并在 messagebox 对话框中显示文本,如下所示:

 except Exception as e:
      messagebox.showerror(
        title='Error',
        message='Problem retrieving weather data',
        detail=str(e)
      )
      self.status.set('Problem retrieving weather data') 

如果 get_local_weather() 成功,我们只需将数据传递给我们的模型方法。我们可以在 else 子句中添加此内容:

 else:
      self.data_model.add_weather_data(weather_data)
      time = weather_data['observation_time_rfc822']
      self.status.set(f"Weather data recorded for {time}") 

除了保存数据外,我们还在状态栏通知用户天气已更新,并显示了更新时间戳。

回调方法完成后,让我们将其添加到我们的回调字典中:

# application.py, in Application.__init__()
    event_callbacks = {
      #...
      **'<<UpdateWeatherData>>'****: self._update_weather_data**
    } 

现在,我们可以在主菜单中添加一个 command 项目作为回调。根据我们在第十章“保持跨平台兼容性”中学到的主菜单指南,我们应该考虑为该命令提供一个合适的子菜单。在 Windows 上,此类功能位于 Tools 菜单中,由于 Gnome 和 macOS 指南似乎没有指示更合适的位置,我们将在 LinuxMainMenuMacOsMainMenu 类中实现一个 Tools 菜单来保持一致性,以包含此命令。

打开 mainmenu.py 文件,从通用的菜单类开始,让我们添加一个私有方法,该方法将添加 command 项目:

# mainmenu.py, inside GenericMainMenu
  def _add_weather_download(self, menu):
    menu.add_command(
      label="Update Weather Data",
      command=self._event('<<UpdateWeatherData>>')
    ) 

现在,在每个菜单类的初始化器中,我们将创建一个 Tools 菜单并将其添加到其中:

# mainmenu.py, inside GenericMainMenu.__init__()
    # Put between the File and Options menus
    self._menus['Tools'] = tk.Menu(self, tearoff=False)
    self._add_weather_download(self._menus['Tools']) 

将此相同代码添加到 macOS 和 Linux 菜单类的初始化器中。在 WindowsMainMenu 类的初始化器中,您只需添加第二行,因为 Tools 菜单已经存在。更新菜单后,您可以运行应用程序并尝试从 Tools 菜单中的新命令。如果一切顺利,您应该在状态栏中看到以下截图所示:

图 13.1:成功下载天气数据。

图 13.1:成功下载天气数据

您还应该使用您的 PostgreSQL 客户端连接到数据库,并执行以下 SQL 命令来检查表中是否包含一些天气数据:

SELECT * FROM local_weather; 

该 SQL 语句应该返回类似于以下内容的输出:

日期时间 温度 相对湿度 压力 条件
2021-08-12 18:53:00-05 26.10 74.00 1013.70 晴朗

正如您所看到的,urllib在从网络下载文件方面相对简单易用;大部分工作涉及解析下载的文件并在应用程序中使用它。然而,并非所有 Web 事务都像单个GETPOST请求那样简单。在下一节中,我们将探讨一个更强大的 HTTP 交互工具,requests

使用请求的 RESTful HTTP

您的经理要求您在程序中创建一个函数,以便她可以将每日数据的 CSV 提取上传到 ABQ 的企业 Web 服务,该服务使用经过身份验证的 REST API。REST代表表征状态转移,它指的是一种围绕高级 HTTP 语义构建的 Web 服务方法,以提供更符合代码的接口。围绕 REST 概念设计的服务被称为RESTful。让我们更深入地了解 REST 交互是如何工作的。

了解 RESTful Web 服务

RESTful 服务围绕访问资源的概念构建。资源通常是数据记录或文件,尽管它也可能是远程过程或硬件接口之类的其他东西。我们通过端点访问资源,端点是表示特定资源的 URL。

我们已经看到,Web 服务器通常允许您使用GET方法获取数据,使用POST方法提交数据。然而,REST API 使用额外的 HTTP 方法,如DELETEPUTPATCH来指示不同的操作。根据我们在请求端点时使用的方法,我们可以在资源上执行不同的操作。

尽管 REST 服务的实现方式各异,以下表格显示了典型 API 中 HTTP 方法的一般公认功能:

方法 功能
GET 获取资源
HEAD 仅检索资源的元数据(头部)
POST 根据提交的数据创建或更新资源
PUT 以原样上传资源(通常用于文件)
PATCH 使用部分数据更新现有资源(很少实现)
DELETE 删除资源

除了更健壮的方法集之外,REST 服务还以更符合代码的方式交换数据。虽然以浏览器为导向的服务接受 URL 编码的字符串数据并返回 HTML 文档,但 RESTful 服务可以接受请求并以 JSON 或 XML 等格式返回响应。在某些情况下,客户端甚至可以请求返回的数据格式。

理解这一点至关重要,尽管存在一些 RESTful 服务的标准,但 REST 站点(包括它们对不同方法的精确响应)的组织和行为差异很大。为了与 REST API 交互,您需要查阅其具体文档。

Python 的 requests 库

正如我们在本章的第一节中看到的,urllib 对于基本的 GETPOST 请求来说相当简单易用,并且作为标准库的一部分,当我们的需求仅限于此时,它是一个很好的选择。然而,涉及身份验证、文件上传或附加 HTTP 方法的更复杂 HTTP 交互,仅使用 urllib 可能会令人沮丧且复杂。为了完成这项工作,我们将转向第三方 requests 库。这个库被 Python 社区高度推荐,用于任何涉及 HTTP 的严肃工作。正如您将看到的,requests 去除了 urllib 中留下的许多粗糙边缘和过时的假设,为更现代的 HTTP 事务(如 REST)提供了方便的类和包装函数。requests 的完整文档可以在 docs.python-requests.org 找到,但下一节将涵盖您需要了解的大部分内容,以便有效地使用它。

安装和使用 requests

requests 包是用纯 Python 编写的,因此使用 pip 安装它不需要编译或二进制下载。只需在终端中输入 pip install --user requests 即可,它将被添加到您的系统中。

让我们来看看 requests 在 Python 命令行中的工作方式;打开一个命令行窗口并输入以下内容:

>>> import requests
>>> response = requests.request('GET', 'http://www.alandmoore.com') 

requests.request() 函数至少需要 HTTP 方法和一个 URL。就像 urlopen() 一样,它构建适当的请求包,将其发送到 URL,并返回一个表示服务器响应的对象。在这里,我们正在向这位作者的网站发送一个 GET 请求。

除了 request() 函数外,requests 还提供了对应于最常见 HTTP 方法的快捷函数。因此,相同的请求可以如下进行:

>>> response = requests.get('http://www.alandmoore.com') 

get() 方法只需要 URL 并执行 GET 请求。同样,post()put()patch()delete()head() 函数使用相应的 HTTP 方法发送请求。所有这些请求函数都接受额外的可选参数。

例如,我们可以通过以下方式使用 POST 请求发送数据:

>>> data = {'q': 'tkinter', 'ko': '-2', 'kz': '-1'}
>>> url = 'https://duckduckgo.com'
>>> response = requests.post(url, data) 

注意,与 urlopen() 不同,我们可以直接使用 Python 字典作为 data 参数;requests 会为我们将其转换为适当的 URL 编码 bytes 对象。

与请求函数一起使用的某些更常见的参数如下:

参数 用途
params data 类似,但添加到查询字符串而不是有效负载中
json 要包含在有效负载中的 JSON 数据
headers 要用于请求的头部数据字典
files 要作为多部分表单数据请求发送的 {字段名: 文件对象} 字典
auth 用于基本 HTTP 摘要身份验证的用户名和密码元组

注意,这里的auth参数仅用于对 HTTP 摘要认证进行认证;这是一种较老的认证方法,它在网络服务器级别实现,而不是在实际的 Web 应用程序中实现,并且在现代网站上很少使用。要与现代认证系统协同工作,我们需要了解会话的使用。

使用 Session 与认证网站交互

HTTP 是一个无状态协议,这意味着每个 HTTP 请求都是独立的,并且不与其他请求连接,即使在同一客户端和服务器之间也是如此。尽管您在登录时可能感觉像是“连接”到了您的社交媒体或银行网站,但实际上,您和服务器之间没有持续的底层连接,只有一系列无关的请求和响应。

那么,这些网站是如何保持您的交互安全的?

在现代网站上,这通常是通过使用会话 cookie认证令牌来完成的。在这两种方法中,当客户端向服务器进行认证时,服务器会返回一个数据片段,客户端可以将其包含在未来的请求中,以标识自己为成功认证的同一实体。通过这种方式,客户端和服务器都可以通过将它们之间的请求和响应关联到会话中,来模拟一个有状态的连接。

对于客户端来说,会话 cookie 和认证令牌之间的区别无关紧要;只需知道两者都需要我们在认证后从服务器存储某些信息,并在未来的每个请求中提供这些信息。

requests模块通过提供Session类使这种交互变得简单。Session对象在多个请求之间持续设置、cookie 和令牌,允许您与需要认证或特殊客户端设置的服务进行交互。要创建一个Session对象,使用以下requests.session()工厂函数:

>>> s = requests.session() 

现在,我们可以在我们的Session对象上调用请求方法,例如get()post()等:

# Assume this is a valid authentication service that returns an auth token
>>> s.post('http://example.com/login', data={'u': 'test', 'p': 'test'})
# Now we would have an auth token stored in s
>>> response = s.get('http://example.com/protected_content')
# Our token cookie would be listed here
>>> print(s.cookies.items()) 

这种类型的令牌和 cookie 处理是在后台发生的,不需要我们采取任何明确的行动。Cookies 存储在Session对象的cookies属性中的CookieJar对象中。

我们还可以在我们的Session对象上设置将在请求之间持续存在的配置选项;例如:

>>> s.headers['User-Agent'] = 'Mozilla'
>>> s.params['uid'] = 12345
# will be sent with a user-agent string of "Mozilla"
# and a parameter of "uid=12345"
>>> s.get('http://example.com') 

在这个例子中,我们将用户代理字符串设置为Mozilla,这将随后用于从这个Session对象发出的所有请求。我们还使用params属性设置了一个默认 URL 参数;因此,实际请求的 URL 是http://example.com?uid=12345

requests.Response 对象

requests 中的所有请求函数和方法都返回一个 Response 对象。这些 Response 对象与 urlopen() 返回的对象不同;它们包含所有相同的数据,但形式略有不同(通常更方便)。此外,它们还有一些有助于快速处理其内容的方法。

例如,响应头已经为我们翻译成了 Python 字典,如下所示:

>>> r = requests.get('http://python.org')
>>> r.headers
{'Connection': 'keep-alive', 'Content-Length': '49812',
'Server': 'nginx', 'Content-Type': 'text/html; charset=utf-8',
 # ... etc 

urllib 的另一个区别是,requests 在 HTTP 错误上不会自动引发异常。但是,可以通过调用 .raise_for_status() 响应方法来这样做。

例如,让我们向一个将返回 HTTP 404 错误的 URL 发送请求:

>>> r = requests.get('http://www.example.com/does-not-exist')
>>> r.status_code
404
>>> r.raise_for_status()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/usr/lib/python3.9/site-packages/requests/models.py", line 935, in
raise_for_status
raise HTTPError(http_error_msg, response=self)
requests.exceptions.HTTPError: 404 Client Error: Not Found for url:
http://www.example.com/does-not-exist 

这给了我们使用异常处理或更传统的流程控制逻辑来处理 HTTP 错误的选择,或者将异常处理推迟到更方便的时刻。

实现 REST 后端

要开始实现与 ABQ 企业 REST 服务器的交互,我们需要弄清楚我们将发送什么类型的请求。我们已从企业办公室获得了一些文档,描述了如何与 REST API 交互。

API 文档告诉我们以下内容:

  • 在访问任何其他端点之前,我们需要获取一个身份验证令牌。我们通过向 /auth 端点提交 POST 请求来完成此操作。POST 请求的有效负载应包括作为 URL 编码数据的 usernamepassword。如果我们的凭证失败,我们将收到 HTTP 401 错误。如果我们没有令牌,任何其他请求都将因 HTTP 403 错误而失败。

  • 一旦我们有了令牌,我们就可以使用 /files 端点来处理文件:

    • 我们可以使用 PUT 请求上传文件。文件作为名为 file 的参数指定的多部分表单数据上传。

    • 我们可以通过发送形式为 /files/FILENAMEGET 请求来检索一个文件,其中 FILENAME 是文件的名称。

    • 或者,我们可以通过向 /files/FILENAME 发送 HEAD 请求来仅检索文件的元数据。

  • 所有 HTTP 错误都伴随着包含状态码和指示错误原因的消息的 JSON 有效负载。

包含示例代码的这本书中包含了一个名为 sample_rest_service.py 的示例脚本,它复制了 ABQ 企业 REST 服务的功能。要使用它,您需要使用命令 pip install -u flask 安装 flask 库,然后在终端提示符下运行命令 python sample_rest_service.py

再次强调,遵循我们的 MVC 设计,我们将实现一个封装所有这些交互的模型。我们将在 models.py 中开始,导入 requests 库,如下所示:

# models.py
import requests 

现在,在文件末尾,让我们开始一个新的模型类,CorporateRestModel,用于 REST 网站:

# models.py
class CorporateRestModel:
  def __init__(self, base_url):
    self.auth_url = f'{base_url}/auth'
    self.files_url = f'{base_url}/files'
    self.session = requests.session() 

类初始化器接受一个base_url参数,用于定义我们想要联系的基础 REST 服务的 URL。然后使用此 URL 来构造上传、认证和文件检索的端点 URL。最后,由于我们需要存储认证令牌,我们为每个方法创建一个会话对象。

我们本可以将base_url指定为一个类属性,就像我们在WeatherDataModel中做的那样;然而,为了使我们能够测试此类与测试服务,或者为了适应公司服务器可能发生的变化,我们将此值存储在用户的设置中,以便可以轻松替换。

在我们继续之前,让我们为我们的SettingsModel添加一个用于 REST 基础 URL 的设置:

# models.py, inside SettingsModel
  fields = {
    #...
    'abq_rest_url': {
      'type': 'str',
      'value': 'http://localhost:8000'
    }
  } 

默认值http://localhost:8000是提供的示例服务器的基础 URL,用于测试;在生产环境中,此设置可以通过编辑每个用户的abq_settings.json文件由技术支持进行更改。

现在,回到我们的CorporateRestModel类中,我们需要实现四个方法:

  • 一个authenticate()方法,通过POST请求将凭据发送到/auth端点。

  • 一个upload_file()方法,通过PUT请求将文件发送到/files端点。

  • 一个check_file()方法,用于从/files端点检索仅包含元数据。

  • 一个get_file()方法,用于从/files端点下载文件。

让我们开始吧!

authenticate()方法

由于没有认证令牌我们无法做任何事情,让我们从authenticate()方法开始:

# models.py, inside CorporateRestModel
  def authenticate(self, username, password):
    response = self.session.post(
      self.auth_url,
      data={'username': username, 'password': password}
    ) 

此方法将接受用户名和密码,并使用我们模型的Session对象将它们发布到auth_url。如果成功,会话将自动存储我们收到的令牌。回想一下,如果提供无效的凭据,服务器将返回 HTTP 401 错误;我们可以简单地检查响应的状态码,并从该方法返回TrueFalse。然而,由于远程 HTTP 服务器调用失败有多种方式(例如,服务器上的问题可能导致 500 错误),如果能向调用代码报告更多关于失败详细信息的反馈会更好。我们可以通过调用Response对象的raise_for_status()方法来实现,这将向调用代码发送HTTPError异常。这可能会给我们一个像这样的错误对话框:

图 13.2:一个丑陋的 401 错误

图 13.2:一个丑陋的 401 错误

当然,我们可以,也应该做得更好。大多数用户不会知道 HTTP 401 错误是什么意思。

从 API 规范中记住,服务器还会返回一个包含更具有意义错误信息的 JSON 对象。我们可以为我们的模型编写一个静态方法来处理HTTPError并将其转换为带有更人性化的信息的异常。将此方法添加到模型中:

 @staticmethod
  def _raise_for_status(response):
    try:
      response.raise_for_status()
    except requests.HTTPError:
      raise Exception(response.json().get('message')) 

此方法接受一个Response对象,然后调用其raise_for_status()方法。如果状态码是成功(200),则不会发生任何事情,该方法返回。然而,如果抛出HTTPError,我们将从Response对象的 JSON 有效负载中提取message值,并使用该消息抛出一个新的Exception错误。

authenticate()中,让我们通过传递响应到此静态方法来结束该方法:

# models.py, inside CorporateRestModel.authenticate()
    self._raise_for_status(response) 

现在失败的登录看起来更像是这样:

图 13.3:一个更友好的错误信息

图 13.3:一个更友好的错误信息

如果没有抛出异常,我们不需要做任何事情。会话中已经有了令牌,我们可以继续进行其他操作。

upload_file()方法

我们下一个要实现的方法是实际上传文件。记住,根据 API 文档,这需要向/files端点发送PUT请求。该方法看起来如下:

 def upload_file(self, filepath):
    with open(filepath, 'rb') as fh:
      files = {'file': fh}
      response = self.session.put(
        self.files_url, files=files
      )
    self._raise_for_status(response) 

要使用requests发送文件,我们必须实际打开它并获取一个文件句柄,然后将文件句柄放入一个字典中,并将其传递给请求方法的files参数。如果每个文件在字典中都有一个不同的键,则可以发送多个文件;然而,我们的 API 一次只允许发送一个文件,并且它必须有一个键为file。再次提醒,我们通过检查响应的错误码来结束方法,使用我们的_raise_for_status()方法。

注意我们以二进制读取模式(rb)打开文件。requests文档建议这样做,因为它确保请求头将计算正确的Content-length值。

check_file()方法

我们需要的下一个方法是check_file()方法,它将检索服务器上文件的头部信息,而无需实际下载它。API 文档告诉我们,通过向files/FILENAME端点发送HEAD请求,我们可以获取文件的元数据,其中FILENAME是我们想要获取信息的文件名。HEAD请求在处理慢速连接或大文件时很有用,因为它们允许我们找到有关文件的信息(例如,其大小或是否存在),而无需实际下载整个文件。

让我们这样实现这个方法:

 def check_file(self, filename):
    url = f"{self.files_url}/{filename}"
    response = self.session.head(url)
    if response.status_code == 200:
      return True
    elif response.status_code == 404:
      return False
    self._raise_for_status(response) 

对于我们的目的,我们主要关心服务器上的文件是否存在,因此我们将根据是否收到状态码 200(成功)或 404(文件未找到)从该方法返回一个布尔值。当然,请求也可能出现其他问题,所以如果状态码不同,我们也会将响应传递给我们的_raise_for_status()方法。

get_file()方法

我们将要实现的最后一个方法是get_file()方法,用于下载文件数据。将以下方法添加到CorporateRestModel中:

 def get_file(self, filename):
    """Download a file from the server"""
    url = f"{self.files_url}/{filename}"
    response = self.session.get(url)
    self._raise_for_status(response)
    return response.text 

与此 API 中的其他端点不同,对/files端点的GET请求返回 JSON,而是返回文件的正文。我们可以从Response对象的text属性中检索这些内容,这是我们方法返回的。这将取决于调用此方法的代码如何处理方法返回的内容。我们将在Application类中这样做,我们将保存下载的内容到文件中。

由于我们的模型现在已经完成,让我们转到Application类,开始处理前端工作。

将 REST 上传集成到应用程序中

在与负责执行 REST 上传的经理讨论后,你确定 REST 上传操作的流程需要类似于以下内容:

  • 当从 GUI 运行 REST 上传时,它应该首先检查数据库中是否有当天的数据,如果没有,则终止操作。如果上传了空文件,这会让你的经理看起来很糟糕!

  • 如果有数据,它应该使用在设施转向 SQL 存储之前使用的原始命名格式创建当天的 CSV 数据提取,因为这是 ABQ 公司期望的文件名格式。

  • 接下来,它应该提示输入 REST API 的认证凭证。

  • 之后,程序应该检查当天数据是否已经上传了文件。如果没有,请上传文件。

  • 如果有文件(有时她忘记并上传两次),程序应该提示是否应该覆盖文件。

  • 如果我们没有覆盖文件,应该有一个选项从服务器下载文件,以便可以手动与 SQL 中的数据进行比较。

让我们开始实现这段代码!

创建 CSV 提取

在我们可以上传任何内容之前,我们需要实现创建每日数据 CSV 提取的方法。这将被多个函数使用,因此我们将将其实现为一个单独的方法。

Application类中开始一个新的私有方法,命名为_create_csv_extract(),如下所示:

# application.py, inside Application
  def _create_csv_extract(self):
    csvmodel = m.CSVModel()
    records = self.model.get_all_records()
    if not records:
      raise Exception('No records were found to build a CSV file.')
    for record in records:
      csvmodel.save_record(record)
    return csvmodel.file 

该方法首先创建我们CSVModel类的新实例;尽管我们不再将数据存储在 CSV 文件中,但我们仍然可以使用该模型导出 CSV 文件。我们没有传递任何参数,只是使用文件的默认文件路径。接下来,我们调用应用程序SQLModel实例的get_all_records()方法。记住,我们的SQLModel.get_all_records()方法默认返回当前日期的所有记录列表。由于你的老板不希望上传空文件,如果没有记录来构建 CSV,我们将引发异常。我们的调用代码可以捕获这个异常并显示适当的警告。如果有记录要保存,该方法将遍历它们,将每个记录保存到 CSV 中,然后返回CSVModel对象的file属性(即指向已保存文件的Path对象)。我们的调用代码将负责对从方法返回的内容进行适当的处理。我们将在Application类中这样做,我们将保存下载的内容到文件中。

创建上传回调

现在我们有了创建 CSV 提取文件的方法,我们可以编写实际的回调方法如下所示:

# application.py, inside Application
  def _upload_to_corporate_rest(self, *_):
    try:
      csvfile = self._create_csv_extract()
    except Exception as e:
      messagebox.showwarning(
        title='Error', message=str(e)
      )
      return 

首先,我们尝试创建一个 CSV 提取文件;如果我们遇到任何异常(例如,我们创建的“没有记录”异常,或者可能是数据库问题),我们将显示错误消息并退出方法。

如果我们成功创建了 CSV 文件,我们的下一步是对 REST API 进行身份验证。为此,我们需要从用户那里获取用户名和密码。幸运的是,我们有完美的类来完成这个任务:

 d = v.LoginDialog(
      self, 'Login to ABQ Corporate REST API'
    )
    if d.result is not None:
      username, password = d.result
    else:
      return 

我们的LoginDialog类在这里为我们提供了很好的服务。与数据库登录不同,我们不会无限循环地运行这个;如果密码错误,我们将直接从函数返回,用户如果需要可以重新运行命令。回想一下,如果用户点击了“取消”,那么对话框的result属性将是None,所以在这种情况下,我们将直接退出回调方法。

现在我们有了凭据和文件名,我们可以尝试对服务器进行身份验证:

 rest_model = m.CorporateRestModel(
      self.settings['abq_rest_url'].get()
    )
    try:
      rest_model.authenticate(username, password)
    except Exception as e:
      messagebox.showerror('Error authenticating', str(e))
      return 

我们首先基于用户的abq_rest_url设置创建一个CorporateRestModel实例,然后将我们的凭据传递给其authenticate()方法。回想一下,在出现 HTTP 问题(包括无效凭据)的情况下,我们的模型将抛出一个带有友好信息的Exception,因此我们可以简单地在一个消息框中显示它并退出回调。

我们下一步是检查服务器上是否已经存在今天日期的文件。我们将使用我们模型的check_file()方法来完成,如下所示:

 try:
      exists = rest_model.check_file(csvfile.name)
    except Exception as e:
      messagebox.showerror('Error checking for file', str(e))
      return 

记住check_file()将返回一个布尔值,表示文件是否存在于服务器上,或者如果出现其他 HTTP 问题,可能会抛出异常。和之前一样,在出现错误的情况下,我们将显示对话框并退出函数。

如果文件已经存在,我们需要确定用户对此的处理意愿;首先,他们是否只想覆盖它,如果不是,是否想要下载它。我们可以通过一些消息框来实现,如下所示:

 if exists:
      overwrite = messagebox.askyesno(
        'File exists',
        f'The file {csvfile.name} already exists on the server, '
        'do you want to overwrite it?'
      )
      if not overwrite:
        download = messagebox.askyesno(
          'Download file',
          'Do you want to download the file to inspect it?'
        ) 

记住来自第七章使用菜单和 Tkinter 对话框创建菜单askyesno()函数返回一个布尔值,取决于用户是否点击了

如果用户想要下载文件,我们可以使用我们的模型来完成,如下所示:

 if download:
          filename = filedialog.asksaveasfilename()
          if not filename:
            return
          try:
            data = rest_model.get_file(csvfile.name)
          except Exception as e:
            messagebox.showerror('Error downloading', str(e))
            return
          with open(filename, 'w', encoding='utf-8') as fh:
            fh.write(data)
          messagebox.showinfo(
            'Download Complete', 'Download Complete.'
            )
        return 

在这里,我们首先使用filedialog函数检索用户想要保存下载文件的文件名。如果他们取消对话框,我们将直接退出函数而不做任何操作。否则,我们尝试使用我们模型的get_file()方法下载文件。和之前一样,如果失败,我们将显示错误并退出。如果成功,我们将打开一个新的 UTF-8 文件并将数据保存到其中。最后,在文件写入后,我们将显示一个成功对话框。最后的return语句将根据用户是否决定下载文件退出方法;因为在这个时候,他们已经选择在两种情况下都不覆盖文件。

如果他们选择了覆盖文件,我们的方法将继续在if块外部执行,如下所示:

 try:
      rest_model.upload_file(csvfile)
    except Exception as e:
      messagebox.showerror('Error uploading', str(e))
    else:
      messagebox.showinfo(
        'Success',
        f'{csvfile} successfully uploaded to REST API.'
      ) 

到目前为止,如果由于错误或用户选择而导致方法尚未返回,我们可以继续上传文件。这是通过我们模型的upload_file()方法完成的。我们将根据操作是否成功获得成功对话框或错误对话框。在任何情况下,我们的方法都到此结束。

完成工作

我们最后需要做的是添加一个用于运行 REST 上传的菜单选项。首先,将方法添加到Application类的回调事件中,如下所示:

# application.py, inside Application.__init__()
    event_callbacks = {
      #...
      '<<UploadToCorporateREST>>': self._upload_to_corporate_rest,
    } 

最后,让我们将命令项添加到我们的主菜单中。我们将首先添加一个创建菜单中 REST 上传条目的方法,如下所示:

# mainmenu.py, inside GenericMainMenu
  def _add_rest_upload(self, menu):
    menu.add_command(
      label="Upload CSV to corporate REST",
      command=self._event('<<UploadToCorporateREST>>')
    ) 

接下来,我们需要在GenericMainMenu类的初始化器和每个平台特定的菜单中添加对这个方法的调用;在每种情况下,它应该看起来像这样:

# mainmenu.py, in each menu class initializer
    # after creation of Tools menu
    self._add_rest_upload(self._menus['Tools']) 

现在,运行应用程序并尝试一下。为了使其工作,你至少需要在数据库中保存一条记录,并且需要从示例代码中启动sample_rest_service.py脚本。

如果一切顺利,你应该会看到一个类似的对话框:

图 13.4:示例代码中的 SFTP 使用

图 13.4:成功上传到 REST 服务器

你的服务器也应该在终端打印出类似以下内容的输出:

127.0.0.1 - - [07/Sep/2021 17:10:27] "POST /auth HTTP/1.1" 200 –
127.0.0.1 - - [07/Sep/2021 17:10:27]
  "HEAD /files/abq_data_record_2021-09-07.csv HTTP/1.1" 200 –
Uploaded abq_data_record_2021-09-07.csv
127.0.0.1 - - [07/Sep/2021 17:10:34] "PUT /files HTTP/1.1" 200 - 

注意POSTHEADPUT请求,以及PUT的有效负载中的 CSV 文件名。

你也可以再次运行上传,在这种情况下,你应该会看到对话框询问你是否要覆盖文件,然后是否要下载它,如下所示:

图 13.5:下载对话框

图 13.5:下载对话框

这就完成了我们为这个应用程序所需的功能。做得好!

使用 paramiko 的 SFTP

虽然自定义编写的 RESTful Web API 在大型公司和第三方服务中很常见,但我们的程序通常需要使用标准通信协议与服务器交换文件或数据。在 Linux 和 Unix 世界中,安全外壳或 SSH 协议长期以来一直是系统间通信的事实标准。大多数 SSH 实现包括 SFTP(安全文件传输协议),它是过时的 FTP 服务的加密替代品。

除了将 CSV 提取上传到企业 REST 服务之外,你的经理还需要使用 SFTP 将第二个副本上传到远程服务器。用户工作流程需要保持一致,尽管有一个要求将文件上传到服务器上的特定目录。你需要在应用程序中实现这个上传,就像你为 REST 服务所做的那样。

设置 SSH 服务以进行测试

为了测试我们将在应用程序中编写的 SFTP 功能,我们需要有一个可供我们使用的 SSH 服务器。如果你没有运行 SSH 的设备,你可以根据你的操作系统轻松地在自己的工作站上安装它:

  • 在 macOS 上,SSH 是预安装的,但需要启用。您可以从系统偏好设置中的共享页面启用它。

  • 在大多数 Linux 发行版中,您可以在包管理器中找到 SSH,作为 sshssh-serveropenssh(如果尚未安装)。大多数发行版在安装后默认启用服务器。

  • 在 Windows 10 及更高版本中,您可以通过在 设置 | 应用 | 应用和功能 下的 可选功能 工具安装 OpenSSH 服务器。安装完成后,通过打开 服务 应用,选择 OpenSSH 服务器,然后点击 启动服务 来启动服务。

一旦服务安装并运行,您可以使用类似 OpenSSH 客户端这样的 SSH 客户端连接到您的计算机,并使用本地用户名和密码登录。您可以使用您的普通用户账户,但由于我们的应用程序将在您用于连接 SSH 的任何用户的家目录下创建目录和复制文件,您可能还希望创建一个测试用户账户以供登录,这样应用程序就不会意外覆盖您的任何文件。

安装和使用 paramiko

虽然标准库在 SSH 或 SFTP 支持方面没有提供任何功能,但第三方 paramiko 库提供了用于处理两者的完整工具集。使用以下命令从 PyPI 安装 paramiko

$ pip install --user paramiko 

paramiko 是纯 Python 编写的,因此它不需要编译或安装额外的程序。您可以在其网站上了解更多关于 paramiko 的信息,www.paramiko.org

使用 paramiko

paramiko 中,我们将主要使用 SSHClient 类,通过它我们将连接并与远程服务器交互。打开一个 Python 命令行界面,并创建一个如下所示的实例:

>>> import paramiko
>>> ssh_client = paramiko.SSHClient() 

在我们可以使用该对象连接到任何服务器之前,我们需要配置其密钥管理策略。作为 SSH 安全设计的一部分,SSH 客户端在第一次连接时会与服务器交换加密密钥;因此,当您第一次使用 SSH 客户端连接到新服务器时,您可能会看到如下消息:

The authenticity of host 'myserver (::1)' can't be established.
ED25519 key fingerprint is
  SHA256:fwefogdhFa2Bh6wnbXSGY8WG6nl7SzOw3fxmI8Ii2oVs.
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? 

如果您选择继续,服务器的密钥(或指纹)将与主机名一起存储在通常称为 known_hosts 的文件中。当再次连接到服务器时,SSH 会咨询已知主机列表以验证我们是否连接到同一服务器。如果密钥不同,连接将失败。

因此,我们首先需要加载我们拥有的任何可用密钥存储;如果您的 SSH 密钥存储在标准位置,调用 load_system_host_keys() 方法就足够了:

>>> ssh_client.load_system_host_keys() 

您还可以使用 load_host_keys() 方法显式指定已知主机文件,如下所示:

>>> ssh.load_host_keys('/home/alanm/.ssh/known_hosts2') 

对于交互式客户端,将未知主机添加到已知主机列表的提示可能是可以接受的,但在编程库中显然不太实用。相反,我们需要设置一个策略,以确定当尝试连接到未知主机时 SSHClient 对象将执行什么操作。默认情况下,它将简单地失败,但我们可以通过使用 set_missing_host_key_policy() 强制它自动信任新主机,如下所示:

>>> ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 

在这里,我们将策略设置为 AutoAddPolicy 的实例,这意味着任何新的主机密钥都将自动被信任。paramiko 还提供了一个 RejectPolicy 类(这是默认设置),它会自动拒绝所有新的密钥,并且对于更高级的使用场景,我们可以定义自己的策略类以实现更细微的行为。在这种情况下,我们使用 AutoAddPolicy 以方便起见;在实际的、安全的环境中,您应该保留默认的 RejectPolicy 设置,并在脚本外部管理 known_hosts 列表。

您可以通过使用 OpenSSH 客户端登录到服务器并在提示添加密钥时选择 yes,或者通过使用 OpenSSH 客户端中包含的 ssh-keyscan 命令检索密钥并将它们手动添加到文件中,简单地将在您的 known_hosts 文件中添加服务器。

一旦解决了密钥管理问题,我们就可以连接到主机。这是通过使用 connect() 方法完成的,如下所示:

>>> ssh_client.connect('localhost', username='test', password='test') 

除了接受一个主机名或 IP 地址作为位置参数外,connect() 还接受多个关键字参数,包括:

参数 默认值 描述
username 本地用户名 用于身份验证的用户名。
password None 身份验证密码。如果为空,SSHClient 将尝试基于密钥的身份验证。
port 22 连接到的 TCP 端口。
pkey None 用于身份验证的私钥字符串。
key_file None 包含用于身份验证的私钥或证书的文件。
compress False 启用或禁用传输数据的压缩。
timeout None 在放弃连接之前等待的秒数。

检查我们的连接

连接到服务器后,我们的代码可能需要获取有关连接的一些信息。这可以通过访问与客户端关联的 Transport 对象来完成。此对象表示连接,并包含设置或检索有关连接信息的方法和属性。

我们可以使用 SSHClientget_transport() 方法检索 Transport 对象,如下所示:

>>> transport = ssh_client.get_transport() 

现在,我们可以以各种方式检查我们的连接,例如:

# See if the connection is still active
>>>  transport.is_active()
True
# See our remote username
>>> transport.get_username()
'alanm'
# See if we're authenticated
>>> transport.is_authenticated()
True
# Get the name or IP of the connected server
>>> transport.getpeername()
('::1', 22, 0, 0)
# Get the compression used by the server
>>> transport.remote_compression
'none' 

这些属性在用户使用从环境中检测到的默认值进行连接的情况下尤其有用。

使用 SFTP

现在我们已经建立到服务器的 SSH 连接,我们可以开始使用 SFTP。为此,我们将使用 open_sftp() 方法创建 SFTPClient 实例,如下所示:

>>> sftp_client = ssh_client.open_sftp() 

我们可以使用 SFTPClient 对象的方法在远程服务器上通过 SFTP 执行各种文件管理命令。以下表格中展示了其中一些更有用的命令:

方法 参数 描述
chdir() path 设置当前工作目录。
getcwd() None 返回当前工作目录的路径。注意,如果目录未使用 chdir() 设置,则返回 None
listdir() path(可选) 返回path路径上的文件和目录列表,如果没有指定,则在当前工作目录返回。
mkdir() path 在服务器上的path路径创建一个目录。
rmdir() path 从服务器上描述的path路径删除目录。
get() remotepath, localpath 从服务器上的remotepath下载文件并将其保存到客户端的localpath
put() localpath, remotepath 将客户端上的文件从localpath上传并保存到服务器上的remotepath
stat() path 返回一个包含path路径上的文件或目录信息的对象。
remove() path 从服务器上描述的path路径删除文件。如果path是目录,则不起作用(请使用rmdir()代替)。
close() None 关闭 SFTP 连接。

例如,假设我们需要在我们的服务器上的Fruit目录中创建一个名为Bananas的文件夹,并将名为cavendish.ban的文件从/home/alanm/bananas/上传到服务器上的新目录。这个交换过程看起来是这样的:

>>> sftp_client.chdir('Fruit')
>>> sftp_client.mkdir('Bananas')
>>> sftp_client.put('/home/alanm/bananas/cavendish.ban', 'Bananas/cavendish.ban') 

注意,在put()调用的目标路径中,我们没有包含Fruit目录。这是因为它是我们的当前工作目录,所以我们的远程路径被认为是相对于它的。

让我们看看我们能否利用我们对paramiko和 SFTP 的理解来实现 ABQ 数据录入中的 SFTP 上传。

实现 SFTP 模型

正如我们在 REST 上传中所做的那样,我们将首先在模型类中封装与 SFTP 服务器的交互。

打开models.py,我们首先导入paramiko

# models.py
import paramiko 

现在,让我们开始我们的模型类:

# models.py
class SFTPModel:
  def __init__(self, host, port=22):
    self.host = host
    self.port = port 

我们类的初始化器将接受一个服务器的主机名,以及可选的端口号。SSH 通常在端口22上运行,尽管出于安全原因,系统管理员在另一个端口上运行它的情况并不少见,所以提供这个选项是好的。

接下来,我们将继续初始化器,通过配置我们的SSHClient对象:

 self._client = paramiko.SSHClient()
    self._client.set_missing_host_key_policy(
      paramiko.AutoAddPolicy()
    )
    self._client.load_system_host_keys() 

在创建客户端实例并将其保存到实例属性后,我们将其配置为自动添加新的宿主密钥。最后,我们从默认的系统位置加载已知的宿主。

在一个安全的生成环境中,你可能希望将此策略保留为默认的RestrictPolicy设置,并在应用程序外部管理已知的宿主列表。请注意,AutoAddPolicy仅影响对新宿主的连接;如果SSHClient在连接到已知宿主时收到无效的指纹,它仍然会引发异常。

这样就处理好了初始化器,接下来我们创建一个authenticate()方法来建立与服务器的连接:

# models.py, inside SFTPModel
  def authenticate(self, username, password):
    try:
      self._client.connect(
        self.host, username=username,
        password=password, port=self.port
      )
    except paramiko.AuthenticationException:
      raise Exception(
        'The username and password were not accepted by the server.'
      ) 

这个方法将接受一个usernamepassword,并使用它们通过connect()方法建立连接。如果认证失败,paramiko将引发一个AuthenticationException。我们可以简单地允许这个异常传递回调用代码,但就像我们在 REST 模型中所做的那样,我们对其进行了一些清理,以便我们的Application对象可以显示一个更友好的消息。

就像我们的RESTModel一样,我们将创建另外三个方法:一个用于上传文件,一个用于下载文件,还有一个用于检查服务器上是否存在文件。尽管所有这些都需要我们连接并验证身份,但如果我们没有连接,有一个引发异常的方法会很有用。

我们将为这个操作创建一个名为_check_auth()的私有方法,如下所示:

 def _check_auth(self):
    transport = self._client.get_transport()
    if not transport.is_active() and transport.is_authenticated():
      raise Exception('Not connected to a server.') 

正如你在上一节中看到的,我们可以从其Transport对象中检索连接的活跃和认证状态;因此,这个方法检索传输对象,然后如果它既不活跃也不认证,就引发一个异常。

为了了解我们将如何使用它,让我们首先创建我们的get_file()方法:

 def get_file(self, remote_path, local_path):
    self._check_auth()
    sftp = self._client.open_sftp()
    sftp.get(remote_path, local_path) 

这个方法将接受一个远程路径和一个本地路径,并将文件从远程路径复制到本地路径。注意,我们首先调用_check_auth()以确保我们已经正确连接到服务器。然后我们创建我们的 SFTP 客户端并运行get()方法。这就是全部内容!

当创建一个复制或移动数据的命令或函数时,一个长期以来的惯例是将你的参数按照(SOURCE, DESTINATION)的顺序排列。搞混这一点可能会让你用户或同行开发者极度不悦。

上传文件

创建一个上传方法将稍微复杂一些。与只处理单个端点的 REST 客户端不同,SFTP 服务器有一个文件系统结构,我们有可能上传到服务器上的子目录。如果我们尝试将文件上传到不存在的目录,paramiko将引发一个异常。

因此,在我们上传文件之前,我们需要连接到服务器并确保目标路径中的所有目录都存在。如果其中任何一个不存在,我们需要创建那个目录。

我们将像以前一样开始我们的方法,检查连接并创建一个 SFTPClient 实例:

 def upload_file(self, local_path, remote_path):
    self._check_auth()
    sftp = self._client.open_sftp() 

现在,我们将检查目录:

 remote_path = Path(remote_path)
    for directory in remote_path.parent.parts:
      if directory not in sftp.listdir():
        sftp.mkdir(directory)
      sftp.chdir(directory) 

我们的remote_path可能是一个字符串,所以我们首先将其转换为pathlib.Path对象,以便更容易地操作。remote_path.parent.parts给我们一个包含文件的目录列表,从最高层到最低层。例如,如果remote_path的值是Food/Fruit/Bananas/cavendish.ban,这个属性会给我们列表['Food', 'Fruit', 'Bananas']

一旦我们有了这个列表,我们就遍历它,检查目录是否在当前工作目录的内容中。如果不是,我们就创建它。一旦我们知道目录存在,我们就将当前工作目录更改为它,并重复对列表中的下一个目录进行操作。

一旦建立了目录结构,我们就可以上传实际的文件:

 sftp.put(local_path, remote_path.name) 

put() 方法接收我们本地文件的路径以及我们想要复制到的远程路径。注意,然而,我们只使用远程路径的 name 部分;这是因为遍历我们的目录的 for 循环已经将当前工作目录留在了文件需要放置的正确父目录中。因此,我们只需将文件名作为目标传递即可。

检查文件是否存在

我们需要的最后一个方法是检查服务器上文件的存在。为此,我们将依赖于 stat() 方法。SFTPClientstat() 方法可以用来获取服务器上文件的相关元数据,如大小和修改时间。我们不需要这些信息,但 stat() 的一个有用的副作用是,如果传递了一个不存在的路径,它会引发 FileNotFoundError

我们可以在我们的方法中使用它,如下所示:

 def check_file(self, remote_path):
    self._check_auth()
    sftp = self._client.open_sftp()
    try:
      sftp.stat(remote_path)
    except FileNotFoundError:
      return False
    return True 

与其他方法一样,这个方法首先检查身份验证,然后创建我们的 SFTPClient 对象。然后,它尝试对 remote_path 上的文件进行 stat()。如果引发 FileNotFoundError,我们返回 False。否则,我们返回 True

这完成了我们的 SFTPModel,至少对于我们的应用程序需要执行的操作来说是这样;但在离开 models.py 之前,跳转到 SettingsModel 类,让我们添加一些与 SFTP 相关的设置:

# models.py, inside SettingsModel
  fields = {
    # ...
    'abq_sftp_host': {'type': 'str', 'value': 'localhost'},
    'abq_sftp_port': {'type': 'int', 'value': 22},
    'abq_sftp_path': {'type': 'str', 'value': 'ABQ/BLTN_IN'}
  } 

这些设置定义了服务器的地址和端口,以及我们的文件需要上传到服务器上的子目录路径。添加这些设置后,我们就可以在 GUI 界面方面开始工作了。

在我们的应用程序中使用 SFTPModel

我们需要实现的 SFTP 上传过程与 REST 上传过程相同:我们需要对服务器进行身份验证,然后检查文件是否已存在。如果存在,我们询问用户是否要覆盖它。如果不,我们提供下载文件供他们检查。

让我们在 Application 中开始这个方法:

try:
      csvfile = self._create_csv_extract()
    except Exception as e:
      messagebox.showwarning(
        title='Error', message=str(e)
      )
      return 

就像之前一样,我们首先尝试从当天的数据创建 CSV 文件;如果出现异常,我们将显示它并退出。

现在,我们将进行身份验证:

 d = v.LoginDialog(self, 'Login to ABQ Corporate SFTP')
    if d.result is None:
      return
    username, password = d.result
    host = self.settings['abq_sftp_host'].get()
    port = self.settings['abq_sftp_port'].get()
    sftp_model = m.SFTPModel(host, port)
    try:
      sftp_model.authenticate(username, password)
    except Exception as e:
      messagebox.showerror('Error Authenticating', str(e))
      return 

同样,就像之前一样,我们使用我们的 LoginDialog 从用户那里请求用户名和密码,只是简单地调整了 SFTP 的标签文本。然后,我们使用 settings 对象中的主机和端口值创建我们的 SFTPModel 实例,并尝试进行身份验证。任何身份验证错误都会在消息框中显示。

接下来,我们需要检查目标路径是否已存在:

 destination_dir = self.settings['abq_sftp_path'].get()
    destination_path = f'{destination_dir}/{csvfile.name}'
    try:
      exists = sftp_model.check_file(destination_path)
    except Exception as e:
      messagebox.showerror(
        f'Error checking file {destination_path}',
        str(e)
      )
      return 

这次,我们需要通过将 settings 中的 abq_sftp_path 值与生成的 CSV 文件名组合来构造完整的目标路径。请注意,我们正在使用字符串格式化而不是使用 Path 对象来构建路径。这是因为 Path 将使用我们本地系统上的路径分隔符字符(正斜杠或反斜杠)来连接路径组件。我们创建的路径需要与远程文件系统兼容。幸运的是,paramiko 不论远程服务器使用的是 Windows 还是类 Unix 系统,都会使用正斜杠(Unix 风格的路径分隔符)。因此,我们明确使用正斜杠来格式化我们的路径。

如果文件已存在,我们需要询问用户下一步要做什么:

 if exists:
      overwrite = messagebox.askyesno(
        'File exists',
        f'The file {destination_path} already exists on the server,'
        ' do you want to overwrite it?'
      )
      if not overwrite:
        download = messagebox.askyesno(
          'Download file',
          'Do you want to download the file to inspect it?'
        )
        if download:
          filename = filedialog.asksaveasfilename()
          try:
            sftp_model.get_file(destination_path, filename)
          except Exception as e:
            messagebox.showerror('Error downloading', str(e))
            return
          messagebox.showinfo(
            'Download Complete', 'Download Complete.'
            )
        return 

再次强调,这与我们的基于 REST 的代码相同,只是我们需要记住我们处理的是路径,而不仅仅是文件名。因此,我们在之前使用 csvfile.name 的地方使用了 destination_path

如果到这一点方法还没有返回,我们可以尝试上传我们的文件:

 try:
      sftp_model.upload_file(csvfile, destination_path)
    except Exception as e:
      messagebox.showerror('Error uploading', str(e))
    else:
      messagebox.showinfo(
        'Success',
        f'{csvfile} successfully uploaded to SFTP server.'
      ) 

这样就完成了我们的 SFTP 上传回调。

一些读者可能会想知道,为什么我们的模型在每次调用时都会检查其认证状态,尽管我们的回调方法只在成功认证后执行操作。首先,这是一个防御性编程的举动。我们不知道我们的模型类将来可能如何被使用,模型也不能总是依赖表现良好的视图和控制层在执行其他操作之前确保认证。其次,这是因为与 HTTP 不同,SSH 是一个有状态的协议。这意味着当我们连接时,会创建一个活动会话,必须保持该会话以执行任何操作。如果在认证和后续操作之间会话被中断(例如,由于临时网络中断或笔记本电脑用户切换网络),那些操作将失败,我们需要重新开始。因此,当与有状态的协议一起工作时,在执行单个操作之前检查连接和认证状态是一个好主意。

完成操作

现在剩下的只是将新功能添加到我们的菜单中。回到 Application.__init__(),将回调添加到我们的 event_callbacks 字典中:

# application.py, inside Application.__init__()
    event_callbacks = {
      #...
      '<<UploadToCorporateSFTP>>': self._upload_to_corporate_sftp,
     } 

现在,转到 mainmenu.py 文件,并在 GenericMainMenu 类中添加一个新的私有方法:

# mainmenu.py, inside GenericMainMenu
  def _add_sftp_upload(self, menu):
    menu.add_command(
      label="Upload CSV to corporate SFTP",
      command=self._event('<<UploadToCorporateSFTP>>'),
    ) 

然后,在每个菜单子类中,将条目添加到 Tools 菜单中,如下所示:

# mainmenu.py, inside each class's _build_menu() method
    self._add_sftp_upload(self._menus['Tools']) 

我们的新上传功能现在已完成!确保您的系统上正在运行 SSH,启动 ABQ 数据录入,确保至少保存了一条当天的记录,然后从 工具 菜单中运行上传。你应该会看到一个成功对话框,如下所示:

图 13.6:SFTP 上传成功对话框

图 13.6:SFTP 上传成功对话框

再次运行该功能,你应该会看到警告对话框,如下所示:

图 13.7:SFTP 上传覆盖对话框

图 13.7:SFTP 上传覆盖对话框

继续努力,确保你能下载该文件。做得很好!

摘要

在本章中,我们使用网络协议 HTTP 和 SSH 连接到了云端。你学习了如何使用urllib通过 HTTP 下载数据,以及如何使用ElementTree模块解析 XML 数据结构。你还发现了一种使用requests库与 HTTP 交互的替代方法,并学习了与 REST API 交互的基础知识。你学习了如何处理需要身份验证和会话 cookie 的 HTTP 交互,并上传了一个文件。最后,你学习了如何使用paramiko库通过 SFTP 服务在 SSH 上传输和管理远程文件。

在下一章中,我们将学习如何通过异步编程来阻止长时间运行的过程冻结我们的应用程序,并提高应用程序的性能。我们将学习如何操作 Tkinter 事件循环以获得更好的响应性,以及使用 Python 的threading库进行高级异步编程。

第十四章:使用线程和队列进行异步编程

许多时候,在测试环境的简单性中完美运行的代码在现实世界中会遇到问题;不幸的是,ABQ 数据输入应用程序似乎就是这样。虽然你的网络功能在你的本地主机测试环境中运行得非常快,但实验室缓慢的 VPN 上行链路暴露了你编程中的一些不足。用户报告说,当进行网络事务时,应用程序会冻结或变得无响应。尽管它确实可以工作,但看起来不够专业,使用户感到烦恼。

为了解决这个问题,我们需要应用异步编程技术,我们将在以下主题中学习这些技术:

  • Tkinter 事件队列 中,我们将学习如何操作 Tkinter 的事件处理来提高应用程序的响应性。

  • 使用线程在后台运行代码 中,我们将探讨使用 Python 的 threading 模块编写多线程应用程序。

  • 使用队列传递消息 中,你将学习如何使用 Queue 对象来实现线程间通信。

  • 使用锁来保护共享资源 中,我们将利用 Lock 对象来防止线程相互覆盖。

让我们开始吧!

Tkinter 的事件队列

正如我们在 第十一章 中讨论的,使用 unittest 创建自动化测试,Tkinter 中的许多任务,如绘图和更新小部件,都是异步执行的,而不是在代码中调用时立即采取行动。更具体地说,你在 Tkinter 中执行的操作,如点击按钮、触发键绑定或跟踪,或调整窗口大小,都会在事件队列中放置一个 事件。在主循环的每次迭代中,Tkinter 从队列中提取所有挂起的事件,并逐个处理它们。对于每个事件,Tkinter 在继续处理队列中的下一个事件之前,执行与事件绑定的任何 任务(即回调或重绘小部件等内部操作)。

Tkinter 大致将任务优先级分为 常规空闲时执行(通常称为 空闲任务)。在事件处理过程中,常规任务首先处理,当所有常规任务完成后,再处理空闲任务。大多数绘图或小部件更新任务被归类为空闲任务,而像回调函数这样的操作默认为常规优先级。

事件队列控制

大多数时候,我们通过依赖高级构造,如 command 回调和 bind(),从 Tkinter 获得所需的行为。然而,在某些情况下,我们可能希望直接与事件队列交互并手动控制事件的处理方式。我们已经看到了一些可用于此目的的功能,但让我们在这里更深入地了解一下。

update() 方法

第十一章使用 unittest 创建自动化测试 中,你学习了 update()update_idletasks() 方法。为了复习,这些方法将导致 Tkinter 执行队列中当前所有事件的任何任务;update() 运行队列中所有当前等待的事件,直到完全清除,而 update_idletasks() 只运行空闲任务。

由于空闲任务通常较小且更安全,除非你发现它不起作用,否则建议使用 update_idletasks()

注意,update()update_idletasks() 将导致处理所有小部件的所有挂起事件,无论方法是在哪个小部件上调用。没有方法可以只处理特定小部件或 Tkinter 对象的事件。

after() 方法

除了允许我们控制队列的处理外,Tkinter 小部件还有两种方法可以在延迟后向事件队列添加任意代码:after()after_idle()

after() 的基本用法如下:

# basic_after_demo.py
import tkinter as tk
root = tk.Tk()
root.after(1000, root.quit)
root.mainloop() 

在这个例子中,我们将 root.quit() 方法设置为在 1 秒(1,000 毫秒)后运行。实际上发生的情况是,一个绑定到 root.quit 的事件被添加到事件队列中,但有一个条件,即它不应在从调用 after() 的那一刻起至少 1,000 毫秒内执行。在这段时间内,队列中的任何其他事件都将首先被处理。因此,虽然命令不会在 1,000 毫秒内执行,但它很可能会在之后执行,具体取决于事件队列中已经正在处理的其他内容。

after_idle() 方法也会将一个任务添加到事件队列中,但它不是提供一个明确的延迟,而是简单地将其添加为一个空闲任务,确保它将在任何常规任务之后运行。

在这两种方法中,任何附加到回调引用的额外参数都简单地作为位置参数传递给回调;例如:

root.after(1000, print, 'hello', 'Python', 'programmers!') 

在这个例子中,我们将 'hello''Python''programmers' 参数传递给一个 print() 调用。这个语句将计划在 1 秒后尽可能快地运行 print('hello', 'Python', 'programmers!') 语句。

注意,after()after_idle() 不能为传递的可调用对象接受关键字参数,只能接受位置参数。

使用 after() 方法计划的任务也可以使用 after_cancel() 方法取消计划。该方法接受一个任务 ID 号,该 ID 号是在我们调用 after() 时返回的。

例如,我们可以修改之前的例子如下:

# basic_after_cancel_demo.py
import tkinter as tk
root = tk.Tk()
task_id = root.after(3000, root.quit)
tk.Button(
  root,
  text='Do not quit!', command=lambda: root.after_cancel(task_id)
).pack()
root.mainloop() 

在这个脚本中,我们保存了 after() 的返回值,这给我们提供了计划任务的 ID。然后,在我们的按钮回调中,我们调用 after_cancel(),传入 ID 值。在 3 秒内点击按钮会导致 root.quit 任务被取消,应用程序保持打开状态。

事件队列控制的一般用途

第十一章使用 unittest 创建自动化测试中,我们很好地使用了队列控制方法,以确保我们的测试运行得既快又高效,而无需等待人工交互。尽管如此,我们可以在实际应用程序中使用这些方法的不同方式,我们将在下面探讨。

平滑显示更改

在具有动态 GUI 更改的应用程序中,当窗口根据元素的出现和重新出现进行缩放时,这些更改的平滑性可能会略有下降。例如,在 ABQ 应用程序中,你可能会注意到登录后立即出现一个较小的应用程序窗口,随着 GUI 的构建,它很快就会被调整大小。这不是一个主要问题,但它会从整体上影响应用程序的展示。

我们可以通过在登录后使用after()延迟deiconify()调用来纠正这个问题。在Application.__init__()内部,让我们将那行代码修改如下:

# application.py, inside Application.__init__()
    self.after(250, self.deiconify) 

现在,我们不再在登录后立即恢复应用程序窗口,而是将其延迟了四分之一秒。虽然对用户来说几乎察觉不到,但它给了 Tkinter 足够的时间在显示窗口之前构建和重绘 GUI,从而平滑了操作。

应该谨慎使用延迟代码,并且不要在延迟代码的稳定性或安全性依赖于其他进程先完成的情况下依赖它。这可能导致竞态条件,在这种情况下,一些不可预见的情况,如缓慢的磁盘或网络连接,可能导致你的延迟不足以正确地安排代码的执行顺序。在我们的应用程序中,我们的延迟仅仅是一个表面上的修复;如果在应用程序窗口完成绘制之前恢复窗口,不会发生灾难性的事故。

缓解 GUI 冻结

由于回调任务优先于屏幕更新任务,一个长时间阻塞代码执行的回调任务可能导致程序看起来冻结或卡在尴尬的位置,而重绘任务则等待其完成。解决这个问题的方法之一是使用after()update()方法手动控制事件队列处理。为了了解这是如何工作的,我们将构建一个简单的应用程序,使用这些方法在长时间运行的任务期间保持 UI 响应。

从这个简单但缓慢的应用程序开始:

# after_demo.py
import tkinter as tk
from time import sleep
class App(tk.Tk):
  def __init__(self):
    super().__init__()
    self.status = tk.StringVar()
    tk.Label(self, textvariable=self.status).pack()
    tk.Button(
      self, text="Run Process",
      command=self.run_process
    ).pack()
  def run_process(self):
    self.status.set("Starting process")
    sleep(2)
    for phase in range(1, 5):
      self.status.set(f"Phase {phase}")
      self.process_phase(phase, 2)
    self.status.set('Complete')
  def process_phase(self, n, length):
    # some kind of heavy processing here
    sleep(length)
App().mainloop() 

此应用程序使用time.sleep()来模拟多个阶段完成的一些重处理任务。GUI 向用户提供了一个按钮,用于启动进程,以及一个状态指示器来显示进度。

当用户点击按钮时,状态指示器应该应该执行以下操作:

  • 显示启动进程2 秒钟。

  • 显示阶段 1阶段 2,通过阶段 4,每个阶段持续 2 秒钟。

  • 最后,它应该显示为完成

尽管如此,如果你尝试它,你会发现它并没有这样做。相反,当按钮按下时,它会立即冻结,并且不会解冻,直到所有阶段都完成并且状态显示为完成。为什么会发生这种情况?

当按钮点击事件由主循环处理时,run_process() 回调比任何绘图任务(因为那些是空闲任务)具有优先级,并且立即执行,阻塞主循环直到它返回。当回调调用 self.status.set() 时,status 变量的写事件被放入队列(它们最终会在 Label 小部件上触发重绘事件)。然而,当前队列的处理已被暂停,等待 run_process() 方法返回。当它最终返回时,所有等待在事件队列中的 status 更新都在一秒钟内执行。

为了使这个方法更好一些,让我们使用 after() 来安排 run_process()

# after_demo2.py
  def run_process(self):
    self.status.set("Starting process")
    self.after(50, self._run_phases)
  def _run_phases(self):
    for phase in range(1, 5):
      self.status.set(f"Phase {phase}")
      self.process_phase(phase, 2)
    self.status.set('Complete') 

这次,run_process() 的循环部分被拆分到一个单独的方法 _run_phases() 中。run_process() 方法本身只是设置起始状态,然后安排 _run_phases() 在 50 毫秒后运行。这个延迟给 Tkinter 时间来完成任何绘图任务并在启动长时间阻塞循环之前更新状态。在这种情况下,确切的时间并不重要,只要足够 Tkinter 完成绘图操作,但又不至于让用户注意到;50 毫秒似乎可以很好地完成这项工作。

尽管如此,我们仍然没有看到这个版本的各个阶段状态消息;它直接从 开始进程 跳到 完成,因为 _run_phases() 方法最终运行时仍然会阻塞事件循环。

为了解决这个问题,我们可以在循环中使用 update_idletasks()

# after_demo_update.py
  def _run_phases(self):
    for phase in range(1, 5):
      self.status.set(f"Phase {phase}")
      self.update_idletasks()
      self.process_phase(phase, 2)
    self.status.set('Complete') 

通过强制 Tkinter 在开始长时间阻塞方法之前运行队列中的剩余空闲任务,我们的 GUI 保持更新。不幸的是,这种方法存在一些缺点:

  • 首先,单个任务在运行时仍然会阻塞应用程序。无论我们如何将其拆分,当处理过程的各个单元执行时,应用程序仍然会被冻结。

  • 其次,这种方法在关注点分离方面存在问题。在实际应用中,我们的处理阶段很可能会在某种后端或模型类中运行。这些类不应该操作 GUI 小部件。

虽然这些队列控制方法可以用于管理 GUI 层面的进程,但很明显,我们需要一个更好的解决方案来处理像 ABQ 网络上传功能这样的慢速后台进程。对于这些,我们需要使用更强大的工具:线程。

使用线程在后台运行代码

书中到目前为止所写的所有代码都可以描述为单线程;也就是说,每个语句一次执行一个,前一个语句完成之前,下一个语句才开始执行。即使像我们的 Tkinter 事件队列这样的异步元素可能会改变任务执行的顺序,但它们仍然一次只执行一个任务。这意味着像慢速网络事务或文件读取这样的长时间运行的过程不可避免地会在运行时冻结我们的应用程序。

要看到这个效果,请运行示例代码中包含的sample_rest_service.py脚本,该脚本对应于第十四章(确保运行的是第十四章版本,而不是第十三章版本!)现在运行 ABQ 数据输入,确保你今天数据库中有一些数据,并运行 REST 上传。上传应该需要大约 20 秒,在这段时间内,服务脚本应该会打印出类似以下的状态消息:

File 0% uploaded
File 5% uploaded
File 10% uploaded
File 15% uploaded
File 20% uploaded
File 25% uploaded 

同时,我们的 GUI 应用程序会冻结。你会发现你无法与任何控件交互,移动或调整大小可能会导致一个空白的灰色窗口。只有当上传过程完成时,你的应用程序才会再次变得响应。

为了真正解决这个问题,我们需要创建一个多线程应用程序,其中多个代码片段可以同时运行,而无需相互等待。在 Python 中,我们可以使用threading模块来实现这一点。

线程模块

多线程应用程序编程可能相当具有挑战性,但标准库的threading模块使得使用线程变得尽可能简单。

为了演示threading的基本用法,让我们首先创建一个故意慢速的函数:

# basic_threading_demo.py
from time import sleep
def print_slowly(string):
  words = string.split()
  for word in words:
    sleep(1)
    print(word) 

这个函数接受一个字符串,并以每秒一个单词的速度打印它。这将模拟一个长时间运行、计算密集型的过程,并给我们一些反馈,表明它仍在运行。

让我们为这个函数创建一个 Tkinter GUI 前端:

# basic_threading_demo.py
import tkinter as tk
# print_slowly() function goes here
# ...
class App(tk.Tk):
  def __init__(self):
    super().__init__()
    self.text = tk.StringVar()
    tk.Entry(self, textvariable=self.text).pack()
    tk.Button(
      self, text="Run unthreaded",
      command=self.print_unthreaded
    ).pack()
  def print_unthreaded(self):
    print_slowly(self.text.get())
App().mainloop() 

这个简单的应用程序有一个文本输入框和一个按钮;当按钮被按下时,输入框中的文本会被发送到print_slowly()函数。运行此代码,然后在Entry小部件中输入或粘贴一个长句子。

当你点击按钮时,你会看到整个应用程序冻结,因为单词被打印到控制台。这是因为所有操作都在单个执行线程中运行。

现在让我们添加线程代码:

# basic_threading_demo.py
from threading import Thread
# at the end of App.__init__()
    tk.Button(
      self, text="Run threaded",
      command=self.print_threaded
    ).pack()
  def print_threaded(self):
    thread = Thread(
      target=print_slowly,
      args=(self.text.get(),)
    )
    thread.start() 

这次,我们导入了Thread类并创建了一个名为print_threaded()的新回调。这个回调使用一个Thread对象在其自己的执行线程中运行print_slowly()

Thread对象接受一个target参数,该参数指向将在新执行线程中运行的调用函数。它还可以接受一个args元组,该元组包含要传递给target参数的参数,以及一个kwargs字典,它也会在target函数的参数列表中展开。

要执行 Thread 对象,我们调用它的 start() 方法。此方法不会阻塞,因此 print_threaded() 回调立即返回,允许 Tkinter 继续其事件循环,同时 thread 在后台执行。

如果你尝试运行此代码,你会看到在打印句子时 GUI 不会冻结。无论句子有多长,GUI 整个过程中都保持响应。

Tkinter 和线程安全

线程引入了大量的复杂性到代码库中,并不是所有的代码都是为在多线程环境中正确行为而编写的。

我们将考虑到线程的代码称为 线程安全

经常有人说 Tkinter 不是线程安全的;这并不完全正确。假设你的 Tcl/Tk 二进制文件已经编译了线程支持(Linux、Windows 和 macOS 的官方 Python 发行版中包含的),Tkinter 应该在多线程程序中运行良好。然而,Python 文档警告我们,在跨线程调用中,Tkinter 仍然存在一些边缘情况,其行为可能不正确。

避免这些问题的最佳方式是将我们的 Tkinter 代码保持在单个线程中,并将线程的使用限制在非 Tkinter 代码(如我们的模型类)中。

关于 Tkinter 和线程的更多信息可以在 docs.python.org/3/library/tkinter.html#threading-model 找到。

将我们的网络函数转换为线程执行

将一个函数传递给 Thread 对象的 target 参数是在线程中运行代码的一种方法;一个更灵活且强大的方法是继承 Thread 类,并用你想要执行的代码覆盖其 run() 方法。为了演示这种方法,让我们更新在 第十三章连接到云端 中为 ABQ 数据录入创建的企业 REST 上传功能,使其在单独的线程中运行缓慢的上传操作。

首先,打开 models.py 并导入 Thread 类,如下所示:

# models.py, at the top
from threading import Thread 

我们不是让一个 CorporateRestModel 方法来执行上传,而是将创建一个基于 Thread 的类,其实例将能够在单独的线程中执行上传操作。我们将它命名为 ThreadedUploader

要执行其上传,ThreadedUploader 实例需要一个端点 URL 和本地文件路径;我们可以简单地将这些传递给对象在其初始化器中。它还需要访问认证会话;这会带来更多的问题。我们可能能够通过将我们的认证 Session 对象传递给线程来解决这个问题,但在编写本文时,关于 Session 对象是否线程安全存在很大的不确定性,因此最好避免在线程之间共享它们。

然而,我们实际上并不需要整个 Session 对象,只需要认证令牌或会话 cookie。

结果表明,当我们对 REST 服务器进行身份验证时,一个名为session的 cookie 被放置在我们的 cookie jar 中,我们可以通过检查终端中的Session.cookies对象来查看,如下所示:

# execute this with the sample REST server running in another terminal
>>> import requests
>>> s = requests.Session()
>>> s.post('http://localhost:8000/auth', data={'username': 'test', 'password': 'test'})
<Response [200]>
>>> dict(s.cookies)
{'session': 'eyJhdXRoZW50aWNhdGVkIjp0cnVlfQ.YTu7xA.c5ZOSuHQbckhasRFRF'} 

cookies属性是一个requests.CookieJar对象,它在许多方面都像字典一样工作。每个 cookie 都有一个唯一的名称,可以用来检索 cookie 本身。在这种情况下,我们的会话 cookie 被称为session

由于 cookie 本身只是一个字符串,我们可以安全地将其传递给另一个线程。一旦到达那里,我们将创建一个新的Session对象,并将 cookie 传递给它,之后它就可以验证请求了。

不可变对象,包括字符串、整数和浮点数,总是线程安全的。由于不可变对象在创建后不能被更改,我们不必担心两个线程会同时尝试更改对象。

让我们以以下方式开始我们的新上传器类:

# models.py
class ThreadedUploader(Thread):
  def __init__(self, session_cookie, files_url, filepath):
    super().__init__()
    self.files_url = files_url
    self.filepath = filepath
    # Create the new session and hand it the cookie
    self.session = requests.Session()
    self.session.cookies['session'] = session_cookie 

初始化方法首先调用超类初始化器以设置Thread对象,然后将传递的files_urlfilepath字符串分配给实例属性。

接下来,我们创建一个新的Session对象,并将传递的 cookie 值添加到 cookie jar 中,通过将其分配给session键(与原始会话的 cookie jar 中使用的相同键)。现在我们有了执行上传过程所需的所有信息。在线程中要执行的实际过程在其run()方法中实现,我们将在下面添加它:

 def run(self, *args, **kwargs):
    with open(self.filepath, 'rb') as fh:
      files = {'file': fh}
      response = self.session.put(
        self.files_url, files=files
      )
      response.raise_for_status() 

注意,这段代码基本上是模型upload()方法的代码,只是函数参数已经被更改为了实例属性。

现在,让我们转到我们的模型,看看我们如何使用这个类。

Python 文档建议,在子类化Thread时,只重写run()__init__()方法。其他方法应保持不变以确保正确操作。

使用线程化上传器

现在我们已经创建了一个线程化的上传器,我们只需要让CorporateRestModel使用它。找到你的模型类,然后按照以下方式重写upload_file()方法:

# models.py, inside CorporateRestModel
  def upload_file(self, filepath):
    """PUT a file on the server"""
    cookie = self.session.cookies.get('session')
    uploader = ThreadedUploader(
      cookie, self.files_url, filepath
    )
    uploader.start() 

在这里,我们首先从我们的Session对象中提取会话 cookie,然后将其与 URL 和文件路径一起传递给ThreadedUploader初始化器。最后,我们调用线程的start()方法以开始上传的执行。

现在,再次尝试你的 REST 上传,你会发现应用程序不会冻结。干得好!然而,它还没有完全按照我们希望的方式表现...

记住,你重写了run()方法,但调用的是start()方法。混淆这些会导致你的代码要么什么都不做,要么像正常单线程调用一样阻塞。

使用队列传递消息

我们已经解决了程序冻结的问题,但现在我们遇到了一些新的问题。最明显的问题是我们的回调立即显示一个消息框,声称我们已经成功上传了文件,尽管你可以从服务器输出中看到该过程仍在后台进行。一个更微妙但更严重的问题是,我们没有收到错误通知。如果你在上传过程中尝试终止测试服务(因此回调应该失败),它仍然会立即声称上传成功,尽管你可以在终端上看到正在抛出异常。这里发生了什么?

这里的问题首先是 Thread.start() 方法不会阻塞代码执行。当然,这是我们想要的,但现在这意味着我们的成功对话框不会等待上传过程完成才显示。一旦启动新线程,主线程中的代码就会与新线程并行执行,立即显示成功对话框。

第二个问题是,在其自己的线程中运行的代码无法将线程的 run() 方法中引起的异常传递回主线程。这些异常是在新线程中抛出的,并且只能在新线程中被捕获。就我们的主线程而言,try 块中的代码执行得很好。事实上,上传操作无法通信失败或成功。

为了解决这些问题,我们需要一种方式让 GUI 和模型线程进行通信,以便上传线程可以将错误或进度消息发送回主线程以适当处理。我们可以使用队列来实现这一点。

队列对象

Python 的 queue.Queue 类提供了一个先进先出FIFO)的数据结构。Python 对象可以使用 put() 方法放入 Queue 对象中,并使用 get() 方法检索;要查看这是如何工作的,请在 Python shell 中执行以下操作:

>>> from queue import Queue
>>> q = Queue()
>>> q.put('My item')
>>> q.get()
'My item' 

这可能看起来并不特别令人兴奋;毕竟,你可以用 list 对象做同样的事情。然而,使 Queue 有用之处在于它是线程安全的。一个线程可以将消息放置在队列上,另一个线程可以检索它们并相应地做出反应。

默认情况下,队列的 get() 方法将阻塞执行,直到接收到一个项目。这种行为可以通过将 False 作为其第一个参数传递或使用 get_nowait() 方法来改变。在不等待模式下,该方法将立即返回,如果队列为空,则抛出异常。

要查看这是如何工作的,请在 shell 中执行以下操作:

>>> q = Queue()
>>> q.get_nowait()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.9/queue.py", line 199, in get_nowait
    return self.get(block=False)
  File "/usr/lib/python3.9/queue.py", line 168, in get
    raise Empty
_queue.Empty 

我们还可以使用 empty()qsize() 方法检查队列是否为空;例如:

>>> q.empty()
True
>>> q.qsize()
0
>>> q.put(1)
>>> q.empty()
False
>>> q.qsize()
1 

如你所见,empty() 返回一个布尔值,表示队列是否为空,而 qsize() 返回队列中的项目数量。Queue 类还有其他一些在更高级的多线程情况下有用的方法,但 get()put()empty() 将足以解决我们的问题。

使用队列在线程之间进行通信

在编辑我们的应用程序代码之前,让我们创建一个简单的示例应用程序,以确保我们理解如何使用 Queue 在线程之间进行通信。

从一个长时间运行的线程开始:

# threading_queue_demo.py
from threading import Thread
from time import sleep
class Backend(Thread):
  def __init__(self, queue, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self.queue = queue
  def run(self):
    self.queue.put('ready')
    for n in range(1, 5):
      self.queue.put(f'stage {n}')
      print(f'stage {n}')
      sleep(2)
    self.queue.put('done') 

Backend 对象是 Thread 的一个子类,它接受一个 Queue 对象作为参数,并将其保存为实例属性。它的 run() 方法使用 print()sleep() 模拟一个长时间运行的四阶段过程。在开始、结束和每个阶段之前,我们使用 queue.put() 将状态消息放入队列模块。

现在,我们将使用 Tkinter 为此过程创建一个前端:

# threading_queue_demo.py
import tkinter as tk
from queue import Queue
class App(tk.Tk):
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self.status = tk.StringVar(self, value='ready')
    tk.Label(self, textvariable=self.status).pack()
    tk.Button(self, text="Run process", command=self.go).pack()
    self.queue = Queue() 

这个简单的应用程序包含一个绑定到 status 控制变量的 Label 对象,一个绑定到回调方法 go()Button 小部件,以及存储为实例变量的 Queue 对象。想法是,当我们点击 运行进程 按钮时,go() 方法将运行我们的 Backend 类,并通过 status 控制变量将队列中的消息显示在标签上。

让我们创建 go() 方法:

 def go(self):
    p = Backend(self.queue)
    p.start() 

go() 方法创建 Backend 类的一个实例,传入应用程序的 Queue 对象,并启动它。因为现在两个线程都有一个对 queue 的引用,我们可以用它来在它们之间进行通信。我们已经看到 Backend 如何将状态消息放置在队列上,那么 App() 应该如何检索它们呢?

也许我们可以启动一个循环,如下所示:

 def go(self):
    p = Backend(self.queue)
    p.start()
    while True:
      status = self.queue.get()
      self.status.set(status)
      if status == 'done':
        break 

当然,这不会起作用,因为循环会阻塞;Tkinter 事件循环会卡在执行 go() 上,冻结 GUI,并违背使用第二个线程的目的。相反,我们需要一种方法来定期轮询 queue 对象以获取状态消息,并在收到消息时更新状态。

我们将首先编写一个可以检查队列并相应响应的方法:

 def check_queue(self):
    msg = ''
    while not self.queue.empty():
      msg = self.queue.get()
      self.status.set(msg) 

使用 Queue.empty() 方法,我们首先找出队列是否为空。如果是,我们不想做任何事情,因为默认情况下 get() 会阻塞,直到它收到消息,我们不希望阻塞执行。如果 queue 对象包含项目,我们将想要获取这些项目并将它们发送到我们的 status 变量。我们正在使用 while 循环这样做,这样我们只有在队列为空时才离开函数。

当然,这只会执行一次检查;我们希望继续轮询队列模块,直到线程发送 done 消息。因此,如果我们的状态不是 done,我们需要调度另一个队列检查。

这可以通过在 check_queue() 的末尾调用 after() 来完成,如下所示:

 if msg != 'done':
      self.after(100, self.check_queue) 

现在 check_queue() 将执行其工作,然后每 100 毫秒调度自己再次运行,直到状态为 done。剩下的只是在 go() 的末尾启动进程,如下所示:

 def go(self):
    p = Backend(self.queue)
    p.start()
    **self.check_queue()** 

如果你运行这个应用程序,你会看到我们能够实时地(相对地)获得状态消息。与我们在本章早期创建的单线程应用程序不同,即使在任务运行时,也没有冻结。

向我们的线程上传器添加通信队列

让我们应用我们对队列的知识来解决ThreadedUploader类的问题。首先,我们将更新初始化器签名,以便我们可以传入一个Queue对象,然后将其存储为实例属性,如下所示:

# models.py, in ThreadedUploader
  def __init__(
    self, session_cookie, files_url, filepath, **queue**
  ):
  # ...
  **self.queue = queue** 

正如我们在示例应用程序中所做的那样,我们将在CorporateRestModel对象中创建Queue对象,以便上传者和模型都可以引用它。此外,我们将保存队列作为模型的公共属性,以便应用程序对象也可以引用它。为此,我们首先需要将Queue导入到models.py中,所以请在顶部添加此导入:

# models.py, at the top
from queue import Queue 

现在,回到CorporateRestModel初始化器中,创建一个Queue对象:

# models.py, inside CorporateRestModel
  def __init__(self, base_url):
    #...
    **self.queue = Queue()** 

接下来,我们需要更新upload_file()方法,以便它将队列传递给ThreadedUploader对象:

 def upload_file(self, filepath):
    cookie = self.session.cookies.get('session')
    uploader = ThreadedUploader(
      cookie, self.files_url, filepath, **self.queue**
    )
    uploader.start() 

现在,GUI 可以从rest_model.queue访问队列,我们可以使用这个连接从我们的上传线程向 GUI 发送消息。然而,在我们可以使用这个连接之前,我们需要开发一个通信协议。

创建通信协议

现在我们已经建立了一个线程间通信的通道,我们必须决定我们的两个线程将如何通信。换句话说,上传线程将确切地在队列上放置什么,以及我们的应用程序线程应该如何响应它?我们可以在队列中随意放入任何东西,并在应用程序端继续编写if语句来处理出现的任何内容,但更好的方法是通过对定义一个简单的协议来标准化通信。

我们的上传线程将主要发送状态相关信息回应用程序,以便它可以在消息框或状态栏上显示正在发生的事情的更新。我们将创建一个消息格式,我们可以使用它来确定线程正在做什么,并将此信息传达给用户。

消息结构将看起来像这样:

字段 描述
status 表示消息类型的单个单词,例如 info 或 error
subject 总结消息的简短句子
body 包含关于消息详细信息的较长的字符串

我们可以使用字典或类创建这样的结构,但像这样简单的命名字段集合是一个很好的用例。collections.namedtuple()函数允许我们快速创建只包含命名属性的迷你类。

创建namedtuple类的样子如下:

from collections import namedtuple
MyClass = namedtuple('MyClass', ['prop1', 'prop2']) 

这相当于编写:

class MyClass():
  def __init__(self, prop1, prop2):
    self.prop1 = prop1
    self.prop2 = prop2 

namedtuple()方法比创建一个类要快得多,并且与字典不同,它强制统一性——也就是说,每个MyClass对象都必须有prop1prop2属性,而字典从不要求有特定的键。

models.py文件的顶部,让我们导入namedtuple并使用它来定义一个名为Message的类:

# models.py, at the top
from collections import namedtuple
Message = namedtuple('Message', ['status', 'subject', 'body']) 

现在我们已经创建了Message类,创建一个新的Message对象就像创建任何其他类的实例一样:

message = Message(
  'info', 'Testing the class', 
  'We are testing the Message class'
) 

让我们在队列中实现这些Message对象的使用。

从上传器发送消息

现在我们已经建立了一个协议,是时候将其付诸实践了。定位ThreadedUploader类,让我们更新run()方法以发送消息,从信息性消息开始:

# models.py, in ThreadedUploader
  def run(self, *args, **kwargs):
    self.queue.put(
      Message(
        'info', 'Upload Started', 
        f'Begin upload of {self.filepath}'
      )
    ) 

我们的第一条消息只是一个信息性消息,表明上传开始。接下来,我们将开始上传并返回一些指示操作成功或失败的消息:

 with open(self.filepath, 'rb') as fh:
      files = {'file': fh}
      response = self.session.put(
        self.files_url, files=files
      )
    try:
      response.raise_for_status()
    except Exception as e:
      self.queue.put(Message('error', 'Upload Error', str(e)))
    else:
      self.queue.put(
        Message(
          'done',  'Upload Succeeded',
          f'Upload of {self.filepath} to REST succeeded'
        )
      ) 

如前所述,我们通过打开文件并向网络服务发出PUT请求开始上传过程。这次,我们在try块中运行raise_for_status()。如果操作中捕获到异常,我们在队列中放置一个状态为error的消息以及异常的文本。如果我们成功,我们在队列中放置一个成功消息。

这就是我们的ThreadedUploader需要做的;现在我们需要转向 GUI 以实现对这些消息的响应。

处理队列消息

Application对象中,我们需要添加一些代码来监控队列,并在从线程发送消息时采取适当的行动。正如我们在队列演示应用程序中所做的那样,我们将创建一个方法,使用 Tkinter 事件循环定期轮询队列并处理从模型的队列对象发送的任何消息。

这样启动Application._check_queue()方法:

# application.py, inside Application
  def _check_queue(self, queue):
    while not queue.empty():
      item = queue.get() 

该方法接受一个Queue对象,首先检查它是否有任何项目。如果有,它检索一个。一旦我们有一个,我们需要检查它并根据status值确定如何处理它。

首先,让我们处理一个done状态;在if块下添加此代码:

# application.py, inside Application._check_queue()
      if item.status == 'done':
        messagebox.showinfo(
          item.status,
          message=item.subject,
          detail=item.body
        )
        self.status.set(item.subject)
        return 

当我们的上传成功完成时,我们想要显示一个消息框并设置状态,然后返回而不做其他任何事情。

Message对象的statussubjectbody属性很好地映射到消息框的titlemessagedetail参数,所以我们直接将它们传递给它。我们还通过设置status变量在应用程序的状态栏中显示消息的主题。

接下来,我们将处理队列中的error消息:

 elif item.status == 'error':
        messagebox.showerror(
          item.status,
          message=item.subject,
          detail=item.body
        )
        self.status.set(item.subject)
        return 

再次显示一个消息框,这次使用showerror()。我们还想退出方法,因为线程可能已经退出,我们不需要安排下一次队列检查。

最后,让我们处理info状态:

 else:
        self.status.set(f'{item.subject}: {item.body}') 

信息性消息并不真正需要模态消息框,所以我们只是将它们发送到状态栏。

在这个方法中,我们最后需要确保如果线程仍在运行,它会被再次调用。由于doneerror消息会导致方法返回,如果我们已经到达函数的这个点,线程仍在运行,我们应该继续轮询它。因此,我们将添加一个对after()的调用:

 self.after(100, self._check_queue, queue) 

_check_queue()编写完成后,我们只需要消除_upload_to_corporate_rest()末尾围绕rest_model.upload_file()的异常处理,并调用_check_queue()代替:

# application.py, in Application._upload_to_corporate_rest()
        rest_model.upload_file(csvfile)
        self._check_queue(self.rest_queue) 

这个调用不需要使用after()来调度,因为第一次调用很可能没有消息,导致_check_queue()只是调度其下一次调用并返回。

现在我们已经完成了更新,启动测试服务器和应用程序,再次尝试 REST 上传。观察状态栏,你会看到进度条被显示出来,当过程完成时会显示一个消息框。尝试关闭 HTTP 服务器,你应该会立即看到一个错误消息弹出。

使用锁来保护共享资源

虽然我们的应用程序在慢速文件上传期间不再冻结是件好事,但它也引发了一个潜在的问题。假设用户在第一个上传正在进行时尝试启动第二个 REST 上传?继续尝试这个操作;启动示例 HTTP 服务器和应用程序,并尝试快速连续启动两个 REST 上传,以便第二个在上一个完成之前开始。注意 REST 服务器的输出;根据你的时间,你可能会看到一些令人困惑的日志消息,百分比上下波动,因为两个线程同时上传文件。

当然,我们的示例 REST 服务器只是使用sleep()模拟慢速链接;实际的文件上传发生得非常快,不太可能引起问题。在真正慢速网络的情况下,并发上传可能会更成问题。虽然接收服务器可能足够健壮,可以合理地处理两个尝试上传相同文件的线程,但最好我们一开始就避免这种情况。

我们需要一种某种类型的标志,它可以在线程之间共享,以指示一个线程是否正在上传,这样其他线程就会知道不要这样做。我们可以使用threading模块的Lock对象来实现这一点。

理解锁对象

锁是一个非常简单的对象,有两个状态:获取释放。当Lock对象处于释放状态时,任何线程都可以调用它的acquire()方法将其置于获取状态。一旦一个线程获取了锁,acquire()方法将阻塞,直到通过调用其release()方法释放锁。这意味着如果另一个线程调用acquire(),它的执行将等待直到第一个线程释放锁。

要了解这是如何工作的,请查看本章前面创建的basic_threading_demo.py脚本。从终端提示符运行该脚本,在Entry小部件中输入一个句子,然后点击运行线程化按钮。

正如我们之前提到的,句子以每秒一个单词的速度打印到终端输出。但现在,连续两次快速点击运行线程按钮。注意,输出是一团糟的重复单词,因为两个线程同时向终端输出文本。你可以想象在类似的情况下,多个线程会对文件或网络会话造成多大的破坏。

为了纠正这个问题,让我们创建一个锁。首先,从threading模块导入Lock并创建一个实例:

# basic_threading_demo_with_lock.py
from threading import Thread, Lock
print_lock = Lock() 

现在,在print_slowly()函数内部,让我们在方法周围添加对acquire()release()的调用,如下所示:

def print_slowly(string):
  print_lock.acquire()
  words = string.split()
  for word in words:
    sleep(1)
    print(word)
  print_lock.release() 

将此文件保存为basic_threading_demo_with_lock.py并再次运行。现在,当你多次点击运行线程按钮时,每次运行都会等待前一个运行释放锁后再开始。这样,我们可以在保持应用程序响应的同时强制线程相互等待。

Lock对象也可以用作上下文管理器,这样在进入块时调用acquire(),在退出时调用release()。因此,我们可以将前面的示例重写如下:

 with print_lock:
    words = string.split()
    for word in words:
      sleep(1)
      print(word) 

使用锁对象防止并发上传

让我们将对Lock对象的理解应用到防止对公司的 REST 服务器并发上传。首先,我们需要将Lock导入到models.py中,如下所示:

from threading import Thread, **Lock** 

接下来,我们将创建一个Lock对象作为ThreadedUploader类的类属性,如下所示:

class ThreadedUploader(Thread):
  **rest_upload_lock = Lock()** 

回想一下第四章使用类组织代码,分配给类属性的实例是共享的。因此,通过将锁作为类属性创建,任何ThreadedUploader线程都可以访问这个锁。

现在,在run()方法内部,我们需要使用我们的锁。最干净的方法是将其用作上下文管理器,如下所示:

# models.py, inside ThreadedUploader.run()
    with self.upload_lock:
      with open(self.filepath, 'rb') as fh:
        files = {'file': fh}
        response = self.session.put(
          self.files_url, files=files
        )
        #... remainder of method in this block 

无论put()调用是返回还是抛出异常,上下文管理器都会确保在块退出时调用release(),以便其他对run()的调用可以获取锁。

添加此代码后,再次运行测试 HTTP 服务器和应用程序,并尝试快速连续启动两个 REST 上传。现在你应该会看到第二个上传直到第一个完成才启动。

线程和 GIL

每当我们讨论 Python 中的线程时,了解 Python 的全局解释器锁(GIL)及其对线程的影响是非常重要的。

GIL 是一种锁机制,通过防止多个线程同时执行 Python 命令来保护 Python 的内存管理。类似于我们在ThreadedUploader类中实现的锁,GIL 可以被看作是一个只能由一个线程一次持有的令牌;持有令牌的线程可以执行 Python 指令,其余的则必须等待。

这可能看起来像是违背了 Python 多线程的理念,然而,有两个因素可以减轻 GIL 的影响:

  • 首先,GIL 只限制 Python 代码的执行;许多库在其它语言中执行代码。例如,Tkinter 执行 TCL 代码,而 psycopg2 执行编译后的 C 代码。这类非 Python 代码可以在单独的线程中运行,同时 Python 代码在另一个线程中运行。

  • 其次,输入/输出I/O)操作,如磁盘访问或网络请求,可以与 Python 代码并发运行。例如,当我们使用 requests 发起 HTTP 请求时,在等待服务器响应的过程中,全局解释器锁(GIL)会被释放。

GIL 真正限制多线程效用的情况是当我们有计算密集型的 Python 代码时。在典型的以数据为导向的应用程序(如 ABQ)中的慢速操作很可能是 I/O 基于的操作,对于计算密集型的情况,我们可以使用非 Python 库,如 numpy。即便如此,了解 GIL 并知道它可能会影响多线程设计的有效性仍然是好的。

摘要

在本章中,你学习了如何使用异步和多线程编程技术从你的程序中移除无响应行为。你学习了如何使用 after()update() 方法与 Tkinter 的事件队列进行交互和控制,以及如何将这些方法应用于解决应用程序中的问题。你还学习了如何使用 Python 的 threading 模块在后台运行进程,以及如何利用 Queue 对象在线程之间进行通信。最后,你学习了如何使用 Lock 对象防止共享资源被破坏。

在下一章中,我们将探索 Tkinter 中最强大的小部件:Canvas。我们将学习如何绘制图像和动画化它们,以及创建有用和富有信息量的图表。

第十五章:使用 Canvas 小部件可视化数据

在数据库中记录了几个月的实验数据后,是时候开始可视化和解释这些数据的过程了。与其将数据导出到电子表格中创建图表和图形,你的同事分析师们询问程序本身是否可以创建图形数据可视化。确实可以!为了实现这个功能,你需要了解 Tkinter 的Canvas小部件。

在本章中,你将在学习以下主题的同时实现数据可视化:

  • 在“使用 Tkinter 的 Canvas 进行绘制和动画”中,你将学习如何使用Canvas小部件进行绘制和动画

  • 在“使用 Canvas 创建简单图表”中,我们将使用 Tkinter Canvas构建一个简单的线形图

  • 在“使用 Matplotlib 创建高级图表”中,我们将学习如何集成 Matplotlib 库以获得更强大的图表和图形功能

使用 Tkinter 的 Canvas 进行绘制和动画

Canvas小部件无疑是 Tkinter 中最强大的小部件之一。它可以用来构建从自定义小部件和视图到完整用户界面的任何东西。

正如其名所示,Canvas小部件是一个空白区域,可以在其上绘制图形和图像。为了理解其基本用法,让我们创建一个小型演示脚本。

通过创建根窗口和Canvas对象开始脚本:

# simple_canvas_demo.py
import tkinter as tk
root = tk.Tk()
canvas = tk.Canvas(
  root, background='black',
  width=1024, height=768
)
canvas.pack() 

创建Canvas对象就像创建任何其他 Tkinter 小部件一样。除了父小部件和background参数外,我们还可以指定widthheight参数来设置Canvas的大小。设置Canvas小部件的大小很重要,因为它不仅定义了小部件的大小,还定义了视口;即我们的绘制对象将可见的区域。我们实际上可以在Canvas的几乎无限表面上绘制任何地方,但只有视口内的区域才是可见的。

我们将在“滚动画布”部分学习如何查看视口下方的区域。

在画布上绘制

一旦我们有了Canvas对象,我们就可以开始使用其许多create_()方法在其上绘制项目。这些方法允许我们绘制形状、线条、图像和文本。随着我们开发simple_canvas_demo.py脚本,让我们更详细地探讨这些方法。

矩形和正方形

可以使用create_rectangle()方法在Canvas上绘制矩形或正方形,如下所示:

# simple_canvas_demo.py
canvas.create_rectangle(240, 240, 260, 260, fill='orange') 

create_rectangle()的前四个参数是上左角和下右角的坐标,从Canvas的上左角开始计算像素。每个create_()方法都以位置参数开始,这些参数定义了形状的位置和大小。随后,我们可以指定各种关键字参数来描述形状的其他方面;例如,这里使用的fill选项指定了对象内部的颜色。

理解Canvas上的垂直坐标非常重要,与典型图表上的坐标不同,它们是从顶部向下延伸的。例如,坐标(200, 100)比(200, 200)高 100 像素。对于所有 Tkinter 小部件上的坐标,以及许多其他 GUI 编程环境中的坐标也是如此。

坐标也可以指定为元组对,如下所示:

canvas.create_rectangle(
  (300, 240), (320, 260),
  fill='#FF8800'
) 

虽然这需要更多的字符,但它大大提高了可读性。create_rectangle()方法支持其他几个关键字参数来配置矩形的填充和轮廓,包括以下内容:

参数 描述
dash 整数元组 定义轮廓的虚线模式(见下文)
outline 颜色字符串 指定边框的颜色
width 整数 指定边框的宽度
stipple 位图名称 使用该位图模式进行填充的位图名称

可以在Canvas对象上使用虚线模式定义虚线或点线。这是一个整数元组,描述了在切换线与空白之间的像素数。例如,dash值为(5, 1, 2, 1)将产生一个重复的模式,包括五像素的线、一个空像素、两个像素的线和一个空像素。

stipple值允许您指定用于填充形状的位图,而不是实心填充。Tkinter 自带一些内置的位图文件,例如gray75gray50gray25gray12(每个都填充了均匀分布的像素,百分比由指定),或者您可以使用格式@filename.xbm加载自己的.xbm文件。

椭圆、圆形和圆弧

除了矩形外,我们还可以使用create_oval()方法创建椭圆和圆形。以下是将椭圆添加到演示中的方法:

canvas.create_oval(
  (350, 200), (450, 250), fill='blue'
) 

与创建矩形类似,我们首先指定坐标来描述形状;然而,这次坐标决定了其边界框的左上角和右下角。边界框是包含一个项目的最小矩形。例如,在这个椭圆的情况下,边界框的角位于(350, 200)(450, 250)。要画一个圆形,当然,我们只需定义一个具有正方形边界框的椭圆。

create_oval()允许与create_rectangle()相同的关键字参数来配置形状的填充和轮廓。

如果我们只想画椭圆的一部分,可以使用create_arc()方法。此方法与create_oval()工作方式相同,但也接受extentstart关键字参数。start参数指定从圆的左中点起原点到绘图开始点的角度数,而extent参数指定逆时针延伸的角度数。例如,extent90start180将绘制从右侧开始到底部的四分之一椭圆,如图所示:

图 15.1:绘制圆弧

让我们在我们的演示中添加一个圆弧:

canvas.create_arc(
  (100, 200), (200, 300),
  fill='yellow', extent=315, start=25
) 

线条

我们还可以使用create_line()方法在Canvas上绘制线条。与矩形、椭圆和圆弧一样,我们首先指定坐标来定义线条。与形状不同,坐标不定义边界框,而是定义一组定义线条的点。

让我们在我们的演示脚本中添加一条线,如下所示:

canvas.create_line(
  (0, 180), (1024, 180),
  width=5, fill='cyan'
) 

在这个例子中,将绘制一条从第一个点(0, 180)到第二个点(1024, 180)的直线。在这种情况下,fill参数定义了线的颜色,而width决定了它的宽度。

create_line()方法不仅限于两点之间的一条线。我们可以指定任意数量的坐标对作为位置参数,Tkinter 将从第一个到最后一个连接它们。例如,将以下内容添加到演示中:

canvas.create_line(
  (0, 320), (500, 320), (500, 768), (640, 768),
  (640, 320), (1024, 320),
  width=5, fill='cyan'
) 

这次我们创建了一条包含六个点的更复杂的线。

这里显示了create_line()的一些附加参数:

参数 描述
arrow FIRSTLASTBOTH 如果指定,将在线条的末端绘制箭头。默认值为无值,表示没有箭头。
capstyle BUTTPROJECTINGROUND 指定线条末端的样式。默认为BUTT
dash 整数元组 定义线的虚线样式。
joinstyle ROUNDBEVELMITER 指定角落连接的样式。默认为ROUND
smooth 布尔值 是否用样条曲线绘制线条。默认为False(直线)。
tags 字符串元组 可分配给线条的任意数量的标签。

多边形

Canvas还允许我们绘制任意多边形;它的工作方式与线条类似,其中每个坐标定义一个点,该点将被连接以绘制多边形的轮廓。区别在于最后一个点和第一个点也将被连接以形成一个封闭形状。

按如下方式将多边形添加到我们的演示脚本中:

canvas.create_polygon(
  (350, 225), (350,  300), (375, 275), (400, 300),
  (425, 275), (450, 300), (450, 225),
  fill='blue'
) 

注意,与create_line()不同,fill参数定义的是多边形内部的颜色,而不是轮廓的颜色。多边形轮廓的外观可以通过与create_rectangle()create_oval()相同的参数进行配置。

文本

除了简单的形状,我们还可以直接在Canvas上放置文本。

例如,让我们在我们的演示中添加一些文本:

canvas.create_text(
  (500, 100), text='Insert a Quarter',
  fill='yellow', font='TkDefaultFont 64'
) 

单一坐标参数确定文本将锚定到Canvas上的位置。默认情况下,文本以其自身的中心点锚定到锚点。在这种情况下,这意味着我们字符串的中间(大约在“a”的位置)将在x=500y=100。然而,anchor参数可以用来指定文本项的哪一部分被锚定到锚点;它可以是指定的任何基本方向常量(NNWW等)或CENTER,这是默认值。

在这种情况下,fill 参数确定文本的颜色,我们可以使用 font 来确定文本的字体属性。Tkinter 8.6 及以后的版本还提供了一个 angle 参数,可以旋转文本指定的角度。

图像

当然,我们不仅限于在 Canvas 上绘制线条和简单的形状;我们还可以使用 create_image() 方法放置位图图像。此方法允许我们在 Canvas 上放置 PhotoImageBitmapImage 对象,如下所示:

# simple_canvas_demo.py
smiley = tk.PhotoImage(file='smile.gif')
canvas.create_image((570, 250), image=smiley) 

与文本一样,图像默认情况下通过其中心锚定坐标连接,但可以使用 anchor 参数将其更改为图像边界框的任何一边或角。

Tkinter 小部件

我们可以在 Canvas 上放置的最后一件东西是另一个 Tkinter 小部件。当然,由于 Canvas 是一个小部件,我们可以使用 pack()grid() 这样的布局管理器来做到这一点,但如果我们将它作为 Canvas 项目使用 create_window() 添加,我们会获得更多的控制。

要使用 create_window() 添加小部件,该小部件只需是 Canvas 小部件同一父窗口上的小部件的子项。然后我们可以将小部件的引用传递给方法的 window 参数。我们还可以指定 widthheight 参数来确定要添加小部件的窗口区域的大小;小部件将默认扩展到该区域。

例如,让我们向演示中添加一个退出按钮:

quit = tk.Button(
  root, text='Quit', bg='black', fg='cyan', font='TkFixedFont 24',
  activeforeground='black', activebackground='cyan',  
  command=root.quit
)
canvas.create_window((100, 700), height=100, width=100, window=quit) 

就像文本和图像一样,小部件默认情况下通过其中心锚定到给定的坐标,可以使用 anchor 参数将其锚定到一边或角。

Canvas 项目和状态

注意上述代码示例中 activeforegroundactivebackground 参数的使用。就像小部件一样,Canvas 项目可以设置各种状态,这些状态可以用来动态改变外观。下表显示了项目的可用状态及其结果:

状态 触发 结果
normal 默认 正常外观
disabled 手动设置 禁用外观
active 鼠标悬停 活跃外观
hidden 手动设置 不显示

所有绘制的项目(即不是图像的项目)都有基于状态的 filloutlinedashwidthstippleoutlinestipple 参数的版本,这些参数只是前面加上 activedisabled 的参数。例如,activefill 在项目被鼠标悬停时设置 fill 值,而 disabledoutline 在项目设置为 disabled 状态时设置轮廓颜色。图像项目有 disabledimageactiveimage 参数,可以在项目禁用或活跃时显示不同的图像。

当鼠标悬停在项目上时,会自动设置 active 状态;可以使用 Canvas.itemconfigure() 方法设置 disabledhidden 状态,该方法将在下面的 Canvas 对象方法 部分中讨论。

Canvas 对象方法

Canvas 项目不是由一个 Python 对象表示;相反,任何 create_() 方法的返回值都是一个整数,它唯一地标识了在 Canvas 对象上下文中该项目。为了在创建后操作 Canvas 项目,我们需要保存该标识值并将其传递给各种 Canvas 方法。

例如,我们可以保存我们添加的图像的 ID,然后使用 Canvas.tag_bind() 方法将图像绑定到一个回调:

# simple_canvas_demo.py
**image_item =** canvas.create_image((570, 250), image=smiley)
**canvas.tag_bind(**
 **image_item,**
**'<Button-1>'****,**
**lambda** **e: canvas.delete(image_item)**
**)** 

在这里,我们使用了 tag_bind() 方法将我们的图像对象上的左键单击绑定到 Canvasdelete() 方法,该方法(当给定项目标识符时)会删除该项目。

Canvas 对象具有许多可以对 Canvas 项目执行操作的方法;其中一些更有用的方法列在这个表中:

方法 参数 描述
bbox() 项目 ID 返回一个描述项目边界的元组。
coords() 项目 ID,坐标 如果只提供 ID,则返回项目的坐标。否则,将项目移动到给定的坐标。
delete() 项目 ID Canvas 中删除项目。
find_overlapping() 矩形坐标 返回一个列表,包含与由坐标描述的矩形重叠的项目 ID。
itemcget() 项目 ID,选项 返回给定项目的 option 值。
itemconfigure() 项目 ID,选项 在指定的项目上设置一个或多个配置选项。
move() 项目 ID,X,Y 将项目在 Canvas 上相对于其当前位置移动给定 XY 的量。
type() 项目 ID 返回一个描述对象类型(矩形、椭圆、弧等)的字符串。

注意,任何接受项目 ID 的这些方法也可以接受一个 标签。回想一下 第九章通过样式和主题改进外观,标签只是一个字符串,可以在创建项目时分配给它,允许我们一次引用多个项目。Canvas 默认有两个内置标签,allcurrent。正如你所期望的,all 指的是 Canvas 上的所有项目,而 current 指的是当前具有焦点的项目。

所有 create_() 方法都允许指定一个字符串元组,将其附加到对象上。

顺便说一下,如果你还没有这样做,请将 root.mainloop() 添加到演示脚本中并执行它,以查看我们绘制了什么!

滚动画布

如前所述,Canvas 小部件的宽度和高度决定了视口的大小,但实际的绘制区域在部件的所有方向上无限延伸。要实际看到视口区域外的对象,我们需要启用滚动。

要了解这是如何工作的,让我们创建一个可滚动的星系;打开一个名为 canvas_scroll.py 的新文件,并开始如下:

# canvas_scroll.py
import tkinter as tk
from random import randint, choice
# Create root and canvas
root = tk.Tk()
width = 1024
height = 768
canvas = tk.Canvas(
  root, background='black',
  width=width, height=height,
)
canvas.grid(row=0, column=0) 

在这里,我们导入了 tkinterrandom 的一些函数,然后创建了一个根窗口和一个具有 1024x768 视口大小的 Canvas 对象。最后,我们使用 grid()Canvas 放置在根窗口上。

现在,让我们绘制一些“星星”:

colors = ['#FCC', '#CFC', '#CCF', '#FFC', '#FFF', '#CFF']
for _ in range(1000):
  x = randint(0, width * 2)
  y = randint(0, height * 2)
  z = randint(1, 10)
  c = choice(colors)
  canvas.create_oval((x - z, y - z), (x + z, y + z), fill=c) 

我们首先定义一个颜色值列表,然后启动一个将迭代 1000 次的for循环。在循环内部,我们将生成随机的XY坐标,一个随机的大小(Z),并随机选择一种颜色。然后,我们将让Canvas在随机点上绘制一个填充随机颜色的圆。

注意,为XY提供的范围是Canvas对象大小的两倍。正因为如此,循环将创建从视口区域右侧和下方延伸出去的圆。

要启用Canvas的滚动,我们首先必须为它定义一个scrollregion值,如下所示:

canvas.configure(scrollregion=(0, 0, width * 2, height * 2)) 

scrollregion的值是一个包含四个整数的元组,描述了我们想要能够滚动的区域的边界框。本质上,前两个整数是框左上角的XY坐标,后两个是右下角的坐标。

要实际滚动Canvas,我们需要一些Scrollbar小部件。我们在第八章使用 Treeview 和 Notebook 导航记录中已经遇到过这些小部件,记得要使用它们,我们需要创建小部件,将它们添加到布局中,并连接适当的回调函数,以便滚动条可以与被滚动的小部件通信。

将以下代码添加到脚本中:

xscroll = tk.Scrollbar(
  root,
  command=canvas.xview,
  orient=tk.HORIZONTAL
)
xscroll.grid(row=1, column=0, sticky='new')
yscroll = tk.Scrollbar(root, command=canvas.yview)
yscroll.grid(row=0, column=1, sticky='nsw')
canvas.configure(yscrollcommand=yscroll.set)
canvas.configure(xscrollcommand=xscroll.set) 

在这里,我们创建了两个Scrollbar小部件,一个用于水平滚动,一个用于垂直滚动。我们将它们分别添加到Canvas下方和右侧的布局中。然后,我们将每个滚动条的command参数连接到Canvasxviewyview方法,并将Canvasyscrollcommandxscrollcommand参数配置为调用相应滚动条的set()方法。

通过调用root.mainloop()来完成此脚本,并执行它;你应该会看到这里所示的内容:

图 15.1:滚动星星!

图 15.2:滚动星星!

Canvas上绘制基于运行时定义的点(例如,基于用户输入)后正确配置滚动区域的一个实用技巧是将scrollregion设置为创建项目后canvas.bbox('all')的输出。当传递一个all标签时,bbox()方法返回一个包含Canvas上所有项目全部内容的边界框。您可以直接将此值设置为scrollregion,以确保您可以看到所有项目。

动画 Canvas 对象

Tkinter 的Canvas小部件没有内置的动画框架,但我们可以通过结合其move()方法与我们对事件队列的理解来创建简单的动画。

为了演示这一点,我们将创建一个虫子赛跑模拟器,其中两个虫子(用彩色圆圈表示)将随意向屏幕另一侧的终点线冲刺。像真正的虫子一样,它们不会意识到自己在比赛中,并且会相对随机地移动,获胜者是偶然第一个撞到终点线的虫子。

首先,打开一个新的 Python 文件,并从一个基本的面向对象模式开始,如下所示:

# bug_race.py
import tkinter as tk
class App(tk.Tk):
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)
    self.canvas = tk.Canvas(self, background='black')
    self.canvas.pack(fill='both', expand=1)
    self.geometry('800x600')
App().mainloop() 

这只是一个简单的 OOP Tkinter 模板应用程序,其中添加了一个 Canvas 对象到根窗口。这将是我们将构建游戏代码的基本平台。

设置比赛场地

现在我们有了基本框架,让我们设置比赛场地。我们希望在每一轮之后能够重置比赛场地,因此我们不会在初始化器中这样做,而是创建一个单独的方法,称为 setup(),如下所示:

 def setup(self):
    self.canvas.left = 0
    self.canvas.top = 0
    self.canvas.right = self.canvas.winfo_width()
    self.canvas.bottom = self.canvas.winfo_height()
    self.canvas.center_x = self.canvas.right // 2
    self.canvas.center_y = self.canvas.bottom // 2
    self.finish_line = self.canvas.create_rectangle(
      (self.canvas.right - 50, 0),
      (self.canvas.right, self.canvas.bottom),
      fill='yellow', stipple='gray50'
    ) 

setup() 方法首先在 Canvas 对象上计算一些相对位置,并将它们保存为实例属性,这将简化在 Canvas 对象上放置对象的过程。在运行时计算这些位置意味着我们可以在回合之间调整窗口大小,以适应更长或更短的赛道。

终点线实现为一个横跨窗口右侧的矩形。注意使用 stipple 参数指定一个位图,该位图将叠加到纯色上,以给它一些纹理;在这种情况下,gray50 是一个内置的位图,交替填充和不填充像素。这让我们得到一个比纯色更有趣的东西。

App.__init__() 的末尾添加对 setup() 的调用,如下所示:

# bug_race.py, in App.__init__()
        self.canvas.wait_visibility()
        self.setup() 

因为 setup() 依赖于 Canvas 对象的宽度和高度值,我们需要确保它不会在操作系统的窗口管理器绘制和调整窗口大小之前被调用。最简单的方法是在 Canvas 对象上调用 wait_visibility(),这将阻塞执行,直到对象被绘制。

设置我们的玩家

现在我们有了比赛场地,我们需要创建我们的玩家。我们将创建一个 Racer 类来表示一个玩家;如下启动它:

# bug_race.py
class Racer:
  def __init__(self, canvas, color):
    self.canvas = canvas
    self.name = f"{color.title()} player"
    size = 50
    self.id = canvas.create_oval(
      (canvas.left, canvas.center_y),
      (canvas.left + size, canvas.center_y + size),
      fill=color
    ) 

Racer 类将通过引用 Canvas 对象和一个颜色字符串来创建,其颜色和名称将从该字符串中派生。我们将最初在屏幕的左中位置绘制赛车,并使其大小为 50 像素。最后,我们将其项目 ID 字符串的引用保存到 self.id 中。

现在,回到 App.setup(),我们将通过添加以下内容创建两个赛车:

# bug_race.py, in App.setup()
    self.racers = [
      Racer(self.canvas, 'red'),
      Racer(self.canvas, 'green')
    ] 

到目前为止,我们游戏中的所有对象都已设置。运行程序,你应该在右侧看到一个带有黄色斑点的终点线,在左侧看到一个绿色圆圈(红色圆圈将隐藏在绿色圆圈下面,因为它们位于相同的坐标)。

动画赛车

为了动画赛车,我们将使用 Canvas.move() 方法。正如我们之前所学的,move() 接收一个项目 ID、一个 X 像素数和一个 Y 像素数,并将项目移动这个量。通过结合这个方法与 random.randint() 函数和一些简单的逻辑,我们可以生成一系列移动,将每辆赛车引导到终点线的蜿蜒路径上。

一个简单的实现可能看起来像这样:

from random import randint
# inside Racer
  def move_racer(self):
    x = randint(0, 100)
    y = randint(-50, 50)
    t = randint(500, 2000)
    self.canvas.after(t, self.canvas.move, self.id, x, y)
    if self.canvas.bbox(self.id)[0] < self.canvas.right:
      self.canvas.after(t, self.move_racer) 

这种方法生成一个随机的向前X移动,一个随机的上下Y移动,以及一个随机的时间间隔。然后我们使用after()方法在随机时间间隔后安排对move()的调用,以生成XY移动。if语句确定赛车者的边界框是否当前位于屏幕的右侧或更远;如果此测试结果为False,我们将安排对move_racer()的另一次调用。

此方法将赛车带到终点线,但这并不是我们想要的。问题是move()是瞬间发生的,导致错误在屏幕上跳跃,而不是平滑移动。

为了使虫子移动得更平滑,我们需要采取更复杂的方法:

  1. 首先,我们将计算一系列线性移动,每个移动都有一个随机的delta xdelta y和间隔,这将达到终点线

  2. 然后,我们将每个单独的动作分解成由将移动间隔除以常规动画帧间隔确定的步骤数

  3. 接下来,我们将每个动作的每一步添加到队列中

  4. 最后,我们将在每个动画帧间隔调用一个方法,该方法将从队列中拉取下一个步骤并将其传递给move()

让我们先定义我们的帧间隔;在Racer类中,创建一个类属性来表示这个:

class Racer:
  FRAME_RES = 50 

FRAME_RES(代表帧分辨率)定义了每个Canvas.move()调用之间的毫秒数。50 毫秒给我们 20 帧每秒,应该足够平滑地移动。

接下来,我们需要导入Queue类并在Racer对象的初始化器中创建一个实例:

# bug_race.py, at top
from queue import Queue
# inside Racer.__init__()
    self.movement_queue = Queue() 

现在,我们将创建一个绘制到终点线路线的方法:

# bug_race.py, inside Racer
  def plot_course(self):
    start_x = self.canvas.left
    start_y = self.canvas.center_y
    total_dx, total_dy = (0, 0)
    while start_x + total_dx < self.canvas.right:
      dx = randint(0, 100)
      dy = randint(-50, 50)
      target_y = start_y + total_dy + dy
      if not (self.canvas.top < target_y < self.canvas.bottom):
        dy = -dy
      total_dx += dx
      total_dy += dy
      time = randint(500, 2000)
      self.queue_move(dx, dy, time) 

此方法通过生成随机的xy移动,直到x的总变化量大于Canvas对象的宽度,从而在Canvas的左中心绘制一条路线到右侧的随机点。x的变化量始终为正,使我们的虫子向终点线移动,但y的变化量可以是正的或负的,以允许向上和向下移动。为了使我们的虫子保持在屏幕上,我们将通过否定任何将玩家置于Canvas顶部或底部边界之外的y变化来约束总的y移动。

除了随机的dxdy值之外,我们还需要为移动生成一个随机的时间间隔,介于半秒和两秒之间。最后,生成的dxdytime值被传递给queue_move()方法。

queue_move()方法需要将大动作分解成单个动作帧,这些动作帧描述了赛车应该在FRAME_RES间隔内如何移动。为了进行这个计算,我们需要一个分区函数,这是一个数学函数,它将整数N分解成K个大约相等的整数。例如,如果我们想将-10分解成四个部分,我们的函数应该返回一个类似[-2, -2, -3, -3]的列表。

让我们在 Racer 上创建一个名为 partition() 的静态方法:

# bug_race.py, inside Racer
  @staticmethod
  def partition(n, k):
    """Return a list of k integers that sum to n"""
    if n == 0:
      return [0] * k 

我们从简单的情况开始这个方法:当 n0 时,返回一个包含 k 个零的列表。

现在,我们将处理更复杂的情况:

 base_step = n // k
    parts = [base_step] * k
    for i in range(n % k):
      parts[i] += 1
    return parts 

对于非零的 n,我们首先通过使用向下取整将 n 除以 k 来计算 base_step,这会将我们的结果向下取整到最接近的整数。然后,我们创建一个长度为 k 的列表,该列表由 base_step 值组成。接下来,我们需要尽可能均匀地将 n / k 的余数分配到这个列表中。为了完成这个任务,我们将第一个 n % k 项添加到部分列表中。

使用我们的 n = -10k = 4 的例子来遵循这里的数学:

  • 基本步长计算为 -10 / 4 = -3(记住,向下取整总是向下取整,所以 -2.5 被四舍五入到 -3)。

  • 然后,我们创建一个包含四个基本步长值的列表:[-3, -3, -3, -3]

  • -10 % 4 = 2,所以我们向列表中的前两项添加 1

  • 我们得到了一个答案 [-2, -2, -3, -3]。完美!

像这样的分区函数是 离散数学 的组成部分,离散数学是处理整数运算的数学分支。离散数学常用于解决绘图和动画中遇到的空间问题。

现在我们有了分区方法,我们可以编写 queue_move() 方法:

 def queue_move(self, dx, dy, time):
    num_steps = time // self.FRAME_RES
    steps = zip(
      self.partition(dx, num_steps),
      self.partition(dy, num_steps)
    )
    for step in steps:
      self.movement_queue.put(step) 

我们首先通过将时间间隔除以 FRAME_RES 使用向下取整来确定这次移动所需的步数。然后,我们通过将 dxdy 分别传递给我们的 partition() 方法来创建一个 X 移动列表和一个 Y 移动列表。这两个列表通过 zip() 组合形成一个单一的 (dx, dy) 对列表,我们迭代这个列表,将每一对添加到动画队列中。

要使动画真正发生,我们需要一个方法来检查队列并执行每个移动;我们将称之为 next_move()

 def next_move(self):
    if not self.movement_queue.empty():
      nextmove = self.movement_queue.get()
      self.canvas.move(self.id, *nextmove) 

next_move() 方法首先检查队列中是否有移动步骤。如果有,则使用赛车者的 ID 和步骤的 XY 值调用 canvas.move()。当游戏开始时,此方法将从 App 对象中反复调用,直到其中一名赛车者获胜。

最后,我们需要将 plot_course() 调用添加到 Racer 类的初始化器中,如下所示:

# bug_race.py, at the end of Racer.__init__()
    self.plot_course() 

因此,一旦创建了一个 Racer 对象,它就会绘制到终点的赛道,并等待 App 类告诉它移动。

运行游戏循环并检测获胜条件

要实际运行游戏,我们需要启动一个游戏循环。当然,我们知道从 第十四章使用线程和队列进行异步编程,我们不能简单地使用 Python 的 forwhile 循环,因为这会阻塞 Tkinter 绘图操作,并使游戏冻结直到结束。相反,我们需要创建一个方法来执行游戏动画的单个“帧”,然后将其调度到 Tkinter 事件循环中再次运行。

那个方法开始是这样的:

# bug_race.py, inside App
  def execute_frame(self):
    for racer in self.racers:
      racer.next_move() 

它首先遍历赛车对象并执行它们的 next_move() 方法。移动每个赛车后,我们的下一步是确定是否有任何一个赛车手已经越过终点线并获胜。

为了检测这种条件,我们需要检查赛车是否与终点线项目重叠。

使用 Tkinter Canvas 小部件进行项目之间的碰撞检测稍微有些棘手。我们必须传递一组边界框坐标到 find_overlapping(),它返回一个重叠边界框的项目标识符元组。

让我们为我们的 Racer 类创建一个 overlapping() 方法:

# bug_race.py, inside Racer
  @property
  def overlapping(self):
    bbox = self.canvas.bbox(self.id)
    overlappers = self.canvas.find_overlapping(*bbox)
    return [x for x in overlappers if x!=self.id] 

此方法使用 Canvasbbox() 方法检索 Racer 项目的边界框。然后,它使用 find_overlapping() 获取与该边界框重叠的项目元组的列表。由于这包括 Racer 项目的 ID,我们将使用列表推导从元组中过滤掉它。结果是与这个 Racer 对象的 Canvas 项目重叠的项目列表。由于此方法不需要任何参数并且只返回一个值,我们将其制作为一个属性。

在我们的 execute_frame() 方法中,我们将检查每个赛车手是否已经越过终点线:

# bug_race.py, inside App
  def execute_frame(self):
    for racer in self.racers:
      racer.next_move()
      **if** **self.finish_line** **in** **racer.overlapping:**
        **self.declare_winner(racer)**
        **return**
    **self.after(Racer.FRAME_RES, self.execute_frame)** 

如果赛车手的 overlapping() 方法返回的列表中包含 finish_line ID,则赛车手已经撞到终点线,将通过调用 declare_winner() 方法并从该方法返回来宣布其为获胜者。

如果没有玩家被宣布为获胜者,execute_frame() 方法将在 Racer.FRAME_RES 毫秒后再次运行。这实际上使用 Tkinter 事件循环实现了一个游戏循环,它将一直运行,直到有赛车获胜。

我们在 declare_winner() 方法中处理获胜条件:

 def declare_winner(self, racer):
    wintext = self.canvas.create_text(
      (self.canvas.center_x, self.canvas.center_y),
      text=f'{racer.name} wins!\nClick to play again.',
      fill='white',
      font='TkDefaultFont 32',
      activefill='violet'
    )
    self.canvas.tag_bind(wintext, '<Button-1>', self.reset) 

在此方法中,我们只是在 Canvas 的中心创建了一个文本项目,宣布 racer.name 为获胜者。activefill 参数使得当鼠标悬停时颜色呈现紫色,这向用户表明此文本是可点击的。

当点击该文本时,它会调用 reset() 方法:

 def reset(self, *args):
    self.canvas.delete('all')
    self.setup() 

reset() 方法需要清除 Canvas,因此它使用 all 参数调用 delete() 方法。记住,all 是一个应用于 Canvas 上所有项目的内置标签,因此这一行实际上删除了所有 Canvas 项目。一旦 Canvas 清空,我们就调用 setup() 来重置并重新开始游戏。

我们最后需要确保每次调用 setup() 时游戏都会开始。要做到这一点,将 execute_frame() 的调用添加到 setup() 的末尾:

# bug_race.py, in App.setup()
  def setup():
    # ...
    self.execute_frame() 

游戏现在已完成;运行脚本,你应该会看到类似这样的结果:

图片

图 15.3:虫子赛跑游戏。红色获胜!

虽然并不完全简单,但经过一些仔细规划和数学计算,Tkinter 中的动画可以提供平滑且令人满意的结果。不过,既然我们已经足够了解游戏,就让我们回到实验室,看看如何使用 Tkinter 的 Canvas 小部件来可视化数据。

使用 Canvas 创建简单的图表

我们想要生成的第一个图表是一个简单的线形图,显示我们的植物随时间增长的情况。每个实验室都有不同的气候条件,我们想看看这些条件是如何影响所有植物的生长的,因此图表将包含每个实验室的一条线,显示实验期间实验室中所有地块的中位数高度测量的平均值。

我们将首先创建一个模型方法来返回原始数据,然后创建基于 Canvas 的线形图表视图,最后创建一个应用程序回调来获取数据并将其发送到图表视图。

创建模型方法

与 ABQ 的另一位数据分析师合作,你开发了一个 SQL 查询,通过从 plot_checks 表中的最老日期减去其日期来确定地块检查的日期编号,然后提取 lab_id 和给定日期给定实验室中所有植物的中位数高度的平均值。查询如下所示:

SELECT
  date - (SELECT min(date) FROM plot_checks) AS "Day",
  lab_id,
  avg(median_height) AS "Average Height (cm)"
FROM plot_checks
GROUP BY date, lab_id
ORDER BY "Day", lab_id; 

查询返回一个看起来像这样的数据表:

Day lab_id Average Height (cm)
0 A 1.4198750000000000
0 B 1.3320000000000000
0 C 1.5377500000000000
1 A 1.7266250000000000
1 B 1.8503750000000000
1 C 1.4633750000000000

使用此查询,让我们创建一个新的 SQLModel 方法 get_growth_by_lab() 来返回所需的数据:

# models.py, inside SQLModel
  def get_growth_by_lab(self):
    query = (
      'SELECT date - (SELECT min(date) FROM plot_checks) AS "Day", '
      'lab_id, avg(median_height) AS "Avg Height (cm)" '
      'FROM plot_checks '
      'GROUP BY date, lab_id ORDER BY "Day", lab_id;'
    )
    return self.query(query) 

这是一个相当直接的方法;它只是运行查询并返回结果。回想一下,SQLModel.query() 方法返回的结果是一个字典列表;在这种情况下,每个字典包含三个字段:Daylab_idAvg Height (cm)。现在我们只需要开发一个可以为此数据可视化给用户的图表视图。

创建图表视图

我们将要创建的图表视图需要从我们的模型方法中获取数据结构,并使用它来绘制线形图。前往 views.py 文件,我们将创建 LineChartView 类:

# views.py
class LineChartView(tk.Canvas):
  """A generic view for plotting a line chart"""
  margin = 20
  colors = [
    'red', 'orange', 'yellow', 'green',
    'blue', 'purple', 'violet'
  ] 

LineChartViewCanvas 的子类,因此我们可以在其上直接绘制项目。这个视图不仅包含数据图表,还包括坐标轴、标签和图例。它将为了可重用性而构建,因此我们将设计它时不会针对我们在此实例中绘制的具体数据进行任何特定参考。理想情况下,我们希望能够向它发送任意数据集以生成线形图。这里定义的两个类属性为图表周围的 margin(以像素为单位)提供了一个默认值,并为每个后续线形图提供了一组 colors。我们制作的增长图表只有三个图表(每个实验室一个),但额外的颜色允许我们指定多达七个。如果您想使用具有七个以上图表的图表,则可以在该列表中提供额外的颜色。

现在,我们将开始初始化方法:

# views.py, inside LineChartView
  def __init__(
    self, parent, data, plot_size,
    x_field, y_field, plot_by_field
  ):
    self.data = data
    self.x_field = x_field
    self.y_field = y_field
    self.plot_by_field = plot_by_field 

除了通常的父小部件参数外,我们还指定了这些额外的位置参数:

  • data 将是我们的包含查询数据的字典列表。

  • plot_size将是一个整数元组,指定绘图区域的宽度和高度(以像素为单位)。

  • x_fieldy_field将是用于绘图XY值的字段名称。对于增长图表,这将是DayAvg Height (cm)

  • plot_by_field将是用于将行分类为单独图表的值所在的字段。对于增长图表,这将是一个lab_id,因为我们希望为每个实验室绘制一个线条图表。

所有这些值都存储在实例变量中,这样我们就可以从我们的方法中访问它们。

我们将实现这个小部件的绘图区域作为一个放置在LineChartView上的第二个Canvas。然后,LineChartView的大小将是图表的大小加上围绕轴和标签的外部边距。我们将计算这个大小,然后将其传递给LineChartView超类初始化器,如下所示:

 self.plot_width, self.plot_height = plot_size
    view_width = self.plot_width + (2 * self.margin)
    view_height = self.plot_height + (2 * self.margin)
    super().__init__(
      parent, width=view_width,
      height=view_height, background='lightgrey'
    ) 

注意,我们已经将绘图区域的宽度和高度保存为实例变量,因为我们将在某些方法中需要它们。

现在我们已经初始化了超类,我们就可以开始在主Canvas上绘制了;首先,让我们绘制轴:

 self.origin = (self.margin, view_height - self.margin)
   # X axis
    self.create_line(
      self.origin,
      (view_width - self.margin, view_height - self.margin)
    )
    # Y axis
    self.create_line(
      self.origin, (self.margin, self.margin), width=2
    ) 

我们的图表原点将距离左下角self.margin像素,我们将从原点向图表边缘绘制简单的黑色线条,表示XY轴。记住,CanvasY坐标是从顶部向下计算的,而不是从底部向上,所以原点的Y坐标是视图区域的高度减去边距。

接下来,我们将标注轴:

 self.create_text(
      (view_width // 2, view_height - self.margin),
      text=x_field, anchor='n'
    )
    self.create_text(
       (self.margin, view_height // 2),
       text=y_field, angle=90, anchor='s'
    ) 

在这里,我们正在创建文本项,将其设置为XY轴的标签,使用传递给对象的字段名称作为文本标签。注意使用anchor来设置文本边界框的哪一侧与提供的坐标相连接。例如,对于X轴,我们指定了n(北),因此文本的顶部将在X轴线下方。对于Y轴标签,我们希望文本是侧放的,所以我们指定了angle=90来旋转它。此外,请注意,我们已将旋转文本的anchor位置指定为s(南);即使它已旋转,这里的四个基本方向是相对于旋转前的对象。因此,“南”始终是文本的正常书写的底部,即使对象已旋转。

在标注了轴之后,我们需要创建一个包含绘图区域的第二个Canvas

 self.plot_area = tk.Canvas(
      self, background='#555',
      width=self.plot_width, height=self.plot_height
    )
    self.create_window(
      self.origin, window=self.plot_area, anchor='sw'
    ) 

这个Canvas对象是实际绘图的地方。虽然我们可以在LineChartView上直接绘制图表,但嵌入第二个Canvas使得计算图表的坐标点更容易,因为我们不需要考虑边距。这也允许我们使用不同的背景颜色,使其看起来更美观。

在我们可以在图表上绘制数据之前,我们需要创建一个可以这样做的方法。让我们创建一个名为_plot_line()的私有实例方法,用于在图表上绘制单个线条图表,其开始如下:

 def _plot_line(self, data, color):
    max_x = max([row[0] for row in data])
    max_y = max([row[1] for row in data])
    x_scale = self.plot_width / max_x
    y_scale = self.plot_height / max_y 

此方法将接收一个包含线XY点的列表的data参数。由于我们的图表是固定像素数,而我们的数据值可能有任意范围,我们首先需要将数据缩放以适应图表的大小。为此,我们首先找到XY字段的极大值,然后为每个轴创建一个缩放比例,通过将图表的总高度除以极大值来计算(注意,这假设最小值是 0。这个特定的图表类不是为处理负值而设计的)。

一旦我们有了刻度值,我们就可以通过使用列表推导式将每个数据点乘以刻度值来将数据点转换为坐标,如下所示:

 coords = [
      (round(x * x_scale), self.plot_height - round(y * y_scale))
      for x, y in data
    ] 

注意,我们正在四舍五入值,因为我们不能绘制到分数像素值。同样,由于数据通常以左下角为原点进行绘图,但Canvas上的坐标从左上角开始测量,我们需要翻转Y坐标;这也在我们的列表推导式中完成,通过从绘图高度中减去新的Y值。

这些坐标现在可以传递给create_line(),同时传递一个合理的宽度和调用者传入的颜色参数,如下所示:

 self.plot_area.create_line(
      *coords, width=4, fill=color, smooth=True
    ) 

注意,我们还使用了smooth参数来使曲线更加圆润,使其看起来更自然。

要使用这个方法,我们需要回到初始化器并做一些计算。由于_plot_line()方法一次只处理一个绘图,我们需要通过plot_by_field字段过滤我们的数据,并逐个渲染线条。

LineChartView.__init__()的末尾添加此代码:

# views.py, in LineChartView.__init__()
    plot_names = sorted(set([
      row[self.plot_by_field]
      for row in self.data
    ]))
    color_map = list(zip(plot_names, self.colors)) 

首先,我们通过从数据中检索唯一的plot_by_field值来获取单个绘图名称。这些值被排序并转换为set对象,这样我们就只能得到唯一的值。然后,我们使用zip()来创建一个名称到颜色的元组列表。由于zip()返回一个生成器,而我们打算多次使用这个映射,所以将其转换为list对象。

现在,让我们绘制我们的线条:

 for plot_name, color in color_map:
      dataxy = [
        (row[x_field], row[y_field])
        for row in data
        if row[plot_by_field] == plot_name
      ]
      self._plot_line(dataxy, color) 

对于每个独特的绘图名称和颜色,我们首先使用列表推导式将数据格式化为一个包含(XY)对的列表。然后我们调用_plot_line()方法,传递数据和颜色。我们的线条现在已经被绘制了!

我们还需要一个图例,以告诉用户图表上每种颜色代表什么。没有它,这个图表对用户来说就没有意义。为此,我们将编写一个_draw_legend()方法:

# views.py, inside LineChartView
  def _draw_legend(self, color_map):
    for i, (label, color) in enumerate(color_map):
      self.plot_area.create_text(
        (10, 10 + (i * 20), text=label, fill=color, anchor='w'
      ) 

我们的方法接受在初始化器中创建的颜色映射列表,并遍历它,使用enumerate()函数为每个迭代生成一个递增的数字。对于每个映射,我们简单地绘制一个包含标签文本和相关填充颜色的文本项。这是从图表左上角开始绘制的,每个项目比上一个项目低二十像素。

最后,让我们从初始化器调用这个方法:

# views.py, inside LineChartView.__init__()
    self._draw_legend(color_map) 

LineChartView 已经准备就绪;现在我们只需要创建调用它的支持代码。

更新应用程序

Application 类中,创建一个新的方法来显示我们的图表:

# application.py, in Application
  def show_growth_chart(self, *_):
    data = self.model.get_growth_by_lab()
    popup = tk.Toplevel()
    chart = v.LineChartView(
      popup, data, (800, 400),
      'Day', 'Avg Height (cm)', 'lab_id'
    )
    chart.pack(fill='both', expand=1) 

第一要务是从我们的 get_growth_by_lab() 方法获取数据。然后,我们创建一个 TopLevel 小部件来容纳我们的 LineChartView 对象。在这个小部件上,我们添加 LineChartView 对象,配置它为 800 x 400 像素,并指定 XDay)、YAvg Height (cm))和 plot_by_field 值(lab_id)。这个图表被打包进 Toplevel

Toplevel 小部件创建了一个新的、空白的窗口,位于根窗口之外。你应该将其用作创建新窗口的基础,这些新窗口不是简单的对话框或消息框。

在完成此方法后,将其添加到 Application 初始化器中的 event_callbacks 字典中:

# application.py, inside Application.__init__()
    event_callbacks = {
      #...
      '<<ShowGrowthChart>>': self.show_growth_chart
     } 

最后,我们需要添加一个菜单项来启动图表。将以下方法添加到 GenericMainMenu 类中:

 def _add_growth_chart(self, menu):
    menu.add_command(
      label='Show Growth Chart',
      command=self._event('<<ShowGrowthChart>>')
    ) 

然后在每个菜单类的 _build_menu() 方法中使用此方法将此选项添加到 工具 菜单中。例如:

# mainmenu.py, in any class's _build_menu() method
    self._add_growth_chart(self._menus['Tools']) 

当你调用你的函数时,你应该看到类似以下的内容:

Ubuntu Linux 上的增长图表

图 15.4:Ubuntu Linux 上的增长图表

如果没有一些样本数据,你的图表看起来可能不会太好。除非你只是喜欢做数据录入,sql 目录中有一个用于加载样本数据的脚本。在测试你的图表之前,在数据库上运行此脚本。

使用 Matplotlib 的高级图表

我们的折线图看起来很漂亮,但还需要做相当多的工作才能成为一个真正专业的可视化:它缺少刻度、网格线、缩放功能和其他使其成为一个完全有用的图表的功能。

我们本可以花更多的时间使其更加完整,但有一个更快的方法可以在我们的 Tkinter 应用程序中获得更多令人满意的图表和图形:Matplotlib。

Matplotlib 是一个用于生成所有类型专业质量、交互式图表的第三方 Python 库。它是一个庞大的库,拥有许多附加组件,我们不会过多地介绍其实际用法,但我们将探讨如何将 Matplotlib 图表集成到 Tkinter 应用程序中。为了演示这一点,我们将创建一个气泡图,展示每个绘图与湿度和温度的关系。

你应该能够使用以下命令通过 pip 安装 matplotlib 库:

$ pip install --user matplotlib 

关于安装的完整说明,请参阅 matplotlib.org/users/installing.html

数据模型方法

在我们能够制作图表之前,我们需要另一个 SQLModel 方法来提取图表所需的数据。再次提醒,你已经提供了一个返回所需数据的 SQL 查询:

SELECT
  seed_sample,
  MAX(fruit) AS yield,
  AVG(humidity) AS avg_humidity,
  AVG(temperature) AS avg_temperature
FROM plot_checks
WHERE NOT equipment_fault
GROUP BY lab_id, plot, seed_sample 

此图表的目的是找到每个种子样本的温度和湿度的最佳点。因此,我们需要为每个图表包含最大果实测量值、图表列的平均湿度和温度以及种子样本的每一行。由于我们不希望有任何不良数据,我们将过滤掉包含设备故障的行。

查询返回的数据看起来像这样:

seed_sample yield avg_humidity avg_temperature
AXM480 11 27.7582142857142857 23.7485714285714286
AXM480 20 27.2146428571428571 23.8032142857142857
AXM480 15 26.2896428571428571 23.6750000000000000
AXM478 31 27.2928571428571429 23.8317857142857143
AXM477 39 27.1003571428571429 23.7360714285714286
AXM478 39 26.8550000000000000 23.7632142857142857

为了将此数据提供给应用程序,让我们将查询放入另一个名为get_yield_by_plot()的方法中:

# models.py, in SQLModel
  def get_yield_by_plot(self):
    query = (
      'SELECT seed_sample, MAX(fruit) AS yield, '
      'AVG(humidity) AS avg_humidity, '
      'AVG(temperature) AS avg_temperature '
      'FROM plot_checks WHERE NOT equipment_fault '
      'GROUP BY lab_id, plot, seed_sample'
    )
    return self.query(query) 

这就是模型所需的所有内容,让我们继续到视图部分。

创建气泡图视图

要将 Matplotlib 集成到 Tkinter 应用程序中,我们需要在views.py中进行几个模块导入。

第一个是matplotlib本身:

import matplotlib
matplotlib.use('TkAgg') 

在脚本导入部分执行方法可能看起来很奇怪,你的代码编辑器或 IDE 甚至可能会对此提出警告。然而,根据 Matplotlib 的文档,use()应该在从matplotlib导入其他模块之前调用,以告诉它应该使用哪个渲染后端。在这种情况下,我们想要TkAgg后端,它是为了集成到 Tkinter 而设计的。

Matplotlib 为各种 GUI 工具包提供了后端,例如 PyQt、wxWidgets 和 Gtk3,以及用于非 GUI 情况(例如,直接将图表渲染到文件)的后端,如 SVG 渲染或网络使用。有关更多详细信息,请参阅matplotlib.org/stable/api/index_backend_api.html文档。

现在我们已经设置了后端,我们可以从matplotlib导入一些其他项目:

from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import (
  FigureCanvasTkAgg,
  NavigationToolbar2Tk
) 

Figure类代表matplotlib图表可以绘制的基本绘图区域。FigureCanvasTkAgg类是Figure和 Tkinter Canvas之间的接口,NavigationToolbar2Tk允许我们在我们的 GUI 上放置一个预先制作的导航工具栏,用于Figure对象。

为了了解这些是如何结合在一起的,让我们从views.py中的YieldChartView类开始:

# views.py
class YieldChartView(tk.Frame):
  def __init__(self, parent, x_axis, y_axis, title):
    super().__init__(parent)
    self.figure = Figure(figsize=(6, 4), dpi=100)
    self.canvas_tkagg = FigureCanvasTkAgg(self.figure, master=self) 

在调用超类初始化器以创建Frame对象之后,我们创建一个Figure对象来保存我们的图表。与像素大小不同,Figure对象接受英寸大小和每英寸点数(dpi)设置。在这种情况下,我们的 6 英寸乘以 4 英寸和每英寸 100 点的参数产生了一个 600 乘以 400 像素的Figure对象。接下来,我们创建一个FigureCanvasTkAgg对象,它将用于将我们的Figure对象与 Tkinter Canvas连接。

FigureCanvasTkAgg 对象本身不是一个 Canvas 对象或其子类,但它包含一个我们可以放置在我们的应用程序中的 Canvas 对象。可以通过 FigureCanvasTkAgg 对象的 get_tk_widget() 方法检索对这个 Canvas 对象的引用。我们将获取一个引用并将其打包到 YieldChartView 小部件中:

 canvas = self.canvas_tkagg.get_tk_widget()
    canvas.pack(fill='both', expand=True) 

接下来,我们将添加工具栏并将其附加到我们的 FigureCanvasTkAgg 对象上:

 self.toolbar = NavigationToolbar2Tk(self.canvas_tkagg, self) 

注意,我们不需要使用几何管理器来添加工具栏;相反,我们只需将 FigureCanvasTkAgg 对象和父小部件(在这种情况下是 self,即我们的 YieldChartView 对象)传递给工具栏的初始化器,这将将其附加到我们的 Figure 上。

下一步是设置坐标轴:

 self.axes = self.figure.add_subplot(1, 1, 1)
    self.axes.set_xlabel(x_axis)
    self.axes.set_ylabel(y_axis)
    self.axes.set_title(title) 

matplotlib 中,Axes 对象代表一个单一的 XY 轴集合,可以在其上绘制数据,它通过 Figure.add_subplot() 方法创建。传递给 add_subplot() 的三个整数确定这是第一组轴,位于一个行一个列的子图的第一组。我们的图可能包含多个以表格格式排列的子图,但我们只需要一个,因此我们在这里传递所有 1。创建后,我们在 Axes 对象上设置标签。

要创建一个气泡图,我们将使用 Matplotlib 的 散点图 功能,使用每个点的尺寸来表示水果产量。我们还将对点进行着色编码,以表示数据点代表哪个种子样本。

让我们实现一个绘制我们的散点图的方法:

 def draw_scatter(self, data, color, label):
    x, y, size = zip(*data)
    scaled_size = [(s ** 2)//2 for s in size]
    scatter = self.axes.scatter(
      x, y, scaled_size,
      c=color, label=label, alpha=0.5
    ) 

传入的数据应包含每条记录的三个列,我们将它们拆分为包含 xysize 值的三个单独的列表。接下来,我们将通过将每个值平方然后除以二来放大 size 值之间的差异,使它们更明显。这并不是严格必要的,但它有助于在差异相对较小时使图表更易于阅读。

最后,我们通过调用 scatter() 将数据绘制到 axes 对象上,同时传递点颜色和标签值,并通过 alpha 参数使它们半透明。

zip(*data) 是 Python 中的一个惯用语,用于将长度为 n 的元组列表拆分为 n 个值列表,本质上与 zip(x, y, s) 相反。

要为我们的 Axes 对象绘制图例,我们需要两样东西:一个包含我们的散点对象的列表以及一个包含它们标签的列表。为了获取这些,我们将在 __init__() 中创建几个空列表,并在每次调用 draw_scatter() 时将适当的值追加到它们中。

__init__() 中添加一些空列表:

# views.py, in YieldChartView.__init__()
    self.scatters = list()
    self.scatter_labels = list() 

现在,在 draw_scatter() 的末尾,追加列表并更新 legend() 方法:

# views.py, in YieldChartView.draw_scatter()
    self.scatters.append(scatter)
    self.scatter_labels.append(label)
    self.axes.legend(self.scatters, self.scatter_labels) 

注意,我们可以反复调用 legend(),它将简单地每次都销毁并重新绘制图例。

更新应用程序类

Application 中,让我们创建一个方法来显示我们的产量数据图表。

首先,创建一个方法来显示带有我们的图表视图的 Toplevel 小部件:

# application.py, inside Application
  def show_yield_chart(self, *_):
     popup = tk.Toplevel()
     chart = v.YieldChartView(
        popup,
       'Average plot humidity', 'Average plot temperature',
       'Yield as a product of humidity and temperature'
     )
     chart.pack(fill='both', expand=True) 

现在让我们设置散点图所需的数据:

 data = self.data_model.get_yield_by_plot()
    seed_colors = {
      'AXM477': 'red', 'AXM478': 'yellow',
      'AXM479': 'green', 'AXM480': 'blue'
    } 

我们已从数据模型中检索出产量数据,并创建了一个字典,将保存我们想要为每个种子样本使用的颜色。现在我们只需要遍历种子样本并绘制散点图:

 for seed, color in seed_colors.items():
      seed_data = [
        (x['avg_humidity'], x['avg_temperature'], x['yield'])
        for x in data if x['seed_sample'] == seed
      ]
      chart.draw_scatter(seed_data, color, seed) 

再次,我们使用列表推导式对数据进行格式化和筛选,为 x 提供平均湿度,为 y 提供平均温度,以及为 s 提供产量。

将该方法添加到回调字典中,并在生长图表选项下方创建一个菜单项。

你的气泡图应该看起来像这样:

图片

图 15.5:我们的散点图显示了种子样本在不同条件下的表现

请花点时间使用导航工具栏来操作这个图表。注意你可以缩放和平移,调整图表的大小,并保存图像。这些强大的工具由 Matplotlib 自动提供,使得图表看起来非常专业。

这暂时结束了我们的图表需求,但正如你所看到的,将 Matplotlib 的强大图表和图形集成到我们的应用程序中非常简单。当然,只要付出足够的努力,使用 Canvas 小部件生成可视化就没有极限。

摘要

在本章中,你学习了 Tkinter 的图形功能。你了解了 Canvas 小部件,以及如何在上面绘制形状、线条、图像、文本和小部件。你通过在 Tkinter 事件队列中排队项目移动来实现 Canvas 上的动画。你使用纯 Canvas 实现了一个简单的折线图类,为 SQL 查询结果提供基本的数据可视化。最后,你学习了如何将强大的 Matplotlib 库及其丰富的图表和图形集成到你的应用程序中。

在下一章中,我们将学习如何打包我们的应用程序以进行分发。我们将学习如何以 Python 代码的形式安排分发目录,以及如何使用第三方工具在 Windows、macOS 和 Linux 上创建可执行文件。

第十六章:使用 setuptools 和 cxFreeze 打包

你的应用程序在 ABQ 公司内部广为人知,你被要求在其他设施中使用它。不幸的是,运行和安装应用程序的过程并不友好;你一直通过繁琐且容易出错的复制粘贴过程来安装它,并且用户从你在每台机器上手动创建的批处理或脚本中启动它。你需要以专业的方式打包你的应用程序,使其在 Windows、macOS 和 Linux 上安装和运行变得容易。

在本章中,你将学习以下主题:

  • 使用 setuptools 创建可分发软件包 中,你将学习如何使用 setuptools 库创建可分发的 Python 源代码和 wheel 软件包。

  • 使用 cx_Freeze 创建可执行文件 中,你将学习如何创建应用程序的独立可执行文件,包括针对 Windows 和 macOS 的具体说明。

使用 setuptools 创建可分发软件包

分发过程经常被引用为 Python 的一个主要缺点;这是一个有着不断发展的工具和方法的悠久历史领域,常常处于过去的遗迹和未来竞争愿景之间。

话虽如此,它的工作效果出奇地好,正如我们在整本书中通过 pip 安装组件的便利性所证明的那样。本节的目标是消除一些混淆,并为你提供一个既尊重传统方法又符合未来趋势的流程。

标准库包含 distutils 库,这是一个与打包和分发 Python 代码相关的功能集合。然而,distutils 文档(docs.python.org/3/library/distutils.html)和官方打包指南都建议不要使用它,而是建议你使用 setuptools

setuptools 库是 distutils 库的扩展,它增加了一些重要的功能,例如依赖关系处理、打包非 Python 文件以及生成可执行文件。尽管 setuptools 不是标准库的一部分,但它包含在 Windows 和 macOS 的官方 Python 发行版中,并且可以从大多数 Linux 发行版的软件仓库中轻松获取。setuptools 被用于 pip 软件包安装器,我们可以使用它来创建可以在任何安装了 Python 和 pip 的系统上安装的软件包。

如果你想创建可以上传到 PyPI 的软件包,你需要 setuptools。有关准备和上传软件包到 PyPI 的更多信息,请参阅官方 Python 打包指南,网址为 packaging.python.org

准备我们的软件包以供分发

尽管我们在 第六章 中对项目目录进行的重构,即 为应用程序的扩展做准备,使我们能够很好地打包我们的应用程序,但我们需要对 Python 包进行一些小的添加和更改,以便它作为一个分布式包成为良好的公民。让我们来看看这些是什么。

创建一个 requirements.txt 文件

requirements.txt 文件是一个纯文本文件,通常放置在应用程序根目录中,列出了我们用于开发应用程序的所有第三方模块。尽管此文件不被 setuptools 使用,但它可以被 pip 用于安装包的依赖项。

创建一个包含以下内容的 requirements.txt 文件:

# requirements.txt
--index-url https://pypi.python.org/simple/
# Runtime:
Requests
Paramiko
psycopg2
matplotlib
# for testing REST:
flask 

文件的第一行指定了我们要从哪个索引安装包;严格来说,这一行不是必需的,因为默认情况下会使用 PyPI。然而,如果我们想使用不同的包索引,我们可以覆盖这个 URL;例如,如果 ABQ 决定出于安全原因创建自己的私有 Python 包索引,我们可以将 pip 重定向到那个服务器。

接下来,我们已指定了运行时需求。我们的应用程序依赖于四个外部库:requestsparamikopsycopg2matplotlib,这些库简单地按行指定。请注意,我们也可以通过在行首添加 # 符号来向文件添加注释。

最后,我们已经将 flask 作为依赖项包含在内,尽管它没有被应用程序使用,但它是我们用于 REST 的测试服务所要求的。包含这类需求可能看起来有些奇怪,但 requirements.txt 文件的目的就是让其他开发者(包括你未来的自己)能够轻松地重现这个应用程序的开发环境。你可能选择将这类非运行时需求放在一个单独的文件中,例如,requirements.development.txtrequirements.testing.txt

然后,可以使用以下命令使用此文件指导 pip 安装这些依赖项:

$ pip install -r requirements.txt 

这个命令会导致 pip 逐行读取文件以安装依赖项。对于每个依赖项,pip 会首先检查该包是否已经安装;如果没有,它将从指定的包索引安装该包的最新版本。

这虽然带来了一点小问题;如果我们的代码依赖于用户系统上安装的包的新版本,会发生什么?或者,如果库的新版本中的不兼容更改要求我们运行比索引中最新版本更旧的版本,会发生什么?

为了解决这个问题,我们可以在 requirements.txt 中包含版本指定符字符串,如下所示:

# These are examples, don't include this in our requirements.txt
requests==2.26.0
paramiko>=2.6
psycopg2<3.0
matplotlib>3.2,<=3.3 

版本指定符由一个比较运算符后跟一个版本字符串组成。这将指导 pip 确保安装的版本与要求匹配;在这种情况下,它将指定以下内容:

  • requests 必须是 正好 版本 2.26.0

  • paramiko 至少需要 2.6 或更高版本

  • psycopg2 必须小于 3.0

  • matplotlib 必须大于 3.2(不包括 3.2!),但 3.3 或更低版本

你是否包含版本指定符,以及你使它们有多具体,这在一定程度上取决于你的项目和用户的需求。一般来说,你不应该限制 pip 安装库的新版本,以免错过错误修复和安全补丁,但在存在已知新版本错误的情况下,这可能至关重要。

pip freeze 命令将打印出所有已安装的包及其确切版本列表。在关键任务环境中工作的开发者,他们希望确保能够重现其确切的开发环境,通常会直接将此输出复制到 requirements.txt 文件中。

创建 pyproject.toml 文件

虽然 setuptools 仍然是打包 Python 项目的既定标准,但 Python 社区正在向工具无关的配置转变,以适应新的选项。作为这一转变的一部分,官方打包指南建议在项目的根目录中创建一个 pyproject.toml 文件。目前,此文件仅用于指定项目的构建系统和构建系统要求,但有迹象表明,未来更多的项目设置将迁移到该文件。

对于我们的项目,文件应包含以下内容:

[build-system]
requires = [
    "setuptools",
    "wheel"
]
build-backend = "setuptools.build_meta" 

此文件表示我们的项目需要 setuptoolswheel 包,并且我们正在使用 setuptools.build_meta 实际构建我们的项目。如果你希望使用 setuptools 构建项目,这些是推荐的配置。

注意,这里列出的要求是 构建要求,这意味着它们是构建工具所需的包,用于创建可分发包。这与我们在 requirements.txt 中列出的要求不同,后者是需要使用包的包。

TOMLTom's Obvious, Minimal Language)是一种相对较新的配置文件格式,于 2013 年推出。它通过引入层次结构和嵌套列表等新特性扩展了传统的 INI 风格格式。它在 Rust、JavaScript 以及当然还有 Python 等语言中作为构建工具的配置格式越来越受欢迎。更多关于 TOML 的信息请访问 toml.io

添加许可文件

当你分发代码时,确保接收者知道他们可以如何使用该代码非常重要。与 C 或 Java 等编译型语言不同,当你使用 setuptools 分发项目时,Python 源代码必然会被包含在内。为了确保接收者适当地使用代码,我们需要在我们的代码中包含一个许可文件。

在决定许可时,你需要考虑一些问题。

首先,如果你在工作中开发软件(例如我们的 ABQ 数据录入程序),你的雇主通常拥有代码的所有权,你需要确保你指定了他们为代码所偏好的许可证。就这个问题咨询你的雇主以了解他们的政策。

其次,如果你使用了第三方库,你需要确保你的许可证与这些库的许可证兼容。例如,如果你正在使用许可为GNU 公共许可证GPL)的软件库,你可能需要将你的软件以 GPL 或类似、兼容的许可证发布。Python 和 Tkinter 是以相当宽松的许可证分发的;以下是我们的四个依赖项的许可证:

许可证 参考
requests Apache2 2.python-requests.org/projects/3/user/intro/
paramiko LGPL 2.1 github.com/paramiko/paramiko (LICENSE 文件)
psycopg2 LGPL 2.1 www.psycopg.org/license
matplotlib Matplotlib 许可证(基于 BSD) matplotlib.org/stable/users/license.html

在分发你的包之前,务必查阅这些许可证,以确保你遵守使用这些库的软件的要求。如果上述任何情况都不适用,你应该简单地考虑哪种许可证最适合项目,并描述你分发它的意图。

无论你选择哪种,都应该包含在你的项目根目录中,文件名为LICENSE

使我们的包可执行

到目前为止,我们通过运行放置在项目根目录外部包中的abq_data_entry.py文件来执行我们的应用程序。理想情况下,我们希望所有我们的 Python 代码——甚至这个微不足道的启动脚本——都位于包内部。我们只需将abq_data_entry.py复制到包目录中,对吧?这似乎很简单,但当我们现在执行脚本时,我们得到了一个错误:

$ python abq_data_entry/abq_data_entry.py
Traceback (most recent call last):
  File ".../abq_data_entry/abq_data_entry.py", line 1, in <module>
    from abq_data_entry.application import Application
  File ".../abq_data_entry/abq_data_entry.py", line 1, in <module>
    from abq_data_entry.application import Application
ModuleNotFoundError: No module named 'abq_data_entry.application';
  'abq_data_entry' is not a package 

不幸的是,我们在包中使用的相对导入在包内部执行代码时将无法正确工作。然而,Python 在这里提供了一个解决方案:我们可以使我们的可执行,而不是依赖于特定的 Python 脚本来执行。

为了做到这一点,我们需要在我们的包内部创建一个__main__.py文件。这个 Python 包内的特殊文件使包可执行;当模块被执行时,Python 将运行__main__.py脚本。然而,它将以一种稍微特殊的方式运行,这将允许我们的相对导入正常工作。

创建一个名为abq_data_entry/__main__.py的新文件,并添加以下内容:

# abq_data_entry/__main__.py
from abq_data_entry.application import Application
def main():
  app = Application()
  app.mainloop()
if __name__ == '__main__':
    main() 

__main__.py的内容几乎与abq_data_entry.py相同;唯一的区别是我们将Application的创建和mainloop()的执行放在了一个名为main()的函数中。原因将在我们开始构建包时简要解释。

一旦我们创建了__main__.py,我们可以像这样执行模块:

$ python -m abq_data_entry 

-m标志告诉 Python 加载并执行提供的模块。注意,目前,这个命令必须在项目根目录内执行。一旦我们创建并安装了我们的 Python 包,我们就可以从任何地方运行它。

配置setup.py脚本

现在我们代码准备好了,我们可以开始创建我们的setuptools配置。要使用setuptools打包我们的项目,我们需要创建一个配置脚本;按照惯例,这个文件叫做setup.py,并且创建在应用程序的根目录中。

setuptools配置也可以创建为一个 INI 风格的配置文件,setup.cfg。最终,这可能会取代setup.py,但在这本书中,我们将坚持使用 Python 脚本方法,因为它允许我们执行一些必要的 Python 代码。

setup.py文件的基本结构如下所示:

# setup.py
from setuptools import setup
setup(
  # Configuration arguments here
) 

我们的大多数配置都将作为参数传递给setup()函数,定义我们包的基本元数据,包括要打包的内容以及安装后提供的一些功能。

基本元数据参数

首先,让我们使用setup.py中的这些参数定义一些关于我们应用程序的基本元数据:

setup(
  name='ABQ_Data_Entry',
  version='1.0',
  author='Alan D Moore',
  author_email='alandmoore@example.com',
  description='Data entry application for ABQ AgriLabs',
  url="http://abq.example.com",
  license="ABQ corporate license",
  #...
) 

这样的元数据将被用于命名包以及为 PyPI 提供信息。如果你只是用于个人或内部打包,并非所有字段都是必要的,但如果你计划上传到 PyPI,你应该包括所有这些字段以及long_description,它应该是一个 reStructuredText 字符串,提供有关程序的扩展信息。

通常,可以直接使用README.rst文件。因为这个配置脚本只是一个 Python 脚本,我们可以使用正常的 Python 来读取文件,并使用其内容进行此配置选项,如下所示:

# setup.py, near the top
with open('README.rst', 'r') as fh:
  long_description = fh.read()
# inside the setup() call:
setup(
  #...
  long_description=long_description,
) 

包和依赖项

一旦我们指定了元数据,我们需要告诉setuptools我们实际上正在捆绑哪些包,使用packages参数。在我们的例子中,我们只有abq_data_entry包,所以我们将如下指定它:

setup(
  #...
  packages=[
    'abq_data_entry',
    'abq_data_entry.images',
    'abq_data_entry.test'
  ], 

注意,我们已经指定了主包以及子模块imagestest。我们需要明确指定我们想要包含的所有子模块,因为setuptools不会自动包含它们。

对于非常复杂的包,这可能会变得繁琐,所以setuptools还包括find_packages()函数,可以像这样使用:

from setuptools import setup, find_packages
setup(
  #...
  packages=**find_packages(),** 

这将自动定位并包含我们项目目录中的所有包。

除了我们项目中定义的模块外,我们的应用程序还依赖于第三方模块,如psycopg2requestsparamikomatplotlib。我们可以在setup()中指定这些依赖项,并且假设它们可以从 PyPI 获取,当我们的包被安装时,pip将自动安装它们。

这是通过install_requires参数实现的,如下所示:

setup(
  #...
  install_requires=[
    'psycopg2', 'requests', 'paramiko', 'matplotlib'
  ], 

注意,这些所需的软件包可能也有它们自己的依赖项;例如,matplotlib需要包括numpypillow在内的几个其他库。我们不必指定所有这些子依赖项;它们也将由pip自动安装。

如果该包需要这些模块的特定版本,我们也可以指定:

# Just an example, do not add to your setup.py!  
  install_requires=[
    'psycopg2==2.9.1', 'requests>=2.26',
    'paramiko', 'matplotlib<3.3.5'
  ] 

这看起来相当熟悉,不是吗?这些是我们可以在requirements.txt中放入的相同类型的规则,以及相同的运行时依赖项列表。一些开发者采取的方法是只读取requirements.txt并将其内容发送到install_requires列表;甚至有工具可以帮助翻译它们之间的一些不兼容语法。但是,请记住,我们的requirements.txt文件的目的是为了重新创建我们的特定开发环境。因此,它包含非运行时包,并且可能包含非常具体的版本指定符以测试一致性。相比之下,install_requires列表仅用于运行时要求,并且通常应该对版本和包来源更加抽象。例如,虽然指定psycopg2版本2.9.1对于开发环境可能是有帮助的,除非我们确定它只与该版本正确工作,否则我们在这里会指定更通用的版本,例如psycopg2<3.0

就像我们可以指定包的版本要求一样,我们也可以使用python_requires参数指定我们的应用程序所需的 Python 版本,如下所示:

setup(
  #...
  python_requires='>= 3.6', 

如果您正在使用在早期版本中找不到的 Python 特性(例如,我们应用程序中使用的 f-strings 在 Python 3.6 之前无法工作),或者您想确保只使用特定的、经过测试的 Python 版本,那么这始终是一个好主意。如果用户没有运行匹配的 Python 版本,pip install将因错误而终止。

setuptools中使用的版本指定符的语法在 PEP 440 中有详细说明,您可以在www.python.org/dev/peps/pep-0440/找到。

添加额外文件

默认情况下,setuptools只会将 Python 文件复制到您的包中。然而,我们的包包含的不仅仅是这些:我们还有 RST 格式的文档、SQL 脚本,最重要的是我们的 PNG 图像,没有这些图像,我们的程序将无法正确运行。

位于我们包结构内部的非 Python 文件可以使用package_data参数进行指定:

setup(
  #...
  package_data={'abq_data_entry.images': ['*.png', '*.xbm']},
) 

package_data 参数接受一个字典,将模块路径与要包含在该模块中的文件列表(或匹配文件列表的 globbing 表达式)相匹配。在这里,我们告诉 setuptools 包含 images 模块中的所有 PNG 和 XBM 文件。

我们的项目还包含 abq_data_entry 模块之外必要的文件;这些文件对于程序运行不是必需的,但应该与软件包一起分发。我们无法在 setup() 中指定这些文件,因为它只处理软件包内的文件。

要添加这些,我们需要在项目根目录中创建一个名为 MANIFEST.in 的单独文件。对于我们的应用程序,该文件应包含以下内容:

# MANIFEST.in
include README.rst
include requirements.txt
include docs/*
include sql/*.sql 

MANIFEST.in 文件包含一系列的 include 指令,其中包含文件名或匹配我们想要包含的文件的 globbing 表达式。在这里,我们包括 docs 目录中的所有文件,sql 目录中的所有 .sql 文件,requirements.txt 文件和 README.rst 文件。由于 setup.py 依赖于 README.rstrequirements.txt 文件来设置数据,因此我们务必将它们包含在软件包中。否则,我们的软件包在其他系统上可能无法构建。

定义命令

之前,我们在包中创建了一个 __main__.py 文件,允许我们通过命令 python -m abq_data_entry 运行我们的包。这当然比寻找要执行的正确 Python 脚本要干净得多,但理想情况下,我们希望我们的软件包设置一个简单的命令,用户可以执行以启动程序。

setuptools 库提供了一个使用 entry_points 参数在我们的软件包中添加可执行命令的方法。入口点是外部环境访问我们代码的方式。一个特定的入口点,console_scripts,定义了一个将映射到外部命令的模块函数列表。当软件包安装时,setuptools 将为每个 console_scripts 项创建一个简单、平台适当的可执行文件,当执行时,将运行指定的函数。

然而,我们不能将 console_scripts 指向一个 Python 文件或包;它必须指向包内的一个 函数。这就是为什么我们之前在 __main__.py 文件中创建 main() 函数的原因,这样我们就可以指定 __main__.main() 作为控制台脚本入口点,如下所示:

setup(
  #...
  entry_points={
    'console_scripts': [
    'abq = abq_data_entry.__main__:main'
  ]}
) 

console_scripts 列表中的每个项都是一个格式为 {executable_name} = {module}.{submodule}:{function_name} 的字符串。我们的代码将导致 setuptools 创建一个名为 abq 的可执行文件,该文件将运行我们在 __main__.py 中定义的 main() 函数。因此,安装后,我们只需在命令行中键入 abq 即可执行应用程序。如果包中有可以独立运行的函数,您还可以在这里定义其他脚本。

测试配置

在我们创建可分发包之前,我们可以通过在项目根目录中运行以下命令来检查其语法和内容:

$ python setup.py check 

如果一切顺利,这将简单地返回字符串running check,没有其他输出。如果缺少某些内容,你会得到一个错误消息。例如,如果你注释掉setup()中的nameversion参数并运行检查,你会得到以下输出:

running check
warning: check: missing required meta-data: name, version 

虽然它不会找到你setup.py中所有潜在的问题,但check命令至少会确保你指定了所有必要的元数据,这对于你希望将包上传到 PyPI 来说尤为重要。

创建和使用源分布

在配置文件全部设置完毕后,我们现在可以创建一个源分布。这种分布将所有构建我们包所需的相关文件打包成一个.tar.gz存档。

要创建源分布,请在项目根目录中运行带有sdist选项的setup.py

$ python setup.py sdist 

完成此操作后,在项目根目录下将出现两个新的目录:

  • ABQ_Data_Entry.egg-info:这个目录包含由setuptools生成的元数据文件。如果你探索这个目录,你会发现我们传递给setup()的所有信息都以某种形式存储在这里。

  • dist:这个目录包含为分发而生成的文件;在这种情况下,只有一个包含我们的源包的单个.tar.gz文件。

要在另一台计算机上安装源分布,首先需要将其提取出来。这可以使用 GUI 工具或使用以下示例中的tar命令在终端中完成:

$ tar -xzf ABQ_Data_Entry-1.0.tar.gz 

提取后,我们可以在提取的目录中通过运行带有install选项的setup.py来安装包,如下所示:

$ cd ABQ_Data_Entry/
$ python3 setup.py install 

测试我们的源分布

如果你没有第二台计算机来测试你的源安装器,你可以使用 Python 虚拟环境。虚拟环境是一个干净、隔离的 Python 安装,可以根据需要激活,以防止安装的包污染系统的主要 Python 环境。

要创建一个,首先确保你已经使用pip安装了virtualenv包:

$ pip install --user virtualenv 

接下来,在你的系统上的任何位置创建一个目录,并使用以下命令在其中生成一个 Python 3 环境:

$ mkdir testenv
$ python -m virtualenv -p python3 testenv 

这将在testenv目录中创建一个虚拟环境,本质上是一个 Python 解释器和标准库的副本,以及一些支持脚本和文件。这个环境可以按你的意愿进行修改,而不会影响你的系统 Python 环境。

要使用新的虚拟环境,你需要通过在终端中执行以下代码来激活它:

# On Linux, macOS, and other unix-like systems:
$ source testenv/bin/activate
# On Windows
> testenv\Scripts\activate 

激活虚拟环境意味着 python 的调用将使用虚拟环境中的二进制和库,而不是你的系统安装。这包括与 Python 相关的命令,如 pip,这意味着 pip install 将将包安装到环境的库中,而不是你的系统或用户 Python 库中。

在你的测试环境激活后,你现在可以在你的源分发上运行 setup.py install。你会注意到 Python 将安装 psycopg2requestsparamikomatplotlib,以及它们的各个依赖项,即使你已经在你的系统上安装了这些包。这是因为虚拟环境从没有第三方包的干净状态开始,所以在新环境中必须重新安装一切。

如果安装成功,你应该找到以下内容:

  • 你在你的虚拟环境中有一个名为 abq 的命令,它启动 ABQ 数据输入应用程序。

  • 你可以从系统的任何目录(而不仅仅是项目根目录)打开 Python 提示符并导入 abq_data_entry

  • abq_data_entry 包目录位于 testenv/lib/python3.9/sitepackages

当你完成虚拟环境后,你可以在终端中输入 deactivate 以返回到你的系统 Python。如果你想删除环境,只需删除 testenv 目录。

构建 wheel 分发

虽然源分发可能适合像我们的应用程序这样的简单软件,但具有复杂构建步骤(如代码编译)的包可能从 构建分发 中受益。正如其名所示,构建分发是指任何构建操作(编译或代码生成等)已经完成的分发。setuptools 用于构建分发的当前格式是 wheel 格式

wheel 格式取代了旧的 distutils 分发格式,称为 egg。当使用 setuptools 或其他 distutils 衍生工具时,你仍然会看到对 egg 的引用。

一个 wheel.whl)文件基本上是一个包含预构建代码的 ZIP 格式存档文件。它有以下三种类型:

  • 通用:这种类型的 wheel 文件只包含在任何平台和任何主要版本的 Python(2 和 3)上运行的 Python 代码。

  • 纯 Python:这种类型的 wheel 文件只包含在任何平台上运行的 Python 代码,但只与一个版本的 Python 兼容。

  • 平台:这个 wheel 文件限制在特定的操作系统、平台或架构上,通常是因为它包含编译的二进制代码。

setuptools 创建的默认 wheel 文件是一个纯 Python wheel,这正是我们的应用程序应该使用的(因为我们没有编译代码,但只与 Python 3 兼容)。创建一个很简单,只需使用 bdist_wheel 选项调用 setup.py,如下所示:

$ python3 setup.py bdist_wheel 

sdist命令类似,这个命令在dist目录下创建一个新的文件,但这次是一个.whl文件。文件名将是ABQ_Data_Entry-1.0-py3-none-any.whl,其各个部分代表以下信息:

  • 包名,在本例中为ABQ_Data_Entry

  • 版本,在本例中为1.0

  • 不论是 Python 3、Python 2 还是通用版;在本例中,为 Python 3 的py3

  • 应用二进制接口ABI)标签,它将指示特定的 Python 实现(例如,CPython 与 IronPython)。在本例中,它是none,因为我们没有特定的 ABI 要求。

  • 支持的平台,在本例中为any,因为我们的应用程序不是平台特定的。请注意,这个组件可以包括 CPU 架构以及操作系统。

注意到bdist_wheel过程还会创建一个build目录,这是在代码被压缩进wheel文件之前,代码被暂存的地方。您可以检查这个目录,以确保您的包正在正确组装。

一旦构建完成,您可以使用pip安装wheel文件,如下所示:

$ pip install ABQ_Data_Entry-1.0-py3-none-any.whl 

与源代码安装一样,pip将首先安装在设置配置中指定的任何依赖项,然后安装包到环境的site-packages目录。可执行文件abq也将被创建并复制到适合您平台的可执行位置。

如果在尝试使用bdist_wheel时遇到错误,您可能需要安装wheel模块,因为这个模块并不总是包含在setuptools中。可以使用命令pip install --user wheel安装此模块。然而,请记住,我们在pyproject.toml中指定了wheel作为构建依赖项,因此这一步应该由setuptools处理。

使用 cx_Freeze 创建可执行文件

虽然源代码和轮子发行版很有用,但它们都需要在程序运行之前在系统上安装 Python 和任何第三方库依赖项。通常,如果我们能提供一个或一组文件,可以直接复制并在系统上运行而不需要先安装其他任何东西,那就方便多了。更好的是,我们希望有适合平台的安装包,这些包可以设置桌面快捷方式并执行其他常见的系统配置。

有几种方法可以用 Python 代码来实现这一点,也有几个项目可供选择;在这本书中,我们将探讨一个名为cx_Freeze的项目。

cx_Freeze的基本思想是将 Python 项目的所有代码和共享库文件以及 Python 解释器捆绑在一起,然后生成一个小的可执行文件,该文件将使用捆绑的解释器启动代码。这种方法通常被称为冻结代码(因此得名),并且大多数时候效果相当不错。然而,正如我们将看到的,有一些限制和困难需要克服。一个显著的限制是cx_Freeze只能为其运行的平台生成可执行文件;换句话说,如果你想生成 Windows 的可执行文件,你需要在 Windows 上构建它;如果你想生成 Linux 的可执行文件,你必须在 Linux 上构建它,依此类推。

cx_Freeze的完整文档可以在cx-freeze.readthedocs.io找到。

使用cx_Freeze的第一步

使用以下命令使用pip安装cx_Freeze

$ pip install --user cx-Freeze 

Linux 用户可能还需要安装patchelf实用程序,通常可在您发行版的包管理器中找到。

setuptools一样,cx_Freezedistutils的扩展;它与setuptools有很多相似之处,但正如您将看到的,它采取了不同的方法来解决某些问题。就像setuptools一样,我们将从一个项目目录中的脚本开始,该脚本调用setup()函数。为了将此脚本与我们的setuptools脚本区分开来,我们将将其命名为cxsetup.py。打开此文件并输入以下内容:

# cxsetup.py
import cx_Freeze as cx
cx.setup(
  name='ABQ_Data_Entry',
  version='1.0',
  author='Alan D Moore',
  author_email='alandmoore@example.com',
  description='Data entry application for ABQ Agrilabs',
  url="http://abq.example.com",
  packages=['abq_data_entry'],
) 

到目前为止,这与setuptools脚本相同,除了我们使用cx_Freeze.setup()函数而不是setuptools的函数。然而,从现在开始,事情将会有很大的不同。

setuptools使用entry_points参数,而cx_Freeze使用executables参数。此参数接受一个cx_Freeze.Excecutable对象的列表,每个对象都描述了我们想要生成的可执行文件的各种属性。为 ABQ 数据输入添加以下代码:

cx.setup(
  #...
 **executables=[**
 **cx.Executable(**
**'abq_data_entry/__main__.py'****,**
 **target_name=****'abq'****,**
 **icon=****'abq.ico'**
 **)**
 **],**
) 

至少,我们需要提供一个 Python 脚本,当可执行文件运行时应该执行;我们正在使用我们的abq_data_entry/__main__.py脚本为此目的。

默认情况下,生成的可执行文件将是脚本名称,不带.py扩展名。在这种情况下,将是__main__,这不是我们应用程序的一个非常描述性的名称。幸运的是,我们可以使用target_name参数来覆盖此默认设置,就像我们在这里所做的那样。通过在这里指定abqcx_Freeze将构建一个名为abq的可执行文件。

我们还可以使用icon参数指定应用程序要使用的图标。这需要是一个.ico文件的路径,因此在使用之前,您需要将 PNG 或其他格式转换为.ico。文件的路径相对于包含cxsetup.py文件的项目目录,并且不需要在包内部。

构建可执行文件选项

可以使用options参数将特定cx_Freeze操作的参数传递给setup()。此参数接受一个字典,其中每个项都是一个与操作特定参数的dict对象配对的cx_Freeze操作名称。我们将首先查看的操作是build_exe,这是所有其他操作的一个通用第一步。正如其名所示,这是可执行文件及其伴随文件被构建的阶段。

在其他方面,这是我们指定包依赖项的地方:

cx.setup(
  #...
 **options={**
**'build_exe'****: {**
**'packages'****: [**
**'psycopg2'****,** **'requests'****,**
**'matplotlib'****,** **'numpy'****,**
**'paramiko'**
 **],**
**'includes'****: [],** 

packages参数是需要安装的包列表。它与setuptoolsinstall_requires参数类似,但有一个重要的区别,即它不支持版本指定符。此外,请注意,我们已经包含了一些超出我们三个主要依赖项的内容。不幸的是,由于cx_Freeze并不总是能够很好地识别所有依赖项,因此通常需要明确列出子依赖项。

packages是一个需要包含的包列表时,我们也可以使用includes参数指定要包含的特定模块。从理论上讲,我们在这里不应该需要指定任何内容,但在实践中,cx_Freeze有时无法捆绑我们的程序需要的模块。

使用includes指令,我们可以明确请求模块以确保它们被包含。

为了确定列表中应该包含什么,遵循基本的试错程序:

  1. 构建可执行文件。

  2. 运行可执行文件。

  3. 如果你收到一个ModuleNotFoundError异常,表明无法找到模块,请将模块添加到includes列表,并再次运行构建命令。

  4. 如果你发现同一个包中的多个模块缺失,将包添加到packages列表并重新构建可能更有效。

例如,假设你构建了 ABQ 数据输入,并在运行abq时得到以下错误:

ModuleNotFoundError: No module named 'zlib' 

在这种情况下,zlib是我们所需的某个包的一个依赖项,由于某种原因cx_Freeze没有将其识别为必要的。为了解决这个问题,我们只需通过更新配置强制其包含。

cx.setup(
  #...
  options={
    'build_exe': {
      #...
      'includes': **[****'zlib'****]**, 

之后,重新构建的可执行文件应包含缺失的模块。通常,对于广泛使用的模块,你不需要这样做,但根据你的平台和所需的包,这些问题确实会出现。

包含外部文件

setuptools一样,cx_Freeze默认只包含 Python 文件。为了包含其他文件,如图像和文档,我们可以使用build_exeinclude_files参数。然而,存在一个问题:由于cx_Freeze以压缩归档的方式捆绑我们的 Python 模块,因此访问模块内部的文件路径需要一些额外的代码。

我们的images模块就存在这样的问题:它包含 PNG 文件,我们的应用程序通过从其__init__.py文件计算相对路径来访问这些文件。

为了解决这个问题,在构建过程中需要将 PNG 文件重新定位到包外的目录。然后,我们的代码将在冻结时在新位置找到它们,在未冻结时在原始位置找到它们。

为了使其工作,按照以下方式修改 images/__init__.py

# abq_data_entry/images/__init__.py
from pathlib import Path
import sys
if getattr(sys, 'frozen', False):
  IMAGE_DIRECTORY = Path(sys.executable).parent / 'images'
else:
  IMAGE_DIRECTORY = Path(__file__).parent / 'images' 

当运行使用 cx_Freeze 冻结的 Python 脚本时,sys 模块有一个名为 frozen 的属性。我们可以测试该属性的存在来指定当应用程序冻结时改变的行为。在这种情况下,当我们的应用程序冻结时,我们将寻找位于可执行文件相同目录下的 images 目录中的图像。可执行文件的位置可以通过 sys.executable 变量找到。如果应用程序未冻结,我们将像以前一样在模块目录中寻找图像。

现在脚本知道在哪里查找图像,我们需要配置我们的 cx_Freeze 设置,将图像复制到我们设定的位置。为此,我们需要更新我们的 build_exe 选项,如下所示:

# cxsetup.py
cx.setup(
  #...
  options={
    'build_exe': {
      #...
      **'include_files'****: [(****'abq_data_entry/images'****,** **'images'****)]** 

include_files 参数是一个包含两个元组的列表。第一个元组成员是相对于 cxsetup.py 脚本的源路径,而第二个是相对于可执行文件的目标路径。在这种情况下,我们告诉它将 abq_data_entry/images 目录中的文件复制到生成的可执行目录下的 images 目录中。

构建可执行文件

到目前为止,我们可以通过运行以下命令来构建一个可执行文件:

$ python cxsetup.py build 

build 命令运行所有步骤直到 build_exe,留下你在 ./build 下的平台特定目录中的构建代码,格式为 exe.(os)-(cpu_arch)-(python_version)。例如,如果你在 64 位 Linux 上使用 Python 3.9 运行此命令,你将有一个包含编译代码的 build/exe.linux-x86_64-3.9 目录。你可以检查此目录以确保文件被正确复制和创建,以及测试生成的可执行二进制文件。在我们的应用程序中,cx_Freeze 应该创建一个名为 abq 的二进制可执行文件,运行时将启动你的应用程序。它还应创建 libimages 目录,并复制 abq.ico 文件。

注意,平台特定构建目录中的所有文件都必须存在,程序才能运行;cx_Freeze 不支持创建单文件独立可执行文件。

对于 Linux 和 BSD,此构建目录可以打包并直接分发;其他计算机上的用户应该能够只需提取目录并执行文件。然而,对于 Windows 和 macOS,我们需要做一些额外的工作来使其准备好分发。实际上,你可能甚至遇到了在运行构建命令或执行二进制文件时出现的错误。我们将在下一节中讨论需要发生的特定于平台的小调整和配置。

cx_Freeze 支持创建 RPM 文件,这是 Fedora 或 SUSE 等某些 Linux 发行版使用的包格式。如果你在一个基于 RPM 的发行版上,你可能想调查这个选项。不幸的是,没有构建操作来为 Debian、Ubuntu 或 Arch 等非 RPM 发行版构建包。

清理构建

尽管我们有一个可工作的可执行文件,但你可能已经注意到,对于像我们这样的简单项目,可分发文件夹非常大。

在结束一天的工作之前,值得在构建目录内部进行一些探索,看看 cx_Freeze 将哪些文件捆绑到你的应用程序中,以及你是否真的需要所有这些文件。

如果你查看 lib/python(python_version)/ 下的平台特定构建目录,你会找到所有作为我们包依赖项拉入的库。你可能发现其中一些实际上并不是运行我们的应用程序所必需的。例如,如果你恰好在你的系统上安装了 PyQt 或 PySide 这样的替代 GUI 库,matplotlib 可能会将其作为依赖项拉入。

如果我们最终有像这样的额外包,我们可以使用 build_exeexcludes 选项来删除它们,如下所示:

cx.setup(
  #...
  options={
    #...
    'build_exe': {
      #...
      'excludes': [
        'PyQt4', 'PyQt5', 'PySide', 'IPython', 'jupyter_client',
        'jupyter_core', 'ipykernel','ipython_genutils'
      ], 

添加此更改后,删除你的 build 目录并重新运行构建命令。你会发现所有这些包都不再存在,并且你的构建大小显著减小。

了解可以包含或排除的内容需要研究和一些试错,但通过仔细修剪,我们可以显著降低我们的可分发文件的大小和包的构建时间。

使用 cx_Freeze 构建 Windows 可执行文件

要在 Microsoft Windows 上构建可执行文件,我们需要正确设置 Executable 初始化器的 base 参数。回想一下 第十章维护跨平台兼容性,Windows 程序以控制台或 GUI 模式启动。对于每个平台,cx_Freeze 有一个或多个基础可执行文件,它从中构建冻结的可执行文件;在 Linux、BSD 和 macOS 上,默认基础可执行文件是可以接受的,但在 Windows 上,默认基础会在控制台模式下启动应用程序。

我们需要指定一个基础,以便以 GUI 模式启动我们的脚本。这可以通过将 Win32GUI 的值传递给 base 参数来实现。因此,在我们的 cxsetup.py 脚本顶部添加以下代码:

# cxsetup.py
import platform
base = None
target_name = 'abq'
if platform.system() == "Windows":
  base = "Win32GUI"
# Inside cx.setup()
  #...
  executables=[
    cx.Executable(
      'abq_data_entry.py',
      base=base,
      target_name=target_name,
      icon='abq.ico'
    )
  ], 

正如我们在 第十章维护跨平台兼容性 中所学到的,我们使用了 platform.system() 来确定我们正在运行的操作系统;如果是 Windows,我们将基础设置为 Win32GUI。对于其他平台,base 应该只是 None,这将导致它使用默认的基础可执行文件。

现在应该在 Windows 上使用 python cxsetup.py build 成功构建应用程序,你应该能在 build/exe.win-amd64-(python_version) 目录中找到 abq.exe

构建 Windows 安装程序文件

除了构建 Windows 可执行文件外,我们还可以使用bdist_msi操作构建 Windows 安装程序文件(.msi)。虽然我们的应用程序可以通过简单地压缩构建文件夹并在目标系统上提取它来分发,但使用 MSI 文件有一些优点:

  • 在大规模 Windows 环境中,系统管理员可以更轻松地部署 MSI 文件。

  • 当安装 MSI 文件时,它会将应用程序注册到操作系统,使得升级、修复和卸载等操作更加干净。

  • MSI 文件具有额外的设置功能,例如安装向导和桌面快捷方式生成。

要开始使用cx_Freeze生成 MSI 文件,我们需要通过设置options参数的bdist_msi字典中的值来配置我们的 MSI 的一些方面。

我们首先将指定一个升级代码

cx.setup(
  #...
  options = {
    #...
    'bdist_msi': {
      'upgrade_code': '{12345678-90AB-CDEF-1234-567890ABCDEF}', 

升级代码是一个全局唯一标识符(GUID)值,它将在操作系统上识别此程序。通过指定此代码,后续构建的此.msi文件将删除并替换任何现有程序的安装。

升级代码由五个部分组成,每个部分由 8、4、4、4 和 12 个字符组成,字符范围从 0 到 9 和 A 到 F。它们可以在 Microsoft Visual Studio 中创建,或者使用以下 PowerShell 命令:

[System.Guid]::NewGuid().ToString().ToUpper() 

一旦指定,你不应该在应用程序后续构建中更改升级代码。

MSI 安装过程还可以创建应用程序快捷方式,当安装包安装时,这些快捷方式将被放置在桌面和/或程序菜单上。为此,我们需要通过创建类似于以下这些的快捷方式表元组来定义我们的快捷方式:

# cxsetup.py, after the imports
shortcut_data = [
  (
    'DesktopShortcut', 'DesktopFolder', 'ABQ Data Entry',
    'TARGETDIR', '[TARGETDIR]' + target_name, None,
    'Data Entry application for ABQ Agrilabs', None,
    None, None, None, 'TARGETDIR'
  ),
  (
    'MenuShortcut', 'ProgramMenuFolder', 'ABQ Data Entry',
    'TARGETDIR', '[TARGETDIR]' + target_name, None,
    'Data Entry application for ABQ Agrilabs', None,
    None, None, None, 'TARGETDIR'
  )
] 

前两个元组分别定义了桌面和菜单的快捷方式。它们包含的数据与微软在msdn.microsoft.com/en-us/library/windows/desktop/aa371847.aspx中描述的快捷方式表布局相匹配。

按顺序,这些字段由微软定义为以下内容:

  • 快捷方式: 要创建的快捷方式类型;在我们的例子中是DesktopShortcutMenuShortcut

  • 目录: 一个特殊的目录键,快捷键将被复制到其中。在这里,DesktopFolder指向桌面,而ProgramMenuFolder指向菜单中的程序文件夹。

  • 名称: 快捷键的名称;在我们的例子中,ABQ 数据录入

  • 组件: 这表示一个程序,其安装或卸载状态决定了我们的快捷方式是否应该安装或卸载。通过指定TARGETDIR,我们的快捷方式的安装/卸载状态与程序目录的安装/卸载状态相匹配。

  • 目标: 由快捷方式启动的可执行文件。这将是我们位于TARGETDIR内的target_name属性。

  • 参数:传递给命令的参数字符串。在这里指定的任何内容都将简单地附加到快捷方式的目标可执行文件中,并在我们的程序中通过 sys.argv 可用。例如,您可以使用此功能创建一个启动测试模式的第二个快捷方式。在我们的情况下,ABQ 程序不期望任何命令行参数,因此这是 None

  • 描述:用于快捷方式描述字段中的字符串。

  • 图标IconIndex:这些用于定位快捷方式的图标,如果我们希望它与可执行文件的图标不同。这些可以保留为 None,因为我们的可执行文件的图标将默认使用。

  • ShowCmd:指定程序将以最小化、最大化还是正常方式启动。将其保留为 None 将以正常方式启动。

  • WkDir:指示要使用的当前工作目录。我们希望这是程序的目录,因此在这里使用 TARGETDIR

一旦创建,这些快捷方式表需要包含在我们的 bdist_msi 选项的数据参数中,如下所示:

cx.setup(
  #...
  options={
  #...
    'bdist_msi': {
      #...
      'data': {'Shortcut': shortcut_data} 

目前,datacx_Freeze 文档中未记录;cx_Freeze 使用标准库的 msilib 模块来构建 .msi 文件,并将传递给此参数的任何内容传递给 msilibadd_data() 函数。如果您想进一步探索此选项,请参阅 docs.python.org/3/library/msilib.html 中的标准库 msilib 文档。

指定了 bdist_msi 选项后,让我们按照以下方式构建 .msi 文件:

$ python cxsetup.py bdist_msi 

此命令在 dist 目录中创建一个新的安装文件,您应该能够在任何兼容的 Windows 系统上安装它,如下面的截图所示:

图 16.1:ABQ 数据输入的 MSI 安装向导

图 16.1:ABQ 数据输入的 MSI 安装向导

请记住,cx_Freeze 使用构建环境中的 Python 二进制文件来构建应用程序;因此,64 位 Python 将构建 64 位可执行文件,而 32 位 Python 将构建 32 位可执行文件。此外,在较新版本的 Windows 上创建的构建可能不与较旧版本的 Windows 兼容。为了获得最大兼容性,请在您计划支持的最早版本 Windows 的 32 位版本上构建您的二进制文件。

使用 cx_Freeze 构建 macOS 可执行文件

cx_Freeze 实现了两个特定于 macOS 的构建操作:bdist_macbdist_dmg。这些操作分别创建 应用程序包压缩磁盘映像 文件。让我们更详细地查看每个操作。

构建 macOS 应用程序包

bdist_mac 构建操作创建了一个应用程序包,一个具有 .app 扩展名的特殊格式目录,Mac 桌面将其视为可执行文件。bdist_mac 有几个配置选项,但我们只将使用两个:

cx.setup(
  #...
  options={
    #...
    **'bdist_mac'****: {**
**'bundle_name'****:** **'ABQ-Data-Entry'****,**
**'iconfile'****:** **'abq.icns'**
 **}** 

在这里,bundle_name设置应用程序包目录的名称,不包括.app扩展名。通常,这会默认为传递给setup()name参数,在我们的例子中是ABQ_Data_Entry。我们在这里覆盖它,使用破折号而不是下划线,因为它对最终用户来说看起来不那么技术化。请注意,虽然在这个值中使用空格在技术上有效,但往往会给cx_Freeze带来问题,最好避免。iconfile设置允许我们指向 macOS 将用于应用程序图标的 ICNS 文件。此图像文件的尺寸需要是 16 到 1,024 像素之间的平方数,且是 2 的幂。示例代码中包含了一个兼容的 ABQ 标志。

请参阅cx_Freeze文档以获取此处额外的选项,包括代码签名和明确指定用于包的额外框架。

一旦添加了配置选项,请使用以下命令运行cxsetup.py脚本:

$ python3 cxsetup.py bdist_mac 

当此过程完成后,ABQ-Data-Entry.app应出现在构建目录中。您可以在 macOS GUI 中双击此目录从任何位置运行它,或者将其拖到/Applications目录中安装。

它应该看起来像以下屏幕截图所示:

图 16.2:构建目录中的 ABQ-Data-Entry 包

图 16.2:构建目录中的 ABQ-Data-Entry 包

如果您从这个包中启动应用程序,您会看到应用程序菜单不再读取Python,正如我们在第十章维护跨平台兼容性中首次看到的;现在它读取abq,这是可执行文件的名称,这正是我们想要的。

与 Windows 可执行文件一样,cx_Freeze为 macOS 生成的包不一定向下兼容,因此最好在您需要支持的 macOS 最旧版本上创建它们。

构建 macOS .dmg 文件

macOS 上的应用程序通常分布在压缩磁盘映像(.dmg)文件中。cx_Freezebuild_dmg操作允许您构建应用程序包并将其打包成 DMG 文件,以便于分发。

要这样做,只需执行此命令而不是bdist_mac命令:

$ python3 cxsetup.py bdist_dmg 

此命令首先运行bdist_mac来构建应用程序包,然后将其打包成 DMG 文件。bdist_dmg的配置选项允许您覆盖文件名并包括指向/Applications目录的快捷方式,以便于安装。构建的文件将出现在build目录中,您可以从该目录复制到另一台 Macintosh 上以进行挂载和使用。

摘要

在本章中,你学习了如何为分发准备和打包你的应用程序。你学习了如何使你的包可执行,以及如何使用setuptools创建源代码和构建版本,以便在组织内部使用或在公共索引如 PyPI 上分发。你还学习了如何使用cx_Freeze将你的 Python 脚本转换为可执行文件,这样就可以在不安装 Python 或依赖包的情况下将其分发到其他系统,以及如何为 Windows 和 macOS 制作应用程序安装包。

恭喜你完成这本书!我们一起将一个简单的 CSV 文件变成了一个复杂且健壮的图形应用程序。你现在拥有了创建用户友好的 GUI 应用程序的知识和信心,这些应用程序可以在所有主要平台上与文件、数据库、网络和 API 协同工作。

至于你在 ABQ 的职业发展,你刚刚收到了一份晋升提议,将作为软件开发者与公司总部一起工作。你还有更多东西要学习,但凭借你迄今为止学到的技能,你已经准备好迎接接下来的任何挑战。祝你好运!

附录

第十七章:A

reStructuredText 快速入门

当涉及到编写软件文档时,软件开发人员通常更喜欢使用轻量级标记语言而不是像 DOCX 或其他字处理文件这样的二进制格式。这些语言旨在在纯文本文件的范围内提供一种标准化的方式来注释基本富文本功能,如项目符号列表、强调文本、章节标题、表格和内联代码,同时保持可读性。使用轻量级标记语言编写的文档可以原样阅读,或者编译成 PDF、DOCX 或 HTML 等其他格式。

这种方法与使用二进制字处理文件相比有几个优点:

  • 文档可以被当作代码来处理:它可以使用代码编辑器进行编辑,并且可以用像版本控制系统VCS)这样的工具轻松管理。

  • 文档具有通用访问性:它可以从任何带有文本编辑器的系统上阅读,甚至可以从终端提示符中读取。

  • 写作过程不那么分散注意力:因为标记语言通常关注语义对象,如标题、段落、表格等,而不是像颜色、字体或文本大小这样的外观细节,开发者就不会被外观细节所分散,更多地专注于组织和正确的信息。

在 1990 年代及之前,开发者倾向于使用各种 ASCII 艺术手段来视觉上传达富文本功能,如由管道和下划线组成的表格、用星号制作的列表或用第二行破折号表示的标题。在 2000 年代初,几个项目致力于正式化和定义这些结构,并开发工具,使开发者能够将他们的标记编译成用于分发或出版的二进制富文本格式。

这本书实际上是用一种标记语言在代码编辑器中编写的,然后通过脚本转换为出版商所需的格式。

reStructuredText 标记语言

虽然存在多种标记语言选项,但 Python 社区倾向于更喜欢reStructuredTextRST)。reStructuredText 标记语言是 Python Docutils 项目的组成部分,位于 docutils.sourceforge.net。Docutils 项目开发了 RST 标准,并提供将 RST 转换为 PDF、ODT、HTML 和 LaTeX 等格式的实用工具。

文档结构

RST 旨在创建结构化文档;因此,我们首先应该创建的是我们文档的标题。这可以通过在单行文本上方和下方使用一行符号来表示,如下所示:

=========================
The reStructuredText saga
========================= 

在这种情况下,我们使用标题两边的等号来表示它是我们的文档标题。我们还可以通过添加带有不同符号的下划线行来添加副标题:

=========================
The reStructuredText saga
=========================
An adventure in markup languages.
--------------------------------- 

这里使用的确切符号并不重要;它们可以是以下任何一种:

! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ \ ] ^ _ ` { | } ~ 

一个作为标题,另一个作为副标题的区分在于顺序。我们在文档中首先使用的符号将成为顶级标题。我们使用的第二个符号将成为二级标题,依此类推。按照惯例,等号通常用于一级,连字符用于二级,波浪号用于三级,加号用于四级。然而,这仅是一种惯例;在文档中,层次结构由您使用符号的顺序决定。

我们也可以不使用符号的顶部行来写标题,如下所示:

Chapter 1
========= 

虽然这不是必需的,但这种样式通常比文档标题更适用于章节标题。文档标题是通过创建一个没有内容的顶级部分标题来表示的,而常规部分标题有内容。

例如,它们可以包含文本段落。RST 中的段落通过在文本块之间留一个空白行来表示,如下所示:

Long ago the world had many markup languages, but they were ugly, and hard to read in plain-text.
Then, one day, everything changed... 

注意,段落不应缩进。缩进一行文本将表示其他结构,如下所示。

列表

RST 能够表示子弹列表和数字列表,两者都可以包含嵌套列表。

列表项是通过在一行开始处使用*-+符号后跟一个空格来创建的,如下所示:

Lightweight markup languages include:
- reStructuredText
- emacs org-mode
- markdown 

要创建一个子列表,只需缩进两个空格,如下所示:

Lightweight markup languages include:
- reStructuredText
  - released in 2002
  - widely used in Python
- emacs org-mode
  + released in 2003
  + included with the emacs editor
- markdown
  * released in 2004
  * Several variants exist,
    including Github-flavored,
    markdown extra, and multimarkdown. 

注意,列表中实际使用的符号没有任何语法意义,尽管它可以帮助纯文本读者区分子列表。另外,请注意,我们已经通过将后续行缩进到与列表第一行文本相同的缩进量,创建了一个多行列表项(markdown下的最后一个点)。

注意列表中的第一个点之前和每个子列表周围的空白行。列表应该在列表的第一个项目之前和最后一个项目之后有一个空白行。列表可以在其项目符号之间包含可选的空白行,这在提高可读性方面有时很有帮助。

编号列表的创建方式与子弹列表类似,但使用数字或#符号,后跟一个点作为项目符号;例如:

Take these steps to use RST:
#. Learn RST
#. Write a document in RST
#. Install docutils:
  1\. Open a terminal
  2\. type pip install docutils
#. Convert your RST to another format using a command-line utility:
  * rst2pdf converts to PDF
  * rst2html converts to HTML
  * rst2odt converts to ODT 

虽然#符号对纯文本读者来说不太有帮助,但转换程序将自动在此情况下生成编号列表。注意,我们可以在编号列表中嵌套编号列表或子弹列表。

字符样式

使用 reStructuredText,我们可以表示各种内联字符样式,其中最常见的是强调、强强调和内联文本。

这是通过用特定的符号包围文本来完成的,如下表所示:

语法 用途 常见显示
*单星号表示强调文本* 轻度强调 斜体文本
**双星号用于强烈强调文本** 强调 粗体文本
py ``Double backticks are for inline literals`` 文本示例,例如代码 等宽文本,保留内联空白

注意,符号和被标记的文本之间不应有空格。

块和引用

当记录代码时,我们可能需要从其他来源包含一个块引用的情况相当常见。在 RST 中,可以通过缩进一个包含只有两个冒号的段落来完成简单的块引用,如下所示:

In the immortal words of my late, great uncle Fred,
    Please pass the banana pudding!
Heaven rest his soul. 

在需要保留空白(如缩进和换行符)的情况下,我们可以使用行块,其中每行以一个竖线和空格开始。例如:

A banana haiku:
| In a skin of gold
|     To be peeled and discarded –
|     Pale treasure awaits. 

虽然您的文档可能包含一些诗歌或文学引用,但更可能需要代码块。在 RST 中,代码块由一个缩进的块表示,该块之前是一个只包含两个冒号的段落,如下所示:

The Fibonacci series can be calculated in
a generator function like so:
::
    def fibonacci():
        a, b = 0, 1
        while True:
            b, a = a + b, b
            yield a 

在代码块中,空白字符将被保留(当然,忽略初始缩进),并且不会解释 RST 标记,正如您期望引用实际代码那样。

表格

在文档中,表格是常见的需求,RST 提供了两种表示表格的方法。更简单但更有限的方法如下所示:

===== ============= ==========================
Fruit Variety       Description
===== ============= ==========================
Apple Gala          Sweet and flavorful
Apple Fuji          Crisp and tangy, yet sweet
Apple Red Delicious Large, bland, insipid
===== ============= ========================== 

使用这种语法,我们使用空格在列中排列数据,并用 = 符号包围表格和标题行。整个表格中的空格表示列分隔符。请注意,符号必须与最长单元格的宽度相同。这种语法的限制在于它不能表示多行单元格或跨越多行或多列的单元格。

为了实现这一点,我们可以使用更详细的表格格式:

+---------+-----------+------------------------------+
| Fruit   | Variety   | Description                  |
+=========+===========+==============================+
| Orange  | All varieties are sweet with orange rind |
+         +-----------+------------------------------+
|         | Navel     | Seedless, thick skin         |
+         +-----------+------------------------------+
|         | Valencia  | Thin skin, very juicy        |
+         +-----------+------------------------------+
|         | Blood     | Smaller, red inside          |
+---------+-----------+------------------------------+ 

在此格式中,表格单元格使用连字符和管道定义,每个单元格的角落使用加号符号。通过简单地省略它们之间的边框字符,可以使单元格跨越多行或多列。例如,在上面的表格中,包含Orange的单元格延伸到表格底部,标题下的第一行跨越了第二和第三列。请注意,表格标题是通过使用等号符号而不是连字符来表示的。

在纯文本编辑器中创建表格可能很繁琐,但一些编程工具具有生成 RST 表格的插件。如果您计划在 RST 文档中创建大量表格,您可能想看看您的编辑器是否有这样的工具。

将 RST 转换为其他格式

如果没有其他选择,遵循 reStructuredText 语法将导致一个非常可读和表达力强的纯文本文件。然而,使用标准化标记语言的真正力量在于将其转换为其他格式。

可在 PyPI 上找到的 docutils 软件包包含几个用于转换 RST 文件的命令行实用程序。其中更有用的列在这里:

命令 格式 格式描述
rst2html 超文本标记语言 (HTML) 网页的标准标记语言,用于发布到网站。
rst2html5 超文本标记语言版本 5 (HTML 5) 更现代的 HTML 版本,适用于网页使用。
rst2pdf 可移植文档格式 (PDF) 适用于打印文档或分发只读文档。
rst2odt Open Document Text (ODT) 文字处理格式,当你想在文字处理器中进行进一步编辑时很有用。
rst2latex LaTeX 标记语言 一种非常强大的标记语言,常用于科学出版物。
rst2man MAN 页面标记 UNIX man 页面使用的标记。在 Linux、BSD 或 macOS 上的文档很有用。
rst2s5 简单的基于标准的幻灯片系统(S5) 基于 HTML 的幻灯片格式。适合演示。

要使用这些命令中的任何一个,只需用 RST 文件的名称调用它即可。

根据命令,输出文件可以通过 -o 开关或作为第二个位置参数指定,例如:

# uses the -o switch
$ rst2pdf README.rst -o README.pdf
# uses the positional argument
$ rst2html5 README.rst README.html 

这些脚本会解析 RST 文件中的标记并构建一个格式良好的 PDF 或 HTML 文件。你可以在附录示例代码中的 README.rst 文件上尝试这些命令,这是 ABQ 数据录入的二进制发布版本的 README。例如,如果你渲染一个默认的 HTML 文件,在浏览器中看起来可能就像这样:

README.rst 的默认 HTML5 渲染

图 A.1:README.rst 的默认 HTML5 渲染

每个命令都有大量的选项可用,你可以通过使用 --help 开关调用命令来查看这些选项,如下所示:

$ rst2html5 --help 

例如,rst2html 命令允许我们指定一个将被嵌入到生成的 HTML 文件中的 CSS 样式表。我们可以用它来改变生成的文档的外观,如下所示:

$ rst2html5 --stylesheet abq_stylesheet.css  README.rst README.abq.html 

随着本书的示例代码,包含了一个 abq_stylesheet.css 文件,尽管如果你知道 CSS,你也可以创建自己的文件。如果你使用了捆绑的文件,在浏览器中的结果看起来可能就像这样:

图 A.2:添加了样式表的 README.rst 文件

其他渲染 RST 的方法

除了 docutils,还有其他工具可以利用 RST 文件:

  • 来自 pandoc.orgpandoc 工具可以将 RST 文件转换为更广泛的输出格式,并提供多种额外的渲染选项。

  • 许多流行的代码共享服务,如 GitHub、GitLab 和 Bitbucket,将自动将 RST 文件渲染为 HTML,以便在它们的网络界面中显示。

  • 来自 sphinx-doc.org 的 Sphinx 项目是一个针对 Python 项目的综合文档生成器。它可以通过渲染代码中的 docstrings、README 文件和其他文档中的 RST 来为你的项目生成完整的文档。Sphinx 在 Python 项目中广泛使用,包括官方 Python 文档在 docs.python.org

由于 RST 被广泛接受为 Python 文档的标准,你可以安全地假设任何针对 Python 的文档工具都将期望与之一起工作。

本教程只是对 reStructuredText 语法进行了浅尝辄止!如需快速语法参考,请参阅docutils.sourceforge.io/docs/user/rst/quickref.html。如需完整文档,请参阅docutils.sourceforge.io/rst.html

第十八章:B

快速 SQL 教程

超过三十年,关系型数据库系统一直是存储商业数据的实际标准。它们更常见地被称为 SQL 数据库,这是由于与它们交互所使用的 结构化查询语言SQL)。尽管对 SQL 的全面处理需要一本自己的书,但本附录将简要介绍其基本概念和语法,这将足以跟随本书中对其的使用。

SQL 概念

SQL 数据库由 组成。表就像 CSV 或电子表格文件,因为它有行表示单个项目,列表示与每个项目关联的数据值。尽管 SQL 表与电子表格有一些重要区别:

  • 首先,表中的每一列都被分配了一个 数据类型,这是严格强制执行的。就像 Python 会在你尝试将 "abcd" 转换为 int0.03 转换为 date 时产生错误一样,如果尝试将字母插入到数字列或十进制值插入到日期列中,SQL 数据库也会返回错误。SQL 数据库通常支持基本数据类型,如文本、数字、日期和时间、布尔值和二进制数据;此外,一些实现还有一些专门的数据类型,用于诸如 IP 地址、JSON 数据、货币或图像等事物。

  • SQL 表也可以有 约束,这进一步确保了插入到表中的数据的有效性。例如,一列可以指定一个 唯一约束,这防止两行在该列中有相同的值,或者一个 非空约束,这意味着每一行都必须有一个值。

SQL 数据库通常包含许多表,这些表可以连接起来以表示更复杂的数据结构。通过将数据分解成多个链接的表,我们可以以比二维纯文本 CSV 文件更高效和更具弹性的方式存储它。

与 Python 的语法差异

如果你以前只使用过 Python 编程,SQL 可能一开始会感觉有些奇怪,因为规则和语法非常不同。我们将介绍单个命令和关键字,但这里有一些与 Python 的一般差异:

  • SQL 是(大部分)不区分大小写的:虽然为了可读性目的,通常会将 SQL 关键字全部大写,但大多数 SQL 实现并不区分大小写。这里和那里有一些小的例外,但总的来说,你可以用对你最容易的任何大小写来输入 SQL。

  • 空白没有意义:在 Python 中,换行和缩进可以改变代码的含义。在 SQL 中,空白没有意义,语句以分号结束。查询中的缩进和新行只是为了可读性。

  • SQL 是声明式的:Python 可以被描述为一种命令式编程语言:我们通过告诉 Python 如何做来告诉它我们想要做什么。SQL 更像是一种声明式语言:我们描述我们想要完成的事情,而 SQL 引擎会找出如何完成它。

我们在查看具体的 SQL 代码示例时,会遇到额外的语法差异。

SQL 操作和语法

SQL 是一种强大且表达丰富的语言,用于对表格数据进行大量操作,但基本概念可以快速掌握。SQL 代码作为单独的查询执行,这些查询要么定义、操作,要么选择数据库中的数据。不同的关系数据库产品之间的 SQL 方言略有不同,但它们大多数都支持ANSI/ISO 标准 SQL的核心操作。

尽管这里涵盖的大多数基本概念和关键字在 SQL 实现中都是通用的,但我们将在本节的示例中使用 PostgreSQL 的方言。如果您想在不同的 SQL 实现上尝试这些示例,请准备好对语法进行一些调整。

要跟随本节内容,请连接到您 PostgreSQL 数据库服务器上的一个空数据库,无论是使用psql命令行工具、pgAdmin图形工具,还是您选择的任何其他数据库客户端软件。

定义表和插入数据

SQL 表是通过使用CREATE TABLE命令创建的,如下面的 SQL 查询所示:

CREATE TABLE musicians (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  born DATE,
  died DATE CHECK(died > born)
 ); 

在这个例子中,我们正在创建一个名为musicians的表。在名称之后,我们指定一个列定义列表。每个列定义遵循以下格式:column_name data_type constraints

让我们分解一下我们定义的这些字段的细节:

  • id列将为行提供一个任意的 ID 值。它的类型是SERIAL,这意味着它将是一个自动增长的整数字段,并且它的约束是PRIMARY KEY,这意味着它将被用作行的唯一标识符。

  • name字段是TEXT类型,因此它可以存储任意长度的字符串。它的NOT NULL约束意味着这个字段不允许有NULL值。

  • borndied字段是DATE类型,因此它们只能存储日期值。

  • born字段没有约束,但died字段有一个CHECK约束,强制其值必须大于任何给定行的born值。

虽然这不是必需的,但为每个表指定一个主键是一个好的实践。主键可以是一个字段,也可以是字段的组合,但任何给定行的值必须是唯一的。例如,如果我们把name设为主键字段,我们表中就不能有两个同名音乐家。

要向这个表中添加数据行,我们使用以下格式的INSERT INTO命令:

INSERT INTO musicians (name, born, died)
VALUES
  ('Robert Fripp','1946-05-16', NULL),
  ('Keith Emerson', '1944-11-02', '2016-03-11'),
  ('Greg Lake', '1947-11-10', '2016-12-7'),
  ('Bill Bruford', '1949-05-17', NULL),
  ('David Gilmour', '1946-03-06', NULL); 

INSERT INTO 命令接受一个表名和一个可选的字段列表,指定接收数据字段;其他字段将接收它们的默认值(如果未在 CREATE 语句中指定,则为 NULL)。VALUES 关键字表示将跟随一系列数据值,格式为逗号分隔的元组列表。每个元组对应一行表,必须与表名之后指定的字段列表的顺序相匹配。

注意,字符串由单引号字符分隔。与 Python 不同,单引号和双引号在 SQL 中的含义不同:单引号表示字符串字面量,而双引号用于包含空格或需要保留大小写的对象名称。例如,如果我们把我们的表命名为 Musicians of the '70s,由于空格、撇号和大小写,我们需要用双引号括住那个名称。

使用双引号括起来的字符串字面量会导致错误,例如:

INSERT INTO musicians (name, born, died)
VALUES
  ("Brian May", "1947-07-19", NULL);
-- Produces error:
ERROR:  column "Brian May" does not exist 

为了使我们的数据库更有趣,让我们创建并填充另一个表;这次是一个 instruments 表:

CREATE TABLE instruments (id SERIAL PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO instruments (name)
VALUES ('bass'), ('drums'), ('guitar'), ('keyboards'), ('sax'); 

注意,VALUES 列表必须始终在每个行周围使用括号,即使每行只有一个值。

要将 musicians 表与 instruments 表相关联,我们需要向其中添加一个列。可以使用 ALTER TABLE 命令在创建表之后更改表。例如,我们可以添加我们的新列如下:

ALTER TABLE musicians
  ADD COLUMN main_instrument INT REFERENCES instruments(id); 

ALTER TABLE 命令接受一个表名,然后是一个改变表某些方面的命令。在这种情况下,我们正在添加一个名为 main_instrument 的新列,它将是一个整数。

我们指定的 REFERENCES 约束称为外键约束;它限制 main_instrument 的可能值只能为 instruments 表中存在的 ID 号。

从表中检索数据

要从表中检索数据,我们可以使用 SELECT 语句,如下所示:

SELECT name FROM musicians; 

SELECT 命令接受一个列或逗号分隔的列列表,后跟一个 FROM 子句,该子句指定包含指定列的表或表。此查询请求 musicians 表中的 name 列。

其输出如下:

name
Bill Bruford
Keith Emerson
Greg Lake
Robert Fripp
David Gilmour

除了列列表之外,我们还可以指定一个星号,表示“所有列”。例如:

SELECT * FROM musicians; 

前面的 SQL 查询返回以下数据表:

ID name born died main_instrument
4 Bill Bruford 1949-05-17
2 Keith Emerson 1944-11-02 2016-03-11
3 Greg Lake 1947-11-10 2016-12-07
1 Robert Fripp 1946-05-16
5 David Gilmour 1946-03-06

要过滤掉我们不需要的行,我们可以指定一个 WHERE 子句,如下所示:

SELECT name FROM musicians WHERE died IS NULL; 

WHERE命令必须后跟一个条件表达式,该表达式评估为TrueFalse;评估为True的表达式的行被显示,而评估为False的行被省略。

在这种情况下,我们要求的是那些died日期为NULL的音乐家的名字。我们可以通过使用ANDOR运算符组合表达式来指定更复杂的条件,如下所示:

SELECT name FROM musicians WHERE born < '1945-01-01' AND died IS NULL; 

在这种情况下,我们只会得到在 1945 年之前出生且数据库中没有死亡日期的音乐家。

SELECT命令也可以对字段进行操作,或根据某些列重新排序结果:

SELECT name, age(born), (died - born)/365 AS "age at death"
FROM musicians
ORDER BY born DESC; 

在这个例子中,我们使用age()函数根据出生日期确定音乐家的年龄。我们还对diedborn日期进行数学运算,以确定已故者的死亡年龄。请注意,我们使用AS关键字来别名或重命名生成的列。

当你运行这个查询时,你应该得到如下输出:

姓名 年龄 死亡年龄
比尔·布鲁福德 72 岁 4 个月 18 天
格雷格·莱克 73 岁 10 个月 24 天 69
罗伯特·弗里普 75 岁 4 个月 19 天
大卫·吉尔莫尔 75 岁 6 个月 29 天
吉思·艾默森 76 岁 11 个月 2 天 71

注意,对于没有死亡日期的人,“死亡年龄”是NULL。对NULL值进行数学或逻辑运算始终返回NULL答案。

ORDER BY子句指定了按哪个列或列列表排序结果。它还接受DESCASC参数来指定降序或升序,分别。

我们在这里按出生日期降序排列了输出。请注意,每种数据类型都有自己的排序规则,就像在 Python 中一样。日期按日历位置排序,字符串按字母顺序排序,数字按其数值排序。

更新行、删除行和更多的 WHERE 子句

要更新或删除现有行,我们使用UPDATEDELETE FROM关键字与WHERE子句结合来选择受影响的行。

删除相对简单;例如,如果我们想删除id值为5instrument记录,它看起来会是这样:

DELETE FROM instruments WHERE id=5; 

DELETE FROM命令将删除任何匹配WHERE条件的行。在这种情况下,我们匹配主键以确保只删除一行。如果没有行匹配WHERE条件,则不会删除任何行。请注意,然而,WHERE子句在技术上不是必需的:DELETE FROM instruments将简单地删除表中的所有行。

更新类似,但它包括一个SET子句来指定新列值,如下所示:

UPDATE musicians SET main_instrument=3 WHERE id=1;
UPDATE musicians SET main_instrument=2 WHERE name='Bill Bruford'; 

在这里,我们将musicians表中的main_instrument设置为instruments表中标识我们想要与每位音乐家关联的乐器的主键值。

我们可以使用主键、名称或任何条件的组合来选择我们想要更新的musician记录。就像DELETE一样,省略WHERE子句会导致查询影响所有行。

SET子句中可以更新任意数量的列;例如:

UPDATE musicians
  SET main_instrument=4, name='Keith Noel Emerson'
  WHERE name LIKE 'Keith%'; 

要更新的附加列只需用逗号分隔。请注意,我们还在使用LIKE运算符和%通配符的同时匹配记录。LIKE可以与文本和字符串数据类型一起使用来匹配部分值。标准 SQL 支持两个通配符:%匹配零个或多个字符,_ 匹配单个字符。

我们也可以匹配转换后的列值:

UPDATE musicians SET main_instrument=1 WHERE LOWER (name) LIKE '%lake'; 

在这里,我们使用了LOWER函数来将我们的字符串与列值的 lowercase 版本进行匹配。这不会永久更改表中的数据;它只是暂时更改值以用于比较目的。

标准 SQL 指定LIKE是区分大小写的匹配。PostgreSQL 提供了一个ILIKE运算符,它执行不区分大小写的匹配,以及一个SIMILAR TO运算符,它使用更高级的正则表达式语法进行匹配。

子查询

使用无意义的键值插入数据并不非常用户友好。为了使插入这些值更加直观,我们可以使用子查询,如下面的 SQL 查询所示:

UPDATE musicians
SET main_instrument=(
  SELECT id FROM instruments WHERE name='guitar'
)
WHERE name IN ('Robert Fripp', 'David Gilmour'); 

子查询是 SQL 查询中的 SQL 查询。如果你的子查询可以保证返回单个值,它可以在任何使用文字值的地方使用。

在这种情况下,我们让数据库来做找出'guitar'的主键是什么的工作,并将返回的整数插入到我们的main_instrument值中。

WHERE子句中,我们也使用了IN运算符来匹配音乐家的姓名。就像 Python 的in关键字一样,这个 SQL 关键字允许我们匹配值列表。IN也可以与子查询一起使用;例如:

SELECT name FROM musicians
WHERE main_instrument IN (
  SELECT id FROM instruments WHERE name LIKE '%r%'
) 

在这个例子中,我们要求数据库给我们每个主要乐器包含字母“r”的音乐家。由于IN是用来与值列表一起使用的,任何返回单个列和任意行数的查询都是有效的。在这种情况下,我们的子查询返回了几个只有id列的行,所以它与IN配合得很好。

返回多行和多列的子查询可以在任何可以使用表的地方使用;例如,我们可以在FROM子句中使用子查询,如下所示:

SELECT name
FROM (
  SELECT * FROM musicians WHERE died IS NULL
) AS living_musicians; 

在这种情况下,SQL 将我们的子查询视为数据库中的一个表。请注意,在FROM子句中使用的子查询需要别名;我们将此子查询别名为living_musicians

表连接

子查询是使用多个表的一种方法,但更灵活和强大的方法是使用JOINJOIN用于 SQL 语句的FROM子句中,例如:

SELECT musicians.name, instruments.name as main_instrument
FROM musicians
  JOIN instruments ON musicians.main_instrument = instrument.id; 

JOIN语句需要一个ON子句,该子句指定用于匹配每个表中行的条件。ON子句的作用就像一个过滤器,就像WHERE子句一样;你可以想象JOIN创建了一个包含来自两个表的所有可能行组合的新表,然后过滤掉不符合ON条件的行。

表通常通过匹配公共字段中的值来连接,例如在外键约束中指定的那些。在这种情况下,我们的musicians.main_instrument列包含来自instrument表的id值,因此我们可以根据这个值将两个表连接起来。

连接用于实现四种类型的表关系:

  • 一对一连接将第一表中的单行与第二表中的单行精确匹配。

  • 多对一连接将第一表中的多行与第二表中的单行精确匹配。

  • 一对多连接将第一表中的一行与第二表中的多行匹配。

  • 多对多连接匹配两个表中的多行。这种连接需要使用一个中间表。

之前的查询显示了一个多对一连接,因为许多音乐家可能有相同的主体乐器。当一列的值应该限制为一组选项时,通常使用多对一连接,例如我们的 GUI 可能用Combobox小部件表示的字段。连接的表通常被称为查找表

如果我们反转最后的查询,它将是一对多:

SELECT instruments.name AS instrument, musicians.name AS musician
FROM instruments
  JOIN musicians ON musicians.main_instrument = instruments.id; 

一对多连接通常用于一个记录有一个与其关联的子记录列表的情况;在这种情况下,每种乐器都有一个将其视为主要乐器的音乐家列表。连接的表通常被称为详细表。前面的 SQL 查询将给出以下输出:

乐器 音乐家
比尔·布鲁福德
键盘 凯斯·埃默森
贝斯 格雷格·莱克
吉他 罗伯特·弗里普
吉他 大卫·吉尔莫

注意到guitarinstrument列表中重复了。当两个表连接时,结果表的行不再指代相同的实体。instrument表中的一行代表一种乐器。

musician表中的一行代表一位音乐家。这张表中的一行代表一种乐器与音乐家的关系。

假设我们想要保持输出,使得一行代表一种乐器,但仍然在每一行中包含有关关联音乐家的信息。为此,我们需要使用聚合函数GROUP BY子句将匹配的音乐家行组合起来,如下面的 SQL 查询所示:

SELECT instruments.name AS instrument,
  count(musicians.id) as musicians
FROM instruments
  JOIN musicians ON musicians.main_instrument = instruments.id
GROUP BY instruments.name; 

GROUP BY子句指定哪些列或列描述了输出表中的每一行代表的内容。不在GROUP BY子句中的输出列必须使用聚合函数将其减少到单个值。

在这种情况下,我们使用count()聚合函数来计算与每种乐器关联的音乐家记录总数。其输出如下所示:

instrument musicians
drums 1
keyboards 1
bass 1
guitar 2

标准 SQL 包含更多聚合函数,如 min()max()sum(),并且大多数 SQL 实现也扩展了它们自己的函数。

多对一和一对多连接并不能涵盖数据库需要建模的每一种可能情况;很多时候,需要多对多关系。

为了演示多对多连接,让我们创建一个新的表 bands,如下所示:

CREATE TABLE bands (id SERIAL PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO bands(name)
VALUES ('ABWH'), ('ELP'), ('King Crimson'), ('Pink Floyd'), ('Yes'); 

一个乐队有多位音乐家,而音乐家也可以属于多个乐队。我们如何创建音乐家和乐队之间的关系?如果我们向 musicians 表中添加一个 band 字段,这将限制每位音乐家只能属于一个乐队。如果我们向 band 表中添加一个 musician 字段,这将限制每个乐队只能有一位音乐家。为了建立这种联系,我们需要创建一个 连接表,其中每一行代表一位音乐家在乐队中的成员资格。

创建 musicians_bands 表如下所示:

CREATE TABLE musicians_bands (
  musician_id INT REFERENCES musicians(id),
  band_id INT REFERENCES bands(id),
  PRIMARY KEY (musician_id, band_id)
);
INSERT INTO musicians_bands(musician_id, band_id)
VALUES (1, 3), (2, 2), (3, 2), (3, 3),
  (4, 1), (4, 2), (4, 5), (5,4); 

musicians_bands 表仅包含两个外键字段,一个指向音乐家的 ID,另一个指向乐队的 ID。

注意,我们不是创建或指定一个字段作为主键,而是使用这两个字段的组合作为主键。如果有多个行具有相同的两个值,那就没有意义,所以这种组合可以作为一个可接受的主键。

要编写使用这种关系的查询,我们的 FROM 子句需要指定两个 JOIN 语句:一个是从 musiciansmusicians_bands,另一个是从 bandsmusicians_bands

例如,让我们获取每位音乐家所属乐队的名称:

SELECT musicians.name, array_agg(bands.name) AS bands
FROM musicians
  JOIN musicians_bands ON musicians.id = musicians_bands.musician_id
  JOIN bands ON bands.id = musicians_bands.band_id
GROUP BY musicians.name
ORDER BY musicians.name ASC; 

这个查询通过连接表将音乐家和乐队关联起来,然后显示音乐家的名字以及他们所属乐队的聚合列表,并按音乐家的名字排序。它给出了以下输出:

name bands
比尔·布鲁福德
大卫·吉尔莫
格雷格·莱克
吉思·艾默森
罗伯特·弗里普

这里使用的 array_agg() 函数将字符串值聚合到一个数组结构中。这种方法以及 ARRAY 数据类型是 PostgreSQL 特有的。

虽然大多数 SQL 实现都有针对聚合字符串值的解决方案,但并没有 SQL 标准函数用于聚合字符串值。

管理事务

虽然我们可以在单个 SQL 查询中完成很多数据操作,但有时一个更改需要多个查询。在这些情况下,如果其中一个查询失败,整个查询集必须被撤销,否则数据会被破坏。

例如,假设我们想在 instruments 表中插入 'Vocals' 作为值,但希望它是 ID #1。为此,我们首先需要将 instruments 表中的其他 ID 值向上移动一位,调整 musicians 表中的外键值,然后添加新行。查询将如下所示:

UPDATE instruments SET id=id+1;
UPDATE musicians SET main_instrument=main_instrument+1;
INSERT INTO instruments(id, name) VALUES (1, 'Vocals'); 

在这个例子中,所有三个查询都必须成功运行才能产生我们想要的变化,而且至少前两个必须运行以避免数据损坏。如果只有第一个查询运行了,我们的数据就会损坏。

为了安全地完成这项操作,我们需要使用一个事务

在 PostgreSQL 中使用事务涉及三个关键字,如下所示:

关键字 功能
BEGIN 开始一个事务
ROLLBACK 取消事务并重新开始
COMMIT 永久保存事务

要将我们的查询放入事务中,我们只需在查询之前添加 BEGIN,之后添加 COMMIT,如下所示:

**BEGIN****;**
UPDATE instruments SET id=id+1;
UPDATE musicians SET main_instrument=main_instrument+1;
INSERT INTO instruments(id, name) VALUES (1, 'Vocals');
**COMMIT****;** 

现在,如果我们的查询中任何一个出现问题,我们可以执行一个 ROLLBACK 语句将数据库回滚到我们调用 BEGIN 时的状态。

在我们 第十二章 中使用的 psycopg2 模块等 DBAPI2 兼容模块中,事务管理通常是通过连接设置隐式处理的,或者通过连接对象方法显式处理,而不是使用 SQL 语句。

学习更多

这只是一个 SQL 概念和语法的快速概述;我们涵盖了您编写简单数据库应用程序所需了解的大部分内容,但还有更多需要学习。PostgreSQL 手册,可在 www.postgresql.org/docs/manuals 找到,是 SQL 语法和 PostgreSQL 特定功能的优秀资源。

posted @ 2025-09-22 13:21  绝不原创的飞龙  阅读(40)  评论(0)    收藏  举报