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 ( 2daeb6...c7969d )
by dup
01:03
created

FeedCache   A

Complexity

Total Complexity 9

Size/Duplication

Total Lines 111
Duplicated Lines 0 %

Test Coverage

Coverage 52.63%

Importance

Changes 8
Bugs 0 Features 0
Metric Value
c 8
b 0
f 0
dl 0
loc 111
ccs 20
cts 38
cp 0.5263
rs 10
wmc 9

7 Methods

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