| # portage: news management code |
| # Copyright 2006-2013 Gentoo Foundation |
| # Distributed under the terms of the GNU General Public License v2 |
| |
| from __future__ import print_function, unicode_literals |
| |
| __all__ = ["NewsManager", "NewsItem", "DisplayRestriction", |
| "DisplayProfileRestriction", "DisplayKeywordRestriction", |
| "DisplayInstalledRestriction", |
| "count_unread_news", "display_news_notifications"] |
| |
| import io |
| import logging |
| import os as _os |
| import re |
| import portage |
| from portage import OrderedDict |
| from portage import os |
| from portage import _encodings |
| from portage import _unicode_decode |
| from portage import _unicode_encode |
| from portage.const import NEWS_LIB_PATH |
| from portage.util import apply_secpass_permissions, ensure_dirs, \ |
| grabfile, normalize_path, write_atomic, writemsg_level |
| from portage.data import portage_gid |
| from portage.dep import isvalidatom |
| from portage.localization import _ |
| from portage.locks import lockfile, unlockfile |
| from portage.output import colorize |
| from portage.exception import InvalidLocation, OperationNotPermitted, \ |
| PermissionDenied |
| |
| class NewsManager(object): |
| """ |
| This object manages GLEP 42 style news items. It will cache news items |
| that have previously shown up and notify users when there are relevant news |
| items that apply to their packages that the user has not previously read. |
| |
| Creating a news manager requires: |
| root - typically ${ROOT} see man make.conf and man emerge for details |
| news_path - path to news items; usually $REPODIR/metadata/news |
| unread_path - path to the news.repoid.unread file; this helps us track news items |
| |
| """ |
| |
| def __init__(self, portdb, vardb, news_path, unread_path, language_id='en'): |
| self.news_path = news_path |
| self.unread_path = unread_path |
| self.language_id = language_id |
| self.config = vardb.settings |
| self.vdb = vardb |
| self.portdb = portdb |
| |
| # GLEP 42 says: |
| # All news item related files should be root owned and in the |
| # portage group with the group write (and, for directories, |
| # execute) bits set. News files should be world readable. |
| self._uid = int(self.config["PORTAGE_INST_UID"]) |
| self._gid = portage_gid |
| self._file_mode = 0o0064 |
| self._dir_mode = 0o0074 |
| self._mode_mask = 0o0000 |
| |
| portdir = portdb.porttree_root |
| profiles_base = os.path.join(portdir, 'profiles') + os.path.sep |
| profile_path = None |
| if portdb.settings.profile_path: |
| profile_path = normalize_path( |
| os.path.realpath(portdb.settings.profile_path)) |
| if profile_path.startswith(profiles_base): |
| profile_path = profile_path[len(profiles_base):] |
| self._profile_path = profile_path |
| |
| def _unread_filename(self, repoid): |
| return os.path.join(self.unread_path, 'news-%s.unread' % repoid) |
| |
| def _skip_filename(self, repoid): |
| return os.path.join(self.unread_path, 'news-%s.skip' % repoid) |
| |
| def _news_dir(self, repoid): |
| repo_path = self.portdb.getRepositoryPath(repoid) |
| if repo_path is None: |
| raise AssertionError(_("Invalid repoID: %s") % repoid) |
| return os.path.join(repo_path, self.news_path) |
| |
| def updateItems(self, repoid): |
| """ |
| Figure out which news items from NEWS_PATH are both unread and relevant to |
| the user (according to the GLEP 42 standards of relevancy). Then add these |
| items into the news.repoid.unread file. |
| """ |
| |
| # Ensure that the unread path exists and is writable. |
| |
| try: |
| ensure_dirs(self.unread_path, uid=self._uid, gid=self._gid, |
| mode=self._dir_mode, mask=self._mode_mask) |
| except (OperationNotPermitted, PermissionDenied): |
| return |
| |
| if not os.access(self.unread_path, os.W_OK): |
| return |
| |
| news_dir = self._news_dir(repoid) |
| try: |
| news = _os.listdir(_unicode_encode(news_dir, |
| encoding=_encodings['fs'], errors='strict')) |
| except OSError: |
| return |
| |
| skip_filename = self._skip_filename(repoid) |
| unread_filename = self._unread_filename(repoid) |
| unread_lock = lockfile(unread_filename, wantnewlockfile=1) |
| try: |
| try: |
| unread = set(grabfile(unread_filename)) |
| unread_orig = unread.copy() |
| skip = set(grabfile(skip_filename)) |
| skip_orig = skip.copy() |
| except PermissionDenied: |
| return |
| |
| for itemid in news: |
| try: |
| itemid = _unicode_decode(itemid, |
| encoding=_encodings['fs'], errors='strict') |
| except UnicodeDecodeError: |
| itemid = _unicode_decode(itemid, |
| encoding=_encodings['fs'], errors='replace') |
| writemsg_level( |
| _("!!! Invalid encoding in news item name: '%s'\n") % \ |
| itemid, level=logging.ERROR, noiselevel=-1) |
| continue |
| |
| if itemid in skip: |
| continue |
| filename = os.path.join(news_dir, itemid, |
| itemid + "." + self.language_id + ".txt") |
| if not os.path.isfile(filename): |
| continue |
| item = NewsItem(filename, itemid) |
| if not item.isValid(): |
| continue |
| if item.isRelevant(profile=self._profile_path, |
| config=self.config, vardb=self.vdb): |
| unread.add(item.name) |
| skip.add(item.name) |
| |
| if unread != unread_orig: |
| write_atomic(unread_filename, |
| "".join("%s\n" % x for x in sorted(unread))) |
| apply_secpass_permissions(unread_filename, |
| uid=self._uid, gid=self._gid, |
| mode=self._file_mode, mask=self._mode_mask) |
| |
| if skip != skip_orig: |
| write_atomic(skip_filename, |
| "".join("%s\n" % x for x in sorted(skip))) |
| apply_secpass_permissions(skip_filename, |
| uid=self._uid, gid=self._gid, |
| mode=self._file_mode, mask=self._mode_mask) |
| |
| finally: |
| unlockfile(unread_lock) |
| |
| def getUnreadItems(self, repoid, update=False): |
| """ |
| Determine if there are unread relevant items in news.repoid.unread. |
| If there are unread items return their number. |
| If update is specified, updateNewsItems( repoid ) will be called to |
| check for new items. |
| """ |
| |
| if update: |
| self.updateItems(repoid) |
| |
| unread_filename = self._unread_filename(repoid) |
| unread_lock = None |
| try: |
| unread_lock = lockfile(unread_filename, wantnewlockfile=1) |
| except (InvalidLocation, OperationNotPermitted, PermissionDenied): |
| pass |
| try: |
| try: |
| return len(grabfile(unread_filename)) |
| except PermissionDenied: |
| return 0 |
| finally: |
| if unread_lock: |
| unlockfile(unread_lock) |
| |
| _formatRE = re.compile("News-Item-Format:\s*([^\s]*)\s*$") |
| _installedRE = re.compile("Display-If-Installed:(.*)\n") |
| _profileRE = re.compile("Display-If-Profile:(.*)\n") |
| _keywordRE = re.compile("Display-If-Keyword:(.*)\n") |
| |
| class NewsItem(object): |
| """ |
| This class encapsulates a GLEP 42 style news item. |
| It's purpose is to wrap parsing of these news items such that portage can determine |
| whether a particular item is 'relevant' or not. This requires parsing the item |
| and determining 'relevancy restrictions'; these include "Display if Installed" or |
| "display if arch: x86" and so forth. |
| |
| Creation of a news item involves passing in the path to the particular news item. |
| """ |
| |
| def __init__(self, path, name): |
| """ |
| For a given news item we only want if it path is a file. |
| """ |
| self.path = path |
| self.name = name |
| self._parsed = False |
| self._valid = True |
| |
| def isRelevant(self, vardb, config, profile): |
| """ |
| This function takes a dict of keyword arguments; one should pass in any |
| objects need to do to lookups (like what keywords we are on, what profile, |
| and a vardb so we can look at installed packages). |
| Each restriction will pluck out the items that are required for it to match |
| or raise a ValueError exception if the required object is not present. |
| |
| Restrictions of the form Display-X are OR'd with like-restrictions; |
| otherwise restrictions are AND'd. any_match is the ORing and |
| all_match is the ANDing. |
| """ |
| |
| if not self._parsed: |
| self.parse() |
| |
| if not len(self.restrictions): |
| return True |
| |
| kwargs = \ |
| { 'vardb' : vardb, |
| 'config' : config, |
| 'profile' : profile } |
| |
| all_match = True |
| for values in self.restrictions.values(): |
| any_match = False |
| for restriction in values: |
| if restriction.checkRestriction( |
| **portage._native_kwargs(kwargs)): |
| any_match = True |
| if not any_match: |
| all_match = False |
| |
| return all_match |
| |
| def isValid(self): |
| if not self._parsed: |
| self.parse() |
| return self._valid |
| |
| def parse(self): |
| f = io.open(_unicode_encode(self.path, |
| encoding=_encodings['fs'], errors='strict'), |
| mode='r', encoding=_encodings['content'], errors='replace') |
| lines = f.readlines() |
| f.close() |
| self.restrictions = {} |
| invalids = [] |
| for i, line in enumerate(lines): |
| # Optimization to ignore regex matchines on lines that |
| # will never match |
| format_match = _formatRE.match(line) |
| if format_match is not None and format_match.group(1) != '1.0': |
| invalids.append((i + 1, line.rstrip('\n'))) |
| break |
| if not line.startswith('D'): |
| continue |
| restricts = { _installedRE : DisplayInstalledRestriction, |
| _profileRE : DisplayProfileRestriction, |
| _keywordRE : DisplayKeywordRestriction } |
| for regex, restriction in restricts.items(): |
| match = regex.match(line) |
| if match: |
| restrict = restriction(match.groups()[0].strip()) |
| if not restrict.isValid(): |
| invalids.append((i + 1, line.rstrip("\n"))) |
| else: |
| self.restrictions.setdefault( |
| id(restriction), []).append(restrict) |
| continue |
| if invalids: |
| self._valid = False |
| msg = [] |
| msg.append(_("Invalid news item: %s") % (self.path,)) |
| for lineno, line in invalids: |
| msg.append(_(" line %d: %s") % (lineno, line)) |
| writemsg_level("".join("!!! %s\n" % x for x in msg), |
| level=logging.ERROR, noiselevel=-1) |
| |
| self._parsed = True |
| |
| class DisplayRestriction(object): |
| """ |
| A base restriction object representing a restriction of display. |
| news items may have 'relevancy restrictions' preventing them from |
| being important. In this case we need a manner of figuring out if |
| a particular item is relevant or not. If any of it's restrictions |
| are met, then it is displayed |
| """ |
| |
| def isValid(self): |
| return True |
| |
| def checkRestriction(self, **kwargs): |
| raise NotImplementedError('Derived class should override this method') |
| |
| class DisplayProfileRestriction(DisplayRestriction): |
| """ |
| A profile restriction where a particular item shall only be displayed |
| if the user is running a specific profile. |
| """ |
| |
| def __init__(self, profile): |
| self.profile = profile |
| |
| def checkRestriction(self, **kwargs): |
| if self.profile == kwargs['profile']: |
| return True |
| return False |
| |
| class DisplayKeywordRestriction(DisplayRestriction): |
| """ |
| A keyword restriction where a particular item shall only be displayed |
| if the user is running a specific keyword. |
| """ |
| |
| def __init__(self, keyword): |
| self.keyword = keyword |
| |
| def checkRestriction(self, **kwargs): |
| if kwargs['config']['ARCH'] == self.keyword: |
| return True |
| return False |
| |
| class DisplayInstalledRestriction(DisplayRestriction): |
| """ |
| An Installation restriction where a particular item shall only be displayed |
| if the user has that item installed. |
| """ |
| |
| def __init__(self, atom): |
| self.atom = atom |
| |
| def isValid(self): |
| return isvalidatom(self.atom) |
| |
| def checkRestriction(self, **kwargs): |
| vdb = kwargs['vardb'] |
| if vdb.match(self.atom): |
| return True |
| return False |
| |
| def count_unread_news(portdb, vardb, repos=None, update=True): |
| """ |
| Returns a dictionary mapping repos to integer counts of unread news items. |
| By default, this will scan all repos and check for new items that have |
| appeared since the last scan. |
| |
| @param portdb: a portage tree database |
| @type portdb: pordbapi |
| @param vardb: an installed package database |
| @type vardb: vardbapi |
| @param repos: names of repos to scan (None means to scan all available repos) |
| @type repos: list or None |
| @param update: check for new items (default is True) |
| @type update: boolean |
| @rtype: dict |
| @return: dictionary mapping repos to integer counts of unread news items |
| """ |
| |
| NEWS_PATH = os.path.join("metadata", "news") |
| UNREAD_PATH = os.path.join(vardb.settings['EROOT'], NEWS_LIB_PATH, "news") |
| news_counts = OrderedDict() |
| if repos is None: |
| repos = portdb.getRepositories() |
| |
| permission_msgs = set() |
| for repo in repos: |
| try: |
| manager = NewsManager(portdb, vardb, NEWS_PATH, UNREAD_PATH) |
| count = manager.getUnreadItems(repo, update=True) |
| except PermissionDenied as e: |
| # NOTE: The NewsManager typically handles permission errors by |
| # returning silently, so PermissionDenied won't necessarily be |
| # raised even if we do trigger a permission error above. |
| msg = "Permission denied: '%s'\n" % (e,) |
| if msg in permission_msgs: |
| pass |
| else: |
| permission_msgs.add(msg) |
| writemsg_level(msg, level=logging.ERROR, noiselevel=-1) |
| news_counts[repo] = 0 |
| else: |
| news_counts[repo] = count |
| |
| return news_counts |
| |
| def display_news_notifications(news_counts): |
| """ |
| Display a notification for unread news items, using a dictionary mapping |
| repos to integer counts, like that returned from count_unread_news(). |
| """ |
| newsReaderDisplay = False |
| for repo, count in news_counts.items(): |
| if count > 0: |
| if not newsReaderDisplay: |
| newsReaderDisplay = True |
| print() |
| print(colorize("WARN", " * IMPORTANT:"), end=' ') |
| print("%s news items need reading for repository '%s'." % (count, repo)) |
| |
| if newsReaderDisplay: |
| print(colorize("WARN", " *"), end=' ') |
| print("Use " + colorize("GOOD", "eselect news") + " to read news items.") |
| print() |