GitHub Access Token became invalid

It seems like the GitHub access token used for retrieving details about this repository from GitHub became invalid. This might prevent certain types of inspections from being run (in particular, everything related to pull requests).
Please ask an admin of your repository to re-new the access token on this website.
Passed
Push — master ( 8eae7b...f9370d )
by dup
01:06
created

get_latest_release_by_title()   C

Complexity

Conditions 7

Size

Total Lines 29

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 8.8142

Importance

Changes 0
Metric Value
cc 7
dl 0
loc 29
ccs 12
cts 18
cp 0.6667
crap 8.8142
rs 5.5
c 0
b 0
f 0
1
#!/usr/bin/env python
2
# -*- coding: utf8 -*-
3
#
4
#  versions.py : checks releases and versions of programs through RSS
5
#                or Atom feeds and tells you
6
#
7
#  (C) Copyright 2016 Olivier Delhomme
8
#  e-mail : [email protected]
9
#
10
#  This program is free software; you can redistribute it and/or modify
11
#  it under the terms of the GNU General Public License as published by
12
#  the Free Software Foundation; either version 2, or (at your option)
13
#  any later version.
14
#
15
#  This program is distributed in the hope that it will be useful,
16
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
17
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18
#  GNU General Public License for more details.
19
#
20
#  You should have received a copy of the GNU General Public License
21
#  along with this program; if not, write to the Free Software Foundation,
22
#  Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
23
#
24 1
import codecs
25 1
import feedparser
26 1
import yaml
27 1
import argparse
28 1
import os
29 1
import errno
30 1
import time
31 1
import doctest
32 1
import re
33
34 1
__author__ = "Olivier Delhomme <[email protected]>"
35 1
__date__ = "30.10.2017"
36 1
__version__ = "1.2.0"
37
38
"""
39
This program checks projects versions throught RSS and Atom feeds and
40
should only print those with new release version.
41
42
It implements checking for projects in github.com and freshcode.club.
43
Projects must be added to a YAML file (named by default
44
~/.config/versions/versions.yaml). One can use --file=FILENAME option
45
to specify an alternative YAML file. version.yaml is included as an
46
example in this project.
47
48
Versions uses and produces text files. Those files are cache files
49
written into ~/.local/versions directory. "*.cache" are cache files
50
containing the project list and their associated version (the latest).
51
"*.feed" are information feed cache files containing on only one line
52
the latest parsed post of the feed.
53
"""
54
55
56 1
class Conf:
57
    """
58
    Class to store configuration of the program
59
    """
60
61 1
    config_dir = ''
62 1
    local_dir = ''
63 1
    config_filename = ''
64 1
    description = {}
65 1
    options = None
66
67 1
    def __init__(self):
68
        """
69
        Inits the class
70
        """
71 1
        self.config_dir = os.path.expanduser("~/.config/versions")
72 1
        self.local_dir = os.path.expanduser("~/.local/versions")
73 1
        self.config_filename = ''  # At this stage we do not know if a filename has been set on the command line
74 1
        self.description = {}
75 1
        self.options = None
76
77
        # Make sure that the directories exists
78 1
        make_directories(self.config_dir)
79 1
        make_directories(self.local_dir)
80
81 1
        self._get_command_line_arguments()
82
83
    # End of init() function
84
85
86 1
    def load_yaml_from_config_file(self, filename):
87
        """
88
        Loads definitions from the YAML config file filename
89
        """
90
91 1
        config_file = codecs.open(filename, 'r', encoding='utf-8')
92
93 1
        self.description = yaml.safe_load(config_file)
94
95 1
        config_file.close()
96
97
    # End of load_yaml_from_config_file() function
98
99
100 1
    def _get_command_line_arguments(self):
101
        """
102
        Defines and gets all the arguments for the command line using
103
        argparse module. This function is called in the __init__ function
104
        of this class.
105
        """
106 1
        str_version = 'versions.py - %s' % __version__
107
108 1
        parser = argparse.ArgumentParser(description='This program checks releases and versions of programs through RSS or Atom feeds', version=str_version)
109
110 1
        parser.add_argument('-f', '--file', action='store', dest='filename', help='Configuration file with projects to check', default='')
111 1
        parser.add_argument('-l', '--list-cache', action='store_true', dest='list_cache', help='Lists all projects and their version in cache', default=False)
112 1
        parser.add_argument('-d', '--debug', action='store_true', dest='debug', help='Starts in debug mode and prints things that may help', default=False)
113
114 1
        self.options = parser.parse_args()
115
116 1
        if self.options.filename != '':
117 1
            self.config_filename = self.options.filename
118
        else:
119
            self.config_filename = os.path.join(self.config_dir, 'versions.yaml')
120
121
    # End of get_command_line_arguments() function
122
123 1
    def extract_site_definition(self, site_name):
124
        """
125
        extracts whole site definition
126
        """
127
128 1
        if site_name in self.description:
129 1
            return self.description[site_name]
130
        else:
131
            return dict()
132
133
    # End of extract_site_definition()
134
135
136 1
    def extract_project_list_from_site_def(self, site_name):
137
        """
138
        Extracts a project list from a site by project definition
139
        """
140
141 1
        site_definition = self.extract_site_definition(site_name)
142
143 1
        if 'projects' in site_definition:
144 1
            project_list = site_definition['projects']
145
        else:
146 1
            project_list = []
147
148 1
        return project_list
149
150
    # End of extract_project_list_from_site_def() function
151
152
153 1
    def extract_project_url(self, site_name):
154
        """
155
        Extracts the url definition where to check project version.
156
        """
157
158 1
        site_definition = self.extract_site_definition(site_name)
159
160 1
        if 'url' in site_definition:
161 1
            project_url = site_definition['url']
162
        else:
163
            project_url = ''
164
165 1
        return project_url
166
167
    # End of extract_project_url() function
168
169
170 1
    def is_site_of_type(self, site_name, type):
171
        """
172
        Returns True if site_name is of type 'type'
173
        """
174
175 1
        site_definition = self.extract_site_definition(site_name)
176 1
        if 'type' in site_definition:
177 1
            return (site_definition['type'] == type)
178
        else:
179
            return False
180
181
    # End of is_site_of_type() function
182
183
184 1
    def extract_site_list(self, type):
185
        """
186
        Extracts all sites from a specific type (byproject or list)
187
        """
188
189 1
        all_site_list = list(self.description.keys())
190 1
        site_list = []
191 1
        for site_name in all_site_list:
192 1
            if self.is_site_of_type(site_name, type):
193 1
                site_list.insert(0, site_name)
194
       
195 1
        return site_list
196
197
    # End of extract_site_list() function
198
199
200 1
    def get_site_cache_liste_name(self):
201
        """
202
        Formats list of cache filenames for all sites
203
        """
204
205 1
        all_site_list = list(self.description.keys())
206 1
        cache_list = []
207 1
        for site_name in all_site_list:
208 1
            site_cache = u'{}.cache'.format(site_name)
209 1
            cache_list.insert(0, site_cache)
210
211 1
        return cache_list
212
213
    # End of get_site_cache_liste_name() function
214
215
# End of Conf class
216
217
218 1
class FileCache:
219
    """
220
    This class should help in managing cache files
221
    """
222
223 1
    cache_filename = ''
224 1
    cache_dict = {}  # Dictionnary of projects and their associated version
225
226 1
    def __init__(self, local_dir, filename):
227
        """
228
        Inits the class. 'local_dir' must be a directory where we want to
229
        store the cache file named 'filename'
230
        """
231
232 1
        self.cache_filename = os.path.join(local_dir, filename)
233 1
        self.cache_dict = {}
234 1
        self._read_cache_file()
235
236
    # End of __init__() function
237
238
239 1
    def _return_project_and_version_from_line(self, line):
240
        """
241
        Splits the line into a project and a version if possible (the line
242
        must contain a whitespace.
243
        """
244
245 1
        line = line.strip()
246
247 1
        if line.count(' ') > 0:
248 1
            (project, version) = line.split(' ', 1)
249
250
        elif line != '':
251
            project = line
252
            version = ''
253
254 1
        return (project, version)
255
256
    # End of _return_project_and_version_from_line() function
257
258
259 1
    def _read_cache_file(self):
260
        """
261
        Reads the cache file and puts it into a dictionnary of project with
262
        their associated version
263
        """
264
265 1
        if os.path.isfile(self.cache_filename):
266 1
            cache_file = codecs.open(self.cache_filename, 'r', encoding='utf-8')
267
268 1
            for line in cache_file:
269 1
                (project, version) = self._return_project_and_version_from_line(line)
270 1
                self.cache_dict[project] = version
271
272 1
            cache_file.close()
273
274
    # End of _read_cache_file() function
275
276
277 1
    def write_cache_file(self):
278
        """
279
        Owerwrites dictionnary cache to the cache file
280
        """
281
282 1
        cache_file = open_and_truncate_file(self.cache_filename)
283
284 1
        for (project, version) in self.cache_dict.iteritems():
285 1
            cache_file.write('%s %s\n' % (project, version))
286
287 1
        cache_file.close()
288
289
    # End of write_cache_file() function
290
291
292 1
    def update_cache_dict(self, project, version, debug):
293
        """
294
        Updates cache dictionnary if needed
295
        """
296
297 1
        try:
298 1
            version_cache = self.cache_dict[project]
299
            print_debug(debug, u'\t\tIn cache: {}'.format(version_cache))
300
301
            if version != version_cache:
302
                print('%s %s' % (project, version))
303
                self.cache_dict[project] = version
304
305 1
        except KeyError:
306 1
            print('%s %s' % (project, version))
307 1
            self.cache_dict[project] = version
308
309
    # End of update_cache_dict() function
310
311
312 1
    def print_cache_dict(self, sitename):
313
        """
314
        Pretty prints the cache dictionary as it is recorded in the files.
315
        """
316
317 1
        print('%s:' % sitename)
318
319
        # Gets project and version tuple sorted by project lowered while sorting
320 1
        for project, version in sorted(self.cache_dict.iteritems(), key=lambda proj: proj[0].lower()):
321 1
            print('\t%s %s' % (project, version))
322
323 1
        print('')
324
325
    # End of print_cache_dict() function
326
# End of FileCache class
327
328
329 1
class FeedCache:
330
331 1
    cache_filename = ''
332 1
    year = 2016
333 1
    month = 05
334 1
    day = 1
335 1
    hour = 0
336 1
    minute = 0
337 1
    date_minutes = 0
338
339
340 1
    def __init__(self, local_dir, filename):
341
        """
342
        Inits the class. 'local_dir' must be a directory where we want to
343
        store the cache file named 'filename'
344
        """
345
346 1
        self.cache_filename = os.path.join(local_dir, filename)
347 1
        self.read_cache_feed()
348
349
    # End of __init__() function
350
351
352 1
    def read_cache_feed(self):
353
        """
354
        Reads the cache file which should only contain one date on the
355
        first line
356
        """
357
358 1
        if os.path.isfile(self.cache_filename):
359
            cache_file = codecs.open(self.cache_filename, 'r', encoding='utf-8')
360
            (self.year, self.month, self.day, self.hour, self.minute) = cache_file.readline().strip().split(' ', 4)
361
            self.date_minutes = self._calculate_minutes(int(self.year), int(self.month), int(self.day), int(self.hour), int(self.minute))
362
            cache_file.close()
363
364
    # End of read_cache_feed() function
365
366
367 1
    def write_cache_feed(self):
368
        """
369
        Overwrites the cache file with values stored in this class
370
        """
371 1
        cache_file = open_and_truncate_file(self.cache_filename)
372
373 1
        cache_file.write('%s %s %s %s %s' % (self.year, self.month, self.day, self.hour, self.minute))
374
375 1
        cache_file.close()
376
377
    # End of write_cache_feed() function
378
379
380 1
    def update_cache_feed(self, date):
381
        """
382
        Updates the values stored in the class with the date which should
383
        be a time.struct_time
384
        """
385
386 1
        self.year = date.tm_year
387 1
        self.month = date.tm_mon
388 1
        self.day = date.tm_mday
389 1
        self.hour = date.tm_hour
390 1
        self.minute = date.tm_min
391 1
        self.date_minutes = self._calculate_minutes_from_date(date)
392
393
    # End of update_cache_feed() function
394
395
396 1
    def _calculate_minutes(self, year, mon, day, hour, mins):
397
        """
398
        Calculate a number of minutes with all parameters and returns
399
        this.
400
        >>> fc = FeedCache('localdir','filename')
401
        >>> fc._calculate_minutes(2016, 5, 1, 0, 0)
402
        1059827040
403
        """
404
405 1
        minutes = (year * 365 * 24 * 60) + \
406
                  (mon * 30 * 24 * 60) + \
407
                  (day * 24 * 60) + \
408
                  (hour * 60) + \
409
                  (mins)
410
411 1
        return minutes
412
413
    # End of _calculate_minutes() function
414
415
416 1
    def _calculate_minutes_from_date(self, date):
417
        """
418
        Transforms a date in a number of minutes to ease comparisons
419
        and returns this number of minutes
420
        """
421
422 1
        return self._calculate_minutes(date.tm_year, date.tm_mon, date.tm_mday, date.tm_hour, date.tm_min)
423
424
    # End of _calculate_minutes() function
425
426
427 1
    def is_newer(self, date):
428
        """
429
        Tells wether "date" is newer than the one in the cache (returns True
430
        or not (returns False)
431
        """
432
433 1
        minutes = self._calculate_minutes_from_date(date)
434
435 1
        if minutes > self.date_minutes:
436 1
            return True
437
438
        else:
439
            return False
440
441
    # End of is_newer() function
442
# End of FeedCache class
443
444
445
######## Below are some utility functions used by classes above ########
446
447
448 1
def make_directories(path):
449
    """
450
    Makes all directories in path if possible. It is not an error if
451
    path already exists.
452
    """
453
454 1
    try:
455 1
        os.makedirs(path)
456
457 1
    except OSError as exc:
458
459 1
        if exc.errno != errno.EEXIST or os.path.isdir(path) is not True:
460
            raise
461
462
# End of make_directories() function
463
464
465 1
def open_and_truncate_file(filename):
466
    """
467
    Opens filename for writing truncating it to a zero length file
468
    and returns a python file object.
469
    """
470
471 1
    cache_file = codecs.open(filename, 'w', encoding='utf-8')
472 1
    cache_file.truncate(0)
473 1
    cache_file.flush()
474
475 1
    return cache_file
476
477
# End of open_and_truncate_file() function
478
####################### End of utility functions #######################
479
480
481 1
def get_latest_release_by_title(program, debug, feed_url):
482
    """
483
    Gets the latest release of a program on github. program must be a
484
    string of the form user/repository.
485
    """
486
487 1
    version = ''
488 1
    regex = ''
489 1
    project = program
490
491 1
    if 'regex' in program and 'name' in program:
492
        regex = program['regex']
493
        project = program['name']
494
495 1
    url = feed_url.format(project)
496 1
    feed = feedparser.parse(url)
497
498 1
    if len(feed.entries) > 0:
499 1
        version = feed.entries[0].title
500
501 1
    if 'regex' in program and 'name' in program:
502
        res = re.match(regex, version)
503
        if res:
504
            version = res.group(1)
505
        print_debug(debug, u'\tname: {}\n\tversion: {}\n\tregex: {} : {}'.format(project, version, regex, res))
506
507 1
    print_debug(debug, u'\tProject {}: {}'.format(project, version))
508
509 1
    return project, version
510
511
# End of get_latest_github_release() function
512
513
514 1
def check_versions_feeds_by_projects(project_list, local_dir, debug, feed_url, cache_filename):
515
    """
516
    Checks project's versions on feed_url if any are defined in the yaml
517
    file under the specified tag that got the project_list passed as an argument.
518
    """
519
520 1
    site_cache = FileCache(local_dir, cache_filename)
521
522 1
    for project in project_list:
523 1
        (project, version) = get_latest_release_by_title(project, debug, feed_url)
524 1
        if version != '':
525 1
            site_cache.update_cache_dict(project, version, debug)
526
527 1
    site_cache.write_cache_file()
528
529
# End of check_versions_for_github_projects() function
530
531
532 1
def cut_title_in_project_version(title):
533
    """
534
    Cuts the title into a tuple (project, version) where possible
535
    """
536
537 1
    try:
538 1
        (project, version) = title.strip().split(' ', 1)
539
540 1
    except ValueError as val:
541 1
        project = title.strip()
542 1
        version = ''
543
544 1
    return (project, version)
545
546
# End of cut_title_in_project_version() function
547
548
549 1
def make_list_of_newer_feeds(feed, feed_info, debug):
550
    """
551
    Compares feed entries and keep those that are newer than the latest
552
    check we've done and inserting the newer ones in reverse order in
553
    a list to be returned
554
    """
555
556 1
    feed_list = []
557
558
    # inserting into a list in reverse order to keep the most recent
559
    # version in case of multiple release of the same project in the
560
    # feeds
561 1
    for a_feed in feed.entries:
562
563 1
        (project, version) = cut_title_in_project_version(a_feed.title)
564 1
        print_debug(debug, u'\tFeed entry ({0}): project: {1:16} version: {2}'.format(time.strftime('%x %X', a_feed.published_parsed), project, version))
565
566 1
        if feed_info.is_newer(a_feed.published_parsed):
567 1
            feed_list.insert(0, a_feed)
568
569 1
    return feed_list
570
571
# End of make_list_of_newer_feeds() function
572
573
574 1
def lower_list_of_strings(project_list):
575
    """
576
    Lowers every string in the list to ease sorting and comparisons
577
    """
578
579 1
    project_list_low = [project.lower() for project in project_list]
580
581 1
    return project_list_low
582
583
# End of lower_list_of_strings() function
584
585
586 1
def check_and_update_feed(feed_list, project_list, cache, debug):
587
    """
588
    Checks every feed entry in the list against project list cache and
589
    then updates the dictionnary then writes the cache file to the disk.
590
     - feed_list    is a list of feed (from feedparser module)
591
     - project_list is the list of project as read from the yaml
592
                    configuration file
593
     - cache is an initialized instance of FileCache
594
    """
595
596
    # Lowers the list before searching in it
597 1
    project_list_low = lower_list_of_strings(project_list)
598
599
    # Checking every feed entry that are newer than the last check
600
    # and updates the dictionnary accordingly
601 1
    for entry in feed_list:
602
603 1
        (project, version) = cut_title_in_project_version(entry.title)
604 1
        print_debug(debug, u'\tChecking {0:16}: {1}'.format(project, version))
605
606 1
        if project.lower() in project_list_low:
607 1
            cache.update_cache_dict(project, version, debug)
608
609 1
    cache.write_cache_file()
610
611
# End of check_and_update_feed()
612
613
614 1
def check_versions_for_list_sites(freshcode_project_list, url, cache_filename, feed_filename, local_dir, debug):
615
    """
616
    Checks projects of list type sites such as freshcode's web site's RSS
617
    """
618
619 1
    freshcode_cache = FileCache(local_dir, cache_filename)
620
621 1
    feed = feedparser.parse(url)
622
623 1
    feed_info = FeedCache(local_dir, feed_filename)
624 1
    feed_info.read_cache_feed()
625
626 1
    length = len(feed.entries)
627
628 1
    if length > 0:
629 1
        print_debug(debug, u'\tFound {} entries'.format(length))
630
631 1
        feed_list = make_list_of_newer_feeds(feed, feed_info, debug)
632 1
        print_debug(debug, u'\tFound {} new entries (relative to {})'.format(len(feed_list), feed_info.date_minutes))
633
634 1
        check_and_update_feed(feed_list, freshcode_project_list, freshcode_cache, debug)
635
636
        # Updating feed_info with the latest parsed feed entry date
637 1
        feed_info.update_cache_feed(feed.entries[0].published_parsed)
638
639
    else:
640
        print_debug(debug, u'No entries found in feed')
641
642 1
    feed_info.write_cache_feed()
643
644
# End of check_versions_for_list_sites() function
645
646
647 1
def print_versions_from_cache(local_dir, cache_filename_list, debug):
648
    """
649
    Prints all projects and their associated data from the cache
650
    """
651 1
    for cache_filename in cache_filename_list:
652 1
        site_cache = FileCache(local_dir, cache_filename)
653 1
        site_cache.print_cache_dict(cache_filename)
654
655
# End of print_versions_from_cache()
656
657
658 1
def get_infos_for_site(versions_conf, site_name):
659
    """
660
    Returns informations about a site as a tuple
661
    (list of projects, url to check, filename of the cache)
662
    """
663
664 1
    project_list = versions_conf.extract_project_list_from_site_def(site_name)
665 1
    project_url = versions_conf.extract_project_url(site_name)
666 1
    cache_filename = u'{}.cache'.format(site_name)
667
668 1
    return (project_list, project_url, cache_filename)
669
670
# End of get_infos_for_site() function
671
672
673 1
def check_versions(versions_conf, debug):
674
    """
675
    Checks versions by parsing online feeds
676
    """
677
678
    # Checks projects from by project sites such as github and sourceforge
679 1
    byproject_site_list = versions_conf.extract_site_list('byproject')
680
681 1
    for site_name in byproject_site_list:
682
683 1
        print_debug(debug, u'Checking {} projects'.format(site_name))
684 1
        (project_list, project_url, cache_filename) = get_infos_for_site(versions_conf, site_name)
685 1
        check_versions_feeds_by_projects(project_list, versions_conf.local_dir, debug, project_url, cache_filename)
686
687
    # Checks projects from 'list' tupe sites such as freshcode.club
688 1
    list_site_list = versions_conf.extract_site_list('list')
689 1
    for site_name in list_site_list:
690 1
        print_debug(debug, u'Checking {} updates'.format(site_name))
691 1
        (project_list, project_url, cache_filename) = get_infos_for_site(versions_conf, site_name)
692 1
        feed_filename = u'{}.feed'.format(site_name)
693 1
        check_versions_for_list_sites(project_list, project_url, cache_filename, feed_filename, versions_conf.local_dir, debug)
694
695
# End of check_versions() function
696
697
698 1
def print_cache_or_check_versions(versions_conf):
699
    """
700
    Decide to pretty print projects and their associated version that
701
    are already in the cache or to check versions of that projects upon
702
    selections made at the command line
703
    """
704
705 1
    debug = versions_conf.options.debug
706 1
    print_debug(debug, u'Loading yaml config file')
707 1
    versions_conf.load_yaml_from_config_file(versions_conf.config_filename)
708
709 1
    if versions_conf.options.list_cache is True:
710
        # Pretty prints all caches.
711 1
        cache_list = versions_conf.get_site_cache_liste_name()
712 1
        print_versions_from_cache(versions_conf.local_dir, cache_list, debug)
713
714
    else:
715
        # Checks version from online feeds
716 1
        check_versions(versions_conf, debug)
717
718
# End of print_list_or_check_versions() function.
719
720
721 1
def main():
722
    """
723
    This is the where the program begins
724
    """
725
726 1
    versions_conf = Conf()  # Configuration options
727
728 1
    if versions_conf.options.debug:
729 1
        doctest.testmod(verbose=True)
730
731 1
    if os.path.isfile(versions_conf.config_filename):
732 1
        print_cache_or_check_versions(versions_conf)
733
734
    else:
735 1
        print('Error: file %s does not exist' % versions_conf.config_filename)
736
737
# End of main() function
738
739
740 1
def print_debug(debug, message):
741
    """
742
    Prints 'message' if debug mode is True
743
    """
744
745 1
    if debug:
746 1
        print(message)
747
748
# End of print_debug() function
749
750
751 1
if __name__ == "__main__":
752
    main()
753