diff options
author | Aleksey Lim <alsroot@sugarlabs.org> | 2012-01-07 04:21:32 (GMT) |
---|---|---|
committer | Aleksey Lim <alsroot@sugarlabs.org> | 2012-01-07 04:21:32 (GMT) |
commit | 4aaba29494bc3336266317f18481124c5513f5c3 (patch) | |
tree | 840812b425fe946d881d30816662e7047a3e527c | |
parent | e52a3eeb759325a889bd0ee3afc3c9211c8c606d (diff) |
Initial implementation
-rw-r--r-- | restful_document/__init__.py | 18 | ||||
-rw-r--r-- | restful_document/document.py | 98 | ||||
-rw-r--r-- | restful_document/env.py | 63 | ||||
-rw-r--r-- | restful_document/metadata.py | 112 | ||||
-rw-r--r-- | restful_document/router.py | 90 | ||||
-rw-r--r-- | restful_document/util.py | 809 | ||||
-rw-r--r-- | tests/__init__.py | 128 | ||||
-rw-r--r-- | tests/units/__init__.py | 9 | ||||
-rw-r--r-- | tests/units/__main__.py | 7 | ||||
-rwxr-xr-x | tests/units/document.py | 181 |
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() |