1#!/usr/bin/env python3 2# SPDX-License-Identifier: MIT 3 4# Copyright © 2021 Intel Corporation 5 6# Permission is hereby granted, free of charge, to any person obtaining a copy 7# of this software and associated documentation files (the "Software"), to deal 8# in the Software without restriction, including without limitation the rights 9# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10# copies of the Software, and to permit persons to whom the Software is 11# furnished to do so, subject to the following conditions: 12 13# The above copyright notice and this permission notice shall be included in 14# all copies or substantial portions of the Software. 15 16# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22# SOFTWARE. 23 24"""Helper script for manipulating the release calendar.""" 25 26from __future__ import annotations 27import argparse 28import csv 29import contextlib 30import datetime 31import pathlib 32import subprocess 33import typing 34 35if typing.TYPE_CHECKING: 36 import _csv 37 from typing_extensions import Protocol 38 39 class RCArguments(Protocol): 40 """Typing information for release-candidate command arguments.""" 41 42 manager: str 43 44 class FinalArguments(Protocol): 45 """Typing information for release command arguments.""" 46 47 series: str 48 manager: str 49 zero_released: bool 50 51 class ExtendArguments(Protocol): 52 """Typing information for extend command arguments.""" 53 54 series: str 55 count: int 56 57 58 CalendarRowType = typing.Tuple[typing.Optional[str], str, str, str, typing.Optional[str]] 59 60 61_ROOT = pathlib.Path(__file__).parent.parent 62CALENDAR_CSV = _ROOT / 'docs' / 'release-calendar.csv' 63VERSION = _ROOT / 'VERSION' 64LAST_RELEASE = 'This is the last planned release of the {}.x series.' 65OR_FINAL = 'Or {}.0 final.' 66 67 68def read_calendar() -> typing.List[CalendarRowType]: 69 """Read the calendar and return a list of it's rows.""" 70 with CALENDAR_CSV.open('r') as f: 71 return [typing.cast('CalendarRowType', tuple(r)) for r in csv.reader(f)] 72 73 74def commit(message: str) -> None: 75 """Commit the changes the the release-calendar.csv file.""" 76 subprocess.run(['git', 'commit', str(CALENDAR_CSV), '--message', message]) 77 78 79 80def _calculate_release_start(major: str, minor: str) -> datetime.date: 81 """Calculate the start of the release for release candidates. 82 83 This is quarterly, on the second wednesday, in January, April, July, and October. 84 """ 85 quarter = datetime.date.fromisoformat(f'20{major}-0{[1, 4, 7, 10][int(minor)]}-01') 86 87 # Wednesday is 3 88 day = quarter.isoweekday() 89 if day > 3: 90 # this will walk back into the previous month, it's much simpler to 91 # duplicate the 14 than handle the calculations for the month and year 92 # changing. 93 return quarter.replace(day=quarter.day - day + 3 + 14) 94 elif day < 3: 95 quarter = quarter.replace(day=quarter.day + 3 - day) 96 return quarter.replace(day=quarter.day + 14) 97 98 99def release_candidate(args: RCArguments) -> None: 100 """Add release candidate entries.""" 101 with VERSION.open('r') as f: 102 version = f.read().rstrip('-devel') 103 major, minor, _ = version.split('.') 104 date = _calculate_release_start(major, minor) 105 106 data = read_calendar() 107 108 with CALENDAR_CSV.open('w', newline='') as f: 109 writer = csv.writer(f) 110 writer.writerows(data) 111 112 writer.writerow([f'{major}.{minor}', date.isoformat(), f'{major}.{minor}.0-rc1', args.manager]) 113 for row in range(2, 4): 114 date = date + datetime.timedelta(days=7) 115 writer.writerow([None, date.isoformat(), f'{major}.{minor}.0-rc{row}', args.manager]) 116 date = date + datetime.timedelta(days=7) 117 writer.writerow([None, date.isoformat(), f'{major}.{minor}.0-rc4', args.manager, OR_FINAL.format(f'{major}.{minor}')]) 118 119 commit(f'docs: Add calendar entries for {major}.{minor} release candidates.') 120 121 122def _calculate_next_release_date(next_is_zero: bool) -> datetime.date: 123 """Calculate the date of the next release. 124 125 If the next is .0, we have the release in seven days, if the next is .1, 126 then it's in 14 127 """ 128 date = datetime.date.today() 129 day = date.isoweekday() 130 if day < 3: 131 delta = 3 - day 132 elif day > 3: 133 # this will walk back into the previous month, it's much simpler to 134 # duplicate the 14 than handle the calculations for the month and year 135 # changing. 136 delta = (3 - day) 137 else: 138 delta = 0 139 delta += 7 140 if not next_is_zero: 141 delta += 7 142 return date + datetime.timedelta(days=delta) 143 144 145def final_release(args: FinalArguments) -> None: 146 """Add final release entries.""" 147 data = read_calendar() 148 date = _calculate_next_release_date(not args.zero_released) 149 150 with CALENDAR_CSV.open('w', newline='') as f: 151 writer = csv.writer(f) 152 writer.writerows(data) 153 154 base = 1 if args.zero_released else 0 155 156 writer.writerow([args.series, date.isoformat(), f'{args.series}.{base}', args.manager]) 157 for row in range(base + 1, 3): 158 date = date + datetime.timedelta(days=14) 159 writer.writerow([None, date.isoformat(), f'{args.series}.{row}', args.manager]) 160 date = date + datetime.timedelta(days=14) 161 writer.writerow([None, date.isoformat(), f'{args.series}.3', args.manager, LAST_RELEASE.format(args.series)]) 162 163 commit(f'docs: Add calendar entries for {args.series} release.') 164 165 166def extend(args: ExtendArguments) -> None: 167 """Extend a release.""" 168 @contextlib.contextmanager 169 def write_existing(writer: _csv._writer, current: typing.List[CalendarRowType]) -> typing.Iterator[CalendarRowType]: 170 """Write the orinal file, yield to insert new entries. 171 172 This is a bit clever, basically what happens it writes out the 173 original csv file until it reaches the start of the release after the 174 one we're appending, then it yields the last row. When control is 175 returned it writes out the rest of the original calendar data. 176 """ 177 last_row: typing.Optional[CalendarRowType] = None 178 in_wanted = False 179 for row in current: 180 if in_wanted and row[0]: 181 in_wanted = False 182 assert last_row is not None 183 yield last_row 184 if row[0] == args.series: 185 in_wanted = True 186 if in_wanted and len(row) >= 5 and row[4] in {LAST_RELEASE.format(args.series), OR_FINAL.format(args.series)}: 187 # If this was the last planned release and we're adding more, 188 # then we need to remove that message and add it elsewhere 189 r = list(row) 190 r[4] = None 191 # Mypy can't figure this out… 192 row = typing.cast('CalendarRowType', tuple(r)) 193 last_row = row 194 writer.writerow(row) 195 # If this is the only entry we can hit a case where the contextmanager 196 # hasn't yielded 197 if in_wanted: 198 yield row 199 200 current = read_calendar() 201 202 with CALENDAR_CSV.open('w', newline='') as f: 203 writer = csv.writer(f) 204 with write_existing(writer, current) as row: 205 # Get rid of -rcX as well 206 if '-rc' in row[2]: 207 first_point = int(row[2].split('rc')[-1]) + 1 208 template = '{}.0-rc{}' 209 days = 7 210 else: 211 first_point = int(row[2].split('-')[0].split('.')[-1]) + 1 212 template = '{}.{}' 213 days = 14 214 215 date = datetime.date.fromisoformat(row[1]) 216 for i in range(first_point, first_point + args.count): 217 date = date + datetime.timedelta(days=days) 218 r = [None, date.isoformat(), template.format(args.series, i), row[3], None] 219 if i == first_point + args.count - 1: 220 if days == 14: 221 r[4] = LAST_RELEASE.format(args.series) 222 else: 223 r[4] = OR_FINAL.format(args.series) 224 writer.writerow(r) 225 226 commit(f'docs: Extend calendar entries for {args.series} by {args.count} releases.') 227 228 229def main() -> None: 230 parser = argparse.ArgumentParser() 231 sub = parser.add_subparsers() 232 233 rc = sub.add_parser('release-candidate', aliases=['rc'], help='Generate calendar entries for a release candidate.') 234 rc.add_argument('manager', help="the name of the person managing the release.") 235 rc.set_defaults(func=release_candidate) 236 237 fr = sub.add_parser('release', help='Generate calendar entries for a final release.') 238 fr.add_argument('manager', help="the name of the person managing the release.") 239 fr.add_argument('series', help='The series to extend, such as "29.3" or "30.0".') 240 fr.add_argument('--zero-released', action='store_true', help='The .0 release was today, the next release is .1') 241 fr.set_defaults(func=final_release) 242 243 ex = sub.add_parser('extend', help='Generate additional entries for a release.') 244 ex.add_argument('series', help='The series to extend, such as "29.3" or "30.0".') 245 ex.add_argument('count', type=int, help='The number of new entries to add.') 246 ex.set_defaults(func=extend) 247 248 args = parser.parse_args() 249 args.func(args) 250 251 252if __name__ == "__main__": 253 main() 254