1#!/usr/bin/env python
2
3# Copyright 2020 Espressif Systems (Shanghai) PTE LTD
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#     http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16#
17import argparse
18import datetime as dt
19import json
20
21import matplotlib.dates
22import matplotlib.patches as mpatches
23import matplotlib.pyplot as plt
24import numpy as np
25import requests
26from dateutil import parser
27from dateutil.relativedelta import relativedelta
28from matplotlib.dates import MONTHLY, DateFormatter, RRuleLocator, rrulewrapper
29
30
31class Version(object):
32    def __init__(self, version_name, explicit_start_date, explicit_end_date, explicit_end_service_date=None):
33        self.version_name = version_name
34
35        self._start_date = parser.parse(explicit_start_date)
36        self._end_of_life_date = parser.parse(explicit_end_date)
37        self._end_service_date = parser.parse(
38            explicit_end_service_date) if explicit_end_service_date is not None else self.compute_end_service_date()
39
40        self.start_date_matplotlib_format = matplotlib.dates.date2num(self._start_date)
41        self.end_of_life_date_matplotlib_format = matplotlib.dates.date2num(self._end_of_life_date)
42
43        self.end_service_date_matplotlib_format = matplotlib.dates.date2num(self._end_service_date)
44
45    @staticmethod
46    def add_months(source_date, months):
47        return source_date + relativedelta(months=+months)
48
49    def get_start_date(self):
50        return self._start_date
51
52    def get_end_of_life_date(self):
53        return self._end_of_life_date
54
55    def get_end_service_date(self):
56        return self._end_service_date
57
58    def compute_end_service_date(self):
59        return self.add_months(self._start_date, 12)
60
61
62class ChartVersions(object):
63    def __init__(self, url=None, filename=None):
64        self._releases = self._get_releases_from_url(url=url, filename=filename)
65        self.sorted_releases_supported = sorted(self.filter_old_versions(self._releases), key=lambda x: x.version_name,
66                                                reverse=True)
67
68    def get_releases_as_json(self):
69        return {
70            x.version_name: {
71                'start_date': x.get_start_date().strftime('%Y-%m-%d'),
72                'end_service': x.get_end_service_date().strftime('%Y-%m-%d'),
73                'end_date': x.get_end_of_life_date().strftime('%Y-%m-%d')
74            } for x in self.sorted_releases_supported
75        }
76
77    @staticmethod
78    def parse_chart_releases_from_js(js_as_string):
79        return json.loads(js_as_string[js_as_string.find('RELEASES: ') + len('RELEASES: '):js_as_string.rfind('};')])
80
81    def _get_all_version_from_url(self, url=None, filename=None):
82        releases_file = requests.get(url).text if url is not None else ''.join(open(filename).readlines())
83        return self.parse_chart_releases_from_js(releases_file)
84
85    def _get_releases_from_url(self, url=None, filename=None):
86        all_versions = self._get_all_version_from_url(url, filename)
87        return [
88            Version(version_name=x,
89                    explicit_start_date=all_versions[x]['start_date'],
90                    explicit_end_date=all_versions[x]['end_date'] if 'end_date' in all_versions[x].keys() else None,
91                    explicit_end_service_date=all_versions[x]['end_service'] if 'end_service' in all_versions[
92                        x].keys() else None)
93            for x in all_versions.keys()
94        ]
95
96    @staticmethod
97    def filter_old_versions(versions):
98        return list(
99            filter(lambda x: x.get_end_of_life_date() >= dt.datetime.now(x.get_end_of_life_date().tzinfo), versions))
100
101    @staticmethod
102    def months_timedelta(datetime_1, datetime2):
103        datetime_1, datetime2 = (datetime2, datetime_1) if datetime_1 > datetime2 else (datetime_1, datetime2)
104        return (datetime2.year * 12 + datetime2.month) - (datetime_1.year * 12 + datetime_1.month)
105
106    @staticmethod
107    def find_next_multiple_of_power_two(number, initial=3):
108        """
109        Computes the next multiple of the number by some power of two.
110        >>> ChartVersions.find_next_multiple_of_power_two(7, 3)
111        12
112        """
113        msb = number.bit_length()
114        return 3 if number <= 1 else initial << msb - 2 << (1 & number >> msb - 2)
115
116    def find_nearest_multiple_of_power_two(self, number, initial=3, prefer_next=False):
117        next_num = self.find_next_multiple_of_power_two(number=number - 1, initial=initial)
118        previous_num = next_num >> 1
119        return next_num if abs(next_num - number) < (abs(previous_num - number) + int(prefer_next)) else previous_num
120
121    def create_chart(self,
122                     figure_size=(41.8330013267, 16.7332005307),
123                     subplot=111,
124                     step_size=0.5,
125                     bar_height=0.3,
126                     version_alpha=0.8,
127                     lts_service_color='darkred',
128                     lts_maintenance_color='red',
129                     bar_align='center',
130                     date_interval=None,
131                     output_chart_name='docs/chart',
132                     output_chart_extension='.png',
133                     months_surrounding_chart=4,
134                     service_period_label='Service period (Recommended for new designs)',
135                     maintenance_period_text='Maintenance period'):
136        fig = plt.figure(figsize=figure_size)
137        ax = fig.add_subplot(subplot)
138
139        labels_count = len(self.sorted_releases_supported)
140
141        pos = np.arange(step_size, labels_count * step_size + step_size, step_size)
142
143        for release, i in zip(self.sorted_releases_supported, range(labels_count)):
144            start_date = release.start_date_matplotlib_format
145            end_of_service_date = release.end_service_date_matplotlib_format
146
147            end_date = release.end_of_life_date_matplotlib_format
148
149            ax.barh((i * step_size) + step_size, (end_of_service_date or end_date) - start_date, left=start_date,
150                    height=bar_height, align=bar_align,
151                    color=lts_service_color,
152                    alpha=version_alpha,
153                    edgecolor=lts_service_color)
154            if end_of_service_date is not None:
155                ax.barh((i * step_size) + step_size, end_date - end_of_service_date, left=end_of_service_date,
156                        height=bar_height, align=bar_align,
157                        color=lts_maintenance_color, alpha=version_alpha, edgecolor=lts_maintenance_color)
158
159        ax.set_ylim(bottom=0, ymax=labels_count * step_size + step_size)
160
161        max_ax_date = Version.add_months(
162            max(self.sorted_releases_supported,
163                key=lambda version: version.get_end_of_life_date().replace(tzinfo=None)).get_end_of_life_date(),
164            months_surrounding_chart + 1).replace(day=1)
165
166        min_ax_date = Version.add_months(
167            min(self.sorted_releases_supported,
168                key=lambda version: version.get_start_date().replace(tzinfo=None)).get_start_date(),
169            -months_surrounding_chart).replace(day=1)
170
171        x_ax_interval = date_interval or self.find_nearest_multiple_of_power_two(
172            self.months_timedelta(max_ax_date, min_ax_date) // 10)
173
174        ax.set_xlim(xmin=min_ax_date, xmax=max_ax_date)
175
176        ax.grid(color='g', linestyle=':')
177        ax.xaxis_date()
178
179        rule = rrulewrapper(MONTHLY, interval=x_ax_interval)
180        loc = RRuleLocator(rule)
181        formatter = DateFormatter('%b %Y')
182
183        ax.xaxis.set_major_locator(loc)
184        ax.xaxis.set_major_formatter(formatter)
185        x_labels = ax.get_xticklabels()
186        plt.ylabel('ESP-IDF Release', size=12)
187
188        ax.invert_yaxis()
189        fig.autofmt_xdate()
190
191        darkred_patch = mpatches.Patch(color=lts_service_color, label=service_period_label)
192        red_patch = mpatches.Patch(color=lts_maintenance_color, label=maintenance_period_text)
193
194        plt.setp(plt.yticks(pos, map(lambda x: x.version_name, self.sorted_releases_supported))[1], rotation=0,
195                 fontsize=10, family='Tahoma')
196        plt.setp(x_labels, rotation=30, fontsize=11, family='Tahoma')
197        plt.legend(handles=[darkred_patch, red_patch], prop={'size': 10, 'family': 'Tahoma'},
198                   bbox_to_anchor=(1.01, 1.165), loc='upper right')
199        fig.set_size_inches(11, 5, forward=True)
200        plt.savefig(output_chart_name + output_chart_extension, bbox_inches='tight')
201        print('Saved into ' + output_chart_name + output_chart_extension)
202
203
204if __name__ == '__main__':
205    arg_parser = argparse.ArgumentParser(
206        description='Create chart of version support. Set the url or filename with versions.'
207                    'If you set both filename and url the script will prefer filename.')
208    arg_parser.add_argument('--url', metavar='URL', default='https://dl.espressif.com/dl/esp-idf/idf_versions.js')
209    arg_parser.add_argument('--filename',
210                            help='Set the name of the source file, if is set, the script ignores the url.')
211    arg_parser.add_argument('--output-format', help='Set the output format of the image.', default='svg')
212    arg_parser.add_argument('--output-file', help='Set the name of the output file.', default='docs/chart')
213    args = arg_parser.parse_args()
214
215    ChartVersions(url=args.url if args.filename is None else None, filename=args.filename).create_chart(
216        output_chart_extension='.' + args.output_format.lower()[-3:], output_chart_name=args.output_file)
217