Python-自动化指南-繁琐工作自动化-第三版-十二-
Python 自动化指南(繁琐工作自动化)第三版(十二)
原文:
automatetheboringstuff.com/译者:飞龙
19 保持时间,调度任务,和启动程序

当你坐在电脑前运行程序时是不错的,但让程序在没有你直接监督的情况下运行也是有用的。你的电脑时钟可以安排程序在指定的时间、日期或定期运行代码。例如,你的程序可以每小时抓取一个网站以检查更改,或者在你睡觉时在凌晨 4 点执行 CPU 密集型任务。Python 的 time 和 datetime 模块提供了这些功能。
你也可以使用 subprocess 模块编写按计划启动其他程序的程序。通常,编程最快的方法是利用其他人已经编写的应用程序。
time 模块
你的电脑系统时钟设置为特定的日期、时间和时区。内置的 time 模块允许你的 Python 程序读取系统时钟以获取当前时间。其中最有用的函数是 time.time(),它返回一个称为纪元时间戳的值,以及 time.sleep(),它暂停程序。
返回 Epoch 时间戳
Unix 纪元是编程中常用的一个时间参考:1970 年 1 月 1 日午夜,协调世界时(UTC)。time.time() 函数返回自那一刻以来的秒数,作为浮点值。 (回想一下,浮点数只是一个带小数点的数字。)这个数字被称为纪元时间戳。例如,在交互式外壳中输入以下内容:
>>> import time
>>> time.time()
1773813875.3518236
这里,我在 2026 年 3 月 17 日晚上 11:04 太平洋标准时间调用了 time.time()。返回值是自 Unix 纪元以来经过的秒数,直到 time.time() 被调用。
time.time() 的返回值很有用,但不是人类可读的。time.ctime() 函数返回当前时间的字符串描述。你也可以选择性地传递自 Unix 纪元以来经过的秒数(由 time.time() 返回),以获取该时间的字符串值。在交互式外壳中输入以下内容:
>>> import time
>>> time.ctime()
'Tue Mar 17 11:05:38 2026'
>>> this_moment = time.time()
>>> time.ctime(this_moment)
'Tue Mar 17 11:05:45 2026'
Epoch 时间戳可以用来分析代码:也就是说,测量一段代码运行所需的时间。如果你在想要测量的代码块开始时调用 time.time(),然后在结束时再次调用,你可以从第二个时间戳中减去第一个时间戳来找到这两个调用之间的经过时间。例如,打开一个新的文件编辑标签并输入以下程序:
# Measure how long it takes to multiply 100,000 numbers.
import time
def calculate_product(): # ❶
# Calculate the product of the first 100,000 numbers.
product = 1
for i in range(1, 100001):
product = product * i
return product
start_time = time.time() # ❷
result = calculate_product()
end_time = time.time() # ❸
print(f'It took {end_time – start_time} seconds to calculate.') # ❹
在❶处,我们定义了一个函数 calculate_product() 来遍历从 1 到 100,000 的整数并返回它们的乘积。在❷处,我们调用 time.time() 并将其存储在 start_time 中。在调用 calculate_product() 后立即再次调用 time.time() 并将其存储在 end_time ❸ 中。最后,我们打印出运行 calculate_product() 所花费的时间 ❹。
将此程序保存为 calcProd.py 并运行它。输出将类似于以下内容:
It took 2.844162940979004 seconds to calculate.
另一种分析你代码的方法是使用 cProfile.run() 函数,它提供的详细信息比简单的 time.time() 技术要丰富得多。你可以在我的另一本书《Python 基础之外》的第十三章中了解 cProfile.run() 函数(No Starch Press,2020 年)。
暂停程序
如果你需要暂停你的程序一段时间,调用 time.sleep() 函数并传递你希望程序保持暂停的秒数。例如,在交互式外壳中输入以下内容:
>>> import time
>>> for i in range(3):
... print('Tick') # ❶
... print('Tick') # ❶
... print('Tick') # ❶
... print('Tick') # ❶
...
Tick
Tock
Tick
Tock
Tick
Tock
>>> time.sleep(5) # ❺
>>>
for 循环将打印 Tick ❶,暂停一秒钟 ❷,打印 Tock ❸,暂停一秒钟 ❹,然后打印 Tick,暂停,以此类推,直到 Tick 和 Tock 各自打印了三次。
time.sleep() 函数将会 阻塞(也就是说,它不会返回或释放你的程序去执行其他代码),直到你传递给 time.sleep() 的秒数过去之后。例如,如果你输入 time.sleep(5) ❺,你会看到下一个提示符 (>>>) 不会出现,直到五秒钟过去。
项目 14:超级秒表
假设你想跟踪你在尚未自动化的无聊任务上花费的时间。你没有物理秒表,而且很难找到一款没有广告且不会将你的浏览器历史记录发送给营销人员的免费秒表应用程序。 (它在你同意的许可协议中声称可以这样做。你真的阅读了许可协议,对吧?) 你可以用 Python 自己编写一个简单的秒表程序。
从高层次来看,你的程序将执行以下操作:
-
通过调用
time.time()获取当前时间,并在程序开始时以及每个圈开始时将其存储为时间戳。 -
保持一个圈数计数器,并在用户每次按下 ENTER 时增加它。
-
通过减去时间戳来计算经过的时间。
-
处理
KeyboardInterrupt异常,以便用户可以按 CTRL-C 退出。
打开一个新的文件编辑标签,并将其保存为 stopwatch.py。
第 1 步:设置程序以跟踪时间
秒表程序需要使用当前时间,所以你想要导入 time 模块。你的程序也应该在调用 input() 之前打印一些简短的说明给用户,这样计时器就可以在用户按下 ENTER 后开始。然后,代码将在用户每次按下 ENTER 时开始跟踪圈数时间,直到他们按 CTRL-C 退出。
将以下代码输入到文件编辑器中,用 TODO 注释作为其余代码的占位符:
# A simple stopwatch program
import time
# Display the program's instructions.
print('Press ENTER to begin and to mark laps. Ctrl-C quits.')
input() # Press Enter to begin.
print('Started.')
start_time = time.time() # Get the first lap's start time.
last_time = start_time
lap_number = 1
# TODO: Start tracking the lap times.
现在你已经编写了显示说明的代码,开始第一圈,记录时间,并将 lap_number 设置为 1。
第 2 步:跟踪和打印圈数时间
现在让我们编写代码来开始每一圈,计算上一圈耗时,以及计算自开始计时器以来的总耗时。我们将显示圈时和总时,并为每一圈增加圈数。将以下代码添加到你的程序中:
# A simple stopwatch program
import time
# --snip--
# Start tracking the lap times.
try: # ❶
print('Tick') # ❶
input()
print('Tick') # ❶
print('Tick') # ❶
print('Tick') # ❶
lap_number += 1
last_time = time.time() # Reset the last lap time.
except KeyboardInterrupt: # ❻
# Handle the Ctrl-C exception to keep its error message from displaying.
print('\nDone.')
如果用户按下 CTRL-C 来停止计时器,将引发KeyboardInterrupt异常,程序将崩溃。为了防止崩溃,我们将程序的这一部分用try语句包裹❶。我们将在except子句中处理这个异常,当异常被引发时,打印Done而不是显示KeyboardInterrupt错误信息。直到这种情况发生,执行将在一个无限循环❷中进行,该循环调用input()并等待用户按下 ENTER 键以结束一圈。当一圈结束时,我们通过从当前时间time.time()减去圈的开始时间last_time来计算圈时。我们通过从总的开始时间start_time减去当前时间来计算总耗时❹。
由于这些时间计算的结果将在小数点后有多个数字(例如4.766272783279419),我们在❸和❹处使用round()函数将浮点值四舍五入到两位数字。
在❺处,我们打印圈数、总耗时和圈时。由于用户按下 ENTER 键对input()调用会打印一个换行符到屏幕上,所以将end=''传递给print()函数以避免输出双空格。打印完圈信息后,我们准备好下一圈,通过将计数lap_number加 1 并将last_time设置为当前时间(下一圈的开始时间)来准备下一圈。
相似程序的想法
时间跟踪为你的程序打开了多种可能性。虽然你可以下载应用程序来做这些事情中的一些,但自己编写程序的好处是它们将是免费的,并且不会因为广告和无用功能而臃肿。你可以编写类似的程序来完成以下任务:
-
创建一个简单的考勤应用,记录你输入某人姓名的时间,并使用当前时间来记录上下班时间。
-
向你的程序添加一个功能,显示自进程开始以来的经过时间,例如使用
requests模块的下载。(见第十三章。) -
间歇性地检查程序运行了多长时间,并给用户提供取消耗时任务的机会。
日期时间模块
time模块对于获取 Unix 纪元时间戳很有用。但如果你想要以更方便的格式显示日期,或者对日期进行算术运算(例如,找出 205 天前的日期或从现在起 123 天的日期),你应该使用datetime模块。
datetime模块有自己的datetime数据类型。datetime值代表特定的时间点。在交互式 shell 中输入以下内容:
>>> import datetime
>>> datetime.datetime.now() # ❶
datetime.datetime(2026, 2, 27, 11, 10, 49, 727297) # ❷
>>> dt = datetime.datetime(2026, 10, 21, 16, 29, 0) # ❸
>>> dt.year, dt.month, dt.day # ❹
(2026, 10, 21)
>>> dt.hour, dt.minute, dt.second # ❺
(16, 29, 0)
调用 datetime.datetime.now() ❶ 返回一个 datetime 对象 ❷,表示根据您的计算机时钟的当前日期和时间。此对象包括当前时刻的年、月、日、时、分、秒和微秒。您还可以使用 datetime.datetime() 函数 ❸ 获取特定时刻的 datetime 对象,传递表示您想要时刻的年、月、日、时和秒的整数。这些整数将存储在 datetime 对象的 year、month、day ❹、hour、minute 和 second ❺ 属性中。
可以使用 datetime.datetime.fromtimestamp() 函数将 Unix 纪元时间戳转换为 datetime 对象。datetime 对象的日期和时间将转换为本地时区。在交互式外壳中输入以下内容:
>>> import datetime, time
>>> datetime.datetime.fromtimestamp(1000000)
datetime.datetime(1970, 1, 12, 5, 46, 40)
>>> datetime.datetime.fromtimestamp(time.time())
datetime.datetime(2026, 10, 21, 16, 30, 0, 604980)
调用 datetime.datetime.fromtimestamp() 并传递 1000000 返回一个表示 Unix 纪元后 1,000,000 秒的时刻的 datetime 对象。传递 time.time(),当前时刻的 Unix 纪元时间戳,返回一个表示当前时刻的 datetime 对象。因此,表达式 datetime.datetime.now() 和 datetime.datetime.fromtimestamp(time.time()) 做的是同一件事;它们都为您提供当前时刻的 datetime 对象。
您可以使用比较运算符比较 datetime 对象,以找出哪个对象在前。较晚的 datetime 对象是“更大的”值。在交互式外壳中输入以下内容:
>>> import datetime
>>> halloween_2026 = datetime.datetime(2026, 10, 31, 0, 0, 0) # ❶
>>> new_years_2027 = datetime.datetime(2027, 1, 1, 0, 0, 0) # ❷
>>> oct_31_2026 = datetime.datetime(2026, 10, 31, 0, 0, 0)
>>> halloween_2026 == oct_31_2026 # ❸
True
>>> halloween_2026 > new_years_2027 # ❹
False
>>> new_years_2027 > halloween_2026 # ❺
True
>>> new_years_2027 != oct_31_2026
True
此代码创建了一个表示 2026 年 10 月 31 日午夜时刻的 datetime 对象,并将其存储在 halloween_2026 ❶ 中。然后,它创建了一个表示 2027 年 1 月 1 日午夜时刻的 datetime 对象,并将其存储在 new_years_2027 ❷ 中。它还创建了一个表示 2026 年 10 月 31 日午夜时刻的另一个对象,并将其存储在 oct_31_2026 中。比较 halloween_2026 和 oct_31_2026 显示它们是相等的 ❸。比较 new_years_2027 和 halloween_2026 显示 new_years_2027 比 halloween_2026 大(晚)❹ ❺。
表示持续时间
datetime 模块还提供了一个 timedelta 数据类型,它表示时间的持续时间而不是时间点。在交互式外壳中输入以下内容:
>>> import datetime
>>> delta = datetime.timedelta(days=11, hours=10, minutes=9, seconds=8) # ❶
>>> delta.days, delta.seconds, delta.microseconds # ❷
(11, 36548, 0)
>>> delta.total_seconds()
986948.0
>>> str(delta)
'11 days, 10:09:08'
要创建一个 timedelta 对象,请使用 datetime.timedelta() 函数。datetime.timedelta() 函数接受关键字参数 weeks、days、hours、minutes、seconds、milliseconds 和 microseconds。没有 month 或 year 关键字参数,因为“一个月”或“一年”是可变的时间长度,取决于特定的月份或年份。timedelta 对象具有表示总持续时间的天数、秒和微秒。这些数字分别存储在 days、seconds 和 microseconds 属性中。total_seconds() 方法将返回仅以秒数表示的持续时间。将 timedelta 对象传递给 str() 将返回一个格式良好、可读的字符串表示形式。
在此示例中,我们向 datetime.delta() 传递关键字参数以指定 11 天、10 小时、9 分钟和 8 秒的持续时间,并将返回的 timedelta 对象存储在 delta 中 ❶。此 timedelta 对象的 days 属性存储 11,其 seconds 属性存储 36548(10 小时、9 分钟和 8 秒,以秒为单位)❷。调用 total_seconds() 告诉我们 11 天、10 小时、9 分钟和 8 秒是 986,948 秒。最后,将 timedelta 对象传递给 str() 返回一个清楚地描述持续时间的字符串。
可以使用算术运算符对 datetime 值执行 日期算术。例如,要计算从现在起 1,000 天的日期,请在交互式外壳中输入以下内容:
>>> import datetime
>>> now = datetime.datetime.now()
>>> now
datetime.datetime(2026, 12, 2, 18, 38, 50, 636181)
>>> thousand_days = datetime.timedelta(days=1000)
>>> now + thousand_days
datetime.datetime(2029, 8, 28, 18, 38, 50, 636181)
首先,创建一个表示当前时刻的 datetime 对象并将其存储在 now 中。然后,创建一个表示 1,000 天持续时间的 timedelta 对象并将其存储在 thousand_days 中。将 now 和 thousand_days 相加以获取从 now 中的日期和时间开始的 1,000 天的 datetime 对象。Python 将执行日期算术,以确定 2026 年 12 月 2 日之后的 1,000 天将是 2029 年 8 月 28 日。在从给定日期计算 1,000 天时,您必须记住每个月有多少天,并考虑闰年和其他复杂细节。datetime 模块为您处理所有这些。
您可以使用 + 和 - 运算符将 timedelta 对象与 datetime 对象或其他 timedelta 对象相加或相减。timedelta 对象可以用 * 和 / 运算符乘以或除以整数或浮点值。在交互式外壳中输入以下内容:
>>> import datetime
>>> oct_21st = datetime.datetime(2026, 10, 21, 16, 29, 0) # ❶
>>> about_thirty_years = datetime.timedelta(days=365 * 30) # ❷
>>> oct_21st
datetime.datetime(2026, 10, 21, 16, 29)
>>> oct_21st – about_thirty_years
datetime.datetime(1996, 10, 28, 16, 29)
>>> oct_21st - (2 * about_thirty_years)
datetime.datetime(1966, 11, 5, 16, 29)
在这里,我们为 2026 年 10 月 21 日创建一个 datetime 对象 ❶,并为大约 30 年的持续时间创建一个 timedelta 对象 ❷。(我们为这些年份中的每一个使用 365 天,并忽略闰年。)从 oct_21st 减去 about_thirty_years 给我们 2026 年 10 月 21 日之前的 30 年的日期的 datetime 对象。从 oct_21st 减去 2 * about_thirty_years 返回大约 60 年前的日期的 datetime 对象:1966 年 11 月 5 日下午晚些时候。
暂停直到特定日期
time.sleep() 方法可以让程序暂停一段时间。通过使用 while 循环,您可以暂停程序直到特定日期。例如,以下代码将一直循环,直到 2039 年万圣节:
import datetime
import time
halloween_2039 = datetime.datetime(2039, 10, 31, 0, 0, 0)
while datetime.datetime.now() < halloween_2039:
time.sleep(1) # Wait 1 second before looping to check again.
time.sleep(1) 调用将暂停您的 Python 程序,这样计算机就不会通过尽可能快地检查时间来浪费 CPU 处理周期。相反,while 循环将每秒检查一次条件,并在 2039 年万圣节之后(或您编程它停止的任何时候)继续执行程序的其余部分。
将 datetime 对象转换为字符串
Epoch 时间戳和 datetime 对象对人类眼睛来说不是很友好。使用 strftime() 方法将 datetime 对象显示为字符串。(strftime() 函数名称中的 f 代表 format。)
strftime() 方法使用与 Python 字符串格式化类似的指令。表 19-1 列出了 strftime() 指令的完整列表。您还可以参考有关此信息的有帮助的网站 strftime.org。
表 19-1:strftime() 指令
| strftime() 指令 | 含义 |
|---|---|
%Y |
带世纪的年份,例如 '2026' |
%y |
不带世纪的年份,'00' 到 '99'(1970 到 2069) |
%m |
月份的十进制数字,'01' 到 '12' |
%B |
完整的月份名称,例如 'November' |
%b |
缩写的月份名称,例如 'Nov' |
%d |
月份中的日,'01' 到 '31' |
%j |
年中的日,'001' 到 '366' |
%w |
星期中的日,'0'(星期日)到 '6'(星期六) |
%A |
完整的星期名称,例如 'Monday' |
%a |
缩写的星期名称,例如 'Mon' |
%H |
24 小时制的小时,'00' 到 '23' |
%I |
12 小时制的小时,'01' 到 '12' |
%M |
分钟,'00' 到 '59' |
%S |
秒,'00' 到 '59' |
%p |
'AM' 或 'PM' |
%% |
文字 '%' 字符 |
将包含格式化指令(以及任何所需的斜杠、冒号等)的自定义格式字符串传递给 strftime(),strftime() 将返回 datetime 对象的信息作为格式化字符串。将以下内容输入到交互式壳中:
>>> oct_21st = datetime.datetime(2026, 10, 21, 16, 29, 0)
>>> oct_21st.strftime('%Y/%m/%d %H:%M:%S')
'2026/10/21 16:29:00'
>>> oct_21st.strftime('%I:%M %p')
'04:29 PM'
>>> oct_21st.strftime("%B of '%y")
"October of '26"
在这里,我们有一个 datetime 对象,表示 2026 年 10 月 21 日下午 4:29,存储在 oct_21st 中。将自定义格式字符串 '%Y/%m/%d %H:%M:%S' 传递给 strftime() 返回一个包含 2026、10 和 21,由斜杠分隔的字符串,以及由冒号分隔的 16、29 和 00。传递 '%I:%M %p' 返回 '04:29 PM',传递 "%B of '%y" 返回 "October of '26"。
将字符串转换为 datetime 对象
如果您有一个包含日期信息的字符串,例如 '2026/10/21 16:29:00' 或 'October 21, 2026',并且您需要将其转换为 datetime 对象,请使用 datetime.datetime.strptime() 函数。strptime() 函数是 strftime() 方法的逆操作,您必须传递一个使用与 strftime() 相同指令的自定义格式字符串,以便函数知道如何解析和理解该字符串。(strptime() 函数名称中的 p 代表 parse。)
将以下内容输入到交互式壳中:
>>> datetime.datetime.strptime('October 21, 2026', '%B %d, %Y') # ❶
datetime.datetime(2026, 10, 21, 0, 0)
>>> datetime.datetime.strptime('2026/10/21 16:29:00', '%Y/%m/%d %H:%M:%S')
datetime.datetime(2026, 10, 21, 16, 29)
>>> datetime.datetime.strptime("October of '26", "%B of '%y")
datetime.datetime(2026, 10, 1, 0, 0)
>>> datetime.datetime.strptime("November of '63", "%B of '%y")
datetime.datetime(2063, 11, 1, 0, 0) # ❷
>>> datetime.datetime.strptime("November of '73", "%B of '%y")
datetime.datetime(1973, 11, 1, 0, 0) # ❸
要从字符串 'October 21, 2026' 获取一个 datetime 对象,将此字符串作为 strptime() 的第一个参数传递,并将对应于 'October 21, 2026' 的自定义格式字符串作为第二个参数 ❶。包含日期信息的字符串必须与自定义格式字符串完全匹配,否则 Python 将引发 ValueError 异常。注意,"November of '63" 被解释为 2063 ❷,而 "November of '73" 被解释为 1973 3,因为 %y 指令的范围是从 1970 到 2069。
从 Python 启动其他程序
你的 Python 程序可以使用内置的subprocess模块中的run()函数在你的计算机上启动其他程序。如果你打开了应用程序的多个实例,每个实例都是同一程序的独立进程。例如,图 19-1 中显示的计算器应用程序的每个打开窗口都是一个不同的进程。

图 19-1:同一计算器程序的六个运行进程
如果你想在 Python 脚本中启动外部程序,将程序的文件名传递给subprocess.run()。(在 Windows 上,右键单击应用程序的开始菜单项并选择属性以查看应用程序的文件名。在 macOS 上,按住 CTRL 键单击应用程序并选择显示包内容以找到可执行文件的路径。)run()函数将阻塞,直到启动的程序关闭。将启动的程序作为列表中的字符串传递给程序的可执行文件路径,记住启动的程序将在单独的进程中运行,而不是与你的 Python 程序在同一个进程中。
在 Windows 计算机上,在交互式 shell 中输入以下内容:
>>> import subprocess
>>> subprocess.run(['C:\\Windows\\System32\\calc.exe'])
CompletedProcess(args=['C:\\Windows\\System32\\calc.exe'], returncode=0)
在 Ubuntu Linux 上,输入以下内容:
>>> import subprocess
>>> subprocess.run(['/usr/bin/gnome-calculator'])
CompletedProcess(args=['/usr/bin/gnome-calculator'], returncode=0)
在 macOS 上,输入以下内容:
>>> import subprocess
>>> subprocess.run(['open', '/System/Applications/Calculator.app'])
CompletedProcess(args=['open', '/System/Applications/Calculator.app'], returncode=0)
注意,macOS 要求你运行open程序,并传递你想要启动的程序命令行参数。
在这些示例中,我们的 Python 代码启动了程序,等待程序关闭,然后继续执行。如果你想让你的 Python 代码启动一个程序然后立即继续,而不等待程序关闭,请调用subprocess.Popen()(“进程打开”)函数:
>>> import subprocess
>>> calc_proc = subprocess.Popen(['C:\\Windows\\System32\\calc.exe'])
返回值是一个Popen对象,它有两个有用的方法:poll()和wait()。
你可以将poll()方法想象成不断地问你的司机“我们到了吗?”直到你到达。如果poll()被调用时进程仍在运行,poll()方法将返回None。如果程序已经终止,它将返回进程的整数退出代码。退出代码表示进程是否在没有错误的情况下终止(由退出代码0表示)或者是否有错误导致进程终止(由非零退出代码表示——通常为1,但可能因程序而异)。
wait()方法就像等待司机到达你的目的地一样。该方法将阻塞,直到启动的进程终止。如果你想让程序暂停,直到用户完成与其他程序的交互,这很有帮助。wait()的返回值是进程的整数退出代码。
在 Windows 上,在交互式 shell 中输入以下内容。请注意,wait()调用可能会阻塞,直到你退出启动的计算器程序:
>>> import subprocess
>>> calc_proc = subprocess.Popen(['c:\\Windows\\System32\\calc.exe']) # ❶
>>> calc_proc.poll() == None # ❷
True
>>> calc_proc.wait() # Doesn't return until Calculator closes # ❸
0
>>> calc_proc.poll()
0
在这里,我们打开一个计算器进程 ❶。在 Windows 的旧版本中,如果进程仍在运行,poll() 返回 None ❷。然后,我们关闭计算器应用程序的窗口,并在交互式外壳中调用已终止进程的 wait() ❸。现在 wait() 和 poll() 返回 0,表示进程在没有错误的情况下终止。
如果你在 Windows 10 及更高版本上使用 subprocess.Popen() 运行 calc.exe,你会注意到 wait() 立即返回,尽管计算器应用程序仍在运行。这是因为 calc.exe 启动了计算器应用程序然后立即关闭了自己。Windows 中的计算器程序是一个“受信任的微软商店应用程序”,其具体细节超出了本书的范围。但可以简单地说,程序可以以许多特定于应用程序和特定于操作系统的多种方式运行。
如果你想要关闭使用 subprocess.Popen() 启动的进程,请调用函数返回的 Popen 对象的 kill() 方法。如果你在 Windows 上有 MS Paint,请在交互式外壳中输入以下内容:
>>> import subprocess
>>> paint_proc = subprocess.Popen('c:\\Windows\\System32\\mspaint.exe')
>>> paint_proc.kill()
注意,kill() 方法会立即终止程序并绕过任何“你确定要退出?”确认窗口。程序中的任何未保存的工作都将丢失。
向进程传递命令行参数
你可以向使用 run() 创建的进程传递命令行参数。为此,将列表作为 run() 的唯一参数传递。列表中的第一个字符串将是你要启动的程序的可执行文件名;所有后续字符串将是在程序启动时传递给程序的命令行参数。实际上,这个列表将是启动程序的 sys.argv 的值。
大多数具有图形用户界面(GUI)的应用程序不像基于命令行或终端的应用程序那样广泛使用命令行参数。但大多数 GUI 应用程序都会接受一个文件参数,当它们启动时会立即打开该文件。例如,如果你使用的是 Windows,创建一个名为 C:\Users\Al\hello.txt 的文本文件,然后进入交互式外壳中输入以下内容:
>>> subprocess.run(['C:\\Windows\\notepad.exe', 'C:\\Users\Al\\hello.txt'])
CompletedProcess(args=['C:\\Windows\\notepad.exe', 'C:\\Users\\Al\\hello.txt'], returncode=0)
这不仅将启动记事本应用程序,还会立即打开 C:\Users\Al\hello.txt 文件。每个程序都有自己的命令行参数集,并且一些程序(尤其是 GUI 应用程序)根本不使用命令行参数。
subprocess.Popen() 函数以类似的方式处理命令行参数,你应该将它们添加到你传递给函数的列表的末尾。
从启动的命令接收输出文本
你也可以使用 subprocess.run() 和 subprocess.Popen() 启动终端命令。你可能想让你的 Python 代码接收这些命令的文本输出或模拟对这些命令的键盘输入。例如,让我们从 Python 中启动 ping 命令并接收它产生的文本。(ping 命令的细节超出了本书的范围。)在 Windows 上,你将使用 -n 4 参数发送四个网络ping请求,以检查 Nostarch.com 服务器是否在线。如果你在 macOS 和 Linux 上,将 -n 替换为 -c。这个命令需要几秒钟才能运行:
>>> import subprocess
>>> proc = subprocess.run(['ping', '-n', '4', 'nostarch.com'], capture_output=True, text=True)
>>> print(proc.stdout)
Pinging nostarch.com [104.20.120.46] with 32 bytes of data:
Reply from 104.20.120.46: bytes=32 time=19ms TTL=59
Reply from 104.20.120.46: bytes=32 time=17ms TTL=59
Reply from 104.20.120.46: bytes=32 time=97ms TTL=59
Reply from 104.20.120.46: bytes=32 time=217ms TTL=59
Ping statistics for 104.20.120.46:
Packets: Sent = 4, Received = 4, Lost = 0 (0% loss),
Approximate round trip times in milli-seconds:
Minimum = 17ms, Maximum = 217ms, Average = 87ms
当你将 capture_output=True 和 text=True 参数传递给 subprocess.run() 时,启动程序的文本输出将作为字符串存储在返回的 CompletedProcess 对象的 stdout(“标准输出”)属性中。你的 Python 脚本可以使用其他程序的功能,然后解析文本输出作为字符串。
运行任务计划程序、launchd 和 cron
如果你熟悉计算机,你可能知道 Windows 上的任务计划程序、macOS 上的 launchd 或 Linux 上的 cron 调度程序。这些经过良好记录且可靠的工具都允许你安排应用程序在特定时间启动。
使用操作系统的内置调度程序可以让你免于编写自己的时钟检查代码来安排你的程序。然而,如果你只需要程序短暂暂停,最好改为循环到特定日期和时间,并在循环的每次迭代中调用 time.sleep(1)。
使用默认应用程序打开文件
在你的计算机上双击一个 .txt 文件将自动启动与 .txt 文件扩展名关联的应用程序。每个操作系统都有一个执行类似双击动作的程序。在 Windows 上,这是 start 命令。在 macOS 和 Linux 上,这是 open 命令。在交互式 shell 中输入以下内容,根据你的系统将 'start' 或 'open' 传递给 run(),在 Windows 上,你应该传递 shell=True 关键字参数,如下所示:
>>> file_obj = open('hello.txt', 'w') # Create a hello.txt file.
>>> file_obj.write('Hello, world!')
13
>>> file_obj.close()
>>> import subprocess
>>> subprocess.run(['`start`', 'hello.txt'], shell=True)
在这里,我们将 Hello, world! 写入一个新的 hello.txt 文件。然后,我们调用 run(),传递一个包含程序或命令名称(在本例中,Windows 的 'start')和文件名的列表。操作系统知道所有的文件关联,可以确定在 Windows 上应该启动,例如,Notepad.exe 来处理 hello.txt 文件。
项目 15:简单倒计时
正如很难找到一个简单的计时器应用程序一样,找到一个简单的倒计时应用程序也可能很难。让我们编写一个倒计时程序,在倒计时结束时播放闹钟。
从高层次来看,你的程序将执行以下操作:
-
在倒计时的每个数字之间暂停一秒钟,通过调用
time.sleep()。 -
调用
subprocess.run()使用默认应用程序打开 alarm.wav 声音文件。
打开一个新的文件编辑标签,并将其保存为 simplecountdown.py。
第 1 步:倒计时
此程序将需要time模块中的time.sleep()函数和subprocess模块中的subprocess.run()函数。输入以下代码,并将文件保存为*simplecountdown.py*:
# https://autbor.com/simplecountdown.py - A simple countdown script
import time, subprocess
time_left = 60 # ❶
while time_left > 0:
print('Tick') # ❶
print('Tick') # ❶
print('Tick') # ❶
# TODO: At the end of the countdown, play a sound file.
在导入time和subprocess后,创建一个名为time_left的变量来保存倒计时剩余的秒数 ❶。这里我们将其设置为60,但你可以将其更改为任何你想要的值,甚至可以根据命令行参数设置它。
在一个while循环中,显示剩余的计数 ❷,暂停一秒钟 ❸,然后在循环开始之前再次递减time_left变量 ❹。只要time_left大于0,循环就会继续。之后,倒计时将结束。接下来,让我们用代码填充TODO注释,以播放声音文件。
第 2 步:播放声音文件
虽然第十二章介绍了playsound3模块来播放各种格式的声音文件,但快速简单的方法是启动用户已经用来播放声音文件的任何应用程序。操作系统将根据.wav文件扩展名确定应该启动哪个应用程序来播放文件。这个.wav文件可以是其他声音文件格式,如.mp3或.ogg。你可以在倒计时结束时使用你电脑上的任何声音文件进行播放,或者从autbor.com/alarm.wav下载alarm.wav。
在你的代码中添加以下内容:
# https://autbor.com/simplecountdown.py - A simple countdown script
# --snip--
# At the end of the countdown, play a sound file.
#subprocess.run(['start', 'alarm.wav'], shell=True) # Windows
#subprocess.run(['open', 'alarm.wav']) # macOS and Linux
在while循环结束后,将播放alarm.wav(或你选择的声音文件)以通知用户倒计时结束。取消注释subprocess.run()调用以适应你的操作系统。在 Windows 上,确保在传递给run()的列表中包含'start'。在 macOS 和 Linux 上,传递'open'而不是'start',并移除shell=True。
除了播放声音文件,你还可以在某个地方保存一个包含类似Break time!的消息的文本文件,并在倒计时结束时使用subprocess.run()打开它。这将有效地创建一个带有消息的弹出窗口。或者,你可以在倒计时结束时使用webbrowser.open()函数打开特定的网站。与你在网上找到的一些免费倒计时应用程序不同,你自己的倒计时程序的闹钟可以是任何你想要的东西!
类似程序的思路
倒计时本质上在程序执行继续之前产生一个简单的延迟。你可以用同样的方法应用于其他应用程序和功能,例如以下内容:
-
使用
time.sleep()给用户一个机会按下 CTRL-C 来取消一个动作,例如删除文件。你的程序可以打印“按 CTRL-C 取消”的消息,然后使用try和except语句处理任何KeyboardInterrupt异常。 -
对于长期倒计时,你可以使用
timedelta对象来测量未来某个时间点(如生日?纪念日?)之前的天数、小时、分钟和秒数。
摘要
Unix 纪元(1970 年 1 月 1 日午夜,UTC)是许多编程语言的标准参考时间,包括 Python。虽然time.time()函数返回纪元时间戳(即自 Unix 纪元以来的秒数的浮点值),但datetime模块更适合执行日期算术、格式化或解析包含日期信息的字符串。
time.sleep()函数将阻塞(即不返回)一定的时间。它可以用来在程序中添加暂停。但如果你想安排你的程序在特定时间启动,nostarch.com/automate-boring-stuff-python-3rd-edition上的说明可以告诉你如何使用操作系统已经提供的调度程序。
最后,你的 Python 程序可以使用subprocess.run()函数启动其他应用程序。可以将命令行参数传递给run()调用,以使用应用程序打开特定文档。或者,你可以使用start或open程序与run()一起使用,并利用你的计算机的文件关联自动确定用于打开文档的应用程序。通过使用计算机上的其他应用程序,你的 Python 程序可以为其自动化需求利用其功能。
练习问题
-
Unix 纪元是什么?
-
哪个函数返回自 Unix 纪元以来的秒数?
-
time模块的哪个函数返回当前时间的可读字符串,例如'Mon Jun 15 14:00:38 2026'? -
你如何使你的程序暂停正好五秒钟?
-
round()函数返回什么? -
datetime对象和timedelta对象之间的区别是什么? -
使用
datetime模块,2019 年 1 月 7 日是星期几?
练习程序
为了练习,编写程序来完成以下任务。
美化计时器
将本章中的计时器项目扩展,使其使用rjust()和ljust()字符串方法来“美化”输出。(这些方法在第八章中已介绍。)而不是像这样的输出
Lap #1: 3.56 (3.56)
Lap #2: 8.63 (5.07)
Lap #3: 17.68 (9.05)
Lap #4: 19.11 (1.43)
输出应该看起来像这样:
Lap # 1: 3.56 ( 3.56)
Lap # 2: 8.63 ( 5.07)
Lap # 3: 17.68 ( 9.05)
Lap # 4: 19.11 ( 1.43)
接下来,使用第八章中介绍的pyperclip模块将文本输出复制到剪贴板,以便用户可以快速将输出粘贴到文本文件或电子邮件中。
13 号星期五寻找器
对于有十三恐惧症的人来说,13 号星期五被认为是不幸的日子(尽管我个人把它当作幸运日)。由于闰年和月份长度不同,确定下一个 13 号星期五何时到来可能会有点困难。
编写两个程序。第一个程序应该为当前天创建一个 datetime 对象和一个一天的 timedelta 对象。如果当前天是 13 号星期五,它应该打印出月份和年份。然后,它应该将 timedelta 对象添加到 datetime 对象中,将其设置为下一天,并重复检查。让它重复,直到找到下一个十个 13 号星期五的日期。
第二个程序应该做同样的事情,但减去 timedelta 对象。这个程序将找到所有过去有 13 号星期五的月份和年份,并在达到年份 1 时停止。
时间模块
你的计算机系统时钟设置为特定的日期、时间和时区。内置的 time 模块允许你的 Python 程序读取系统时钟以获取当前时间。其中最有用的函数是 time.time(),它返回一个称为纪元时间戳的值,以及 time.sleep(),它暂停程序。
返回纪元时间戳
Unix 纪元 是编程中常用的时间参考:1970 年 1 月 1 日午夜,协调世界时(UTC)。time.time() 函数返回自那一刻以来的秒数,作为浮点值。 (回想一下,浮点数只是一个带小数点的数字。) 这个数字被称为 纪元时间戳。例如,在交互式 shell 中输入以下内容:
>>> import time
>>> time.time()
1773813875.3518236
这里,我在 2026 年 3 月 17 日晚上 11:04 太平洋标准时间调用 time.time()。返回值是自 Unix 纪元以来到 time.time() 被调用这一刻经过的秒数。
time.time() 函数的返回值很有用,但不是人类可读的。time.ctime() 函数返回当前时间的字符串描述。你也可以选择性地传递自 Unix 纪元以来经过的秒数,该秒数由 time.time() 返回,以获取该时间的字符串值。在交互式 shell 中输入以下内容:
>>> import time
>>> time.ctime()
'Tue Mar 17 11:05:38 2026'
>>> this_moment = time.time()
>>> time.ctime(this_moment)
'Tue Mar 17 11:05:45 2026'
纪元时间戳可以用于 分析 代码:也就是说,测量一段代码运行所需的时间。如果你在要测量的代码块的开始和结束时分别调用 time.time(),你可以从第二个时间戳中减去第一个时间戳以找到这两个调用之间的经过时间。例如,打开一个新的文件编辑标签并输入以下程序:
# Measure how long it takes to multiply 100,000 numbers.
import time
def calculate_product(): # ❶
# Calculate the product of the first 100,000 numbers.
product = 1
for i in range(1, 100001):
product = product * i
return product
start_time = time.time() # ❷
result = calculate_product()
end_time = time.time() # ❸
print(f'It took {end_time – start_time} seconds to calculate.') # ❹
在 ❶ 处,我们定义了一个函数 calculate_product(),用于循环遍历从 1 到 100,000 的整数并返回它们的乘积。在 ❷ 处,我们调用 time.time() 并将其存储在 start_time 中。在调用 calculate_product() 后立即,我们再次调用 time.time() 并将其存储在 end_time ❸ 中。最后,我们打印出运行 calculate_product() 所花费的时间 ❹。
将此程序保存为 calcProd.py 并运行它。输出将类似于以下内容:
It took 2.844162940979004 seconds to calculate.
另一种对代码进行性能分析的方法是使用cProfile.run()函数,它比简单的time.time()技术提供了更详细的信息。您可以在我的另一本书《Python 基础之外》的第十三章中阅读有关cProfile.run()函数的内容(No Starch Press,2020 年)。
暂停程序
如果您需要暂停程序一段时间,请调用time.sleep()函数,并传递您希望程序保持暂停的秒数。例如,将以下内容输入到交互式外壳中:
>>> import time
>>> for i in range(3):
... print('Tick') # ❶
... print('Tick') # ❶
... print('Tick') # ❶
... print('Tick') # ❶
...
Tick
Tock
Tick
Tock
Tick
Tock
>>> time.sleep(5) # ❺
>>>
for循环将打印Tick❶,暂停一秒钟❷,打印Tock❸,暂停一秒钟❹,然后打印Tick,暂停,以此类推,直到Tick和Tock各自打印了三次。
time.sleep()函数将阻塞(即,它不会返回或释放您的程序以执行其他代码),直到传递给time.sleep()的秒数过去之后。例如,如果您输入time.sleep(5)❺,您会看到下一个提示符(>>>)不会出现,直到过了五秒钟。
项目 14:超级计时器
假设您想跟踪您在尚未自动化的无聊任务上花费的时间。您没有物理计时器,而且很难找到一款不带广告且不会将您的浏览器历史记录发送给营销人员的免费计时器应用程序。 (它在您同意的许可协议中声称可以这样做。您真的阅读了许可协议,不是吗?) 您可以使用 Python 自己编写一个简单的计时器程序。
从高层次来看,您的程序将执行以下操作:
-
通过调用
time.time()找到当前时间,并在程序开始时以及每个圈数开始时将其存储为时间戳。 -
维护一个圈数计数器,并在用户每次按 ENTER 时增加它。
-
通过减去时间戳来计算经过的时间。
-
处理
KeyboardInterrupt异常,以便用户可以按 CTRL-C 退出。
打开一个新的文件编辑器标签,并将其保存为stopwatch.py。
第 1 步:设置程序以跟踪时间
计时器程序需要使用当前时间,因此您想要导入time模块。在调用input()之前,您的程序还应向用户打印一些简短的说明,以便在用户按下 ENTER 后计时器可以开始。然后,代码将在用户按下 CTRL-C 退出之前,每次用户按下 ENTER 时开始跟踪圈数时间。
将以下代码输入到文件编辑器中,用TODO注释作为其余代码的占位符:
# A simple stopwatch program
import time
# Display the program's instructions.
print('Press ENTER to begin and to mark laps. Ctrl-C quits.')
input() # Press Enter to begin.
print('Started.')
start_time = time.time() # Get the first lap's start time.
last_time = start_time
lap_number = 1
# TODO: Start tracking the lap times.
现在您已经编写了显示说明的代码,开始第一个圈数,记录时间,并将lap_number设置为 1。
第 2 步:跟踪和打印圈数时间
现在让我们编写代码以开始每个新的圈数,计算上一个圈数所用的时间,并计算自开始计时器以来经过的总时间。我们将显示圈数时间和总时间,并在每个新的圈数时增加圈数计数。将以下代码添加到您的程序中:
# A simple stopwatch program
import time
# --snip--
# Start tracking the lap times.
try: # ❶
print('Tick') # ❶
input()
print('Tick') # ❶
print('Tick') # ❶
print('Tick') # ❶
lap_number += 1
last_time = time.time() # Reset the last lap time.
except KeyboardInterrupt: # ❻
# Handle the Ctrl-C exception to keep its error message from displaying.
print('\nDone.')
如果用户按下 CTRL-C 来停止秒表,将会引发 KeyboardInterrupt 异常,程序将会崩溃。为了防止崩溃,我们将程序的这个部分用 try 语句包裹❶。我们将在 except 子句中处理这个异常,当异常被引发时,将打印 Done 而不是显示 KeyboardInterrupt 错误信息。直到这种情况发生,执行将在一个无限循环❷中进行,该循环调用 input() 并等待用户按下 ENTER 键结束一个圈。当一个圈结束时,我们通过从当前时间 time.time() 减去圈的开始时间 last_time 来计算这个圈花费的时间❸。我们通过从秒表的总体开始时间 start_time 减去当前时间来计算经过的总时间❹。
因为这些时间计算的结果在小数点后会有很多位数(例如 4.766272783279419),所以我们使用 round() 函数将浮点值在❸和❹处四舍五入到两位数字。
在❺处,我们打印圈数、经过的总时间和圈时间。由于用户按下 input() 调用的 ENTER 键会在屏幕上打印一个换行符,所以将 end='' 传递给 print() 函数以避免输出双空格。打印完圈信息后,我们准备进行下一个圈,通过将 lap_number 计数加 1 并将 last_time 设置为当前时间(下一个圈的开始时间)来准备下一个圈。
类似程序的创意
时间跟踪为你的程序打开了几个可能性。虽然你可以下载应用程序来做这些事情中的一些,但自己编写程序的好处是它们将是免费的,并且不会充斥着广告和无用的功能。你可以编写类似的程序来完成以下任务:
-
创建一个简单的考勤应用,记录你输入某个人名字的时间,并使用当前时间来记录他们的上下班时间。
-
向你的程序添加一个功能,显示自进程开始以来经过的时间,例如使用
requests模块的下载数据。(参见第十三章。) -
不时检查程序运行了多长时间,并给用户提供取消耗时过长的任务的机会。
返回纪元时间戳
Unix 纪元 是编程中常用的时间参考:1970 年 1 月 1 日午夜,协调世界时(UTC)。time.time() 函数返回自那一刻以来的秒数,作为一个浮点值。(回想一下,浮点数只是一个带小数点的数字。)这个数字被称为 纪元时间戳。例如,在交互式外壳中输入以下内容:
>>> import time
>>> time.time()
1773813875.3518236
在这里,我正在调用 2026 年 3 月 17 日晚上 11:04 太平洋标准时间的 time.time()。返回值是自 Unix 纪元到调用 time.time() 时刻所经过的秒数。
time.time() 的返回值很有用,但不是人类可读的。time.ctime() 函数返回当前时间的字符串描述。你也可以选择性地传递自 Unix 纪元以来返回的秒数,以获取该时间的字符串值。在交互式外壳中输入以下内容:
>>> import time
>>> time.ctime()
'Tue Mar 17 11:05:38 2026'
>>> this_moment = time.time()
>>> time.ctime(this_moment)
'Tue Mar 17 11:05:45 2026'
可以使用纪元时间戳来 分析 代码:即,测量一段代码运行所需的时间。如果你在想要测量的代码块的开始和结束时分别调用 time.time(),然后从第一个时间戳减去第二个时间戳,就可以找到这两个调用之间的经过时间。例如,打开一个新的文件编辑标签并输入以下程序:
# Measure how long it takes to multiply 100,000 numbers.
import time
def calculate_product(): # ❶
# Calculate the product of the first 100,000 numbers.
product = 1
for i in range(1, 100001):
product = product * i
return product
start_time = time.time() # ❷
result = calculate_product()
end_time = time.time() # ❸
print(f'It took {end_time – start_time} seconds to calculate.') # ❹
在 ❶,我们定义了一个函数 calculate_product() 来循环遍历从 1 到 100,000 的整数并返回它们的乘积。在 ❷,我们调用 time.time() 并将其存储在 start_time 中。在调用 calculate_product() 之后,我们再次调用 time.time() 并将其存储在 end_time ❸ 中。最后,我们打印出运行 calculate_product() 所花费的时间 ❹。
将此程序保存为 calcProd.py 并运行它。输出将类似于以下内容:
It took 2.844162940979004 seconds to calculate.
分析代码的另一种方法是使用 cProfile.run() 函数,它提供了比简单的 time.time() 技术更详细的信息级别。你可以在我的另一本书 Beyond the Basic Stuff with Python(No Starch Press,2020)的第十三章中了解 cProfile.run() 函数。
暂停程序
如果你需要暂停程序一段时间,请调用 time.sleep() 函数并传递你希望程序保持暂停的秒数。例如,在交互式外壳中输入以下内容:
>>> import time
>>> for i in range(3):
... print('Tick') # ❶
... print('Tick') # ❶
... print('Tick') # ❶
... print('Tick') # ❶
...
Tick
Tock
Tick
Tock
Tick
Tock
>>> time.sleep(5) # ❺
>>>
for 循环将打印 Tick ❶,暂停一秒钟 ❷,打印 Tock ❸,暂停一秒钟 ❹,然后打印 Tick,暂停,以此类推,直到 Tick 和 Tock 各自打印了三次。
time.sleep() 函数将 阻塞(即,它不会返回或释放你的程序以执行其他代码),直到传递给 time.sleep() 的秒数过去之后。例如,如果你输入 time.sleep(5) ❺,你会看到下一个提示符 (>>>) 不会出现,直到过去五秒钟。
项目 14:超级秒表
假设你想跟踪你在尚未自动化的无聊任务上花费的时间。你没有物理秒表,而且令人惊讶的是,很难找到一款没有广告且不会将你的浏览器历史记录发送给营销人员的免费秒表应用程序。 (它在你同意的许可协议中声称可以这样做。你真的阅读了许可协议,不是吗?) 你可以用 Python 自己编写一个简单的秒表程序。
在高层次上,你的程序将执行以下操作:
-
通过调用
time.time()获取当前时间,并将其作为时间戳存储在程序开始时以及每个圈开始时。 -
维护一个圈数计数器,并在用户按下 ENTER 时递增它。
-
通过减去时间戳来计算已过时间。
-
处理
KeyboardInterrupt异常,以便用户可以按 CTRL-C 退出。
打开一个新的文件编辑器标签,并将其保存为 stopwatch.py。
第 1 步:设置程序以跟踪时间
停表程序需要使用当前时间,因此您想要导入 time 模块。在调用 input() 之前,您的程序还应向用户打印一些简要说明,以便计时器在用户按 ENTER 键后开始。然后,代码将在用户每次按 ENTER 键时开始跟踪圈数时间,直到他们按 CTRL-C 退出。
将以下代码输入到文件编辑器中,用 TODO 注释作为其余代码的占位符:
# A simple stopwatch program
import time
# Display the program's instructions.
print('Press ENTER to begin and to mark laps. Ctrl-C quits.')
input() # Press Enter to begin.
print('Started.')
start_time = time.time() # Get the first lap's start time.
last_time = start_time
lap_number = 1
# TODO: Start tracking the lap times.
现在您已经编写了显示说明的代码,开始第一圈,记录时间,并将 lap_number 设置为 1。
第 2 步:跟踪和打印圈数
现在,让我们编写代码以开始每一圈新计时,计算上一圈耗时,并计算自开始计时以来的总耗时。我们将显示圈数时间和总时间,并在每一圈新计时中增加圈数计数。将以下代码添加到您的程序中:
# A simple stopwatch program
import time
# --snip--
# Start tracking the lap times.
try: # ❶
print('Tick') # ❶
input()
print('Tick') # ❶
print('Tick') # ❶
print('Tick') # ❶
lap_number += 1
last_time = time.time() # Reset the last lap time.
except KeyboardInterrupt: # ❻
# Handle the Ctrl-C exception to keep its error message from displaying.
print('\nDone.')
如果用户按 CTRL-C 停止秒表,将引发 KeyboardInterrupt 异常,程序将崩溃。为了防止崩溃,我们将程序的这一部分用 try 语句❶包裹。我们将在 except 子句❻中处理异常,当异常被引发时,打印 Done 而不是显示 KeyboardInterrupt 错误消息。直到这种情况发生,执行将在一个无限循环❷内部进行,该循环调用 input() 并等待用户按 ENTER 键结束一圈。当一圈结束时,我们通过从圈的开始时间 last_time 减去当前时间 time.time()❸来计算圈数耗时。我们通过从秒表的总体开始时间 start_time 减去当前时间❹来计算总耗时。
由于这些时间计算的结果将在小数点后有多个数字(例如 4.766272783279419),我们使用 round() 函数将浮点值在❸和❹处四舍五入到两位数字。
在❺处,我们打印圈数、总耗时和圈数时间。由于用户按 ENTER 键对 input() 调用将打印换行符到屏幕上,所以将 end='' 传递给 print() 函数以避免输出双空格。打印完圈数信息后,我们通过将 lap_number 计数加 1 并将 last_time 设置为当前时间来准备下一圈,这是下一圈的开始时间。
类似程序的思路
时间跟踪为您的程序打开了多种可能性。虽然您可以下载应用程序来完成其中的一些任务,但自己编写程序的好处是它们将是免费的,并且不会因为广告和无用功能而臃肿。您可以编写类似的程序来完成以下任务:
-
创建一个简单的考勤应用,记录您输入某人姓名的时间,并使用当前时间来记录他们的上下班时间。
-
向您的程序添加一个功能,以显示自进程开始以来的经过时间,例如使用
requests模块的下载数据。(参见第十三章。) -
定期检查程序运行了多长时间,并给用户提供取消耗时任务的机会。
datetime 模块
time 模块对于获取 Unix 纪元时间戳很有用。但如果你想要以更方便的格式显示日期,或者对日期进行算术运算(例如,找出 205 天前的日期或从现在起 123 天的日期),你应该使用 datetime 模块。
datetime 模块有其自己的 datetime 数据类型。datetime 值代表特定的时间点。在交互式外壳中输入以下内容:
>>> import datetime
>>> datetime.datetime.now() # ❶
datetime.datetime(2026, 2, 27, 11, 10, 49, 727297) # ❷
>>> dt = datetime.datetime(2026, 10, 21, 16, 29, 0) # ❸
>>> dt.year, dt.month, dt.day # ❹
(2026, 10, 21)
>>> dt.hour, dt.minute, dt.second # ❺
(16, 29, 0)
调用 datetime.datetime.now() ❶ 返回一个 ❷ 当前日期和时间的 datetime 对象,根据您的计算机时钟。此对象包括当前时刻的年、月、日、时、分、秒和微秒。您还可以通过使用 datetime.datetime() 函数 ❸,传入代表您想要时刻的年、月、日、时和秒的整数来检索特定时刻的 datetime 对象。这些整数将被存储在 datetime 对象的 year、month、day ❹、hour、minute 和 second ❺ 属性中。
可以使用 datetime.datetime.fromtimestamp() 函数将 Unix 纪元时间戳转换为 datetime 对象。datetime 对象的日期和时间将转换为本地时区。在交互式外壳中输入以下内容:
>>> import datetime, time
>>> datetime.datetime.fromtimestamp(1000000)
datetime.datetime(1970, 1, 12, 5, 46, 40)
>>> datetime.datetime.fromtimestamp(time.time())
datetime.datetime(2026, 10, 21, 16, 30, 0, 604980)
调用 datetime.datetime.fromtimestamp() 并传入 1000000 返回从 Unix 纪元起 1,000,000 秒的 datetime 对象。传入 time.time(),即当前时刻的 Unix 纪元时间戳,返回当前时刻的 datetime 对象。因此,表达式 datetime.datetime.now() 和 datetime.datetime.fromtimestamp(time.time()) 做的是同一件事;它们都为您提供当前时刻的 datetime 对象。
您可以使用比较运算符来比较 datetime 对象,以找出哪个对象先于另一个。较晚的 datetime 对象是“较大的”值。在交互式外壳中输入以下内容:
>>> import datetime
>>> halloween_2026 = datetime.datetime(2026, 10, 31, 0, 0, 0) # ❶
>>> new_years_2027 = datetime.datetime(2027, 1, 1, 0, 0, 0) # ❷
>>> oct_31_2026 = datetime.datetime(2026, 10, 31, 0, 0, 0)
>>> halloween_2026 == oct_31_2026 # ❸
True
>>> halloween_2026 > new_years_2027 # ❹
False
>>> new_years_2027 > halloween_2026 # ❺
True
>>> new_years_2027 != oct_31_2026
True
此代码为 2026 年 10 月 31 日午夜的第一时刻创建了一个 datetime 对象,并将其存储在 halloween_2026 中 ❶。然后,它为 2027 年 1 月 1 日午夜的第一时刻创建了一个 datetime 对象,并将其存储在 new_years_2027 中 ❷。它还为 2026 年 10 月 31 日午夜创建了一个另一个对象,并将其存储在 oct_31_2026 中。比较 halloween_2026 和 oct_31_2026 显示它们是相等的 ❸。比较 new_years_2027 和 halloween_2026 显示 new_years_2027 大于(晚于)halloween_2026 ❹ ❺。
表示持续时间
datetime模块还提供了一个timedelta数据类型,它表示时间的持续时间而不是时间的时刻。在交互式外壳中输入以下内容:
>>> import datetime
>>> delta = datetime.timedelta(days=11, hours=10, minutes=9, seconds=8) # ❶
>>> delta.days, delta.seconds, delta.microseconds # ❷
(11, 36548, 0)
>>> delta.total_seconds()
986948.0
>>> str(delta)
'11 days, 10:09:08'
要创建一个timedelta对象,请使用datetime.timedelta()函数。datetime.timedelta()函数接受关键字参数weeks、days、hours、minutes、seconds、milliseconds和microseconds。没有month或year关键字参数,因为“一个月”或“一年”是随特定月份或年份而变化的固定时间量。timedelta对象以天数、秒数和微秒数表示总持续时间。这些数字分别存储在days、seconds和microseconds属性中。total_seconds()方法将仅返回以秒数表示的持续时间。将timedelta对象传递给str()将返回一个格式良好、易于阅读的对象字符串表示形式。
在此示例中,我们向datetime.timedelta()传递关键字参数以指定 11 天、10 小时、9 分钟和 8 秒的持续时间,并将返回的timedelta对象存储在delta中 ❶。此timedelta对象的days属性存储11,其seconds属性存储36548(10 小时、9 分钟和 8 秒,以秒表示) ❷。调用total_seconds()告诉我们 11 天、10 小时、9 分钟和 8 秒是 986,948 秒。最后,将timedelta对象传递给str()返回一个清楚地描述持续时间的字符串。
可以使用算术运算符对datetime值执行日期算术。例如,要计算从现在起 1,000 天的日期,请在交互式外壳中输入以下内容:
>>> import datetime
>>> now = datetime.datetime.now()
>>> now
datetime.datetime(2026, 12, 2, 18, 38, 50, 636181)
>>> thousand_days = datetime.timedelta(days=1000)
>>> now + thousand_days
datetime.datetime(2029, 8, 28, 18, 38, 50, 636181)
首先,创建一个表示当前时刻的datetime对象并将其存储在now中。然后,创建一个表示 1,000 天持续时间的timedelta对象并将其存储在thousand_days中。将now和thousand_days相加以获得从now中的日期和时间起 1,000 天的datetime对象。Python 将执行日期算术以确定从 2026 年 12 月 2 日起 1,000 天后将是 2029 年 8 月 28 日。在从给定日期计算 1,000 天时,您必须记住每个月有多少天,并考虑闰年和其他复杂细节。datetime模块为您处理所有这些。
您可以使用+和-运算符将timedelta对象与datetime对象或其他timedelta对象相加或相减。timedelta对象可以用*和/运算符乘以或除以整数或浮点值。在交互式外壳中输入以下内容:
>>> import datetime
>>> oct_21st = datetime.datetime(2026, 10, 21, 16, 29, 0) # ❶
>>> about_thirty_years = datetime.timedelta(days=365 * 30) # ❷
>>> oct_21st
datetime.datetime(2026, 10, 21, 16, 29)
>>> oct_21st – about_thirty_years
datetime.datetime(1996, 10, 28, 16, 29)
>>> oct_21st - (2 * about_thirty_years)
datetime.datetime(1966, 11, 5, 16, 29)
在这里,我们为 2026 年 10 月 21 日创建一个 datetime 对象❶,并为大约 30 年的持续时间创建一个 timedelta 对象❷。(我们为这些年份中的每一个使用 365 天,并忽略闰年。)从 oct_21st 减去 about_thirty_years 给我们一个 datetime 对象,代表 2026 年 10 月 21 日之前的 30 年日期。从 oct_21st 减去 2 * about_thirty_years 返回一个 datetime 对象,代表大约 60 年前的日期:1966 年 11 月 5 日下午晚些时候。
暂停直到特定日期
time.sleep() 方法让你可以暂停程序一段时间。通过使用 while 循环,你可以使你的程序暂停,直到特定的日期。例如,以下代码将一直循环,直到 2039 年万圣节:
import datetime
import time
halloween_2039 = datetime.datetime(2039, 10, 31, 0, 0, 0)
while datetime.datetime.now() < halloween_2039:
time.sleep(1) # Wait 1 second before looping to check again.
time.sleep(1) 调用将暂停你的 Python 程序,这样计算机就不会尽可能快地反复检查时间,浪费 CPU 处理周期。相反,while 循环将每秒检查一次条件,并在 2039 年万圣节之后(或你编程使其停止的任何时候)继续执行程序的其余部分。
将 datetime 对象转换为字符串
Epoch 时间戳和 datetime 对象对人类眼睛来说不是很友好。使用 strftime() 方法将 datetime 对象显示为字符串。(strftime() 函数名称中的 f 代表 format。)
strftime() 方法使用与 Python 字符串格式化类似的指令。表 19-1 列出了所有 strftime() 指令。你也可以参考strftime.org网站获取这些信息。
表 19-1:strftime() 指令
strftime() 指令 |
含义 |
|---|---|
%Y |
带世纪的年份,例如 '2026' |
%y |
不带世纪的年份,'00' 到 '99'(1970 到 2069) |
%m |
月份作为十进制数字,'01' 到 '12' |
%B |
完整的月份名称,例如 'November' |
%b |
缩写的月份名称,例如 'Nov' |
%d |
月份中的天数,'01' 到 '31' |
%j |
一年中的天数,'001' 到 '366' |
%w |
星期几,'0'(星期日)到 '6'(星期六) |
%A |
完整的星期名称,例如 'Monday' |
%a |
缩写的星期名称,例如 'Mon' |
%H |
小时(24 小时制),'00' 到 '23' |
%I |
小时(12 小时制),'01' 到 '12' |
%M |
分钟,'00' 到 '59' |
%S |
秒,'00' 到 '59' |
%p |
'AM' 或 'PM' |
%% |
文字 '%' 字符 |
将自定义格式字符串(包含格式指令以及任何所需的斜杠、冒号等)传递给 strftime(),strftime() 将返回格式化字符串形式的 datetime 对象信息。在交互式外壳中输入以下内容:
>>> oct_21st = datetime.datetime(2026, 10, 21, 16, 29, 0)
>>> oct_21st.strftime('%Y/%m/%d %H:%M:%S')
'2026/10/21 16:29:00'
>>> oct_21st.strftime('%I:%M %p')
'04:29 PM'
>>> oct_21st.strftime("%B of '%y")
"October of '26"
在这里,我们有一个表示 2026 年 10 月 21 日下午 4:29 的datetime对象,存储在oct_21st中。将自定义格式字符串'%Y/%m/%d %H:%M:%S'传递给strftime()函数返回一个包含 2026、10 和 21,由斜杠分隔,以及 16、29 和 00,由冒号分隔的字符串。传递'%I:%M %p'返回'04:29 PM',传递"%B of '%y"返回"October of '26"。
将字符串转换为 datetime 对象
如果你有一个日期信息的字符串,例如'2026/10/21 16:29:00'或'October 21, 2026',并且你需要将其转换为datetime对象,请使用datetime.datetime.strptime()函数。strptime()函数是strftime()方法的逆操作,你必须传递一个与strftime()相同的自定义格式字符串,以便函数知道如何解析和理解该字符串。(strptime()函数名称中的p代表parse。)
在交互式 shell 中输入以下内容:
>>> datetime.datetime.strptime('October 21, 2026', '%B %d, %Y') # ❶
datetime.datetime(2026, 10, 21, 0, 0)
>>> datetime.datetime.strptime('2026/10/21 16:29:00', '%Y/%m/%d %H:%M:%S')
datetime.datetime(2026, 10, 21, 16, 29)
>>> datetime.datetime.strptime("October of '26", "%B of '%y")
datetime.datetime(2026, 10, 1, 0, 0)
>>> datetime.datetime.strptime("November of '63", "%B of '%y")
datetime.datetime(2063, 11, 1, 0, 0) # ❷
>>> datetime.datetime.strptime("November of '73", "%B of '%y")
datetime.datetime(1973, 11, 1, 0, 0) # ❸
要从字符串'October 21, 2026'获取datetime对象,请将那个字符串作为strptime()的第一个参数传递,并将与'October 21, 2026'对应的自定义格式字符串作为第二个参数传递 ❶。包含日期信息的字符串必须与自定义格式字符串完全匹配,否则 Python 将引发ValueError异常。注意,"November of '63"被解释为 2063 ❷,而"November of '73"被解释为 1973 3,因为%y指令的范围是从 1970 年到 2069 年。
表示持续时间
datetime模块还提供了一个timedelta数据类型,它表示时间的持续时间而不是时间的时刻。在交互式 shell 中输入以下内容:
>>> import datetime
>>> delta = datetime.timedelta(days=11, hours=10, minutes=9, seconds=8) # ❶
>>> delta.days, delta.seconds, delta.microseconds # ❷
(11, 36548, 0)
>>> delta.total_seconds()
986948.0
>>> str(delta)
'11 days, 10:09:08'
要创建一个timedelta对象,请使用datetime.timedelta()函数。datetime.timedelta()函数接受关键字参数weeks、days、hours、minutes、seconds、milliseconds和microseconds。没有month或year关键字参数,因为“一个月”或“一年”是可变的时间长度,取决于特定的月份或年份。timedelta对象以天数、秒和微秒表示总持续时间。这些数字分别存储在days、seconds和microseconds属性中。total_seconds()方法将仅返回以秒为单位的持续时间。将timedelta对象传递给str()将返回一个格式良好、易于阅读的对象字符串表示。
在这个例子中,我们向datetime.delta()传递关键字参数以指定 11 天、10 小时、9 分钟和 8 秒的持续时间,并将返回的timedelta对象存储在delta ❶中。这个timedelta对象的days属性存储11,其seconds属性存储36548(10 小时、9 分钟和 8 秒,以秒为单位)❷。调用total_seconds()告诉我们 11 天、10 小时、9 分钟和 8 秒是 986,948 秒。最后,将timedelta对象传递给str()返回一个清楚地描述持续时间的字符串。
可以使用算术运算符对 datetime 值执行 日期算术。例如,要计算从现在起 1,000 天的日期,请在交互式外壳中输入以下内容:
>>> import datetime
>>> now = datetime.datetime.now()
>>> now
datetime.datetime(2026, 12, 2, 18, 38, 50, 636181)
>>> thousand_days = datetime.timedelta(days=1000)
>>> now + thousand_days
datetime.datetime(2029, 8, 28, 18, 38, 50, 636181)
首先,创建一个表示当前时刻的 datetime 对象并将其存储在 now 中。然后,创建一个表示 1,000 天的 timedelta 对象并将其存储在 thousand_days 中。将 now 和 thousand_days 相加,以获得从 now 中的日期和时间起 1,000 天的 datetime 对象。Python 将执行日期算术,以确定 2026 年 12 月 2 日之后的 1,000 天将是 2029 年 8 月 28 日。在从给定日期计算 1,000 天时,您必须记住每个月有多少天,并考虑闰年和其他复杂细节。datetime 模块会为您处理所有这些。
您可以使用 + 和 - 运算符将 timedelta 对象与 datetime 对象或其他 timedelta 对象相加或相减。timedelta 对象可以用 * 和 / 运算符乘以或除以整数或浮点数。在交互式外壳中输入以下内容:
>>> import datetime
>>> oct_21st = datetime.datetime(2026, 10, 21, 16, 29, 0) # ❶
>>> about_thirty_years = datetime.timedelta(days=365 * 30) # ❷
>>> oct_21st
datetime.datetime(2026, 10, 21, 16, 29)
>>> oct_21st – about_thirty_years
datetime.datetime(1996, 10, 28, 16, 29)
>>> oct_21st - (2 * about_thirty_years)
datetime.datetime(1966, 11, 5, 16, 29)
在这里,我们创建一个表示 2026 年 10 月 21 日的 datetime 对象❶,以及一个表示大约 30 年的 timedelta 对象❷。(我们为这些年份中的每一个使用 365 天,并忽略闰年。)从 oct_21st 中减去 about_thirty_years 给我们一个表示 2026 年 10 月 21 日之前 30 年的 datetime 对象。从 oct_21st 中减去 2 * about_thirty_years 返回一个表示大约 60 年前的日期的 datetime 对象:1966 年 11 月 5 日傍晚。
暂停到特定日期
time.sleep() 方法允许您暂停程序一段时间。通过使用 while 循环,您可以暂停程序直到特定日期。例如,以下代码将一直循环,直到 2039 年万圣节:
import datetime
import time
halloween_2039 = datetime.datetime(2039, 10, 31, 0, 0, 0)
while datetime.datetime.now() < halloween_2039:
time.sleep(1) # Wait 1 second before looping to check again.
time.sleep(1) 调用将暂停您的 Python 程序,这样计算机就不会尽可能快地反复检查时间,从而浪费 CPU 处理周期。相反,while 循环将每秒检查一次条件,并在 2039 年万圣节之后(或您编程它停止的任何时候)继续执行程序的其余部分。
将 datetime 对象转换为字符串
Epoch 时间戳和 datetime 对象对人类眼睛来说不是很友好。使用 strftime() 方法将 datetime 对象显示为字符串。(strftime() 函数名称中的 f 代表 format。)
strftime() 方法使用与 Python 字符串格式化类似的指令。表 19-1 列出了完整的 strftime() 指令列表。您还可以参考strftime.org 网站获取这些信息。
表 19-1: strftime() 指令
| strftime() 指令 | 含义 |
|---|---|
%Y |
带世纪的年份,例如 '2026' |
%y |
不带世纪的年份,'00' 到 '99'(1970 到 2069) |
%m |
月份作为十进制数字,'01' 到 '12' |
%B |
全月名称,例如 '十一月' |
%b |
缩写的月份名称,例如'Nov' |
%d |
月份中的日,'01'到'31' |
%j |
一年中的日,'001'到'366' |
%w |
星期中的日,'0'(星期日)到'6'(星期六) |
%A |
完整的星期名称,例如'Monday' |
%a |
缩写的星期名称,例如'Mon' |
%H |
24 小时制的小时,'00'到'23' |
%I |
12 小时制的小时,'01'到'12' |
%M |
分钟,'00'到'59' |
%S |
秒,'00'到'59' |
%p |
'AM'或'PM' |
%% |
文字'%'字符 |
将包含格式化指令(以及任何所需的斜杠、冒号等)的自定义格式字符串传递给strftime(),然后strftime()将返回datetime对象的信息作为格式化的字符串。在交互式 shell 中输入以下内容:
>>> oct_21st = datetime.datetime(2026, 10, 21, 16, 29, 0)
>>> oct_21st.strftime('%Y/%m/%d %H:%M:%S')
'2026/10/21 16:29:00'
>>> oct_21st.strftime('%I:%M %p')
'04:29 PM'
>>> oct_21st.strftime("%B of '%y")
"October of '26"
在这里,我们有一个表示 2026 年 10 月 21 日下午 4:29 的datetime对象,存储在oct_21st中。将自定义格式字符串'%Y/%m/%d %H:%M:%S'传递给strftime()函数返回一个包含 2026、10 和 21,由斜杠分隔,以及 16、29 和 00,由冒号分隔的字符串。传递'%I:%M %p'返回'04:29 PM',传递"%B of '%y"返回"October of '26"。
将字符串转换为 datetime 对象
如果你有一个包含日期信息的字符串,例如'2026/10/21 16:29:00'或'October 21, 2026',并且你需要将其转换为datetime对象,请使用datetime.datetime.strptime()函数。strptime()函数是strftime()方法的逆操作,你必须传递一个使用与strftime()相同的指令的自定义格式字符串,以便函数知道如何解析和理解字符串。(strptime()函数名称中的p代表parse。)
在交互式 shell 中输入以下内容:
>>> datetime.datetime.strptime('October 21, 2026', '%B %d, %Y') # ❶
datetime.datetime(2026, 10, 21, 0, 0)
>>> datetime.datetime.strptime('2026/10/21 16:29:00', '%Y/%m/%d %H:%M:%S')
datetime.datetime(2026, 10, 21, 16, 29)
>>> datetime.datetime.strptime("October of '26", "%B of '%y")
datetime.datetime(2026, 10, 1, 0, 0)
>>> datetime.datetime.strptime("November of '63", "%B of '%y")
datetime.datetime(2063, 11, 1, 0, 0) # ❷
>>> datetime.datetime.strptime("November of '73", "%B of '%y")
datetime.datetime(1973, 11, 1, 0, 0) # ❸
要从字符串'October 21, 2026'获取一个datetime对象,将这个字符串作为strptime()的第一个参数传递,并将对应于'October 21, 2026'的自定义格式字符串作为第二个参数 ❶。包含日期信息的字符串必须与自定义格式字符串完全匹配,否则 Python 将引发一个ValueError异常。注意,"November of '63"被解释为 2063 ❷,而"November of '73"被解释为 1973 3,因为%y指令的范围是从 1970 年到 2069 年。
从 Python 启动其他程序
你的 Python 程序可以使用内置的subprocess模块中的run()函数在你的计算机上启动其他程序。如果你打开了应用程序的多个实例,那么每个实例都是同一程序的独立进程。例如,图 19-1 中显示的计算器应用程序的每个打开窗口都是一个不同的进程。

图 19-1:同一计算器程序的六个运行进程
如果你想在 Python 脚本中启动外部程序,将程序的文件名传递给 subprocess.run()。 (在 Windows 上,右键单击应用程序的 开始 菜单项并选择 属性 以查看应用程序的文件名。在 macOS 上,按住 CTRL 键单击应用程序并选择 显示包内容 以找到可执行文件的路径。)run() 函数将阻塞,直到启动的程序关闭。将启动的程序作为列表中的字符串传递给可执行程序的文件路径,请注意,启动的程序将在单独的进程中运行,而不是与你的 Python 程序在同一个进程中。
在 Windows 计算机上,请在交互式 shell 中输入以下命令:
>>> import subprocess
>>> subprocess.run(['C:\\Windows\\System32\\calc.exe'])
CompletedProcess(args=['C:\\Windows\\System32\\calc.exe'], returncode=0)
在 Ubuntu Linux 上,请输入以下命令:
>>> import subprocess
>>> subprocess.run(['/usr/bin/gnome-calculator'])
CompletedProcess(args=['/usr/bin/gnome-calculator'], returncode=0)
在 macOS 上,请输入以下命令:
>>> import subprocess
>>> subprocess.run(['open', '/System/Applications/Calculator.app'])
CompletedProcess(args=['open', '/System/Applications/Calculator.app'], returncode=0)
注意,macOS 要求你运行 open 程序,并传递你想要启动的程序的控制台参数。
在这些示例中,我们的 Python 代码启动了程序,等待程序关闭,然后继续执行。如果你想让你的 Python 代码启动一个程序然后立即继续,而不等待程序关闭,请调用 subprocess.Popen() (“进程打开”)函数:
>>> import subprocess
>>> calc_proc = subprocess.Popen(['C:\\Windows\\System32\\calc.exe'])
返回值是一个 Popen 对象,它有两个有用的方法:poll() 和 wait()。
你可以将 poll() 方法想象成不断地问你的司机“我们到了吗?”直到到达。当 poll() 被调用时,如果进程仍在运行,poll() 方法将返回 None。如果程序已经终止,它将返回进程的整数 退出代码。退出代码表示进程是否在没有错误的情况下终止(由退出代码 0 表示)或者是否有错误导致进程终止(由非零退出代码表示——通常为 1,但可能根据程序的不同而有所变化)。
wait() 方法就像等待司机到达你的目的地。该方法将阻塞,直到启动的进程终止。如果你想让你的程序暂停,直到用户完成与其他程序的交互,这很有用。wait() 的返回值是进程的整数退出代码。
在 Windows 上,请在交互式 shell 中输入以下命令。请注意,wait() 调用可能会阻塞,直到你退出启动的计算器程序:
>>> import subprocess
>>> calc_proc = subprocess.Popen(['c:\\Windows\\System32\\calc.exe']) # ❶
>>> calc_proc.poll() == None # ❷
True
>>> calc_proc.wait() # Doesn't return until Calculator closes # ❸
0
>>> calc_proc.poll()
0
在这里,我们打开了一个计算器进程 ❶。在 Windows 的旧版本中,如果进程仍在运行,poll() 将返回 None ❷。然后,我们关闭计算器应用程序的窗口,并在交互式 shell 中对已终止的进程调用 wait() ❸。现在 wait() 和 poll() 返回 0,表示进程在没有错误的情况下终止。
如果您在 Windows 10 及更高版本上使用 subprocess.Popen() 运行 calc.exe,您会注意到 wait() 立即返回,即使计算器应用程序仍在运行。这是因为 calc.exe 启动了计算器应用程序然后立即关闭自己。Windows 中的计算器程序是一个“受信任的微软商店应用程序”,其具体细节超出了本书的范围。简单地说,程序可以以许多特定于应用程序和特定于操作系统的运行方式运行。
如果您想关闭使用 subprocess.Popen() 启动的进程,请调用函数返回的 Popen 对象的 kill() 方法。如果您在 Windows 上有 MS Paint,请在交互式外壳中输入以下内容:
>>> import subprocess
>>> paint_proc = subprocess.Popen('c:\\Windows\\System32\\mspaint.exe')
>>> paint_proc.kill()
注意,kill() 方法会立即终止程序并绕过任何“您确定要退出吗?”确认窗口。程序中未保存的工作将会丢失。
向进程传递命令行参数
您可以使用 run() 方法向您创建的进程传递命令行参数。为此,将一个列表作为 run() 方法的唯一参数传递。列表中的第一个字符串将是您要启动的程序的可执行文件名;所有后续的字符串将是在程序启动时传递给程序的命令行参数。实际上,这个列表将是启动程序的 sys.argv 的值。
大多数具有图形用户界面 (GUI) 的应用程序不像基于命令行或基于终端的程序那样广泛使用命令行参数。但大多数 GUI 应用程序将接受一个文件参数,当它们启动时将立即打开该文件。例如,如果您使用 Windows,创建一个名为 C:\Users\Al\hello.txt 的文本文件,然后请在交互式外壳中输入以下内容:
>>> subprocess.run(['C:\\Windows\\notepad.exe', 'C:\\Users\Al\\hello.txt'])
CompletedProcess(args=['C:\\Windows\\notepad.exe', 'C:\\Users\\Al\\hello.txt'], returncode=0)
这不仅将启动记事本应用程序,还会立即打开 C:\Users\Al\hello.txt 文件。每个程序都有自己的命令行参数集合,并且一些程序(尤其是 GUI 应用程序)根本不使用命令行参数。
subprocess.Popen() 函数以类似的方式处理命令行参数,并且您应该将它们添加到传递给函数的列表的末尾。
从启动的命令接收输出文本
您还可以使用 subprocess.run() 和 subprocess.Popen() 启动终端命令。您可能希望您的 Python 代码接收这些命令的文本输出或模拟对它们的键盘输入。例如,让我们从 Python 中启动 ping 命令并接收它产生的文本。(ping 命令的详细信息超出了本书的范围。)在 Windows 上,您将使用 -n 4 参数发送四个网络ping请求,以检查 Nostarch.com 服务器是否在线。如果您在 macOS 和 Linux 上,将 -n 替换为 -c。此命令需要几秒钟才能运行:
>>> import subprocess
>>> proc = subprocess.run(['ping', '-n', '4', 'nostarch.com'], capture_output=True, text=True)
>>> print(proc.stdout)
Pinging nostarch.com [104.20.120.46] with 32 bytes of data:
Reply from 104.20.120.46: bytes=32 time=19ms TTL=59
Reply from 104.20.120.46: bytes=32 time=17ms TTL=59
Reply from 104.20.120.46: bytes=32 time=97ms TTL=59
Reply from 104.20.120.46: bytes=32 time=217ms TTL=59
Ping statistics for 104.20.120.46:
Packets: Sent = 4, Received = 4, Lost = 0 (0% loss),
Approximate round trip times in milli-seconds:
Minimum = 17ms, Maximum = 217ms, Average = 87ms
当你将 capture_output=True 和 text=True 参数传递给 subprocess.run() 时,启动程序的文字输出将作为字符串存储在返回的 CompletedProcess 对象的 stdout(“标准输出”)属性中。你的 Python 脚本可以使用其他程序的功能,然后解析文本输出作为字符串。
运行任务计划程序、launchd 和 cron
如果你熟悉计算机,你可能知道 Windows 上的任务计划程序、macOS 上的 launchd 或 Linux 上的 cron 调度程序。这些经过良好记录且可靠的工具都允许你安排应用程序在特定时间启动。
使用你操作系统的内置调度程序可以让你免于编写自己的时钟检查代码来安排你的程序。然而,如果你只需要程序短暂暂停,那么最好是在循环中循环直到特定日期和时间,并在每次迭代中调用 time.sleep(1)。
使用默认应用程序打开文件
在你的计算机上双击一个 .txt 文件将自动启动与 .txt 文件扩展名关联的应用程序。每个操作系统都有一个执行类似双击动作的程序。在 Windows 上,这是 start 命令。在 macOS 和 Linux 上,这是 open 命令。在交互式 shell 中输入以下内容,根据你的系统将 'start' 或 'open' 传递给 run(),在 Windows 上,你还应该传递 shell=True 关键字参数,如下所示:
>>> file_obj = open('hello.txt', 'w') # Create a hello.txt file.
>>> file_obj.write('Hello, world!')
13
>>> file_obj.close()
>>> import subprocess
>>> subprocess.run(['`start`', 'hello.txt'], shell=True)
在这里,我们将 Hello, world! 写入一个新的 hello.txt 文件。然后,我们调用 run(),传递一个包含程序或命令名称(在本例中,Windows 的 'start')和文件名的列表。操作系统知道所有的文件关联,可以确定在 Windows 上应该启动,例如,Notepad.exe 来处理 hello.txt 文件。
项目 15:简单倒计时
就像很难找到一个简单的秒表应用程序一样,找到一个简单的倒计时应用程序也可能很难。让我们编写一个倒计时程序,在倒计时结束时播放警报。
从高层次来看,你的程序将执行以下操作:
-
在倒计时中显示每个数字之间暂停一秒钟,通过调用
time.sleep()。 -
调用
subprocess.run()使用默认应用程序打开 alarm.wav 声音文件。
打开一个新的文件编辑标签,并将其保存为 simplecountdown.py。
第 1 步:倒计时
此程序将需要 time 模块中的 time.sleep() 函数和 subprocess 模块中的 subprocess.run() 函数。输入以下代码,并将文件保存为 simplecountdown.py:
# https://autbor.com/simplecountdown.py - A simple countdown script
import time, subprocess
time_left = 60 # ❶
while time_left > 0:
print('Tick') # ❶
print('Tick') # ❶
print('Tick') # ❶
# TODO: At the end of the countdown, play a sound file.
在导入 time 和 subprocess 模块后,创建一个名为 time_left 的变量来保存倒计时剩余的秒数 ❶。这里我们将其设置为 60,但你可以将其更改为任何你想要的值,甚至可以根据命令行参数来设置它。
在while循环中,显示剩余的计数❷,暂停一秒钟❸,然后在循环重新开始之前递减time_left变量❹。只要time_left大于0,循环就会继续。之后,倒计时就会结束。接下来,让我们用播放声音文件的代码填充TODO注释。
第 2 步:播放声音文件
虽然第十二章介绍了playsound3模块来播放各种格式的声音文件,但快速简单的方法是启动用户已经用来播放声音文件的任何应用程序。操作系统将根据.wav文件扩展名确定应该启动哪个应用程序来播放文件。这个.wav文件可以是其他声音文件格式,例如.mp3或.ogg。你可以在倒计时结束时使用电脑上的任何声音文件进行播放,或者从autbor.com/alarm.wav下载alarm.wav。
在你的代码中添加以下内容:
# https://autbor.com/simplecountdown.py - A simple countdown script
# --snip--
# At the end of the countdown, play a sound file.
#subprocess.run(['start', 'alarm.wav'], shell=True) # Windows
#subprocess.run(['open', 'alarm.wav']) # macOS and Linux
在while循环完成后,alarm.wav(或你选择的声音文件)将播放以通知用户倒计时结束。取消注释针对你的操作系统的subprocess.run()调用。在 Windows 上,确保在传递给run()的列表中包含'start'。在 macOS 和 Linux 上,传递'open'而不是'start'并移除shell=True。
除了播放声音文件,你还可以在某个地方保存一个包含类似“休息时间!”这样的消息的文本文件,并使用subprocess.run()在倒计时结束时打开它。这将有效地创建一个带有消息的弹出窗口。或者,你可以在倒计时结束时使用webbrowser.open()函数打开特定的网站。与你在网上找到的一些免费倒计时应用程序不同,你自己的倒计时程序的闹钟可以是任何你想要的东西!
类似程序的思路
倒计时本质上在继续程序执行之前产生一个简单的延迟。你可以使用相同的方法来实现其他应用程序和功能,例如以下内容:
-
使用
time.sleep()给用户一个机会按下 CTRL-C 来取消一个动作,例如删除文件。你的程序可以打印一条“按 CTRL-C 取消”的消息,然后使用try和except语句处理任何KeyboardInterrupt异常。 -
对于长期倒计时,你可以使用
timedelta对象来测量距离未来某个时间点(比如生日?纪念日?)的天数、小时、分钟和秒数。
向进程传递命令行参数
你可以向使用run()创建的进程传递命令行参数。为此,将一个列表作为run()的唯一参数传递。列表中的第一个字符串是你想要启动的程序的可执行文件名;所有后续的字符串将是启动程序时传递给程序的命令行参数。实际上,这个列表将是启动程序的sys.argv的值。
大多数具有图形用户界面(GUI)的应用程序不像基于命令行或终端的程序那样广泛使用命令行参数。但大多数 GUI 应用程序将接受一个文件参数,当它们启动时会立即打开该文件。例如,如果你使用的是 Windows,创建一个名为 C:\Users\Al\hello.txt 的文本文件,然后输入以下内容到交互式 shell 中:
>>> subprocess.run(['C:\\Windows\\notepad.exe', 'C:\\Users\Al\\hello.txt'])
CompletedProcess(args=['C:\\Windows\\notepad.exe', 'C:\\Users\\Al\\hello.txt'], returncode=0)
这不仅将启动记事本应用程序,还会立即打开 C:\Users\Al\hello.txt 文件。每个程序都有自己的命令行参数集合,有些程序(尤其是 GUI 应用程序)根本不使用命令行参数。
subprocess.Popen() 函数以类似的方式处理命令行参数,你应该将它们添加到你传递给函数的列表的末尾。
从启动的命令接收输出文本
你还可以使用 subprocess.run() 和 subprocess.Popen() 启动终端命令。你可能希望你的 Python 代码接收这些命令的文本输出或模拟向它们输入键盘输入。例如,让我们从 Python 中启动 ping 命令并接收它产生的文本。(ping 命令的详细信息超出了本书的范围。)在 Windows 上,你将使用 -n 4 参数发送四个网络ping请求,检查 Nostarch.com 服务器是否在线。如果你在 macOS 和 Linux 上,将 -n 替换为 -c。这个命令需要几秒钟才能运行:
>>> import subprocess
>>> proc = subprocess.run(['ping', '-n', '4', 'nostarch.com'], capture_output=True, text=True)
>>> print(proc.stdout)
Pinging nostarch.com [104.20.120.46] with 32 bytes of data:
Reply from 104.20.120.46: bytes=32 time=19ms TTL=59
Reply from 104.20.120.46: bytes=32 time=17ms TTL=59
Reply from 104.20.120.46: bytes=32 time=97ms TTL=59
Reply from 104.20.120.46: bytes=32 time=217ms TTL=59
Ping statistics for 104.20.120.46:
Packets: Sent = 4, Received = 4, Lost = 0 (0% loss),
Approximate round trip times in milli-seconds:
Minimum = 17ms, Maximum = 217ms, Average = 87ms
当你将 capture_output=True 和 text=True 参数传递给 subprocess.run() 时,启动程序的文本输出将存储在返回的 CompletedProcess 对象的 stdout(“标准输出”)属性中作为一个字符串。你的 Python 脚本可以使用其他程序的功能,然后将文本输出作为字符串解析。
运行任务计划程序、launchd 和 cron
如果你熟悉计算机,你可能知道 Windows 上的任务计划程序、macOS 上的 launchd 或 Linux 上的 cron 调度程序。这些经过良好记录且可靠的工具都允许你在特定时间安排应用程序的启动。
使用操作系统的内置调度程序可以节省你编写自己的时钟检查代码来安排程序的时间。然而,如果你只需要程序短暂暂停,最好改为循环直到特定日期和时间,在循环的每次迭代中调用 time.sleep(1)。
使用默认应用程序打开文件
在你的计算机上双击一个 .txt 文件将自动启动与 .txt 文件扩展名关联的应用程序。每个操作系统都有一个执行双击动作等价功能的程序。在 Windows 上,这是 start 命令。在 macOS 和 Linux 上,这是 open 命令。将以下内容输入到交互式 shell 中,根据你的系统传递 'start' 或 'open' 给 run(),在 Windows 上,你还应该传递 shell=True 关键字参数,如下所示:
>>> file_obj = open('hello.txt', 'w') # Create a hello.txt file.
>>> file_obj.write('Hello, world!')
13
>>> file_obj.close()
>>> import subprocess
>>> subprocess.run(['`start`', 'hello.txt'], shell=True)
在这里,我们将 Hello, world! 写入一个新的 hello.txt 文件。然后,我们调用 run(),传递一个包含程序或命令名称(在本例中,Windows 的 'start')和文件名的列表。操作系统知道所有的文件关联,可以确定应该启动,例如,Notepad.exe 来处理 Windows 上的 hello.txt 文件。
项目 15:简单倒计时
正如找到一个简单的秒表应用程序很难一样,找到一个简单的倒计时应用程序也可能很难。让我们编写一个倒计时程序,在倒计时结束时播放警报。
从高层次来看,您的程序将执行以下操作:
-
在倒计时中每次显示数字之间暂停一秒钟,通过调用
time.sleep()实现。 -
调用
subprocess.run()使用默认应用程序打开 alarm.wav 声音文件。
打开一个新的文件编辑标签,并将其保存为 simplecountdown.py。
第 1 步:倒计时
此程序将需要 time 模块的 time.sleep() 函数和 subprocess 模块的 subprocess.run() 函数。输入以下代码并将文件保存为 simplecountdown.py:
# https://autbor.com/simplecountdown.py - A simple countdown script
import time, subprocess
time_left = 60 # ❶
while time_left > 0:
print('Tick') # ❶
print('Tick') # ❶
print('Tick') # ❶
# TODO: At the end of the countdown, play a sound file.
在导入 time 和 subprocess 之后,创建一个名为 time_left 的变量来保存倒计时剩余的秒数 ❶。这里我们将其设置为 60,但您可以更改此值,甚至可以根据命令行参数设置它。
在 while 循环中,显示剩余的计数 ❷,暂停一秒钟 ❸,然后在循环重新开始之前递减 time_left 变量 ❹。只要 time_left 大于 0,循环就会继续。之后,倒计时将结束。接下来,让我们用播放声音文件的代码填充 TODO 注释。
第 2 步:播放声音文件
虽然第十二章介绍了 playsound3 模块来播放各种格式的声音文件,但最简单快捷的方法是启动用户已经用来播放声音文件的任何应用程序。操作系统将根据 .wav 文件扩展名确定应该启动哪个应用程序来播放文件。这个 .wav 文件可以是其他声音文件格式,例如 .mp3 或 .ogg。您可以使用计算机上的任何声音文件在倒计时结束时播放,或者从 autbor.com/alarm.wav 下载 alarm.wav。
将以下内容添加到您的代码中:
# https://autbor.com/simplecountdown.py - A simple countdown script
# --snip--
# At the end of the countdown, play a sound file.
#subprocess.run(['start', 'alarm.wav'], shell=True) # Windows
#subprocess.run(['open', 'alarm.wav']) # macOS and Linux
在 while 循环完成后,alarm.wav(或您选择的声音文件)将播放以通知用户倒计时结束。取消注释针对您操作系统的 subprocess.run() 调用。在 Windows 上,请确保在传递给 run() 的列表中包含 'start'。在 macOS 和 Linux 上,传递 'open' 而不是 'start' 并删除 shell=True。
而不是播放声音文件,你可以在某个地方保存一个包含类似Break time!的消息的文本文件,并在倒计时结束时使用subprocess.run()打开它。这将有效地创建一个带有消息的弹出窗口。或者,你可以在倒计时结束时使用webbrowser.open()函数打开特定的网站。与你在网上找到的一些免费倒计时应用程序不同,你自己的倒计时程序的闹钟可以是任何你想要的东西!
类似程序的创意
倒计时本质上在继续程序执行之前产生一个简单的延迟。你可以用同样的方法来处理其他应用程序和功能,例如以下内容:
-
使用
time.sleep()给用户一个机会按下 CTRL-C 来取消一个动作,例如删除文件。你的程序可以打印一条“按下 CTRL-C 来取消”的消息,然后使用try和except语句处理任何KeyboardInterrupt异常。 -
对于长期倒计时,你可以使用
timedelta对象来测量距离未来某个点(生日?纪念日?)的天数、小时、分钟和秒数。
摘要
Unix 纪元(1970 年 1 月 1 日午夜,UTC)是许多编程语言的标准参考时间,包括 Python。虽然time.time()函数返回纪元时间戳(即自 Unix 纪元以来的秒数的浮点值),但datetime模块更适合执行日期算术、格式化或解析包含日期信息的字符串。
time.sleep()函数将阻塞(即不返回)一定的时间。它可以用来给你的程序添加暂停。但如果你想安排你的程序在特定时间启动,nostarch.com/automate-boring-stuff-python-3rd-edition上的说明可以告诉你如何使用操作系统已经提供的调度器。
最后,你的 Python 程序可以使用subprocess.run()函数启动其他应用程序。可以将命令行参数传递给run()调用以使用应用程序打开特定文档。或者,你可以使用start或open程序与run()一起使用,并利用你的计算机的文件关联自动确定用于打开文档的应用程序。通过使用计算机上的其他应用程序,你的 Python 程序可以为其自动化需求利用其功能。
练习问题
-
Unix 纪元是什么?
-
哪个函数返回自 Unix 纪元以来的秒数?
-
time模块的哪个函数返回当前时间的可读字符串,例如'Mon Jun 15 14:00:38 2026'? -
你如何让你的程序暂停正好五秒钟?
-
round()函数返回什么? -
datetime对象和timedelta对象之间的区别是什么? -
使用
datetime模块,2019 年 1 月 7 日是星期几?
练习程序
为了练习,编写程序来完成以下任务。
美化计时器
扩展本章中的计时器项目,使其使用 rjust() 和 ljust() 字符串方法来“美化”输出。(这些方法在第八章中已介绍。)而不是输出如下
Lap #1: 3.56 (3.56)
Lap #2: 8.63 (5.07)
Lap #3: 17.68 (9.05)
Lap #4: 19.11 (1.43)
输出应该看起来像这样:
Lap # 1: 3.56 ( 3.56)
Lap # 2: 8.63 ( 5.07)
Lap # 3: 17.68 ( 9.05)
Lap # 4: 19.11 ( 1.43)
接下来,使用第八章中介绍的 pyperclip 模块将文本输出复制到剪贴板,以便用户可以快速将输出粘贴到文本文件或电子邮件中。
13 号星期五查找器
对于有十三恐惧症的人来说,13 号星期五被认为是不幸的日子(尽管我个人把它当作幸运日来庆祝)。由于闰年和月份长度不同,很难确定下一个 13 号星期五何时到来。
编写两个程序。第一个程序应该为当前日期创建一个 datetime 对象和一个一天的时间差 timedelta 对象。如果当前日期是 13 号星期五,它应该打印出月份和年份。然后,它应该将 timedelta 对象添加到 datetime 对象中,将其设置为下一天,并重复检查。让它重复,直到找到下一个十个 13 号星期五的日期。
第二个程序应该执行相同的功能,但减去 timedelta 对象。这个程序将找到所有过去有 13 号星期五的月份和年份,并在达到年份 1 时停止。
美化计时器
扩展本章中的计时器项目,使其使用 rjust() 和 ljust() 字符串方法来“美化”输出。(这些方法在第八章中已介绍。)而不是输出如下
Lap #1: 3.56 (3.56)
Lap #2: 8.63 (5.07)
Lap #3: 17.68 (9.05)
Lap #4: 19.11 (1.43)
输出应该看起来像这样:
Lap # 1: 3.56 ( 3.56)
Lap # 2: 8.63 ( 5.07)
Lap # 3: 17.68 ( 9.05)
Lap # 4: 19.11 ( 1.43)
接下来,使用第八章中介绍的 pyperclip 模块将文本输出复制到剪贴板,以便用户可以快速将输出粘贴到文本文件或电子邮件中。
13 号星期五查找器
对于有十三恐惧症的人来说,13 号星期五被认为是不幸的日子(尽管我个人把它当作幸运日来庆祝)。由于闰年和月份长度不同,很难确定下一个 13 号星期五何时到来。
编写两个程序。第一个程序应该为当前日期创建一个 datetime 对象和一个一天的时间差 timedelta 对象。如果当前日期是 13 号星期五,它应该打印出月份和年份。然后,它应该将 timedelta 对象添加到 datetime 对象中,将其设置为下一天,并重复检查。让它重复,直到找到下一个十个 13 号星期五的日期。
第二个程序应该执行相同的功能,但减去 timedelta 对象。这个程序将找到所有过去有 13 号星期五的月份和年份,并在达到年份 1 时停止。
20 发送电子邮件、短信和推送通知

检查和回复电子邮件是一个巨大的时间黑洞,您不能简单地编写一个程序来处理您所有的电子邮件,因为每条消息都需要自己的回复。但是,一旦您知道如何编写可以发送和接收电子邮件的程序,您仍然可以自动化许多与电子邮件相关的任务。
例如,也许您有一个包含客户记录的电子表格,并希望根据客户的年龄和位置详情给每位客户发送不同的格式化信函。商业软件可能无法为您做到这一点。幸运的是,您可以编写自己的程序来发送这些电子邮件,从而节省大量复制粘贴的时间。
您还可以编写程序发送短信文本消息和推送通知,即使在您远离电脑时也能通知您。如果您正在自动化一个需要几个小时才能完成的任务,您可能不想每隔几分钟就回到电脑前检查程序的状态。相反,程序可以在完成后直接给您的手机发短信,让您在离开电脑时也能专注于更重要的事情。
本章介绍了 EZGmail 模块,这是一个简单的方法,可以从 Gmail 账户发送和读取电子邮件,以及提供设备间推送通知的免费ntfy服务。
警告
我强烈建议您为任何发送或接收电子邮件的脚本设置一个单独的电子邮件账户。这将防止您的程序中的错误影响您的个人电子邮件账户(例如,通过删除电子邮件或意外向联系人发送垃圾邮件)。首先,通过注释掉实际发送或删除电子邮件的代码,并用临时的*print() *调用替换它,进行一次干运行是一个好主意。这样,您可以在实际运行之前测试您的程序。
Gmail API
Gmail 拥有近三分之一的电子邮件客户端市场份额,很可能您至少有一个 Gmail 电子邮件地址。由于 Gmail 的额外安全性和反垃圾邮件措施,通过 Python 标准库中的smtplib和imaplib模块来控制 Gmail 账户不如通过 EZGmail 模块更好。我编写了 EZGmail,以便在官方 Gmail API 之上运行,并提供使从 Python 使用 Gmail 变得容易的功能。有关安装 EZGmail 的完整说明,请参阅附录 A。
启用 API
在编写代码之前,你必须首先在 gmail.com 上注册一个 Gmail 电子邮件账户。然后,你必须通过 Google Cloud 控制台在 console.cloud.google.com 为你的账户设置 Gmail API。这些步骤与第十五章中详细说明的设置 EZSheets 的步骤相同,因此我不会在本章中重复它们。创建一个新的项目,并确保启用 Gmail API 而不是 Google Sheets API。在 OAuth 同意屏幕配置的第 2 步中,添加 mail.google.com 范围,以便你的 Python 脚本可以读取和发送电子邮件。完成之后,你应该会有一个凭证文件和一个令牌文件。
然后,在交互式 shell 中,输入以下代码:
>>> import ezgmail
>>> ezgmail.init()
如果没有出现错误,说明 EZGmail 已经正确安装。
发送邮件
一旦 EZGmail 设置完成,你应该能够通过单个函数调用发送电子邮件:
>>> import ezgmail
>>> ezgmail.send('recipient@example.com', 'Subject line', 'Body of the email')
如果你想在电子邮件中附加文件,你可以向 send() 函数提供一个额外的列表参数:
>>> ezgmail.send('recipient@example.com', 'Subject line', 'Body of the email',
['attachment1.jpg', 'attachment2.mp3'])
注意,作为其安全和反垃圾邮件功能的一部分,Gmail 可能不会发送具有完全相同文本的重复电子邮件(因为这些可能是垃圾邮件)或包含 .exe 或 .zip 文件附件的电子邮件(因为这些可能是病毒)。
你还可以提供可选的关键字参数 cc 和 bcc 来发送抄送和密送:
>>> import ezgmail
>>> ezgmail.send('recipient@example.com', 'Subject line', 'Body of the
email', cc='friend@example.com', bcc='otherfriend@example.com,
someoneelse@example.com')
如果你需要记住 token.json 文件配置的是哪个 Gmail 地址,你可以检查 ezgmail.EMAIL_ADDRESS:
>>> import ezgmail
>>> ezgmail.EMAIL_ADDRESS
'example@gmail.com'
一定要像对待你的密码一样对待 token.json 文件。如果其他人获得了这个文件,他们可以访问你的 Gmail 账户(尽管他们无法更改你的 Gmail 密码)。要撤销之前发行的 token.json 文件,请返回 Google Cloud 控制台并删除受损害令牌的凭证。在你可以继续使用 EZGmail 之前,你需要重复设置步骤来生成新的凭证和令牌文件。
读取邮件
Gmail 将彼此回复的电子邮件组织成对话线程。当你通过网页浏览器或应用程序登录 Gmail 时,你实际上是在查看电子邮件线程,而不是单个电子邮件(即使线程中只有一个电子邮件)。
EZGmail 有 GmailThread 和 GmailMessage 对象,分别代表对话线程和单个电子邮件。一个 GmailThread 对象有一个 messages 属性,其中包含一个 GmailMessage 对象的列表。unread() 函数返回一个包含 25 个最新未读电子邮件的 GmailThread 对象列表,然后可以将这些对象传递给 ezgmail.summary() 来打印该列表中对话线程的摘要:
>>> import ezgmail
>>> unread_threads = ezgmail.unread() # List of GmailThread objects
>>> ezgmail.summary(unread_threads)
Al, Jon - Do you want to watch RoboCop this weekend? - Dec 09
Jon - Thanks for stopping me from buying Bitcoin. - Dec 09
summary() 函数对于显示电子邮件线程的快速摘要很有用,但为了访问特定的消息(以及消息的部分),你需要检查 GmailThread 对象的 messages 属性。messages 属性包含构成线程的 GmailMessage 对象的列表,这些对象具有 subject、body、timestamp、sender 和 recipient 属性,它们描述了电子邮件:
>>> len(unread_threads)
2
>>> str(unread_threads[0])
"<GmailThread len=2 snippet= Do you want to watch RoboCop this weekend?'>"
>>> len(unread_threads[0].messages)
2
>>> str(unread_threads[0].messages[0])
"<GmailMessage from='Al Sweigart <al@inventwithpython.com>' to='Jon Doe
<example@gmail.com>' timestamp=datetime.datetime(2026, 12, 9, 13, 28, 48)
subject='RoboCop' snippet='Do you want to watch RoboCop this weekend?'>"
>>> unread_threads[0].messages[0].subject
'RoboCop'
>>> unread_threads[0].messages[0].body
'Do you want to watch RoboCop this weekend?\r\n'
>>> unread_threads[0].messages[0].timestamp
datetime.datetime(2026, 12, 9, 13, 28, 48)
>>> unread_threads[0].messages[0].sender
'Al Sweigart <al@inventwithpython.com>'
>>> unread_threads[0].messages[0].recipient
'Jon Doe <example@gmail.com>'
要检索超过 25 封最新未读电子邮件,请传递一个整数作为 maxResults 关键字参数。例如,ezgmail.unread(maxResults=50) 将返回 50 封最新未读电子邮件。
与 ezgmail.unread() 函数类似,ezgmail.recent() 函数将返回你 Gmail 账户中最近的 25 个线程:
>>> recent_threads = ezgmail.recent()
>>> len(recent_threads)
25
>>> recent_threads = ezgmail.recent(maxResults=100)
>>> len(recent_threads)
46
你可以传递一个可选的 maxResults 关键字参数来更改此限制。
搜索邮件
除了使用 ezgmail.unread() 和 ezgmail.recent(),你还可以通过调用 ezgmail.search() 来搜索特定的电子邮件,就像你在 Gmail 搜索框中输入查询一样:
>>> result_threads = ezgmail.search('RoboCop')
>>> len(result_threads)
1
>>> ezgmail.summary(result_threads)
Al, Jon - Do you want to watch RoboCop this weekend? - Dec 09
之前的 search() 调用应该产生与你在图 20-1 中将 RoboCop 输入到搜索框时相同的结果。

图 20-1:在 Gmail 网站上搜索 RoboCop 电子邮件
与 unread() 和 recent() 类似,search() 函数返回一个 GmailThread 对象的列表。你还可以将任何可以输入到搜索框中的特殊搜索操作符传递给 search() 函数,例如以下内容:
'label:UNREAD' 对于未读电子邮件
'from:al@inventwithpython.com' 对于来自 al@inventwithpython.com 的电子邮件
'subject:hello' 对于主题中包含“hello”的电子邮件
'has:attachment' 对于带有文件附件的电子邮件
你可以在 support.google.com/mail/answer/7190 查看完整的搜索操作符列表。
下载附件
GmailMessage 对象有一个 attachments 属性,它是一个包含消息附件文件名的列表。你可以将这些名称中的任何一个传递给 GmailMessage 对象的 downloadAttachment() 方法来下载文件。你也可以使用 downloadAllAttachments() 方法一次性下载所有文件。默认情况下,EZGmail 将附件保存到当前工作目录,但你也可以将额外的 downloadFolder 关键字参数传递给 downloadAttachment() 和 downloadAllAttachments() 方法。以下是一个示例:
>>> import ezgmail
>>> threads = ezgmail.search('vacation photos')
>>> threads[0].messages[0].attachments
['tulips.jpg', 'canal.jpg', 'bicycles.jpg']
>>> threads[0].messages[0].downloadAttachment('tulips.jpg')
>>> threads[0].messages[0].downloadAllAttachments(downloadFolder='vacation2026')
['tulips.jpg', 'canal.jpg', 'bicycles.jpg']
如果附件的文件名已经存在,下载的附件将自动覆盖它。
EZGmail 包含其他功能,你可以在 github.com/asweigart/ezgmail 找到完整的文档。
SMS 电子邮件网关
人们更可能靠近他们的智能手机而不是电脑,因此短信通常是发送即时通知比电子邮件更可靠的途径。此外,短信通常更短,因此更有可能有人会抽出时间阅读它们。发送短信最简单的方法是使用短消息服务(SMS)电子邮件网关,这是一种手机服务提供商设置的电子邮件服务器,用于接收通过电子邮件发送的短信,然后将它们转发给收件人作为短信。
你可以使用 EZGmail 或 smtplib 模块编写的程序来发送这些电子邮件。电话号码和电话公司的电子邮件服务器构成了收件人的电子邮件地址。例如,要向电话号码为 212-555-1234 的威瑞森客户发送短信,你将向 2125551234@vtext.com 发送电子邮件。电子邮件的主题和正文将显示在短信的正文部分。
你可以通过在网络上搜索“sms email gateway 提供商名称”来找到手机服务提供商的 SMS 电子邮件网关。表 20-1 列出了几个流行提供商的网关。许多提供商为 SMS 和多媒体消息服务(MMS)设有单独的电子邮件服务器,这限制了消息的字符数为 160 个,而 MMS 没有字符限制。如果你想发送照片,你必须使用 MMS 网关并将文件附加到电子邮件中。
如果你不知道收件人的手机服务提供商,你可以尝试使用运营商查找网站来搜索它。找到这些网站的最佳方式是通过网络搜索“为号码查找手机服务提供商”。许多这些网站允许你免费查找号码(尽管他们将通过他们的 API 查找数百或数千个电话号码来向你收费)。
表 20-1:手机服务提供商的 SMS 电子网关
| 手机服务提供商 | SMS 网关 | MMS 网关 |
|---|---|---|
| AT&T | number@txt.att.net | number@mms.att.net |
| 激发移动 | number@sms.myboostmobile.com | 与 SMS 相同 |
| Cricket | number@sms.cricketwireless.net | number@mms.cricketwireless.net |
| Google Fi | number@msg.fi.google.com | 与 SMS 相同 |
| Metro PCS | number@mymetropcs.com | 与 SMS 相同 |
| 共和国无线 | number@text.republicwireless.com | 与 SMS 相同 |
| Sprint(现为 T-Mobile) | number@messaging.sprintpcs.com | number@pm.sprint.com |
| T-Mobile | number@tmomail.net | 与 SMS 相同 |
| 美国蜂窝 | number@email.uscc.net | number@mms.uscc.net |
| 威瑞森 | number@vtext.com | number@vzwpix.com |
| 维珍移动 | number@vmobl.com | number@vmpix.com |
| Xfinity Mobile | number@vtext.com | number@mypixmessages.com |
虽然 SMS 电子邮件网关免费且易于使用,但它们有几个主要的缺点:
-
你无法保证短信会及时到达,或者根本不会到达。
-
你无法知道短信是否未能送达。
-
文本收件人无法回复。
-
如果你发送太多电子邮件,SMS 网关可能会阻止你,而且无法找出“太多”是多少。
-
今天短信网关成功发送了一条短信并不意味着明天它还会工作。
当你需要偶尔发送非紧急消息时,通过短信网关发送短信是理想的。如果你想有一个更可靠的发送短信的方法,尤其是在大量发送时,你可以使用像 Twilio 这样的电信服务提供商的 API。这些服务通常需要订阅或使用费,并且你可能需要提交申请才能使用它们。每个国家的规定可能不同,并且会随时间变化。
发送短信文本的另一种选择是使用下一节将解释的免费推送通知服务。
推送通知
HTTP pub-sub 通知服务允许你通过 HTTP 网络请求在互联网上发送和接收短期的、一次性的消息。第十三章介绍了如何使用 Requests 库发送 HTTP 请求,我们在这里将使用它来与免费在线服务 ntfy(发音为“notify”,并且始终以小写形式书写)交互,网址为ntfy.sh。ntfy 服务是免费的,并且不需要任何注册或登录。
在我们开始之前,请在你的手机上安装 ntfy 应用,以便你可以接收通知。这个应用是免费的,可以在 Android 和 iPhone 的应用商店中找到。你还可以通过访问ntfy.sh/app在你的网络浏览器中接收通知。
这些应用会检查 ntfy 服务是否有发送到某个主题的消息。你可以将主题视为聊天室或群聊的名称。世界上任何人都可以向一个主题发送消息,世界上任何人都可以订阅一个主题来阅读这些消息。如果你想只给自己发送消息,请使用包含随机字母的保密主题。将这个主题名称视为密码,并且只与那些你打算阅读消息的人分享。本章将在示例代码中使用AlSweigartZPgxBQ42主题,尽管我建议你使用包含随机字母和数字后缀的自己的保密主题。主题是区分大小写的,即使你保密主题,也不要使用 ntfy 发送敏感信息,如密码或信用卡号码。
发送通知
向订阅了某个主题的所有人发送推送通知,只需向 ntfy 网络服务器发送一个 HTTP 请求即可。这意味着你可以完全使用 Requests 库来完成这项操作。你不需要安装任何特定于 ntfy 的包。
要发送请求,请在交互式 shell 中输入以下内容。将本章中使用的整个示例中的AlSweigartZPgxBQ42示例主题替换为你自己的随机、秘密主题:
>>> import requests
>>> requests.post('https://ntfy.sh/AlSweigartZPgxBQ42', 'Hello, world!')
<Response [200]>
注意,我们调用requests.post()来发送 POST HTTP 请求以发送通知。这与第十三章中介绍的用于下载网页的requests.get()函数不同。
订阅了主题 AlSweigartZPgxBQ42 的任何人将在几秒钟内收到“Hello, world!”的消息(尽管有时消息可能会延迟几分钟)。你也可以自己查看这些消息ntfy.sh/AlSweigartZPgxBQ42。
ntfy 服务有一些限制。免费用户每天最多只能发送 250 条消息,消息大小最多为 4,096 字节。向多个主题发送大量消息可能会导致你的 IP 地址暂时被封锁。你可以在 ntfy 网站上获得付费账户来提高这些限制。付费的 ntfy 账户可以设置保留主题并限制谁可以发布到它们。如果你没有权限发布到保留主题,你的 requests.post() 函数调用将得到 <Response [403]> 响应。
在 4,096 字节限制内,你的消息可以采用任何格式。请注意,没有方法可以确定谁向主题发送了消息,因此你可能想在消息文本中包含“收件人”和“发件人”标签。更好的做法是,你可以使用 JSON 或第十八章中介绍的其他数据序列化格式来实现这一点。
如果你希望你的 Python 程序能向你发送通知,一旦你在手机上安装了 ntfy 应用并订阅了主题,你只需要这两行代码。你可以自由地运行你的 Python 程序,去喝杯咖啡,知道你的程序完成无聊的任务后,你的手机会收到通知。
传输元数据
尽管你发送的消息是一个自由形式的文本字符串值,但 ntfy 可以选择性地附加元数据值,例如标题、优先级和标签,到每条消息。
标题类似于电子邮件的主题行,大多数应用都会以更大的字体在消息文本上方显示它。优先级从 1(最低优先级)到 5(最高优先级),默认值为 3。更高的优先级并不会使消息发送得更快;它只是允许订阅者配置他们的通知应用,只显示特定优先级或更高优先级的消息。标签是订阅者可以用来过滤消息的关键词。标签也可以是显示在消息标题旁边的表情符号的名称。你可以在docs.ntfy.sh/publish/#tags-emojis找到这些表情符号的列表。
这些元数据作为 HTTP 请求的头部信息包含在内,因此你需要将这些元数据作为字典传递给 headers 关键字参数。在交互式外壳中输入以下内容以发送带有元数据的消息:
>>> import requests
>>> requests.post('https://ntfy.sh/AlSweigartZPgxBQ42', 'The rent is too high!',
headers={'Title':'Important: Read this!', 'Tags': 'warning,neutral_face', 'Priority':'5'})
<Response [200]>
这些功能对于在手机应用上阅读通知的人类用户来说很有用。然而,你也可以编写代码,使 Python 脚本能够接收推送通知,我们将在下一节中讨论这一点。
接收通知
您的 Python 程序也可以通过使用 Requests 库发送 HTTP 请求来读取特定主题发布的消息。使用前几节中的代码发送通知消息,然后使用与通知相同的主题在交互式 shell 中输入以下内容:
>>> import requests
>>> resp = requests.get('https://ntfy.sh/AlSweigartZPgxBQ42/json?poll=1')
>>> resp.text
'{"id":"1jnHKeFNqwnS","time":1797823340,"expires":1797866540,"event":
"message","topic":"AlSweigartZPgxBQ42","message":"Hello, world!"}\n
{"id":"wZ22cjyKXw1F","time":1797823712,"expires":1797866912,"event":
"message","topic":"AlSweigartZPgxBQ42","title":"Important: Read this!",
"message":"The rent is too high!","priority":5,"tags":["warning",
"neutral_face"]}\n'
注意,我们调用requests.get()函数来接收通知,而不是在发送通知时使用的requests.post()函数。此外,URL 以/json?poll=1结尾。
这是通过轮询来检索消息,它返回服务器拥有的某个主题的所有消息。还有用于检索 ntfy 消息的流式传输方法,但轮询的代码最简单。您还可以在poll=1之后添加一个*since* URL 参数,以以下标准之一获取消息:
since=10m 检索过去 10 分钟内的主题所有消息。您还可以使用s表示秒和h表示小时,例如since=2h30m表示过去两小时半的所有消息。
since=1737866912 检索自 Unix 纪元时间戳1737866912以来的所有消息。这种时间戳由time.time()返回,表示自 1970 年 1 月 1 日以来的秒数。第十九章涵盖了与时间相关的函数。
since=wZ22cjyKXw1F 检索 ID 为'wZ22cjyKXw1F'的消息之后的所有消息。
使用与号(&)分隔额外的 URL 参数。例如,传递 URLntfy.sh/AlSweigartZPgxBQ42/json?poll=1&since=10m检索过去 10 分钟内AlSweigartZPgxBQ42主题的所有消息。为了减少对 ntfy 服务器的负载,您应该每分钟或每隔几分钟轮询一次,而不是在无限循环中尽可能快地轮询。如果您需要立即接收通知,请查阅在线文档了解订阅通知流的说明。
这个 HTTP 响应的文本不是有效的 JSON,因为它在每一行文本中都包含一个 JSON 对象,而不是一个 JSON 对象,所以我们使用splitlines()字符串方法在解析之前将它们分开(如第十八章所述)。继续上一个交互式 shell 示例:
>>> import json
>>> notifications = []
>>> for json_text in resp.text.splitlines():
... notifications.append(json.loads(json_text))
...
>>> notifications[0]['message']
'Hello, world!'
>>> notifications[1]['message']
'The rent is too high!'
json.loads()函数将 ntfy 中的 JSON 文本转换为 Python 字典。让我们看看每个键值对:
"id":"wZ22cjyKXw1F" 'id'键的值是一个唯一的标识字符串,即使通知文本相同,也可以帮助区分多个通知。
"time":1797823712 'time'键的值是通知创建时的 Unix 纪元时间戳。调用str(datetime.datetime.fromtimestamp(1797823712))返回可读字符串'2026-12-20 21:28:32'。
"expires":1797866912 'expires'键的值是通知将从 ntfy 服务器删除时的 Unix 纪元时间戳。
"event":"message" 'event' 键的值可以是 'message'、'open'、'keepalive' 或 'poll_request'。这些事件类型在在线文档中有解释,但到目前为止,你可能只对 'message' 事件感兴趣。
"topic":"AlSweigartZPgxBQ42" URL 中的主题部分在 'topic' 键的值中重复。
"title":"Important: Read this!" 如果通知有标题,将会有一个带有字符串值的 'title' 键。
"message":"The rent is too high!" 'message' 键的值是通知文本的字符串。
"priority":5 如果通知有优先级,将会有一个带有整数值(从 1 到 5)的 'priority' 键。
"tags":["warning","neutral_face"] 如果通知有标签,将会有一个带有它作为字符串值列表的 'tags' 键。这些字符串值可能是要显示的 emoji 字符名称。
通过读取这个字典中的值,您的 Python 程序可以使用 Requests 库接收用户或其他 Python 脚本发出的通知。ntfy 服务是制作能够通过互联网相互通信的程序(尽管请记住免费用户每天 250 条消息的限制)中最简单的方法之一。
摘要
我们通过互联网和手机网络以数十种不同的方式相互沟通,但电子邮件和短信占主导地位。您的程序可以通过这些渠道进行通信,这为它们提供了强大的新通知功能。
作为安全和垃圾邮件预防措施,一些流行的电子邮件服务,如 Gmail,不允许您使用标准的 SMTP 和 IMAP 协议来访问其服务。EZGmail 包作为 Gmail API 的便捷包装器,允许您的 Python 脚本访问您的 Gmail 账户。我强烈建议您为您的脚本设置一个单独的 Gmail 账户,这样您程序中的潜在错误就不会影响您的个人 Gmail 账户。
短信与电子邮件略有不同,因为与电子邮件不同,您需要不仅仅是互联网连接来发送短信。您可以使用 SMS 电子邮件网关从电子邮件账户发送短信,但这需要您知道手机用户的电信运营商,并且这不是发送消息的可靠方式。如果您只是向自己发送短消息,您可以使用ntfy.sh上的推送通知系统,然后在您的手机上安装 ntfy 应用,这样您的 Python 脚本就可以向主题订阅者发送消息。
在您的技能集中拥有这些模块,您将能够编程特定条件,在这些条件下,您的程序应该发送通知或提醒。现在,您的程序将具有远远超出它们运行的计算机的覆盖范围!
实践问题
-
使用 Gmail API 时,凭证和令牌文件是什么?
-
在 Gmail API 中,“thread”对象和“message”对象有什么区别?
-
使用
ezgmail.search(),如何找到带有附件的电子邮件? -
使用短信电子邮件网关发送短信有哪些缺点?
-
使用 Python 库向 ntfy 发送和接收通知的有哪些?
实践程序
为了练习,编写程序来完成以下任务。
伞具提醒
第十三章向你展示了如何使用requests模块从weather.gov爬取数据。编写一个程序,在早上醒来之前运行,检查当天是否有雨的预报。如果有,让程序通过短信给你发送一个提醒,在出门前带上伞。
自动取消订阅
编写一个程序,扫描你的电子邮件账户,找到所有邮件中的取消订阅链接,并在浏览器中自动打开它们。这个程序将需要登录到你的 Gmail 账户。你可以使用 Beautiful Soup(在第十三章中介绍)来检查任何单词“取消订阅”出现在 HTML 链接标签内的实例。一旦你有了这些 URL 的列表,你可以使用webbrowser.open()自动在浏览器中打开所有这些链接。
你仍然需要手动完成任何额外的步骤来从这些列表中取消订阅。在大多数情况下,这涉及到点击一个链接进行确认。但这个脚本可以让你免于浏览所有邮件寻找取消订阅链接。
基于电子邮件的电脑控制
编写一个程序,每 15 分钟检查一次电子邮件或 ntfy 账户,看看是否有你发送的指令,并自动执行这些指令。例如,BitTorrent 是一个对等下载系统。使用免费的 BitTorrent 软件,如 qBittorrent,你可以在家里的电脑上下载大型的媒体文件。如果你向程序发送一个(完全合法,绝对不是盗版的)BitTorrent 链接,程序最终会检查其电子邮件或查找 ntfy 通知,找到这条消息,提取链接,然后启动 qBittorrent 开始下载文件。这样,你可以在离开家的时候让你的电脑开始下载,并在你回家时完成(完全合法,绝对不是盗版的)下载。
第十九章介绍了如何使用subprocess.Popen()函数在你的电脑上启动程序。例如,以下调用将启动 qBittorrent 程序,以及一个种子文件:
qbProcess = subprocess.Popen(['C:\\Program Files (x86)\\qBittorrent\\
qbittorrent.exe', 'shakespeare_complete_works.torrent'])
当然,你希望程序确保邮件来自你。特别是,你可能希望要求邮件包含密码,因为黑客伪造邮件中的“发件人”地址相对容易。程序应该删除它找到的邮件,这样每次检查电子邮件账户时就不会重复指令。作为一个额外功能,让程序在执行每个命令时通过电子邮件或短信给你发送一个确认。由于你不会坐在运行程序的电脑前,使用日志功能(见第五章)来写入一个文本文件日志是一个好主意,这样你可以在出现错误时进行检查。
qBittorrent 程序(以及其他 BitTorrent 应用程序)有一个功能,可以在下载完成后自动退出。第十九章解释了如何使用wait()方法为Popen对象确定已启动的应用程序何时退出。wait()方法调用将阻塞,直到 qBittorrent 停止,然后你的程序可以通过电子邮件或短信通知你下载已完成。
你可以为这个项目添加许多可能的功能。如果你遇到了困难,你可以从nostarch.com/automate-boring-stuff-python-3rd-edition下载这个程序的示例实现。
Gmail API
Gmail 拥有接近三分之一的电子邮件客户端市场份额,你很可能至少有一个 Gmail 电子邮件地址。由于 Gmail 的额外安全和反垃圾邮件措施,通过 EZGmail 模块而不是 Python 标准库中的smtplib和imaplib模块来控制 Gmail 账户会更好。我编写了 EZGmail,以便在官方 Gmail API 之上运行,并提供使从 Python 使用 Gmail 变得容易的功能。有关安装 EZGmail 的完整说明,请参阅附录 A。
启用 API
在编写代码之前,你必须首先在gmail.com上注册一个 Gmail 电子邮件账户。然后,你必须通过 Google Cloud 控制台在console.cloud.google.com为你的账户设置 Gmail API。这些步骤与第十五章中详细说明的设置 EZSheets 的步骤相同,因此我不会在本章中重复它们。创建一个新的项目,并确保启用 Gmail API 而不是 Google Sheets API。在 OAuth 同意屏幕配置的第 2 步中,添加mail.google.com作用域,以便你的 Python 脚本可以读取和发送电子邮件。完成操作后,你应该有一个凭证文件和一个令牌文件。
然后,在交互式 shell 中,输入以下代码:
>>> import ezgmail
>>> ezgmail.init()
如果没有出现错误,说明 EZGmail 已经正确安装。
发送邮件
一旦 EZGmail 配置完成,你应该能够通过单个函数调用发送电子邮件:
>>> import ezgmail
>>> ezgmail.send('recipient@example.com', 'Subject line', 'Body of the email')
如果你想在电子邮件中附加文件,你可以向send()函数提供一个额外的列表参数:
>>> ezgmail.send('recipient@example.com', 'Subject line', 'Body of the email',
['attachment1.jpg', 'attachment2.mp3'])
注意,作为其安全和反垃圾邮件功能的一部分,Gmail 可能不会发送重复的、文本完全相同的电子邮件(因为这些很可能是垃圾邮件),或者包含.exe或.zip文件附件的电子邮件(因为这些可能是病毒)。
你还可以提供可选的关键字参数cc和bcc来发送抄送和密送:
>>> import ezgmail
>>> ezgmail.send('recipient@example.com', 'Subject line', 'Body of the
email', cc='friend@example.com', bcc='otherfriend@example.com,
someoneelse@example.com')
如果你需要记住token.json文件配置的是哪个 Gmail 地址,你可以检查ezgmail.EMAIL_ADDRESS:
>>> import ezgmail
>>> ezgmail.EMAIL_ADDRESS
'example@gmail.com'
请务必像对待你的密码一样对待 token.json 文件。如果其他人获得了此文件,他们可以访问你的 Gmail 账户(尽管他们无法更改你的 Gmail 密码)。要撤销之前签发的 token.json 文件,请返回 Google Cloud 控制台并删除受损害令牌的凭证。在你可以继续使用 EZGmail 之前,你需要重复设置步骤来生成新的凭证和令牌文件。
阅读邮件
Gmail 将彼此回复的电子邮件组织成对话线程。当你通过网页浏览器或应用程序登录 Gmail 时,你实际上是在查看电子邮件线程,而不是单个电子邮件(即使线程中只有一个电子邮件)。
EZGmail 有 GmailThread 和 GmailMessage 对象来分别表示对话线程和单个电子邮件。一个 GmailThread 对象有一个 messages 属性,它包含一个 GmailMessage 对象的列表。unread() 函数返回一个 GmailThread 对象的列表,这些对象对应于 25 个最近的未读电子邮件,然后可以将这些对象传递给 ezgmail.summary() 来打印该列表中对话线程的摘要:
>>> import ezgmail
>>> unread_threads = ezgmail.unread() # List of GmailThread objects
>>> ezgmail.summary(unread_threads)
Al, Jon - Do you want to watch RoboCop this weekend? - Dec 09
Jon - Thanks for stopping me from buying Bitcoin. - Dec 09
summary() 函数对于显示电子邮件线程的快速摘要很有用,但为了访问特定的邮件(以及邮件的某些部分),你需要检查 GmailThread 对象的 messages 属性。messages 属性包含构成线程的 GmailMessage 对象的列表,这些对象具有 subject、body、timestamp、sender 和 recipient 属性,它们描述了电子邮件:
>>> len(unread_threads)
2
>>> str(unread_threads[0])
"<GmailThread len=2 snippet= Do you want to watch RoboCop this weekend?'>"
>>> len(unread_threads[0].messages)
2
>>> str(unread_threads[0].messages[0])
"<GmailMessage from='Al Sweigart <al@inventwithpython.com>' to='Jon Doe
<example@gmail.com>' timestamp=datetime.datetime(2026, 12, 9, 13, 28, 48)
subject='RoboCop' snippet='Do you want to watch RoboCop this weekend?'>"
>>> unread_threads[0].messages[0].subject
'RoboCop'
>>> unread_threads[0].messages[0].body
'Do you want to watch RoboCop this weekend?\r\n'
>>> unread_threads[0].messages[0].timestamp
datetime.datetime(2026, 12, 9, 13, 28, 48)
>>> unread_threads[0].messages[0].sender
'Al Sweigart <al@inventwithpython.com>'
>>> unread_threads[0].messages[0].recipient
'Jon Doe <example@gmail.com>'
要检索超过 25 个最近的未读电子邮件,请传递一个整数作为 maxResults 关键字参数。例如,ezgmail.unread(maxResults=50) 将返回 50 个最近的未读电子邮件。
与 ezgmail.unread() 函数一样,ezgmail.recent() 函数将返回你 Gmail 账户中最近的 25 个线程:
>>> recent_threads = ezgmail.recent()
>>> len(recent_threads)
25
>>> recent_threads = ezgmail.recent(maxResults=100)
>>> len(recent_threads)
46
你可以传递一个可选的 maxResults 关键字参数来更改此限制。
搜索邮件
除了使用 ezgmail.unread() 和 ezgmail.recent() 之外,你还可以通过调用 ezgmail.search() 来搜索特定的电子邮件,就像你将查询输入到 Gmail 搜索框中一样:
>>> result_threads = ezgmail.search('RoboCop')
>>> len(result_threads)
1
>>> ezgmail.summary(result_threads)
Al, Jon - Do you want to watch RoboCop this weekend? - Dec 09
上一个 search() 调用应该产生与你在搜索框中输入 RoboCop 相同的结果,如图 20-1 所示。

图 20-1:在 Gmail 网站上搜索 RoboCop 电子邮件
与 unread() 和 recent() 一样,search() 函数返回一个 GmailThread 对象的列表。你也可以将任何可以输入到搜索框中的特殊搜索运算符传递给 search() 函数,如下所示:
'label:UNREAD' - 未读电子邮件
'from:al@inventwithpython.com' - 来自 al@inventwithpython.com 的电子邮件
'subject:hello' - 主题中包含“hello”的电子邮件
'has:attachment' - 用于带有文件附件的电子邮件
你可以在 support.google.com/mail/answer/7190 查看搜索操作符的完整列表。
下载附件
GmailMessage 对象有一个 attachments 属性,它是一个包含消息附加文件文件名的列表。你可以将这些名称中的任何一个传递给 GmailMessage 对象的 downloadAttachment() 方法来下载文件。你还可以使用 downloadAllAttachments() 一次性下载所有文件。默认情况下,EZGmail 将附件保存到当前工作目录,但你也可以向 downloadAttachment() 和 downloadAllAttachments() 传递额外的 downloadFolder 关键字参数。以下是一个示例:
>>> import ezgmail
>>> threads = ezgmail.search('vacation photos')
>>> threads[0].messages[0].attachments
['tulips.jpg', 'canal.jpg', 'bicycles.jpg']
>>> threads[0].messages[0].downloadAttachment('tulips.jpg')
>>> threads[0].messages[0].downloadAllAttachments(downloadFolder='vacation2026')
['tulips.jpg', 'canal.jpg', 'bicycles.jpg']
如果存在与附件文件名相同的文件,下载的附件将自动覆盖它。
EZGmail 包含其他功能,你可以在 github.com/asweigart/ezgmail 找到完整的文档。
启用 API
在编写代码之前,你必须首先在 gmail.com 上注册一个 Gmail 电子邮件账户。然后,你必须通过 Google Cloud 控制台在 console.cloud.google.com 为你的账户设置 Gmail API。这些步骤与第十五章中详细说明的设置 EZSheets 的步骤相同,因此我不会在本章中重复它们。创建一个新的项目,并确保启用 Gmail API 而不是 Google Sheets API。在 OAuth 同意屏幕配置的第 2 步中,添加 mail.google.com 范围,以便你的 Python 脚本可以读取和发送电子邮件。完成操作后,你应该有一个凭证文件和一个令牌文件。
然后,在交互式 shell 中输入以下代码:
>>> import ezgmail
>>> ezgmail.init()
如果没有出现错误,则表示 EZGmail 已正确安装。
发送邮件
一旦配置了 EZGmail,你应该能够通过单个函数调用发送电子邮件:
>>> import ezgmail
>>> ezgmail.send('recipient@example.com', 'Subject line', 'Body of the email')
如果你想将文件附加到电子邮件中,你可以向 send() 函数提供一个额外的列表参数:
>>> ezgmail.send('recipient@example.com', 'Subject line', 'Body of the email',
['attachment1.jpg', 'attachment2.mp3'])
注意,作为其安全性和反垃圾邮件功能的一部分,Gmail 可能不会发送具有完全相同文本的重复电子邮件(因为这些可能是垃圾邮件)或包含 .exe 或 .zip 文件附件的电子邮件(因为它们可能是病毒)。
你还可以提供可选的关键字参数 cc 和 bcc 来发送抄送和密送:
>>> import ezgmail
>>> ezgmail.send('recipient@example.com', 'Subject line', 'Body of the
email', cc='friend@example.com', bcc='otherfriend@example.com,
someoneelse@example.com')
如果你需要记住为哪个 Gmail 地址配置了 token.json 文件,你可以检查 ezgmail.EMAIL_ADDRESS:
>>> import ezgmail
>>> ezgmail.EMAIL_ADDRESS
'example@gmail.com'
请务必像对待您的密码一样对待 token.json 文件。如果其他人获得了此文件,他们可以访问您的 Gmail 账户(尽管他们无法更改您的 Gmail 密码)。要撤销之前签发的 token.json 文件,请返回 Google Cloud 控制台并删除受损害令牌的凭证。在您可以使用 EZGmail 之前,您需要重复设置步骤以生成新的凭证和令牌文件。
阅读邮件
Gmail 将彼此回复的电子邮件组织成对话线程。当您通过网页浏览器或应用程序登录 Gmail 时,您实际上是在查看电子邮件线程,而不是单个电子邮件(即使线程中只有一个电子邮件)。
EZGmail 有 GmailThread 和 GmailMessage 对象来分别表示对话线程和单个电子邮件。一个 GmailThread 对象有一个 messages 属性,其中包含 GmailMessage 对象的列表。unread() 函数返回一个 GmailThread 对象列表,表示 25 个最近的未读电子邮件,然后可以将这些对象传递给 ezgmail.summary() 来打印该列表中对话线程的摘要:
>>> import ezgmail
>>> unread_threads = ezgmail.unread() # List of GmailThread objects
>>> ezgmail.summary(unread_threads)
Al, Jon - Do you want to watch RoboCop this weekend? - Dec 09
Jon - Thanks for stopping me from buying Bitcoin. - Dec 09
summary() 函数非常适合显示电子邮件线程的快速摘要,但若要访问特定的消息(以及消息的部分),您需要检查 GmailThread 对象的 messages 属性。messages 属性包含构成线程的 GmailMessage 对象列表,这些对象具有 subject、body、timestamp、sender 和 recipient 属性,用于描述电子邮件:
>>> len(unread_threads)
2
>>> str(unread_threads[0])
"<GmailThread len=2 snippet= Do you want to watch RoboCop this weekend?'>"
>>> len(unread_threads[0].messages)
2
>>> str(unread_threads[0].messages[0])
"<GmailMessage from='Al Sweigart <al@inventwithpython.com>' to='Jon Doe
<example@gmail.com>' timestamp=datetime.datetime(2026, 12, 9, 13, 28, 48)
subject='RoboCop' snippet='Do you want to watch RoboCop this weekend?'>"
>>> unread_threads[0].messages[0].subject
'RoboCop'
>>> unread_threads[0].messages[0].body
'Do you want to watch RoboCop this weekend?\r\n'
>>> unread_threads[0].messages[0].timestamp
datetime.datetime(2026, 12, 9, 13, 28, 48)
>>> unread_threads[0].messages[0].sender
'Al Sweigart <al@inventwithpython.com>'
>>> unread_threads[0].messages[0].recipient
'Jon Doe <example@gmail.com>'
要检索超过 25 个最近的未读电子邮件,请传递一个整数作为 maxResults 关键字参数。例如,ezgmail.unread(maxResults=50) 将返回 50 个最近的未读电子邮件。
与 ezgmail.unread() 函数类似,ezgmail.recent() 函数将返回您 Gmail 账户中最近的 25 个线程:
>>> recent_threads = ezgmail.recent()
>>> len(recent_threads)
25
>>> recent_threads = ezgmail.recent(maxResults=100)
>>> len(recent_threads)
46
您可以通过传递一个可选的 maxResults 关键字参数来更改此限制。
搜索邮件
除了使用 ezgmail.unread() 和 ezgmail.recent(),您还可以通过调用 ezgmail.search() 来搜索特定的电子邮件,就像您在 Gmail 搜索框中输入查询一样:
>>> result_threads = ezgmail.search('RoboCop')
>>> len(result_threads)
1
>>> ezgmail.summary(result_threads)
Al, Jon - Do you want to watch RoboCop this weekend? - Dec 09
前一个 search() 调用应该会产生与您在搜索框中输入 RoboCop 时相同的结果,如图 20-1 所示。

图 20-1:在 Gmail 网站上搜索 RoboCop 电子邮件
与 unread() 和 recent() 类似,search() 函数返回一个 GmailThread 对象列表。您还可以将任何可以输入到搜索框中的特殊搜索运算符传递给 search() 函数,如下所示:
'label:UNREAD' - 用于未读电子邮件
'from:al@inventwithpython.com' - 用于来自 al@inventwithpython.com 的电子邮件
'subject:hello' - 用于主题中包含“hello”的电子邮件
'has:attachment' - 用于包含文件附件的电子邮件
您可以在support.google.com/mail/answer/7190找到完整的搜索操作符列表。
下载附件
GmailMessage对象有一个attachments属性,它是一个包含消息附加文件文件名的列表。您可以将这些名称中的任何一个传递给GmailMessage对象的downloadAttachment()方法来下载文件。您还可以使用downloadAllAttachments()一次性下载所有文件。默认情况下,EZGmail 将附件保存到当前工作目录,但您也可以将额外的downloadFolder关键字参数传递给downloadAttachment()和downloadAllAttachments()方法。以下是一个示例:
>>> import ezgmail
>>> threads = ezgmail.search('vacation photos')
>>> threads[0].messages[0].attachments
['tulips.jpg', 'canal.jpg', 'bicycles.jpg']
>>> threads[0].messages[0].downloadAttachment('tulips.jpg')
>>> threads[0].messages[0].downloadAllAttachments(downloadFolder='vacation2026')
['tulips.jpg', 'canal.jpg', 'bicycles.jpg']
如果一个文件已经存在并且具有附件的文件名,下载的附件将自动覆盖它。
EZGmail 包含额外的功能,您可以在github.com/asweigart/ezgmail找到完整的文档。
SMS Email Gateways
人们更可能靠近他们的智能手机而不是电脑,因此短信通常是发送即时通知比电子邮件更可靠的途径。此外,短信通常更短,这使得人们更有可能抽出时间阅读它们。发送短信最简单的方法,尽管不是最可靠的方法,是使用短信服务(SMS)电子邮件网关,这是一种手机运营商设置的电子邮件服务器,用于接收通过电子邮件发送的短信并将其转发给收件人作为短信。
您可以使用 EZGmail 或smtplib模块编写程序来发送这些电子邮件。电话号码和电话公司的电子邮件服务器构成了收件人的电子邮件地址。例如,要向电话号码为 212-555-1234 的 Verizon 客户发送短信,您将向2125551234@vtext.com发送电子邮件。电子邮件的主题和正文将显示在短信的正文部分。
您可以通过在网络上搜索“sms email gateway provider name”来找到手机运营商的短信电子邮件网关。表 20-1 列出了几个流行提供商的网关。许多提供商为短信和多媒体消息服务(MMS)设有单独的电子邮件服务器,这限制了消息的字符数为 160 个,而 MMS 没有字符限制。如果您想发送照片,您将不得不使用 MMS 网关并将文件附加到电子邮件中。
如果您不知道收件人的手机运营商,您可以通过运营商查找网站来尝试搜索它。找到这些网站的最佳方式是在网络上搜索“find cell phone provider for number。”许多这些网站允许您免费查找号码(尽管他们将通过他们的 API 查找数百或数千个电话号码向您收费)。
表 20-1:手机运营商的短信电子邮件网关
| Cell phone provider | SMS gateway | MMS gateway |
|---|---|---|
| AT&T | number@txt.att.net | number@mms.att.net |
| Boost Mobile | number@sms.myboostmobile.com | 与短信相同 |
| Cricket | number@sms.cricketwireless.net | number@mms.cricketwireless.net |
| Google Fi | number@msg.fi.google.com | 与短信相同 |
| Metro PCS | number@mymetropcs.com | 与短信相同 |
| Republic Wireless | number@text.republicwireless.com | 与短信相同 |
| Sprint(现为 T-Mobile) | number@messaging.sprintpcs.com | number@pm.sprint.com |
| T-Mobile | number@tmomail.net | 与短信相同 |
| 美国电讯 | number@email.uscc.net | number@mms.uscc.net |
| 威瑞森 | number@vtext.com | number@vzwpix.com |
| 维珍移动 | number@vmobl.com | number@vmpix.com |
| Xfinity Mobile | number@vtext.com | number@mypixmessages.com |
虽然短信电子邮件网关免费且易于使用,但它们有几个主要的缺点:
-
您无法保证短信会及时到达,或者根本无法到达。
-
您无法知道短信是否未能送达。
-
文本接收者无法回复。
-
如果您发送太多电子邮件,短信网关可能会阻止您,而且无法知道“太多”是多少。
-
短信网关今天能够发送短信并不意味着明天它还会工作。
当您需要偶尔发送非紧急消息时,通过短信网关发送短信是理想的。如果您想使用更可靠的方式来发送短信,尤其是大量发送,可以使用像 Twilio 这样的电信服务提供商的 API。这些服务通常需要订阅或使用费,并且您可能需要提交申请才能使用它们。每个国家的规定可能不同,并且会随时间变化。
发送短信的替代方法是使用免费推送通知服务,下一节将进行解释。
推送通知
HTTP pub-sub 通知服务允许您通过 HTTP 网络请求在互联网上发送和接收短期的、一次性的消息。第十三章介绍了如何使用 Requests 库发送 HTTP 请求,我们将使用它来与免费在线服务 ntfy(发音为“notify”,始终小写)进行交互,网址为ntfy.sh。ntfy 服务是免费的,并且不需要任何注册或登录。
在我们开始之前,请在您的手机上安装 ntfy 应用程序,以便您能够接收通知。此应用程序是免费的,可在 Android 和 iPhone 的应用商店中找到。您还可以通过访问ntfy.sh/app在您的网络浏览器中接收通知。
这些应用会检查 ntfy 服务是否有发送到主题的消息。您可以将主题视为聊天室或群聊名称。世界上任何人都可以向主题发送消息,世界上任何人都可以订阅主题以阅读这些消息。如果您只想给自己发送消息,请使用包含随机字母的秘密主题。将此主题名称视为密码,并且只与您打算阅读消息的人分享。尽管示例代码中将使用主题 AlSweigartZPgxBQ42,但我建议您使用包含随机字母和数字后缀的秘密主题。主题区分大小写,即使您将主题保密,也不要使用 ntfy 发送敏感信息,如密码或信用卡号码。
发送通知
向订阅了某个主题的每个人发送推送通知,只需向 ntfy 网服务器发送 HTTP 请求即可。这意味着您可以使用 Requests 库完全完成此操作。您不需要安装特定的 ntfy 包。
要发送请求,请在交互式外壳中输入以下内容。将本章中使用的示例主题 AlSweigartZPgxBQ42 替换为您自己的随机、秘密主题:
>>> import requests
>>> requests.post('https://ntfy.sh/AlSweigartZPgxBQ42', 'Hello, world!')
<Response [200]>
注意,我们调用 requests.post() 来发送 POST HTTP 请求以发送通知。这与第十三章中介绍的用于下载网页的 requests.get() 函数不同。
订阅了主题 AlSweigartZPgxBQ42 的任何人将在几秒钟内收到消息“Hello, world!”(尽管有时消息可能会延迟几分钟)。您也可以自己查看这些消息,网址为 ntfy.sh/AlSweigartZPgxBQ42。
ntfy 服务有一些限制。免费用户每天最多只能发送 250 条消息,并且消息大小最多为 4,096 字节。向多个主题发送大量消息可能会导致您的 IP 地址暂时被封锁。您可以在 ntfy 网站上获得付费账户以增加这些限制。付费 ntfy 账户可以设置保留主题并限制谁可以发布到它们。如果您没有权限发布到保留主题,您的 requests.post() 函数调用将收到 <Response [403]> 响应。
在 4,096 字节限制内,您的消息可以采用任何格式。请注意,无法确定谁向主题发送了消息,因此您可能希望在消息文本中包含“收件人”和“发件人”标签。更好的做法是,您可以使用 JSON 或第十八章中介绍的其他数据序列化格式来实现这一点。
如果您想使您的 Python 程序发送通知给您,一旦在您的手机上安装了 ntfy 应用并订阅了主题,您只需要这两行代码。您可以运行您的 Python 程序并去喝咖啡,知道当您的程序完成无聊的任务时,您会在手机上收到通知。
传输元数据
虽然您发送的消息是一个自由形式的文本字符串值,但 ntfy 可以可选地附加元数据值,例如标题、优先级级别和标签,到每条消息。
标题类似于电子邮件的主题行,并且大多数应用程序都会以较大的字体在消息文本上方显示它。优先级级别从 1(最低优先级)到 5(最高优先级)不等,其中 3 是默认值。更高的优先级并不会使消息传递得更快;它只是允许订阅者配置他们的通知应用程序,以仅显示特定优先级或更高优先级的消息。标签是订阅者可以使用来过滤消息的关键词。标签也可以是显示在消息标题旁边的表情符号的名称。您可以在docs.ntfy.sh/publish/#tags-emojis找到这些表情符号的列表。
这些元数据包含在 HTTP 请求的头部中,因此您需要将它们的字典传递给headers关键字参数。将以下内容输入到交互式 shell 中,以带有元数据的消息进行发布:
>>> import requests
>>> requests.post('https://ntfy.sh/AlSweigartZPgxBQ42', 'The rent is too high!',
headers={'Title':'Important: Read this!', 'Tags': 'warning,neutral_face', 'Priority':'5'})
<Response [200]>
这些功能对于在手机应用程序上阅读通知的人类用户来说很有用。然而,您也可以编写代码,以便 Python 脚本可以接收推送通知,我们将在下一节中讨论。
接收通知
您的 Python 程序也可以通过使用 Requests 库发送 HTTP 请求来读取特定主题发布的消息。使用前几节中的代码发送通知消息,然后使用与通知相同的主题在交互式 shell 中输入以下内容:
>>> import requests
>>> resp = requests.get('https://ntfy.sh/AlSweigartZPgxBQ42/json?poll=1')
>>> resp.text
'{"id":"1jnHKeFNqwnS","time":1797823340,"expires":1797866540,"event":
"message","topic":"AlSweigartZPgxBQ42","message":"Hello, world!"}\n
{"id":"wZ22cjyKXw1F","time":1797823712,"expires":1797866912,"event":
"message","topic":"AlSweigartZPgxBQ42","title":"Important: Read this!",
"message":"The rent is too high!","priority":5,"tags":["warning",
"neutral_face"]}\n'
注意,我们调用requests.get()函数来接收通知,而不是在发送通知时使用的requests.post()函数。此外,URL 以/json?poll=1结尾。
这是通过轮询检索消息,它返回服务器拥有的特定主题的所有消息。还有用于检索 ntfy 消息的流式传输方法,但轮询具有最简单的代码。您还可以在poll=1之后添加一个since URL 参数,以根据以下标准获取消息:
since=10m 检索过去 10 分钟内特定主题的所有消息。您也可以使用s表示秒和h表示小时,例如since=2h30m表示过去两小时半内的所有消息。
since=1737866912 检索自 Unix 纪元时间戳1737866912以来的所有消息。这种时间戳由time.time()返回,表示自 1970 年 1 月 1 日以来的秒数。第十九章涵盖了与时间相关的函数。
since=wZ22cjyKXw1F 检索自消息 ID 为'wZ22cjyKXw1F'的消息之后的所有消息。
使用与符号(&)分隔额外的 URL 参数。例如,传递 URLntfy.sh/AlSweigartZPgxBQ42/json?poll=1&since=10m检索过去 10 分钟内AlSweigartZPgxBQ42主题的所有消息。为了减少对 ntfy 服务器的负载,你应该每分钟或每隔几分钟进行一次轮询,而不是在无限循环中尽可能快地进行轮询。如果你需要立即接收通知,请查阅在线文档了解订阅通知流。
此 HTTP 响应的文本不是有效的 JSON,因为它在文本的每一行都包含一个 JSON 对象,而不是一个 JSON 对象,所以我们使用splitlines()字符串方法在解析之前将它们分开(如第十八章所述)。继续上一个交互式 shell 示例:
>>> import json
>>> notifications = []
>>> for json_text in resp.text.splitlines():
... notifications.append(json.loads(json_text))
...
>>> notifications[0]['message']
'Hello, world!'
>>> notifications[1]['message']
'The rent is too high!'
json.loads()函数将 ntfy 中的 JSON 文本转换为 Python 字典。让我们看看每个键值对:
"id":"wZ22cjyKXw1F" 'id'键的值是一个唯一的标识字符串,可以帮助区分多个通知,即使它们具有相同的文本。
"time":1797823712 'time'键的值是通知创建时的 Unix 纪元时间戳。调用str(datetime.datetime.fromtimestamp(1797823712))返回可读字符串'2026-12-20 21:28:32'。
"expires":1797866912 'expires'键的值是通知将从 ntfy 服务器删除时的 Unix 纪元时间戳。
"event":"message" 'event'键的值可以是'message'、'open'、'keepalive'或'poll_request'。这些事件类型在在线文档中有解释,但到目前为止,你可能只对'message'事件感兴趣。
"topic":"AlSweigartZPgxBQ42" URL 的主题部分在'topic'键的值中重复。
"title":"Important: Read this!" 如果通知有标题,将有一个'title'键,其中包含作为字符串值的标题。
"message":"The rent is too high!" 'message'键的值是通知文本的字符串。
"priority":5 如果通知有优先级,将有一个'priority'键,其值为 1 到 5 的整数。
"tags":["warning","neutral_face"] 如果通知有标签,将有一个'tags'键,其中包含作为字符串值列表的标签。这些字符串值可能是要显示的 emoji 字符名称。
通过读取这个字典中的值,你的 Python 程序可以使用 Requests 库接收用户或其他 Python 脚本发出的通知。ntfy 服务是制作能够通过互联网相互通信的程序的最简单方法之一(尽管请记住免费用户的每日消息限制为 250 条)。
发送通知
向订阅了主题的每个人发送推送通知,只需向 ntfy 网络服务器发送 HTTP 请求即可。这意味着这可以通过 Requests 库完全完成。你不需要安装特定的 ntfy 包。
要发送请求,请在交互式外壳中输入以下内容。将本章中使用的示例主题AlSweigartZPgxBQ42替换为你自己的随机、秘密主题:
>>> import requests
>>> requests.post('https://ntfy.sh/AlSweigartZPgxBQ42', 'Hello, world!')
<Response [200]>
注意,我们调用requests.post()来发送 POST HTTP 请求以发送通知。这与第十三章中介绍的用于下载网页的requests.get()函数不同。
订阅了主题AlSweigartZPgxBQ42的任何人将在几秒钟内收到消息“Hello, world!”(尽管有时消息可能会延迟几分钟)。你也可以自己查看这些消息,网址为ntfy.sh/AlSweigartZPgxBQ42。
ntfy 服务有一些限制。免费用户每天最多只能发送 250 条消息,并且消息大小最多为 4,096 字节。向多个主题发送大量消息可能会导致你的 IP 地址暂时被封锁。你可以在 ntfy 网站上获得付费账户来提高这些限制。付费的 ntfy 账户可以设置保留主题并限制谁可以发布到它们。如果你没有权限发布到保留主题,你会在requests.post()函数调用时收到一个<Response [403]>响应。
在 4,096 字节的限制内,你的消息可以采用任何格式。请注意,没有办法确定谁向主题发送了消息,因此你可能在消息文本中包含“收件人”和“发件人”标签。更好的做法是,你可以使用 JSON 或其他在第十八章中介绍的数据序列化格式来实现这一点。
如果你想让你的 Python 程序发送通知给你,一旦你在手机上安装了 ntfy 应用并订阅了主题,你只需要这两行代码。你可以自由运行你的 Python 程序,去喝杯咖啡,知道当你程序完成无聊的任务时,你的手机会收到通知。
传输元数据
虽然你发送的消息是一个自由格式的文本字符串值,但 ntfy 可以可选地附加元数据值,例如标题、优先级级别和标签,到每条消息。
标题 类似于电子邮件的主题行,大多数应用都会以较大的字体在消息文本上方显示它。优先级级别 从 1(最低优先级)到 5(最高优先级)不等,默认值为 3。更高的优先级并不会使消息传递得更快;它只是允许订阅者配置他们的通知应用,只显示特定优先级或更高优先级的消息。标签 是订阅者可以使用来过滤消息的关键词。标签也可以是显示在消息标题旁边的表情符号的名称。您可以在docs.ntfy.sh/publish/#tags-emojis找到这些表情符号的列表。
这些元数据包含在 HTTP 请求的头部中,因此您需要将它们作为字典传递给 headers 关键字参数。将以下内容输入到交互式外壳中,以带有元数据的消息进行发布:
>>> import requests
>>> requests.post('https://ntfy.sh/AlSweigartZPgxBQ42', 'The rent is too high!',
headers={'Title':'Important: Read this!', 'Tags': 'warning,neutral_face', 'Priority':'5'})
<Response [200]>
这些功能对于在手机应用上阅读通知的人类用户来说很有用。然而,您也可以编写代码,使 Python 脚本能够接收推送通知,我们将在下一节中讨论。
接收通知
您的 Python 程序也可以通过使用 Requests 库发送 HTTP 请求来读取特定主题发布的消息。使用前几节中的代码发送通知消息,然后使用相同的通知主题在交互式外壳中输入以下内容:
>>> import requests
>>> resp = requests.get('https://ntfy.sh/AlSweigartZPgxBQ42/json?poll=1')
>>> resp.text
'{"id":"1jnHKeFNqwnS","time":1797823340,"expires":1797866540,"event":
"message","topic":"AlSweigartZPgxBQ42","message":"Hello, world!"}\n
{"id":"wZ22cjyKXw1F","time":1797823712,"expires":1797866912,"event":
"message","topic":"AlSweigartZPgxBQ42","title":"Important: Read this!",
"message":"The rent is too high!","priority":5,"tags":["warning",
"neutral_face"]}\n'
注意,我们调用 requests.get() 函数来接收通知,而不是发送通知时使用的 requests.post() 函数。此外,URL 以 /json?poll=1 结尾。
这是通过轮询检索消息,它返回服务器拥有的该主题的所有消息。还有用于检索 ntfy 消息的流式方法,但轮询具有最简单的代码。您还可以在 poll=1 之后添加一个 since URL 参数,以以下标准之一获取消息:
since=10m 检索过去 10 分钟内该主题的所有消息。您也可以使用 s 表示秒,h 表示小时,例如 since=2h30m 表示过去两小时三十分钟内的所有消息。
since=1737866912 检索自 Unix 纪元时间戳 1737866912 的所有消息。这种时间戳由 time.time() 返回,表示自 1970 年 1 月 1 日以来的秒数。第十九章涵盖了与时间相关的函数。
since=wZ22cjyKXw1F 检索所有在 ID 为 'wZ22cjyKXw1F' 的消息之后的消息。
使用与符号(&)分隔额外的 URL 参数。例如,传递 URLntfy.sh/AlSweigartZPgxBQ42/json?poll=1&since=10m检索过去 10 分钟内AlSweigartZPgxBQ42主题的所有消息。为了减少对 ntfy 服务器的负载,你应该每分钟或每隔几分钟轮询一次,而不是在无限循环中尽可能快地轮询。如果您需要立即接收通知,请查阅在线文档了解订阅通知流。
此 HTTP 响应的文本不是有效的 JSON,因为它在文本的每一行都包含一个 JSON 对象,而不是一个 JSON 对象,所以我们使用splitlines()字符串方法在解析之前将它们分开(如第十八章所述)。继续上一个交互式 shell 示例:
>>> import json
>>> notifications = []
>>> for json_text in resp.text.splitlines():
... notifications.append(json.loads(json_text))
...
>>> notifications[0]['message']
'Hello, world!'
>>> notifications[1]['message']
'The rent is too high!'
json.loads()函数将 ntfy 中的 JSON 文本转换为 Python 字典。让我们看看这个字典中的每个键值对:
"id":"wZ22cjyKXw1F" 'id'键的值是一个唯一的标识字符串,可以帮助区分多个通知,即使它们有相同的文本。
"time":1797823712 'time'键的值是通知创建时的 Unix 纪元时间戳。调用str(datetime.datetime.fromtimestamp(1797823712))返回可读字符串'2026 -12-20 21:28:32'。
"expires":1797866912 'expires'键的值是通知将从 ntfy 服务器删除的 Unix 纪元时间戳。
"event":"message" 'event'键的值可以是'message'、'open'、'keepalive'或'poll_request'。这些事件类型在在线文档中有解释,但到目前为止,你可能只对'message'事件感兴趣。
"topic":"AlSweigartZPgxBQ42" URL 中的'topic'部分在'topic'键的值中重复。
"title":"Important: Read this!" 如果通知有标题,则会有一个包含字符串值的'title'键。
"message":"The rent is too high!" 'message'键的值是通知文本的字符串。
"priority":5 如果通知有优先级,则会有一个包含整数值(从 1 到 5)的'priority'键。
"tags":["warning","neutral_face"] 如果通知有标签,则会有一个包含作为字符串值列表的'tags'键。这些字符串值可能是要显示的 emoji 字符名称。
通过读取这个字典中的值,您的 Python 程序可以使用 Requests 库接收由用户或其他 Python 脚本发出的通知。ntfy 服务是制作能够通过互联网相互通信的程序的最简单方法之一(尽管请记住免费用户的每日消息限制为 250 条)。
摘要
我们通过互联网和手机网络以数十种不同的方式相互沟通,但电子邮件和短信占主导地位。您的程序可以通过这些渠道进行通信,这为它们提供了强大的新通知功能。
作为安全和垃圾邮件预防措施,一些流行的电子邮件服务,如 Gmail,不允许您使用标准的 SMTP 和 IMAP 协议来访问他们的服务。EZGmail 包作为 Gmail API 的方便包装器,允许您的 Python 脚本访问您的 Gmail 账户。我强烈建议您为您的脚本设置一个单独的 Gmail 账户,以便您的程序中的潜在错误不会影响您的个人 Gmail 账户。
与电子邮件不同,短信需要除了互联网连接之外的东西来发送短信。您可以使用短信电子邮件网关从电子邮件账户发送短信,但这需要您知道手机用户的电信运营商,并且这不是发送消息的可靠方式。如果您只是给自己发送短消息,您可以使用ntfy.sh上的推送通知系统,然后在您的手机上安装 ntfy 应用,以便您的 Python 脚本向主题订阅者发送消息。
在您的技能集中拥有这些模块后,您将能够编程特定条件,以便您的程序在这些条件下发送通知或提醒。现在,您的程序将具有远远超出它们运行的计算机的覆盖范围!
练习问题
-
在使用 Gmail API 时,凭证和令牌文件是什么?
-
在 Gmail API 中,“线程”对象和“消息”对象之间有什么区别?
-
使用
ezgmail.search(),您如何找到带有附件的电子邮件? -
使用短信电子邮件网关发送短信有哪些不利之处?
-
哪个 Python 库可以发送和接收 ntfy 的通知?
练习程序
为了练习,编写程序来完成以下任务。
雨伞提醒
第十三章向您展示了如何使用requests模块从weather.gov抓取数据。编写一个程序,在您早上醒来之前运行,检查当天是否有雨的预报。如果有,让程序通过短信提醒您出门前带上雨伞。
自动退订程序
编写一个程序,该程序可以扫描您的电子邮件账户,找到所有电子邮件中的退订链接,并在浏览器中自动打开它们。这个程序将需要登录到您的 Gmail 账户。您可以使用 Beautiful Soup(在第十三章中介绍)来检查任何单词“退订”出现在 HTML 链接标签内的实例。一旦您有了这些 URL 的列表,您就可以使用webbrowser.open()自动在浏览器中打开所有这些链接。
你仍然需要手动完成任何额外的步骤来从这些列表中取消订阅。在大多数情况下,这涉及到点击一个链接进行确认。但这个脚本可以让你不必浏览所有电子邮件来寻找取消订阅链接。
基于电子邮件的计算机控制
编写一个程序,每 15 分钟检查一次电子邮件或 ntfy 账户,看看是否有你发送的指令,并自动执行这些指令。例如,BitTorrent 是一个对等下载系统。使用免费的 BitTorrent 软件,如 qBittorrent,你可以在家里的计算机上下载大型媒体文件。如果你向程序发送一个(完全合法,绝对不是盗版的)BitTorrent 链接,程序最终会检查其电子邮件或查找 ntfy 通知,找到这条消息,提取链接,然后启动 qBittorrent 开始下载文件。这样,你可以在离开家的时候让你的家用计算机开始下载,并在你回家时完成(完全合法,绝对不是盗版的)下载。
第十九章介绍了如何使用subprocess.Popen()函数在你的计算机上启动程序。例如,以下调用将启动 qBittorrent 程序,并附带一个种子文件:
qbProcess = subprocess.Popen(['C:\\Program Files (x86)\\qBittorrent\\
qbittorrent.exe', 'shakespeare_complete_works.torrent'])
当然,你希望程序确保邮件来自你。特别是,你可能希望要求邮件包含密码,因为黑客伪造邮件中的“发件人”地址相对容易。程序应该删除它找到的邮件,这样每次检查电子邮件账户时就不会重复指令。作为一个额外功能,让程序在每次执行命令时通过电子邮件或短信给你发送一个确认。由于你不会坐在运行程序的计算机前,使用日志功能(见第五章)来写入一个文本文件日志是一个好主意,这样你可以在出现错误时进行检查。
qBittorrent 程序(以及其他 BitTorrent 应用程序)有一个功能,可以在下载完成后自动退出。第十九章解释了如何使用Popen对象的wait()方法确定启动的应用程序何时退出。wait()方法调用将阻塞,直到 qBittorrent 停止,然后你的程序可以给你发送电子邮件或短信通知下载已完成。
你可以为这个项目添加许多可能的功能。如果你遇到困难,你可以从nostarch.com/automate-boring-stuff-python-3rd-edition下载这个程序的示例实现。
伞形提醒
第十三章向你展示了如何使用requests模块从weather.gov抓取数据。编写一个程序,在早上醒来之前运行,检查当天是否有雨的预报。如果有,让程序给你发一条提醒,在出门前带上雨伞。
自动取消订阅器
编写一个程序,扫描你的电子邮件账户,找到所有邮件中的取消订阅链接,并在浏览器中自动打开它们。这个程序将需要登录到你的 Gmail 账户。你可以使用 Beautiful Soup(第十三章中介绍)检查任何单词取消订阅出现在 HTML 链接标签内的实例。一旦你有了这些 URL 的列表,你可以使用webbrowser.open()自动在浏览器中打开所有这些链接。
你仍然需要手动逐个完成取消订阅这些列表的额外步骤。在大多数情况下,这涉及到点击一个链接进行确认。但这个脚本可以帮你避免在所有邮件中寻找取消订阅链接。
基于电子邮件的电脑控制
编写一个程序,每 15 分钟检查一次电子邮件或 ntfy 账户,看看是否有你发送的指令,并自动执行这些指令。例如,BitTorrent 是一个对等下载系统。使用免费的 BitTorrent 软件,如 qBittorrent,你可以在家里的电脑上下载大型媒体文件。如果你给程序发送一个(完全合法,绝对不是盗版的)BitTorrent 链接,程序最终会检查电子邮件或查找 ntfy 通知,找到这条消息,提取链接,然后启动 qBittorrent 开始下载文件。这样,你可以在离开家的时候让你的电脑开始下载,并在你回家时完成(完全合法,绝对不是盗版的)下载。
第十九章介绍了如何使用subprocess.Popen()函数在你的电脑上启动程序。例如,以下调用将启动 qBittorrent 程序,以及一个种子文件:
qbProcess = subprocess.Popen(['C:\\Program Files (x86)\\qBittorrent\\
qbittorrent.exe', 'shakespeare_complete_works.torrent'])
当然,你希望程序确保邮件来自你。特别是,你可能希望要求邮件包含密码,因为黑客伪造邮件中的“发件人”地址相对容易。程序应该删除它找到的邮件,以免每次检查电子邮件账户时重复指令。作为一个额外功能,让程序在每次执行命令时给你发送或发送确认。由于你不会坐在运行程序的电脑前,使用日志功能(见第五章)来写入一个文本文件日志是一个好主意,这样你可以在出现错误时进行检查。
qBittorrent 程序(以及其他 BitTorrent 应用程序)有一个特性,可以在下载完成后自动退出。第十九章解释了如何使用Popen对象的wait()方法确定启动的应用程序何时退出。wait()方法调用将阻塞,直到 qBittorrent 停止,然后你的程序可以发送电子邮件或短信通知你下载已完成。
你可以为这个项目添加许多可能的功能。如果你遇到了难题,你可以从nostarch.com/automate-boring-stuff-python-3rd-edition下载这个程序的示例实现。


浙公网安备 33010602011771号