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 ( 5f9481...9c7fcc )
by dup
01:00
created

Conf   A

Complexity

Total Complexity 23

Size/Duplication

Total Lines 218
Duplicated Lines 0 %

Test Coverage

Coverage 95.45%

Importance

Changes 12
Bugs 0 Features 0
Metric Value
c 12
b 0
f 0
dl 0
loc 218
ccs 84
cts 88
cp 0.9545
rs 10
wmc 23

12 Methods

Rating   Name   Duplication   Size   Complexity  
A load_yaml_from_config_file() 0 10 1
A __init__() 0 15 1
A extract_site_definition() 0 9 2
A extract_project_url() 0 13 2
A _get_command_line_arguments() 0 20 2
A extract_site_list() 0 12 3
A is_site_of_type() 0 10 2
A extract_project_list_from_site_def() 0 13 2
A print_cache_or_check_versions() 0 18 2
A make_site_cache_list_name() 0 12 2
A get_infos_for_site() 0 11 1
A check_versions() 0 21 3
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 - 2017 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 and check version.
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 make_site_cache_list_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 make_site_cache_list_name() function
214
215
216 1
    def print_cache_or_check_versions(self):
217
        """
218
        Decide to pretty print projects and their associated version that
219
        are already in the cache or to check versions of that projects upon
220
        selections made at the command line
221
        """
222
223 1
        print_debug(self.options.debug, u'Loading yaml config file')
224 1
        self.load_yaml_from_config_file(self.config_filename)
225
226 1
        if self.options.list_cache is True:
227
            # Pretty prints all caches.
228 1
            cache_list = self.make_site_cache_list_name()
229 1
            print_versions_from_cache(self.local_dir, cache_list, self.options.debug)
230
231
        else:
232
            # Checks version from online feeds
233 1
            self.check_versions()
234
235
    # End of print_list_or_check_versions() function.
236
237
238 1
    def check_versions(self):
239
        """
240
        Checks versions by parsing online feeds
241
        """
242
243
        # Checks projects from by project sites such as github and sourceforge
244 1
        byproject_site_list = self.extract_site_list('byproject')
245
246 1
        for site_name in byproject_site_list:
247
248 1
            print_debug(self.options.debug, u'Checking {} projects'.format(site_name))
249 1
            (project_list, project_url, cache_filename) = self.get_infos_for_site(site_name)
250 1
            check_versions_feeds_by_projects(project_list, self.local_dir, self.options.debug, project_url, cache_filename)
251
252
        # Checks projects from 'list' tupe sites such as freshcode.club
253 1
        list_site_list = self.extract_site_list('list')
254 1
        for site_name in list_site_list:
255 1
            print_debug(self.options.debug, u'Checking {} updates'.format(site_name))
256 1
            (project_list, project_url, cache_filename) = self.get_infos_for_site(site_name)
257 1
            feed_filename = u'{}.feed'.format(site_name)
258 1
            check_versions_for_list_sites(project_list, project_url, cache_filename, feed_filename, self.local_dir, self.options.debug)
259
260
    # End of check_versions() function
261
    
262
    
263 1
    def get_infos_for_site(self, site_name):
264
        """
265
        Returns informations about a site as a tuple
266
        (list of projects, url to check, filename of the cache)
267
        """
268
269 1
        project_list = self.extract_project_list_from_site_def(site_name)
270 1
        project_url = self.extract_project_url(site_name)
271 1
        cache_filename = u'{}.cache'.format(site_name)
272
273 1
        return (project_list, project_url, cache_filename)
274
275
    # End of get_infos_for_site() function
276
277
278
# End of Conf class
279
280
281 1
class FileCache:
282
    """
283
    This class should help in managing cache files
284
    """
285
286 1
    cache_filename = ''
287 1
    cache_dict = {}  # Dictionnary of projects and their associated version
288
289 1
    def __init__(self, local_dir, filename):
290
        """
291
        Inits the class. 'local_dir' must be a directory where we want to
292
        store the cache file named 'filename'
293
        """
294
295 1
        self.cache_filename = os.path.join(local_dir, filename)
296 1
        self.cache_dict = {}
297 1
        self._read_cache_file()
298
299
    # End of __init__() function
300
301
302 1
    def _return_project_and_version_from_line(self, line):
303
        """
304
        Splits the line into a project and a version if possible (the line
305
        must contain a whitespace.
306
        """
307
308 1
        line = line.strip()
309
310 1
        if line.count(' ') > 0:
311 1
            (project, version) = line.split(' ', 1)
312
313
        elif line != '':
314
            project = line
315
            version = ''
316
317 1
        return (project, version)
318
319
    # End of _return_project_and_version_from_line() function
320
321
322 1
    def _read_cache_file(self):
323
        """
324
        Reads the cache file and puts it into a dictionnary of project with
325
        their associated version
326
        """
327
328 1
        if os.path.isfile(self.cache_filename):
329 1
            cache_file = codecs.open(self.cache_filename, 'r', encoding='utf-8')
330
331 1
            for line in cache_file:
332 1
                (project, version) = self._return_project_and_version_from_line(line)
333 1
                self.cache_dict[project] = version
334
335 1
            cache_file.close()
336
337
    # End of _read_cache_file() function
338
339
340 1
    def write_cache_file(self):
341
        """
342
        Owerwrites dictionnary cache to the cache file
343
        """
344
345 1
        cache_file = open_and_truncate_file(self.cache_filename)
346
347 1
        for (project, version) in self.cache_dict.iteritems():
348 1
            cache_file.write('%s %s\n' % (project, version))
349
350 1
        cache_file.close()
351
352
    # End of write_cache_file() function
353
354
355 1
    def update_cache_dict(self, project, version, debug):
356
        """
357
        Updates cache dictionnary if needed
358
        """
359
360 1
        try:
361 1
            version_cache = self.cache_dict[project]
362 1
            print_debug(debug, u'\t\tIn cache: {}'.format(version_cache))
363
364 1
            if version != version_cache:
365 1
                print('%s %s' % (project, version))
366 1
                self.cache_dict[project] = version
367
368 1
        except KeyError:
369 1
            print('%s %s' % (project, version))
370 1
            self.cache_dict[project] = version
371
372
    # End of update_cache_dict() function
373
374
375 1
    def print_cache_dict(self, sitename):
376
        """
377
        Pretty prints the cache dictionary as it is recorded in the files.
378
        """
379
380 1
        print('%s:' % sitename)
381
382
        # Gets project and version tuple sorted by project lowered while sorting
383 1
        for project, version in sorted(self.cache_dict.iteritems(), key=lambda proj: proj[0].lower()):
384 1
            print('\t%s %s' % (project, version))
385
386 1
        print('')
387
388
    # End of print_cache_dict() function
389
# End of FileCache class
390
391
392 1
class FeedCache:
393
394 1
    cache_filename = ''
395 1
    year = 2016
396 1
    month = 05
397 1
    day = 1
398 1
    hour = 0
399 1
    minute = 0
400 1
    date_minutes = 0
401
402
403 1
    def __init__(self, local_dir, filename):
404
        """
405
        Inits the class. 'local_dir' must be a directory where we want to
406
        store the cache file named 'filename'
407
        """
408
409 1
        self.cache_filename = os.path.join(local_dir, filename)
410 1
        self.read_cache_feed()
411
412
    # End of __init__() function
413
414
415 1
    def read_cache_feed(self):
416
        """
417
        Reads the cache file which should only contain one date on the
418
        first line
419
        """
420
421 1
        if os.path.isfile(self.cache_filename):
422
            cache_file = codecs.open(self.cache_filename, 'r', encoding='utf-8')
423
            (self.year, self.month, self.day, self.hour, self.minute) = cache_file.readline().strip().split(' ', 4)
424
            self.date_minutes = self._calculate_minutes(int(self.year), int(self.month), int(self.day), int(self.hour), int(self.minute))
425
            cache_file.close()
426
427
    # End of read_cache_feed() function
428
429
430 1
    def write_cache_feed(self):
431
        """
432
        Overwrites the cache file with values stored in this class
433
        """
434 1
        cache_file = open_and_truncate_file(self.cache_filename)
435
436 1
        cache_file.write('%s %s %s %s %s' % (self.year, self.month, self.day, self.hour, self.minute))
437
438 1
        cache_file.close()
439
440
    # End of write_cache_feed() function
441
442
443 1
    def update_cache_feed(self, date):
444
        """
445
        Updates the values stored in the class with the date which should
446
        be a time.struct_time
447
        """
448
449 1
        self.year = date.tm_year
450 1
        self.month = date.tm_mon
451 1
        self.day = date.tm_mday
452 1
        self.hour = date.tm_hour
453 1
        self.minute = date.tm_min
454 1
        self.date_minutes = self._calculate_minutes_from_date(date)
455
456
    # End of update_cache_feed() function
457
458
459 1
    def _calculate_minutes(self, year, mon, day, hour, mins):
460
        """
461
        Calculate a number of minutes with all parameters and returns
462
        this.
463
        >>> fc = FeedCache('localdir','filename')
464
        >>> fc._calculate_minutes(2016, 5, 1, 0, 0)
465
        1059827040
466
        """
467
468 1
        minutes = (year * 365 * 24 * 60) + \
469
                  (mon * 30 * 24 * 60) + \
470
                  (day * 24 * 60) + \
471
                  (hour * 60) + \
472
                  (mins)
473
474 1
        return minutes
475
476
    # End of _calculate_minutes() function
477
478
479 1
    def _calculate_minutes_from_date(self, date):
480
        """
481
        Transforms a date in a number of minutes to ease comparisons
482
        and returns this number of minutes
483
        """
484
485 1
        return self._calculate_minutes(date.tm_year, date.tm_mon, date.tm_mday, date.tm_hour, date.tm_min)
486
487
    # End of _calculate_minutes() function
488
489
490 1
    def is_newer(self, date):
491
        """
492
        Tells wether "date" is newer than the one in the cache (returns True
493
        or not (returns False)
494
        """
495
496 1
        minutes = self._calculate_minutes_from_date(date)
497
498 1
        if minutes > self.date_minutes:
499 1
            return True
500
501
        else:
502
            return False
503
504
    # End of is_newer() function
505
# End of FeedCache class
506
507
508
######## Below are some utility functions used by classes above ########
509
510
511 1
def make_directories(path):
512
    """
513
    Makes all directories in path if possible. It is not an error if
514
    path already exists.
515
    """
516
517 1
    try:
518 1
        os.makedirs(path)
519
520 1
    except OSError as exc:
521
522 1
        if exc.errno != errno.EEXIST or os.path.isdir(path) is not True:
523
            raise
524
525
# End of make_directories() function
526
527
528 1
def open_and_truncate_file(filename):
529
    """
530
    Opens filename for writing truncating it to a zero length file
531
    and returns a python file object.
532
    """
533
534 1
    cache_file = codecs.open(filename, 'w', encoding='utf-8')
535 1
    cache_file.truncate(0)
536 1
    cache_file.flush()
537
538 1
    return cache_file
539
540
# End of open_and_truncate_file() function
541
####################### End of utility functions #######################
542
543
544 1
def get_values_from_project(project):
545
    """
546
    Gets the values of 'regex' and 'name' keys if found and
547
    returns a tuple (valued, name, regex)
548
    """
549
550 1
    regex = ''
551 1
    name = project
552 1
    valued = False
553
554 1
    if 'regex' in project and 'name' in project:
555
        regex = project['regex']
556
        name = project['name']
557
        valued = True
558
559 1
    return (valued, name, regex)
560
561
# End of get_values_from_project() function
562
563
564 1
def get_latest_release_by_title(project, debug, feed_url):
565
    """
566
    Gets the latest release of a program on github. program must be a
567
    string of the form user/repository.
568
    """
569
570 1
    version = ''
571
572 1
    (valued, name, regex) = get_values_from_project(project)
573
574 1
    url = feed_url.format(name)
575 1
    feed = feedparser.parse(url)
576
577 1
    if len(feed.entries) > 0:
578 1
        version = feed.entries[0].title
579
580 1
    if valued:
581
        res = re.match(regex, version)
582
        if res:
583
            version = res.group(1)
584
        print_debug(debug, u'\tname: {}\n\tversion: {}\n\tregex: {} : {}'.format(name, version, regex, res))
585
586 1
    print_debug(debug, u'\tProject {}: {}'.format(name, version))
587
588 1
    return (name, version)
589
590
# End of get_latest_github_release() function
591
592
593 1
def check_versions_feeds_by_projects(project_list, local_dir, debug, feed_url, cache_filename):
594
    """
595
    Checks project's versions on feed_url if any are defined in the yaml
596
    file under the specified tag that got the project_list passed as an argument.
597
    """
598
599 1
    site_cache = FileCache(local_dir, cache_filename)
600
601 1
    for project in project_list:
602 1
        (project, version) = get_latest_release_by_title(project, debug, feed_url)
603 1
        if version != '':
604 1
            site_cache.update_cache_dict(project, version, debug)
605
606 1
    site_cache.write_cache_file()
607
608
# End of check_versions_for_github_projects() function
609
610
611 1
def cut_title_in_project_version(title):
612
    """
613
    Cuts the title into a tuple (project, version) where possible
614
    """
615
616 1
    try:
617 1
        (project, version) = title.strip().split(' ', 1)
618
619 1
    except ValueError as val:
620 1
        project = title.strip()
621 1
        version = ''
622
623 1
    return (project, version)
624
625
# End of cut_title_in_project_version() function
626
627
628 1
def make_list_of_newer_feeds(feed, feed_info, debug):
629
    """
630
    Compares feed entries and keep those that are newer than the latest
631
    check we've done and inserting the newer ones in reverse order in
632
    a list to be returned
633
    """
634
635 1
    feed_list = []
636
637
    # inserting into a list in reverse order to keep the most recent
638
    # version in case of multiple release of the same project in the
639
    # feeds
640 1
    for a_feed in feed.entries:
641
642 1
        (project, version) = cut_title_in_project_version(a_feed.title)
643 1
        print_debug(debug, u'\tFeed entry ({0}): project: {1:16} version: {2}'.format(time.strftime('%x %X', a_feed.published_parsed), project, version))
644
645 1
        if feed_info.is_newer(a_feed.published_parsed):
646 1
            feed_list.insert(0, a_feed)
647
648 1
    return feed_list
649
650
# End of make_list_of_newer_feeds() function
651
652
653 1
def lower_list_of_strings(project_list):
654
    """
655
    Lowers every string in the list to ease sorting and comparisons
656
    """
657
658 1
    project_list_low = [project.lower() for project in project_list]
659
660 1
    return project_list_low
661
662
# End of lower_list_of_strings() function
663
664
665 1
def check_and_update_feed(feed_list, project_list, cache, debug):
666
    """
667
    Checks every feed entry in the list against project list cache and
668
    then updates the dictionnary then writes the cache file to the disk.
669
     - feed_list    is a list of feed (from feedparser module)
670
     - project_list is the list of project as read from the yaml
671
                    configuration file
672
     - cache is an initialized instance of FileCache
673
    """
674
675
    # Lowers the list before searching in it
676 1
    project_list_low = lower_list_of_strings(project_list)
677
678
    # Checking every feed entry that are newer than the last check
679
    # and updates the dictionnary accordingly
680 1
    for entry in feed_list:
681 1
        (project, version) = cut_title_in_project_version(entry.title)
682 1
        print_debug(debug, u'\tChecking {0:16}: {1}'.format(project, version))
683 1
        if project.lower() in project_list_low:
684 1
            cache.update_cache_dict(project, version, debug)
685
686 1
    cache.write_cache_file()
687
688
# End of check_and_update_feed()
689
690
691 1
def check_versions_for_list_sites(freshcode_project_list, url, cache_filename, feed_filename, local_dir, debug):
692
    """
693
    Checks projects of list type sites such as freshcode's web site's RSS
694
    """
695
696 1
    freshcode_cache = FileCache(local_dir, cache_filename)
697
698 1
    feed = feedparser.parse(url)
699
700 1
    feed_info = FeedCache(local_dir, feed_filename)
701 1
    feed_info.read_cache_feed()
702
703 1
    length = len(feed.entries)
704
705 1
    if length > 0:
706 1
        print_debug(debug, u'\tFound {} entries'.format(length))
707
708 1
        feed_list = make_list_of_newer_feeds(feed, feed_info, debug)
709 1
        print_debug(debug, u'\tFound {} new entries (relative to {})'.format(len(feed_list), feed_info.date_minutes))
710
711 1
        check_and_update_feed(feed_list, freshcode_project_list, freshcode_cache, debug)
712
713
        # Updating feed_info with the latest parsed feed entry date
714 1
        feed_info.update_cache_feed(feed.entries[0].published_parsed)
715
716
    else:
717
        print_debug(debug, u'No entries found in feed')
718
719 1
    feed_info.write_cache_feed()
720
721
# End of check_versions_for_list_sites() function
722
723
724 1
def print_versions_from_cache(local_dir, cache_filename_list, debug):
725
    """
726
    Prints all projects and their associated data from the cache
727
    """
728 1
    for cache_filename in cache_filename_list:
729 1
        site_cache = FileCache(local_dir, cache_filename)
730 1
        site_cache.print_cache_dict(cache_filename)
731
732
# End of print_versions_from_cache()
733
734
735 1
def main():
736
    """
737
    This is the where the program begins
738
    """
739
740 1
    versions_conf = Conf()  # Configuration options
741
742 1
    if versions_conf.options.debug:
743 1
        doctest.testmod(verbose=True)
744
745 1
    if os.path.isfile(versions_conf.config_filename):
746 1
        versions_conf.print_cache_or_check_versions()
747
748
    else:
749 1
        print('Error: file %s does not exist' % versions_conf.config_filename)
750
751
# End of main() function
752
753
754 1
def print_debug(debug, message):
755
    """
756
    Prints 'message' if debug mode is True
757
    """
758
759 1
    if debug:
760 1
        print(message)
761
762
# End of print_debug() function
763
764
765 1
if __name__ == "__main__":
766
    main()
767