Web   ·   Wiki   ·   Activities   ·   Blog   ·   Lists   ·   Chat   ·   Meeting   ·   Bugs   ·   Git   ·   Translate   ·   Archive   ·   People   ·   Donate
summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAleksey Lim <alsroot@sugarlabs.org>2012-01-07 04:21:32 (GMT)
committer Aleksey Lim <alsroot@sugarlabs.org>2012-01-07 04:21:32 (GMT)
commit4aaba29494bc3336266317f18481124c5513f5c3 (patch)
tree840812b425fe946d881d30816662e7047a3e527c
parente52a3eeb759325a889bd0ee3afc3c9211c8c606d (diff)
Initial implementation
-rw-r--r--restful_document/__init__.py18
-rw-r--r--restful_document/document.py98
-rw-r--r--restful_document/env.py63
-rw-r--r--restful_document/metadata.py112
-rw-r--r--restful_document/router.py90
-rw-r--r--restful_document/util.py809
-rw-r--r--tests/__init__.py128
-rw-r--r--tests/units/__init__.py9
-rw-r--r--tests/units/__main__.py7
-rwxr-xr-xtests/units/document.py181
10 files changed, 1515 insertions, 0 deletions
diff --git a/restful_document/__init__.py b/restful_document/__init__.py
new file mode 100644
index 0000000..3a3770e
--- /dev/null
+++ b/restful_document/__init__.py
@@ -0,0 +1,18 @@
+# Copyright (C) 2012, Aleksey Lim
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+
+from restful_document.document import Document, restful_method
+from restful_document.metadata import Metadata, Method
+from restful_document.router import Router, Responce
diff --git a/restful_document/document.py b/restful_document/document.py
new file mode 100644
index 0000000..19ab82c
--- /dev/null
+++ b/restful_document/document.py
@@ -0,0 +1,98 @@
+# Copyright (C) 2012, Aleksey Lim
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+
+from gettext import gettext as _
+
+import active_document as ad
+
+from restful_document import env
+from restful_document.util import enforce
+
+
+def restful_method(**kwargs):
+
+ def decorate(func):
+ func.is_restful_method = True
+ func.restful_cls_kwargs = kwargs
+ return func
+
+ return decorate
+
+
+class Document(ad.Document):
+ """All RESTful document classes need to inherit this one."""
+
+ @staticmethod
+ @restful_method(method='GET')
+ def _restful_hello(response):
+ response.headers['Content-Type'] = 'text/plain'
+ return _('Hello, world!')
+
+ @classmethod
+ @restful_method(method='POST')
+ def _restful_create(cls, response):
+ doc = cls.create(response.request)
+ return {'guid': doc.guid}
+
+ @classmethod
+ @restful_method(method='GET')
+ def _restful_find(cls, response, **kwargs):
+ offset = env.pop_int('offset', kwargs, None)
+ limit = env.pop_int('limit', kwargs, None)
+ query = env.pop_str('query', kwargs, None)
+ reply = env.pop_list('reply', kwargs, None)
+ order_by = env.pop_list('order_by', kwargs, None)
+ group_by = env.pop_str('group_by', kwargs, None)
+
+ documents, total = cls.find(offset, limit, kwargs, query, reply,
+ order_by, group_by)
+ return {'total': total,
+ 'documents': [i.all_properties(reply) for i in documents]}
+
+ @restful_method(method='PUT')
+ def _restful_update(self, response, prop=None):
+ if prop is None:
+ self.update(self.guid, response.request)
+ elif isinstance(self.metadata[prop], ad.BlobProperty):
+ self.set_blob(prop, response.rfile, response.rsize)
+ else:
+ self[prop] = response.request
+ self.post()
+
+ @restful_method(method='DELETE')
+ def _restful_delete(self, response, prop=None):
+ enforce(prop is None, env.Forbidden,
+ _('Properties cannot be deleted'))
+ self.delete(self.guid)
+
+ @restful_method(method='GET')
+ def _restful_get(self, response, prop=None):
+ if prop is None:
+ reply = []
+ for name, prop in self.metadata.items():
+ if prop.is_trait:
+ reply.append(name)
+ return self.all_properties(reply)
+ elif isinstance(self.metadata[prop], ad.BlobProperty):
+ response.headers['Content-Type'] = self.metadata[prop].mime_type
+ return self.get_blob(prop)
+ else:
+ return self[prop]
+
+ def all_properties(self, reply):
+ result = {}
+ for prop_name in (reply or ['guid']):
+ result[prop_name] = self[prop_name]
+ return result
diff --git a/restful_document/env.py b/restful_document/env.py
new file mode 100644
index 0000000..1d4adca
--- /dev/null
+++ b/restful_document/env.py
@@ -0,0 +1,63 @@
+# Copyright (C) 2012, Aleksey Lim
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+
+from gettext import gettext as _
+
+from restful_document.util import enforce
+
+
+_default = object()
+
+
+class HTTPError(Exception):
+
+ status = '500 Internal Server Error'
+
+
+class Forbidden(HTTPError):
+
+ status = '403 Forbidden'
+
+
+class BadRequest(HTTPError):
+
+ status = '400 Bad Request'
+
+
+def pop_str(name, kwargs, default=_default):
+ if name in kwargs:
+ return kwargs.pop(name)
+ else:
+ enforce(default is not _default, BadRequest,
+ _('Argument "%s" should be specified'), name)
+ return default
+
+
+def pop_int(name, kwargs, default=_default):
+ value = pop_str(name, kwargs, default)
+ if value is not default:
+ # pylint: disable-msg=E1103
+ enforce(value.isdigit(), BadRequest,
+ _('Argument "%s" should be an integer value'), name)
+ value = int(value)
+ return value
+
+
+def pop_list(name, kwargs, default=_default):
+ value = pop_str(name, kwargs, default)
+ if value is not default:
+ # pylint: disable-msg=E1103
+ value = value.split(',')
+ return value
diff --git a/restful_document/metadata.py b/restful_document/metadata.py
new file mode 100644
index 0000000..dfcdbec
--- /dev/null
+++ b/restful_document/metadata.py
@@ -0,0 +1,112 @@
+# Copyright (C) 2012, Aleksey Lim
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+
+import types
+from gettext import gettext as _
+
+from restful_document import util, env
+from restful_document.util import enforce
+
+
+class Metadata(object):
+
+ def __init__(self, classes):
+ self._methods = [{}, {}, {}]
+
+ for meth in _list_methods(classes):
+ methods = self._methods[meth.scope].setdefault(meth.document, {})
+ if meth.cmd:
+ enforce(meth.cmd not in methods,
+ _('Method %s already exists in %s'),
+ meth.cmd, methods.get(meth.cmd))
+ methods[meth.name] = meth
+ else:
+ enforce(meth.method not in methods,
+ _('%s method already exists in %s'),
+ meth.method, methods.get(meth.method))
+ methods[meth.method] = meth
+
+ def get_method(self, response):
+ enforce(len(response.path) <= 3, env.BadRequest,
+ _('Requested path consists of more than three parts'))
+ if len(response.path) == 3:
+ response.query['prop'] = response.path.pop()
+
+ scope = len(response.path)
+ if scope == 0:
+ document = None
+ else:
+ document = response.path[0]
+ enforce(document in self._methods[scope], env.BadRequest,
+ _('Unknown document type, %s'), document)
+ method_name = response.query.get('cmd') or response.request_method
+
+ return self._methods[scope][document].get(method_name)
+
+
+class Method(object):
+
+ def __init__(self, cls, scope, cb,
+ method, cmd=None, mime_type='application/json'):
+ self.cls = cls
+ self.document = cls.__name__.lower() if scope else None
+ self.scope = scope
+ self.method = method
+ self.cmd = cmd
+ self.mime_type = mime_type
+ self._cb = cb
+
+ def __str__(self):
+ return self._cb
+
+ def __call__(self, response):
+ try:
+ result = self._call(response)
+ except TypeError:
+ util.exception()
+ raise env.BadRequest(_('Inappropriate arguments'))
+ response.headers.setdefault('Content-Type', self.mime_type)
+ return result
+
+ def _call(self, response):
+ return self._cb(response, **response.query)
+
+
+class _ObjectMethod(Method):
+
+ def _call(self, response):
+ guid = response.path[1]
+ doc = self.cls(guid)
+ return self._cb(doc, response, **response.query)
+
+
+def _list_methods(classes):
+ for cls in classes:
+ cls.init()
+ for attr in [getattr(cls, i) for i in dir(cls)]:
+ if not hasattr(attr, 'is_restful_method'):
+ continue
+ method_cls = Method
+ if isinstance(attr, types.FunctionType):
+ slot = 0
+ elif isinstance(attr, types.MethodType):
+ if attr.im_self is not None:
+ slot = 1
+ else:
+ method_cls = _ObjectMethod
+ slot = 2
+ else:
+ raise RuntimeError(_('Incorrect RESTful method for %r') % attr)
+ yield method_cls(cls, slot, attr, **attr.restful_cls_kwargs)
diff --git a/restful_document/router.py b/restful_document/router.py
new file mode 100644
index 0000000..b87332c
--- /dev/null
+++ b/restful_document/router.py
@@ -0,0 +1,90 @@
+# Copyright (C) 2012, Aleksey Lim
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+
+import json
+import types
+import urlparse
+from gettext import gettext as _
+
+from restful_document import util, env
+from restful_document.metadata import Metadata
+from restful_document.util import enforce
+
+
+class Router(object):
+
+ def __init__(self, classes):
+ self.metadata = Metadata(classes)
+
+ def __call__(self, environ, start_response):
+ response = Responce(environ)
+ try:
+ method = self.metadata.get_method(response)
+ enforce(method is not None and \
+ method.method == response.request_method, env.BadRequest,
+ _('No way to handle the request'))
+ if environ.get('CONTENT_TYPE', '').lower() == 'application/json':
+ request = response.read()
+ if request:
+ response.request = json.loads(request)
+ result = method(response)
+ except Exception, error:
+ util.exception(_('Error while processing %s request'),
+ response.request_url)
+ if isinstance(error, env.HTTPError):
+ response.status = error.status
+ else:
+ response.status = '500 Internal Server Error'
+ response.headers['Content-Type'] = 'application/json'
+ result = {'error': str(error), 'request': response.request_url}
+
+ start_response(response.status, response.headers.items())
+ if isinstance(result, types.GeneratorType):
+ for i in result:
+ yield i
+ else:
+ if response.headers['Content-Type'] == 'application/json':
+ result = json.dumps(result)
+ yield result
+
+
+class Responce(object):
+
+ def __init__(self, environ):
+ self.status = '200 OK'
+ self.headers = {}
+ self.query = dict(urlparse.parse_qsl(environ.get('QUERY_STRING', '')))
+ self.path = \
+ [i for i in environ['PATH_INFO'].strip('/').split('/') if i]
+ self.request_method = environ['REQUEST_METHOD']
+ self.request = None
+ self.request_url = environ['PATH_INFO'] or '/'
+ if self.query:
+ self.request_url += '?' + environ.get('QUERY_STRING')
+ self.rfile = environ.get('wsgi.input')
+ self.rsize = 0
+
+ rsize = environ.get('CONTENT_LENGTH')
+ if rsize:
+ self.rsize = int(rsize)
+
+ def read(self, size=None):
+ if size is None:
+ size = self.rsize
+ if not size:
+ return ''
+ result = self.rfile.read(size)
+ self.rsize -= len(result)
+ return result
diff --git a/restful_document/util.py b/restful_document/util.py
new file mode 100644
index 0000000..aebd86e
--- /dev/null
+++ b/restful_document/util.py
@@ -0,0 +1,809 @@
+# Copyright (C) 2011, Aleksey Lim
+#
+# 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 3 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, see <http://www.gnu.org/licenses/>.
+
+"""Swiss knife module.
+
+$Repo: git://git.sugarlabs.org/alsroot/codelets.git$
+$File: src/util.py$
+$Data: 2012-01-05$
+
+"""
+
+import os
+import re
+import sys
+import fcntl
+import shutil
+import atexit
+import logging
+import tempfile
+import datetime
+import subprocess
+from os.path import exists, join, islink, isdir, dirname, basename, lexists
+from os.path import abspath, expanduser
+from gettext import gettext as _
+
+try:
+ import json
+ if not hasattr(json, 'dumps'):
+ raise ImportError()
+except ImportError:
+ import simplejson as json
+
+
+def enforce(condition, error=None, *args):
+ """Make an assertion in runtime.
+
+ In comparing with `assert`, it will all time present in the code.
+ Just a bit of syntax sugar.
+
+ :param condition:
+ the condition to assert; if not False then return,
+ otherse raise an RuntimeError exception
+ :param error:
+ error message to pass to RuntimeError object
+ or Exception class to raise
+ :param args:
+ optional '%' arguments for the `error`
+
+ """
+ if condition:
+ return
+
+ if isinstance(error, type):
+ exception_class = error
+ if args:
+ error = args[0]
+ args = args[1:]
+ else:
+ error = None
+ else:
+ exception_class = RuntimeError
+
+ if args:
+ error = error % args
+ elif not error:
+ # pylint: disable-msg=W0212
+ frame = sys._getframe(1)
+ error = _('Runtime assertion failed at %s:%s') % \
+ (frame.f_globals['__file__'], frame.f_lineno - 1)
+
+ raise exception_class(error)
+
+
+def exception(*args):
+ """Log about exception on low log level.
+
+ That might be useful for non-critial exception. Input arguments are the
+ same as for `logging.exception` function.
+
+ :param args:
+ optional arguments to pass to logging function;
+ the first argument might be a `logging.Logger` to use instead of
+ using direct `logging` calls
+
+ """
+ if args and isinstance(args[0], logging.Logger):
+ logger = args[0]
+ args = args[1:]
+ else:
+ logger = logging
+
+ klass, error, tb = sys.exc_info()
+
+ import traceback
+ tb = [i.rstrip() for i in traceback.format_exception(klass, error, tb)]
+
+ error = str(error) or _('Something weird happened')
+ if args:
+ if len(args) == 1:
+ message = args[0]
+ else:
+ message = args[0] % args[1:]
+ error = '%s: %s' % (message, error)
+
+ logger.error(error)
+ logger.debug('\n'.join(tb))
+
+
+def assert_call(cmd, stdin=None, **kwargs):
+ """Variant of `call` method with raising exception of errors.
+
+ :param cmd:
+ commad to execute, might be string or argv list
+ :param stdin:
+ text that will be used as an input for executed process
+
+ """
+ return call(cmd, stdin=stdin, asserts=True, **kwargs)
+
+
+def call(cmd, stdin=None, asserts=False, raw=False, error_cb=None, **kwargs):
+ """Convenient wrapper around subprocess call.
+
+ Note, this function is intended for processes that output finite
+ and not big amount of text.
+
+ :param cmd:
+ commad to execute, might be string or argv list
+ :param stdin:
+ text that will be used as an input for executed process
+ :param asserts:
+ whether to raise `RuntimeError` on fail execution status
+ :param error_cb:
+ call callback(stderr) on getting error exit status from the process
+ :returns:
+ `None` on errors, otherwise `str` value of stdout
+
+ """
+ stdout, stderr = None, None
+ returncode = 1
+ try:
+ logging.debug('Exec %r', cmd)
+ process = subprocess.Popen(cmd, stderr=subprocess.PIPE,
+ stdout=subprocess.PIPE, stdin=subprocess.PIPE, **kwargs)
+ if stdin is not None:
+ process.stdin.write(stdin)
+ process.stdin.close()
+ # Avoid using Popen.communicate()
+ # http://bugs.python.org/issue4216#msg77582
+ process.wait()
+ stdout = _nb_read(process.stdout)
+ stderr = _nb_read(process.stderr)
+ if not raw:
+ stdout = stdout.strip()
+ stderr = stderr.strip()
+ returncode = process.returncode
+ enforce(returncode == 0, _('Exit status is an error'))
+ logging.debug('Successfully executed stdout=%r stderr=%r',
+ stdout.split('\n'), stderr.split('\n'))
+ return stdout
+ except Exception, error:
+ logging.debug('Failed to execute error="%s" stdout=%r stderr=%r',
+ error, str(stdout).split('\n'), str(stderr).split('\n'))
+ if asserts:
+ raise RuntimeError(_('Failed to execute %r command: %s') % \
+ (cmd, error))
+ elif error_cb is not None:
+ error_cb(returncode, stdout, stderr)
+
+
+def rmtree(path, ignore_errors=True, **kwargs):
+ """Remove directory with all its content.
+
+ Function will check if owner has permissions for removing directories
+ (it makes sense for 0install implementaion caches).
+
+ :param path:
+ path to the directory to remove
+ :param ignore_errors:
+ ignore all errors while removing
+ :returns:
+ `None` on errors, otherwise `str` value of stdout
+
+ """
+ if isdir(path):
+
+ def fix_dir(path):
+ # 0install removes owner permissions
+ stat = os.stat(path).st_mode & 0777
+ if stat & 0700 != 0700:
+ os.chmod(path, stat | 0700)
+
+ fix_dir(path)
+ for root, dirs, __ in os.walk(path):
+ for i in dirs:
+ fix_dir(join(root, i))
+
+ shutil.rmtree(path, ignore_errors=ignore_errors, **kwargs)
+ elif lexists(path):
+ os.unlink(path)
+
+
+def cptree(src, dst):
+ """Efficient version of copying directories.
+
+ Function will try to make hard links for copying files at first and
+ will fallback to regular copying overwise.
+
+ :param src:
+ path to the source directory
+ :param dst:
+ path to the new directory
+
+ """
+ if abspath(src) == abspath(dst):
+ return
+
+ do_copy = []
+
+ def link(src, dst):
+ if not exists(dirname(dst)):
+ os.makedirs(dirname(dst))
+
+ if islink(src):
+ link_to = os.readlink(src)
+ os.symlink(link_to, dst)
+ elif isdir(src):
+ cptree(src, dst)
+ else:
+ if do_copy:
+ shutil.copy(src, dst)
+ else:
+ try:
+ os.link(src, dst)
+ except OSError:
+ do_copy.append(True)
+ shutil.copy(src, dst)
+ shutil.copystat(src, dst)
+
+ if isdir(src):
+ for root, __, files in os.walk(src):
+ dst_root = join(dst, root[len(src):].lstrip(os.sep))
+ if not exists(dst_root):
+ os.makedirs(dst_root)
+ for i in files:
+ link(join(root, i), join(dst_root, i))
+ else:
+ link(src, dst)
+
+
+def new_file(path, mode=None):
+ """Atomic new file creation.
+
+ Method will create temporaty file in the same directory as the specified
+ one. When file object associated with this temporaty file will be closed,
+ temporaty file will be renamed to the final destination.
+
+ :param path:
+ path to save final file to
+ :param mode:
+ mode for new file
+ :returns:
+ file object
+
+ """
+ tmp_path = TempFilePath(dir=dirname(path), prefix=basename(path))
+
+ result = _NewFile(tmp_path, 'w')
+ if mode:
+ os.fchmod(result.fileno(), mode)
+ result.tmp_path = tmp_path
+ result.dst_path = path
+
+ return result
+
+
+def get_frame(frame_no):
+ """Return Python call stack frame.
+
+ The reason to have this wrapper is that this stack information is a private
+ data and might depend on Python implementaion.
+
+ :param frame_no:
+ number of stack frame starting from caller's stack position
+ :returns:
+ frame object
+
+ """
+ # +1 since the calling `get_frame` adds one more frame
+ # pylint: disable-msg=W0212
+ return sys._getframe(frame_no + 1)
+
+
+def utcnow():
+ """Return local time in UTC.
+
+ Support testing workflow on multi processes level.
+
+ :returns:
+ `datetime.datetime.utcnow()` value
+
+ """
+ direct_time_path = '/tmp/.utcnow'
+ if exists(direct_time_path):
+ ts = os.stat(direct_time_path).st_mtime
+ return datetime.datetime.fromtimestamp(ts)
+ else:
+ return datetime.datetime.utcnow()
+
+
+def _set_utcnow(value):
+ direct_time_path = '/tmp/.utcnow'
+ file(direct_time_path, 'w').close()
+ os.utime(direct_time_path, (value, value))
+
+
+def _unset_utcnow():
+ direct_time_path = '/tmp/.utcnow'
+ if exists(direct_time_path):
+ os.unlink(direct_time_path)
+
+
+class Option(object):
+ """Configuration option.
+
+ `Option` object will be used as command-line argument and
+ configuration file option. All these objects will be automatically
+ collected from `sugar_server.env` module and from `etc` module from
+ all services.
+
+ """
+ #: Collected by `Option.seek()` options in original order.
+ unsorted_items = []
+ #: Collected by `Option.seek()` options by name.
+ items = {}
+ #: Collected by `Option.seek()` options by section.
+ sections = {}
+ _config = None
+
+ def __init__(self, description=None, default=None, short_option=None,
+ type_cast=None, type_repr=None, action=None):
+ """
+ :param description:
+ description string
+ :param default:
+ default value for the option
+ :param short_option:
+ value in for of `-<char>` to use as a short option for command-line
+ parser
+ :param type_cast:
+ function that will be uses to type cast to option type
+ while setting option value
+ :param type_repr:
+ function that will be uses to type cast from option type
+ while converting option value to string
+ :param action:
+ value for `action` argument of `OptionParser.add_option()`
+
+ """
+ if default is not None and type_cast is not None:
+ default = type_cast(default)
+ self._value = default
+ self.description = description
+ self.type_cast = type_cast
+ self.type_repr = type_repr
+ self.short_option = short_option or ''
+ self.action = action
+ self.section = None
+ self.name = None
+ self.attr_name = None
+
+ @property
+ def long_option(self):
+ """Long command-line argument name."""
+ return '--%s' % self.name
+
+ # pylint: disable-msg=E0202
+ @property
+ def value(self):
+ """Get option raw value."""
+ return self._value
+
+ # pylint: disable-msg=E1101, E0102, E0202
+ @value.setter
+ def value(self, x):
+ """Set option value.
+
+ The `Option.type_cast` function will be used for type casting specified
+ value to option.
+
+ """
+ if x is None:
+ self._value = None
+ elif self.type_cast is not None:
+ self._value = self.type_cast(x)
+ else:
+ self._value = str(x) or None
+
+ @staticmethod
+ def seek(section, mod=None):
+ """Collect `Option` objects.
+
+ Function will populate `Option.unsorted_items`, `Option.items` and
+ `Option.sections` values. Call this function before any usage
+ of `Option` objects.
+
+ :param section:
+ arbitrary name to group options per section
+ :param mod:
+ mdoule object to search for `Option` objects;
+ if omited, use caller's module
+
+ """
+ if mod is None:
+ mod_name = get_frame(1).f_globals['__name__']
+ mod = sys.modules[mod_name]
+
+ for name in sorted(dir(mod)):
+ attr = getattr(mod, name)
+ # Options might be from different `util` modules
+ if not (type(attr).__name__ == 'Option' and \
+ type(attr).__module__.split('.')[-1] == 'util'):
+ continue
+
+ attr.attr_name = name
+ attr.name = name.replace('_', '-')
+ attr.module = mod
+ attr.section = section
+
+ Option.unsorted_items.append(attr)
+ Option.items[attr.name] = attr
+ if section not in Option.sections:
+ Option.sections[section] = {}
+ Option.sections[section][attr.name] = attr
+
+ @staticmethod
+ def bind(parser, config_files=None, notice=None):
+ """Initilize option usage.
+
+ Call this function after invoking `Option.seek()`.
+
+ :param parser:
+ if not `None`, `OptionParser` object to export,
+ collected by `Option.seek` options, to
+ :param config_files:
+ list of paths to files that will be used to read default
+ option values; this value will initiate `Option.config` variable
+ :param notice:
+ optional notice to print with arguments' description
+
+ """
+ if config_files:
+ Option._config = Option()
+ Option._config.name = 'config'
+ Option._config.attr_name = 'config'
+ Option._config.description = \
+ _('colon separated list of paths to alternative ' \
+ 'configuration file(s)')
+ Option._config.short_option = '-c'
+ Option._config.type_cast = \
+ lambda x: [i for i in re.split('[\s:;,]+', x) if i]
+ Option._config.type_repr = \
+ lambda x: ':'.join(x)
+ Option._config.value = ':'.join(config_files)
+
+ for prop in [Option._config] + Option.items.values():
+ desc = prop.description
+ if prop.value is not None:
+ desc += ' [%s]' % prop
+ if notice:
+ desc += '; ' + notice
+ if parser is not None:
+ parser.add_option(prop.short_option, prop.long_option,
+ action=prop.action, help=desc)
+
+ @staticmethod
+ def merge(options, config_files=None):
+ """Combine default values with command-line arguments and config files.
+
+ Call this function after invoking `Option.bind()`.
+
+ :param options:
+ the first value from a tuple returned by
+ `OptionParser.parse_args()` function
+ :param config_files:
+ list of paths to files that will be used to read default
+ option values instead of reusing `--config` value
+
+ """
+ from ConfigParser import ConfigParser
+
+ if config_files is None:
+ if Option._config is None:
+ raise RuntimeError(_('Method Option.merge was not called or ' \
+ 'its config_files argument was None'))
+ config_files = Option._config.value
+
+ config = ConfigParser()
+ for i in config_files:
+ if exists(expanduser(i)):
+ config.read(expanduser(i))
+
+ for prop in Option.items.values():
+ if hasattr(options, prop.attr_name) and \
+ getattr(options, prop.attr_name) is not None:
+ prop.value = getattr(options, prop.attr_name)
+ elif config.has_option(prop.section, prop.name):
+ prop.value = config.get(prop.section, prop.name)
+
+ @staticmethod
+ def export():
+ """Current configuration in human readable form.
+
+ :returns:
+ list of lines
+
+ """
+ import textwrap
+
+ lines = []
+ sections = set()
+
+ for prop in Option.unsorted_items:
+ if prop.section not in sections:
+ if sections:
+ lines.append('')
+ lines.append('[%s]' % prop.section)
+ sections.add(prop.section)
+ lines.append('\n'.join(
+ ['# %s' % i for i in textwrap.wrap(prop.description, 78)]))
+ value = '\n\t'.join(str(prop).split('\n'))
+ lines.append('%s = %s' % (prop.name, value))
+
+ return lines
+
+ @staticmethod
+ def bool_cast(x):
+ if not x or str(x).strip().lower() in ['', 'false', 'none']:
+ return False
+ else:
+ return bool(x)
+
+ @staticmethod
+ def list_cast(x):
+ if isinstance(x, str) or isinstance(x, unicode):
+ return [i for i in x.strip().split(':') if i]
+ else:
+ return x
+
+ @staticmethod
+ def list_repr(x):
+ return ':'.join(x)
+
+ def __str__(self):
+ if self.value is None:
+ return ''
+ else:
+ if self.type_repr is None:
+ return str(self.value)
+ else:
+ return self.type_repr(self.value)
+
+ def __unicode__(self):
+ return self.__str__()
+
+
+class Command(object):
+ """Service command.
+
+ `Command` is a way to have custom sub-commands in services. All these
+ objects will be automatically collected from `etc` module
+ from all services.
+
+ """
+ #: Collected by `Command.seek()` commands by name.
+ items = {}
+ #: Collected by `Command.seek()` commands by section.
+ sections = {}
+
+ def __init__(self, description=None, cmd_format=None):
+ """
+ :param description:
+ command description
+ :param cmd_format:
+ part of description to explain additional command arguments
+
+ """
+ self.description = description or ''
+ self.cmd_format = cmd_format or ''
+ self.name = None
+ self.attr_name = None
+
+ @staticmethod
+ def seek(section, mod=None):
+ """Collect `Command` objects.
+
+ Function will populate `Command.items` and `Command.sections` values.
+ Call this function before any usage of `Command` objects.
+
+ :param section:
+ arbitrary name to group options per section
+ :param mod:
+ mdoule object to search for `Option` objects;
+ if omited, use caller's module
+
+ """
+ if mod is None:
+ mod_name = get_frame(1).f_globals['__name__']
+ mod = sys.modules[mod_name]
+
+ for name in sorted(dir(mod)):
+ attr = getattr(mod, name)
+ # Commands might be from different `util` modules
+ if not (type(attr).__name__ == 'Command' and \
+ type(attr).__module__.split('.')[-1] == 'util'):
+ continue
+
+ attr.name = name.replace('_', '-')
+ attr.attr_name = name
+ attr.module = mod
+ attr.section = section
+
+ Command.items[attr.name] = attr
+ if section not in Command.sections:
+ Command.sections[section] = {}
+ Command.sections[section][attr.name] = attr
+
+ @staticmethod
+ def call(mod, name, *args, **kwargs):
+ """Call the command.
+
+ Specfied module should contain a function with a name
+ `CMD_<command-name>()`. All additional `Command.call()` arguments
+ will be passed as-is to command implementaion function.
+
+ :param mod:
+ module to search for command implementaion
+ :param name:
+ command name
+ :returns:
+ what command implementaion returns
+
+ """
+ cmd = Command.items.get(name)
+ enforce(cmd is not None, _('No such command, %s'), name)
+
+ func_name = 'CMD_%s' % cmd.attr_name
+ if not hasattr(mod, func_name):
+ raise RuntimeError(_('No such command, %s, in module %s') % \
+ (name, mod.__name__))
+ getattr(mod, func_name)(*args, **kwargs)
+
+ def __str__(self):
+ return self.name
+
+ def __unicode__(self):
+ return self.__str__()
+
+
+class TempFilePath(unicode):
+ """Auto removed temporary file.
+
+ Right after creating `TempFilePath` object, temporaty file will be
+ created. On `TempFilePath` object deleting, this file will be removed.
+ The key difference with `tempfile.NamedTemporaryFile` is that
+ `TempFilePath` doesn't keep open file descriptor with removing file
+ right after closing it (though starting form Python 2.6,
+ `tempfile.NamedTemporaryFile` supports `delete` argument).
+
+ """
+
+ def __new__(cls, path=None, text=None, **kwargs):
+ """
+ Function supports the same arguments as `tempfile.mkstemp`.
+
+ :param path:
+ instead of generating temporary file name, exact `path` value
+ will be used
+ :param text:
+ content for newly created file
+
+ """
+ if path:
+ if not exists(dirname(path)):
+ os.makedirs(dirname(path))
+ fd = None
+ else:
+ if 'dir' in kwargs:
+ dir_value = kwargs['dir']
+ if not exists(dir_value):
+ os.makedirs(dir_value)
+ fd, path = tempfile.mkstemp(**kwargs)
+
+ _temp_file_paths.add(path)
+
+ if text is not None:
+ if fd is None:
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT)
+ os.write(fd, text)
+
+ if fd is not None:
+ os.close(fd)
+
+ return unicode.__new__(cls, path)
+
+ def __del__(self):
+ if _temp_file_paths and self in _temp_file_paths:
+ _temp_file_paths.remove(self)
+ if exists(self):
+ os.unlink(self)
+
+
+class TempDir(unicode):
+ """Auto removed temporary directory.
+
+ Right after creating `TempDir` object, temporaty directory will be
+ created. On `TempDir` object deleting, this directory will be removed.
+
+ """
+
+ def __new__(cls, path=None, **kwargs):
+ """
+ Function supports the same arguments as `tempfile.mkdtemp`.
+
+ :param path:
+ instead of generating temporary file name, exact `path` value
+ will be used
+
+ """
+ if path is not None:
+ if not exists(path):
+ os.makedirs(path)
+ else:
+ if 'dir' in kwargs:
+ dir_value = kwargs['dir']
+ if not exists(dir_value):
+ os.makedirs(dir_value)
+ path = tempfile.mkdtemp(**kwargs)
+
+ _temp_dirs.add(path)
+
+ return unicode.__new__(cls, path)
+
+ def __del__(self):
+ if _temp_dirs and self in _temp_dirs:
+ _temp_dirs.remove(self)
+ if exists(self):
+ rmtree(self)
+
+ def persist(self):
+ """Do not auto remove this directory."""
+ if self in _temp_dirs:
+ _temp_dirs.remove(self)
+
+
+class _NewFile(file):
+
+ dst_path = None
+ tmp_path = None
+
+ def close(self):
+ file.close(self)
+ if self.tmp_path is not None:
+ os.rename(self.tmp_path, self.dst_path)
+ self.tmp_path = None
+
+ def __del__(self):
+ self.tmp_path = None
+
+
+def _cleanup_temp_files():
+ for path in _temp_file_paths:
+ if exists(path):
+ os.unlink(path)
+
+ for path in _temp_dirs:
+ if exists(path):
+ rmtree(path)
+
+
+_temp_file_paths = set()
+_temp_dirs = set()
+atexit.register(_cleanup_temp_files)
+
+
+def _nb_read(stream):
+ if stream is None:
+ return ''
+ fd = stream.fileno()
+ orig_flags = fcntl.fcntl(fd, fcntl.F_GETFL)
+ try:
+ fcntl.fcntl(fd, fcntl.F_SETFL, orig_flags | os.O_NONBLOCK)
+ return stream.read()
+ except Exception:
+ return ''
+ finally:
+ fcntl.fcntl(fd, fcntl.F_SETFL, orig_flags)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..5ebf1e4
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,128 @@
+# sugar-lint: disable
+
+import os
+import sys
+import time
+import signal
+import shutil
+import logging
+import unittest
+from wsgiref.simple_server import make_server
+from os.path import dirname, join, exists, abspath
+
+import gobject
+import dbus.glib
+import dbus.mainloop.glib
+
+import active_document as ad
+import restful_document as rd
+
+from active_document import env as _env
+
+
+root = abspath(dirname(__file__))
+tmproot = join(root, '.tmp')
+tmpdir = None
+
+gobject.threads_init()
+dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
+
+
+def main():
+ shutil.rmtree(tmproot, ignore_errors=True)
+ unittest.main()
+
+
+class Test(unittest.TestCase):
+
+ httpd_pids = {}
+
+ def setUp(self):
+ self._overriden = []
+
+ global tmpdir
+ tmpdir = join(tmproot, '.'.join(self.id().split('.')[1:]))
+ shutil.rmtree(tmpdir, ignore_errors=True)
+ os.makedirs(tmpdir)
+ os.chdir(tmpdir)
+
+ logfile = tmpdir + '.log'
+ if exists(logfile):
+ os.unlink(logfile)
+
+ self._logfile = file(logfile + '.out', 'a')
+ sys.stdout = sys.stderr = self._logfile
+
+ for handler in logging.getLogger().handlers:
+ logging.getLogger().removeHandler(handler)
+ logging.basicConfig(level=logging.DEBUG, filename=logfile)
+
+ _env.data_root.value = tmpdir
+ _env.index_flush_timeout.value = 0
+ _env.index_flush_threshold.value = 1
+ _env.find_limit.value = 1024
+ _env.index_pool.value = 0
+ _env.index_write_queue.value = 0
+ _env.LAYOUT_VERSION = 1
+
+ ad.data_root.value = tmpdir + '/db'
+
+ def tearDown(self):
+ while Test.httpd_pids:
+ self.httpdown(Test.httpd_pids.keys()[0])
+ while self._overriden:
+ mod, name, old_handler = self._overriden.pop(0)
+ setattr(mod, name, old_handler)
+ sys.stdout.flush()
+
+ def override(self, mod, name, new_handler):
+ self._overriden.append((mod, name, getattr(mod, name)))
+ setattr(mod, name, new_handler)
+
+ def touch(self, *files):
+ for i in files:
+ if isinstance(i, str):
+ if i.endswith(os.sep):
+ i = i + '.stamp'
+ path = i
+ if exists(path):
+ content = file(path).read()
+ else:
+ content = i
+ else:
+ path, content = i
+ if isinstance(content, list):
+ content = '\n'.join(content)
+ path = join(tmpdir, path)
+
+ if not exists(dirname(path)):
+ os.makedirs(dirname(path))
+ if exists(path):
+ os.unlink(path)
+
+ f = file(path, 'w')
+ f.write(str(content))
+ f.close()
+
+ def httpd(self, port, classes):
+ if port in Test.httpd_pids:
+ self.httpdown(port)
+
+ child_pid = os.fork()
+ if child_pid:
+ time.sleep(0.25)
+ Test.httpd_pids[port] = child_pid
+ return
+
+ for handler in logging.getLogger().handlers:
+ logging.getLogger().removeHandler(handler)
+ logging.basicConfig(level=logging.DEBUG, filename=tmpdir + '.http.log')
+
+ httpd = make_server('', port, rd.Router(classes))
+ httpd.serve_forever()
+
+ def httpdown(self, port):
+ pid = Test.httpd_pids[port]
+ del Test.httpd_pids[port]
+ os.kill(pid, signal.SIGTERM)
+ os.waitpid(pid, 0)
diff --git a/tests/units/__init__.py b/tests/units/__init__.py
new file mode 100644
index 0000000..ad1971a
--- /dev/null
+++ b/tests/units/__init__.py
@@ -0,0 +1,9 @@
+# sugar-lint: disable
+
+import sys
+from os.path import dirname, join, abspath
+
+src_root = abspath(join(dirname(__file__), '..', '..'))
+sys.path.insert(0, src_root)
+
+import tests
diff --git a/tests/units/__main__.py b/tests/units/__main__.py
new file mode 100644
index 0000000..2524a13
--- /dev/null
+++ b/tests/units/__main__.py
@@ -0,0 +1,7 @@
+# sugar-lint: disable
+
+from __init__ import tests
+
+from document import *
+
+tests.main()
diff --git a/tests/units/document.py b/tests/units/document.py
new file mode 100755
index 0000000..12389d1
--- /dev/null
+++ b/tests/units/document.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python
+# sugar-lint: disable
+
+import json
+
+import restkit
+
+from __init__ import tests
+
+import active_document as ad
+import restful_document as rd
+
+
+class Resource(restkit.Resource):
+
+ def get(self, path=None, headers=None, **kwargs):
+ if headers is None:
+ headers = {}
+ if 'Content-Type' not in headers:
+ headers['Content-Type'] = 'application/json'
+ return restkit.Resource.get(self, path, headers=headers, **kwargs)
+
+ def put(self, path=None, headers=None, **kwargs):
+ if headers is None:
+ headers = {}
+ if 'Content-Type' not in headers:
+ headers['Content-Type'] = 'application/json'
+ return restkit.Resource.put(self, path, headers=headers, **kwargs)
+
+ def post(self, path=None, headers=None, **kwargs):
+ if headers is None:
+ headers = {}
+ if 'Content-Type' not in headers:
+ headers['Content-Type'] = 'application/json'
+ return restkit.Resource.post(self, path, headers=headers, **kwargs)
+
+ def delete(self, path=None, headers=None, **kwargs):
+ if headers is None:
+ headers = {}
+ if 'Content-Type' not in headers:
+ headers['Content-Type'] = 'application/json'
+ return restkit.Resource.delete(self, path, headers=headers, **kwargs)
+
+
+class DocumentTest(tests.Test):
+
+ def test_Walkthrough(self):
+
+ class Vote(ad.AggregatorProperty):
+
+ @property
+ def value(self):
+ return -1
+
+ class Document(rd.Document):
+
+ @ad.active_property(slot=1, prefix='A', full_text=True)
+ def term(self, value):
+ return value
+
+ @ad.active_property(ad.StoredProperty)
+ def stored(self, value):
+ return value
+
+ @ad.active_property(ad.BlobProperty)
+ def blob(self, value):
+ return value
+
+ @ad.active_property(ad.CounterProperty, slot=2)
+ def counter(self, value):
+ return value
+
+ @ad.active_property(Vote, counter='counter')
+ def vote(self, value):
+ return value
+
+ self.httpd(8000, [Document])
+ rest = Resource('http://localhost:8000')
+
+ guid_1 = json.loads(
+ rest.post('/document', payload=json.dumps({
+ 'term': 'term',
+ 'stored': 'stored',
+ })).body_string()
+ )['guid']
+
+ self.assertEqual(
+ {'stored': 'stored',
+ 'vote': '0',
+ 'term': 'term',
+ 'counter': '0',
+ 'guid': guid_1,
+ },
+ json.loads(
+ rest.get('/document/' + guid_1).body_string())
+ )
+
+ guid_2 = json.loads(
+ rest.post('/document', payload=json.dumps({
+ 'term': 'term2',
+ 'stored': 'stored2',
+ })).body_string()
+ )['guid']
+
+ self.assertEqual(
+ {'stored': 'stored2',
+ 'vote': '0',
+ 'term': 'term2',
+ 'counter': '0',
+ 'guid': guid_2,
+ },
+ json.loads(
+ rest.get('/document/' + guid_2).body_string())
+ )
+
+ self.assertEqual(
+ {'total': 2,
+ 'documents': sorted([
+ {'guid': guid_1, 'stored': 'stored', 'term': 'term', 'vote': '0', 'counter': '0'},
+ {'guid': guid_2, 'stored': 'stored2', 'term': 'term2', 'vote': '0', 'counter': '0'},
+ ])},
+ json.loads(rest.get('/document', reply='guid,stored,term,vote,counter').body_string()))
+
+ rest.put('/document/' + guid_2, payload=json.dumps({
+ 'vote': '1',
+ 'stored': 'stored3',
+ 'term': 'term3',
+ }))
+
+ self.assertEqual(
+ {'stored': 'stored3',
+ 'vote': '1',
+ 'term': 'term3',
+ 'counter': '1',
+ 'guid': guid_2,
+ },
+ json.loads(
+ rest.get('/document/' + guid_2).body_string())
+ )
+
+ self.assertEqual(
+ {'total': 2,
+ 'documents': sorted([
+ {'guid': guid_1, 'stored': 'stored', 'term': 'term', 'vote': '0', 'counter': '0'},
+ {'guid': guid_2, 'stored': 'stored3', 'term': 'term3', 'vote': '1', 'counter': '1'},
+ ])},
+ json.loads(rest.get('/document', reply='guid,stored,term,vote,counter').body_string()))
+
+ rest.delete('/document/' + guid_1)
+
+ self.assertEqual(
+ {'total': 1,
+ 'documents': sorted([
+ {'guid': guid_2, 'stored': 'stored3', 'term': 'term3', 'vote': '1', 'counter': '1'},
+ ])},
+ json.loads(rest.get('/document', reply='guid,stored,term,vote,counter').body_string()))
+
+ self.assertEqual(
+ 'term3',
+ json.loads(rest.get('/document/' + guid_2 + '/term').body_string()))
+ rest.put('/document/' + guid_2 + '/term', payload=json.dumps('term4'))
+ self.assertEqual(
+ 'term4',
+ json.loads(rest.get('/document/' + guid_2 + '/term').body_string()))
+
+ rest.put('/document/' + guid_2 + '/blob', payload='blob',
+ headers={'Content-Type': 'application/octet-stream'})
+ self.assertEqual(
+ 'blob',
+ rest.get('/document/' + guid_2 + '/blob').body_string())
+
+ rest.delete('/document/' + guid_2)
+
+ self.assertEqual(
+ {'total': 0,
+ 'documents': sorted([])},
+ json.loads(rest.get('/document', reply='guid,stored,term,vote,counter').body_string()))
+
+
+if __name__ == '__main__':
+ tests.main()