函数使用(六)

前言

  • 这一篇是「跟文件跟时间打交道」会用到的标准库: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
    44
    import 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
    8
    copy 没保留 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
    27
    import 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
    19
    import 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 能用: True

    make_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
    11
    import 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_size

    disk_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
    31
    import 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
    7
    import 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
    8
    import 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
    4
    gmtime(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 9

    struct_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
    8
    import 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 / %p 24 时 / 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 / %Z UTC 偏移 / 时区名 +0800 / 中国夏令时
    %c / %x / %X 本地完整日期时间 / 日期 / 时间 Wed Sep 20 13:04:05 2023 / 09/20/23 / 13:04:05
    %G / %V / %u ISO 年 / 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
    10
    import 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
    2
    time 模块 %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
    18
    import 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
    3
    perf_counter 含睡眠: True
    process_time 不含睡眠: True
    monotonic 含睡眠: True
  • 时区相关的模块变量

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import 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
    4
    timezone: -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
    9
    import 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
    3
    Wed 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
    32
    import 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
    9
    UTC -> 本地小时: 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
    14
    import 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
    6
    isleap: 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 = 4
    • isleap 走的是完整规则:能被 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 只是好看。
  • 一周从哪天开始:firstweekday

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import 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
    9
    import 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
    6
       September 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 与 HTMLCalendar

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import 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
    4
       September 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
    25
    import 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
    7
    2024-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 时分秒微秒 可带 tzinfo
    datetime 日期 + 时间 是 date 的子类
    timedelta 时间间隔 没有 months / years 参数
    tzinfo / timezone 时区 timezone 是 tzinfo 的固定偏移实现
  • date

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    from 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
    5
    2023 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
    15
    from 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
    6
    14: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
    10
    from 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
    3
    naive.timestamp(): 1684132215.0
    当成 UTC 的: 1684161015.0 | 差 8 小时: True
    fromtimestamp 给回本地时间: True

    datetime.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
    15
    from 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
    5
    total_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 与 tzinfo

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    from 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
    5
    UTC: 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:00

    naive 和 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
    26
    from 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
    18
    from 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
    9
    from 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
    13
    from 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:00

    utcoffset() 打印成 -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 对象不可变且自带缓存,重复用同一个名字不会反复读盘。
  • 整体示例(zoneinfo)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    from 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
    14
    from 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 时区塞进 tzinfo

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    from 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
    14
    from 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
    21
    from 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
    7
    pytz   : 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()。

参考

本文结束 感谢您的阅读
正在加载今日诗词....