Added libbe.command.serve and libbe.storage.http for HTTP backend.
authorW. Trevor King <wking@drexel.edu>
Fri, 1 Jan 2010 19:43:03 +0000 (14:43 -0500)
committerW. Trevor King <wking@drexel.edu>
Fri, 1 Jan 2010 19:43:03 +0000 (14:43 -0500)
Now the following works:
  some-BE-dir$ ./be serve
  $ ./be --repo http://localhost:8000 list

I haven't come up with a clean idea for testing this yet, so other
commands may be broken, but once we get the testing working, it
shouldn't be too hard to get everything working over HTTP :).

libbe/command/serve.py [new file with mode: 0644]
libbe/storage/__init__.py
libbe/storage/http.py [new file with mode: 0644]
libbe/ui/command_line.py

diff --git a/libbe/command/serve.py b/libbe/command/serve.py
new file mode 100644 (file)
index 0000000..3f96d00
--- /dev/null
@@ -0,0 +1,346 @@
+# Copyright (C) 2005-2010 Aaron Bentley and Panometrics, Inc.
+#                         Gianluca Montecchi <gian@grys.it>
+#                         Oleg Romanyshyn <oromanyshyn@panoramicfeedback.com>
+#                         W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import BaseHTTPServer as server
+import posixpath
+import urllib
+import urlparse
+
+import libbe
+import libbe.command
+import libbe.command.util
+import libbe.version
+
+HTTP_USER_ERROR = 418
+STORAGE = None
+COMMAND = None
+
+class BERequestHandler (server.BaseHTTPRequestHandler):
+    """Simple HTTP request handler for serving the
+    libbe.storage.http.HTTP backend with GET, POST, and HEAD commands.
+
+    This serves files from a connected storage instance, usually
+    a VCS-based repository located on the local machine.
+
+    The GET and HEAD requests are identical except that the HEAD
+    request omits the actual content of the file.
+    """
+
+    server_version = "BE-server/" + libbe.version.version()
+
+    def do_GET(self, head=False):
+        """Serve a GET (or HEAD, if head==True) request."""
+        self.s = STORAGE
+        self.c = COMMAND
+        request = 'GET'
+        if head == True:
+            request = 'HEAD'
+        self.log_request(request)
+        path,query,fragment = self.parse_path(self.path)
+        if fragment != '':
+            self.send_error(406,
+                '%s implementation does not allow fragment URL portion'
+                % request)
+            return None
+        data = self.parse_query(query)
+
+        if path == ['children']:
+            content,ctype = self.handle_children(data)
+        elif len(path) > 1 and path[0] == 'get':
+            content,ctype = self.handle_get('/'.join(path[1:]), data)
+        elif path == ['revision-id']:
+            content,ctype = self.handle_revision_id(data)
+        elif path == ['version']:
+            content,ctype = self.handle_version(data)
+        else:
+            self.send_error(400, 'File not found')
+            return None
+        if content != None:
+            self.send_header('Content-type', ctype)
+            self.send_header('Content-Length', len(content))
+        self.end_headers()
+        if request == 'GET' and content != None:
+            self.wfile.write(content)
+
+    def do_HEAD(self):
+        """Serve a HEAD request."""
+        return self.do_GET(head=True)
+
+    def do_POST(self):
+        """Serve a POST request."""
+        self.s = STORAGE
+        self.c = COMMAND
+        self.log_request('POST')
+        post_data = self.rfile.read()
+        print 'got post', post_data
+        data = self.parse_post(post_data)
+
+        path,query,fragment = self.parse_path(self.path)
+        if query != '':
+            self.send_error(
+                406, 'POST implementation does not allow query URL portion')
+            return None
+        if fragment != '':
+            self.send_error(
+                406, 'POST implementation does not allow fragment URL portion')
+            return None
+        if path == ['add']:
+            content,ctype = self.handle_add(data)
+        elif path == ['remove']:
+            content,ctype = self.handle_remove(data)
+        elif len(path) > 1 and path[0] == 'set':
+            content,ctype = self.handle_set('/'.join(path[1:]), data)
+        elif path == ['commit']:
+            content,ctype = self.handle_commit(data)
+        else:
+            self.send_error(400, 'File not found')
+            return None
+        if content != None:
+            self.send_header('Content-type', ctype)
+            self.send_header('Content-Length', len(content))
+        self.end_headers()
+        if content != None:
+            self.wfile.write(content)
+
+    def handle_add(self, data):
+        if not 'id' in data:
+            self.send_error(406, 'Missing query key id')
+            return None
+        elif data['id'] == 'None':
+            data['id'] = None
+        id = data['id']
+        if not 'parent' in data or data['parent'] == None:
+            data['parent'] = None
+        parent = data['parent']
+        if not 'directory' in data:
+            directory = False
+        elif data['directory'] == 'True':
+            directory = True
+        else:
+            directory = False
+        self.s.add(id, parent, directory)
+        self.send_response(200)
+        return (None,None)
+
+    def handle_remove(self, data):
+        if not 'id' in data:
+            self.send_error(406, 'Missing query key id')
+            return None
+        elif data['id'] == 'None':
+            data['id'] = None
+        id = data['id']
+        if not 'recursive' in data:
+            recursive = False
+        elif data['recursive'] == 'True':
+            recursive = True
+        else:
+            recursive = False
+        if recursive == True:
+            self.s.recursive_remove(id)
+        else:
+            self.s.remove(id)
+        self.send_response(200)
+        return (None,None)
+
+    def handle_children(self, data):
+        if not 'id' in data:
+            self.send_error(406, 'Missing query key id')
+            return None
+        elif data['id'] == 'None':
+            data['id'] = None
+        id = data['id']
+        if not 'revision' in data or data['revision'] == 'None':
+            data['revision'] = None
+        revision = data['revision']
+        content = '\n'.join(self.s.children(id, revision))
+        ctype = 'application/octet-stream'
+        self.send_response(200)
+        return content,ctype
+
+    def handle_get(self, id, data):
+        if not 'revision' in data or data['revision'] == 'None':
+            data['revision'] = None
+        revision = data['revision']
+        try:
+            content = self.s.get(id, revision)
+        except libbe.storage.InvalidID, e:
+            self.send_error(HTTP_USER_ERROR, 'InvalidID %s' % e)
+            return None
+        be_version = self.s.storage_version(revision)
+        ctype = 'application/octet-stream'
+        self.send_response(200)
+        self.send_header('X-BE-Version', be_version)
+        return content,ctype
+
+    def handle_set(self, id, data):
+        if not 'value' in data:
+            self.send_error(406, 'Missing query key value')
+            return None
+        self.s.set(id, value)
+        self.send_response(200)
+        return None
+
+    def handle_commit(self, data):
+        if not 'summary' in data:
+            self.send_error(406, 'Missing query key summary')
+            return None
+        summary = data['summary']
+        if not body in data or data['body'] == 'None':
+            data['body'] = None
+        body = data['body']
+        if not allow_empty in data \
+                or data['allow_empty'] == 'True':
+            allow_empty = True
+        else:
+            allow_empty = False
+        self.s.commit(summary, body, allow_empty)
+        self.send_response(200)
+        return None
+
+    def handle_revision_id(self, data):
+        if not 'index' in data:
+            self.send_error(406, 'Missing query key index')
+            return None
+        index = int(data['index'])
+        content = self.s.revision_id(index)
+        ctype = 'application/octet-stream'
+        self.send_response(200)
+        return content,ctype
+
+    def handle_version(self, data):
+        if not 'revision' in data or data['revision'] == 'None':
+            data['revision'] = None
+        revision = data['revision']
+        content = self.s.storage_version(revision)
+        ctype = 'application/octet-stream'
+        self.send_response(200)
+        return content,ctype
+
+    def parse_path(self, path):
+        """Parse a url to path,query,fragment parts."""
+        # abandon query parameters
+        scheme,netloc,path,query,fragment = urlparse.urlsplit(path)
+        path = posixpath.normpath(urllib.unquote(path)).split('/')
+        assert path[0] == '', path
+        path = path[1:]
+        return (path,query,fragment)
+
+    def log_request(self, request):
+        print >> self.c.stdout, request, self.path
+
+    def parse_query(self, query):
+        if len(query) == 0:
+            return {}
+        data = urlparse.parse_qs(
+            query, keep_blank_values=True, strict_parsing=True)
+        for k,v in data.items():
+            if len(v) == 1:
+                data[k] = v[0]
+        return data
+
+    def parse_post(self, post):
+        return self.parse_query(post)
+
+class Serve (libbe.command.Command):
+    """Serve a Storage backend for the HTTP storage client
+
+    >>> import libbe.bugdir
+    >>> bd = libbe.bugdir.SimpleBugDir(memory=False)
+    >>> io = libbe.command.StringInputOutput()
+    >>> io.stdout = sys.stdout
+    >>> ui = libbe.command.UserInterface(io=io)
+    >>> ui.storage_callbacks.set_storage(bd.storage)
+    >>> cmd = List(ui=ui)
+
+    >>> ret = ui.run(cmd)
+    abc/a:om: Bug A
+    >>> ret = ui.run(cmd, {'status':'closed'})
+    abc/b:cm: Bug B
+    >>> bd.storage.writeable
+    True
+    >>> ui.cleanup()
+    >>> bd.cleanup()
+    """
+
+    name = 'serve'
+
+    def __init__(self, *args, **kwargs):
+        libbe.command.Command.__init__(self, *args, **kwargs)
+        self.options.extend([
+                libbe.command.Option(name='port',
+                    help='Bind server to port (%default)',
+                    arg=libbe.command.Argument(
+                        name='port', metavar='INT', type='int', default=8000)),
+                libbe.command.Option(name='host',
+                    help='Set host string (blank for localhost, %default)',
+                    arg=libbe.command.Argument(
+                        name='host', metavar='HOST', default='')),
+                ])
+
+    def _run(self, **params):
+        global STORAGE, COMMAND
+        STORAGE = self._get_storage()
+        COMMAND = self
+        server_class = server.HTTPServer
+        handler_class = BERequestHandler
+        server_address = (params['host'], params['port'])
+        httpd = server_class(server_address, handler_class)
+        sa = httpd.socket.getsockname()
+        print >> self.stdout, 'Serving HTTP on', sa[0], 'port', sa[1], '...'
+        print >> self.stdout, 'BE repository', STORAGE.repo
+        try:
+            httpd.serve_forever()
+        except KeyboardInterrupt:
+            pass
+        print >> self.stdout, 'Closing server'
+        httpd.server_close()
+
+    def _long_help(self):
+        return """
+This command lists bugs.  Normally it prints a short string like
+  576:om: Allow attachments
+Where
+  576   the bug id
+  o     the bug status is 'open' (first letter)
+  m     the bug severity is 'minor' (first letter)
+  Allo... the bug summary string
+
+You can optionally (-u) print only the bug ids.
+
+There are several criteria that you can filter by:
+  * status
+  * severity
+  * assigned (who the bug is assigned to)
+Allowed values for each criterion may be given in a comma seperated
+list.  The special string "all" may be used with any of these options
+to match all values of the criterion.  As with the --status and
+--severity options for `be depend`, starting the list with a minus
+sign makes your selections a blacklist instead of the default
+whitelist.
+
+status
+  %s
+severity
+  %s
+assigned
+  free form, with the string '-' being a shortcut for yourself.
+
+In addition, there are some shortcut options that set boolean flags.
+The boolean options are ignored if the matching string option is used.
+"""
index 7abe791836f20302c3b2845a40f86faca3567469..b6b0ac1661376441808444742e8a129b30b3138d 100644 (file)
@@ -36,15 +36,24 @@ STORAGE_VERSIONS = ['Bugs Everywhere Tree 1 0',
 # the current version
 STORAGE_VERSION = STORAGE_VERSIONS[-1]
 
-def get_storage(location):
-    """
-    Return a Storage instance from a repo location string.
-    """
+def get_http_storage(location):
+    import http
+    return http.HTTP(location)
+
+def get_vcs_storage(location):
     import vcs
     s = vcs.detect_vcs(location)
     s.repo = location
     return s
 
+def get_storage(location):
+    """
+    Return a Storage instance from a repo location string.
+    """
+    if location.startswith('http://'):
+        return get_http_storage(location)
+    return get_vcs_storage(location)
+
 __all__ = [ConnectionError, InvalidStorageVersion, InvalidID,
            InvalidRevision, InvalidDirectory, NotWriteable, NotReadable,
            EmptyCommit, STORAGE_VERSIONS, STORAGE_VERSION,
diff --git a/libbe/storage/http.py b/libbe/storage/http.py
new file mode 100644 (file)
index 0000000..bc9af35
--- /dev/null
@@ -0,0 +1,262 @@
+# Copyright (C) 2009-2010 W. Trevor King <wking@drexel.edu>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+# For urllib2 information, see
+#   urllib2, from urllib2 - The Missing Manual
+#   http://www.voidspace.org.uk/python/articles/urllib2.shtml
+# 
+# A dictionary of response codes is available in
+#   httplib.responses
+
+"""
+Access bug repository data over HTTP.
+"""
+
+import sys
+import urllib
+import urllib2
+import urlparse
+
+import libbe
+import libbe.version
+import base
+from libbe import TESTING
+
+if TESTING == True:
+    import doctest
+    import unittest
+
+
+USER_AGENT = 'BE-HTTP-Storage'
+HTTP_OK = 200
+HTTP_FOUND = 302
+HTTP_TEMP_REDIRECT = 307
+HTTP_USER_ERROR = 418
+HTTP_VALID = [HTTP_OK, HTTP_FOUND, HTTP_TEMP_REDIRECT, HTTP_USER_ERROR]
+
+class InvalidURL (Exception):
+    def __init__(self, error=None, url=None, msg=None):
+        Exception.__init__(self, msg)
+        self.url = url
+        self.error = error
+        self.msg = msg
+    def __str__(self):
+        if self.msg == None:
+            if self.error == None:
+                return "Unknown URL error: %s" % self.url
+            return self.error.__str__()
+        return self.msg
+
+def get_post_url(url, get=True, data_dict=None):
+    """
+    get:        use GET if True, otherwise use POST.
+    data_dict:  dict of data to send.
+    """
+    if data_dict == None:
+        data_dict = {}
+    if get == True:
+        if data_dict != {}:
+            # encode get parameters in the url
+            param_string = urllib.urlencode(data_dict)
+            url = "%s?%s" % (url, param_string)
+        data = None
+    else:
+        data = urllib.urlencode(data_dict)
+    headers = {'User-Agent':USER_AGENT}
+    req = urllib2.Request(url, data=data, headers=headers)
+    try:
+        response = urllib2.urlopen(req)
+    except urllib2.HTTPError, e:
+        if hasattr(e, 'reason'):
+            msg = 'We failed to reach a server.\nURL: %s\nReason: %s' \
+                % (url, e.reason)
+        elif hasattr(e, 'code'):
+            msg = "The server couldn't fulfill the request.\nURL: %s\nError code: %s" \
+                % (url, e.code)
+        raise InvalidURL(error=e, url=url, msg=msg)
+    page = response.read()
+    final_url = response.geturl()
+    info = response.info()
+    response.close()
+    return (page, final_url, info)
+
+
+class HTTP (base.VersionedStorage):
+    """
+    This class implements a Storage interface over HTTP, using GET to
+    retrieve information and POST to set information.
+    """
+    name = 'HTTP'
+
+    def __init__(self, *args, **kwargs):
+        base.VersionedStorage.__init__(self, *args, **kwargs)
+
+    def storage_version(self, revision=None):
+        """Return the storage format for this backend."""
+        return libbe.storage.STORAGE_VERSION
+
+    def _init(self):
+        """Create a new storage repository."""
+        raise base.NotSupported(
+            'init', 'Cannot initialize this repository format.')
+
+    def _destroy(self):
+        """Remove the storage repository."""
+        raise base.NotSupported(
+            'destroy', 'Cannot destroy this repository format.')
+
+    def _connect(self):
+        self.check_storage_version()
+
+    def _disconnect(self):
+        pass
+
+    def _add(self, id, parent=None, directory=False):
+        url = urlparse.urljoin(self.repo, 'add')
+        page,final_url,info = get_post_url(
+            url, get=False,
+            data_dict={'id':id, 'parent':parent, 'directory':directory})
+
+    def _remove(self, id):
+        url = urlparse.urljoin(self.repo, 'remove')
+        page,final_url,info = get_post_url(
+            url, get=False,
+            data_dict={'id':id, 'recursive':False})
+
+    def _recursive_remove(self, id):
+        url = urlparse.urljoin(self.repo, 'remove')
+        page,final_url,info = get_post_url(
+            url, get=False,
+            data_dict={'id':id, 'recursive':True})
+
+    def _children(self, id=None, revision=None):
+        url = urlparse.urljoin(self.repo, 'children')
+        page,final_url,info = get_post_url(
+            url, get=True,
+            data_dict={'id':id, 'revision':revision})
+        return page.strip('\n').splitlines()
+
+    def _get(self, id, default=base.InvalidObject, revision=None):
+        url = urlparse.urljoin(self.repo, '/'.join(['get', id]))
+        try:
+            page,final_url,info = get_post_url(
+                url, get=True,
+                data_dict={'revision':revision})
+        except InvalidURL, e:
+            if not (hasattr(e.error, 'code') and e.error.code in HTTP_VALID):
+                raise
+            elif default == base.InvalidObject:
+                raise base.InvalidID(id)
+            return default
+        version = info['X-BE-Version']
+        if version != libbe.storage.STORAGE_VERSION:
+            raise base.InvalidStorageVersion(
+                version, libbe.storage.STORAGE_VERSION)
+        return page
+
+    def _set(self, id, value):
+        url = urlparse.urljoin(self.repo, '/'.join(['set', id]))
+        try:
+            page,final_url,info = get_post_url(
+                url, get=False,
+                data_dict={'value':value})
+        except InvalidURL, e:
+            if not (hasattr(e.error, 'code') and e.error.code in HTTP_VALID):
+                raise
+            if self.e.error.code == HTTP_USER_ERROR:
+                raise base.InvalidDirectory(
+                    'Directory %s cannot have data' % self.id)
+            raise base.InvalidID(id)
+
+    def _commit(self, summary, body=None, allow_empty=False):
+        url = urlparse.urljoin(self.repo, 'commit')
+        try:
+            page,final_url,info = get_post_url(
+                url, get=False,
+                data_dict={'summary':summary, 'body':body,
+                           'allow_empty':allow_empty})
+        except InvalidURL, e:
+            if not (hasattr(e.error, 'code') and e.error.code in HTTP_VALID):
+                raise
+            if self.e.error.code == HTTP_USER_ERROR:
+                raise base.EmptyCommit
+            raise base.InvalidID(id)
+        return page.rstrip('\n')
+
+    def revision_id(self, index=None):
+        """
+        Return the name of the <index>th revision.  The choice of
+        which branch to follow when crossing branches/merges is not
+        defined.  Revision indices start at 1; ID 0 is the blank
+        repository.
+
+        Return None if index==None.
+
+        If the specified revision does not exist, raise InvalidRevision.
+        """
+        if index == None:
+            return None
+        try:
+            if int(index) != index:
+                raise InvalidRevision(index)
+        except ValueError:
+            raise InvalidRevision(index)
+        url = urlparse.urljoin(self.repo, 'revision-id')
+        try:
+            page,final_url,info = get_post_url(
+                url, get=True,
+                data_dict={'index':index})
+        except InvalidURL, e:
+            if not (hasattr(e.error, 'code') and e.error.code in HTTP_VALID):
+                raise
+            if self.e.error.code == HTTP_USER_ERROR:
+                raise base.InvalidRevision(index)
+            raise base.InvalidID(id)
+        return page.rstrip('\n')
+
+    def check_storage_version(self):
+        version = self.storage_version()
+        if version != libbe.storage.STORAGE_VERSION:
+            raise base.InvalidStorageVersion(
+                version, libbe.storage.STORAGE_VERSION)
+
+    def storage_version(self, revision=None):
+        url = urlparse.urljoin(self.repo, 'version')
+        page,final_url,info = get_post_url(
+            url, get=True, data_dict={'revision':revision})
+        return page.rstrip('\n')
+
+if TESTING == True:
+    class GetPostUrlTestCase (unittest.TestCase):
+        """Test cases for get_post_url()"""
+        def test_get(self):
+            url = 'http://bugseverywhere.org/be/show/HomePage'
+            page,final_url,info = get_post_url(url=url)
+            self.failUnless(final_url == url,
+                'Redirect?\n  Expected: "%s"\n  Got:      "%s"'
+                % (url, final_url))
+        def test_get_redirect(self):
+            url = 'http://bugseverywhere.org'
+            expected = 'http://bugseverywhere.org/be/show/HomePage'
+            page,final_url,info = get_post_url(url=url)
+            self.failUnless(final_url == expected,
+                'Redirect?\n  Expected: "%s"\n  Got:      "%s"'
+                % (expected, final_url))
+
+    #make_storage_testcase_subclasses(VersionedStorage, sys.modules[__name__])
+
+    unitsuite =unittest.TestLoader().loadTestsFromModule(sys.modules[__name__])
+    suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()])
index f57983f931970a68edf5ade7dc4f10705d5faa67..7ba6cf5b20b78ec9159ffce29fdcf348cae3ded3 100644 (file)
@@ -299,7 +299,7 @@ def main():
     command = Class(ui=ui)
     ui.setup_command(command)
 
-    if command.name in ['comment', 'commit']:
+    if command.name in ['comment', 'commit', 'serve']:
         paginate = 'never'
     else:
         paginate = 'auto'