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 ( c7969d...7eb681 )
by dup
59s
created

FeedCache   A

Complexity

Total Complexity 9

Size/Duplication

Total Lines 111
Duplicated Lines 0 %

Test Coverage

Coverage 86.84%

Importance

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

7 Methods

Rating   Name   Duplication   Size   Complexity  
A __init__() 0 8 1
A _calculate_minutes() 0 16 1
A _calculate_minutes_from_date() 0 7 1
A update_cache_feed() 0 12 1
A is_newer() 0 13 2
A read_cache_feed() 0 11 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 1
        if site_name in self.description:
130 1
            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 1
        site_definition = self.extract_site_definition(site_name)
143
144 1
        if 'projects' in site_definition:
145 1
            project_list = site_definition['projects']
146
        else:
147 1
            project_list = []
148
149 1
        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 1
        site_definition = self.extract_site_definition(site_name)
160
161 1
        if 'url' in site_definition:
162 1
            project_url = site_definition['url']
163
        else:
164
            project_url = ''
165
166 1
        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 1
        site_definition = self.extract_site_definition(site_name)
177 1
        if 'type' in site_definition:
178 1
            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 1
        all_site_list = list(self.description.keys())
191 1
        site_list = []
192 1
        for site_name in all_site_list:
193 1
            if self.is_site_of_type(site_name, type):
194 1
                site_list.insert(0, site_name)
195
       
196 1
        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 1
        cache_file = open_and_truncate_file(self.cache_filename)
284
285 1
        for (project, version) in self.cache_dict.iteritems():
286 1
            cache_file.write('%s %s\n' % (project, version))
287
288 1
        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 1
        try:
299 1
            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 1
        except KeyError:
307 1
            print('%s %s' % (project, version))
308 1
            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 1
        cache_file = open_and_truncate_file(self.cache_filename)
373
374 1
        cache_file.write('%s %s %s %s %s' % (self.year, self.month, self.day, self.hour, self.minute))
375
376 1
        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 1
        self.year = date.tm_year
388 1
        self.month = date.tm_mon
389 1
        self.day = date.tm_mday
390 1
        self.hour = date.tm_hour
391 1
        self.minute = date.tm_min
392 1
        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 1
        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 1
        minutes = self._calculate_minutes_from_date(date)
435
436 1
        if minutes > self.date_minutes:
437 1
            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 1
    cache_file = codecs.open(filename, 'w', encoding='utf-8')
473 1
    cache_file.truncate(0)
474 1
    cache_file.flush()
475
476 1
    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 1
    version = ''
489 1
    url = feed_url.format(program)
490 1
    feed = feedparser.parse(url)
491
492 1
    if len(feed.entries) > 0:
493 1
        version = feed.entries[0].title
494
495 1
    print_debug(debug, u'\tProject {}: {}'.format(program, version))
496
497 1
    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 1
    site_cache = FileCache(local_dir, cache_filename)
509
510 1
    for project in project_list:
511 1
        version = get_latest_release_by_title(project, debug, feed_url)
512 1
        if version != '':
513 1
            site_cache.update_cache_dict(project, version, debug)
514
515 1
    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 1
    try:
526 1
        (project, version) = title.strip().split(' ', 1)
527
528 1
    except ValueError as val:
529 1
        project = title.strip()
530 1
        version = ''
531
532 1
    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 1
    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 1
    for a_feed in feed.entries:
550
551 1
        (project, version) = cut_title_in_project_version(a_feed.title)
552 1
        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 1
        if feed_info.is_newer(a_feed.published_parsed):
555 1
            feed_list.insert(0, a_feed)
556
557 1
    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 1
    project_list_low = [project.lower() for project in project_list]
568
569 1
    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 1
    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 1
    for entry in feed_list:
590
591 1
        (project, version) = cut_title_in_project_version(entry.title)
592 1
        print_debug(debug, u'\tChecking {0:16}: {1}'.format(project, version))
593
594 1
        if project.lower() in project_list_low:
595 1
            cache.update_cache_dict(project, version, debug)
596
597 1
    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 1
    freshcode_cache = FileCache(local_dir, cache_filename)
608
609 1
    feed = feedparser.parse(url)
610
611 1
    feed_info = FeedCache(local_dir, feed_filename)
612 1
    feed_info.read_cache_feed()
613
614 1
    length = len(feed.entries)
615
616 1
    if length > 0:
617 1
        print_debug(debug, u'\tFound {} entries'.format(length))
618
619 1
        feed_list = make_list_of_newer_feeds(feed, feed_info, debug)
620 1
        print_debug(debug, u'\tFound {} new entries (relative to {})'.format(len(feed_list), feed_info.date_minutes))
621
622 1
        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 1
        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 1
    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 1
    project_list = versions_conf.extract_project_list_from_site_def(site_name)
653 1
    project_url = versions_conf.extract_project_url(site_name)
654 1
    cache_filename = u'{}.cache'.format(site_name)
655
656 1
    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 1
    byproject_site_list = versions_conf.extract_site_list('byproject')
668
669 1
    for site_name in byproject_site_list:
670
671 1
        print_debug(debug, u'Checking {} projects'.format(site_name))
672 1
        (project_list, project_url, cache_filename) = get_infos_for_site(versions_conf, site_name)
673 1
        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 1
    list_site_list = versions_conf.extract_site_list('list')
677 1
    for site_name in list_site_list:
678 1
        print_debug(debug, u'Checking {} updates'.format(site_name))
679 1
        (project_list, project_url, cache_filename) = get_infos_for_site(versions_conf, site_name)
680 1
        feed_filename = u'{}.feed'.format(site_name)
681 1
        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 1
        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 1
        print(message)
735
736
# End of print_debug() function
737
738
739 1
if __name__ == "__main__":
740
    main()
741