| # portage: news management code |
| # Copyright 2006-2011 Gentoo Foundation |
| # Distributed under the terms of the GNU General Public License v2 |
| |
| __all__ = ["NewsManager", "NewsItem", "DisplayRestriction", |
| "DisplayProfileRestriction", "DisplayKeywordRestriction", |
| "DisplayInstalledRestriction"] |
| |
| import io |
| import logging |
| import os as _os |
| import re |
| from portage import os |
| from portage import _encodings |
| from portage import _unicode_decode |
| from portage import _unicode_encode |
| 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.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.target_root = vardb.root |
| 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 |
| |
| updates = [] |
| 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(**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 |