# 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()