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 - 2018 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 3, 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 sys |
|
26 | 1 | import locale |
|
27 | 1 | import argparse |
|
28 | 1 | import os |
|
29 | 1 | import re |
|
30 | 1 | import errno |
|
31 | 1 | import time |
|
32 | 1 | import doctest |
|
33 | 1 | import feedparser |
|
34 | 1 | import yaml |
|
35 | 1 | import operator |
|
36 | |||
37 | 1 | __author__ = "Olivier Delhomme <[email protected]>" |
|
38 | 1 | __date__ = "06.11.2018" |
|
39 | 1 | __version__ = "1.5.2" |
|
40 | |||
41 | """ |
||
42 | This program checks projects versions through RSS and Atom feeds and |
||
43 | should only print those with new release version. |
||
44 | |||
45 | It implements checking for projects in github.com and freshcode.club. |
||
46 | Projects must be added to a YAML file (named by default |
||
47 | ~/.config/versions/versions.yaml). One can use --file=FILENAME option |
||
48 | to specify an alternative YAML file. version.yaml is included as an |
||
49 | example in this project. |
||
50 | |||
51 | Versions uses and produces text files. Those files are cache files |
||
52 | written into ~/.local/versions directory. "*.cache" are cache files |
||
53 | containing the project list and their associated version (the latest). |
||
54 | "*.feed" are information feed cache files containing on only one line |
||
55 | the latest parsed post of the feed. |
||
56 | """ |
||
57 | |||
58 | |||
59 | 1 | class Conf: |
|
60 | """ |
||
61 | Class to store configuration of the program and check version. |
||
62 | """ |
||
63 | |||
64 | 1 | config_dir = '' |
|
65 | 1 | local_dir = '' |
|
66 | 1 | config_filename = '' |
|
67 | 1 | description = {} |
|
68 | 1 | options = None |
|
69 | |||
70 | 1 | def __init__(self): |
|
71 | """ |
||
72 | Inits the class |
||
73 | """ |
||
74 | 1 | self.config_dir = os.path.expanduser("~/.config/versions") |
|
75 | 1 | self.local_dir = os.path.expanduser("~/.local/versions") |
|
76 | 1 | self.config_filename = '' # At this stage we do not know if a filename has been set on the command line |
|
77 | 1 | self.description = {} |
|
78 | 1 | self.options = None |
|
79 | |||
80 | # Make sure that the directories exists |
||
81 | 1 | make_directories(self.config_dir) |
|
82 | 1 | make_directories(self.local_dir) |
|
83 | |||
84 | 1 | self._get_command_line_arguments() |
|
85 | |||
86 | # End of init() function |
||
87 | |||
88 | |||
89 | 1 | def load_yaml_from_config_file(self, filename): |
|
90 | """ |
||
91 | Loads definitions from the YAML config file filename |
||
92 | >>> conf = Conf() |
||
93 | >>> conf.load_yaml_from_config_file('./bad_formatted.yaml') |
||
94 | Error in configuration file ./bad_formatted.yaml at position: 9:1 |
||
95 | """ |
||
96 | |||
97 | 1 | config_file = codecs.open(filename, 'r', encoding='utf-8') |
|
98 | |||
99 | 1 | try: |
|
100 | 1 | self.description = yaml.safe_load(config_file) |
|
101 | 1 | except yaml.YAMLError as err: |
|
102 | 1 | if hasattr(err, 'problem_mark'): |
|
103 | 1 | mark = err.problem_mark |
|
104 | 1 | print(u'Error in configuration file {} at position: {}:{}'.format(filename, mark.line+1, mark.column+1)) |
|
105 | else: |
||
106 | print(u'Error in configuration file {}'.format(filename)) |
||
107 | |||
108 | 1 | config_file.close() |
|
109 | |||
110 | # End of load_yaml_from_config_file() function |
||
111 | |||
112 | |||
113 | 1 | def _get_command_line_arguments(self): |
|
114 | """ |
||
115 | Defines and gets all the arguments for the command line using |
||
116 | argparse module. This function is called in the __init__ function |
||
117 | of this class. |
||
118 | """ |
||
119 | 1 | str_version = 'versions.py - %s' % __version__ |
|
120 | |||
121 | 1 | parser = argparse.ArgumentParser(description='This program checks releases and versions of programs through RSS or Atom feeds') |
|
122 | |||
123 | 1 | parser.add_argument('-v', '--version', action='version', version=str_version) |
|
124 | 1 | parser.add_argument('-f', '--file', action='store', dest='filename', help='Configuration file with projects to check', default='') |
|
125 | 1 | parser.add_argument('-l', '--list-cache', action='store_true', dest='list_cache', help='Lists all projects and their version in cache', default=False) |
|
126 | 1 | parser.add_argument('-d', '--debug', action='store_true', dest='debug', help='Starts in debug mode and prints things that may help', default=False) |
|
127 | |||
128 | 1 | self.options = parser.parse_args() |
|
129 | |||
130 | 1 | if self.options.filename != '': |
|
131 | 1 | self.config_filename = self.options.filename |
|
132 | else: |
||
133 | 1 | self.config_filename = os.path.join(self.config_dir, 'versions.yaml') |
|
134 | |||
135 | # End of get_command_line_arguments() function |
||
136 | |||
137 | |||
138 | 1 | def extract_site_definition(self, site_name): |
|
139 | """ |
||
140 | extracts whole site definition |
||
141 | """ |
||
142 | |||
143 | 1 | if site_name in self.description: |
|
144 | 1 | return self.description[site_name] |
|
145 | else: |
||
146 | return dict() |
||
147 | |||
148 | # End of extract_site_definition() |
||
149 | |||
150 | |||
151 | 1 | def extract_regex_from_site(self, site_name): |
|
152 | """ |
||
153 | Extracts a regex from a site as defined in the YAML file. |
||
154 | Returns the regex if it exists or None otherwise. |
||
155 | """ |
||
156 | |||
157 | 1 | return self.extract_variable_from_site(site_name, 'regex', None) |
|
158 | |||
159 | # End of extract_regex_from_site() function |
||
160 | |||
161 | |||
162 | 1 | def extract_multiproject_from_site(self, site_name): |
|
163 | """ |
||
164 | Extracts from a site its separator list for its multiple |
||
165 | projects in one title. It returns None if multiproject |
||
166 | is not defined and the list of separators instead |
||
167 | """ |
||
168 | |||
169 | 1 | return self.extract_variable_from_site(site_name, 'multiproject', None) |
|
170 | |||
171 | # End of extract…multiproject_from_site() function |
||
172 | |||
173 | |||
174 | 1 | def extract_variable_from_site(self, site_name, variable, default_return): |
|
175 | """ |
||
176 | Extracts variable from site site_name if it exists and return |
||
177 | default_return otherwise |
||
178 | """ |
||
179 | |||
180 | 1 | site_definition = self.extract_site_definition(site_name) |
|
181 | |||
182 | 1 | if variable in site_definition: |
|
183 | 1 | value = site_definition[variable] |
|
184 | 1 | if value is None: |
|
185 | 1 | print(u'Warning: no variable "{}" for site "{}".'.format(variable, site_name)) |
|
186 | 1 | value = default_return |
|
187 | else: |
||
188 | 1 | value = default_return |
|
189 | |||
190 | 1 | return value |
|
191 | |||
192 | # End of extract_variable_from_site() function |
||
193 | |||
194 | |||
195 | 1 | def extract_project_list_from_site(self, site_name): |
|
196 | """ |
||
197 | Extracts a project list from a site as defined in the YAML file. |
||
198 | """ |
||
199 | |||
200 | 1 | return self.extract_variable_from_site(site_name, 'projects', []) |
|
201 | |||
202 | # End of extract_project_list_from_site() function |
||
203 | |||
204 | |||
205 | 1 | def extract_project_url(self, site_name): |
|
206 | """ |
||
207 | Extracts the url definition where to check project version. |
||
208 | """ |
||
209 | |||
210 | 1 | return self.extract_variable_from_site(site_name, 'url', '') |
|
211 | |||
212 | # End of extract_project_url() function |
||
213 | |||
214 | |||
215 | 1 | def extract_project_entry(self, site_name): |
|
216 | """ |
||
217 | Extracts the entry definition (if any) of a site. |
||
218 | """ |
||
219 | |||
220 | 1 | return self.extract_variable_from_site(site_name, 'entry', '') |
|
221 | |||
222 | # End of extract_project_entry() function. |
||
223 | |||
224 | |||
225 | 1 | def is_site_of_type(self, site_name, site_type): |
|
226 | """ |
||
227 | Returns True if site_name is of type 'site_type' |
||
228 | """ |
||
229 | |||
230 | 1 | site_definition = self.extract_site_definition(site_name) |
|
231 | 1 | if 'type' in site_definition: |
|
232 | 1 | return (site_definition['type'] == site_type) |
|
233 | else: |
||
234 | return False |
||
235 | |||
236 | # End of is_site_of_type() function |
||
237 | |||
238 | |||
239 | 1 | def extract_site_list(self, site_type): |
|
240 | """ |
||
241 | Extracts all sites from a specific type (byproject or list) |
||
242 | """ |
||
243 | |||
244 | 1 | all_site_list = list(self.description.keys()) |
|
245 | 1 | site_list = [] |
|
246 | 1 | for site_name in all_site_list: |
|
247 | 1 | if self.is_site_of_type(site_name, site_type): |
|
248 | 1 | site_list.insert(0, site_name) |
|
249 | |||
250 | 1 | return site_list |
|
251 | |||
252 | # End of extract_site_list() function |
||
253 | |||
254 | |||
255 | 1 | def make_site_cache_list_name(self): |
|
256 | """ |
||
257 | Formats list of cache filenames for all sites. |
||
258 | """ |
||
259 | |||
260 | 1 | all_site_list = list(self.description.keys()) |
|
261 | 1 | cache_list = [] |
|
262 | 1 | for site_name in all_site_list: |
|
263 | 1 | site_cache = u'{}.cache'.format(site_name) |
|
264 | 1 | cache_list.insert(0, site_cache) |
|
265 | |||
266 | 1 | return cache_list |
|
267 | |||
268 | # End of make_site_cache_list_name() function |
||
269 | |||
270 | |||
271 | 1 | def print_cache_or_check_versions(self): |
|
272 | """ |
||
273 | Decide to pretty print projects and their associated version that |
||
274 | are already in the cache or to check versions of that projects upon |
||
275 | selections made at the command line |
||
276 | """ |
||
277 | |||
278 | 1 | print_debug(self.options.debug, u'Loading yaml config file') |
|
279 | 1 | self.load_yaml_from_config_file(self.config_filename) |
|
280 | |||
281 | 1 | if self.options.list_cache is True: |
|
282 | # Pretty prints all caches. |
||
283 | 1 | cache_list = self.make_site_cache_list_name() |
|
284 | 1 | print_versions_from_cache(self.local_dir, cache_list) |
|
285 | |||
286 | else: |
||
287 | # Checks version from online feeds |
||
288 | 1 | self.check_versions() |
|
289 | |||
290 | # End of print_list_or_check_versions() function. |
||
291 | |||
292 | |||
293 | 1 | def check_versions(self): |
|
294 | """ |
||
295 | Checks versions by parsing online feeds. |
||
296 | """ |
||
297 | |||
298 | # Checks projects from by project sites such as github and sourceforge |
||
299 | 1 | byproject_site_list = self.extract_site_list('byproject') |
|
300 | |||
301 | 1 | for site_name in byproject_site_list: |
|
302 | 1 | print_debug(self.options.debug, u'Checking {} projects'.format(site_name)) |
|
303 | 1 | (project_list, project_url, cache_filename, project_entry) = self.get_infos_for_site(site_name) |
|
304 | 1 | feed_filename = u'{}.feed'.format(site_name) |
|
305 | 1 | check_versions_feeds_by_projects(project_list, self.local_dir, self.options.debug, project_url, cache_filename, feed_filename, project_entry) |
|
306 | |||
307 | # Checks projects from 'list' tupe sites such as freshcode.club |
||
308 | 1 | list_site_list = self.extract_site_list('list') |
|
309 | 1 | for site_name in list_site_list: |
|
310 | 1 | print_debug(self.options.debug, u'Checking {} updates'.format(site_name)) |
|
311 | 1 | (project_list, project_url, cache_filename, project_entry) = self.get_infos_for_site(site_name) |
|
312 | 1 | regex = self.extract_regex_from_site(site_name) |
|
313 | 1 | multiproject = self.extract_multiproject_from_site(site_name) |
|
314 | 1 | feed_filename = u'{}.feed'.format(site_name) |
|
315 | 1 | check_versions_for_list_sites(project_list, project_url, cache_filename, feed_filename, self.local_dir, self.options.debug, regex, multiproject) |
|
316 | |||
317 | # End of check_versions() function |
||
318 | |||
319 | |||
320 | 1 | def get_infos_for_site(self, site_name): |
|
321 | """ |
||
322 | Returns informations about a site as a tuple |
||
323 | (list of projects, url to check, filename of the cache) |
||
324 | """ |
||
325 | |||
326 | 1 | project_list = self.extract_project_list_from_site(site_name) |
|
327 | 1 | project_url = self.extract_project_url(site_name) |
|
328 | 1 | project_entry = self.extract_project_entry(site_name) |
|
329 | 1 | cache_filename = u'{}.cache'.format(site_name) |
|
330 | |||
331 | 1 | return (project_list, project_url, cache_filename, project_entry) |
|
332 | |||
333 | # End of get_infos_for_site() function |
||
334 | |||
335 | |||
336 | # End of Conf class |
||
337 | |||
338 | |||
339 | 1 | class FileCache: |
|
340 | """ |
||
341 | This class should help in managing cache files |
||
342 | """ |
||
343 | |||
344 | 1 | cache_filename = '' |
|
345 | 1 | cache_dict = {} # Dictionnary of projects and their associated version |
|
346 | |||
347 | 1 | def __init__(self, local_dir, filename): |
|
348 | """ |
||
349 | Inits the class. 'local_dir' must be a directory where we want to |
||
350 | store the cache file named 'filename' |
||
351 | """ |
||
352 | |||
353 | 1 | self.cache_filename = os.path.join(local_dir, filename) |
|
354 | 1 | self.cache_dict = {} |
|
355 | 1 | self._read_cache_file() |
|
356 | |||
357 | # End of __init__() function |
||
358 | |||
359 | |||
360 | 1 | def _return_project_and_version_from_line(self, line): |
|
361 | """ |
||
362 | Splits the line into a project and a version if possible (the line |
||
363 | must contain a whitespace. |
||
364 | """ |
||
365 | |||
366 | 1 | line = line.strip() |
|
367 | |||
368 | 1 | if line.count(' ') > 0: |
|
369 | 1 | (project, version) = line.split(' ', 1) |
|
370 | |||
371 | elif line != '': |
||
372 | project = line |
||
373 | version = '' |
||
374 | |||
375 | 1 | return (project, version) |
|
376 | |||
377 | # End of _return_project_and_version_from_line() function |
||
378 | |||
379 | |||
380 | 1 | def _read_cache_file(self): |
|
381 | """ |
||
382 | Reads the cache file and puts it into a dictionnary of project with |
||
383 | their associated version |
||
384 | """ |
||
385 | |||
386 | 1 | if os.path.isfile(self.cache_filename): |
|
387 | 1 | cache_file = codecs.open(self.cache_filename, 'r', encoding='utf-8') |
|
388 | |||
389 | 1 | for line in cache_file: |
|
390 | 1 | (project, version) = self._return_project_and_version_from_line(line) |
|
391 | 1 | self.cache_dict[project] = version |
|
392 | |||
393 | 1 | cache_file.close() |
|
394 | |||
395 | # End of _read_cache_file() function |
||
396 | |||
397 | |||
398 | 1 | def write_cache_file(self): |
|
399 | """ |
||
400 | Owerwrites dictionnary cache to the cache file |
||
401 | """ |
||
402 | |||
403 | 1 | cache_file = open_and_truncate_file(self.cache_filename) |
|
404 | |||
405 | 1 | for (project, version) in self.cache_dict.items(): |
|
406 | 1 | cache_file.write('%s %s\n' % (project, version)) |
|
407 | |||
408 | 1 | cache_file.close() |
|
409 | |||
410 | # End of write_cache_file() function |
||
411 | |||
412 | 1 | def print_if_newest_version(self, project, version, debug): |
|
413 | """ |
||
414 | Prints the project and it's version if it is newer than the |
||
415 | one in cache. |
||
416 | """ |
||
417 | 1 | try: |
|
418 | 1 | version_cache = self.cache_dict[project] |
|
419 | 1 | print_debug(debug, u'\t\tIn cache: {}'.format(version_cache)) |
|
420 | |||
421 | 1 | if version != version_cache: |
|
422 | 1 | print_project_version(project, version) |
|
423 | |||
424 | 1 | except KeyError: |
|
425 | 1 | print_project_version(project, version) |
|
426 | |||
427 | # End of print_if_newest_version() function. |
||
428 | |||
429 | |||
430 | 1 | def update_cache_dict(self, project, version, debug): |
|
431 | """ |
||
432 | Updates cache dictionnary if needed. We always keep the latest version. |
||
433 | """ |
||
434 | |||
435 | 1 | try: |
|
436 | 1 | version_cache = self.cache_dict[project] |
|
437 | 1 | print_debug(debug, u'\t\tUpdating cache with in cache: {} / new ? version {}'.format(version_cache, version)) |
|
438 | |||
439 | 1 | if version != version_cache: |
|
440 | 1 | self.cache_dict[project] = version |
|
441 | |||
442 | 1 | except KeyError: |
|
443 | 1 | self.cache_dict[project] = version |
|
444 | |||
445 | # End of update_cache_dict() function |
||
446 | |||
447 | |||
448 | 1 | def print_cache_dict(self, sitename): |
|
449 | """ |
||
450 | Pretty prints the cache dictionary as it is recorded in the files. |
||
451 | """ |
||
452 | |||
453 | 1 | print(u'{}:'.format(sitename)) |
|
454 | |||
455 | # Gets project and version tuple sorted by project lowered while sorting |
||
456 | 1 | for project, version in sorted(self.cache_dict.items(), key=lambda proj: proj[0].lower()): |
|
457 | 1 | print(u'\t{} {}'.format(project, version)) |
|
458 | |||
459 | 1 | print('') |
|
460 | |||
461 | # End of print_cache_dict() function |
||
462 | # End of FileCache class |
||
463 | |||
464 | |||
465 | 1 | class FeedCache: |
|
466 | |||
467 | 1 | cache_filename = '' |
|
468 | 1 | year = 2016 |
|
469 | 1 | month = 5 |
|
470 | 1 | day = 1 |
|
471 | 1 | hour = 0 |
|
472 | 1 | minute = 0 |
|
473 | 1 | date_minutes = 0 |
|
474 | |||
475 | |||
476 | 1 | def __init__(self, local_dir, filename): |
|
477 | """ |
||
478 | Inits the class. 'local_dir' must be a directory where we want to |
||
479 | store the cache file named 'filename' |
||
480 | """ |
||
481 | |||
482 | 1 | self.cache_filename = os.path.join(local_dir, filename) |
|
483 | 1 | self.read_cache_feed() |
|
484 | |||
485 | # End of __init__() function |
||
486 | |||
487 | |||
488 | 1 | def read_cache_feed(self): |
|
489 | """ |
||
490 | Reads the cache file which should only contain one date on the |
||
491 | first line |
||
492 | """ |
||
493 | |||
494 | 1 | if os.path.isfile(self.cache_filename): |
|
495 | 1 | cache_file = codecs.open(self.cache_filename, 'r', encoding='utf-8') |
|
496 | 1 | (self.year, self.month, self.day, self.hour, self.minute) = cache_file.readline().strip().split(' ', 4) |
|
497 | 1 | self.date_minutes = self._calculate_minutes(int(self.year), int(self.month), int(self.day), int(self.hour), int(self.minute)) |
|
498 | 1 | cache_file.close() |
|
499 | |||
500 | # End of read_cache_feed() function |
||
501 | |||
502 | |||
503 | 1 | def write_cache_feed(self): |
|
504 | """ |
||
505 | Overwrites the cache file with values stored in this class |
||
506 | """ |
||
507 | 1 | cache_file = open_and_truncate_file(self.cache_filename) |
|
508 | |||
509 | 1 | cache_file.write('%s %s %s %s %s' % (self.year, self.month, self.day, self.hour, self.minute)) |
|
510 | |||
511 | 1 | cache_file.close() |
|
512 | |||
513 | # End of write_cache_feed() function |
||
514 | |||
515 | |||
516 | 1 | def update_cache_feed(self, date): |
|
517 | """ |
||
518 | Updates the values stored in the class with the date which should |
||
519 | be a time.struct_time |
||
520 | """ |
||
521 | |||
522 | 1 | self.year = date.tm_year |
|
523 | 1 | self.month = date.tm_mon |
|
524 | 1 | self.day = date.tm_mday |
|
525 | 1 | self.hour = date.tm_hour |
|
526 | 1 | self.minute = date.tm_min |
|
527 | 1 | self.date_minutes = self._calculate_minutes_from_date(date) |
|
528 | |||
529 | # End of update_cache_feed() function |
||
530 | |||
531 | |||
532 | 1 | def _calculate_minutes(self, year, mon, day, hour, mins): |
|
533 | """ |
||
534 | Calculate a number of minutes with all parameters and returns |
||
535 | this. |
||
536 | >>> fc = FeedCache('localdir','filename') |
||
537 | >>> fc._calculate_minutes(2016, 5, 1, 0, 0) |
||
538 | 1059827040 |
||
539 | """ |
||
540 | |||
541 | 1 | minutes = (year * 365 * 24 * 60) + \ |
|
542 | (mon * 30 * 24 * 60) + \ |
||
543 | (day * 24 * 60) + \ |
||
544 | (hour * 60) + \ |
||
545 | (mins) |
||
546 | |||
547 | 1 | return minutes |
|
548 | |||
549 | # End of _calculate_minutes() function |
||
550 | |||
551 | |||
552 | 1 | def _calculate_minutes_from_date(self, date): |
|
553 | """ |
||
554 | Transforms a date in a number of minutes to ease comparisons |
||
555 | and returns this number of minutes |
||
556 | """ |
||
557 | |||
558 | 1 | return self._calculate_minutes(date.tm_year, date.tm_mon, date.tm_mday, date.tm_hour, date.tm_min) |
|
559 | |||
560 | # End of _calculate_minutes() function |
||
561 | |||
562 | |||
563 | 1 | def is_newer(self, date): |
|
564 | """ |
||
565 | Tells wether "date" is newer than the one in the cache (returns True |
||
566 | or not (returns False) |
||
567 | """ |
||
568 | |||
569 | 1 | minutes = self._calculate_minutes_from_date(date) |
|
570 | |||
571 | 1 | if minutes > self.date_minutes: |
|
572 | 1 | return True |
|
573 | |||
574 | else: |
||
575 | 1 | return False |
|
576 | |||
577 | # End of is_newer() function |
||
578 | # End of FeedCache class |
||
579 | |||
580 | |||
581 | ######## Below are some utility functions used by classes above ######## |
||
582 | |||
583 | |||
584 | 1 | def make_directories(path): |
|
585 | """ |
||
586 | Makes all directories in path if possible. It is not an error if |
||
587 | path already exists. |
||
588 | """ |
||
589 | |||
590 | 1 | try: |
|
591 | 1 | os.makedirs(path) |
|
592 | |||
593 | 1 | except OSError as exc: |
|
594 | |||
595 | 1 | if exc.errno != errno.EEXIST or os.path.isdir(path) is not True: |
|
596 | raise |
||
597 | |||
598 | # End of make_directories() function |
||
599 | |||
600 | |||
601 | 1 | def open_and_truncate_file(filename): |
|
602 | """ |
||
603 | Opens filename for writing truncating it to a zero length file |
||
604 | and returns a python file object. |
||
605 | """ |
||
606 | |||
607 | 1 | cache_file = codecs.open(filename, 'w', encoding='utf-8') |
|
608 | 1 | cache_file.truncate(0) |
|
609 | 1 | cache_file.flush() |
|
610 | |||
611 | 1 | return cache_file |
|
612 | |||
613 | # End of open_and_truncate_file() function |
||
614 | ####################### End of utility functions ####################### |
||
615 | |||
616 | |||
617 | 1 | def get_values_from_project(project): |
|
618 | """ |
||
619 | Gets the values of 'regex' and 'name' keys if found and |
||
620 | returns a tuple (valued, name, regex, entry) |
||
621 | """ |
||
622 | |||
623 | 1 | regex = '' |
|
624 | 1 | entry = '' |
|
625 | 1 | name = project |
|
626 | 1 | valued = False |
|
627 | |||
628 | 1 | if type(project) is dict: |
|
629 | 1 | if 'name' in project: |
|
630 | 1 | name = project['name'] |
|
631 | |||
632 | 1 | if 'regex' in project: |
|
633 | 1 | regex = project['regex'] |
|
634 | 1 | valued = True |
|
635 | |||
636 | 1 | if 'entry' in project: |
|
637 | 1 | entry = project['entry'] |
|
638 | 1 | valued = True |
|
639 | |||
640 | 1 | return (valued, name, regex, entry) |
|
641 | |||
642 | # End of get_values_from_project() function |
||
643 | |||
644 | |||
645 | 1 | def format_project_feed_filename(feed_filename, name): |
|
646 | """ |
||
647 | Returns a valid filename formatted based on feed_filename (the site name) |
||
648 | and name the name of the project |
||
649 | """ |
||
650 | |||
651 | 1 | (root, ext) = os.path.splitext(feed_filename) |
|
652 | 1 | norm_name = name.replace('/', '_') |
|
653 | |||
654 | 1 | filename = "{}_{}{}".format(root, norm_name, ext) |
|
655 | |||
656 | 1 | return filename |
|
657 | |||
658 | # End of format_project_feed_filename() function |
||
659 | |||
660 | |||
661 | 1 | def is_entry_last_checked(entry): |
|
662 | """ |
||
663 | Returns true if entry is equal to last checked and |
||
664 | false otherwise. |
||
665 | >>> is_entry_last_checked('last checked') |
||
666 | True |
||
667 | >>> is_entry_last_checked('') |
||
668 | False |
||
669 | >>> is_entry_last_checked('latest') |
||
670 | False |
||
671 | """ |
||
672 | |||
673 | 1 | return entry == 'last checked' |
|
674 | |||
675 | # End of is_entry_last_checked() function |
||
676 | |||
677 | |||
678 | 1 | def sort_feed_list(feed_list, feed): |
|
679 | """ |
||
680 | Sorts the feed list with the right attribute which depends on the feed. |
||
681 | sort is reversed because feed_list is build by inserting ahead when |
||
682 | parsing the feed from the most recent to the oldest entry. |
||
683 | Returns a sorted list (by date) the first entry is the newest one. |
||
684 | """ |
||
685 | |||
686 | 1 | if feed.entries[0]: |
|
687 | 1 | if 'published_parsed' in feed.entries[0]: |
|
688 | feed_list = sorted(feed_list, key=operator.attrgetter('published_parsed'), reverse=True) |
||
689 | 1 | elif 'updated_parsed' in feed.entries[0]: |
|
690 | 1 | feed_list = sorted(feed_list, key=operator.attrgetter('updated_parsed'), reverse=True) |
|
691 | |||
692 | 1 | return feed_list |
|
693 | |||
694 | # End of sort_feed_list() function |
||
695 | |||
696 | |||
697 | 1 | def get_releases_filtering_feed(debug, local_dir, filename, feed, entry): |
|
698 | """ |
||
699 | Filters the feed and returns a list of releases with one |
||
700 | or more elements |
||
701 | """ |
||
702 | |||
703 | 1 | feed_list = [] |
|
704 | |||
705 | 1 | if is_entry_last_checked(entry): |
|
706 | 1 | feed_info = FeedCache(local_dir, filename) |
|
707 | 1 | feed_info.read_cache_feed() |
|
708 | 1 | feed_list = make_list_of_newer_feeds(feed, feed_info, debug) |
|
709 | 1 | feed_list = sort_feed_list(feed_list, feed) |
|
710 | |||
711 | # Updating feed_info with the latest parsed feed entry date |
||
712 | 1 | if len(feed_list) >= 1: |
|
713 | 1 | published_date = get_entry_published_date(feed_list[0]) |
|
714 | 1 | feed_info.update_cache_feed(published_date) |
|
715 | |||
716 | 1 | feed_info.write_cache_feed() |
|
717 | |||
718 | else: |
||
719 | 1 | feed_list.insert(0, feed.entries[0]) |
|
720 | |||
721 | 1 | return feed_list |
|
722 | |||
723 | |||
724 | 1 | def get_latest_release_by_title(project, debug, feed_url, local_dir, feed_filename, project_entry): |
|
725 | """ |
||
726 | Gets the latest release or the releases between the last checked time of |
||
727 | a program on a site of type 'byproject'. |
||
728 | project must be a string that represents the project (user/repository in |
||
729 | github for instance). |
||
730 | Returns a tuple which contains the name of the project, a list of versions |
||
731 | and a boolean that indicates if we checked by last checked time (True) or |
||
732 | by release (False). |
||
733 | """ |
||
734 | |||
735 | 1 | feed_list = [] |
|
736 | |||
737 | 1 | (valued, name, regex, entry) = get_values_from_project(project) |
|
738 | |||
739 | 1 | if is_entry_last_checked(project_entry): |
|
740 | 1 | last_checked = True |
|
741 | 1 | entry = project_entry |
|
742 | else: |
||
743 | 1 | last_checked = is_entry_last_checked(entry) |
|
744 | 1 | filename = format_project_feed_filename(feed_filename, name) |
|
745 | |||
746 | 1 | url = feed_url.format(name) |
|
747 | 1 | feed = get_feed_entries_from_url(url) |
|
748 | |||
749 | 1 | if feed is not None and len(feed.entries) > 0: |
|
750 | 1 | feed_list = get_releases_filtering_feed(debug, local_dir, filename, feed, entry) |
|
751 | |||
752 | 1 | if valued and regex != '': |
|
753 | # Here we match the whole list against the regex and replace the |
||
754 | # title's entry of the result of that match upon success. |
||
755 | 1 | for entry in feed_list: |
|
756 | 1 | res = re.match(regex, entry.title) |
|
757 | # Here we should make a new list with the matched entries and leave tho other ones |
||
758 | 1 | if res: |
|
759 | 1 | entry.title = res.group(1) |
|
760 | 1 | print_debug(debug, u'\tname: {}\n\tversion: {}\n\tregex: {} : {}'.format(name, entry.title, regex, res)) |
|
761 | |||
762 | 1 | print_debug(debug, u'\tProject {}: {}'.format(name, entry.title)) |
|
763 | |||
764 | 1 | return (name, feed_list, last_checked) |
|
765 | |||
766 | # End of get_latest_release_by_title() function |
||
767 | |||
768 | |||
769 | 1 | def print_project_version(project, version): |
|
770 | """ |
||
771 | Prints to the standard output project name and it's version. |
||
772 | """ |
||
773 | |||
774 | 1 | print(u'{} {}'.format(project, version)) |
|
775 | |||
776 | # End of print_project_version() function |
||
777 | |||
778 | |||
779 | 1 | def check_versions_feeds_by_projects(project_list, local_dir, debug, feed_url, cache_filename, feed_filename, project_entry): |
|
780 | """ |
||
781 | Checks project's versions on feed_url if any are defined in the yaml |
||
782 | file under the specified tag that got the project_list passed as an argument. |
||
783 | """ |
||
784 | |||
785 | 1 | site_cache = FileCache(local_dir, cache_filename) |
|
786 | |||
787 | 1 | for project in project_list: |
|
788 | 1 | (name, feed_list, last_checked) = get_latest_release_by_title(project, debug, feed_url, local_dir, feed_filename, project_entry) |
|
789 | |||
790 | |||
791 | 1 | if len(feed_list) >= 1: |
|
792 | # Updating the cache with the latest version (the first entry) |
||
793 | 1 | version = feed_list[0].title |
|
794 | |||
795 | 1 | if not last_checked: |
|
796 | # printing only for latest release as last checked is |
||
797 | # already filtered and to be printed entirely |
||
798 | 1 | site_cache.print_if_newest_version(name, version, debug) |
|
799 | |||
800 | 1 | site_cache.update_cache_dict(name, version, debug) |
|
801 | |||
802 | 1 | if not last_checked: |
|
803 | # we already printed this. |
||
804 | 1 | del feed_list[0] |
|
805 | |||
806 | 1 | for entry in feed_list: |
|
807 | 1 | print_project_version(name, entry.title) |
|
808 | |||
809 | 1 | site_cache.write_cache_file() |
|
810 | |||
811 | # End of check_versions_feeds_by_projects() function |
||
812 | |||
813 | |||
814 | 1 | def cut_title_with_default_method(title): |
|
815 | """ |
||
816 | Cuts title with a default method and a fallback |
||
817 | >>> cut_title_with_default_method('versions 1.3.2') |
||
818 | ('versions', '1.3.2') |
||
819 | >>> cut_title_with_default_method('no_version_project') |
||
820 | ('no_version_project', '') |
||
821 | """ |
||
822 | |||
823 | 1 | try: |
|
824 | 1 | (project, version) = title.strip().split(' ', 1) |
|
825 | |||
826 | 1 | except ValueError: |
|
827 | 1 | project = title.strip() |
|
828 | 1 | version = '' |
|
829 | |||
830 | 1 | return (project, version) |
|
831 | |||
832 | # End of cut_title_with_default_method() function |
||
833 | |||
834 | |||
835 | 1 | def cut_title_with_regex_method(title, regex): |
|
836 | """ |
||
837 | Cuts title using a regex. If it does not success |
||
838 | fallback to default. |
||
839 | >>> cut_title_with_regex_method('versions 1.3.2', '([\w]+)\s([\d\.]+)') |
||
840 | ('versions', '1.3.2', False) |
||
841 | >>> cut_title_with_regex_method('versions 1.3.2', '([\w]+)notgood\s([\d\.]+)') |
||
842 | ('', '', True) |
||
843 | """ |
||
844 | |||
845 | 1 | default = False |
|
846 | 1 | project = '' |
|
847 | 1 | version = '' |
|
848 | |||
849 | 1 | res = re.match(regex, title) |
|
850 | 1 | if res: |
|
851 | 1 | project = res.group(1) |
|
852 | 1 | version = res.group(2) |
|
853 | else: |
||
854 | 1 | default = True |
|
855 | |||
856 | 1 | return (project, version, default) |
|
857 | |||
858 | # End of cut_title_with_regex_method() function |
||
859 | |||
860 | |||
861 | 1 | def cut_title_in_project_version(title, regex): |
|
862 | """ |
||
863 | Cuts the title into a tuple (project, version) where possible with a regex |
||
864 | or if there is no regex or the regex did not match cuts the title with a |
||
865 | default method |
||
866 | """ |
||
867 | 1 | default = False |
|
868 | |||
869 | 1 | if regex is not None: |
|
870 | 1 | (project, version, default) = cut_title_with_regex_method(title, regex) |
|
871 | else: |
||
872 | 1 | default = True |
|
873 | |||
874 | 1 | if default: |
|
875 | 1 | (project, version) = cut_title_with_default_method(title) |
|
876 | |||
877 | 1 | return (project, version) |
|
0 ignored issues
–
show
introduced
by
![]() |
|||
878 | |||
879 | # End of cut_title_in_project_version() function |
||
880 | |||
881 | |||
882 | |||
883 | 1 | def get_entry_published_date(entry): |
|
884 | """ |
||
885 | Returns the published date of an entry. |
||
886 | Selects the right field to do so |
||
887 | """ |
||
888 | |||
889 | 1 | if 'published_parsed' in entry: |
|
890 | 1 | published_date = entry.published_parsed |
|
891 | 1 | elif 'updated_parsed' in entry: |
|
892 | 1 | published_date = entry.updated_parsed |
|
893 | elif 'pubDate' in entry: # rss-0.91.dtd (netscape) |
||
894 | published_date = entry.pubDate |
||
895 | |||
896 | 1 | return published_date |
|
0 ignored issues
–
show
|
|||
897 | |||
898 | # End of get_entry_published_date() function |
||
899 | |||
900 | |||
901 | 1 | def make_list_of_newer_feeds(feed, feed_info, debug): |
|
902 | """ |
||
903 | Compares feed entries and keep those that are newer than the latest |
||
904 | check we've done and inserting the newer ones in reverse order in |
||
905 | a list to be returned |
||
906 | """ |
||
907 | |||
908 | 1 | feed_list = [] |
|
909 | |||
910 | # inserting into a list in reverse order to keep the most recent |
||
911 | # version in case of multiple release of the same project in the |
||
912 | # feeds |
||
913 | 1 | for a_feed in feed.entries: |
|
914 | |||
915 | 1 | if a_feed: |
|
916 | 1 | published_date = get_entry_published_date(a_feed) |
|
917 | |||
918 | 1 | print_debug(debug, u'\tFeed entry ({0}): Feed title: "{1:16}"'.format(time.strftime('%x %X', published_date), a_feed.title)) |
|
919 | |||
920 | 1 | if feed_info.is_newer(published_date): |
|
921 | 1 | feed_list.insert(0, a_feed) |
|
922 | else: |
||
923 | 1 | print(u'Warning: empty feed in {}'.format(feed)) |
|
924 | |||
925 | 1 | return feed_list |
|
926 | |||
927 | # End of make_list_of_newer_feeds() function |
||
928 | |||
929 | |||
930 | 1 | def lower_list_of_strings(project_list): |
|
931 | """ |
||
932 | Lowers every string in the list to ease sorting and comparisons |
||
933 | """ |
||
934 | |||
935 | 1 | project_list_low = [project.lower() for project in project_list] |
|
936 | |||
937 | 1 | return project_list_low |
|
938 | |||
939 | # End of lower_list_of_strings() function |
||
940 | |||
941 | |||
942 | 1 | def split_multiproject_title_into_list(title, multiproject): |
|
943 | """ |
||
944 | Splits title into a list of projects according to multiproject being |
||
945 | a list of separators |
||
946 | """ |
||
947 | |||
948 | 1 | if multiproject is not None: |
|
949 | 1 | titles = re.split(multiproject, title) |
|
950 | else: |
||
951 | 1 | titles = [title] |
|
952 | |||
953 | 1 | return titles |
|
954 | |||
955 | # End of split_multiproject_title_into_list() function |
||
956 | |||
957 | |||
958 | |||
959 | |||
960 | 1 | def check_and_update_feed(feed_list, project_list, cache, debug, regex, multiproject): |
|
961 | """ |
||
962 | Checks every feed entry in the list against project list cache and |
||
963 | then updates the dictionnary then writes the cache file to the disk. |
||
964 | - feed_list is a list of feed (from feedparser module) |
||
965 | - project_list is the list of project as read from the yaml |
||
966 | configuration file |
||
967 | - cache is an initialized instance of FileCache |
||
968 | """ |
||
969 | |||
970 | # Lowers the list before searching in it |
||
971 | 1 | project_list_low = lower_list_of_strings(project_list) |
|
972 | |||
973 | # Checking every feed entry that are newer than the last check |
||
974 | # and updates the dictionnary accordingly |
||
975 | 1 | for entry in feed_list: |
|
976 | |||
977 | 1 | titles = split_multiproject_title_into_list(entry.title, multiproject) |
|
978 | |||
979 | 1 | for title in titles: |
|
980 | 1 | (project, version) = cut_title_in_project_version(title, regex) |
|
981 | 1 | print_debug(debug, u'\tChecking {0:16}: {1}'.format(project, version)) |
|
982 | 1 | if project.lower() in project_list_low: |
|
983 | 1 | cache.print_if_newest_version(project, version, debug) |
|
984 | 1 | cache.update_cache_dict(project, version, debug) |
|
985 | |||
986 | 1 | cache.write_cache_file() |
|
987 | |||
988 | # End of check_and_update_feed() function |
||
989 | |||
990 | |||
991 | 1 | def manage_http_status(feed, url): |
|
992 | """ |
||
993 | Manages http status code present in feed and prints |
||
994 | an error in case of a 3xx, 4xx or 5xx and stops |
||
995 | doing anything for the feed by returning None. |
||
996 | """ |
||
997 | |||
998 | 1 | err = feed.status / 100 |
|
999 | |||
1000 | 1 | if err > 2: |
|
1001 | 1 | print(u'Error {} while fetching "{}".'.format(feed.status, url)) |
|
1002 | 1 | feed = None |
|
1003 | |||
1004 | 1 | return feed |
|
1005 | |||
1006 | # End of manage_http_status() function |
||
1007 | |||
1008 | |||
1009 | 1 | def manage_non_http_errors(feed, url): |
|
1010 | """ |
||
1011 | Tries to manage non http errors and gives |
||
1012 | a message to the user. |
||
1013 | """ |
||
1014 | |||
1015 | 1 | if feed.bozo: |
|
1016 | 1 | if feed.bozo_exception: |
|
1017 | 1 | exc = feed.bozo_exception |
|
1018 | 1 | if hasattr(exc, 'reason'): |
|
1019 | 1 | message = exc.reason |
|
1020 | else: |
||
1021 | message = 'unaddressed' |
||
1022 | |||
1023 | 1 | print(u'Error {} while fetching "{}".'.format(message, url)) |
|
1024 | |||
1025 | else: |
||
1026 | print(u'Error while fetching url "{}".'.format(url)) |
||
1027 | |||
1028 | # End of manage_non_http_errors() function |
||
1029 | |||
1030 | |||
1031 | 1 | def get_feed_entries_from_url(url): |
|
1032 | """ |
||
1033 | Gets feed entries from an url that should be an |
||
1034 | RSS or Atom feed. |
||
1035 | >>> get_feed_entries_from_url("http://delhomme.org/notfound.html") |
||
1036 | Error 404 while fetching "http://delhomme.org/notfound.html". |
||
1037 | >>> feed = get_feed_entries_from_url("http://blog.delhomme.org/index.php?feed/atom") |
||
1038 | >>> feed.status |
||
1039 | 200 |
||
1040 | """ |
||
1041 | |||
1042 | 1 | feed = feedparser.parse(url) |
|
1043 | |||
1044 | 1 | if 'status' in feed: |
|
1045 | 1 | feed = manage_http_status(feed, url) |
|
1046 | else: |
||
1047 | # An error happened such that the feed does not contain an HTTP response |
||
1048 | 1 | manage_non_http_errors(feed, url) |
|
1049 | 1 | feed = None |
|
1050 | |||
1051 | 1 | return feed |
|
1052 | |||
1053 | # End of get_feed_entries_from_url() function |
||
1054 | |||
1055 | |||
1056 | 1 | def check_versions_for_list_sites(feed_project_list, url, cache_filename, feed_filename, local_dir, debug, regex, multiproject): |
|
1057 | """ |
||
1058 | Checks projects of 'list' type sites such as freshcode's web site's RSS |
||
1059 | """ |
||
1060 | |||
1061 | 1 | freshcode_cache = FileCache(local_dir, cache_filename) |
|
1062 | |||
1063 | 1 | feed_info = FeedCache(local_dir, feed_filename) |
|
1064 | 1 | feed_info.read_cache_feed() |
|
1065 | |||
1066 | 1 | feed = get_feed_entries_from_url(url) |
|
1067 | |||
1068 | 1 | if feed is not None: |
|
1069 | 1 | print_debug(debug, u'\tFound {} entries'.format(len(feed.entries))) |
|
1070 | 1 | feed_list = make_list_of_newer_feeds(feed, feed_info, debug) |
|
1071 | 1 | print_debug(debug, u'\tFound {} new entries (relative to {})'.format(len(feed_list), feed_info.date_minutes)) |
|
1072 | |||
1073 | 1 | check_and_update_feed(feed_list, feed_project_list, freshcode_cache, debug, regex, multiproject) |
|
1074 | |||
1075 | # Updating feed_info with the latest parsed feed entry date |
||
1076 | 1 | feed_info.update_cache_feed(feed.entries[0].published_parsed) |
|
1077 | |||
1078 | 1 | feed_info.write_cache_feed() |
|
1079 | |||
1080 | # End of check_versions_for_list_sites() function |
||
1081 | |||
1082 | |||
1083 | 1 | def print_versions_from_cache(local_dir, cache_filename_list): |
|
1084 | """ |
||
1085 | Prints all projects and their associated data from the cache |
||
1086 | """ |
||
1087 | 1 | for cache_filename in cache_filename_list: |
|
1088 | 1 | site_cache = FileCache(local_dir, cache_filename) |
|
1089 | 1 | site_cache.print_cache_dict(cache_filename) |
|
1090 | |||
1091 | # End of print_versions_from_cache() |
||
1092 | |||
1093 | |||
1094 | 1 | def main(): |
|
1095 | """ |
||
1096 | This is the where the program begins |
||
1097 | """ |
||
1098 | |||
1099 | 1 | if sys.version_info[0] == 2: |
|
1100 | sys.stdout = codecs.getwriter(locale.getpreferredencoding())(sys.stdout) |
||
1101 | |||
1102 | 1 | versions_conf = Conf() # Configuration options |
|
1103 | |||
1104 | 1 | if versions_conf.options.debug: |
|
1105 | 1 | doctest.testmod(verbose=True) |
|
1106 | |||
1107 | 1 | if os.path.isfile(versions_conf.config_filename): |
|
1108 | 1 | versions_conf.print_cache_or_check_versions() |
|
1109 | |||
1110 | else: |
||
1111 | 1 | print(u'Error: file {} does not exist'.format(versions_conf.config_filename)) |
|
1112 | |||
1113 | # End of main() function |
||
1114 | |||
1115 | |||
1116 | 1 | def print_debug(debug, message): |
|
1117 | """ |
||
1118 | Prints 'message' if debug mode is True |
||
1119 | """ |
||
1120 | |||
1121 | 1 | if debug: |
|
1122 | 1 | print(u'{}'.format(message)) |
|
1123 | |||
1124 | # End of print_debug() function |
||
1125 | |||
1126 | |||
1127 | 1 | if __name__ == "__main__": |
|
1128 | main() |
||
1129 |