Python-GUI-编程-全-
Python GUI 编程(全)
原文:
zh.annas-archive.org/md5/9d5f7126bd532a80dd6a9dce44175aaa
译者:飞龙
前言
响应式图形用户界面(GUI)帮助您与应用程序交互,提高用户体验,并增强应用程序的效率。使用 Python,您将可以访问精心设计的 GUI 框架,可以用来构建与众不同的交互式 GUI。
本学习路径首先介绍了 Tkinter 和 PyQt,然后引导您通过应用程序开发过程。随着您通过添加更多小部件扩展您的 GUI,您将使用增强其功能的网络、数据库和图形库。您还将学习如何连接到外部数据库和网络资源,测试您的代码,并使用异步编程来最大化性能。在后面的章节中,您将了解如何使用 Tkinter 和 Qt5 的跨平台功能来保持跨平台兼容性。您将能够模仿平台本地的外观和感觉,并构建可在流行的计算平台上部署的可执行文件。
在学习路径结束时,您将具备设计和构建高端 GUI 应用程序的技能和信心,可以解决现实世界中的问题。
本学习路径包括以下 Packt 产品的内容:
-
Python GUI Programming with Tkinter by Alan D. Moore
-
Qt5 Python GUI Programming Cookbook by B. M. Harwani
本书适合对象
如果您是一名中级 Python 程序员,希望通过使用 PyQT 和 Tkinter 在 Python 中编写强大的 GUI 来增强您的编码技能,那么这对您来说是一个理想的学习路径。对 Python 语言的深入理解是理解本书中解释的概念的必要条件。
本书涵盖的内容
第一章,Tkinter 简介,向您介绍了 Tkinter 库的基础知识,并带您创建一个 Hello World 应用程序。它还将向您介绍 IDLE 作为 Tkinter 应用程序的示例。
第二章,使用 Tkinter 设计 GUI 应用程序,介绍了将一组用户需求转化为我们可以实现的设计过程。
第三章,使用 Tkinter 和 ttk 小部件创建基本表单,向您展示如何创建一个基本的数据输入表单,将数据追加到 CSV 文件中。
第四章,使用验证和自动化减少用户错误,演示了如何自动填充和验证我们表单输入中的数据。
第五章,规划我们应用程序的扩展,让您了解如何将一个小脚本分解为多个文件,并构建一个可以导入的 Python 模块。它还包含一些关于如何管理更大代码库的一般建议。
第六章,使用 Menu 和 Tkinter 对话框创建菜单,概述了使用 Tkinter 创建主菜单。它还将展示使用几种内置对话框类型来实现常见菜单功能。
第七章,使用 Treeview 导航记录,详细介绍了使用 Tkinter Treeview 构建记录导航系统,并将我们的应用程序从仅追加转换为具有完整读取、写入和更新功能。
第八章,使用样式和主题改善外观,告诉您如何更改应用程序的颜色、字体和小部件样式,以及如何使用它们使您的应用程序更易用。
第九章,使用 unittest 创建自动化测试,讨论了如何使用自动化单元测试和集成测试来验证您的代码。
第十章《使用 SQL 改进数据存储》带您了解如何将我们的应用程序从 CSV 平面文件转换为 SQL 数据存储。您将学习有关 SQL 和关系数据模型的所有知识。
第十一章《连接到云》介绍了如何使用云服务,如 Web 服务和 FTP 来下载和上传数据。
第十二章《使用 Canvas 小部件可视化数据》教会您如何使用 Tkinter 的 Canvas 小部件来创建可视化和动画。
第十三章《使用 Qt 组件创建用户界面》教会您如何使用 Qt Designer 的某些基本小部件,以及如何显示欢迎消息和用户名。您将学会使用单选按钮从几个选项中选择一个选项,并通过复选框选择多个选项。
第十四章《事件处理-信号和槽》介绍了如何在任何小部件上发生特定事件时执行特定任务,以及如何从一个行编辑小部件复制和粘贴文本到另一个小部件,转换数据类型并制作一个小型计算器,以及使用微调框、滚动条和滑块。您还将学会使用列表小部件执行多个任务。
第十五章《理解面向对象编程概念》讨论了面向对象编程概念,如如何在 GUI 应用程序中使用类、单继承、多级继承和多重继承。
第十六章《理解对话框》探讨了使用特定对话框,每个对话框用于获取不同类型的信息。您还将学会使用输入对话框从用户那里获取输入。
第十七章《理解布局》解释了如何通过使用水平布局、垂直布局和不同布局来水平、垂直地排列小部件,以及如何使用表单布局在两列布局中排列小部件。
第十八章《网络和管理大型文档》演示了如何制作一个小型浏览器,建立客户端和服务器之间的连接,创建一个可停靠和可浮动的登录表单,并使用 MDI 管理多个文档。此外,您还将学会使用选项卡小部件在各个部分显示信息,以及如何创建一个自定义菜单栏,在选择特定菜单项时调用不同的图形工具。
第十九章《数据库处理》概述了如何管理 SQLite 数据库以保存未来使用的信息。利用所学知识,您将学会制作一个登录表单,检查用户的电子邮件地址和密码是否正确。
第二十章《使用图形》解释了如何在应用程序中显示特定的图形。您还将学习如何创建自己的工具栏,其中包含可用于绘制不同图形的特定工具。
第二十一章《实现动画》介绍了如何显示 2D 图形图像,使球在点击按钮时向下移动,制作一个弹跳的球,以及根据指定曲线使球动画化。
第二十二章《使用 Google 地图》展示了如何使用 Google API 显示位置和其他信息。您将学会根据输入的经度和纬度值计算两个位置之间的距离,并在 Google 地图上显示位置。
为了充分利用本书
本书期望您了解 Python 3 的基础知识。您应该知道如何使用内置类型和函数编写和运行简单脚本,如何定义自己的函数和类,以及如何从标准库导入模块。
您可以在 Windows、macOS、Linux 甚至 BSD 上使用本书。确保您已安装 Python 3 和 Tcl/Tk,并且有一个您熟悉的编辑环境(我们建议使用 IDLE,因为它与 Python 捆绑在一起并使用 Tkinter)。在后面的章节中,您需要访问互联网,以便安装 Python 软件包和 PostgreSQL 数据库。
要在 Android 设备上运行 Python 脚本,您需要在 Android 设备上安装 QPython。要使用 Kivy 库将 Python 脚本打包成 Android 的 APK,您需要安装 Kivy、Virtual Box 和 Buildozer 打包程序。同样,要在 iOS 设备上运行 Python 脚本,您需要一台 macOS 机器和一些库工具,包括 Cython。
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便直接通过电子邮件接收文件。
您可以按照以下步骤下载代码文件:
-
在www.packt.com上登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载和勘误”。
-
在“搜索”框中输入书名,然后按照屏幕上的说明操作。
下载文件后,请确保使用最新版本的解压缩或提取文件夹。
-
Windows 的 WinRAR/7-Zip
-
Mac 的 Zipeg/iZip/UnRarX
-
Linux 的 7-Zip/PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Python-GUI-Programming-A-Complete-Reference-Guide
.。如果代码有更新,将在现有的 GitHub 存储库中进行更新。
我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/
上找到。请查看!
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“确定每个数据字段的适当input
小部件。”
代码块设置如下:
def has_five_or_less_chars(string):
return len(string) <= 5
wrapped_function = root.register(has_five_or_less_chars)
vcmd = (wrapped_function, '%P')
five_char_input = ttk.Entry(root, validate='key', validatecommand=vcmd)
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:
[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
任何命令行输入或输出都以如下形式书写:
pip install --user psycopg2-binary
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“安装后,启动 pgAdmin,并通过选择 Object | Create | Login/Group Role 来为自己创建一个新的管理员用户。”
警告或重要说明会出现在这样。提示和技巧会出现在这样。
第一章:Tkinter 简介
欢迎,Python 程序员!如果您已经掌握了 Python 的基础知识,并希望开始设计强大的 GUI 应用程序,那么这本书就是为您准备的。
到目前为止,您无疑已经体验到了 Python 的强大和简单。也许您已经编写了 Web 服务,进行了数据分析,或者管理了服务器。也许您已经编写了游戏,自动化了例行任务,或者只是在代码中玩耍。但现在,您已经准备好去应对 GUI 了。
在如此强调网络、移动和服务器端编程的今天,开发简单的桌面 GUI 应用程序似乎越来越像是一门失传的艺术;许多经验丰富的开发人员从未学会创建这样的应用程序。真是一种悲剧!桌面计算机在工作和家庭计算中仍然发挥着至关重要的作用,能够为这个无处不在的平台构建简单、功能性的应用程序的能力应该成为每个软件开发人员工具箱的一部分。幸运的是,对于 Python 程序员来说,由于 Tkinter,这种能力完全可以实现。
在本章中,您将涵盖以下主题:
-
发现 Tkinter——一个快速、有趣、易学的 GUI 库,直接内置在 Python 标准库中
-
了解 IDLE——一个使用 Tkinter 编写并与 Python 捆绑在一起的编辑器和开发环境
-
创建两个
Hello World
应用程序,以学习编写 Tkinter GUI 的基础知识
介绍 Tkinter 和 Tk
Tk 的小部件库起源于“工具命令语言”(Tcl)编程语言。Tcl 和 Tk 是由约翰·奥斯特曼(John Ousterman)在 20 世纪 80 年代末担任伯克利大学教授时创建的,作为一种更简单的方式来编写在大学中使用的工程工具。由于其速度和相对简单性,Tcl/Tk 在学术、工程和 Unix 程序员中迅速流行起来。与 Python 本身一样,Tcl/Tk 最初是在 Unix 平台上诞生的,后来才迁移到 macOS 和 Windows。Tk 的实际意图和 Unix 根源仍然影响着它的设计,与其他工具包相比,它的简单性仍然是一个主要优势。
Tkinter 是 Python 对 Tk GUI 库的接口,自 1994 年以来一直是 Python 标准库的一部分,随着 Python 1.1 版本的发布,它成为了 Python 的事实标准 GUI 库。Tkinter 的文档以及进一步学习的链接可以在标准库文档中找到:docs.python.org/3/library/tkinter.html
。
选择 Tkinter
想要构建 GUI 的 Python 程序员有几种工具包选择;不幸的是,Tkinter 经常被诋毁或被忽视为传统选项。公平地说,它并不是一种时髦的技术,无法用时髦的流行词和光辉的炒作来描述。然而,Tkinter 不仅适用于各种应用程序,而且具有以下无法忽视的优势:
-
它在标准库中:除了少数例外,Tkinter 在 Python 可用的任何地方都可以使用。无需安装
pip
,创建虚拟环境,编译二进制文件或搜索网络安装包。对于需要快速完成的简单项目来说,这是一个明显的优势。 -
它是稳定的:虽然 Tkinter 的开发并没有停止,但它是缓慢而渐进的。API 已经稳定多年,主要变化是额外的功能和错误修复。您的 Tkinter 代码可能会在未来数十年内保持不变。
-
它只是一个 GUI 工具包:与一些其他 GUI 库不同,Tkinter 没有自己的线程库、网络堆栈或文件系统 API。它依赖于常规的 Python 库来实现这些功能,因此非常适合将 GUI 应用于现有的 Python 代码。
-
它简单而直接:Tkinter 是直接、老派的面向对象的 GUI 设计。要使用 Tkinter,您不必学习数百个小部件类、标记或模板语言、新的编程范式、客户端-服务器技术或不同的编程语言。
当然,Tkinter 并非完美。它还具有以下缺点:
-
外观和感觉:它经常因其外观和感觉而受到批评,这些外观和感觉仍带有一些 1990 年代 Unix 世界的痕迹。在过去几年中,由于 Tk 本身的更新和主题化小部件库的添加,这方面已经有了很大改进。我们将在本书中学习如何修复或避免 Tkinter 更古老的默认设置。
-
复杂的小部件:它还缺少更复杂的小部件,比如富文本或 HTML 渲染小部件。正如我们将在本书中看到的,Tkinter 使我们能够通过定制和组合其简单小部件来创建复杂的小部件。
Tkinter 可能不是游戏用户界面或时尚商业应用的正确选择;但是,对于数据驱动的应用程序、简单实用程序、配置对话框和其他业务逻辑应用程序,Tkinter 提供了所需的一切以及更多。
安装 Tkinter
Tkinter 包含在 Python 标准库中,适用于 Windows 和 macOS 发行版。这意味着,如果您在这些平台上安装了 Python,您无需执行任何操作来安装 Tkinter。
但是,我们将专注于本书中的 Python 3.x;因此,您需要确保已安装了这个版本。
在 Windows 上安装 Python 3
您可以通过以下步骤从python.org网站获取 Windows 的 Python 3 安装程序:
-
选择最新的 Python 3 版本。在撰写本文时,最新版本为 3.6.4,3.7 版本预计将在发布时推出。
-
在文件部分,选择适合您系统架构的 Windows 可执行安装程序(32 位 Windows 选择 x86,64 位 Windows 选择 x86_64)。
-
启动下载的安装程序。
-
单击“自定义安装”。确保 tcl/tk 和 IDLE 选项已被选中(默认情况下应该是这样)。
-
按照所有默认设置继续安装程序。
在 macOS 上安装 Python 3
截至目前,macOS 内置 Python 2 和 Tcl/Tk 8.5。但是,Python 2 计划在 2020 年停用,本书中的代码将无法与其一起使用,因此 macOS 用户需要安装 Python 3 才能跟随本书学习。
让我们按照以下步骤在 macOS 上安装 Python3:
-
选择最新的 Python 3 版本。在撰写本文时,最新版本为 3.6.4,但在出版时应该会有 3.7 版本。
-
在文件部分,选择并下载
macOS 64 位/32 位安装程序
。 -
启动您下载的
.pkg
文件,并按照安装向导的步骤进行操作,选择默认设置。
目前在 macOS 上没有推荐的升级到 Tcl/Tk 8.6 的方法,尽管如果您愿意,可以使用第三方工具来完成。我们的大部分代码将与 8.5 兼容,不过当某些内容仅适用于 8.6 时会特别提到。
在 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 是一个集成开发环境,随 Windows 和 macOS Python 发行版捆绑提供(在大多数 Linux 发行版中通常也可以找到,通常称为 IDLE 或 IDLE3)。IDLE 使用 Tkinter 用 Python 编写,它不仅为 Python 提供了一个编辑环境,还是 Tkinter 的一个很好的示例。因此,虽然许多 Python 编码人员可能不认为 IDLE 的基本功能集是专业级的,而且您可能已经有了首选的 Python 代码编写环境,但我鼓励您在阅读本书时花一些时间使用 IDLE。
让我们熟悉 IDLE 的两种主要模式:shell模式和editor模式。
使用 IDLE 的 shell 模式
当您启动 IDLE 时,您将开始进入 shell 模式,这只是一个类似于在终端窗口中键入python
时获得的 Python Read-Evaluate-Print-Loop(REPL)。
查看下面的屏幕截图中的 shell 模式:
IDLE 的 shell 具有一些很好的功能,这些功能在命令行 REPL 中无法获得,如语法高亮和制表符补全。REPL 对 Python 开发过程至关重要,因为它使您能够实时测试代码并检查类和 API,而无需编写完整的脚本。我们将在后面的章节中使用 shell 模式来探索模块的特性和行为。如果您没有打开 shell 窗口,可以通过单击“开始”,然后选择“运行”,并搜索 Python shell 来打开一个。
使用 IDLE 的编辑器模式
编辑器模式用于创建 Python 脚本文件,稍后可以运行。当本书告诉您创建一个新文件时,这是您将使用的模式。要在编辑器模式中打开新文件,只需在菜单中导航到 File | New File,或者在键盘上按下Ctrl + N。
以下是一个可以开始输入脚本的窗口:
您可以通过在编辑模式下按下F5而无需离开 IDLE 来运行脚本;输出将显示在一个 shell 窗口中。
IDLE 作为 Tkinter 示例
在我们开始使用 Tkinter 编码之前,让我们快速看一下您可以通过检查 IDLE 的一些 UI 来做些什么。导航到主菜单中的 Options | Configure IDLE,打开 IDLE 的配置设置,您可以在那里更改 IDLE 的字体、颜色和主题、键盘快捷键和默认行为,如下面的屏幕截图所示:
考虑一些构成此用户界面的组件:
-
有下拉列表和单选按钮,允许您在不同选项之间进行选择
-
有许多按钮,您可以单击以执行操作
-
有一个文本窗口可以显示多彩的文本
-
有包含组件组的标记帧
这些组件中的每一个都被称为widget;我们将在本书中遇到这些小部件以及更多内容,并学习如何像这里使用它们。然而,我们将从更简单的东西开始。
创建一个 Tkinter Hello World
通过执行以下步骤学习 Tkinter 的基础知识,创建一个简单的Hello World
Tkinter 脚本:
- 在 IDLE 或您喜欢的编辑器中创建一个新文件,输入以下代码,并将其保存为
hello_tkinter.py
:
"""Hello World application for Tkinter"""
from tkinter import *
from tkinter.ttk import *
root = Tk()
label = Label(root, text="Hello World")
label.pack()
root.mainloop()
- 通过按下F5在 IDLE 中运行此命令,或者在终端中键入以下命令:
python3 hello_tkinter.py
您应该看到一个非常小的窗口弹出,其中显示了“Hello World”文本,如下面的屏幕截图所示:
- 关闭窗口并返回到编辑器屏幕。让我们分解这段代码并谈谈它的作用:
-
from tkinter import *
:这将 Tkinter 库导入全局命名空间。这不是最佳实践,因为它会填充您的命名空间,您可能会意外覆盖很多类,但对于非常小的脚本来说还可以。 -
from tkinter.ttk import *
: 这导入了ttk
或主题Tk 部件库。我们将在整本书中使用这个库,因为它添加了许多有用的部件,并改善了现有部件的外观。由于我们在这里进行了星号导入,我们的 Tk 部件将被更好看的ttk
部件替换(例如,我们的Label
对象)。 -
root = Tk()
: 这将创建我们的根或主应用程序对象。这代表应用程序的主要顶层窗口和主执行线程,因此每个应用程序应该有且只有一个 Tk 的实例。 -
label = Label(root, text="Hello World")
: 这将创建一个新的Label
对象。顾名思义,Label
对象只是用于显示文本(或图像)的部件。仔细看这一行,我们可以看到以下内容: -
我们传递给
Label()
的第一个参数是parent
或主部件。Tkinter 部件按层次结构排列,从根窗口开始,每个部件都包含在另一个部件中。每次创建部件时,第一个参数将是包含新部件的部件对象。在这种情况下,我们将Label
对象放在主应用程序窗口上。 -
第二个参数是一个关键字参数,指定要显示在
Label
对象上的文本。 -
我们将新的
Label
实例存储在一个名为label
的变量中,以便以后可以对其进行更多操作。 -
label.pack()
: 这将新的标签部件放在其parent
部件上。在这种情况下,我们使用pack()
方法,这是您可以使用的三种几何管理器方法中最简单的一种。我们将在以后的章节中更详细地了解这些内容。 -
root.mainloop()
: 这最后一行启动我们的主事件循环。这个循环负责处理所有事件——按键、鼠标点击等等——并且会一直运行直到程序退出。这通常是任何 Tkinter 脚本的最后一行,因为它之后的任何代码都不会在主窗口关闭之前运行。
花点时间玩弄一下这个脚本,在root.mainloop()
调用之前添加更多的部件。你可以添加更多的Label
对象,或者尝试Button
(创建一个可点击的按钮)或Entry
(创建一个文本输入字段)。就像Label
一样,这些部件都是用parent
对象(使用root
)和text
参数初始化的。不要忘记调用pack()
将你的部件添加到窗口中。
你也可以尝试注释掉ttk
导入,看看小部件外观是否有所不同。根据你的操作系统,外观可能会有所不同。
创建一个更好的 Hello World Tkinter
像我们刚才做的那样创建 GUI 对于非常小的脚本来说还可以,但更可扩展的方法是子类化 Tkinter 部件,以创建我们将随后组装成一个完成的应用程序的组件部件。
子类化只是一种基于现有类创建新类的方法,只添加或更改新类中不同的部分。我们将在本书中广泛使用子类化来扩展 Tkinter 部件的功能。
让我们构建一个更健壮的Hello World
脚本,演示一些我们将在本书的其余部分中使用的模式。看一下以下步骤:
- 创建一个名为
better_hello_tkinter.py
的文件,并以以下行开始:
"""A better Hello World for Tkinter"""
import tkinter as tk
from tkinter import ttk
这一次,我们不使用星号导入;相反,我们将保持 Tkinter 和ttk
对象在它们自己的命名空间中。这样可以避免全局命名空间被混乱,消除潜在的错误源。
星号导入(from module import *
)在 Python 教程和示例代码中经常见到,但在生产代码中应该避免使用。Python 模块可以包含任意数量的类、函数或变量;当你进行星号导入时,你会导入所有这些内容,这可能导致一个导入覆盖从另一个模块导入的对象。如果你发现一个模块名在重复输入时很麻烦,可以将其别名为一个简短的名称,就像我们对 Tkinter 所做的那样。
- 接下来,我们创建一个名为
HelloView
的新类,如下所示:
class HelloView(tk.Frame):
"""A friendly little module"""
def __init__(self, parent, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
我们的类是从Tkinter.Frame
继承的。Frame
类是一个通用的 Tk 小部件,通常用作其他小部件的容器。我们可以向Frame
类添加任意数量的小部件,然后将整个内容视为单个小部件。这比在单个主窗口上单独放置每个按钮、标签和输入要简单得多。构造函数的首要任务是调用super().__init__()
。super()
函数为我们提供了对超类的引用(在本例中是我们继承的类,即tk.Frame
)。通过调用超类构造函数并传递*args
和**kwargs
,我们的新HelloWidget
类可以接受Frame
可以接受的任何参数。
在较旧的 Python 版本中,super()
必须使用子类的名称和对当前实例的引用来调用,例如super(MyChildClass, self)
。Python 3 允许您无需参数调用它,但您可能会遇到使用旧调用的代码。
- 接下来,我们将创建两个 Tkinter 变量对象来存储名称和问候语字符串,如下所示:
self.name = tk.StringVar()
self.hello_string = tk.StringVar()
self.hello_string.set("Hello World")
Tkinter 有一系列变量类型,包括StringVar
、IntVar
、DoubleVar
和BooleanVar
。您可能会想知道为什么我们要使用这些,当 Python 已经为所有这些(以及更多!)提供了完全良好的数据类型。Tkinter 变量不仅仅是数据的容器:它们具有常规 Python 变量缺乏的特殊功能,例如自动传播对所有引用它们的小部件的更改或在它们更改时触发事件的能力。在这里,我们将它们用作一种访问小部件中的数据的方式,而无需保留或传递对小部件本身的引用。
注意,将值设置为 Tkinter 变量需要使用set()
方法,而不是直接赋值。同样,检索数据需要使用get()
方法。在这里,我们将hello_string
的值设置为Hello World
。我们通过创建Label
对象和Entry
来开始构建我们的视图,如下所示:
name_label = ttk.Label(self, text="Name:")
name_entry = ttk.Entry(self, textvariable=self.name)
Label()
的调用看起来很熟悉,但Entry
对象获得了一个新的参数:textvariable
。通过将 Tkinter StringVar
变量传递给此参数,Entry
框的内容将绑定到该变量,我们可以在不需要引用小部件的情况下访问它。每当用户在Entry
对象中输入文本时,self.name
将立即在出现的任何地方更新。
- 现在,让我们创建
Button
,如下所示:
ch_button = ttk.Button(self, text="Change",
command=self.on_change)
在上述代码中,我们再次有一个新的参数command
,它接受对 Python 函数或方法的引用。我们通过这种方式传递的函数或方法称为回调,正如你所期望的那样,当单击按钮时将调用此回调。这是将函数绑定到小部件的最简单方法;稍后,我们将学习一种更灵活的方法,允许我们将各种按键、鼠标点击和其他小部件事件绑定到函数或方法调用。
确保此时不要实际调用回调函数——它应该是self.on_change
,而不是self.on_change()
。回调函数应该是对函数或方法的引用,而不是它的输出。
- 让我们创建另一个
Label
,如下所示,这次用于显示我们的文本:
hello_label = ttk.Label(self, textvariable=self.hello_string,
font=("TkDefaultFont", 64), wraplength=600)
在这里,我们将另一个StringVar
变量self.hello_string
传递给textvariable
参数;在标签上,textvariable
变量决定了将显示什么。通过这样做,我们可以通过简单地更改self.hello_string
来更改标签上的文本。我们还将使用font
参数设置一个更大的字体,该参数采用格式为(font_name, font_size)
的元组。
您可以在这里输入任何字体名称,但它必须安装在系统上才能工作。Tk 有一些内置的别名,可以映射到每个平台上合理的字体,例如这里使用的TkDefaultFont
。我们将在第八章“使用样式和主题改善外观”中学习更多关于在 Tkinter 中使用字体的知识。
wraplength
参数指定文本在换行到下一行之前可以有多宽。我们希望当文本达到窗口边缘时换行;默认情况下,标签文本不会换行,因此会在窗口边缘被截断。通过将换行长度设置为 600 像素,我们的文本将在屏幕宽度处换行。
- 到目前为止,我们已经创建了小部件,但尚未放置在
HelloView
上。让我们安排我们的小部件如下:
name_label.grid(row=0, column=0, sticky=tk.W)
name_entry.grid(row=0, column=1, sticky=(tk.W + tk.E))
ch_button.grid(row=0, column=2, sticky=tk.E)
hello_label.grid(row=1, column=0, columnspan=3)
在这种情况下,我们使用grid()
几何管理器添加我们的小部件,而不是之前使用的pack()
几何管理器。顾名思义,grid()
允许我们使用行和列在它们的parent
对象上定位小部件,就像电子表格或 HTML 表格一样。我们的前三个小部件在第 0 行的三列中排列,而hello_label
将在第二行(第 1 行)。sticky
参数采用基本方向(N
、S
、E
或W
—您可以使用字符串或 Tkinter 常量),指定内容必须粘附到单元格的哪一侧。您可以将这些加在一起,以将小部件粘附到多个侧面;例如,通过将name_entry
小部件粘附到东侧和西侧,它将拉伸以填满整个列的宽度。grid()
调用hello_label
使用columnspan
参数。正如您可能期望的那样,这会导致小部件跨越三个网格列。由于我们的第一行为网格布局建立了三列,如果我们希望这个小部件填满应用程序的宽度,我们需要跨越所有三列。最后,我们将通过调整网格配置来完成__init__()
方法:
self.columnconfigure(1, weight=1)
在上述代码中,columnconfigure()
方法用于更改小部件的网格列。在这里,我们告诉它要比其他列更加重视第 1 列(第二列)。通过这样做,网格的第二列(我们的输入所在的位置)将水平扩展并压缩周围的列到它们的最小宽度。还有一个rowconfigure()
方法,用于对网格行进行类似的更改。
- 在完成
HelloModule
类之前,我们必须创建ch_button
的回调,如下所示:
def on_change(self):
if self.name.get().strip():
self.hello_string.set("Hello " + self.name.get())
else:
self.hello_string.set("Hello World")
要获取文本输入的值,我们调用其文本变量的get()
方法。如果这个变量包含任何字符(请注意我们去除了空格),我们将设置我们的问候文本来问候输入的名字;否则,我们将只是问候整个世界。
通过使用StringVar
对象,我们不必直接与小部件交互。这使我们不必在我们的类中保留大量小部件引用,但更重要的是,我们的变量可以从任意数量的来源更新或更新到任意数量的目的地,而无需明确编写代码来执行此操作。
- 创建了
HelloView
后,我们转到实际的应用程序类,如下所示:
class MyApplication(tk.Tk):
"""Hello World Main Application"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.title("Hello Tkinter")
self.geometry("800x600")
self.resizable(width=False, height=False)
这次,我们对Tk
进行了子类化,它将代表我们的主要应用程序对象。在 Tkinter 世界中,是否这样做是最佳实践存在一些争议。由于应用程序中只能有一个Tk
对象,如果我们将来想要多个MyApplication
对象,这可能会在某种程度上造成问题;对于简单的单窗口应用程序,这是完全可以的。
- 与我们的模块一样,我们调用
super().__init__()
并传递任何参数。请注意,这次我们不需要一个parent
小部件,因为Tk
对象是根窗口,没有parent
。然后有以下三个调用来配置我们的应用程序窗口:
-
self.title()
: 这个调用设置窗口标题,通常出现在任务列表和/或我们的 OS 环境中的窗口栏中。 -
self.geometry()
: 此调用以像素为单位设置窗口的大小,格式为x * y
(宽度 x 高度)。 -
self.resizable()
: 此调用设置程序窗口是否可以调整大小。我们在这里禁用调整大小,宽度和高度都禁用。
- 我们通过将视图添加到主窗口来完成我们的应用程序类,如下所示:
HelloView(self).grid(sticky=(tk.E + tk.W + tk.N + tk.S))
self.columnconfigure(0, weight=1)
请注意,我们在一行代码中创建和放置HelloView
。我们在不需要保留对小部件的引用的情况下这样做,但由于grid()
不返回值,如果您想在代码中稍后访问小部件,则必须坚持使用两个语句的版本。
因为我们希望视图填充应用程序窗口,我们的grid()
调用将其固定在单元格的所有边上,我们的columnconfigure()
调用会导致第一列扩展。请注意,我们省略了row
和column
参数,没有它们,grid()
将简单地使用下一个可用行的第一列(在本例中为0
,0
)。
- 定义了我们的类之后,我们将开始实际执行代码,如下所示:
if __name__ == '__main__':
app = MyApplication()
app.mainloop()
在 Python 中,if __name__ == '__main__':
是一个常见的习语,用于检查脚本是否直接运行,例如当我们在终端上键入python3 better_hello_world.py
时。如果我们将此文件作为模块导入到另一个 Python 脚本中,此检查将为 false,并且之后的代码将不会运行。在此检查下方放置程序的主执行代码是一个良好的做法,这样您可以在更大的应用程序中安全地重用您的类和函数。
请记住,MyApplication
是Tk
的子类,因此它充当根窗口。我们只需要创建它,然后启动它的主循环。看一下以下的屏幕截图:
对于Hello World
应用程序来说,这显然是过度的,但它演示了使用子类将我们的应用程序分成模块的用法,这将大大简化我们构建更大程序时的布局和代码组织。
摘要
现在您已经安装了 Python 3,学会了使用 IDLE,品尝了 Tkinter 的简单性和强大性,并且已经看到了如何开始为更复杂的应用程序进行结构化,现在是时候开始编写一个真正的应用程序了。
在下一章中,您将开始在 ABQ AgriLabs 的新工作,并面临一个需要用您的编程技能和 Tkinter 解决的问题。您将学习如何分解这个问题,制定程序规范,并设计一个用户友好的应用程序,这将成为解决方案的一部分。
第二章:使用 Tkinter 设计 GUI 应用程序
软件应用程序的开发分为三个重复阶段:理解问题、设计解决方案和实施解决方案。这些阶段在应用程序的整个生命周期中重复,不断完善和改进,直到它变得最佳或过时。
在本章中,我们将学习以下主题:
-
介绍和分析工作场所中需要软件解决方案的场景
-
记录解决方案的要求
-
为实施解决方案的软件设计一个设计
ABQ AgriLabs 的问题
恭喜!你的 Python 技能让你在 ABQ AgriLabs 找到了一份出色的数据分析师工作。到目前为止,你的工作相当简单:整理并对每天从实验室数据录入人员那里收到的 CSV 文件进行简单的数据分析。
然而,有一个问题。你沮丧地注意到实验室的 CSV 文件质量非常不一致。数据缺失,错别字丛生,而且文件经常需要重新输入,耗费大量时间。实验室主任也注意到了这一点,并且知道你是一位技艺高超的 Python 程序员,她认为你可能能够提供帮助。
你被委托编写一个解决方案,允许数据录入人员将实验室数据输入到 CSV 文件中,减少错误。你的应用程序需要简单,并且尽量减少错误的可能性。
评估问题
电子表格通常是需要跟踪数据的计算机用户的第一站。它们的表格布局和计算功能似乎使它们成为完成任务的理想选择。然而,随着数据集的增长和多个用户的添加,电子表格的缺点变得明显:它们不能强制数据完整性,它们的表格布局在处理稀疏或模糊数据的长行时可能会造成视觉混乱,如果用户不小心的话,他们可以轻松地删除或覆盖数据。
为了改善这种情况,你建议实现一个简单的 GUI 数据输入表单,将数据以我们需要的格式追加到 CSV 文件中。表单可以在多种方式上帮助改善数据完整性:
-
只允许输入正确类型的数据(例如,只允许在数字字段中输入数字)
-
限制选择只能是有效的选项
-
自动填充当前日期、时间等信息
-
验证输入的数据是否在预期范围内或与预期模式匹配
-
确保所有数据都已填写
通过实施这样的表单,我们可以大大减少数据录入人员输入的错误数量。
收集有关问题的信息
要构建数据输入表单应用程序,你需要收集关于它需要完成的任务的详细信息。幸运的是,你已经知道了等式的输出部分:你需要一个包含每个实验室地块上植物生长和每个地块的环境条件数据的 CSV 文件。你每天都在使用这些文件,所以你对字段布局非常熟悉。
然而,你并不知道关于数据或输入过程的一切;你需要与其他相关人员交谈,以获取更多信息。
首先,你需要更详细地了解正在记录的数据。这并不总是那么容易。软件在处理数据时需要绝对的、黑白分明的规则;而人们往往倾向于以一般性的方式思考他们的数据,并且通常在没有一些提示的情况下不考虑限制或边缘情况的确切细节。
作为程序员,你的工作是提出问题,以获取你所需要的信息。
你决定应该从实验室技术人员开始,了解他们正在收集的数据。你提出了以下问题:
-
每个字段的可接受值是什么?是否有任何字段受限于一组值?
-
每个数字字段代表什么单位?
-
数字字段是否真的只是数字字段?它们是否会需要字母或符号?
-
每个数字字段的可接受范围是多少?
-
你是如何记录数据的,需要多长时间?
数据不是唯一的考虑因素。如果我们正在制作一个帮助减少用户错误的程序,我们还必须了解这些用户以及他们的工作方式。在这个应用程序的情况下,我们的用户将是数据录入人员。我们需要向他们询问关于他们的需求和工作流程的问题,以了解如何为他们创建一个良好运行的应用程序。
我们列出了以下问题清单:
-
你输入的数据是以什么格式?
-
数据是何时接收并且多快被输入?最晚可能是什么时候输入?
-
是否有字段可以自动填充?用户是否能够覆盖自动值?
-
用户的整体技术能力如何?
-
你喜欢当前解决方案的什么?你不喜欢什么?
-
用户是否有视觉或手动障碍需要考虑?
最后,我们需要了解与操作我们的应用程序相关的技术——用于完成任务的计算机、网络、服务器和平台。
你决定添加以下问题,当你与数据录入人员会面时,你将自己评估:
-
数据录入使用什么样的计算机?
-
它运行在什么平台上?
-
它有多快或多强大?
-
这些系统上是否有 Python 可用?
-
有哪些 Python 库可用?
你发现了什么
你首先写下你知道的关于 ABQ 的基本信息:
-
你的 ABQ 设施有五个温室,每个温室都有不同的气候,标记为 A、B、C、D 和 E
-
每个温室有 20 个地块(标记为 1 到 20)
-
目前有四个种子样本,每个都用一个六位字符标签编码
-
每个样本的每个地块都种植了 20 颗种子,以及自己的环境传感器单元
正在收集的数据的信息
你与实验室技术人员的交谈揭示了很多关于数据的信息。每天四次,分别在 8:00、12:00、16:00 和 20:00,每个技术人员检查一两个实验室的地块。他们使用纸质表格记录每个地块的值,将所有值记录到小数点后两位。这通常需要每个实验室 30 到 40 分钟,整个过程通常需要 90 分钟。
每个地块都有一个环境传感器,用于检测地块的光线、温度和湿度。不幸的是,这些设备容易出现故障,单位上的设备
故障
指示灯会亮起。技术人员记录这个灯是否亮起,因为它会使环境数据无效。
最后,技术人员告诉你有关单位和字段的可接受范围,你记录在以下图表中:
字段 | 数据类型 | 备注 |
---|---|---|
日期 |
日期 |
数据收集日期。几乎总是当前日期 |
时间 |
时间 |
测量期间的开始时间。8:00、12:00、16:00 或 20:00 之一 |
实验室 |
字符 |
实验室 ID,将是 A 到 E |
技术人员 |
文本 |
记录数据的技术人员的姓名 |
地块 |
整数 |
地块 ID,从 1 到 20 |
种子样本 |
文本 |
种子样本的 ID 字符串。始终是包含数字 0 到 9 和大写字母 A 到 Z 的六位字符代码 |
故障 |
布尔 |
如果环境设备注册了故障,则为真,否则为假 |
湿度 |
小数 |
每立方米的绝对湿度,大约在 0.5 到 52.0 之间 |
光线 |
小数 |
地块中心的阳光量,单位为千勒克斯,介于 0 和 100 之间 |
温度 |
小数 |
摄氏度,不应低于 4 或高于 40 |
开花 |
整数 |
地块上的花朵数量必须是 0 或更多,但不太可能接近 1000 |
水果 |
整数 |
地块上的水果数量必须是 0 或更多,但不太可能接近 1000 |
植物 |
整数 |
生长植物的数量,介于 0 和 20 之间。 |
最大高度 |
小数 |
植物的最大高度(厘米)。至少为 0,不太可能接近 1,000。 |
中位高度 |
小数 |
样地内植物的中位高度(厘米)。至少为 0,不太可能接近 1,000 |
最小高度 |
小数 |
植物的最小高度(厘米)。至少为 0,不太可能接近 1,000 |
备注 |
长文本 |
关于植物、数据、仪器等的其他观察。 |
应用程序用户的信息
您与数据录入人员的会话为您提供了关于他们的工作流程、要求和技术的有用信息。
实验室技术人员在完成后交接他们的纸质表格。数据通常会立即输入,并且通常在交接当天就会完成。
技术人员目前正在使用 Debian Linux 工作站上的 LibreOffice 进行数据输入。使用复制和粘贴,他们可以批量填写重复数据,如日期、时间和技术人员。LibreOffice 的自动完成功能在文本字段中通常很有帮助,但有时会在数字字段中导致意外的数据错误。
正在使用的工作站已经使用了几年,但性能仍然良好。您有机会查看它,并发现 Python 和 Tkinter 已经安装。
总共有四名数据录入员,但一次只有一名工作;在采访这些员工时,您了解到其中一名员工有红绿色盲,另一名员工由于 RSI 问题难以使用鼠标。所有员工都具有合理的计算机素养。
记录规格要求
现在,您已经收集了关于应用程序的数据,是时候撰写一份规格说明了。软件规格说明可以从非常正式的、包括时间估计和截止日期的合同文件,到程序员打算构建的简单描述集合。规格说明的目的是为项目中的所有参与者提供开发人员将创建的参考点。它详细说明了要解决的问题、所需的功能以及程序应该做什么和不应该做什么的范围。
您的情景相当非正式,您的应用程序很简单,因此在这种情况下您不需要详细的正式规格说明。然而,对您所知道的基本描述将确保您、您的老板和用户都在同一页面上。
简要规格说明的内容
我们将从以下项目的概述开始撰写我们需要的内容:
-
描述:这是描述应用程序的主要目的、功能和目标的一两句话。将其视为程序的使命宣言。
-
所需功能:这一部分是程序需要具备的最基本功能的具体列表。它可以包括硬性要求,如详细的输出和输入格式,以及软性要求——无法量化实现的目标,但程序应该努力实现(例如,“尽量减少用户错误”)。
-
不需要的功能:这一部分是程序不需要执行的功能的列表;它存在的目的是澄清软件的范围,并确保没有人对应用程序期望不合理的事情。
-
限制:这是程序必须在其中运行的技术和人为约束的列表。
-
数据字典:这是应用程序将处理的数据字段及其参数的详细列表。这些内容可能会变得非常冗长,但在应用程序扩展和数据在其他上下文中被利用时,它们是关键的参考。
编写 ABQ 数据录入程序规格说明
您可以在您喜欢的文字处理器中编写规格说明,但理想情况下,规格说明应该是代码的一部分;它需要与代码一起保存,并与应用程序的任何更改同步。因此,我们将使用reStructuredText标记语言在我们的文本编辑器中编写它。
对于 Python 文档,reStructuredText 或 reST 是官方的标记语言。Python 社区鼓励使用 reST 来记录 Python 项目,并且 Python 社区中使用的许多打包和发布工具都期望 reST 格式。我们将在第五章“规划我们应用程序的扩展”中更深入地介绍 reST,但您可以在docutils.sourceforge.net/rst.html
找到官方文档。
让我们开始逐个部分编写我们的规范:
- 以应用程序的名称和简短描述开始规范。这应该包含程序目的的摘要,如下:
======================================
ABQ Data Entry Program specification
======================================
Description
-----------
The program is being created to minimize data entry errors for laboratory measurements.
- 现在,让我们列出要求。请记住,硬性要求是客观可实现的目标——输入和输出要求、必须进行的计算、必须存在的功能,而我们的软性要求是主观或尽力而为的目标。浏览上一节的发现,并考虑哪些需求属于哪种需求。
您应该得出类似以下的结论:
Functionality Required
----------------------
The program must:
* allow all relevant, valid data to be entered, as per the field chart
* 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 checks in
ISO format (Year-month-day)
- The CSV file must have all the fields as per the chart
* enforce correct datatypes per field
The program should try, whenever possible, to:
* enforce reasonable limits on data entered
* Auto-fill data
* Suggest likely correct values
* Provide a smooth and efficient workflow
- 接下来,我们将通过
Functionality Not Required
部分限制程序的范围。请记住,目前这只是一个输入表单;编辑或删除将在电子表格应用程序中处理。我们将明确如下:
Functionality Not Required
--------------------------
The program does not need to:
* Allow editing of data. This can be done in LibreOffice if necessary.
* Allow deletion of data.
- 对于
Limitations
部分,请记住我们有一些具有身体限制的用户,还有硬件和操作系统的限制。添加如下:
Limitations
-----------
The program must:
* Be efficiently operable by keyboard-only users.
* Be accessible to color blind users.
* Run on Debian Linux.
* Run acceptably on a low-end PC.
- 最后,数据字典,这本质上是我们之前制作的表格,但我们将分解范围、数据类型和单位以供快速参考,如下:
+------------+----------+------+--------------+---------------------+
|Field | Datatype | Units| Range |Descripton |
+============+==========+======+==============+=====================+
|Date |Date | | |Date of record |
+------------+----------+------+--------------+---------------------+
|Time |Time | |8, 12, 16, 20 |Time period |
+------------+----------+------+--------------+---------------------+
|Lab |String | | A - E |Lab ID |
+------------+----------+------+--------------+---------------------+
|Technician |String | | |Technician name |
+------------+----------+------+--------------+---------------------+
|Plot |Int | | 1 - 20 |Plot ID |
+------------+----------+------+--------------+---------------------+
|Seed |String | | |Seed sample ID |
|sample | | | | |
+------------+----------+------+--------------+---------------------+
|Fault |Bool | | |Fault on sensor |
+------------+----------+------+--------------+---------------------+
|Light |Decimal |klx | 0 - 100 |Light at plot |
+------------+----------+------+--------------+---------------------+
|Humidity |Decimal |g/m³ | 0.5 - 52.0 |Abs humidity at plot |
+------------+----------+------+--------------+---------------------+
|Temperature |Decimal |°C | 4 - 40 |Temperature at plot |
+------------+----------+------+--------------+---------------------+
|Blossoms |Int | | 0 - 1000 |# blossoms in plot |
+------------+----------+------+--------------+---------------------+
|Fruit |Int | | 0 - 1000 |# fruits in plot |
+------------+----------+------+--------------+---------------------+
|Plants |Int | | 0 - 20 |# plants in plot |
+------------+----------+------+--------------+---------------------+
|Max height |Decimal |cm | 0 - 1000 |Ht of tallest plant |
+------------+----------+------+--------------+---------------------+
|Min height |Decimal |cm | 0 - 1000 |Ht of shortest plant |
+------------+----------+------+--------------+---------------------+
|Median |Decimal |cm | 0 - 1000 |Median ht of plants |
|height | | | | |
+------------+----------+------+--------------+---------------------+
|Notes |String | | |Miscellaneous notes |
+------------+----------+------+--------------+---------------------+
这就是我们目前的规范!随着我们发现新的需求,规范很可能会增长、改变或发展复杂性。
设计应用程序
有了我们手头的规范和清晰的要求,现在是时候开始设计我们的解决方案了。我们将从表单 GUI 组件本身开始。
| ttk.Combobox
| 带有可选文本输入的下拉列表 | 在几个值之间进行选择以及文本输入 |
-
确定每个数据字段的适当
input
小部件 -
将相关项目分组以创建组织感
-
在表单上将我们的小部件分组布局
探索 Tkinter 输入小部件
与所有工具包一样,Tkinter 为不同类型的数据提供了各种input
小部件。然而,ttk
提供了额外的小部件类型,并增强了 Tkinter 的一些(但不是全部!)原生小部件。以下表格提供了关于哪些小部件最适合不同类型的数据输入的建议:
小部件 | 描述 | 用于 |
---|---|---|
ttk.Entry |
基本文本输入 | 单行字符串 |
ttk.Spinbox |
具有增量/减量箭头的文本输入 | 数字 |
Tkinter.Listbox |
带有选择列表的框 | 在几个值之间进行选择 |
Tkinter.OptionMenu |
带有选择列表的下拉列表 | 在几个值之间进行选择 |
我们将按照以下三个步骤为我们的表单创建一个基本设计: | ||
ttk.Checkbutton |
带标签的复选框 | 布尔值 |
ttk.Radiobutton |
类似复选框,但只能选择一组中的一个 | 在一组小部件中选择 |
Tkiner.Text |
多行文本输入框 | 长、多行字符串 |
Tkinter.Scale |
鼠标操作滑块 | 有界数值数据 |
让我们考虑哪些小部件适合需要输入的数据:
-
有几个
Decimal
字段,其中许多具有明确的边界范围,包括Min height
,Max height
,Median height
,Humidity
,Temperature
和Light
。您可以使用Scale
小部件进行操作,但对于精确的数据输入来说并不是很合适,因为它需要仔细的定位才能获得精确的值。它也是鼠标操作的,这违反了您的规范要求。相反,对于这些字段,请使用Spinbox
小部件。 -
还有一些
Int
字段,例如植物
,花朵
和水果
。同样,Spinbox
小部件是正确的选择。 -
有一些字段具有一组可能值—
时间
和实验室
。Radiobutton
或Listbox
小部件可能适用于这些字段,但两者都占用大量空间,并且不太友好,因为它们需要使用箭头键进行选择。还有OptionMenu
,但它也只能使用鼠标或箭头键。对于这些字段,应使用Combobox
小部件。 -
图表是一个棘手的情况。乍一看,它看起来像一个
Int
字段,但仔细想想。图表也可以用字母、符号或名称来标识。数字只是一组易于分配任意标识符的值。图表 ID
,就像实验室 ID
一样,是一组受限制的值;因此,在这里使用Combobox
小部件更合理。 -
注释
字段是多行文本,因此在这里使用Text
小部件是合适的。 -
有一个
Boolean
字段,故障
。它可以使用Radiobutton
或Combobox
来处理,但Checkbutton
是最佳选择—它紧凑且相对键盘友好。 -
其余行都是简单的单行字符字段。我们将使用
Entry
来处理这些字段。 -
您可能会对
日期
字段感到困惑。Tkinter 没有专门用于日期的小部件;因此,我们暂时将在这里使用通用的Entry
小部件。
我们的最终分析将如下所示:
字段 | 小部件类型 |
---|---|
花朵 |
ttk.Spinbox |
日期 |
ttk.Entry |
故障 |
ttk.Checkbutton |
水果 |
ttk.Spinbox |
湿度 |
ttk.Spinbox |
实验室 |
ttk.Combobox |
光线 |
ttk.Spinbox |
最大高度 |
ttk.Spinbox |
中位高度 |
ttk.Spinbox |
最小高度 |
ttk.Spinbox |
注释 |
Tkinter.Text |
植物 |
ttk.Spinbox |
图表 |
ttk.Combobox |
种子样本 |
ttk.Entry |
技术人员 |
ttk.Entry |
温度 |
ttk.Spinbox |
时间 |
ttk.Combobox |
对我们的字段进行分组
人们在没有特定顺序的大量输入面前往往会感到困惑。通过将输入表单分成相关字段的集合,您可以为用户做出很大的帮助。当然,这假设您的数据具有相关字段的集合,不是吗?
在查看了您的字段后,您确定了以下相关组:
-
日期
,实验室
,图表
,种子样本
,技术人员
和时间
字段是关于记录本身的标识数据或元数据。您可以将它们组合在一个标题下,如记录信息
。 -
花朵
,水果
,三个高度
字段和植物
字段都是与图表
字段中的植物有关的测量值。您可以将它们组合在一起,称为植物数据
。 -
湿度
,光线
,温度
和设备故障
字段都是来自环境传感器的信息。您可以将它们组合为环境数据
。 -
注释
字段可能与任何事物有关,因此它属于自己的类别。
在 Tkinter 中对前面的字段进行分组,我们可以在每组字段之间插入标签,但值得探索将小部件组合在一起的各种选项:
小部件 | 描述 |
---|---|
ttk.LabelFrame |
带有标签文本和可选边框的框架 |
ttk.NoteBook |
允许多个页面的选项卡小部件 |
Tkinter.PanedWindow |
允许在水平或垂直排列中有多个可调整大小的框架 |
我们不希望我们的表单跨多个页面,用户也不需要调整各个部分的大小,但LabelFrame
小部件非常适合我们的需求。
布置表单
到目前为止,我们知道我们有 17 个输入,分组如下:
-
记录信息
下的六个字段 -
环境数据
下的四个字段 -
植物数据
下的六个字段 -
一个大的
注释
字段
我们希望使用LabelFrame
来对前面的输入进行分组。
请注意,前三个部分中的两个有三个小部件。这表明我们可以将它们排列在一个三个项目横向的网格中。我们应该如何对每个组内的字段进行排序?
字段的排序似乎是一个微不足道的事项,但对于用户来说,它可能在可用性上产生重大影响。必须在表单中随意跳转以匹配其工作流程的用户更有可能出错。
正如您所了解的,数据是由实验室技术人员填写的纸质表格输入的。您已经获得了表格的副本,如下截图所示:
看起来项目大多按照我们的记录分组的方式进行分组,因此我们将使用此表单上的顺序来对我们的字段进行排序。这样,数据输入员就可以直接通过表单,而不必在屏幕上来回跳动。
在设计新应用程序以替换现有工作流程的某个部分时,了解和尊重该工作流程是很重要的。虽然我们必须调整工作流程以实际改进它,但我们不希望使某人的工作变得更加困难,只是为了使我们正在处理的部分更简单。
我们设计中的最后一个考虑是标签与字段的相对位置。在 UI 设计社区中,关于标签的最佳放置位置存在很多争论,但共识是以下两种选项中的一种最佳:
-
字段上方的标签
-
字段左侧的标签
您可以尝试绘制两者,看看哪个更适合您,但对于此应用程序,字段上方的标签可能会更好,原因如下:
-
由于字段和标签都是矩形形状,我们的表单将通过将它们堆叠在一起更加紧凑。
-
这样做起来要容易得多,因为我们不必找到适用于所有标签的标签宽度,而不会使它们与字段之间的距离太远
唯一的例外是复选框字段;复选框通常标记在小部件的右侧。
花点时间用纸和铅笔或绘图程序制作一个表单的草图。您的表单应如下所示:
布局应用程序
设计好您的表单后,现在是考虑应用程序 GUI 的其余部分的时候了:
-
您需要一个保存按钮来触发输入数据的存储
-
有时,我们可能需要向用户提供状态信息;应用程序通常有一个状态栏,用于显示这些类型的消息
-
最后,最好有一个标题来指示表单是什么
将以下内容添加到我们的草图中,我们得到了以下截图:
看起来不错!这绝对是一个我们可以在 Tkinter 中实现的表单。您的最后一步是向用户和主管展示这些设计,以获取任何反馈或批准。
尽量让利益相关者参与到应用程序设计过程中。这样可以减少您以后不得不回头重新设计应用程序的可能性。
总结
在这一章中,您已经完成了应用程序开发的前两个阶段:了解问题和设计解决方案。您学会了如何通过采访用户和检查数据和要求来开发应用程序规范,为用户创建最佳的表单布局,并了解了 Tkinter 中可用的小部件,用于处理不同类型的输入数据。最重要的是,您学会了开发应用程序不是从编码开始,而是从研究和规划开始。
在下一章中,您将使用 Tkinter 和 Python 创建您设计的基本实现。我们将熟悉创建表单所需的 Tkinter 小部件,构建表单,并将表单放置在应用程序中。我们还将学习如何使我们的表单触发回调操作,并发现如何构建我们的代码以确保效率和一致性。
第三章:使用 Tkinter 和 ttk 小部件创建基本表单
好消息!您的设计已经得到主管的审查和批准。现在是时候开始实施了!
在本章中,您将涵盖以下主题:
-
根据设计评估您的技术选择
-
了解我们选择的 Tkinter 和
ttk
小部件 -
实现和测试表单和应用程序
让我们开始编码吧!
评估我们的技术选择
我们对设计的第一次实现将是一个非常简单的应用程序,它提供了规范的核心功能和很少的其他功能。这被称为最小可行产品或MVP。一旦我们建立了 MVP,我们将更好地了解如何将其发展成最终产品。
在我们开始之前,让我们花点时间评估我们的技术选择。
选择技术
当然,我们将使用 Python 和 Tkinter 构建这个表单。然而,值得问一下,Tkinter 是否真的是应用程序的良好技术选择。在选择用于实现此表单的 GUI 工具包时,我们需要考虑以下几点:
-
您目前的专业知识和技能:您的专业是 Python,但在创建 GUI 方面经验不足。为了最快的交付时间,您需要一个能够很好地与 Python 配合使用并且不难学习的选项。您还希望选择一些已经建立并且稳定的东西,因为您没有时间跟上工具包的新发展。Tkinter 在这里适用。
-
目标平台:您将在 Windows PC 上开发应用程序,但它需要在 Debian Linux 上运行,因此 GUI 的选择应该是跨平台的。它将在一台又老又慢的计算机上运行,因此您的程序需要节约资源。Tkinter 在这里也适用。
-
应用功能:您的应用程序需要能够显示基本表单字段,验证输入的数据,并将其写入 CSV。Tkinter 可以处理这些前端要求,Python 可以轻松处理 CSV 文件。
鉴于 Python 的可用选项,Tkinter 是一个不错的选择。它学习曲线短,轻量级,在您的开发和目标平台上都很容易获得,并且包含了表单所需的功能。
Python 还有其他用于 GUI 开发的选项,包括PyQT、Kivy和wxPython。与 Tkinter 相比,它们各自有不同的优势和劣势,但如果发现 Tkinter 不适合某个项目,其中一个可能是更好的选择。
探索 Tkinter 小部件
当我们设计应用程序时,我们挑选了一个小部件类,它最接近我们需要的每个字段。这些是Entry
、Spinbox
、Combobox
、Checkbutton
和Text
小部件。我们还确定我们需要Button
和LabelFrame
小部件来实现应用程序布局。在我们开始编写我们的类之前,让我们来看看这些小部件。
我们的一些小部件在 Tkinter 中,另一些在ttk
主题小部件集中,还有一些在两个库中都有。我们更喜欢ttk
版本,因为它们在各个平台上看起来更好。请注意我们从哪个库导入每个小部件。
输入小部件
ttk.Entry
小部件是一个基本的、单行字符输入,如下截图所示:
您可以通过执行以下代码来创建一个输入:
my_entry = ttk.Entry(parent, textvariable=my_text_var)
在上述代码中,ttk.Entry
的常用参数如下:
-
parent
:此参数为输入设置了parent
小部件。 -
textvariable
:这是一个 TkinterStringVar
变量,其值将绑定到此input
小部件。 -
show
:此参数确定在您输入框中键入时将显示哪个字符。默认情况下,它是您键入的字符,但这可以被替换(例如,对于密码输入,您可以指定*
或点来代替显示)。 -
Entry
:像所有的ttk
小部件一样,此小部件支持额外的格式和样式选项。
在所有上述参数中,使用textvariable
参数是可选的;没有它,我们可以使用其get()
方法提取Entry
小部件中的值。然而,将变量绑定到我们的input
小部件具有一些优势。首先,我们不必保留或传递对小部件本身的引用。这将使得在后面的章节中更容易将我们的软件重新组织为单独的模块。此外,对输入值的更改会自动传播到变量,反之亦然。
Spinbox 小部件
ttk.Spinbox
小部件向常规Entry
小部件添加了增量和减量按钮,使其适用于数字数据。
在 Python 3.7 之前,Spinbox
只在 Tkinter 中可用,而不是在ttk
中。如果您使用的是 Python 3.6 或更早版本,请改用Tkinter.Spinbox
小部件。示例代码使用了 Tkinter 版本以确保兼容性。
创建Spinbox
小部件如下:
my_spinbox = tk.Spinbox(
parent,
from_=0.5,
to=52.0,
increment=.01,
textvariable=my_double_var)
如前面的代码所示,Spinbox
小部件需要一些额外的构造函数参数来控制增量和减量按钮的行为,如下所示:
-
from_
:此参数确定箭头递减到的最低值。需要添加下划线,因为from
是 Python 关键字;在 Tcl/Tk
中只是from
。 -
to
:此参数确定箭头递增到的最高值。 -
increment
:此参数表示箭头递增或递减的数量。 -
values
:此参数接受一个可以通过递增的字符串或数字值列表。
请注意,如果使用了from_
和to
,则两者都是必需的;也就是说,您不能只指定一个下限,这样做将导致异常或奇怪的行为。
查看以下截图中的Spinbox
小部件:
Spinbox
小部件不仅仅是用于数字,尽管这主要是我们将要使用它的方式。它也可以接受一个字符串列表,可以使用箭头按钮进行选择。因为它可以用于字符串或数字,所以textvariable
参数接受StringVar
、IntVar
或DoubleVar
数据类型。
请注意,这些参数都不限制可以输入到Spinbox
小部件中的内容。它只不过是一个带有按钮的Entry
小部件,您不仅可以输入有效范围之外的值,还可以输入字母和符号。这样做可能会导致异常,如果您已将小部件绑定到非字符串变量。
Combobox 小部件
ttk.Combobox
参数是一个Entry
小部件,它添加了一个下拉选择菜单。要填充下拉菜单,只需传入一个带有用户可以选择的字符串列表的values
参数。
您可以执行以下代码来创建一个Combobox
小部件:
combobox = ttk.Combobox(
parent, textvariable=my_string_var,
values=["Option 1", "Option 2", "Option 3"])
上述代码将生成以下小部件:
如果您习惯于 HTML 的
<SELECT>
小部件或其他工具包中的下拉小部件,ttk.Combobox
小部件可能对您来说有些陌生。它实际上是一个带有下拉菜单以选择一些预设字符串的Entry
小部件。就像Spinbox
小部件一样,它不限制可以输入的值。
Checkbutton 小部件
ttk.Checkbutton
小部件是一个带有标签的复选框,用于输入布尔数据。与Spinbox
和Combobox
不同,它不是从Entry
小部件派生的,其参数如下所示:
-
text
:此参数设置小部件的标签。 -
variable
:此参数是BooleanVar
,绑定了复选框的选中状态。 -
textvariable
:与基于Entry
的小部件不同,此参数可用于将变量绑定到小部件的标签文本。您不会经常使用它,但您应该知道它存在,以防您错误地将变量分配给它。
您可以执行以下代码来创建一个Checkbutton
小部件:
my_checkbutton = ttk.Checkbutton(
parent, text="Check to make this option True",
variable=my_boolean_var)
Checkbox
小部件显示为一个带有标签的可点击框,如下截图所示:
文本小部件
Text
小部件不仅仅是一个多行Entry
小部件。它具有强大的标记系统,允许您实现多彩的文本,超链接样式的可点击文本等。与其他小部件不同,它不能绑定到 Tkinter 的StringVar
,因此设置或检索其内容需要通过其get()
、insert()
和delete()
方法来完成。
在使用这些方法进行读取或修改时,您需要传入一个或两个索引值来选择您要操作的字符或字符范围。这些索引值是字符串,可以采用以下任何格式:
-
由点分隔的行号和字符号。行号从 1 开始,字符从 0 开始,因此第一行上的第一个字符是
1.0
,而第四行上的第十二个字符将是4.11
。 -
end
字符串或 Tkinter 常量END
,表示字段的结束。 -
一个数字索引加上单词
linestart
、lineend
、wordstart
和wordend
中的一个,表示相对于数字索引的行或单词的开始或结束。例如,6.2 wordstart
将是包含第六行第三个字符的单词的开始;2.0 lineend
将是第二行的结束。 -
前述任何一个,加上加号或减号运算符,以及一定数量的字符或行。例如,
2.5 wordend - 1 chars
将是第二行第六个字符所在的单词结束前的字符。
以下示例显示了使用Text
小部件的基础知识:
# create the widget. Make sure to save a reference.
mytext = tk.Text(parent)
# 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.
# Note that there is always a newline character
# at the end of the input, so we backup 2 chars.
mytext.delete('end - 2 chars')
运行上述代码,您将获得以下输出:
在这个表单中的Notes
字段中,我们只需要一个简单的多行Entry
;所以,我们现在只会使用Text
小部件的最基本功能。
按钮小部件
ttk.Button
小部件也应该很熟悉。它只是一个可以用鼠标或空格键单击的简单按钮,如下截图所示:
就像Checkbutton
小部件一样,此小部件使用text
和textvariable
配置选项来控制按钮上的标签。Button
对象不接受variable
,但它们确实接受command
参数,该参数指定单击按钮时要运行的 Python 函数。
以下示例显示了Button
对象的使用:
tvar = tk.StringVar()
def swaptext():
if tvar.get() == 'Hi':
tvar.set('There')
else:
tvar.set('Hi')
my_button = ttk.Button(parent, textvariable=tvar, command=swaptext)
LabelFrame 小部件
我们选择了ttk.LabelFrame
小部件来对我们的应用程序中的字段进行分组。顾名思义,它是一个带有标签的Frame
(通常带有一个框)。LabelFrame
小部件在构造函数中接受一个text
参数,用于设置标签,该标签位于框架的左上角。
Tkinter 和ttk
包含许多其他小部件,其中一些我们将在本书的后面遇到。Python 还附带了一个名为tix
的小部件库,其中包含几十个小部件。但是,tix
已经非常过时,我们不会在本书中涵盖它。不过,您应该知道它的存在。
实现应用程序
要启动我们的应用程序脚本,请创建一个名为ABQ data entry
的文件夹,并在其中创建一个名为data_entry_app.py
的文件。
我们将从以下样板代码开始:
import tkinter as tk
from tkinter import ttk
# Start coding here
class Application(tk.Tk):
"""Application root window"""
if __name__ == "__main__":
app = Application()
app.mainloop()
运行此脚本应该会给您一个空白的 Tk 窗口。
使用 LabelInput 类节省一些时间
我们表单上的每个input
小部件都有一个与之关联的标签。在一个小应用程序中,我们可以分别创建标签和输入,然后将每个标签添加到parent
框架中,如下所示:
form = Frame()
label = Label(form, text='Name')
name_input = Entry(form)
label.grid(row=0, column=0)
name_input.grid(row=1, column=0)
这样做很好,你可以为你的应用程序这样做,但它也会创建大量乏味、重复的代码,并且移动输入意味着改变两倍的代码。由于label
和input
小部件是一起的,创建一个小的包装类来包含它们并建立一些通用默认值会很聪明。
在编码时,要注意包含大量重复代码的部分。您通常可以将此代码抽象为类、函数或循环。这样做不仅可以节省您的输入,还可以确保一致性,并减少您需要维护的代码总量。
让我们看看以下步骤:
- 我们将这个类称为
LabelInput
,并在我们的代码顶部定义它,就在Start coding here
注释下面:
"""Start coding here"""
class LabelInput(tk.Frame):
"""A widget containing a label and input together."""
def __init__(self, parent, label='', input_class=ttk.Entry,
input_var=None, input_args=None, label_args=None,
**kwargs):
super().__init__(parent, **kwargs)
input_args = input_args or {}
label_args = label_args or {}
self.variable = input_var
- 我们将基于
Tkinter.Frame
类,就像我们在HelloWidget
中所做的一样。我们的构造函数接受以下参数:
-
parent
:这个参数是对parent
小部件的引用;我们创建的所有小部件都将以此作为第一个参数。 -
label
:这是小部件标签部分的文本。 -
input_class
:这是我们想要创建的小部件类。它应该是一个实际的可调用类对象,而不是一个字符串。如果留空,将使用ttk.Entry
。 -
input_var
:这是一个 Tkinter 变量,用于分配输入。这是可选的,因为有些小部件不使用变量。 -
input_args
:这是input
构造函数的任何额外参数的可选字典。 -
label_args
:这是label
构造函数的任何额外参数的可选字典。 -
**kwargs
:最后,我们在**kwargs
中捕获任何额外的关键字参数。这些将传递给Frame
构造函数。
- 在构造函数中,我们首先调用
super().__init__()
,并传入parent
和额外的关键字参数。然后,我们确保input_args
和label_args
都是字典,并将我们的输入变量保存为self.variable
的引用。
不要诱使使用空字典({}
)作为方法关键字参数的默认值。如果这样做,当方法定义被评估时会创建一个字典,并被类中的所有对象共享。这会对您的代码产生一些非常奇怪的影响!接受的做法是对于可变类型如字典和列表,传递None
,然后在方法体中用空容器替换None
。
- 我们希望能够使用任何类型的
input
小部件,并在我们的类中适当处理它;不幸的是,正如我们之前学到的那样,不同小部件类的构造函数参数和行为之间存在一些小差异,比如Combobox
和Checkbutton
使用它们的textvariable
参数的方式。目前,我们只需要区分Button
和Checkbutton
等按钮小部件处理变量和标签文本的方式。为了处理这个问题,我们将添加以下代码:
if input_class in (ttk.Checkbutton, ttk.Button,
ttk.Radiobutton):
input_args["text"] = label
input_args["variable"] = input_var
else:
self.label = ttk.Label(self, text=label, **label_args)
self.label.grid(row=0, column=0, sticky=(tk.W + tk.E))
input_args["textvariable"] = input_var
- 对于按钮类型的小部件,我们以不同的方式执行以下任务:
-
我们不是添加一个标签,而是设置
text
参数。所有按钮都使用这个参数来添加一个label
到小部件中。 -
我们将变量分配给
variable
,而不是分配给textvariable
。
-
对于其他
input
类,我们设置textvariable
并创建一个Label
小部件,将其添加到LabelInput
类的第一行。 -
现在我们需要创建
input
类,如下所示:
self.input = input_class(self, **input_args)
self.input.grid(row=1, column=0, sticky=(tk.W + tk.E))
-
这很简单:我们用扩展为关键字参数的
input_args
字典调用传递给构造函数的input_class
类。然后,我们将其添加到第1
行的网格中。 -
最后,我们配置
grid
布局,将我们的单列扩展到整个小部件,如下所示:
self.columnconfigure(0, weight=1)
- 当创建自定义小部件时,我们可以做的一件好事是为其几何管理器方法添加默认值,这将节省我们大量的编码。例如,我们将希望所有的
LabelInput
对象填充它们所放置的整个网格单元。我们可以通过覆盖方法将sticky=(tk.W + tk.E)
添加为默认值,而不是在每个LabelInput.grid()
调用中添加它:
def grid(self, sticky=(tk.E + tk.W), **kwargs):
super().grid(sticky=sticky, **kwargs)
通过将其定义为默认参数,我们仍然可以像往常一样覆盖它。所有input
小部件都有一个get()
方法,返回它们当前的值。为了节省一些重复的输入,我们将在LabelInput
类中实现一个get()
方法,它将简单地将请求传递给输入或其变量。接下来添加这个方法:
def get(self):
try:
if self.variable:
return self.variable.get()
elif type(self.input) == tk.Text:
return self.input.get('1.0', tk.END)
else:
return self.input.get()
except (TypeError, tk.TclError):
# happens when numeric fields are empty.
return ''
我们在这里使用try
块,因为在某些条件下,例如当数字字段为空时(空字符串无法转换为数字值),Tkinter 变量将抛出异常,如果调用get()
。在这种情况下,我们将简单地从表单中返回一个空值。此外,我们需要以不同的方式处理tk.Text
小部件,因为它们需要一个范围来检索文本。我们总是希望从这个表单中获取所有文本,所以我们在这里指定。作为get()
的补充,我们将实现一个set()
方法,将请求传递给变量或小部件,如下所示:
def set(self, value, *args, **kwargs):
if type(self.variable) == tk.BooleanVar:
self.variable.set(bool(value))
elif self.variable:
self.variable.set(value, *args, **kwargs)
elif type(self.input) in (ttk.Checkbutton,
ttk.Radiobutton):
if value:
self.input.select()
else:
self.input.deselect()
elif type(self.input) == tk.Text:
self.input.delete('1.0', tk.END)
self.input.insert('1.0', value)
else: # input must be an Entry-type widget with no variable
self.input.delete(0, tk.END)
self.input.insert(0, value)
.set()
方法抽象了各种 Tkinter 小部件设置其值的差异:
-
如果我们有一个
BooleanVar
类的变量,将value
转换为bool
并设置它。BooleanVar.set()
只接受bool
,而不是其他假值或真值。这确保我们的变量只获得实际的布尔值。 -
如果我们有任何其他类型的变量,只需将
value
传递给其.set()
方法。 -
如果我们没有变量,并且是一个按钮样式的类,我们使用
.select()
和.deselect()
方法来根据变量的真值选择和取消选择按钮。 -
如果它是一个
tk.Text
类,我们可以使用它的.delete
和.insert
方法。 -
否则,我们使用
input
的.delete
和.insert
方法,这些方法适用于Entry
、Spinbox
和Combobox
类。我们必须将这个与tk.Text
输入分开,因为索引值的工作方式不同。
这可能并不涵盖每种可能的input
小部件,但它涵盖了我们计划使用的以及我们以后可能需要的一些。虽然构建LabelInput
类需要很多工作,但我们将看到现在定义表单要简单得多。
构建表单
我们不直接在主应用程序窗口上构建我们的表单,而是将我们的表单构建为自己的对象。最初,这样做可以更容易地维护一个良好的布局,而在将来,这将使我们更容易扩展我们的应用程序。让我们执行以下步骤来构建我们的表单:
- 一旦再次子类化
Tkinter.Frame
来构建这个模块。在LabelInput
类定义之后,开始一个新的类,如下所示:
class DataRecordForm(tk.Frame):
"""The input form for our widgets"""
def __init__(self, parent, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
这应该现在很熟悉了。我们子类化Frame
,定义我们的构造函数,并调用super().__init__()
来初始化底层的Frame
对象。
- 现在我们将创建一个结构来保存表单中所有
input
小部件的引用,如下所示:
# A dict to keep track of input widgets
self.inputs = {}
在创建input
小部件时,我们将在字典中存储对它们的引用,使用字段名作为键。这将使我们以后更容易检索所有的值。
添加 LabelFrame 和其他小部件
我们的表单被分成了带有标签和框的各个部分。对于每个部分,我们将创建一个LabelFrame
小部件,并开始向其中添加我们的LabelInput
小部件,执行以下步骤:
- 让我们从执行以下代码开始记录信息框架:
recordinfo = tk.LabelFrame(self, text="Record Information")
记住,LabelFrame
的text
参数定义了标签的文本。这个小部件将作为记录信息组中所有输入的parent
小部件传递。
- 现在,我们将添加
input
小部件的第一行,如下所示:
self.inputs['Date'] = LabelInput(recordinfo, "Date",
input_var=tk.StringVar())
self.inputs['Date'].grid(row=0, column=0)
self.inputs['Time'] = LabelInput(recordinfo, "Time",
input_class=ttk.Combobox, input_var=tk.StringVar(),
input_args={"values": ["8:00", "12:00", "16:00", "20:00"]})
self.inputs['Time'].grid(row=0, column=1)
self.inputs['Technician'] = LabelInput(recordinfo,
"Technician",
input_var=tk.StringVar())
self.inputs['Technician'].grid(row=0, column=2)
-
Date
和Technician
输入是简单的文本输入;我们只需要将parent
,label
和input
变量传递给我们的LabelInput
构造函数。对于Time
输入,我们指定一个可能值的列表,这些值将用于初始化Combobox
小部件。 -
让我们按照以下方式处理第 2 行:
# line 2
self.inputs['Lab'] = LabelInput(recordinfo, "Lab",
input_class=ttk.Combobox, input_var=tk.StringVar(),
input_args={"values": ["A", "B", "C", "D", "E"]})
self.inputs['Lab'].grid(row=1, column=0)
self.inputs['Plot'] = LabelInput(recordinfo, "Plot",
input_class=ttk.Combobox, input_var=tk.IntVar(),
input_args={"values": list(range(1, 21))})
self.inputs['Plot'].grid(row=1, column=1)
self.inputs['Seed sample'] = LabelInput(
recordinfo, "Seed sample", input_var=tk.StringVar())
self.inputs['Seed sample'].grid(row=1, column=2)
recordinfo.grid(row=0, column=0, sticky=tk.W + tk.E)
- 这里,我们有两个
Combobox
小部件和另一个Entry
。这些创建方式与第 1 行中的方式类似。Plot
的值只需要是 1 到 20 的数字列表;我们可以使用 Python 内置的range()
函数创建它。完成记录信息后,我们通过调用grid()
将其LabelFrame
添加到表单小部件。其余字段以基本相同的方式定义。例如,我们的环境数据将如下所示:
# Environment Data
environmentinfo = tk.LabelFrame(self, text="Environment Data")
self.inputs['Humidity'] = LabelInput(
environmentinfo, "Humidity (g/m³)",
input_class=tk.Spinbox, input_var=tk.DoubleVar(),
input_args={"from_": 0.5, "to": 52.0, "increment": .01})
self.inputs['Humidity'].grid(row=0, column=0)
- 在这里,我们添加了我们的第一个
Spinbox
小部件,指定了有效范围和增量;您可以以相同的方式添加Light
和Temperature
输入。请注意,我们的grid()
坐标已经从0, 0
重新开始;这是因为我们正在开始一个新的父对象,所以坐标重新开始。
所有这些嵌套的网格可能会让人困惑。请记住,每当在小部件上调用.grid()
时,坐标都是相对于小部件父级的左上角。父级的坐标是相对于其父级的,依此类推,直到根窗口。
这一部分还包括唯一的Checkbutton
小部件:
self.inputs['Equipment Fault'] = LabelInput(
environmentinfo, "Equipment Fault",
input_class=ttk.Checkbutton,
input_var=tk.BooleanVar())
self.inputs['Equipment Fault'].grid(
row=1, column=0, columnspan=3)
- 对于
Checkbutton
,没有真正的参数可用,尽管请注意我们使用BooleanVar
来存储其值。现在,我们继续进行植物数据部分:
plantinfo = tk.LabelFrame(self, text="Plant Data")
self.inputs['Plants'] = LabelInput(
plantinfo, "Plants",
input_class=tk.Spinbox,
input_var=tk.IntVar(),
input_args={"from_": 0, "to": 20})
self.inputs['Plants'].grid(row=0, column=0)
self.inputs['Blossoms'] = LabelInput(
plantinfo, "Blossoms",
input_class=tk.Spinbox,
input_var=tk.IntVar(),
input_args={"from_": 0, "to": 1000})
self.inputs['Blossoms'].grid(row=0, column=1)
请注意,与我们的十进制Spinboxes
不同,我们没有为整数字段设置增量;这是因为它默认为1.0
,这正是我们想要的整数字段。
- 尽管从技术上讲
Blossoms
没有最大值,但我们也使用1000
作为最大值;我们的Lab
Technicians
向我们保证它永远不会接近 1000。由于Spinbox
需要to
和from_
,如果我们使用其中一个,我们将使用这个值。
您还可以指定字符串infinity
或-infinity
作为值。这些可以转换为float
值,其行为是适当的。
Fruit
字段和三个Height
字段将与这些基本相同。继续创建它们,确保遵循适当的input_args
值和input_var
类型的数据字典。通过添加以下注释完成我们的表单字段:
# Notes section
self.inputs['Notes'] = LabelInput(
self, "Notes",
input_class=tk.Text,
input_args={"width": 75, "height": 10}
)
self.inputs['Notes'].grid(sticky="w", row=3, column=0)
- 这里不需要
LabelFrame
,因此我们只需将注释的LabelInput
框直接添加到表单中。Text
小部件采用width
和height
参数来指定框的大小。我们将为注释输入提供一个非常大的尺寸。
从我们的表单中检索数据
现在我们已经完成了表单,我们需要一种方法来从中检索数据,以便应用程序对其进行处理。我们将创建一个返回表单数据字典的方法,并且与我们的LabelInput
对象一样,遵循 Tkinter 的约定将其命名为get()
。
在你的表单类中添加以下方法:
def get(self):
data = {}
for key, widget in self.inputs.items():
data[key] = widget.get()
return data
代码很简单:我们遍历包含我们的LabelInput
对象的实例的inputs
对象,并通过对每个变量调用get()
来构建一个新字典。
这段代码展示了可迭代对象和一致命名方案的强大之处。如果我们将输入存储为表单的离散属性,或者忽略了规范化get()
方法,我们的代码将不够优雅。
重置我们的表单
我们的表单类几乎完成了,但还需要一个方法。在每次保存表单后,我们需要将其重置为空字段;因此,让我们通过执行以下步骤添加一个方法来实现:
- 将此方法添加到表单类的末尾:
def reset(self):
for widget in self.inputs.values():
widget.set('')
-
与我们的
get()
方法一样,我们正在遍历input
字典并将每个widget
设置为空值。 -
为了确保我们的应用程序行为一致,我们应该在应用程序加载后立即调用
reset()
,清除我们可能不想要的任何Tk
默认设置。 -
回到
__init__()
的最后一行,并添加以下代码行:
self.reset()
构建我们的应用程序类
让我们看看构建我们的应用程序类的以下步骤:
- 在
Application
类文档字符串(读作Application root window
的行)下面移动,并开始为Application
编写一个__init__()
方法,如下所示:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.title("ABQ Data Entry Application")
self.resizable(width=False, height=False)
- 再次调用
super().__init__()
,传递任何参数或关键字参数。
请注意,我们这里没有传入parent
小部件,因为Application
是根窗口。
-
我们调用
.title()
来设置我们应用程序的标题字符串;这不是必需的,但它肯定会帮助运行多个应用程序的用户快速在他们的桌面环境中找到我们的应用程序。 -
我们还通过调用
self.resizable
禁止窗口的调整大小。这也不是严格必要的,但它使我们暂时更容易控制我们的布局。让我们开始添加我们的应用程序组件,如下所示:
ttk.Label(
self,
text="ABQ Data Entry Application",
font=("TkDefaultFont", 16)
).grid(row=0)
- 应用程序将从顶部开始,显示一个
Label
对象,以比正常字体大的字体显示应用程序的名称。请注意,我们这里没有指定column
;我们的主应用程序布局只有一列,所以没有必要严格指定column
,因为它默认为0
。接下来,我们将添加我们的DataRecordForm
如下:
self.recordform = DataRecordForm(self)
self.recordform.grid(row=1, padx=10)
-
我们使用
padx
参数向左和向右添加了 10 像素的填充。这只是在表单的边缘周围添加了一些空白,使其更易读。 -
接下来,让我们添加保存按钮,如下所示:
self.savebutton = ttk.Button(self, text="Save",
command=self.on_save)
self.savebutton.grid(sticky=tk.E, row=2, padx=10)
- 我们给按钮一个
command
值为self.on_save
;我们还没有编写该方法,所以在运行代码之前我们需要这样做。
当编写用于 GUI 事件的方法或函数时,惯例是使用格式on_EVENTNAME
,其中EVENTNAME
是描述触发它的事件的字符串。我们也可以将此方法命名为on_save_button_click()
,但目前on_save()
就足够了。
- 最后,让我们添加状态栏,如下所示:
# status bar
self.status = tk.StringVar()
self.statusbar = ttk.Label(self, textvariable=self.status)
self.statusbar.grid(sticky=(tk.W + tk.E), row=3, padx=10)
- 我们首先创建一个名为
self.status
的字符串变量,并将其用作ttk.Label
的textvariable
。我们的应用程序只需要在类内部调用self.status.set()
来更新状态。通过将状态栏添加到应用程序小部件的底部,我们的 GUI 完成了。
保存到 CSV
当用户点击保存时,需要发生以下一系列事件:
-
打开一个名为
abq_data_record_CURRENTDATE.csv
的文件 -
如果文件不存在,它将被创建,并且字段标题将被写入第一行
-
数据字典从
DataEntryForm
中检索 -
数据被格式化为 CSV 行并附加到文件
-
表单被清除,并通知用户记录已保存
我们将需要一些其他 Python 库来帮助我们完成这个任务:
-
首先,我们需要一个用于我们文件名的日期字符串。Python 的
datetime
库可以帮助我们。 -
接下来,我们需要能够检查文件是否存在。Python 的
os
库有一个用于此的函数。 -
最后,我们需要能够写入 CSV 文件。Python 在标准库中有一个 CSV 库,这里非常适用。
让我们看看以下步骤:
- 回到文件顶部,并在 Tkinter 导入之前添加以下导入:
from datetime import datetime
import os
import csv
- 现在,回到
Application
类,并开始on_save()
方法,如下所示:
def on_save(self):
datestring = datetime.today().strftime("%Y-%m-%d")
filename = "abq_data_record_{}.csv".format(datestring)
newfile = not os.path.exists(filename)
-
我们要做的第一件事是创建我们的日期字符串。
datetime.today()
方法返回当前日期的午夜datetime
;然后我们使用strftime()
将其格式化为年-月-日的 ISO 日期字符串(使用数字 01 到 12 表示月份)。这将被插入到我们规范的文件名模板中,并保存为filename
。 -
接下来,我们需要确定文件是否已经存在;
os.path.exists()
将返回一个布尔值,指示文件是否存在;我们对这个值取反,并将其存储为newfile
。 -
现在,让我们从
DataEntryForm
获取数据:
data = self.recordform.get()
- 获得数据后,我们需要打开文件并将数据写入其中。添加以下代码:
with open(filename, 'a') as fh:
csvwriter = csv.DictWriter(fh, fieldnames=data.keys())
if newfile:
csvwriter.writeheader()
csvwriter.writerow(data)
with open(filename, 'a') as fh:
语句以追加模式打开我们生成的文件名,并为我们提供一个名为fh
的文件句柄。追加模式意味着我们不能读取或编辑文件中的任何现有行,只能添加到文件的末尾,这正是我们想要的。
with
关键字与上下文管理器对象一起使用,我们调用open()
返回的就是这样的对象。上下文管理器是特殊的对象,它定义了在with
块之前和之后要运行的代码。通过使用这种方法打开文件,它们将在块结束时自动正确关闭。
-
接下来,我们使用文件句柄创建一个
csv.DictWriter
对象。这个对象将允许我们将数据字典写入 CSV 文件,将字典键与 CSV 的标题行标签匹配。这对我们来说比默认的 CSV 写入对象更好,后者每次都需要正确顺序的字段。 -
要配置这一点,我们首先必须将
fieldnames
参数传递给DictWriter
构造函数。我们的字段名称是从表单中获取的data
字典的键。如果我们正在处理一个新文件,我们需要将这些字段名称写入第一行,我们通过调用DictWriter.writeheader()
来实现。 -
最后,我们使用
DictWriter
对象的.writerow()
方法将我们的data
字典写入新行。在代码块的末尾,文件会自动关闭和保存。
完成和测试
此时,您应该能够运行应用程序,输入数据,并将其保存到 CSV 文件中。试试看!您应该会看到类似以下截图的东西:
也许您注意到的第一件事是,单击保存没有明显的效果。表单保持填充状态,没有任何指示已经完成了什么。我们应该修复这个问题。
我们将执行以下两件事来帮助这里:
- 首先,在我们的状态栏中放置一个通知,说明记录已保存以及本次会话已保存多少条记录。对于第一部分,将以下代码行添加到
Application
构造函数的末尾,如下所示:
self.records_saved = 0
- 其次,在保存后清除表单,以便可以开始下一个记录。然后将以下代码行添加到
on_save()
方法的末尾,如下所示:
self.records_saved += 1
self.status.set(
"{} records saved this session".format(self.records_saved))
这段代码设置了一个计数器变量,用于跟踪自应用程序启动以来保存的记录数。
-
保存文件后,我们增加值,然后设置我们的状态以指示已保存多少条记录。用户将能够看到这个数字增加,并知道他们的按钮点击已经做了一些事情。
-
接下来,我们将在保存后重置表单。将以下代码追加到
Application.on_save()
的末尾,如下所示:
self.recordform.reset()
这将清空表单,并准备好下一个记录的输入。
- 现在,再次运行应用程序。它应该清除并在保存记录时给出状态指示。
摘要
嗯,我们在这一章取得了长足的进步!您将您的设计从规范和一些图纸转化为一个运行的应用程序,它已经涵盖了您需要的基本功能。您学会了如何使用基本的 Tkinter 和ttk
小部件,并创建自定义小部件,以节省大量重复的工作。
在下一章中,我们将解决input
小部件的问题。我们将学习如何自定义input
小部件的行为,防止错误的按键,并验证数据,以确保它在我们规范中规定的容差范围内。在此过程中,我们将深入研究 Python 类,并学习更多高效和优雅的代码技巧。
第四章:通过验证和自动化减少用户错误
我们的表单有效,主管和数据输入人员都对表单设计感到满意,但我们还没有准备好投入生产!我们的表单还没有履行承诺的任务,即防止或阻止用户错误。数字框仍然允许字母,组合框不限于给定的选择,日期必须手动填写。在本章中,我们将涵盖以下主题:
-
决定验证用户输入的最佳方法
-
学习如何使用 Tkinter 的验证系统
-
为我们的表单创建自定义小部件,验证输入的数据
-
在我们的表单中适当的情况下自动化默认值
让我们开始吧!
验证用户输入
乍一看,Tkinter 的输入小部件选择似乎有点令人失望。它没有给我们一个真正的数字输入,只允许数字,也没有一个真正的下拉选择器,只允许从下拉列表中选择项目。我们没有日期输入、电子邮件输入或其他特殊格式的输入小部件。
但这些弱点可以成为优势。因为这些小部件什么都不假设,我们可以使它们以适合我们特定需求的方式行为,而不是以可能或可能不会最佳地工作的通用方式。例如,字母在数字输入中可能看起来不合适,但它们呢?在 Python 中,诸如NaN
和Infinity
之类的字符串是有效的浮点值;拥有一个既可以增加数字又可以处理这些字符串值的框在某些应用中可能非常有用。
我们将学习如何根据需要调整我们的小部件,但在学习如何控制这种行为之前,让我们考虑一下我们想要做什么。
防止数据错误的策略
对于小部件如何响应用户尝试输入错误数据,没有通用答案。各种图形工具包中的验证逻辑可能大不相同;当输入错误数据时,输入小部件可能会验证用户输入如下:
-
防止无效的按键注册
-
接受输入,但在提交表单时返回错误或错误列表
-
当用户离开输入字段时显示错误,可能会禁用表单提交,直到它被纠正
-
将用户锁定在输入字段中,直到输入有效数据
-
使用最佳猜测算法悄悄地纠正错误的数据
数据输入表单中的正确行为(每天由甚至可能根本不看它的用户填写数百次)可能与仪器控制面板(值绝对必须正确以避免灾难)或在线用户注册表单(用户以前从未见过的情况下填写一次)不同。我们需要向自己和用户询问哪种行为将最大程度地减少错误。
与数据输入人员讨论后,您得出以下一组指南:
-
尽可能忽略无意义的按键(例如数字字段中的字母)
-
空字段应该注册一个错误(所有字段都是必填的),但
Notes
除外 -
包含错误数据的字段应以某种可见的方式标记,并描述问题
-
如果存在错误字段,则应禁用表单提交
让我们在继续之前,将以下要求添加到我们的规范中。在“必要功能”部分,更新硬性要求如下:
The program must:
...
* have inputs that:
- ignore meaningless keystrokes
- require a value for all fields, except Notes
- get marked with an error if the value is invalid on focusout
* prevent saving the record when errors are present
那么,我们如何实现这一点呢?
Tkinter 中的验证
Tkinter 的验证系统是工具包中不太直观的部分之一。它依赖于以下三个配置选项,我们可以将其传递到任何输入小部件中:
-
validate
:此选项确定哪种类型的事件将触发验证回调 -
validatecommand
:此选项接受将确定数据是否有效的命令 -
invalidcommand
:此选项接受一个命令,如果validatecommand
返回False
,则运行该命令
这似乎很简单,但有一些意想不到的曲线。
我们可以传递给validate
的值如下:
验证字符串 | 触发时 |
---|---|
none |
它是关闭验证的无 |
focusin |
用户输入或选择小部件 |
unfocus |
用户离开小部件 |
focus |
focusin 或focusout |
key |
用户在小部件中输入文本 |
all |
focusin ,focusout 和key |
validatecommand
参数是事情变得棘手的地方。您可能会认为这需要 Python 函数或方法的名称,但事实并非如此。相反,我们需要给它一个包含对 Tcl/Tk
函数的引用的元组,并且可以选择一些替换代码,这些代码指定我们要传递到函数中的触发事件的信息。
我们如何获得对 Tcl/Tk
函数的引用?幸运的是,这并不太难;我们只需将 Python 可调用对象传递给任何 Tkinter 小部件的.register()
方法。这将返回一个字符串,我们可以在validatecommand
中使用。
当然,除非我们传入要验证的数据,否则验证函数没有什么用。为此,我们向我们的validatecommand
元组添加一个或多个替换代码。
这些代码如下:
代码 | 传递的值 |
---|---|
“%d” | 指示正在尝试的操作的代码:0 表示delete ,1 表示插入,-1 表示其他事件。请注意,这是作为字符串而不是整数传递的。 |
“%P” | 更改后字段将具有的建议值(仅限键事件)。 |
“%s” | 字段中当前的值(仅限键事件)。 |
“%i” | 在键事件上插入或删除的文本的索引(从0 开始),或在非键事件上为-1 。请注意,这是作为字符串而不是整数传递的。 |
“%S” | 对于插入或删除,正在插入或删除的文本(仅限键事件)。 |
“%v” | 小部件的“验证”值。 |
“%V” | 触发验证的事件:focusin ,focusout ,key 或forced (表示文本变量已更改)。 |
“%W” | Tcl/Tk 中小部件的名称,作为字符串。 |
invalidcommand
选项的工作方式完全相同,需要使用.register()
方法和替换代码。
要查看这些内容是什么样子,请考虑以下代码,用于仅接受五个字符的Entry
小部件:
def has_five_or_less_chars(string):
return len(string) <= 5
wrapped_function = root.register(has_five_or_less_chars)
vcmd = (wrapped_function, '%P')
five_char_input = ttk.Entry(root, validate='key', validatecommand=vcmd)
在这里,我们创建了一个简单的函数,它只返回字符串的长度是否小于或等于五个字符。然后,我们使用“register()”方法将此函数注册到Tk
,将其引用字符串保存为wrapped_function
。接下来,我们使用引用字符串和“'%P'”替换代码构建我们的validatecommand
元组,该替换代码表示建议的值(如果接受键事件,则输入将具有的值)。
您可以传入任意数量的替换代码,并且可以按任何顺序,只要您的函数是写入接受这些参数的。最后,我们将创建我们的Entry
小部件,将验证类型设置为key
,并传入我们的验证命令元组。
请注意,在这种情况下,我们没有定义invalidcommand
方法;当通过按键触发验证时,从validate
命令返回False
将导致忽略按键。当通过焦点或其他事件类型触发验证时,情况并非如此;在这种情况下,没有定义默认行为,需要invalidcommand
方法。
考虑以下FiveCharEntry
的替代基于类的版本,它允许您输入任意数量的文本,但在离开字段时会截断您的文本:
class FiveCharEntry2(ttk.Entry):
"""An Entry that truncates to five characters on exit."""
def __init__(self, parent, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.config(
validate='focusout',
validatecommand=(self.register(self._validate), '%P'),
invalidcommand=(self.register(self._on_invalid),)
)
def _validate(self, proposed_value):
return len(proposed_value) <= 5
def _on_invalid(self):
self.delete(5, tk.END)
这一次,我们通过对Entry
进行子类化并在方法中定义我们的验证逻辑来实现验证,而不是在外部函数中。这简化了我们在验证方法中访问小部件。
_validate()
和_on_invalid()
开头的下划线表示这些是内部方法,只能在类内部访问。虽然这并不是必要的,而且 Python 并不会将其与普通方法区别对待,但它让其他程序员知道这些方法是供内部使用的,不应该在类外部调用。
我们还将validate
参数更改为focusout
,并添加了一个_on_invalid()
方法,该方法将使用Entry
小部件的delete()
方法截断值。每当小部件失去焦点时,将调用_validate()
方法并传入输入的文本。如果失败,将调用_on_invalid()
,导致内容被截断。
创建一个 DateEntry 小部件
让我们尝试创建一个验证版本的Date
字段。我们将创建一个DateEntry
小部件,它可以阻止大多数错误的按键,并在focusout
时检查日期的有效性。如果日期无效,我们将以某种方式标记该字段并显示错误。让我们执行以下步骤来完成相同的操作:
- 打开一个名为
DateEntry.py
的新文件,并从以下代码开始:
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.config(
validate='all',
validatecommand=(
self.register(self._validate),
'%S', '%i', '%V', '%d'
),
invalidcommand=(self.register(self._on_invalid), '%V')
)
self.error = tk.StringVar()
-
由于我们需要在验证方法中使用
datetime
,所以我们在这里导入它。 -
我们对
ttk.Entry
进行子类化,然后在构造方法中开始调用super().__init__()
,就像往常一样。 -
接下来,我们使用
self.config()
来更改小部件的配置。你可能会想知道为什么我们没有将这些参数传递给super().__init__()
调用;原因是直到底层的Entry
小部件被初始化之后,self.register()
方法才存在。 -
我们注册以下两种方法:
self._validate
和self._on_invalid
,我们将很快编写:
-
_validate()
:这个方法将获取插入的文本(%S
),插入的索引(%i
),事件类型(%V
)和执行的操作(%d
)。 -
_on_invalid()
:这个方法只会获取事件类型。由于我们希望在按键和focusout
时进行验证,所以我们将validate
设置为all
。我们的验证方法可以通过查看事件类型(%V
)来确定正在发生的事件。
-
最后,我们创建
StringVar
来保存我们的错误文本;这将在类外部访问,所以我们不在其名称中使用前导下划线。 -
我们创建的下一个方法是
_toggle_error()
,如下所示:
def _toggle_error(self, error=''):
self.error.set(error)
if error:
self.config(foreground='red')
else:
self.config(foreground='black')
- 我们使用这种方法来在出现错误的情况下整合小部件的行为。它首先将我们的
error
变量设置为提供的字符串。如果字符串不为空,我们会打开错误标记(在这种情况下,将文本变为红色);如果为空,我们会关闭错误标记。_validate()
方法如下:
def _validate(self, char, index, event, action):
# reset error state
self._toggle_error()
valid = True
# ISO dates, YYYY-MM-DD, only need digits and hyphens
if event == 'key':
if action == '0': # A delete event should always validate
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':
告诉我们用户是否尝试删除字符。我们总是希望允许这样做,以便用户可以编辑字段。
ISO 日期的基本格式是:四位数字,一个破折号,两位数字,一个破折号,和两位数字。我们可以通过检查插入的字符是否与我们在插入的index
位置的期望相匹配来测试用户是否遵循这种格式。例如,index in ('0', '1', '2', '3', '5', '6', '8', '9')
将告诉我们插入的字符是否是需要数字的位置之一,如果是,我们检查该字符是否是数字。索引为4
或7
应该是一个破折号。任何其他按键都是无效的。
尽管你可能期望它们是整数,但 Tkinter 将动作代码传递为字符串并将其索引化。在编写比较时要记住这一点。
虽然这是一个对于正确日期的幼稚的启发式方法,因为它允许完全无意义的日期,比如0000-97-46
,或者看起来正确但仍然错误的日期,比如2000-02-29
,但至少它强制执行了基本格式并消除了大量无效的按键。一个完全准确的部分日期分析器是一个单独的项目,所以现在这样做就可以了。
在focusout
上检查我们的日期是否正确更简单,也更可靠,如下所示:
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
标志。
验证方法必须始终返回一个布尔值。如果由于某种原因,您的验证方法没有返回值(或返回None
),您的验证将在没有任何错误的情况下悄悄中断。请务必确保您的方法始终返回一个布尔值,特别是如果您使用多个return
语句。
正如您之前看到的,对于无效的按键,只需返回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()
tk.Label(textvariable=entry.error).pack()
# add this so we can unfocus the DateEntry
tk.Entry(root).pack()
root.mainloop()
保存文件并运行它以尝试新的DateEntry
类。尝试输入各种错误的日期或无效的按键,并看看会发生什么。
在我们的表单中实现验证小部件
现在您知道如何验证您的小部件,您有很多工作要做!我们有 16 个输入小部件,您将不得不为所有这些编写代码,以获得我们需要的行为。在这个过程中,您需要确保小部件对错误的响应是一致的,并向应用程序提供一致的 API。
如果这听起来像是你想无限期推迟的事情,我不怪你。也许有一种方法可以减少我们需要编写的代码量。
利用多重继承的力量
到目前为止,我们已经了解到 Python 允许我们通过子类化创建新的类,从超类继承特性,并只添加或更改新类的不同之处。Python 还支持多重继承,其中子类可以从多个超类继承。我们可以利用这个特性来为我们带来好处,创建所谓的混合类。
混合类只包含我们想要能够与其他类混合以组成新类的特定功能集。
看一下以下示例代码:
class Displayer():
def display(self, message):
print(message)
class LoggerMixin():
def log(self, message, filename='logfile.txt'):
with open(filename, 'a') as fh:
fh.write(message)
def display(self, message):
super().display(message)
self.log(message)
class MySubClass(LoggerMixin, Displayer):
def log(self, message):
super().log(message, filename='subclasslog.txt')
subclass = MySubClass()
subclass.display("This string will be shown and logged in subclasslog.txt.")
我们实现了一个名为Displayer
的基本类,其中包含一个display()
方法,用于打印消息。然后,我们创建了一个名为LoggerMixin
的混合类,它添加了一个log()
方法来将消息写入文本文件,并覆盖了display()
方法以调用log()
。最后,我们通过同时继承LoggerMixin
和Displayer
来创建一个子类。子类然后覆盖了log()
方法并设置了不同的文件名。
当我们创建一个使用多重继承的类时,我们指定的最右边的类称为基类,混合类应该在它之前指定。对于混合类与任何其他类没有特殊的语法,但要注意混合类的display()
方法中使用super()
。从技术上讲,LoggerMixin
继承自 Python 内置的object
类,该类没有display()
方法。那么,我们如何在这里调用super().display()
呢?
在多重继承的情况下,super()
做的事情比仅仅代表超类要复杂一些。它使用一种叫做方法解析顺序的东西来查找继承链,并确定定义我们调用的方法的最近的类。因此,当我们调用MySubclass.display()
时,会发生一系列的方法解析,如下所示:
-
MySubClass.display()
被解析为LoggerMixin.display()
。 -
LoggerMixin.display()
调用super().display()
,解析为Displayer.display()
。 -
它还调用
self.log()
。在这种情况下,self
是一个MySubClass
实例,所以它解析为MySubClass.log()
。 -
MySubClass.log()
调用super().log()
,解析回LoggerMixin.log()
。
如果这看起来令人困惑,只需记住self.method()
将首先在当前类中查找method()
,然后按照从左到右的继承类列表查找方法。super().method()
也会这样做,只是它会跳过当前类。
类的方法解析顺序存储在它的__mro__
属性中;如果你在 Python shell 或调试器中遇到继承方法的问题,你可以检查这个方法。
请注意,LoggerMixin
不能单独使用:它只在与具有display()
方法的类结合时起作用。这就是为什么它是一个 mixin 类,因为它的目的是混合到其他类中以增强它们。
一个验证 mixin 类
让我们运用我们对多重继承的知识来构建一个 mixin,通过执行以下步骤来给我们一些样板验证逻辑:
- 打开
data_entry_app.py
并在Application
类定义之前开始这个类:
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.config(
validate='all',
validatecommand=(vcmd, '%P', '%s', '%S', '%V', '%i', '%d'),
invalidcommand=(invcmd, '%P', '%s', '%S', '%V', '%i', '%d')
)
-
我们在这里设置了我们的
validate
和invalid
方法。我们将继续传入所有的替换代码(除了'%w'
,因为在类上下文中它几乎没有用)。我们对所有条件进行验证,所以我们可以捕获焦点和按键事件。 -
现在,我们将定义我们的错误条件处理程序:
def _toggle_error(self, on=False):
self.config(foreground=('red' if on else 'black'))
- 如果有错误,这将只是将文本颜色更改为红色,否则更改为黑色。我们不在这个函数中设置错误,因为我们将希望在验证方法中设置实际的错误文本,如下所示:
def _validate(self, proposed, current, char, event, index,
action):
self._toggle_error(False)
self.error.set('')
valid = True
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
def _focusout_validate(self, **kwargs):
return True
def _key_validate(self, **kwargs):
return True
我们的_validate()
方法只处理一些设置工作,比如关闭错误和清除错误消息。然后,它运行一个特定于事件的验证方法,取决于传入的事件类型。我们现在只关心key
和focusout
事件,所以任何其他事件都会返回True
。
请注意,我们使用关键字调用各个方法;当我们创建我们的子类时,我们将覆盖这些方法。通过使用关键字参数,我们覆盖的函数只需指定所需的关键字或从**kwargs
中提取单个参数,而不必按正确的顺序获取所有参数。还要注意,所有参数都传递给_key_validate()
,但只有event
传递给_focusout_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):
self._toggle_error(True)
def _key_invalid(self, **kwargs):
pass
-
我们对这些方法采取相同的方法。不像验证方法,我们的无效数据处理程序不需要返回任何内容。对于无效的键,默认情况下我们什么也不做,对于
focusout
上的无效数据,我们切换错误状态。 -
按键验证只在输入键的情况下才有意义,但有时我们可能希望手动运行
focusout
检查,因为它有效地检查完全输入的值。因此,我们将实现以下方法:
def trigger_focusout_validation(self):
valid = self._validate('', '', '', 'focusout', '', '')
if not valid:
self._focusout_invalid(event='focusout')
return valid
- 我们只是复制了
focusout
事件发生时发生的逻辑:运行验证函数,如果失败,则运行无效处理程序。这就是我们对ValidatedMixin
所需的全部内容,所以让我们开始将其应用于一些小部件,看看它是如何工作的。
构建我们的小部件
让我们仔细考虑我们需要使用新的ValidatedMixin
类实现哪些类,如下所示:
-
除了
Notes
之外,我们所有的字段都是必需的,因此我们需要一个基本的Entry
小部件,如果没有输入,则会注册错误。 -
我们有一个
Date
字段,因此我们需要一个强制有效日期字符串的Entry
小部件。 -
我们有一些用于十进制或整数输入的
Spinbox
小部件。我们需要确保这些只接受有效的数字字符串。 -
我们有一些
Combobox
小部件的行为不太符合我们的期望。
让我们开始吧!
需要数据
我们所有的字段都是必需的,所以让我们从一个需要数据的基本Entry
小部件开始。我们可以将这些用于字段:Technician
和Seed sample
。
在ValidatedMixin
类下添加以下代码:
class RequiredEntry(ValidatedMixin, ttk.Entry):
def _focusout_validate(self, event):
valid = True
if not self.get():
valid = False
self.error.set('A value is required')
return valid
这里没有按键验证要做,所以我们只需要创建_focusout_validate()
。如果输入的值为空,我们只需设置一个错误字符串并返回False
。
就是这样了!
日期小部件
现在,让我们将 mixin 类应用于之前制作的DateEntry
类,保持相同的验证算法如下:
class DateEntry(ValidatedMixin, ttk.Entry):
def _key_validate(self, action, index, char, **kwargs):
valid = True
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
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
同样,非常简单,我们只需要指定验证逻辑。我们还添加了来自我们的RequiredEntry
类的逻辑,因为Date
值是必需的。
让我们继续进行一些更复杂的工作。
更好的 Combobox 小部件
不同工具包中的下拉式小部件在鼠标操作时表现相当一致,但对按键的响应有所不同,如下所示:
-
有些什么都不做
-
有些需要使用箭头键来选择项目
-
有些移动到按下任意键开始的第一个条目,并在后续按键开始的条目之间循环
-
有些会缩小列表以匹配所键入的内容
我们需要考虑我们的Combobox
小部件应该具有什么行为。由于我们的用户习惯于使用键盘进行数据输入,有些人使用鼠标有困难,小部件需要与键盘配合使用。让他们重复按键来选择选项也不是很直观。与数据输入人员讨论后,您决定采用以下行为:
-
如果建议的文本与任何条目都不匹配,它将被忽略。
-
当建议的文本与单个条目匹配时,小部件将设置为该值
-
删除或退格会清除整个框
在DateEntry
代码下添加此代码:
class ValidatedCombobox(ValidatedMixin, ttk.Combobox):
def _key_validate(self, proposed, action, **kwargs):
valid = True
# if the user tries to delete, just clear the field
if action == '0':
self.set('')
return True
_key_validate()
方法首先设置一个valid
标志,并快速检查是否是删除操作。如果是,我们将值设置为空字符串并返回True
。
现在,我们将添加逻辑来匹配建议的文本与我们的值:
# get our values list
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
,我们找到了匹配,所以我们将变量设置为该值。如果是其他任何值,我们需要让用户继续输入。作为最后的修饰,如果找到匹配,我们将使用.icursor()
方法将光标发送到字段的末尾。这并不是严格必要的,但比将光标留在文本中间看起来更好。现在,我们将添加focusout
验证器,如下所示:
def _focusout_validate(self, **kwargs):
valid = True
if not self.get():
valid = False
self.error.set('A value is required')
return valid
这里我们不需要做太多,因为关键验证方法确保唯一可能的值是空字段或值列表中的项目,但由于所有字段都需要有一个值,我们将从RequiredEntry
复制验证。
这就处理了我们的Combobox
小部件。接下来,我们将处理Spinbox
小部件。
范围限制的 Spinbox 小部件
数字输入似乎不应该太复杂,但有许多微妙之处需要解决,以使其牢固。除了将字段限制为有效的数字值之外,您还希望将from
、to
和increment
参数分别强制为输入的最小、最大和精度。
算法需要实现以下规则:
-
删除始终允许
-
数字始终允许
-
如果
from
小于0
,则允许减号作为第一个字符 -
如果
increment
有小数部分,则允许一个点 -
如果建议的值大于
to
值,则忽略按键 -
如果建议的值需要比
increment
更高的精度,则忽略按键 -
在
focusout
时,确保值是有效的数字字符串 -
同样在
focusout
时,确保值大于from
值
看一下以下步骤:
- 以下是我们将如何编码,关于前面的规则:
class ValidatedSpinbox(ValidatedMixin, tk.Spinbox):
def __init__(self, *args, min_var=None, max_var=None,
focus_update_var=None, from_='-Infinity',
to='Infinity', **kwargs):
super().__init__(*args, from_=from_, to=to, **kwargs)
self.resolution = Decimal(str(kwargs.get('increment',
'1.0')))
self.precision = (
self.resolution
.normalize()
.as_tuple()
.exponent
)
-
我们将首先重写
__init__()
方法,以便我们可以指定一些默认值,并从构造函数参数中获取increment
值以进行处理。 -
Spinbox
参数可以作为浮点数、整数或字符串传递。无论如何传递,Tkinter 都会将它们转换为浮点数。确定浮点数的精度是有问题的,因为浮点误差的原因,所以我们希望在它变成浮点数之前将其转换为 PythonDecimal
。
浮点数尝试以二进制形式表示十进制数。打开 Python shell 并输入1.2 / .2
。您可能会惊讶地发现答案是5.999999999999999
而不是6
。这被称为浮点误差,几乎在每种编程语言中都是计算错误的来源。Python 为我们提供了Decimal
类,它接受一个数字字符串并以一种使数学运算免受浮点误差的方式存储它。
- 在我们使用
Decimal
之前,我们需要导入它。在文件顶部的导入中添加以下代码:
from decimal import Decimal, InvalidOperation
InvalidOperation
是当Decimal
得到一个它无法解释的字符串时抛出的异常。我们稍后会用到它。
请注意,在将其传递给Decimal
之前,我们将increment
转换为str
。理想情况下,我们应该将increment
作为字符串传递,以确保它将被正确解释,但以防我们因某种原因需要传递一个浮点数,str
将首先进行一些明智的四舍五入。
-
我们还为
to
和from_
设置了默认值:-Infinity
和Infinity
。float
和Decimal
都会愉快地接受这些值,并将它们视为您期望的那样处理。Tkinter.Spinbox
的默认to
和from_
值为0
;如果它们保留在那里,Tkinter 会将其视为无限制,但如果我们指定一个而不是另一个,这就会产生问题。 -
我们提取
resolution
值的precision
作为最小有效小数位的指数。我们将在验证类中使用这个值。 -
我们的构造函数已经确定,所以让我们编写验证方法。关键验证方法有点棘手,所以我们将一步一步地走过它。首先,我们开始这个方法:
def _key_validate(self, char, index, current,
proposed, action, **kwargs):
valid = True
min_val = self.cget('from')
max_val = self.cget('to')
no_negative = min_val >= 0
no_decimal = self.precision >= 0
- 首先,我们检索
from
和to
值,然后分配标志变量以指示是否应允许负数和小数,如下所示:
if action == '0':
return True
删除应该总是有效的,所以如果是删除,返回True
。
我们在这里打破了不要多次返回的准则,因为只有一个return
的相同逻辑会嵌套得非常深。在尝试编写可读性好、易于维护的代码时,有时不得不选择两害相权取其轻。
- 接下来,我们测试按键是否是有效字符,如下所示:
# First, filter out obviously invalid keystrokes
if any([
(char not in ('-1234567890.')),
(char == '-' and (no_negative or index != '0')),
(char == '.' and (no_decimal or '.' in current))
]):
return False
有效字符是数字加上-
和.
。减号只在索引0
处有效,点只能出现一次。其他任何字符都返回False
。
内置的any
函数接受一个表达式列表,并在列表中的任何一个表达式为真时返回True
。还有一个all
函数,如果所有表达式都为真,则返回True
。这些函数允许您压缩一长串布尔表达式。
在这一点上,我们几乎可以保证有一个有效的Decimal
字符串,但还不够;我们可能只有-
、.
或-.
字符。
- 以下是有效的部分条目,因此我们只需为它们返回
True
:
# At this point, proposed is either '-', '.', '-.',
# or a valid Decimal string
if proposed in '-.':
return True
- 此时,建议的文本只能是有效的
Decimal
字符串,因此我们将从中制作一个Decimal
并进行更多的测试:
# Proposed is a valid Decimal string
# convert to Decimal and check more:
proposed = Decimal(proposed)
proposed_precision = proposed.as_tuple().exponent
if any([
(proposed > max_val),
(proposed_precision < self.precision)
]):
return False
return valid
- 我们最后两个测试检查建议的文本是否大于我们的最大值,或者比我们指定的“增量”具有更多的精度(我们在这里使用
<
运算符的原因是因为“精度”给出为小数位的负值)。如果还没有返回任何内容,我们将返回valid
值作为保障。我们的focusout
验证器要简单得多,如下所示:
def _focusout_validate(self, **kwargs):
valid = True
value = self.get()
min_val = self.cget('from')
try:
value = Decimal(value)
except InvalidOperation:
self.error.set('Invalid number string: {}'.format(value))
return False
if value < min_val:
self.error.set('Value is too low (min {})'.format(min_val))
valid = False
return valid
- 有了整个预期值,我们只需要确保它是有效的
Decimal
字符串并且大于最小值。
有了这个,我们的ValidatedSpinbox
已经准备就绪。
动态调整 Spinbox 范围
我们的ValidatedSpinbox
方法似乎对我们的大多数字段都足够了。但是考虑一下Height
字段。Mini height
值大于Max height
值或Median height
值不在它们之间是没有意义的。有没有办法将这种相互依赖的行为融入到我们的类中?
我们可以!为此,我们将依赖 Tkinter 变量的跟踪功能。跟踪本质上是对变量的.get()
和.set()
方法的钩子,允许您在读取或更改变量时触发任何 Python 函数或方法。
语法如下:
sv = tk.StringVar()
sv.trace('w', some_function_or_method)
.trace()
的第一个参数表示我们要跟踪的事件。这里,w
表示写(.set()
),r
表示读(.get()
),u
表示未定义的变量或删除变量。
我们的策略是允许可选的min_var
和max_var
变量进入ValidatedSpinbox
方法,并在这些变量上设置一个跟踪,以便在更改此变量时更新ValidatedSpinbox
方法的最小或最大值。我们还将有一个focus_update_var
变量,它将在focusout
时间更新为Spinbox
小部件值。
让我们看看以下步骤:
- 首先,我们将更新我们的
ValidatedSpinbox
构造函数如下:
def __init__(self, *args, min_var=None, max_var=None,
focus_update_var=None, from_='-Infinity', to='Infinity',
**kwargs
):
super().__init__(*args, from_=from_, to=to, **kwargs)
self.resolution = Decimal(str(kwargs.get('increment', '1.0')))
self.precision = (
self.resolution
.normalize()
.as_tuple()
.exponent
)
# there should always be a variable,
# or some of our code will fail
self.variable = kwargs.get('textvariable') or tk.DoubleVar()
if min_var:
self.min_var = min_var
self.min_var.trace('w', self._set_minimum)
if max_var:
self.max_var = max_var
self.max_var.trace('w', self._set_maximum)
self.focus_update_var = focus_update_var
self.bind('<FocusOut>', self._set_focus_update_var)
-
首先,请注意我们已经添加了一行来将变量存储在
self.variable
中,如果程序没有明确传入变量,我们将创建一个变量。我们需要编写的一些代码将取决于文本变量的存在,因此我们将强制执行这一点,以防万一。 -
如果我们传入
min_var
或max_var
参数,该值将被存储,并配置一个跟踪。trace()
方法指向一个适当命名的方法。 -
我们还存储了对
focus_update_var
参数的引用,并将<FocusOut>
事件绑定到一个方法,该方法将用于更新它。
bind()
方法可以在任何 Tkinter 小部件上调用,它用于将小部件事件连接到 Python 可调用函数。事件可以是按键、鼠标移动或点击、焦点事件、窗口管理事件等等。
- 现在,我们需要为我们的
trace()
和bind()
命令添加回调方法。首先从_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
参数,则将其设置为相同的值。请注意,如果小部件当前存在错误,我们不会设置值。将值更新为无效值是没有意义的。
当 Tkinter 调用bind
回调时,它传递一个包含有关触发回调的事件的信息的事件对象。即使您不打算使用这些信息,您的函数或方法也需要能够接受此参数。
- 现在,让我们创建设置最小值的回调,如下所示:
def _set_minimum(self, *args):
current = self.get()
try:
new_min = self.min_var.get()
self.config(from_=new_min)
except (tk.TclError, ValueError):
pass
if not current:
self.delete(0, tk.END)
else:
self.variable.set(current)
self.trigger_focusout_validation()
-
我们要做的第一件事是检索当前值。
Tkinter.Spinbox
在更改to
或from
值时有稍微让人讨厌的行为,将太低的值移动到from
值,将太高的值移动到to
值。这种悄悄的自动校正可能会逃过我们用户的注意,导致坏数据被保存。我们希望的是将值留在范围之外,并将其标记为错误;因此,为了解决 Tkinter 的问题,我们将保存当前值,更改配置,然后将原始值放回字段中。 -
保存当前值后,我们尝试获取
min_var
的值,并从中设置我们的小部件的from_
值。这里可能会出现几种问题,例如控制我们的最小和最大变量的字段中有空白或无效值,所有这些都应该引发tk.TclError
或ValueError
。在任何一种情况下,我们都不会做任何事情。
通常情况下,只是消除异常是一个坏主意;然而,在这种情况下,如果变量有问题,我们无法合理地做任何事情,除了忽略它。
-
现在,我们只需要将我们保存的当前值写回字段。如果为空,我们只需删除字段;否则,我们设置输入的变量。该方法以调用
trigger_focusout_validation()
方法结束,以重新检查字段中的值与新最小值的匹配情况。 -
_set_maximum()
方法将与此方法相同,只是它将使用max_var
来更新to
值。您可以自己编写它,或者查看本书附带的示例代码。 -
我们需要对我们的
ValidatedSpinbox
类进行最后一个更改。由于我们的最大值可能在输入后更改,并且我们依赖于我们的focusout
验证来检测它,我们需要添加一些条件来检查最大值。 -
我们需要将这个添加到
_focusout_validate()
方法中:
max_val = self.cget('to')
if value > max_val:
self.error.set('Value is too high (max {})'.format(max_val))
- 在
return
语句之前添加这些行以检查最大值并根据需要设置错误。
更新我们的表单
现在我们所有的小部件都已经制作好了,是时候通过执行以下步骤让表单使用它们了:
- 向下滚动到
DataRecordForm
类构造函数,并且我们将逐行更新我们的小部件。第 1 行非常简单:
self.inputs['Date'] = LabelInput(
recordinfo, "Date",
input_class=DateEntry,
input_var=tk.StringVar())
self.inputs['Date'].grid(row=0, column=0)
self.inputs['Time'] = LabelInput(
recordinfo, "Time",
input_class=ValidatedCombobox,
input_var=tk.StringVar(),
input_args={"values": ["8:00", "12:00", "16:00", "20:00"]})
self.inputs['Time'].grid(row=0, column=1)
self.inputs['Technician'] = LabelInput(
recordinfo, "Technician",
input_class=RequiredEntry,
input_var=tk.StringVar())
self.inputs['Technician'].grid(row=0, column=2)
- 将
LabelInput
中的input_class
值替换为我们的新类就像交换一样简单。继续运行你的应用程序并尝试小部件。尝试一些不同的有效和无效日期,并查看Combobox
小部件的工作方式(RequiredEntry
在这一点上不会有太多作用,因为唯一可见的指示是红色文本,如果为空,就没有文本标记为红色;我们稍后会解决这个问题)。现在,转到第 2 行,首先添加Lab
小部件,如下所示:
self.inputs['Lab'] = LabelInput(
recordinfo, "Lab",
input_class=ValidatedCombobox,
input_var=tk.StringVar(),
input_args={"values": ["A", "B", "C", "D", "E"]})
- 接下来,添加
Plot
小部件,如下所示:
self.inputs['Plot'] = LabelInput(
recordinfo, "Plot",
input_class=ValidatedCombobox,
input_var=tk.IntVar(),
input_args={"values": list(range(1, 21))})
再次相当简单,但如果您运行它,您会发现Plot
存在问题。事实证明,当值为整数时,我们的ValidatedComobox
方法无法正常工作,因为用户键入的字符始终是字符串(即使它们是数字);我们无法比较字符串和整数。
- 如果您考虑一下,
Plot
实际上不应该是一个整数值。是的,这些值在技术上是整数,但正如我们在第三章使用 Tkinter 和 ttk 小部件创建基本表单中决定的那样,它们也可以是字母或符号;您不会在一个图表号上进行数学运算。因此,我们将更改Plot
以使用StringVar
变量,并将小部件的值也更改为字符串。更改Plot
小部件的创建如下所示:
self.inputs['Plot'] = LabelInput(
recordinfo, "Plot",
input_class=ValidatedCombobox,
input_var=tk.StringVar(),
input_args={"values": [str(x) for x in range(1, 21)]})
-
在这里,我们只是将
input_var
更改为StringVar
,并使用列表推导将每个values
项转换为字符串。现在,Plot
的工作正常了。 -
继续通过表单,用新验证的版本替换默认的
ttk
小部件。对于Spinbox
小部件,请确保将to
、from_
和increment
值作为字符串而不是整数传递。例如,Humidity
小部件应该如下所示:
self.inputs['Humidity'] = LabelInput(
environmentinfo, "Humidity (g/m³)",
input_class=ValidatedSpinbox,
input_var=tk.DoubleVar(),
input_args={"from_": '0.5', "to": '52.0', "increment":
'.01'})
- 当我们到达
Height
框时,是时候测试我们的min_var
和max_var
功能了。首先,我们需要设置变量来存储最小和最大高度,如下所示:
# Height data
# create variables to be updated for min/max height
# they can be referenced for min/max variables
min_height_var = tk.DoubleVar(value='-infinity')
max_height_var = tk.DoubleVar(value='infinity')
我们创建两个新的DoubleVar
对象来保存当前的最小和最大高度,将它们设置为无限值。这确保一开始实际上没有最小或最大高度。
请注意,我们的小部件直到它们实际更改才会受到这些值的影响,因此它们不会使传入的原始to
和from_
值无效。
- 现在,我们创建
Min Height
小部件,如下所示:
self.inputs['Min Height'] = LabelInput(
plantinfo, "Min Height (cm)",
input_class=ValidatedSpinbox,
input_var=tk.DoubleVar(),
input_args={
"from_": '0', "to": '1000', "increment": '.01',
"max_var": max_height_var, "focus_update_var":
min_height_var})
- 我们将使用
max_height_var
在此处设置最大值,确保我们的最小值永远不会超过最大值,并将focus_update_var
设置为min_height_var
的值,以便在更改此字段时它将被更新。现在,Max Height
小部件如下所示:
self.inputs['Max Height'] = LabelInput(
plantinfo, "Max Height (cm)",
input_class=ValidatedSpinbox,
input_var=tk.DoubleVar(),
input_args={
"from_": 0, "to": 1000, "increment": .01,
"min_var": min_height_var, "focus_update_var":
max_height_var})
- 这一次,我们使用我们的
min_height_var
变量来设置小部件的最小值,并从小部件的当前值更新max_height_var
。最后,Median Height
字段如下所示:
self.inputs['Median Height'] = LabelInput(
plantinfo, "Median Height (cm)",
input_class=ValidatedSpinbox,
input_var=tk.DoubleVar(),
input_args={
"from_": 0, "to": 1000, "increment": .01,
"min_var": min_height_var, "max_var": max_height_var})
-
在这里,我们分别从
min_height_var
和max_height_var
变量设置字段的最小和最大值。我们不会更新任何来自Median Height
字段的变量,尽管我们可以在这里添加额外的变量和代码,以确保Min Height
不会超过它,或者Max Height
不会低于它。在大多数情况下,如果用户按顺序输入数据,Median Height
就不重要了。 -
您可能会想知道为什么我们不直接使用
Min Height
和Max Height
中的input_var
变量来保存这些值。如果您尝试这样做,您会发现原因:input_var
会随着您的输入而更新,这意味着您的部分值立即成为新的最大值或最小值。我们宁愿等到用户提交值后再分配这个值,因此我们创建了一个只在focusout
时更新的单独变量。
显示错误
如果您运行应用程序,您可能会注意到,虽然focusout
错误的字段变红,但我们无法看到实际的错误。我们需要通过执行以下步骤来解决这个问题:
- 找到您的
LabelInput
类,并将以下代码添加到构造方法的末尾:
self.error = getattr(self.input, 'error', tk.StringVar())
self.error_label = ttk.Label(self, textvariable=self.error)
self.error_label.grid(row=2, column=0, sticky=(tk.W + tk.E))
-
在这里,我们检查我们的输入是否有错误变量,如果没有,我们就创建一个。我们将它保存为
self.error
的引用,然后创建一个带有错误的textvariable
的Label
。 -
最后,我们将这个放在输入小部件下面。
-
现在,当您尝试应用程序时,您应该能够看到字段错误。
防止表单在出现错误时提交
阻止错误进入 CSV 文件的最后一步是,如果表单存在已知错误,则停止应用程序保存。让我们执行以下步骤来做到这一点:
-
实施这一步的第一步是为
Application
对象(负责保存数据)提供一种从DataRecordForm
对象检索错误状态的方法。 -
在
DataRecordForm
类的末尾,添加以下方法:
def get_errors(self):
"""Get a list of field errors in the form"""
errors = {}
for key, widget in self.inputs.items():
if hasattr(widget.input, 'trigger_focusout_validation'):
widget.input.trigger_focusout_validation()
if widget.error.get():
errors[key] = widget.error.get()
return errors
-
与我们处理数据的方式类似,我们只需循环遍历
LabelFrame
小部件。我们寻找具有trigger_focusout_validation
方法的输入,并调用它,以确保所有值都已经被检查。然后,如果小部件的error
变量有任何值,我们将其添加到一个errors
字典中。这样,我们可以检索每个字段的字段名称和错误的字典。 -
现在,我们需要将此行为添加到
Application
类的保存逻辑中。 -
在
on_save()
的开头添加以下代码,在docstring
下面:
# Check for errors first
errors = self.recordform.get_errors()
if errors:
self.status.set(
"Cannot save, error in fields: {}"
.format(', '.join(errors.keys()))
)
return False
这个逻辑很简单:获取错误,如果我们找到任何错误,就在状态区域警告用户并从函数返回(因此不保存任何内容)。
- 启动应用程序并尝试保存一个空白表单。您应该在所有字段中收到错误消息,并在底部收到一个消息,告诉您哪些字段有错误。
自动化输入
防止用户输入错误数据是帮助用户输入更好数据的一种方式;另一种方法是自动化。利用我们对表单可能如何填写的理解,我们可以插入对于某些字段非常可能是正确的值。
请记住第二章中提到的,使用 Tkinter 设计 GUI 应用程序,表单几乎总是在填写当天录入,并且按顺序从Plot
1 到Plot
20 依次填写。还要记住,Date
,Lab
和Technician
的值对每个填写的表单保持不变。让我们为我们的用户自动化这个过程。
插入日期
插入当前日期是一个简单的开始地方。这个地方是在DataRecordForm.reset()
方法中,该方法设置了输入新记录的表单。
按照以下方式更新该方法:
def reset(self):
"""Resets the form entries"""
# clear all values
for widget in self.inputs.values():
widget.set('')
current_date = datetime.today().strftime('%Y-%m-%d')
self.inputs['Date'].set(current_date)
就像我们在Application.save()
方法中所做的那样,我们从datetime.today()
获取当前日期并将其格式化为 ISO 日期。然后,我们将Date
小部件的输入设置为该值。
自动化 Lab,Time 和 Technician
稍微复杂一些的是我们对Lab
,Time
和Technician
的处理。让我们按照以下逻辑进行审查:
-
在清除数据之前,保存
Lab
,Time
和Technician
的值。 -
如果
Plot
小于最后一个值(20
),我们将在清除所有字段后将这些值放回,然后增加到下一个Plot
值。 -
如果
Plot
是最后一个值或没有值,则将这些字段留空。代码如下:
def reset(self):
"""Resets the form entries"""
# gather the values to keep for each lab
lab = self.inputs['Lab'].get()
time = self.inputs['Time'].get()
technician = self.inputs['Technician'].get()
plot = self.inputs['Plot'].get()
plot_values = self.inputs['Plot'].input.cget('values')
# clear all values
for widget in self.inputs.values():
widget.set('')
current_date = datetime.today().strftime('%Y-%m-%d')
self.inputs['Date'].set(current_date)
self.inputs['Time'].input.focus()
# check if we need to put our values back, then do it.
if plot not in ('', plot_values[-1]):
self.inputs['Lab'].set(lab)
self.inputs['Time'].set(time)
self.inputs['Technician'].set(technician)
next_plot_index = plot_values.index(plot) + 1
self.inputs['Plot'].set(plot_values[next_plot_index])
self.inputs['Seed sample'].input.focus()
因为Plot
看起来像一个整数,可能会诱人像增加一个整数一样增加它,但最好将其视为非整数。我们使用值列表的索引。
- 最后一个微调,表单的焦点始终从第一个字段开始,但这意味着用户必须通过已经填写的字段进行标签。如果下一个空输入从一开始就聚焦,那将是很好的。Tkinter 输入有一个
focus()
方法,它可以给它们键盘焦点。根据我们填写的字段,这要么是Time
,要么是Seed sample
。在设置Date
值的下一行下面,添加以下代码行:
self.inputs['Time'].input.focus()
- 在设置
Plot
值的行下面,在条件块内,添加以下代码行:
self.inputs['Seed sample'].input.focus()
我们的表单现在已经准备好与用户进行试运行。在这一点上,它绝对比 CSV 输入有所改进,并将帮助数据输入快速完成这些表单。
总结
应用程序已经取得了长足的进步。在本章中,我们学习了 Tkinter 验证,创建了一个验证混合类,并用它来创建Entry
,Combobox
和Spinbox
小部件的验证版本。我们在按键和焦点事件上验证了不同类型的数据,并创建了根据相关字段的值动态更新其约束的字段。
在下一章中,我们将准备我们的代码基础以便扩展,并学习如何组织一个大型应用程序以便更容易维护。更具体地说,我们将学习 MVC 模式以及如何将我们的代码结构化为多个文件,以便更简单地进行维护。我们还将更多地了解 RST 和版本控制软件。
第五章:规划我们应用程序的扩展
这个应用程序真的很受欢迎!经过一些初步测试和定位,数据录入人员现在已经使用您的新表单几个星期了。错误和数据输入时间的减少是显著的,人们对这个程序可能解决的其他问题充满了兴奋的讨论。即使主管也加入了头脑风暴,你强烈怀疑你很快就会被要求添加一些新功能。然而,有一个问题;这个应用程序已经是几百行的脚本了,你担心随着它的增长,它的可管理性。你需要花一些时间来组织你的代码库,为未来的扩展做准备。
在本章中,我们将学习以下主题:
-
如何使用模型-视图-控制器模式来分离应用程序的关注点
-
如何将代码组织成 Python 包
-
为您的包结构创建基本文件和目录
-
如何使用 Git 版本控制系统跟踪您的更改
分离关注点
适当的建筑设计对于任何需要扩展的项目都是至关重要的。任何人都可以支撑起一些支柱,建造一个花园棚屋,但是建造一座房子或摩天大楼需要仔细的规划和工程。软件也是一样的;简单的脚本可以通过一些快捷方式,比如全局变量或直接操作类属性来解决,但随着程序的增长,我们的代码需要以一种限制我们需要在任何给定时刻理解的复杂度的方式来隔离和封装不同的功能。
我们称之为关注点的分离,通过使用描述不同应用程序组件及其交互方式的架构模式来实现。
MVC 模式
这些模式中最持久的可能是 MVC 模式,它是在 20 世纪 70 年代引入的。尽管这种模式多年来已经发展并衍生出各种变体,但基本的要点仍然是:将数据、数据的呈现和应用程序逻辑保持在独立的组件中。
让我们更深入地了解这些组件,并在我们的应用程序的上下文中理解它们。
什么是模型?
MVC 中的模型代表数据。这包括数据的存储,以及数据可以被查询或操作的各种方式。理想情况下,模型不关心或受到数据如何呈现或授予什么 UI 控件的影响,而是提供一个高级接口,只在最小程度上关注其他组件的内部工作。理论上,如果您决定完全更改程序的 UI(比如,从 Tkinter 应用程序到 Web 应用程序),模型应该完全不受影响。
模型中包含的功能或信息的一些示例包括以下内容:
-
准备并将程序数据写入持久介质(数据文件、数据库等)
-
从文件或数据库中检索数据并将其转换为程序有用的格式
-
一组数据中字段的权威列表,以及它们的数据类型和限制
-
根据定义的数据类型和限制验证数据
-
对存储的数据进行计算
我们的应用程序目前没有模型类;数据布局是在表单类中定义的,到目前为止,Application.on_save()
方法是唯一关心数据持久性的代码。我们需要将这个逻辑拆分成一个单独的对象,该对象将定义数据布局并处理所有 CSV 操作。
什么是视图?
视图是向用户呈现数据和控件的接口。应用程序可能有许多视图,通常是在相同的数据上。视图不直接与模型交互,并且理想情况下只包含足够的逻辑来呈现 UI 并将用户操作传递回控制器。
在视图中找到的一些代码示例包括以下内容:
-
GUI 布局和小部件定义
-
表单自动化,例如字段的自动完成,小部件的动态切换,或错误对话框的显示
-
原始数据的格式化呈现
我们的DataRecordForm
类是我们的主视图:它包含了我们应用程序用户界面的大部分代码。它还当前定义了我们数据记录的结构。这个逻辑可以留在视图中,因为视图确实需要一种在将数据临时传递给模型之前存储数据的方式,但从现在开始它不会再定义我们的数据记录。
随着我们继续前进,我们将向我们的应用程序添加更多视图。
什么是控制器?
控制器是应用程序的大中央车站。它处理用户的请求,并负责在视图和模型之间路由数据。MVC 的大多数变体都会改变控制器的角色(有时甚至是名称),但重要的是它充当视图和模型之间的中介。我们的控制器对象将需要保存应用程序使用的视图和模型的引用,并负责管理它们之间的交互。
在控制器中找到的代码示例包括以下内容:
-
应用程序的启动和关闭逻辑
-
用户界面事件的回调
-
模型和视图实例的创建
我们的Application
对象目前充当着应用程序的控制器,尽管它也包含一些视图和模型逻辑。随着应用程序的发展,我们将把更多的展示逻辑移到视图中,将更多的数据逻辑移到模型中,留下的主要是连接代码在我们的Application
对象中。
为什么要复杂化我们的设计?
最初,以这种方式拆分应用程序似乎会增加很多不必要的开销。我们将不得不在不同对象之间传输数据,并最终编写更多的代码来完成完全相同的事情。为什么我们要这样做呢?
简而言之,我们这样做是为了使扩展可管理。随着应用程序的增长,复杂性也会增加。将我们的组件相互隔离限制了任何一个组件需要管理的复杂性的数量;例如,当我们重新构造表单视图的布局时,我们不应该担心模型将如何在输出文件中结构化数据。程序的这两个方面应该彼此独立。
这也有助于我们在放置某些类型的逻辑时保持一致。例如,拥有一个独立的模型对象有助于我们避免在 UI 代码中散布临时数据查询或文件访问尝试。
最重要的是,如果没有一些指导性的架构策略,我们的程序很可能会变成一团无法解开的逻辑混乱。即使不遵循严格的 MVC 设计定义,始终遵循松散的 MVC 模式也会在应用程序变得更加复杂时节省很多麻烦。
构建我们的应用程序目录结构
将程序逻辑上分解为单独的关注点有助于我们管理每个组件的逻辑复杂性,将代码物理上分解为多个文件有助于我们保持每个文件的复杂性可管理。这也加强了组件之间的隔离;例如,您不能共享全局变量,如果您的模型文件导入了tkinter
,那么您就知道您做错了什么。
基本目录结构
Python 应用程序目录布局没有官方标准,但有一些常见的约定可以帮助我们保持整洁,并且以后更容易打包我们的软件。让我们按照以下方式设置我们的目录结构:
-
首先,创建一个名为
ABQ_Data_Entry
的目录。这是我们应用程序的根目录,所以每当我们提到应用程序根目录时,就是它。 -
在应用程序根目录下,创建另一个名为
abq_data_entry
的目录。注意它是小写的。这将是一个 Python 包,其中将包含应用程序的所有代码;它应该始终被赋予一个相当独特的名称,以免与现有的 Python 包混淆。通常情况下,应用程序根目录和主模块之间不会有不同的大小写,但这也不会有任何问题;我们在这里这样做是为了避免混淆。
Python 模块的命名应始终使用全部小写的名称和下划线。这个约定在 PEP 8 中有详细说明,PEP 8 是 Python 的官方风格指南。有关 PEP 8 的更多信息,请参见www.python.org/dev/peps/pep-0008
。
-
接下来,在应用程序根目录下创建一个名为
docs
的文件夹。这个文件夹将用于存放关于应用程序的文档文件。 -
最后,在应用程序根目录中创建两个空文件:
README.rst
和abq_data_entry.py
。你的目录结构应该如下所示:
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 文件
自上世纪 70 年代以来,程序一直包含一个名为README
的简短文本文件,其中包含程序文档的简要摘要。对于小型程序,它可能是唯一的文档;对于大型程序,它通常包含用户或管理员的基本预先飞行指令。
README
文件没有规定的内容集,但作为基本指南,考虑以下部分:
-
描述:程序及其功能的简要描述。我们可以重用规格说明中的描述,或类似的描述。这可能还包含主要功能的简要列表。
-
作者信息:作者的姓名和版权日期。如果你计划分享你的软件,这一点尤为重要,但即使对于公司内部的软件,让未来的维护者知道谁创建了软件以及何时创建也是有用的。
-
要求:软件和硬件要求的列表,如果有的话。
-
安装:安装软件、先决条件、依赖项和基本设置的说明。
-
配置:如何配置应用程序以及有哪些选项可用。这通常针对命令行或配置文件选项,而不是在程序中交互设置的选项。
-
用法:启动应用程序的描述,命令行参数和用户需要了解的其他注意事项。
-
一般注意事项:用户应该知道的注意事项或关键信息。
-
错误:应用程序中已知的错误或限制的列表。
并不是所有这些部分都适用于每个程序;例如,ABQ 数据输入目前没有任何配置选项,所以没有理由有一个配置部分。根据情况,你可能会添加其他部分;例如,公开分发的软件可能会有一个常见问题解答部分,或者开源软件可能会有一个包含如何提交补丁的贡献部分。
README
文件以纯 ASCII 或 Unicode 文本编写,可以是自由格式的,也可以使用标记语言。由于我们正在进行一个 Python 项目,我们将使用 reStructuredText,这是 Python 文档的官方标记语言(这就是为什么我们的文件使用rst
文件扩展名)。
ReStructuredText
reStructuredText 标记语言是 Python docutils
项目的一部分,完整的参考资料可以在 Docutils 网站找到:docutils.sourceforge.net
。docutils
项目还提供了将 RST 转换为 PDF、ODT、HTML 和 LaTeX 等格式的实用程序。
基础知识可以很快掌握,所以让我们来看看它们:
-
段落是通过在文本块之间留下一个空行来创建的。
-
标题通过用非字母数字符号下划线单行文本来创建。确切的符号并不重要;你首先使用的符号将被视为文档其余部分的一级标题,你其次使用的符号将被视为二级标题,依此类推。按照惯例,
=
通常用于一级,-
用于二级,~
用于三级,+
用于四级。 -
标题和副标题的创建方式与标题相似,只是在上下都有一行符号。
-
项目列表是通过在行首加上
*
、-
或+
和一个空格来创建的。切换符号将创建子列表,多行点由将后续行缩进到文本从第一个项目符号开始的位置来创建。 -
编号列表的创建方式与项目列表相似,但使用数字(不需要正确排序)或
#
符号作为项目符号。 -
代码示例可以通过用双反引号字符括起来来指定内联(
`
),或者在一个代码块中,用双冒号结束一个引入行,并缩进代码块。 -
表格可以通过用
=
符号包围文本列,并用空格分隔表示列断点,或者通过使用|
、-
和+
构建 ASCII 表格来创建。在纯文本编辑器中创建表格可能会很繁琐,但一些编程工具有插件可以生成 RST 表格。
我们已经在第二章中使用了 RST,用 Tkinter 设计 GUI 应用程序,来创建我们的程序规范;在那里,您看到了标题、头部、项目符号和表格的使用。让我们逐步创建我们的 README.rst
文件:
- 打开文件并以以下方式开始标题和描述:
============================
ABQ Data Entry Application
============================
Description
===========
This program provides a data entry form for ABQ Agrilabs laboratory data.
Features
--------
* Provides a validated entry form to ensure correct data
* Stores data to ABQ-format CSV files
* Auto-fills form fields whenever possible
- 接下来,我们将通过添加以下代码来列出作者:
Authors
=======
Alan D Moore, 2018
当然要添加自己。最终,其他人可能会在您的应用程序上工作;他们应该在这里加上他们的名字以及他们工作的日期。现在,添加以下要求:
Requirements
============
* Python 3
* Tkinter
目前,我们只需要 Python 3 和 Tkinter,但随着我们的应用程序的增长,我们可能会扩展这个列表。我们的应用程序实际上不需要被安装,并且没有配置选项,所以现在我们可以跳过这些部分。相反,我们将跳到 使用方法
如下:
Usage
=====
To start the application, run::
python3 ABQ_Data_Entry/abq_data_entry.py
除了这个命令之外,关于运行程序没有太多需要了解的东西;没有命令行开关或参数。我们不知道任何错误,所以我们将在末尾留下一些一般的说明,如下所示:
General Notes
=============
The CSV file will be saved to your current directory in the format "abq_data_record_CURRENTDATE.csv", where CURRENTDATE is today's date in ISO format.
This program only appends to the CSV file. You should have a spreadsheet program installed in case you need to edit or check the file.
现在告诉用户文件将被保存在哪里以及它将被命名为什么,因为这是硬编码到程序中的。此外,我们应该提到用户应该有某种电子表格,因为程序无法编辑或查看数据。这就完成了 README.rst
文件。保存它,然后我们继续到 docs
文件夹。
填充文档文件夹
docs
文件夹是用于存放文档的地方。这可以是任何类型的文档:用户手册、程序规范、API 参考、图表等等。
现在,您可以复制我们在前几章中编写的程序规范、您的界面模型和技术人员使用的表单的副本。
在某个时候,您可能需要编写一个用户手册,但是现在程序足够简单,不需要它。
制作一个 Python 包
创建自己的 Python 包其实非常简单。一个 Python 包由以下三个部分组成:
-
一个目录
-
那个目录中的一个或多个 Python 文件
-
目录中的一个名为
__init__.py
的文件
一旦完成这一步,您可以整体或部分地导入您的包,就像导入标准库包一样,只要您的脚本与包目录在同一个父目录中。
注意,模块中的 __init__.py
有点类似于类中的 self.__init__()
。其中的代码将在包被导入时运行。Python 社区一般不鼓励在这个文件中放置太多代码,而且由于实际上不需要任何代码,我们将保持此文件为空。
让我们开始构建我们应用程序的包。在abq_data_entry
下创建以下六个空文件:
-
__init__.py
-
widgets.py
-
views.py
-
models.py
-
application.py
-
constants.py
这些 Python 文件中的每一个都被称为一个模块。模块只是一个包目录中的 Python 文件。您的目录结构现在应该是这样的:
此时,您已经有了一个工作的包,尽管里面没有实际的代码。要测试这个,请打开一个终端/命令行窗口,切换到您的ABQ_Data_Entry
目录,并启动一个 Python shell。
现在,输入以下命令:
from abq_data_entry import application
这应该可以正常工作。当然,它什么也不做,但我们接下来会解决这个问题。
不要将此处的“包”一词与实际的可分发的 Python 包混淆,比如使用pip
下载的那些。
将我们的应用程序拆分成多个文件
现在我们的目录结构已经就绪,我们需要开始解剖我们的应用程序脚本,并将其分割成我们的模块文件。我们还需要创建我们的模型类。打开您从第四章减少用户错误:验证和自动化中的abq_data_entry.py
文件,让我们开始吧!
创建模型模块
当您的应用程序完全关注数据时,最好从模型开始。记住,模型的工作是管理我们应用程序数据的存储、检索和处理,通常是关于其持久存储格式的(在本例中是 CSV)。为了实现这一点,我们的模型应该包含关于我们数据的所有知识。
目前,我们的应用程序没有类似模型的东西;关于应用程序数据的知识散布在表单字段中,而Application
对象只是在请求保存操作时获取表单包含的任何数据,并直接将其塞入 CSV 文件中。由于我们还没有检索或更新信息,所以我们的应用程序对 CSV 文件中的内容一无所知。
为了将我们的应用程序转移到 MVC 架构,我们需要创建一个模型类,它既管理数据存储和检索,又代表我们数据的权威来源。换句话说,我们必须在这里编码我们数据字典中包含的知识。我们真的不知道我们将如何使用这些知识,但它们应该在这里。
我们可以以几种方式存储这些数据,例如创建一个自定义字段类或一个namedtuple
对象,但现在我们将保持简单,只使用一个字典,将字段名称映射到字段元数据。
字段元数据将同样被存储为关于字段的属性字典,其中将包括:
-
字段是否必填
-
字段中存储的数据类型
-
可能值的列表(如果适用)
-
值的最小、最大和增量(如果适用)
要为每个字段存储数据类型,让我们定义一些数据类型。打开constants.py
文件并添加以下代码:
class FieldTypes:
string = 1
string_list = 2
iso_date_string = 3
long_string = 4
decimal = 5
integer = 6
boolean = 7
我们创建了一个名为FieldTypes
的类,它简单地存储一些命名的整数值,这些值将描述我们将要存储的不同类型的数据。我们可以在这里只使用 Python 类型,但是区分一些可能是相同 Python 类型的数据类型是有用的(例如long
、short
和date
字符串)。请注意,这里的整数值基本上是无意义的;它们只需要彼此不同。
Python 3 有一个Enum
类,我们可以在这里使用它,但在这种情况下它添加的功能非常少。如果您正在创建大量常量,比如我们的FieldTypes
类,并且需要额外的功能,可以研究一下这个类。
现在打开models.py
,我们将导入FieldTypes
并创建我们的模型类和字段定义如下:
import csv
import os
from .constants import FieldTypes as FT
class CSVModel:
"""CSV file 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']},
"Technician": {'req': True, 'type': FT.string},
"Lab": {'req': True, 'type': FT.string_list,
'values': ['A', 'B', 'C', 'D', 'E']},
"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},
"Median Height": {'req': True, 'type': FT.decimal,
'min': 0, 'max': 1000, 'inc': .01},
"Notes": {'req': False, 'type': FT.long_string}
}
注意我们导入FieldTypes
的方式:from .constants import FieldTypes
。点号在constants
前面使其成为相对导入。相对导入可在 Python 包内部用于定位同一包中的其他模块。在这种情况下,我们位于models
模块中,需要访问abq_data_entry
包内的constants
模块。单个点号表示我们当前的父模块(abq_data_entry
),因此.constants
表示abq_data_entry
包的constants
模块。
相对导入还可以区分我们的自定义模块与PYTHONPATH
中的模块。因此,我们不必担心任何第三方或标准库包与我们的模块名称冲突。
除了字段属性之外,我们还在这里记录字段的顺序。在 Python 3.6 及更高版本中,字典会保留它们定义的顺序;如果您使用的是较旧版本的 Python 3,则需要使用collections
标准库模块中的OrderedDict
类来保留字段顺序。
现在我们有了一个了解哪些字段需要存储的类,我们需要将保存逻辑从应用程序类迁移到模型中。
我们当前脚本中的代码如下:
datestring = datetime.today().strftime("%Y-%m-%d")
filename = "abq_data_record_{}.csv".format(datestring)
newfile = not os.path.exists(filename)
data = self.recordform.get()
with open(filename, 'a') as fh:
csvwriter = csv.DictWriter(fh, fieldnames=data.keys())
if newfile:
csvwriter.writeheader()
csvwriter.writerow(data)
让我们通过这段代码确定什么属于模型,什么属于控制器(即Application
类):
-
前两行定义了我们要使用的文件名。这可以放在模型中,但是提前思考,似乎用户可能希望能够打开任意文件或手动定义文件名。这意味着应用程序需要能够告诉模型要使用哪个文件名,因此最好将确定名称的逻辑留在控制器中。
-
newfile
行确定文件是否存在。作为数据存储介质的实现细节,这显然是模型的问题,而不是应用程序的问题。 -
data = self.recordform.get()
从表单中提取数据。由于我们的模型不知道表单的存在,这需要留在控制器中。 -
最后一块打开文件,创建一个
csv.DictWriter
对象,并追加数据。这明显是模型的关注点。
现在,让我们开始将代码移入CSVModel
类:
- 要开始这个过程,让我们为
CSVModel
创建一个允许我们传入文件名的构造函数:
def __init__(self, filename):
self.filename = filename
构造函数非常简单;它只接受一个filename
参数并将其存储为一个属性。现在,我们将迁移保存逻辑如下:
def save_record(self, data):
"""Save a dict of data to the CSV file"""
newfile = not os.path.exists(self.filename)
with open(self.filename, 'a') as fh:
csvwriter = csv.DictWriter(fh,
fieldnames=self.fields.keys())
if newfile:
csvwriter.writeheader()
csvwriter.writerow(data)
这本质上是我们选择从Application.on_save()
中复制的逻辑,但有一个区别;在对csv.DictWriter()
的调用中,fieldnames
参数由模型的fields
列表而不是data
字典的键定义。这允许我们的模型管理 CSV 文件本身的格式,并不依赖于表单提供的内容。
- 在我们完成之前,我们需要处理我们的模块导入。
save_record()
方法使用os
和csv
库,所以我们需要导入它们。将此添加到文件顶部如下:
import csv
import os
模型就位后,让我们开始处理我们的视图组件。
移动小部件
虽然我们可以将所有与 UI 相关的代码放在一个views
文件中,但我们有很多小部件类,实际上应该将它们放在自己的文件中,以限制views
文件的复杂性。
因此,我们将所有小部件类的代码移动到widgets.py
文件中。小部件包括实现可重用 GUI 组件的所有类,包括LabelInput
等复合小部件。随着我们开发更多的这些,我们将把它们添加到这个文件中。
打开widgets.py
并复制ValidatedMixin
、DateInput
、RequiredEntry
、ValidatedCombobox
、ValidatedSpinbox
和LabelInput
的所有代码。这些是我们的小部件。
widgets.py
文件需要导入被复制代码使用的任何模块依赖项。我们需要查看我们的代码,并找出我们使用的库并将它们导入。显然,我们需要tkinter
和ttk
,所以在顶部添加它们如下:
import tkinter as tk
from tkinter import ttk
我们的DateInput
类使用datetime
库中的datetime
类,因此也要导入它,如下所示:
from datetime import datetime
最后,我们的ValidatedSpinbox
类使用decimal
库中的Decimal
类和InvalidOperation
异常,如下所示:
from decimal import Decimal, InvalidOperation
这是现在我们在widgets.py
中需要的全部,但是当我们重构我们的视图逻辑时,我们会再次访问这个文件。
移动视图
接下来,我们需要创建views.py
文件。视图是较大的 GUI 组件,如我们的DataRecordForm
类。目前它是我们唯一的视图,但我们将在后面的章节中创建更多的视图,并将它们添加到这里。
打开views.py
文件,复制DataRecordForm
类,然后返回顶部处理模块导入。同样,我们需要tkinter
和ttk
,我们的文件保存逻辑依赖于datetime
以获得文件名。
将它们添加到文件顶部如下:
import tkinter as tk
from tkinter import ttk
from datetime import datetime
不过,我们还没有完成;我们实际的小部件还没有,我们需要导入它们。由于我们将在文件之间进行大量对象导入,让我们暂停一下,考虑一下处理这些导入的最佳方法。
我们可以导入对象的三种方式:
-
使用通配符导入从
widgets.py
中导入所有类 -
使用
from ... import ...
格式明确地从widgets.py
中导入所有所需的类 -
导入
widgets
并将我们的小部件保留在它们自己的命名空间中
让我们考虑一下这些方法的相对优点:
-
第一个选项是迄今为止最简单的,但随着应用程序的扩展,它可能会给我们带来麻烦。通配符导入将会导入模块内在全局范围内定义的每个名称。这不仅包括我们定义的类,还包括任何导入的模块、别名和定义的变量或函数。随着应用程序在复杂性上的扩展,这可能会导致意想不到的后果和微妙的错误。
-
第二个选项更清晰,但意味着我们将需要维护导入列表,因为我们添加新类并在不同文件中使用它们,这导致了一个长而丑陋的导入部分,难以让人理解。
-
第三种选项是目前为止最好的,因为它将所有名称保留在命名空间内,并保持代码优雅简单。唯一的缺点是我们需要更新我们的代码,以便所有对小部件类的引用都包含模块名称。为了避免这变得笨拙,让我们将
widgets
模块别名为一个简短的名字,比如w
。
将以下代码添加到你的导入中:
from . import widgets as w
现在,我们只需要遍历代码,并在所有LabelInput
、RequiredEntry
、DateEntry
、ValidatedCombobox
和ValidatedSpinbox
的实例之前添加w.
。这应该很容易在 IDLE 或任何其他文本编辑器中使用一系列搜索和替换操作来完成。
例如,表单的line 1
如下所示:
# line 1
self.inputs['Date'] = w.LabelInput(
recordinfo, "Date",
input_class=w.DateEntry,
input_var=tk.StringVar()
)
self.inputs['Date'].grid(row=0, column=0)
self.inputs['Time'] = w.LabelInput(
recordinfo, "Time",
input_class=w.ValidatedCombobox,
input_var=tk.StringVar(),
input_args={"values": ["8:00", "12:00", "16:00", "20:00"]}
)
self.inputs['Time'].grid(row=0, column=1)
self.inputs['Technician'] = w.LabelInput(
recordinfo, "Technician",
input_class=w.RequiredEntry,
input_var=tk.StringVar()
)
self.inputs['Technician'].grid(row=0, column=2)
在你到处更改之前,让我们停下来,花一点时间重构这段代码中的一些冗余。
在我们的视图逻辑中消除冗余
查看视图逻辑中的字段定义:它们包含了很多与我们的模型中的信息相同的信息。最小值、最大值、增量和可能值在这里和我们的模型代码中都有定义。甚至输入小部件的类型直接与存储的数据类型相关。理想情况下,这应该只在一个地方定义,而且那个地方应该是模型。如果我们因为某种原因需要更新模型,我们的表单将不同步。
我们需要做的是将字段规范从我们的模型传递到视图类,并让小部件的详细信息从该规范中定义。
由于我们的小部件实例是在LabelInput
类内部定义的,我们将增强该类的功能,以自动从我们模型的字段规范格式中计算出input
类和参数。打开widgets.py
文件,并像在model.py
中一样导入FieldTypes
类。
现在,找到LabelInput
类,并在__init__()
方法之前添加以下代码:
field_types = {
FT.string: (RequiredEntry, tk.StringVar),
FT.string_list: (ValidatedCombobox, tk.StringVar),
FT.iso_date_string: (DateEntry, tk.StringVar),
FT.long_string: (tk.Text, lambda: None),
FT.decimal: (ValidatedSpinbox, tk.DoubleVar),
FT.integer: (ValidatedSpinbox, tk.IntVar),
FT.boolean: (ttk.Checkbutton, tk.BooleanVar)
}
这段代码充当了将我们模型的字段类型转换为适合字段类型的小部件类型和变量类型的关键。
现在,我们需要更新__init__()
,接受一个field_spec
参数,并在给定时使用它来定义输入小部件,如下所示:
def __init__(self, parent, label='', input_class=None,
input_var=None, input_args=None, label_args=None,
field_spec=None, **kwargs):
super().__init__(parent, **kwargs)
input_args = input_args or {}
label_args = label_args or {}
if field_spec:
field_type = field_spec.get('type', FT.string)
input_class = input_class or
self.field_types.get(field_type)[0]
var_type = self.field_types.get(field_type)[1]
self.variable = input_var if input_var else var_type()
# min, max, increment
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')
# values
if 'values' in field_spec and 'values' not in input_args:
input_args['values'] = field_spec.get('values')
else:
self.variable = input_var
if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton):
input_args["text"] = label
input_args["variable"] = self.variable
else:
self.label = ttk.Label(self, text=label, **label_args)
self.label.grid(row=0, column=0, sticky=(tk.W + tk.E))
input_args["textvariable"] = self.variable
# ... Remainder of __init__() is the same
让我们逐步解析这些更改:
-
首先,我们将
field_spec
添加为一个关键字参数,并将None
作为默认值。我们可能会在没有字段规范的情况下使用这个类,所以我们保持这个参数是可选的。 -
如果给出了
field_spec
,我们将执行以下操作:-
我们将获取
type
值,并将其与我们类的字段键一起使用以获取input_class
。如果我们想要覆盖这个值,显式传递的input_class
将覆盖检测到的值。 -
我们将以相同的方式确定适当的变量类型。再次,如果显式传递了
input_var
,我们将优先使用它,否则我们将使用从字段类型确定的那个。我们将以任何方式创建一个实例,并将其存储在self.variable
中。 -
对于
min
、max
、inc
和values
,如果字段规范中存在键,并且相应的from_
、to
、increment
或values
参数没有显式传递进来,我们将使用field_spec
值设置input_args
变量。
-
-
如果没有传入
field_spec
,我们需要将self.variable
从input_var
参数中赋值。 -
现在我们使用
self.variable
而不是input_var
来分配输入的变量,因为这些值可能不再是相同的,而self.variable
将包含正确的引用。
现在,我们可以更新我们的视图代码以利用这种新的能力。我们的DataRecordForm
类将需要访问模型的fields
字典,然后可以使用它将字段规范发送到LabelInput
类。
回到views.py
文件,在方法签名中编辑,以便我们可以传入字段规范的字典:
def __init__(self, parent, fields, *args, **kwargs):
有了对fields
字典的访问权限,我们只需从中获取字段规范,并将其传递到LabelInput
类中,而不是指定输入类、输入变量和输入参数。
现在,第一行看起来是这样的:
self.inputs['Date'] = w.LabelInput(
recordinfo, "Date",
field_spec=fields['Date'])
self.inputs['Date'].grid(row=0, column=0)
self.inputs['Time'] = w.LabelInput(
recordinfo, "Time",
field_spec=fields['Time'])
self.inputs['Time'].grid(row=0, column=1)
self.inputs['Technician'] = w.LabelInput(
recordinfo, "Technician",
field_spec=fields['Technician'])
self.inputs['Technician'].grid(row=0, column=2)
继续以相同的方式更新其余的小部件,用field_spec
替换input_class
、input_var
和input_args
。请注意,当您到达高度字段时,您将需要保留定义min_var
、max_var
和focus_update_var
的input_args
部分。
例如,以下是Min Height
输入的定义:
self.inputs['Min Height'] = w.LabelInput(
plantinfo, "Min Height (cm)",
field_spec=fields['Min Height'],
input_args={"max_var": max_height_var,
"focus_update_var": min_height_var})
就这样。现在,我们对字段规范的任何更改都可以仅在模型中进行,并且表单将简单地执行正确的操作。
创建应用程序文件
最后,让我们按照以下步骤创建我们的控制器类Application
:
-
打开
application.py
文件,并将脚本中的Application
类定义复制进去。 -
首先,我们要修复的是我们的导入项。在文件顶部添加以下代码:
import tkinter as tk
from tkinter import ttk
from datetime import datetime
from . import views as v
from . import models as m
当然,我们需要tkinter
和ttk
,以及datetime
来定义我们的文件名。虽然我们只需要从views
和models
中各自选择一个类,但我们还是要将它们保留在各自的命名空间中。随着应用程序的扩展,我们可能会有更多的视图,可能还会有更多的模型。
- 我们需要更新在新命名空间中
__init__()
中对DataRecordForm
的调用,并确保我们传递所需的字段规范字典,如下所示:
self.recordform = v.DataRecordForm(self, m.CSVModel.fields)
- 最后,我们需要更新
Application.on_save()
以使用模型,如下所示:
def on_save(self):
"""Handles save button clicks"""
errors = self.recordform.get_errors()
if errors:
self.status.set(
"Cannot save, error in fields: {}"
.format(', '.join(errors.keys())))
return False
# For now, we save to a hardcoded filename
with a datestring.
datestring = datetime.today().strftime("%Y-%m-%d")
filename = "abq_data_record_{}.csv".format(datestring)
model = m.CSVModel(filename)
data = self.recordform.get()
model.save_record(data)
self.records_saved += 1
self.status.set(
"{} records saved this session".
format(self.records_saved)
)
self.recordform.reset()
正如您所看到的,使用我们的模型非常简单;我们只需通过传递文件名创建了一个CSVModel
类,然后将表单的数据传递给save_record()
。
运行应用程序
应用程序现在完全迁移到了新的数据格式。要测试它,请导航到应用程序根文件夹ABQ_Data_Entry
,然后执行以下命令:
python3 abq_data_entry.py
它应该看起来和行为就像第四章中的单个脚本通过验证和自动化减少用户错误一样,并且在下面的截图中运行无错误:
成功!
使用版本控制软件
我们的代码结构良好,可以扩展,但是还有一个非常关键的问题我们应该解决:版本控制。您可能已经熟悉了版本控制系统(VCS),有时也称为修订控制或源代码管理,但如果不了解,它是处理大型和不断变化的代码库的不可或缺的工具。
在开发应用程序时,我们有时会认为自己知道需要更改什么,但事实证明我们错了。有时我们不完全知道如何编写某些代码,需要多次尝试才能找到正确的方法。有时我们需要恢复到很久之前更改过的代码。有时我们有多个人在同一段代码上工作,需要将他们的更改合并在一起。版本控制系统就是为了解决这些问题以及更多其他问题而创建的。
有数十种不同的版本控制系统,但它们大多数本质上都是相同的:
-
您有一个可用于进行更改的代码副本
-
您定期选择要提交回主副本的更改
-
您可以随时查看代码的旧版本,然后恢复到主副本
-
您可以创建代码分支来尝试不同的方法、新功能或大型重构
-
您随后可以将这些分支合并回主副本
VCS 提供了一个安全网,让您可以自由更改代码,而无需担心您会彻底毁坏它:返回到已知的工作状态只需几个快速的命令即可。它还帮助我们记录代码的更改,并在机会出现时与他人合作。
有数十种 VC 系统可供选择,但迄今为止,远远最流行的是Git。
使用 Git 的超快速指南
Git 是由 Linus Torvalds 创建的,用于 Linux 内核项目的版本控制软件,并且已经发展成为世界上最流行的 VC 软件。它被源代码共享网站如 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 仓库添加文件和目录:
git add abq_data_entry
git add abq_data_entry.py
git add docs
git add README.rst
此时,我们的文件已经准备就绪,但尚未提交到仓库。您可以随时输入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
命令:
alanm@alanm-laptop:~/ABQ_Data_Entry$ git log
commit df48707422875ff545dc30f4395f82ad2d25f103 (HEAD -> master)
Author: Alan Moore <alan@example.com>
Date: Thu Dec 21 18:12:17 2017 -0600
Initial commit
正如您所看到的,我们上次提交的作者
、日期
和提交
消息都显示出来。如果我们有更多的提交,它们也会在这里列出,从最新到最旧。您在输出的第一行中看到的长十六进制值是提交哈希,这是一个唯一的值,用于标识提交。这个值可以用来在其他操作中引用提交。
例如,我们可以使用它将我们的存储库重置到过去的状态,如下所示:
-
删除
README.rst
文件,并验证它已完全消失。 -
现在,输入命令
git reset --hard df48707
,将df48707
替换为您提交哈希的前七个字符。 -
再次检查您的文件清单:
README.rst
文件已经回来了。
这里发生的是我们改变了我们的存储库,然后告诉 Git 将存储库的状态硬重置到我们的第一个提交。如果您不想重置您的存储库,您也可以暂时检出一个旧的提交,或者使用特定的提交作为基础创建一个分支。正如您已经看到的,这为我们提供了一个强大的实验安全网;无论您如何调整代码,任何提交都只是一个命令的距离!
Git 有许多更多的功能超出了本书的范围。如果您想了解更多信息,Git 项目在git-scm.com/book
提供了免费的在线手册,您可以在那里了解分支和设置远程存储库等高级功能。目前,重要的是在进行更改时提交更改,以便保持您的安全网并记录更改的历史。
总结
在本章中,您学会了为您的简单脚本做一些严肃的扩展准备。您学会了如何将应用程序的职责领域划分为单独的组件,以及如何将代码分割成单独的模块。您学会了如何使用 reStructuredText 记录代码并使用版本控制跟踪所有更改。
在下一章中,我们将通过实现一些新功能来测试我们的新项目布局。您将学习如何使用 Tkinter 的应用程序菜单小部件,如何实现文件打开和保存,以及如何使用消息弹出窗口来警告用户或确认操作。
第六章:使用 Menu 和 Tkinter 对话框创建菜单
随着应用程序的增长,组织对其功能的访问变得越来越重要。传统上,应用程序通过菜单系统来解决这个问题,通常位于应用程序窗口的顶部或(在某些平台上)全局桌面菜单中。虽然这些菜单是特定于应用程序的,但已经制定了一些组织惯例,我们应该遵循以使我们的软件更加用户友好。
在本章中,我们将涵盖以下主题:
-
分析一些报告的问题并决定解决方案
-
探索一些 Tkinter 的对话框类,并使用它们来实现常见菜单功能
-
学习如何使用 Tkinter 的 Menu 小部件,并使用它为我们的应用程序创建菜单
-
为我们的应用程序创建一些选项并将它们保存到磁盘
解决我们应用程序中的问题
您的老板给您带来了需要在您的应用程序中解决的第一组问题。首先,在无法在第二天之前输入当天最后的报告的情况下,文件名中的硬编码日期字符串是一个问题。数据输入人员需要一种手动选择要追加的文件的方法。
此外,数据输入人员对表单中的自动填充功能有不同的看法。有些人觉得这非常有帮助,但其他人真的希望看到它被禁用。您需要一种允许用户打开和关闭此功能的方法。
最后,一些用户很难注意到底部状态栏的文本,并希望应用程序在由于错误而无法保存数据时更加显眼。
决定如何解决这些问题
很明显,您需要实现一种选择文件和切换表单自动填充功能的方法。首先,您考虑只向主应用程序添加这两个控件,并进行快速的模拟:
您很快就会意识到这不是一个很好的设计,当然也不是一个能够适应增长的设计。您的用户不想盲目地在框中输入文件路径和文件名,也不想让很多额外的字段混乱 UI。
幸运的是,Tkinter 提供了一些工具,可以帮助我们解决这些问题:
-
文件对话框:Tkinter 的
filedialog
库将帮助简化文件选择 -
错误对话框:Tkinter 的
messagebox
库将让我们更加显眼地显示错误消息 -
主菜单:Tkinter 的
Menu
类可以帮助我们组织常见功能,以便轻松访问
实现简单的 Tkinter 对话框
状态栏适用于不应中断用户工作流程的偶发信息,但对于阻止工作按预期继续的错误,用户应该以更有力的方式受到警告。一个中断程序直到通过鼠标点击确认的错误对话框是相当有力的,似乎是解决用户看不到错误的问题的好方法。为了实现这些,您需要了解 Tkinter 的messagebox
库。
Tkinter messagebox
在 Tkinter 中显示简单对话框的最佳方法是使用tkinter.messagebox
库,其中包含几个方便的函数,允许您快速创建常见的对话框类型。每个函数显示一个预设的图标和一组按钮,带有您指定的消息和详细文本,并根据用户点击的按钮返回一个值。
以下表格显示了一些messagebox
函数及其图标和返回值:
函数 | 图标 | 按钮 / 返回值 |
---|---|---|
askokcancel |
问题 | 确定 (True ), 取消 (False ) |
askretrycancel |
警告 | 重试 (True ), 取消 (False ) |
askyesno |
问题 | 是 (True ), 否 (False ) |
askyesnocancel |
问题 | 是 (True ), 否 (False ), 取消 (None ) |
showerror |
错误 | 确定 (ok ) |
showinfo |
信息 | 确定(ok ) |
showwarning |
警告 | 确定(ok ) |
我们可以将以下三个文本参数传递给任何messagebox
函数:
-
title
:此参数设置窗口的标题,在您的桌面环境中显示在标题栏和/或任务栏中。 -
message
:此参数设置对话框的主要消息。通常使用标题字体,应保持相当简短。 -
detail
:此参数设置对话框的正文文本,通常显示在标准窗口字体中。
这是对showinfo()
的基本调用:
messagebox.showinfo(
title='This is the title',
message="This is the message",
detail='This is the detail')
在 Windows 10 中,它会导致一个对话框(在其他平台上可能看起来有点不同),如下面的屏幕截图所示:
Tkinter 的messagebox
对话框是模态的,这意味着程序执行会暂停,而 UI 的其余部分在对话框打开时无响应。没有办法改变这一点,所以只能在程序暂停执行时使用它们。
让我们创建一个小例子来展示messagebox
函数的使用:
import tkinter as tk
from tkinter import messagebox
要使用messagebox
,我们需要从 Tkinter 导入它;你不能简单地使用tk.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()
这将创建一个带有是和否按钮的对话框;如果点击是,函数返回True
。如果点击否,函数返回False
,应用程序退出。
如果我们的用户想要看到更多的框,让我们显示一个信息框:
messagebox.showinfo(title='You got it',
message="Ok, here's another dialog.",
detail='Hope you like it!')
注意message
和detail
在您的平台上显示方式的不同。在某些平台上,没有区别;在其他平台上,message
是大而粗体的,这对于短文本是合适的。对于跨平台软件,最好使用detail
进行扩展输出。
显示错误对话框
现在您了解了如何使用messagebox
,错误对话框应该很容易实现。Application.on_save()
方法已经在状态栏中显示错误;我们只需要通过以下步骤使此错误显示在错误消息框中:
- 首先,我们需要在
application.py
中导入它,如下所示:
from tkinter import messagebox
- 现在,在
on_save()
方法中检查错误后,我们将设置错误对话框的消息。我们将通过使用"\n *"
将错误字段制作成项目符号列表。不幸的是,messagebox
不支持任何标记,因此需要使用常规字符手动构建类似项目符号列表的结构,如下所示:
message = "Cannot save record"
detail = "The following fields have errors: \n * {}".format(
'\n * '.join(errors.keys()))
- 现在,我们可以在
status()
调用之后调用showerror()
,如下所示:
messagebox.showerror(title='Error', message=message, detail=detail)
- 现在,打开程序并点击保存;您将看到一个对话框,提示应用程序中的错误,如下面的屏幕截图所示:
这个错误应该对任何人来说都很难错过!
messagebox
对话框的一个缺点是它们不会滚动;长错误消息将创建一个可能填满(或超出)屏幕的对话框。如果这是一个潜在的问题,您将需要创建一个包含可滚动小部件的自定义对话框。
设计我们的菜单
大多数应用程序将功能组织成一个分层的菜单系统,通常显示在应用程序或屏幕的顶部(取决于操作系统)。虽然这个菜单的组织在操作系统之间有所不同,但某些项目在各个平台上都是相当常见的。
在这些常见项目中,我们的应用程序将需要以下内容:
-
包含文件操作(如打开/保存/导出)的文件菜单,通常还有退出应用程序的选项。我们的用户将需要此菜单来选择文件并退出程序。
-
一个选项、首选项或设置菜单,用户可以在其中配置应用程序。我们将需要此菜单来进行切换设置;暂时我们将其称为选项。
-
帮助菜单,其中包含指向帮助文档的链接,或者至少包含一个关于应用程序的基本信息的消息。我们将为关于对话框实现这个菜单。
苹果、微软和 Gnome 项目分别发布了 macOS、Windows 和 Gnome 桌面(在 Linux 和 BSD 上使用)的指南;每套指南都涉及特定平台的菜单布局。
在我们实现菜单之前,我们需要了解 Tkinter 中菜单的工作原理。
在 Tkinter 中创建菜单
tkinter.Menu
小部件用于在 Tkinter 应用程序中实现菜单;它是一个相当简单的小部件,作为任意数量的菜单项的容器。
菜单项可以是以下五种类型之一:
-
command
:这些项目是带有标签的按钮,当单击时运行回调。 -
checkbutton
:这些项目就像我们表单中的Checkbutton
一样,可以用来切换BooleanVar
。 -
radiobutton
:这些项目类似于Checkbutton
,但可以用来在几个互斥选项之间切换任何类型的 Tkinter 变量。 -
separator
:这些项目用于将菜单分成几个部分。 -
cascade
:这些项目允许您向菜单添加子菜单。子菜单只是另一个tkinter.Menu
对象。
让我们编写以下小程序来演示 Tkinter 菜单的使用:
import tkinter as tk
root = tk.Tk()
main_text = tk.StringVar(value='Hi')
label = tk.Label(root, textvariable=main_text)
label.pack()
root.mainloop()
该应用程序设置了一个标签,其文本由字符串变量main_text
控制。如果您运行此应用程序,您将看到一个简单的窗口,上面写着 Hi。让我们开始添加菜单组件。
在root.mainloop()
的正上方,添加以下代码:
main_menu = tk.Menu(root)
root.config(menu=main_menu)
这将创建一个主菜单,然后将其设置为我们应用程序的主菜单。
目前,该菜单是空的,所以让我们通过添加以下代码来添加一个项目:
main_menu.add('command', label='Quit', command=root.quit)
我们已经添加了一个退出应用程序的命令。add
方法允许我们指定一个项目类型和任意数量的属性来创建一个新的菜单项。对于命令,我们至少需要有一个label
参数来指定菜单中显示的文本,以及一个指向 Python 回调的command
参数。
一些平台,如 macOS,不允许在顶级菜单中使用命令。
让我们尝试创建一个子菜单,如下所示:
text_menu = tk.Menu(main_menu, tearoff=False)
创建子菜单就像创建菜单一样,只是我们将parent
菜单指定为小部件的parent
。注意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'))
我们在这里使用lambda
函数是为了方便,但您可以传递任何 Python 可调用的函数。这里使用的add_command
方法只是add('command')
的快捷方式。添加其他项目的方法也是类似的(级联,分隔符等)。
让我们使用add_cascade
方法将我们的菜单添加回其parent
小部件,如下所示:
main_menu.add_cascade(label="Text", menu=text_menu)
在将子菜单添加到其parent
菜单时,我们只需提供菜单的标签和菜单本身。
我们也可以将Checkbutton
和Radiobutton
小部件添加到菜单中。为了演示这一点,让我们创建另一个子菜单来改变标签的外观。
首先,我们需要以下设置代码:
font_bold = tk.BooleanVar()
font_size = tk.IntVar()
def set_font(*args):
font_spec = 'TkDefaultFont {size} {bold}'.format(
size=font_size.get(),
bold='bold' if font_bold.get() else '')
label.config(font=font_spec)
font_bold.trace('w', set_font)
font_size.trace('w', set_font)
在这里,我们只是创建变量来存储粗体选项和字体大小的状态,然后创建一个回调方法,当调用时实际上从这些变量设置标签的字体。然后,我们在两个变量上设置了一个跟踪,以便在它们的值发生变化时调用回调。
现在,我们只需要通过添加以下代码来创建菜单选项来改变变量:
# appearance menu
appearance_menu = tk.Menu(main_menu, tearoff=False)
main_menu.add_cascade(label="Appearance", menu=appearance_menu)
# bold text button
appearance_menu.add_checkbutton(label="Bold", variable=font_bold)
像普通的Checkbutton
小部件一样,add_checkbutton
方法接受BooleanVar
,它被传递给variable
参数,该参数将绑定到其选中状态。与普通的Checkbutton
小部件不同,使用label
参数而不是text
参数来分配标签文本。
为了演示单选按钮,让我们向我们的子菜单添加一个子菜单,如下所示:
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 之间生成的偶数列表;对于每一个,我们都添加一个值等于该大小的radiobutton
项。就像普通的Radiobutton
小部件一样,variable
参数中给定的变量在按钮被选中时将被更新为value
参数中给定的值。
启动应用程序并尝试一下,如下面的屏幕截图所示:
现在你了解了Menu
小部件,让我们在我们的应用程序中添加一个。
实现我们的应用程序菜单
作为 GUI 的一个重要组件,我们的菜单显然是一个视图,并且应该在views.py
文件中实现。但是,它还需要设置影响其他视图的选项(例如我们现在正在实现的表单选项)并运行影响应用程序的函数(如退出)。我们需要以这样一种方式实现它,即我们将控制器函数保留在Application
类中,但仍将 UI 代码保留在views.py
中。让我们看看以下步骤:
- 让我们首先打开
views.py
并创建一个继承了tkinter.Menu
的MainMenu
类:
class MainMenu(tk.Menu):
"""The Application's main menu"""
我们重写的__init__()
方法将使用两个字典,settings
字典和callbacks
字典,如下所示:
def __init__(self, parent, settings, callbacks, **kwargs):
super().__init__(parent, **kwargs)
我们将使用这些字典与控制器进行通信:settings
将包含可以绑定到我们菜单控件的 Tkinter 变量,callbacks
将是我们可以绑定到菜单命令的控制器方法。当然,我们需要确保在我们的Application
对象中使用预期的变量和可调用对象来填充这些字典。
- 现在,让我们开始创建我们的子菜单,首先是文件菜单如下:
file_menu = tk.Menu(self, tearoff=False)
file_menu.add_command(
label="Select file…",
command=callbacks['file->open'])
我们文件菜单中的第一个命令是“选择文件...”。注意标签中的省略号:这向用户表明该选项将打开另一个需要进一步输入的窗口。我们将command
设置为从我们的callbacks
字典中使用file->open
键的引用。这个函数还不存在;我们将很快实现它。让我们添加我们的下一个文件菜单命令,file->quit
:
file_menu.add_separator()
file_menu.add_command(label="Quit",
command=callbacks['file->quit'])
再次,我们将这个命令指向了一个尚未定义的函数,它在我们的callbacks
字典中。我们还添加了一个分隔符;由于退出程序与选择目标文件是一种根本不同的操作,将它们分开是有意义的,你会在大多数应用程序菜单中看到这一点。
- 这完成了文件菜单,所以我们需要将它添加到主
menu
对象中,如下所示:
self.add_cascade(label='File', menu=file_menu)
- 我们需要创建的下一个子菜单是我们的“选项”菜单。由于我们只有两个菜单选项,我们将直接将它们添加到子菜单中作为
Checkbutton
。选项菜单如下所示:
options_menu = tk.Menu(self, tearoff=False)
options_menu.add_checkbutton(label='Autofill Date',
variable=settings['autofill date'])
options_menu.add_checkbutton(label='Autofill Sheet data',
variable=settings['autofill sheet data'])
self.add_cascade(label='Options', menu=options_menu)
绑定到这些Checkbutton
小部件的变量在settings
字典中,因此我们的Application
类将用两个BooleanVar
变量填充settings
:autofill date
和autofill sheet data
。
- 最后,我们将创建一个“帮助”菜单,其中包含一个显示“关于”对话框的选项:
help_menu = tk.Menu(self, tearoff=False)
help_menu.add_command(label='About…', command=self.show_about)
self.add_cascade(label='Help', menu=help_menu)
我们的“关于”命令指向一个名为show_about
的内部MainMenu
方法,我们将在下面实现。关于对话框将是纯 UI 代码,没有实际的应用程序功能,因此我们可以完全在视图中实现它。
显示关于对话框
我们已经看到如何使用messagebox
来创建错误对话框。现在,我们可以应用这些知识来创建我们的About
框,具体步骤如下:
- 在
__init__()
之后开始一个新的方法定义:
def show_about(self):
"""Show the about dialog"""
About
对话框可以显示您认为相关的任何信息,包括您的联系信息、支持信息、版本信息,甚至整个README
文件。在我们的情况下,我们会保持它相当简短。让我们指定message
标题文本和detail
正文文本:
about_message = 'ABQ Data Entry'
about_detail = ('by Alan D Moore\n'
'For assistance please contact the author.')
我们只是在标题中使用应用程序名称,然后在详细信息中简要介绍我们的姓名以及联系支持的方式。请随意在您的About
框中放入任何文本。
在 Python 代码中,有几种处理长的多行字符串的方法;这里使用的方法是在括号之间放置多个字符串,它们之间只有空格。Python 会自动连接只有空格分隔的字符串,因此对 Python 来说,这看起来像是一组括号内的单个长字符串。与其他方法相比,例如三引号,这允许您保持清晰的缩进并明确控制换行。
- 最后,我们需要显示我们的
About
框如下:
messagebox.showinfo(title='About', message=about_message,
detail=about_detail)
在上述代码中,showinfo()
函数显然是最合适的,因为我们实际上是在显示信息。这完成了我们的show_about()
方法和我们的MainMenu
类。接下来,我们需要对Application
进行必要的修改以使其正常工作。
在控制器中添加菜单功能
现在我们的菜单类已经定义,我们的Application
对象需要创建一个实例并将其添加到主窗口中。在我们这样做之前,我们需要定义一些MainMenu
类需要的东西。
从上一节中记住以下事项:
-
我们需要一个包含我们两个设置选项的 Tkinter 变量的
settings
字典 -
我们需要一个指向
file->select
和file->quit
回调的callbacks
字典 -
我们需要实际实现文件选择和退出的函数
让我们定义一些MainMenu
类需要的东西。
打开application.py
,让我们在创建self.recordform
之前开始添加代码:
self.settings = {
'autofill date': tk.BooleanVar(),
'autofill sheet data': tk.BooleanVar()
}
这将是我们的全局设置字典,用于存储两个配置选项的布尔变量。接下来,我们将创建callbacks
字典:
self.callbacks = {
'file->select': self.on_file_select,
'file->quit': self.quit
}
在这里,我们将我们的两个回调指向Application
类的方法,这些方法将实现功能。对我们来说,幸运的是,Tkinter 已经实现了self.quit
,它确实做了您期望它做的事情,因此我们只需要自己实现on_file_select
。我们将通过创建我们的menu
对象并将其添加到应用程序来完成这里:
menu = v.MainMenu(self, self.settings, self.callbacks)
self.config(menu=menu)
处理文件选择
当用户需要输入文件或目录路径时,首选的方法是显示一个包含迷你文件浏览器的对话框,通常称为文件对话框。与大多数工具包一样,Tkinter 为我们提供了用于打开文件、保存文件和选择目录的对话框。这些都是filedialog
模块的一部分。
就像messagebox
一样,filedialog
是一个 Tkinter 子模块,需要显式导入才能使用。与messagebox
一样,它包含一组方便的函数,用于创建适合不同场景的文件对话框。
以下表格列出了函数、它们的返回值和它们的 UI 特性:
功能 | 返回值 | 特点 |
---|---|---|
askdirectory |
目录路径字符串 | 仅显示目录,不显示文件 |
askopenfile |
文件句柄对象 | 仅允许选择现有文件 |
askopenfilename |
文件路径字符串 | 仅允许选择现有文件 |
askopenfilenames |
字符串列表的文件路径 | 类似于askopenfilename ,但允许多个选择 |
askopenfiles |
文件句柄对象列表 | 类似于askopenfile ,但允许多个选择 |
asksaveasfile |
文件句柄对象 | 允许创建新文件,在现有文件上进行确认提示 |
asksaveasfilename |
文件路径字符串 | 允许创建新文件,在现有文件上进行确认提示 |
正如您所看到的,每个文件选择对话框都有两个版本:一个返回路径作为字符串,另一个返回打开的文件对象。
每个函数都可以使用以下常见参数:
-
title
:此参数指定对话框窗口标题。 -
parent
:此参数指定(可选的)parent
小部件。文件对话框将出现在此小部件上方。 -
initialdir
:此参数是文件浏览器应该开始的目录。 -
filetypes
:此参数是一个元组列表,每个元组都有一个标签和匹配模式,用于创建过滤下拉类型的文件,通常在文件名输入框下方看到。这用于将可见文件过滤为仅由应用程序支持的文件。
asksaveasfile
和asksaveasfilename
方法还接受以下两个附加选项:
-
initialfile
:此选项是要选择的默认文件路径 -
defaultextension
:此选项是一个文件扩展名字符串,如果用户没有这样做,它将自动附加到文件名
最后,返回文件对象的方法接受一个指定文件打开模式的mode
参数;这些是 Python 的open
内置函数使用的相同的一到两个字符字符串。
我们的应用程序需要使用哪个对话框?让我们考虑一下我们的需求:
-
我们需要一个对话框,允许我们选择一个现有文件
-
我们还需要能够创建一个新文件
-
由于打开文件是模型的责任,我们只想获得一个文件名传递给模型
这些要求清楚地指向了asksaveasfilename
函数。让我们看看以下步骤:
- 在
Application
对象上启动一个新方法:
def on_file_select(self):
"""Handle the file->select action from the menu"""
filename = filedialog.asksaveasfilename(
title='Select the target file for saving records',
defaultextension='.csv',
filetypes=[('Comma-Separated Values', '*.csv *.CSV')])
该方法首先要求用户选择一个具有.csv
扩展名的文件;使用filetypes
参数,现有文件的选择将被限制为以.csv
或 CSV 结尾的文件。对话框退出时,函数将将所选文件的路径作为字符串返回给filename
。不知何故,我们必须将此路径传递给我们的模型。
-
目前,文件名是在
Application
对象的on_save
方法中生成并传递到模型中。我们需要将filename
移动到Application
对象的属性中,以便我们可以从我们的on_file_select()
方法中覆盖它。 -
回到
__init__()
方法,在settings
和callbacks
定义之前添加以下代码行:
self.filename = tk.StringVar()
self.filename
属性将跟踪当前选择的保存文件。以前,我们在on_save()
方法中设置了我们的硬编码文件名;没有理由每次调用on_save()
时都这样做,特别是因为我们只在用户没有选择文件的情况下使用它。相反,将这些行从on_save()
移到self.filename
定义的上方:
datestring = datetime.today().strftime("%Y-%m-%d")
default_filename = "abq_data_record_{}.csv".
format(datestring)
self.filename = tk.StringVar(value=default_filename)
- 定义了默认文件名后,我们可以将其作为
StringVar
的默认值提供。每当用户选择文件名时,on_file_select()
将更新该值。这是通过on_file_select()
末尾的以下行完成的:
if filename:
self.filename.set(filename)
-
if
语句的原因是,我们只想在用户实际选择了文件时才设置一个值。请记住,如果用户取消操作,文件对话框将返回None
;在这种情况下,用户希望当前设置的文件名仍然是目标。 -
最后,当设置了这个值时,我们需要让我们的
on_save()
方法使用它,而不是硬编码的默认值。 -
在
on_save()
方法中,找到定义filename
的行,并将其更改为以下行:
filename = self.filename.get()
- 这完成了代码更改,使文件名选择起作用。此时,您应该能够运行应用程序并测试文件选择功能。保存几条记录并注意它们确实保存到您选择的文件中。
使我们的设置生效
虽然文件保存起作用,但设置却没有。settings
菜单项应该按预期工作,保持选中或取消选中,但它们尚未改变数据输入表单的行为。让我们让它起作用。
请记住,DataRecordForm
类的reset()
方法中实现了两个自动填充功能。为了使用我们的新设置,我们需要通过以下步骤让我们的表单访问settings
字典:
- 打开
views.py
并更新DataRecordForm.__init__()
方法如下:
def __init__(self, parent, fields, settings, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.settings = settings
- 我们添加了一个额外的位置参数
settings
,然后将其设置为self.settings
,以便类中的所有方法都可以访问它。现在,看一下reset()
方法;目前,日期自动填充代码如下:
current_date = datetime.today().strftime('%Y-%m-%d')
self.inputs['Date'].set(current_date)
self.inputs['Time'].input.focus()
- 我们只需要确保这仅在
settings['autofill date']
为True
时发生:
if self.settings['autofill date'].get():
current_date = datetime.today().strftime('%Y-%m-%d')
self.inputs['Date'].set(current_date)
self.inputs['Time'].input.focus()
表格数据的自动填充已经在条件语句下,如下所示:
if plot not in ('', plot_values[-1]):
self.inputs['Lab'].set(lab)
self.inputs['Time'].set(time)
...
- 为了使设置生效,我们只需要在
if
语句中添加另一个条件:
if (self.settings['autofill sheet data'].get() and
plot not in ('', plot_values[-1])):
...
最后一部分的难题是确保我们在创建DataRecordForm
时将我们的settings
字典发送到DataRecordForm
。
- 回到
Application
代码,更新我们对DataRecordForm()
的调用,包括self.settings
如下:
self.recordform = v.DataRecordForm(self,
m.CSVModel.fields, self.settings)
- 现在,如果运行程序,您应该会发现设置得到了尊重;尝试勾选和取消勾选它们,然后保存记录后查看发生了什么。
持久化设置
我们的设置有效,但有一个主要的烦恼:它们在会话之间不持久。关闭应用程序并重新启动,您会发现设置恢复为默认值。这不是一个主要问题,但这是一个我们不应该留给用户的粗糙边缘。
Python 为我们提供了各种将数据持久保存在文件中的方法。我们已经体验过 CSV,它是为表格数据设计的;还有其他设计用于不同功能的格式。
以下表格仅显示了 Python 标准库中可用的存储数据选项中的一些选项:
库 | 数据类型 | 适用 | 优点 | 缺点 |
---|---|---|---|---|
pickle |
二进制 | 任何类型的对象 | 快速、简单、文件小 | 不安全,文件不易读,必须读取整个文件 |
configparser |
文本 | key->value 对 |
快速、简单、易读的文件 | 无法处理序列或复杂对象,层次有限 |
json |
文本 | 简单值和序列 | 广泛使用,易读的文件 | 无法序列化复杂对象而不经修改 |
xml |
文本 | 任何类型的 Python 对象 | 强大、灵活、大部分易读的文件 | 不安全,使用复杂,文件语法冗长 |
sqlite |
二进制 | 关系数据 | 快速而强大的文件 | 需要 SQL 知识,对象必须转换为表 |
如果这还不够,第三方库中甚至还有更多选项可用。几乎任何一个都适合存储一些布尔值,那么我们该如何选择呢?
-
SQL 和 XML 功能强大,但对于我们这里的简单需求来说太复杂了。
-
我们希望坚持使用文本格式,以防需要调试损坏的设置文件,因此
pickle
不适用。 -
configparser
现在可以工作了,但它无法处理列表、元组和字典,这在将来可能会有限制。 -
这留下了
json
,这是一个不错的选择。虽然它不能处理每种类型的 Python 对象,但它可以处理字符串、数字和布尔值,以及列表和字典。这应该可以很好地满足我们的配置需求。
当我们说一个库是“不安全”时,这意味着什么?一些数据格式设计有强大的功能,比如可扩展性、链接或别名,解析库必须实现这些功能。不幸的是,这些功能可能被用于恶意目的。例如,十亿次笑 XML 漏洞结合了三个 XML 功能,制作了一个文件,当解析时,会扩展到一个巨大的大小(通常导致程序或者在某些情况下,系统崩溃)。
为设置持久性构建模型
与任何数据持久化一样,我们需要先实现一个模型。与我们的CSVModel
类一样,设置模型需要保存和加载数据,以及定义设置数据的布局。
在models.py
文件中,让我们按照以下方式开始一个新的类:
class SettingsModel:
"""A model for saving settings"""
就像我们的CSVModel
类一样,我们需要定义我们模型的模式:
variables = {
'autofill date': {'type': 'bool', 'value': True},
'autofill sheet data': {'type': 'bool', 'value': True}
}
variables
字典将存储每个项目的模式和值。每个设置都有一个列出数据类型和默认值的字典(如果需要,我们可以在这里列出其他属性,比如最小值、最大值或可能的值)。variables
字典将是我们保存到磁盘并从磁盘加载以持久化程序设置的数据结构。
模型还需要一个位置来保存配置文件,因此我们的构造函数将以文件名和路径作为参数。现在,我们只提供并使用合理的默认值,但在将来我们可能会想要更改这些值。
然而,我们不能只提供一个单一的文件路径;我们在同一台计算机上有不同的用户,他们会想要保存不同的设置。我们需要确保设置保存在各个用户的主目录中,而不是一个单一的公共位置。
因此,我们的__init__()
方法如下:
def __init__(self, filename='abq_settings.json', path='~'):
# determine the file path
self.filepath = os.path.join(
os.path.expanduser(path), filename)
作为 Linux 或 macOS 终端的用户会知道,~
符号是 Unix 的快捷方式,指向用户的主目录。Python 的os.path.expanduser()
函数将这个字符转换为绝对路径(即使在 Windows 上也是如此),这样文件将被保存在运行程序的用户的主目录中。os.path.join()
将文件名附加到扩展路径上,给我们一个完整的路径到用户特定的配置文件。
一旦模型被创建,我们就希望从磁盘加载用户保存的选项。从磁盘加载数据是一个非常基本的模型操作,我们应该能够在类外部控制,所以我们将这个方法设为公共方法。
我们将称这个方法为load()
,并在这里调用它:
self.load()
load()
将期望找到一个包含与variables
字典相同格式的字典的 JSON 文件。它将需要从文件中加载数据,并用文件副本替换自己的variables
副本。
一个简单的实现如下:
def load(self):
"""Load the settings from the file"""
with open(self.filepath, 'r') as fh:
self.variables = json.loads(fh.read())
json.loads()
函数读取 JSON 字符串并将其转换为 Python 对象,我们直接保存到我们的variables
字典中。当然,这种方法也存在一些问题。首先,如果设置文件不存在会发生什么?在这种情况下,open
会抛出一个异常,程序会崩溃。不好!
因此,在我们尝试打开文件之前,让我们测试一下它是否存在,如下所示:
# if the file doesn't exist, return
if not os.path.exists(self.filepath):
return
如果文件不存在,该方法将简单地返回并不执行任何操作。文件不存在是完全合理的,特别是如果用户从未运行过程序或编辑过任何设置。在这种情况下,该方法将保持self.variables
不变,用户将最终使用默认值。
第二个问题是我们的设置文件可能存在,但不包含任何数据或无效数据(比如variables
字典中不存在的键),导致程序崩溃。为了防止这种情况,我们将 JSON 数据拉到一个本地变量中;然后通过询问raw_values
只获取那些存在于variables
中的键来更新variables
,如果它们不存在,则提供一个默认值。
新的、更安全的代码如下:
# open the file and read in the raw values
with open(self.filepath, 'r') as fh:
raw_values = json.loads(fh.read())
# don't implicitly trust the raw values,
# but only get known keys
for key in self.variables:
if key in raw_values and 'value' in raw_values[key]:
raw_value = raw_values[key]['value']
self.variables[key]['value'] = raw_value
由于variables
已经使用默认值创建,如果raw_values
没有给定键,或者该键中的字典不包含values
项,我们只需要忽略raw_values
。
现在load()
已经编写好了,让我们编写一个save()
方法将我们的值写入文件:
def save(self, settings=None):
json_string = json.dumps(self.variables)
with open(self.filepath, 'w') as fh:
fh.write(json_string)
json.dumps()
函数是loads()
的反函数:它接受一个 Python 对象并返回一个 JSON 字符串。保存我们的settings
数据就像将variables
字典转换为字符串并将其写入指定的文本文件一样简单。
我们的模型需要的最后一个方法是让外部代码设置值的方法;他们可以直接操作variables
,但为了保护我们的数据完整性,我们将通过方法调用来实现。遵循 Tkinter 的惯例,我们将称这个方法为set()
。
set()
方法的基本实现如下:
def set(self, key, value):
self.variables[key]['value'] = value
这个简单的方法只是接受一个键和值,并将它们写入variables
字典。不过,这又带来了一些潜在的问题;如果提供的值对于数据类型来说不是有效的怎么办?如果键不在我们的variables
字典中怎么办?这可能会导致难以调试的情况,因此我们的set()
方法应该防范这种情况。
将代码更改如下:
if (
key in self.variables and
type(value).__name__ == self.variables[key]['type']
):
self.variables[key]['value'] = value
通过使用与实际 Python 类型名称相对应的type
字符串,我们可以使用type(value).__name__
将其与值的类型名称进行匹配(我们本可以在我们的variables
字典中使用实际的类型对象,但这些对象无法序列化为 JSON)。现在,尝试写入未知键或不正确的变量类型将会失败。
然而,我们不应该让它悄悄失败;我们应该立即引发ValueError
来提醒我们存在问题,如下所示:
else:
raise ValueError("Bad key or wrong variable type")
为什么要引发异常?如果测试失败,这只能意味着调用代码中存在错误。通过异常,我们将立即知道调用代码是否向我们的模型发送了错误的请求。如果没有异常,请求将悄悄失败,留下难以发现的错误。
故意引发异常的想法对于初学者来说通常似乎很奇怪;毕竟,我们正在尽量避免异常,对吧?对于主要是使用现有模块的小脚本来说,这是正确的;然而,当编写自己的模块时,异常是模块与使用它的代码交流问题的正确方式。试图处理或更糟糕的是消除外部调用代码的不良行为,最好会破坏模块化;在最坏的情况下,它会产生难以追踪的微妙错误。
在我们的应用程序中使用设置模型
我们的应用程序在启动时需要加载设置,然后在更改设置时自动保存。目前,应用程序的settings
字典是手动创建的,但是我们的模型应该真正告诉它创建什么样的变量。让我们按照以下步骤在我们的应用程序中使用settings
模型:
- 用以下代码替换定义
Application.settings
的代码:
self.settings_model = m.SettingsModel()
self.load_settings()
首先,我们创建一个settings
模型并将其保存到我们的Application
对象中。然后,我们将运行一个load_settings()
方法。这个方法将负责根据settings_model
设置Application.settings
字典。
- 现在,让我们创建
Application.load_settings()
:
def load_settings(self):
"""Load settings into our self.settings dict."""
- 我们的模型存储了每个变量的类型和值,但我们的应用程序需要 Tkinter 变量。我们需要一种方法将模型对数据的表示转换为
Application
可以使用的结构。一个字典提供了一个方便的方法来做到这一点,如下所示:
vartypes = {
'bool': tk.BooleanVar,
'str': tk.StringVar,
'int': tk.IntVar,
'float': tk.DoubleVar
}
注意,每个名称都与 Python 内置函数的类型名称匹配。我们可以在这里添加更多条目,但这应该涵盖我们未来的大部分需求。现在,我们可以将这个字典与模型的variables
字典结合起来构建settings
字典:
self.settings = {}
for key, data in self.settings_model.variables.items():
vartype = vartypes.get(data['type'], tk.StringVar)
self.settings[key] = vartype(value=data['value'])
- 在这里使用 Tkinter 变量的主要原因是,我们可以追踪用户通过 UI 对值所做的任何更改并立即做出响应。具体来说,我们希望在用户进行更改时立即保存我们的设置,如下所示:
for var in self.settings.values():
var.trace('w', self.save_settings)
- 当然,这意味着我们需要编写一个名为
Application.save_settings()
的方法,每当值发生更改时都会运行。Application.load_settings()
已经完成,所以让我们接着做这个:
def save_settings(self, *args):
"""Save the current settings to a preferences file"""
save_settings()
方法只需要从Application.settings
中获取数据并保存到模型中:
for key, variable in self.settings.items():
self.settings_model.set(key, variable.get())
self.settings_model.save()
这很简单,只需要循环遍历self.settings
,并调用我们模型的set()
方法逐个获取值。然后,我们调用模型的save()
方法。
- 现在,你应该能够运行程序并观察到设置被保存了,即使你关闭并重新打开应用程序。你还会在你的主目录中找到一个名为
abq_settings.json
的文件。
总结
在这一章中,我们简单的表单迈出了成为一个完全成熟的应用程序的重要一步。我们实现了一个主菜单,选项设置在执行之间是持久的,并且有一个“关于”对话框。我们增加了选择保存记录的文件的能力,并通过错误对话框改善了表单错误的可见性。在这个过程中,你学到了关于 Tkinter 菜单、文件对话框和消息框,以及标准库中持久化数据的各种选项。
在下一章中,我们将被要求让程序读取和写入。我们将学习关于 Tkinter 的树部件,如何在主视图之间切换,以及如何使我们的CSVModel
和DataRecordForm
类能够读取和更新现有数据。
第七章:使用 Treeview 导航记录
您收到了应用程序中的另一个功能请求。现在,您的用户可以打开任意文件,他们希望能够查看这些文件中的内容,并使用他们已经习惯的数据输入表单来更正旧记录,而不必切换到电子表格。简而言之,现在终于是时候在我们的应用程序中实现读取和更新功能了。
在本章中,我们将涵盖以下主题:
-
修改我们的 CSV 模型以实现读取和更新功能
-
发现 ttk
Treeview
小部件,并使用它构建记录列表 -
在我们的数据记录表单中实现记录加载和更新
-
重新设计菜单和应用程序,考虑到读取和更新
在模型中实现读取和更新
到目前为止,我们的整个设计都是围绕着一个只能向文件追加数据的表单;添加读取和更新功能是一个根本性的改变,几乎会触及应用程序的每个部分。这可能看起来是一项艰巨的任务,但通过逐个组件地进行,我们会发现这些变化并不那么令人难以承受。
我们应该做的第一件事是更新我们的文档,从Requirements
部分开始:
The program must:
* Provide a UI for reading, updating, and appending data to the CSV file
* ...
当然,还要更新后面不需要的部分:
The program does not need to:
* Allow deletion of data.
现在,只需让代码与文档匹配即可。
将读取和更新添加到我们的模型中
打开models.py
并考虑CSVModel
类中缺少的内容:
-
我们需要一种方法,可以检索文件中的所有记录,以便我们可以显示它们。我们将其称为
get_all_records()
。 -
我们需要一种方法来按行号从文件中获取单个记录。我们可以称之为
get_record()
。 -
我们需要以一种既能追加新记录又能更新现有记录的方式保存记录。我们可以更新我们的
save_record()
方法来适应这一点。
实现get_all_records()
开始一个名为get_all_records()
的新方法:
def get_all_records(self):
if not os.path.exists(self.filename):
return []
我们所做的第一件事是检查模型的文件是否已经存在。请记住,当我们的应用程序启动时,它会生成一个默认文件名,指向一个可能尚不存在的文件,因此get_all_records()
将需要优雅地处理这种情况。在这种情况下返回一个空列表是有道理的,因为如果文件不存在,就没有数据。
如果文件存在,让我们以只读模式打开它并获取所有记录:
with open(self.filename, 'r') as fh:
csvreader = csv.DictReader(fh)
records = list(csvreader)
虽然不是非常高效,但在我们的情况下,将整个文件加载到内存中并将其转换为列表是可以接受的,因为我们知道我们的最大文件应该限制在仅 401 行:20 个图形乘以 5 个实验室加上标题行。然而,这段代码有点太信任了。我们至少应该进行一些合理性检查,以确保用户实际上已经打开了包含正确字段的 CSV 文件,而不是其他任意文件。
让我们检查文件是否具有正确的字段结构:
csvreader = csv.DictReader(fh)
missing_fields = (set(self.fields.keys()) -
set(csvreader.fieldnames))
if len(missing_fields) > 0:
raise Exception(
"File is missing fields: {}"
.format(', '.join(missing_fields))
)
else:
records = list(csvreader)
在这里,我们首先通过将我们的fields
字典keys
的列表和 CSV 文件的fieldnames
转换为 Pythonset
对象来找到任何缺失的字段。我们可以从keys
中减去fieldnames
集合,并确定文件中缺少的字段。如果有任何字段缺失,我们将引发异常;否则,我们将 CSV 数据转换为list
。
Python 的set
对象非常有用,可以比较list
、tuple
和其他序列对象的内容。它们提供了一种简单的方法来获取诸如差异(x
中的项目不在y
中)或交集(x
和y
中的项目)之类的信息,或者允许您比较不考虑顺序的序列。
在我们可以返回records
列表之前,我们需要纠正一个问题;CSV 文件中的所有数据都存储为文本,并由 Python 作为字符串读取。这大多数情况下不是问题,因为 Tkinter 会负责根据需要将字符串转换为float
或int
,但是bool
值在 CSV 文件中存储为字符串True
和False
,直接将这些值强制转换回bool
是行不通的。False
是一个非空字符串,在 Python 中所有非空字符串都会被视为True
。
为了解决这个问题,让我们首先定义一个应被解释为True
的字符串列表:
trues = ('true', 'yes', '1')
不在此列表中的任何值都将被视为False
。我们将进行不区分大小写的比较,因此我们的列表中只有小写值。
接下来,我们使用列表推导式创建一个包含boolean
字段的字段列表,如下所示:
bool_fields = [
key for key, meta
in self.fields.items()
if meta['type'] == FT.boolean]
我们知道Equipment Fault
是我们唯一的布尔字段,因此从技术上讲,我们可以在这里硬编码它,但是最好设计您的模型,以便对模式的任何更改都将自动适当地处理逻辑部分。
现在,让我们通过添加以下代码来检查每行中的布尔字段:
for record in records:
for key in bool_fields:
record[key] = record[key].lower() in trues
对于每条记录,我们遍历我们的布尔字段列表,并根据我们的真值字符串列表检查其值,相应地设置该项的值。
修复布尔值后,我们可以将我们的records
列表返回如下:
return records
实现get_record()
我们的get_record()
方法需要接受行号并返回包含该行数据的单个字典。
如果我们利用我们的get_all_records()
方法,这就非常简单了,如下所示:
def get_record(self, rownum):
return self.get_all_records()[rownum]
由于我们的文件很小,拉取所有记录的开销很小,我们可以简单地这样做,然后取消引用我们需要的记录。
请记住,可能会传递不存在于我们记录列表中的rownum
;在这种情况下,我们会得到IndexError
;我们的调用代码将需要捕获此错误并适当处理。
将更新添加到save_record()
将我们的save_record()
方法转换为可以更新记录的方法,我们首先需要做的是提供传入要更新的行号的能力。默认值将是None
,表示数据是应追加的新行。
新的方法签名如下:
def save_record(self, data, rownum=None):
"""Save a dict of data to the CSV file"""
我们现有的逻辑不需要更改,但只有在rownum
为None
时才应运行。
因此,在该方法中要做的第一件事是检查rownum
:
if rownum is not None:
# This is an update, new code here
else:
# Old code goes here, indented one more level
对于相对较小的文件,更新单行的最简单方法是将整个文件加载到列表中,更改列表中的行,然后将整个列表写回到一个干净的文件中。
在if
块下,我们将添加以下代码:
records = self.get_all_records()
records[rownum] = data
with open(self.filename, 'w') as fh:
csvwriter = csv.DictWriter(fh,
fieldnames=self.fields.keys())
csvwriter.writeheader()
csvwriter.writerows(records)
再次利用我们的get_all_records()
方法将 CSV 文件的内容提取到列表中。然后,我们用提供的data
字典替换请求行中的字典。最后,我们以写模式(w
)打开文件,这将清除其内容并用我们写入文件的内容替换它,并将标题和所有记录写回文件。
我们采取的方法使得两个用户同时在保存 CSV 文件中工作是不安全的。创建允许多个用户编辑单个文件的软件是非常困难的,许多程序选择使用锁文件或其他保护机制来防止这种情况。
这个方法已经完成了,这就是我们需要在模型中进行的所有更改,以实现更新和查看。现在,是时候向我们的 GUI 添加必要的功能了。
实现记录列表视图
记录列表视图将允许我们的用户浏览文件的内容,并打开记录进行查看或编辑。我们的用户习惯于在电子表格中看到这些数据,以表格格式呈现,因此设计我们的视图以类似的方式是有意义的。由于我们的视图主要存在于查找和选择单个记录,我们不需要显示所有信息;只需要足够让用户区分一个记录和另一个记录。
快速分析表明我们需要 CSV 行号、Date
、Time
、Lab
和Plot
。
对于构建具有可选择行的类似表格的视图,Tkinter 为我们提供了 ttk Treeview
小部件。为了构建我们的记录列表视图,我们需要了解Treeview
。
ttk Treeview
Treeview
是一个 ttk 小部件,设计用于以分层结构显示数据的列。
也许这种数据的最好例子是文件系统树:
-
每一行可以代表一个文件或目录
-
每个目录可以包含额外的文件或目录
-
每一行都可以有额外的数据属性,比如权限、大小或所有权信息
为了探索Treeview
的工作原理,我们将借助pathlib
创建一个简单的文件浏览器。
在之前的章节中,我们使用os.path
来处理文件路径。pathlib
是 Python 3 标准库的一个新添加,它提供了更面向对象的路径处理方法。
打开一个名为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
的方法,将给我们提供这样的列表,如下所示:
paths = Path('.').glob('**/*')
glob()
会对文件系统树扩展通配符字符,比如*
和?
。这个名称可以追溯到一个非常早期的 Unix 命令,尽管现在相同的通配符语法在大多数现代操作系统中都被使用。
Path('.')
创建一个引用当前工作目录的路径对象,**/*
是一个特殊的通配符语法,递归地抓取路径下的所有对象。结果是一个包含当前目录下每个目录和文件的Path
对象列表。
完成后,我们可以通过执行以下代码来创建和配置我们的Treeview
小部件:
tv = ttk.Treeview(root, columns=['size', 'modified'],
selectmode='None')
与任何 Tkinter 小部件一样,Treeview
的第一个参数是它的parent
小部件。Treeview
小部件中的每一列都被赋予一个标识字符串;默认情况下,总是有一个名为"#0"
的列。这一列代表树中每个项目的基本标识信息,比如名称或 ID 号。要添加更多列,我们使用columns
参数来指定它们。这个列表包含任意数量的字符串,用于标识随后的列。
最后,我们设置selectmode
,确定用户如何在树中选择项目。
以下表格显示了selectmode
的选项:
Value | Behavior |
---|---|
selectmode |
可以进行选择 |
none (作为字符串,而不是None 对象) |
不能进行选择 |
browse |
用户只能选择一个项目 |
extended |
用户可以选择多个项目 |
在这种情况下,我们正在阻止选择,所以将其设置为none
。
为了展示我们如何使用列名,我们将为列设置一些标题:
tv.heading('#0', text='Name')
tv.heading('size', text='Size', anchor='center')
tv.heading('modified', text='Modified', anchor='e')
Treeview
heading 方法用于操作列heading
小部件;它接受列名,然后是要分配给列heading
小部件的任意数量的属性。
这些属性可以包括:
-
text
:标题显示的文本。默认情况下为空。 -
anchor
:文本的对齐方式;可以是八个基本方向之一或center
,指定为字符串或 Tkinter 常量。 -
command
:单击标题时要运行的命令。这可能用于按该列对行进行排序,或选择该列中的所有值,例如。 -
image
:要在标题中显示的图像。
最后,我们将列打包到root
小部件中,并扩展它以填充小部件:
tv.pack(expand=True, fill='both')
除了配置标题之外,我们还可以使用Treeview.column
方法配置列本身的一些属性。
例如,我们可以添加以下代码:
tv.column('#0', stretch=True)
tv.column('size', width=200)
在此示例中,我们已将第一列中的stretch
设置为True
,这将导致它扩展以填充可用空间;我们还将size
列上的width
值设置为200
像素。
可以设置的列参数包括:
-
stretch
:是否将此列扩展以填充可用空间。 -
width
:列的宽度,以像素为单位。 -
minwidth
:列可以调整的最小宽度,以像素为单位。 -
anchor
:列中文本的对齐方式。可以是八个基本方向或中心,指定为字符串或 Tkinter 常量。
树视图配置完成后,现在需要填充数据。使用insert
方法逐行填充Treeview
的数据。
insert
方法如下所示:
mytreeview.insert(parent, 'end', iid='item1',
text='My Item 1', values=['12', '42'])
第一个参数指定插入行的parent
项目。这不是parent
小部件,而是层次结构中插入行所属的parent
行。该值是一个字符串,指的是parent
项目的iid
。对于顶级项目,该值应为空字符串。
下一个参数指定应将项目插入的位置。它可以是数字索引或end
,将项目放在列表末尾。
之后,我们可以指定关键字参数,包括:
-
text
:这是要显示在第一列中的值。 -
values
:这是剩余列的值列表。 -
image
:这是要显示在列最左侧的图像对象。 -
iid
:项目 ID 字符串。如果不指定,将自动分配。 -
open
:行在开始时是否打开(显示子项)。 -
tags
:标签字符串列表。
要将我们的路径插入Treeview
,让我们按如下方式遍历我们的paths
列表:
for path in paths:
meta = path.stat()
parent = str(path.parent)
if parent == '.':
parent = ''
在调用insert
之前,我们需要从路径对象中提取和准备一些数据。path.stat()
将给我们一个包含各种文件信息的对象。path.parent
提供了包含路径;但是,我们需要将root
路径的名称(当前为单个点)更改为一个空字符串,这是Treeview
表示root
节点的方式。
现在,我们按如下方式添加insert
调用:
tv.insert(parent, 'end', iid=str(path),
text=str(path.name), values=[meta.st_size, meta.st_mtime])
通过使用路径字符串作为项目 ID,我们可以将其指定为其子对象的父级。我们仅使用对象的name
(不包含路径)作为我们的显示值,然后使用st_size
和st_mtime
来填充大小和修改时间列。
运行此脚本,您应该会看到一个简单的文件树浏览器,类似于这样:
Treeview
小部件默认不提供任何排序功能,但我们可以相当容易地添加它。
首先,让我们通过添加以下代码创建一个排序函数:
def sort(tv, col):
itemlist = list(tv.get_children(''))
itemlist.sort(key=lambda x: tv.set(x, col))
for index, iid in enumerate(itemlist):
tv.move(iid, tv.parent(iid), index)
在上述代码片段中,sort
函数接受一个Treeview
小部件和我们将对其进行排序的列的 ID。它首先使用Treeview
的get_children()
方法获取所有iid
值的列表。接下来,它使用col
的值作为键对各种iid
值进行排序;令人困惑的是,Treeview
的set()
方法用于检索列的值(没有get()
方法)。最后,我们遍历列表,并使用move()
方法将每个项目移动到其父级下的新索引(使用parent()
方法检索)。
为了使我们的列可排序,使用command
参数将此函数作为回调添加到标题中,如下所示:
tv.heading('#0', text='Name', command=lambda: sort(tv, '#0'))
tv.heading('size', text='Size', anchor='center',
command=lambda: sort(tv, 'size'))
tv.heading('modified', text='Modified', anchor='e',
command=lambda: sort(tv, 'modified'))
使用Treeview
实现我们的记录列表
现在我们了解了如何使用Treeview
小部件,让我们开始构建我们的记录列表小部件。
我们将首先通过子类化tkinter.Frame
来开始,就像我们在记录表单中所做的那样。
class RecordList(tk.Frame):
"""Display for CSV file contents"""
为了节省一些重复的代码,我们将在类常量中定义我们的列属性和默认值。这也使得更容易调整它们以满足我们的需求。
使用以下属性开始你的类:
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
。对于第一个默认列,我们将显示 CSV 行号。我们还为一些列设置了width
和anchor
值,并配置了Date
字段以进行拉伸。我们将在__init__()
中配置Treeview
小部件时使用这些值。
让我们从以下方式开始定义我们的__init__()
:
def __init__(self, parent, callbacks, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.callbacks = callbacks
与其他视图一样,我们将从Application
对象接受回调方法的字典,并将其保存为实例属性。
配置 Treeview 小部件
现在,通过执行以下代码片段来创建我们的Treeview
小部件:
self.treeview = ttk.Treeview(self,
columns=list(self.column_defs.keys())[1:],
selectmode='browse')
请注意,我们正在从我们的columns
列表中排除#0
列;它不应在这里指定,因为它会自动创建。我们还选择了browse
选择模式,这样用户就可以选择 CSV 文件的单独行。
让我们继续将我们的Treeview
小部件添加到RecordList
并使其填充小部件:
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.treeview.grid(row=0, column=0, sticky='NSEW')
现在,通过迭代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)
添加滚动条
ttk 的Treeview
默认没有滚动条;它可以使用键盘或鼠标滚轮控件进行滚动,但用户合理地期望在可滚动区域上有滚动条,以帮助他们可视化列表的大小和当前位置。
幸运的是,ttk 为我们提供了一个可以连接到我们的Treeview
小部件的Scrollbar
对象:
self.scrollbar = ttk.Scrollbar(self,
orient=tk.VERTICAL, command=self.treeview.yview)
在这里,Scrollbar
接受以下两个重要参数:
-
orient
:此参数确定是水平滚动还是垂直滚动 -
command
:此参数为滚动条移动事件提供回调
在这种情况下,我们将回调设置为树视图的yview
方法,该方法用于使Treeview
上下滚动。另一个选项是xview
,它将用于水平滚动。
我们还需要将我们的Treeview
连接回滚动条:
self.treeview.configure(yscrollcommand=self.scrollbar.set)
如果我们不这样做,我们的Scrollbar
将不知道我们已经滚动了多远或列表有多长,并且无法适当地设置滚动条小部件的大小或位置。
配置了我们的Scrollbar
后,我们需要将其放置在小部件上——通常是在要滚动的小部件的右侧。
我们可以使用我们的grid
布局管理器来实现这一点:
self.scrollbar.grid(row=0, column=1, sticky='NSW')
请注意,我们将sticky
设置为 north、south 和 west。north 和 south 确保滚动条拉伸到小部件的整个高度,west 确保它紧贴着Treeview
小部件的左侧。
填充 Treeview
现在我们有了Treeview
小部件,我们将创建一个populate()
方法来填充它的数据:
def populate(self, rows):
"""Clear the treeview & write the supplied data rows to it."""
rows
参数将接受dict
数据类型的列表,例如从model
返回的类型。其想法是控制器将从模型中获取一个列表,然后将其传递给此方法。
在重新填充Treeview
之前,我们需要清空它:
for row in self.treeview.get_children():
self.treeview.delete(row)
Treeview
的get_children()
方法返回每行的iid
列表。我们正在迭代此列表,将每个iid
传递给Treeview.delete()
方法,正如您所期望的那样,删除该行。
清除Treeview
后,我们可以遍历rows
列表并填充表格:
valuekeys = list(self.column_defs.keys())[1:]
for rownum, rowdata in enumerate(rows):
values = [rowdata[key] for key in valuekeys]
self.treeview.insert('', 'end', iid=str(rownum),
text=str(rownum), values=values)
我们在这里要做的第一件事是创建一个我们实际想要从每一行获取的所有键的列表;这只是从self.column_defs
减去#0
列的键列表。
接下来,我们使用 enumerate()
函数迭代行以生成行号。对于每一行,我们将使用列表推导创建正确顺序的值列表,然后使用 insert()
方法将列表插入到 Treeview
小部件的末尾。请注意,我们只是将行号(转换为字符串)用作行的 iid
和 text
。
在这个函数中我们需要做的最后一件事是进行一些小的可用性调整。为了使我们的 Treeview
对键盘友好,我们需要将焦点放在第一项上,这样键盘用户就可以立即开始使用箭头键进行导航。
在 Treeview
小部件中实际上需要三个方法调用,如下所示:
if len(rows) > 0:
self.treeview.focus_set()
self.treeview.selection_set(0)
self.treeview.focus('0')
首先,focus_set
将焦点移动到 Treeview
。接下来,selection_set(0)
选择列表中的第一条记录。最后,focus('0')
将焦点放在 iid
为 0
的行上。当然,我们只在有任何行的情况下才这样做。
响应记录选择
这个小部件的目的是让用户选择和打开记录;因此,我们需要一种方法来做到这一点。最好能够从双击或键盘选择等事件触发这一点。
Treeview
小部件有三个特殊事件,我们可以使用它们来触发回调,如下表所示:
事件字符串 | 触发时 |
---|---|
<<TreeviewSelect>> |
选择行,例如通过鼠标点击 |
<<TreeviewOpen>> |
通过双击或选择并按 Enter 打开行 |
<<TreeviewClose>> |
关闭打开的行 |
<<TreeviewOpen>>
听起来像我们想要的事件;即使我们没有使用分层列表,用户仍然在概念上打开记录,并且触发动作(双击)似乎很直观。我们将将此事件绑定到一个方法,该方法将打开所选记录。
将此代码添加到 __init__()
的末尾:
self.treeview.bind('<<TreeviewOpen>>', self.on_open_record)
on_open_record()
方法非常简单;将此代码添加到类中:
def on_open_record(self, *args):
selected_id = self.treeview.selection()[0]
self.callbacks'on_open_record'
只需从 Treeview
中检索所选 ID,然后使用控制器中的 callbacks
字典提供的函数调用所选 ID。这将由控制器来做一些适当的事情。
RecordList
类现在已经完成,但是我们的其他视图类需要注意。
修改记录表单以进行读取和更新
只要我们在编辑视图,我们就需要查看我们的 DataRecordForm
视图,并调整它以使其能够更新记录。
花点时间考虑一下我们需要进行的以下更改:
-
表单将需要一种方式来加载控制器提供的记录。
-
表单将需要跟踪它正在编辑的记录,或者是否是新记录。
-
我们的用户需要一些视觉指示来指示正在编辑的记录。
-
我们的保存按钮当前在应用程序中。它在表单之外没有任何意义,因此它可能应该是表单的一部分。
-
这意味着我们的表单将需要一个在单击保存按钮时调用的回调。我们需要像我们的其他视图一样为它提供一个
callbacks
字典。
更新 __init__()
让我们从我们的 __init__()
方法开始逐步进行这些工作:
def __init__(self, parent, fields,
settings, callbacks, *args, **kwargs):
self.callbacks = callbacks
我们正在添加一个新的参数 callbacks
,并将其存储为实例属性。这将为控制器提供一种方法来提供视图调用的方法。
接下来,我们的 __init__()
方法应该设置一个变量来存储当前记录:
self.current_record = None
我们将使用 None
来指示没有加载记录,表单正在用于创建新记录。否则,这个值将是一个引用 CSV 数据中行的整数。
我们可以在这里使用一个 Tkinter 变量,但在这种情况下没有真正的优势,而且我们将无法使用 None
作为值。
在表单顶部,在第一个表单字段之前,让我们添加一个标签,用于跟踪我们正在编辑的记录:
self.record_label = ttk.Label()
self.record_label.grid(row=0, column=0)
我们将其放在第0
行,第0
列,但第一个LabelFrame
也在那个位置。您需要逐个检查每个LabelFrame
,并在其对grid
的调用中递增row
值。
我们将确保每当记录加载到表单中时,此标签都会得到更新。
在小部件的最后,Notes
字段之后,让我们添加我们的新保存按钮如下:
self.savebutton = ttk.Button(self,
text="Save", command=self.callbacks["on_save"])
self.savebutton.grid(sticky="e", row=5, padx=10)
当点击按钮时,按钮将调用callbacks
字典中的on_save()
方法。在Application
中创建DataRecordForm
时,我们需要确保提供这个方法。
添加load_record()
方法
在我们的视图中添加的最后一件事是加载新记录的方法。这个方法需要使用控制器中给定的行号和数据字典设置我们的表单。
让我们将其命名为load_record()
如下:
def load_record(self, rownum, data=None):
我们应该首先从提供的rownum
设置表单的current_record
值:
self.current_record = rownum
回想一下,rownum
可能是None
,表示这是一个新记录。
让我们通过执行以下代码来检查:
if rownum is None:
self.reset()
self.record_label.config(text='New Record')
如果我们要插入新记录,我们只需重置表单,然后将标签设置为指示这是新记录。
请注意,这里的if
条件专门检查rownum
是否为None
;我们不能只检查rownum
的真值,因为0
是一个有效的用于更新的rownum
!
如果我们有一个有效的rownum
,我们需要让它表现得不同:
else:
self.record_label.config(text='Record #{}'.format(rownum))
for key, widget in self.inputs.items():
self.inputs[key].set(data.get(key, ''))
try:
widget.input.trigger_focusout_validation()
except AttributeError:
pass
在这个块中,我们首先使用正在编辑的行号适当地设置标签。
然后,我们循环遍历inputs
字典的键和小部件,并从data
字典中提取匹配的值。我们还尝试在每个小部件的输入上调用trigger_focusout_validation()
方法,因为 CSV 文件可能包含无效数据。如果输入没有这样的方法(也就是说,如果我们使用的是常规的 Tkinter 小部件而不是我们的自定义小部件之一,比如Checkbutton
),我们就什么也不做。
更新应用程序的其余部分
在我们对表单进行更改生效之前,我们需要更新应用程序的其余部分以实现新功能。我们的主菜单需要一些导航项,以便用户可以在记录列表和表单之间切换,并且需要在Application
中创建或更新控制器方法,以整合我们的新模型和视图功能。
主菜单更改
由于我们已经在views.py
文件中,让我们首先通过一些命令来在我们的主菜单视图中切换记录列表和记录表单。我们将在我们的菜单中添加一个Go
菜单,其中包含两个选项,允许在记录列表和空白记录表单之间切换。
在Options
和Help
菜单之间添加以下行:
go_menu = tk.Menu(self, tearoff=False)
go_menu.add_command(label="Record List",
command=callbacks['show_recordlist'])
go_menu.add_command(label="New Record",
command=callbacks['new_record'])
self.add_cascade(label='Go', menu=go_menu)
与以前一样,我们将这些菜单命令绑定到callbacks
字典中的函数,我们需要在Application
类中添加这些函数。
在应用程序中连接各部分
让我们快速盘点一下我们需要在Application
类中进行的以下更改:
-
我们需要添加一个
RecordList
视图的实例 -
我们需要更新我们对
CSVModel
的使用,以便可以从中访问数据 -
我们需要实现或重构视图使用的几个回调方法
添加RecordList
视图
我们将在__init__()
中创建RecordList
对象,就在DataRecordForm
之后,通过执行以下代码片段:
self.recordlist = v.RecordList(self, self.callbacks)
self.recordlist.grid(row=1, padx=10, sticky='NSEW')
请注意,当我们调用grid()
时,我们将RecordList
视图添加到已经包含DataRecordForm
的网格单元中。这是有意的。当我们这样做时,Tkinter 会将第二个小部件堆叠在第一个小部件上,就像将一张纸放在另一张纸上一样;我们将在稍后添加代码来控制哪个视图可见,通过将其中一个提升到堆栈的顶部。请注意,我们还将小部件粘贴到单元格的所有边缘。如果没有这段代码,一个小部件的一部分可能会在另一个小部件的后面可见。
类似地,我们需要更新记录表单的grid
调用如下:
self.recordform.grid(row=1, padx=10, sticky='NSEW')
移动模型
目前,我们的数据模型对象仅在on_save()
方法中创建,并且每次用户保存时都会重新创建。我们将要编写的其他一些回调函数也需要访问模型,因此我们将在Application
类启动或选择新文件时创建一个可以由所有方法共享的单个数据模型实例。让我们看看以下步骤:
- 首先,在创建
default_filename
后编辑Application.__init__()
方法:
self.filename = tk.StringVar(value=default_filename)
self.data_model = m.CSVModel(filename=self.filename.get())
-
接下来,每当文件名更改时,
on_file_select()
方法需要重新创建data_model
对象。 -
将
on_file_select()
的结尾更改为以下代码:
if filename:
self.filename.set(filename)
self.data_model = m.CSVModel(filename=self.filename.get())
现在,self.data_model
将始终指向当前数据模型,我们的所有方法都可以使用它来保存或读取数据。
填充记录列表
Treeview
小部件已添加到我们的应用程序中,但我们需要一种方法来用数据填充它。
我们将通过执行以下代码创建一个名为populate_recordlist()
的方法:
def populate_recordlist(self):
逻辑很简单:只需从模型中获取所有行并将它们发送到记录列表的populate()
方法。
我们可以简单地写成这样:
rows = self.data_model.get_all_records()
self.recordlist.populate(rows)
但要记住,如果文件出现问题,get_all_records()
将引发一个Exception
;我们需要捕获该异常并让用户知道出了问题。
使用以下代码更新代码:
try:
rows = self.data_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()
获得异常,我们将显示一个显示Exception
文本的错误对话框。
RecordList
视图应在创建新模型时重新填充;目前,这在Application.__init__()
和Application.on_file_select()
中发生。
在创建记录列表后立即更新__init__()
:
self.recordlist = v.RecordList(self, self.callbacks)
self.recordlist.grid(row=1, padx=10, sticky='NSEW')
self.populate_recordlist()
在if filename:
块的最后,更新on_file_select()
如下:
if filename:
self.filename.set(filename)
self.data_model = m.CSVModel(filename=self.filename.get())
self.populate_recordlist()
添加新的回调函数
检查我们的视图代码,以下回调函数需要添加到我们的callbacks
字典中:
-
show_recordlist()
:当用户点击菜单中的记录列表选项时调用此函数,它应该导致记录列表可见 -
new_record()
:当用户点击菜单中的新记录时调用此函数,它应该显示一个重置的DataRecordForm
-
on_open_record()
:当打开记录列表项时调用此函数,它应该显示填充有记录 ID 和数据的DataRecordForm
-
on_save()
:当点击保存按钮(现在是DataRecordForm
的一部分)时调用此函数,它应该导致记录表单中的数据被更新或插入模型。
我们将从show_recordlist()
开始:
def show_recordlist(self):
"""Show the recordform"""
self.recordlist.tkraise()
记住,当我们布置主应用程序时,我们将recordlist
叠放在数据输入表单上,以便一个遮挡另一个。tkraise()
方法可以在任何 Tkinter 小部件上调用,将其提升到小部件堆栈的顶部。在这里调用它将使我们的RecordList
小部件升至顶部并遮挡数据输入表单。
不要忘记将以下内容添加到callbacks
字典中:
self.callbacks = {
'show_recordlist': self.show_recordlist,
...
new_record()
和on_open_record()
回调都会导致recordform
被显示;一个在没有行号的情况下调用,另一个在有行号的情况下调用。我们可以在一个方法中轻松地回答这两个问题。
让我们称这个方法为open_record()
:
def open_record(self, rownum=None):
记住我们的DataRecordForm.load_record()
方法需要一个行号和一个data
字典,如果行号是None
,它会重置表单以进行新记录。所以,我们只需要设置行号和记录,然后将它们传递给load_record()
方法。
首先,我们将处理rownum
为None
的情况:
if rownum is None:
record = None
没有行号,就没有记录。很简单。
现在,如果有行号,我们需要尝试从模型中获取该行并将其用于record
:
else:
rownum = int(rownum)
record = self.data_model.get_record(rownum)
请注意,Tkinter 可能会将rownum
作为字符串传递,因为Treeview
的iid
值是字符串。我们将进行安全转换为int
,因为这是我们的模型所期望的。
记住,如果在读取文件时出现问题,模型会抛出Exception
,所以我们应该捕获这个异常。
将get_record()
的调用放在try
块中:
try:
record = self.data_model.get_record(rownum)
except Exception as e:
messagebox.showerror(title='Error',
message='Problem reading file',
detail=str(e))
return
在出现Exception
的情况下,我们将显示一个错误对话框,并在不改变任何内容的情况下从函数中返回。
有了正确设置的rownum
和record
,现在我们可以将它们传递给DataRecordForm
:
self.recordform.load_record(rownum, record)
最后,我们需要提升form
小部件,使其位于记录列表的顶部:
self.recordform.tkraise()
现在,我们可以更新我们的callbacks
字典,将这些键指向新的方法:
self.callbacks = {
'new_record': self.open_record,
'on_open_record': self.open_record,
...
你可以说我们不应该在这里有相同的方法,而只是让我们的视图拉取相同的键;然而,让视图在语义上引用回调是有意义的——也就是说,根据它们打算实现的目标,而不是它是如何实现的——然后让控制器确定哪段代码最符合这个语义需求。如果在某个时候,我们需要将这些分成两个方法,我们只需要在Application
中做这个操作。
我们已经有了一个on_save()
方法,所以将其添加到我们的回调中就足够简单了:
self.callbacks = {
...
'on_save': self.on_save
}
然而,我们当前的on_save()
方法只处理插入新记录。我们需要修复这个问题。
首先,我们可以删除获取文件名和创建模型的两行,因为我们可以直接使用Application
对象的data_model
属性。
现在,用以下内容替换下面的两行:
data = self.recordform.get()
rownum = self.recordform.current_record
try:
self.data_model.save_record(data, rownum)
我们只需要从DataRecordForm
中获取数据和当前记录,然后将它们传递给模型的save_record()
方法。记住,如果我们发送None
的rownum
,模型将插入一个新记录;否则,它将更新该行号的记录。
因为save_record()
可能会抛出几种不同的异常,所以它在这里是在一个try
块下面。
首先,如果我们尝试更新一个不存在的行号,我们会得到IndexError
,所以让我们捕获它:
except IndexError as e:
messagebox.showerror(title='Error',
message='Invalid row specified', detail=str(e))
self.status.set('Tried to update invalid row')
在出现问题的情况下,我们将显示一个错误对话框并更新状态文本。
save_record()
方法也可能会抛出一个通用的Exception
,因为它调用了模型的get_all_records()
方法。
我们也会捕获这个异常,并显示一个适当的错误:
except Exception as e:
messagebox.showerror(title='Error',
message='Problem saving record', detail=str(e))
self.status.set('Problem saving record')
这个方法中剩下的代码只有在没有抛出异常时才应该运行,所以将它移动到一个else
块下面:
else:
self.records_saved += 1
self.status.set(
"{} records saved this session".format(self.records_saved)
)
self.recordform.reset()
由于插入或更新记录通常会导致记录列表的更改,所以在成功保存文件后,我们还应该重新填充记录列表。
在if
块下面添加以下行:
self.populate_recordlist()
最后,我们只想在插入新文件时重置记录表单;如果不是,我们应该什么都不做。
将对recordform.reset()
的调用放在一个if
块下面:
if self.recordform.current_record is None:
self.recordform.reset()
清理
在退出application.py
之前,确保删除保存按钮的代码,因为我们已经将该 UI 部分移动到DataRecordForm
中。
在__init__()
中查找这些行并删除它们:
self.savebutton = ttk.Button(self, text="Save",
command=self.on_save)
self.savebutton.grid(sticky="e", row=2, padx=10)
你还可以将statusbar
的位置上移一行:
self.statusbar.grid(sticky="we", row=2, padx=10)
测试我们的程序
此时,您应该能够运行应用程序并加载一个示例 CSV 文件,如下面的截图所示:
确保尝试打开记录,编辑和保存它,以及插入新记录和打开不同的文件。
你还应该测试以下错误条件:
-
尝试打开一个不是 CSV 文件的文件,或者一个带有不正确字段的 CSV 文件。会发生什么?
-
打开一个有效的 CSV 文件,选择一个记录进行编辑,然后在点击保存之前,选择一个不同的或空文件。会发生什么?
-
打开两个程序的副本,并将它们指向保存的 CSV 文件。尝试在程序之间交替编辑或更新操作。注意发生了什么。
摘要
我们已经将我们的程序从仅追加的形式改变为能够从现有文件加载、查看和更新数据的应用程序。您学会了如何制作读写模型,如何使用 ttk Treeview
,以及如何修改现有的视图和控制器来读取和更新记录。
在我们的下一章中,我们将学习如何修改应用程序的外观和感觉。我们将学习如何使用小部件属性、样式和主题,以及如何使用位图图形。
第八章:使用样式和主题改进外观
虽然程序可以完全使用黑色、白色和灰色的纯文本进行功能性操作,但是对颜色、字体和图像的微妙使用可以增强视觉吸引力和可用性,即使是最实用的应用程序也是如此。您的数据输入应用程序也不例外。您的老板和用户已经向您提出了几个问题,似乎需要使用 Tkinter 的样式功能。您的老板已经告诉您,总部要求所有内部软件突出显示公司标志,而数据输入人员已经提到了应用程序的可读性和整体外观的各种问题。
在本章中,我们将学习一些 Tkinter 的功能,这些功能将帮助我们解决这些问题:
-
我们将学习如何向我们的 Tkinter GUI 添加图像
-
我们将学习如何调整 Tkinter 小部件的字体和颜色,直接和使用标签
-
我们将学习如何使用样式和主题调整 Ttk 小部件的外观
在 Tkinter 中使用图像
我们将处理的第一个要求是添加公司标志。由于公司政策的结果,您的应用程序应该嵌入公司标志,并且如果可能的话,您已被要求使您的应用程序符合要求。
要将此图像添加到我们的应用程序中,您需要了解 Tkinter 的PhotoImage
类。
Tkinter PhotoImage
包括Label
和Button
在内的几个 Tkinter 小部件可以接受image
参数,从而允许它们显示图像。在这些情况下,我们不能简单地将图像文件的路径放入其中;相反,我们必须创建一个PhotoImage
对象。
创建PhotoImage
对象相当简单:
myimage = tk.PhotoImage(file='my_image.png')
通常使用关键字参数file
调用PhotoImage
,该参数指向文件路径。或者,您可以使用data
参数指向包含图像数据的bytes
对象。
PhotoImage
可以在接受image
参数的任何地方使用,例如Label
:
mylabel = tk.Label(root, image=myimage)
必须注意的是,您的应用程序必须保留对PhotoImage
对象的引用,该引用将在图像显示期间保持在范围内;否则,图像将不会显示。
考虑以下示例:
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()
如果您运行此示例,您会注意到没有显示任何图像。这是因为smile
变量在__init__()
退出时立即被销毁;由于没有对PhotoImage
对象的引用,图像消失了,即使我们已经将其打包到布局中。要解决此问题,您需要将image
对象存储在实例变量中,例如self.smile
,这样在方法返回后它将继续存在。
Tkinter 中的图像支持仅限于 GIF、PGM、PPM 和(从版本 8.6 开始)PNG 文件。要使用其他文件格式,如 JPEG 或 BMP,您需要使用图像处理库(如 Pillow)将它们转换为 Tkinter 可以理解的格式。
在撰写本文时,macOS 的 Python 3 附带 Tkinter 8.5。要在 macOS 上使用 PNG,您需要升级到 Tkinter 8.6 或更高版本,或者使用 Pillow。有关 Tcl/Tk 和 macOS 的更多信息,请参见www.python.org/download/mac/tcltk/
。Pillow 不在 Python 标准库中。要安装它,请按照python-pillow.org
上的说明操作。
添加公司标志
凭借我们对PhotoImage
的了解,将公司标志添加到我们的程序应该很简单;但是,我们必须解决如何确定图像文件的路径的问题。路径可以是绝对路径,也可以是相对于工作目录的路径,但我们不知道另一个系统上的路径是什么。幸运的是,有一种方法可以找出来。
在abq_data_entry
目录下,创建一个名为images
的新目录,在其中放置一个适当大小的 PNG 文件,我们可以在我们的应用程序中使用(图像具有 8x5 的宽高比,所以在这种情况下,我们使用32x20
)。要获取图像的绝对路径,我们将依赖 Python 中一个名为__file__
的内置变量。在任何 Python 脚本中,__file__
变量将包含当前脚本文件的绝对路径,我们可以使用它来定位我们的图像文件。
例如,从我们的application.py
文件中,我们可以使用这段代码找到我们的图像:
from os import path
image_path = path.join(path.dirname(__file__),
'images/abq_logo_32x20.png')
在这个例子中,我们首先通过调用path.dirname(__file__)
找到包含application.py
文件的目录。这给了我们abq_data_entry
的绝对路径,从中我们知道图像的相对路径。我们可以连接这两个路径,并获得图像的绝对路径,无论程序安装在文件系统的何处。
这种方法可以正常工作,但考虑到我们可能希望从应用程序的各种模块中访问图像,并且不得不在多个文件中导入path
并重复这个逻辑,这不是最佳的。一个更干净的方法是将我们的images
文件夹视为 Python 包,并在其中创建指向图像路径的常量。
首先,在images
文件夹内创建一个__init__.py
文件,并添加以下代码:
from os import path
IMAGE_DIRECTORY = path.dirname(__file__)
ABQ_LOGO_32 = path.join(IMAGE_DIRECTORY, 'abq_logo-32x20.png')
ABQ_LOGO_64 = path.join(IMAGE_DIRECTORY, 'abq_logo-64x40.png')
现在,我们的application.py
模块可以简单地这样做:
from .images import ABQ_LOGO_32
Application.__init__()
然后可以使用ABQ_LOGO_32
中的路径创建一个PhotoImage
对象:
self.logo = tk.PhotoImage(file=ABQ_LOGO_32)
tk.Label(self, image=self.logo).grid(row=0)
创建PhotoImage
对象后,我们使用Label
显示它。如果你运行应用程序,你应该会看到标志出现在顶部。
设置我们的窗口图标
我们还可以将标志添加为我们的窗口图标,这比保留默认的 Tkinter 标志更有意义。这样,标志将显示在窗口装饰和操作系统的任务栏中。
作为 Tk 的子类,我们的Application
对象有一个名为iconbitmap
的方法,应该可以根据图标文件的路径设置图标。不幸的是,这个方法对于给定的文件类型非常挑剔,并且在各个平台上工作效果不佳。我们可以使用PhotoImage
和特殊的 Tk call()
方法来解决这个问题。
call
方法允许我们直接调用 Tcl/Tk 命令,并且可以用于访问 Tkinter 包装不好或根本不包装的 Tk 功能。
代码如下:
self.taskbar_icon = tk.PhotoImage(file=ABQ_LOGO_64)
self.call('wm', 'iconphoto', self._w, self.taskbar_icon)
第一行创建了另一个PhotoImage
对象,引用了一个更大的标志的版本。接下来,我们执行self.call()
,传入 Tcl/Tk 命令的各个标记。在这种情况下,我们调用wm iconphoto
命令。self._w
返回我们的Application
对象的 Tcl/Tk 名称;最后,我们传入我们创建的PhotoImage
对象。
希望你不需要经常使用call
,但如果你需要,你可以在这里找到有关 Tcl/Tk 命令的文档:www.tcl.tk/doc/
。
运行你的应用程序,注意图标已经改变了。
样式化 Tkinter 小部件
Tkinter 基本上有两种样式系统:旧的 Tkinter 小部件系统和更新的 Ttk 系统。由于我们仍然需要使用 Tkinter 和 Ttk 小部件,我们将不得不查看这两个系统。让我们首先看一下旧的 Tkinter 系统,并对我们应用程序中的 Tkinter 小部件应用一些样式。
小部件颜色属性
基本的 Tkinter 小部件允许你更改两种颜色:前景,主要是文本和边框,和背景,指其余的小部件。这些可以使用foreground
和background
参数,或它们的别名fg
和bg
来设置。
这个例子展示了在Label
上使用颜色:
l = tk.Label(text='Hot Dog!', fg='yellow', bg='red')
颜色的值可以是颜色名称字符串或 CSS 样式的 RGB 十六进制字符串。
例如,这段代码产生了相同的效果:
l2 = tk.Label(text='Also Hot Dog!',
foreground='#FFFF00',
background='#FF0000')
Tkinter 识别了 700 多种命名颜色,大致对应于 Linux 和 Unix 上使用的 X11 显示服务器或 Web 设计师使用的 CSS 命名颜色。有关完整列表,请参见www.tcl.tk/man/tcl8.6/TkCmd/colors.htm
。
在我们的表单上使用小部件属性
数据输入人员收到的一个请求是增加数据输入表单上各个部分之间的视觉分隔。我们的LabelFrame
小部件是简单的 Tkinter 小部件(不是 Ttk),因此我们可以通过给各个部分设置有色的背景来相对简单地实现这一点。
经过一番思考和讨论,您决定按以下方式对各个部分进行颜色编码:
-
记录信息将使用
khaki
,表明用于纸质记录的经典马尼拉文件夹 -
环境信息将使用
lightblue
,象征着水和空气 -
植物信息将具有
lightgreen
背景,象征着植物 -
Notes
已经足够与众不同,因此它将保持不变
打开views.py
并编辑DataRecordForm.__init__()
中的LabelFrame
调用:
recordinfo = tk.LabelFrame(
self, text="Record Information",
bg="khaki", padx=10, pady=10)
...
environmentinfo = tk.LabelFrame(
self, text="Environment Data",
bg='lightblue', padx=10, pady=10)
...
plantinfo = tk.LabelFrame(
self, text="Plant Data",
bg="lightgreen", padx=10, pady=10)
请注意,我们在这里添加了一些填充,以使小部件周围的颜色更加明显,并在表单中创建更多的分隔。
我们应该在Notes
小部件周围添加类似的填充:
self.inputs['Notes'].grid(sticky="w", row=4, column=0,
padx=10, pady=10)
在这种情况下,我们在grid
调用中添加了填充,以便整个LabelInput
被移动。
至少在 Debian Linux 上,结果看起来像这样:
尽管还不是一个视觉杰作,但我们在表单各个部分之间有了一些分隔和颜色编码。
使用标签
前景和背景对于按钮等简单小部件已经足够了,但是像Text
小部件或 Ttk Treeview
这样的更复杂的 Tkinter 小部件依赖于一种标签系统。在 Tkinter 中,标签是可以应用颜色和字体设置的小部件内容的命名区域。为了看看这是如何工作的,让我们构建一个粗糙但漂亮的 Python shell。
我们将首先创建一个Text
小部件:
import tkinter as tk
text = tk.Text(width=50, height=20, bg='black', fg='lightgreen')
text.pack()
在这里,我们使用fg
和bg
参数设置了一个程序员喜欢的黑底绿字的主题。但是,我们不仅仅要绿色的文本,让我们为我们的提示和解释器输出配置不同的颜色。
为此,我们将定义一些标签:
text.tag_configure('prompt', foreground='magenta')
text.tag_configure('output', foreground='yellow')
tag_configure
方法允许我们在Text
小部件上创建和配置标签。我们为我们的 shell 提示创建了一个名为'prompt'
的标签,另一个名为'output'
的标签。
要使用给定标签插入文本,我们需要执行以下操作:
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
的区域的结束之后开始获取文本。
接下来,让我们执行cmd
:
if cmd:
try:
output = str(eval(cmd))
except Exception as e:
output = str(e)
如果我们的cmd
实际上包含任何内容,我们将尝试使用eval
执行它,然后将响应值的字符串存储为output
。如果它抛出异常,我们将获得我们的异常作为字符串,并将其设置为output
。
然后,我们只需显示我们的output
:
text.insert('end', '\n' + output, ('output',))
我们插入我们的output
文本,前面加上一个换行符,并标记为output
。
我们将通过给用户返回一个prompt
来完成该函数:
text.insert('end', '\n>>> ', ('prompt',))
return 'break'
我们还在这里返回字符串break
,告诉 Tkinter 忽略触发回调的原始事件。因为我们将从Return
/Enter
按键触发这个操作,所以我们希望在完成后忽略该按键。如果不这样做,按键将在我们的函数返回后执行,用户将在提示下的下一行。
最后,我们需要将我们的函数绑定到Return
键:
text.bind('<Return>', on_return)
请注意,Enter
/Return
键的事件始终是<Return>
,即使在非苹果硬件上(该按键更常被标记为Enter
)也是如此。
你的应用程序应该看起来像这样:
虽然这个 shell 不会很快取代 IDLE,但它看起来确实很好看,你觉得呢?
使用标签样式化我们的记录列表
虽然Treeview
是一个 Ttk 小部件,但它使用标签来控制单独行的样式。我们可以利用这一点来回答数据输入人员提出的另一个要求:他们希望记录列表突出显示在当前会话期间更新和插入的记录。
首先,我们需要做的是让我们的Application
对象在会话期间跟踪哪些行已被更新或插入。
在Application.__init__()
中,我们将创建以下实例变量:
self.inserted_rows.clear()
self.updated_rows.clear()
当记录保存时,我们需要更新这些列表中的一个或另一个与其行号。我们将在Application.on_save()
中完成这个操作,在记录保存后但在重新填充记录列表之前。
首先,我们将检查更新的记录:
if rownum is not None:
self.updated_rows.append(rownum)
更新有rownum
,没有None
值,所以如果是这种情况,我们将把它追加到列表中。如果记录不断更新,我们的列表中会有重复,但在我们操作的规模上,这实际上并不重要。
现在,我们需要处理插入:
else:
rownum = len(self.data_model.get_all_records()) - 1
self.inserted_rows.append(rownum)
插入的记录会更麻烦一些,因为我们没有一个行号可以记录。不过,我们知道插入总是添加到文件的末尾,所以它应该比文件中的行数少一个。
我们的插入和更新记录将保留到程序会话结束(用户退出程序)时,但在用户选择新文件的情况下,我们需要手动删除它们。
我们可以通过在on_file_select()
中清除列表来处理这个问题:
if filename:
...
self.inserted_rows = []
self.updated_rows = []
现在,我们的控制器知道插入和更新的记录。不过,我们的记录列表并不知道;我们需要解决这个问题。
在Application.__init__()
中找到RecordList
调用,并将这些变量添加到其参数中:
self.recordlist = v.RecordList(
self, self.callbacks,
self.inserted_rows,
self.updated_rows)
现在,我们需要回到views.py
,告诉RecordList
如何处理这些信息。
我们将首先更新其参数列表并将列表保存到实例变量中:
def __init__(self, parent, callbacks,
inserted, updated,
*args, **kwargs):
self.inserted = inserted
self.updated = updated
接下来,我们需要配置适当颜色的标签。我们的数据输入人员认为lightgreen
是插入记录的合理颜色,lightblue
是更新记录的颜色。
在__init__()
中添加以下代码,放在self.treeview
配置之后:
self.treeview.tag_configure('inserted', background='lightgreen')
self.treeview.tag_configure('updated', background='lightblue')
就像我们之前对Text
小部件所做的那样,我们调用tag_configure
来将背景颜色设置与我们的标签名称连接起来。请注意,这里不仅限于一个配置设置;我们可以在同一个调用中添加foreground
、font
或其他配置设置。
要将标签添加到我们的TreeView
行中,我们需要更新populate
方法。
在for
循环中,在插入行之前,我们将添加这段代码:
if self.inserted and rownum in self.inserted:
tag = 'inserted'
elif self.updated and rownum in self.updated:
tag = 'updated'
else:
tag = ''
如果inserted
列表存在且我们的rownum
在其中,我们希望tag
等于'inserted'
;如果updated
列表存在且我们的rownum
在其中,我们希望它等于'updated'
。否则,我们将其留空。
现在,我们的treeview.insert
调用只需要用这个tag
值来修改:
self.treeview.insert('', 'end', iid=str(rownum),
text=str(rownum), values=values,
tag=tag)
运行应用程序并尝试插入和更新一些记录。
你应该得到类似这样的东西:
Tkinter 字体
在 Tkinter 中,有三种指定小部件字体的方法。
最简单的方法就是使用字符串格式:
tk.Label(text="Direct font format",
font="Times 20 italic bold")
字符串采用Font-family
size
styles
的格式,其中styles
可以是任何有效的文本样式关键字的组合。
这些单词包括:
-
bold
用于粗体文本,或normal
用于正常字重 -
italic
用于斜体文本,或roman
用于常规倾斜 -
underline
用于下划线文本 -
overstrike
用于删除线文本
除了字体系列之外,其他都是可选的,尽管如果要指定任何样式关键字,您需要指定一个size
。样式关键字的顺序无关紧要,但是重量和斜体关键字是互斥的(也就是说,您不能同时拥有bold
normal
或italic roman
)。
字符串方法的一个缺点是它无法处理名称中带有空格的字体。
要处理这些,您可以使用字体的元组格式:
tk.Label(
text="Tuple font format",
font=('Droid sans', 15, 'overstrike'))
这种格式与字符串格式完全相同,只是不同的组件被写成元组中的项目。size
组件可以是整数或包含数字的字符串,这取决于值来自何处。
这种方法适用于在启动时设置少量字体更改的情况,但是对于需要动态操作字体设置的情况,Tkinter 具有一种称为命名字体的功能。这种方法使用一个可以分配给小部件然后动态更改的Font
类。
要使用Font
,必须从tkinter.font
模块中导入它:
from tkinter.font import Font
现在,我们可以创建一个自定义的Font
对象并将其分配给小部件:
labelfont = Font(family='Courier', size=30,
weight='bold', slant='roman',
underline=False, overstrike=False)
tk.Label(text='Using the Font class', font=labelfont).pack()
正如您所看到的,Font
构造函数参数与字符串和元组字体规范中使用的关键字相关。
一旦分配了这个字体,我们就可以在运行时动态地改变它的各个方面:
def toggle_overstrike():
labelfont['overstrike'] = not labelfont['overstrike']
tk.Button(text='Toggle Overstrike', command=toggle_overstrike).pack()
在这个例子中,我们提供了一个Button
,它将切换overstrike
属性的开和关。
Tk 已经配置了几个命名字体;我们可以使用tkinter.font
模块的nametofont
函数从中创建 Python Font
对象。
这个表格显示了 Tkinter 中包含的一些命名字体:
字体名称 | 默认为 | 用于 |
---|---|---|
TkCaptionFont |
系统标题字体 | 窗口和对话框标题栏 |
TkDefaultFont |
系统默认字体 | 未另行指定的项目 |
TkFixedFont |
系统等宽字体 | 无 |
TkHeadingFont |
系统标题字体 | 列表和表格中的列标题 |
TkIconFont |
系统图标字体 | 图标标题 |
TkMenuFont |
系统菜单字体 | 菜单标签 |
TkSmallCaptionFont |
系统标题 | 子窗口,工具对话框 |
TkTextFont |
系统输入字体 | 输入小部件:Entry,Spinbox 等 |
TkTooltipFont |
系统工具提示字体 | 工具提示 |
如果您想知道 Tkinter 在您的操作系统上使用的字体,可以使用tkinter.font.names()
函数检索它们的列表。
要改变应用程序的整体外观,我们可以覆盖这些命名字体,更改将应用于所有未另行设置字体的小部件。
例如:
import tkinter as tk
from tkinter.font import nametofont
default_font = nametofont('TkDefaultFont')
default_font.config(family='Helvetica', size=32)
tk.Label(text='Feeling Groovy').pack()
在这个例子中,我们使用nametofont
函数检索TkDefaultFont
的对象,Tkinter 应用程序的默认命名字体类。检索后,我们可以设置它的字体family
和size
,为使用TkDefaultFont
的所有小部件更改这些值。
Label
然后显示这个调整的结果:
给用户提供字体选项
我们的一些数据输入用户抱怨应用程序的字体有点太小,不容易阅读,但其他人不喜欢您增加它,因为这样会使应用程序对屏幕来说太大。为了满足所有用户,我们可以添加一个配置选项,允许他们设置首选字体大小。
我们需要首先向我们的设置模型添加一个'font size'
选项。
打开models.py
并将SettingsModel.variables
字典追加如下:
variables = {
...
'font size': {'type': 'int', 'value': 9}
接下来,我们将在选项菜单中添加一组单选按钮,以便用户可以设置值。
打开views.py
,让我们在将选项菜单添加到主菜单之前开始创建一个菜单:
font_size_menu = tk.Menu(self, tearoff=False)
for size in range(6, 17, 1):
font_size_menu.add_radiobutton(
label=size, value=size,
variable=settings['font size'])
options_menu.add_cascade(label='Font size',
menu=font_size_menu)
这应该看起来很熟悉,因为我们在学习 Tkinter Menu
小部件时创建了一个几乎相同的字体大小菜单。我们允许字体大小从6
到16
,这应该为我们的用户提供足够的范围。
在Application
类中,让我们创建一个方法,将应用程序的字体设置应用到我们的应用程序字体中:
def set_font(self, *args):
我们包括*args
,因为set_font
将作为trace
回调调用,所以我们需要捕获发送的任何参数,尽管我们不会使用它们。
接下来,我们将获取当前的'font size'
值:
font_size = self.settings['font size'].get()
我们需要更改几个命名字体,不仅仅是TkDefaultFont
。对于我们的应用程序,TkDefaultFont
、TkTextFont
和TkMenuFont
应该足够了。
我们只需循环遍历这些,检索类并在每个类上设置大小:
font_names = ('TkDefaultFont', 'TkMenuFont', 'TkTextFont')
for font_name in font_names:
tk_font = nametofont(font_name)
tk_font.config(size=font_size)
我们需要做的最后一件事是确保调用此回调。
在Application.__init__()
中的load_settings()
调用之后,添加以下内容:
self.set_font()
self.settings['font size'].trace('w', self.set_font)
我们调用set_font()
一次以激活任何保存的字体大小
设置,然后设置一个trace
,以便在更改值时运行它。
运行应用程序并尝试使用字体菜单。它应该看起来像这样:
Ttk 小部件的样式
Ttk 小部件在功能上代表了对标准 Tkinter 小部件的重大改进。这种灵活性使得 Ttk 小部件能够在各个平台上模仿本机 UI 控件,但代价是:Ttk 样式令人困惑,文档不完善,有时不一致。
要了解 Ttk 样式,让我们从最基本的元素到最复杂的元素开始使用一些词汇:
-
Ttk 从元素开始。元素是小部件的一部分,例如边框、箭头或可以输入文本的字段。
-
元素使用布局组成一个完整的小部件(例如
Combobox
或Treeview
)。 -
样式是定义颜色和字体设置的属性集合:
-
每个样式都有一个名称,通常是 T,加上小部件的名称,例如
TButton
或TEntry
。也有一些例外。 -
布局中的每个元素都引用一个或多个样式属性来定义其外观。
-
小部件有许多状态,这些状态是可以打开或关闭的标志:
-
样式可以通过映射进行配置,将属性值连接到状态或状态的组合
-
一组样式称为主题。Tkinter 在不同平台上提供了不同的主题。:
-
一个主题可能不仅定义不同的样式,还定义不同的布局。例如,默认 macOS 主题上的
ttk.Button
可能包含不同的元素集,与在 Windows 中使用默认主题的ttk.Button
相比,应用样式设置的方式也不同。
如果你现在感到困惑,没关系。让我们深入了解ttk.Combobox
的解剖,以更好地了解这些概念。
探索 Ttk 小部件
为了更好地了解 Ttk 小部件是如何构建的,请在 IDLE 中打开一个 shell 并导入tkinter
、ttk
和pprint
:
>>> import tkinter as tk
>>> from tkinter import ttk
>>> from pprint import pprint
现在,创建一个根窗口、Combobox
和Style
对象:
>>> root = tk.Tk()
>>> cb = ttk.Combobox(root)
>>> cb.pack()
>>> style = ttk.Style()
Style
对象可能命名有点不准确;它并不指向单个样式,而是给了我们一个处理当前主题的样式、布局和映射的句柄。
为了检查我们的Combobox
,我们首先使用winfo_class()
方法获取它的stylename
:
>>> cb_stylename = cb.winfo_class()
>>> print(cb_stylename)
TCombobox
然后,我们可以使用Style.layout()
方法检查Combobox
的布局:
>>> 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'})]
通过将样式名称(在本例中为TCombobox
)传递给style.layout()
方法,我们得到一个布局规范,显示用于构建此小部件的元素的层次结构。
在这种情况下,元素是"Combobox.field"
、"Combobox.downarrow"
、"Combobox.padding"
和"Combobox.textarea"
。正如您所看到的,每个元素都有与pack()
中传递的定位属性类似的关联定位属性。
layout
方法也可以用于通过传入新的布局规范来替换样式的布局。不幸的是,这需要替换整个布局规范,您不能只是在原地调整或替换单个元素。
要查看样式如何连接到元素,我们可以使用style.element_options()
方法。该方法接受一个元素名称,并返回一个可以用于更改它的选项列表。
例如:
>>> pprint(style.element_options('Combobox.downarrow'))
('background', 'relief', 'borderwidth', 'arrowcolor', 'arrowsize')
这告诉我们Combobox
的downarrow
元素使用这些样式属性来确定其外观。要更改这些属性,我们将需要使用style.configure()
方法。
让我们将箭头的颜色更改为red
:
>>> style.configure('TCombobox', arrowcolor='red')
您应该看到arrowcolor
已更改为red
。这就是我们需要了解的配置小部件进行静态更改的全部内容,但是动态更改呢?
要进行动态更改,我们需要了解小部件的状态。
我们可以使用state
方法检查或更改我们的Combobox
的状态:
>>> print(cb.state())
()
>>> cb.state(['active', 'invalid'])
('!active', '!invalid')
>>> print(cb.state())
('active', 'invalid')
Combobox.state()
没有参数时将返回一个包含当前设置的状态标志的元组;当与参数一起使用时(参数必须是字符串序列),它将设置相应的状态标志。
要关闭状态标志,需要在标志名称前加上!
:
>>> cb.state(['!invalid'])
('invalid',)
>>> print(cb.state())
('active',)
当您调用state()
并带有参数来更改值时,返回值是一个元组,其中包含一组状态,如果应用,则会撤消您刚刚设置的状态更改。这在您想要临时设置小部件的状态,然后将其返回到先前(未知)状态的情况下可能会有用。
您不能只是使用任何字符串来调用state()
;它们必须是以下之一:
-
active
-
disabled
-
focus
-
pressed
-
selected
-
background
-
readonly
-
alternate
-
invalid
每个小部件如何使用这些状态取决于小部件;并非每个state()
默认情况下都配置为具有效果。
小部件状态通过映射与小部件样式进行交互。我们使用style.map()
方法来检查或设置每个样式的映射。
看一下TCombobox
的默认映射:
>>> pprint(style.map(cb_stylename))
{'arrowcolor': [('disabled', '#a3a3a3')],
'fieldbackground': [('readonly', '#d9d9d9'),
('disabled', '#d9d9d9')]}
正如您所看到的,TCombobox
默认情况下具有arrowcolor
和fieldbackground
属性的样式映射。每个样式映射都是一个元组列表,每个元组都是一个或多个状态标志,后跟一个设置的值。当所有状态标志与小部件的当前状态匹配时,该值生效。
默认映射在设置disabled
标志时将箭头颜色更改为浅灰色,并在设置disabled
或readonly
标志时将字段背景更改为不同的浅灰色。
我们可以使用相同的方法设置自己的样式映射:
>>> 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
属性,当invalid
标志未设置时,它为blue
,当invalid
和focus
标志都设置时,它为red
。请注意,虽然我们对map
的调用完全覆盖了arrowcolor
样式映射,但fieldbackground
映射未受影响。这意味着您可以单独替换样式映射,但不能简单地追加到给定属性的现有映射中。
到目前为止,我们一直在操作TCombobox
样式,这是所有Combobox
小部件的默认样式。我们所做的任何更改都会影响应用程序中的每个Combobox
。我们还可以通过在现有样式名称前加上名称和点来创建从现有样式派生的自定义样式。
例如:
>>> style.configure('Blue.TCombobox', fieldbackground='blue')
>>> cb.configure(style='Blue.TCombobox')
Blue.TCombobox
继承了TCombobox
的所有属性(包括我们之前配置的blue
downarrow
),但可以添加或覆盖自己的设置,而不会影响TCombobox
。这使您可以为某些小部件创建自定义样式,而不会影响相同类型的其他小部件。
我们可以通过更改主题来一次性改变所有 Ttk 小部件的外观。请记住,主题是一组样式,因此通过更改主题,我们将替换所有内置样式和布局。
不同的主题在不同的平台上发行;要查看您平台上可用的主题,使用theme_names()
方法:
>>> style.theme_names()
('clam', 'alt', 'default', 'classic')
(这些是 Debian Linux 上可用的主题;您的可能不同。)
要查询当前主题,或设置新主题,使用theme_use()
方法:
>>> style.theme_use()
'default'
>>> style.theme_use('alt')
注意当您更改主题时,之前的样式已经消失。但是,如果您切换回默认主题,您会发现您的更改已被保留。
为我们的表单标签设置样式
我们可以利用我们对样式的知识来解决的第一件事是我们的表单小部件。由于LabelInput
小部件保留其默认的沉闷颜色,我们的表单的着色相当丑陋和不完整。我们需要为每个小部件设置样式,以匹配其LabelInput
的颜色。
在views.py
文件中,在DataRecordForm.__init__()
方法的开头添加此内容:
style = ttk.Style()
我们正在创建我们的Style
对象,以便我们可以开始使用我们的 Ttk 样式。我们需要哪些样式?
-
我们需要为每个部分的 Ttk
Label
小部件设置样式,因为我们需要为RecordInfo
、EnvironmentInfo
和Plant Info
中的小部件设置不同的颜色。 -
我们需要为我们的 Ttk
Checkbutton
设置样式,因为它使用自己内置的标签而不是单独的标签小部件。由于现在只有一个,我们只需要一个样式。
让我们创建这些样式:
style.configure('RecordInfo.TLabel', background='khaki')
style.configure(
'EnvironmentInfo.TLabel',
background='lightblue')
style.configure(
'EnvironmentInfo.TCheckbutton',
background='lightblue')
style.configure('PlantInfo.TLabel', background='lightgreen')
如您所见,我们正在基于TLabel
创建自定义样式,但这是为每个单独的部分添加前缀。对于每种样式,我们只需适当设置background
颜色。
现在是将此样式添加到每个小部件的繁琐任务:
self.inputs['Date'] = w.LabelInput(
recordinfo, "Date",
field_spec=fields['Date'],
label_args={'style': 'RecordInfo.TLabel'})
在每个LabelInput
调用中,您需要添加一个label_args
参数,将style
设置为相应部分的TLabel
样式。为所有小部件执行此操作。
对于Checkbutton
,您需要以不同的方式进行操作:
self.inputs['Equipment Fault'] = w.LabelInput(
environmentinfo, "Equipment Fault",
field_spec=fields['Equipment Fault'],
label_args={'style': 'EnvironmentInfo.TLabel'},
input_args={'style': 'EnvironmentInfo.TCheckbutton'})
在这里,我们设置了input_args
,因为该样式适用于Checkbutton
而不是标签(保留label_args
;我们一会儿会用到)。
如果你此时运行程序,你会看到明显的改进,但还不够完美;错误标签仍然是旧的默认颜色。
要解决这个问题,我们只需要编辑我们的LabelInput
小部件,以便错误标签也使用label_args
。
在widgets.py
中,修复LabelInput.__init__()
中的self.error_label
赋值:
self.error_label = ttk.Label(self, textvariable=self.error,
**label_args)
现在,您的应用程序应该具有一致的颜色,并且看起来更加吸引人:
在错误时为输入小部件设置样式
我们的数据输入人员抱怨说,我们字段中的错误样式并不是非常显眼。目前,我们只是将foreground
颜色设置为红色
。
这有几个问题:
-
对于空字段,实际上没有什么可以涂成
红色
-
我们的色盲用户很难区分
红色
和普通文本颜色
我们将利用我们的样式知识来改进错误样式,并使无效字段更加显眼。
不过,在这之前,您可能需要修复一个小部件的小问题。
使我们的 Spinbox 成为 Ttk 小部件
如果您使用的是 Python 3.6 或更早版本,则Spinbox
小部件仅在tkinter
中可用,而不在ttk
中。我们需要修复这个问题,以便我们的错误样式可以保持一致。
在撰写本书时,作者已经提交了一个补丁到 Python 3.7 中,以包括 TtkSpinbox
。如果您使用的是 Python 3.7 或更高版本,您可以直接使用ttk::spinbox
并跳过此部分。
由于Spinbox
已经在 Tcl/Tk Ttk 库中,为其创建一个 Python 类非常容易。
在widgets.py
的顶部附近添加此代码:
class TtkSpinbox(ttk.Entry):
def __init__(self, parent=None, **kwargs):
super().__init__(parent, 'ttk::spinbox', **kwargs)
这就是为这个应用程序创建 Ttk Spinbox
所需的全部内容。我们只是对ttk.Entry
进行子类化,但在__init__
语句中更改了使用的 Ttk 小部件。如果我们需要Entry
缺少的任何Spinbox
方法,我们需要提供这些方法;对于这个应用程序,我们不需要其他任何东西。
现在,我们只需要更新我们的ValidatedSpinbox
类,使其继承TtkSpinbox
而不是tk.Spinbox
:
class ValidatedSpinbox(ValidatedMixin, TtkSpinbox):
更新 ValidatedMixin
现在我们正在使用所有的 Ttk 小部件,我们可以通过一些动态样式来更新我们的ValidatedMixin
类。
我们将从__init__()
方法开始,创建一个Style
对象:
style = ttk.Style()
由于这是一个混合类,我们不知道我们正在混合的小部件的原始样式名称,因此我们将不得不获取它。
记住我们可以用winfo_class()
来实现这一点:
widget_class = self.winfo_class()
validated_style = 'ValidatedInput.' + widget_class
在获取小部件类之后,我们通过在其前面添加ValidatedInput
来创建一个派生样式。为了在错误和非错误外观之间切换我们的输入外观,我们将创建一个样式映射,根据invalid
状态标志的状态进行切换。
您可以通过调用style.map()
来实现这一点:
style.map(
validated_style,
foreground=[('invalid', 'white'), ('!invalid', 'black')],
fieldbackground=[('invalid', 'darkred'), ('!invalid', 'white')]
)
我们仍然使用红色,因为它是一个已建立的“错误颜色”,但这次我们将字段从浅色背景变为深色背景。这应该帮助我们的色盲用户区分错误,即使它们是红色的。
最后,我们需要更新对self.config
的调用,以包括将小部件的样式设置为我们的新验证样式:
self.config(
style=validated_style,
validate='all',
...
Ttk 小部件会自动设置它们的invalid
标志作为内置验证系统的一部分。目前,我们有一个名为_toggle_error()
的方法,每当验证开始或失败时都会调用它,并设置错误状态。我们可以完全删除该方法,以及所有对它的引用。
如果现在尝试应用程序,您会看到带有错误的字段现在会变成深红色,并带有白色文本:
设置主题
一般来说,任何给定平台上的默认 Ttk 主题可能是最好的选择,但外观是主观的,有时我们可能觉得 Tkinter 做错了。有一种方法可以调整主题可能有助于消除一些粗糙的边缘,并使一些用户更舒适地看待应用程序的外观。
正如我们已经看到的,查询可用主题并设置新主题是相当简单的。让我们创建一个配置选项来更改我们应用程序的主题。
构建主题选择器
主题不是用户经常需要更改的东西,正如我们所见,更改主题可能会撤消我们对小部件的样式更改。鉴于此,我们将通过设计我们的主题更改器的方式来确保需要重新启动程序才能实际更改。
我们将首先在我们的SettingsModel
中添加一个主题选项:
variables = {
...
'theme': {'type': 'str', 'value': 'default'}
}
每个平台都有一个theme
别名为default
,因此这是一个安全和合理的默认值。
接下来,我们的Application.__init__()
方法将需要检查这个值,并相应地设置theme
。
在调用load_settings()
之后添加此代码:
style = ttk.Style()
theme = self.settings.get('theme').get()
if theme in style.theme_names():
style.theme_use(theme)
我们创建一个Style
对象,查询主题名称的设置,然后(假设保存的theme
在可用主题中)相应地设置theme
。
现在剩下的就是创建 UI。
在views.py
文件中,我们将为options_menu
创建一个新的子菜单:
style = ttk.Style()
themes_menu = tk.Menu(self, tearoff=False)
for theme in style.theme_names():
themes_menu.add_radiobutton(
label=theme, value=theme,
variable=settings['theme']
)
options_menu.add_cascade(label='Theme', menu=themes_menu)
在这里,我们只是循环遍历可用的主题,并为每个主题添加一个Radiobutton
,将其绑定到我们的settings['theme']
变量。
对于用户来说,可能不明显更改主题需要重新启动,因此让我们确保让他们知道。
我们将为变量添加一个trace
:
settings['theme'].trace('w', self.on_theme_change)
on_theme_change
方法将显示一个警告对话框,通知用户需要重新启动才能实现更改。
将其添加到MainMenu
类的末尾:
def on_theme_change(self, *args):
"""Popup a message about theme changes"""
message = "Change requires restart"
detail = (
"Theme changes do not take effect"
" until application restart")
messagebox.showwarning(
title='Warning',
message=message,
detail=detail)
现在,您可以运行应用程序并尝试更改theme
。哪个主题在您的平台上看起来最好?
你可能会发现,平台上的一些主题会破坏表单中的小部件样式。请记住,主题不仅仅改变默认颜色和字体,它们还改变小部件元素本身的布局和内容。有时,由于属性名称的更改,样式设置在不同主题之间无法传递。
总结
在本章中,我们为了美观和可用性改进彻底改变了应用程序的外观和感觉。您学会了如何处理 Tkinter 小部件的颜色和字体设置,以及 Ttk 样式的复杂世界。
第九章:使用 unittest 创建自动化测试
随着应用程序的规模和复杂性迅速扩大,您开始对进行更改感到紧张。如果你弄坏了什么?你怎么知道?您需要一种可靠的方法来确保您的程序在代码更改时正常工作。
幸运的是,我们有一种方法:自动化测试。在本章中,您将涵盖以下主题:
-
学习自动化测试的基础知识
-
学习测试 Tkinter 应用程序的具体策略
-
将这些知识应用于我们的数据输入应用程序
自动化测试基础
到目前为止,测试我们的应用程序一直是一个启动它,运行它通过一些基本程序,并验证它是否按我们预期的那样工作的过程。这种方法在一个非常小的脚本上可以接受,但随着应用程序的增长,验证应用程序行为变得越来越耗时和容易出错。使用自动化测试,我们可以在几秒钟内始终验证我们的应用逻辑。
自动化测试有几种形式,但最常见的两种是单元测试和集成测试。单元测试与隔离的代码片段一起工作,允许我们快速验证特定部分的行为。集成测试验证多个代码单元的交互。我们将编写这两种测试来验证我们应用程序的行为。
一个简单的单元测试
在其最基本的层面上,单元测试只是一个短小的程序,它在不同条件下运行代码单元,并将其输出与预期结果进行比较。
考虑以下计算类:
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))
该类使用两个数字进行初始化,然后可以对它们执行各种算术方法。
让我们创建一个简单的对该函数的测试:
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 statement, "message"
本质上等同于这个:
if not statement:
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
基于许多测试框架中发现的这些标准单元测试概念:
-
测试:一个测试是一个单独的方法,要么完成,要么引发异常。测试通常专注于代码的一个单元,比如一个函数、方法或过程。一个测试可以通过,意味着测试成功;失败,意味着代码未通过测试;或者错误,意味着测试本身遇到了问题。
-
测试用例:一个测试用例是一组应该一起运行的测试,包含类似的设置和拆卸要求,通常对应一个类或模块。测试用例可以有夹具,这些夹具需要在每个测试之前设置并在每个测试之后拆卸,以提供一个干净、可预测的环境,让测试可以运行。
-
测试套件:一个测试套件是一组覆盖应用程序或模块所有代码的测试用例。
-
模拟:模拟是一个代表外部资源(比如文件或数据库)的对象。在测试期间,模拟会被覆盖到这些资源上。
为了深入探讨这些概念,让我们使用unittest
来测试我们的MyCalc
类。
编写测试用例
让我们在test_mycalc.py
中为MyCalc
类创建一个测试用例,如下所示:
from mycalc import MyCalc
import unittest
class TestMyCalc(unittest.TestCase):
def test_add(self):
mc = 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
表示错误。最后,我们会得到一个总结。
为了看看测试失败时会发生什么,让我们改变我们的测试使其不正确:
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.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 是True |
assertFalse(a) |
a 是False |
assertIn(item, sequence) |
item 在sequence 中 |
assertRaises(exception, callable, args) |
callable 用args 调用引发exception |
assertGreater(a, b) |
a 大于b |
assertLess(a, b) |
a 小于b |
你也可以轻松地向你的测试用例中添加自定义的断言方法;只需要创建一个在某些条件下引发AssertionError
异常的方法。
让我们使用一个断言方法来测试mod_divide()
在b
为0
时是否引发ValueError
:
def test_mod_divide(self):
mycalc = mycalc.MyCalc(1, 0)
self.assertRaises(ValueError, mycalc.mod_divide)
assertRaises
在调用时,如果函数引发给定的断言,则通过。如果我们需要将任何参数传递到被测试的函数中,它们可以作为额外的参数指定给assertRaises()
。
assertRaises()
也可以像这样用作上下文管理器:
mycalc = MyCalc(1, 0)
with self.assertRaises(ValueError):
mycalc.mod_divide()
这段代码实现了完全相同的功能,但更清晰、更灵活。
固定装置
我们的TestCase
对象可以具有一个setUp()
方法,自动创建我们的测试需要的任何资源,而不必在每个测试中执行创建MyCalc
对象的繁琐任务。
例如,看一下以下代码:
def setUp(self):
self.mycalc1_0 = mycalc.MyCalc(1, 0)
self.mycalc36_12 = mycalc.MyCalc(36, 12)
现在,每个测试用例都可以使用这些对象来运行其测试。setUp()
方法将在每个测试之前重新运行,因此这些对象将始终在测试方法之间重置。如果我们有需要在每个测试后清理的项目,我们可以定义一个tearDown()
方法,在每个测试后运行(在这种情况下,这是不必要的)。
例如,我们的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
rand_between()
方法生成a
和b
之间的随机数。因为我们不可能预测它的输出,所以我们无法提供一个固定值来测试它。我们如何测试这个方法?
一个天真的方法如下:
def test_rand_between(self):
rv = self.mycalc1_0.rand_between()
self.assertLessEqual(rv, 1)
self.assertGreaterEqual(rv, 0)
如果我们的代码正确,这个测试通过,但如果代码错误,它不一定会失败;事实上,如果代码错误,它可能会以不可预测的方式通过或失败。例如,如果MyCalc(1, 10).rand_between()
错误地返回 2 到 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
,我们调用我们的方法并测试输出。因为fakerandom
将始终返回0.5
,所以我们知道当a
为1
,b
为0
时,答案应该是(0.5 × 1 + 0)或0.5
。任何其他值都会表明我们的算法存在错误。最后,我们将random
恢复为原始函数,以便其他测试不会意外使用模拟。
每次都必须存储或恢复原始库是一个麻烦,所以unittest.mock
提供了一个更清晰的方法,使用patch
。patch
命令可以作为上下文管理器或装饰器使用,无论哪种方法都可以将Mock
对象补丁到我们的代码中,使其更加清晰。
使用我们的模拟random()
,使用patch
作为上下文管理器看起来像这样:
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
创建的模拟对象作为参数传递给我们的测试方法,并将在装饰函数的持续时间内保持补丁状态。
运行多个单元测试
虽然我们可以在最后包含一个调用unittest.main()
来运行单元测试,但这种方法不太适用。随着应用程序的增长,我们将编写许多测试文件,我们希望以组或全部运行。
幸运的是,unittest
可以通过一个命令发现并运行项目中的所有测试:
python -m unittest
只要你遵循了推荐的命名方案,将测试模块以test_
为前缀,运行这个命令在项目的根目录中应该可以运行所有的测试。
测试 Tkinter 代码
测试 Tkinter 代码会带来一些特殊的挑战。首先,Tkinter 处理许多回调和方法是异步的,这意味着我们不能指望某些代码的结果立即显现。此外,测试 GUI 行为通常依赖于诸如窗口管理或视觉提示之类的外部因素,而我们的测试无法检测到。
我们将学习一些工具和策略,帮助你为 Tkinter 代码编写测试。
管理异步代码
每当与 Tkinter UI 交互时,无论是点击按钮、在字段中输入,还是提升窗口,例如,响应都不会立即执行。相反,这些操作被放在一个待办事项列表中,称为事件队列,稍后处理,而您的代码执行则继续。虽然这些操作对用户来说似乎是瞬间发生的,但测试代码不能指望请求的操作在下一行代码执行之前完成。
为了解决这个问题,我们可以使用这些特殊的小部件方法来管理事件队列:
-
wait_visibility()
: 这个方法会导致程序等待,直到小部件完全绘制在屏幕上,然后再执行下一行代码。 -
update_idletasks()
: 这个方法强制 Tkinter 处理小部件上当前未完成的任何空闲任务。空闲任务是低优先级的任务,如绘图和渲染。 -
update()
: 这个方法强制 Tkinter 处理小部件上未完成的所有事件,包括调用回调、重绘和几何管理。它包括update_idletasks()
的所有功能以及更多。
模拟用户操作
在自动化 GUI 测试时,我们可能希望知道当用户点击某个小部件或键入某个按键时会发生什么。当这些操作在 GUI 中发生时,Tkinter 会为小部件生成一个Event
对象并将其传递给事件队列。我们可以在代码中做同样的事情,使用小部件的event_generate()
方法。
指定事件序列
要使用event_generate()
创建一个事件,我们需要传入一个事件序列字符串,格式为<EventModifier-EventType-EventDetail>
。
事件类型指定了我们发送的事件类型,比如按键、鼠标点击、窗口事件等。
Tkinter 大约有 30 种事件类型,但通常只需要处理以下几种:
事件类型 | 描述 |
---|---|
ButtonPress |
也是Button ,表示鼠标按钮点击 |
ButtonRelease |
表示释放鼠标按钮 |
KeyPress |
也是Key ,表示按下键盘按键 |
KeyRelease |
表示释放键盘键 |
FocusIn |
表示将焦点放在小部件上 |
FocusOut |
表示退出小部件 |
Enter |
表示鼠标光标进入小部件 |
Leave |
表示鼠标光标移出小部件 |
Configure |
当小部件的配置发生变化时调用,可以是.config() 调用或用户操作(例如调整大小) |
事件修饰符是可以改变事件类型的可选词语;例如,Control
,Alt
和Shift
可以用来指示其中一个修改键被按下;Double
或Triple
可以用来指示所描述按钮的双击或三击。如果需要,可以将多个修饰符串在一起。
事件详情,仅适用于键盘或鼠标事件,描述了按下哪个键或按钮。例如,<Button-1>
指的是鼠标左键,而<Button-3>
指的是右键。对于字母和数字键,可以使用字面上的字母或数字;然而,大多数符号是用单词(minus
,colon
,semicolon
等)来描述,以避免语法冲突。
对于按钮按下和键盘按下,事件类型在技术上是可选的;然而,出于清晰起见,最好将其保留。例如,<1>
是一个有效的事件,但它是指鼠标左键还是按下1
键?您可能会惊讶地发现它是鼠标按钮。
以下表格显示了一些有效事件序列的示例:
序列 | 意义 |
---|---|
<Double-Button-3> |
双击鼠标右键 |
<Alt-KeyPress-exclam> |
按住Alt 并输入感叹号 |
<Control-Alt-Key-m> |
按住Control 和Alt 并按下m 键 |
<KeyRelease-minus> |
释放按下的减号键 |
除了序列,我们还可以向event_generate()
传递其他参数,这些参数描述事件的各个方面。其中许多是多余的,但在某些情况下,我们需要提供额外的信息,以使事件具有任何意义;例如,鼠标按钮事件需要包括指定单击坐标的x
和y
参数。
管理焦点和抓取
焦点指的是当前接收键盘输入的小部件或窗口。小部件还可以抓取焦点,防止鼠标移动或超出其范围的按键。
Tkinter 为我们提供了这些小部件方法来管理焦点和抓取,其中一些对于运行测试非常有用:
方法 | 描述 |
---|---|
focus_set() |
在其窗口下次获得焦点时,将焦点设置到小部件 |
focus_force() |
立即将焦点设置到小部件和其所在的窗口 |
grab_set() |
小部件抓取应用程序的所有事件 |
grab_set_global() |
小部件抓取所有屏幕事件 |
grab_release() |
小部件放弃抓取 |
在测试环境中,我们可以使用这些方法来确保我们生成的键盘和鼠标事件发送到正确的小部件或窗口。
获取小部件信息
Tkinter 小部件有一组winfo_
方法,可以让我们访问有关小部件的信息。虽然这组方法还有很多不足之处,但它确实提供了一些方法,我们可以在测试中使用这些方法来提供有关给定小部件状态的反馈。
以下是一些我们会发现有用的winfo_
方法:
方法 | 描述 |
---|---|
winfo_height() ,winfo_width() |
获取小部件的高度和宽度 |
winfo_children() |
获取子小部件列表 |
winfo_geometry() |
获取小部件的大小和位置 |
winfo_ismapped() |
确定小部件是否已映射,意味着它已被添加到布局中,例如使用pack() 或grid() |
winfo_viewable() |
确定小部件是否可见,意味着它和所有父级都已被映射 |
winfo_x() ,winfo_y() |
获取小部件左上角的x 或y 坐标 |
为我们的应用编写测试
让我们利用unittest
的知识,为我们的应用程序编写一些测试。要开始,我们需要为我们的应用程序创建一个测试模块。在abq_data_entry
包内创建一个名为test
的目录,并在其中创建习惯的空__init__.py
文件。我们将在这个目录内创建所有的测试模块。
测试我们的模型
我们的CSVModel
代码相当自包含,除了需要读写文件。由于文件操作是测试中需要模拟的常见事物之一,mock
模块提供了mock_open
,这是一个准备好替换 Python 的open
方法的Mock
子类。当调用时,mock_open
对象返回一个mock
文件句柄对象,支持read()
、write()
和readlines()
方法。
让我们开始创建我们的测试用例类,位于test/test_models.py
中:
from .. import models
from unittest import TestCase
from unittest import mock
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,Median Height,Notes\r\n"
"2018-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"
"2018-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')
mock_open
和read_data
参数允许我们指定一个字符串,当文件句柄被读取时将返回该字符串。我们创建了两个mock_open
对象,一个包含 CSV 标题和两行数据,另一个什么都没有。
我们还创建了两个CSVModel
对象,一个文件名为file1
,另一个文件名为file2
。值得一提的是,我们的模型和mock_open
对象之间实际上没有任何连接。选择mock_open
对象,而不是文件名,将决定返回什么数据。
在get_all_records()
中测试文件读取
看看我们如何使用这些,让我们从get_all_records()
方法的测试开始:
@mock.patch('abq_data_entry.models.os.path.exists')
def test_get_all_records(self, mock_exists):
mock_exists.return_value = True
由于我们的文件名实际上并不存在,我们使用patch
的装饰器版本来将os.path.exists
补丁为一个总是返回True
的模拟函数。如果我们想测试文件不存在的情况,我们可以稍后更改return_value
的值。
为了运行get_all_records()
方法,我们将使用patch()
的上下文管理器形式如下:
with mock.patch('abq_data_entry.models.open', self.file1_open):
records = self.model1.get_all_records()
models.py
文件中任何在上下文管理器块内启动的open()
调用都将被我们的mock_open
对象替换,并且返回的文件句柄将包含我们指定的read_data
。然而,在我们继续之前,mock_open
存在一个不幸的缺陷,我们需要解决。虽然它实现了大多数文件方法,但它没有实现csv
库需要从文件处理程序中读取数据的迭代器方法。
对我们的models.py
代码进行轻微修改将解决这个问题:
def get_all_records(self):
...
with open(self.filename, 'r', encoding='utf-8') as fh:
csvreader = csv.DictReader(list(fh.readlines()))
我们需要调用readlines()
并将其转换为list
,而不是简单地将fh
传递给DictReader
。这不会以任何方式影响程序,但它将允许mock_open()
正常工作。
对于调整代码以适应测试没有任何问题;在许多情况下,代码甚至会因此变得更好!然而,如果您进行了不直观的更改,比如前面的更改,请确保在代码中添加注释以解释原因。否则,有人很可能会在将来的某个时候将其删除。
现在我们可以开始对返回的记录进行断言:
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',
'Median 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('file1', 'r', encoding='utf-8')
assert_called_with()
接受一组参数,并检查对mock
对象的最后一次调用是否使用了这些参数。我们期望file1_open
被调用时使用文件名file1
,模式为r
,编码为utf-8
。通过确认模拟函数是否使用了正确的参数进行了调用,并假设真实函数的正确性(在本例中是内置的open()
函数),我们可以避免测试实际结果。
测试save_record()
中的文件保存
为了演示如何使用mock_open
测试文件写入,让我们测试save_record()
:
@patch('abq_data_entry.models.os.path.exists')
def test_save_record(self, mock_exists):
为了测试从dict
到csv
字符串的转换,我们需要两种格式的样本记录:
record = {
"Date": '2018-07-01', "Time": '12:00',
"Technician": 'Test Tech', "Lab": 'E',
"Plot": '7', "Seed sample": 'test',
"Humidity": '10', "Light": '99',
"Temperature": '20', "Equipment Fault": False,
"Plants": '10', "Blossoms": '200', "Fruit": '250',
"Min Height": '40', "Max Height": '50',
"Median Height": '55', "Notes": 'Test Note\r\nTest Note\r\n'}
record_as_csv = (
'2018-07-01,12:00,Test Tech,E,17,test,10,99,20,False,'
'10,200,250,40,50,55,"Test Note\r\nTest Note\r\n"\r\n')
你可能会被诱惑使用代码生成记录或其预期输出,但在测试中最好坚持使用文字;这样做可以使测试的期望明确,并避免测试中的逻辑错误。
对于我们的第一个场景,让我们通过使用file2_open
和model2
来模拟向一个空但已存在的文件写入:
mock_exists.return_value = True
with patch('abq_data_entry.models.open', self.file2_open):
self.model2.save_record(record, None)
将我们的mock_exists.return_value
设置为True
,告诉我们的方法文件已经存在,然后用第二个mock_open
对象覆盖open()
,并调用save_record()
方法。由于我们传入的记录没有行号(表示记录插入),这应该导致我们的代码尝试以追加模式打开file2
并在 CSV 格式的记录中写入。
assert_called_with()
将测试这一假设,如下所示:
self.file2_open.assert_called_with('file2', 'a',
encoding='utf-8')
file2_open
可以告诉我们它是否使用了预期的参数进行了调用,但我们如何访问它的文件处理程序,以便我们可以看到写入了什么?
事实证明,我们可以直接调用我们的mock_open
对象并检索mock
文件处理程序对象:
file2_handle = self.file2_open()
file2_handle.write.assert_called_with(record_as_csv)
一旦我们有了mock
文件处理程序(它本身是一个Mock
),我们可以对其运行测试方法,以找出它是否按预期被调用。在这种情况下,文件处理程序的write
方法应该被调用,并传入 CSV 格式的记录字符串。
让我们进行一组类似的测试,传入一个行号来模拟记录更新:
with patch('abq_data_entry.models.open', self.file1_open):
self.model1.save_record(record, 1)
self.file1_open.assert_called_with('file1', 'w',
encoding='utf-8')
检查我们的更新是否正确完成存在一个问题:assert_called_with()
只检查对模拟函数的最后一次调用。当我们更新 CSV 文件时,整个 CSV 文件都会被更新,每行一个write()
调用。我们不能只检查最后一次调用是否正确;我们需要确保所有行的write()
调用都是正确的。为了实现这一点,Mock
为我们提供了assert_has_calls()
,我们可以向其传递一个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,'
'Median Height,Notes\r\n'),
mock.call('2018-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('2018-07-01,12:00,Test Tech,E,17,test,10,99,20,'
'False,10,200,250,'40,50,55,'
'"Test Note\r\nTest Note\r\n"\r\n')
])
call()
的参数表示传递给函数调用的参数。我们向assert_has_calls()
传递的Call
对象列表表示应该按顺序进行的每次对write()
的调用。关键字参数in_order
也可以设置为False
,在这种情况下,顺序不需要匹配。在这种情况下,顺序很重要,因为错误的顺序会导致损坏的 CSV 文件。
更多测试
测试CSVModel
类和SettingsModel
类方法的其余部分应该基本上与这两个方法相同。示例代码中包含了一些额外的测试,但看看你是否也能想出一些自己的测试。
测试我们的应用程序
我们已经将我们的应用程序实现为一个Tk
对象,它不仅充当主窗口,还充当控制器,将在应用程序的其他地方定义的模型和视图进行拼接。正如你可能期望的那样,patch()
将在我们的测试代码中大量出现,因为我们模拟了所有其他组件,以隔离Application
。让我们看看这是如何完成的:
- 在一个名为
test_application.py
的新文件中,导入unittest
和application
。现在开始一个测试用例,如下所示:
class TestApplication(TestCase):
records = [
{'Blossoms': '21', 'Date': '2018-06-01',
'Equipment Fault': 'False', 'Fruit': '3,
'Humidity': '24.09', 'Lab': 'A', 'Light': '1.03',
'Max Height': '8.7', 'Median Height': '2.73',
'Min Height': '1.67','Notes': '\n\n', 'Plants': '9',
'Plot': '1', 'Seed sample': 'AX477',
'Technician': 'J Simms', 'Temperature': '22.01',
'Time': '8:00'},
{'Blossoms': '27', 'Date': '2018-06-01',
'Equipment Fault': 'False', 'Fruit': '1',
'Humidity': '24.47', 'Lab': 'A', 'Light': '1.01',
'Max Height': '9.2', 'Median Height': '5.09',
'Min Height': '2.35', 'Notes': '', 'Plants': '14',
'Plot': '2', 'Seed sample': 'AX478',
'Technician': 'J Simms', 'Temperature': '21.44',
'Time': '8:00'}]
settings = {
'autofill date': {'type': 'bool', 'value': True},
'autofill sheet data': {'type': 'bool', 'value': True},
'font size': {'type': 'int', 'value': 9},
'theme': {'type': 'str', 'value': 'default'}}
我们的TestApplication
类将使用模拟数据和设置模型的替代品,因此我们创建了一些类属性来存储Application
期望从这些模型中检索的数据样本。setUp()
方法将使用模拟数据替换所有外部类,配置模拟模型以返回我们的样本数据,然后创建一个Application
实例,供我们的测试使用。
- 让我们首先使用
patch()
作为上下文管理器来替换所有外部资源,如下所示:
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.v.DataRecordForm'), \
patch('abq_data_entry.application.v.RecordList'),\
patch('abq_data_entry.application.get_main_menu_for_os')\
:
在这里,我们创建了一个with
块,使用了五个patch()
上下文管理器,每个库都有一个。请注意,我们只为模型模拟创建别名,因为我们希望对它们进行一些额外的配置。视图模拟不需要做太多事情,只需要被导入或调用,而且我们可以将它们作为Application
对象的属性访问。
自 Python 3.2 以来,您可以通过使用逗号分隔每个上下文管理器调用来创建具有多个上下文管理器的块。不幸的是,您不能将它们放在括号中,因此我们使用了相对丑陋的转义换行方法,将这个巨大的调用分成多行。
- 在块内,我们需要配置我们的模型模拟以返回适当的数据,如下所示:
settingsmodel().variables = self.settings
csvmodel().get_all_records.return_value = self.records
请注意,我们正在实例化我们的settingsmodel
和csvmodel
对象,并配置返回值上的方法,而不是在模拟对象本身上配置。请记住,我们的模拟对象替换的是类,而不是对象,而是包含Application
对象将要调用的方法的对象。因此,我们需要调用它们来访问Application
将用作数据或设置模型的实际Mock
对象。
与其代表的实际类不同,作为函数调用的Mock
对象每次被调用时都会返回相同的对象。因此,我们不必保存通过调用模拟类创建的对象的引用;我们只需重复调用模拟类以访问该对象。但是,请注意,Mock
类每次都会返回一个唯一的Mock
对象。
- 这样我们的模拟就处理好了,让我们创建一个
Application
对象:
self.app = application.Application()
- 因为
Application
是Tk
的子类,所以我们最好在每次使用后安全地处理它;即使我们重新分配了它的变量名,它仍将继续存在并在我们的测试中造成问题。为了解决这个问题,创建一个tearDown()
方法:
def tearDown(self):
self.app.update()
self.app.destroy()
请注意对app.update()
的调用。如果我们在销毁app
之前不调用它,可能会有任务在事件队列中尝试在它消失后访问它。这不会破坏我们的代码,但会在我们的测试输出中产生错误消息。
- 现在我们的固定装置已经处理好了,让我们写一个测试:
def test_show_recordlist(self):
self.app.show_recordlist()
self.app.update()
self.app.recordlist.tkraise.assert_called()
Application.show_recordlist()
包含一行代码,只是调用recordlist.tkraise()
。因为我们将recordlist
设置为模拟对象,tkraise
也是模拟对象,我们可以检查它是否被调用。assert_called()
只是检查方法是否被调用,而不检查参数,在这种情况下是合适的,因为tkraise()
不需要参数。
- 我们可以使用类似的技术来检查
populate_recordlist()
,如下所示:
def test_populate_recordlist(self):
self.app.populate_recordlist()
self.app.data_model.get_all_records.assert_called()
self.app.recordlist.populate.assert_called_with(self.records)
- 在某些情况下,
get_all_records()
可能会引发异常,在这种情况下,我们应该显示一个错误消息框。但是,由于我们模拟了我们的数据模型,我们如何让它引发异常呢?解决方案是使用模拟的side_effect
属性,如下所示:
self.app.data_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()
时,它会抛出一个异常,促使该方法调用messagebox.showerror()
。由于我们已经模拟了showerror()
,我们可以断言它是否以预期的参数被调用。
显然,测试我们的Application
对象最困难的部分是补丁所有模拟的组件,并确保它们的行为足够像真实的东西,以满足Application
。一旦我们做到了这一点,编写实际的测试就相当简单了。
测试我们的小部件
到目前为止,我们在patch
、Mock
和默认的TestCase
方面做得很好,但是测试我们的小部件模块将带来一些新的挑战。首先,我们的小部件将需要一个Tk
实例作为它们的根窗口。我们可以在每个案例的setUp()
方法中创建这个实例,但这将大大减慢测试的速度,并且并不是真正必要的;我们的测试不会修改根窗口,因此一个根窗口对于每个测试案例就足够了。我们可以利用setUpClass()
方法,在类实例化时只创建一个 Tk 的单个实例。其次,我们有大量的小部件需要测试,这意味着我们有大量的测试案例需要相同的样板Tk()
设置和拆卸。
为了解决这个问题,让我们从一个自定义的TestCase
类开始我们的test_widgets.py
模块,如下所示:
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()
,只是为了确保我们的窗口在我们的测试开始使用它之前是可见的。就像我们在Application
测试中所做的那样,我们还提供了一个补充的拆卸方法,更新Tk
实例并销毁它。
单元测试 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()
我们的设置方法创建一个变量来存储小部件的值,然后使用一些基本设置创建ValidatedSpinbox
小部件的实例:最小值为-10,最大值为 10,增量为 1。创建后,我们将其打包并等待它变得可见。对于我们的拆卸方法,我们只是销毁小部件。
在测试我们的小部件时,我们可以采取几种方法。第一种方法是面向单元测试的方法,我们专注于实际的方法代码,简单地模拟任何外部功能。
让我们尝试使用_key_validate()
方法如下:
def test__key_validate(self):
# test valid input
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=''):
# args are inserted char, insertion index, current value,
# proposed value, and action code (where '1' is 'insert')
return self.vsb._key_validate(new, 'end', current,
current + new, '1')
这将使将来对该方法的调用更短,更不容易出错。
现在让我们使用它来测试一些无效的输入,如下所示:
# test letters
valid = self.key_validate('a')
self.assertFalse(valid)
# test non-increment number
valid = self.key_validate('1', '0.')
self.assertFalse(valid)
# test too high number
valid = self.key_validate('0', '10')
self.assertFalse(valid)
在第一个示例中,我们输入a
;在第二个示例中,当框中已经有0.
时,我们输入1
,结果为0.1
;在第三个示例中,当框中已经有10
时,我们输入0
,结果为100
。所有这些情况都应该使验证方法失败。
集成测试 ValidatedSpinbox 小部件
在前面的测试中,我们实际上并没有向小部件输入任何数据;我们只是直接调用键验证方法并评估其输出。这是很好的单元测试,但作为对这段代码的测试来说并不够令人满意。由于我们的自定义小部件非常依赖于 Tkinter 的验证 API,我们希望测试我们是否正确地实现了这个 API。毕竟,代码的这一方面比我们的验证方法中的实际逻辑更具挑战性。
我们可以通过创建一些集成测试来实现这一点,这些测试模拟了实际用户操作,然后检查这些操作的结果。为了做到这一点,我们首先需要创建一些支持方法。
首先在TkTestCase
类中添加一个新方法,如下所示:
def type_in_widget(self, widget, string):
widget.focus_force()
for char in string:
char = self.keysyms.get(char, char)
这个类将接受一个小部件和一个字符串,并尝试模拟用户将字符串输入到小部件中。我们首先做的是强制焦点到小部件;我们需要使用focus_force()
,因为我们的测试 Tk 窗口在运行测试时不太可能处于焦点状态。
一旦我们获得焦点,我们将遍历字符串中的字符,并将原始字符转换为事件序列的适当键符号。请记住,一些字符,特别是符号,必须表示为字符串,比如minus
或colon
。
为了使这个方法起作用,我们需要一个名为dict
的类属性,用于在字符和它们的键符号之间进行转换,如下所示:
keysyms = {'-': 'minus', ' ': 'space', ':': 'colon', ...}
更多的键符号可以在www.tcl.tk/man/tcl8.4/TkCmd/keysyms.htm
找到,但现在这些就够了。
一旦我们的字符被转换为适当的键符号,我们就可以创建我们的事件序列并生成我们的按键事件。在type_in_widget()
方法中,我们可以创建并调用一个按键事件序列,如下所示:
self.root.update()
widget.event_generate('<KeyPress-{}>'.format(char))
self.root.update()
请注意,在生成按键事件之前和之后都调用了self.root.update()
。这确保小部件已准备好输入,并且生成的输入在生成后注册。顺便说一句,update_idletasks()
在这里行不通;试一试,你会发现测试会失败。
我们可以创建一个类似的方法来模拟鼠标点击按钮,如下所示:
def click_on_widget(self, widget, x, y, button=1):
widget.focus_force()
self.root.update()
widget.event_generate("<ButtonPress-{}>".format(button),
x=x, y=y)
self.root.update()
就像我们使用按键方法一样,我们首先强制焦点,更新应用程序,生成我们的事件,然后再次更新。然而,在这个方法中,我们还需要指定鼠标点击的x
和y
坐标。这些坐标是相对于小部件左上角的坐标。我们也可以指定按钮编号,但我们将默认为左按钮(1
)。
有了这些方法,回到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()
模拟一些有效的输入,并检查小部件是否接受了输入。请注意,在这些集成测试中,我们需要每次清除小部件,因为我们正在模拟实际小部件中的按键,并触发所有这些操作的副作用。
接下来,让我们通过执行以下代码来测试一些无效的输入:
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')
我们可以使用鼠标点击方法来测试Spinbox
箭头按钮的功能。为了简化这个过程,让我们在测试用例类中创建一个辅助方法来点击我们想要的箭头。将这个方法添加到TestValidatedSpinbox
中:
def click_arrow(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)
我们可以通过点击距离小部件右侧5
像素,顶部5
像素来定位增量箭头。减量箭头可以在距右侧5
像素,顶部15
像素的位置找到。当然,这可能需要根据主题或屏幕设置进行一些调整。现在,我们可以轻松地测试我们的箭头键功能,如下所示:
def test_arrows(self):
self.value.set(0)
self.click_arrow(times=1)
self.assertEqual(self.vsb.get(), '1')
self.click_arrow(times=5)
self.assertEqual(self.vsb.get(), '6')
self.click_arrow(arrow='dec', times=1)
self.assertEqual(self.vsb.get(), '5')
通过设置小部件的值,然后点击适当的箭头指定次数,我们可以测试箭头是否根据我们的小部件类的规则完成了它们的工作。
测试我们的混合类
我们还没有解决的一个额外挑战是测试我们的混合类。与我们的其他小部件类不同,我们的混合类实际上不能独立存在:它依赖于与之组合的ttk
小部件中找到的方法和属性。
测试这个类的一种方法是将它与一个Mock
对象混合,该对象模拟了任何继承方法。这种方法是有优点的,但一个更简单(虽然不太理想)的方法是用最简单的ttk
小部件的子类来继承它,并测试生成的子类。
这种方法看起来是这样的:
class TestValidatedMixin(TkTestCase):
def setUp(self):
class TestClass(widgets.ValidatedMixin, ttk.Entry):
pass
self.vw1 = TestClass(self.root)
在这里,我们只是使用ttk.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
被调用时使用了正确的参数。
通过更新args
中的event
值,我们可以检查focusout
事件是否也起作用:
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
库提供的功能。我们针对应用程序的部分编写了单元测试和集成测试,您学会了解决各种测试挑战的方法。
在下一章中,我们将升级我们的后端以使用关系数据库。您还将学习关系数据库、SQL 和数据库规范化。您将学习如何与 PostgreSQL 数据库服务器和 Python 的psycopg2
PostgreSQL 接口库一起工作。
第十章:使用 SQL 改进数据存储
随着时间的推移,实验室出现了一个越来越严重的问题:CSV 文件到处都是!冲突的副本,丢失的文件,非数据输入人员更改的记录,以及其他与 CSV 相关的挫折正在困扰着项目。很明显,单独的 CSV 文件不适合作为存储实验数据的方式。需要更好的东西。
该设施有一个安装了 PostgreSQL 数据库的较旧的 Linux 服务器。您被要求更新您的程序,以便将数据存储在 PostgreSQL 数据库中,而不是在 CSV 文件中。这将是对您的应用程序的重大更新!
在本章中,您将学习以下主题:
-
安装和配置 PostgreSQL 数据库系统
-
在数据库中构建数据以获得良好的性能和可靠性
-
SQL 查询的基础知识
-
使用
psycopg2
库将您的程序连接到 PostgreSQL
PostgreSQL
PostgreSQL(通常发音为 post-gress)是一个免费的、开源的、跨平台的关系数据库系统。它作为一个网络服务运行,您可以使用客户端程序或软件库进行通信。在撰写本文时,该项目刚刚发布了 10.0 版本。
尽管 ABQ 提供了一个已安装和配置的 PostgreSQL 服务器,但您需要为开发目的在您的工作站上下载并安装该软件。
共享的生产资源,如数据库和网络服务,永远不应该用于测试或开发。始终在您自己的工作站或单独的服务器上设置这些资源的独立开发副本。
安装和配置 PostgreSQL
要下载 PostgreSQL,请访问www.postgresql.org/download/
。EnterpriseDB 公司为 Windows、macOS 和 Linux 提供了安装程序,这是一个为 PostgreSQL 提供付费支持的商业实体。这些软件包包括服务器、命令行客户端和 pgAdmin 图形客户端。
要安装软件,请使用具有管理权限的帐户启动安装程序,并按照安装向导中的屏幕进行操作。
安装后,启动 pgAdmin,并通过选择 Object | Create | Login/Group Role 来为自己创建一个新的管理员用户。确保访问特权选项卡以检查超级用户,并访问定义选项卡以设置密码。然后,通过选择 Object | Create | Database 来创建一个数据库。确保将您的用户设置为所有者。要在数据库上运行 SQL 命令,请选择您的数据库并单击 Tools | Query Tool。
喜欢使用命令行的 MacOS 或 Linux 用户也可以使用以下命令:
sudo -u postgres createuser -sP myusername
sudo -u postgres createdb -O myusername mydatabasename
psql -d mydatabasename -U myusername
尽管 Enterprise DB 为 Linux 提供了二进制安装程序,但大多数 Linux 用户更喜欢使用其发行版提供的软件包。您可能会得到一个稍旧的 PostgreSQL 版本,但对于大多数基本用例来说这并不重要。请注意,pgAdmin 通常是单独的软件包的一部分,最新版本(pgAdmin 4)可能不可用。不过,您应该没有问题遵循本章使用旧版本。
使用 psycopg2 连接
要从我们的应用程序进行 SQL 查询,我们需要安装一个可以直接与我们的数据库通信的 Python 库。最受欢迎的选择是psycopg2
。psycopg2
库不是 Python 标准库的一部分。您可以在initd.org/psycopg/docs/install.html
找到最新的安装说明;但是,首选方法是使用pip
。
对于 Windows、macOS 和 Linux,以下命令应该有效:
pip install --user psycopg2-binary
如果这不起作用,或者您更愿意从源代码安装它,请在网站上检查要求。psycopg2
库是用 C 编写的,而不是 Python,因此它需要 C 编译器和其他几个开发包。Linux 用户通常可以从其发行版的软件包管理系统中安装psycopg2
。我们将在本章后面深入研究psycopg2
的使用。
SQL 和关系数据库基础知识
在我们开始使用 Python 与 PostgreSQL 之前,您至少需要对 SQL 有基本的了解。如果您已经有了,可以跳到下一节;否则,准备好接受关系数据库和 SQL 的超短速成课程。
三十多年来,关系数据库系统一直是存储业务数据的事实标准。它们更常被称为SQL 数据库,因为与它们交互的结构化查询语言(SQL)。
SQL 数据库由表组成。表类似于我们的 CSV 文件,因为它具有表示单个项目的行和表示与每个项目关联的数据值的列。SQL 表与我们的 CSV 文件有一些重要的区别。首先,表中的每一列都被分配了一个严格执行的数据类型;就像当您尝试将abcd
作为int
使用时,Python 会产生错误一样,当您尝试将字母插入到数字或其他非字符串列中时,SQL 数据库会抱怨。SQL 数据库通常支持文本、数字、日期和时间、布尔值、二进制数据等数据类型。
SQL 表还可以具有约束,进一步强制执行插入到表中的数据的有效性。例如,可以给列添加唯一约束,这可以防止两行具有相同的值,或者添加非空约束,这意味着每一行都必须有一个值。
SQL 数据库通常包含许多表;这些表可以连接在一起,以表示更复杂的数据结构。通过将数据分解为多个链接的表,可以以比我们的二维纯文本 CSV 文件更有效和更具弹性的方式存储数据。
基本的 SQL 操作
SQL 是一个用于对表格数据进行大规模操作的强大而表达性的语言,但基础知识可以很快掌握。SQL 作为单独的查询来执行,这些查询要么定义数据,要么在数据库中操作数据。SQL 方言在不同的关系数据库产品之间略有不同,但它们大多数支持 ANSI/ISO 标准 SQL 进行核心操作。虽然我们将在本章中使用 PostgreSQL,但我们编写的大多数 SQL 语句都可以在不同的数据库中使用。
要遵循本节,连接到您的 PostgreSQL 数据库服务器上的空数据库,可以使用psql
命令行工具、pgAdmin 4 图形工具或您选择的其他数据库客户端软件。
与 Python 的语法差异
如果您只在 Python 中编程过,那么最初可能会觉得 SQL 很奇怪,因为规则和语法非常不同。
我们将介绍各个命令和关键字,但以下是与 Python 不同的一些一般区别:
-
SQL(大部分)不区分大小写:尽管为了可读性的目的,按照惯例,将 SQL 关键字输入为全大写,但大多数 SQL 实现不区分大小写。这里有一些小的例外,但大部分情况下,您可以以最容易的方式输入 SQL 的大小写。
-
空格不重要:在 Python 中,换行和缩进可以改变代码的含义。在 SQL 中,空格不重要,语句以分号结尾。查询中的缩进和换行只是为了可读性。
-
SQL 是声明性的:Python 可以被描述为一种命令式编程语言:我们通过告诉 Python 如何做来告诉 Python 我们想要它做什么。SQL 更像是一种声明性语言:我们描述我们想要的,SQL 引擎会找出如何做。
当我们查看特定的 SQL 代码示例时,我们会遇到其他语法差异。
定义表和插入数据
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
值。 -
born
和died
字段是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 中具有不同的含义:单引号表示字符串文字,而双引号用于包含空格或需要保留大小写的对象名称。如果我们在这里使用双引号,将导致错误。
让我们创建并填充一个instruments
表:
CREATE TABLE instruments (id SERIAL PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO instruments (name) VALUES ('bass'), ('drums'), ('guitar'), ('keyboards');
请注意,VALUES
列表必须始终在每一行周围使用括号,即使每行只有一个值。
表在创建后可以使用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
命令必须跟随一个条件语句;满足条件的行将被显示,而不满足条件的行将被排除。在这种情况下,我们要求没有死亡日期的音乐家的名字。
我们可以使用AND
和OR
运算符指定复杂条件如下:
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()
函数来确定音乐家的年龄。我们还对died
和born
日期进行数学运算,以确定那些已故者的死亡年龄。请注意,我们使用AS
关键字来重命名或别名生成的列。
当运行此查询时,请注意,对于没有死亡日期的人,age at death
为NULL
。对NULL
值进行数学或逻辑运算总是返回NULL
。
ORDER BY
子句指定结果应该按照哪些列进行排序。它还接受DESC
或ASC
的参数来指定降序或升序。我们在这里按出生日期降序排序输出。请注意,每种数据类型都有其自己的排序规则,就像在 Python 中一样。日期按照它们的日历位置排序,字符串按照字母顺序排序,数字按照它们的数值排序。
更新行,删除行,以及更多的 WHERE 子句
要更新或删除现有行,我们使用UPDATE
和DELETE FROM
关键字与WHERE
子句一起选择受影响的行。
删除很简单,看起来像这样:
DELETE FROM instruments WHERE id=4;
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';
在这里,我们将main_instrument
设置为两位音乐家对应的instruments
主键值。我们可以通过主键、名称或任何有效的条件集来选择要更新的音乐家记录。与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
函数将我们的字符串与列值的小写版本进行匹配。这不会永久改变表中的数据;它只是临时更改值以进行检查。
标准 SQL 规定LIKE
是区分大小写的匹配。PostgreSQL 提供了一个ILIKE
运算符,它可以进行不区分大小写的匹配,还有一个SIMILAR TO
运算符,它使用更高级的正则表达式语法进行匹配。
子查询
与其每次使用instruments
表的原始主键值,我们可以像以下 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
运算符来匹配一个值列表。这允许我们匹配一个值列表。
IN
可以与子查询一起使用,如下所示:
SELECT name FROM musicians WHERE main_instrument IN (SELECT id FROM instruments WHERE name like '%r%')
由于IN
是用于与值列表一起使用的,任何返回单列的查询都是有效的。
返回多行和多列的子查询可以在任何可以使用表的地方使用:
SELECT name FROM (SELECT * FROM musicians WHERE died IS NULL) AS living_musicians;
请注意,FROM
子句中的子查询需要一个别名;我们将子查询命名为living_musicians
。
连接表
子查询是使用多个表的一种方法,但更灵活和强大的方法是使用JOIN
。
JOIN
在 SQL 语句的FROM
子句中使用如下:
SELECT musicians.name, instruments.name as main_instrument FROM musicians JOIN instruments ON musicians.main_instrument = instruments.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 查询将给出以下输出:
instrument |
musician |
---|---|
drums |
Bill Bruford |
keyboards |
Keith Emerson |
bass |
Greg Lake |
guitar |
Robert Fripp |
guitar |
David Gilmour |
请注意,guitar
在乐器列表中重复了。当两个表连接时,结果的行不再指代相同类型的对象。乐器表中的一行代表一个乐器。musician
表中的一行代表一个音乐家。这个表中的一行代表一个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()
函数来计算与每个乐器关联的音乐家记录的总数。标准 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
语句:一个从musicians
到musicians_bands
,一个从bands
到musicians_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;
这个查询使用连接表将音乐家
和乐队
联系起来,然后显示音乐家的名字以及他们所在乐队的聚合列表,并按音乐家的名字排序。
前面的 SQL 查询给出了以下输出:
name |
bands |
---|---|
Bill Bruford |
{ABWH,"King Crimson",Yes} |
David Gilmour |
{"Pink Floyd"} |
Greg Lake |
{ELP,"King Crimson"} |
Keith Emerson |
{ELP} |
Robert Fripp |
{"King Crimson"} |
这里使用的array_agg()
函数将字符串值聚合成数组结构。这种方法和ARRAY
数据类型是特定于 PostgreSQL 的。没有用于聚合字符串值的 SQL 标准函数,但大多数 SQL 实现都有解决方案。
学习更多
这是对 SQL 概念和语法的快速概述;我们已经涵盖了你需要了解的大部分内容,但还有很多东西需要学习。PostgreSQL 手册,可在www.postgresql.org/docs/manuals/
上找到,是 SQL 语法和 PostgreSQL 特定功能的重要资源和参考。
建模关系数据
我们的应用目前将数据存储在一个单独的 CSV 文件中;这种文件通常被称为平面文件,因为数据已经被压缩成了两个维度。虽然这种格式对我们的应用程序来说可以接受,并且可以直接转换成 SQL 表,但更准确和有用的数据模型需要更复杂的结构。
规范化
将平面数据文件拆分成多个表的过程称为规范化。规范化是一个涉及一系列级别的过程,称为范式,逐步消除重复并创建更精确的数据模型。虽然有许多范式,但大多数常见业务数据中遇到的问题都可以通过符合前三个范式来解决。
粗略地说,这需要以下条件:
-
第一范式要求每个字段只包含一个值,并且必须消除重复的列。
-
第二范式还要求每个值必须依赖于整个主键。换句话说,如果一个表有主键字段
A
、B
和C
,并且列X
的值仅取决于列A
的值,而不考虑B
或C
,那么该表就违反了第二范式。 -
第三范式还要求表中的每个值只依赖于主键。换句话说,给定一个具有主键
A
和数据字段X
和Y
的表,Y
的值不能依赖于X
的值。
符合这些规范的数据消除了冗余、冲突或未定义数据情况的可能性。
实体关系图
帮助规范化我们的数据并为关系数据库做好准备的一种有效方法是分析数据并创建一个实体-关系图,或ERD。 ERD 是一种用图表表示数据库存储信息和这些信息之间关系的方法。
这些东西被称为实体。实体是一个唯一可识别的对象;它对应于单个表的单行。实体具有属性,对应于其表的列。实体与其他实体有关系,这对应于我们在 SQL 中定义的外键关系。
让我们考虑实验室场景中的实体及其属性和关系:
-
有实验室。每个实验室都有一个名字。
-
有地块。每个地块都属于一个实验室,并有一个编号。在地块中种植种子样本。
-
有实验室技术人员,每个人都有一个名字。
-
有实验室检查,由实验室技术人员在特定实验室进行。每个检查都有日期和时间。
-
有地块检查,这是在实验室检查期间在地块上收集的数据。每个地块检查都记录了各种植物和环境数据。
以下是这些实体和关系的图表:
在前面的图表中,实体由矩形表示。我们有五个实体:实验室,地块,实验室技术人员,实验室检查和地块检查。每个实体都有属性,用椭圆形表示。关系由菱形表示,其中的文字描述了左到右的关系。例如,实验室技术人员执行实验室检查,实验室检查在实验室中进行。请注意关系周围的小1和n字符:这些显示了关系是一对多,多对一还是多对多。
这个图表代表了我们数据的一个相当规范化的结构。要在 SQL 中实现它,我们只需为每个实体创建一个表,为每个属性创建一个列,并为每个关系创建一个外键关系(可能包括一个中间表)。在我们这样做之前,让我们考虑 SQL 数据类型。
分配数据类型
标准 SQL 定义了 16 种数据类型,包括各种大小的整数和浮点数类型、固定大小或可变大小的 ASCII 或 Unicode 字符串、日期和时间类型以及位类型。几乎每个 SQL 引擎都会扩展这些类型,以适应二进制数据、特殊类型的字符串或数字等。许多数据类型似乎有点多余,而且有几个别名在不同的实现之间可能是不同的。选择列的数据类型可能会令人困惑!
对于 PostgreSQL,以下图表提供了一些合理的选择:
存储的数据 | 推荐类型 | 备注 |
---|---|---|
固定长度字符串 | CHAR |
需要长度。 |
短到中等长度的字符串 | VARCHAR |
需要一个最大长度参数,例如,VARCHAR(256) 。 |
长、自由格式文本 | TEXT |
无限长度,性能较慢。 |
较小的整数 | SMALLINT |
最多±32,767。 |
大多数整数 | INT |
最多约±21 亿。 |
较大的整数 | BIGINT |
最多约±922 万亿。 |
小数 | NUMERIC |
接受可选的长度和精度参数。 |
整数主键 | SERIAL ,BIGSERIAL |
自动递增整数或大整数。 |
布尔 | BOOLEAN |
|
日期和时间 | TIMESTAMP WITH TIMEZONE |
存储日期、时间和时区。精确到 1 微秒。 |
无时间的日期 | DATE |
|
无日期的时间 | TIME |
可以有或没有时区。 |
这些类型可能在大多数应用中满足您的绝大多数需求,我们将在我们的 ABQ 数据库中使用其中的一部分。在创建表时,我们将参考我们的数据字典,并为我们的列选择适当的数据类型。
注意不要选择过于具体或限制性的数据类型。任何数据最终都可以存储在TEXT
字段中;选择更具体的类型的目的主要是为了能够使用特定类型的运算符、函数或排序。如果不需要这些,可以考虑使用更通用的类型。例如,电话号码和美国社会安全号码可以纯粹用数字表示,但这并不意味着要将它们作为INTEGER
或NUMERIC
字段;毕竟,你不会用它们进行算术运算!
创建 ABQ 数据库
现在我们已经对数据进行了建模,并对可用的数据类型有了一定的了解,是时候建立我们的数据库了。首先,在您的 SQL 服务器上创建一个名为abq
的数据库,并将自己设为所有者。
接下来,在您的项目根目录下,创建一个名为sql
的新目录。在sql
文件夹中,创建一个名为create_db.sql
的文件。我们将从这个文件开始编写我们的数据库创建代码。
创建我们的表
我们创建表的顺序很重要。在外键关系中引用的任何表都需要在定义关系之前存在。因此,最好从查找表开始,并遵循一对多关系的链,直到所有表都被创建。在我们的 ERD 中,这将使我们从大致左上到右下。
创建查找表
我们需要创建以下三个查找表:
-
labs
:这个查找表将包含我们实验室的 ID 字符串。 -
lab_techs
:这个查找表将包含实验室技术员的姓名,通过他们的员工 ID 号进行标识。 -
plots
:这个查找表将为每个物理地块创建一行,由实验室和地块号标识。它还将跟踪地块中种植的当前种子样本。
将用于创建这些表的 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));
在我们可以使用我们的数据库之前,查找表将需要被填充:
-
labs
应该有值A
到E
,代表五个实验室。 -
lab_techs
需要我们四名实验室技术员的姓名和 ID 号:J Simms
(4291
)、P Taylor
(4319
)、Q Murphy
(4478
)和L Taniff
(5607
)。 -
plots
需要所有 100 个地块,每个实验室的地块号为1
到20
。种子样本在四个值之间轮换,如AXM477
、AXM478
、AXM479
和AXM480
。
您可以手动使用 pgAdmin 填充这些表,或者使用包含在示例代码中的db_populate.sql
脚本。
实验室检查表
lab_check
表是一个技术人员在给定日期的给定时间检查实验室的所有地块的一个实例,如下所示的 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));
date
、time
和lab_id
列一起唯一标识了实验室检查,因此我们将它们指定为主键列。执行检查的实验室技术员的 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,
这是lab_check
表的主键加上plot
号;它的键约束看起来像这样:
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);
请注意我们对数据类型和CHECK
约束的使用,以复制我们的data
字典中的限制。使用这些,我们利用了数据库的功能来防止无效数据。
创建视图
在完成数据库设计之前,我们将创建一个视图,以简化对我们数据的访问。视图在大多数方面都像表一样,但不包含实际数据;它实际上只是一个存储的SELECT
查询。我们的视图将为与 GUI 交互更容易地格式化我们的数据。
视图是使用CREATE VIEW
命令创建的,如下所示:
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 "Median 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_checks
和lab_techs
连接起来。请注意,我们使用AS
关键字给这些表起了别名。像这样的简短别名可以帮助使大查询更易读。我们还将每个字段别名为应用程序数据结构中使用的名称。这些必须用双引号括起来,以允许使用空格并保留大小写。通过使列名与应用程序中的data
字典键匹配,我们就不需要在应用程序代码中翻译字段名。
诸如 PostgreSQL 之类的 SQL 数据库引擎在连接和转换表格数据方面非常高效。在可能的情况下,利用这种能力,让数据库为了您的应用程序的方便而进行数据格式化工作。
这完成了我们的数据库创建脚本。在您的 PostgreSQL 客户端中运行此脚本,并验证已创建四个表和视图。
将 SQL 集成到我们的应用程序中
将我们的应用程序转换为 SQL 后端将不是一项小任务。该应用程序是围绕 CSV 文件的假设构建的,尽管我们已经注意到了分离我们的关注点,但许多事情都需要改变。
让我们分解一下我们需要采取的步骤:
-
我们需要编写一个 SQL 模型
-
我们的
Application
类将需要使用 SQL 模型 -
记录表格需要重新排序以优先考虑我们的键,使用新的查找和使用数据库自动填充
-
记录列表将需要调整以适应新的数据模型和主键
在这个过程中,我们将需要修复其他错误或根据需要实现一些新的 UI 元素。让我们开始吧!
创建一个新模型
我们将从models.py
开始导入psycopg2
和DictCursor
:
import psycopg2 as pg
from psycopg2.extras import DictCursor
DictCursor
将允许我们以 Python 字典而不是默认的元组获取结果,这在我们的应用程序中更容易处理。
开始一个名为SQLModel
的新模型类,并从CSVModel
复制fields
属性。
首先清除Technician
、Lab
和Plot
的值列表,并将Technician
设置为FT.string_list
类型:
class SQLModel:
fields = {
...
"Technician": {'req': True, 'type': FT.string_list,
'values': []},
"Lab": {'req': True, 'type': FT.string_list, 'values': []},
"Plot": {'req': True, 'type': FT.string_list,'values': []},
这些列表将从我们的查找表中填充,而不是硬编码到模型中。
我们将在__init__()
方法中完成这些列表的填充:
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 * 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()
建立与数据库的连接。因为我们将DictCursor
作为cursor_factory
传入,这个连接将返回所有数据查询的字典列表。
然后,我们查询数据库以获取我们三个查找表中的相关列,并使用列表推导式来展平每个查询的结果以获得values
列表。
这里使用的query
方法是我们需要接下来编写的包装器:
def query(self, query, parameters=None):
cursor = self.connection.cursor()
try:
cursor.execute(query, parameters)
except (pg.Error) as e:
self.connection.rollback()
raise e
else:
self.connection.commit()
if cursor.description is not None:
return cursor.fetchall()
使用psycopg2
查询数据库涉及从连接生成cursor
对象,然后使用查询字符串和可选参数数据调用其execute()
方法。默认情况下,所有查询都在事务中执行,这意味着它们在我们提交更改之前不会生效。如果查询因任何原因(SQL 语法错误、约束违反、连接问题等)引发异常,事务将进入损坏状态,并且必须在我们再次使用连接之前回滚(恢复事务的初始状态)。因此,我们将在try
块中执行我们的查询,并在任何psycopg2
相关异常(所有都是从pg.Error
继承的)的情况下使用connection.rollback()
回滚事务。
在查询执行后从游标中检索数据时,我们使用 fetchall()
方法,它将所有结果作为列表检索。但是,如果查询不是返回数据的查询(例如 INSERT
),fetchall()
将抛出异常。为了避免这种情况,我们首先检查 cursor.description
:如果查询返回了数据(即使是空数据集),cursor.description
将包含有关返回表的元数据(例如列名)。如果没有,则为 None
。
让我们通过编写 get_all_records()
方法来测试我们的 query()
方法:
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", "Time", "Lab", "Plot"')
return self.query(query, {'all_dates': all_dates})
由于我们的用户习惯于仅使用当天的数据,因此默认情况下只显示该数据,但如果我们需要检索所有数据,我们可以添加一个可选标志。我们可以在大多数 SQL 实现中使用 CURRENT_DATE
常量获取当前日期,我们在这里使用了它。为了使用我们的 all_dates
标志,我们正在使用准备好的查询。
语法 %(all_dates)s
定义了一个参数;它告诉 psycopg2
检查包含的参数字典,以便将其值替换到查询中。psycopg2
库将自动以一种安全的方式执行此操作,并正确处理各种数据类型,如 None
或布尔值。
始终使用准备好的查询将数据传递到 SQL 查询中。永远不要使用字符串格式化或连接!不仅比你想象的更难以正确实现,而且可能会导致意外或恶意的数据库损坏。
接下来,让我们创建 get_record()
:
def get_record(self, date, time, lab, plot):
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 {}
我们不再处理像我们的 CSVModel
那样的行号,因此此方法需要所有四个关键字段来检索记录。再次,我们使用了准备好的查询,为这四个字段指定参数。请注意参数括号的右括号后面的 s
;这是一个必需的格式说明符,应始终为 s
。
即使只有一行,query()
也会以列表的形式返回结果。我们的应用程序期望从 get_record()
中获得一个单行字典,因此我们的 return
语句会在列表不为空时提取 result
中的第一项,如果为空则返回一个空的 dict
。
检索实验室检查记录非常类似:
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 {}
在此查询中,我们使用连接来确保我们有技术员名称可用,而不仅仅是 ID。这种方法将在我们的 save_record()
方法和表单数据自动填充方法中非常有用。
save_record()
方法将需要四个查询:对 lab_checks
和 plot_checks
的 INSERT
和 UPDATE
查询。为了保持方法相对简洁,让我们将查询字符串创建为类属性。
我们将从实验室检查查询开始:
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_id=%(Lab)s')
lc_insert_query = ('INSERT INTO lab_checks VALUES (%(Date)s,
'%(Time)s, %(Lab)s,(SELECT id FROM lab_techs '
'WHERE name=%(Technician)s))')
这些查询非常简单,但请注意我们使用子查询来填充每种情况中的 lab_tech_id
。我们的应用程序不知道实验室技术员的 ID 是什么,因此我们需要通过名称查找 ID。另外,请注意我们的参数名称与应用程序字段中使用的名称相匹配。这将使我们无需重新格式化从表单获取的记录数据。
地块检查查询更长,但并不复杂:
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 = '
'%(Median Height)s, notes = %(Notes)s '
'WHERE date=%(Date)s AND time=%(Time)s '
'AND lab_id=%(Lab)s AND plot=%(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,'
' %(Median Height)s, %(Notes)s)')
有了这些查询,我们可以开始 save_record()
方法:
def save_record(self, record):
date = record['Date']
time = record['Time']
lab = record['Lab']
plot = record['Plot']
CSVModel.save_record()
方法接受一个 record
字典和一个 rownum
,但是我们不再需要 rownum
,因为它没有意义。我们所有的关键信息已经在记录中。为了方便起见,我们将提取这四个字段并为它们分配本地变量名。
当我们尝试在这个数据库中保存记录时,有三种可能性:
-
实验室检查或地块检查记录都不存在。两者都需要创建。
-
实验室检查存在,但地块检查不存在。如果用户想要更正技术员的值,则需要更新实验室检查,而地块检查需要添加。
-
实验室检查和地块检查都存在。两者都需要使用提交的值进行更新。
为了确定哪种可能性是真实的,我们将利用我们的 get_
方法:
if self.get_lab_check(date, time, lab):
lc_query = self.lc_update_query
else:
lc_query = self.lc_insert_query
if self.get_record(date, time, lab, plot):
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
中过滤不需要的项目。
这里还有一件事情要做:记住我们的Application
需要跟踪更新和插入的行。由于我们不再处理行号,只有数据库模型知道是否执行了插入或更新。
让我们创建一个实例属性来共享这些信息:
if self.get_record(date, time, lab, plot):
pc_query = self.pc_update_query
self.last_write = 'update'
else:
pc_query = self.pc_insert_query
self.last_write = 'insert'
现在Application
可以在调用save_record()
后检查last_write
的值,以确定执行了哪种操作。
这个模型还需要最后一个方法;因为我们的数据库知道每个地块当前种子样本是什么,我们希望我们的表单自动为用户填充这些信息。我们需要一个方法,它接受一个lab
和plot_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
列的值。如果没有result
,我们将返回一个空字符串。
这完成了我们的模型类;现在让我们将其合并到应用程序中。
调整 SQL 后端的 Application 类
Application
类需要的第一件事是数据库连接信息,以传递给模型。
对于主机和数据库名称,我们可以只需向我们的SettingsModel
添加设置:
variables = {
...
'db_host': {'type': 'str', 'value': 'localhost'},
'db_name': {'type': 'str', 'value': 'abq'}
这些可以保存在我们的 JSONconfig
文件中,可以编辑以从开发切换到生产,但我们的用户名和密码需要用户输入。为此,我们需要构建一个登录对话框。
构建登录窗口
Tkinter 没有为我们提供现成的登录对话框,但它提供了一个通用的Dialog
类,可以被子类化以创建自定义对话框。
从tkinter.simpledialog
中导入这个类到我们的views.py
文件:
from tkinter.simpledialog import Dialog
让我们从我们的类声明和__init__()
方法开始:
class LoginDialog(Dialog):
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)
我们的类将像往常一样接受一个parent
,一个窗口title
,以及一个可选的error
,如果需要重新显示带有error
消息的对话框(例如,如果密码错误)。__init__()
的其余部分为密码、用户名和error
字符串设置了一些 Tkinter 变量;然后,它以通常的方式调用super()
结束。
表单本身不是在__init__()
中定义的;相反,我们需要重写body()
方法:
def body(self, parent):
lf = tk.Frame(self)
ttk.Label(lf, text='Login to ABQ', font='Sans 20').grid()
我们做的第一件事是制作一个框架,并使用大字体在第一行添加一个标题标签。
接下来,我们将检查是否有error
字符串,如果有,以适当的样式显示它。
if self.error.get():
tk.Label(lf, textvariable=self.error,
bg='darkred', fg='white').grid()
现在我们将添加用户名和密码字段,并将我们的框架打包到对话框中。
ttk.Label(lf, text='User name:').grid()
self.username_inp = ttk.Entry(lf, textvariable=self.user)
self.username_inp.grid()
ttk.Label(lf, text='Password:').grid()
self.password_inp = ttk.Entry(lf, show='*',
textvariable=self.pw)
self.password_inp.grid()
lf.pack()
return self.username_inp
注意我们在密码输入中使用show
选项,它用我们指定的字符替换任何输入的文本,以创建一个隐藏的文本字段。另外,请注意我们从方法中返回用户名输入小部件。Dialog
在显示时将聚焦在这里返回的小部件上。
Dialog
自动提供OK
和Cancel
按钮;我们想知道点击了哪个按钮,如果是OK
按钮,检索输入的信息。
点击 OK 会调用apply()
方法,因此我们可以重写它来设置一个result
值。
def apply(self):
self.result = (self.user.get(), self.pw.get())
Dialog
默认创建一个名为result
的属性,其值设置为None
。但是现在,如果我们的用户点击了 OK,result
将是一个包含用户名和密码的元组。我们将使用这个属性来确定点击了什么,输入了什么。
使用登录窗口
为了使用对话框,我们的应用程序需要一个方法,它将在无限循环中显示对话框,直到用户单击取消或提供的凭据成功验证。
在Application
中启动一个新的database_login()
方法:
def database_login(self):
error = ''
db_host = self.settings['db_host'].get()
db_name = self.settings['db_name'].get()
title = "Login to {} at {}".format(db_name, db_host)
我们首先设置一个空的error
字符串和一个title
字符串,以传递给我们的LoginDialog
类。
现在我们将开始无限循环:
while True:
login = v.LoginDialog(self, title, error)
if not login.result:
break
在循环内部,我们创建一个LoginDialog
,它将阻塞,直到用户单击其中一个按钮。对话框返回后,如果login.result
是None
,则用户已单击取消,因此我们会跳出循环并退出方法。
如果我们有一个非None
的login.result
,我们将尝试用它登录:
else:
username, password = login.result
try:
self.data_model = m.SQLModel(
db_host, db_name, username, password)
except m.pg.OperationalError:
error = "Login Failed"
else:
break
从result
元组中提取username
和password
后,我们尝试用它创建一个SQLModel
实例。如果凭据失败,psycopg2.connect
将引发OperationalError
,在这种情况下,我们将简单地填充我们的error
字符串,让无限循环再次迭代。
如果数据模型创建成功,我们只需跳出循环并退出方法。
回到__init__()
,在设置我们的设置之后,让我们让database_login()
开始工作:
self.database_login()
if not hasattr(self, 'data_model'):
self.destroy()
return
在调用self.database_login()
之后,Application
要么有一个data_model
属性(因为登录成功),要么没有(因为用户单击了取消)。如果没有,我们将通过销毁主窗口并立即从__init__()
返回来退出应用程序。
当然,在这个逻辑生效之前,我们需要删除CSVModel
的创建:
# Delete this line:
self.data_model = m.CSVModel(filename=self.filename.get())
修复一些模型不兼容性
理论上,我们应该能够用相同的方法调用交换一个新模型,我们的应用程序对象将正常工作,但情况并非完全如此。我们需要做一些小的修复来让Application
与我们的新模型一起工作。
DataRecordForm 创建
首先,让我们在Application.__init__()
中修复DataRecordForm
的实例化:
# The data record form
self.recordform = v.DataRecordForm(
self, self.data_model.fields, self.settings,
self.callbacks)
以前,我们从CSVModel
的静态类属性中提取了fields
参数。我们现在需要从我们的数据模型实例中提取它,因为实例正在设置一些值。
修复 open_record()方法
接下来,我们需要修复我们的open_record()
方法。它目前需要一个rownum
,但我们不再有行号;我们有date
、time
、lab
和plot
。
为了反映这一点,用rowkey
替换所有rownum
的实例:
def open_record(self, rowkey=None):
if rowkey is None:
# ...etc
最后,在get_record()
调用中扩展rowkey
,因为它期望四个位置参数:
record = self.data_model.get_record(*rowkey)
修复 on_save()方法
on_save()
的错误处理部分是好的,但在if errors:
块之后,我们将开始改变事情:
data = self.recordform.get()
try:
self.data_model.save_record(data)
我们不再需要提取行号或将其传递给save_record()
,并且我们可以删除对IndexError
的处理,因为SQLModel
不会引发该异常。我们还需要重写inserted_rows
和updated_rows
的更新。
在调用self.status.set()
之后,删除此方法中的所有代码,并用以下代码替换:
key = (data['Date'], data['Time'], data['Lab'], data['Plot'])
if self.data_model.last_write == 'update':
self.updated_rows.append(key)
else:
self.inserted_rows.append(key)
self.populate_recordlist()
if self.data_model.last_write == 'insert':
self.recordform.reset()
从传递给方法的data
中构建主键元组后,我们使用last_write
的值将其附加到正确的列表中。最后,在插入的情况下重置记录表单。
创建新的回调
我们希望为我们的记录表单有两个回调。当用户输入lab
和plot
值时,我们希望自动填充当前种植在该plot
中的正确seed
值。此外,当date
、time
和lab
值已输入,并且我们有匹配的现有实验室检查时,我们应该填充执行该检查的实验室技术人员的姓名。
当然,如果我们的用户不希望数据自动填充,我们也不应该做这些事情。
让我们从get_current_seed_sample()
方法开始:
def get_current_seed_sample(self, *args):
if not (hasattr(self, 'recordform')
and self.settings['autofill sheet data'].get()):
return
data = self.recordform.get()
plot = data['Plot']
lab = data['Lab']
if plot and lab:
seed = self.data_model.get_current_seed_sample(lab, plot)
self.recordform.inputs['Seed sample'].set(seed)
我们首先检查是否已创建记录表单对象,以及用户是否希望数据自动填充。如果不是,我们退出该方法。接下来,我们从表单的当前数据中获取plot
和lab
。如果我们两者都有,我们将使用它们从模型中获取seed
样本值,并相应地设置表单的Seed sample
值。
我们将以类似的方式处理实验技术值:
def get_tech_for_lab_check(self, *args):
if not (hasattr(self, 'recordform')
and self.settings['autofill sheet data'].get()):
return
data = self.recordform.get()
date = data['Date']
time = data['Time']
lab = data['Lab']
if all([date, time, lab]):
check = self.data_model.get_lab_check(date, time, lab)
tech = check['lab_tech'] if check else ''
self.recordform.inputs['Technician'].set(tech)
这一次,我们需要date
、time
和lab
参数来获取实验检查记录。因为我们不能确定是否存在与这些值匹配的检查,所以如果我们找不到匹配的实验检查,我们将把tech
设置为空字符串。
将这两种方法添加到callbacks
字典中,Application
类应该准备就绪。
更新我们的视图以适应 SQL 后端
让我们回顾一下我们需要在视图中进行的更改:
-
重新排列我们的字段,将所有主键放在前面
-
修复我们表单的
load_record()
方法,使其与新的关键结构配合使用 -
为我们的表单添加触发器以填充
Technician
和Seed sample
-
修复我们的记录列表以适应新的关键
让我们从我们的记录表单开始。
数据记录表单
我们的第一个任务是移动字段。这实际上只是剪切和粘贴代码,然后修复我们的grid()
参数。将它们放在正确的键顺序中:Date、Time、Lab、Plot。然后,将 Technician 和 Seed sample 留在 Record Information 部分的末尾。
它应该看起来像这样:
这种更改的原因是,所有可能触发 Technician 或 Seed sample 自动填充的字段将出现在这些字段之前。如果它们中的任何一个出现在之后,我们将无用地自动填充用户已经填写的字段。
在__init__()
的末尾,让我们添加触发器来填充 Technician 和 Seed sample:
for field in ('Lab', 'Plot'):
self.inputs[field].variable.trace(
'w', self.callbacks['get_seed_sample'])
for field in ('Date', 'Time', 'Lab'):
self.inputs[field].variable.trace(
'w', self.callbacks['get_check_tech'])
我们正在对实验检查和绘图的关键变量进行跟踪;如果它们中的任何一个发生变化,我们将调用适当的回调函数来自动填充表单。
在load_record()
中,为了清晰起见,用rowkey
替换rownum
,然后修复标签text
,使其有意义:
self.record_label.config(
text='Record for Lab {2}, Plot {3} at {0} {1}'
.format(*rowkey))
对于DataRecordForm
的最后一个更改涉及一个小的可用性问题。随着我们自动填充表单,确定下一个需要聚焦的字段变得越来越令人困惑。我们将通过创建一个方法来解决这个问题,该方法找到并聚焦表单中的第一个空字段。
我们将称之为focus_next_empty()
:
def focus_next_empty(self):
for labelwidget in self.inputs.values():
if (labelwidget.get() == ''):
labelwidget.input.focus()
break
在这个方法中,我们只是迭代所有的输入并检查它们当前的值。当我们找到一个返回空字符串时,我们将聚焦它,然后打破循环,这样就不会再检查了。我们可以删除DataRecordForm.reset()
中对聚焦字段的任何调用,并将其替换为对此方法的调用。您还可以将其添加到我们应用程序的自动填充方法get_current_seed_sample()
和get_tech_for_lab_check()
中。
记录列表
在RecordList
中,Row
列不再包含我们希望显示的有用信息。
我们无法删除它,但我们可以使用这段代码隐藏它:
self.treeview.config(show='headings')
show
配置选项接受两个值中的任意一个或两个:tree
和headings
。tree
参数代表#0
列,因为它用于展开tree
。headings
参数代表其余的列。通过在这里只指定headings
,#0
列被隐藏了。
我们还需要处理我们的populate()
方法,它在很大程度上依赖于rownum
。
我们将从更改填充值的for
循环开始:
for rowdata in rows:
rowkey = (str(rowdata['Date']), rowdata['Time'],
rowdata['Lab'], str(rowdata['Plot']))
values = [rowdata[key] for key in valuekeys]
我们可以删除enumerate()
调用,只需处理行数据,从中提取rowkey
元组,通过获取Date
、Time
、Lab
和Plot
。这些需要转换为字符串,因为它们作为 Python 对象(如date
和int
)从数据库中出来,我们需要将它们与inserted
和updated
中的键进行匹配,这些键都是字符串值(因为它们是从我们的表单中提取的)。
让我们进行比较并设置我们的行标签:
if self.inserted and rowkey in self.inserted:
tag = 'inserted'
elif self.updated and rowkey in self.updated:
tag = 'updated'
else:
tag = ''
现在,我们需要决定如何处理我们行的iid
值。iid
值必须是字符串;当我们的主键是整数时,这不是问题(可以轻松转换为字符串),但是我们的元组必须以某种方式进行序列化,以便我们可以轻松地反转。
解决这个问题的一个简单方法是将我们的元组转换为一个分隔的字符串:
stringkey = '{}|{}|{}|{}'.format(*rowkey)
任何不会出现在数据中的字符都可以作为分隔符;在这种情况下,我们选择使用管道字符。
现在我们可以在treeview
中使用键的字符串版本:
self.treeview.insert('', 'end', iid=stringkey,
text=stringkey, values=values, tag=tag)
该方法的最后部分将键盘用户聚焦在第一行。以前,为了聚焦第一行,我们依赖于第一个iid
始终为0
的事实。现在它将是一些数据相关的元组,所以我们必须在设置选择和焦点之前检索第一个iid
。
我们可以使用Treeview.identify_row()
方法来实现这一点:
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()
。
我们最后的更改是on_open_record()
方法。由于我们使用了我们序列化的元组作为iid
值,显然我们需要将其转换回一个可以传递回on_open_record()
方法的元组。
这就像调用split()
一样简单:
self.callbacks'on_open_record')
这修复了我们所有的视图代码,我们的程序已经准备好运行了!
最后的更改
呼!这是一次相当艰难的旅程,但你还没有完成。作业是,您需要更新您的单元测试以适应数据库和登录。最好的方法是模拟数据库和登录对话框。
还有一些 CSV 后端的残留物,比如文件菜单中的选择目标... 项目。您可以删除这些 UI 元素,但是将后端代码保留下来可能会在不久的将来派上用场。
总结
在本章中,您了解了关系数据库和 SQL,用于处理它们的语言。您学会了对数据进行建模和规范化,以减少不一致性的可能性,以及如何将平面文件转换为关系数据。您学会了如何使用psycopg2
库,并经历了将应用程序转换为使用 SQL 后端的艰巨任务。
在下一章中,我们将接触云。我们需要使用不同的网络协议联系一些远程服务器来交换数据。您将了解有关 Python 标准库模块的信息,用于处理 HTTP 和 FTP,并使用它们来下载和上传数据。
第十一章:连接到云
似乎几乎每个应用程序迟早都需要与外部世界交流,你的ABQ 数据录入
应用程序也不例外。您收到了一些新的功能请求,这将需要与远程服务器和服务进行一些交互。首先,质量保证部门正在研究当地天气条件如何影响每个实验室的环境数据;他们要求以按需下载和存储当地天气数据的方式。第二个请求来自您的老板,她仍然需要每天上传 CSV 文件到中央公司服务器。她希望这个过程能够简化,并且可以通过鼠标点击来完成。
在本章中,您将学习以下主题:
-
连接到 Web 服务并使用
urllib
下载数据 -
使用
requests
库管理更复杂的 HTTP 交互 -
使用
ftplib
连接和上传到 FTP 服务
使用urllib
进行 HTTP 连接
每次在浏览器中打开网站时,您都在使用超文本传输协议,或 HTTP。 HTTP 是在 25 年前创建的,作为 Web 浏览器下载 HTML 文档的一种方式,但已经发展成为最受欢迎的客户端-服务器通信协议之一,用于任何数量的目的。我们不仅可以使用它在互联网上传输从纯文本到流媒体视频的任何内容,而且应用程序还可以使用它来传输数据,启动远程过程或分发计算任务。
基本的 HTTP 事务包括客户端和服务器,其功能如下:
-
客户端:客户端创建请求。请求指定一个称为方法的操作。最常见的方法是
GET
,用于检索数据,以及POST
,用于提交数据。请求有一个 URL,指定了请求所在的主机、端口和路径,以及包含元数据的标头,如数据类型或授权令牌。最后,它有一个有效负载,其中可能包含键值对中的序列化数据。 -
服务器:服务器接收请求并返回响应。响应包含一个包含元数据的标头,例如响应的状态代码或内容类型。它还包含实际响应内容的有效负载,例如 HTML、XML、JSON 或二进制数据。
在 Web 浏览器中,这些操作是在后台进行的,但我们的应用程序将直接处理请求和响应对象,以便与远程 HTTP 服务器进行通信。
使用urllib.request
进行基本下载
urllib.request
模块是一个用于生成 HTTP 请求的 Python 模块。它包含一些用于生成 HTTP 请求的函数和类,其中最基本的是urlopen()
函数。urlopen()
函数可以创建GET
或POST
请求并将其发送到远程服务器。
让我们探索urllib
的工作原理;打开 Python shell 并执行以下命令:
>>> from urllib.request import urlopen
>>> response = urlopen('http://packtpub.com')
urlopen()
函数至少需要一个 URL 字符串。默认情况下,它会向 URL 发出GET
请求,并返回一个包装从服务器接收到的响应的对象。这个response
对象公开了从服务器接收到的元数据或内容,我们可以在我们的应用程序中使用。
响应的大部分元数据都在标头中,我们可以使用getheader()
来提取,如下所示:
>>> response.getheader('Content-Type')
'text/html; charset=utf-8'
>>> response.getheader('Server')
'nginx/1.4.5'
响应具有状态,指示在请求过程中遇到的错误条件(如果有);状态既有数字又有文本解释,称为reason
。
我们可以从我们的response
对象中提取如下:
>>> response.status
200
>>> response.reason
'OK'
在上述代码中,200
状态表示事务成功。客户端端错误,例如发送错误的 URL 或不正确的权限,由 400 系列的状态表示,而服务器端问题由 500 系列的状态表示。
可以使用类似于文件句柄的接口来检索response
对象的有效负载,如下所示:
>>> html = response.read()
>>> html[:15]
b'<!DOCTYPE html>'
就像文件句柄一样,响应只能使用read()
方法读取一次;与文件句柄不同的是,它不能使用seek()
“倒带”,因此如果需要多次访问响应数据,重要的是将响应数据保存在另一个变量中。response.read()
的输出是一个字节对象,应将其转换或解码为适当的对象。
在这种情况下,我们有一个utf-8
字符串如下:
>>> html.decode('utf-8')[:15]
'<!DOCTYPE html>'
除了GET
请求之外,urlopen()
还可以生成POST
请求。
为了做到这一点,我们包括一个data
参数如下:
>>> response = urlopen('http://duckduckgo.com', data=b'q=tkinter')
data
值需要是一个 URL 编码的字节对象。URL 编码的数据字符串由用&
符号分隔的键值对组成,某些保留字符被编码为 URL 安全的替代字符(例如,空格字符是%20
,或者有时只是+
)。
这样的字符串可以手工创建,但使用urllib.parse
模块提供的urlencode
函数更容易。看一下以下代码:
>>> from urllib.parse import urlencode
>>> data = {'q': 'tkinter, python', 'ko': '-2', 'kz': '-1'}
>>> urlencode(data)
'q=tkinter%2C+python&ko=-2&kz=-1'
>>> response = urlopen('http://duckduckgo.com', data=urlencode(data).encode())
data
参数必须是字节,而不是字符串,因此在urlopen
接受它之前必须对 URL 编码的字符串调用encode()
。
让我们尝试下载我们应用程序所需的天气数据。我们将使用http://weather.gov
提供美国境内的天气数据。我们将要下载的实际 URL 是w1.weather.gov/xml/current_obs/STATION.xml
,其中STATION
被本地天气站的呼号替换。在 ABQ 的情况下,我们将使用位于印第安纳州布卢明顿的 KBMG。
QA 团队希望您记录温度(摄氏度)、相对湿度、气压(毫巴)和天空状况(一个字符串,如阴天或晴天)。他们还需要天气站观测到天气的日期和时间。
创建下载函数
我们将创建几个访问网络资源的函数,这些函数不会与任何特定的类绑定,因此我们将它们放在自己的文件network.py
中。让我们看看以下步骤:
-
在
abq_data_entry
模块目录中创建network.py
。 -
现在,让我们打开
network.py
并开始我们的天气下载功能:
from urllib.request import urlopen
def get_local_weather(station):
url = (
'http://w1.weather.gov/xml/current_obs/{}.xml'
.format(station))
response = urlopen(url)
我们的函数将以station
字符串作为参数,以防以后需要更改,或者如果有人想在不同的设施使用这个应用程序。该函数首先通过构建天气数据的 URL 并使用urlopen()
请求来开始。
- 假设事情进行顺利,我们只需要解析出这个
response
数据,并将其放入Application
类可以传递给数据库模型的形式中。为了确定我们将如何处理响应,让我们回到 Python shell 并检查其中的数据:
>>> response = urlopen('http://w1.weather.gov/xml/current_obs/KBMG.xml')
>>> 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"
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>Wed, 14 Feb 2018 14:53:00
-0500</observation_time_rfc822>
<weather>Fog/Mist</weather>
<temp_c>11.7</temp_c>
<relative_humidity>96</relative_humidity>
<pressure_mb>1018.2</pressure_mb>
好的,我们需要的数据都在那里,所以我们只需要将它从 XML 字符串中提取出来,以便我们的应用程序可以使用。让我们花点时间了解一下解析 XML 数据。
解析 XML 天气数据
Python 标准库包含一个xml
包,其中包含用于解析或创建 XML 数据的几个子模块。xml.etree.ElementTree
子模块是一个简单、轻量级的解析器,应该满足我们的需求。
让我们将ElementTree
导入到我们的network.py
文件中,如下所示:
from xml.etree import ElementTree
现在,在函数的末尾,我们将解析我们的response
对象中的 XML 数据,如下所示:
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>
。我们希望我们的get_local_weather()
函数返回一个包含每个键的 Python dict
。
让我们在network.py
文件中添加以下行:
xmlroot = ElementTree.fromstring(response.read())
weatherdata = {
'observation_time_rfc822': None,
'temp_c': None,
'relative_humidity': None,
'pressure_mb': None,
'weather': None
}
我们的第一行从响应中提取原始 XML 并将其解析为Element
树,将根节点返回给xmlroot
。然后,我们设置了包含我们想要从 XML 数据中提取的标签的dict
。
现在,让我们通过执行以下代码来获取值:
for tag in weatherdata:
element = xmlroot.find(tag)
if element is not None:
weatherdata[tag] = element.text
对于我们的每个标签名称,我们将使用find()
方法来尝试在xmlroot
中定位具有匹配标签的元素。这个特定的 XML 文档不使用重复的标签,所以任何标签的第一个实例应该是唯一的。如果匹配了标签,我们将得到一个Element
对象;如果没有,我们将得到None
,因此在尝试访问其text
值之前,我们需要确保element
不是None
。
要完成函数,只需返回weatherdata
。
您可以在 Python shell 中测试此函数;从命令行,导航到ABQ_Data_Entry
目录并启动 Python shell:
>>> from abq_data_entry.network import get_local_weather
>>> get_local_weather('KBMG')
{'observation_time_rfc822': 'Wed, 14 Feb 2018 16:53:00 -0500',
'temp_c': '11.7', 'relative_humidity': '96', 'pressure_mb': '1017.0',
'weather': 'Drizzle Fog/Mist'}
您应该得到一个包含印第安纳州布卢明顿当前天气状况的dict
。您可以在w1.weather.gov/xml/current_obs/
找到美国其他城市的站点代码。
现在我们有了天气函数,我们只需要构建用于存储数据和触发操作的表格。
实现天气数据存储
为了存储我们的天气数据,我们将首先在 ABQ 数据库中创建一个表来保存单独的观测数据,然后构建一个SQLModel
方法来存储数据。我们不需要担心编写代码来检索数据,因为我们实验室的质量保证团队有他们自己的报告工具,他们将使用它来访问数据。
创建 SQL 表
打开create_db.sql
文件,并添加一个新的CREATE TABLE
语句如下:
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
中,让我们添加一个名为add_weather_data()
的新方法到SQLModel
类中,它只接受一个数据dict
作为参数。
让我们通过以下方式开始这个方法,编写一个INSERT
查询:
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)'
)
这是一个使用与get_local_weather()
函数从 XML 数据中提取的dict
键匹配的变量名的参数化INSERT
查询。我们只需要将这个查询和数据dict
传递给我们的query()
方法。
然而,有一个问题;如果我们得到重复的时间戳,我们的查询将因为重复的主键而失败。我们可以先进行另一个查询来检查,但这有点多余,因为 PostgreSQL 在插入新行之前会检查重复的键。当它检测到这样的错误时,psycopg2
会引发一个IntegrityError
异常,所以我们只需要捕获这个异常,如果它被引发了,就什么都不做。
为了做到这一点,我们将在try...except
块中包装我们的query()
调用如下:
try:
self.query(query, data)
except pg.IntegrityError:
# already have weather for this datetime
pass
现在,我们的数据录入人员可以随意调用这个方法,但只有在有新的观测数据需要保存时才会保存记录。
更新SettingsModel
类
在离开models.py
之前,我们需要添加一个新的应用程序设置来存储首选的天气站。在SettingsModel.variables
字典中添加一个新条目如下:
variables = {
...
'weather_station': {'type': 'str', 'value': 'KBMG'},
...
我们不会为这个设置添加 GUI,因为用户不需要更新它。这将由我们或其他实验室站点的系统管理员来确保在每台工作站上正确设置。
添加天气下载的 GUI 元素
Application
对象现在需要将network.py
中的天气下载方法与SQLModel
中的数据库方法连接起来,并使用适当的回调方法,主菜单类可以调用。按照以下步骤进行:
- 打开
application.py
并开始一个新的方法如下:
def update_weather_data(self):
try:
weather_data = n.get_local_weather(
self.settings['weather_station'].get())
- 请记住,在错误场景中,
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:
self.data_model.add_weather_data(weather_data)
self.status.set(
'Weather data recorded for {}'
.format(weather_data['observation_time_rfc822']))
除了保存数据,我们还在状态栏中通知用户天气已更新,并显示更新的时间戳。
- 回调方法完成后,让我们将其添加到我们的
callbacks
字典中:
self.callbacks = {
...
'update_weather_data': self.update_weather_data,
...
- 现在我们可以在主菜单中添加一个回调的命令项。在 Windows 上,这样的功能放在
Tools
菜单中,由于 Gnome 和 macOS 的指南似乎没有指示更合适的位置,我们将在LinxMainMenu
和MacOsMainMenu
类中实现一个Tools
菜单来保存这个命令,以保持一致。在mainmenu.py
中,从通用菜单类开始,添加一个新菜单如下:
#Tools menu
tools_menu = tk.Menu(self, tearoff=False)
tools_menu.add_command(
label="Update Weather Data",
command=self.callbacks['update_weather_data'])
self.add_cascade(label='Tools', menu=tools_menu)
- 将相同的菜单添加到 macOS 和 Linux 菜单类中,并将命令添加到 Windows 主菜单的
tools_menu
。更新菜单后,您可以运行应用程序并尝试从Tools
菜单中运行新命令。如果一切顺利,您应该在状态栏中看到如下截图所示的指示:
- 您还应该使用您的 PostgreSQL 客户端连接到数据库,并通过执行以下 SQL 命令来检查表中是否现在包含一些天气数据:
SELECT * FROM local_weather;
该 SQL 语句应返回类似以下的输出:
datetime |
temperature |
rel[hum] |
pressure |
conditions |
---|---|---|---|---|
2018-02-14 22:53:00-06 |
15.00 |
87.00 |
1014.00 |
Overcast |
使用 requests 进行 HTTP
您被要求在您的程序中创建一个函数,将每日数据的 CSV 提取上传到 ABQ 的企业 Web 服务,该服务使用经过身份验证的 REST API。虽然urllib
足够简单,用于简单的一次性GET
和POST
请求,但涉及身份验证令牌、文件上传或 REST 服务的复杂交互令人沮丧和复杂,仅使用urllib
就很困难。为了完成这项任务,我们将转向requests
库。
REST代表REpresentational State Transfer,是围绕高级 HTTP 语义构建的 Web 服务的名称。除了GET
和POST
,REST API 还使用额外的 HTTP 方法,如DELETE
,PUT
和PATCH
,以及 XML 或 JSON 等数据格式,以提供完整范围的 API 交互。
Python 社区强烈推荐第三方的requests
库,用于涉及 HTTP 的任何严肃工作(即使urllib
文档也推荐它)。正如您将看到的,requests
消除了urllib
中留下的许多粗糙边缘和过时假设,并为更现代的 HTTP 交易提供了方便的类和包装函数。requests
的完整文档可以在docs.python-requests.org
找到,但下一节将涵盖您有效使用它所需的大部分内容。
安装和使用 requests
requests
包是用纯 Python 编写的,因此使用pip
安装它不需要编译或二进制下载。只需在终端中输入pip install --user requests
,它就会被添加到您的系统中。
打开您的 Python shell,让我们进行如下请求:
>>> 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
请求发送数据如下:
>>> response = requests.post(
'http://duckduckgo.com',
data={'q': 'tkinter', 'ko': '-2', 'kz': '-1'})
请注意,与urlopen()
不同的是,我们可以直接使用 Python 字典作为data
参数;requests
会将其转换为适当的字节对象。
与请求函数一起使用的一些常见参数如下:
参数 | 目的 |
---|---|
params |
类似于data ,但添加到查询字符串而不是有效负载 |
json |
要包含在有效负载中的 JSON 数据 |
headers |
用于请求的头数据字典 |
files |
一个{fieldnames: file objects} 字典,作为多部分表单数据请求发送 |
auth |
用于基本 HTTP 摘要身份验证的用户名和密码元组 |
requests.session()函数
Web 服务,特别是私人拥有的服务,通常是受密码保护的。有时,这是使用较旧的 HTTP 摘要身份验证系统完成的,我们可以使用请求函数的auth
参数来处理这个问题。不过,如今更常见的是,身份验证涉及将凭据发布到 REST 端点以获取会话 cookie 或认证令牌,用于验证后续请求。
端点简单地是与 API 公开的数据或功能对应的 URL。数据被发送到端点或从端点检索。
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
response = s.get('http://example.com/protected_content')
# Our token cookie would be listed here
print(s.cookies.items())
这样的令牌和 cookie 处理是在后台进行的,我们不需要采取任何明确的操作。Cookie 存储在CookieJar
对象中,存储为我们的Session
对象的cookies
属性。
我们还可以在Session
对象上设置值,这些值将在请求之间持续存在,就像这个例子中一样:
s.headers['User-Agent'] = 'Mozilla'
# will be sent with a user-agent string of "Mozilla"
s.get('http://example.com')
在这个例子中,我们将用户代理字符串设置为Mozilla
,这将用于从这个Session
对象发出的所有请求。我们还可以使用params
属性设置默认的 URL 参数,或者使用hooks
属性设置回调函数。
响应对象
从这些请求函数返回的响应对象与urlopen()
返回的对象不同;它们包含相同的数据,但以稍微不同(通常更方便)的形式返回。
例如,响应头已经被转换成 Python 的dict
,如下所示:
>>> r = requests.get('http://www.alandmoore.com')
>>> r.headers
{'Date': 'Thu, 15 Feb 2018 21:13:42 GMT', 'Server': 'Apache',
'Last-Modified': 'Sat, 17 Jun 2017 14:13:49 GMT',
'ETag': '"20c003f-19f7-5945391d"', 'Content-Length': '6647',
'Keep-Alive': 'timeout=15, max=200', 'Connection': 'Keep-Alive',
'Content-Type': 'text/html'}
另一个区别是,requests
不会自动在 HTTP 错误时引发异常。但是,可以调用.raise_for_status()
响应方法来实现这一点。
例如,这个 URL 将返回一个 HTTP 404
错误,如下面的代码所示:
>>> 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.6/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 错误。
实现 API 上传
要开始实现我们的上传功能,我们需要弄清楚我们将要发送的请求的类型。我们已经从公司总部得到了一些关于如何与 REST API 交互的文档。
文档告诉我们以下内容:
-
首先,我们需要获取一个认证令牌。我们通过向
/auth
端点提交一个POST
请求来实现这一点。POST
请求的参数应包括username
和password
。 -
获得认证令牌后,我们需要提交我们的 CSV 文件。请求是一个发送到
/upload
端点的PUT
请求。文件作为多部分表单数据上传,指定在file
参数中。
我们已经知道足够的知识来使用requests
实现我们的 REST 上传功能,但在这之前,让我们创建一个服务,我们可以用来测试我们的代码。
创建一个测试 HTTP 服务
开发与外部服务互操作的代码可能会很令人沮丧。在编写和调试代码时,我们需要向服务发送大量错误或测试数据;我们不希望在生产服务中这样做,而且“测试模式”并不总是可用的。自动化测试可以使用Mock
对象来完全屏蔽网络请求,但在开发过程中,能够看到实际发送到 Web 服务的内容是很好的。
让我们实现一个非常简单的 HTTP 服务器,它将接受我们的请求并打印有关其接收到的信息。我们可以使用 Python 标准库的http.server
模块来实现这一点。
模块文档显示了一个基本 HTTP 服务器的示例:
from http.server import HTTPServer, BaseHTTPRequestHandler
def run(server_class=HTTPServer, handler_class=BaseHTTPRequestHandler):
server_address = ('', 8000)
httpd = server_class(server_address, handler_class)
httpd.serve_forever()
run()
服务器类HTTPServer
定义了一个对象,该对象在配置的地址和端口上监听 HTTP 请求。处理程序类BaseHTTPRequestHandler
定义了一个接收实际请求数据并返回响应数据的对象。我们将使用此代码作为起点,因此请将其保存在名为sample_http_server.py
的文件中,保存在ABQ_Data_Entry
目录之外。
如果您运行此代码,您将在本地计算机的端口8000
上运行一个 Web 服务;但是,如果您对此服务进行任何请求,无论是使用requests
、类似curl
的工具,还是只是一个 Web 浏览器,您都会发现它只返回一个 HTTP501
(不支持的方法
)错误。为了创建一个足够工作的服务器,就像我们的目标 API 用于测试目的一样,我们需要创建一个自己的处理程序类,该类可以响应必要的 HTTP 方法。
为此,我们将创建一个名为TestHandler
的自定义处理程序类,如下所示:
class TestHandler(BaseHTTPRequestHandler):
pass
def run(server_class=HTTPServer, handler_class=TestHandler):
...
我们的公司 API 使用POST
方法接收登录凭据,使用PUT
方法接收文件,因此这两种方法都需要工作。要使 HTTP 方法在请求处理程序中起作用,我们需要实现一个do_VERB
方法,其中VERB
是我们的 HTTP 方法名称的大写形式。
因此,对于PUT
和POST
,添加以下代码:
class TestHandler(BaseHTTPRequestHandler):
def do_POST(self, *args, **kwargs):
pass
def do_PUT(self, *args, **kwargs):
pass
仅仅这样还不能解决问题,因为这些方法需要导致我们的处理程序发送某种响应。对于我们的目的,我们不需要任何特定的响应;只要有一个状态为200
(OK
)的响应就可以了。
由于两种方法都需要这个,让我们添加一个第三种方法,我们可以从其他两种方法中调用如下:
def _send_200(self):
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
这是大多数 HTTP 客户端所需的最小响应:状态为200
,带有有效Content-type
的标头。这不会向客户端发送任何实际数据,但会告诉客户端其请求已被接收并成功处理。
我们在我们的方法中还想做的另一件事是打印出发送的任何数据,以便我们可以确保我们的客户端发送了正确的数据。
我们将实现以下方法来实现这一点:
def _print_request_data(self):
content_length = self.headers['Content-Length']
print("Content-length: {}".format(content_length))
data = self.rfile.read(int(content_length))
print(data.decode('utf-8'))
处理程序对象的headers
属性是一个包含请求标头的dict
对象,其中包括发送的字节数(content-length
)。除了打印该信息之外,我们还可以使用它来读取发送的数据。处理程序的rfile
属性是一个类似文件的对象,其中包含数据;其read()
方法需要一个长度参数来指定应该读取多少数据,因此我们使用我们提取的content-length
值。返回的数据是一个bytes
对象,因此我们将其解码为utf-8
。
现在我们有了这两种方法,让我们更新do_POST()
和do_PUT()
来调用它们,如下所示:
def do_POST(self, *args, **kwargs):
print('POST request received')
self._print_request_data()
self._send_200()
def do_PUT(self, *args, **kwargs):
print("PUT request received")
self._print_request_data()
self._send_200()
现在,每个方法都将打印出它接收到的POST
或PUT
的长度和数据,以及任何数据。在终端窗口中运行此脚本,以便您可以监视其输出。
现在,打开一个 shell,让我们测试它,如下所示:
>>> import requests
>>> requests.post('http://localhost:8000', data={1: 'test1', 2: 'test2'})
<Response[200]>
在 Web 服务器终端中,您应该看到以下输出:
POST request received
Content-length: 15
1=test1&2=test2
127.0.0.1 - - [15/Feb/2018 16:22:41] "POST / HTTP/1.1" 200 -
我们可以实现其他功能,比如实际检查凭据并返回身份验证令牌,但目前此服务器已足够帮助我们编写和测试客户端代码。
创建我们的网络功能
现在我们的测试服务已经启动,让我们开始编写与 REST API 交互的网络功能:
- 我们将首先在
network.py
中创建一个函数,该函数将接受 CSV 文件的路径、上传和身份验证 URL 以及用户名和密码:
import requests
...
def upload_to_corporate_rest(
filepath, upload_url, auth_url, username, password):
- 由于我们将不得不处理身份验证令牌,我们应该做的第一件事是创建一个会话。我们将其称为
session
,如下所示:
session = requests.session()
- 创建会话后,我们将用户名和密码发布到身份验证端点,如下所示:
response = session.post(
auth_url,
data={'username': username, 'password': password})
response.raise_for_status()
如果成功,session
对象将自动存储我们收到的令牌。如果出现问题,我们调用raise_for_status()
,这样函数将中止,调用代码可以处理网络或数据问题引发的任何异常。
- 假设我们没有引发异常,那么在这一点上我们必须经过身份验证,现在可以提交文件了。这将通过
put()
调用完成,如下所示:
files = {'file': open(filepath, 'rb')}
response = session.put(
upload_url,
files=files
)
发送文件,我们实际上必须打开它并将其作为文件句柄传递给put()
;请注意,我们以二进制读取模式(rb
)打开它。requests
文档建议这样做,因为它确保正确的content-length
值将被计算到头部中。
- 发送请求后,我们关闭文件并再次检查失败状态,然后结束函数,如下所示:
files['file'].close()
response.raise_for_status()
更新应用程序
在我们可以从Application
中调用新函数之前,我们需要实现一种方法来创建每日数据的 CSV 提取。这将被多个函数使用,因此我们将它与调用上传代码的函数分开实现。按照以下步骤进行:
- 首先,我们需要一个临时位置来存储我们生成的 CSV 文件。
tempfile
模块包括用于处理临时文件和目录的函数;我们将导入mkdtemp()
,它将为我们提供一个特定于平台的临时目录的名称。
from tempfile import mkdtemp
请注意,mdktemp()
实际上并不创建目录;它只是在平台首选的temp
文件位置中提供一个随机命名的目录的绝对路径。我们必须自己创建目录。
- 现在,让我们开始我们的新
Application
方法,如下所示:
def _create_csv_extract(self):
tmpfilepath = mkdtemp()
csvmodel = m.CSVModel(
filename=self.filename.get(), filepath=tmpfilepath)
创建临时目录名称后,我们创建了我们的CSVModel
类的一个实例;即使我们不再将数据存储在 CSV 文件中,我们仍然可以使用该模型导出 CSV 文件。我们传递了Application
对象的默认文件名,仍然设置为abq_data_record-CURRENTDATE.csv
,以及临时目录的路径作为filepath
。当然,我们的CSVModel
目前并不接受filepath
,但我们马上就会解决这个问题。
- 创建 CSV 模型后,我们将从数据库中提取我们的记录,如下所示:
records = self.data_model.get_all_records()
if not records:
return None
请记住,我们的SQLModel.get_all_records()
方法默认返回当天的所有记录的列表。如果我们碰巧没有当天的记录,最好立即停止并警告用户,而不是将空的 CSV 文件发送给公司,因此如果没有记录,我们从方法中返回None
。我们的调用代码可以测试None
返回值并显示适当的警告。
- 现在,我们只需要遍历记录并将每个记录保存到 CSV 中,然后返回
CSVModel
对象的文件名,如下所示:
for record in records:
csvmodel.save_record(record)
return csvmodel.filename
- 现在我们有了创建 CSV 提取文件的方法,我们可以编写回调方法,如下所示:
def upload_to_corporate_rest(self):
csvfile = self._create_csv_extract()
if csvfile is None:
messagebox.showwarning(
title='No records',
message='There are no records to upload'
)
return
首先,我们创建了一个 CSV 提取文件并检查它是否为None
。如果是,我们将显示错误消息并退出该方法。
- 在上传之前,我们需要从用户那里获取用户名和密码。幸运的是,我们有一个完美的类来做到这一点:
d = v.LoginDialog(
self,
'Login to ABQ Corporate REST API')
if d.result is not None:
username, password = d.result
else:
return
我们的登录对话框在这里为我们服务。与数据库登录不同,我们不会在无限循环中运行它;如果密码错误,用户可以重新运行命令。请记住,如果用户点击取消,result
将为None
,因此在这种情况下我们将退出回调方法。
- 现在,我们可以执行我们的网络函数,如下所示:
try:
n.upload_to_corporate_rest(
csvfile,
self.settings['abq_upload_url'].get(),
self.settings['abq_auth_url'].get(),
username,
password)
我们在try
块中执行upload_to_corporate_rest()
,因为它可能引发许多异常。我们从设置对象中传递上传和身份验证 URL;我们还没有添加这些,所以在完成之前需要这样做。
- 现在,让我们捕获一些异常,首先是
RequestException
。如果我们发送到 API 的数据出现问题,最有可能是用户名和密码错误,就会发生这种异常。我们将异常字符串附加到向用户显示的消息中,如下所示:
except n.requests.RequestException as e:
messagebox.showerror('Error with your request', str(e))
- 接下来我们将捕获
ConnectionError
;这个异常将是网络问题的结果,比如实验室的互联网连接断开,或者服务器没有响应:
except n.requests.ConnectionError as e:
messagebox.showerror('Error connecting', str(e))
- 任何其他异常都将显示为
General Exception
,如下所示:
except Exception as e:
messagebox.showerror('General Exception', str(e))
- 让我们用以下成功对话框结束这个方法:
else:
messagebox.showinfo(
'Success',
'{} successfully uploaded to REST API.'
.format(csvfile))
- 让我们通过将此方法添加到
callbacks
中来完成对Application
的更改:
self.callbacks = {
...
'upload_to_corporate_rest':
self.upload_to_corporate_rest,
...
更新 models.py 文件
在我们测试新功能之前,models.py
文件中有一些需要修复的地方。我们将按照以下步骤来解决这些问题:
- 首先,我们的
CSVModel
类需要能够接受filepath
:
def __init__(self, filename, filepath=None):
if filepath:
if not os.path.exists(filepath):
os.mkdir(filepath)
self.filename = os.path.join(filepath, filename)
else:
self.filename = filename
如果指定了filepath
,我们需要首先确保目录存在。由于在Application
类中调用的mkdtmp()
方法实际上并没有创建临时目录,我们将在这里创建它。完成后,我们将连接filepath
和filename
的值,并将其存储在CSVModel
对象的filename
属性中。
- 我们在
models.py
中需要做的另一件事是添加我们的新设置。滚动到SettingsModel
类,添加两个更多的variables
条目如下:
variables = {
...
'abq_auth_url': {
'type': 'str',
'value': 'http://localhost:8000/auth'},
'abq_upload_url': {
'type': 'str',
'value': 'http://localhost:8000/upload'},
...
我们不会构建一个 GUI 来设置这些设置,它们需要在用户的配置文件中手动创建,尽管在测试时,我们可以使用默认值。
收尾工作
最后要做的事情是将命令添加到我们的主菜单中。
在每个菜单类中为tools_menu
添加一个新条目:
tools_menu.add_command(
label="Upload CSV to corporate REST",
command=self.callbacks['upload_to_corporate_rest'])
现在,运行应用程序,让我们试试。为了使其工作,您至少需要有一个数据输入,并且需要启动sample_http_server.py
脚本。
如果一切顺利,您应该会得到一个像这样的对话框:
您的服务器还应该在终端上打印出类似这样的输出:
POST request received
Content-length: 27
username=test&password=test
127.0.0.1 - - [16/Feb/2018 10:17:22] "POST /auth HTTP/1.1" 200 -
PUT request received
Content-length: 397
--362eadeb828747769e75d5b4b6d32f31
Content-Disposition: form-data; name="file"; filename="abq_data_record_2018-02-16.csv"
Date,Time,Technician,Lab,Plot,Seed sample,Humidity,Light,Temperature,Equipment Fault,Plants,Blossoms,Fruit,Min Height,Max Height,Median Height,Notes
2018-02-16,8:00,Q Murphy,A,1,AXM477,10.00,10.00,10.00,,1,2,3,1.00,3.00,2.00,"
"
--362eadeb828747769e75d5b4b6d32f31--
127.0.0.1 - - [16/Feb/2018 10:17:22] "PUT /upload HTTP/1.1" 200 -
注意POST
和PUT
请求,以及PUT
有效负载中的 CSV 文件的原始文本。我们已成功满足了此功能的 API 要求。
使用 ftplib 的 FTP
虽然 HTTP 和 REST API 是客户端-服务器交互的当前趋势,但企业依赖于旧的、经过时间考验的,有时是过时的技术来实现数据传输并不罕见。ABQ 也不例外:除了 REST 上传,您还需要实现对依赖于 FTP 的 ABQ 公司的遗留系统的支持。
FTP 的基本概念
文件传输协议,或FTP,可以追溯到 20 世纪 70 年代初,比 HTTP 早了近 20 年。尽管如此,它仍然被许多组织广泛用于在互联网上交换大文件。由于 FTP 以明文形式传输数据和凭据,因此在许多领域被认为有些过时,尽管也有 SSL 加密的 FTP 变体可用。
与 HTTP 一样,FTP 客户端发送包含纯文本命令的请求,类似于 HTTP 方法,FTP 服务器返回包含头部和有效负载信息的响应数据包。
然而,这两种协议之间存在许多重大的区别:
-
FTP 是有状态连接,这意味着客户端和服务器在会话期间保持恒定的连接。换句话说,FTP 更像是一个实时电话,而 HTTP 则像是两个人在语音信箱中对话。
-
在发送任何其他命令或数据之前,FTP 需要对会话进行身份验证,即使对于匿名用户也是如此。FTP 服务器还实现了更复杂的权限集。
-
FTP 有用于传输文本和二进制数据的不同模式(主要区别在于文本模式会自动纠正行尾和接收操作系统的编码)。
-
FTP 服务器在其命令的实现上不够一致。
创建一个测试 FTP 服务
在实现 FTP 上传功能之前,有一个测试 FTP 服务是有帮助的,就像我们测试 HTTP 服务一样。当然,您可以下载许多免费的 FTP 服务器,如 FileZilla、PureFTPD、ProFTPD 或其他。
不要为了测试应用程序的一个功能而在系统上安装、配置和后来删除 FTP 服务,我们可以在 Python 中构建一个基本的服务器。第三方的pyftpdlib
包为我们提供了一个简单的实现快速脏 FTP 服务器的方法,足以满足测试需求。
使用pip
安装pyftpdlib
:
pip install --user pyftpdlib
就像我们简单的 HTTP 服务器一样,FTP 服务由服务器对象和处理程序对象组成。它还需要一个授权者对象来处理身份验证和权限。
我们将从导入这些开始我们的basic_ftp_server.py
文件:
from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer
为了确保我们的身份验证代码正常工作,让我们用一个测试用户设置我们的DummyAuthorizer
类:
auth = DummyAuthorizer()
auth.add_user('test', 'test', '.', perm='elrw')
perm
参数接受一个字符的字符串,每个字符代表服务器上的特定权限。在这种情况下,我们有e
(连接)、l
(列出)、r
(读取)和w
(写入新文件)。还有许多其他权限可用,默认情况下都是关闭的,直到授予,但这对我们的需求已经足够了。
现在,让我们设置处理程序:
handler = FTPHandler
handler.authorizer = auth
请注意,我们没有实例化处理程序,只是给类取了别名。服务器类将管理处理程序类的创建。但是,我们可以将我们的auth
对象分配为处理程序的authorizer
类,以便任何创建的处理程序都将使用我们的授权者。
最后,让我们设置并运行服务器部分:
address = ('127.0.0.1', 2100)
server = FTPServer(address, handler)
server.serve_forever()
这只是简单地用地址元组和处理程序类实例化一个FTPServer
对象,然后调用对象的server_forever()
方法。地址元组的形式是(ip_address,port)
,所以('127.0.0.1',2100)
的元组意味着我们将在计算机的回环地址上的端口2100
上提供服务。FTP 的默认端口通常是 21,但在大多数操作系统上,启动监听在1024
以下端口的服务需要 root 或系统管理员权限。为了简单起见,我们将使用一个更高的端口。
虽然可以使用pyftpdlib
构建生产质量的 FTP 服务器,但我们在这里没有这样做。这个脚本对于测试是足够的,但如果您重视安全性,请不要在生产中使用它。
实现 FTP 上传功能
现在测试服务器已经启动,让我们构建我们的 FTP 上传功能和 GUI 的逻辑。虽然标准库中没有包含 FTP 服务器库,但它包含了ftplib
模块形式的 FTP 客户端库。
首先在我们的network.py
文件中导入ftplib
:
import ftplib as ftp
可以使用ftplib.FTP
类创建一个 FTP 会话。因为这是一个有状态的会话,在完成后需要关闭;为了确保我们这样做,FTP
可以用作上下文管理器。
让我们从连接到 FTP 服务器开始我们的函数:
def upload_to_corporate_ftp(
filepath, ftp_host,
ftp_port, ftp_user, ftp_pass):
with ftp.FTP() as ftp_cx:
ftp_cx.connect(ftp_host, ftp_port)
ftp_cx.login(ftp_user, ftp_pass)
upload_to_corporate()
函数接受 CSV 文件路径和FTP
主机、端口、用户和密码,就像我们的upload_to_corporate_rest()
函数一样。我们首先创建我们的FTP
对象,然后调用FTP.connect()
和FTP.login
。
接下来,connect()
接受我们要交谈的主机和端口,并与服务器开始会话。在这一点上,我们还没有经过身份验证,但我们确实建立了连接。
然后,login()
接受用户名和密码,并尝试验证我们的会话。如果我们的凭据检查通过,我们就登录到服务器上,并可以开始发送更多的命令;如果不通过,就会引发error_perm
异常。但是,我们的会话仍然是活动的,直到我们关闭它,并且如果需要,我们可以发送额外的登录尝试。
要实际上传文件,我们使用storbinary()
方法:
filename = path.basename(filepath)
with open(filepath, 'rb') as fh:
ftp_cx.storbinary('STOR {}'.format(filename), fh)
要发送文件,我们必须以二进制读取模式打开它,然后调用storbinary
(是的,“stor”,而不是“store”—20 世纪 70 年代的程序员对删除单词中的字母有一种偏好)。
storbinary
的第一个参数是一个有效的 FTPSTOR
命令,通常是STOR filename
,其中“filename”是您希望在服务器上称为上传数据的名称。必须包含实际的命令字符串似乎有点违反直觉;据推测,这必须是指定的,以防服务器使用稍有不同的命令或语法。
第二个参数是文件对象本身。由于我们将其作为二进制数据发送,因此应该以二进制模式打开它。这可能看起来有点奇怪,因为我们发送的 CSV 文件本质上是一个纯文本文件,但将其作为二进制数据发送可以保证服务器在传输过程中不会以任何方式更改文件;这几乎总是在传输文件时所希望的,无论所交换数据的性质如何。
这就是我们的网络功能需要为 FTP 上传完成的所有工作。尽管我们的程序只需要storbinary()
方法,但值得注意的是,如果您发现自己不得不使用 FTP 服务器,还有一些其他常见的ftp
方法。
列出文件
在 FTP 服务器上列出文件有三种方法。mlsd()
方法调用MLSD
命令,通常是可用的最佳和最完整的输出。它可以接受一个可选的path
参数,指定要列出的路径(否则它将列出当前目录),以及一个facts
列表,例如“size”、“type”或“perm”,反映了您希望与文件名一起包括的数据。 mlsd()
命令返回一个生成器对象,可以迭代或转换为另一种序列类型。
MLSD
是一个较新的命令,不一定总是可用,因此还有另外两种可用的方法,nlst()
和dir()
,它们对应于较旧的NLST
和DIR
命令。这两种方法都接受任意数量的参数,这些参数将被原样附加到发送到服务器的命令字符串。
检索文件
从 FTP 服务器下载文件涉及retrbinary()
或retrlines()
方法中的一个,具体取决于我们是否希望使用二进制或文本模式(如前所述,您可能应该始终使用二进制)。与storbinary
一样,每种方法都需要一个命令字符串作为其第一个参数,但在这种情况下,它应该是一个有效的RETR
命令(通常“RETR filename”就足够了)。
第二个参数是一个回调函数,它将在每一行(对于retrlines()
)或每个块(对于retrbinary()
)上调用。此回调可用于存储已下载的数据。
例如,看一下以下代码:
from ftplib import FTP
from os.path import join
filename = 'raytux.jpg'
path = '/pub/ibiblio/logos/penguins'
destination = open(filename, 'wb')
with FTP('ftp.nluug.nl', 'anonymous') as ftp:
ftp.retrbinary(
'RETR {}'.format(join(path, filename)),
destination.write)
destination.close()
每个函数的返回值都是一个包含有关下载的一些统计信息的结果字符串,如下所示:
'226-File successfully transferred\n226 0.000 seconds (measured here), 146.96 Mbytes per second'
删除或重命名文件
使用ftplib
删除和重命名文件相对简单。 delete()
方法只需要一个文件名,并尝试删除服务器上给定的文件。rename()
方法只需要一个源和目标,并尝试将源重命名为目标名称。
自然地,任何一种方法的成功都取决于登录帐户被授予的权限。
将 FTP 上传添加到 GUI
我们的 FTP 上传功能已经准备就绪,所以让我们将必要的部分添加到我们应用程序的其余部分,使其一起运行。
首先,我们将在models.py
中的SettingsModel
中添加 FTP 主机和端口:
variables = {
...
'abq_ftp_host': {'type': 'str', 'value': 'localhost'},
'abq_ftp_port': {'type': 'int', 'value': 2100}
...
请记住,我们的测试 FTP 使用端口2100
,而不是通常的端口21
,所以现在我们将2100
作为默认值。
现在,我们将转到application.py
并创建回调方法,该方法将创建 CSV 文件并将其传递给 FTP 上传功能。
在Application
对象中创建一个新方法:
def upload_to_corporate_ftp(self):
csvfile = self._create_csv_extract()
我们要做的第一件事是使用我们为REST
上传创建的方法创建我们的 CSV 文件。
接下来,我们将要求用户输入 FTP 用户名和密码:
d = v.LoginDialog(
self,
'Login to ABQ Corporate FTP')
现在,我们将调用我们的网络功能:
if d.result is not None:
username, password = d.result
try:
n.upload_to_corporate_ftp(
csvfile,
self.settings['abq_ftp_host'].get(),
self.settings['abq_ftp_port'].get(),
username,
password)
我们在try
块中调用 FTP 上传函数,因为我们的 FTP 过程可能会引发多个异常。
与其逐个捕获它们,我们可以捕获ftplib.all_errors
:
except n.ftp.all_errors as e:
messagebox.showerror('Error connecting to ftp', str(e))
请注意,ftplib.all_errors
是ftplib
中定义的所有异常的基类,其中包括认证错误、权限错误和连接错误等。
结束这个方法时,我们将显示一个成功的消息:
else:
messagebox.showinfo(
'Success',
'{} successfully uploaded to FTP'.format(csvfile))
写好回调方法后,我们需要将其添加到callbacks
字典中:
self.callbacks = {
...
'upload_to_corporate_ftp': self.upload_to_corporate_ftp
}
我们需要做的最后一件事是将我们的回调添加到主菜单类中。
在mainmenu.py
中,为每个类的tools_menu
添加一个新的命令:
tools_menu.add_command(
label="Upload CSV to corporate FTP",
command=self.callbacks['upload_to_corporate_ftp'])
在终端中启动示例 FTP 服务器,然后运行你的应用程序并尝试 FTP 上传。记得输入test
作为用户名和密码!
你应该会看到一个成功的对话框,类似这样:
同样,在你运行示例 FTP 服务器的目录中应该有一个新的 CSV 文件。
FTP 服务器应该已经打印出了一些类似这样的信息:
127.0.0.1:32878-[] FTP session opened (connect)
127.0.0.1:32878-[test] USER 'test' logged in.
127.0.0.1:32878-[test] STOR /home/alanm/FTPserver/abq_data_record_2018-02-17.csv completed=1 bytes=235 seconds=0.001
127.0.0.1:32878-[test] FTP session closed (disconnect).
看起来我们的 FTP 上传效果很棒!
总结
在本章中,我们使用 HTTP 和 FTP 与云进行了交互。你学会了如何使用urllib
下载数据并使用ElementTree
解析 XML。你还了解了requests
库,并学会了与 REST API 进行交互的基础知识。最后,我们学会了如何使用 Python 的ftplib
下载和上传文件到 FTP。
第十二章:使用 Canvas 小部件可视化数据
在数据库中记录了数月的实验数据后,现在是开始可视化和解释数据的过程。你的同事分析师们询问程序本身是否可以创建图形数据可视化,而不是将数据导出到电子表格中创建图表和图形。为了实现这一功能,你需要了解 Tkinter 的Canvas
小部件。
在本章中,你将学习以下主题:
-
使用 Canvas 小部件进行绘图和动画
-
使用 Canvas 构建简单的折线图
-
使用 Matplotlib 集成更高级的图表和图表
使用 Tkinter 的 Canvas 进行绘图和动画
Canvas
小部件无疑是 Tkinter 中最强大的小部件。它可以用于构建从自定义小部件和视图到完整用户界面的任何内容。顾名思义,Canvas
是一个可以绘制图形和图像的空白区域。
可以像创建其他小部件一样创建Canvas
对象:
root = tk.Tk()
canvas = tk.Canvas(root, width=1024, height=768)
canvas.pack()
Canvas
接受通常的小部件配置参数,以及用于设置其大小的width
和height
。创建后,我们可以使用其许多create_()
方法开始向canvas
添加项目。
例如,我们可以使用以下代码添加一个矩形:
canvas.create_rectangle(100, 100, 200, 200, fill='orange')
前四个参数是左上角和右下角的坐标,以像素为单位,从画布的左上角开始。每个create_()
方法都是以定义形状的坐标开始的。fill
选项指定了对象内部的颜色。
坐标也可以指定为元组对,如下所示:
canvas.create_rectangle((600, 100), (700, 200), fill='#FF8800')
尽管这是更多的字符,但它显着提高了可读性。还要注意,就像 Tkinter 中的其他颜色一样,我们可以使用名称或十六进制代码。
我们还可以创建椭圆,如下所示:
canvas.create_oval((350, 250), (450, 350), fill='blue')
椭圆和矩形一样,需要其边界框的左上角和右下角的坐标。边界框是包含项目的最小矩形,因此在这个椭圆的情况下,你可以想象一个圆在一个角坐标为(350, 250)
和(450, 350)
的正方形内。
我们可以使用create_line()
创建线,如下所示:
canvas.create_line((100, 400), (400, 500),
(700, 400), (100, 400), width=5, fill='red')
行可以由任意数量的点组成,Tkinter 将连接这些点。我们已经指定了线的宽度以及颜色(使用fill
参数)。额外的参数可以控制角和端点的形状,线两端箭头的存在和样式,线条是否虚线,以及线条是直线还是曲线。
类似地,我们可以创建多边形,如下所示:
canvas.create_polygon((400, 150), (350, 300), (450, 300),
fill='blue', smooth=True)
这与创建线条类似,只是 Tkinter 将最后一个点连接回第一个点,并填充内部。将smooth
设置为True
会使用贝塞尔曲线使角变圆。
除了简单的形状之外,我们还可以按照以下方式在canvas
对象上放置文本或图像:
canvas.create_text((400, 600), text='Smile!',
fill='cyan', font='TkDefaultFont 64')
smiley = tk.PhotoImage(file='smile.gif')
image_item = canvas.create_image((400, 300), image=smiley)
任何create_()
方法的返回值都是一个字符串,它在Canvas
对象的上下文中唯一标识该项。我们可以使用该标识字符串在创建后对该项进行操作。
例如,我们可以这样绑定事件:
canvas.tag_bind(image_item, '<Button-1>', lambda e: canvas.delete(image_item))
在这里,我们使用tag_bind
方法将鼠标左键单击我们的图像对象绑定到画布的delete()
方法,该方法(给定一个项目标识符)会删除该项目。
为 Canvas 对象添加动画
Tkinter 的Canvas
小部件没有内置的动画框架,但我们仍然可以通过将其move()
方法与对事件队列的理解相结合来创建简单的动画。
为了演示这一点,我们将创建一个虫子赛跑模拟器,其中两只虫子(用彩色圆圈表示)将杂乱地向屏幕的另一侧的终点线赛跑。就像真正的虫子一样,它们不会意识到自己在比赛,会随机移动,赢家是哪只虫子碰巧先到达终点线。
首先,打开一个新的 Python 文件,并从以下基本样板开始:
import tkinter as tk
class App(tk.Tk):
def __init__(self):
super().__init__()
App().mainloop()
创建我们的对象
让我们创建用于游戏的对象:
- 在
App.__init__()
中,我们将简单地创建我们的canvas
对象,并使用pack()
添加它:
self.canvas = tk.Canvas(self, background='black')
self.canvas.pack(fill='both', expand=1)
- 接下来,我们将创建一个
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
是一个内置的位图,交替黑色和透明像素。
- 在
__init__()
的末尾添加一个对setup()
的调用如下:
self.after(200, self.setup)
因为setup()
依赖于canvas
对象的width
和height
值,我们需要确保在操作系统的窗口管理器绘制和调整窗口大小之前不调用它。最简单的方法是将调用延迟几百毫秒。
- 接下来,我们需要创建我们的玩家。让我们创建一个类来表示他们如下:
class Racer:
def __init__(self, canvas, color):
self.canvas = canvas
self.name = "{} player".format(color.title())
size = 50
self.id = canvas.create_oval(
(canvas.left, canvas.center_y),
(canvas.left + size, canvas.center_y + size),
fill=color)
Racer
类将使用对canvas
的引用和一个color
字符串创建,并从中派生其颜色和名称。我们将最初在屏幕的中间左侧绘制赛车,并使其大小为50
像素。最后,我们将其项目 ID 字符串的引用保存在self.id
中。
- 现在,在
App.setup()
中,我们将通过执行以下代码创建两个赛车:
self.racers = [
Racer(self.canvas, 'red'),
Racer(self.canvas, 'green')]
- 到目前为止,我们游戏中的所有对象都已设置好。运行程序,你应该能看到右侧的黄色点线终点线和左侧的绿色圆圈(红色圆圈将被隐藏在绿色下面)。
动画赛车
为了使我们的赛车动画化,我们将使用Canvas.move()
方法。move()
接受一个项目 ID,一定数量的x
像素和一定数量的y
像素,并将项目移动该数量。通过使用random.randint()
和一些简单的逻辑,我们可以生成一系列移动,将每个赛车发送到一条蜿蜒的路径朝着终点线。
一个简单的实现可能如下所示:
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)
然而,这并不是我们真正想要的;问题在于move()
是瞬间发生的,导致错误跳跃到屏幕的另一侧;我们希望我们的移动在一段时间内平稳进行。
为了实现这一点,我们将采取以下方法:
-
计算一系列线性移动,每个移动都有一个随机的增量
x
,增量y
和时间
,可以到达终点线 -
将每个移动分解为由时间分成的一定间隔的步骤
-
将每个移动的每一步添加到队列中
-
在我们的常规间隔中,从队列中提取下一步并传递给
move()
让我们首先定义我们的帧间隔并创建我们的动画队列:
from queue import Queue
...
class Racer:
FRAME_RES = 50
def __init__(...):
...
self.animation_queue = Queue()
FRAME_RES
(帧分辨率的缩写)定义了每个Canvas.move()
调用之间的毫秒数。50
毫秒给我们 20 帧每秒,应该足够平滑移动。
现在创建一个方法来绘制到终点线的路径:
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
time = randint(500, 2000)
self.queue_move(dx, dy, time)
total_dx += dx
total_dy += dy
这个方法通过生成随机的x
和y
移动,从canvas
的左中心绘制一条到右侧的路径,直到总x
大于canvas
对象的宽度。x
的变化总是正的,使我们的错误向着终点线移动,但y
的变化可以是正的也可以是负的。为了保持我们的错误在屏幕上,我们通过否定任何会使玩家超出画布顶部或底部边界的y
变化来限制总的y
移动。
除了dx
和dy
,我们还生成了移动所需的随机time
数量,介于半秒和两秒之间,并将生成的值发送到queue_move()
方法。
queue_move()
命令将需要将大移动分解为描述在一个FRAME_RES
间隔中应该发生多少移动的单个帧。为此,我们需要一个partition 函数:一个数学函数,将整数n
分解为大致相等的整数k
。例如,如果我们想将-10 分成四部分,我们的函数应返回一个类似于[-3, -3, -2, -2]的列表。
将partition()
创建为Racer
的静态方法:
@staticmethod
def partition(n, k):
"""Return a list of k integers that sum to n"""
if n == 0:
return [0] * k
我们从简单的情况开始:当n
为0
时,返回一个由k
个零组成的列表。
代码的其余部分如下所示:
base_step = int(n / k)
parts = [base_step] * k
for i in range(n % k):
parts[i] += n / abs(n)
return parts
首先,我们创建一个长度为k
的列表,由base_step
组成,即n
除以k
的整数部分。我们在这里使用int()
的转换而不是地板除法,因为它在负数时表现更合适。接下来,我们需要尽可能均匀地在列表中分配余数。为了实现这一点,我们在部分列表的前n % k
项中添加1
或-1
(取决于余数的符号)。
使用我们的例子n = -10
和k = 4
,按照这里的数学:
-
-10 / 4 = -2.5,截断为-2。
-
所以我们有一个列表:[-2, -2, -2, -2]。
-
-10 % 4 = 2,所以我们在列表的前两个项目中添加-1(即-10 / 10)。
-
我们得到了一个答案:[-3, -3, -2, -2]。完美!
现在我们可以编写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.animation_queue.put(step)
我们首先通过使用地板除法将时间除以FRAME_RES
来确定此移动中的步数。我们通过将dx
和dy
分别传递给我们的partition()
方法来创建x
移动列表和y
移动列表。这两个列表与zip
结合形成一个(dx, dy)
对的单个列表,然后添加到动画队列中。
为了使动画真正发生,我们将编写一个animate()
方法:
def animate(self):
if not self.animation_queue.empty():
nextmove = self.animation_queue.get()
self.canvas.move(self.id, *nextmove)
self.canvas.after(self.FRAME_RES, self.animate)
animate()
方法检查队列是否有移动。如果有,将调用canvas.move()
,并传递赛车的 ID 和需要进行的移动。最后,animate()
方法被安排在FRAME_RES
毫秒后再次运行。
动画赛车的最后一步是在__init__()
的末尾调用self.plot_course()
和self.animate()
。如果现在运行游戏,你的两个点应该从左到右在屏幕上漫游。但目前还没有人获胜!
检测和处理获胜条件
为了检测获胜条件,我们将定期检查赛车是否与终点线项目重叠。当其中一个重叠时,我们将宣布它为获胜者,并提供再玩一次的选项。
物品之间的碰撞检测在 Tkinter 的 Canvas 小部件中有些尴尬。我们必须将一组边界框坐标传递给find_overlapping()
,它会返回与边界框重叠的项目标识的元组。
让我们为我们的Racer
类创建一个overlapping()
方法:
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]
这个方法使用画布的bbox()
方法检索Racer
项目的边界框。然后使用find_overlapping()
获取与此边界框重叠的项目的元组。接下来,我们将过滤此元组,以删除Racer
项目的 ID,有效地返回与Racer
类重叠的项目列表。
回到我们的App()
方法,我们将创建一个check_for_winner()
方法:
def check_for_winner(self):
for racer in self.racers:
if self.finish_line in racer.overlapping():
self.declare_winner(racer)
return
self.after(Racer.FRAME_RES, self.check_for_winner)
这个方法迭代我们的赛车列表,并检查赛车的overlapping()
方法返回的列表中是否有finish_line
ID。如果有,racer
就到达了终点线,并将被宣布为获胜者。
如果没有宣布获胜者,我们将在Racer.FRAME_RES
毫秒后再次安排检查运行。
我们在declare_winner()
方法中处理获胜条件:
def declare_winner(self, racer):
wintext = self.canvas.create_text(
(self.canvas.center_x, self.canvas.center_y),
text='{} wins!\nClick to play again.'.format(racer.name),
fill='white',
font='TkDefaultFont 32',
activefill='violet')
self.canvas.tag_bind(wintext, '<Button-1>', self.reset)
在这个方法中,我们刚刚创建了一个text
项目,在canvas
的中心声明racer.name
为获胜者。activefill
参数使颜色在鼠标悬停在其上时变为紫色,向用户指示此文本是可点击的。
当点击该文本时,它调用reset()
方法:
def reset(self, *args):
for item in self.canvas.find_all():
self.canvas.delete(item)
self.setup()
reset()
方法需要清除画布,因此它使用find_all()
方法检索所有项目标识符的列表,然后对每个项目调用delete()
。最后,我们调用setup()
来重置游戏。
如您在下面的截图中所见,游戏现在已经完成:
虽然不是很简单,但 Tkinter 中的动画可以通过一些仔细的规划和一点数学来提供流畅和令人满意的结果。
不过,够玩游戏了;让我们回到实验室,看看如何使用 Tkinter 的Canvas
小部件来可视化数据。
在画布上创建简单的图表
我们想要生成的第一个图形是一个简单的折线图,显示我们植物随时间的生长情况。每个实验室的气候条件各不相同,我们想要看到这些条件如何影响所有植物的生长,因此图表将显示每个实验室的一条线,显示实验期间实验室中所有地块的中位高度测量的平均值。
我们将首先创建一个模型方法来返回原始数据,然后创建一个基于Canvas
的折线图视图,最后创建一个应用程序回调来获取数据并将其发送到图表视图。
创建模型方法
假设我们有一个 SQL 查询,通过从plot_checks
表中的最旧日期中减去其日期来确定地块检查的天数,然后在给定实验室和给定日期上拉取lab_id
和所有植物的median_height
的平均值。
我们将在一个名为get_growth_by_lab()
的新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 FROM plot_checks '
'GROUP BY date, lab_id ORDER BY day, lab_id;')
return self.query(query)
我们将得到一个数据表,看起来像这样:
Day | Lab ID | Average height |
---|---|---|
0 | A | 7.4198750000000000 |
0 | B | 7.3320000000000000 |
0 | C | 7.5377500000000000 |
0 | D | 8.4633750000000000 |
0 | E | 7.8530000000000000 |
1 | A | 6.7266250000000000 |
1 | B | 6.8503750000000000 |
我们将使用这些数据来构建我们的图表。
创建图形视图
转到views.py
,在那里我们将创建LineChartView
类:
class LineChartView(tk.Canvas):
margin = 20
def __init__(self, parent, chart_width, chart_height,
x_axis, y_axis, x_max, y_max):
self.max_x = max_x
self.max_y = max_y
self.chart_width = chart_width
self.chart_height = chart_height
LineChartView
是Canvas
的子类,因此我们将能够直接在其上绘制项目。我们将接受父小部件、图表部分的高度和宽度、x
和y
轴的标签作为参数,并显示x
和y
的最大值。我们将保存图表的尺寸和最大值以供以后使用,并将边距宽度设置为 20 像素的类属性。
让我们开始设置这个Canvas
:
view_width = chart_width + 2 * self.margin
view_height = chart_height + 2 * self.margin
super().__init__(
parent, width=view_width,
height=view_height, background='lightgrey')
通过将边距添加到两侧来计算视图的width
和height
值,然后使用它们调用超类__init__()
,同时将背景设置为lightgrey
。我们还将保存图表的width
和height
作为实例属性。
接下来,让我们绘制轴:
self.origin = (self.margin, view_height - self.margin)
self.create_line(
self.origin, (self.margin, self.margin), width=2)
self.create_line(
self.origin,
(view_width - self.margin,
view_height - self.margin))
我们的图表原点将距离左下角self.margin
像素,并且我们将绘制x
和y
轴,作为简单的黑色线条从原点向左和向上延伸到图表的边缘。
接下来,我们将标记轴:
self.create_text(
(view_width // 2, view_height - self.margin),
text=x_axis, anchor='n')
# angle requires tkinter 8.6 -- macOS users take note!
self.create_text(
(self.margin, view_height // 2),
text=y_axis, angle=90, anchor='s')
在这里,我们创建了设置为x
和y
轴标签的text
项目。这里使用了一些新的参数:anchor
设置文本边界框的哪一侧与提供的坐标相连,angle
将文本对象旋转给定的角度。请注意,angle
是 Tkinter 8.6 的一个特性,因此对于 macOS 用户可能会有问题。另外,请注意,我们将旋转的文本的anchor
设置为 south;即使它被旋转,基本方向仍然指的是未旋转的边,因此 south 始终是文本的底部,就像正常打印的那样。
最后,我们需要创建一个包含实际图表的第二个Canvas
:
self.chart = tk.Canvas(
self, width=chart_width, height=chart_height,
background='white')
self.create_window(
self.origin, window=self.chart, anchor='sw')
虽然我们可以使用pack()
或grid()
等几何管理器在canvas
上放置小部件,但create_window()
方法将小部件作为Canvas
项目放置在Canvas
上,使用坐标。我们将图表的左下角锚定到我们图表的原点。
随着这些部分的就位,我们现在将创建一个在图表上绘制数据的方法:
def plot_line(self, data, color):
x_scale = self.chart_width / self.max_x
y_scale = self.chart_height / self.max_y
coords = [(round(x * x_scale),
self.chart_height - round(y * y_scale))
for x, y in data]
self.chart.create_line(*coords, width=2, fill=color)
在plot_line()
中,我们首先必须将原始数据转换为可以绘制的坐标。我们需要缩放我们的数据
点,使它们的范围从图表对象的高度和宽度为0
。我们的方法通过将图表尺寸除以x
和y
的最大值来计算x
和y
的比例(即每个单位x
或y
有多少像素)。然后我们可以通过使用列表推导将每个数据点乘以比例值来转换我们的数据。
此外,数据通常是以左下角为原点绘制的,但坐标是从左上角开始测量的,因此我们需要翻转y
坐标;这也是我们的列表推导中所做的,通过从图表高度中减去新的y
值来完成。现在可以将这些坐标传递给create_line()
,并与合理的宽度
和调用者传入的颜色
参数一起传递。
我们需要的最后一件事是一个图例,告诉用户图表上的每种颜色代表什么。没有图例,这个图表将毫无意义。
让我们创建一个draw_legend()
方法:
def draw_legend(self, mapping):
y = self.margin
x = round(self.margin * 1.5) + self.chart_width
for label, color in mapping.items():
self.create_text((x, y), text=label, fill=color,
anchor='w')
y += 20
我们的方法接受一个将标签映射到颜色的字典,这将由应用程序提供。对于每一个,我们只需绘制一个包含标签
文本和相关填充
颜色的文本项。由于我们知道我们的标签会很短(只有一个字符),我们可以只把它放在边缘。
更新应用程序
在Application
类中,创建一个新方法来显示我们的图表:
def show_growth_chart(self):
data = self.data_model.get_growth_by_lab()
max_x = max([x['day'] for x in data])
max_y = max([x['avg_height'] for x in data])
首要任务是从我们的get_growth_by_lab()
方法中获取数据,并计算x
和y
轴的最大值。我们通过使用列表推导将值提取到列表中,并在其上调用内置的max()
函数来完成这一点。
接下来,我们将构建一个小部件来容纳我们的LineChartView
对象:
popup = tk.Toplevel()
chart = v.LineChartView(popup, 600, 300, 'day',
'centimeters', max_x, max_y)
chart.pack(fill='both', expand=1)
在这种情况下,我们使用Toplevel
小部件,它在我们的主应用程序窗口之外创建一个新窗口。然后我们创建了LineChartView
,它是600
乘300
像素,带有x轴和y轴标签,并将其添加到Toplevel
中使用pack()
。
接下来,我们将为每个实验室分配颜色并绘制图例
:
legend = {'A': 'green', 'B': 'blue', 'C': 'cyan',
'D': 'yellow', 'E': 'purple'}
chart.draw_legend(legend)
最后要做的是绘制实际的线:
for lab, color in legend.items():
dataxy = [(x['day'], x['avg_height'])
for x in data
if x['lab_id'] == lab]
chart.plot_line(dataxy, color)
请记住,我们的数据包含所有实验室的值,因此我们正在图例
中迭代实验室,并使用列表推导来提取该实验室的数据。然后我们的plot_line()
方法完成其余工作。
完成此方法后,将其添加到callbacks
字典中,并为每个平台的工具菜单添加一个菜单项。
当您调用您的函数时,您应该看到类似这样的东西:
没有一些示例数据,图表看起来不会很好。除非您只是喜欢进行数据输入,否则在
sql
目录中有一个加载示例数据的脚本。
使用 Matplotlib 和 Tkinter 创建高级图表
我们的折线图很漂亮,但要使其完全功能,仍需要相当多的工作:它缺少比例、网格线和其他功能,这些功能将使它成为一个完全有用的图表。
我们可以花很多时间使它更完整,但在我们的 Tkinter 应用程序中获得更令人满意的图表和图形的更快方法是Matplotlib。
Matplotlib 是一个第三方库,用于生成各种类型的专业质量、交互式图表。这是一个庞大的库,有许多附加组件,我们不会涵盖其实际用法的大部分内容,但我们应该看一下如何将 Matplotlib 集成到 Tkinter 应用程序中。为此,我们将创建一个气泡图,显示每个地块的产量与湿度
和温度
的关系。
您应该能够使用pip install --user matplotlib
命令使用pip
安装matplotlib
。有关安装的完整说明,请参阅matplotlib.org/users/installing.html.
数据模型方法
在我们制作图表之前,我们需要一个SQLModel
方法来提取数据:
def get_yield_by_plot(self):
query = (
'SELECT lab_id, plot, 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)
此图表的目的是找到每个种子样本的温度
和湿度
的最佳点。因此,我们需要每个plot
的一行,其中包括最大的fruit
测量值,plot
列处的平均湿度和温度,以及seed_sample
。由于我们不想要任何错误的数据,我们将过滤掉具有Equipment
Fault
的行。
创建气泡图表视图
要将 MatplotLib 集成到 Tkinter 应用程序中,我们需要进行几次导入。
第一个是matplotlib
本身:
import matplotlib
matplotlib.use('TkAgg')
在“导入”部分运行代码可能看起来很奇怪,甚至您的编辑器可能会对此进行投诉。但在我们从matplotlib
导入任何其他内容之前,我们需要告诉它应该使用哪个后端。在这种情况下,我们想要使用TkAgg
后端,这是专为集成到 Tkinter 中而设计的。
现在我们可以从matplotlib
中再引入一些内容:
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import (
FigureCanvasTkAgg, NavigationToolbar2TkAgg)
Figure
类表示matplotlib
图表可以绘制的基本绘图区域。FigureCanvasTkAgg
类是Figure
和 TkinterCanvas
之间的接口,NavigationToolbar2TkAgg
允许我们在图表上放置一个预制的Figure
工具栏。
为了看看这些如何配合,让我们在views.py
中启动我们的YieldChartView
类:
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 = FigureCanvasTkAgg(self.figure, master=self)
在调用super().__init__()
创建Frame
对象之后,我们创建一个Figure
对象来保存我们的图表。Figure
对象不是以像素为单位的大小,而是以英寸和每英寸点数(dpi)设置为单位(在这种情况下,得到的是一个 600x400 像素的Figure
)。接下来,我们创建一个FigureCanvasTkAgg
对象,将我们的Figure
对象与 TkinterCanvas
连接起来。FigureCanvasTkAgg
对象本身不是Canvas
对象或子类,但它有一个Canvas
对象,我们可以将其放置在我们的应用程序中。
接下来,我们将工具栏和pack()
添加到我们的FigureCanvasTkAgg
对象中:
self.toolbar = NavigationToolbar2TkAgg(self.canvas, self)
self.canvas.get_tk_widget().pack(fill='both', expand=True)
我们的工具栏被传递给了我们的FigureCanvasTkAgg
对象和根窗口(在这种情况下是self
),将其附加到我们的图表和它的画布上。要将FigureCanvasTkAgg
对象放在我们的Frame
对象上,我们需要调用get_tk_widget()
来检索其 TkinterCanvas
小部件,然后我们可以使用pack()
和grid()
按需要对其进行打包或网格化。
下一步是设置轴:
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
对象表示可以在其上绘制数据的单个x
和y
轴集,使用add_subplot()
方法创建。传递给add_subplot()
的三个整数建立了这是一个子图中一行中的第一个axes
集。我们的图表可能包含多个以表格形式排列的子图,但我们只需要一个。创建后,我们设置axes
对象上的标签。
要创建气泡图表,我们将使用 Matplotlib 的散点图功能,但使用每个点的大小来指示水果产量。我们还将对点进行颜色编码以指示种子样本。
让我们实现一个绘制散点图的方法:
def draw_scatter(self, data, color, label):
x, y, s = zip(*data)
s = [(x ** 2)//2 for x in s]
scatter = self.axes.scatter(
x, y, s, c=color, label=label, alpha=0.5)
传入的数据应该包含每条记录的三列,并且我们将这些分解为包含x
、y
和size
值的三个单独的列表。接下来,我们将放大大小值之间的差异,使它们更加明显,方法是将每个值平方然后除以一半。这并不是绝对必要的,但在差异相对较小时,它有助于使图表更易读。
最后,我们通过调用scatter()
将数据绘制到axes
对象上,同时传递color
和label
值给点,并使用alpha
参数使它们半透明。
zip(*data)
是一个 Python 习语,用于将 n 长度元组的列表分解为值的 n 个列表,本质上是zip(x, y, s)
的反向操作。
为了为我们的axes
对象绘制图例,我们需要两样东西:我们的scatter
对象的列表和它们的标签列表。为了获得这些,我们将不得不在__init__()
中创建一些空列表,并在每次调用draw_scatter()
时进行追加。
在__init__()
中,添加一些空列表:
self.scatters = []
self.scatter_labels = []
现在,在draw_scatter()
的末尾,追加列表并更新legend()
方法:
self.scatters.append(scatter)
self.scatter_labels.append(label)
self.axes.legend(self.scatters, self.scatter_labels)
我们可以反复调用legend()
,它会简单地销毁并重新绘制图例。
应用程序方法
回到Application
,让我们创建一个显示产量数据的方法。
首先创建一个Toplevel
方法并添加我们的图表视图:
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'}
我们从数据模型中检索了产量data
,并创建了一个将保存我们想要为每个种子样本使用的颜色的字典。
现在我们只需要遍历种子样本并绘制散点图:
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_dots(seed_data, color, seed)
再次,我们使用列表推导式格式化和过滤我们的数据,为x
提供平均湿度,为y
提供平均温度,为s
提供产量。
将该方法添加到callbacks
字典中,并在生长图选项下方创建一个菜单项。
您的气泡图应该看起来像这样:
请利用导航工具栏玩一下这个图表,注意你可以缩放和平移,调整图表的大小,并保存图像。这些都是 Matplotlib 自动提供的强大工具。
总结
在本章中,您了解了 Tkinter 的图形能力。您学会了如何在 Tkinter 的Canvas
小部件上绘制和动画图形,以及如何利用这些能力来可视化数据。您还学会了如何将 Matplotlib 图形集成到您的应用程序中,并通过将 SQL 查询连接到我们的图表视图,在我们的应用程序中实现了两个图表。
第十三章:使用 Qt 组件创建用户界面
在本章中,我们将学习使用以下小部件:
-
显示欢迎消息
-
使用单选按钮小部件
-
分组单选按钮
-
以复选框形式显示选项
-
显示两组复选框
介绍
我们将学习使用 Qt 工具包创建 GUI 应用程序。Qt 工具包,简称 Qt,是由 Trolltech 开发的跨平台应用程序和 UI 框架,用于开发 GUI 应用程序。它可以在多个平台上运行,包括 Windows、macOS X、Linux 和其他 UNIX 平台。它也被称为小部件工具包,因为它提供了按钮、标签、文本框、推按钮和列表框等小部件,这些小部件是设计 GUI 所必需的。它包括一组跨平台的类、集成工具和跨平台 IDE。为了创建实时应用程序,我们将使用 Python 绑定的 Qt 工具包,称为 PyQt5。
PyQt
PyQt 是一个用于跨平台应用程序框架的 Python 绑定集合,结合了 Qt 和 Python 的所有优势。使用 PyQt,您可以在 Python 代码中包含 Qt 库,从而能够用 Python 编写 GUI 应用程序。换句话说,PyQt 允许您通过 Python 代码访问 Qt 提供的所有功能。由于 PyQt 依赖于 Qt 库来运行,因此在安装 PyQt 时,所需版本的 Qt 也会自动安装在您的计算机上。
GUI 应用程序可能包括一个带有多个对话框的主窗口,或者只包括一个对话框。一个小型 GUI 应用程序通常至少包括一个对话框。对话框应用程序包含按钮。它不包含菜单栏、工具栏、状态栏或中央小部件,而主窗口应用程序通常包括所有这些。
对话框有以下两种类型:
-
模态:这种对话框会阻止用户与应用程序的其他部分进行交互。对话框是用户可以与之交互的应用程序的唯一部分。在对话框关闭之前,无法访问应用程序的其他部分。
-
非模态:这种对话框与模态对话框相反。当非模态对话框处于活动状态时,用户可以自由地与对话框和应用程序的其他部分进行交互。
创建 GUI 应用程序的方式
有以下两种方式编写 GUI 应用程序:
-
使用简单文本编辑器从头开始
-
使用 Qt Designer,一个可视化设计工具,可以快速使用拖放功能创建用户界面
您将使用 Qt Designer 在 PyQt 中开发 GUI 应用程序,因为这是一种快速简便的设计用户界面的方法,无需编写一行代码。因此,双击桌面上的图标启动 Qt Designer。
打开时,Qt Designer 会要求您为新应用程序选择模板,如下截图所示:
Qt Designer 提供了适用于不同类型应用程序的多个模板。您可以选择其中任何一个模板,然后单击“创建”按钮。
Qt Designer 为新应用程序提供以下预定义模板:
-
带有底部按钮的对话框:此模板在右下角创建一个带有确定和取消按钮的表单。
-
带有右侧按钮的对话框:此模板在右上角创建一个带有确定和取消按钮的表单。
-
没有按钮的对话框:此模板创建一个空表单,您可以在其中放置小部件。对话框的超类是
QDialog
。 -
主窗口:此模板提供一个带有菜单栏和工具栏的主应用程序窗口,如果不需要可以删除。
-
小部件:此模板创建一个表单,其超类是
QWidget
而不是QDialog
。
每个 GUI 应用程序都有一个顶级小部件,其余的小部件称为其子级。顶级小部件可以是QDialog
、QWidget
或QMainWindow
,具体取决于您需要的模板。如果要基于对话框模板创建应用程序,则顶级小部件或您继承的第一个类将是QDialog
。类似地,要基于主窗口模板创建应用程序,顶级小部件将是QMainWindow
,要基于窗口小部件模板创建应用程序,您需要继承QWidget
类。如前所述,用于用户界面的其余小部件称为这些类的子小部件。
Qt Designer 在顶部显示菜单栏和工具栏。它在左侧显示一个包含各种小部件的窗口小部件框,用于开发应用程序,分组显示。您只需从表单中拖放您想要的小部件即可。您可以在布局中排列小部件,设置它们的外观,提供初始属性,并将它们的信号连接到插槽。
显示欢迎消息
在这个示例中,用户将被提示输入他/她的名字,然后点击一个按钮。点击按钮后,将出现一个欢迎消息,“你好”,后面跟着用户输入的名字。对于这个示例,我们需要使用三个小部件,标签、行编辑和按钮。让我们逐个了解这些小部件。
理解标签小部件
标签小部件是QLabel
类的一个实例,用于显示消息和图像。因为标签小部件只是显示计算结果,不接受任何输入,所以它们只是用于在屏幕上提供信息。
方法
以下是QLabel
类提供的方法:
-
setText()
: 该方法将文本分配给标签小部件 -
setPixmap()
: 该方法将pixmap
,QPixmap
类的一个实例,分配给标签小部件 -
setNum()
: 该方法将整数或双精度值分配给标签小部件 -
clear()
: 该方法清除标签小部件中的文本
QLabel
的默认文本是 TextLabel。也就是说,当您通过拖放标签小部件将QLabel
类添加到表单时,它将显示 TextLabel。除了使用setText()
,您还可以通过在属性编辑器窗口中设置其文本属性来为选定的QLabel
对象分配文本。
理解行编辑小部件
行编辑小部件通常用于输入单行数据。行编辑小部件是QLineEdit
类的一个实例,您不仅可以输入,还可以编辑数据。除了输入数据,您还可以在行编辑小部件中撤消、重做、剪切和粘贴数据。
方法
以下是QLineEdit
类提供的方法:
-
setEchoMode()
: 它设置行编辑小部件的回显模式。也就是说,它确定如何显示行编辑小部件的内容。可用选项如下: -
Normal
: 这是默认模式,它以输入的方式显示字符 -
NoEcho
: 它关闭了行编辑的回显,也就是说,它不显示任何内容 -
Password
: 该选项用于密码字段,不会显示文本;而是用户输入的文本将显示为星号 -
PasswordEchoOnEdit
: 在编辑密码字段时显示实际文本,否则将显示文本的星号 -
maxLength()
: 该方法用于指定可以在行编辑小部件中输入的文本的最大长度。 -
setText()
: 该方法用于为行编辑小部件分配文本。 -
text()
: 该方法访问在行编辑小部件中输入的文本。 -
clear()
: 该方法清除或删除行编辑小部件的全部内容。 -
setReadOnly()
:当将布尔值 true 传递给此方法时,它将使 LineEdit 小部件变为只读,即不可编辑。用户无法对通过 LineEdit 小部件显示的内容进行任何更改,但只能复制。 -
isReadOnly()
:如果 LineEdit 小部件处于只读模式,则此方法返回布尔值 true,否则返回 false。 -
setEnabled()
:默认情况下,LineEdit 小部件是启用的,即用户可以对其进行更改。但是,如果将布尔值 false 传递给此方法,它将禁用 LineEdit 小部件,因此用户无法编辑其内容,但只能通过setText()
方法分配文本。 -
setFocus()
:此方法将光标定位在指定的 LineEdit 小部件上。
了解 PushButton 小部件
要在应用程序中显示一个按钮,您需要创建一个QPushButton
类的实例。在为按钮分配文本时,您可以通过在文本中的任何字符前加上一个和字符来创建快捷键。例如,如果分配给按钮的文本是Click Me
,则字符C
将被下划线标记,表示它是一个快捷键,用户可以通过按Alt + C来选择按钮。按钮在激活时发出 clicked()信号。除了文本,图标也可以显示在按钮中。在按钮中显示文本和图标的方法如下:
-
setText()
:此方法用于为按钮分配文本 -
setIcon()
:此方法用于为按钮分配图标
如何做...
让我们基于没有按钮的对话框模板创建一个新应用程序。如前所述,此应用程序将提示用户输入姓名,并在输入姓名后单击按钮后,应用程序将显示一个 hello 消息以及输入的姓名。以下是创建此应用程序的步骤:
-
具有默认文本的另一个 Label 应该具有
labelResponse
的 objectName 属性 -
从显示小部件类别中拖动一个 Label 小部件,并将其放在表单上。不要更改此 Label 小部件的文本属性,并将其文本属性保留为其默认值 TextLabel。这是因为此 Label 小部件的文本属性将通过代码设置,即将用于向用户显示 hello 消息。
-
从输入小部件类别中拖动一个 LineEdit,并将其放在表单上。将其 objectName 属性设置为
lineEditName
。 -
从按钮类别中拖动一个 PushButton 小部件,并将其放在表单上。将其 text 属性设置为
Click
。您可以通过以下三种方式之一更改 PushButton 小部件的 text 属性:通过双击 PushButton 小部件并覆盖默认文本,通过右键单击 PushButton 小部件并从弹出的上下文菜单中选择更改文本...选项,或者通过从属性编辑器窗口中选择文本属性并覆盖默认文本。 -
将 PushButton 小部件的 objectName 属性设置为
ButtonClickMe
。 -
将应用程序保存为
demoLineEdit.ui
。现在,表单将显示如下截图所示:
您使用 Qt Designer 创建的用户界面存储在一个.ui
文件中,其中包括所有表单的信息:其小部件、布局等。.ui
文件是一个 XML 文件,您需要将其转换为 Python 代码。这样,您可以在视觉界面和代码中实现的行为之间保持清晰的分离。
- 要使用
.ui
文件,您首先需要将其转换为 Python 脚本。您将用于将.ui
文件转换为 Python 脚本的命令实用程序是pyuic5
。在 Windows 中,pyuic5
实用程序与 PyQt 捆绑在一起。要进行转换,您需要打开命令提示符窗口并导航到保存文件的文件夹,并发出以下命令:
C:\Pythonbook\PyQt5>pyuic5 demoLineEdit.ui -o demoLineEdit.py
假设我们将表单保存在此位置:C:\Pythonbook\PyQt5>
。上述命令显示了demoLineEdit.ui
文件转换为 Python 脚本demoLineEdit.py
的过程。
此方法生成的 Python 代码不应手动修改,因为任何更改都将在下次运行pyuic5
命令时被覆盖。
生成的 Python 脚本文件demoLineEdit.py
的代码可以在本书的源代码包中找到。
- 将
demoLineEdit.py
文件中的代码视为头文件,并将其导入到将调用其用户界面设计的文件中。
头文件是指那些被导入到当前文件中的文件。导入这些文件的命令通常写在脚本的顶部,因此被称为头文件。
- 让我们创建另一个名为
callLineEdit.py
的 Python 文件,并将demoLineEdit.py
的代码导入其中,如下所示:
import sys from PyQt5.QtWidgets import QDialog, QApplication
from demoLineEdit import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.ButtonClickMe.clicked.connect(self.dispmessage)
self.show()
def dispmessage(self):
self.ui.labelResponse.setText("Hello "
+self.ui.lineEditName.text())
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
demoLineEdit.py
文件非常容易理解。创建了一个名为顶级对象的类,前面加上Ui_
。由于我们应用程序中使用的顶级对象是Dialog
,因此创建了Ui_Dialog
类,并存储了我们小部件的界面元素。该类有两个方法,setupUi()
和retranslateUi()
。setupUi()
方法设置小部件;它创建了您在 Qt Designer 中定义用户界面时使用的小部件。该方法逐个创建小部件,并设置它们的属性。setupUi()
方法接受一个参数,即创建用户界面(子小部件)的顶级小部件。在我们的应用程序中,它是QDialog
的一个实例。retranslateUi()
方法翻译界面。
让我们逐条理解callLineEdit.py
的作用:
-
它导入了必要的模块。
QWidget
是 PyQt5 中所有用户界面对象的基类。 -
它创建了一个继承自基类
QDialog
的新MyForm
类。 -
它为
QDialog
提供了默认构造函数。默认构造函数没有父级,没有父级的小部件称为窗口。 -
PyQt5 中的事件处理使用信号和槽。信号是一个事件,槽是在发生信号时执行的方法。例如,当您单击一个按钮时,会发生一个
clicked()
事件,也称为信号。connect()
方法将信号与槽连接起来。在这种情况下,槽是一个方法:dispmessage()
。也就是说,当用户单击按钮时,将调用dispmessage()
方法。clicked()
在这里是一个事件,事件处理循环等待事件发生,然后将其分派以执行某些任务。事件处理循环会继续工作,直到调用exit()
方法或主窗口被销毁为止。 -
它通过
QApplication()
方法创建了一个名为app
的应用程序对象。每个 PyQt5 应用程序都必须创建sys.argv
应用程序对象,其中包含从命令行传递的参数列表,并在创建应用程序对象时传递给方法。sys.argv
参数有助于传递和控制脚本的启动属性。 -
使用
MyForm
类的一个实例被创建,名为w
。 -
show()
方法将在屏幕上显示小部件。 -
dispmessage()
方法执行按钮的事件处理。它显示 Hello 文本,以及在行编辑小部件中输入的名称。 -
sys.exit()
方法确保干净退出,释放内存资源。
exec_()
方法有一个下划线,因为exec
是 Python 关键字。
在执行上述程序时,您将获得一个带有行编辑和按钮小部件的窗口,如下截图所示。当选择按钮时,将执行dispmessage()
方法,显示 Hello 消息以及输入在行编辑小部件中的用户名:
使用单选按钮小部件
这个示例通过单选按钮显示特定的航班类型,当用户选择单选按钮时,将显示与该航班相关的价格。我们需要首先了解单选按钮的工作原理。
了解单选按钮
当您希望用户只能从可用选项中选择一个选项时,单选按钮小部件非常受欢迎。这些选项被称为互斥选项。当用户选择一个选项时,先前选择的选项将自动取消选择。单选按钮小部件是QRadioButton
类的实例。每个单选按钮都有一个关联的文本标签。单选按钮可以处于选定(已选中)或未选定(未选中)状态。如果您想要两个或更多组单选按钮,其中每组允许单选按钮的互斥选择,请将它们放入不同的按钮组(QButtonGroup
的实例)中。QRadioButton
提供的方法如下所示。
方法
QRadioButton
类提供以下方法:
-
isChecked()
: 如果按钮处于选定状态,则此方法返回布尔值 true。 -
setIcon()
: 此方法显示带有单选按钮的图标。 -
setText()
: 此方法为单选按钮分配文本。如果您想为单选按钮指定快捷键,请在文本中使用和号(&
)前置所选字符。快捷字符将被下划线标记。 -
setChecked()
: 要使任何单选按钮默认选定,将布尔值 true 传递给此方法。
信号描述
QRadioButton
发射的信号如下:
-
toggled(): 当按钮从选中状态变为未选中状态,或者反之时,将发射此信号
-
点击():当按钮被激活(即按下并释放)或者按下其快捷键时,将发射此信号
-
stateChanged(): 当单选按钮从选中状态变为未选中状态,或者反之时,将发射此信号
为了理解单选按钮的概念,让我们创建一个应用程序,询问用户选择航班类型,并通过单选按钮以头等舱
,商务舱
和经济舱
的形式显示三个选项。通过单选按钮选择一个选项后,将显示该航班的价格。
如何做...
让我们基于没有按钮的对话框模板创建一个新的应用程序。这个应用程序将显示不同的航班类型以及它们各自的价格。当用户选择一个航班类型时,它的价格将显示在屏幕上:
-
将两个标签小部件和三个单选按钮小部件拖放到表单上。
-
将第一个标签小部件的文本属性设置为
选择航班类型
,并删除第二个标签小部件的文本属性。第二个标签小部件的文本属性将通过代码设置;它将用于显示所选航班类型的价格。 -
将三个单选按钮小部件的文本属性设置为
头等舱 $150
,商务舱 $125
和经济舱 $100
。 -
将第二个标签小部件的 objectName 属性设置为
labelFare
。三个单选按钮的默认对象名称分别为radioButton
,radioButton_2
和radioButton_3
。将这三个单选按钮的 objectName 属性更改为radioButtonFirstClass
,radioButtonBusinessClass
和radioButtonEconomyClass
。 -
将应用程序保存为
demoRadioButton1.ui
。
看一下以下的屏幕截图:
demoRadioButton1.ui
应用程序是一个 XML 文件,需要通过pyuic5
命令实用程序转换为 Python 代码。本书的源代码包中可以看到生成的 Python 代码demoRadioButton1.py
。
-
将
demoRadioButton1.py
文件作为头文件导入到您即将创建的 Python 脚本中,以调用用户界面设计。 -
在 Python 脚本中,编写代码根据用户选择的单选按钮显示飞行类型。将源文件命名为
callRadioButton1.py
;其代码如下所示:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoRadioButton1 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.radioButtonFirstClass.toggled.connect(self.
dispFare)
self.ui.radioButtonBusinessClass.toggled.connect(self.
dispFare)
self.ui.radioButtonEconomyClass.toggled.connect(self.
dispFare)
self.show()
def dispFare(self):
fare=0
if self.ui.radioButtonFirstClass.isChecked()==True:
fare=150
if self.ui.radioButtonBusinessClass.isChecked()==True:
fare=125
if self.ui.radioButtonEconomyClass.isChecked()==True:
fare=100
self.ui.labelFare.setText("Air Fare is "+str(fare))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理
单选按钮的 toggled()事件连接到dispFare()
函数,该函数将显示所选航班类型的价格。在dispFare()
函数中,您检查单选按钮的状态。因此,如果选择了radioButtonFirstClass
,则将值150
分配给票价变量。同样,如果选择了radioButtonBusinessClass
,则将值125
分配给fare
变量。同样,当选择radioButtonEconomyClass
时,将值100
分配给fare
变量。最后,通过labelFare
显示fare
变量中的值。
在执行上一个程序时,您会得到一个对话框,其中显示了三种飞行类型,并提示用户选择要用于旅行的飞行类型。选择飞行类型后,所选飞行类型的价格将显示出来,如下面的屏幕截图所示:
分组单选按钮
在这个应用程序中,我们将学习创建两组单选按钮。用户可以从任一组中选择单选按钮,相应地结果或文本将出现在屏幕上。
准备工作
我们将显示一个对话框,其中显示不同尺码的衬衫和不同的付款方式。选择衬衫尺码和付款方式后,所选的衬衫尺码和付款方式将显示在屏幕上。我们将创建两组单选按钮,一组是衬衫尺码,另一组是付款方式。衬衫尺码组显示四个单选按钮,显示四种不同尺码的衬衫,例如 M、L、XL 和 XXL,其中 M 代表中号,L 代表大号,依此类推。付款方式组显示三个单选按钮,分别是借记/信用卡、网上银行和货到付款。用户可以从任一组中选择任何单选按钮。当用户选择任何衬衫尺码或付款方式时,所选的衬衫尺码和付款方式将显示出来。
如何做到...
让我们逐步重新创建前面的应用程序:
-
基于无按钮对话框模板创建一个新应用程序。
-
拖放三个 Label 小部件和七个 Radio Button 小部件。在这七个单选按钮中,我们将四个单选按钮排列在一个垂直布局中,将另外三个单选按钮排列在第二个垂直布局中。这两个布局将有助于将这些单选按钮分组。单选按钮是互斥的,只允许从布局或组中选择一个单选按钮。
-
将前两个 Label 小部件的文本属性分别设置为
选择您的衬衫尺码
和选择您的付款方式
。 -
删除第三个 Label 小部件的文本属性,因为我们将通过代码显示所选的衬衫尺码和付款方式。
-
在属性编辑器窗口中,增加所有小部件的字体大小,以增加它们在应用程序中的可见性。
-
将前四个单选按钮的文本属性设置为
M
、L
、XL
和XXL
。将这四个单选按钮排列成一个垂直布局。 -
将接下来的三个单选按钮的文本属性设置为
借记/信用卡
、网上银行
和货到付款
。将这三个单选按钮排列成第二个垂直布局。请记住,这些垂直布局有助于将这些单选按钮分组。 -
将前四个单选按钮的对象名称更改为
radioButtonMedium
、radioButtonLarge
、radioButtonXL
和radioButtonXXL
。 -
将第一个
VBoxLayout
布局的 objectName 属性设置为verticalLayout
。VBoxLayout
布局将用于垂直对齐单选按钮。 -
将下一个三个单选按钮的对象名称更改为
radioButtonDebitCard
,radioButtonNetBanking
和radioButtonCashOnDelivery
。 -
将第二个
QVBoxLayout
对象的 objectName 属性设置为verticalLayout_2
。 -
将第三个标签小部件的
objectName
属性设置为labelSelected
。通过此标签小部件,将显示所选的衬衫尺寸和付款方式。 -
将应用程序保存为
demoRadioButton2.ui
。 -
现在,表单将显示如下截图所示:
然后,.ui
(XML)文件通过pyuic5
命令实用程序转换为 Python 代码。您可以在本书的源代码包中找到 Python 代码demoRadioButton2.py
。
-
将
demoRadioButton2.py
文件作为头文件导入我们的程序,以调用用户界面设计并编写代码,通过标签小部件显示所选的衬衫尺寸和付款方式,当用户选择或取消选择任何单选按钮时。 -
让我们将程序命名为
callRadioButton2.pyw
;其代码如下所示:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoRadioButton2 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.radioButtonMedium.toggled.connect(self.
dispSelected)
self.ui.radioButtonLarge.toggled.connect(self.
dispSelected)
self.ui.radioButtonXL.toggled.connect(self.dispSelected)
self.ui.radioButtonXXL.toggled.connect(self.
dispSelected)
self.ui.radioButtonDebitCard.toggled.connect(self.
dispSelected)
self.ui.radioButtonNetBanking.toggled.connect(self.
dispSelected)
self.ui.radioButtonCashOnDelivery.toggled.connect(self.
dispSelected)
self.show()
def dispSelected(self):
selected1="";
selected2=""
if self.ui.radioButtonMedium.isChecked()==True:
selected1="Medium"
if self.ui.radioButtonLarge.isChecked()==True:
selected1="Large"
if self.ui.radioButtonXL.isChecked()==True:
selected1="Extra Large"
if self.ui.radioButtonXXL.isChecked()==True:
selected1="Extra Extra Large"
if self.ui.radioButtonDebitCard.isChecked()==True:
selected2="Debit/Credit Card"
if self.ui.radioButtonNetBanking.isChecked()==True:
selected2="NetBanking"
if self.ui.radioButtonCashOnDelivery.isChecked()==True:
selected2="Cash On Delivery"
self.ui.labelSelected.setText("Chosen shirt size is
"+selected1+" and payment method as " + selected2)
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
所有单选按钮的toggled()
事件都连接到dispSelected()
函数,该函数将显示所选的衬衫尺寸和付款方式。在dispSelected()
函数中,您检查单选按钮的状态,以确定它们是选中还是未选中。根据第一个垂直布局中选择的单选按钮,selected1
变量的值将设置为中号
、大号
、特大号
或特特大号
。类似地,从第二个垂直布局中,根据所选的单选按钮,selected2
变量的值将初始化为借记卡/信用卡
、网上银行
或货到付款
。最后,通过labelSelected
小部件显示分配给selected1
变量和selected
变量的衬衫尺寸和付款方式。运行应用程序时,会弹出对话框,提示您选择衬衫尺寸和付款方式。选择衬衫尺寸和付款方式后,所选的衬衫尺寸和付款方式将通过标签小部件显示,如下截图所示:
以复选框形式显示选项
在创建应用程序时,您可能会遇到需要为用户提供多个选项以供选择的情况。也就是说,您希望用户从一组选项中选择一个或多个选项。在这种情况下,您需要使用复选框。让我们更多地了解复选框。
准备就绪
而单选按钮只允许在组中选择一个选项,复选框允许您选择多个选项。也就是说,选择复选框不会影响应用程序中的其他复选框。复选框显示为文本标签,是QCheckBox
类的一个实例。复选框可以处于三种状态之一:选中(已选中)、未选中(未选中)或三态(未更改)。三态是一种无变化状态;用户既没有选中也没有取消选中复选框。
方法应用
以下是QCheckBox
类提供的方法:
-
isChecked()
: 如果复选框被选中,此方法返回布尔值 true,否则返回 false。 -
setTristate()
: 如果您不希望用户更改复选框的状态,请将布尔值 true 传递给此方法。用户将无法选中或取消选中复选框。 -
setIcon()
: 此方法用于显示复选框的图标。 -
setText()
: 此方法将文本分配给复选框。要为复选框指定快捷键,请在文本中的首选字符前加上一个和字符。快捷字符将显示为下划线。 -
setChecked()
: 为了使复选框默认显示为选中状态,请将布尔值 true 传递给此方法。
信号描述
QCheckBox
发出的信号如下:
-
clicked(): 当复选框被激活(即按下并释放)或按下其快捷键时,将发出此信号
-
stateChanged(): 每当复选框从选中到未选中或反之亦然时,将发出此信号
理解复选框小部件,让我们假设您经营一家餐厅,销售多种食物,比如比萨。比萨可以搭配不同的配料,比如额外的奶酪,额外的橄榄等,每种配料的价格也会显示出来。用户可以选择普通比萨并加上一个或多个配料。您希望的是,当选择了配料时,比萨的总价,包括所选的配料,会显示出来。
操作步骤...
本教程的重点是理解当复选框的状态从选中到未选中或反之时如何触发操作。以下是创建这样一个应用程序的逐步过程:
-
首先,基于无按钮的对话框模板创建一个新应用程序。
-
将三个标签小部件和三个复选框小部件拖放到表单上。
-
将前两个标签小部件的文本属性设置为
Regular Pizza $10
和Select your extra toppings
。 -
在属性编辑器窗口中,增加所有三个标签和复选框的字体大小,以增加它们在应用程序中的可见性。
-
将三个复选框的文本属性设置为
Extra Cheese $1
,Extra Olives $1
和Extra Sausages $2
。三个复选框的默认对象名称分别为checkBox
,checkBox_2
和checkBox_3
。 -
分别更改为
checkBoxCheese
,checkBoxOlives
和checkBoxSausages
。 -
将标签小部件的 objectName 属性设置为
labelAmount
。 -
将应用程序保存为
demoCheckBox1.ui
。现在,表单将显示如下截图所示:
然后,通过pyuic5
命令实用程序将.ui
(XML)文件转换为 Python 代码。在本书的源代码包中可以看到生成的demoCheckBox1.py
文件中的 Python 代码。
-
将
demoCheckBox1.py
文件作为头文件导入我们的程序,以调用用户界面设计并编写代码,通过标签小部件计算普通比萨的总成本以及所选的配料,当用户选择或取消选择任何复选框时。 -
让我们将程序命名为
callCheckBox1.pyw
;其代码如下所示:
import sys
from PyQt5.QtWidgets import QDialog
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton
from demoCheckBox1 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.checkBoxCheese.stateChanged.connect(self.
dispAmount)
self.ui.checkBoxOlives.stateChanged.connect(self.
dispAmount)
self.ui.checkBoxSausages.stateChanged.connect(self.
dispAmount)
self.show()
def dispAmount(self):
amount=10
if self.ui.checkBoxCheese.isChecked()==True:
amount=amount+1
if self.ui.checkBoxOlives.isChecked()==True:
amount=amount+1
if self.ui.checkBoxSausages.isChecked()==True:
amount=amount+2
self.ui.labelAmount.setText("Total amount for pizza is
"+str(amount))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
将复选框的 stateChanged()事件连接到dispAmount
函数,该函数将计算所选配料的比萨的成本。在dispAmount
函数中,您检查复选框的状态,以找出它们是选中还是未选中。被选中的复选框的配料成本被添加并存储在amount
变量中。最后,存储在amount
变量中的金额加法通过labelAmount
显示出来。运行应用程序时,会弹出对话框提示您选择要添加到普通比萨中的配料。选择任何配料后,普通比萨的金额以及所选的配料将显示在屏幕上,如下截图所示:
每当任何复选框的状态改变时,
dispAmount
函数将被调用。因此,只要勾选或取消任何复选框,总金额将通过标签小部件显示出来。
显示两组复选框
在这个应用程序中,我们将学习如何制作两组复选框。用户可以从任一组中选择任意数量的复选框,相应的结果将显示出来。
准备工作
我们将尝试显示一家餐厅的菜单,那里供应不同类型的冰淇淋和饮料。我们将创建两组复选框,一组是冰淇淋,另一组是饮料。冰淇淋组显示四个复选框,显示四种不同类型的冰淇淋,薄荷巧克力片、曲奇面团等,以及它们的价格。饮料组显示三个复选框,咖啡、苏打水等,以及它们的价格。用户可以从任一组中选择任意数量的复选框。当用户选择任何冰淇淋或饮料时,所选冰淇淋和饮料的总价格将显示出来。
操作步骤...
以下是创建应用程序的步骤,解释了如何将复选框排列成不同的组,并在任何组的任何复选框的状态发生变化时采取相应的操作:
-
基于没有按钮的对话框模板创建一个新的应用程序。
-
将四个标签小部件、七个复选框小部件和两个分组框小部件拖放到表单上。
-
将前三个标签小部件的文本属性分别设置为
菜单
,选择您的冰淇淋
和选择您的饮料
。 -
删除第四个标签小部件的文本属性,因为我们将通过代码显示所选冰淇淋和饮料的总金额。
-
通过属性编辑器,增加所有小部件的字体大小,以增加它们在应用程序中的可见性。
-
将前四个复选框的文本属性设置为
Mint Choclate Chips $4
,Cookie Dough $2
,Choclate Almond $3
和Rocky Road $5
。将这四个复选框放入第一个分组框中。 -
将接下来三个复选框的文本属性设置为
Coffee $2
,Soda $3
和Tea $1
。将这三个复选框放入第二个分组框中。 -
将前四个复选框的对象名称更改为
checkBoxChoclateChips
,checkBoxCookieDough
,checkBoxChoclateAlmond
和checkBoxRockyRoad
。 -
将第一个分组框的
objectName
属性设置为groupBoxIceCreams
。 -
将接下来三个复选框的
objectName
属性更改为checkBoxCoffee
,checkBoxSoda
和checkBoxTea
。 -
将第二个分组框的
objectName
属性设置为groupBoxDrinks
。 -
将第四个标签小部件的
objectName
属性设置为labelAmount
。 -
将应用程序保存为
demoCheckBox2.ui
。通过这个标签小部件,所选冰淇淋和饮料的总金额将显示出来,如下面的屏幕截图所示:
然后,通过pyuic5
命令实用程序将.ui
(XML)文件转换为 Python 代码。您可以在本书的源代码包中找到生成的 Python 代码demoCheckbox2.py
文件。
-
在我们的程序中将
demoCheckBox2.py
文件作为头文件导入,以调用用户界面设计,并编写代码来通过标签小部件计算冰淇淋和饮料的总成本,当用户选择或取消选择任何复选框时。 -
让我们将程序命名为
callCheckBox2.pyw
;其代码如下所示:
import sys
from PyQt5.QtWidgets import QDialog
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton
from demoCheckBox2 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.checkBoxChoclateAlmond.stateChanged.connect
(self.dispAmount)
self.ui.checkBoxChoclateChips.stateChanged.connect(self.
dispAmount)
self.ui.checkBoxCookieDough.stateChanged.connect(self.
dispAmount)
self.ui.checkBoxRockyRoad.stateChanged.connect(self.
dispAmount)
self.ui.checkBoxCoffee.stateChanged.connect(self.
dispAmount)
self.ui.checkBoxSoda.stateChanged.connect(self.
dispAmount)
self.ui.checkBoxTea.stateChanged.connect(self.
dispAmount)
self.show()
def dispAmount(self):
amount=0
if self.ui.checkBoxChoclateAlmond.isChecked()==True:
amount=amount+3
if self.ui.checkBoxChoclateChips.isChecked()==True:
amount=amount+4
if self.ui.checkBoxCookieDough.isChecked()==True:
amount=amount+2
if self.ui.checkBoxRockyRoad.isChecked()==True:
amount=amount+5
if self.ui.checkBoxCoffee.isChecked()==True:
amount=amount+2
if self.ui.checkBoxSoda.isChecked()==True:
amount=amount+3
if self.ui.checkBoxTea.isChecked()==True:
amount=amount+1
self.ui.labelAmount.setText("Total amount is
$"+str(amount))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
所有复选框的stateChanged()
事件都连接到dispAmount
函数,该函数将计算所选冰淇淋和饮料的成本。在dispAmount
函数中,您检查复选框的状态,以找出它们是选中还是未选中。选中复选框的冰淇淋和饮料的成本被添加并存储在amount
变量中。最后,通过labelAmount
小部件显示存储在amount
变量中的金额的总和。运行应用程序时,会弹出对话框提示您选择要订购的冰淇淋或饮料。选择冰淇淋或饮料后,所选项目的总金额将显示出来,如下面的屏幕截图所示:
第十四章:事件处理-信号和插槽
在本章中,我们将学习以下主题:
-
使用信号/插槽编辑器
-
从一个Line Edit小部件复制并粘贴文本到另一个Line Edit小部件
-
转换数据类型并制作一个小型计算器
-
使用旋转框小部件
-
使用滚动条和滑块
-
使用列表小部件
-
从一个列表小部件中选择多个列表项,并在另一个列表中显示它们
-
将项目添加到列表小部件中
-
在列表小部件中执行操作
-
使用组合框小部件
-
使用字体组合框小部件
-
使用进度条小部件
介绍
事件处理是每个应用程序中的重要机制。应用程序不仅应该识别事件,还必须采取相应的行动来服务事件。在任何事件上采取的行动决定了应用程序的进程。每种编程语言都有不同的处理或监听事件的技术。让我们看看 Python 如何处理其事件。
使用信号/插槽编辑器
在 PyQt 中,事件处理机制也被称为信号和插槽。事件可以是在小部件上单击或双击的形式,或按下Enter键,或从单选按钮、复选框等中选择选项。每个小部件在应用事件时都会发出一个信号,该信号需要连接到一个方法,也称为插槽。插槽是指包含您希望在发生信号时执行的代码的方法。大多数小部件都有预定义的插槽;您不必编写代码来将预定义的信号连接到预定义的插槽。
您甚至可以通过导航到工具栏中的编辑|编辑信号/插槽工具来编辑信号/插槽。
如何做...
要编辑放置在表单上的不同小部件的信号和插槽,您需要执行以下步骤切换到信号和插槽编辑模式:
- 您可以按F4键,导航到编辑|编辑信号/插槽选项,或从工具栏中选择编辑信号/插槽图标。该模式以箭头的形式显示所有信号和插槽连接,指示小部件与其相应插槽的连接。
您还可以在此模式下创建小部件之间的新信号和插槽连接,并删除现有信号。
-
要在表单中的两个小部件之间建立信号和插槽连接,请通过在小部件上单击鼠标,将鼠标拖向要连接的另一个小部件,然后释放鼠标按钮来选择小部件。
-
在拖动鼠标时取消连接,只需按下Esc键。
-
在释放鼠标到达目标小部件时,将出现“连接对话框”,提示您从源小部件中选择信号和从目标小部件中选择插槽。
-
选择相应的信号和插槽后,选择“确定”以建立信号和插槽连接。
以下屏幕截图显示了将Push Button拖动到Line Edit小部件上:
- 在Line Edit小部件上释放鼠标按钮后,您将获得预定义信号和插槽的列表,如下图所示:
您还可以在“配置连接”对话框中选择取消以取消信号和插槽连接。
-
连接后,所选信号和插槽将显示为箭头中的标签,连接两个小部件。
-
要修改信号和插槽连接,请双击连接路径或其标签之一,以显示“配置连接”对话框。
-
从“配置连接”对话框中,您可以根据需要编辑信号或插槽。
-
要删除信号和插槽连接,请在表单上选择其箭头,然后按删除键。
信号和插槽连接也可以在任何小部件和表单之间建立。为此,您可以执行以下步骤:
-
选择小部件,拖动鼠标,并释放鼠标按钮到表单上。连接的终点会变成电气接地符号,表示已经在表单上建立了连接。
-
要退出信号和插槽编辑模式,导航到 Edit | Edit Widgets 或按下F3键。
从一个 Line Edit 小部件复制文本并粘贴到另一个
这个教程将让您了解一个小部件上执行的事件如何调用相关小部件上的预定义动作。因为我们希望在点击推按钮时从一个 Line Edit 小部件复制内容,所以我们需要在推按钮的 pressed()事件发生时调用selectAll()
方法。此外,我们需要在推按钮的 released()事件发生时调用copy()
方法。要在点击另一个推按钮时将剪贴板中的内容粘贴到另一个 Line Edit 小部件中,我们需要在另一个推按钮的 clicked()事件发生时调用paste()
方法。
准备就绪
让我们创建一个包含两个 Line Edit 和两个 Push Button 小部件的应用程序。点击第一个推按钮时,第一个 Line Edit 小部件中的文本将被复制,点击第二个推按钮时,从第一个 Line Edit 小部件中复制的文本将被粘贴到第二个 Line Edit 小部件中。
让我们根据无按钮对话框模板创建一个新应用程序,执行以下步骤:
- 通过从小部件框中将 Line Edit 和 Push Button 小部件拖放到表单上,开始添加
QLineEdit
和QPushButton
。
在编辑时预览表单,选择 Form、Preview,或使用Ctrl + R。
- 要在用户在表单上选择推按钮时复制 Line Edit 小部件的文本,您需要将推按钮的信号连接到 Line Edit 的插槽。让我们学习如何做到这一点。
如何操作...
最初,表单处于小部件编辑模式,要应用信号和插槽连接,您需要首先切换到信号和插槽编辑模式:
-
从工具栏中选择编辑信号/插槽图标,切换到信号和插槽编辑模式。
-
在表单上,选择推按钮,拖动鼠标到 Line Edit 小部件上,然后释放鼠标按钮。配置连接对话框将弹出,允许您在 Push Button 和 Line Edit 小部件之间建立信号和插槽连接,如下截图所示:
- 从 pushButton (QPushButton)选项卡中选择 pressed()事件或信号,从 lineEdit (QLineEdit)选项卡中选择 selectAll()插槽。
Push Button 小部件与 Line Edit 的连接信号将以箭头的形式显示,表示两个小部件之间的信号和插槽连接,如下截图所示:
-
将 Push Button 小部件的文本属性设置为
Copy
,表示它将复制 Line Edit 小部件中输入的文本。 -
接下来,我们将重复点击推按钮并将其拖动到 Line Edit 小部件上,以连接 push 按钮的 released()信号与 Line Edit 小部件的 copy()插槽。在表单上,您将看到另一个箭头,表示两个小部件之间建立的第二个信号和插槽连接,如下截图所示:
-
为了粘贴复制的内容,将一个推按钮和一个 Line Edit 小部件拖放到表单上。
-
将 Push Button 小部件的文本属性设置为
Paste
。 -
点击推按钮,按住鼠标按钮拖动,然后释放到 Line Edit 小部件上。
-
从配置连接对话框中,选择 pushButton (QPushButton)列中的 clicked()事件和 lineEdit (QLineEdit)列中的 paste()插槽。
-
将表单保存为
demoSignal1.ui
。表单现在将显示如下截图所示:
表单将保存在扩展名为.ui
的文件中。demoSignal1.ui
文件将包含表单的所有信息,包括其小部件、布局等。.ui
文件是一个 XML 文件,需要使用pyuic5
实用程序将其转换为 Python 代码。生成的 Python 代码文件demoSignal1.py
可以在本书的源代码包中找到。在demoSignal1.py
文件中,您会发现它从QtCore
和QtGui
两个模块中导入了所有内容,因为您将需要它们来开发 GUI 应用程序:
-
QtCore
:QtCore
模块构成了所有基于 Qt 的应用程序的基础。它包含了最基本的类,如QCoreApplication
、QObject
等。这些类执行重要的任务,如事件处理、实现信号和槽机制、I/O 操作、处理字符串等。该模块包括多个类,包括QFile
、QDir
、QIODevice
、QTimer
、QString
、QDate
和QTime
。 -
QtGui
:顾名思义,QtGUI
模块包含了开发跨平台 GUI 应用程序所需的类。该模块包含了 GUI 类,如QCheckBox
、QComboBox
、QDateTimeEdit
、QLineEdit
、QPushButton
、QPainter
、QPaintDevice
、QApplication
、QTextEdit
和QTextDocument
。
-
将
demoSignalSlot1.py
文件视为头文件,并将其导入到您将调用其用户界面设计的文件中。 -
创建另一个名为
calldemoSignal1.pyw
的 Python 文件,并将demoSignal1.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoSignalSlot1 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.show()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
sys
模块被导入,因为它提供了对存储在sys.argv
列表中的命令行参数的访问。这是因为每个 PyQt GUI 应用程序必须有一个QApplication
对象,以提供对应用程序目录、屏幕大小等信息的访问,因此您创建了一个QApplication
对象。为了使 PyQt 能够使用和应用命令行参数(如果有的话),您在创建QApplication
对象时传递命令行参数。您创建了MyForm
的一个实例,并调用其show()
方法,该方法向QApplication
对象的事件队列中添加了一个新事件。这个新事件用于显示MyForm
类中指定的所有小部件。调用app.exec_
方法来启动QApplication
对象的事件循环。一旦事件循环开始,MyForm
类中使用的顶级小部件以及其子小部件将被显示。所有系统生成的事件以及用户交互事件都将被添加到事件队列中。应用程序的事件循环不断检查是否发生了事件。发生事件时,事件循环会处理它并调用相关的槽或方法。在关闭应用程序的顶级小部件时,PyQt 会删除该小部件,并对应用程序进行清理终止。
在 PyQt 中,任何小部件都可以用作顶级窗口。super().__init__()
方法从MyForm
类中调用基类构造函数,即从MyForm
类中调用QDialog
类的构造函数,以指示通过该类显示QDialog
是一个顶级窗口。
通过调用 Python 代码中创建的类的setupUI()
方法来实例化用户界面设计(Ui_Dialog
)。我们创建了Ui_Dialog
类的一个实例,该类是在 Python 代码中创建的,并调用了它的setupUi()
方法。对话框小部件将被创建为所有用户界面小部件的父级,并显示在屏幕上。请记住,QDialog
、QMainWindow
以及 PyQt 的所有小部件都是从QWidget
派生的。
运行应用程序时,您将获得两对行编辑和按钮小部件。在一个行编辑小部件中输入文本,当您单击复制按钮时,文本将被复制。
现在,单击粘贴按钮后,复制的文本将粘贴在第二个行编辑小部件中,如下截图所示:
转换数据类型并创建一个小型计算器
接受单行数据最常用的小部件是行编辑小部件,行编辑小部件的默认数据类型是字符串。为了对两个整数值进行任何计算,需要将行编辑小部件中输入的字符串数据转换为整数数据类型,然后将计算结果(将是数值数据类型)转换回字符串类型,然后通过标签小部件显示。这个示例正是这样做的。
如何做...
为了了解用户如何接受数据以及如何进行类型转换,让我们创建一个基于对话框无按钮模板的应用程序,执行以下步骤:
-
通过拖放三个标签、两个行编辑和四个按钮小部件到表单上,向表单添加三个
QLabel
、两个QLineEdit
和一个QPushButton
小部件。 -
将两个标签小部件的文本属性设置为
输入第一个数字
和输入第二个数字
。 -
将三个标签的 objectName 属性设置为
labelFirstNumber
,labelSecondNumber
和labelResult
。 -
将两个行编辑小部件的 objectName 属性设置为
lineEditFirstNumber
和lineEditSecondNumber
。 -
将四个按钮小部件的 objectName 属性分别设置为
pushButtonPlus
,pushButtonSubtract
,pushButtonMultiply
和pushButtonDivide
。 -
将按钮的文本属性分别设置为
+
,-
,x
和/
。 -
删除第三个标签的默认文本属性,因为 Python 脚本将设置该值,并在添加两个数字值时显示它。
-
不要忘记在设计师中拖动标签小部件,以确保它足够长,可以显示通过 Python 脚本分配给它的文本。
-
将 UI 文件保存为
demoCalculator.ui
。 -
您还可以通过在属性编辑器窗口中的 geometry 下设置宽度属性来增加标签小部件的宽度:
.ui
文件以 XML 格式,需要转换为 Python 代码。生成的 Python 代码demoCalculator.py
可以在本书的源代码包中看到。
- 创建一个名为
callCalculator.pyw
的 Python 脚本,导入 Python 代码demoCalculator.py
来调用用户界面设计,并获取输入的行编辑小部件中的值,并显示它们的加法。Python 脚本callCalculator.pyw
中的代码如下所示:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoCalculator import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonPlus.clicked.connect(self.addtwonum)
self.ui.pushButtonSubtract.clicked.connect
(self.subtracttwonum)
self.ui.pushButtonMultiply.clicked.connect
(self.multiplytwonum)
self.ui.pushButtonDivide.clicked.connect(self.dividetwonum)
self.show()
def addtwonum(self):
if len(self.ui.lineEditFirstNumber.text())!=0:
a=int(self.ui.lineEditFirstNumber.text())
else:
a=0
if len(self.ui.lineEditSecondNumber.text())!=0:
b=int(self.ui.lineEditSecondNumber.text())
else:
b=0
sum=a+b
self.ui.labelResult.setText("Addition: " +str(sum))
def subtracttwonum(self):
if len(self.ui.lineEditFirstNumber.text())!=0:
a=int(self.ui.lineEditFirstNumber.text())
else:
a=0
if len(self.ui.lineEditSecondNumber.text())!=0:
b=int(self.ui.lineEditSecondNumber.text())
else:
b=0
diff=a-b
self.ui.labelResult.setText("Substraction: " +str(diff))
def multiplytwonum(self):
if len(self.ui.lineEditFirstNumber.text())!=0:
a=int(self.ui.lineEditFirstNumber.text())
else:
a=0
if len(self.ui.lineEditSecondNumber.text())!=0:
b=int(self.ui.lineEditSecondNumber.text())
else:
b=0
mult=a*b
self.ui.labelResult.setText("Multiplication: " +str(mult))
def dividetwonum(self):
if len(self.ui.lineEditFirstNumber.text())!=0:
a=int(self.ui.lineEditFirstNumber.text())
else:
a=0
if len(self.ui.lineEditSecondNumber.text())!=0:
b=int(self.ui.lineEditSecondNumber.text())
else:
b=0
division=a/b
self.ui.labelResult.setText("Division: "+str(round
(division,2)))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
此代码中使用了以下四个函数:
-
len()
: 这个函数返回字符串中的字符数 -
str()
: 这个函数将传递的参数转换为字符串数据类型 -
int()
: 这个函数将传递的参数转换为整数数据类型 -
round()
: 这个函数将传递的数字四舍五入到指定的小数位
pushButtonPlus
的clicked()
事件连接到addtwonum()
方法,以显示在两个行编辑小部件中输入的数字的总和。在addtwonum()
方法中,首先验证lineEditFirstNumber
和lineEditSecondNumber
,以确保用户是否将任一行编辑留空,如果是,则该行编辑的值为零。
检索两个行编辑小部件中输入的值,通过int()
转换为整数,并赋值给两个变量a
和b
。计算a
和b
变量中的值的总和,并存储在sum
变量中。通过str
方法将变量sum
中的结果转换为字符串格式,并通过labelResult
显示,如下截图所示:
类似地,pushButtonSubtract
的clicked()
事件连接到subtracttwonum()
方法,以显示两个行编辑小部件中输入的数字的减法。再次,在验证两个行编辑小部件之后,检索并将其输入的值转换为整数。对这两个数字进行减法运算,并将结果分配给diff
变量。
最后,通过str()
方法将diff
变量中的结果转换为字符串格式,并通过labelResult
显示,如下面的屏幕截图所示:
类似地,pushButtonMultiply
和pushButtonDivide
的clicked()
事件分别连接到multiplytwonum()
和dividetwonum()
方法。这些方法将两个行编辑小部件中输入的值相乘和相除,并通过labelResult
小部件显示它们。
乘法的结果如下所示:
除法的结果如下所示:
使用旋转框小部件
旋转框小部件用于显示整数值、浮点值和文本。它对用户施加了约束:用户不能输入任意数据,但只能从旋转框显示的可用选项中进行选择。旋转框小部件默认显示初始值,可以通过选择上/下按钮或在键盘上按上/下箭头键来增加或减少该值。您可以通过单击或手动输入来选择要显示的值。
准备就绪
旋转框小部件可以使用两个类QSpinBox
和QDoubleSpinBox
创建,其中QSpinBox
仅显示整数值,而QDoubleSpinBox
类显示浮点值。QSpinBox
提供的方法如下所示:
-
value()
: 此方法返回从旋转框中选择的当前整数值。 -
text()
: 此方法返回旋转框显示的文本。 -
setPrefix()
: 此方法分配要添加到旋转框返回值之前的前缀文本。 -
setSuffix()
: 此方法分配要附加到旋转框返回值的后缀文本。 -
cleanText()
: 此方法返回旋转框的值,不带后缀、前缀或前导或尾随空格。 -
setValue()
: 此方法分配值给旋转框。 -
setSingleStep()
: 此方法设置旋转框的步长。步长是旋转框的增量/减量值,即旋转框的值将通过选择上/下按钮或使用setValue()
方法增加或减少的值。 -
setMinimum()
: 此方法设置旋转框的最小值。 -
setMaximum()
: 此方法设置旋转框的最大值。 -
setWrapping()
: 此方法将布尔值 true 传递给此方法,以启用旋转框中的包装。包装意味着当按下上按钮显示最大值时,旋转框返回到第一个值(最小值)。
QSpinBox
类发出的信号如下:
-
valueChanged(): 当通过选择上/下按钮或使用
setValue()
方法更改旋转框的值时,将发出此信号。 -
editingFinished()
: 当焦点离开旋转框时发出此信号
用于处理旋转框中浮点值的类是QDoubleSpinBox
。所有前述方法也受QDoubleSpinBox
类的支持。它默认显示值,保留两位小数。要更改精度,请使用round()
,它会显示值,保留指定数量的小数位;该值将四舍五入到指定数量的小数位。
旋转框的默认最小值、最大值、单步值和值属性分别为 0、99、1 和 0;双精度旋转框的默认值为 0.000000、99.990000、1.000000 和 0.000000。
让我们创建一个应用程序,该应用程序将要求用户输入书的价格,然后输入客户购买的书的数量,并显示书的总金额。此外,该应用程序将提示您输入 1 公斤糖的价格,然后输入用户购买的糖的数量。在输入糖的数量时,应用程序将显示糖的总量。书籍和糖的数量将分别通过微调框和双精度微调框输入。
如何做...
要了解如何通过微调框接受整数和浮点值并在进一步计算中使用,让我们基于无按钮模板创建一个新的应用程序,并按照以下步骤操作:
-
让我们开始拖放三个标签,一个微调框,一个双精度微调框和四个行编辑小部件。
-
两个标签小部件的文本属性设置为
Book Price value
和Sugar Price
,第三个标签小部件的 objectName 属性设置为labelTotalAmount
。 -
将四个行编辑小部件的 objectName 属性设置为
lineEditBookPrice
,lineEditBookAmount
,lineEditSugarPrice
和lineEditSugarAmount
。 -
将 Spin Box 小部件的 objectName 属性设置为
spinBoxBookQty
,将 Double Spin Box 小部件的 objectName 属性设置为doubleSpinBoxSugarWeight
。 -
删除第三个标签小部件 TextLabe 的默认文本属性,因为您将在程序中设置其文本以显示总金额。
-
删除第三个标签小部件的文本属性后,它将变得不可见。
-
禁用两个行编辑小部件
lineEditBookAmount
和lineEditSugarAmount
,通过取消选中它们的属性编辑器窗口中的启用属性,因为您希望它们显示不可编辑的值。 -
使用名称
demoSpinner.ui
保存应用程序:
-
使用
pyuic5
命令实用程序,.ui
(XML)文件将转换为 Python 代码。生成的 Python 代码文件demoSpinner.py
可以在本书的源代码中看到。 -
创建一个名为
calldemoSpinner.pyw
的 Python 脚本文件,导入代码demoSpinner.py
,使您能够调用显示通过微调框选择的数字并计算总书籍金额和总糖量的用户界面设计。calldemoSpinner.pyw
文件将显示如下:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoSpinBox import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.spinBoxBookQty.editingFinished.connect(self.
result1)
self.ui.doubleSpinBoxSugarWeight.editingFinished.connect
(self.result2)
self.show()
def result1(self):
if len(self.ui.lineEditBookPrice.text())!=0:
bookPrice=int(self.ui.lineEditBookPrice.text())
else:
bookPrice=0
totalBookAmount=self.ui.spinBoxBookQty.value() *
bookPrice
self.ui.lineEditBookAmount.setText(str
(totalBookAmount))
def result2(self):
if len(self.ui.lineEditSugarPrice.text())!=0:
sugarPrice=float(self.ui.lineEditSugarPrice.
text())
else:
sugarPrice=0
totalSugarAmount=self.ui.
doubleSpinBoxSugarWeight.value() * sugarPrice
self.ui.lineEditSugarAmount.setText(str(round
(totalSugarAmount,2)))
totalBookAmount=int(self.ui.lineEditBookAmount.
text())
totalAmount=totalBookAmount+totalSugarAmount
self.ui.labelTotalAmount.setText(str(round
(totalAmount,2)))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
在此代码中,您可以看到两个微调框的editingFinished
信号附加到result1
和result2
函数。这意味着当焦点离开任何微调框时,将调用相应的方法。当用户使用鼠标移动到其他微调框或按 Tab 键时,焦点将离开小部件:
-
在
result1
方法中,您从 Spin Box 小部件中检索购买的书的数量的整数值,并将其乘以在lineEditBookPrice
小部件中输入的书的价格,以计算总书费。然后通过lineEditBookAmount
小部件显示总书费。 -
类似地,在
result2
方法中,您从双精度微调框中检索购买的糖的重量的浮点值,并将其乘以在lineEditSugarPrice
小部件中输入的每公斤糖的价格,以计算总糖成本,然后通过lineEditSugarAmount
小部件显示。书的成本和糖的成本的总和最终通过labelTotalAmount
小部件显示,如下面的屏幕截图所示:
使用滚动条和滑块
滚动条在查看无法出现在有限可见区域的大型文档或图像时非常有用。滚动条水平或垂直出现,指示您在文档或图像中的当前位置以及不可见区域的大小。使用这些滚动条提供的滑块手柄,您可以访问文档或图像的隐藏部分。
滑块是选择两个值之间的整数值的一种方式。也就是说,滑块可以表示一系列最小和最大值,并且用户可以通过将滑块手柄移动到滑块中所需位置来选择此范围内的值。
准备就绪
滚动条用于查看大于视图区域的文档或图像。要显示水平或垂直滚动条,您可以使用HorizontalScrollBar
和VerticalScrollBar
小部件,它们是QScrollBar
类的实例。这些滚动条有一个滑块手柄,可以移动以查看不可见的区域。滑块手柄的位置指示文档或图像内的位置。滚动条具有以下控件:
-
滑块手柄: 此控件用于快速移动到文档或图像的任何部分。
-
滚动箭头: 这些是滚动条两侧的箭头,用于查看当前不可见的文档或图像的所需区域。使用这些滚动箭头时,滑块手柄的位置移动以显示文档或图像内的当前位置。
-
页面控制: 页面控制是滑块手柄拖动的滚动条的背景。单击背景时,滑块手柄向单击位置移动一个页面。滑块手柄移动的量可以通过 pageStep 属性指定。页面步进是用户按下Page Up和Page Down键时滑块移动的量。您可以使用
setPageStep()
方法设置 pageStep 属性的量。
用于设置和检索滚动条的值的特定方法是value()
方法,这里进行了描述。
value()
方法获取滑块手柄的值,即其距离滚动条起始位置的距离值。当滑块手柄在垂直滚动条的顶部边缘或水平滚动条的左边缘时,您会得到滚动条的最小值;当滑块手柄在垂直滚动条的底部边缘或水平滚动条的右边缘时,您会得到滚动条的最大值。您也可以通过键盘将滑块手柄移动到其最小和最大值,分别按下Home和End键。让我们来看看以下方法:
-
setValue()
: 此方法将值分配给滚动条,并根据分配的值设置滑块手柄在滚动条中的位置 -
minimum()
: 此方法返回滚动条的最小值 -
maximum()
: 此方法返回滚动条的最大值 -
setMinimum()
: 此方法将最小值分配给滚动条 -
setMaximum()
: 此方法将最大值分配给滚动条 -
setSingleStep()
: 此方法设置单步值 -
setPageStep()
: 此方法设置页面步进值
QScrollBar
仅提供整数值。
通过QScrollBar
类发出的信号如下所示:
-
valueChanged(): 当滚动条的值发生变化时发出此信号,即当其滑块手柄移动时
-
sliderPressed(): 当用户开始拖动滑块手柄时发出此信号
-
sliderMoved(): 当用户拖动滑块手柄时发出此信号
-
sliderReleased(): 当用户释放滑块手柄时发出此信号
-
actionTriggered(): 当用户交互改变滚动条时发出此信号
滑块通常用于表示某个整数值。与滚动条不同,滚动条大多用于显示大型文档或图像,滑块是交互式的,是输入或表示整数值的更简单的方式。也就是说,通过移动和定位其手柄沿水平或垂直槽,可以使水平或垂直滑块表示某个整数值。为了显示水平和垂直滑块,使用了HorizontalSlider
和VerticalSlider
小部件,它们是QSlider
类的实例。与我们在滚动条中看到的方法类似,滑块在移动滑块手柄时也会生成信号,例如valueChanged()
,sliderPressed()
,sliderMoved()
,sliderReleased()
等等。
滚动条和滑块中的滑块手柄表示在最小和最大范围内的值。要更改默认的最小和最大值,可以通过为 minimum、maximum、singleStep 和 pageStep 属性分配值来更改它们的值。
滑块的最小值、最大值、singleStep、pageStep 和 value 属性的默认值分别为 0、99、1、10 和 0。
让我们创建一个应用程序,其中包括水平和垂直滚动条,以及水平和垂直滑块。水平滚动条和滑块将分别表示血糖水平和血压。也就是说,移动水平滚动条时,患者的血糖水平将通过行编辑小部件显示。同样,移动水平滑块时,将表示血压,并通过行编辑小部件显示。
垂直滚动条和滑块将分别表示心率和胆固醇水平。移动垂直滚动条时,心率将通过行编辑小部件显示,移动垂直滑块时,胆固醇水平将通过行编辑小部件显示。
操作步骤...
为了理解水平和垂直滚动条的工作原理,以及水平和垂直滑块的工作原理,了解滚动条和滑块在值更改时如何生成信号,以及如何将相应的槽或方法与它们关联,执行以下步骤:
-
让我们创建一个新的对话框应用程序,没有按钮模板,并将水平和垂直滚动条和滑块拖放到表单上。
-
将四个标签小部件和一个行编辑小部件放置到显示滚动条和滑块手柄值的位置。
-
将四个标签小部件的 text 属性分别设置为
血糖水平
,血压
,脉搏率
和胆固醇
。 -
将水平滚动条的 objectName 属性设置为
horizontalScrollBarSugarLevel
,垂直滚动条的 objectName 属性设置为verticalScrollBarPulseRate
,水平滑块的 objectName 属性设置为horizontalSliderBloodPressure
,垂直滑块的 objectName 属性设置为verticalSliderCholestrolLevel
。 -
将行编辑小部件的 objectName 属性设置为
lineEditResult
。 -
将应用程序保存为名称为
demoSliders.ui
的文件。表单将显示如下截图所示:
pyuic5
命令实用程序将把.ui
(XML)文件转换为 Python 代码。生成的 Python 文件demoScrollBar.py
可以在本书的源代码包中找到。
- 创建一个名为
callScrollBar.pyw
的 Python 脚本文件,导入代码demoScrollBar.py
,以调用用户界面设计并同步滚动条和滑块手柄的移动。该脚本还将通过标签小部件显示滚动条和滑块手柄的值。Python 脚本callScrollBar.pyw
将显示如下:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoScrollBar import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.horizontalScrollBarSugarLevel.valueChanged.connect
(self.scrollhorizontal)
self.ui.verticalScrollBarPulseRate.valueChanged.connect
(self.scrollvertical)
self.ui.horizontalSliderBloodPressure.valueChanged.connect
(self.sliderhorizontal)
self.ui.verticalSliderCholestrolLevel.valueChanged.connect
(self.slidervertical)
self.show()
def scrollhorizontal(self,value):
self.ui.lineEditResult.setText("Sugar Level : "+str(value))
def scrollvertical(self, value):
self.ui.lineEditResult.setText("Pulse Rate : "+str(value))
def sliderhorizontal(self, value):
self.ui.lineEditResult.setText("Blood Pressure :
"+str(value))
def slidervertical(self, value):
self.ui.lineEditResult.setText("Cholestrol Level :
"+str(value))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
在此代码中,您正在将每个窗口部件的valueChanged()
信号与相应的函数连接起来,以便如果窗口部件的滚动条或滑块移动,将调用相应的函数来执行所需的任务。例如,当水平滚动条的滑块移动时,将调用scrollhorizontal
函数。scrollhorizontal
函数通过 Label 窗口部件显示滚动条表示的值,即血糖水平。
同样,当垂直滚动条或滑块的滑块移动时,将调用scrollvertical
函数,并且垂直滚动条的滑块的值,即心率,将通过 Label 窗口部件显示,如下面的屏幕截图所示:
同样,当水平和垂直滑块移动时,血压和胆固醇水平会相应地显示,如下面的屏幕截图所示:
使用 List 窗口部件
要以更简单和可扩展的格式显示多个值,可以使用 List 窗口部件,它是QListWidget
类的实例。List 窗口部件显示多个项目,不仅可以查看,还可以编辑和删除。您可以逐个添加或删除列表项目,也可以使用其内部模型集合地设置列表项目。
准备工作
列表中的项目是QListWidgetItem
类的实例。QListWidget
提供的方法如下所示:
-
insertItem()
: 此方法将提供的文本插入到 List 窗口部件的指定位置。 -
insertItems()
: 此方法从提供的列表中的指定位置开始插入多个项目。 -
count()
: 此方法返回列表中项目数量的计数。 -
takeItem()
: 此方法从列表窗口中指定的行中移除并返回项目。 -
currentItem()
: 此方法返回列表中的当前项目。 -
setCurrentItem()
: 此方法用指定的项目替换列表中的当前项目。 -
addItem()
: 此方法将具有指定文本的项目附加到 List 窗口部件的末尾。 -
addItems()
: 此方法将提供的列表中的项目附加到 List 窗口部件的末尾。 -
clear()
: 此方法从 List 窗口部件中移除所有项目。 -
currentRow()
: 此方法返回当前选定列表项的行号。如果未选择列表项,则返回值为-1
。 -
setCurrentRow()
: 此方法选择 List 窗口部件中的指定行。 -
item()
: 此方法返回指定行处的列表项。
QListWidget
类发出的信号如下所示:
-
currentRowChanged(): 当当前列表项的行更改时发出此信号
-
currentTextChanged(): 当当前列表项中的文本更改时发出此信号
-
currentItemChanged(): 当当前列表项的焦点更改时发出此信号
如何做...
因此,让我们创建一个应用程序,通过 List 窗口部件显示特定的诊断测试,并且当用户从 List 窗口部件中选择任何测试时,所选测试将通过 Label 窗口部件显示。以下是创建应用程序的逐步过程:
-
创建一个没有按钮模板的对话框的新应用程序,并将两个 Label 窗口部件和一个 List 窗口部件拖放到表单上。
-
将第一个 Label 窗口部件的文本属性设置为“选择诊断测试”。
-
将 List 窗口部件的 objectName 属性设置为
listWidgetDiagnosis
。 -
将 Label 窗口部件的 objectName 属性设置为
labelTest
。 -
删除
labelTest
窗口部件的默认文本属性,因为我们将通过代码通过此窗口部件显示所选的诊断测试。 -
要通过 List 窗口部件显示诊断测试,请右键单击它,并从打开的上下文菜单中选择“编辑项目”选项。
-
逐个添加诊断测试,然后在输入每个测试后单击底部的+按钮,如下截图所示:
- 使用名称
demoListWidget1.ui
保存应用程序。表单将显示如下截图所示:
pyuic5
命令实用程序将把.ui
(XML)文件转换为 Python 代码。生成的 Python 代码demoListWidget1.py
可以在本书的源代码包中看到。
- 创建一个名为
callListWidget1.pyw
的 Python 脚本文件,导入代码demoListWidget1.py
,以调用用户界面设计和从列表窗口中显示所选的诊断测试的代码。Python 脚本callListWidget1.pyw
中的代码如下所示:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoListWidget1 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.listWidgetDiagnosis.itemClicked.connect(self.
dispSelectedTest)
self.show()
def dispSelectedTest(self):
self.ui.labelTest.setText("You have selected
"+self.ui.listWidgetDiagnosis.currentItem().text())
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
您可以看到列表窗口的itemClicked
事件连接到dispSelectedTest()
方法。也就是说,单击列表窗口中的任何列表项时,将调用dispSelectedTest()
方法,该方法使用列表窗口的currentItem
方法通过名为labelTest
的标签显示列表窗口的所选项目。
运行应用程序时,您将看到列表窗口显示一些诊断测试;从列表窗口中选择一个测试,该测试将通过 Label 窗口显示,如下截图所示:
从一个列表窗口中选择多个列表项,并在另一个列表窗口中显示它们
在前面的应用程序中,您只从列表窗口中选择了单个诊断测试。如果我想要从列表窗口中进行多重选择怎么办?在进行多重选择的情况下,您需要另一个列表窗口来存储所选的诊断测试,而不是使用行编辑窗口。
如何做...
让我们创建一个应用程序,通过列表窗口显示特定的诊断测试,当用户从列表窗口中选择任何测试时,所选测试将显示在另一个列表窗口中:
-
因此,创建一个没有按钮模板的对话框的新应用程序,并将两个 Label 窗口小部件和两个列表窗口拖放到表单上。
-
将第一个 Label 窗口小部件的文本属性设置为
诊断测试
,另一个设置为已选择的测试为
。 -
将第一个列表窗口的 objectName 属性设置为
listWidgetDiagnosis
,第二个列表窗口的设置为listWidgetSelectedTests
。 -
要通过列表窗口显示诊断测试,请右键单击它,从打开的上下文菜单中选择“编辑项目”选项。
-
逐个添加诊断测试,然后在输入每个测试后单击底部的+按钮。
-
要从列表窗口启用多重选择,请选择
listWidgetDiagnosis
窗口小部件,并从属性编辑器窗口中将 selectionMode 属性从SingleSelection
更改为MultiSelection
。 -
使用名称
demoListWidget2.ui
保存应用程序。表单将显示如下截图所示:
通过使用pyuic5
实用程序,XML 文件demoListWidget2.ui
将被转换为 Python 代码,即demoListWidget2.py
文件。可以在本书的源代码包中看到从demoListWidget2.py
文件生成的 Python 代码。
- 创建一个名为
callListWidget2.pyw
的 Python 脚本文件,导入代码demoListWidget2.py
,以调用用户界面设计和显示从列表窗口中选择的多个诊断测试的代码。Python 脚本callListWidget2.pyw
将显示如下:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoListWidget2 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.listWidgetDiagnosis.itemSelectionChanged.connect
(self.dispSelectedTest)
self.show()
def dispSelectedTest(self):
self.ui.listWidgetSelectedTests.clear()
items = self.ui.listWidgetDiagnosis.selectedItems()
for i in list(items):
self.ui.listWidgetSelectedTests.addItem(i.text())
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
您可以看到,第一个列表小部件的itemSelectionChanged
事件连接到dispSelectedTest()
方法。也就是说,在从第一个列表小部件中选择或取消选择任何列表项目时,将调用dispSelectedTest()
方法。dispSelectedTest()
方法调用列表小部件上的selectedItems()
方法以获取所有选定项目的列表。然后,使用for
循环,通过在第二个列表小部件上调用addItem()
方法,将所有选定的项目添加到第二个列表小部件中。
运行应用程序时,您将看到列表小部件显示一些诊断测试;从第一个列表小部件中选择任意数量的测试,所有选定的测试将通过第二个列表小部件项目显示,如下截图所示:
向列表小部件添加项目
虽然您可以通过属性编辑器手动向列表小部件添加项目,但有时需要通过代码动态向列表小部件添加项目。让我们创建一个应用程序,解释向列表小部件添加项目的过程。
在此应用程序中,您将使用标签、行编辑、按钮和列表小部件。列表小部件项目最初将为空,并要求用户将所需的食物项目输入到行编辑中,并选择“添加到列表”按钮。然后将输入的食物项目添加到列表小部件项目中。所有后续的食物项目将添加到上一个条目下方。
如何做...
执行以下步骤以了解如何向列表小部件项目添加项目:
-
我们将从基于无按钮对话框模板创建一个新应用程序开始,并将标签、行编辑、按钮和列表小部件拖放到表单中。
-
将标签和按钮小部件的文本属性分别设置为“您最喜欢的食物项目”和“添加到列表”。
-
将行编辑小部件的 objectName 属性设置为
lineEditFoodItem
,按钮的 objectName 设置为pushButtonAdd
,列表小部件的 objectName 设置为listWidgetSelectedItems
。 -
将应用程序保存为
demoListWidget3.ui
。表单将显示如下截图所示:
在执行pyuic5
实用程序时,XML 文件demoListWidget3.ui
将被转换为 Python 代码demoListWidget3.py
。生成的 Python 文件demoListWidget3.py
的代码可以在本书的源代码包中找到。
- 创建一个名为
callListWidget3.pyw
的 Python 脚本文件,导入 Python 代码demoListWidget3.py
以调用用户界面设计,并将用户在行编辑中输入的食物项目添加到列表小部件中。callListWidget3.pyw
文件中的 Python 代码将如下所示:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoListWidget3 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonAdd.clicked.connect(self.addlist)
self.show()
def addlist(self):
self.ui.listWidgetSelectedItems.addItem(self.ui.
lineEditFoodItem.text())
self.ui.lineEditFoodItem.setText('')
self.ui.lineEditFoodItem.setFocus()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
将按钮小部件的 clicked()事件连接到addlist
函数。因此,在在行编辑小部件中输入要添加到列表小部件中的文本后,当用户选择“添加到列表”按钮时,将调用addlist
函数。addlist
函数检索在行编辑中输入的文本,并将其添加到列表小部件中。然后,清除行编辑小部件中的文本,并将焦点设置在它上面,使用户能够输入不同的文本。
在下面的截图中,您可以看到用户在行编辑小部件中输入的文本在用户选择“添加到列表”按钮时添加到列表小部件中:
在列表小部件中执行操作
在这个示例中,您将学习如何在 List Widget 中执行不同的操作。List Widget 基本上用于显示一组相似的项目,使用户能够选择所需的项目。因此,您需要向 List Widget 添加项目。此外,您可能需要编辑 List Widget 中的任何项目。有时,您可能需要从 List Widget 中删除项目。您可能还希望对 List Widget 执行的另一个操作是删除其中的所有项目,清除整个 List Widget 项目。在学习如何向 List Widget 添加、编辑和删除项目之前,让我们先了解列表项的概念。
准备工作
List Widget 包含多个列表项。这些列表项是QListWidgetItem
类的实例。可以使用insertItem()
或addItem()
方法将列表项插入 List Widget 中。列表项可以是文本或图标形式,并且可以被选中或取消选中。QListWidgetItem
提供的方法如下。
QListWidgetItem
类提供的方法
让我们来看看QListWidgetItem
类提供的以下方法:
-
setText()
: 这个方法将指定的文本分配给列表项 -
setIcon()
: 这个方法将指定的图标分配给列表项 -
checkState()
: 这个方法根据列表项是选中还是未选中状态返回布尔值 -
setHidden()
: 这个方法将布尔值 true 传递给这个方法以隐藏列表项 -
isHidden()
: 如果列表项被隐藏,这个方法返回 true
我们已经学会了向 List Widget 添加项目。如果您想编辑 List Widget 中的现有项目,或者您想从 List Widget 中删除项目,或者您想从 List Widget 中删除所有项目呢?
让我们通过创建一个应用程序来学习在列表小部件上执行不同的操作。这个应用程序将显示 Line Edit,List Widget 和一对 Push Button 小部件。您可以通过在 Line Edit 中输入文本,然后单击“Add”按钮来向 List Widget 添加项目。同样,您可以通过单击 List Widget 中的项目,然后单击“Edit”按钮来编辑 List Widget 中的任何项目。不仅如此,您甚至可以通过单击“Delete”按钮来删除 List Widget 中的任何项目。如果您想清除整个 List Widget,只需单击“Delete All”按钮。
如何做....
执行以下步骤以了解如何在列表小部件上应用不同的操作;如何向列表小部件添加、编辑和删除项目;以及如何清除整个列表小部件:
-
打开 Qt Designer,基于无按钮模板创建一个新应用程序,并将一个标签、一个 Line Edit、四个 Push Button 和 List Widget 小部件拖放到表单上。
-
将标签小部件的文本属性设置为
Enter an item
。 -
将四个 Push Button 小部件的文本属性设置为
Add
,Edit
,Delete
和Delete All
。 -
将四个 Push Button 小部件的 objectName 属性设置为
psuhButtonAdd
,pushButtonEdit
,pushButtonDelete
和pushButtonDeleteAll
。 -
将应用程序保存为
demoListWidgetOp.ui
。
表单将显示如下截图所示:
需要使用pyuic5
命令实用程序将 XML 文件demoListWidgetOp.ui
转换为 Python 脚本。本书的源代码包中可以看到生成的 Python 文件demoListWidgetOp.py
。
- 创建一个名为
callListWidgetOp.pyw
的 Python 脚本文件,导入 Python 代码demoListWidgetOp.py
,使您能够调用用户界面设计并在 List Widget 中添加、删除和编辑列表项。Python 脚本callListWidgetOp.pyw
中的代码如下所示:
import sys
from PyQt5.QtWidgets import QDialog, QApplication, QInputDialog, QListWidgetItem
from demoListWidgetOp import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.listWidget.addItem('Ice Cream')
self.ui.listWidget.addItem('Soda')
self.ui.listWidget.addItem('Coffee')
self.ui.listWidget.addItem('Chocolate')
self.ui.pushButtonAdd.clicked.connect(self.addlist)
self.ui.pushButtonEdit.clicked.connect(self.editlist)
self.ui.pushButtonDelete.clicked.connect(self.delitem)
self.ui.pushButtonDeleteAll.clicked.connect
(self.delallitems)
self.show()
def addlist(self):
self.ui.listWidget.addItem(self.ui.lineEdit.text())
self.ui.lineEdit.setText('')
self.ui.lineEdit.setFocus()
def editlist(self):
row=self.ui.listWidget.currentRow()
newtext, ok=QInputDialog.getText(self, "Enter new text",
"Enter new text")
if ok and (len(newtext) !=0):
self.ui.listWidget.takeItem(self.ui.listWidget.
currentRow())
self.ui.listWidget.insertItem(row,
QListWidgetItem(newtext))
def delitem(self):
self.ui.listWidget.takeItem(self.ui.listWidget.
currentRow())
def delallitems(self):
self.ui.listWidget.clear()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
pushButtonAdd
的 clicked()事件连接到addlist
函数。同样,pushButtonEdit
,pushButtonDelete
和pushButtonDeleteAll
对象的 clicked()事件分别连接到editlist
,delitem
和delallitems
函数。也就是说,单击任何按钮时,将调用相应的函数。addlist
函数调用addItem
函数来添加在 Line Edit 部件中输入的文本。editlist
函数使用 List Widget 上的currentRow
方法来找出要编辑的列表项目。
调用QInputDialog
类的getText
方法来提示用户输入新文本或编辑文本。在对话框中单击 OK 按钮后,当前列表项目将被对话框中输入的文本替换。delitem
函数调用 List Widget 上的takeItem
方法来删除当前行,即所选的列表项目。delallitems
函数调用 List Widget 上的clear
方法来清除或删除 List Widget 中的所有列表项目。
运行应用程序后,您将在 Line Edit 部件下方找到一个空的 List Widget、Line Edit 和 Add 按钮。在 Line Edit 部件中添加任何文本,然后单击添加按钮将该项目添加到 List Widget 中。在 List Widget 中添加了四个项目后,可能会显示如下截图所示:
让我们向 List Widget 中再添加一个项目 Pizza。在 Line Edit 部件中输入Pizza
,然后单击添加按钮。Pizza 项目将被添加到 List Widget 中,如下截图所示:
假设我们要编辑 List Widget 中的 Pizza 项目,点击 List Widget 中的 Pizza 项目,然后点击编辑按钮。单击编辑按钮后,将弹出一个对话框,提示您输入一个新项目来替换 Pizza 项目。让我们在对话框中输入Cold Drink
,然后单击 OK 按钮,如下截图所示:
在下面的截图中,您可以看到列表部件中的 Pizza 项目被文本 Cold Drink 替换:
要从列表部件中删除任何项目,只需点击列表部件中的项目,然后点击删除按钮。让我们点击列表部件中的 Coffee 项目,然后点击删除按钮;如下截图所示,Coffee 项目将从列表部件中删除:
单击删除所有按钮后,整个 List Widget 项目将变为空,如下截图所示:
使用组合框部件
组合框用于从用户那里获取输入,并应用约束;也就是说,用户将以弹出列表的形式看到某些选项,他/她只能从可用选项中选择。与 List Widget 相比,组合框占用更少的空间。QComboBox
类用于显示组合框。您不仅可以通过组合框显示文本,还可以显示pixmaps
。以下是QComboBox
类提供的方法:
方法 | 用途 |
---|---|
setItemText() |
设置或更改组合框中项目的文本。 |
removeItem() |
从组合框中删除特定项目。 |
clear() |
从组合框中删除所有项目。 |
currentText() |
返回当前项目的文本,即当前选择的项目。 |
setCurrentIndex() |
设置组合框的当前索引,即将组合框中的所需项目设置为当前选择的项目。 |
count() |
返回组合框中项目的计数。 |
setMaxCount() |
设置允许在组合框中的最大项目数。 |
setEditable() |
使组合框可编辑,即用户可以编辑组合框中的项目。 |
addItem() |
将指定内容附加到组合框中。 |
addItems() |
将提供的每个字符串附加到组合框中。 |
itemText() |
返回组合框中指定索引位置的文本。 |
currentIndex() |
返回组合框中当前选择项目的索引位置。如果组合框为空或组合框中当前未选择任何项目,则该方法将返回-1 作为索引。 |
以下是由QComboBox
生成的信号:
信号 | 描述 |
---|---|
currentIndexChanged() | 当组合框的索引更改时发出,即用户在组合框中选择了一些新项目。 |
activated() | 当用户更改索引时发出。 |
highlighted() | 当用户在组合框中突出显示项目时发出。 |
editTextChanged() | 当可编辑组合框的文本更改时发出。 |
为了实际了解组合框的工作原理,让我们创建一个示例。这个示例将通过一个组合框显示特定的银行账户类型,并提示用户选择他/她想要开设的银行账户类型。通过组合框选择的银行账户类型将通过Label
小部件显示在屏幕上。
如何做…
以下是创建一个应用程序的步骤,该应用程序利用组合框显示某些选项,并解释了如何显示来自组合框的所选选项:
-
创建一个没有按钮的对话框的新应用程序模板,从小部件框中拖动两个 Label 小部件和一个 Combo Box 小部件,并将它们放到表单中。
-
将第一个 Label 小部件的文本属性设置为
选择您的账户类型
。 -
删除第二个 Label 小部件的默认文本属性,因为其文本将通过代码设置。
-
将组合框小部件的 objectName 属性设置为
comboBoxAccountType
。 -
第二个 Label 小部件将用于显示用户选择的银行账户类型,因此将第二个 Label 小部件的 objectName 属性设置为
labelAccountType
。 -
由于我们希望组合框小部件显示特定的银行账户类型,因此右键单击组合框小部件,并从打开的上下文菜单中选择编辑项目选项。
-
逐个向组合框小部件添加一些银行账户类型。
-
将应用程序保存为
demoComboBox.ui
。 -
单击对话框底部显示的+按钮,将银行账户类型添加到组合框小部件中,如下截图所示:
- 在添加所需的银行账户类型后,单击“确定”按钮退出对话框。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。可以使用pyuic5
实用程序从 XML 文件生成 Python 代码。生成的文件demoComboBox.py
可以在本书的源代码包中看到。
-
将
demoComboBox.py
文件视为头文件,并将其导入到将调用其用户界面设计的文件中,这样您就可以访问组合框。 -
创建另一个名为
callComboBox.pyw
的 Python 文件,并将demoComboBox.py
的代码导入其中。Python 脚本callComboBox.pyw
中的代码如下所示:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoComboBox import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.comboBoxAccountType.currentIndexChanged.connect
(self.dispAccountType)
self.show()
def dispAccountType(self):
self.ui.labelAccountType.setText("You have selected
"+self.ui.comboBoxAccountType.itemText(self.ui.
comboBoxAccountType.currentIndex()))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理…
在demoComboBox.py
文件中,创建了一个名为顶级对象的类,其名称为Ui_ prepended
。也就是说,对于顶级对象Dialog
,创建了Ui_Dialog
类,并存储了我们小部件的接口元素。该类包括两种方法,setupUi
和retranslateUi
。
setupUi
方法创建了在 Qt Designer 中定义用户界面时使用的小部件。此方法还设置了小部件的属性。setupUi
方法接受一个参数,即应用程序的顶层小部件,即QDialog
的一个实例。retranslateUi
方法用于翻译界面。
在callComboBox.pyw
文件中,每当用户从组合框中选择任何项目时,currentIndexChanged
信号将被发射,并且currentIndexChanged
信号连接到dispAccountType
方法,因此每当从组合框中选择任何项目时,dispAccountType
方法将被调用。
在dispAccountType
方法中,通过调用QComboBox
类的currentIndex
方法来访问当前选定的索引号,并将获取的索引位置传递给QComboBox
类的itemText
方法,以获取当前选定的组合框项目的文本。然后通过标签小部件显示当前选定的组合框项目。
运行应用程序时,您将看到一个下拉框显示四种银行账户类型:储蓄账户、活期账户、定期存款账户和定期存款账户,如下截图所示:
从组合框中选择一个银行账户类型后,所选的银行账户类型将通过标签小部件显示,如下截图所示:
使用字体组合框小部件
字体组合框小部件,顾名思义,显示一个可选择的字体样式列表。如果需要,所选的字体样式可以应用到所需的内容中。
准备工作
为了实际理解字体组合框小部件的工作原理,让我们创建一个示例。这个示例将显示一个字体组合框小部件和一个文本编辑小部件。用户可以在文本编辑小部件中输入所需的内容。在文本编辑小部件中输入文本后,当用户从字体组合框小部件中选择任何字体样式时,所选字体将被应用到文本编辑小部件中输入的内容。
如何做…
以下是显示活动字体组合框小部件并将所选字体应用于文本编辑小部件中的文本的步骤:
-
创建一个没有按钮的对话框模板的新应用程序,并从小部件框中拖动两个标签小部件、一个字体组合框小部件和一个文本编辑小部件,并将它们放到表单上。
-
将第一个标签小部件的文本属性设置为
选择所需的字体
,将第二个标签小部件的文本属性设置为输入一些文本
。 -
将应用程序保存为
demoFontComboBox.ui
。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在一个.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。转换为 Python 代码后,生成的文件demoFontComboBox.py
将在本书的源代码包中可见。上述代码将被用作头文件,并被导入到需要 GUI 的文件中,也就是说,设计的用户界面可以通过简单地导入上述代码在任何 Python 脚本中访问。
- 创建另一个名为
callFontFontComboBox.pyw
的 Python 文件,并将demoFontComboBox.py
代码导入其中。
Python 脚本callFontComboBox.pyw
中的代码如下所示:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoFontComboBox import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
myFont=QtGui.QFont(self.ui.fontComboBox.itemText(self.ui.
fontComboBox.currentIndex()),15)
self.ui.textEdit.setFont(myFont)
self.ui.fontComboBox.currentFontChanged.connect
(self.changeFont)
self.show()
def changeFont(self):
myFont=QtGui.QFont(self.ui.fontComboBox.itemText(self.ui.
fontComboBox.currentIndex()),15)
self.ui.textEdit.setFont(myFont)
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
在callFontComboBox.pyw
文件中,每当用户从字体组合框小部件中选择任何字体样式时,将发射currentFontChanged
信号,并且该信号连接到changeFont
方法,因此每当从字体组合框小部件中选择任何字体样式时,将调用changeFont()
方法。
在changeFont()
方法中,通过调用两个方法来访问所选的字体样式。首先调用的是QFontComboBox
类的currentIndex()
方法,该方法获取所选字体样式的索引号。然后调用的是itemText()
方法,并将当前所选字体样式的索引位置传递给该方法,以访问所选的字体样式。然后将所选的字体样式应用于文本编辑小部件中的内容。
运行应用程序时,您将看到一个字体组合框小部件,显示系统中可用的字体样式,如下截图所示:
在文本编辑小部件中输入一些文本,并从字体组合框中选择所需的字体。所选的字体样式将应用于文本编辑小部件中的文本,如下截图所示:
使用进度条小部件
进度条小部件在表示任何任务的进度时非常有用。无论是从服务器下载文件,还是在计算机上进行病毒扫描,或者其他一些关键任务,进度条小部件都有助于通知用户任务完成的百分比和待处理的百分比。随着任务的完成,进度条小部件不断更新,指示任务的进展。
准备工作
为了理解如何更新进度条以显示任何任务的进度,让我们创建一个示例。这个示例将显示一个进度条小部件,指示下载文件所需的总时间。当用户点击推送按钮开始下载文件时,进度条小部件将从 0%逐渐更新到 100%;也就是说,随着文件的下载,进度条将更新。当文件完全下载时,进度条小部件将显示 100%。
如何做…
最初,进度条小部件为 0%,为了使其增加,我们需要使用循环。随着进度条小部件表示的任务向完成的进展,循环将增加其值。循环值的每次增加都会增加进度条小部件的一些进度。以下是逐步过程,展示了如何更新进度条:
-
从没有按钮的对话框模板创建一个新应用程序,并从小部件框中拖动一个标签小部件、一个进度条小部件和一个推送按钮小部件,然后将它们放到表单上。
-
将标签小部件的文本属性设置为
下载文件
,将推送按钮小部件的文本属性设置为开始下载
。 -
将推送按钮小部件的 objectName 属性设置为
pushButtonStart
。 -
将应用程序保存为
demoProgressBar.ui
。现在表单将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。生成的 Python 代码demoProgressBar.py
可以在本书的源代码包中找到。上述代码将用作头文件,并导入到需要 GUI 的文件中;也就是说,代码中设计的用户界面可以通过简单导入上述代码在任何 Python 脚本中访问。
- 创建另一个名为
callProgressBar.pyw
的 Python 文件,并将demoProgressBar.py
代码导入其中。Python 脚本callProgressBar.pyw
中的代码如下所示:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoProgressBar import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonStart.clicked.connect(self.updateBar)
self.show()
def updateBar(self):
x = 0
while x < 100:
x += 0.0001
self.ui.progressBar.setValue(x)
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理…
在callProgressBar.pyw
文件中,因为我们希望在按下按钮时进度条显示其进度,所以将进度条的 clicked()事件连接到updateBar()
方法,因此当按下按钮时,将调用updateBar()
方法。在updateBar()
方法中,使用了一个while
循环,从0
到100
循环。一个变量x
被初始化为值0
。在 while 循环的每次迭代中,变量x
的值增加了0.0001
。在更新进度条时,将x
变量的值应用于进度条。也就是说,每次 while 循环的迭代中,变量x
的值都会增加,并且变量x
的值会用于更新进度条。因此,进度条将从 0%开始逐渐增加,直到达到 100%。
在运行应用程序时,最初,您会发现进度条小部件为 0%,底部有一个带有标题“开始下载”的按钮(请参见以下屏幕截图)。单击“开始下载”按钮,您会看到进度条开始逐渐显示进度。进度条会持续增加,直到达到 100%,表示文件已完全下载:
第十五章:理解 OOP 概念
在本章中,我们将涵盖以下主题:
-
面向对象编程
-
在 GUI 中使用类
-
使用单一继承
-
使用多层继承
-
使用多重继承
面向对象编程
Python 支持面向对象编程(OOP)。OOP 支持可重用性;也就是说,之前编写的代码可以被重用来制作大型应用程序,而不是从头开始。OOP 中的对象指的是类的变量或实例,其中类是一个结构的模板或蓝图,包括方法和变量。类中的变量称为数据成员,方法称为成员函数。当类的实例或对象被创建时,对象会自动获得对数据成员和方法的访问。
创建一个类
class
语句用于创建一个类。以下是创建类的语法:
class class_name(base_classes):
statement(s)
这里,class_name
是一个标识符,用于标识类。在class
语句之后是构成类主体的语句。class
主体包括要在该类中定义的不同方法和变量。
您可以创建一个独立的类或继承另一个类。被继承的类称为基类。在语法中,class_name
后的base_classes
参数表示该类将继承的所有基类。如果有多个基类,则它们需要用逗号分隔。被继承的类称为超类或基类,继承的类称为派生类或子类。派生类可以使用基类的方法和变量,从而实现可重用性:
class Student:
name = ""
def __init__(self, name):
self.name = name
def printName(self):
return self.name
在此示例中,Student
是一个包含名为name
的属性的类,该属性初始化为 null。
使用内置类属性
class
语句会自动为某些固定的类属性分配特定的值。这些类属性可以用于获取有关类的信息。类属性的列表如下:
-
__name__
:表示class
语句中使用的类名 -
__bases__
:表示class
语句中提到的基类名称 -
__dict__
:表示其他类属性的字典对象 -
__module__
:表示定义类的模块名称
一个类可以有任意数量的方法,每个方法可以有任意数量的参数。方法中始终定义了一个强制的第一个参数,通常将该第一个参数命名为self
(尽管您可以为此参数指定任何名称)。self
参数指的是调用方法的类的实例。在类中定义方法的语法如下:
class class_name(base_classes):
Syntax:
variable(s)
def method 1(self):
statement(s)
[def method n(self):
statement(s)]
一个类可以有以下两种类型的数据成员:
-
类变量:这些是所有实例可共享的变量,任何一个实例对这些变量所做的更改也可以被其他实例看到。这些是在类的任何方法之外定义的数据成员。
-
实例变量:这些变量仅在方法内部定义,仅属于对象的当前实例,并且被称为实例变量。任何实例对实例变量所做的更改仅限于该特定实例,并不影响其他实例的实例变量。
让我们看看如何创建一个实例方法以及如何使用它来访问类变量。
在实例方法中访问类变量
要访问类变量,必须使用类名作为前缀。例如,要访问Student
类的name
类变量,需要按以下方式访问:
Student.name
您可以看到,name
类变量以Student
类名作为前缀。
实例
要使用任何类的变量和方法,我们需要创建其对象或实例。类的实例会得到自己的变量和方法的副本。这意味着一个实例的变量不会干扰另一个实例的变量。我们可以创建任意数量的类的实例。要创建类的实例,需要写类名,后跟参数(如果有)。例如,以下语句创建了一个名为studentObj
的Student
类的实例:
studentObj=Student()
可以创建任意数量的Student
类的实例。例如,以下行创建了Student
类的另一个实例:
courseStudent=Student()
现在,实例可以访问类的属性和方法。
在定义方法时需要明确指定self
。在调用方法时,self
不是必需的,因为 Python 会自动添加它。
要定义类的变量,我们需要使用__init__()
方法的帮助。__init__()
方法类似于传统面向对象编程语言中的构造函数,并且是在创建实例后首先执行的方法。它用于初始化类的变量。根据类中如何定义__init__()
方法,即是否带有参数,参数可能会传递给__init__()
方法,也可能不会。
如前所述,每个类方法的第一个参数是一个称为self
的类实例。在__init__()
方法中,self
指的是新创建的实例:
class Student:
name = ""
def __init__(self):
self.name = "David"
studentObj=Student()
在上面的例子中,studentObj
实例是正在创建的Student
类的实例,并且其类变量将被初始化为David
字符串。
甚至可以将参数传递给__init__()
方法,如下例所示:
class Student:
name = ""
def __init__(self, name):
self.name = name
studentObj=Student("David")
在上面的例子中,创建了studentObj
实例并将David
字符串传递给它。该字符串将被分配给__init__()
方法中定义的name
参数,然后用于初始化实例的类变量name
。请记住,__init__()
方法不能返回值。
与类变量一样,可以通过类的实例访问类的方法,后跟方法名,中间用句点(.
)分隔。假设Student
类中有一个printName()
方法,可以通过以下语句通过studentObj
实例访问:
studentObj.printName()
在 GUI 中使用类
通过 GUI 从用户接收的数据可以直接通过简单变量进行处理,并且处理后的数据只能通过变量显示。但是,为了保持数据的结构化格式并获得面向对象编程的好处,我们将学习将数据保存在类的形式中。也就是说,用户通过 GUI 访问的数据可以分配给类变量,通过类方法进行处理和显示。
让我们创建一个应用程序,提示用户输入姓名,并在输入姓名后点击推送按钮时,应用程序将显示一个 hello 消息以及输入的姓名。用户输入的姓名将被分配给一个类变量,并且 hello 消息也将通过调用类的类方法生成。
如何做...
本节的重点是理解用户输入的数据如何分配给类变量,以及如何通过类方法访问显示的消息。让我们基于没有按钮的对话框模板创建一个新应用程序,并按照以下步骤进行操作:
-
将两个标签小部件、一个行编辑和一个推送按钮小部件拖放到表单上。
-
将第一个标签小部件的文本属性设置为
输入您的姓名
。
让我们不要更改第二个标签小部件的文本属性,并将其文本属性保持为默认值TextLabel
。这是因为它的文本属性将通过代码设置以显示 hello 消息。
-
将推送按钮小部件的文本属性设置为
Click
。 -
将 LineEdit 小部件的 objectName 属性设置为
lineEditName
。 -
将 Label 小部件的 objectName 属性设置为
labelResponse
。 -
将 Push Button 小部件的 objectName 属性设置为
ButtonClickMe
。 -
将应用程序保存为名称为
LineEditClass.ui
的应用程序。应用程序将显示如下屏幕截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。
- 要进行转换,您需要打开命令提示符窗口,导航到保存文件的文件夹,并发出以下命令行:
C:\Pythonbook\PyQt5>pyuic5 LineEdit.uiClass -o LineEditClass.py
可以在本书的源代码包中看到生成的 Python 脚本LineEditClass.py
。
-
将上述代码视为头文件,并将其导入到将调用其用户界面设计的文件中。
-
创建另一个名为
callLineEditClass.pyw
的 Python 文件,并将LineEditClass.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from LineEditClass import *
class Student:
name = ""
def __init__(self, name):
self.name = name
def printName(self):
return self.name
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.ButtonClickMe.clicked.connect(self.dispmessage)
self.show()
def dispmessage(self):
studentObj=Student(self.ui.lineEditName.text())
self.ui.labelResponse.setText("Hello
"+studentObj.printName())
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
在LineEditClass.py
文件中,创建了一个名为顶级对象的类,其名称为Ui_ prepended
。也就是说,对于顶级对象Dialog
,创建了Ui_Dialog
类,并存储了小部件的接口元素。该类有两个方法,setupUi()
和retranslateUi()
。setupUi()
方法创建了在 Qt Designer 中定义用户界面时使用的小部件。此方法还设置了小部件的属性。setupUi()
方法接受一个参数,即应用程序的顶级小部件,即QDialog
的实例。retranslateUi()
方法翻译了界面。
在callLineEditClass.py
文件中,可以看到定义了一个名为Student
的类。Student
类包括一个名为name
的类变量和以下两个方法:
-
__init__()
: 这是一个构造函数,它接受强制的self
参数和一个name
参数,该参数将用于初始化name
类变量 -
printName
: 此方法简单地返回名称类变量中的值
将 Push Button 小部件的clicked()
事件连接到dispmessage()
方法;在 LineEdit 小部件中输入名称后,当用户单击按钮时,将调用dispmessage()
方法。dispmessage()
方法通过名称定义了Student
类的对象,studentObj
,并将用户在 LineEdit 小部件中输入的名称作为参数传递。因此,将调用Student
类的构造函数,并将用户输入的名称传递给构造函数。在 LineEdit 小部件中输入的名称将被分配给类变量name
。之后,名为labelResponse
的 Label 小部件将设置为显示字符串Hello
,并调用Student
类的printName
方法,该方法返回分配给名称变量的字符串。
因此,单击按钮后,Label 小部件将设置为显示字符串Hello
,然后是用户在 LineEdit 框中输入的名称,如下面的屏幕截图所示:
使应用程序更加详细
我们还可以在类中使用两个或更多类属性。
假设除了类名Student
之外,我们还想将学生的代码添加到类中。在这种情况下,我们需要向类中添加一个名为code
的属性,还需要一个getCode()
方法,该方法将访问分配的学生代码。除了类之外,GUI 也将发生变化。
我们需要向应用程序添加一个以上的 Label 小部件和一个 LineEdit 小部件,并将其保存为另一个名称demoStudentClass
。添加 Label 和 LineEdit 小部件后,用户界面将显示如下屏幕截图所示:
用户界面文件demoStudentClass.ui
需要转换为 Python 代码。可以在本书的源代码包中看到生成的 Python 脚本demoStudentClass.py
。
让我们创建另一个名为callStudentClass.pyw
的 Python 文件,并将demoStudentClass.py
代码导入其中。callStudentClass.pyw
中的代码如下:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoStudentClass import *
class Student:
name = ""
code = ""
def __init__(self, code, name):
self.code = code
self.name = name
def getCode(self):
return self.code
def getName(self):
return self.name
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.ButtonClickMe.clicked.connect(self.dispmessage)
self.show()
def dispmessage(self):
studentObj=Student(self.ui.lineEditCode.text(),
self.ui.lineEditName.text())
self.ui.labelResponse.setText("Code:
"+studentObj.getCode()+", Name:"+studentObj.getName())
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
在上述代码中,您可以看到定义了一个名为Student
的类。Student
类包括两个名为name
和code
的类变量。除了这两个类变量,Student
类还包括以下三个方法:
-
__init__()
: 这是一个构造函数,它接受强制的self
参数和两个参数,code
和name
,它们将用于初始化两个类变量,code
和name
-
getCode()
: 该方法简单地返回code
类变量中的值 -
getName()
: 该方法简单地返回name
类变量中的值
推按钮小部件的clicked()
事件连接到dispmessage()
方法;在行编辑小部件中输入代码和名称后,用户单击推按钮,将调用dispmessage()
方法。dispmessage()
方法通过名称定义Student
类的对象,studentObj
,并将用户在行编辑小部件中输入的代码和名称作为参数传递。Student
类的构造函数__init__()
将被调用,并将用户输入的代码和名称传递给它。输入的代码和名称将分别分配给类变量 code 和 name。之后,标签小部件称为labelResponse
被设置为通过Student
类的studentObj
对象调用两个方法getCode
和getName
显示输入的代码和名称。
因此,单击推按钮后,标签小部件将显示用户在两个行编辑小部件中输入的代码和名称,如下截图所示:
继承
继承是一个概念,通过该概念,现有类的方法和变量可以被另一个类重用,而无需重新编写它们。也就是说,经过测试和运行的现有代码可以立即在其他类中重用。
继承的类型
以下是三种继承类型:
-
单一继承: 一个类继承另一个类
-
多级继承: 一个类继承另一个类,而后者又被另一个类继承
-
多重继承: 一个类继承两个或更多个类
使用单一继承
单一继承是最简单的继承类型,其中一个类从另一个单一类派生,如下图所示:
类B继承类A。在这里,类A将被称为超类或基类,类B将被称为派生类或子类。
以下语句定义了单一继承,其中Marks
类继承了Student
类:
class Marks(Student):
在上述语句中,Student
是基类,Marks
是派生类。因此,Marks
类的实例可以访问Student
类的方法和变量。
准备就绪
为了通过一个运行示例理解单一继承的概念,让我们创建一个应用程序,提示用户输入学生的代码、名称、历史和地理成绩,并在单击按钮时显示它们。
用户输入的代码和名称将被分配给名为Student
的类的类成员。历史和地理成绩将被分配给名为Marks
的另一个类的类成员。
为了访问代码和名称,以及历史和地理成绩,Marks
类将继承Student
类。使用继承,Marks
类的实例将访问并显示Student
类的代码和名称。
如何做...
启动 Qt Designer,并根据以下步骤创建一个基于无按钮对话框模板的新应用程序:
-
在应用程序中,将五个标签小部件、四个行编辑小部件和一个按钮小部件拖放到表单上。
-
将四个标签小部件的文本属性设置为
学生代码
,学生姓名
,历史成绩
和地理成绩
。 -
删除第五个标签小部件的文本属性,因为它的文本属性将通过代码设置以显示代码、名称、历史和地理成绩。
-
将按钮小部件的文本属性设置为
点击
。 -
将四个行编辑小部件的 objectName 属性设置为
lineEditCode
,lineEditName
,lineEditHistoryMarks
和lineEditGeographyMarks
。 -
将标签小部件的 objectName 属性设置为
labelResponse
,将按钮小部件的 objectName 属性设置为ButtonClickMe
。 -
使用名称
demoSimpleInheritance.ui
保存应用程序。应用程序将显示如下截图所示:
用户界面文件demoSimpleInheritance.ui
是一个 XML 文件,并使用pyuic5
实用程序转换为 Python 代码。您可以在本书的源代码包中找到生成的 Python 脚本demoSimpleInheritance.py
。上述代码将被用作头文件,并将被导入到另一个 Python 脚本文件中,该文件将调用在demoSimpleInheritance.py
文件中定义的用户界面设计。
- 创建另一个名为
callSimpleInheritance.pyw
的 Python 文件,并将demoSimpleInheritance.py
代码导入其中。Python 脚本callSimpleInheritance.pyw
中的代码如下所示:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoSimpleInheritance import *
class Student:
name = ""
code = ""
def __init__(self, code, name):
self.code = code
self.name = name
def getCode(self):
return self.code
def getName(self):
return self.name
class Marks(Student):
historyMarks = 0
geographyMarks = 0
def __init__(self, code, name, historyMarks,
geographyMarks):
Student.__init__(self,code,name)
self.historyMarks = historyMarks
self.geographyMarks = geographyMarks
def getHistoryMarks(self):
return self.historyMarks
def getGeographyMarks(self):
return self.geographyMarks
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.ButtonClickMe.clicked.connect(self.dispmessage)
self.show()
def dispmessage(self):
marksObj=Marks(self.ui.lineEditCode.text(),
self.ui.lineEditName.text(),
self.ui.lineEditHistoryMarks.text(),
self.ui.lineEditGeographyMarks.text())
self.ui.labelResponse.setText("Code:
"+marksObj.getCode()+", Name:"+marksObj.getName()+"
nHistory Marks:"+marksObj.getHistoryMarks()+", Geography
Marks:"+marksObj.getGeographyMarks())
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
在这段代码中,您可以看到定义了一个名为Student
的类。Student
类包括两个名为name
和code
的类变量,以及以下三个方法:
-
__init__()
: 这是一个构造函数,它接受强制的self
参数和两个参数,code
和name
,这些参数将用于初始化两个类变量,code
和name
-
getCode()
: 这个方法简单地返回code
类变量中的值 -
getName()
: 这个方法简单地返回name
类变量中的值
Marks
类继承了Student
类。因此,Marks
类的实例不仅能够访问自己的成员,还能够访问Student
类的成员。
Marks
类包括两个名为historyMarks
和geographyMarks
的类变量,以及以下三个方法:
-
__init__()
: 这是一个构造函数,它接受强制的self
参数和四个参数,code
,name
,historyMarks
和geographyMarks
。从这个构造函数中,将调用Student
类的构造函数,并将code
和name
参数传递给这个构造函数。historyMarks
和geographyMarks
参数将用于初始化类成员historyMarks
和geographyMarks
。 -
getHistoryMarks()
: 这个方法简单地返回historyMarks
类变量中的值。 -
getGeographyMarks()
: 这个方法简单地返回geographyMarks
类变量中的值。
按钮的clicked()
事件连接到dispmessage()
方法。在 Line Edit 小部件中输入代码、姓名、历史和地理成绩后,用户单击按钮时,将调用dispmessage()
方法。dispmessage()
方法通过名称定义了Marks
类的对象marksObj
,并将用户在 Line Edit 小部件中输入的代码、姓名、历史和地理成绩作为参数传递。Marks
类的构造函数__init__()
将被调用,并将用户输入的代码、姓名、历史和地理成绩传递给它。从Marks
类的构造函数中,将调用Student
类的构造函数,并将code
和name
传递给该构造函数。code
和name
参数将分别分配给Student
类的code
和name
类变量。
类似地,历史和地理成绩将分配给Marks
类的historyMarks
和geographyMarks
类变量。之后,将设置名为labelResponse
的 Label 小部件,以通过调用四个方法getCode
、getName
、getHistoryMarks
和getGeographyMarks
来显示用户输入的代码、姓名、历史和地理成绩。通过marksObj
对象,Marks
类的marksObj
对象获得了访问Student
类的getCode
和getName
方法的权限,因为使用了继承。
因此,单击按钮后,Label 小部件将通过名为labelResponse
的 Label 小部件显示用户输入的代码、姓名、历史成绩和地理成绩,如下图所示:
使用多级继承
多级继承是指一个类继承另一个单一类。转而继承第三个类,如下图所示:
在上图中,您可以看到类B继承了类A,而类C又继承了类B。
以下语句定义了多级继承,其中Result
类继承了Marks
类,而Marks
类又继承了Student
类:
class Student:
class Marks(Student):
class Result(Marks):
在上述语句中,Student
是基类,Marks
类继承了Student
类。Result
类继承了Marks
类。因此,Result
类的实例可以访问Marks
类的方法和变量,而Marks
类的实例可以访问Student
类的方法和变量。
准备就绪
为了理解多级继承的概念,让我们创建一个应用程序,提示用户输入学生的代码、姓名、历史成绩和地理成绩,并在单击按钮时显示总分和百分比。总分将是历史成绩和地理成绩的总和。假设最高分为 100,计算百分比的公式为:总分/200 * 100。
用户输入的代码和姓名将分配给名为Student
的类的类成员。历史和地理成绩将分配给名为Marks
的另一个类的类成员。
为了访问代码和姓名以及历史和地理成绩,Marks
类将继承Student
类。
使用这种多层继承,Marks
类的实例将访问Student
类的代码和名称。为了计算总分和百分比,还使用了一个名为Result
的类。Result
类将继承Marks
类。因此,Result
类的实例可以访问Marks
类的类成员,以及Student
类的成员。Result
类有两个类成员,totalMarks
和percentage
。totalMarks
类成员将被分配为Marks
类的historyMarks
和geographyMarks
成员的总和。百分比成员将根据历史和地理成绩获得的百分比进行分配。
如何做到...
总之,有三个类,名为Student
,Marks
和Result
,其中Result
类将继承Marks
类,而Marks
类将继承Student
类。因此,Result
类的成员可以访问Marks
类的类成员以及Student
类的成员。以下是创建此应用程序的逐步过程:
-
启动 Qt Designer 并基于无按钮模板创建一个新应用程序。
-
将六个 Label 小部件、六个 Line Edit 小部件和一个 Push Button 小部件拖放到表单上。
-
将六个 Label 小部件的文本属性设置为
Student Code
、Student Name
、History Marks
、Geography Marks
、Total
和Percentage
。 -
将 Push Button 小部件的文本属性设置为
Click
。 -
将六个 Line Edit 小部件的对象名称属性设置为
lineEditCode
、lineEditName
、lineEditHistoryMarks
、lineEditGeographyMarks
、lineEditTotal
和lineEditPercentage
。 -
将 Push Button 小部件的对象名称属性设置为
ButtonClickMe
。 -
通过取消选中属性编辑器窗口中的启用属性,禁用
lineEditTotal
和lineEditPercentage
框。lineEditTotal
和lineEditPercentage
小部件被禁用,因为这些框中的值将通过代码分配,我们不希望用户更改它们的值。 -
使用名称
demoMultilevelInheritance.ui
保存应用程序。应用程序将显示如下截图所示:
用户界面文件demoMultilevelInheritance.ui
是一个 XML 文件,并通过使用pyuic5
实用程序将其转换为 Python 代码。您可以在本书的源代码包中看到生成的 Python 脚本demoMultilevelInheritance.py
。demoMultilevelInheritance.py
文件将用作头文件,并将在另一个 Python 脚本文件中导入,该文件将使用在demoMultilevelInheritance.py
中创建的 GUI。
- 创建另一个名为
callMultilevelInheritance.pyw
的 Python 文件,并将demoMultilevelInheritance.py
代码导入其中。Python 脚本callMultilevelInheritance.pyw
中的代码如下所示:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoMultilevelInheritance import *
class Student:
name = ""
code = ""
def __init__(self, code, name):
self.code = code
self.name = name
def getCode(self):
return self.code
def getName(self):
return self.name
class Marks(Student):
historyMarks = 0
geographyMarks = 0
def __init__(self, code, name, historyMarks,
geographyMarks):
Student.__init__(self,code,name)
self.historyMarks = historyMarks
self.geographyMarks = geographyMarks
def getHistoryMarks(self):
return self.historyMarks
def getGeographyMarks(self):
return self.geographyMarks
class Result(Marks):
totalMarks = 0
percentage = 0
def __init__(self, code, name, historyMarks,
geographyMarks):
Marks.__init__(self, code, name, historyMarks,
geographyMarks)
self.totalMarks = historyMarks + geographyMarks
self.percentage = (historyMarks +
geographyMarks) / 200 * 100
def getTotalMarks(self):
return self.totalMarks
def getPercentage(self):
return self.percentage
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.ButtonClickMe.clicked.connect(self.dispmessage)
self.show()
def dispmessage(self):
resultObj=Result(self.ui.lineEditCode.text(),
self.ui.lineEditName.text(),
int(self.ui.lineEditHistoryMarks.text()),
int(self.ui.lineEditGeographyMarks.text()))
self.ui.lineEditTotal.setText(str(resultObj.
getTotalMarks()))
self.ui.lineEditPercentage.setText(str(resultObj.
getPercentage()))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
在上述代码中,在callMultilevelInheritance.pyw
文件中,您可以看到定义了一个名为Student
的类。Student
类包括两个名为name
和code
的类变量,以及以下三种方法:
-
__init__()
: 这是一个构造函数,它接受强制的self
参数和两个参数,code
和name
,用于初始化两个类变量code
和name
-
getCode()
: 此方法简单地返回code
类变量中的值 -
getName()
: 此方法简单地返回name
类变量中的值
Marks
类继承Student
类。因此,Marks
类的实例不仅能够访问自己的成员,还能够访问Student
类的成员。
Marks
类包括两个名为historyMarks
和geographyMarks
的类变量,以及以下三种方法:
-
__init__()
: 这是一个构造函数,它接受强制的self
参数和四个参数,code
、name
、historyMarks
和geographyMarks
。从这个构造函数中,将调用Student
类的构造函数,并将code
和name
参数传递给该构造函数。historyMarks
和geographyMarks
参数将用于初始化historyMarks
和geographyMarks
类成员。 -
getHistoryMarks()
: 此方法简单地返回historyMarks
类变量中的值。 -
getGeographyMarks()
: 此方法简单地返回geographyMarks
类变量中的值。
Result
类继承了Marks
类。Result
类的实例不仅能够访问自己的成员,还能访问Marks
类和Student
类的成员。
Result
类包括两个类变量,称为totalMarks
和percentage
,以及以下三个方法:
-
__init__()
: 这是一个构造函数,它接受强制的self
参数和四个参数,code
、name
、historyMarks
和geographyMarks
。从这个构造函数中,将调用Marks
类的构造函数,并将code
、name
、historyMarks
和geographyMarks
参数传递给该构造函数。historyMarks
和geographyMarks
的总和将被赋给totalMarks
类变量。假设每个的最高分为 100,将计算历史和地理成绩的百分比,并将其分配给百分比类变量。 -
getTotalMarks()
: 此方法简单地返回historyMarks
和geographyMarks
类变量的总和。 -
getPercentage()
: 此方法简单地返回历史和地理成绩的百分比。
按钮小部件的clicked()
事件连接到dispmessage()
方法。在行编辑小部件中输入代码、姓名、历史成绩和地理成绩后,用户单击按钮,将调用dispmessage()
方法。dispmessage()
方法通过姓名resultObj
定义Result
类的对象,并将用户在行编辑小部件中输入的代码、姓名、历史和地理成绩作为参数传递。Result
类的构造函数__init__()
将被调用,并将用户输入的代码、姓名、历史成绩和地理成绩传递给它。从Result
类的构造函数中,将调用Marks
类的构造函数,并将代码、姓名、历史成绩和地理成绩传递给该构造函数。从Marks
类的构造函数中,将调用Student
类的构造函数,并将code
和name
参数传递给它。在Student
类的构造函数中,code
和name
参数将分配给类变量code
和name
。类似地,历史和地理成绩将分配给Marks
类的historyMarks
和geographyMarks
类变量。
historyMarks
和geographyMarks
的总和将被赋给totalMarks
类变量。此外,历史和地理成绩的百分比将被计算并赋给percentage
类变量。
之后,称为lineEditTotal
的行编辑小部件被设置为通过resultObj
调用getTotalMarks
方法来显示总分,即历史和地理成绩的总和。同样,称为lineEditPercentage
的行编辑小部件被设置为通过resultObj
调用getPercentage
方法来显示百分比。
因此,单击按钮后,称为lineEditTotal
和lineEditPercentage
的行编辑小部件将显示用户输入的历史和地理成绩的总分和百分比,如下截图所示:
使用多重继承
多重继承是指一个类继承了两个或更多个类,如下图所示:
类C同时继承类A和类B。
以下语句定义了多级继承,其中Result
类继承了Marks
类,而Marks
类又继承了Student
类:
class Student:
class Marks:
class Result(Student, Marks):
在前面的语句中,Student
和Marks
是基类,Result
类继承了Student
类和Marks
类。因此,Result
类的实例可以访问Marks
和Student
类的方法和变量。
准备就绪
为了实际理解多级继承的概念,让我们创建一个应用程序,提示用户输入学生的代码、姓名、历史成绩和地理成绩,并在单击按钮时显示总分和百分比。总分将是历史成绩和地理成绩的总和。假设每个的最高分为 100,计算百分比的公式为:总分/200 * 100。
用户输入的代码和姓名将分配给一个名为Student
的类的类成员。历史和地理成绩将分配给另一个名为Marks
的类的类成员。
为了访问代码和姓名,以及历史和地理成绩,Result
类将同时继承Student
类和Marks
类。使用这种多重继承,Result
类的实例可以访问Student
类的代码和姓名,以及Marks
类的historyMarks
和geographyMarks
类变量。换句话说,使用多重继承,Result
类的实例可以访问Marks
类的类成员,以及Student
类的类成员。Result
类有两个类成员,totalMarks
和percentage
。totalMarks
类成员将被分配为Marks
类的historyMarks
和geographyMarks
成员的总和。百分比成员将根据历史和地理成绩的基础上获得的百分比进行分配。
如何做...
让我们通过逐步过程来了解多级继承如何应用于三个类,Student
,Marks
和Result
。Result
类将同时继承Student
和Marks
两个类。这些步骤解释了Result
类的成员如何通过多级继承访问Student
和Marks
类的类成员:
-
启动 Qt Designer,并基于无按钮的对话框模板创建一个新应用程序。
-
在应用程序中,将六个标签小部件、六个行编辑小部件和一个按钮小部件拖放到表单上。
-
将六个标签小部件的文本属性设置为
学生代码
,学生姓名
,历史成绩
,地理成绩
,总分
和百分比
。 -
将按钮小部件的文本属性设置为
点击
。 -
将六个行编辑小部件的 objectName 属性设置为
lineEditCode
,lineEditName
,lineEditHistoryMarks
,lineEditGeographyMarks
,lineEditTotal
和lineEditPercentage
。 -
将按钮小部件的 objectName 属性设置为
ButtonClickMe
。 -
通过取消选中属性编辑器窗口中的启用属性,禁用
lineEditTotal
和lineEditPercentage
框。lineEditTotal
和lineEditPercentage
框被禁用,因为这些框中的值将通过代码分配,并且我们不希望用户更改它们的值。 -
使用名称
demoMultipleInheritance.ui
保存应用程序。应用程序将显示如下截图所示:
用户界面文件demoMultipleInheritance .ui
是一个 XML 文件,并使用pyuic5
实用程序转换为 Python 代码。您可以在本书的源代码包中找到生成的 Python 代码demoMultipleInheritance.py
。demoMultipleInheritance.py
文件将被用作头文件,并将在另一个 Python 脚本文件中导入,该文件将调用在demoMultipleInheritance.py
文件中创建的 GUI。
- 创建另一个名为
callMultipleInheritance.pyw
的 Python 文件,并将demoMultipleInheritance.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoMultipleInheritance import *
class Student:
name = ""
code = ""
def __init__(self, code, name):
self.code = code
self.name = name
def getCode(self):
return self.code
def getName(self):
return self.name
class Marks:
historyMarks = 0
geographyMarks = 0
def __init__(self, historyMarks, geographyMarks):
self.historyMarks = historyMarks
self.geographyMarks = geographyMarks
def getHistoryMarks(self):
return self.historyMarks
def getGeographyMarks(self):
return self.geographyMarks
class Result(Student, Marks):
totalMarks = 0
percentage = 0
def __init__(self, code, name, historyMarks,
geographyMarks):
Student.__init__(self, code, name)
Marks.__init__(self, historyMarks, geographyMarks)
self.totalMarks = historyMarks + geographyMarks
self.percentage = (historyMarks +
geographyMarks) / 200 * 100
def getTotalMarks(self):
return self.totalMarks
def getPercentage(self):
return self.percentage
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.ButtonClickMe.clicked.connect(self.dispmessage)
self.show()
def dispmessage(self):
resultObj=Result(self.ui.lineEditCode.text(),
self.ui.lineEditName.text(),
int(self.ui.lineEditHistoryMarks.text()),
int(self.ui.lineEditGeographyMarks.text()))
self.ui.lineEditTotal.setText(str(resultObj.
getTotalMarks()))
self.ui.lineEditPercentage.setText(str(resultObj.
getPercentage()))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
在这段代码中,您可以看到定义了一个名为Student
的类。Student
类包括两个名为name
和code
的类变量,以及以下三种方法:
-
__init__()
: 这是一个构造函数,它接受强制的self
参数和两个参数,code
和name
,这些参数将用于初始化两个类变量code
和name
。 -
getCode()
: 此方法简单地返回code
类变量中的值 -
getName()
: 此方法简单地返回name
类变量中的值
Marks
类包括两个类变量,名为historyMarks
和geographyMarks
,以及以下三种方法:
-
__init__()
: 这是一个构造函数,它接受强制的self
参数和两个参数,historyMarks
和geographyMarks
。historyMarks
和geographyMarks
参数将用于初始化historyMarks
和geographyMarks
类成员。 -
getHistoryMarks()
: 此方法简单地返回historyMarks
类变量中的值。 -
getGeographyMarks()
: 此方法简单地返回geographyMarks
类变量中的值。
Result
类继承了Student
类以及Marks
类。Result
类的实例不仅能够访问自己的成员,还能够访问Marks
类和Student
类的成员。
Result
类包括两个名为totalMarks
和percentage
的类变量,以及以下三种方法:
-
__init__()
: 这是一个构造函数,它接受强制的self
参数和四个参数,code
、name
、historyMarks
和geographyMarks
。从这个构造函数中,将调用Student
类的构造函数,并将code
和name
参数传递给该构造函数。同样,从这个构造函数中,将调用Marks
类的构造函数,并将historyMarks
和geographyMarks
参数传递给该构造函数。historyMarks
和geographyMarks
的总和将被分配给totalMarks
类变量。假设每个的最高分数为 100,将计算历史和地理成绩的百分比,并将其分配给percentage
类变量。 -
getTotalMarks()
: 此方法简单地返回historyMarks
和geography
类变量的总和。 -
getPercentage()
: 此方法简单地返回历史和地理成绩的百分比。
按钮小部件的clicked()
事件连接到dispmessage()
方法。在 LineEdit 小部件中输入代码、名称、历史成绩和地理成绩后,当用户单击按钮时,将调用dispmessage()
方法。dispmessage()
方法通过名称定义了Result
类的对象,resultObj
,并将用户在 LineEdit 小部件中输入的代码、名称、历史成绩和地理成绩作为参数传递。将调用Result
类的构造函数__init__()
,并将用户输入的代码、名称、历史成绩和地理成绩传递给它。从Result
类的构造函数中,将调用Student
类的构造函数和Marks
类的构造函数。代码和名称将传递给Student
类的构造函数,历史和地理成绩将传递给Marks
类的构造函数。
在Student
类构造函数中,代码和名称将分配给code
和name
类变量。同样,在Marks
类构造函数中,历史和地理成绩将分配给Marks
类的historyMarks
和geographyMarks
类变量。
historyMarks
和geographyMarks
的总和将分配给totalMarks
类变量。此外,历史和地理成绩的百分比将计算并分配给percentage
类变量。
之后,LineEdit 小部件称为lineEditTotal
被设置为通过resultObj
调用getTotalMarks
方法来显示总分,即历史和地理成绩的总和。同样,LineEdit 小部件称为lineEditPercentage
被设置为通过resultObj
调用getPercentage
方法来显示百分比。
因此,单击按钮后,LineEdit 小部件称为lineEditTotal
和lineEditPercentage
将显示用户输入的历史和地理成绩的总分和百分比,如下面的屏幕截图所示:
第十六章:理解对话框
在本章中,我们将学习如何使用以下类型的对话框:
-
输入对话框
-
使用输入对话框
-
使用颜色对话框
-
使用字体对话框
-
使用文件对话框
介绍
在所有应用程序中都需要对话框来从用户那里获取输入,还要指导用户输入正确的数据。交互式对话框也使应用程序变得非常用户友好。基本上有以下两种类型的对话框:
-
模态对话框:模态对话框是一种要求用户输入强制信息的对话框。这种对话框在关闭之前不允许用户使用应用程序的其他部分。也就是说,用户需要在模态对话框中输入所需的信息,关闭对话框后,用户才能访问应用程序的其余部分。
-
非模态或无模式对话框:这些对话框使用户能够与应用程序的其余部分和对话框进行交互。也就是说,用户可以在保持无模式对话框打开的同时继续与应用程序的其余部分进行交互。这就是为什么无模式对话框通常用于从用户那里获取非必要或非关键信息。
输入对话框
使用QInputDialog
类来创建输入对话框。QInputDialog
类提供了一个对话框,用于从用户那里获取单个值。提供的输入对话框包括一个文本字段和两个按钮,OK 和 Cancel。文本字段使我们能够从用户那里获取单个值,该单个值可以是字符串、数字或列表中的项目。以下是QInputDialog
类提供的方法,用于接受用户不同类型的输入:
getInt()
:该方法显示一个旋转框以接受整数。要从用户那里得到一个整数,您需要使用以下语法:
getInt(self, window title, label before LineEdit widget, default value, minimum, maximum and step size)
看一下下面的例子:
quantity, ok = QInputDialog.getInt(self, "Order Quantity", "Enter quantity:", 2, 1, 100, 1)
前面的代码提示用户输入数量。如果用户没有输入任何值,则默认值2
将被赋给quantity
变量。用户可以输入1
到100
之间的任何值。
getDouble()
:该方法显示一个带有浮点数的旋转框,以接受小数值。要从用户那里得到一个小数值,您需要使用以下语法:
getDouble(self, window title, label before LineEdit widget, default value, minimum, maximum and number of decimal places desired)
看一下下面的例子:
price, ok = QInputDialog.getDouble(self, "Price of the product", "Enter price:", 1.50,0, 100, 2)
前面的代码提示用户输入产品的价格。如果用户没有输入任何值,则默认值1.50
将被赋给price
变量。用户可以输入0
到100
之间的任何值。
getText()
:该方法显示一个 Line Edit 小部件,以从用户那里接受文本。要从用户那里获取文本,您需要使用以下语法:
getText(self, window title, label before LineEdit widget)
看一下下面的例子:
name, ok = QtGui.QInputDialog.getText(self, 'Get Customer Name', 'Enter your name:')
前面的代码将显示一个标题为“获取客户名称”的输入对话框。对话框还将显示一个 Line Edit 小部件,允许用户输入一些文本。在 Line Edit 小部件之前还将显示一个 Label 小部件,显示文本“输入您的姓名:”。在对话框中输入的客户姓名将被赋给name
变量。
getItem()
:该方法显示一个下拉框,显示多个可供选择的项目。要从下拉框中获取项目,您需要使用以下语法:
getItem(self, window title, label before combo box, array , current item, Boolean Editable)
这里,array
是需要在下拉框中显示的项目列表。current item
是在下拉框中被视为当前项目的项目。Editable
是布尔值,如果设置为True
,则意味着用户可以编辑下拉框并输入自己的文本。当Editable
设置为False
时,这意味着用户只能从下拉框中选择项目,但不能编辑项目。看一下下面的例子:
countryName, ok = QInputDialog.getItem(self, "Input Dialog", "List of countries", countries, 0, False)
上述代码将显示一个标题为“输入对话框”的输入对话框。对话框显示一个下拉框,其中显示了通过 countries 数组的元素显示的国家列表。下拉框之前的 Label 小部件显示文本“国家列表”。从下拉框中选择的国家名称将被分配给countryName
变量。用户只能从下拉框中选择国家,但不能编辑任何国家名称。
使用输入对话框
输入对话框可以接受任何类型的数据,包括整数、双精度和文本。在本示例中,我们将学习如何从用户那里获取文本。我们将利用输入对话框来了解用户所居住的国家的名称。
输入对话框将显示一个显示不同国家名称的下拉框。通过名称选择国家后,所选的国家名称将显示在文本框中。
如何做...
让我们根据没有按钮的对话框模板创建一个新的应用程序,执行以下步骤:
-
由于应用程序将提示用户通过输入对话框选择所居住的国家,因此将一个 Label 小部件、一个 Line Edit 小部件和一个 Push Button 小部件拖放到表单中。
-
将 Label 小部件的文本属性设置为“你的国家”。
-
将 Push Button 小部件的文本属性设置为“选择国家”。
-
将 Line Edit 小部件的 objectName 属性设置为
lineEditCountry
。 -
将 Push Button 小部件的 objectName 属性设置为
pushButtonCountry
。 -
将应用程序保存为
demoInputDialog.ui
。
现在表单将如下所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。
- 要进行转换,您需要打开一个命令提示符窗口,导航到保存文件的文件夹,并发出以下命令行:
C:\Pythonbook\PyQt5>pyuic5 demoInputDialog.ui -o demoInputDialog.py
您可以在本书的源代码包中找到生成的 Python 脚本demoInputDialog.py
。
-
将
demoInputDialog.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callInputDialog.pyw
的 Python 文件,并将demoInputDialog.py
的代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication, QInputDialog
from demoInputDialog import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonCountry.clicked.connect(self.dispmessage)
self.show()
def dispmessage(self):
countries = ("Albania", "Algeria", "Andorra", "Angola",
"Antigua and Barbuda", "Argentina", "Armenia", "Aruba",
"Australia", "Austria", "Azerbaijan")
countryName, ok = QInputDialog.getItem(self, "Input
Dialog", "List of countries", countries, 0, False)
if ok and countryName:
self.ui.lineEditCountry.setText(countryName)
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
在demoInputDialog.py
文件中,创建一个名为顶层对象的类,前面加上Ui_
。也就是说,对于顶层对象 Dialog,创建了Ui_Dialog
类,并存储了我们小部件的接口元素。该类有两个方法,setupUi()
和retranslateUi()
。
setupUi()
方法创建了在 Qt Designer 中定义用户界面中使用的小部件。此方法还设置了小部件的属性。setupUi()
方法接受一个参数,即应用程序的顶层小部件,即QDialog
的一个实例。retranslateUi()
方法翻译了界面。
在callInputDialog.pyw
文件中,可以看到 Push Button 小部件的单击事件连接到dispmessage()
方法,该方法用于选择国家;当用户单击推送按钮时,将调用dispmessage()
方法。dispmessage()
方法定义了一个名为 countries 的字符串数组,其中包含了几个国家名称的数组元素。之后,调用QInputDialog
类的getItem
方法,打开一个显示下拉框的输入对话框。当用户单击下拉框时,它会展开,显示分配给countries
字符串数组的国家名称。当用户选择一个国家,然后单击对话框中的 OK 按钮,所选的国家名称将被分配给countryName
变量。然后,所选的国家名称将通过 Line Edit 小部件显示出来。
运行应用程序时,您将得到一个空的 Line Edit 小部件和一个名为“选择国家”的推送按钮,如下截图所示:
单击“选择国家”按钮后,输入对话框框将打开,如下截图所示。输入对话框显示一个组合框以及两个按钮“确定”和“取消”。单击组合框,它将展开显示所有国家名称,如下截图所示:
从组合框中选择国家名称,然后单击“确定”按钮后,所选国家名称将显示在行编辑框中,如下截图所示:
使用颜色对话框
在本教程中,我们将学习使用颜色对话框显示颜色调色板,允许用户从调色板中选择预定义的颜色或创建新的自定义颜色。
该应用程序包括一个框架,当用户从颜色对话框中选择任何颜色时,所选颜色将应用于框架。除此之外,所选颜色的十六进制代码也将通过 Label 小部件显示。
在本教程中,我们将使用QColorDialog
类,该类提供了一个用于选择颜色值的对话框小部件。
如何做到...
让我们根据以下步骤创建一个基于无按钮对话框模板的新应用程序:
-
将一个 Push Button、一个 Frame 和一个 Label 小部件拖放到表单上。
-
将 Push Button 小部件的文本属性设置为“选择颜色”。
-
将 Push Button 小部件的 objectName 属性设置为
pushButtonColor
。 -
将 Frame 小部件的 objectName 属性设置为
frameColor
。 -
将 Label 小部件设置为
labelColor
。 -
将应用程序保存为
demoColorDialog.ui
。
表格现在将如下所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件。您可以使用pyuic5
实用程序将 XML 文件转换为 Python 代码。生成的 Python 脚本demoColorDialog.py
可以在本书的源代码包中找到。demoColorDialog.py
脚本将用作头文件,并将在另一个 Python 脚本文件中导入,该文件将调用此用户界面设计。
- 创建另一个名为
callColorDialog.pyw
的 Python 文件,并将demoColorDialog.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication, QColorDialog
from PyQt5.QtGui import QColor
from demoColorDialog import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
col = QColor(0, 0, 0)
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.frameColor.setStyleSheet("QWidget { background-
color: %s }" % col.name())
self.ui.pushButtonColor.clicked.connect(self.dispcolor)
self.show()
def dispcolor(self):
col = QColorDialog.getColor()
if col.isValid():
self.ui.frameColor.setStyleSheet("QWidget { background-
color: %s }" % col.name())
self.ui.labelColor.setText("You have selected the color with
code: " + str(col.name()))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
在callColorDialog.pyw
文件中,您可以看到按钮的 click()事件连接到dispcolor()
方法;也就是说,当用户单击“选择颜色”按钮时,将调用dispcolor()
方法。dispmessage()
方法调用QColorDialog
类的getColor()
方法,打开一个显示不同颜色的对话框。用户不仅可以从对话框中选择任何预定义的基本颜色,还可以创建新的自定义颜色。选择所需的颜色后,当用户从颜色对话框中单击“确定”按钮时,所选颜色将通过在 Frame 小部件类上调用setStyleSheet()
方法来分配给框架。此外,所选颜色的十六进制代码也通过 Label 小部件显示。
运行应用程序时,最初会看到一个按钮“选择颜色”,以及一个默认填充为黑色的框架,如下截图所示:
单击“选择颜色”按钮,颜色对话框将打开,显示以下截图中显示的基本颜色。颜色对话框还可以让您创建自定义颜色:
选择颜色后,单击“确定”按钮,所选颜色将应用于框架,并且所选颜色的十六进制代码将通过 Label 小部件显示,如下截图所示:
使用字体对话框
在本教程中,我们将学习使用字体对话框为所选文本应用不同的字体和样式。
在这个应用程序中,我们将使用 Text Edit 小部件和 Push Button 小部件。点击按钮后,将打开字体对话框。从字体对话框中选择的字体和样式将应用于 Text Edit 小部件中的文本。
在这个示例中,我们将使用QFontDialog
类,该类显示一个用于选择字体的对话框小部件。
如何做...
让我们根据无按钮模板创建一个新的应用程序,执行以下步骤:
-
将一个 Push Button 和一个 Text Edit 小部件拖放到表单上。
-
将 Push Button 小部件的文本属性设置为
Choose Font
。 -
将 Push Button 小部件的 objectName 属性设置为
pushButtonFont
。 -
将应用程序保存为
demoFontDialog.ui
。 -
执行上述步骤后,应用程序将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件。使用pyuic5
命令,您可以将 XML 文件转换为 Python 代码。生成的 Python 脚本demoFontDialog.py
可以在本书的源代码包中找到。demoFontDialog.py
脚本将被用作头文件,并将在另一个 Python 脚本文件中导入,该文件将调用此用户界面设计。
- 创建另一个名为
callFontDialog.pyw
的 Python 文件,并将demoFontDialog.py
代码导入其中。
import sys
from PyQt5.QtWidgets import QDialog, QApplication, QFontDialog
from demoFontDialog import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonFont.clicked.connect(self.changefont)
self.show()
def changefont(self):
font, ok = QFontDialog.getFont()
if ok:
self.ui.textEdit.setFont(font)
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
在callFontDialog.pyw
文件中,您可以看到将 push button 的 click()事件连接到changefont()
方法;也就是说,当用户点击 Choose Font 按钮时,将调用change()
方法。changefont()
方法调用QFontDialog
类的getFont()
方法,打开一个对话框,显示不同的字体、字体样式、大小和效果。选择字体、字体样式、大小或效果后,将在示例框中显示文本的选择效果。选择所需的字体、字体样式、大小和效果后,当用户点击 OK 按钮时,所选的选择将被分配给font
变量。随后,在TextEdit
类上调用setFont()
方法,将所选的字体和样式应用于通过 Text Edit 小部件显示的文本。
运行应用程序后,您将看到一个按钮,Change Font 小部件和 Text Edit 小部件,如下截图所示:
要查看从字体对话框中选择的字体的影响,您需要在 Text Edit 小部件中输入一些文本,如下截图所示:
选择 Change Font 按钮后,字体对话框将打开,如下截图所示。您可以看到不同的字体名称将显示在最左边的选项卡上。中间选项卡显示不同的字体样式,使您可以使文本以粗体、斜体、粗斜体和常规形式显示。最右边的选项卡显示不同的大小。在底部,您可以看到不同的复选框,使您可以使文本显示为下划线、删除线等。从任何选项卡中选择选项,所选字体和样式对示例框中显示的示例文本的影响可见。选择所需的字体和样式后,点击 OK 按钮关闭字体对话框:
所选字体和样式的效果将显示在 Text Edit 小部件中显示的文本上,如下截图所示:
使用文件对话框
在这个示例中,我们将学习使用文件对话框,了解如何执行不同的文件操作,如打开文件和保存文件。
我们将学习创建一个包含两个菜单项 Open 和 Save 的文件菜单。单击 Open 菜单项后,将打开文件打开对话框,帮助浏览和选择要打开的文件。打开文件的文件内容将显示在文本编辑框中。用户甚至可以在需要时更新文件内容。在对文件进行所需的修改后,当用户从文件菜单中单击 Save 选项时,文件内容将被更新。
准备工作
在这个教程中,我们将使用QFileDialog
类,该类显示一个对话框,允许用户选择文件或目录。文件可以用于打开和保存。
在这个教程中,我将使用QFileDialog
类的以下两种方法:
getOpenFileName()
: 该方法打开文件对话框,使用户可以浏览目录并打开所需的文件。getOpenFileName()
方法的语法如下:
file_name = QFileDialog.getOpenFileName(self, dialog_title, path, filter)
在上述代码中,filter
表示文件扩展名;它确定要显示的文件类型,例如如下所示:
file_name = QFileDialog.getOpenFileName(self, 'Open file', '/home')
In the preceding example, file dialog is opened that shows all the files of home directory to browse from.
file_name = QFileDialog.getOpenFileName(self, 'Open file', '/home', "Images (*.png *.jpg);;Text files (.txt);;XML files (*.xml)")
在上面的示例中,您可以看到来自home
目录的文件。对话框中将显示扩展名为.png
、.jpg
、.txt
和.xml
的文件。
getSaveFileName()
: 该方法打开文件保存对话框,使用户可以以所需的名称和所需的文件夹保存文件。getSaveFileName()
方法的语法如下:
file_name = QFileDialog.getSaveFileName(self, dialog_title, path, filter, options)
options
表示如何运行对话框的各种选项,例如,请查看以下代码:
file_name, _ = QFileDialog.getSaveFileName(self,"QFileDialog.getSaveFileName()","","All Files (*);;Text Files (*.txt)", options=options)
In the preceding example, the File Save dialog box will be opened allowing you to save the files with the desired extension. If you don't specify the file extension, then it will be saved with the default extension, .txt.
如何操作...
让我们基于主窗口模板创建一个新的应用程序。主窗口模板默认包含顶部的菜单:
-
我们甚至可以使用两个按钮来启动文件打开对话框和文件保存对话框,但使用菜单项来启动文件操作将给人一种实时应用程序的感觉。
-
主窗口模板中的默认菜单栏显示“Type Here”代替菜单名称。
-
“Type Here”选项表示用户可以输入所需的菜单名称,替换“Type Here”文本。让我们输入
File
,在菜单栏中创建一个菜单。 -
按下Enter键后,术语“Type Here”将出现在文件菜单下的菜单项中。
-
在文件菜单中将 Open 作为第一个菜单项。
-
在创建第一个菜单项 Open 后按下Enter键后,术语“Type Here”将出现在 Open 下方。
-
用菜单项 Save 替换 Type Here。
-
创建包含两个菜单项 Open 和 Save 的文件菜单后
-
应用程序将显示如下截图所示:
在属性编辑器窗口下方的操作编辑器窗口中,可以看到 Open 和 Save 菜单项的默认对象名称分别为actionOpen
和actionSave
。操作编辑器窗口中的 Shortcut 选项卡目前为空,因为尚未为任何菜单项分配快捷键:
- 要为 Open 菜单项分配快捷键,双击
actionOpen
菜单项的 Shortcut 选项卡中的空白处。您将得到如下截图所示的对话框:
文本、对象名称和工具提示框会自动填充默认文本。
-
单击 Shortcut 框以将光标放置在该框中,并按下Ctrl和O键,将Ctrl + O分配为 Open 菜单项的快捷键。
-
在
actionSave
菜单项的 Shortcut 选项卡的空白处双击,并在打开的对话框的 Shortcut 框中按下Ctrl + S。 -
在为两个菜单项 Open 和 Save 分配快捷键后。操作编辑器窗口将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件。在应用pyuic5
命令后,XML 文件将被转换为 Python 代码。生成的 Python 脚本demoFileDialog.py
可以在本书的源代码包中找到。demoFileDialog.py
脚本将用作头文件,并将在另一个 Python 脚本文件中导入,该文件将调用此用户界面设计、“文件”菜单及其相应的菜单项。
- 创建另一个名为
callFileDialog.pyw
的 Python 文件,并将demoFileDialog.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QMainWindow, QApplication, QAction, QFileDialog
from demoFileDialog import *
class MyForm(QMainWindow):
def __init__(self):
super().__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.ui.actionOpen.triggered.connect(self.openFileDialog)
self.ui.actionSave.triggered.connect(self.saveFileDialog)
self.show()
def openFileDialog(self):
fname = QFileDialog.getOpenFileName(self, 'Open file',
'/home')
if fname[0]:
f = open(fname[0], 'r')
with f:
data = f.read()
self.ui.textEdit.setText(data)
def saveFileDialog(self):
options = QFileDialog.Options()
options |= QFileDialog.DontUseNativeDialog
fileName, _ = QFileDialog.getSaveFileName(self,
"QFileDialog.
getSaveFileName()","","All Files (*);;Text Files (*.txt)",
options=options)
f = open(fileName,'w')
text = self.ui.textEdit.toPlainText()
f.write(text)
f.close()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
在callFileDialog.pyw
文件中,您可以看到具有objectName
、actionOpen
的“打开”菜单项的 click()事件连接到openFileDialog
方法;当用户单击“打开”菜单项时,将调用openFileDialog
方法。类似地,“保存”菜单项的 click()事件与objectName
、actionSave
连接到saveFileDialog
方法;当用户单击“保存”菜单项时,将调用saveFileDialog
方法。
在openFileDialog
方法中,通过调用QFileDialog
类的getOpenFileName
方法打开文件对话框。打开文件对话框使用户能够浏览目录并选择要打开的文件。选择文件后,当用户单击“确定”按钮时,所选文件名将被分配给fname
变量。文件以只读模式打开,并且文件内容被读取并分配给文本编辑小部件;也就是说,文件内容显示在文本编辑小部件中。
在文本编辑小部件中显示的文件内容进行更改后,当用户从文件对话框中单击“保存”菜单项时,将调用saveFileDialog()
方法。
在saveFileDialog()
方法中,调用QFileDialog
类上的getSaveFileName()
方法,将打开文件保存对话框。您可以在相同位置使用相同名称保存文件,或者使用其他名称。如果在相同位置提供相同的文件名,则单击“确定”按钮后,将会出现一个对话框,询问您是否要用更新的内容覆盖原始文件。提供文件名后,该文件将以写入模式打开,并且文本编辑小部件中的内容将被读取并写入文件。也就是说,文本编辑小部件中可用的更新文件内容将被写入提供的文件名。
运行应用程序后,您会发现一个带有两个菜单项“打开”和“保存”的文件菜单,如下面的屏幕截图所示。您还可以看到“打开”和“保存”菜单项的快捷键:
单击文件菜单中的“打开”菜单项,或按下快捷键Ctrl + O,您将获得打开文件对话框,如下面的屏幕截图所示。您可以浏览所需的目录并选择要打开的文件。选择文件后,您需要从对话框中单击“打开”按钮:
所选文件的内容将显示在文本编辑框中,如下面的屏幕截图所示:
在文本编辑框中显示的文件内容进行修改后,当用户从文件菜单中单击“保存”菜单项时,将调用getSaveFileName
方法以显示保存文件对话框。让我们使用原始名称保存文件,然后单击“保存”按钮,如下面的屏幕截图所示:
因为文件将以相同的名称保存,您将收到一个对话框,询问是否要用新内容替换原始文件,如下面的屏幕截图所示。单击“是”以使用新内容更新文件:
第十七章:理解布局
在本章中,我们将重点关注以下主题:
-
使用水平布局
-
使用垂直布局
-
使用网格布局
-
使用表单布局
理解布局
正如其名称所示,布局用于以所需格式排列小部件。在布局中排列某些小部件时,自动将某些尺寸和对齐约束应用于小部件。例如,增大窗口的尺寸时,布局中的小部件也会增大,以利用增加的空间。同样,减小窗口的尺寸时,布局中的小部件也会减小。以下问题出现了:布局如何知道小部件的推荐尺寸是多少?
基本上,每个小部件都有一个名为 sizeHint 的属性,其中包含小部件的推荐尺寸。当窗口调整大小并且布局大小也改变时,通过小部件的 sizeHint 属性,布局管理器知道小部件的尺寸要求。
为了在小部件上应用尺寸约束,可以使用以下两个属性:
-
最小尺寸:如果窗口大小减小,小部件仍然不会变得比最小尺寸属性中指定的尺寸更小。
-
最大尺寸:同样,如果窗口增大,小部件不会变得比最大尺寸属性中指定的尺寸更大。
当设置了前述属性时,sizeHint 属性中指定的值将被覆盖。
要在布局中排列小部件,只需选择所有小部件,然后单击工具栏上的“布局管理器”。另一种方法是右键单击以打开上下文菜单。从上下文菜单中,可以选择“布局”菜单选项,然后从弹出的子菜单中选择所需的布局。
在选择所需的布局后,小部件将以所选布局布置,并且在运行时不可见的小部件周围会有一条红线表示布局。要查看小部件是否正确布置,可以通过选择“表单”、“预览”或Ctrl + R来预览表单。要打破布局,选择“表单”、“打破布局”,输入Ctrl + O,或从工具栏中选择“打破布局”图标。
布局可以嵌套。
以下是 Qt Designer 提供的布局管理器:
-
水平布局
-
垂直布局
-
网格布局
-
表单布局
间隔器
为了控制小部件之间的间距,使用水平和垂直间隔器。当两个小部件之间放置水平间隔器时,两个小部件将被推到尽可能远的左右两侧。如果窗口大小增加,小部件的尺寸不会改变,额外的空间将被间隔器占用。同样,当窗口大小减小时,间隔器会自动减小,但小部件的尺寸不会改变。
间隔器会扩展以填充空白空间,并在空间减小时收缩。
让我们看看在水平框布局中排列小部件的步骤。
使用水平布局
水平布局将小部件在一行中排列,即使用水平布局水平对齐小部件。让我们通过制作一个应用程序来理解这个概念。
如何做...
在这个应用程序中,我们将提示用户输入电子邮件地址和密码。这个配方的主要重点是理解如何水平对齐两对标签和行编辑小部件。以下是创建此应用程序的逐步过程:
-
让我们创建一个基于没有按钮的对话框模板的应用程序,并通过将两个标签、两个行编辑和一个按钮小部件拖放到表单上,来添加两个
QLabel
、两个QLineEdit
和一个QPushButton
小部件。 -
将两个标签小部件的文本属性设置为
姓名
和电子邮件地址
。 -
还要将按钮小部件的文本属性设置为
提交
。 -
由于此应用程序的目的是了解布局而不是其他任何内容,因此我们不会设置应用程序中任何小部件的 objectName 属性。
现在表单将显示如下截图所示:
- 我们将在每对 Label 和 LineEdit 小部件上应用水平布局。因此,单击文本为
Name
的 Label 小部件,并保持按住Ctrl键,然后单击其旁边的 LineEdit 小部件。
您可以使用Ctrl +左键选择多个小部件。
-
选择 Label 和 LineEdit 小部件后,右键单击并从打开的上下文菜单中选择布局菜单选项。
-
选择布局菜单选项后,屏幕上将出现几个子菜单选项;选择水平布局子菜单选项。两个 Label 和 LineEdit 小部件将水平对齐,如下截图所示:
-
如果您想要打破布局怎么办?这很简单:您可以随时通过选择布局并右键单击来打破任何布局。上下文菜单将弹出;从上下文菜单中选择布局菜单选项,然后选择打破布局子菜单选项。
-
要水平对齐文本为
Email Address
的第二对 Label 小部件和其旁边的 LineEdit 小部件,请重复步骤 6 和 7 中提到的相同过程。这对 Label 和 LineEdit 小部件也将水平对齐,如下截图所示。
您可以看到一个红色的矩形围绕着这两个小部件。这个红色的矩形是水平布局窗口:
- 要在第一对 Label 和 LineEdit 小部件之间创建一些空间,请从小部件框的间隔器选项卡中拖动水平间隔器小部件,并将其放置在文本为
Name
的 Label 小部件和其旁边的 LineEdit 小部件之间。
水平间隔器小部件最初占据两个小部件之间的默认空间。间隔器显示为表单上的蓝色弹簧。
- 通过拖动其节点来调整水平间隔器的大小,以限制 LineEdit 小部件的宽度,如下截图所示:
-
从第一对 Label 和 LineEdit 小部件的水平布局小部件的红色矩形中选择,并将其向右拖动,使其宽度等于第二对小部件。
-
拖动水平布局小部件时,水平间隔器将增加其宽度,以消耗两个小部件之间的额外空白空间,如下截图所示:
- 将应用程序保存为
demoHorizontalLayout.ui
。
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,我们需要将其转换为 Python 代码。要进行转换,您需要打开命令提示符窗口并导航到保存文件的文件夹,然后发出以下命令行:
C:\Pythonbook\PyQt5>pyuic5 demoHorizontalLayout.ui -o demoHorizontalLayout.py
Python 脚本文件demoHorizontalLayout.py
可能包含以下代码:
from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_Dialog(object):
def setupUi(self, Dialog):
Dialog.setObjectName("Dialog")
Dialog.resize(483, 243)
self.pushButton = QtWidgets.QPushButton(Dialog)
self.pushButton.setGeometry(QtCore.QRect(120, 130, 111,
23))
font = QtGui.QFont()
font.setPointSize(12)
self.pushButton.setFont(font)
self.pushButton.setObjectName("pushButton")
self.widget = QtWidgets.QWidget(Dialog)
self.widget.setGeometry(QtCore.QRect(20, 30, 271, 27))
self.widget.setObjectName("widget")
self.horizontalLayout = QtWidgets.QHBoxLayout(self.widget)
self.horizontalLayout.setContentsMargins(0, 0, 0, 0)
self.horizontalLayout.setObjectName("horizontalLayout")
self.label = QtWidgets.QLabel(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.label.setFont(font)
self.label.setObjectName("label")
self.horizontalLayout.addWidget(self.label)
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.
QSizePolicy.Expanding,QtWidgets.QSizePolicy.Minimum)
self.horizontalLayout.addItem(spacerItem)
self.lineEdit = QtWidgets.QLineEdit(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.lineEdit.setFont(font)
self.lineEdit.setObjectName("lineEdit")
self.horizontalLayout.addWidget(self.lineEdit)
self.widget1 = QtWidgets.QWidget(Dialog)
self.widget1.setGeometry(QtCore.QRect(20, 80, 276, 27))
self.widget1.setObjectName("widget1")
self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.
widget1)
self.horizontalLayout_2.setContentsMargins(0, 0, 0, 0)
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.label_2 = QtWidgets.QLabel(self.widget1)
font = QtGui.QFont()
font.setPointSize(12)
self.label_2.setFont(font)
self.label_2.setObjectName("label_2")
self.horizontalLayout_2.addWidget(self.label_2)
self.lineEdit_2 = QtWidgets.QLineEdit(self.widget1)
font = QtGui.QFont()
font.setPointSize(12)
self.lineEdit_2.setFont(font)
self.lineEdit_2.setObjectName("lineEdit_2")
self.horizontalLayout_2.addWidget(self.lineEdit_2)
self.retranslateUi(Dialog)
QtCore.QMetaObject.connectSlotsByName(Dialog)
def retranslateUi(self, Dialog):
_translate = QtCore.QCoreApplication.translate
Dialog.setWindowTitle(_translate("Dialog", "Dialog"))
self.pushButton.setText(_translate("Dialog", "Submit"))
self.label.setText(_translate("Dialog", "Name"))
self.label_2.setText(_translate("Dialog", "Email Address"))
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
Dialog = QtWidgets.QDialog()
ui = Ui_Dialog()
ui.setupUi(Dialog)
Dialog.show()
sys.exit(app.exec_())
它是如何工作的...
您可以在代码中看到,一个具有默认 objectName 属性lineEdit
的 LineEdit 小部件和一个具有默认 objectName 属性为label的 Label 小部件被放置在表单上。使用水平布局小部件水平对齐 LineEdit 和 Label 小部件。水平布局小部件具有默认的 objectName 属性horizontalLayout
。在对齐 Label 和 LineEdit 小部件时,两个小部件之间的水平空间被减小。因此,在 Label 和 LineEdit 小部件之间保留了一个间隔。第二对 Label 具有默认的 objectName 属性label_2
和 LineEdit 小部件具有默认的 objectName 属性lineEdit_2
,通过具有默认 objectName 属性horizontalLayout_2
的水平布局水平对齐。
运行应用程序后,您会发现两对标签和行编辑小部件水平对齐,如下面的屏幕截图所示:
使用垂直布局
垂直布局将选定的小部件垂直排列,以列的形式一个接一个地排列。在下面的应用程序中,您将学习如何在垂直布局中放置小部件。
如何做...
在这个应用程序中,我们将提示用户输入姓名和电子邮件地址。用于输入姓名和电子邮件地址的标签和文本框,以及提交按钮,将通过垂直布局垂直排列。以下是创建应用程序的步骤:
-
启动 Qt Designer 并基于无按钮对话框模板创建一个应用程序,然后通过将两个标签、两个行编辑和一个
QPushButton
小部件拖放到表单上,向表单添加两个QLabel
、两个QlineEdit
和一个QPushButton
小部件。 -
将两个标签小部件的文本属性设置为
Name
和Email Address
。 -
将提交按钮的文本属性设置为
Submit
。因为这个应用程序的目的是理解布局,而不是其他任何东西,所以我们不会设置应用程序中任何小部件的 objectName 属性。表单现在将显示如下屏幕截图所示:
-
在对小部件应用垂直布局之前,我们需要将小部件水平对齐。因此,我们将在每对标签和行编辑小部件上应用水平布局小部件。因此,点击文本为
Name
的标签小部件,并保持Ctrl键按下,然后点击其旁边的行编辑小部件。 -
在选择标签和行编辑小部件后,右键单击鼠标按钮,并从打开的上下文菜单中选择布局菜单选项。
-
选择布局菜单选项后,屏幕上会出现几个子菜单选项。选择水平布局子菜单选项。标签和行编辑小部件将水平对齐。
-
要水平对齐文本为
Email Address
的第二对标签和其旁边的行编辑小部件,请重复前面步骤 5 和 6 中提到的相同过程。您会看到一个红色矩形围绕着这两个小部件。这个红色矩形是水平布局窗口。 -
要在第一对标签和行编辑小部件之间创建一些空间,请从小部件框的间隔器选项卡中拖动水平间隔器小部件,并将其放在文本为
Name
的标签小部件和其旁边的行编辑小部件之间。水平间隔器将最初占据两个小部件之间的默认空间。 -
从第一对标签和行编辑小部件中选择 Horizontal Layout 小部件的红色矩形,并将其向右拖动,使其宽度等于第二对的宽度。
-
拖动水平布局小部件时,水平间隔器将增加其宽度,以消耗两个小部件之间的额外空白空间,如下面的屏幕截图所示:
-
现在,选择三个项目:第一个水平布局窗口、第二个水平布局窗口和提交按钮。在这些多重选择过程中保持Ctrl键按下。
-
选择这三个项目后,右键单击以打开上下文菜单。
-
从上下文菜单中选择布局菜单选项,然后选择垂直布局子菜单选项。这三个项目将垂直对齐,并且提交按钮的宽度将增加以匹配最宽布局的宽度,如下面的屏幕截图所示:
-
您还可以从工具栏中选择垂直布局图标,以将小部件排列成垂直布局。
-
如果要控制提交按钮的宽度,可以使用此小部件的 minimumSize 和 maximumSize 属性。您会注意到两个水平布局之间的垂直空间大大减少了。
-
要在两个水平布局之间创建一些空间,请从小部件框的间隔器选项卡中拖动垂直间隔器小部件,并将其放置在两个水平布局之间。
垂直间隔器最初将占据两个水平布局之间的默认空间
-
要在第二个水平布局和提交按钮之间创建垂直空间,请拖动垂直间隔器,并将其放置在第二个水平布局和提交按钮之间。
-
选择垂直布局的红色矩形,并向下拖动以增加其高度。
-
拖动垂直布局小部件时,垂直间隔器将增加其高度,以消耗两个水平布局和提交按钮之间的额外空白空间,如下面的屏幕截图所示:
- 将应用程序保存为
demoverticalLayout.ui
。
由于我们知道使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要将其转换为 Python 代码。要进行转换,您需要打开命令提示符窗口,并导航到保存文件的文件夹,然后发出以下命令:
C:PyQt5>pyuic5 demoverticalLayout.ui -o demoverticalLayout.py
Python 脚本文件demoverticalLayout.py
可能包含以下代码:
from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_Dialog(object):
def setupUi(self, Dialog):
Dialog.setObjectName("Dialog")
Dialog.resize(407, 211)
self.widget = QtWidgets.QWidget(Dialog)
self.widget.setGeometry(QtCore.QRect(20, 30, 278, 161))
self.widget.setObjectName("widget")
self.verticalLayout = QtWidgets.QVBoxLayout(self.widget)
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
self.verticalLayout.setObjectName("verticalLayout")
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.label = QtWidgets.QLabel(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.label.setFont(font)
self.label.setObjectName("label")
self.horizontalLayout.addWidget(self.label)
spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.
QSizePolicy.Expanding,QtWidgets.QSizePolicy.Minimum)
self.horizontalLayout.addItem(spacerItem)
self.lineEdit = QtWidgets.QLineEdit(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.lineEdit.setFont(font)
self.lineEdit.setObjectName("lineEdit")
self.horizontalLayout.addWidget(self.lineEdit)
self.verticalLayout.addLayout(self.horizontalLayout)
spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.
QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding)
self.verticalLayout.addItem(spacerItem1)
self.horizontalLayout_2 = QtWidgets.QHBoxLayout()
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.label_2 = QtWidgets.QLabel(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.label_2.setFont(font)
self.label_2.setObjectName("label_2")
self.horizontalLayout_2.addWidget(self.label_2)
self.lineEdit_2 = QtWidgets.QLineEdit(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.lineEdit_2.setFont(font)
self.lineEdit_2.setObjectName("lineEdit_2")
self.horizontalLayout_2.addWidget(self.lineEdit_2)
self.verticalLayout.addLayout(self.horizontalLayout_2)
spacerItem2 = QtWidgets.QSpacerItem(20, 40, QtWidgets.
QSizePolicy.Minimum,QtWidgets.QSizePolicy.
Expanding)
self.verticalLayout.addItem(spacerItem2)
self.pushButton = QtWidgets.QPushButton(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.pushButton.setFont(font)
self.pushButton.setObjectName("pushButton")
self.verticalLayout.addWidget(self.pushButton)
self.retranslateUi(Dialog)
QtCore.QMetaObject.connectSlotsByName(Dialog)
def retranslateUi(self, Dialog):
_translate = QtCore.QCoreApplication.translate
Dialog.setWindowTitle(_translate("Dialog", "Dialog"))
self.label.setText(_translate("Dialog", "Name"))
self.label_2.setText(_translate("Dialog", "Email Address"))
self.pushButton.setText(_translate("Dialog", "Submit"))
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
Dialog = QtWidgets.QDialog()
ui = Ui_Dialog()
ui.setupUi(Dialog)
Dialog.show()
sys.exit(app.exec_())
它是如何工作的...
您可以在代码中看到,具有默认 objectName lineEdit
属性的 Line Edit 小部件和具有默认 objectName label
属性的 Label 小部件被放置在表单上,并使用具有默认 objectName 属性horizontalLayout
的水平布局进行水平对齐。在对齐标签和行编辑小部件时,两个小部件之间的水平空间减小了。因此,在标签和行编辑小部件之间保留了一个间隔器。第二对,具有默认 objectName label_2
属性的 Label 小部件和具有默认 objectName lineEdit_2
属性的 Line Edit 小部件,使用具有默认 objectName horizontalLayout_2
属性的水平布局进行水平对齐。然后,使用具有默认objectName
属性verticalLayout
的垂直布局对前两个水平布局和具有默认 objectName pushButton
属性的提交按钮进行垂直对齐。通过在它们之间放置一个水平间隔器,增加了第一对标签和行编辑小部件之间的水平空间。类似地,通过在它们之间放置一个名为spacerItem1
的垂直间隔器,增加了两个水平布局之间的垂直空间。此外,还在第二个水平布局和提交按钮之间放置了一个名为spacerItem2
的垂直间隔器,以增加它们之间的垂直空间。
运行应用程序后,您会发现两对标签和行编辑小部件以及提交按钮垂直对齐,如下面的屏幕截图所示:
使用网格布局
网格布局将小部件排列在可伸缩的网格中。要了解网格布局小部件如何排列小部件,让我们创建一个应用程序。
如何做...
在这个应用程序中,我们将制作一个简单的登录表单,提示用户输入电子邮件地址和密码,然后点击提交按钮。在提交按钮下方,将有两个按钮,取消和忘记密码。该应用程序将帮助您了解这些小部件如何以网格模式排列。以下是创建此应用程序的步骤:
-
启动 Qt Designer,并基于无按钮的对话框模板创建一个应用程序,然后通过拖放两个 Label、两个 Line Edit 和三个 Push Button 小部件到表单上,将两个
QLabel
、两个QlineEdit
和三个QPushButton
小部件添加到表单上。 -
将两个 Label 小部件的文本属性设置为
Name
和Email Address
。 -
将三个 Push Button 小部件的文本属性设置为
Submit
,Cancel
和Forgot Password
。 -
因为此应用程序的目的是了解布局而不是其他任何内容,所以我们不会设置应用程序中任何小部件的 objectName 属性。
-
为了增加两个 Line Edit 小部件之间的垂直空间,从 Widget Box 的间隔符选项卡中拖动垂直间隔符小部件,并将其放置在两个 Line Edit 小部件之间。垂直间隔符将最初占据两个 Line Edit 小部件之间的空白空间。
-
为了在第二个 Line Edit 小部件和提交按钮之间创建垂直空间,拖动垂直间隔符小部件并将其放置在它们之间。
应用程序将显示如下截图所示:
-
通过按下Ctrl键并单击表单上的所有小部件来选择表单上的所有小部件。
-
选择所有小部件后,右键单击鼠标按钮以打开上下文菜单。
-
从上下文菜单中,选择布局菜单选项,然后选择网格布局子菜单选项。
小部件将按照网格中所示的方式对齐:
-
为了增加提交和取消按钮之间的垂直空间,从 Widget Box 的间隔符选项卡中拖动垂直间隔符小部件,并将其放置在它们之间。
-
为了增加取消和忘记密码按钮之间的水平空间,从间隔符选项卡中拖动水平间隔符小部件,并将其放置在它们之间。
现在表格将显示如下截图所示:
- 将应用程序保存为
demoGridLayout.ui
。
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。要进行转换,您需要打开命令提示符窗口并导航到保存文件的文件夹,然后发出以下命令:
C:PyQt5>pyuic5 demoGridLayout.ui -o demoGridLayout.py
Python 脚本文件demoGridLayout.py
可能包含以下代码:
from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_Dialog(object):
def setupUi(self, Dialog):
Dialog.setObjectName("Dialog")
Dialog.resize(369, 279)
self.widget = QtWidgets.QWidget(Dialog)
self.widget.setGeometry(QtCore.QRect(20, 31, 276, 216))
self.widget.setObjectName("widget")
self.gridLayout = QtWidgets.QGridLayout(self.widget)
self.gridLayout.setContentsMargins(0, 0, 0, 0)
self.gridLayout.setObjectName("gridLayout")
self.pushButton = QtWidgets.QPushButton(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.pushButton.setFont(font)
self.pushButton.setObjectName("pushButton")
self.gridLayout.addWidget(self.pushButton, 4, 0, 1, 5)
spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.
QSizePolicy.Minimum,QtWidgets.QSizePolicy.Expanding)
self.gridLayout.addItem(spacerItem, 5, 0, 1, 1)
self.label = QtWidgets.QLabel(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.label.setFont(font)
self.label.setObjectName("label")
self.gridLayout.addWidget(self.label, 0, 0, 1, 1)
self.label_2 = QtWidgets.QLabel(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.label_2.setFont(font)
self.label_2.setObjectName("label_2")
self.gridLayout.addWidget(self.label_2, 2, 0, 1, 2)
self.lineEdit_2 = QtWidgets.QLineEdit(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.lineEdit_2.setFont(font)
self.lineEdit_2.setObjectName("lineEdit_2")
self.gridLayout.addWidget(self.lineEdit_2, 2, 2, 1, 3)
self.lineEdit = QtWidgets.QLineEdit(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.lineEdit.setFont(font)
self.lineEdit.setObjectName("lineEdit")
self.gridLayout.addWidget(self.lineEdit, 0, 2, 1, 3)
spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.
QSizePolicy.Minimum,QtWidgets.QSizePolicy.Expanding)
self.gridLayout.addItem(spacerItem1, 3, 1, 1, 1)
spacerItem2 = QtWidgets.QSpacerItem(20, 40, QtWidgets.
QSizePolicy.Minimum,QtWidgets.QSizePolicy.Expanding)
self.gridLayout.addItem(spacerItem2, 1, 2, 1, 3)
self.pushButton_2 = QtWidgets.QPushButton(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.pushButton_2.setFont(font)
self.pushButton_2.setObjectName("pushButton_2")
self.gridLayout.addWidget(self.pushButton_2, 6, 0, 1, 3)
self.pushButton_3 = QtWidgets.QPushButton(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.pushButton_3.setFont(font)
self.pushButton_3.setObjectName("pushButton_3")
self.gridLayout.addWidget(self.pushButton_3, 6, 4, 1, 1)
spacerItem3 = QtWidgets.QSpacerItem(40, 20, QtWidgets.
QSizePolicy.Expanding,QtWidgets.QSizePolicy.Minimum)
self.gridLayout.addItem(spacerItem3, 6, 3, 1, 1)
self.retranslateUi(Dialog)
QtCore.QMetaObject.connectSlotsByName(Dialog)
def retranslateUi(self, Dialog):
_translate = QtCore.QCoreApplication.translate
Dialog.setWindowTitle(_translate("Dialog", "Dialog"))
self.pushButton.setText(_translate("Dialog", "Submit"))
self.label.setText(_translate("Dialog", "Name"))
self.label_2.setText(_translate("Dialog", "Email Address"))
self.pushButton_2.setText(_translate("Dialog", "Cancel"))
self.pushButton_3.setText(_translate("Dialog",
"Forgot Password"))
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
Dialog = QtWidgets.QDialog()
ui = Ui_Dialog()
ui.setupUi(Dialog)
Dialog.show()
sys.exit(app.exec_())
工作原理...
您可以在代码中看到,具有默认 objectNamelineEdit
属性的 Line Edit 小部件和具有默认 objectNamelabel
属性的 Label 小部件放置在表单上。类似地,第二对具有默认 objectNamelabel_2
属性的 Label 小部件和具有默认 objectNamelineEdit_2
属性的 Line Edit 小部件也放置在表单上。通过在它们之间放置名为spacerItem1
的垂直间隔符,增加了两对 Label 和 Line Edit 小部件之间的垂直空间。还在表单上放置了一个文本为Submit
,objectName 为pushButton
的 Push Button 小部件。同样,通过在具有 objectNamelabel_2
的第二个 Label 和具有 objectNamepushButton
的 Push Button 小部件之间放置名为spacerItem2
的垂直间隔符,增加了它们之间的垂直空间。另外两个具有默认 objectName 属性pushButton_2
和pushButton_3
的 push 按钮也放置在表单上。所有小部件都以默认对象名称gridLayout
排列在一个可伸缩的网格布局中。具有 object 名称pushButton
和pushButton_2
的两个 push 按钮之间的垂直空间通过在它们之间放置名为spacerItem3
的垂直间隔符来增加。
运行应用程序时,您会发现两对 Label 和 Line Edit 小部件以及提交、取消和忘记密码按钮都排列在一个可伸缩的网格中,如下截图所示:
使用表单布局
表单布局被认为是几乎所有应用程序中最需要的布局。当显示产品、服务等以及接受用户或客户的反馈或其他信息时,需要这种两列布局。
做好准备
表单布局以两列格式排列小部件。就像任何网站的注册表单或任何订单表单一样,表单被分成两列,左侧列显示标签或文本,右侧列显示空文本框。同样,表单布局将小部件排列在左列和右列。让我们使用一个应用程序来理解表单布局的概念。
如何做...
在这个应用程序中,我们将创建两列,一列用于显示消息,另一列用于接受用户输入。除了两对用于从用户那里获取输入的 Label 和 Line Edit 小部件之外,该应用程序还将有两个按钮,这些按钮也将按照表单布局排列。以下是创建使用表单布局排列小部件的应用程序的步骤:
-
启动 Qt Designer,并基于无按钮的对话框模板创建一个应用程序,然后通过拖放两个 Label、两个 LineEdit 和两个 PushButton 小部件到表单上,添加两个
QLabel
、两个QLineEdit
和两个QPushButton
小部件。 -
将两个 Label 小部件的文本属性设置为
Name
和Email Address
。 -
将两个 Push Button 小部件的文本属性设置为
Cancel
和Submit
。 -
因为这个应用程序的目的是理解布局,而不是其他任何东西,所以我们不会设置应用程序中任何小部件的 objectName 属性。
应用程序将显示如下屏幕截图所示:
-
通过按下Ctrl键并单击表单上的所有小部件来选择所有小部件。
-
选择所有小部件后,右键单击鼠标按钮以打开上下文菜单。
-
从上下文菜单中,选择布局菜单选项,然后选择表单布局子菜单选项中的布局。
小部件将在表单布局小部件中对齐,如下面的屏幕截图所示:
-
为了增加两个 Line Edit 小部件之间的垂直空间,请从 Widget Box 的间隔器选项卡中拖动垂直间隔器小部件,并将其放置在它们之间。
-
为了增加第二个 Line Edit 小部件和提交按钮之间的垂直空间,请从间隔器选项卡中拖动垂直间隔器小部件,并将其放置在它们之间。
-
选择表单布局小部件的红色矩形,并垂直拖动以增加其高度。两个垂直间隔器将自动增加高度,以利用小部件之间的空白空间。
表单现在将显示如下屏幕截图所示:
- 将应用程序保存为
demoFormLayout.ui
。
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。要进行转换,您需要打开命令提示符窗口,并导航到保存文件的文件夹,然后发出以下命令:
C:PyQt5>pyuic5 demoFormLayout.ui -o demoFormLayout.py
Python 脚本文件demoFormLayout.py
可能包含以下代码:
from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_Dialog(object):
def setupUi(self, Dialog):
Dialog.setObjectName("Dialog")
Dialog.resize(407, 211)
self.widget = QtWidgets.QWidget(Dialog)
self.widget.setGeometry(QtCore.QRect(20, 30, 276, 141))
self.widget.setObjectName("widget")
self.formLayout = QtWidgets.QFormLayout(self.widget)
self.formLayout.setContentsMargins(0, 0, 0, 0)
self.formLayout.setObjectName("formLayout")
self.label = QtWidgets.QLabel(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.label.setFont(font)
self.label.setObjectName("label")
self.formLayout.setWidget(0, QtWidgets.QFormLayout.
LabelRole,self.label)
self.lineEdit = QtWidgets.QLineEdit(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.lineEdit.setFont(font)
self.lineEdit.setObjectName("lineEdit")
self.formLayout.setWidget(0, QtWidgets.QFormLayout.
FieldRole,self.lineEdit)
self.label_2 = QtWidgets.QLabel(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.label_2.setFont(font)
self.label_2.setObjectName("label_2")
self.formLayout.setWidget(2, QtWidgets.QFormLayout.
LabelRole,self.label_2)
self.lineEdit_2 = QtWidgets.QLineEdit(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.lineEdit_2.setFont(font)
self.lineEdit_2.setObjectName("lineEdit_2")
self.formLayout.setWidget(2, QtWidgets.QFormLayout.
FieldRole, self.lineEdit_2)
self.pushButton_2 = QtWidgets.QPushButton(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.pushButton_2.setFont(font)
self.pushButton_2.setObjectName("pushButton_2")
self.formLayout.setWidget(4, QtWidgets.QFormLayout.
LabelRole,self.pushButton_2)
self.pushButton = QtWidgets.QPushButton(self.widget)
font = QtGui.QFont()
font.setPointSize(12)
self.pushButton.setFont(font)
self.pushButton.setObjectName("pushButton")
self.formLayout.setWidget(4, QtWidgets.QFormLayout.
FieldRole,self.pushButton)
spacerItem = QtWidgets.QSpacerItem(20, 40, QtWidgets.
QSizePolicy.Minimum,QtWidgets.QSizePolicy.Expanding)
self.formLayout.setItem(1, QtWidgets.QFormLayout.FieldRole,
spacerItem)
spacerItem1 = QtWidgets.QSpacerItem(20, 40, QtWidgets.
QSizePolicy.Minimum,QtWidgets.QSizePolicy.Expanding)
self.formLayout.setItem(3, QtWidgets.QFormLayout.FieldRole,
spacerItem1)
self.retranslateUi(Dialog)
QtCore.QMetaObject.connectSlotsByName(Dialog)
def retranslateUi(self, Dialog):
_translate = QtCore.QCoreApplication.translate
Dialog.setWindowTitle(_translate("Dialog", "Dialog"))
self.label.setText(_translate("Dialog", "Name"))
self.label_2.setText(_translate("Dialog", "Email Address"))
self.pushButton_2.setText(_translate("Dialog", "Cancel"))
self.pushButton.setText(_translate("Dialog", "Submit"))
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
Dialog = QtWidgets.QDialog()
ui = Ui_Dialog()
ui.setupUi(Dialog)
Dialog.show()
sys.exit(app.exec_())
它是如何工作的...
您可以在代码中看到,一个带有默认 objectName lineEdit
属性的 Line Edit 小部件和一个带有默认 objectName labels
属性的 Label 小部件被放置在表单上。同样,第二对,一个带有默认 objectName label_2
属性的 Label 小部件和一个带有默认 objectName lineEdit_2
属性的 Line Edit 小部件被放置在表单上。两个带有 object names pushButton
和pushButton_2
的按钮被放置在表单上。所有六个小部件都被选中,并使用默认 objectName formLayout
属性的表单布局小部件以两列格式对齐。
运行应用程序时,您会发现两对 Label 和 Line Edit 小部件以及取消和提交按钮被排列在表单布局小部件中,如下面的屏幕截图所示:
第十八章:网络和管理大型文档
在本章中,我们将学习如何使用网络概念以及如何以块的形式查看大型文档。我们将涵盖以下主题:
-
创建一个小型浏览器
-
创建一个服务器端应用程序
-
建立客户端-服务器通信
-
创建一个可停靠和可浮动的登录表单
-
多文档界面
-
使用选项卡小部件在部分中显示信息
-
创建自定义菜单栏
介绍
设备屏幕上的空间总是有限的,但有时您会遇到这样的情况:您想在屏幕上显示大量信息或服务。在这种情况下,您可以使用可停靠的小部件,这些小部件可以在屏幕的任何位置浮动;MDI 可以根据需要显示多个文档;选项卡小部件框可以显示不同块中的信息;或者菜单可以在单击菜单项时显示所需的信息。此外,为了更好地理解网络概念,您需要了解客户端和服务器如何通信。本章将帮助您了解所有这些。
创建一个小型浏览器
现在让我们学习一种显示网页或 HTML 文档内容的技术。我们将简单地使用 LineEdit 和 PushButton 小部件,以便用户可以输入所需站点的 URL,然后点击 PushButton 小部件。单击按钮后,该站点将显示在自定义小部件中。让我们看看。
在这个示例中,我们将学习如何制作一个小型浏览器。因为 Qt Designer 没有包含任何特定的小部件,所以这个示例的重点是让您了解如何将自定义小部件提升为QWebEngineView
,然后可以用于显示网页。
应用程序将提示输入 URL,当用户输入 URL 并点击“Go”按钮后,指定的网页将在QWebEngineView
对象中打开。
如何做...
在这个示例中,我们只需要三个小部件:一个用于输入 URL,第二个用于点击按钮,第三个用于显示网站。以下是创建一个简单浏览器的步骤:
-
基于没有按钮的对话框模板创建一个应用程序。
-
通过拖放 Label、LineEdit、PushButton 和 Widget 将
QLabel
、QLineEdit
、QPushButton
和QWidget
小部件添加到表单中。 -
将 Label 小部件的文本属性设置为“输入 URL”。
-
将 PushButton 小部件的文本属性设置为
Go
。 -
将 LineEdit 小部件的 objectName 属性设置为
lineEditURL
,将 PushButton 小部件的 objectName 属性设置为pushButtonGo
。 -
将应用程序保存为
demoBrowser.ui
。
表单现在将显示如下截图所示:
-
下一步是将
QWidget
提升为QWebEngineView
,因为要显示网页,需要QWebEngineView
。 -
通过右键单击 QWidget 对象并从弹出菜单中选择“提升为...”选项来提升
QWidget
对象。 -
在弹出的对话框中,将基类名称选项保留为默认的 QWidget。
-
在 Promoted 类名框中输入
QWebEngineView
,在头文件框中输入PyQt5.QtWebEngineWidgets
。 -
选择 Promote 按钮,将 QWidget 提升为
QWebEngineView
类,如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。
- 要进行转换,您需要打开命令提示符窗口并导航到保存文件的文件夹,然后发出以下命令:
C:\Pythonbook\PyQt5>pyuic5 demoBrowser.ui -o demoBrowser.py
您可以在本书的源代码包中看到自动生成的 Python 脚本文件demoBrowser.py
。
-
将上述代码视为一个头文件,并将其导入到将调用其用户界面设计的文件中。
-
让我们创建另一个名为
callBrowser.pyw
的 Python 文件,并将demoBrowser.py
代码导入其中:
import sys
from PyQt5.QtCore import QUrl
from PyQt5.QtWidgets import QApplication, QDialog
from PyQt5.QtWebEngineWidgets import QWebEngineView
from demoBrowser import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonGo.clicked.connect(self.dispSite)
self.show()
def dispSite(self):
self.ui.widget.load(QUrl(self.ui.lineEditURL.text()))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
在demoBrowser.py
文件中,创建了一个名为顶级对象的类,前面加上Ui_
。也就是说,对于顶级对象Dialog
,创建了Ui_Dialog
类,并存储了我们小部件的接口元素。该类包括两个方法,setupUi()
和retranslateUi()
。setupUi()
方法创建了在 Qt Designer 中定义用户界面时使用的小部件。此方法还设置了小部件的属性。setupUi()
方法接受一个参数,即应用程序的顶级小部件,即QDialog
的实例。retranslateUi()
方法翻译了界面。
在callBrowser.pyw
文件中,您会看到推送按钮小部件的 click()事件连接到dispSite
方法;在行编辑小部件中输入 URL 后,当用户单击推送按钮时,将调用dispSite
方法。
dispSite()
方法调用QWidget
类的load()
方法。请记住,QWidget
对象被提升为QWebEngineView
类,用于查看网页。QWebEngineView
类的load()
方法接收lineEditURL
对象中输入的 URL,因此指定 URL 的网页将在QWebEngine
小部件中打开或加载。
运行应用程序时,您会得到一个空的行编辑框和一个推送按钮小部件。在行编辑小部件中输入所需的 URL,然后单击“Go”按钮,您会发现网页在QWebEngineView
小部件中打开,如下屏幕截图所示:
创建服务器端应用程序
网络在现代生活中扮演着重要角色。我们需要了解两台机器之间的通信是如何建立的。当两台机器通信时,一台通常是服务器,另一台是客户端。客户端向服务器发送请求,服务器通过为客户端提出的请求提供响应。
在本示例中,我们将创建一个客户端-服务器应用程序,在客户端和服务器之间建立连接,并且每个都能够向另一个传输文本消息。也就是说,将创建两个应用程序,并且将同时执行,一个应用程序中编写的文本将出现在另一个应用程序中。
如何做...
让我们首先创建一个服务器应用程序,如下所示:
-
基于无按钮对话框模板创建应用程序。
-
通过将标签、文本编辑、行编辑和推送按钮小部件拖放到表单上,向表单添加
QLabel
、QTextEdit
、QLineEdit
和QPushButton
。 -
将标签小部件的文本属性设置为“服务器”,以指示这是服务器应用程序。
-
将推送按钮小部件的文本属性设置为“发送”。
-
将文本编辑小部件的对象名称属性设置为
textEditMessages
。 -
将行编辑小部件的对象名称属性设置为
lineEditMessage
。 -
将推送按钮小部件设置为
pushButtonSend
。 -
将应用程序保存为
demoServer.ui
。表单现在将显示如下屏幕截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。生成文件demoServer.py
的代码可以在本书的源代码包中看到。
工作原理...
demoServer.py
文件将被视为头文件,并将被导入到另一个 Python 文件中,该文件将使用头文件的 GUI 并在服务器和客户端之间传输数据。但在此之前,让我们为客户端应用程序创建一个 GUI。客户端应用程序的 GUI 与服务器应用程序完全相同,唯一的区别是该应用程序顶部的标签小部件将显示文本“客户端”。
demoServer.py
文件是我们拖放到表单上的 GUI 小部件的生成 Python 脚本。
要在服务器和客户端之间建立连接,我们需要一个套接字对象。要创建套接字对象,您需要提供以下两个参数:
-
套接字地址:套接字地址使用特定的地址系列表示。每个地址系列都需要一些参数来建立连接。在本应用程序中,我们将使用
AF_INET
地址系列。AF_INET
地址系列需要一对(主机,端口)来建立连接,其中参数host
是主机名,可以是字符串格式、互联网域表示法或 IPv4 地址格式,参数port
是用于通信的端口号。 -
套接字类型:套接字类型通过几个常量表示:
SOCK_STREAM
、SOCK_DGRAM
、SOCK_RAW
、SOCK_RDM
和SOCK_SEQPACKET
。在本应用程序中,我们将使用最常用的套接字类型SOCK_STREAM
。
应用程序中使用setsockopt()
方法设置给定套接字选项的值。它包括以下两个基本参数:
-
SOL_SOCKET
:此参数是套接字层本身。它用于协议无关的选项。 -
SO_REUSEADDR
:此参数允许其他套接字bind()
到此端口,除非已经有一个活动的监听套接字绑定到该端口。
您可以在先前的代码中看到,创建了一个ServerThread
类,它继承了 Python 的线程模块的Thread
类。run()
函数被重写,其中定义了TCP_IP
和TCP_HOST
变量,并且tcpServer
与这些变量绑定。
此后,服务器等待看是否有任何客户端连接。对于每个新的客户端连接,服务器在while
循环内创建一个新的ClientThread
。这是因为为每个客户端创建一个新线程不会阻塞服务器的 GUI 功能。最后,线程被连接。
建立客户端-服务器通信
在这个教程中,我们将学习如何制作一个客户端,并看到它如何向服务器发送消息。主要思想是理解消息是如何发送的,服务器如何监听端口,以及两者之间的通信是如何建立的。
如何做...
要向服务器发送消息,我们将使用LineEdit
和PushButton
小部件。在单击推送按钮时,LineEdit 小部件中编写的消息将传递到服务器。以下是创建客户端应用程序的逐步过程:
-
基于没有按钮的对话框模板创建另一个应用程序。
-
通过将 Label、TextEdit、LineEdit 和 PushButton 小部件拖放到表单上,向表单添加
QLabel
、QTextEdit
、QLineEdit
和QPushButton
。 -
将 Label 小部件的文本属性设置为
Client
。 -
将 PushButton 小部件的文本属性设置为
Send
。 -
将 TextEdit 小部件的 objectName 属性设置为
textEditMessages
。 -
将 LineEdit 小部件的 objectName 属性设置为
lineEditMessage
。 -
将 PushButton 小部件设置为
pushButtonSend
。 -
将应用程序保存为
demoClient.ui
。
表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。自动生成的文件demoClient.py
的代码可以在本书的源代码包中找到。要使用demoClient.py
文件中创建的 GUI,需要将其导入到另一个 Python 文件中,该文件将使用 GUI 并在服务器和客户端之间传输数据。
- 创建另一个名为
callServer.pyw
的 Python 文件,并将demoServer.py
代码导入其中。callServer.pyw
脚本中的代码如下所示:
import sys, time
from PyQt5 import QtGui
from PyQt5 import QtCore
from PyQt5.QtWidgets import QApplication, QDialog
from PyQt5.QtCore import QCoreApplication
import socket
from threading import Thread
from socketserver import ThreadingMixIn
conn=None
from demoServer import *
class Window(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.textEditMessages=self.ui.textEditMessages
self.ui.pushButtonSend.clicked.connect(self.dispMessage)
self.show()
def dispMessage(self):
text=self.ui.lineEditMessage.text()
global conn
conn.send(text.encode("utf-8"))
self.ui.textEditMessages.append("Server:
"+self.ui.lineEditMessage.text())
self.ui.lineEditMessage.setText("")
class ServerThread(Thread):
def __init__(self,window):
Thread.__init__(self)
self.window=window
def run(self):
TCP_IP = '0.0.0.0'
TCP_PORT = 80
BUFFER_SIZE = 1024
tcpServer = socket.socket(socket.AF_INET,
socket.SOCK_STREAM)
tcpServer.setsockopt(socket.SOL_SOCKET,
socket.SO_REUSEADDR, 1)
tcpServer.bind((TCP_IP, TCP_PORT))
threads = []
tcpServer.listen(4)
while True:
global conn
(conn, (ip,port)) = tcpServer.accept()
newthread = ClientThread(ip,port,window)
newthread.start()
threads.append(newthread)
for t in threads:
t.join()
class ClientThread(Thread):
def __init__(self,ip,port,window):
Thread.__init__(self)
self.window=window
self.ip = ip
self.port = port
def run(self):
while True :
global conn
data = conn.recv(1024)
window.textEditMessages.append("Client:
"+data.decode("utf-8"))
if __name__=="__main__":
app = QApplication(sys.argv)
window = Window()
serverThread=ServerThread(window)
serverThread.start()
window.exec()
sys.exit(app.exec_())
工作原理...
在ClientThread
类中,run
函数被重写。在run
函数中,每个客户端等待从服务器接收的数据,并在文本编辑小部件中显示该数据。一个window
类对象被传递给ServerThread
类,后者将该对象传递给ClientThread
,后者又使用它来访问在行编辑元素中编写的内容。
接收到的数据被解码,因为接收到的数据是以字节形式,必须使用 UTF-8 编码转换为字符串。
在前面的部分生成的demoClient.py
文件需要被视为一个头文件,并且需要被导入到另一个 Python 文件中,该文件将使用头文件的 GUI 并在客户端和服务器之间传输数据。因此,让我们创建另一个名为callClient.pyw
的 Python 文件,并将demoClient.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QApplication, QDialog
import socket
from threading import Thread
from socketserver import ThreadingMixIn
from demoClient import *
tcpClientA=None
class Window(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.textEditMessages=self.ui.textEditMessages
self.ui.pushButtonSend.clicked.connect(self.dispMessage)
self.show()
def dispMessage(self):
text=self.ui.lineEditMessage.text()
self.ui.textEditMessages.append("Client:
"+self.ui.lineEditMessage.text())
tcpClientA.send(text.encode())
self.ui.lineEditMessage.setText("")
class ClientThread(Thread):
def __init__(self,window):
Thread.__init__(self)
self.window=window
def run(self):
host = socket.gethostname()
port = 80
BUFFER_SIZE = 1024
global tcpClientA
tcpClientA = socket.socket(socket.AF_INET,
socket.SOCK_STREAM)
tcpClientA.connect((host, port))
while True:
data = tcpClientA.recv(BUFFER_SIZE)
window.textEditMessages.append("Server:
"+data.decode("utf-8"))
tcpClientA.close()
if __name__=="__main__":
app = QApplication(sys.argv)
window = Window()
clientThread=ClientThread(window)
clientThread.start()
window.exec()
sys.exit(app.exec_())
ClientThread
类是一个继承Thread
类并重写run
函数的类。在run
函数中,通过在socket
类上调用hostname
方法来获取服务器的 IP 地址;并且,使用端口80
,客户端尝试连接到服务器。一旦与服务器建立连接,客户端尝试在 while 循环内从服务器接收数据。
从服务器接收数据后,将数据从字节格式转换为字符串格式,并显示在文本编辑小部件中。
我们需要运行两个应用程序来查看客户端-服务器通信。运行callServer.pyw
文件,您将在以下截图的左侧看到输出,运行callClient.pyw
文件,您将在右侧看到输出。两者相同;只有顶部的标签有所区别:
用户可以在底部的行编辑框中输入文本,然后按下发送按钮。按下发送按钮后,输入的文本将出现在服务器和客户端应用程序的文本编辑框中。文本以Server:
为前缀,以指示该文本是从服务器发送的,如下截图所示:
同样,如果在客户端应用程序的行编辑小部件中输入文本,然后按下发送按钮,文本将出现在两个应用程序的文本编辑小部件中。文本将以Client:
为前缀,以指示该文本已从客户端发送,如下截图所示:
创建一个可停靠和可浮动的登录表单
在本教程中,我们将学习创建一个登录表单,该表单将要求用户输入电子邮件地址和密码以进行身份验证。这个登录表单不同于通常的登录表单,因为它是一个可停靠的表单。也就是说,您可以将这个登录表单停靠在窗口的四个边缘之一——顶部、左侧、右侧和底部,甚至可以将其用作可浮动的表单。这个可停靠的登录表单将使用 Dock 小部件创建,所以让我们快速了解一下 Dock 小部件。
准备工作
要创建一组可分离的小部件或工具,您需要一个 Dock 小部件。Dock 小部件是使用QDockWidget
类创建的,它是一个带有标题栏和顶部按钮的容器,用于调整大小。包含一组小部件或工具的 Dock 小部件可以关闭、停靠在停靠区域中,或者浮动并放置在桌面的任何位置。Dock 小部件可以停靠在不同的停靠区域,例如LeftDockWidgetArea
、RightDockWidgetArea
、TopDockWidgetArea
和BottomDockWidgetArea
。TopDockWidgetArea
停靠区域位于工具栏下方。您还可以限制 Dock 小部件可以停靠的停靠区域。这样做后,Dock 小部件只能停靠在指定的停靠区域。当将 Dock 窗口拖出停靠区域时,它将成为一个自由浮动的窗口。
以下是控制 Dock 小部件的移动以及其标题栏和其他按钮外观的属性:
属性 | 描述 |
---|---|
DockWidgetClosable |
使 Dock 小部件可关闭。 |
DockWidgetMovable |
使 Dock 小部件在停靠区域之间可移动。 |
DockWidgetFloatable |
使 Dock 小部件可浮动,也就是说,Dock 小部件可以从主窗口中分离并在桌面上浮动。 |
DockWidgetVerticalTitleBar |
在 Dock 小部件的左侧显示垂直标题栏。 |
AllDockWidgetFeatures |
它打开属性,如DockWidgetClosable ,DockWidgetMovable 和DockWidgetFloatable ,也就是说,Dock 小部件可以关闭,移动或浮动。 |
NoDockWidgetFeatures |
如果选择,Dock 小部件将无法关闭,移动或浮动。 |
为了制作可停靠的登录表单,我们将使用 Dock 小部件和其他一些小部件。让我们看看逐步的操作步骤。
如何做...
让我们在 Dock 小部件中制作一个小的登录表单,提示用户输入其电子邮件地址和密码。由于可停靠,此登录表单可以移动到屏幕上的任何位置,并且可以浮动。以下是创建此应用程序的步骤:
-
启动 Qt Designer 并创建一个新的主窗口应用程序。
-
将一个 Dock 小部件拖放到表单上。
-
拖放您希望在停靠区域或作为浮动窗口在 Dock 小部件中可用的小部件。
-
在 Dock 小部件上拖放三个 Label 小部件,两个 LineEdit 小部件和一个 PushButton 小部件。
-
将三个 Label 小部件的文本属性设置为
登录
,电子邮件地址
和密码
。 -
将 Push Button 小部件的文本属性设置为
登录
。 -
我们将不设置 LineEdit 和 PushButton 小部件的 objectName 属性,并且不会为 PushButton 小部件提供任何代码,因为此应用程序的目的是了解 Dock 小部件的工作原理。
-
将应用程序保存为
demoDockWidget.ui
。
表单将显示如下屏幕截图所示:
- 要启用 Dock 小部件中的所有功能,请选择它并在属性编辑器窗口的功能部分中检查其 AllDockWidgetFeatures 属性,如下图所示:
在上述屏幕截图中,AllDockWidgetFeatures 属性是使 Dock 小部件可关闭,在停靠时可移动,并且可以在桌面的任何位置浮动。如果选择了 NoDockWidgetFeatures 属性,则功能部分中的所有其他属性将自动取消选中。这意味着所有按钮将从 Dock 小部件中消失,您将无法关闭或移动它。如果希望 Dock 小部件在应用程序启动时显示为可浮动,请在属性编辑器窗口中的功能部分上方检查浮动属性。
查看以下屏幕截图,显示了 Dock 小部件上的各种功能和约束:
执行以下步骤,将所需的功能和约束应用于 Dock 小部件:
-
在 allowedAreas 部分中检查 AllDockWidgetAreas 选项,以使 Dock 小部件可以停靠在左侧,右侧,顶部和底部的所有 Dock 小部件区域。
-
此外,通过在属性编辑器窗口中使用 windowTitle 属性,将停靠窗口的标题设置为 Dockable Sign In Form,如上图所示。
-
检查停靠属性,因为这是使 Dock 小部件可停靠的重要属性。如果未选中停靠属性,则 Dock 小部件无法停靠到任何允许的区域。
-
将 dockWidgetArea 属性保留其默认值 LeftDockWidgetArea。dockWidgetArea 属性确定您希望停靠窗口小部件在应用程序启动时出现为停靠的位置。dockWidgetArea 属性的 LeftDockWidgetArea 值将使停靠窗口小部件首先出现为停靠在左侧停靠窗口区域。如果在 allowedAreas 部分设置了 NoDockWidgetArea 属性,则 allowedAreas 部分中的所有其他属性将自动取消选择。因此,您可以将停靠窗口移动到桌面的任何位置,但不能将其停靠在主窗口模板的停靠区域中。使用 Qt Designer 创建的用户界面存储在一个
.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。在 XML 文件上应用pyuic5
命令行实用程序后,生成的文件是一个 Python 脚本文件demoDockWidget.py
。您可以在本书的源代码包中看到生成的demoDockWidget.py
文件的代码。 -
将
demoDockWidget.py
文件中的代码视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callDockWidget.pyw
的 Python 文件,并将demoDockWidget.py
的代码导入其中:
import sys
from PyQt5.QtWidgets import QMainWindow, QApplication
from demoDockWidget import *
class AppWindow(QMainWindow):
def __init__(self):
super().__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.show()
if __name__=="__main__":
app = QApplication(sys.argv)
w = AppWindow()
w.show()
sys.exit(app.exec_())
工作原理...
如前面的代码所示,导入了必要的模块。创建了一个AppWindow
类,它继承自基类QMainWindow
。调用了QMainWindow
的默认构造函数。
因为每个 PyQt5 应用程序都需要一个应用程序对象,在上面的代码中,通过调用QApplication()
方法创建了一个名为 app 的应用程序对象。将sys.argv
参数作为参数传递给QApplication()
方法,以传递命令行参数和其他外部属性给应用程序。sys.argv
参数包含命令行参数和其他外部属性(如果有的话)。为了显示界面中定义的小部件,创建了一个名为w
的AppWindow
类的实例,并在其上调用了show()
方法。为了退出应用程序并将代码返回给可能用于错误处理的 Python 解释器,调用了sys.exit()
方法。
当应用程序执行时,默认情况下会得到一个停靠在左侧可停靠区域的停靠窗口小部件,如下面的屏幕截图所示。这是因为您已经将dockWidgetArea
属性的值分配给了LeftDockWidgetArea
:
停靠窗口小部件内的小部件不完全可见,因为默认的左侧和可停靠区域比停靠窗口小部件中放置的小部件要窄。因此,您可以拖动停靠窗口小部件的右边框,使所有包含的小部件可见,如下面的屏幕截图所示:
您可以将小部件拖动到任何区域。如果将其拖动到顶部,则会停靠在TopDockWidgetArea
停靠区域,如下面的屏幕截图所示:
同样,当将停靠窗口小部件拖动到右侧时,它将停靠在RightDockWidgetArea
中
您可以将停靠窗口小部件拖动到主窗口模板之外,使其成为一个独立的浮动窗口。停靠窗口小部件将显示为一个独立的浮动窗口,并可以移动到桌面的任何位置:
多文档界面
在这个示例中,我们将学习如何创建一个应用程序,可以同时显示多个文档。我们不仅能够管理多个文档,还将学会以不同的格式排列这些文档。我们将能够使用称为多文档界面的概念来管理多个文档,让我们快速了解一下这个概念。
准备工作
通常,一个应用程序提供一个主窗口对应一个文档,这样的应用程序被称为单文档界面(SDI)应用程序。顾名思义,多文档界面(MDI)应用程序能够显示多个文档。MDI 应用程序由一个主窗口以及一个菜单栏、一个工具栏和一个中心空间组成。多个文档可以显示在中心空间中,每个文档可以通过各自的子窗口小部件进行管理;在 MDI 中,可以显示多个文档,每个文档都显示在自己的窗口中。这些子窗口也被称为子窗口。
MDI 是通过使用MdiArea
小部件来实现的。MdiArea
小部件提供了一个区域,用于显示子窗口。子窗口有标题和按钮,用于显示、隐藏和最大化其大小。每个子窗口可以显示一个单独的文档。可以通过设置MdiArea
小部件的相应属性,将子窗口以级联或平铺方式排列。MdiArea
小部件是QMdiArea
类的一个实例,子窗口是QMdiSubWindow
的实例。
以下是QMdiArea
提供的方法:
-
subWindowList()
: 这个方法返回 MDI 区域中所有子窗口的列表。返回的列表按照通过WindowOrder()
函数设置的顺序排列。 -
WindowOrder
:这个静态变量设置了对子窗口列表进行排序的标准。以下是可以分配给这个静态变量的有效值: -
CreationOrder
:窗口按照它们创建的顺序返回。这是默认顺序。 -
StackingOrder
:窗口按照它们叠放的顺序返回,最上面的窗口最后出现在列表中。 -
ActivationHistoryOrder
:窗口按照它们被激活的顺序返回。 -
activateNextSubWindow()
: 这个方法将焦点设置为子窗口列表中的下一个窗口。当前窗口的顺序决定了要激活的下一个窗口。 -
activatePreviousSubWindow()
: 这个方法将焦点设置为子窗口列表中的上一个窗口。当前窗口的顺序决定了要激活的上一个窗口。 -
cascadeSubWindows()
: 这个方法以级联方式排列子窗口。 -
tileSubWindows()
: 这个方法以平铺方式排列子窗口。 -
closeAllSubWindows()
: 这个方法关闭所有子窗口。 -
setViewMode()
: 这个方法设置 MDI 区域的视图模式。子窗口可以以两种模式查看,子窗口视图和选项卡视图: -
子窗口视图:这个方法显示带有窗口框架的子窗口(默认)。如果以平铺方式排列,可以看到多个子窗口的内容。它还由一个常量值
0
表示。 -
选项卡视图:在选项卡栏中显示带有选项卡的子窗口。一次只能看到一个子窗口的内容。它还由一个常量值
1
表示。
如何做...
让我们创建一个应用程序,其中包含两个文档,每个文档将通过其各自的子窗口显示。我们将学习如何按需排列和查看这些子窗口:
-
启动 Qt Designer 并创建一个新的主窗口应用程序。
-
将
MdiArea
小部件拖放到表单上。 -
右键单击小部件,从上下文菜单中选择“添加子窗口”以将子窗口添加到
MdiArea
小部件中。
当子窗口添加到MdiArea
小部件时,该小部件将显示为深色背景,如下面的屏幕截图所示:
-
让我们再次右键单击
MdiArea
小部件,并向其添加一个子窗口。 -
要知道哪一个是第一个,哪一个是第二个子窗口,可以在每个子窗口上拖放一个 Label 小部件。
-
将放置在第一个子窗口中的 Label 小部件的文本属性设置为
First subwindow
。 -
将放置在第二个子窗口中的 Label 小部件的文本属性设置为
Second subwindow
,如下面的屏幕截图所示:
MdiArea
小部件以以下两种模式显示放置在其子窗口中的文档:
-
子窗口视图:这是默认视图模式。在此视图模式下,子窗口可以以级联或平铺方式排列。当子窗口以平铺方式排列时,可以同时看到多个子窗口的内容。
-
选项卡视图:在此模式下,选项卡栏中会显示多个选项卡。选择选项卡时,将显示与之关联的子窗口。一次只能看到一个子窗口的内容。
- 通过菜单选项激活子窗口视图和选项卡视图模式,双击菜单栏中的 Type Here 占位符,并向其添加两个条目:子窗口视图和选项卡视图。
此外,为了查看子窗口以级联和平铺方式排列时的外观,将两个菜单项 Cascade View 和 Tile View 添加到菜单栏中,如下面的屏幕截图所示:
- 将应用程序保存为
demoMDI.ui
。使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。在应用pyuic5
命令行实用程序时,.ui
(XML)文件将被转换为 Python 代码:
C:\Pythonbook\PyQt5>pyuic5 demoMDI.ui -o demoMDI.py.
您可以在本书的源代码包中看到生成的 Python 代码demoMDI.py
。
- 将
demoMDI.py
文件中的代码视为头文件,并将其导入到您将调用其用户界面设计的文件中。前面的代码中的用户界面设计包括MdiArea
,用于显示其中创建的子窗口以及它们各自的小部件。我们将要创建的 Python 脚本将包含用于执行不同任务的菜单选项的代码,例如级联和平铺子窗口,将视图模式从子窗口视图更改为选项卡视图,反之亦然。让我们将该 Python 脚本命名为callMDI.pyw
,并将demoMDI.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QMainWindow, QApplication, QAction, QFileDialog
from demoMDI import *
class MyForm(QMainWindow):
def __init__(self):
super().__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.ui.mdiArea.addSubWindow(self.ui.subwindow)
self.ui.mdiArea.addSubWindow(self.ui.subwindow_2)
self.ui.actionSubWindow_View.triggered.connect
(self.SubWindow_View)
self.ui.actionTabbed_View.triggered.connect(self.
Tabbed_View)
self.ui.actionCascade_View.triggered.connect(self.
cascadeArrange)
self.ui.actionTile_View.triggered.connect(self.tileArrange)
self.show()
def SubWindow_View(self):
self.ui.mdiArea.setViewMode(0)
def Tabbed_View(self):
self.ui.mdiArea.setViewMode(1)
def cascadeArrange(self):
self.ui.mdiArea.cascadeSubWindows()
def tileArrange(self):
self.ui.mdiArea.tileSubWindows()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
在上述代码中,您可以看到具有默认 objectName 属性subwindow
和subwindow_2
的两个子窗口被添加到MdiArea
小部件中。之后,具有 objectName 属性actionSubWindow_View
、actionTabbed_View
、actionCascade_View
和actionTile_View
的四个菜单选项分别连接到四个方法SubWindow_View
、Tabbed_View
、cascadeArrange
和tileArrange
。因此,当用户选择子窗口视图菜单选项时,将调用SubWindow_View
方法。在SubWindow_View
方法中,通过将0
常量值传递给MdiArea
小部件的setViewMode
方法来激活子窗口视图模式。子窗口视图显示带有窗口框架的子窗口。
类似地,当用户选择选项卡视图菜单选项时,将调用Tabbed_View
方法。在Tabbed_View
方法中,通过将1
常量值传递给MdiArea
小部件的setViewMode
方法来激活选项卡视图模式。选项卡视图模式在选项卡栏中显示选项卡,单击选项卡时,将显示关联的子窗口。
选择级联视图菜单选项时,将调用cascadeArrange
方法,该方法又调用MdiArea
小部件的cascadeSubWindows
方法以级联形式排列子窗口。
选择平铺视图菜单选项时,将调用tileArrange
方法,该方法又调用MdiArea
小部件的tileSubWindows
方法以平铺形式排列子窗口。
运行应用程序时,子窗口最初以缩小模式出现在MdiArea
小部件中,如下面的屏幕截图所示。您可以看到子窗口以及它们的标题和最小化、最大化和关闭按钮:
您可以拖动它们的边框到所需的大小。在 Windows 菜单中选择第一个窗口时,子窗口将变为活动状态;选择第二个窗口时,下一个子窗口将变为活动状态。活动子窗口显示为更亮的标题和边界。在下面的截图中,您可以注意到第二个子窗口是活动的。您可以拖动任何子窗口的边界来增加或减少其大小。您还可以最小化一个子窗口,并拖动另一个子窗口的边界以占据整个MdiArea
小部件的整个宽度。如果在任何子窗口中选择最大化,它将占据MdiArea
的所有空间,使其他子窗口不可见:
在选择级联时,子窗口以级联模式排列,如下截图所示。如果在级联模式下最大化窗口,则顶部子窗口将占据整个MdiArea
小部件,将其他子窗口隐藏在其后,如下截图所示:
在选择平铺按钮时,子窗口会展开并平铺。两个子窗口均等地扩展以覆盖整个工作区,如下截图所示:
在选择选项卡视图按钮时,MdiArea
小部件将从子窗口视图更改为选项卡视图。您可以选择任何子窗口的选项卡使其处于活动状态,如下截图所示:
使用选项卡小部件显示信息的部分
在这个应用程序中,我们将制作一个小型购物车,它将在一个选项卡中显示某些待售产品;在用户从第一个选项卡中选择所需产品后,当用户选择第二个选项卡时,他们将被提示输入首选付款选项。第三个选项卡将要求用户输入交付产品的地址。
我们将使用选项卡小部件使我们能够选择并分块填写所需的信息,所以您一定想知道,选项卡小部件是什么?
当某些信息被分成小节,并且您希望为用户显示所需部分的信息时,您需要使用选项卡小部件。在选项卡小部件容器中,有许多选项卡,当用户选择任何选项卡时,将显示分配给该选项卡的信息。
如何做...
以下是逐步创建应用程序以使用选项卡显示信息的过程:
-
让我们基于没有按钮的对话框模板创建一个新应用程序。
-
将选项卡小部件拖放到表单上。当您将选项卡小部件拖放到对话框上时,它将显示两个默认选项卡按钮,标有 Tab1 和 Tab2,如下截图所示:
-
您可以向选项卡小部件添加更多选项卡按钮,并通过添加新的选项卡按钮删除现有按钮;右键单击任一选项卡按钮,然后从弹出的菜单中选择“插入页面”。您将看到两个子选项,当前页面之后和当前页面之前。
-
选择“当前页面之后”子选项以在当前选项卡之后添加一个新选项卡。新选项卡将具有默认文本“页面”,您可以随时更改。我们将要制作的应用程序包括以下三个选项卡:
-
第一个选项卡显示某些产品以及它们的价格。用户可以从第一个选项卡中选择任意数量的产品,然后单击“添加到购物车”按钮。
-
在选择第二个选项卡时,将显示所有付款选项。用户可以选择通过借记卡、信用卡、网上银行或货到付款进行付款。
-
第三个选项卡在选择时将提示用户输入交付地址:客户的完整地址以及州、国家和联系电话。
我们将首先更改选项卡的默认文本:
-
使用选项卡小部件的 currentTabText 属性,更改每个选项卡按钮上显示的文本。
-
将第一个选项卡按钮的文本属性设置为“产品列表”,将第二个选项卡按钮的文本属性设置为“付款方式”。
-
要添加一个新的选项卡按钮,在“付款方式”选项卡上右键单击,并从出现的上下文菜单中选择“插入页面”。
-
从出现的两个选项中,选择“当前页之后”和“当前页之前”,选择“当前页之后”以在“付款方式”选项卡之后添加一个新选项卡。新选项卡将具有默认文本“页面”。
-
使用 currentTabText 属性,将其文本更改为“交付地址”。
-
通过选择并拖动其节点来展开选项卡窗口,以在选项卡按钮下方提供空白空间,如下面的屏幕截图所示:
-
选择每个选项卡按钮,并将所需的小部件放入提供的空白空间。例如,将四个复选框小部件放到第一个选项卡按钮“产品列表”上,以显示可供销售的物品。
-
在表单上放置一个推送按钮小部件。
-
将四个复选框的文本属性更改为
手机$150
、笔记本电脑$500
、相机$250
和鞋子$200
。 -
将推送按钮小部件的文本属性更改为“添加到购物车”,如下面的屏幕截图所示:
-
类似地,要提供不同的付款方式,选择第二个选项卡,并在可用空间中放置四个单选按钮。
-
将四个单选按钮的文本属性设置为“借记卡”、“信用卡”、“网上银行”和“货到付款”,如下面的屏幕截图所示:
-
选择第三个选项卡,然后拖放几个 LineEdit 小部件,提示用户提供交付地址。
-
将六个 Label 和六个 LineEdit 小部件拖放到表单上。
-
将 Label 小部件的文本属性设置为
地址 1
、地址 2
、州
、国家
、邮政编码
和联系电话
。每个 Label 小部件前面的 LineEdit 小部件将用于获取交付地址,如下面的屏幕截图所示:
-
将应用程序保存为
demoTabWidget.ui
。 -
使用 Qt Designer 创建的用户界面存储在一个
.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。要进行转换,需要打开命令提示符窗口,转到保存文件的文件夹,并发出此命令:
C:PythonbookPyQt5>pyuic5 demoTabWidget.ui -o demoTabWidget.py
生成的 Python 脚本文件demoTabWidget.py
的代码可以在本书的源代码包中找到。通过将其导入到另一个 Python 脚本中,使用自动生成的代码demoTablWidget.py
创建的用户界面设计。
- 创建另一个名为
callTabWidget.pyw
的 Python 文件,并将demoTabWidget.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoTabWidget import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.show()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
如callTabWidget.pyw
中所示,导入了必要的模块。创建了MyForm
类,并继承自基类QDialog
。调用了QDialog
的默认构造函数。
通过QApplication()
方法创建名为app
的应用程序对象。每个 PyQt5 应用程序都必须创建一个应用程序对象。在创建应用程序对象时,将sys.argv
参数传递给QApplication()
方法。sys.argv
参数包含来自命令行的参数列表,并有助于传递和控制脚本的启动属性。之后,使用MyForm
类的实例创建名为w
的实例。在实例上调用show()
方法,将在屏幕上显示小部件。sys.exit()
方法确保干净的退出,释放内存资源。
当应用程序执行时,您会发现默认情况下选择了第一个选项卡“产品列表”,并且该选项卡中指定的可供销售的产品如下屏幕截图所示:
同样,在选择其他选项卡“付款方式”和“交货地址”时,您将看到小部件提示用户选择所需的付款方式并输入交货地址。
创建自定义菜单栏
一个大型应用程序通常被分解为小的、独立的、可管理的模块。这些模块可以通过制作不同的工具栏按钮或菜单项来调用。也就是说,我们可以在单击菜单项时调用一个模块。我们在不同的软件包中看到了文件菜单、编辑菜单等,因此让我们学习如何制作自己的自定义菜单栏。
在本教程中,我们将学习创建显示特定菜单项的菜单栏。我们将学习如何添加菜单项,向菜单项添加子菜单项,在菜单项之间添加分隔符,向菜单项添加快捷键和工具提示,以及更多内容。我们还将学习如何向这些菜单项添加操作,以便单击任何菜单项时会执行某个操作。
我们的菜单栏将包括两个菜单,绘图和编辑。绘图菜单将包括四个菜单项,绘制圆形、绘制矩形、绘制直线和属性。属性菜单项将包括两个子菜单项,页面设置和设置密码。第二个菜单,编辑,将包括三个菜单项,剪切、复制和粘贴。让我们创建一个新应用程序,以了解如何实际创建这个菜单栏。
如何做…
我们将按照逐步程序来制作两个菜单,以及每个菜单中的相应菜单项。为了快速访问,每个菜单项也将与快捷键相关联。以下是创建我们自定义菜单栏的步骤:
- 启动 Qt Designer 并创建一个基于 Main Window 模板的应用程序。
您会得到具有默认菜单栏的新应用程序,因为 Qt Designer 的 Main Window 模板默认提供了一个显示菜单栏的主应用程序窗口。默认菜单栏如下截图所示:
-
您可以通过右键单击主窗口并从弹出的上下文菜单中选择“删除菜单栏”选项来删除默认菜单栏。
-
您还可以通过从上下文菜单中选择“创建菜单栏”选项来稍后添加菜单栏。
默认菜单栏包含“在此处输入”占位符。您可以用菜单项文本替换它们。
-
单击占位符以突出显示它,并输入以修改其文本。当您添加菜单项时,“在此处输入”将出现在新菜单项下方。
-
再次,只需单击“在此处输入”占位符以选择它,然后简单地输入下一个菜单项的文本。
-
您可以通过右键单击任何菜单项并从弹出的上下文菜单中选择“删除操作 action_name”选项来删除任何菜单项。
菜单栏中的菜单和菜单项可以通过拖放在所需位置进行排列。
在编写菜单或菜单项文本时,如果在任何字符之前添加一个&
字符,菜单中的该字符将显示为下划线,并且将被视为快捷键。我们还将学习如何稍后为菜单项分配快捷键。
- 当您通过替换“在此处输入”占位符创建新菜单项时,该菜单项将显示为操作编辑框中的单独操作,您可以从那里配置其属性。
回想一下,我们想在这个菜单栏中创建两个菜单,文本为“绘图”和“编辑”。绘图菜单将包含三个菜单项,绘制圆形、绘制矩形和绘制直线。在这三个菜单项之后,将插入一个分隔符,然后是一个名为“属性”的第四个菜单项。属性菜单项将包含两个子菜单项,页面设置和设置密码。编辑菜单将包含三个菜单项,剪切、复制和粘贴。
- 双击“在此处输入”占位符,输入第一个菜单“绘图”的文本。
在“绘图”菜单上按下箭头键会弹出“在此处输入”和“添加分隔符”选项,如下截图所示:
-
双击“在此处输入”,并为“绘制”菜单下的第一个菜单项输入“绘制圆形”。在“绘制圆形”菜单上按下箭头键会再次提供“在此处输入”和“添加分隔符”选项。
-
双击“在此处输入”并输入“绘制矩形”作为菜单项。
-
按下下箭头键以获取两个选项,“在此处输入”和“添加分隔符”。
-
双击“在此处输入”,并为第三个菜单项输入“绘制线条”。
-
按下下箭头键后,再次会出现两个选项,“在此处输入”和“添加分隔符”,如下截图所示:
-
选择“添加分隔符”以在前三个菜单项后添加分隔符。
-
在分隔符后按下下箭头键,并添加第四个菜单项“属性”。这是因为我们希望“属性”菜单项有两个子菜单项。
-
选择右箭头以向“属性”菜单添加子菜单项。
-
在任何菜单项上按下右箭头键,以向其添加子菜单项。在子菜单项中,选择“在此处输入”,并输入第一个子菜单“页面设置”。
-
选择下箭头,并在页面设置子菜单项下输入“设置密码”,如下截图所示:
-
第一个菜单“绘制”已完成。现在,我们需要添加另一个菜单“编辑”。选择“绘制”菜单,并按下右箭头键,表示要在菜单栏中添加第二个菜单。
-
将“在此处输入”替换为“编辑”。
-
按下下箭头,并添加三个菜单项,剪切、复制和粘贴,如下截图所示:
所有菜单项的操作将自动显示在操作编辑框中,如下截图所示:
您可以看到操作名称是通过在每个菜单文本前缀文本操作并用下划线替换空格而生成的。这些操作可用于配置菜单项。
-
要添加悬停在任何菜单项上时出现的工具提示消息,可以使用 ToolTip 属性。
-
要为“绘制”菜单的“绘制圆形”菜单项分配工具提示消息,请在操作编辑框中选择 actionDraw_Circle,并将 ToolTip 属性设置为“绘制圆形”。类似地,您可以为所有菜单项分配工具提示消息。
-
要为任何菜单项分配快捷键,请从操作编辑框中打开其操作,并单击快捷方式框内。
-
在快捷方式框中,按下要分配给所选菜单项的键组合。
例如,如果在快捷方式框中按下Ctrl + C,则如下截图所示,Ctrl+C 将出现在框中:
您可以使用任何组合的快捷键,例如Shift +键,Alt +键和Ctrl + Shift +键,用于任何菜单项。快捷键将自动显示在菜单栏中的菜单项中。您还可以使任何菜单项可选,即可以将其设置为切换菜单项。
- 为此,选择所需菜单项的操作并勾选可选复选框。每个菜单项的操作,以及其操作名称、菜单文本、快捷键、可选状态和工具提示,都会显示在操作编辑框中。以下截图显示了“设置密码”子菜单项的操作,确认其快捷键为Shift + P,并且可以选择:
-
对于“绘制圆形”、“绘制矩形”和“绘制线条”菜单项,我们将添加代码来分别绘制圆形、矩形和直线。
-
对于其余的菜单项,我们希望当用户选择任何一个时,在表单上会出现一个文本消息,指示选择了哪个菜单项。
-
要显示消息,请将标签小部件拖放到表单上。
-
我们的菜单栏已完成;使用名称
demoMenuBar.ui
保存应用程序。 -
我们使用
pyuic5
命令行实用程序将.ui
(XML)文件转换为 Python 代码。
生成的 Python 代码demoMenuBar.py
可以在本书的源代码包中找到。
- 创建一个名为
callMenuBar.pyw
的 Python 脚本,导入之前的代码demoMenuBar.py
,以调用菜单并在选择菜单项时显示带有 Label 小部件的文本消息。
您希望出现一条消息,指示选择了哪个菜单项。此外,当选择 Draw Circle、Draw Rectangle 和 Draw Line 菜单项时,您希望分别绘制一个圆、矩形和线。Python callMenuBar.pyw
脚本中的代码将如下屏幕截图所示:
import sys
from PyQt5.QtWidgets import QMainWindow, QApplication
from PyQt5.QtGui import QPainter
from demoMenuBar import *
class AppWindow(QMainWindow):
def __init__(self):
super().__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.pos1 = [0,0]
self.pos2 = [0,0]
self.toDraw=""
self.ui.actionDraw_Circle.triggered.connect(self.
drawCircle)
self.ui.actionDraw_Rectangle.triggered.connect(self.
drawRectangle)
self.ui.actionDraw_Line.triggered.connect(self.drawLine)
self.ui.actionPage_Setup.triggered.connect(self.pageSetup)
self.ui.actionSet_Password.triggered.connect(self.
setPassword)
self.ui.actionCut.triggered.connect(self.cutMethod)
self.ui.actionCopy.triggered.connect(self.copyMethod)
self.ui.actionPaste.triggered.connect(self.pasteMethod)
self.show()
def paintEvent(self, event):
qp = QPainter()
qp.begin(self)
if self.toDraw=="rectangle":
width = self.pos2[0]-self.pos1[0]
height = self.pos2[1] - self.pos1[1]
qp.drawRect(self.pos1[0], self.pos1[1], width, height)
if self.toDraw=="line":
qp.drawLine(self.pos1[0], self.pos1[1], self.pos2[0],
self.pos2[1])
if self.toDraw=="circle":
width = self.pos2[0]-self.pos1[0]
height = self.pos2[1] - self.pos1[1]
rect = QtCore.QRect(self.pos1[0], self.pos1[1], width,
height)
startAngle = 0
arcLength = 360 *16
qp.drawArc(rect, startAngle,
arcLength)
qp.end()
def mousePressEvent(self, event):
if event.buttons() & QtCore.Qt.LeftButton:
self.pos1[0], self.pos1[1] = event.pos().x(),
event.pos().y()
def mouseReleaseEvent(self, event):
self.pos2[0], self.pos2[1] = event.pos().x(),
event.pos().y()
self.update()
def drawCircle(self):
self.ui.label.setText("")
self.toDraw="circle"
def drawRectangle(self):
self.ui.label.setText("")
self.toDraw="rectangle"
def drawLine(self):
self.ui.label.setText("")
self.toDraw="line"
def pageSetup(self):
self.ui.label.setText("Page Setup menu item is selected")
def setPassword(self):
self.ui.label.setText("Set Password menu item is selected")
def cutMethod(self):
self.ui.label.setText("Cut menu item is selected")
def copyMethod(self):
self.ui.label.setText("Copy menu item is selected")
def pasteMethod(self):
self.ui.label.setText("Paste menu item is selected")
app = QApplication(sys.argv)
w = AppWindow()
w.show()
sys.exit(app.exec_())
工作原理...
每个菜单项的操作的 triggered()信号都连接到其相应的方法。每个菜单项的 triggered()信号都连接到drawCircle()
方法,因此每当从菜单栏中选择 Draw Circle 菜单项时,都会调用drawCircle()
方法。类似地,actionDraw_Rectangle 和 actionDraw_Line 菜单的 triggered()信号分别连接到drawRectangle()
和drawLine()
方法。在drawCircle()
方法中,toDraw
变量被分配一个字符串circle
。toDraw
变量将用于确定在paintEvent
方法中要绘制的图形。toDraw
变量可以分配三个字符串中的任何一个,即line
、circle
或rectangle
。对toDraw
变量中的值应用条件分支,相应地将调用绘制线条、矩形或圆的方法。图形将根据鼠标确定的大小进行绘制,即用户需要单击鼠标并拖动以确定图形的大小。
两种方法,mousePressEvent()
和mouseReleaseEvent()
,在按下和释放左鼠标按钮时会自动调用。为了存储按下和释放左鼠标按钮的位置的x
和y
坐标,使用了两个数组pos1
和pos2
。左鼠标按钮按下和释放的位置的x
和y
坐标值通过mousePressEvent
和mouseReleaseEvent
方法分配给pos1
和pos2
数组。
在mouseReleaseEvent
方法中,分配鼠标释放位置的x
和y
坐标值后,调用self.update
方法来调用paintEvent()
方法。在paintEvent()
方法中,基于分配给toDraw
变量的字符串进行分支。如果toDraw
变量分配了line
字符串,QPainter
类将通过drawLine()
方法来绘制两个鼠标位置之间的线。类似地,如果toDraw
变量分配了circle
字符串,QPainter
类将通过drawArc()
方法来绘制直径由鼠标位置提供的圆。如果toDraw
变量分配了rectangle
字符串,QPainter
类将通过drawRect()
方法来绘制由鼠标位置提供的宽度和高度的矩形。
除了三个菜单项 Draw Circle、Draw Rectangle 和 Draw Line 之外,如果用户单击任何其他菜单项,将显示一条消息,指示用户单击的菜单项。因此,其余菜单项的 triggered()信号将连接到显示用户通过 Label 小部件选择的菜单项的消息信息的方法。
运行应用程序时,您会发现一个带有两个菜单 Draw 和 Edit 的菜单栏。Draw 菜单将显示四个菜单项 Draw Circle、Draw Rectangle、Draw Line 和 Properties,在 Properties 菜单项之前显示一个分隔符。Properties 菜单项显示两个子菜单项 Page Setup 和 Set Password,以及它们的快捷键,如下面的屏幕截图所示:
绘制一个圆,点击“绘制圆”菜单项,在窗体上的某个位置点击鼠标按钮,保持鼠标按钮按住,拖动以定义圆的直径。释放鼠标按钮时,将在鼠标按下和释放的位置之间绘制一个圆,如下截图所示:
选择其他菜单项时,将显示一条消息,指示按下的菜单项。例如,选择“复制”菜单项时,将显示消息“选择了复制菜单项”,如下截图所示:
第十九章:数据库处理
数据库处理在任何应用程序中都起着重要作用,因为数据需要存储以备将来使用。您需要存储客户信息、用户信息、产品信息、订单信息等。在本章中,您将学习与数据库处理相关的每项任务:
-
创建数据库
-
创建数据库表
-
在指定的数据库表中插入行
-
显示指定数据库表中的行
-
在指定的数据库表中导航行
-
在数据库表中搜索特定信息
-
创建登录表单-应用认证程序
-
更新数据库表-更改用户密码
-
从数据库表中删除一行
我们将使用 SQLite 进行数据库处理。在我们进入本章的更深入之前,让我们快速介绍一下 SQLite。
介绍
SQLite 是一个非常易于使用的数据库引擎。基本上,它是一个轻量级数据库,适用于存储在单个磁盘文件中的小型应用程序。它是一个非常受欢迎的数据库,用于手机、平板电脑、小型设备和仪器。SQLite 不需要单独的服务器进程,甚至不需要任何配置。
为了使这个数据库在 Python 脚本中更容易使用,Python 标准库包括一个名为sqlite3
的模块。因此,要在任何 Python 应用程序中使用 SQLite,您需要使用import
语句导入sqlite3
模块,如下所示:
import sqlite3
使用任何数据库的第一步是创建一个connect
对象,通过它您需要与数据库建立连接。以下示例建立到ECommerce
数据库的连接:
conn = sqlite3.connect('ECommerce.db')
如果数据库已经存在,此示例将建立到ECommerce
数据库的连接。如果数据库不存在,则首先创建数据库,然后建立连接。
您还可以使用connect
方法中的:memory:
参数在内存中创建临时数据库。
conn = sqlite3.connect(':memory:')
您还可以使用:memory:
特殊名称在 RAM 中创建数据库。
一旦与数据库相关的工作结束,您需要使用以下语句关闭连接:
conn.close()
创建游标对象
要使用数据库表,您需要获取一个cursor
对象,并将 SQL 语句传递给cursor
对象以执行它们。以下语句创建一个名为cur
的cursor
对象:
cur = conn.cursor()
使用cursor
对象cur
,您可以执行 SQL 语句。例如,以下一组语句创建一个包含三列id
、EmailAddress
和Password
的Users
表:
# Get a cursor object
cur = conn.cursor() cur.execute('''CREATE TABLE Users(id INTEGER PRIMARY KEY, EmailAddress TEXT, Password TEXT)''') conn.commit()
请记住,您需要通过在连接对象上调用commit()
方法来提交对数据库的更改,否则对数据库所做的所有更改都将丢失。
以下一组语句将删除Users
表:
# Get a cursor object
cur = conn.cursor() cur.execute('''DROP TABLE Users''') conn.commit()
创建数据库
在这个示例中,我们将提示用户输入数据库名称,然后点击按钮。点击按钮后,如果指定的数据库不存在,则创建它,如果已经存在,则连接它。
如何做…
按照逐步过程在 SQLite 中创建数据库:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
通过拖放两个标签小部件、一个行编辑小部件和一个按钮小部件到表单上,添加两个
QLabel
小部件、一个QLineEdit
小部件和一个QPushButton
小部件。 -
将第一个标签小部件的文本属性设置为
输入数据库名称
。 -
删除第二个标签小部件的文本属性,因为这是已经建立的。
-
将行编辑小部件的对象名称属性设置为
lineEditDBName
。 -
将按钮小部件的对象名称属性设置为
pushButtonCreateDB
。 -
将第二个标签小部件的对象名称属性设置为
labelResponse
。 -
将应用程序保存为
demoDatabase.ui
。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。通过应用pyuic5
实用程序,将 XML 文件转换为 Python 代码。生成的 Python 脚本demoDatabase.py
可以在本书的源代码包中看到。
-
将
demoDatabase.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callDatabase.pyw
的 Python 文件,并将demoDatabase.py
代码导入其中:
import sqlite3, sys
from PyQt5.QtWidgets import QDialog, QApplication
from sqlite3 import Error
from demoDatabase import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonCreateDB.clicked.connect(self.
createDatabase)
self.show()
def createDatabase(self):
try:
conn = sqlite3.connect(self.ui.lineEditDBName.
text()+".db")
self.ui.labelResponse.setText("Database is created")
except Error as e:
self.ui.labelResponse.setText("Some error has
occurred")
finally:
conn.close()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
您可以在脚本中看到,具有 objectName 属性pushButtonCreateDB
的按钮的 click()事件与createDatabase()
方法连接在一起。这意味着每当单击按钮时,就会调用createDatabase()
方法。在createDatabase()
方法中,调用了sqlite3
类的connect()
方法,并将用户在 Line Edit 小部件中输入的数据库名称传递给connect()
方法。如果在创建数据库时没有发生错误,则通过 Label 小部件显示消息“数据库已创建”以通知用户;否则,通过 Label 小部件显示消息“发生了一些错误”以指示发生错误。
运行应用程序时,将提示您输入数据库名称。假设我们输入数据库名称为Ecommerce
。单击“创建数据库”按钮后,将创建数据库并收到消息“数据库已创建”:
创建数据库表
在这个示例中,我们将学习如何创建一个数据库表。用户将被提示指定数据库名称,然后是要创建的表名称。该示例使您能够输入列名及其数据类型。单击按钮后,将在指定的数据库中创建具有定义列的表。
如何做...
以下是创建一个 GUI 的步骤,使用户能够输入有关要创建的数据库表的所有信息。使用此 GUI,用户可以指定数据库名称、列名,并且还可以选择列类型:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
通过拖放五个 Label、三个 Line Edit、一个 Combo Box 和两个 Push Button 小部件到表单上,添加五个 QLabel、三个 QLineEdit、一个 QComboBox 和两个 QPushButton 小部件。
-
将前四个 Label 小部件的文本属性设置为
输入数据库名称
,输入表名称
,列名
和数据类型
。 -
删除第五个 Label 小部件的文本属性,因为这是通过代码建立的。
-
将两个 push 按钮的文本属性设置为
添加列
和创建表
。 -
将三个 Line Edit 小部件的 objectName 属性设置为
lineEditDBName
、lineEditTableName
和lineEditColumnName
。 -
将 Combo Box 小部件的 objectName 属性设置为
ComboBoxDataType
。 -
将两个 push 按钮的 objectName 属性设置为
pushButtonAddColumn
和pushButtonCreateTable
。 -
将第五个 Label 小部件的 objectName 属性设置为
labelResponse
。 -
将应用程序保存为
demoCreateTable.ui
。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。使用pyuic5
命令将 XML 文件转换为 Python 代码。本书的源代码包中可以看到生成的 Python 脚本demoCreateTable.py
。
-
将
demoCreateTable.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callCreateTable.pyw
的 Python 文件,并将demoCreateTable.py
代码导入其中:
import sqlite3, sys
from PyQt5.QtWidgets import QDialog, QApplication
from sqlite3 import Error
from demoCreateTable import *
tabledefinition=""
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonCreateTable.clicked.connect(
self.createTable)
self.ui.pushButtonAddColumn.clicked.connect(self.
addColumns)
self.show()
def addColumns(self):
global tabledefinition
if tabledefinition=="":
tabledefinition="CREATE TABLE IF NOT EXISTS "+
self.ui.lineEditTableName.text()+" ("+
self.ui.lineEditColumnName.text()+" "+
self.ui.comboBoxDataType.itemText(self.ui.
comboBoxDataType.currentIndex())
else:
tabledefinition+=","+self.ui.lineEditColumnName
.text()+" "+ self.ui.comboBoxDataType.itemText
(self.ui.comboBoxDataType.currentIndex())
self.ui.lineEditColumnName.setText("")
self.ui.lineEditColumnName.setFocus()
def createTable(self):
global tabledefinition
try:
conn = sqlite3.connect(self.ui.lineEditDBName.
text()+".db")
self.ui.labelResponse.setText("Database is
connected")
c = conn.cursor()
tabledefinition+=");"
c.execute(tabledefinition)
self.ui.labelResponse.setText("Table is successfully
created")
except Error as e:
self.ui.labelResponse.setText("Error in creating
table")
finally:
conn.close()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理…
在脚本中可以看到,具有 objectName 属性pushButtonCreateTable
的按钮的 click()事件与createTable()
方法相连。这意味着每当单击此按钮时,将调用createTable()
方法。类似地,具有 objectName 属性pushButtonAddColumn
的按钮的 click()事件与addColumns()
方法相连。也就是说,单击此按钮将调用addColumns()
方法。
在addColumns()
方法中,定义了CREATE TABLE SQL
语句,其中包括在 LineEdit 小部件中输入的列名和从组合框中选择的数据类型。用户可以向表中添加任意数量的列。
在createTable()
方法中,首先建立与数据库的连接,然后执行addColumns()
方法中定义的CREATE TABLE SQL
语句。如果成功创建表,将通过最后一个 Label 小部件显示一条消息,通知您表已成功创建。最后,关闭与数据库的连接。
运行应用程序时,将提示您输入要创建的数据库名称和表名称,然后输入该表中所需的列。假设您要在ECommerce
表中创建一个Users
表,其中包括EmailAddress
和Password
两列,这两列都假定为文本类型。
Users
表中的第一列名为Email Address
,如下面的屏幕截图所示:
让我们在Users
表中定义另一列,称为Password
,类型为文本,然后点击 Create Table 按钮。如果成功创建了指定列数的表,将通过最后一个 Label 小部件显示消息“表已成功创建”,如下面的屏幕截图所示:
为了验证表是否已创建,我将使用一种可视化工具,该工具可以让您创建、编辑和查看数据库表及其中的行。这个可视化工具是 SQLite 的 DB Browser,我从sqlitebrowser.org/
下载了它。在启动 DB Browser for SQLite 后,点击主菜单下方的“打开数据库”选项卡。浏览并选择当前文件夹中的ECommerce
数据库。ECommerce
数据库显示了一个包含两列EmailAddress
和Password
的Users
表,如下面的屏幕截图所示,证实数据库表已成功创建:
在指定的数据库表中插入行
在本教程中,我们将学习如何向表中插入行。我们假设一个名为Users
的表已经存在于名为ECommerce
的数据库中,包含两列EmailAddress
和Password
。
在分别输入电子邮件地址和密码后,当用户点击“插入行”按钮时,将会将行插入到指定的数据库表中。
操作步骤…
以下是向存在于 SQLite 中的数据库表中插入行的步骤:
-
让我们创建一个基于无按钮对话框模板的应用程序。
-
通过拖放五个 Label 小部件、四个 LineEdit 小部件和一个 PushButton 小部件将它们添加到表单中。
-
将前四个 Label 小部件的文本属性设置为“输入数据库名称”、“输入表名称”、“电子邮件地址”和“密码”。
-
删除第五个 Label 小部件的文本属性,这是通过代码建立的。
-
将按钮的文本属性设置为“插入行”。
-
将四个 Line Edit 小部件的 objectName 属性设置为
lineEditDBName
、lineEditTableName
、lineEditEmailAddress
和lineEditPassword
。 -
将 Push Button 小部件的 objectName 属性设置为
pushButtonInsertRow
。 -
将第五个 Label 小部件的 objectName 属性设置为
labelResponse
。由于我们不希望密码显示出来,我们希望用户输入密码时显示星号。 -
为此,选择用于输入密码的 Line Edit 小部件,并从 Property Editor 窗口中选择 echoMode 属性,并将其设置为 Password,而不是默认的 Normal,如下屏幕截图所示:
echoMode 属性显示以下四个选项:
-
- Normal: 这是默认属性,当在 Line Edit 小部件中键入字符时显示。
-
NoEcho: 在 Line Edit 小部件中键入时不显示任何内容,也就是说,您甚至不会知道输入的文本长度。
-
Password: 主要用于密码。在 Line Edit 小部件中键入时显示星号。
-
PasswordEchoOnEdit: 在 Line Edit 小部件中键入密码时显示密码,尽管输入的内容会很快被星号替换。
- 将应用程序保存为
demoInsertRowsInTable.ui
。表单现在将显示如下屏幕截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。通过应用pyuic5
实用程序,XML 文件将被转换为 Python 代码。生成的 Python 脚本demoInsertRowsInTable.py
可以在本书的源代码包中找到。
- 创建另一个名为
callInsertRows.pyw
的 Python 文件,并将demoInsertRowsInTable.py
代码导入其中。Python 脚本callInsertRows.pyw
中的代码如下所示:
import sqlite3, sys
from PyQt5.QtWidgets import QDialog, QApplication
from sqlite3 import Error
from demoInsertRowsInTable import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonInsertRow.clicked.connect(self.
InsertRows)
self.show()
def InsertRows(self):
sqlStatement="INSERT INTO "+
self.ui.lineEditTableName.text() +"
VALUES('"+self.ui.lineEditEmailAddress.text()+"',
'"+self.ui.lineEditPassword.text()+"')"
try:
conn = sqlite3.connect(self.ui.lineEditDBName.
text()+ ".db")
with conn:
cur = conn.cursor()
cur.execute(sqlStatement)
self.ui.labelResponse.setText("Row successfully
inserted")
except Error as e:
self.ui.labelResponse.setText("Error in inserting
row")
finally:
conn.close()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
您可以在脚本中看到,具有 objectName 属性pushButtonInsertRow
的 push 按钮的单击事件连接到InsertRows()
方法。这意味着每当单击此 push 按钮时,将调用InsertRows()
方法。在InsertRows()
方法中,定义了一个INSERT SQL
语句,用于获取在 Line Edit 小部件中输入的电子邮件地址和密码。与输入数据库名称的 Line Edit 小部件建立连接。然后,执行INSERT SQL
语句,将新行添加到指定的数据库表中。最后,关闭与数据库的连接。
运行应用程序时,将提示您指定数据库名称、表名称以及两个列Email Address
和Password
的数据。输入所需信息后,单击插入行按钮,将向表中添加新行,并显示消息“成功插入行”,如下屏幕截图所示:
为了验证行是否插入了Users
表,我将使用一个名为 DB Browser for SQLite 的可视化工具。这是一个很棒的工具,可以让您创建、编辑和查看数据库表及其中的行。您可以从sqlitebrowser.org/
下载 DB Browser for SQLite。启动 DB Browser for SQLite 后,您需要首先打开数据库。要这样做,请单击主菜单下方的打开数据库选项卡。浏览并选择当前文件夹中的Ecommerce
数据库。Ecommerce
数据库显示Users
表。单击执行 SQL 按钮;您会得到一个小窗口来输入 SQL 语句。编写一个 SQL 语句,select * from Users
,然后单击窗口上方的运行图标。
在Users
表中输入的所有行将以表格格式显示,如下屏幕截图所示。确认我们在本教程中制作的应用程序运行良好:
在指定的数据库表中显示行
在这个示例中,我们将学习从给定数据库表中获取行并通过表小部件以表格格式显示它们。我们假设一个名为Users
的表包含两列,EmailAddress
和Password
,已经存在于名为ECommerce
的数据库中。此外,我们假设Users
表中包含一些行。
如何做…
按照以下逐步过程访问 SQLite 数据库表中的行:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
通过拖放三个标签小部件、两个行编辑小部件、一个按钮和一个表小部件到表单上,向表单添加三个
QLabel
小部件、两个QLineEdit
小部件、一个QPushButton
小部件和一个QTableWidget
小部件。 -
将两个标签小部件的文本属性设置为
输入数据库名称
和输入表名称
。 -
删除第三个标签小部件的文本属性,因为它的文本属性将通过代码设置。
-
将按钮的文本属性设置为
显示行
。 -
将两个行编辑小部件的 objectName 属性设置为
lineEditDBName
和lineEditTableName
。 -
将按钮小部件的 objectName 属性设置为
pushButtonDisplayRows
。 -
将第三个标签小部件的 objectName 属性设置为
labelResponse
。 -
将应用程序保存为
demoDisplayRowsOfTable.ui
。表单现在将显示如下截图所示:
将通过表小部件显示的Users
表包含两列。
-
选择表小部件,并在属性编辑器窗口中选择其 columnCount 属性。
-
将 columnCount 属性设置为
2
,将 rowCount 属性设置为3
,如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。通过应用pyuic5
实用程序,XML 文件将被转换为 Python 代码。生成的 Python 脚本demoInsertRowsInTable.py
可以在本书的源代码包中找到。
-
将
demoInsertRowsInTable.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callDisplayRows.pyw
的 Python 文件,并将demoDisplayRowsOfTable.py
代码导入其中:
import sqlite3, sys
from PyQt5.QtWidgets import QDialog, QApplication,QTableWidgetItem
from sqlite3 import Error
from demoDisplayRowsOfTable import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonDisplayRows.clicked.
connect(self.DisplayRows)
self.show()
def DisplayRows(self):
sqlStatement="SELECT * FROM "+
self.ui.lineEditTableName.text()
try:
conn = sqlite3.connect(self.ui.lineEditDBName.
text()+ ".db")
cur = conn.cursor()
cur.execute(sqlStatement)
rows = cur.fetchall()
rowNo=0
for tuple in rows:
self.ui.labelResponse.setText("")
colNo=0
for columns in tuple:
oneColumn=QTableWidgetItem(columns)
self.ui.tableWidget.setItem(rowNo, colNo, oneColumn)
colNo+=1
rowNo+=1
except Error as e:
self.ui.tableWidget.clear()
self.ui.labelResponse.setText("Error in accessing
table")
finally:
conn.close()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理…
您可以在脚本中看到,按钮的 click()事件与 objectName 属性pushButtonDisplayRows
连接到DisplayRows()
方法。这意味着每当单击此按钮时,将调用DisplayRows()
方法。在DisplayRows()
方法中,定义了一个SQL SELECT
语句,该语句从在行编辑小部件中指定的表中获取行。还与在行编辑小部件中输入的数据库名称建立了连接。然后执行SQL SELECT
语句。在光标上执行fetchall()
方法,以保留从数据库表中访问的所有行。
执行for
循环以一次访问接收到的行中的一个元组,并再次在元组上执行for
循环以获取该行中每一列的数据。在表小部件中显示分配给行每一列的数据。在显示第一行的数据后,从行中选择第二行,并重复该过程以在表小部件中显示第二行的数据。两个嵌套的for
循环一直执行,直到通过表小部件显示所有行。
运行应用程序时,您将被提示指定数据库名称和表名。输入所需信息后,单击“显示行”按钮,指定数据库表的内容将通过表部件显示,如下截图所示:
浏览指定数据库表的行
在本教程中,我们将学习逐个从给定数据库表中获取行。也就是说,运行应用程序时,将显示数据库表的第一行。应用程序中提供了四个按钮,称为 Next、Previous、First 和 Last。顾名思义,单击 Next 按钮将显示序列中的下一行。类似地,单击 Previous 按钮将显示序列中的上一行。单击 Last 按钮将显示数据库表的最后一行,单击 First 按钮将显示数据库表的第一行。
如何做…
以下是了解如何逐个访问和显示数据库表中的行的步骤:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
通过拖放三个标签部件、两个行编辑部件和四个按钮部件将它们添加到表单上。
-
将两个标签部件的文本属性设置为
Email Address
和Password
。 -
删除第三个标签部件的文本属性,因为它的文本属性将通过代码设置。
-
将四个按钮的文本属性设置为
First Row
、Previous
、Next
和Last Row
。 -
将两个行编辑部件的 objectName 属性设置为
lineEditEmailAddress
和lineEditPassword
。 -
将四个按钮的 objectName 属性设置为
pushButtonFirst
、pushButtonPrevious
、pushButtonNext
和pushButtonLast
。 -
将第三个标签部件的 objectName 属性设置为
labelResponse
。因为我们不希望密码被显示,我们希望用户输入密码时出现星号。 -
选择用于输入密码的行编辑部件(lineEditPassword),从属性编辑器窗口中选择 echoMode 属性,并将其设置为 Password,而不是默认的 Normal。
-
将应用程序保存为
demoShowRecords
。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,应用pyuic5
命令后,XML 文件可以转换为 Python 代码。书籍的源代码包中可以看到生成的 Python 脚本demoShowRecords.py
。
-
将
demoShowRecords.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callShowRecords.pyw
的 Python 文件,并将demoShowRecords.py
代码导入其中。
import sqlite3, sys
from PyQt5.QtWidgets import QDialog, QApplication,QTableWidgetItem
from sqlite3 import Error
from demoShowRecords import *
rowNo=1
sqlStatement="SELECT EmailAddress, Password FROM Users"
conn = sqlite3.connect("ECommerce.db")
cur = conn.cursor()
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
cur.execute(sqlStatement)
self.ui.pushButtonFirst.clicked.connect(self.
ShowFirstRow)
self.ui.pushButtonPrevious.clicked.connect(self.
ShowPreviousRow)
self.ui.pushButtonNext.clicked.connect(self.ShowNextRow)
self.ui.pushButtonLast.clicked.connect(self.ShowLastRow)
self.show()
def ShowFirstRow(self):
try:
cur.execute(sqlStatement)
row=cur.fetchone()
if row:
self.ui.lineEditEmailAddress.setText(row[0])
self.ui.lineEditPassword.setText(row[1])
except Error as e:
self.ui.labelResponse.setText("Error in accessing
table")
def ShowPreviousRow(self):
global rowNo
rowNo -= 1
sqlStatement="SELECT EmailAddress, Password FROM Users
where rowid="+str(rowNo)
cur.execute(sqlStatement)
row=cur.fetchone()
if row:
self.ui.labelResponse.setText("")
self.ui.lineEditEmailAddress.setText(row[0])
self.ui.lineEditPassword.setText(row[1])
else:
rowNo += 1
self.ui.labelResponse.setText("This is the first
row")
def ShowNextRow(self):
global rowNo
rowNo += 1
sqlStatement="SELECT EmailAddress, Password FROM
Users where rowid="+str(rowNo)
cur.execute(sqlStatement)
row=cur.fetchone()
if row:
self.ui.labelResponse.setText("")
self.ui.lineEditEmailAddress.setText(row[0])
self.ui.lineEditPassword.setText(row[1])
else:
rowNo -= 1
self.ui.labelResponse.setText("This is the last
row")
def ShowLastRow(self):
cur.execute(sqlStatement)
for row in cur.fetchall():
self.ui.lineEditEmailAddress.setText(row[0])
self.ui.lineEditPassword.setText(row[1])
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的…
您可以在脚本中看到,具有 objectName 属性pushButtonFirst
的按钮的 click()事件连接到ShowFirstRow()
方法,具有 objectName 属性pushButtonPrevious
的按钮连接到ShowPreviousRow()
方法,具有 objectName 属性pushButtonNext
的按钮连接到ShowNextRow()
方法,具有 objectName 属性pushButtonLast
的按钮连接到ShowLastRow()
方法。
每当单击按钮时,将调用相关方法。
在ShowFirstRow()
方法中,执行了一个SQL SELECT
语句,获取了Users
表的电子邮件地址和密码列。在光标上执行了fetchone()
方法,以访问执行SQL SELECT
语句后接收到的第一行。EmailAddress
和Password
列中的数据通过屏幕上的两个 Line Edit 小部件显示出来。如果在访问行时发生错误,错误消息Error in accessing table
将通过标签小部件显示出来。
为了获取上一行,我们使用了一个全局变量rowNo
,它被初始化为1
。在ShowPreviousRow()
方法中,全局变量rowNo
的值减少了1
。然后,执行了一个SQL SELECT
语句,获取了Users
表的EmailAddress
和Password
列,其中rowid=rowNo
。因为rowNo
变量减少了1
,所以SQL SELECT
语句将获取序列中的上一行。在光标上执行了fetchone()
方法,以访问接收到的行,EmailAddress
和Password
列中的数据通过屏幕上的两个 Line Edit 小部件显示出来。
如果已经显示了第一行,则点击“上一个”按钮,它将通过标签小部件简单地显示消息“This is the first row”。
在访问序列中的下一行时,我们使用全局变量rowNo
。在ShowNextRow()
方法中,全局变量rowNo
的值增加了1
。然后,执行了一个SQL SELECT
语句,获取了Users
表的EmailAddress
和Password
列,其中rowid=rowNo
;因此,访问了下一行,即rowid
比当前行高1
的行。在光标上执行了fetchone()
方法,以访问接收到的行,EmailAddress
和Password
列中的数据通过屏幕上的两个 Line Edit 小部件显示出来。
如果您正在查看数据库表中的最后一行,然后点击“下一个”按钮,它将通过标签小部件简单地显示消息“This is the last row”。
在ShowLastRow()
方法中,执行了一个SQL SELECT
语句,获取了Users
表的EmailAddress
和Password
列。在光标上执行了fetchall()
方法,以访问数据库表中其余的行。使用for
循环,将row
变量从执行SQL SELECT
语句后接收到的行中移动到最后一行。最后一行的EmailAddress
和Password
列中的数据通过屏幕上的两个 Line Edit 小部件显示出来。
运行应用程序后,您将在屏幕上看到数据库表的第一行,如下截图所示。如果现在点击“上一个”按钮,您将收到消息“This is the first row”。
点击“下一个”按钮后,序列中的下一行将显示在屏幕上,如下截图所示:
点击“最后一行”按钮后,数据库表中的最后一行将显示出来,如下截图所示:
搜索数据库表中的特定信息
在这个示例中,我们将学习如何在数据库表中执行搜索,以获取所需的信息。我们假设用户忘记了他们的密码。因此,您将被提示输入数据库名称、表名称和需要密码的用户的电子邮件地址。如果数据库表中存在使用提供的电子邮件地址的用户,则将搜索、访问并在屏幕上显示该用户的密码。
如何做…
按照以下步骤了解如何在 SQLite 数据库表中搜索数据:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
通过拖放五个 Label 小部件、四个 LineEdit 小部件和一个 PushButton 小部件到表单上,向表单添加五个
QLabel
小部件、四个QLineEdit
小部件和一个QPushButton
小部件。 -
将前三个 Label 小部件的文本属性设置为
输入数据库名称
、输入表名称
和电子邮件地址
。 -
删除第四个 Label 小部件的文本属性,这是通过代码建立的。
-
将第五个 Label 小部件的文本属性设置为
Password
。 -
将 PushButton 的文本属性设置为
搜索
。 -
将四个 LineEdit 小部件的 objectName 属性设置为
lineEditDBName
、lineEditTableName
、lineEditEmailAddress
和lineEditPassword
。 -
将 PushButton 小部件的 objectName 属性设置为
pushButtonSearch
。 -
将第四个 Label 小部件的 objectName 属性设置为
labelResponse
。 -
将应用程序保存为
demoSearchRows.ui
。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在一个.ui
文件中,这是一个需要通过pyuic5
命令应用转换为 Python 代码的 XML 文件。书籍的源代码包中可以看到生成的 Python 脚本demoSearchRows.py
。
-
将
demoSearchRows.py
脚本视为头文件,并将其导入到您将调用其用户界面设计的文件中。 -
创建另一个名为
callSearchRows.pyw
的 Python 文件,并将demoSearchRows.py
代码导入其中:
import sqlite3, sys
from PyQt5.QtWidgets import QDialog, QApplication
from sqlite3 import Error
from demoSearchRows import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonSearch.clicked.connect(self.
SearchRows)
self.show()
def SearchRows(self):
sqlStatement="SELECT Password FROM
"+self.ui.lineEditTableName.text()+" where EmailAddress
like'"+self.ui.lineEditEmailAddress.text()+"'"
try:
conn = sqlite3.connect(self.ui.lineEditDBName.text()+
".db")
cur = conn.cursor()
cur.execute(sqlStatement)
row = cur.fetchone()
if row==None:
self.ui.labelResponse.setText("Sorry, No User found with
this email address")
self.ui.lineEditPassword.setText("")
else:
self.ui.labelResponse.setText("Email Address Found,
Password of this User is :")
self.ui.lineEditPassword.setText(row[0])
except Error as e:
self.ui.labelResponse.setText("Error in accessing row")
finally:
conn.close()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
您可以在脚本中看到,具有 objectName 属性pushButtonSearch
的 PushButton 的 click()事件连接到SearchRows()
方法。这意味着每当单击 PushButton 时,都会调用SearchRows()
方法。在SearchRows()
方法中,对sqlite3
类调用connect()
方法,并将用户在 LineEdit 小部件中输入的数据库名称传递给connect()
方法。建立与数据库的连接。定义一个 SQL search
语句,从所提供的表中获取Password
列,该表中的电子邮件地址与提供的电子邮件地址匹配。在给定的数据库表上执行search
SQL 语句。在光标上执行fetchone()
方法,从执行的 SQL 语句中获取一行。如果获取的行不是None
,即数据库表中有一行与给定的电子邮件地址匹配,则访问该行中的密码,并将其分配给 object 名称为lineEditPassword
的 LineEdit 小部件以进行显示。最后,关闭与数据库的连接。
如果在执行 SQL 语句时发生错误,即找不到数据库、表名输入错误,或者给定表中不存在电子邮件地址列,则会通过具有 objectName 属性labelResponse
的 Label 小部件显示错误消息“访问行时出错”。
运行应用程序后,我们会得到一个对话框,提示我们输入数据库名称、表名和表中的列名。假设我们想要找出在ECommerce
数据库的Users
表中,邮箱地址为bmharwani@yahoo.com
的用户的密码。在框中输入所需信息后,当点击搜索按钮时,用户的密码将从表中获取,并通过行编辑小部件显示,如下截图所示:
如果在 Users 表中找不到提供的电子邮件地址,您将收到消息“抱歉,找不到使用此电子邮件地址的用户”,该消息将通过 Label 小部件显示,如下所示:
创建一个登录表单 - 应用认证程序
在本教程中,我们将学习如何访问特定表中的行,并将其与提供的信息进行比较。
我们假设数据库ECommerce
已经存在,并且ECommerce
数据库中也存在名为Users
的表。Users
表包括两列,EmailAddress
和Password
。此外,我们假设Users
表中包含一些行。用户将被提示在登录表单中输入其电子邮件地址和密码。将在Users
表中搜索指定的电子邮件地址。如果在Users
表中找到电子邮件地址,则将比较该行中的密码与输入的密码。如果两个密码匹配,则显示欢迎消息;否则,显示指示电子邮件地址或密码不匹配的错误消息。
如何做…
以下是了解如何将数据库表中的数据与用户输入的数据进行比较并对用户进行身份验证的步骤:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
在表单中通过拖放三个 Label 小部件、两个 Line Edit 小部件和一个 Push Button 小部件,添加三个
QLabel
小部件、两个QLineEdit
小部件和一个QPushButton
小部件。 -
将前两个 Label 小部件的文本属性设置为
电子邮件地址
和密码
。 -
通过代码删除第三个 Label 小部件的文本属性。
-
将按钮的文本属性设置为
登录
。 -
将两个 Line Edit 小部件的 objectName 属性设置为
lineEditEmailAddress
和lineEditPassword
。 -
将 Push Button 小部件的 objectName 属性设置为
pushButtonSearch
。 -
将第三个 Label 小部件的 objectName 属性设置为
labelResponse
。 -
将应用程序保存为
demoSignInForm.ui
。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。通过应用pyuic5
命令,可以将 XML 文件转换为 Python 代码。生成的 Python 脚本demoSignInForm.py
可以在本书的源代码包中找到。
-
将
demoSignInForm.py
文件视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callSignInForm.pyw
的 Python 文件,并将demoSignInForm.py
代码导入其中:
import sqlite3, sys
from PyQt5.QtWidgets import QDialog, QApplication
from sqlite3 import Error
from demoSignInForm import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonSearch.clicked.connect(self.
SearchRows)
self.show()
def SearchRows(self):
sqlStatement="SELECT EmailAddress, Password FROM Users
where EmailAddress like'"+self.ui.lineEditEmailAddress.
text()+"'and Password like '"+ self.ui.lineEditPassword.
text()+"'"
try:
conn = sqlite3.connect("ECommerce.db")
cur = conn.cursor()
cur.execute(sqlStatement)
row = cur.fetchone()
if row==None:
self.ui.labelResponse.setText("Sorry, Incorrect
email address or password ")
else:
self.ui.labelResponse.setText("You are welcome ")
except Error as e:
self.ui.labelResponse.setText("Error in accessing
row")
finally:
conn.close()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理…
您可以在脚本中看到,具有 objectName 属性pushButtonSearch
的按钮的单击事件与SearchRows()
方法相连。这意味着每当单击按钮时,都会调用SearchRows()
方法。在SearchRows()
方法中,调用sqlite3
类的connect()
方法与ECommerce
数据库建立连接。定义了一个 SQL search
语句,该语句从Users
表中获取EmailAddress
和Password
列,这些列的电子邮件地址与提供的电子邮件地址匹配。在Users
表上执行search
SQL 语句。在光标上执行fetchone()
方法,以从执行的 SQL 语句中获取一行。如果获取的行不是None
,即数据库表中存在与给定电子邮件地址和密码匹配的行,则会通过具有 objectName 属性labelResponse
的 Label 小部件显示欢迎消息。最后,关闭与数据库的连接。
如果在执行 SQL 语句时发生错误,如果找不到数据库,或者表名输入错误,或者Users
表中不存在电子邮件地址或密码列,则通过具有 objectName 属性labelResponse
的 Label 小部件显示错误消息“访问行时出错”。
运行应用程序时,您将被提示输入电子邮件地址和密码。输入正确的电子邮件地址和密码后,当您单击“登录”按钮时,您将收到消息“欢迎”,如下截图所示:
但是,如果电子邮件地址或密码输入不正确,您将收到消息“抱歉,电子邮件地址或密码不正确”,如下截图所示:
更新数据库表-更改用户密码
在这个示例中,您将学习如何更新数据库中的任何信息。在几乎所有应用程序中,更改密码都是一个非常常见的需求。在这个示例中,我们假设一个名为ECommerce
的数据库已经存在,并且ECommerce
数据库中也存在一个名为Users
的表。Users
表包含两列,EmailAddress
和Password
。此外,我们假设Users
表中已经包含了一些行。用户将被提示在表单中输入他们的电子邮件地址和密码。将搜索Users
表以查找指定的电子邮件地址和密码。如果找到具有指定电子邮件地址和密码的行,则将提示用户输入新密码。新密码将被要求输入两次,也就是说,用户将被要求在新密码框和重新输入新密码框中输入他们的新密码。如果两个框中输入的密码匹配,密码将被更改,也就是说,旧密码将被新密码替换。
如何做...
从数据库表中删除数据的过程非常关键,执行此类应用程序的任何错误都可能导致灾难。以下是从给定数据库表中删除任何行的步骤:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
通过拖放五个标签小部件、四个行编辑小部件和一个按钮小部件将它们添加到表单上。
-
将前四个标签小部件的文本属性设置为
电子邮件地址
、旧密码
、新密码
和重新输入新密码
。 -
删除第五个标签小部件的文本属性,这是通过代码建立的。将按钮的文本属性设置为
更改密码
。 -
将四个行编辑小部件的 objectName 属性设置为
lineEditEmailAddress
、lineEditOldPassword
、lineEditNewPassword
和lineEditRePassword
。由于我们不希望密码显示在与密码相关联的任何行编辑小部件中,我们希望用户输入密码时显示星号。 -
依次从属性编辑器窗口中选择三个行编辑小部件。
-
选择 echoMode 属性,并将其设置为
Password
,而不是默认的 Normal。 -
将按钮小部件的 objectName 属性设置为
pushButtonChangePassword
。 -
将第五个标签小部件的 objectName 属性设置为
labelResponse
。 -
将应用程序保存为
demoChangePassword.ui
。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。pyuic5
命令用于将 XML 文件转换为 Python 代码。本书的源代码包中可以看到生成的 Python 脚本demoChangePassword.py
。
-
将
demoChangePassword.py
脚本视为头文件,并将其导入到您将调用其用户界面设计的文件中。 -
创建另一个名为
callChangePassword.pyw
的 Python 文件,并将demoChangePassword.py
代码导入其中:
import sqlite3, sys
from PyQt5.QtWidgets import QDialog, QApplication
from sqlite3 import Error
from demoChangePassword import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonChangePassword.clicked.connect(self.
ChangePassword)
self.show()
def ChangePassword(self):
selectStatement="SELECT EmailAddress, Password FROM
Users where EmailAddress like '"+self.ui.
lineEditEmailAddress.text()+"'and Password like '"+
self.ui.lineEditOldPassword.text()+"'"
try:
conn = sqlite3.connect("ECommerce.db")
cur = conn.cursor()
cur.execute(selectStatement)
row = cur.fetchone()
if row==None:
self.ui.labelResponse.setText("Sorry, Incorrect
email address or password")
else:
if self.ui.lineEditNewPassword.text()==
self.ui.lineEditRePassword.text():
updateStatement="UPDATE Users set Password = '" +
self.ui.lineEditNewPassword.text()+"' WHERE
EmailAddress like'"+self.ui.lineEditEmailAddress.
text()+"'"
with conn:
cur.execute(updateStatement)
self.ui.labelResponse.setText("Password successfully
changed")
else:
self.ui.labelResponse.setText("The two passwords
don't match")
except Error as e:
self.ui.labelResponse.setText("Error in accessing
row")
finally:
conn.close()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
您可以在脚本中看到,具有 objectName 属性pushButtonChangePassword
的按钮的 click()事件与ChangePassword()
方法相连。这意味着每当单击按钮时,都会调用ChangePassword()
方法。在ChangePassword()
方法中,调用sqlite3
类的connect()
方法与ECommerce
数据库建立连接。定义了一个 SQL SELECT
语句,该语句从Users
表中获取与在 LineEdit 小部件中输入的电子邮件地址和密码匹配的EmailAddress
和Password
列。在Users
表上执行 SQL SELECT
语句。在光标上执行fetchone()
方法,以从执行的 SQL 语句中获取一行。如果获取的行不是None
,即数据库表中有一行,则确认两个 LineEdit 小部件lineEditNewPassword
和lineEditRePassword
中输入的新密码是否完全相同。如果两个密码相同,则执行UPDATE
SQL 语句来更新Users
表,将密码更改为新密码。
如果两个密码不匹配,则不会对数据库表进行更新,并且通过 Label 小部件显示消息“两个密码不匹配”。
如果在执行 SQL SELECT
或UPDATE
语句时发生错误,则会通过具有 objectName 属性labelResponse
的 Label 小部件显示错误消息“访问行时出错”。
运行应用程序时,您将被提示输入电子邮件地址和密码,以及新密码。如果电子邮件地址或密码不匹配,则会通过 Label 小部件显示错误消息“抱歉,电子邮件地址或密码不正确”,如下面的屏幕截图所示:
如果输入的电子邮件地址和密码正确,但在新密码和重新输入新密码框中输入的新密码不匹配,则屏幕上会显示消息“两个密码不匹配”,如下面的屏幕截图所示:
如果电子邮件地址和密码都输入正确,也就是说,如果在数据库表中找到用户行,并且在新密码和重新输入新密码框中输入的新密码匹配,则更新Users
表,并且在成功更新表后,屏幕上会显示消息“密码已成功更改”,如下面的屏幕截图所示:
从数据库表中删除一行
在本教程中,我们将学习如何从数据库表中删除一行。我们假设名为ECommerce
的数据库已经存在,并且ECommerce
数据库中也存在名为Users
的表。Users
表包含两列,EmailAddress
和Password
。此外,我们假设User
表中包含一些行。用户将被提示在表单中输入他们的电子邮件地址和密码。将在Users
表中搜索指定的电子邮件地址和密码。如果在Users
表中找到具有指定电子邮件地址和密码的任何行,则将提示您确认是否确定要删除该行。如果单击“是”按钮,则将删除该行。
如何做…
从数据库表中删除数据的过程非常关键,执行此类应用程序时的任何错误都可能导致灾难。以下是从给定数据库表中删除任何行的步骤:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
通过将四个 Label 小部件、两个 LineEdit 小部件和三个 PushButton 小部件拖放到表单上,向表单添加四个
QLabel
小部件、两个QLineEdit
小部件和三个QPushButton
小部件。 -
将前三个 Label 小部件的文本属性设置为
电子邮件地址
,密码
和你确定吗?
-
删除第四个 Label 小部件的文本属性,这是通过代码建立的。
-
将三个按钮的文本属性设置为
删除用户
,是
和否
。 -
将两个 Line Edit 小部件的 objectName 属性设置为
lineEditEmailAddress
和lineEditPassword
。 -
将三个 Push Button 小部件的 objectName 属性设置为
pushButtonDelete
,pushButtonYes
和pushButtonNo
。 -
将第四个 Label 小部件的 objectName 属性设置为
labelResponse
。 -
将应用程序保存为
demoDeleteUser.ui
。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在一个.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。使用pyuic5
命令将 XML 文件转换为 Python 代码。生成的 Python 脚本demoDeleteUser.py
可以在本书的源代码包中找到。
-
将
demoDeleteUser.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callDeleteUser.pyw
的 Python 文件,并将demoDeleteUser.py
代码导入其中:
import sqlite3, sys
from PyQt5.QtWidgets import QDialog, QApplication
from sqlite3 import Error
from demoDeleteUser import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonDelete.clicked.connect(self.
DeleteUser)
self.ui.pushButtonYes.clicked.connect(self.
ConfirmDelete)
self.ui.labelSure.hide()
self.ui.pushButtonYes.hide()
self.ui.pushButtonNo.hide()
self.show()
def DeleteUser(self):
selectStatement="SELECT * FROM Users where EmailAddress
like'"+self.ui.lineEditEmailAddress.text()+"'
and Password like '"+ self.ui.lineEditPassword.
text()+"'"
try:
conn = sqlite3.connect("ECommerce.db")
cur = conn.cursor()
cur.execute(selectStatement)
row = cur.fetchone()
if row==None:
self.ui.labelSure.hide()
self.ui.pushButtonYes.hide()
self.ui.pushButtonNo.hide()
self.ui.labelResponse.setText("Sorry, Incorrect
email address or password ")
else:
self.ui.labelSure.show()
self.ui.pushButtonYes.show()
self.ui.pushButtonNo.show()
self.ui.labelResponse.setText("")
except Error as e:
self.ui.labelResponse.setText("Error in accessing
user account")
finally:
conn.close()
def ConfirmDelete(self):
deleteStatement="DELETE FROM Users where EmailAddress
like '"+self.ui.lineEditEmailAddress.text()+"'
and Password like '"+ self.ui.lineEditPassword.
text()+"'"
try:
conn = sqlite3.connect("ECommerce.db")
cur = conn.cursor()
with conn:
cur.execute(deleteStatement)
self.ui.labelResponse.setText("User successfully
deleted")
except Error as e:
self.ui.labelResponse.setText("Error in deleting
user account")
finally:
conn.close()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理…
在这个应用程序中,带有文本“你确定吗?”的 Label 小部件和两个按钮 Yes 和 No 最初是隐藏的。只有当用户输入的电子邮件地址和密码在数据库表中找到时,这三个小部件才会显示出来。这三个小部件使用户能够确认他们是否真的想要删除该行。因此,对这三个小部件调用hide()
方法,使它们最初不可见。此外,将具有 objectName 属性pushButtonDelete
的按钮的 click()事件连接到DeleteUser()
方法。这意味着每当单击删除按钮时,都会调用DeleteUser()
方法。类似地,具有 objectName 属性pushButtonYes
的按钮的 click()事件连接到ConfirmDelete()
方法。这意味着当用户通过单击 Yes 按钮确认删除该行时,将调用ConfirmDelete()
方法。
在DeleteUser()
方法中,首先搜索是否存在与输入的电子邮件地址和密码匹配的Users
表中的任何行。在sqlite3
类上调用connect()
方法,与ECommerce
数据库建立连接。定义了一个 SQL SELECT
语句,从Users
表中获取EmailAddress
和Password
列,其电子邮件地址和密码与提供的电子邮件地址和密码匹配。在Users
表上执行 SQL SELECT
语句。在游标上执行fetchone()
方法,从执行的 SQL 语句中获取一行。如果获取的行不是None
,即数据库表中存在与给定电子邮件地址和密码匹配的行,则会使三个小部件,Label 和两个按钮可见。用户将看到消息“你确定吗?”,然后是两个带有文本 Yes 和 No 的按钮。
如果用户单击 Yes 按钮,则会执行ConfirmDelete()
方法。在ConfirmDelete()
方法中,定义了一个 SQL DELETE
方法,用于从Users
表中删除与输入的电子邮件地址和密码匹配的行。在与ECommerce
数据库建立连接后,执行 SQL DELETE
方法。如果成功从Users
表中删除了行,则通过 Label 小部件显示消息“用户成功删除”;否则,将显示错误消息“删除用户帐户时出错”。
在运行应用程序之前,我们将启动一个名为 SQLite 数据库浏览器的可视化工具。该可视化工具使我们能够创建,编辑和查看数据库表及其中的行。使用 SQLite 数据库浏览器,我们将首先查看“用户”表中的现有行。之后,应用程序将运行并删除一行。再次从 SQLite 数据库浏览器中,我们将确认该行是否真的已从“用户”表中删除。
因此,启动 SQLite 数据库浏览器并在主菜单下方点击“打开数据库”选项卡。浏览并从当前文件夹中选择“电子商务”数据库。 “电子商务”数据库显示由两列“电子邮件地址”和“密码”组成的“用户”表。单击“执行 SQL”按钮以编写 SQL 语句。在窗口中,写入 SQL 语句select * from Users
,然后单击运行图标。 “用户”表中的所有现有行将显示在屏幕上。您可以在以下屏幕截图中看到“用户”表有两行:
运行应用程序后,您将被提示输入您的电子邮件地址和密码。如果您输入错误的电子邮件地址和密码,您将收到消息“抱歉,电子邮件地址或密码不正确”,如下面的屏幕截图所示:
在输入正确的电子邮件地址和密码后,当您点击删除用户按钮时,三个小部件——标签小部件和两个按钮,将变为可见,并且您会收到消息“您确定吗?”,以及两个按钮“Yes”和“No”,如下面的屏幕截图所示:
点击“Yes”按钮后,“用户”表中与提供的电子邮件地址和密码匹配的行将被删除,并且通过标签小部件显示确认消息“用户成功删除”,如下面的屏幕截图所示:
让我们通过可视化工具检查行是否实际上已从用户表中删除。因此,启动 SQLite 数据库浏览器并在主菜单下方点击“打开数据库”选项卡。浏览并从当前文件夹中选择“电子商务”数据库。 “电子商务”数据库将显示“用户”表。单击“执行 SQL”按钮以编写 SQL 语句。在窗口中,写入 SQL 语句select * from Users
,然后单击运行图标。 “用户”表中的所有现有行将显示在屏幕上。
在运行应用程序之前,我们看到“用户”表中有两行。这次,您只能在“用户”表中看到一行(请参阅下面的屏幕截图),证实已从“用户”表中删除了一行:
第二十章:使用图形
在每个应用程序中,图形在使其更加用户友好方面起着重要作用。图形使概念更容易理解。在本章中,我们将涵盖以下主题:
-
显示鼠标坐标
-
显示鼠标点击和释放的坐标
-
显示鼠标按钮点击的点
-
在两次鼠标点击之间绘制一条线
-
绘制不同类型的线
-
绘制所需大小的圆
-
在两次鼠标点击之间绘制一个矩形
-
以所需的字体和大小绘制文本
-
创建显示不同图形工具的工具栏
-
使用 Matplotlib 绘制一条线
-
使用 Matplotlib 绘制条形图
介绍
为了在 Python 中进行绘制和绘画,我们将使用几个类。其中最重要的是QPainter
类。
这个类用于绘图。它可以绘制线条、矩形、圆形和复杂的形状。在使用QPainter
绘图时,可以使用QPainter
类的笔来定义绘图的颜色、笔/刷的粗细、样式,以及线条是实线、虚线还是点划线等。
本章中使用了QPainter
类的几种方法来绘制不同的形状。以下是其中的一些:
-
QPainter::drawLine()
: 该方法用于在两组x和y坐标之间绘制一条线 -
QPainter::drawPoints()
: 该方法用于在通过提供的x和y坐标指定的位置绘制一个点 -
QPainter::drawRect()
: 该方法用于在两组x和y坐标之间绘制一个矩形 -
QPainter::drawArc()
: 该方法用于从指定的中心位置绘制弧,介于两个指定的角度之间,并具有指定的半径 -
QPainter::drawText()
: 该方法用于以指定的字体样式、颜色和大小绘制文本
为了实际显示图形所需的不同类和方法,让我们遵循一些操作步骤。
显示鼠标坐标
要用鼠标绘制任何形状,您需要知道鼠标按钮的点击位置,鼠标拖动到何处以及鼠标按钮释放的位置。只有在知道鼠标按钮点击的坐标后,才能执行命令来绘制不同的形状。在这个教程中,我们将学习在表单上显示鼠标移动到的x和y坐标。
操作步骤...
在这个教程中,我们将跟踪鼠标移动,并在表单上显示鼠标移动的x和y坐标。因此,在这个应用程序中,我们将使用两个 Label 小部件,一个用于显示消息,另一个用于显示鼠标坐标。创建此应用程序的完整步骤如下:
-
让我们创建一个基于没有按钮的对话框模板的应用程序。
-
通过将两个 Label 小部件拖放到表单上,向表单添加两个
QLabel
小部件。 -
将第一个 Label 小部件的文本属性设置为
This app will display x,y coordinates where mouse is moved on
。 -
删除第二个 Label 小部件的文本属性,因为它的文本属性将通过代码设置。
-
将应用程序保存为
demoMousetrack.ui
。
表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。使用pyuic5
实用程序将 XML 文件转换为 Python 代码。书籍的源代码包中可以看到生成的 Python 脚本demoMousetrack.py
。
-
将
demoMousetrack.py
脚本视为头文件,并将其从中调用用户界面设计的文件中导入。 -
创建另一个名为
callMouseTrack.pyw
的 Python 文件,并将demoMousetrack.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoMousetrack import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.setMouseTracking(True)
self.ui.setupUi(self)
self.show()
def mouseMoveEvent(self, event):
x = event.x()
y = event.y()
text = "x: {0}, y: {1}".format(x, y)
self.ui.label.setText(text)
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
为了使应用程序跟踪鼠标,使用了一个方法setMouseTracking(True)
。这个方法将感应鼠标移动,每当鼠标移动时,它将调用mouseMoveEvent()
方法。在mouseMoveEvent()
中,对event
对象调用x
和y
方法以获取鼠标位置的x和y坐标值。x和y坐标分别赋给x
和y
变量。通过标签小部件以所需的格式显示x和y坐标的值。
运行应用程序时,将会收到一条消息,提示鼠标移动时将显示其x和y坐标值。当您在表单上移动鼠标时,鼠标位置的x和y坐标将通过第二个标签小部件显示,如下截图所示:
显示鼠标按下和释放的坐标
在这个示例中,我们将学习显示鼠标按下的x和y坐标,以及鼠标释放的坐标。
如何做...
两种方法,mousePressEvent()
和mouseReleaseEvent()
,在这个示例中将起到重要作用。当鼠标按下时,mousePressEvent()
方法将自动被调用,并在鼠标按下事件发生时显示x和y坐标。同样,mouseReleaseEvent()
方法将在鼠标按钮释放时自动被调用。两个标签小部件将用于显示鼠标按下和释放的坐标。以下是创建这样一个应用程序的步骤:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
通过将三个标签小部件拖放到表单上,向表单添加三个
QLabel
小部件。 -
将第一个标签小部件的文本属性设置为
显示鼠标按下和释放的*x*和*y*坐标
。 -
删除第二个和第三个标签小部件的文本属性,因为它们的文本属性将通过代码设置。
-
将第二个标签小部件的 objectName 属性设置为
labelPress
,因为它将用于显示鼠标按下的位置的x和y坐标。 -
将第三个标签小部件的 objectName 属性设置为
labelRelease
,因为它将用于显示鼠标释放的位置的x和y坐标。 -
将应用程序保存为
demoMouseClicks.ui
。
表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在一个.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。使用pyuic5
实用程序将 XML 文件转换为 Python 代码。生成的 Python 脚本demoMouseClicks.py
可以在本书的源代码包中看到。
-
将
demoMouseClicks.py
脚本视为头文件,并将其导入到您将调用其用户界面设计的文件中。 -
创建另一个名为
callMouseClickCoordinates.pyw
的 Python 文件,并将demoMouseClicks.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from demoMouseClicks import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.show()
def mousePressEvent(self, event):
if event.buttons() & QtCore.Qt.LeftButton:
x = event.x()
y = event.y()
text = "x: {0}, y: {1}".format(x, y)
self.ui.labelPress.setText('Mouse button pressed at
'+text)
def mouseReleaseEvent(self, event):
x = event.x()
y = event.y()
text = "x: {0}, y: {1}".format(x, y)
self.ui.labelRelease.setText('Mouse button released at
'+text)
self.update()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
当单击鼠标时,会自动调用两个方法。当按下鼠标按钮时,会调用mousePressEvent()
方法,当释放鼠标按钮时,会调用mouseReleaseEvent()
方法。为了显示鼠标点击和释放的位置的x和y坐标,我们使用这两种方法。在这两种方法中,我们只需在event
对象上调用x()
和y()
方法来获取鼠标位置的x和y坐标值。获取的x和y值将分别赋给x
和y
变量。x
和y
变量中的值将以所需的格式进行格式化,并通过两个 Label 部件显示出来。
运行应用程序时,将会收到一个消息,显示鼠标按下和释放的位置的x和y坐标。
当你按下鼠标按钮并释放它时,鼠标按下和释放的位置的x和y坐标将通过两个 Label 部件显示出来,如下截图所示:
显示鼠标点击的点
在这个教程中,我们将学习在窗体上显示鼠标点击的点。这里的点指的是一个小圆点。也就是说,无论用户在哪里按下鼠标,都会在那个坐标处出现一个小圆点。你还将学会定义小圆点的大小。
如何做...
在这个教程中,将使用mousePressEvent()
方法,因为它是在窗体上按下鼠标时自动调用的方法。在mousePressEvent()
方法中,我们将执行命令来显示所需大小的点或圆点。以下是了解如何在单击鼠标的地方在窗体上显示一个点或圆点的步骤:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
通过拖放 Label 部件将
QLabel
部件添加到窗体中。 -
将 Label 部件的文本属性设置为“单击鼠标以显示一个点的位置”。
-
将应用程序保存为
demoDrawDot.ui
。
窗体现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在一个.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。使用pyuic5
工具将 XML 文件转换为 Python 代码。生成的 Python 脚本demoDrawDot.py
可以在本书的源代码包中找到。
-
将
demoDrawDot.py
脚本视为头文件,并将其从用户界面设计中调用的文件中导入。 -
创建另一个名为
callDrawDot.pyw
的 Python 文件,并将demoDrawDot.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from PyQt5.QtGui import QPainter, QPen
from PyQt5.QtCore import Qt
from demoDrawDot import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.pos1 = [0,0]
self.show()
def paintEvent(self, event):
qp = QPainter()
qp.begin(self)
pen = QPen(Qt.black, 5)
qp.setPen(pen)
qp.drawPoint(self.pos1[0], self.pos1[1])
qp.end()
def mousePressEvent(self, event):
if event.buttons() & QtCore.Qt.LeftButton:
self.pos1[0], self.pos1[1] = event.pos().x(),
event.pos().y()
self.update()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
因为我们想要显示鼠标点击的点,所以使用了mousePressEvent()
方法。在mousePressEvent()
方法中,对event
对象调用pos().x()
和pos().y()
方法来获取x和y坐标的位置,并将它们分配给pos1
数组的0
和1
元素。也就是说,pos1
数组被初始化为鼠标点击的x和y坐标值。在初始化pos1
数组之后,调用self.update()
方法来调用paintEvent()
方法。
在paintEvent()
方法中,通过名称为qp
的QPainter
类对象定义了一个对象。通过名称为 pen 的QPen
类对象设置了笔的粗细和颜色。最后,通过在pos1
数组中定义的位置调用drawPoint()
方法显示一个点。
运行应用程序时,将会收到一条消息,指出鼠标按钮点击的地方将显示一个点。当您点击鼠标时,一个点将出现在那个位置,如下截图所示:
在两次鼠标点击之间画一条线
在这个示例中,我们将学习如何在两个点之间显示一条线,从鼠标按钮点击的地方到鼠标按钮释放的地方。这个示例的重点是理解如何处理鼠标按下和释放事件,如何访问鼠标按钮点击和释放的x和y坐标,以及如何在鼠标按钮点击的位置和鼠标按钮释放的位置之间绘制一条线。
如何操作...
这个示例中的主要方法是mousePressEvent()
、mouseReleaseEvent()
和paintEvent()
。mousePressEvent()
和mouseReleaseEvent()
方法在鼠标按钮被点击或释放时自动执行。这两种方法将用于访问鼠标按钮被点击和释放的x和y坐标。最后,paintEvent()
方法用于在mousePressEvent()
和mouseReleaseEvent()
方法提供的坐标之间绘制一条线。以下是创建此应用程序的逐步过程:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
通过拖放标签小部件到表单上,向表单添加一个
QLabel
小部件。 -
将标签小部件的文本属性设置为
单击鼠标并拖动以绘制所需大小的线
。 -
将应用程序保存为
demoDrawLine.ui
。
表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在一个.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。pyuic5
实用程序用于将 XML 文件转换为 Python 代码。生成的 Python 脚本demoDrawLine.py
可以在书的源代码包中看到。
-
将
demoDrawLine.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callDrawLine.pyw
的 Python 文件,并将demoDrawLine.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from PyQt5.QtGui import QPainter
from demoDrawLine import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.pos1 = [0,0]
self.pos2 = [0,0]
self.show()
def paintEvent(self, event):
qp = QPainter()
qp.begin(self)
qp.drawLine(self.pos1[0], self.pos1[1], self.pos2[0],
self.pos2[1])
qp.end()
def mousePressEvent(self, event):
if event.buttons() & QtCore.Qt.LeftButton:
self.pos1[0], self.pos1[1] = event.pos().x(),
event.pos().y()
def mouseReleaseEvent(self, event):
self.pos2[0], self.pos2[1] = event.pos().x(),
event.pos().y()
self.update()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
由于我们想要在鼠标按钮点击和释放的位置之间显示一条线,我们将使用两种方法,mousePressEvent()
和mouseReleaseEvent()
。顾名思义,mousePressEvent()
方法在鼠标按钮按下时自动调用。同样,mouseReleaseEvent()
方法在鼠标按钮释放时自动调用。在这两种方法中,我们将简单地保存鼠标按钮点击和释放的x和y坐标的值。在这个应用程序中定义了两个数组pos1
和pos2
,其中pos1
存储鼠标按钮点击的位置的x和y坐标,pos2
数组存储鼠标按钮释放的位置的x和y坐标。一旦鼠标按钮点击和释放的位置的x和y坐标被分配给pos1
和pos2
数组,self.update()
方法在mouseReleaseEvent()
方法中被调用以调用paintEvent()
方法。在paintEvent()
方法中,调用drawLine()
方法,并将存储在pos1
和pos2
数组中的x和y坐标传递给它,以在鼠标按下和鼠标释放的位置之间绘制一条线。
运行应用程序时,您将收到一条消息,要求在需要绘制线条的位置之间单击并拖动鼠标按钮。因此,单击鼠标按钮并保持鼠标按钮按下,将其拖动到所需位置,然后释放鼠标按钮。将在鼠标按钮单击和释放的位置之间绘制一条线,如下面的屏幕截图所示:
绘制不同类型的线条
在本示例中,我们将学习在两个点之间显示不同类型的线条,从鼠标单击位置到释放鼠标按钮的位置。用户将显示不同的线条类型可供选择,例如实线、虚线、虚线点线等。线条将以所选线条类型绘制。
如何做...
用于定义绘制形状的笔的大小或厚度的是QPen
类。在这个示例中,使用QPen
类的setStyle()
方法来定义线条的样式。以下是绘制不同样式线条的逐步过程:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
通过在表单上拖放一个标签小部件来向表单添加一个
QLabel
小部件。 -
通过拖放一个列表小部件项目在表单上添加一个
QListWidget
小部件。 -
将标签小部件的文本属性设置为
从列表中选择样式,然后单击并拖动以绘制一条线
。 -
将应用程序保存为
demoDrawDiffLine.ui
。 -
列表小部件将用于显示不同类型的线条,因此右键单击列表小部件并选择“编辑项目”选项以向列表小部件添加几种线条类型。单击打开的对话框框底部的+(加号)按钮,并添加几种线条类型,如下面的屏幕截图所示:
- 将列表小部件项目的 objectName 属性设置为
listWidgetLineType
。
表单现在将显示如下屏幕截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。pyuic5
实用程序用于将 XML 文件转换为 Python 代码。生成的 Python 脚本demoDrawDiffLine.py
可以在本书的源代码包中看到。
-
将
demoDrawDiffLine.py
脚本视为头文件,并将其导入到您将调用其用户界面设计的文件中。 -
创建另一个名为
callDrawDiffLine.pyw
的 Python 文件,并将demoDrawDiffLine.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from PyQt5.QtGui import QPainter, QPen
from PyQt5.QtCore import Qt
from demoDrawDiffLine import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.lineType="SolidLine"
self.pos1 = [0,0]
self.pos2 = [0,0]
self.show()
def paintEvent(self, event):
qp = QPainter()
qp.begin(self)
pen = QPen(Qt.black, 4)
self.lineTypeFormat="Qt."+self.lineType
if self.lineTypeFormat == "Qt.SolidLine":
pen.setStyle(Qt.SolidLine)
elif self.lineTypeFormat == "Qt.DashLine":
pen.setStyle(Qt.DashLine)
elif self.lineTypeFormat =="Qt.DashDotLine":
pen.setStyle(Qt.DashDotLine)
elif self.lineTypeFormat =="Qt.DotLine":
pen.setStyle(Qt.DotLine)
elif self.lineTypeFormat =="Qt.DashDotDotLine":
pen.setStyle(Qt.DashDotDotLine)
qp.setPen(pen)
qp.drawLine(self.pos1[0], self.pos1[1],
self.pos2[0], self.pos2[1])
qp.end()
def mousePressEvent(self, event):
if event.buttons() & QtCore.Qt.LeftButton:
self.pos1[0], self.pos1[1] = event.pos().x(),
event.pos().y()
def mouseReleaseEvent(self, event):
self.lineType=self.ui.listWidgetLineType.currentItem()
.text()
self.pos2[0], self.pos2[1] = event.pos().x(),
event.pos().y()
self.update()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
必须在鼠标按下和鼠标释放位置之间绘制一条线,因此我们将在此应用程序中使用两种方法,mousePressEvent()
和mouseReleaseEvent()
。当单击鼠标左键时,mousePressEvent()
方法会自动调用。同样,当鼠标按钮释放时,mouseReleaseEvent()
方法会自动调用。
在这两种方法中,我们将保存鼠标单击和释放时的x和y坐标的值。在这个应用程序中定义了两个数组pos1
和pos2
,其中pos1
存储鼠标单击的位置的x和y坐标,pos2
数组存储鼠标释放的位置的x和y坐标。在mouseReleaseEvent()
方法中,我们从列表小部件中获取用户选择的线类型,并将所选的线类型分配给lineType
变量。此外,在mouseReleaseEvent()
方法中调用了self.update()
方法来调用paintEvent()
方法。在paintEvent()
方法中,您定义了一个宽度为4
像素的画笔,并将其分配为黑色。此外,您为画笔分配了一个与用户从列表小部件中选择的线类型相匹配的样式。最后,调用drawLine()
方法,并将存储在pos1
和pos2
数组中的x和y坐标传递给它,以在鼠标按下和鼠标释放位置之间绘制一条线。所选的线将以从列表小部件中选择的样式显示。
运行应用程序时,您将收到一条消息,要求从列表中选择线类型,并在需要线的位置之间单击并拖动鼠标按钮。因此,在选择所需的线类型后,单击鼠标按钮并保持鼠标按钮按下,将其拖动到所需位置,然后释放鼠标按钮。将在鼠标按钮单击和释放的位置之间绘制一条线,以所选的样式显示在列表中。以下截图显示了不同类型的线:
绘制所需大小的圆
在这个示例中,我们将学习如何绘制一个圆。用户将点击并拖动鼠标来定义圆的直径,圆将根据用户指定的直径进行绘制。
如何做...
一个圆实际上就是从 0 到 360 度绘制的弧。弧的长度,或者可以说是圆的直径,由鼠标按下事件和鼠标释放事件的距离确定。在鼠标按下事件到鼠标释放事件之间内部定义了一个矩形,并且圆在该矩形内绘制。以下是创建此应用程序的完整步骤:
-
让我们创建一个基于无按钮对话框模板的应用程序。
-
通过拖放一个标签小部件到表单上,向表单添加一个
QLabel
小部件。 -
将标签小部件的文本属性设置为
单击鼠标并拖动以绘制所需大小的圆
。 -
将应用程序保存为
demoDrawCircle.ui
。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,它是一个 XML 文件。通过应用pyuic5
实用程序将 XML 文件转换为 Python 代码。您可以在本书的源代码包中找到生成的 Python 代码demoDrawCircle.py
。
-
将
demoDrawCircle.py
脚本视为头文件,并将其导入到您将调用其用户界面设计的文件中。 -
创建另一个名为
callDrawCircle.pyw
的 Python 文件,并将demoDrawCircle.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from PyQt5.QtGui import QPainter
from demoDrawCircle import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.pos1 = [0,0]
self.pos2 = [0,0]
self.show()
def paintEvent(self, event):
width = self.pos2[0]-self.pos1[0]
height = self.pos2[1] - self.pos1[1]
qp = QPainter()
qp.begin(self)
rect = QtCore.QRect(self.pos1[0], self.pos1[1], width,
height)
startAngle = 0
arcLength = 360 *16
qp.drawArc(rect, startAngle, arcLength)
qp.end()
def mousePressEvent(self, event):
if event.buttons() & QtCore.Qt.LeftButton:
self.pos1[0], self.pos1[1] = event.pos().x(),
event.pos().y()
def mouseReleaseEvent(self, event):
self.pos2[0], self.pos2[1] = event.pos().x(),
event.pos().y()
self.update()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
为了在鼠标按下和释放的位置之间绘制直径定义的圆,我们将使用两种方法,mousePressEvent()
和mouseReleaseEvent()
。当鼠标按钮按下时,mousePressEvent()
方法会自动调用,当鼠标按钮释放时,mouseReleaseEvent()
方法会自动调用。在这两种方法中,我们将简单地保存鼠标按下和释放的x和y坐标的值。定义了两个数组pos1
和pos2
,其中pos1
数组存储鼠标按下的位置的x和y坐标,pos2
数组存储鼠标释放的位置的x和y坐标。在mouseReleaseEvent()
方法中调用的self.update()
方法将调用paintEvent()
方法。在paintEvent()
方法中,通过找到鼠标按下和鼠标释放位置的x坐标之间的差异来计算矩形的宽度。类似地,通过找到鼠标按下和鼠标释放事件的y坐标之间的差异来计算矩形的高度。
圆的大小将等于矩形的宽度和高度,也就是说,圆将在用户用鼠标指定的边界内创建。
此外,在paintEvent()
方法中,调用了drawArc()
方法,并将矩形、弧的起始角度和弧的长度传递给它。起始角度被指定为0
。
运行应用程序时,会收到一条消息,要求点击并拖动鼠标按钮以定义要绘制的圆的直径。因此,点击鼠标按钮并保持鼠标按钮按下,将其拖动到所需位置,然后释放鼠标按钮。将在鼠标按下和释放的位置之间绘制一个圆,如下截图所示:
在两次鼠标点击之间绘制一个矩形
在这个示例中,我们将学习在表单上显示鼠标按下和释放的两个点之间的矩形。
如何做...
这是一个非常简单的应用程序,其中使用mousePressEvent()
和mouseReleaseEvent()
方法来分别找到鼠标按下和释放的位置的x和y坐标。然后,调用drawRect()
方法来从鼠标按下的位置到鼠标释放的位置绘制矩形。创建此应用程序的逐步过程如下:
-
让我们基于没有按钮的对话框模板创建一个应用程序。
-
在表单上通过拖放标签小部件添加一个
QLabel
小部件。 -
将标签小部件的文本属性设置为
点击鼠标并拖动以绘制所需大小的矩形
。 -
将应用程序保存为
demoDrawRectangle.ui
。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在一个.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。使用pyuic5
工具将 XML 文件转换为 Python 代码。生成的 Python 脚本demoDrawRectangle.py
可以在本书的源代码包中找到。
-
将
demoDrawRectangle.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callDrawRectangle.pyw
的 Python 文件,并将demoDrawRectangle.py
的代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from PyQt5.QtGui import QPainter
from demoDrawRectangle import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.pos1 = [0,0]
self.pos2 = [0,0]
self.show()
def paintEvent(self, event):
width = self.pos2[0]-self.pos1[0]
height = self.pos2[1] - self.pos1[1]
qp = QPainter()
qp.begin(self)
qp.drawRect(self.pos1[0], self.pos1[1], width, height)
qp.end()
def mousePressEvent(self, event):
if event.buttons() & QtCore.Qt.LeftButton:
self.pos1[0], self.pos1[1] = event.pos().x(),
event.pos().y()
def mouseReleaseEvent(self, event):
self.pos2[0], self.pos2[1] = event.pos().x(),
event.pos().y()
self.update()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
为了在鼠标按钮按下和释放的位置之间绘制矩形,我们将使用两种方法,mousePressEvent()
和mouseReleaseEvent()
。当鼠标按钮按下时,mousePressEvent()
方法会自动被调用,当鼠标按钮释放时,mouseReleaseEvent()
方法会自动被调用。在这两种方法中,我们将简单地保存鼠标按钮单击和释放时的x和y坐标的值。定义了两个数组pos1
和pos2
,其中pos1
数组存储鼠标按钮单击的位置的x和y坐标,pos2
数组存储鼠标按钮释放的位置的x和y坐标。在mouseReleaseEvent()
方法中调用的self.update()
方法将调用paintEvent()
方法。在paintEvent()
方法中,矩形的宽度通过找到鼠标按下和鼠标释放位置的x坐标之间的差异来计算。同样,矩形的高度通过找到鼠标按下和鼠标释放事件的y坐标之间的差异来计算。
此外,在paintEvent()
方法中,调用了drawRect()
方法,并将存储在pos1
数组中的x和y坐标传递给它。此外,矩形的宽度和高度也传递给drawRect()
方法,以在鼠标按下和鼠标释放位置之间绘制矩形。
运行应用程序时,您将收到一条消息,要求单击并拖动鼠标按钮以在所需位置之间绘制矩形。因此,单击鼠标按钮并保持鼠标按钮按下,将其拖动到所需位置,然后释放鼠标按钮。
在鼠标按钮单击和释放的位置之间将绘制一个矩形,如下截图所示:
以所需的字体和大小绘制文本
在这个教程中,我们将学习如何以特定的字体和特定的字体大小绘制文本。在这个教程中将需要四个小部件,如文本编辑,列表小部件,组合框和按钮。文本编辑小部件将用于输入用户想要以所需字体和大小显示的文本。列表小部件框将显示用户可以从中选择的不同字体名称。组合框小部件将显示用户可以选择以定义文本大小的字体大小。按钮小部件将启动操作,也就是说,单击按钮后,文本编辑小部件中输入的文本将以所选字体和大小显示。
如何操作...
QPainter
类是本教程的重点。QPainter
类的setFont()
和drawText()
方法将在本教程中使用。setFont()
方法将被调用以设置用户选择的字体样式和字体大小,drawText()
方法将以指定的字体样式和大小绘制用户在文本编辑小部件中编写的文本。以下是逐步学习这些方法如何使用的过程:
-
让我们创建一个基于无按钮对话框模板的应用程序。
-
将
QLabel
,QTextEdit
,QListWidget
,QComboBox
和QPushButton
小部件通过拖放标签小部件,文本编辑小部件,列表小部件框,组合框小部件和按钮小部件添加到表单中。 -
将标签小部件的文本属性设置为“在最左边的框中输入一些文本,选择字体和大小,然后单击绘制文本按钮”。
-
列表小部件框将用于显示不同的字体,因此右键单击列表小部件框,选择“编辑项目”选项,向列表小部件框添加一些字体名称。单击打开的对话框底部的+(加号)按钮,并添加一些字体名称,如下截图所示:
-
组合框小部件将用于显示不同的字体大小,因此我们需要向组合框小部件添加一些字体大小。右键单击组合框小部件,然后选择“编辑项目”选项。
-
单击打开的对话框框底部的+(加号)按钮,并添加一些字体大小,如下面的屏幕截图所示:
-
将推送按钮小部件的文本属性设置为“绘制文本”。
-
将列表小部件框的 objectName 属性设置为
listWidgetFont
。 -
将组合框小部件的 objectName 属性设置为
comboBoxFontSize
。 -
将推送按钮小部件的 objectName 属性设置为 pushButtonDrawText。
-
将应用程序保存为
demoDrawText.ui
。
表单现在将显示如下的屏幕截图:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,它是一个 XML 文件。通过应用pyuic5
实用程序将 XML 文件转换为 Python 代码。您可以在本书的源代码包中找到生成的 Python 代码demoDrawText.py
。
-
将
demoDrawText.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callDrawText.pyw
的 Python 文件,并将demoDrawText.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from PyQt5.QtGui import QPainter, QColor, QFont
from PyQt5.QtCore import Qt
from demoDrawText import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonDrawText.clicked.connect(self.
dispText)
self.textToDraw=""
self.fontName="Courier New"
self.fontSize=5
self.show()
def paintEvent(self, event):
qp = QPainter()
qp.begin(self)
qp.setPen(QColor(168, 34, 3))
qp.setFont(QFont(self.fontName, self.fontSize))
qp.drawText(event.rect(), Qt.AlignCenter,
self.textToDraw)
qp.end()
def dispText(self):
self.fontName=self.ui.listWidgetFont.currentItem().
text()
self.fontSize=int(self.ui.comboBoxFontSize.itemText(
self.ui.comboBoxFontSize.currentIndex()))
self.textToDraw=self.ui.textEdit.toPlainText()
self.update()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的…
推送按钮小部件的 click()事件连接到dispText()
方法,也就是说,每当点击推送按钮时,将调用dispText()
方法。
在dispText()
方法中,访问从列表小部件框中选择的字体名称,并将其分配给fontName
变量。此外,访问从组合框中选择的字体大小,并将其分配给fontSize
变量。除此之外,获取并分配在文本编辑小部件中编写的文本给textToDraw
变量。最后,调用self.update()
方法;它将调用paintEvent()
方法。
在paintEvent()
方法中,调用drawText()
方法,将以fontName
变量分配的字体样式和fontSize
变量中指定的字体大小绘制在文本编辑小部件中编写的文本。运行应用程序后,您将在极左边看到一个文本编辑小部件,字体名称显示在列表小部件框中,字体大小通过组合框小部件显示。您需要在文本编辑小部件中输入一些文本,从列表小部件框中选择一个字体样式,从组合框小部件中选择一个字体大小,然后单击“绘制文本”按钮。单击“绘制文本”按钮后,文本编辑小部件中编写的文本将以所选字体和所选字体大小显示,如下面的屏幕截图所示:
创建一个显示不同图形工具的工具栏
在这个示例中,我们将学习创建一个显示三个工具栏按钮的工具栏。这三个工具栏按钮显示线条、圆圈和矩形的图标。当用户从工具栏中单击线条工具栏按钮时,他/她可以在表单上单击并拖动鼠标以在两个鼠标位置之间绘制一条线。类似地,通过单击圆圈工具栏按钮,用户可以通过单击和拖动鼠标在表单上绘制一个圆圈。
如何做…
这个示例的重点是帮助您理解如何通过工具栏向用户提供应用程序中经常使用的命令,使它们易于访问和使用。您将学习创建工具栏按钮,定义它们的快捷键以及它们的图标。为工具栏按钮定义图标,您将学习创建和使用资源文件。逐步清晰地解释了每个工具栏按钮的创建和执行过程:
-
让我们创建一个新应用程序来了解创建工具栏涉及的步骤。
-
启动 Qt Designer 并创建一个基于主窗口的应用程序。您将获得一个带有默认菜单栏的新应用程序。
-
您可以右键单击菜单栏,然后从弹出的快捷菜单中选择“删除菜单栏”选项来删除菜单栏。
-
要添加工具栏,右键单击“主窗口”模板,然后从上下文菜单中选择“添加工具栏”。将在菜单栏下方添加一个空白工具栏,如下截图所示:
我们想要创建一个具有三个工具栏按钮的工具栏,分别是线条、圆形和矩形。由于这三个工具栏按钮将代表三个图标图像,我们假设已经有了图标文件,即扩展名为.ico
的线条、圆形和矩形文件。
-
要将工具添加到工具栏中,在“操作编辑器”框中创建一个操作;工具栏中的每个工具栏按钮都由一个操作表示。操作编辑器框通常位于属性编辑器窗口下方。
-
如果“操作编辑器”窗口不可见,请从“视图”菜单中选择“操作编辑器”。操作编辑器窗口将显示如下:
-
在“操作编辑器”窗口中,选择“新建”按钮,为第一个工具栏按钮创建一个操作。您将获得一个对话框,以输入新操作的详细信息。
-
在文本框中,指定操作的名称为
Circle
。 -
在“对象名称”框中,操作对象的名称将自动显示,前缀为文本
action
。 -
在“工具提示”框中,输入任何描述性文本。
-
在“快捷方式”框中,按下Ctrl + C字符,将
Ctrl + C
分配为绘制圆形的快捷键。 -
图标下拉列表显示两个选项,选择资源…和选择文件。
-
您可以通过单击“选择文件…”选项或从资源文件中为操作分配图标图像:
您可以在资源文件中选择多个图标,然后该资源文件可以在不同的应用程序中使用。
- 选择“选择资源…”选项。您将获得“选择资源”对话框,如下截图所示:
由于尚未创建任何资源,对话框为空。您会在顶部看到两个图标。第一个图标代表编辑资源,第二个图标代表重新加载。单击“编辑资源”图标后,您将看到如下对话框:
现在让我们看看如何通过以下步骤创建资源文件:
-
第一步是创建一个资源文件或加载一个现有的资源文件。底部的前三个图标分别代表新资源文件、编辑资源文件和删除。
-
单击“新建资源文件”图标。将提示您指定资源文件的名称。
-
让我们将新资源文件命名为
iconresource
。该文件将以扩展名.qrc
保存。 -
下一步是向资源文件添加前缀。前缀/路径窗格下的三个图标分别是添加前缀、添加文件和删除。
-
单击“添加前缀”选项,然后将提示您输入前缀名称。
-
将前缀输入为
Graphics
。添加前缀后,我们准备向资源文件添加我们的三个图标,圆形、矩形和线条。请记住,我们有三个扩展名为.ico
的图标文件。 -
单击“添加文件”选项以添加图标。单击“添加文件”选项后,将要求您浏览到驱动器/目录并选择图标文件。
-
逐个选择三个图标文件。添加完三个图标后,编辑资源对话框将显示如下:
-
单击“确定”按钮后,资源文件将显示三个可供选择的图标。
-
由于我们想要为圆形操作分配一个图标,因此单击圆形图标,然后单击“确定”按钮:
所选的圆形图标将被分配给 actionCircle。
- 类似地,为矩形和线条工具栏按钮创建另外两个操作,
actionRectangle
和actionLine
。添加了这三个操作后,操作编辑器窗口将显示如下:
-
要在工具栏中显示工具栏按钮,从操作编辑器窗口中单击一个操作,并保持按住状态,将其拖动到工具栏中。
-
将应用程序保存为
demoToolBars.ui
。
将三个操作拖动到工具栏后,工具栏将显示如下:
pyuic5
命令行实用程序将把.ui
(XML)文件转换为 Python 代码,生成的代码将被命名为demoToolBars.py
。您可以在本书的源代码包中找到demoToolBars.py
脚本。我们创建的iconresource.qrc
文件必须在我们继续之前转换为 Python 格式。以下命令行将资源文件转换为 Python 脚本:
pyrcc5 iconresource.qrc -o iconresource_rc.py
- 创建一个名为
callToolBars.pyw
的 Python 脚本,导入代码demoToolBar.py
,以调用工具栏并绘制从工具栏中选择的图形。脚本文件将如下所示:
import sys
from PyQt5.QtWidgets import QMainWindow, QApplication
from PyQt5.QtGui import QPainter
from demoToolBars import *
class AppWindow(QMainWindow):
def __init__(self):
super().__init__()
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.pos1 = [0,0]
self.pos2 = [0,0]
self.toDraw=""
self.ui.actionCircle.triggered.connect(self.drawCircle)
self.ui.actionRectangle.triggered.connect(self.
drawRectangle)
self.ui.actionLine.triggered.connect(self.drawLine)
self.show()
def paintEvent(self, event):
qp = QPainter()
qp.begin(self)
if self.toDraw=="rectangle":
width = self.pos2[0]-self.pos1[0]
height = self.pos2[1] - self.pos1[1]
qp.drawRect(self.pos1[0], self.pos1[1], width,
height)
if self.toDraw=="line":
qp.drawLine(self.pos1[0], self.pos1[1],
self.pos2[0], self.pos2[1])
if self.toDraw=="circle":
width = self.pos2[0]-self.pos1[0]
height = self.pos2[1] - self.pos1[1]
rect = QtCore.QRect(self.pos1[0], self.pos1[1],
width, height)
startAngle = 0
arcLength = 360 *16
qp.drawArc(rect, startAngle, arcLength)
qp.end()
def mousePressEvent(self, event):
if event.buttons() & QtCore.Qt.LeftButton:
self.pos1[0], self.pos1[1] = event.pos().x(),
event.pos().y()
def mouseReleaseEvent(self, event):
self.pos2[0], self.pos2[1] = event.pos().x(),
event.pos().y()
self.update()
def drawCircle(self):
self.toDraw="circle"
def drawRectangle(self):
self.toDraw="rectangle"
def drawLine(self):
self.toDraw="line"
app = QApplication(sys.argv)
w = AppWindow()
w.show()
sys.exit(app.exec_())
它是如何工作的...
每个工具栏按钮的操作的 triggered()信号都连接到相应的方法。actionCircle 工具栏按钮的 triggered()信号连接到drawCircle()
方法,因此每当从工具栏中选择圆形工具栏按钮时,将调用drawCircle()
方法。类似地,actionRectangle
和actionLine
的 triggered()信号分别连接到drawRectangle()
和drawLine()
方法。在drawCircle()
方法中,一个变量toDraw
被赋予一个字符串circle
。toDraw
变量将用于确定在paintEvent()
方法中要绘制的图形。toDraw
变量可以分配任何三个字符串之一,line
、circle
或rectangle
。在toDraw
变量的值上应用条件分支,相应地,将调用绘制线条、矩形或圆形的方法。
绘制线条、圆形或矩形的大小由鼠标点击确定;用户需要在窗体上单击鼠标,拖动鼠标并释放它到想要绘制线条、圆形或矩形的位置。换句话说,线条的长度、矩形的宽度和高度以及圆形的直径将由鼠标确定。
使用pos1
和pos2
两个数组来存储鼠标单击位置和鼠标释放位置的x和y坐标。x和y坐标值通过mousePressEvent()
和mouseReleaseEvent()
两种方法分配给pos1
和pos2
数组。当鼠标按钮被单击时,mousePressEvent()
方法会自动调用,当鼠标按钮释放时,mouseReleaseEvent()
方法会自动调用。
在mouseReleaseEvent()
方法中,分配鼠标释放的位置的x和y坐标值后,调用self.update()
方法来调用paintEvent()
方法。在paintEvent()
方法中,基于分配给toDraw
变量的字符串进行分支。如果toDraw
变量被分配了字符串line
(由drawLine()
方法),则将调用QPainter
类的drawLine()
方法来在两个鼠标位置之间绘制线。类似地,如果toDraw
变量被分配了字符串circle
(由drawCircle()
方法),则将调用QPainter
类的drawArc()
方法来绘制由鼠标位置提供的直径的圆。如果toDraw
变量由drawRectangle()
方法分配了字符串rectangle
,则将调用QPainter
类的drawRect()
方法来绘制由鼠标位置提供的宽度和高度的矩形。
运行应用程序后,您将在工具栏上找到三个工具栏按钮,圆形、矩形和线,如下截图所示(左)。点击圆形工具栏按钮,然后在表单上点击鼠标按钮,并保持鼠标按钮按下,拖动以定义圆的直径,然后释放鼠标按钮。将从鼠标按钮点击的位置到释放鼠标按钮的位置绘制一个圆(右):
要绘制一个矩形,点击矩形工具,点击鼠标按钮在表单上的一个位置,并保持鼠标按钮按下,拖动以定义矩形的高度和宽度。释放鼠标按钮时,将在鼠标按下和鼠标释放的位置之间绘制一个矩形(左)。类似地,点击线工具栏按钮,然后在表单上点击鼠标按钮。保持鼠标按钮按下,将其拖动到要绘制线的位置。释放鼠标按钮时,将在鼠标按下和释放的位置之间绘制一条线(右):
使用 Matplotlib 绘制一条线
在本示例中,我们将学习使用 Matplotlib 绘制通过特定x和y坐标的线。
Matplotlib 是一个 Python 2D 绘图库,使绘制线条、直方图、条形图等复杂的任务变得非常容易。该库不仅可以绘制图表,还提供了一个 API,可以在应用程序中嵌入图表。
准备工作
您可以使用以下语句安装 Matplotlib:
pip install matplotlib
假设我们要绘制一条线,使用以下一组x和y坐标:
x=10, y=20
x=20, y=40
x=30, y=60
在x轴上,x
的值从0
开始向右增加,在y轴上,y
的值在底部为0
,向上移动时增加。因为最后一对坐标是30
,60
,所以图表的最大x
值为30
,最大y
值为60
。
本示例中将使用matplotlib.pyplot
的以下方法:
-
title()
: 该方法用于设置图表的标题 -
xlabel()
: 该方法用于在x轴上显示特定文本 -
ylabel()
: 该方法用于在y轴上显示特定文本 -
plot()
: 该方法用于在指定的x和y坐标处绘制图表
如何操作...
创建一个名为demoPlotLine.py
的 Python 脚本,并在其中编写以下代码:
import matplotlib.pyplot as graph
graph.title('Plotting a Line!')
graph.xlabel('x - axis')
graph.ylabel('y - axis')
x = [10,20,30]
y = [20,40,60]
graph.plot(x, y)
graph.show()
工作原理...
您在脚本中导入matplotlib.pyplot
并将其命名为 graph。使用title()
方法,您设置图表的标题。然后,调用xlabel()
和ylabel()
方法来定义x轴和y轴的文本。因为我们想要使用三组x和y坐标绘制一条线,所以定义了两个名为x和y的数组。在这两个数组中分别定义了我们想要绘制的三个x和y坐标值的值。调用plot()
方法,并将这两个x和y数组传递给它,以使用这两个数组中定义的三个x和y坐标值绘制线。调用 show 方法显示绘图。
运行应用程序后,您会发现绘制了一条通过指定的x和y坐标的线。此外,图表将显示指定的标题,绘制一条线!除此之外,您还可以在x轴和y轴上看到指定的文本,如下截图所示:
使用 Matplotlib 绘制条形图
在本示例中,我们将学习使用 Matplotlib 绘制条形图,比较过去三年业务增长。您将提供 2016 年、2017 年和 2018 年的利润百分比,应用程序将显示代表过去三年利润百分比的条形图。
准备工作
假设组织过去三年的利润百分比如下:
-
2016 年:利润为 70%
-
2017 年:利润为 90%
-
2018 年:利润为 80%
您想显示代表利润百分比的条形,并沿x轴显示年份:2016 年、2017 年和 2018 年。沿y轴,您希望显示代表利润百分比的条形。 y轴上的y
值将从底部的0
开始增加,向顶部移动时增加,最大值为顶部的100
。
本示例将使用matplotlib.pyplot
的以下方法:
-
title()
: 用于设置图表的标题 -
bar()
: 从两个提供的数组绘制条形图;一个数组将代表x轴的数据,第二个数组将代表y轴的数据 -
plot()
: 用于在指定的x和y坐标处绘图
如何做...
创建一个名为demoPlotBars.py
的 Python 脚本,并在其中编写以下代码:
import matplotlib.pyplot as graph
years = ['2016', '2017', '2018']
profit = [70, 90, 80]
graph.bar(years, profit)
graph.title('Growth in Business')
graph.plot(100)
graph.show()
工作原理...
您在脚本中导入matplotlib.pyplot
并将其命名为 graph。您定义两个数组,years 和 profit,其中 years 数组将包含 2016 年、2017 年和 2018 年的数据,以表示我们想要比较利润的年份。类似地,profit 数组将包含代表过去三年利润百分比的值。然后,调用bar()
方法,并将这两个数组 years 和 profit 传递给它,以显示比较过去三年利润的条形图。调用title()
方法显示标题,业务增长。调用plot()
方法指示y轴上的最大y
值。最后,调用show()
方法显示条形图。
运行应用程序后,您会发现绘制了一根条形图,显示了组织在过去三年的利润。 x轴显示年份,y轴显示利润百分比。此外,图表将显示指定的标题,业务增长,如下截图所示:
第二十一章:实现动画
在本章中,您将学习如何对给定的图形图像应用运动,从而实现动画。动画在解释任何机器、过程或系统的实际工作中起着重要作用。在本章中,我们将涵盖以下主题:
-
显示 2D 图形图片
-
点击按钮使球移动
-
制作一个弹跳的球
-
使球根据指定的曲线进行动画
介绍
要在 Python 中查看和管理 2D 图形项,我们需要使用一个名为QGraphicsScene
的类。为了显示QGraphicsScene
的内容,我们需要另一个名为QGraphicsView
的类的帮助。基本上,QGraphicsView
提供了一个可滚动的视口,用于显示QGraphicsScene
的内容。QGraphicsScene
充当多个图形项的容器。它还提供了几种标准形状,如矩形和椭圆,包括文本项。还有一点:QGraphicsScene
使用 OpenGL 来渲染图形。OpenGL 非常高效,可用于显示图像和执行多媒体处理任务。QGraphicsScene
类提供了几种方法,可帮助添加或删除场景中的图形项。也就是说,您可以通过调用addItem
函数向场景添加任何图形项。同样,要从图形场景中删除项目,可以调用removeItem
函数。
实现动画
要在 Python 中应用动画,我们将使用QPropertyAnimation
类。PyQt 中的QPropertyAnimation
类帮助创建和执行动画。QPropertyAnimation
类通过操纵 Qt 属性(如小部件的几何形状、位置等)来实现动画。以下是QPropertyAnimation
的一些方法:
-
start()
: 该方法开始动画 -
stop()
: 该方法结束动画 -
setStartValue()
: 该方法用于指定动画的起始值 -
setEndValue()
: 该方法用于指定动画的结束值 -
setDuration()
: 该方法用于设置动画的持续时间(毫秒) -
setKeyValueAt()
: 该方法在给定值处创建关键帧 -
setLoopCount()
: 该方法设置动画中所需的重复次数
显示 2D 图形图像
在本教程中,您将学习如何显示 2D 图形图像。我们假设您的计算机上有一个名为scene.jpg
的图形图像,并将学习如何在表单上显示它。本教程的重点是了解如何使用 Graphics View 小部件来显示图像。
操作步骤...
显示图形的过程非常简单。您首先需要创建一个QGraphicsScene
对象,该对象又利用QGraphicsView
类来显示其内容。然后通过调用QGraphicsScene
类的addItem
方法向QGraphicsScene
类添加图形项,包括图像。以下是在屏幕上显示 2D 图形图像的步骤:
-
基于无按钮对话框模板创建一个新应用程序。
-
将 Graphics View 小部件拖放到其中。
-
将应用程序保存为
demoGraphicsView.ui
。表单将显示如下截图所示:
pyuic5
命令实用程序将.ui
(XML)文件转换为 Python 代码。生成的 Python 脚本demoGraphicsView.py
可以在本书的源代码包中找到。
- 创建一个名为
callGraphicsView.pyw
的 Python 脚本,导入代码demoGraphicsView.py
,以调用用户界面设计,从磁盘加载图像,并通过 Graphics View 显示它。Python 脚本文件callGraphicsView.pyw
将包括以下代码:
import sys
from PyQt5.QtWidgets import QDialog, QApplication, QGraphicsScene, QGraphicsPixmapItem
from PyQt5.QtGui import QPixmap
from demoGraphicsView import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.scene = QGraphicsScene(self)
pixmap= QtGui.QPixmap()
pixmap.load("scene.jpg")
item=QGraphicsPixmapItem(pixmap)
self.scene.addItem(item)
self.ui.graphicsView.setScene(self.scene)
if __name__=="__main__":
app = QApplication(sys.argv)
myapp = MyForm()
myapp.show()
sys.exit(app.exec_())
工作原理...
在此应用程序中,您正在使用 Graphics View 来显示图像。您向 Graphics View 小部件添加了一个图形场景,并添加了QGraphicsPixmapItem
。如果要将图像添加到图形场景中,需要以pixmap
项目的形式提供。首先,您需要将图像表示为pixmap
,然后在将其添加到图形场景之前将其显示为pixmap
项目。您需要创建QPixmap
的实例,并通过其load()
方法指定要通过其显示的图像。然后,通过将pixmap
传递给QGraphicsPixmapItem
的构造函数,将pixmap
项目标记为pixmapitem
。然后,通过addItem
将pixmapitem
添加到场景中。如果pixmapitem
比QGraphicsView
大,则会自动启用滚动。
在上面的代码中,我使用了文件名为scene.jpg
的图像。请将文件名替换为您的磁盘上可用的图像文件名,否则屏幕上将不显示任何内容。
使用了以下方法:
-
QGraphicsView.setScene
:此方法(self,QGraphicsScene
scene)将提供的场景分配给GraphicView
实例以进行显示。如果场景已经在视图中显示,则此函数不执行任何操作。设置场景时,将生成QGraphicsScene.changed
信号,并调整视图的滚动条以适应场景的大小。 -
addItem
:此方法将指定的项目添加到场景中。如果项目已经在不同的场景中,则首先将其从旧场景中移除,然后添加到当前场景中。运行应用程序时,将通过GrahicsView
小部件显示scene.jpg
图像,如下面的屏幕截图所示:
点击按钮使球移动
在本教程中,您将了解如何在对象上应用基本动画。本教程将包括一个按钮和一个球,当按下按钮时,球将开始向地面动画。
操作步骤...
为了制作这个教程,我们将使用QPropertyAnimation
类。QPropertyAnimation
类的setStartValue()
和setEndValue()
方法将用于分别定义动画需要开始和结束的坐标。setDuration()
方法将被调用以指定每次动画移动之间的延迟时间(以毫秒为单位)。以下是应用动画的逐步过程:
-
基于无按钮对话框模板创建一个新应用程序。
-
将一个 Label 小部件和一个 Push Button 小部件拖放到表单上。
-
将 Push Button 小部件的文本属性设置为
Move Down
。我们假设您的计算机上有一个名为coloredball.jpg
的球形图像。 -
选择其 pixmap 属性以将球图像分配给 Label 小部件。
-
在 pixmap 属性中,从两个选项中选择 Resource 和 Choose File,选择 Choose File 选项,浏览您的磁盘,并选择
coloredball.jpg
文件。球的图像将出现在 Label 小部件的位置。 -
将 Push Button 小部件的 objectName 属性设置为
pushButtonPushDown
,Label 小部件的 objectName 属性设置为labelPic
。 -
使用名称
demoAnimation1.ui
保存应用程序。应用程序将显示如下屏幕截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个需要转换为 Python 代码的 XML 文件。在应用pyuic5
命令实用程序时,.ui
文件将被转换为 Python 脚本。生成的 Python 脚本demoAnimation1.py
可以在本书的源代码包中看到。
-
将
demoAnimation1.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callAnimation1.pyw
的 Python 文件,并将demoAnimation1.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from PyQt5.QtCore import QRect, QPropertyAnimation
from demoAnimation1 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonMoveDown.clicked.connect(self.
startAnimation)
self.show()
def startAnimation(self):
self.anim = QPropertyAnimation(self.ui.labelPic,
b"geometry")
self.anim.setDuration(10000)
self.anim.setStartValue(QRect(160, 70, 80, 80))
self.anim.setEndValue(QRect(160, 70, 220, 220))
self.anim.start()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
您可以看到,具有 objectName 属性pushButtonMoveDown
的推送按钮小部件的 click()事件连接到startAnimation
方法;当点击推送按钮时,将调用startAnimation
方法。在startAnimation
方法中,创建一个QPropertyAnimation
类的对象并命名为anim
。在创建QPropertyAnimation
实例时,传递两个参数;第一个是要应用动画的标签小部件,第二个是定义要将动画应用于对象属性的属性。因为您想要对球的几何图形应用动画,所以在定义QPropertyAnimation
对象时,将b"geometry"
作为第二个属性传递。之后,将动画的持续时间指定为10000
毫秒,这意味着您希望每隔 10,000 毫秒更改对象的几何图形。通过setStartValue
方法,指定要开始动画的矩形区域,并通过调用setEndValue
方法,指定要停止动画的矩形区域。通过调用start
方法,启动动画;因此,球从通过setStartValue
方法指定的矩形区域向下移动,直到达到通过setEndValue
方法指定的矩形区域。
运行应用程序时,您会在屏幕上找到一个推送按钮和一个代表球图像的标签小部件,如下截图所示(左)。点击 Move Down 推送按钮后,球开始向地面动画,并在通过setEndValue
方法指定的区域停止动画,如下截图所示(右):
制作一个弹跳的球
在这个示例中,您将制作一个弹跳的球;当点击按钮时,球向地面掉落,触及地面后,它会反弹到顶部。在这个示例中,您将了解如何在对象上应用基本动画。这个示例将包括一个推送按钮和一个球,当按下推送按钮时,球将开始向地面动画。
如何做...
要使球看起来像是在弹跳,我们需要首先使其向地面动画,然后从地面向天空动画。为此,我们将三次调用QPropertyAnimation
类的setKeyValueAt
方法。前两次调用setKeyValueAt
方法将使球从顶部向底部动画。第三次调用setKeyValueAt
方法将使球从底部向顶部动画。在三个setKeyValueAt
方法中提供坐标,以使球以相反方向弹跳,而不是从哪里来的。以下是了解如何使球看起来像在弹跳的步骤:
-
基于没有按钮的对话框模板创建一个新的应用程序。
-
将一个标签小部件和一个推送按钮小部件拖放到表单上。
-
将推送按钮小部件的文本属性设置为
Bounce
。我们假设您的计算机上有一个名为coloredball.jpg
的球形图像。 -
要将球形图像分配给标签小部件,请选择其 pixmap 属性。
-
在 pixmap 属性中,从两个选项
Choose Resource
和Choose File
中选择Choose File
选项,浏览您的磁盘,并选择coloredball.jpg
文件。球的图像将出现在标签小部件的位置。 -
将推送按钮小部件的 objectName 属性设置为
pushButtonBounce
,标签小部件的 objectName 属性设置为labelPic
。 -
将应用程序保存为
demoAnimation3.ui
。
应用程序将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。在应用pyuic5
命令实用程序时,.ui
文件将被转换为 Python 脚本。生成的 Python 脚本demoAnimation3.py
可以在本书的源代码包中找到。
-
将
demoAnimation3.py
脚本视为头文件,并将其导入到您将调用其用户界面设计的文件中。 -
创建另一个名为
callAnimation3.pyw
的 Python 文件,并将demoAnimation3.py
代码导入其中。
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from PyQt5.QtCore import QRect, QPropertyAnimation
from demoAnimation3 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonBounce.clicked.connect(self.
startAnimation)
self.show()
def startAnimation(self):
self.anim = QPropertyAnimation(self.ui.labelPic,
b"geometry")
self.anim.setDuration(10000)
self.anim.setKeyValueAt(0, QRect(0, 0, 100, 80));
self.anim.setKeyValueAt(0.5, QRect(160, 160, 200, 180));
self.anim.setKeyValueAt(1, QRect(400, 0, 100, 80));
self.anim.start()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
您可以看到,具有 objectName 属性pushButtonMoveDown
的 Push 按钮小部件的 click()事件与startAnimation
方法连接在一起;当单击按钮时,将调用startAnimation
方法。在startAnimation
方法中,您创建一个QPropertyAnimation
类的对象,并将其命名为anim
。在创建QPropertyAnimation
实例时,您传递两个参数:第一个是要应用动画的 Label 小部件,第二个是定义要将动画应用于对象属性的属性。因为您想要将动画应用于球的几何属性,所以在定义QPropertyAnimation
对象时,将b"geometry"
作为第二个属性传递。之后,您将动画的持续时间指定为10000
毫秒,这意味着您希望每隔 10,000 毫秒更改对象的几何形状。通过setKeyValue
方法,您指定要开始动画的区域,通过这种方法指定左上角区域,因为您希望球从左上角向地面掉落。通过对setKeyValue
方法的第二次调用,您提供了球掉落到地面的区域。您还指定了掉落的角度。球将对角线向下掉落到地面。通过调用第三个setValue
方法,您指定动画停止的结束值,在这种情况下是在右上角。通过对setKeyValue
方法的这三次调用,您使球对角线向下掉落到地面,然后反弹回右上角。通过调用start
方法,您启动动画。
运行应用程序时,您会发现 Push 按钮和 Label 小部件代表球图像显示在屏幕左上角,如下面的屏幕截图所示(左侧)。
单击 Bounce 按钮后,球开始沿对角线向下动画移动到地面,如中间屏幕截图所示,触地后,球反弹回屏幕的右上角,如右侧所示:
根据指定的曲线使球动起来
创建一个具有所需形状和大小的曲线,并设置一个球在单击按钮时沿着曲线的形状移动。在这个示例中,您将了解如何实现引导动画。
如何做...
QPropertyAnimation
类的setKeyValueAt
方法确定动画的方向。对于引导动画,您在循环中调用setKeyValueAt
方法。在循环中将曲线的坐标传递给setKeyValueAt
方法,以使球沿着曲线动画。以下是使对象按预期动画的步骤:
-
基于无按钮对话框模板创建一个新的应用程序。
-
将一个 Label 小部件和一个 Push 按钮小部件拖放到表单上。
-
将 Push 按钮小部件的文本属性设置为
Move With Curve
。 -
假设您的计算机上有一个名为
coloredball.jpg
的球形图像,您可以使用其 pixmap 属性将此球形图像分配给 Label 小部件。 -
在
pixmap
属性中,您会找到两个选项,选择资源和选择文件;选择选择文件选项,浏览您的磁盘,并选择coloredball.jpg
文件。球的图像将出现在Label
小部件的位置。 -
将
Push Button
小部件的objectName
属性设置为pushButtonMoveCurve
,将Label
小部件的objectName
属性设置为labelPic
。 -
将应用程序保存为
demoAnimation4.ui
。应用程序将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,是一个 XML 文件。通过应用pyuic5
实用程序,将 XML 文件转换为 Python 代码。您可以在本书的源代码包中找到生成的 Python 代码demoAnimation4.py
。
-
将
demoAnimation4.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callAnimation4.pyw
的 Python 文件,并将demoAnimation4.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from PyQt5.QtCore import QRect, QPointF, QPropertyAnimation, pyqtProperty
from PyQt5.QtGui import QPainter, QPainterPath
from demoAnimation4 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonMoveCurve.clicked.connect(self.
startAnimation)
self.path = QPainterPath()
self.path.moveTo(30, 30)
self.path.cubicTo(30, 30, 80, 180, 180, 170)
self.ui.labelPic.pos = QPointF(20, 20)
self.show()
def paintEvent(self, e):
qp = QPainter()
qp.begin(self)
qp.drawPath(self.path)
qp.end()
def startAnimation(self):
self.anim = QPropertyAnimation(self.ui.labelPic, b'pos')
self.anim.setDuration(4000)
self.anim.setStartValue(QPointF(20, 20))
positionValues = [n/80 for n in range(0, 50)]
for i in positionValues:
self.anim.setKeyValueAt(i,
self.path.pointAtPercent(i))
self.anim.setEndValue(QPointF(160, 150))
self.anim.start()
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理...
首先,让曲线出现在屏幕上。这是将指导球动画的曲线;也就是说,它将作为动画的路径。您定义了QPainterPath
类的实例并将其命名为path
。您调用QPainterPath
类的moveTo
方法来指定路径或曲线的起始位置。调用cubicTo
方法来指定球动画的曲线路径。
您会发现Push Button
小部件的objectName
属性为pushButtonMoveCurve
的点击事件与startAnimation
方法相连接;当单击Push Button
小部件时,将调用startAnimation()
方法。在startAnimation
方法中,您创建了QPropertyAnimation
类的对象并将其命名为anim
。在创建QPropertyAnimation
实例时,您传递了两个参数:第一个是要应用动画的Label
小部件,第二个是定义要将动画应用于对象属性的属性。因为您想要将动画应用于球的位置,所以在定义QPropertyAnimation
对象时,您将b'pos'
作为第二个属性传递。之后,您将动画的持续时间指定为4000
毫秒,这意味着您希望每4000
毫秒更改球的位置。使用QPropertyAnimation
类的setStartValue()
方法,您指定了希望球进行动画的坐标。您设置了指定球需要沿着移动的值的for
循环。您通过在for
循环内调用setKeyValue
方法来指定球的动画路径。因为球需要在路径中指定的每个点绘制,所以您通过调用pointAtPercent()
方法并将其传递给setKeyValueAt()
方法来设置球需要绘制的点。您还需要通过调用setEndValue()
方法来设置动画需要停止的位置。
不久之后,您会指定动画的开始和结束位置,指定动画的路径,并调用paintEvent()
方法来在路径的每一点重新绘制球。
运行应用程序后,您会在屏幕左上角(截图的左侧)找到Push Button
小部件和代表球形图像的Label
小部件,并在单击Move With Curve
按钮后,球会沿着绘制的曲线开始动画,并在曲线结束的地方停止(截图的右侧):
第二十二章:使用谷歌地图
在本章中,您将学习如何在 Python 应用程序中使用谷歌地图,并探索谷歌提供的不同优势。您将学习以下任务:
-
查找位置或地标的详细信息
-
从经度和纬度值获取完整信息
-
查找两个位置之间的距离
-
在谷歌地图上显示位置
介绍
谷歌地图 API 是一组方法和工具,可用于查找任何位置的完整信息,包括经度和纬度值。您可以使用谷歌地图 API 方法查找两个位置之间的距离或到达任何位置的方向;甚至可以显示谷歌地图,标记该位置,等等。
更准确地说,谷歌地图服务有一个 Python“客户端”库。谷歌地图 API 包括方向 API、距离矩阵 API、地理编码 API、地理位置 API 等多个 API。要使用任何谷歌地图网络服务,您的 Python 脚本会向谷歌发送一个请求;为了处理该请求,您需要一个 API 密钥。您需要按照以下步骤获取 API 密钥:
-
使用您的谷歌账号登录控制台
-
选择您现有的项目之一或创建一个新项目。
-
启用您想要使用的 API
-
复制 API 密钥并在您的 Python 脚本中使用它
您需要访问谷歌 API 控制台,console.developers.google.com
,并获取 API 密钥,以便您的应用程序经过身份验证可以使用谷歌地图 API 网络服务。
API 密钥在多个方面有帮助;首先,它有助于识别您的应用程序。API 密钥包含在每个请求中,因此它有助于谷歌监视您的应用程序的 API 使用情况,了解您的应用程序是否已经消耗完每日的免费配额,并因此向您的应用程序收费。
因此,为了在您的 Python 应用程序中使用谷歌地图 API 网络服务,您只需要启用所需的 API 并获取一个 API 密钥。
查找位置或地标的详细信息
在这个教程中,您将被提示输入您想要了解的位置或地标的详细信息。例如,如果您输入“白金汉宫”,该教程将显示宫殿所在地的城市和邮政编码,以及其经度和纬度值。
如何做…
GoogleMaps
类的 search 方法是这个教程的关键。用户输入的地标或位置被传递给 search 方法。从 search 方法返回的对象的city
、postal_code
、lat
和lng
属性用于分别显示位置的城市、邮政编码、纬度和经度。让我们通过以下逐步过程来看看如何完成这个操作:
-
基于无按钮对话框模板创建一个应用程序。
-
通过将六个标签、一个行编辑和一个推送按钮小部件拖放到表单上,向表单添加六个标签、一个行编辑和一个推送按钮小部件。
-
将第一个标签小部件的文本属性设置为“查找城市、邮政编码、经度和纬度”,将第二个标签小部件的文本属性设置为“输入位置”。
-
删除第三、第四、第五和第六个标签小部件的文本属性,因为它们的文本属性将通过代码设置;也就是说,输入位置的城市、邮政编码、经度和纬度将通过代码获取并通过这四个标签小部件显示。
-
将推送按钮小部件的文本属性设置为“搜索”。
-
将行编辑小部件的 objectName 属性设置为
lineEditLocation
。 -
将推送按钮小部件的 objectName 属性设置为
pushButtonSearch
。 -
将其余四个标签小部件的 objectName 属性设置为
labelCity
、labelPostalCode
、labelLongitude
和labelLatitude
。 -
将应用程序保存为
demoGoogleMap1.ui
。表单现在将显示如下屏幕截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,它是一个 XML 文件。通过应用pyuic5
实用程序将 XML 文件转换为 Python 代码。您可以在本书的源代码包中找到生成的 Python 代码demoGoogleMap1.py
。
-
将
demoGoogleMap1.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callGoogleMap1.pyw
的 Python 文件,并将demoGoogleMap1.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from geolocation.main import GoogleMaps
from demoGoogleMap1 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonSearch.clicked.connect(self.
displayDetails)
self.show()
def displayDetails(self):
address = str(self.ui.lineEditLocation.text())
google_maps = GoogleMaps(api_key=
'xxxxxxxxxxxxxxxxxxxxxxxxxxxx')
location = google_maps.search(location=address)
my_location = location.first()
self.ui.labelCity.setText("City:
"+str(my_location.city))
self.ui.labelPostalCode.setText("Postal Code: "
+str(my_location.postal_code))
self.ui.labelLongitude.setText("Longitude:
"+str(my_location.lng))
self.ui.labelLatitude.setText("Latitude:
"+str(my_location.lat))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的...
您可以在脚本中看到,具有 objectName 属性pushButtonSearch
的按钮的单击事件连接到displayDetails
方法。这意味着每当单击按钮时,将调用displayDetails
方法。在displayDetails
方法中,您访问用户在行编辑小部件中输入的位置,并将该位置分配给地址变量。通过传递在 Google 注册时获得的 API 密钥来定义 Google Maps 实例。在 Google Maps 实例上调用search
方法,传递用户在此方法中输入的位置。search
方法的结果分配给my_location
结构。my_location
结构的 city 成员包含用户输入的城市。类似地,my_location
结构的postal_code
、lng
和lat
成员分别包含用户输入位置的邮政编码、经度和纬度信息。城市、邮政编码、经度和纬度信息通过最后四个标签小部件显示。
运行应用程序时,将提示您输入要查找信息的位置。假设您在位置中输入泰姬陵
,然后单击搜索按钮。泰姬陵地标的城市、邮政编码、经度和纬度信息将显示在屏幕上,如下面的屏幕截图所示:
从纬度和经度值获取完整信息
在本教程中,您将学习如何查找已知经度和纬度值的位置的完整详细信息。将点位置(即纬度和经度值)转换为可读地址(地名、城市、国家名称等)的过程称为反向地理编码。
应用程序将提示您输入经度和纬度值,然后显示匹配的位置名称、城市、国家和邮政编码。
如何做...
让我们根据以下步骤创建一个基于无按钮对话框模板的应用程序:
-
通过将七个
QLabel
、两个QLineEdit
和一个QPushButton
小部件拖放到表单上,向表单添加七个标签、两个行编辑和一个按钮小部件。 -
将第一个标签小部件的文本属性设置为
查找位置、城市、国家和邮政编码
,将第二个标签小部件的文本属性设置为输入经度
,将第三个标签小部件的文本属性设置为输入纬度
。 -
删除第四、第五、第六和第七个标签小部件的文本属性,因为它们的文本属性将通过代码设置;也就是说,用户输入经度和纬度的位置的位置、城市、国家和邮政编码将通过代码访问,并通过这四个标签小部件显示。
-
将 Push Button 小部件的文本属性设置为
搜索
。 -
将两个行编辑小部件的 objectName 属性设置为
lineEditLongitude
和lineEditLatitude
。 -
将 Push Button 小部件的 objectName 属性设置为
pushButtonSearch
。 -
将其他四个标签小部件的 objectName 属性设置为
labelLocation
、labelCity
、labelCountry
和labelPostalCode
。 -
将应用程序保存为
demoGoogleMap2.ui
。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。使用pyuic5
实用程序将 XML 文件转换为 Python 代码。在本书的源代码包中可以看到生成的 Python 脚本demoGoogleMap2.py
。
-
将
demoGoogleMap2.py
脚本视为头文件,并将其导入到将调用其用户界面设计的文件中。 -
创建另一个名为
callGoogleMap2.pyw
的 Python 文件,并将demoGoogleMap2.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from geolocation.main import GoogleMaps
from demoGoogleMap2 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonSearch.clicked.connect(self.
displayLocation)
self.show()
def displayLocation(self):
lng = float(self.ui.lineEditLongitude.text())
lat = float(self.ui.lineEditLatitude.text())
google_maps = GoogleMaps(api_key=
'AIzaSyDzCMD-JTg-IbJZZ9fKGE1lipbBiFRiGHA')
my_location = google_maps.search(lat=lat, lng=lng).
first()
self.ui.labelLocation.setText("Location:
"+str(my_location))
self.ui.labelCity.setText("City:
"+str(my_location.city))
self.ui.labelCountry.setText("Country:
"+str(my_location.country))
self.ui.labelPostalCode.setText("Postal Code:
"+str(my_location.postal_code))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
操作原理如下:
在脚本中,您可以看到具有 objectName 属性pushButtonSearch
的推送按钮的 click()事件连接到displayLocation
方法。这意味着每当单击推送按钮时,将调用displayLocation
方法。在displayLocation
方法中,您通过两个 Line Edit 小部件访问用户输入的经度和纬度,并分别将它们分配给两个变量lng
和lat
。通过传递在 Google 注册时获得的 API 密钥来定义 Google Maps 实例。在 Google Maps 实例上调用search
方法,传递用户提供的经度和纬度值。在检索到的搜索上调用first
方法,并将与提供的经度和纬度值匹配的第一个位置分配给my_location
结构。位置名称通过 Label 小部件显示。为了显示位置的城市、国家和邮政编码,使用my_location
结构的city
、country
和postal_code
成员。
运行应用程序时,您将被提示输入经度和纬度值。与提供的经度和纬度相关的位置名称、城市、国家和邮政编码将通过四个标签小部件显示在屏幕上,如下截图所示:
查找两个位置之间的距离
在这个教程中,您将学习如何找出用户输入的两个位置之间的距离(以公里为单位)。该教程将简单地提示用户输入两个位置,然后单击“查找距离”按钮,两者之间的距离将被显示。
操作步骤如下:
让我们根据没有按钮模板的对话框创建一个应用程序,执行以下步骤:
-
通过将四个标签、两个行编辑和一个推送按钮小部件拖放到表单上,向表单添加四个
QLabel
、两个QLineEdit
和一个QPushButton
小部件。 -
将第一个标签小部件的文本属性设置为“查找两个位置之间的距离”,将第二个标签小部件的文本属性设置为“输入第一个位置”,将第三个标签小部件的文本属性设置为“输入第二个位置”。
-
删除第四个标签小部件的文本属性,因为它的文本属性将通过代码设置;也就是说,两个输入位置之间的距离将通过代码计算并显示在第四个标签小部件中。
-
将推送按钮小部件的文本属性设置为“查找距离”。
-
将两个行编辑小部件的 objectName 属性设置为
lineEditFirstLocation
和lineEditSecondLocation
。 -
将推送按钮小部件的 objectName 属性设置为
pushButtonFindDistance
。 -
将第四个标签小部件的 objectName 属性设置为
labelDistance
。 -
将应用程序保存为
demoGoogleMap3.ui
。表单现在将显示如下截图所示:
使用 Qt Designer 创建的用户界面存储在.ui
文件中,它是一个 XML 文件。通过应用pyuic5
实用程序,将 XML 文件转换为 Python 代码。您可以在本书的源代码包中找到生成的 Python 代码demoGoogleMap3.py
。
-
要使用在
demoGoogleMap3.py
文件中创建的 GUI,我们需要创建另一个 Python 脚本并在该脚本中导入demoGoogleMap3.py
文件。 -
创建另一个名为
callGoogleMap3.pyw
的 Python 文件,并将demoGoogleMap3.py
代码导入其中:
import sys
from PyQt5.QtWidgets import QDialog, QApplication
from googlemaps.client import Client
from googlemaps.distance_matrix import distance_matrix
from demoGoogleMap3 import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonFindDistance.clicked.connect(self.
displayDistance)
self.show()
def displayDistance(self):
api_key = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
gmaps = Client(api_key)
data = distance_matrix(gmaps,
self.ui.lineEditFirstLocation.text(),
self.ui.lineEditSecondLocation.text())
distance = data['rows'][0]['elements'][0]['distance']
['text']
self.ui.labelDistance.setText("Distance between
"+self.ui.lineEditFirstLocation.text()+"
and "+self.ui.lineEditSecondLocation.text()+" is
"+str(distance))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
它是如何工作的…
您创建一个Client
类的实例并将其命名为gmaps
。在创建Client
实例时,您需要传递在注册 Google 时获得的 API 密钥。具有 objectNamepushButtonFindDistance
的按钮的 click()事件连接到displayDistance
方法。这意味着每当单击按钮时,将调用displayDistance
方法。在displayDistance
方法中,您调用distance_matrix
方法,传递Client
实例和用户输入的两个位置,以找出它们之间的距离。distance_matrix
方法返回一个多维数组,该数组分配给数据数组。从数据数组中,访问并将两个位置之间的距离分配给distance
变量。最终通过 Label 小部件显示distance
变量中的值。
运行应用程序时,将提示您输入要了解其相隔距离的两个位置。输入两个位置后,单击查找距离按钮,两个位置之间的距离将显示在屏幕上,如下截图所示:
在 Google 地图上显示位置
在本教程中,您将学习如何在 Google 地图上显示位置,如果您知道该位置的经度和纬度值。您将被提示简单输入经度和纬度值,当您单击显示地图按钮时,该位置将显示在 Google 地图上。
如何做…
让我们创建一个基于无按钮对话框模板的应用程序,执行以下步骤:
-
通过将两个 Label、两个 Line Edit、一个 PushButton 和一个 QWidget 小部件拖放到表单上,向表单添加两个 QLabel、两个 QLineEdit、一个 QPushButton 和一个 QWidget 小部件。
-
将两个 Label 小部件的文本属性设置为
Longitude
和Latitude
。 -
将 Push Button 小部件的文本属性设置为
Show Map
。 -
将两个 Line Edit 小部件的 objectName 属性设置为
lineEditLongitude
和lineEditLatitude
。 -
将 Push Button 小部件的 objectName 属性设置为
pushButtonShowMap
。 -
将应用程序保存为
showGoogleMap.ui
。现在,表单将显示如下截图所示:
-
下一步是将
QWidget
小部件提升为QWebEngineView
,因为要显示 Google 地图,需要QWebEngineView
。因为 Google 地图是一个 Web 应用程序,我们需要一个 QWebEngineView 来显示和与 Google 地图交互。 -
通过右键单击 QWidget 小部件并从弹出菜单中选择 Promote to ...选项来提升
QWidget
小部件。在出现的对话框中,将 Base class name 选项保留为默认的 QWidget。 -
在 Promoted class name 框中输入
QWebEngineView
,在 header file 框中输入PyQT5.QtWebEngineWidgets
。 -
单击 Promote 按钮,将
QWidget
小部件提升为QWebEngineView
类,如下截图所示:
-
单击关闭按钮关闭 Promoted Widgets 对话框。使用 Qt Designer 创建的用户界面存储在
.ui
文件中,这是一个 XML 文件,需要转换为 Python 代码。使用pyuic5
实用程序将 XML 文件转换为 Python 代码。生成的 Python 脚本showGoogleMap.py
可以在本书的源代码包中找到。 -
将
showGoogleMap.py
脚本视为头文件,并将其导入到您将调用其用户界面设计的文件中。 -
创建另一个名为
callGoogleMap.pyw
的 Python 文件,并将showGoogleMap.py
代码导入其中:
import sys
from PyQt5.QtCore import QUrl
from PyQt5.QtWidgets import QApplication, QDialog
from PyQt5.QtWebEngineWidgets import QWebEngineView
from showGoogleMap import *
class MyForm(QDialog):
def __init__(self):
super().__init__()
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.pushButtonShowMap.clicked.connect(self.dispSite)
self.show()
def dispSite(self):
lng = float(self.ui.lineEditLongitude.text())
lat = float(self.ui.lineEditLatitude.text())
URL="https://www.google.com/maps/@"+self.ui.
lineEditLatitude.text()+","
+self.ui.lineEditLongitude.text()+",9z"
self.ui.widget.load(QUrl(URL))
if __name__=="__main__":
app = QApplication(sys.argv)
w = MyForm()
w.show()
sys.exit(app.exec_())
工作原理…
在脚本中,您可以看到具有 objectName 属性pushButtonShowMap
的按钮的点击事件与dispSite()
方法相连。这意味着,每当点击按钮时,将调用dispSite()
方法。在dispSite()
方法中,您通过两个 Line Edit 小部件访问用户输入的经度和纬度,并分别将它们分配给两个变量lng
和lat
。然后,您创建一个 URL,从google.com调用 Google 地图,并传递用户输入的纬度和经度值。
URL 最初是以文本形式存在的,并且被强制转换为QUrl
实例,并传递给被提升为QWebEngineView
以显示网站的小部件。QUrl
是 Qt 中提供多种方法和属性来管理 URL 的类。然后,通过QWebEngineView
小部件显示具有指定纬度和经度值的 Google 地图。
运行应用程序时,您将被提示输入您想在 Google 地图上查看的位置的经度和纬度值。输入经度和纬度值后,当您点击“显示地图”按钮时,Google 地图将显示该位置,如下面的屏幕截图所示: