• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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