用Python标准库实现年月周日周期切分,一个函数生成完整周期桶
上个月给一个数据分析项目做周期报表需求本身很简单根据给定的时间范围把年、季、月、周的开始和结束日期全部算出来再拿去做统计聚合。我一开始没当回事直接写了几个循环结果跨年、闰年、周一起点、季度分界这些细节全冒出来了光是改边界条件就折腾了半个下午。更麻烦的是业务方后来要求“每个周期必须是完整的自然周/自然月”第一周要从周一开始算哪怕传入的起始日是周三我也得把这一周从周一开始补齐。这种需求在报表系统、数据分片、批量任务调度里太常见了但网上能搜到的代码大多只讲某一种粒度很少一次性把年、季、月、周四种情况统一处理干净。这篇文章就把我最终沉淀下来的方法完整拆开讲一个函数输入起止日期和粒度输出内部所有周期的起止日期。代码基于标准库datetime和calendar不依赖任何第三方包可以直接复制进项目里用。适合正在做时间序列统计、报表开发、数据同步分片的同学参考也适合刚接触 Python 日期处理、想搞明白周和自然月边界到底怎么算的人。1. 为什么“算日期区间”这件事没有想象中简单1.1 一个真实的业务场景数据分片与按周聚合我最初面对的需求来自一个数据同步任务每隔一段时间要把一个线上数据库里某个时间段的数据捞出来写入离线数仓。每次同步不能一次性拉一个月一是慢二是源库压力大所以要按周拆成一个个小批次。每个小批次要去数据库里查“某周开始到某周结束”的数据然后把批次信息记录到任务表里。听起来不复杂但第一版我犯了个典型错误直接用start timedelta(days7)往后推。结果遇到 2023 年 1 月 1 日这种日期推出来的“周”有的从周日开始有的从周一开始全乱套了。后来才意识到业务方说的“周”是自然周自然周必须有固定的周起始日而不是你从哪里开始推哪里就是这一周的开始。1.2 “年、季、月、周”的边界不同业务有不同定义同样是“月份”财务月、自然月、营业月可能是三套完全不同的东西。同样是一周欧美一些系统习惯周日作为一周开始国内多数业务习惯周一作为一周开始而 ISO 8601 标准又规定周一才开始。至于季度有的公司按自然季度算1-3 月、4-6 月有的行业按淡旺季自定义季度比如零售业干脆把 11 月和 12 月单独拎出来当促销季。这些差异直接决定了你的切分算法怎么写。我的建议是先跟业务方确认三个问题。第一周到底从哪天开始第二返回的日期是自然边界比如 1 月 1 日到 12 月 31 日还是必须在给定的起止时间内第三最后一个不完整的周期是截断到结束日期还是仍返回完整周期。在大多数报表需求里大家希望第一个周期从自然起点开始最后一个周期截断到传入的结束日期这样每个周期的“计划开始时间”和“实际结束时间”都明确不会出现超出用户给定范围的数据。1.3 常见实现方案的坑网上搜“python 计算月开始结束”通常会给你monthrange的答案这个确实能拿到当月最后一天但它只解决单个月份的问题。搜“python 计算某个日期所在周的周一”也会有几个答案但和月、季、年组合起来往往就变成一坨不断 if-else 的烂代码。用pandas的date_range也能做pd.date_range(start, end, freqW-MON)能生成周一序列freqM能生成月末序列。但 pandas 的周期对齐规则比较重对“给定时间范围自然周期边界”这种任务反而要绕不少弯。后面我会专门对比一下两种方案的取舍。先把核心逻辑讲透。2. 核心设计把时间范围拆成“周期桶”列表2.1 周期桶模型每个桶的东家是自然边界我用的思路是先给定开始日期start和结束日期end再给定粒度freq然后生成一个列表列表里每个元素是一个二元组(bucket_start, bucket_end)代表一个完整周期。关键点在于bucket_start的语义——它不一定是传入的start本身而是start 所在的自然周期的起点。举个例子传入2023-01-15按月切分时第一个桶起点是2023-01-01不是2023-01-15按周切分时如果 2023-01-15 是周日而业务上按周一开始那第一个桶起点就是2023-01-09因为是那一周的周一。这个桶的起点可能早于传入的start但这样才是业务上真正想要的“完整周期”。最后一个桶的结束日期则会被截断到传入的end保证所有桶拼起来正好覆盖用户给定的时间范围。这个模型在后续做 SQL 查询、归档分片、报表对齐时都非常好用因为每个桶的边界就是权威的周期边界不会因为用户传入的日期落在周期中间而失真。2.2 四种粒度的起点、步进、终点推导逻辑要统一处理四种粒度关键是找到每个粒度的三个要素起始点怎么找、每批之间怎么推进、周期的自然终点怎么算。先说起点年粒度date(start.year, 1, 1)就是当年的 1 月 1 日。季粒度先算(start.month - 1) // 3得到 0、1、2、3 四个季度编号再把起始月份映射到 1 月、4 月、7 月、10 月即date(start.year, q * 3 1, 1)。月粒度date(start.year, start.month, 1)当月 1 日。周粒度start - timedelta(daysstart.weekday())这里start.weekday()周一返回 0周日返回 6这样往前减到周一。如果要周日作为一周开始算法是start - timedelta(days(start.weekday() 1) % 7)后面扩展里会说到。再说推进年推进是年份加一回到下一年 1 月 1 日季推进是月份加 3但日期一定要固定在当季第一个月的 1 日月推进是月份加一周推进最简单直接加 7 天。这里月、季推进不能用简单的month 3因为 12 月加 3 就是 3 月年份还要进位。我封装了一个add_months的小工具函数来统一处理。最后是自然终点年自然终点12 月 31 日。季自然终点当前季首加 3 个月得到下一季首再减一天这样 1 月 1 日会变成 3 月 31 日4 月 1 日会变成 6 月 30 日。月自然终点calendar.monthrange(year, month)[1]拿当月天数。周自然终点周起点加 6 天。2.3 周粒度自动处理跨年用“周起点加 7 天”的方式推进天然就把跨年问题解决了。比如2023-12-25是周一这一周的自然终点是2023-12-31跨过了元旦下一周的起点就是2024-01-01又是周一。因为 Python 的date类型加减timedelta会自动处理月份和年份进位所以代码里完全不需要关心 12 月加 7 天会怎样。这个看起来微不足道但恰恰是很多手写推导代码最容易出错的地方。3. 完整代码实现与使用示例3.1 一个零依赖的基础函数from datetime import date, timedelta from calendar import monthrange def add_months(y: int, m: int, delta: int): 在年份 y、月份 m 的基础上增加 delta 个月返回新的 (year, month)。 total y * 12 (m - 1) delta return total // 12, total % 12 1 def period_start(start: date, freq: str) - date: 返回 start 所在的自然周期开始日期。 if freq year: return date(start.year, 1, 1) if freq quarter: q (start.month - 1) // 3 return date(start.year, q * 3 1, 1) if freq month: return date(start.year, start.month, 1) if freq week: # 周一作为一周的开始 return start - timedelta(daysstart.weekday()) raise ValueError(funsupported freq: {freq}) def advance_period(cur: date, freq: str) - date: 从当前周期的开始日期推进到下一个周期的开始日期。 if freq year: return date(cur.year 1, 1, 1) if freq quarter: y, m add_months(cur.year, cur.month, 3) return date(y, m, 1) if freq month: y, m add_months(cur.year, cur.month, 1) return date(y, m, 1) if freq week: return cur timedelta(days7) raise ValueError(funsupported freq: {freq}) def period_end(bucket_start: date, freq: str) - date: 返回以 bucket_start 开始的自然周期的结束日期。 if freq year: return date(bucket_start.year, 12, 31) if freq quarter: y, m add_months(bucket_start.year, bucket_start.month, 3) return date(y, m, 1) - timedelta(days1) if freq month: last monthrange(bucket_start.year, bucket_start.month)[1] return date(bucket_start.year, bucket_start.month, last) if freq week: return bucket_start timedelta(days6) raise ValueError(funsupported freq: {freq}) def split_date_range(start: date, end: date, freq: str): 根据给定的时间范围 [start, end] 和 freq生成所有周期桶。 返回的列表每个元素是 (bucket_start, bucket_end) - bucket_start 是自然周期的起点可能早于传入的 start - bucket_end 是自然周期的终点但最后一个桶会被截断到 end。 if start end: raise ValueError(start must not be later than end) if freq not in (year, quarter, month, week): raise ValueError(freq must be one of: year, quarter, month, week) cur period_start(start, freq) buckets [] while cur end: bucket_end period_end(cur, freq) buckets.append((cur, min(bucket_end, end))) cur advance_period(cur, freq) return buckets这段代码的循环条件是cur end。因为cur始终是某个自然周期的起点所以只有当起点都没超过结束日期时这个周期才应该被纳入结果。如果最后一段只覆盖了几天也没关系min(bucket_end, end)会把它截断到end。3.2 典型调用与输出start date(2023, 1, 15) end date(2023, 3, 10) for bucket in split_date_range(start, end, month): print(bucket)输出(datetime.date(2023, 1, 1), datetime.date(2023, 1, 31)) (datetime.date(2023, 2, 1), datetime.date(2023, 2, 28)) (datetime.date(2023, 3, 1), datetime.date(2023, 3, 10))按周切for bucket in split_date_range(date(2023, 1, 4), date(2023, 1, 20), week): print(bucket)2023-01-04 是周三它所在的自然周从 2023-01-02 周一开始所以第一个桶是(datetime.date(2023, 1, 2), datetime.date(2023, 1, 8)) (datetime.date(2023, 1, 9), datetime.date(2023, 1, 15)) (datetime.date(2023, 1, 16), datetime.date(2023, 1, 20))第二个和第三个桶是完整周最后一个桶因为范围结束日是 1 月 20 日被截断到了当天。这种输出在“按周补数据”的场景里非常合适前两批都是完整周最后一批处理剩余几天逻辑清晰。3.3 为什么不用 pandas 的 date_range 硬扛很多人一看到时间序列就会想到pandas.date_range但在这个“周期桶”场景下它其实不是最优解至少有三个问题。第一是语义偏差。pd.date_range(start, end, freqM)生成的频率是“月末”不是“月首”你要的却是每个月 1 号和月末两个边界还得手动处理错位。freqW-MON能生成每个周一但要拿到“周一周日”的对还得自己去配对多了个层级。第二是返回值类型。date_range默认返回DatetimeIndex内部带时区语义如果你只是处理日期还需要再转date容易在类型判断上出问题。第三是重量级。项目里如果只是为了算几个日期就引入 pandas完全没必要部署环境也会因此多出一大坨依赖。当然pandas 的优势在于已经生成好的索引可以直接做groupby聚合。我的建议是在纯日期切分阶段用标准库计算结果喂给 pandas 做后续分析各干各的别让一个函数把所有事情都包了。4. 边界条件与测试用例4.1 最容易翻车的四类边界场景边界条件这种东西光看代码很难发现错在哪必须列成用例去推。我整理了四类最容易翻车的场景直接做成表格场景输入期望结果容易错的点跨年周start2023-12-25, end2024-01-05, freqweek2023-12-25~2023-12-312024-01-01~2024-01-05周终点 2023-12-31 跨到明年下一周起点必须是 2024-01-01闰年二月start2024-02-01, end2024-02-29, freqmonth2024-02-01~2024-02-292024 年 2 月有 29 天不能用固定 28季末截断start2023-03-15, end2023-04-20, freqquarter2023-01-01~2023-03-312023-04-01~2023-04-20季度起点早于 start第二季最后一天被截断单日范围startend2023-06-15, freqweek2023-06-12~2023-06-15只有一条桶且桶起点是周一而非传入日期跨年周是我自己在项目里真正踩过的坑。2023-12-25这一周自然终点是2023-12-31但业务上这一周的数据实际上包含了 2024 年 1 月 1 日之前的最后一天。如果用date_range配合月末很容易漏掉这一周或者把 2024 年第一周算重。我的代码里因为完全依赖timedelta做日期运算跨年时自动把日期进位到 2024 年就不会出现这种问题。4.2 把用例写成断言有了用例最好直接转成自动化测试。哪怕只是几行assert也比手动跑一遍强因为以后改代码可以立刻发现行为变化。def test_quarter_boundary(): got split_date_range(date(2023, 3, 15), date(2023, 4, 20), quarter) expected [ (date(2023, 1, 1), date(2023, 3, 31)), (date(2023, 4, 1), date(2023, 4, 20)), ] assert got expected, got def test_leap_year_month(): got split_date_range(date(2024, 2, 1), date(2024, 2, 29), month) expected [(date(2024, 2, 1), date(2024, 2, 29))] assert got expected, got def test_week_cross_year(): got split_date_range(date(2023, 12, 25), date(2024, 1, 5), week) expected [ (date(2023, 12, 25), date(2023, 12, 31)), (date(2024, 1, 1), date(2024, 1, 5)), ] assert got expected, got def test_single_day_week(): got split_date_range(date(2023, 6, 15), date(2023, 6, 15), week) assert got [(date(2023, 6, 12), date(2023, 6, 15))], got跑一下这四个测试如果全过基本可以放心用。我建议凡是涉及日期边界计算的工具函数都优先把测试写好特别是切换粒度、传入跨年份范围这些场景测试能帮你把绝大多数隐患卡在提交之前。4.3 容易误导的 ISO 周数与 isocalendar还有一个容易误导人的地方date.isocalendar()返回的是(ISO year, ISO week number, ISO weekday)它遵循 ISO 8601 标准ISO 年的起始周并不是 1 月 1 日所在的那一周。比如 2021 年 1 月 1 日它的 ISO 年份是 2020ISO 周数可能是第 53 周。如果你拿着“2021 年第 1 周”去算容易对不上月份和业务日历。我的代码不用isocalendar()直接用date.weekday()往前偏移这才是“自然周从周一开始”的直观算法。如果你确实需要“给定 ISO 年周号求日期”Python 3.8 以上可以用date.fromisocalendar(year, week, weekday)但要注意这里的year是 ISO 年不是自然年。两者的差异很细微一旦踩进去调试成本瞬间翻倍。5. 真实项目中的扩展应用5.1 按时间分片做数据同步我在数据同步里的用法是这样给定2023-01-01到2023-04-01按周拆出所有桶然后对每个桶执行一次查询。查询条件不要写成created_at bucket_start AND created_at bucket_end这个闭区间在边界时间戳上很容易重复或者漏数据。更稳的写法是把它转成半开区间bucket_end_exclusive bucket_end timedelta(days1)然后用 bucket_start AND bucket_end_exclusive。我为此专门写了个小包装函数返回的结果里额外加上一个“end_exclusive”字段。如果你也做分片任务强烈建议用半开区间。原因很简单2023-12-31 23:59:59这种时间戳精度很不可靠数据库里存2023-12-31 23:59:59、2024-01-01 00:00:00的都有用加可以彻底避开这一层的类型和精度问题。5.2 统计报表的周期对齐报表场景里经常会遇到“本周期与上周期对比”的需求。用这个函数生成周期桶然后把一个周期的开始日期作为聚合键就很自然。月份对比时同一个自然月的数据会落进同一个(2023-06-01, 2023-06-30)桶周报同理。只要理解了桶的边界是自然周期后续GROUP BY或者PIVOT都很顺手。5.3 进阶扩展自定义周起始、半开区间、字符串输入如果一个项目里既有国内业务又有海外业务有的要周一开头有的要周日开头那period_start里的周计算就应该抽出来加一个week_start_day参数def period_start_with_weekday(start: date, freq: str, week_start_day: int 0): if freq ! week: # 年月季逻辑保持不变可以直接复用 period_start return period_start(start, freq) offset (start.weekday() - week_start_day) % 7 return start - timedelta(daysoffset)这里week_start_day0表示周一6表示周日。分母% 7的作用是保证无论传入哪一天都能一致地往前对齐到指定起始日。接口层面我建议对外暴露一个更友好的入口允许直接传字符串日期和粒度字符串def split_by_str(start_str: str, end_str: str, freq: str): start date.fromisoformat(start_str) end date.fromisoformat(end_str) return split_date_range(start, end, freq)date.fromisoformat在处理2023-01-15这种字符串时很好用别自己去split(-)再int()转换了。6. 我在实际代码里踩过的几个坑6.1 datetime 与 date 混用导致类型报错这个坑很小但出现频率极高。很多接口返回的是datetime比如datetime.now()传入我的函数后会直接报TypeError: cant compare datetime.datetime to datetime.date。我一开始也给忽略了。后来我在函数入口统一加了一行防御if isinstance(start, datetime): start start.date() if isinstance(end, datetime): end end.date()为什么一定要转成date因为周起点计算、monthrange等函数都期望纯日期带时区的datetime在边界计算上还会有额外的隐式转换一不小心就把“今天零点”和“今天 23:59:59”搞混了。统一成date之后逻辑简单很多也不容易出歧义。6.2 时区问题用哪个时区的日期去切分如果项目涉及时区和定时任务要注意“运行时的自然日”和“业务上的自然日”可能不是同一天。比如服务器时区是 UTC业务时区是东八区凌晨两点运行任务时UTC 还是前一天直接拿date.today()算周起点会差一天。我处理这类问题的做法是所有跟业务日期相关的切分都先显式地把时间转换到业务时区再取.date()。绝不在函数内部用date.today()去兜底。这种时区问题用代码很难一眼看出来但等报表数据对不上的时候再去排查成本就高了。我的经验是日期切分函数只接收明确的date对象谁调用谁负责把“哪个时区的自然日”这件事搞清楚。6.3 闭区间的边界重复问题最后一个想提的坑还是闭区间边界。如果你的下游 SQL 用的是BETWEEN bucket_start AND bucket_end那相邻两个桶的数据很可能会在边界上重复。比如第一个桶到2023-06-11第二个桶从2023-06-12开始如果表里有created_at 2023-06-11 23:59:59的数据它只会落在第一个桶这没问题但如果有2023-06-11 00:00:00到2023-06-11 23:59:59.999这种时间戳就会出现跨边界存储不统一的问题。所以我现在的默认习惯是所有分片查询都用半开区间。如果是纯日期字段那就用bucket_end timedelta(days1)作为右边界如果是带时间的时间戳字段更确切的做法是bucket_end timedelta(days1)然后查询条件写成 start AND end_exclusive。这块代码不多但能把线上脏数据的概率降到很低。我在项目里现在基本把split_date_range当工具箱里的固定函数使用任何涉及时间周期切分的需求都优先复用这一套逻辑。如果你也需要处理类似问题建议直接把上面这段代码放进公共工具模块配上那几条边界测试用例以后改参数、扩展新粒度都能稳得住。