Skip to content

Latest commit

 

History

History
786 lines (534 loc) · 41 KB

File metadata and controls

786 lines (534 loc) · 41 KB

六、使用菜单和 Tkinter 对话框创建菜单

随着应用的增长,组织对其功能的访问变得越来越重要。传统上,应用通过菜单系统来解决这一问题,该系统通常位于应用窗口顶部或(在某些平台上)全局桌面菜单中。虽然这些菜单是特定于应用的,但已经制定了某些组织约定,我们应该遵循这些约定,以使我们的软件具有用户友好性。

在本章中,我们将介绍以下主题:

  • 分析一些报告的问题并决定解决方案
  • 探索 Tkinter 的一些对话类,并使用它们实现常见的菜单功能
  • 学习如何使用 Tkinter 的菜单小部件,并使用它为我们的应用创建菜单
  • 为应用创建一些选项并将其保存到磁盘

解决应用中的问题

你的老板给你带来了第一批需要在你的申请中解决的问题。首先,如果直到第二天才能输入当天的最后一份报告,则文件名中的硬编码日期字符串是一个问题。数据输入人员需要一种方法来手动选择要附加到哪个文件。

此外,数据输入人员对表单中的自动填充功能有着复杂的感觉。有些人觉得它非常有用,但其他人真的希望看到它被禁用。您需要一种允许用户打开和关闭此功能的方法。

最后,一些用户很难注意到底部的状态栏文本,并且希望应用在由于错误而无法保存数据时更加显眼。

决定如何解决这些问题

很明显,您需要实现一种方法来选择文件并切换表单的自动填充功能。首先,考虑将这两个控件添加到主应用中,并进行快速模拟:

不用多久,您就会意识到这不是一个伟大的设计,当然也不会适应增长。您的用户不希望在框中盲目地键入文件路径和文件名,也不希望有很多额外的字段扰乱 UI。

幸运的是,Tkinter 提供了一些工具来帮助我们解决这些问题:

  • 文件对话框:Tkinter 的filedialog库有助于简化文件选择
  • 错误对话框:Tkinter 的messagebox库将让我们更明显地显示错误消息
  • 主菜单:Tkinter 的Menu类可以帮助我们组织常用功能,方便访问

实现简单的 Tkinter 对话框

对于不应中断用户工作流程的偶然信息,状态栏是合适的,但对于阻止工作按预期继续的错误,应以更自信的方式提醒用户。一个错误对话框会暂停程序,直到用鼠标点击确认为止,这是一个相当自信的方法,似乎是解决用户看不到错误问题的好方法。为了实现这些,您需要了解 Tkinter 的messagebox库。

Tkinter 消息框

在 Tkinter 中显示简单对话框的最佳方式是使用tkinter.messagebox库,该库包含几个方便的函数,允许您快速创建常见对话框类型。每个函数都显示一个预设图标和一组按钮,其中包含您指定的消息和详细信息文本,并根据用户单击的按钮返回一个值。

下表显示了一些messagebox函数及其图标和返回值:

| 功能 | 图标 | 按钮/返回值 | | askokcancel | 问题 | 确定(True),取消(False) | | askretrycancel | 警告 | 重试(True),取消(False) | | askyesno | 问题 | 是(True),否(False) | | askyesnocancel | 问题 | 是(True),否(False),取消(None) | | showerror | 错误 | Ok(ok) | | showinfo | 信息 | Ok(ok) | | showwarning | 警告 | Ok(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 中,它会产生一个对话框(在其他平台上,它可能看起来有点不同),如以下屏幕截图所示:

Tkintermessagebox对话框是模式,这意味着在对话框打开时程序执行暂停,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!')

注意messagedetail在您的平台上的显示方式之间的差异。在一些平台上,没有区别;另一方面,message较大且粗体,适用于短文本。对于跨平台软件,最好使用detail进行扩展输出。

显示错误对话框

现在您已经了解了如何使用messagebox,错误对话框应该很容易实现。Application.on_save()方法已经在状态栏中显示错误;我们只需通过执行以下步骤,将此错误显示在错误消息框中:

  1. 首先,我们需要在application.py中导入它,如下所示:
from tkinter import messagebox
  1. 现在,在检查错误后的on_save()方法中,我们将为错误对话框设置消息。我们将通过将字段与"\n *"连接,来创建一个包含错误的字段的项目符号列表。不幸的是,messagebox不支持任何类型的标记,因此需要使用常规字符手动构建项目符号列表之类的结构,如下所示:
        message = "Cannot save record"
        detail = "The following fields have errors: \n  * {}".format(
            '\n  * '.join(errors.keys()))
  1. 现在,我们可以在调用status()之后调用showerror(),如下所示:
        messagebox.showerror(title='Error', message=message, detail=detail)
  1. 现在,打开程序并点击 Save;您将看到一个对话框,提醒您应用中的错误,如以下屏幕截图所示:

这一错误应该是任何人都很难错过的!

One shortcoming of the messagebox dialogs is that they don't scroll; a long error message will create a dialog that may fill (or extend beyond) the screen. If this is a potential problem, you'll want to create a custom dialog containing a scrollable widget.

设计我们的菜单

大多数应用将功能组织到分层的菜单系统,通常显示在应用或屏幕的顶部(取决于操作系统)。虽然此菜单的组织结构因操作系统而异,但某些项目在不同平台上相当常见

在这些常见项目中,我们的应用将需要以下内容:

  • 包含文件操作(如打开/保存/导出)的文件菜单,通常还有退出应用的选项。我们的用户需要此菜单来选择文件并退出程序。
  • 用户可以在其中配置应用的选项、首选项或设置菜单。我们需要使用此菜单进行切换设置;我们现在称之为选项。
  • “帮助”菜单,其中包含指向帮助文档的链接,或者至少包含一条关于消息,提供有关应用的基本信息。我们将为 about 对话框实现此菜单。

苹果、微软和 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参数。

Some platforms, such as macOS, don't allow a command in the top-level menu.

让我们尝试创建一个子菜单,如下所示:

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菜单中添加子菜单时,我们只需提供菜单和菜单本身的标签。

我们还可以将CheckbuttonRadiobutton小部件添加到菜单中。为了演示这一点,让我们创建另一个子菜单来更改标签的外观。

首先,我们需要以下设置代码:

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类中。让我们来看看下面的步骤:

  1. 让我们首先打开views.py并创建一个MainMenu类,该类将tkinter.Menu子类化:
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对象中的预期变量和可调用项填充这些字典。

  1. 现在,让我们开始创建子菜单,从文件菜单开始,如下所示:
        file_menu = tk.Menu(self, tearoff=False)
        file_menu.add_command(
            label="Select file…",
            command=callbacks['file->open'])

我们在文件菜单中的第一个命令是Select file...。注意标签中的省略号:这向用户表示该选项将打开另一个窗口,需要进一步输入。我们正在使用file->open键将command设置为callbacks字典中的引用。这个函数还不存在;我们很快就会实施它。让我们添加下一个文件菜单命令file->quit

        file_menu.add_separator()
        file_menu.add_command(label="Quit",
                command=callbacks['file->quit'])

我们再次将此命令指向callbacks字典中尚未定义的函数。我们还添加了一个分隔符;由于退出程序与选择目标文件是一种根本不同的操作,因此将它们分开是有意义的,您将在大多数应用菜单中看到这一点。

  1. 这就完成了文件菜单,所以我们需要将其添加到主menu对象中,如下所示:
        self.add_cascade(label='File', menu=file_menu)
  1. 我们需要创建的下一个子菜单是options菜单。由于我们只有两个菜单选项,我们将直接将它们添加到子菜单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变量填充settingsautofill dateautofill sheet data

  1. 最后,我们将创建一个help菜单,具有显示About对话框的选项:
        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)

我们的About命令指向一个名为show_about的内部MainMenu方法,我们将在下一步实现该方法。About对话框将是纯 UI 代码,其中没有实际的应用功能,因此我们可以在视图中完全实现它。

显示关于对话框

我们已经看到了如何使用messagebox创建错误对话框。现在,我们可以通过执行以下步骤来应用这些知识创建我们的About框:

  1. __init__()之后开始新的方法定义:
    def show_about(self):
        """Show the about dialog"""
  1. 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 看来,这就像一组括号中的一个长字符串。与其他方法(如三重引号)不同,这允许您保持清晰的缩进并显式控制新行。

  1. 最后,我们需要显示我们的About框,如下所示:
        messagebox.showinfo(title='About', message=about_message,  
            detail=about_detail)

在前面的代码中,showinfo()函数显然是最合适的,因为我们实际上是在显示信息。这就完成了我们的show_about()方法和MainMenu课程。接下来,我们需要对Application进行必要的修改以使其正常工作。

在控制器中添加菜单功能

现在我们的菜单类已经定义,我们的Application对象需要创建一个实例并将其添加到主窗口中。在此之前,我们需要定义MainMenu类需要的一些东西。

记住上一节中的以下内容:

  • 我们需要一个settings字典,其中包含两个设置选项的 Tkinter 变量
  • 我们需要一个指向file->selectfile->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:此参数是一个元组列表,每个元组都有一个标签和匹配模式,用于创建文件名条目下常见的过滤器下拉类型的文件。这用于将可见文件筛选为仅受应用支持的文件。

asksaveasfileasksaveasfilename方法采用以下两个附加选项:

  • initialfile:此选项是要选择的默认文件路径
  • defaultextension:此选项是一个文件扩展名字符串,如果用户不这样做,它将自动附加到文件名中

最后,返回文件对象的方法采用指定文件打开模式的mode参数;这些字符串与 Python 的open内置函数使用的一个或两个字符串相同。

我们需要在应用中使用哪个对话框?让我们考虑我们的需求:

  • 我们需要一个对话框,允许我们选择一个现有的文件
  • 我们还需要能够创建一个新文件
  • 因为打开文件是模型的责任,所以我们只想获得一个文件名来传递给模型

这些要求明确指向asksaveasfilename功能。让我们来看看下面的步骤:

  1. 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。不知何故,我们必须找到我们模型的路径。

  1. 目前,文件名是在Application对象的on_save方法中生成的,并传递到模型中。我们需要将filename移动到Application对象的一个属性,这样我们就可以从on_file_select()方法中重写它。
  2. 回到__init__()方法中,在settingscallbacks定义之前添加以下代码行:
        self.filename = tk.StringVar()
  1. 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)
  1. 定义了默认文件名后,我们可以将其作为StringVar的默认值提供。每当用户选择文件名时,on_file_select()将更新该值。这是通过on_file_select()末尾的以下行完成的:
    if filename:
        self.filename.set(filename)
  1. if语句的原因是,我们只想在用户实际选择文件时设置一个值。请记住,如果用户取消操作,文件对话框将返回None;在这种情况下,用户希望当前设置的文件名仍然是目标。
  2. 最后,我们需要让我们的on_save()方法在设置时使用这个值,而不是硬编码的默认值。
  3. on_save()方法下,定位定义filename的行,并将其更改为以下行:
    filename = self.filename.get()
  1. 这就完成了代码更改,使文件名选择生效。此时,您应该能够运行应用并测试文件选择功能。保存一些记录,并注意它们确实保存到您选择的文件中。

让我们的设置工作

文件保存工作时,设置不起作用。settings菜单项应按预期工作,保持选中或未选中状态,但它们尚未更改数据输入表单的行为。让我们来做这件事。

回想一下,这两个自动填充功能都是在DataRecordForm类的reset()方法中实现的。要使用我们的新设置,我们需要通过执行以下步骤为表单提供对settings字典的访问权限:

  1. 打开views.py并更新DataRecordForm.__init__()方法如下:
    def __init__(self, parent, fields, settings, *args, **kwargs):
        super().__init__(parent, *args, **kwargs)
        self.settings = settings
  1. 我们添加了一个额外的位置参数settings,然后将其设置为self.settings,这样类中的所有方法都可以访问它。现在,看看reset()方法;当前,日期自动填充代码如下所示:
        current_date = datetime.today().strftime('%Y-%m-%d')
        self.inputs['Date'].set(current_date)
        self.inputs['Time'].input.focus()
  1. 我们只需要确保只有当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)
       ...
  1. 为了使设置有效,我们只需要在if语句中添加另一个条件:
    if (self.settings['autofill sheet data'].get() and
        plot not in ('', plot_values[-1])):
        ...

最后一个难题是确保我们在创建settings词典时将其发送给DataRecordForm

  1. 回到Application代码中,将我们的呼叫更新为DataRecordForm()以包括self.settings,如下所示:
        self.recordform = v.DataRecordForm(self, 
            m.CSVModel.fields, self.settings)
  1. 现在,如果你运行这个程序,你应该会发现设置是受尊重的;尝试检查和取消选中它们,看看保存记录后会发生什么。

持久化设置

我们的设置可以工作,但有一个主要的烦恼:它们在会话之间无法持久。关闭应用并再次启动,您将看到设置已恢复为默认设置。这不是一个大问题,但这是一个我们不应该留给用户的粗糙边缘。

Python 为我们提供了多种将数据持久化到文件中的方法。我们已经体验过 CSV,它是为表格数据设计的;在设计其他格式时考虑到了不同的功能。

下表仅显示了用于存储 Python 标准库中可用数据的几个选项:

| 图书馆 | 数据类型 | 合适 | 利益 | 缺点 | | pickle | 二进制的 | 任何物体 | 快速、简单、小文件 | 不安全,文件不可读,必须读取整个文件 | | configparser | 文本 | key->value双 | 快速、简单、易读的文件 | 无法处理序列或复杂对象,继承权有限 | | json | 文本 | 简单值和序列 | 广泛使用、简单易读的文件 | 未经修改无法序列化复杂对象 | | xml | 文本 | 任何类型的 Python 对象 | 功能强大、灵活、大多数为人类可读的文件 | 不安全,使用复杂,文件语法冗长 | | sqlite | 二进制的 | 关系数据 | 快速而强大的文件 | 需要 SQL 知识,必须将对象转换为表 |

如果这还不够的话,第三方库中还有更多的选项。它们中几乎任何一个都适合存储两个布尔值,那么我们如何选择呢?

  • SQL 和 XML 功能强大,但对于我们这里的简单需求来说太复杂了。
  • 我们希望坚持使用文本格式,以防我们需要调试损坏的设置文件,因此pickle已过时。
  • configparser现在可以工作了,但它无法处理列表、元组和字典的能力在未来可能会受到限制。
  • 这就剩下了json,这是一个不错的选择。虽然它不能处理所有类型的 Python 对象,但它可以处理字符串、数字和布尔值,以及列表和字典。这应该可以满足我们的配置需求。

我们说图书馆“不安全”是什么意思?一些数据格式的设计具有强大的功能,如可扩展性、链接或别名,解析器库必须实现这些功能。不幸的是,这些功能可能被恶意利用。例如,billion laughs 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()希望找到一个 JSON 文件,其中包含与variables字典格式相同的字典。它需要从文件中加载该数据,并从文件副本中替换自己的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 数据拉入一个局部变量;然后,我们将更新variables,只询问raw_values中存在于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项,我们只需要忽略它。

既然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")

为什么要提出例外?如果测试失败,它只能意味着调用代码中有一个 bug。除了一个例外,我们将立即知道调用代码是否向我们的模型发送了错误的请求。如果没有它,请求将无声地失败,留下一个难以发现的 bug。

对于初学者来说,故意提出一个例外的想法似乎很奇怪;毕竟,例外是我们试图避免的,对吗?在小脚本的情况下也是如此,我们主要是现有模块的用户;但是,在编写自己的模块时,异常是模块与使用它的代码沟通问题的正确方式。试图处理或更糟的是,通过外部调用代码来消除不良行为,充其量只能破坏模块化;在最坏的情况下,它会产生难以追踪的细微缺陷。

在我们的应用中使用设置模型

我们的应用需要在启动时加载设置,然后在更改时自动保存。目前,应用的settings字典是手动创建的,但是我们的模型应该告诉它要创建什么样的变量。让我们执行以下步骤来在我们的应用中使用settings模型:

  1. 将定义Application.settings的代码替换为以下代码:
        self.settings_model = m.SettingsModel()
        self.load_settings()

首先,我们创建一个settings模型并将其保存到Application对象。然后,我们将运行一个load_settings()方法。此方法将负责基于settings_model建立Application.settings字典。

  1. 现在,让我们创建Application.load_settings()
    def load_settings(self):
        """Load settings into our self.settings dict."""
  1. 我们的模型存储每个变量的类型和值,但我们的应用需要 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'])
  1. 这里使用 Tkinter 变量的主要原因是,我们可以通过 UI 跟踪用户对值所做的任何更改,并立即做出响应。具体而言,我们希望在用户进行以下更改时保存设置:
        for var in self.settings.values():
            var.trace('w', self.save_settings)
  1. 当然,这意味着我们需要编写一个名为Application.save_settings()的方法,该方法将在值发生更改时运行。Application.load_settings()已完成,接下来我们将执行该操作:
    def save_settings(self, *args):
        """Save the current settings to a preferences file"""
  1. 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()方法。

  1. 现在,您应该能够运行该程序并观察设置是否已保存,即使在关闭并重新打开应用时也是如此。您还会在主目录中找到一个名为abq_settings.json的文件。

总结

在本章中,我们的简单表单朝着完全成熟的应用迈进了一大步。我们实现了一个主菜单、在执行之间保持的选项设置和一个About对话框。我们添加了选择保存记录的文件的功能,并通过错误对话框提高了表单错误的可见性。在此过程中,您了解了 Tkinter 菜单、文件对话框和消息框,以及在标准库中保存数据的各种选项。

在下一章中,我们将被要求对程序进行读写。我们将学习 Tkinter 的树小部件,如何在主视图之间切换,以及如何使CSVModelDataRecordForm类能够读取和更新现有数据。