函数使用(六)
前言
- 这一篇是「跟文件跟时间打交道」会用到的标准库:
shutil(复制/移动/删除/归档)、time(时间戳、格式化、计时)、calendar(日历表、闰年)、datetime(日期时间对象),以及处理时区的两套东西:标准库的zoneinfo和第三方的pytz。 - 所有示例都在 Python 3.13.9 / Windows(系统时区 UTC+8,中文 locale) 上实跑过,输出原样贴在代码块下面;涉及文件读写的片段都跑在
tempfile.mkdtemp()开出的临时目录里。凡是输出会随「当前时间」变化的地方,示例里都改成固定时间戳或只打印布尔结论,保证你重跑能对上。 - 顺手修了几处凭印象写的说法:
%f在time模块里不可用(会抛ValueError),calendar.day_name不受setfirstweekday影响,美国 2023 年夏令时开始是 3 月 12 日而不是 3 月 11 日。
shutil:文件与目录的高级操作
shutil(shell utilities)是对 os 的补充:os 负责「能不能」,shutil 负责「一次搞定一整棵目录树」。
-
复制一族
函数 复制什么 目标可以是目录吗 元数据(mtime 等) copyfile(src, dst)只有内容 只能给文件名 不保留 copy(src, dst)内容 + 权限位 可以给目录(进去同名) 不保留 copy2(src, dst)内容 + 权限 + 时间等元数据 可以给目录 保留 copytree(src, dst)整棵目录树 必须是新目录名 默认不保留, copy_function=copy2可换copyfileobj(fsrc, fdst)两个已打开的文件对象之间 — — 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44import os, shutil, tempfile
work = tempfile.mkdtemp()
os.chdir(work)
with open('src.txt', 'w', encoding='utf-8') as f:
f.write('AAA')
# 把 mtime 钉死,后面才能验证谁保留了时间戳
os.utime('src.txt', (1_600_000_000, 1_600_000_000)) # 把 mtime 定死,方便对比
# copy:只带权限位,不带时间
shutil.copy('src.txt', 'c1.txt')
# copy2:连 mtime 一起复制
shutil.copy2('src.txt', 'c2.txt')
print('copy 没保留 mtime:', int(os.path.getmtime('c1.txt')) != 1600000000)
print('copy2 保留了 mtime:', int(os.path.getmtime('c2.txt')) == 1600000000)
# 返回值就是落地路径,dst 给相对的返回相对的
print('返回值是目标路径:', shutil.copy('src.txt', 'c3.txt'))
os.makedirs('adir')
with open('adir/a.txt', 'w') as f:
f.write('A')
# 整棵目录树一次搬走
shutil.copytree('adir', 'backup')
print('copytree 连目录一起搬:', sorted(os.listdir('backup')))
try:
# 整棵目录树一次搬走
shutil.copytree('adir', 'backup') # 目标已存在
except FileExistsError:
print('copytree 默认不许目标存在')
# 3.8+ 才有这个参数
shutil.copytree('adir', 'backup', dirs_exist_ok=True) # 3.8+ 允许合并
print('dirs_exist_ok=True 就能合并:', sorted(os.listdir('backup')))
try:
# 故意传目录
shutil.copyfile('adir', 'x.txt') # 传了目录
except OSError as e:
print('copyfile 传目录:', type(e).__name__)
try:
# 故意让源 == 目标
shutil.copyfile('src.txt', 'src.txt') # 源就是目标
except shutil.SameFileError as e:
print('源和目标同一个文件:', type(e).__name__)
os.chdir(tempfile.gettempdir())
shutil.rmtree(work)输出:
1
2
3
4
5
6
7
8copy 没保留 mtime: True
copy2 保留了 mtime: True
返回值是目标路径: c3.txt
copytree 连目录一起搬: ['a.txt']
copytree 默认不许目标存在
dirs_exist_ok=True 就能合并: ['a.txt']
copyfile 传目录: PermissionError
源和目标同一个文件: SameFileError几个实测细节:
- 传目录给
copy/copyfile,Windows 上抛的是PermissionError(POSIX 上是IsADirectoryError),别指望有友好提示。 copy/copy2的返回值就是「最终落地的路径字符串」,dst传相对路径时返回的也是相对的。- 源和目标指向同一个文件时抛
shutil.SameFileError(它是OSError的子类)。
- 传目录给
-
移动与删除
shutil.move(src, dst):dst是已存在的目录 → 移进去;否则 → 改名。跨磁盘时它内部是「复制 + 删除」。shutil.rmtree(path):整棵目录树删掉,标准库版rm -rf。path不存在会抛FileNotFoundError,加ignore_errors=True就闭嘴。- 上一节说过:Windows 上
rmtree删不掉进程当前所在的目录(PermissionError),要先os.chdir出去。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27import os, shutil, tempfile
work = tempfile.mkdtemp()
os.chdir(work)
open('a.txt', 'w').close()
os.makedirs('dest')
open('b.txt', 'w').close()
# dst 不存在 -> 等价改名
shutil.move('a.txt', 'renamed.txt') # dst 不存在 -> 改名
# dst 是已存在目录 -> 移进去
shutil.move('b.txt', 'dest') # dst 是目录 -> 移进去
print('改名:', os.path.exists('renamed.txt'), '| 移入:', os.path.exists(os.path.join('dest', 'b.txt')))
os.makedirs('tree/sub')
open('tree/sub/f.txt', 'w').close()
# 整棵树一次删掉,标准库版 rm -rf
shutil.rmtree('tree')
print('rmtree 一次删干净:', not os.path.exists('tree'))
try:
shutil.rmtree('ghost')
except FileNotFoundError:
print('rmtree 目标不存在会报错')
# 目标不存在也不吭声
shutil.rmtree('ghost', ignore_errors=True)
print('ignore_errors=True 就没事')
os.chdir(tempfile.gettempdir())
shutil.rmtree(work)输出:
1
2
3
4改名: True | 移入: True
rmtree 一次删干净: True
rmtree 目标不存在会报错
ignore_errors=True 就没事 -
归档与解压
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import os, shutil, tempfile, zipfile
work = tempfile.mkdtemp()
os.chdir(work)
os.makedirs('folder/sub')
open('folder/a.txt', 'w').write('A')
open('folder/b.txt', 'w').write('B')
# base_name 不用写扩展名,第三个参数是要打包的目录
arch = shutil.make_archive('backup', 'zip', 'folder') # 第三个参数是要打包的目录
print('生成的包:', os.path.basename(arch), '| 里面几个条目:', len(zipfile.ZipFile(arch).namelist()))
# 解到指定目录
shutil.unpack_archive(arch, 'out')
print('解压出来:', sorted(os.listdir('out')))
# tar 系要写全名,"tar.gz" 不认
formats = [f[0] for f in shutil.get_archive_formats()] # 这张表随版本变,所以只做包含判断
print('zip 能用:', 'zip' in formats, '| gztar 能用:', 'gztar' in formats)
os.chdir(tempfile.gettempdir())
shutil.rmtree(work)输出:
1
2
3生成的包: backup.zip | 里面几个条目: 3
解压出来: ['a.txt', 'b.txt', 'sub']
zip 能用: True | gztar 能用: Truemake_archive返回绝对路径,base_name不用写扩展名(它按format自己加)。gztar/bztar/xztar这些也要写全,'tar.gz'这种写法不认。补一句:
shutil.get_archive_formats()返回的清单跟版本和机器装的压缩库有关(本机 3.14.10 就多出一个zstdtar),所以别把这张表抄下来当结论,判断「某个格式能不能用」用in就稳了。 -
磁盘与杂项
1
2
3
4
5
6
7
8
9
10
11import os, shutil, tempfile
# 传任意路径,取它所在分区的统计
du = shutil.disk_usage(tempfile.gettempdir()) # 传任意路径,取它所在分区的统计
print('字段:', du._fields, '| 是命名元组:', isinstance(du, tuple))
print("传 '/' 在 Windows 上也能用:", shutil.disk_usage('/').total > 0)
print('总容量按 GB 取整 > 0:', du.total // 2**30 > 0)
print('which 找可执行文件:', bool(shutil.which('cmd') or shutil.which('ls')))
# 查环境变量 COLUMNS/LINES,拿不到就用 80x24 兜底
ts = shutil.get_terminal_size()
print('终端尺寸:', ts.columns > 0, ts.lines > 0, '| 类型:', type(ts).__name__)输出:
1
2
3
4
5字段: ('total', 'used', 'free') | 是命名元组: True
传 '/' 在 Windows 上也能用: True
总容量按 GB 取整 > 0: True
which 找可执行文件: True
终端尺寸: True True | 类型: terminal_sizedisk_usage的容量是你机器上的真实值,示例里只打印布尔结论,保证输出可复现。 -
整体示例(shutil)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31import os, shutil, tempfile, zipfile
work = tempfile.mkdtemp()
os.chdir(work)
os.makedirs('proj/src/pkg')
open('proj/src/pkg/main.py', 'w', encoding='utf-8').write('print(1)\n')
open('proj/readme.md', 'w', encoding='utf-8').write('# doc\n')
os.utime('proj/readme.md', (1_600_000_000, 1_600_000_000))
# 1) 备份整棵树:copytree + copy2 连时间戳一起留
# 备份整棵树,连时间戳一起留
shutil.copytree('proj', 'backup', copy_function=shutil.copy2)
print('备份内容:', sorted(os.listdir('backup')), '| mtime 保住了:',
int(os.path.getmtime('backup/readme.md')) == 1600000000)
# 2) 单文件:copy2 覆盖式复制;move 顺手改名
shutil.copy2('proj/readme.md', 'backup/readme.md')
shutil.move('backup/readme.md', 'backup/README.md')
print('改名后:', 'README.md' in os.listdir('backup'), '| 旧的没了:', 'readme.md' not in os.listdir('backup'))
# 3) 打压缩包,再解到别处比对
arch = shutil.make_archive('proj_backup', 'zip', 'backup')
shutil.unpack_archive(arch, 'restored')
print('包大小 > 0:', os.path.getsize(arch) > 0, '| 解压一致:', sorted(os.listdir('restored')) == sorted(os.listdir('backup')))
print('zip 条目:', sorted(zipfile.ZipFile(arch).namelist()))
# 4) 清理:整棵树交给 rmtree,注意先跳出当前目录
print('当前分区剩余 GB:', shutil.disk_usage(work).free // 2**30 >= 0)
os.chdir(tempfile.gettempdir())
shutil.rmtree(work)
print('全清完:', not os.path.exists(work))实跑输出:
1
2
3
4
5
6备份内容: ['readme.md', 'src'] | mtime 保住了: True
改名后: True | 旧的没了: True
包大小 > 0: True | 解压一致: True
zip 条目: ['README.md', 'src/', 'src/pkg/', 'src/pkg/main.py']
当前分区剩余 GB: True
全清完: True
time:时间戳、struct_time 与格式化
Python 里的时间有三种形态,time 模块的函数基本都在这三者之间转换:
| 从 | 到 | 用哪个函数 |
|---|---|---|
| 时间戳 float | struct_time(按本地时区) | localtime(ts) |
| 时间戳 float | struct_time(按 UTC) | gmtime(ts) |
| struct_time | 时间戳(按本地时区解释) | mktime(t) |
| struct_time(UTC 语义) | 时间戳 | calendar.timegm(t) |
| struct_time | 字符串 | strftime(格式, t) |
| 字符串 | struct_time | strptime(字符串, 格式) |
一句话记法:time 系列负责「时间戳 ↔ struct_time」,str 系列负责「struct_time ↔ 字符串」;localtime/mktime 走本地时区,gmtime/timegm 走 UTC。
-
时间戳
时间戳是从 1970-01-01 00:00:00 UTC(纪元) 起算的秒数(浮点数),它本身与时区无关。
1
2
3
4
5
6
7import time
# 从 1970-01-01 00:00:00 UTC 起算的秒数,与时区无关
ts = time.time()
print('是浮点数:', isinstance(ts, float), '| 1970 之后:', ts > 0)
# 3.7+ 有 *_ns 版本,避免浮点丢精度
print('整数版(纳秒):', isinstance(time.time_ns(), int))输出:
1
2是浮点数: True | 1970 之后: True
整数版(纳秒): True -
时间戳 ↔ struct_time
1
2
3
4
5
6
7
8import time
# 固定一个不随运行时刻变化的时间戳,保证输出可复现
ts = 1_695_211_200 # 2023-09-20 12:00:00 UTC
print('gmtime(UTC):', time.gmtime(ts))
print('localtime(本地):', time.localtime(ts))
print('mktime 转回去:', time.mktime(time.localtime(ts)) == ts)
print('取字段:', time.localtime(ts).tm_year, time.localtime(ts).tm_mon)输出(本机是 UTC+8):
1
2
3
4gmtime(UTC): time.struct_time(tm_year=2023, tm_mon=9, tm_mday=20, tm_hour=12, tm_min=0, tm_sec=0, tm_wday=2, tm_yday=263, tm_isdst=0)
localtime(本地): time.struct_time(tm_year=2023, tm_mon=9, tm_mday=20, tm_hour=20, tm_min=0, tm_sec=0, tm_wday=2, tm_yday=263, tm_isdst=0)
mktime 转回去: True
取字段: 2023 9struct_time是个命名元组,字段含义:字段 含义 范围 tm_year/tm_mon/tm_mday年 / 月 / 日 月是 1-12 tm_hour/tm_min/tm_sec时 / 分 / 秒 秒一般 0-61(闰秒) tm_wday星期几 0-6,0 是周一 tm_yday一年中第几天 1-366 tm_isdst是否夏令时 -1 未知 / 0 否 / 1 是 tm_gmtoff与 UTC 的偏移秒数 东区为正(3.6+) tm_zone时区名 本机是 '中国标准时间'(3.6+)注意
tm_wday(0 = 周一)和格式化符号%w(0 = 周日)不是一套编号。 -
struct_time ↔ 字符串
1
2
3
4
5
6
7
8import time
# 字符串 -> struct_time,格式必须严格对上
st = time.strptime('2023-09-20 13:04:05', '%Y-%m-%d %H:%M:%S')
print('解析:', st.tm_year, st.tm_mon, st.tm_hour)
print('格式化:', time.strftime('%Y-%m-%d %H:%M:%S', st))
# strftime 的第二个参数可省
print('不传第二个参数就用当前时间:', len(time.strftime('%Y-%m-%d')) == 10)输出:
1
2
3解析: 2023 9 13
格式化: 2023-09-20 13:04:05
不传第二个参数就用当前时间: True常用格式符(本机中文 locale 实测):
格式符 说明 实测输出 %Y/%y四位年 / 两位年 2023/23%m/%d月 / 日(补零) 09/20%H/%I/%p24 时 / 12 时 / 上下午 13/01/PM%M/%S分 / 秒 04/05%j一年中第几天 263%w星期几,0 是周日 3%U/%W一年中第几周(周日 / 周一起算) 38/38%a/%A星期缩写 / 全称 Wed/Wednesday%b/%B月份缩写 / 全称 Sep/September%z/%ZUTC 偏移 / 时区名 +0800/中国夏令时%c/%x/%X本地完整日期时间 / 日期 / 时间 Wed Sep 20 13:04:05 2023/09/20/23/13:04:05%G/%V/%uISO 年 / ISO 周 / ISO 星期(1=周一) 2023/38/3%%字面 %%这张表的每一格都是
time.strftime(符号, struct_time)在本机的真实输出(基准时间是 2023-09-20 13:04:05)。%Z给出「中国夏令时」纯属巧合:strptime不判断夏令时,tm_isdst是-1(未知),这时%z/%Z会按系统 tz 表随便挑一个填上——再次说明这两个符号只能用于显示,不能参与计算。两个坑:
%f(微秒)在time模块里不能用,time.strftime('%f')直接抛ValueError: Invalid format string,time.strptime里它会被当成「什么都没匹配」。微秒只有datetime的strftime/strptime支持。%a %A %b %B %c %x %X %Z全是跟着 locale 走的,换台机器输出就不一样,别拿它们做字符串比较或存进数据库。
1
2
3
4
5
6
7
8
9
10import time
from datetime import datetime
try:
# 微秒只有 datetime 支持
time.strftime('%f')
except ValueError as e:
print('time 模块 %f:', e)
# 同一个符号,datetime 里是好的
print('datetime 模块 %f:', datetime(2023, 1, 1, 0, 0, 0, 123456).strftime('%f'))输出:
1
2time 模块 %f: Invalid format string
datetime 模块 %f: 123456 -
睡眠与计时
函数 测什么 说明 time.sleep(secs)挂起 参数可以是小数;精度不保证,别当定时器用 time.perf_counter()最短间隔首选 含睡眠时间,精度最高,起点无意义 time.perf_counter_ns()同上,整数纳秒 避免浮点误差 time.monotonic()间隔,且保证不倒退 含睡眠;系统调时钟也不会跳,适合做超时 time.process_time()CPU 时间 不含 sleep,也不含别的进程 time.time()墙上时钟 会被手动改时间 / NTP 校时影响,别拿来测耗时 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import time
# 测短间隔首选,含睡眠
a = time.perf_counter()
time.sleep(0.05)
b = time.perf_counter()
print('perf_counter 含睡眠:', round(b - a, 2) >= 0.05)
a = time.process_time()
time.sleep(0.05)
# CPU 时间,不含 sleep
b = time.process_time()
print('process_time 不含睡眠:', b - a < 0.05)
a = time.monotonic()
time.sleep(0.02)
# 保证不倒退,适合做超时判断
print('monotonic 含睡眠:', time.monotonic() - a >= 0.02)输出:
1
2
3perf_counter 含睡眠: True
process_time 不含睡眠: True
monotonic 含睡眠: True -
时区相关的模块变量
1
2
3
4
5
6
7
8
9
10import time
# 注意是「往西算的秒数」:东区为负
print('timezone:', time.timezone)
# (标准名, 夏令时名),跟着系统 locale 走
print('tzname:', time.tzname)
# daylight 才用来判断有没有夏令时
print('daylight:', time.daylight, '| altzone:', time.altzone)
st = time.localtime()
print('当天是否夏令时:', st.tm_isdst, '| 偏移秒:', st.tm_gmtoff, '| 时区名:', st.tm_zone)输出(中国无夏令时,本机 UTC+8):
1
2
3
4timezone: -28800
tzname: ('中国标准时间', '中国夏令时')
daylight: 0 | altzone: -32400
当天是否夏令时: 0 | 偏移秒: 28800 | 时区名: 中国标准时间time.timezone是「往西算的秒数」:东区(比如 UTC+8)是负数-28800,西区(比如 UTC-5)才是正数。而struct_time.tm_gmtoff是「往东算的秒数」,符号正好相反——上面输出里-28800和28800说的同一件事。time.altzone是夏令时下的那个值,本地不启用夏令时它也存在(照抄系统的 tz 表),别用它判断有没有夏令时,用time.daylight和tm_isdst。
-
其他:ctime / asctime
1
2
3
4
5
6
7
8
9import time
# 固定一个不随运行时刻变化的时间戳,保证输出可复现
ts = 1_695_211_200
# 时间戳 -> 英文可读串
print(time.ctime(ts)) # 时间戳 -> 可读字符串
# struct_time -> 英文可读串
print(time.asctime(time.gmtime(ts))) # struct_time -> 可读字符串
print(len(time.ctime(ts)) == len(time.asctime(time.localtime(ts))))输出:
1
2
3Wed Sep 20 20:00:00 2023
Wed Sep 20 12:00:00 2023
True这两个是「给人看一眼」的格式(英文月名、日期补空格),要存要传就用
strftime或datetime.isoformat()。 -
整体示例(time)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32import time
# 固定一个不随运行时刻变化的时间戳,保证输出可复现
ts = 1_695_211_200 # 2023-09-20 12:00:00 UTC
# 1) 三种形态互转
# 按 UTC 拆开
st_utc = time.gmtime(ts)
# 按本地时区拆开
st_local = time.localtime(ts)
print('UTC -> 本地小时:', st_utc.tm_hour, '->', st_local.tm_hour)
# struct_time -> 时间戳,走本地时区
print('mktime 能还原:', int(time.mktime(st_local)) == ts)
# struct_time -> 字符串
s = time.strftime('%Y-%m-%d %H:%M:%S', st_utc)
print('格式化:', s, '| 解析回来一致:', time.strptime(s, '%Y-%m-%d %H:%M:%S')[:3] == (2023, 9, 20))
# 2) 星期编号的两个坑
print('%w(0=周日):', time.strftime('%w', st_utc), ' tm_wday(0=周一):', st_utc.tm_wday)
# 3) 时区信息
print('time.timezone(西为正):', time.timezone, ' tm_gmtoff(东为正):', st_local.tm_gmtoff)
# 4) 计时:sleep 0.1 秒,三种钟各测一次
for name in ('perf_counter', 'monotonic', 'process_time'):
f = getattr(time, name)
a = f()
time.sleep(0.1)
print(f'{name} 量到 >= 0.1:', round(f() - a, 1) >= 0.1)
# 5) 可读格式
print('ctime 与 asctime 长度一致:', len(time.ctime(ts)) == len(time.asctime(st_local)))实跑输出:
1
2
3
4
5
6
7
8
9UTC -> 本地小时: 12 -> 20
mktime 能还原: True
格式化: 2023-09-20 12:00:00 | 解析回来一致: True
%w(0=周日): 3 tm_wday(0=周一): 2
time.timezone(西为正): -28800 tm_gmtoff(东为正): 28800
perf_counter 量到 >= 0.1: True
monotonic 量到 >= 0.1: True
process_time 量到 >= 0.1: False
ctime 与 asctime 长度一致: True
calendar:日历表、闰年与月份信息
-
闰年与月份
1
2
3
4
5
6
7
8
9
10
11
12
13
14import calendar
# 能被4整除且不被100整除,或被400整除
print('isleap:', calendar.isleap(2020), calendar.isleap(2023), calendar.isleap(1900), calendar.isleap(2000))
# 左闭右开:数的是 [2000, 2020) 里的闰年
print('leapdays(2000, 2020):', calendar.leapdays(2000, 2020))
# 右端点取到就多用一年
print('leapdays(2000, 2021):', calendar.leapdays(2000, 2021))
# 返回(第一天是星期几, 该月天数),0 = 周一
print('monthrange(2023, 9):', calendar.monthrange(2023, 9), '共', calendar.monthrange(2023, 9)[1], '天')
# 闰年 29 天,平年 28 天
print('2 月:', calendar.monthrange(2024, 2)[1], calendar.monthrange(2023, 2)[1])
# 和 date.weekday() 同一套编号
print('weekday(2023, 9, 1):', calendar.weekday(2023, 9, 1), '| FRIDAY =', calendar.FRIDAY)输出:
1
2
3
4
5
6isleap: True False False True
leapdays(2000, 2020): 5
leapdays(2000, 2021): 6
monthrange(2023, 9): (calendar.FRIDAY, 30) 共 30 天
2 月: 29 28
weekday(2023, 9, 1): 4 | FRIDAY = 4isleap走的是完整规则:能被 4 整除且不被 100 整除,或被 400 整除——所以 1900 不是闰年、2000 是。leapdays(y1, y2)是左闭右开,数的是[y1, y2)里的闰年个数,不是「两年之差」。monthrange返回(第一天是星期几, 天数);星期编号 0 = 周一 … 6 = 周日,和datetime.date.weekday()一致。calendar.FRIDAY打印成4是因为它是Day枚举(IntEnum),参与运算时就是整数 4;repr显示成calendar.FRIDAY只是好看。
-
一周从哪天开始:
firstweekday1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20import calendar
# 默认 0 = 周一
print('默认 firstweekday:', calendar.firstweekday(), '| MONDAY =', calendar.MONDAY, 'SUNDAY =', calendar.SUNDAY)
# 0 表示那格不属于本月
print('周一起始的 2023-09 前两行:', calendar.monthcalendar(2023, 9)[:2])
# 改的是全局状态,库代码里慎用
calendar.setfirstweekday(calendar.SUNDAY)
# 同一份数据,列的位置整体右移
print('周日起始后:', calendar.monthcalendar(2023, 9)[:2], '| firstweekday:', calendar.firstweekday())
# 它不受 firstweekday 影响
print('day_name 永远是周一起:', list(calendar.day_name)[:3])
# 这个是模块级没有、要经 Calendar 实例调
print('Calendar().iterweekdays() 跟着变:', [calendar.day_abbr[d] for d in calendar.Calendar(firstweekday=6).iterweekdays()][:3])
try:
# 故意传越界值
calendar.setfirstweekday(7)
except Exception as e:
print('越界:', type(e).__name__, e)
calendar.setfirstweekday(calendar.MONDAY)输出:
1
2
3
4
5
6默认 firstweekday: 0 | MONDAY = 0 SUNDAY = 6
周一起始的 2023-09 前两行: [[0, 0, 0, 0, 1, 2, 3], [4, 5, 6, 7, 8, 9, 10]]
周日起始后: [[0, 0, 0, 0, 0, 1, 2], [3, 4, 5, 6, 7, 8, 9]] | firstweekday: 6
day_name 永远是周一起: ['Monday', 'Tuesday', 'Wednesday']
Calendar().iterweekdays() 跟着变: ['Sun', 'Mon', 'Tue']
越界: IllegalWeekdayError bad weekday number 7; must be 0 (Monday) to 6 (Sunday)day_name/day_abbr/month_name不受setfirstweekday影响(永远是周一开头 / 一月在索引 1),受影响的是monthcalendar、iterweekdays()和打印出来的日历表头。原笔记在这里标了「注意:受 firstweekday 设置影响」,是错的。 -
打印日历
1
2
3
4
5
6
7
8
9import calendar
# 返回字符串;prmonth 是直接 print
cal = calendar.month(2023, 9)
print('\n'.join(cal.split('\n')[:4]))
# 一年 12 个月排下来
print('全年日历行数:', len(calendar.calendar(2023).split('\n')))
# 每行一周,7 列
print('monthcalendar 是二维表:', len(calendar.monthcalendar(2023, 9)), '行 x 7 列,0 表示空位')输出:
1
2
3
4
5
6September 2023
Mo Tu We Th Fr Sa Su
1 2 3
4 5 6 7 8 9 10
全年日历行数: 37
monthcalendar 是二维表: 5 行 x 7 列,0 表示空位prmonth/prcalendar是直接打印版(print出来的),month/calendar是返回字符串版,要拼进别处就用后者。 -
TextCalendar与HTMLCalendar1
2
3
4
5
6
7
8
9
10
11import calendar
# 类接口:firstweekday 只影响这个对象
tc = calendar.TextCalendar(firstweekday=0)
print('\n'.join(tc.formatmonth(2023, 9).split('\n')[:2]))
print('全年文本长度:', len(tc.formatyear(2023)))
# 周日起始,美式网页日历
hc = calendar.HTMLCalendar(firstweekday=6) # 周日起始,美式网页日历
html = hc.formatmonth(2023, 9)
# 直接能嵌进网页
print('HTML 以 <table 开头:', html.startswith('<table'), '| 有几行 <tr>:', html.count('<tr'))输出:
1
2
3
4September 2023
Mo Tu We Th Fr Sa Su
全年文本长度: 2059
HTML 以 <table 开头: True | 有几行 <tr>: 7两个类都接受
firstweekday参数,不用改全局状态,比setfirstweekday干净——写库代码时优先用它们。 -
整体示例(calendar)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25import calendar
# 1) 这个月几天、第一天星期几、怎么排
y, m = 2024, 2
first, days = calendar.monthrange(y, m)
print(f'{y}-{m:02d} 共 {days} 天,第一天是', calendar.day_name[first])
print('闰年判断:', calendar.isleap(y), '| 2000-2024 之间闰年数:', calendar.leapdays(2000, 2024))
# 2) 用 monthcalendar 自己数「有几个完整周」「周三有几天」
weeks = calendar.monthcalendar(y, m)
print('行数:', len(weeks), '| 周三那一列的非零天数:', sum(1 for w in weeks if w[calendar.WEDNESDAY]))
# 3) 找某个月里所有周五
fridays = [w[calendar.FRIDAY] for w in weeks if w[calendar.FRIDAY]]
print(f'{y}-{m:02d} 的周五:', fridays)
# 4) 局部设置一周起点,不影响全局
tc = calendar.TextCalendar(firstweekday=calendar.SUNDAY)
print('周日起始表头:', tc.formatmonth(y, m).split('\n')[1])
print('全局默认还是:', calendar.day_abbr[calendar.firstweekday()])
# 5) 星期编号对照:calendar / datetime 用 0=周一,%w 用 0=周日
print('calendar.weekday(2023,9,1):', calendar.weekday(2023, 9, 1),
'| date(2023,9,1).weekday():', __import__('datetime').date(2023, 9, 1).weekday(),
'| isoweekday():', __import__('datetime').date(2023, 9, 1).isoweekday())实跑输出:
1
2
3
4
5
6
72024-02 共 29 天,第一天是 Thursday
闰年判断: True | 2000-2024 之间闰年数: 6
行数: 5 | 周三那一列的非零天数: 4
2024-02 的周五: [2, 9, 16, 23]
周日起始表头: Su Mo Tu We Th Fr Sa
全局默认还是: Mon
calendar.weekday(2023,9,1): 4 | date(2023,9,1).weekday(): 4 | isoweekday(): 5
datetime:日期与时间对象
-
五个类(
from datetime import date, time, datetime, timedelta, timezone):类 表示 备注 date年月日 无时区 time时分秒微秒 可带 tzinfodatetime日期 + 时间 是 date的子类timedelta时间间隔 没有 months/years参数tzinfo/timezone时区 timezone是tzinfo的固定偏移实现 -
date
1
2
3
4
5
6
7
8
9
10
11
12from datetime import date
# 年月日,无时区
d = date(2023, 5, 15)
print(d.year, d.month, d.day, '| isoformat:', d.isoformat())
# date 是不可变对象
print('replace 产生新对象:', d.replace(day=1), '| 原对象不变:', d)
# 两套编号差 1
print('weekday(0=周一) / isoweekday(1=周一):', d.weekday(), d.isoweekday())
print('fromisoformat:', date.fromisoformat('2023-05-15'))
# 不是天数,是个间隔对象
print('两个日期相减得到 timedelta:', date(2023, 6, 1) - d)输出:
1
2
3
4
52023 5 15 | isoformat: 2023-05-15
replace 产生新对象: 2023-05-01 | 原对象不变: 2023-05-15
weekday(0=周一) / isoweekday(1=周一): 0 1
fromisoformat: 2023-05-15
两个日期相减得到 timedelta: 17 days, 0:00:00 -
time 与 datetime
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15from datetime import date, datetime, time, timezone
# 时分秒微秒
t = time(14, 30, 15, 123456)
print(t, '| 属性:', t.hour, t.minute, t.second, t.microsecond, '| tzinfo 默认:', t.tzinfo)
# date + time
dt = datetime(2023, 5, 15, 14, 30, 15)
# date() / time() 互相转
print('拆成两半:', dt.date(), dt.time())
print('combine 合成:', datetime.combine(date(2023, 5, 15), time(14, 30)))
print('strptime / strftime:', datetime.strptime('2023-05-15', '%Y-%m-%d'), '|', dt.strftime('%Y/%m/%d'))
# 3.11+ 能解析 ISO 的 Z 和任意偏移
print('fromisoformat 带偏移:', datetime.fromisoformat('2023-05-15T14:30:15+08:00'))
print('now() 是 naive,now(timezone.utc) 才带时区:', datetime.now().tzinfo, '/', datetime.now(timezone.utc).tzinfo)输出:
1
2
3
4
5
614:30:15.123456 | 属性: 14 30 15 123456 | tzinfo 默认: None
拆成两半: 2023-05-15 14:30:15
combine 合成: 2023-05-15 14:30:00
strptime / strftime: 2023-05-15 00:00:00 | 2023/05/15
fromisoformat 带偏移: 2023-05-15 14:30:15+08:00
now() 是 naive,now(timezone.utc) 才带时区: None / UTC时间戳互转有个必须记住的点:naive(不带时区)的
datetime在调timestamp()时按本地时区解释。1
2
3
4
5
6
7
8
9
10from datetime import datetime, timezone
# 不带 tzinfo
naive = datetime(2023, 5, 15, 14, 30, 15)
# 同一串数字,声明成 UTC
aware = naive.replace(tzinfo=timezone.utc)
print('naive.timestamp():', naive.timestamp())
print('当成 UTC 的:', aware.timestamp(), '| 差 8 小时:', int(aware.timestamp() - naive.timestamp()) == 8 * 3600)
# fromtimestamp 按本地时区解释
print('fromtimestamp 给回本地时间:', datetime.fromtimestamp(naive.timestamp()) == naive)输出(本机 UTC+8):
1
2
3naive.timestamp(): 1684132215.0
当成 UTC 的: 1684161015.0 | 差 8 小时: True
fromtimestamp 给回本地时间: Truedatetime.utcnow()/utcfromtimestamp()从 3.12 起弃用(会报DeprecationWarning),改用datetime.now(timezone.utc)和datetime.fromtimestamp(ts, timezone.utc)。 -
timedelta
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15from datetime import datetime, timedelta
# 间隔可以是各分量拼出来的
d = timedelta(days=1, hours=2, minutes=3)
print('total_seconds():', d.total_seconds())
print('构造参数只有 weeks/days/hours/minutes/seconds/microseconds/milliseconds:', end=' ')
try:
# "月" 长度不固定,所以没有这个参数
timedelta(months=1)
except TypeError:
print('没有 months')
# -1 天 + 23 小时 会拆成 days=-1, seconds=82800
print('规范化:days 可负,seconds 永远 0~86399:', timedelta(days=-1, hours=23).days, timedelta(days=-1, hours=23).seconds)
print('日期加减:', datetime(2023, 5, 15) + timedelta(weeks=1), '|', datetime(2023, 5, 15) - timedelta(days=3))
print('时间差:', (datetime(2023, 5, 20) - datetime(2023, 5, 15)).days, '天')输出:
1
2
3
4
5total_seconds(): 93780.0
构造参数只有 weeks/days/hours/minutes/seconds/microseconds/milliseconds: 没有 months
规范化:days 可负,seconds 永远 0~86399: -1 82800
日期加减: 2023-05-22 00:00:00 | 2023-05-12 00:00:00
时间差: 5 天「一个月之后」得自己算(换月逻辑用
date.replace或第三方dateutil.relativedelta),因为「月」的长度不固定。 -
时区:
timezone与tzinfo1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22from datetime import datetime, timedelta, timezone, tzinfo
print('UTC:', timezone.utc, '| 东八区:', timezone(timedelta(hours=8)))
# 东八区
dt8 = datetime(2023, 5, 15, 14, 30, tzinfo=timezone(timedelta(hours=8)))
print('带时区的字符串:', dt8, '| isoformat:', dt8.isoformat())
# astimezone 换区,时刻不变
print('转 UTC:', dt8.astimezone(timezone.utc))
print('utcoffset:', dt8.utcoffset())
# 自己实现 tzinfo 只要三个方法(固定偏移其实用 timezone 就够了)
class FixedOffset(tzinfo):
def __init__(self, hours, name):
self.__offset, self.__name = timedelta(hours=hours), name
def utcoffset(self, dt):
return self.__offset
def tzname(self, dt):
return self.__name
def dst(self, dt):
return timedelta(0)
print('自定义时区:', datetime(2023, 5, 15, 14, 30, tzinfo=FixedOffset(8, 'Beijing')))输出:
1
2
3
4
5UTC: UTC | 东八区: UTC+08:00
带时区的字符串: 2023-05-15 14:30:00+08:00 | isoformat: 2023-05-15T14:30:00+08:00
转 UTC: 2023-05-15 06:30:00+00:00
utcoffset: 8:00:00
自定义时区: 2023-05-15 14:30:00+08:00naive 和 aware 两种 datetime 不能混着比较或相减,会抛
TypeError: can't subtract offset-naive and offset-aware datetimes。 -
整体示例(datetime)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26from datetime import date, datetime, time, timedelta, timezone
# 1) 解析 -> 计算 -> 格式化,一条龙
start = datetime.strptime('2023-05-15 09:30', '%Y-%m-%d %H:%M')
end = start + timedelta(days=3, hours=4, minutes=45)
print('结束时间:', end.isoformat())
print('相隔秒数:', int((end - start).total_seconds()))
# 2) 只取日期部分做比较(抹掉时分秒)
print('同一天:', end.date() == (start + timedelta(days=3)).date(), '| 跨了几天:', (end.date() - start.date()).days)
# 3) 时间戳往返
ts = start.timestamp()
print('往返一致:', datetime.fromtimestamp(ts) == start, '| 当成 UTC 就差了 8 小时:',
int(start.replace(tzinfo=timezone.utc).timestamp() - ts) == 8 * 3600)
# 4) 时区换算:东八区 -> UTC -> 东九区
dt8 = start.replace(tzinfo=timezone(timedelta(hours=8)))
print('东八区:', dt8.isoformat())
print('UTC :', dt8.astimezone(timezone.utc).isoformat())
print('东九区:', dt8.astimezone(timezone(timedelta(hours=9))).isoformat())
# 5) 生日倒计时这类「只关心日期」的场景
today = date(2023, 5, 1)
birthday = date(2023, 5, 15)
print('还有', (birthday - today).days, '天 | 今年已过', (today - date(today.year, 1, 1)).days, '天')实跑输出:
1
2
3
4
5
6
7
8结束时间: 2023-05-18T14:15:00
相隔秒数: 276300
同一天: True | 跨了几天: 3
往返一致: True | 当成 UTC 就差了 8 小时: True
东八区: 2023-05-15T09:30:00+08:00
UTC : 2023-05-15T01:30:00+00:00
东九区: 2023-05-15T10:30:00+09:00
还有 14 天 | 今年已过 120 天
zoneinfo:按地区处理时区
timezone(timedelta(hours=8)) 只能表示「固定偏移」,管不了夏令时。要按地区处理时区(纽约、上海……),用 zoneinfo,它读的是 IANA(Olson)时区数据库。
-
基本用法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18from datetime import datetime
from zoneinfo import ZoneInfo, available_timezones
# 按地区取时区,夏令时规则它自己算
sh = ZoneInfo('Asia/Shanghai')
ny = ZoneInfo('America/New_York')
# 构造时直接给 tzinfo 就行,不像 pytz 要 localize
dt = datetime(2023, 4, 15, 12, 0, tzinfo=sh)
print('上海:', dt)
print('纽约:', dt.astimezone(ny))
# str() 给的是时区名,不是偏移
print('ZoneInfo 的 str/key:', str(ny), ny.key)
print('时区可用数量 > 400:', len(available_timezones()) > 400)
print('同一个名字拿到的是同一个对象(内置缓存):', ZoneInfo('Asia/Shanghai') is sh)
try:
ZoneInfo('Mars/Olympus')
except LookupError as e:
print('不存在的时区:', type(e).__name__)输出:
1
2
3
4
5
6上海: 2023-04-15 12:00:00+08:00
纽约: 2023-04-15 00:00:00-04:00
ZoneInfo 的 str/key: America/New_York America/New_York
时区可用数量 > 400: True
同一个名字拿到的是同一个对象(内置缓存): True
不存在的时区: ZoneInfoNotFoundError -
固定偏移 ≠ 地区时区
1
2
3
4
5
6
7
8
9from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
# 手写死偏移
jul = datetime(2023, 7, 1, tzinfo=timezone(timedelta(hours=-5)))
# 同一地区,7 月是 EDT
same = datetime(2023, 7, 1, tzinfo=ZoneInfo('America/New_York'))
print('写死 -5:', jul, '| 纽约 7 月实际是 EDT:', same)
print('两者相差小时:', (same.utcoffset() - jul.utcoffset()).total_seconds() / 3600)输出:
1
2写死 -5: 2023-07-01 00:00:00-05:00 | 纽约 7 月实际是 EDT: 2023-07-01 00:00:00-04:00
两者相差小时: 1.0这就是为什么跨时区的业务要用
ZoneInfo,别手写偏移。 -
夏令时切换
美国 2023 年的夏令时从 3 月 12 日(3 月第二个周日)凌晨 2 点开始:时钟从 1:59:59 直接跳到 3:00:00,中间那一小时在墙上不存在。
1
2
3
4
5
6
7
8
9
10
11
12
13from datetime import datetime
from zoneinfo import ZoneInfo
ny = ZoneInfo('America/New_York')
# 切换前,EST -05:00
pre = datetime(2023, 3, 12, 1, 59, tzinfo=ny) # 切换前,EST -05:00
# 切换后,EDT -04:00
post = datetime(2023, 3, 12, 3, 0, tzinfo=ny) # 切换后,EDT -04:00
print('切换前偏移:', pre.utcoffset(), '切换后:', post.utcoffset())
print('墙上时钟差 1 小时 1 分,真实经过:', post - pre)
# 这个时间根本不存在
gap = datetime(2023, 3, 12, 2, 30, tzinfo=ny) # 这个时间根本不存在
print('不存在的 2:30 被按', gap.utcoffset(), '处理,不会报错:', gap)输出:
1
2
3切换前偏移: -1 day, 19:00:00 切换后: -1 day, 20:00:00
墙上时钟差 1 小时 1 分,真实经过: 1:01:00
不存在的 2:30 被按 -1 day, 19:00:00 处理,不会报错: 2023-03-12 02:30:00-05:00utcoffset()打印成-1 day, 19:00:00是timedelta表示负数的常规写法,意思就是-5:00:00;想要人话就str(pre.tzname())或pre.strftime('%z')。 -
数据来源
- 优先用操作系统的时区数据(Linux/macOS 上一般在
/usr/share/zoneinfo)。 - Windows 上没有系统 tz 库,所以要装
tzdata包:pip install tzdata(本机已装,上面的例子才跑得动)。装好后zoneinfo自动用它,代码不用改。 ZoneInfo对象不可变且自带缓存,重复用同一个名字不会反复读盘。
- 优先用操作系统的时区数据(Linux/macOS 上一般在
-
整体示例(zoneinfo)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15from datetime import datetime
from zoneinfo import ZoneInfo
cities = ['Asia/Shanghai', 'Europe/London', 'America/New_York']
moment = datetime(2023, 7, 1, 9, 0, tzinfo=ZoneInfo('Asia/Shanghai'))
print('基准:', moment.isoformat())
for c in cities:
loc = moment.astimezone(ZoneInfo(c))
print(f'{c:20} {loc.strftime("%H:%M %z")} {loc.tzname()}')
# 同一个城市,冬夏偏移不同 -> 说明它是「地区」不是「固定偏移」
utc = ZoneInfo('UTC')
jan = datetime(2023, 1, 15, 12, 0, tzinfo=utc).astimezone(ZoneInfo('America/New_York'))
jul = datetime(2023, 7, 15, 12, 0, tzinfo=utc).astimezone(ZoneInfo('America/New_York'))
print('UTC 12:00 -> 纽约 1 月:', jan.strftime('%H:%M %z'), '| 7 月:', jul.strftime('%H:%M %z'))实跑输出:
1
2
3
4
5基准: 2023-07-01T09:00:00+08:00
Asia/Shanghai 09:00 +0800 CST
Europe/London 02:00 +0100 BST
America/New_York 21:00 -0400 EDT
UTC 12:00 -> 纽约 1 月: 07:00 -0500 | 7 月: 08:00 -0400
pytz:老的第三方时区库
Python 3.9 有了 zoneinfo 之后,pytz 基本只出现在老代码里,但它在依赖树里活着,看懂它的坑有用。本机 pytz 已安装(pip install pytz)。
-
基本用法
1
2
3
4
5
6
7
8
9
10
11
12
13
14from datetime import datetime, timedelta
import pytz
print('时区总数:', len(pytz.all_timezones), '| 常用:', len(pytz.common_timezones))
# 只有 UTC 可以直接当 tzinfo 用
utc = pytz.utc
# 其它时区都不行
eastern = pytz.timezone('US/Eastern')
now = datetime(2023, 1, 1, 12, 0, tzinfo=utc) # UTC 可以直接塞 tzinfo
print('UTC:', now)
# 把 naive 时间「贴」上时区
local = eastern.localize(datetime(2023, 1, 1, 12, 0)) # 非 UTC 必须 localize
print('localize 之后:', local, local.strftime('%Z'))
print('转回 UTC:', local.astimezone(utc))输出:
1
2
3
4时区总数: 597 | 常用: 433
UTC: 2023-01-01 12:00:00+00:00
localize 之后: 2023-01-01 12:00:00-05:00 EST
转回 UTC: 2023-01-01 17:00:00+00:00 -
最大的坑:不要直接把
pytz时区塞进tzinfo1
2
3
4
5
6
7
8
9
10
11
12from datetime import datetime, timedelta
import pytz
# 其它时区都不行
eastern = pytz.timezone('US/Eastern')
# 错法:会取到 1883 年的 LMT 偏移
bad = datetime(2023, 1, 1, 12, tzinfo=eastern) # 错
# 对法
good = eastern.localize(datetime(2023, 1, 1, 12)) # 对
print('直接塞 tzinfo:', bad, bad.strftime('%Z'))
print('localize :', good, good.strftime('%Z'))
print('差了', int((good - bad).total_seconds() // 60), '分钟')输出:
1
2
3直接塞 tzinfo: 2023-01-01 12:00:00-04:56 LMT
localize : 2023-01-01 12:00:00-05:00 EST
差了 4 分钟pytz的时区对象里带着一串历史偏移(1883 年之前的 LMT 平局时间),直接当tzinfo用会取到第一个(LMT,1883 年的 -04:56),而不是当前该用的那个。所以:pytz时区只能用localize()/fromutc(),不能塞进构造参数。 -
加减时间后要
normalize()1
2
3
4
5
6
7
8
9
10
11
12
13
14from datetime import datetime, timedelta
import pytz
# 其它时区都不行
eastern = pytz.timezone('US/Eastern')
dt = eastern.localize(datetime(2023, 1, 1, 12))
# 偏移还按 1 月的 -05:00 算
naive_plus = dt + timedelta(days=200) # 偏移还按 1 月的 -05:00 算
# 重算偏移,落到 7 月的 EDT
fixed = eastern.normalize(naive_plus) # 重新落到 7 月的 EDT -04:00
print('直接相加:', naive_plus)
print('normalize:', fixed)
print('还是同一时刻:', fixed == naive_plus, '| 墙上时间差了:',
(fixed.replace(tzinfo=None) - naive_plus.replace(tzinfo=None)).total_seconds() / 3600, '小时')输出:
1
2
3直接相加: 2023-07-20 12:00:00-05:00
normalize: 2023-07-20 13:00:00-04:00
还是同一时刻: True | 墙上时间差了: 1.0 小时zoneinfo不需要这一步:它的偏移是每次取值时按日期算出来的,这也是「接口更符合 Python 习惯」的具体含义。 -
整体示例(pytz 与 zoneinfo 对照)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21from datetime import datetime, timedelta
import pytz
from zoneinfo import ZoneInfo
# 同一个「纽约 1 月中午」,两种库分别怎么构造
# pytz 要 localize
p_local = pytz.timezone('America/New_York').localize(datetime(2023, 1, 15, 12, 0))
# zoneinfo 直接构造
z_local = datetime(2023, 1, 15, 12, 0, tzinfo=ZoneInfo('America/New_York'))
print('pytz :', p_local, p_local.strftime('%Z'))
print('zoneinfo:', z_local, z_local.strftime('%Z'))
print('两者指向同一时刻:', p_local == z_local)
# 加 200 天:pytz 要 normalize,zoneinfo 不用
print('pytz 直接加:', p_local + timedelta(days=200))
print('pytz normalize:', pytz.timezone('America/New_York').normalize(p_local + timedelta(days=200)))
print('zoneinfo 直接加:', z_local + timedelta(days=200))
# 转 UTC 一致
# astimezone 换区,时刻不变
print('转 UTC:', p_local.astimezone(pytz.utc) == z_local.astimezone(ZoneInfo('UTC')))实跑输出:
1
2
3
4
5
6
7pytz : 2023-01-15 12:00:00-05:00 EST
zoneinfo: 2023-01-15 12:00:00-05:00 EST
两者指向同一时刻: True
pytz 直接加: 2023-08-03 12:00:00-05:00
pytz normalize: 2023-08-03 13:00:00-04:00
zoneinfo 直接加: 2023-08-03 12:00:00-04:00
转 UTC: True结论:新代码一律用
zoneinfo(Python < 3.9 才装pytz);维护老代码时记住pytz的两条规矩——构造用localize(),加减之后用normalize()。