| #!/usr/bin/env python | |
| # -*- coding: utf-8 -*- | |
| # ==================================================================== | |
| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| # ==================================================================== | |
| """Usage: svn2feed.py [OPTION...] REPOS-PATH | |
| Generate an RSS 2.0 or Atom 1.0 feed file containing commit | |
| information for the Subversion repository located at REPOS-PATH. Once | |
| the maximum number of items is reached, older elements are removed. | |
| The item title is the revision number, and the item description | |
| contains the author, date, log messages and changed paths. | |
| Options: | |
| -h, --help Show this help message. | |
| -F, --format=FORMAT Required option. FORMAT must be one of: | |
| 'rss' (RSS 2.0) | |
| 'atom' (Atom 1.0) | |
| to select the appropriate feed format. | |
| -f, --feed-file=PATH Store the feed in the file located at PATH, which will | |
| be created if it does not exist, or overwritten if it | |
| does. If not provided, the script will store the feed | |
| in the current working directory, in a file named | |
| REPOS_NAME.rss or REPOS_NAME.atom (where REPOS_NAME is | |
| the basename of the REPOS_PATH command-line argument, | |
| and the file extension depends on the selected | |
| format). | |
| -r, --revision=X[:Y] Subversion revision (or revision range) to generate | |
| info for. If not provided, info for the single | |
| youngest revision in the repository will be generated. | |
| -m, --max-items=N Keep only N items in the feed file. By default, | |
| 20 items are kept. | |
| -u, --item-url=URL Use URL as the basis for generating feed item links. | |
| This value is appended with '?rev=REV_NUMBER' to form | |
| the actual item links. | |
| -U, --feed-url=URL Use URL as the global link associated with the feed. | |
| -P, --svn-path=DIR Look in DIR for the svnlook binary. If not provided, | |
| svnlook must be on the PATH. | |
| """ | |
| # TODO: | |
| # --item-url should support arbitrary formatting of the revision number, | |
| # to be useful with web viewers other than ViewVC. | |
| # Rather more than intended is being cached in the pickle file. Instead of | |
| # only old items being drawn from the pickle, all the global feed metadata | |
| # is actually set only on initial feed creation, and thereafter simply | |
| # re-used from the pickle each time. | |
| # $HeadURL$ | |
| # $LastChangedDate$ | |
| # $LastChangedBy$ | |
| # $LastChangedRevision$ | |
| import sys | |
| # Python 2.4 is required for subprocess | |
| if sys.version_info < (2, 4): | |
| sys.stderr.write("Error: Python 2.4 or higher required.\n") | |
| sys.stderr.flush() | |
| sys.exit(1) | |
| import getopt | |
| import os | |
| import subprocess | |
| try: | |
| # Python <3.0 | |
| import cPickle as pickle | |
| except ImportError: | |
| # Python >=3.0 | |
| import pickle | |
| import datetime | |
| import time | |
| def usage_and_exit(errmsg=None): | |
| """Print a usage message, plus an ERRMSG (if provided), then exit. | |
| If ERRMSG is provided, the usage message is printed to stderr and | |
| the script exits with a non-zero error code. Otherwise, the usage | |
| message goes to stdout, and the script exits with a zero | |
| errorcode.""" | |
| if errmsg is None: | |
| stream = sys.stdout | |
| else: | |
| stream = sys.stderr | |
| stream.write("%s\n" % __doc__) | |
| stream.flush() | |
| if errmsg: | |
| stream.write("\nError: %s\n" % errmsg) | |
| stream.flush() | |
| sys.exit(2) | |
| sys.exit(0) | |
| def check_url(url, opt): | |
| """Verify that URL looks like a valid URL or option OPT.""" | |
| if not (url.startswith('https://') \ | |
| or url.startswith('http://') \ | |
| or url.startswith('file://')): | |
| usage_and_exit("svn2feed.py: Invalid url '%s' is specified for " \ | |
| "'%s' option" % (url, opt)) | |
| class Svn2Feed: | |
| def __init__(self, svn_path, repos_path, item_url, feed_file, | |
| max_items, feed_url): | |
| self.repos_path = repos_path | |
| self.item_url = item_url | |
| self.feed_file = feed_file | |
| self.max_items = max_items | |
| self.feed_url = feed_url | |
| self.svnlook_cmd = 'svnlook' | |
| if svn_path is not None: | |
| self.svnlook_cmd = os.path.join(svn_path, 'svnlook') | |
| self.feed_title = ("%s's Subversion Commits Feed" | |
| % (os.path.basename(os.path.abspath(self.repos_path)))) | |
| self.feed_desc = "The latest Subversion commits" | |
| def _get_item_dict(self, revision): | |
| revision = str(revision) | |
| cmd = [self.svnlook_cmd, 'info', '-r', revision, self.repos_path] | |
| proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) | |
| proc.wait() | |
| info_lines = proc.stdout.readlines() | |
| cmd = [self.svnlook_cmd, 'changed', '-r', revision, self.repos_path] | |
| proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) | |
| proc.wait() | |
| changed_data = proc.stdout.readlines() | |
| desc = ("\nRevision: %s\nLog: %sModified: \n%s" | |
| % (revision, info_lines[3], changed_data)) | |
| item_dict = { | |
| 'author': info_lines[0].strip('\n'), | |
| 'title': "Revision %s" % revision, | |
| 'link': self.item_url and "%s?rev=%s" % (self.item_url, revision), | |
| 'date': self._format_updated_ts(info_lines[1]), | |
| 'description': "<pre>" + desc + "</pre>", | |
| } | |
| return item_dict | |
| def _format_updated_ts(self, revision_ts): | |
| # Get "2006-08-10 20:17:08" from | |
| # "2006-07-28 20:17:18 +0530 (Fri, 28 Jul 2006) | |
| date = revision_ts[0:19] | |
| epoch = time.mktime(time.strptime(date, "%Y-%m-%d %H:%M:%S")) | |
| return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(epoch)) | |
| class Svn2RSS(Svn2Feed): | |
| def __init__(self, svn_path, repos_path, item_url, feed_file, | |
| max_items, feed_url): | |
| Svn2Feed.__init__(self, svn_path, repos_path, item_url, feed_file, | |
| max_items, feed_url) | |
| try: | |
| import PyRSS2Gen | |
| except ImportError: | |
| sys.stderr.write(""" | |
| Error: Required PyRSS2Gen module not found. You can download the PyRSS2Gen | |
| module from: | |
| http://www.dalkescientific.com/Python/PyRSS2Gen.html | |
| """) | |
| sys.exit(1) | |
| self.PyRSS2Gen = PyRSS2Gen | |
| (file, ext) = os.path.splitext(self.feed_file) | |
| self.pickle_file = file + ".pickle" | |
| if os.path.exists(self.pickle_file): | |
| self.rss = pickle.load(open(self.pickle_file, "r")) | |
| else: | |
| self.rss = self.PyRSS2Gen.RSS2( | |
| title = self.feed_title, | |
| link = self.feed_url, | |
| description = self.feed_desc, | |
| lastBuildDate = datetime.datetime.now(), | |
| items = []) | |
| @staticmethod | |
| def get_default_file_extension(): | |
| return ".rss" | |
| def add_revision_item(self, revision): | |
| rss_item = self._make_rss_item(revision) | |
| self.rss.items.insert(0, rss_item) | |
| if len(self.rss.items) > self.max_items: | |
| del self.rss.items[self.max_items:] | |
| def write_output(self): | |
| s = pickle.dumps(self.rss) | |
| f = open(self.pickle_file, "w") | |
| f.write(s) | |
| f.close() | |
| f = open(self.feed_file, "w") | |
| self.rss.write_xml(f) | |
| f.close() | |
| def _make_rss_item(self, revision): | |
| info = self._get_item_dict(revision) | |
| rss_item = self.PyRSS2Gen.RSSItem( | |
| author = info['author'], | |
| title = info['title'], | |
| link = info['link'], | |
| description = info['description'], | |
| guid = self.PyRSS2Gen.Guid(info['link']), | |
| pubDate = info['date']) | |
| return rss_item | |
| class Svn2Atom(Svn2Feed): | |
| def __init__(self, svn_path, repos_path, item_url, feed_file, | |
| max_items, feed_url): | |
| Svn2Feed.__init__(self, svn_path, repos_path, item_url, feed_file, | |
| max_items, feed_url) | |
| from xml.dom import getDOMImplementation | |
| self.dom_impl = getDOMImplementation() | |
| self.pickle_file = self.feed_file + ".pickle" | |
| if os.path.exists(self.pickle_file): | |
| self.document = pickle.load(open(self.pickle_file, "r")) | |
| self.feed = self.document.getElementsByTagName('feed')[0] | |
| else: | |
| self._init_atom_document() | |
| @staticmethod | |
| def get_default_file_extension(): | |
| return ".atom" | |
| def add_revision_item(self, revision): | |
| item = self._make_atom_item(revision) | |
| total = 0 | |
| for childNode in self.feed.childNodes: | |
| if childNode.nodeName == 'entry': | |
| if total == 0: | |
| self.feed.insertBefore(item, childNode) | |
| total += 1 | |
| total += 1 | |
| if total > self.max_items: | |
| self.feed.removeChild(childNode) | |
| if total == 0: | |
| self.feed.appendChild(item) | |
| def write_output(self): | |
| s = pickle.dumps(self.document) | |
| f = open(self.pickle_file, "w") | |
| f.write(s) | |
| f.close() | |
| f = open(self.feed_file, "w") | |
| f.write(self.document.toxml()) | |
| f.close() | |
| def _make_atom_item(self, revision): | |
| info = self._get_item_dict(revision) | |
| doc = self.document | |
| entry = doc.createElement("entry") | |
| id = doc.createElement("id") | |
| entry.appendChild(id) | |
| id.appendChild(doc.createTextNode(info['link'])) | |
| title = doc.createElement("title") | |
| entry.appendChild(title) | |
| title.appendChild(doc.createTextNode(info['title'])) | |
| updated = doc.createElement("updated") | |
| entry.appendChild(updated) | |
| updated.appendChild(doc.createTextNode(info['date'])) | |
| link = doc.createElement("link") | |
| entry.appendChild(link) | |
| link.setAttribute("href", info['link']) | |
| summary = doc.createElement("summary") | |
| entry.appendChild(summary) | |
| summary.appendChild(doc.createTextNode(info['description'])) | |
| author = doc.createElement("author") | |
| entry.appendChild(author) | |
| aname = doc.createElement("name") | |
| author.appendChild(aname) | |
| aname.appendChild(doc.createTextNode(info['author'])) | |
| return entry | |
| def _init_atom_document(self): | |
| doc = self.document = self.dom_impl.createDocument(None, None, None) | |
| feed = self.feed = doc.createElement("feed") | |
| doc.appendChild(feed) | |
| feed.setAttribute("xmlns", "http://www.w3.org/2005/Atom") | |
| title = doc.createElement("title") | |
| feed.appendChild(title) | |
| title.appendChild(doc.createTextNode(self.feed_title)) | |
| id = doc.createElement("id") | |
| feed.appendChild(id) | |
| id.appendChild(doc.createTextNode(self.feed_url)) | |
| updated = doc.createElement("updated") | |
| feed.appendChild(updated) | |
| now = datetime.datetime.now() | |
| updated.appendChild(doc.createTextNode(self._format_date(now))) | |
| link = doc.createElement("link") | |
| feed.appendChild(link) | |
| link.setAttribute("href", self.feed_url) | |
| author = doc.createElement("author") | |
| feed.appendChild(author) | |
| aname = doc.createElement("name") | |
| author.appendChild(aname) | |
| aname.appendChild(doc.createTextNode("subversion")) | |
| def _format_date(self, dt): | |
| """ input date must be in GMT """ | |
| return ("%04d-%02d-%02dT%02d:%02d:%02d.%02dZ" | |
| % (dt.year, dt.month, dt.day, dt.hour, dt.minute, | |
| dt.second, dt.microsecond)) | |
| def main(): | |
| # Parse the command-line options and arguments. | |
| try: | |
| opts, args = getopt.gnu_getopt(sys.argv[1:], "hP:r:u:f:m:U:F:", | |
| ["help", | |
| "svn-path=", | |
| "revision=", | |
| "item-url=", | |
| "feed-file=", | |
| "max-items=", | |
| "feed-url=", | |
| "format=", | |
| ]) | |
| except getopt.GetoptError as msg: | |
| usage_and_exit(msg) | |
| # Make sure required arguments are present. | |
| if len(args) != 1: | |
| usage_and_exit("You must specify a repository path.") | |
| repos_path = os.path.abspath(args[0]) | |
| # Now deal with the options. | |
| max_items = 20 | |
| commit_rev = svn_path = None | |
| item_url = feed_url = None | |
| feed_file = None | |
| feedcls = None | |
| feed_classes = { 'rss': Svn2RSS, 'atom': Svn2Atom } | |
| for opt, arg in opts: | |
| if opt in ("-h", "--help"): | |
| usage_and_exit() | |
| elif opt in ("-P", "--svn-path"): | |
| svn_path = arg | |
| elif opt in ("-r", "--revision"): | |
| commit_rev = arg | |
| elif opt in ("-u", "--item-url"): | |
| item_url = arg | |
| check_url(item_url, opt) | |
| elif opt in ("-f", "--feed-file"): | |
| feed_file = arg | |
| elif opt in ("-m", "--max-items"): | |
| try: | |
| max_items = int(arg) | |
| except ValueError as msg: | |
| usage_and_exit("Invalid value '%s' for --max-items." % (arg)) | |
| if max_items < 1: | |
| usage_and_exit("Value for --max-items must be a positive " | |
| "integer.") | |
| elif opt in ("-U", "--feed-url"): | |
| feed_url = arg | |
| check_url(feed_url, opt) | |
| elif opt in ("-F", "--format"): | |
| try: | |
| feedcls = feed_classes[arg] | |
| except KeyError: | |
| usage_and_exit("Invalid value '%s' for --format." % arg) | |
| if feedcls is None: | |
| usage_and_exit("Option -F [--format] is required.") | |
| if item_url is None: | |
| usage_and_exit("Option -u [--item-url] is required.") | |
| if feed_url is None: | |
| usage_and_exit("Option -U [--feed-url] is required.") | |
| if commit_rev is None: | |
| svnlook_cmd = 'svnlook' | |
| if svn_path is not None: | |
| svnlook_cmd = os.path.join(svn_path, 'svnlook') | |
| cmd = [svnlook_cmd, 'youngest', repos_path] | |
| proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) | |
| proc.wait() | |
| cmd_out = proc.stdout.readlines() | |
| try: | |
| revisions = [int(cmd_out[0])] | |
| except IndexError as msg: | |
| usage_and_exit("svn2feed.py: Invalid value '%s' for " \ | |
| "REPOS-PATH" % (repos_path)) | |
| else: | |
| try: | |
| rev_range = commit_rev.split(':') | |
| len_rev_range = len(rev_range) | |
| if len_rev_range == 1: | |
| revisions = [int(commit_rev)] | |
| elif len_rev_range == 2: | |
| start, end = rev_range | |
| start = int(start) | |
| end = int(end) | |
| if (start > end): | |
| tmp = start | |
| start = end | |
| end = tmp | |
| revisions = list(range(start, end + 1)[-max_items:]) | |
| else: | |
| raise ValueError() | |
| except ValueError as msg: | |
| usage_and_exit("svn2feed.py: Invalid value '%s' for --revision." \ | |
| % (commit_rev)) | |
| if feed_file is None: | |
| feed_file = (os.path.basename(repos_path) + | |
| feedcls.get_default_file_extension()) | |
| feed = feedcls(svn_path, repos_path, item_url, feed_file, max_items, | |
| feed_url) | |
| for revision in revisions: | |
| feed.add_revision_item(revision) | |
| feed.write_output() | |
| if __name__ == "__main__": | |
| main() |