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 ( 67cb76...a7c4b9 )
by dup
01:01
created

Conf.extract_project_url()   A

Complexity

Conditions 2

Size

Total Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2.0185

Importance

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