trunk/MANIFEST.in000644 000000 000000 00000000307 14621323677 012140 0ustar00000000 000000 include README.wiki include setup.cfg include tracrpc/htdocs/*.css include tracrpc/htdocs/*.js include tracrpc/templates/*.html include tracrpc/locale/*.pot include tracrpc/locale/*/LC_MESSAGES/*.po trunk/README.wiki000644 000000 000000 00000006134 11367407612 012224 0ustar00000000 000000 = Trac RPC plugin = Remote Procedure Call interface for Trac. Protocols: * XML-RPC * JSON-RPC API support: * search * system * ticket * ticket.component * ticket.milestone * ticket.priority * ticket.resolution * ticket.severity * ticket.status * ticket.type * ticket.version * wiki == Installing and Using == See http://trac-hacks.org/wiki/XmlRpcPlugin for details on how to install, how get help, and how to report issues. == API Documentation == The API documentation is available at `/rpc` for projects that have the plugin installed and enabled. It can be accessed by all users that have been granted `XML_RPC` permission. == Development == The Trac RPC plugin uses pluggable interfaces to do all its work. That means it is easy to extend, and currently supports: * protocols; add a new protocol in addition to the builtin ones and read input and answer request in whatever form and format needed. * methods; adding new methods available for remote procedure calls that will work for any enabled protocol. See source for documentation. The source code can be obtained from: http://trac-hacks.org/svn/xmlrpcplugin/ For work on the plugin itself (for submitting patches and more), please verify patches by running unittests (requires Trac source code on path): {{{ python setup.py test }}} == Thanks == Thanks to all those that use the plugin, and contribute with error reports, and patches for bugs and enhancements. Special thanks to: * Matt Good * Steffen Pingel * Olemis Lang == License == {{{ Copyright (c) 2005-2008, Alec Thomas (alec@swapoff.org) Copyright (c) 2009, CodeResort.com/BV Network AS (simon-code@bvnetwork.no) All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder(s) nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. }}} trunk/messages.cfg000644 000000 000000 00000000510 14621300126 012646 0ustar00000000 000000 [ignore: **/tests/**] [python: **.py] [html: **/templates/**_jinja.html] variable_start_string = ${ variable_end_string = } line_statement_prefix = # line_comment_prefix = ## trim_blocks = yes lstrip_blocks = yes newstyle_gettext = yes silent = no [extractors] python = trac.dist:extract_python html = trac.dist:extract_html trunk/setup.cfg000644 000000 000000 00000001064 14621300126 012204 0ustar00000000 000000 [egg_info] tag_build = dev [aliases] release = sdist bdist_wheel bdist_egg [extract_messages] add_comments = TRANSLATOR: copyright_holder = msgid_bugs_address = output_file = tracrpc/locale/messages.pot keywords = _ gettext ngettext:1,2 N_ tag_ tagn_:1,2 cleandoc_ mapping_file = messages.cfg [init_catalog] input_file = tracrpc/locale/messages.pot output_dir = tracrpc/locale domain = tracrpc [compile_catalog] directory = tracrpc/locale domain = tracrpc [update_catalog] input_file = tracrpc/locale/messages.pot output_dir = tracrpc/locale domain = tracrpc trunk/setup.py000644 000000 000000 00000002221 14621323677 012111 0ustar00000000 000000 #!/usr/bin/env python """ License: BSD (c) 2005-2008 ::: Alec Thomas (alec@swapoff.org) (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ from setuptools import setup, find_packages extra = {} try: from trac.dist import get_l10n_cmdclass except ImportError: pass else: extra['cmdclass'] = get_l10n_cmdclass() or {} setup( name='TracXMLRPC', version='1.2.0', license='BSD', author='Alec Thomas', author_email='alec@swapoff.org', maintainer='Odd Simon Simonsen', maintainer_email='simon-code@bvnetwork.no', url='https://trac-hacks.org/wiki/XmlRpcPlugin', description='RPC interface to Trac', zip_safe=True, test_suite='tracrpc.tests.test_suite', packages=find_packages(exclude=['*.tests']), package_data={ 'tracrpc': ['templates/*.html', 'htdocs/*.js', 'htdocs/*.css', 'locale/*/LC_MESSAGES/*.mo'] }, classifiers=[ 'Framework :: Trac', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', ], entry_points={ 'trac.plugins': 'TracXMLRPC = tracrpc', }, **extra) trunk/tox.ini000644 000000 000000 00000000523 14524427170 011710 0ustar00000000 000000 [tox] envlist = py27-trac{012,10,12,14} py3{5,6,7,8,9,10,11,12}-trac16 [testenv] deps = Babel<2.10 trac012: Trac~=0.12.0 trac10: Trac~=1.0.0 trac12: Trac~=1.2.0 trac14: Trac~=1.4.0 trac16: Trac~=1.6.0 setenv = TMP = {envtmpdir} commands = {envpython} -Wdefault -m unittest tracrpc.tests.test_suite trunk/tracrpc/000755 000000 000000 00000000000 14667017475 012046 5ustar00000000 000000 trunk/tracrpc/__init__.py000644 000000 000000 00000001171 14402744352 014143 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2005-2008 ::: Alec Thomas (alec@swapoff.org) (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import pkg_resources pkg_resources.require('Trac>=0.12') __author__ = ['Alec Thomas ', 'Odd Simon Simonsen '] __license__ = 'BSD' __version__ = pkg_resources.get_distribution('TracXMLRPC').version from tracrpc.api import * from tracrpc.json_rpc import * from tracrpc.xml_rpc import * from tracrpc.web_ui import * from tracrpc.ticket import * from tracrpc.wiki import * from tracrpc.search import * trunk/tracrpc/api.py000644 000000 000000 00000033472 14630624614 013167 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2005-2008 ::: Alec Thomas (alec@swapoff.org) (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import inspect import re from datetime import datetime from trac.core import (Component, ExtensionPoint, Interface, TracError, implements) from trac.perm import IPermissionRequestor from . import __version__ from .util import basestring, getargspec, unicode, xmlrpclib __all__ = [ 'IRPCProtocol', 'IXMLRPCHandler', 'AbstractRPCHandler', 'Method', 'XMLRPCSystem', 'Binary', 'RPCError', 'MethodNotFound', 'ProtocolException', 'ServiceException', 'api_version', 'expose_rpc', ] class Binary(xmlrpclib.Binary): """ RPC Binary type. Currently == xmlrpclib.Binary. """ pass #---------------------------------------------------------------- # RPC Exception classes #---------------------------------------------------------------- class RPCError(TracError): """ Error class for general RPC-related errors. """ class MethodNotFound(RPCError): """ Error to raise when requested method is not found. """ class _CompositeRpcError(RPCError): def __init__(self, details, title=None, show_traceback=False): if isinstance(details, Exception): self._exc = details message = unicode(details) else: self._exc = None message = details RPCError.__init__(self, message, title, show_traceback) def _to_u(self): return u"%s details: %s" % (self.__class__.__name__, self.message) if unicode is str: __str__ = _to_u else: __unicode__ = _to_u __str__ = lambda self: unicode(self).encode('utf-8') class ProtocolException(_CompositeRpcError): """Protocol could not handle RPC request. Usually this means that the request has some sort of syntactic error, a library needed to parse the RPC call is not available, or similar errors.""" class ServiceException(_CompositeRpcError): """The called method threw an exception. Helpful to identify bugs ;o)""" RPC_TYPES = {int: 'int', bool: 'boolean', str: 'string', float: 'double', datetime: 'DateTime', Binary: 'Binary', list: 'array', dict: 'struct', None: 'int'} def _build_api_version(): match = re.match(r'([0-9]+)\.([0-9]+)\.([0-9]+)', __version__) return tuple(int(value) for value in match.groups()) api_version = _build_api_version() def expose_rpc(permission, return_type, *arg_types): """ Decorator for exposing a method as an RPC call with the given signature. """ def decorator(func): if not hasattr(func, '_xmlrpc_signatures'): func._xmlrpc_signatures = [] func._xml_rpc_permission = permission func._xmlrpc_signatures.append((return_type,) + tuple(arg_types)) return func return decorator class IRPCProtocol(Interface): def rpc_info(): """ Returns a tuple of (name, docs). Method provides general information about the protocol used for the RPC HTML view. name: Shortname like 'XML-RPC'. docs: Documentation for the protocol. """ def rpc_match(): """ Return an iterable of (path_item, content_type) combinations that will be handled by the protocol. path_item: Single word to use for matching against (/login)?/. Answer to 'rpc' only if possible. content_type: Starts-with check of 'Content-Type' request header. """ def parse_rpc_request(req, content_type): """ Parse RPC requests. req : HTTP request object content_type : Input MIME type Return a dictionary with the following keys set. All the other values included in this mapping will be ignored by the core RPC subsystem, will be protocol-specific, and SHOULD NOT be needed in order to invoke a given method. method (MANDATORY): target method name (e.g. 'ticket.get') params (OPTIONAL) : a tuple containing input positional arguments headers (OPTIONAL) : if the protocol supports custom headers set by the client, then this value SHOULD be a dictionary binding `header name` to `value`. However, protocol handlers as well as target RPC methods *MUST (SHOULD ?) NOT* rely on specific values assigned to a particular header in order to send a response back to the client. mimetype : request MIME-type. This value will be set by core RPC components after calling this method so, please, ignore If the request cannot be parsed this method *MUST* raise an instance of `ProtocolException` optionally wrapping another exception containing details about the failure. """ def send_rpc_result(req, result): """Serialize the result of the RPC call and send it back to the client. req : Request object. The same mapping returned by `parse_rpc_request` can be accessed through `req.rpc` (see above). result : The value returned by the target RPC method """ def send_rpc_error(req, rpcreq, e): """Send a fault message back to the caller. Exception type and message are used for this purpose. This method *SHOULD* handle `RPCError`, `PermissionError`, `ResourceNotFound` and their subclasses. This method is *ALWAYS* called from within an exception handler. req : Request object. The same mapping returned by `parse_rpc_request` can be accessed through `req.rpc` (see above). e : exception object describing the failure """ class IXMLRPCHandler(Interface): def xmlrpc_namespace(): """ Provide the namespace in which a set of methods lives. This can be overridden if the 'name' element is provided by xmlrpc_methods(). """ def xmlrpc_methods(): """ Return an iterator of (permission, signatures, callable[, name]), where callable is exposed via XML-RPC if the authenticated user has the appropriate permission. The callable itself can be a method or a normal method. The first argument passed will always be a request object. The XMLRPCSystem performs some extra magic to remove the "self" and "req" arguments when listing the available methods. Signatures is a list of XML-RPC introspection signatures for this method. Each signature is a tuple consisting of the return type followed by argument types. """ class AbstractRPCHandler(Component): implements(IXMLRPCHandler) abstract = True def _init_methods(self): self._rpc_methods = [] for name, val in inspect.getmembers(self): if hasattr(val, '_xmlrpc_signatures'): self._rpc_methods.append((val._xml_rpc_permission, val._xmlrpc_signatures, val, name)) def xmlrpc_methods(self): if not hasattr(self, '_rpc_methods'): self._init_methods() return self._rpc_methods class Method(object): """ Represents an XML-RPC exposed method. """ def __init__(self, provider, permission, signatures, callable, name = None): """ Accept a signature in the form returned by xmlrpc_methods. """ self.permission = permission self.callable = callable self.rpc_signatures = signatures self.description = inspect.getdoc(callable) if name is None: self.name = provider.xmlrpc_namespace() + '.' + callable.__name__ else: self.name = provider.xmlrpc_namespace() + '.' + name self.namespace = provider.xmlrpc_namespace() self.namespace_description = inspect.getdoc(provider) def __call__(self, req, args): if self.permission: req.perm.assert_permission(self.permission) result = self.callable(req, *args) # If result is null, return a zero if result is None: result = 0 elif isinstance(result, dict): pass elif not isinstance(result, basestring): # Try and convert result to a list try: result = [i for i in result] except TypeError: pass return (result,) def _get_signature(self): """ Return the signature of this method. """ if hasattr(self, '_signature'): return self._signature fullargspec = getargspec(self.callable) argspec = fullargspec[0] assert argspec[0:2] == ['self', 'req'] or argspec[0] == 'req', \ 'Invalid argspec %s for %s' % (argspec, self.name) while argspec and (argspec[0] in ('self', 'req')): argspec.pop(0) argspec.reverse() defaults = fullargspec[3] if not defaults: defaults = [] else: defaults = list(defaults) args = [] sig = [] for sigcand in self.xmlrpc_signatures(): if len(sig) < len(sigcand): sig = sigcand sig = list(sig) for arg in argspec: if defaults: value = defaults.pop() if type(value) is str: if '"' in value: value = "'%s'" % value else: value = '"%s"' % value arg += '=%s' % value args.insert(0, RPC_TYPES[sig.pop()] + ' ' + arg) self._signature = '%s %s(%s)' % (RPC_TYPES[sig.pop()], self.name, ', '.join(args)) return self._signature signature = property(_get_signature) def xmlrpc_signatures(self): """ Signature as an XML-RPC 'signature'. """ return self.rpc_signatures class XMLRPCSystem(Component): """ Core of the RPC system. """ implements(IPermissionRequestor, IXMLRPCHandler) method_handlers = ExtensionPoint(IXMLRPCHandler) def __init__(self): # systeminfo is removed in Trac 1.3.1 and ISystemInfoProvider # should generally be used instead, but in this case the plugin # version is shown in Installed Plugins on the /about page, so # it doesn't need to also be shown in System Information. try: self.env.systeminfo.append(('RPC', __version__)) except AttributeError: pass # IPermissionRequestor methods def get_permission_actions(self): yield 'XML_RPC' # IXMLRPCHandler methods def xmlrpc_namespace(self): return 'system' def xmlrpc_methods(self): yield ('XML_RPC', ((list, list),), self.multicall) yield ('XML_RPC', ((list,),), self.listMethods) yield ('XML_RPC', ((str, str),), self.methodHelp) yield ('XML_RPC', ((list, str),), self.methodSignature) yield ('XML_RPC', ((list,),), self.getAPIVersion) def get_method(self, method): """ Get an RPC signature by full name. """ for provider in self.method_handlers: for candidate in provider.xmlrpc_methods(): # self.env.log.debug(candidate) p = Method(provider, *candidate) if p.name == method: return p raise MethodNotFound('RPC method "%s" not found' % method) # Exported methods def all_methods(self, req): """ List all methods exposed via RPC. Returns a list of Method objects. """ for provider in self.method_handlers: for candidate in provider.xmlrpc_methods(): # Expand all fields of method description yield Method(provider, *candidate) def multicall(self, req, signatures): """ Takes an array of RPC calls encoded as structs of the form (in a Pythonish notation here): `{'methodName': string, 'params': array}`. For JSON-RPC multicall, signatures is an array of regular method call structs, and result is an array of return structures. """ for signature in signatures: try: yield self.get_method(signature['methodName'])(req, signature['params']) except Exception as e: yield e def listMethods(self, req): """ This method returns a list of strings, one for each (non-system) method supported by the RPC server. """ for method in self.all_methods(req): yield method.name def methodHelp(self, req, method): """ This method takes one parameter, the name of a method implemented by the RPC server. It returns a documentation string describing the use of that method. If no such string is available, an empty string is returned. The documentation string may contain HTML markup. """ p = self.get_method(method) return '\n'.join((p.signature, '', p.description)) def methodSignature(self, req, method): """ This method takes one parameter, the name of a method implemented by the RPC server. It returns an array of possible signatures for this method. A signature is an array of types. The first of these types is the return type of the method, the rest are parameters. """ p = self.get_method(method) return [','.join([RPC_TYPES[x] for x in sig]) for sig in p.xmlrpc_signatures()] def getAPIVersion(self, req): """ Returns a list with three elements. First element is the epoch (0=Trac 0.10, 1=Trac 0.11 or higher). Second element is the major version number, third is the minor. Changes to the major version indicate API breaking changes, while minor version changes are simple additions, bug fixes, etc. """ return api_version trunk/tracrpc/htdocs/000755 000000 000000 00000000000 14034101442 013302 5ustar00000000 000000 trunk/tracrpc/htdocs/rpc.css000644 000000 000000 00000000311 11367407612 014611 0ustar00000000 000000 #rpc-toc { display: none; float: right; width: 15em; } #rpc-toc.wiki-toc h4 { text-align: center; } #rpc-toc ul { margin: 0; padding: 0.3em 0.15em; list-style-type: none; } trunk/tracrpc/htdocs/rpc.js000644 000000 000000 00000002077 14034101442 014432 0ustar00000000 000000 (function($) { $(document).ready(function () { // Create a Table of Contents (TOC) $('#wikipage') .prepend('

Contents

    '); function toc_entry(_this, item) { return $('
  • ' + _this.id.replace(/^rpc\./, '') + '
  • '); } var ul = $('#rpc-toc ul'); $("#content").find("*[id]").each(function(index, item) { var elem = undefined; if (this.tagName == 'H2') { elem = toc_entry(this, item); elem.css('padding-top', '0.5em'); } if (this.tagName == 'H3') { elem = toc_entry(this, item); elem.css('padding-left', '1.2em'); } ul.append(elem); }); $('#rpc-toc').toggle(); // Add anchors to headings $("#content").find("h2,h3").addAnchor("Link here"); }); })(jQuery); trunk/tracrpc/json_rpc.py000644 000000 000000 00000027646 14621300126 014226 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import base64 import datetime import json import re import sys try: import babel except ImportError: babel = None from trac.core import Component, implements from trac.perm import PermissionError from trac.resource import ResourceNotFound from trac.util.datefmt import FixedOffset, utc from trac.util.html import Fragment, Markup from trac.util.text import empty, exception_to_unicode, to_unicode from trac.web.api import HTTPBadRequest, RequestDone from .api import IRPCProtocol, Binary, MethodNotFound, ProtocolException from .util import cleandoc_, gettext, iteritems, unicode, izip __all__ = ['JsonRpcProtocol'] class TracRpcJSONEncoder(json.JSONEncoder): """ Extending the JSON encoder to support some additional types: 1. datetime.datetime => {'__jsonclass__': ["datetime", ""]} 2. tracrpc.api.Binary => {'__jsonclass__': ["binary", ""]} 3. empty => '' 4. Fragment|Markup => unicode 5. babel.support.LazyProxy => unicode """ def default(self, obj): if isinstance(obj, datetime.datetime): # http://www.ietf.org/rfc/rfc3339.txt if obj.tzinfo is None: obj = obj.replace(tzinfo=utc) elif obj.tzinfo is not utc: obj = obj.astimezone(utc) value = obj.strftime('%Y-%m-%dT%H:%M:%S') if obj.microsecond != 0: value += '.%06d' % obj.microsecond return {'__jsonclass__': ["datetime", value]} if isinstance(obj, Binary): encoded = base64.b64encode(obj.data) if not isinstance(encoded, str): encoded = unicode(encoded, 'ascii') return {'__jsonclass__': ["binary", encoded]} if obj is empty: return '' if isinstance(obj, (Fragment, Markup)): return unicode(obj) if babel and isinstance(obj, babel.support.LazyProxy): return unicode(obj) return super(TracRpcJSONEncoder, self).default(obj) class TracRpcJSONDecoder(json.JSONDecoder): """ Extending the JSON decoder to support some additional types: 1. {'__jsonclass__': ["datetime", ""]} => datetime.datetime 2. {'__jsonclass__': ["binary", ""]} => tracrpc.api.Binary """ _datetime_re = re.compile(r""" \A ([0-9]{4})-([0-9]{2})-([0-9]{2}) (?: [Tt_ ] ([0-9]{2}):([0-9]{2}):([0-9]{2}) (?:\.([0-9]{1,}))? ([Zz]|[-+][0-9]{2}:[0-5][0-9])? )? \Z """, re.VERBOSE) @classmethod def _parse_datetime(cls, val): match = cls._datetime_re.match(val) if not match: raise Exception("Invalid datetime string (%s)" % val) def convert(idx, arg): if idx < 3: return int(arg) if idx < 6: return int(arg) if arg else 0 if idx == 6: return int((arg + '00000')[:6]) if arg else 0 if idx == 7: if arg in (None, 'Z', 'z'): return utc hour = int(arg[1:3]) minute = int(arg[4:6]) offset = hour * 60 + minute if offset == 0: return utc if arg.startswith('-'): offset = -offset name = '%s%d:%02d' % (arg[:1], hour, minute) return FixedOffset(offset, name) args = [convert(idx, arg) for idx, arg in enumerate(match.groups())] try: return datetime.datetime(*args) except: raise Exception("Invalid datetime string (%s)" % val) @classmethod def _parse_binary(cls, val): try: data = base64.b64decode(val) except: raise Exception("Invalid base64 string") else: return Binary(data) def _normalize(self, obj): """ Helper to traverse JSON decoded object for custom types. """ normalize = self._normalize if isinstance(obj, tuple): return tuple(normalize(item) for item in obj) if isinstance(obj, list): return [normalize(item) for item in obj] if isinstance(obj, unicode): return obj if isinstance(obj, bytes): return to_unicode(obj) if isinstance(obj, dict): if len(obj) != 1 or tuple(obj) != ('__jsonclass__',): return dict(normalize(item) for item in iteritems(obj)) kind, val = obj['__jsonclass__'] if kind == 'datetime': return self._parse_datetime(val) if kind == 'binary': return self._parse_binary(val) raise Exception("Unknown __jsonclass__: %s" % kind) return obj def decode(self, obj, *args, **kwargs): obj = super(TracRpcJSONDecoder, self).decode(obj, *args, **kwargs) return self._normalize(obj) class JsonProtocolException(ProtocolException): """Impossible to handle JSON-RPC request.""" def __init__(self, details, code=-32603, title=None, show_traceback=False): ProtocolException.__init__(self, details, title, show_traceback) self.code = code class JsonRpcProtocol(Component): _descritpion = cleandoc_(r""" Example `POST` request using `curl` with `Content-Type` header and body: {{{ user: ~ > cat body.json {"params": ["WikiStart"], "method": "wiki.getPage", "id": 123} user: ~ > curl -H "Content-Type: application/json" --data @body.json %(url_anon)s {"id": 123, "error": null, "result": "= Welcome to.... }}} Implementation details: * JSON-RPC has no formalized type system, so a class-hint system is used for input and output of non-standard types: * `{"__jsonclass__": ["datetime", "YYYY-MM-DDTHH:MM:SS"]} => DateTime (UTC)` * `{"__jsonclass__": ["binary", ""]} => Binary` * `"id"` is optional, and any marker value received with a request is returned with the response. """) implements(IRPCProtocol) # IRPCProtocol methods def rpc_info(self): return 'JSON-RPC', gettext(self._descritpion) def rpc_match(self): yield 'rpc', 'application/json' # Legacy path - provided for backwards compatibility: yield 'jsonrpc', 'application/json' def parse_rpc_request(self, req, content_type): """ Parse JSON-RPC requests""" try: data = json_load(req) except Exception as e: self.log.warning("RPC(json) decode error: %s", exception_to_unicode(e)) if sys.version_info[0] == 2: message = to_unicode(e) else: message = 'No JSON object could be decoded (%s)' % e raise JsonProtocolException(message, -32700) if not isinstance(data, dict): self.log.warning("RPC(json) decode error (not a dict)") raise JsonProtocolException('JSON object is not a dict', -32700) try: self.log.info("RPC(json) JSON-RPC request ID : %s.", data.get('id')) if data.get('method') == 'system.multicall': # Prepare for multicall self.log.debug("RPC(json) Multicall request %s", data) params = data.get('params', []) for signature in params: signature['methodName'] = signature.get('method', '') data['params'] = [params] return data except Exception as e: # Abort with exception - no data can be read self.log.warning("RPC(json) decode error: %s", exception_to_unicode(e)) raise JsonProtocolException(e, -32700) def send_rpc_result(self, req, result): """Send JSON-RPC response back to the caller.""" rpcreq = req.rpc r_id = rpcreq.get('id') try: if rpcreq.get('method') == 'system.multicall': # Custom multicall args = (rpcreq.get('params') or [[]])[0] mcresults = [self._json_result( isinstance(value, Exception) and \ value or value[0], \ sig.get('id') or r_id) \ for sig, value in izip(args, result)] response = self._json_result(mcresults, r_id) else: response = self._json_result(result, r_id) self.log.debug("RPC(json) result: %r", response) try: # JSON encoding response = json.dumps(response, cls=TracRpcJSONEncoder) except Exception as e: self.log.warning("RPC(json) dumps error: %s", exception_to_unicode(e)) response = json.dumps(self._json_error(e, r_id=r_id), cls=TracRpcJSONEncoder) except Exception as e: self.log.error("RPC(json) error%s", exception_to_unicode(e, traceback=True)) response = json.dumps(self._json_error(e, r_id=r_id), cls=TracRpcJSONEncoder) self._send_response(req, response + '\n', rpcreq['mimetype']) def send_rpc_error(self, req, e): """Send a JSON-RPC fault message back to the caller. """ rpcreq = req.rpc r_id = rpcreq.get('id') response = json.dumps(self._json_error(e, r_id=r_id), \ cls=TracRpcJSONEncoder) self._send_response(req, response + '\n', rpcreq['mimetype']) # Internal methods def _send_response(self, req, response, content_type='application/json'): self.log.debug("RPC(json) encoded response: %s", response) response = to_unicode(response).encode("utf-8") req.send_response(200) req.send_header('Content-Type', content_type) req.send_header('Content-Length', len(response)) req.end_headers() req.write(response) raise RequestDone() def _json_result(self, result, r_id=None): """ Create JSON-RPC response dictionary. """ if not isinstance(result, Exception): return {'result': result, 'error': None, 'id': r_id} else: return self._json_error(result, r_id=r_id) def _json_error(self, e, c=None, r_id=None): """ Makes a response dictionary that is an error. """ if isinstance(e, MethodNotFound): c = -32601 elif isinstance(e, PermissionError): c = 403 elif isinstance(e, ResourceNotFound): c = 404 else: c = c or hasattr(e, 'code') and e.code or -32603 return {'result': None, 'id': r_id, 'error': { 'name': hasattr(e, 'name') and e.name or 'JSONRPCError', 'code': c, 'message': to_unicode(e)}} class RequestReader(object): req = None remaining = None def __init__(self, req): length = req.get_header('Content-Length') if length is None: raise HTTPBadRequest('Missing Content-Length') try: length = int(length) except: raise HTTPBadRequest('Invalid Content-Length %r' % length) self.req = req self.remaining = length def read(self, n=-1): if self.remaining <= 0: return b'' if n == -1: n = self.remaining data = self.req.read(min(n, self.remaining)) if data: self.remaining -= len(data) return data if sys.version_info[:2] != (3, 5): def json_load(req): return json.load(RequestReader(req), cls=TracRpcJSONDecoder) else: import codecs def json_load(req): reader = codecs.getreader('utf-8')(RequestReader(req)) return json.load(reader, cls=TracRpcJSONDecoder) trunk/tracrpc/locale/000755 000000 000000 00000000000 14621316055 013267 5ustar00000000 000000 trunk/tracrpc/locale/ja/000755 000000 000000 00000000000 14621316055 013661 5ustar00000000 000000 trunk/tracrpc/locale/ja/LC_MESSAGES/000755 000000 000000 00000000000 14621316055 015446 5ustar00000000 000000 trunk/tracrpc/locale/ja/LC_MESSAGES/tracrpc.po000644 000000 000000 00000015256 14621316055 017455 0ustar00000000 000000 # Japanese translations for TracXMLRPC. # Copyright (C) 2024 ORGANIZATION # This file is distributed under the same license as the TracXMLRPC project. # FIRST AUTHOR , 2024. # msgid "" msgstr "" "Project-Id-Version: TracXMLRPC 1.2.0\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "POT-Creation-Date: 2024-05-16 11:48+0900\n" "PO-Revision-Date: 2024-05-16 09:26+0900\n" "Last-Translator: FULL NAME \n" "Language: ja\n" "Language-Team: ja \n" "Plural-Forms: nplurals=1; plural=0;\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.15.0\n" #: tracrpc/json_rpc.py:163 #, python-format msgid "" "Example `POST` request using `curl` with `Content-Type` header\n" "and body:\n" "\n" "{{{\n" "user: ~ > cat body.json\n" "{\"params\": [\"WikiStart\"], \"method\": \"wiki.getPage\", \"id\": 123}\n" "user: ~ > curl -H \"Content-Type: application/json\" --data @body.json " "%(url_anon)s\n" "{\"id\": 123, \"error\": null, \"result\": \"= Welcome to....\n" "}}}\n" "\n" "Implementation details:\n" "\n" " * JSON-RPC has no formalized type system, so a class-hint system is " "used\n" " for input and output of non-standard types:\n" " * `{\"__jsonclass__\": [\"datetime\", \"YYYY-MM-DDTHH:MM:SS\"]} => " "DateTime (UTC)`\n" " * `{\"__jsonclass__\": [\"binary\", \"\"]} => Binary`" "\n" " * `\"id\"` is optional, and any marker value received with a\n" " request is returned with the response." msgstr "" "`curl` を使って `Content-Type` ヘッダとデータを `POST` リクエストする例です。\n" "\n" "{{{\n" "user: ~ > cat body.json\n" "{\"params\": [\"WikiStart\"], \"method\": \"wiki.getPage\", \"id\": 123}\n" "user: ~ > curl -H \"Content-Type: application/json\" --data @body.json " "%(url_anon)s\n" "{\"id\": 123, \"error\": null, \"result\": \"= Welcome to....\n" "}}}\n" "\n" "実装の詳細:\n" "\n" " * JSON-RPC には正式な型システムがないため、標準でない型の入出力にクラスヒントを使います。\n" " * `{\"__jsonclass__\": [\"datetime\", \"YYYY-MM-DDTHH:MM:SS\"]} => " "DateTime (UTC)`\n" " * `{\"__jsonclass__\": [\"binary\", \"\"]} => Binary`" "\n" " * `\"id\"` は省略可能です。リクエストで渡した値が目印としてレスポンスに戻ってきます。" #: tracrpc/web_ui.py:244 msgid "API" msgstr "API" #: tracrpc/xml_rpc.py:67 #, python-format msgid "" "There should be XML-RPC client implementations available for all\n" "popular programming languages.\n" "Example call using `curl`:\n" "\n" "{{{\n" "user: ~ > cat body.xml\n" "\n" "\n" "wiki.getPage\n" "\n" "WikiStart\n" "\n" "\n" "\n" "user: ~ > curl -H \"Content-Type: application/xml\" --data @body.xml " "%(url_anon)s\n" "\n" "\n" "\n" "\n" "= Welcome to....\n" "}}}\n" "\n" "The following snippet illustrates how to perform authenticated calls in " "Python.\n" "\n" "{{{\n" ">>> try:\n" "... from xmlrpc import client as cli\n" "... except ImportError:\n" "... import xmlrpclib as cli\n" "...\n" ">>> p = cli.ServerProxy(%(url_auth)r)\n" ">>> p.system.getAPIVersion()\n" "%(version)r\n" "}}}" msgstr "" "大抵のプログラム言語には XML-RPC クライアント実装があるはずです。\n" "`curl` を使った呼び出し例:\n" "\n" "{{{\n" "user: ~ > cat body.xml\n" "\n" "\n" "wiki.getPage\n" "\n" "WikiStart\n" "\n" "\n" "\n" "user: ~ > curl -H \"Content-Type: application/xml\" --data @body.xml " "%(url_anon)s\n" "\n" "\n" "\n" "\n" "= Welcome to....\n" "}}}\n" "\n" "以下のコードは、Python による認証ありでの呼び出し方法です。\n" "\n" "{{{\n" ">>> try:\n" "... from xmlrpc import client as cli\n" "... except ImportError:\n" "... import xmlrpclib as cli\n" "...\n" ">>> p = cli.ServerProxy(%(url_auth)r)\n" ">>> p.system.getAPIVersion()\n" "%(version)r\n" "}}}" #: tracrpc/templates/rpc_jinja.html:4 msgid "Remote Procedure Call (RPC)" msgstr "リモートプロシージャーコール (RPC)" #: tracrpc/templates/rpc_jinja.html:15 msgid "Installed API version:" msgstr "インストールされている API のバージョン:" #: tracrpc/templates/rpc_jinja.html:16 msgid "Protocol reference:" msgstr "プロトコルリファレンス:" #: tracrpc/templates/rpc_jinja.html:18 msgid "" "Below you will find a detailed description of all the RPC protocols " "installed in this environment. This includes supported content types as " "well as target URLs for anonymous and authenticated access. Use this " "information to interact with this environment from a remote location." msgstr "" "下記は、この環境でインストールされている RPC プロトコルの詳細です。匿名ユーザとログインユーザ向けにサポートしているコンテンツタイプと URL" " があります。この情報を用いて、リモートからこの環境を操作してください。" #: tracrpc/templates/rpc_jinja.html:25 msgid "" "Libraries for remote procedure calls and parsing exists for most major " "languages and platforms - use a tested, standard library for consistent " "results." msgstr "リモートプロシージャコールのライブラリが多くの主要なプログラム言語やプラットホームで存在しています。正しい結果を得るためにテストされている標準ライブラリを使用してください。" #: tracrpc/templates/rpc_jinja.html:34 #, python-format msgid "For %(name)s protocol, use any one of:" msgstr "%(name)s プロトコルの場合、以下の何れかを使います:" #: tracrpc/templates/rpc_jinja.html:39 #, python-format msgid "%(header)s header with request to:" msgstr "%(header)s ヘッダを付けてリクエスト:" #: tracrpc/templates/rpc_jinja.html:43 msgid "For anonymous access:" msgstr "匿名ユーザの場合:" #: tracrpc/templates/rpc_jinja.html:53 msgid "For authenticated access:" msgstr "ログインユーザの場合:" #: tracrpc/templates/rpc_jinja.html:74 msgid "RPC exported functions" msgstr "RPC で使用できる関数" #: tracrpc/templates/rpc_jinja.html:87 msgid "Function" msgstr "関数" #: tracrpc/templates/rpc_jinja.html:88 msgid "Description" msgstr "説明" #: tracrpc/templates/rpc_jinja.html:89 msgid "Permission required" msgstr "必要な権限" #: tracrpc/templates/rpc_jinja.html:97 msgid "By resource" msgstr "リソースによる" trunk/tracrpc/locale/messages.pot000644 000000 000000 00000007744 14621316055 015636 0ustar00000000 000000 # Translations template for TracXMLRPC. # Copyright (C) 2024 ORGANIZATION # This file is distributed under the same license as the TracXMLRPC project. # FIRST AUTHOR , 2024. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: TracXMLRPC 1.2.0\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "POT-Creation-Date: 2024-05-16 11:48+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.15.0\n" #: tracrpc/json_rpc.py:163 #, python-format msgid "" "Example `POST` request using `curl` with `Content-Type` header\n" "and body:\n" "\n" "{{{\n" "user: ~ > cat body.json\n" "{\"params\": [\"WikiStart\"], \"method\": \"wiki.getPage\", \"id\": 123}\n" "user: ~ > curl -H \"Content-Type: application/json\" --data @body.json " "%(url_anon)s\n" "{\"id\": 123, \"error\": null, \"result\": \"= Welcome to....\n" "}}}\n" "\n" "Implementation details:\n" "\n" " * JSON-RPC has no formalized type system, so a class-hint system is " "used\n" " for input and output of non-standard types:\n" " * `{\"__jsonclass__\": [\"datetime\", \"YYYY-MM-DDTHH:MM:SS\"]} => " "DateTime (UTC)`\n" " * `{\"__jsonclass__\": [\"binary\", \"\"]} => Binary`" "\n" " * `\"id\"` is optional, and any marker value received with a\n" " request is returned with the response." msgstr "" #: tracrpc/web_ui.py:244 msgid "API" msgstr "" #: tracrpc/xml_rpc.py:67 #, python-format msgid "" "There should be XML-RPC client implementations available for all\n" "popular programming languages.\n" "Example call using `curl`:\n" "\n" "{{{\n" "user: ~ > cat body.xml\n" "\n" "\n" "wiki.getPage\n" "\n" "WikiStart\n" "\n" "\n" "\n" "user: ~ > curl -H \"Content-Type: application/xml\" --data @body.xml " "%(url_anon)s\n" "\n" "\n" "\n" "\n" "= Welcome to....\n" "}}}\n" "\n" "The following snippet illustrates how to perform authenticated calls in " "Python.\n" "\n" "{{{\n" ">>> try:\n" "... from xmlrpc import client as cli\n" "... except ImportError:\n" "... import xmlrpclib as cli\n" "...\n" ">>> p = cli.ServerProxy(%(url_auth)r)\n" ">>> p.system.getAPIVersion()\n" "%(version)r\n" "}}}" msgstr "" #: tracrpc/templates/rpc_jinja.html:4 msgid "Remote Procedure Call (RPC)" msgstr "" #: tracrpc/templates/rpc_jinja.html:15 msgid "Installed API version:" msgstr "" #: tracrpc/templates/rpc_jinja.html:16 msgid "Protocol reference:" msgstr "" #: tracrpc/templates/rpc_jinja.html:18 msgid "" "Below you will find a detailed description of all the RPC protocols " "installed in this environment. This includes supported content types as " "well as target URLs for anonymous and authenticated access. Use this " "information to interact with this environment from a remote location." msgstr "" #: tracrpc/templates/rpc_jinja.html:25 msgid "" "Libraries for remote procedure calls and parsing exists for most major " "languages and platforms - use a tested, standard library for consistent " "results." msgstr "" #: tracrpc/templates/rpc_jinja.html:34 #, python-format msgid "For %(name)s protocol, use any one of:" msgstr "" #: tracrpc/templates/rpc_jinja.html:39 #, python-format msgid "%(header)s header with request to:" msgstr "" #: tracrpc/templates/rpc_jinja.html:43 msgid "For anonymous access:" msgstr "" #: tracrpc/templates/rpc_jinja.html:53 msgid "For authenticated access:" msgstr "" #: tracrpc/templates/rpc_jinja.html:74 msgid "RPC exported functions" msgstr "" #: tracrpc/templates/rpc_jinja.html:87 msgid "Function" msgstr "" #: tracrpc/templates/rpc_jinja.html:88 msgid "Description" msgstr "" #: tracrpc/templates/rpc_jinja.html:89 msgid "Permission required" msgstr "" #: tracrpc/templates/rpc_jinja.html:97 msgid "By resource" msgstr "" trunk/tracrpc/search.py000644 000000 000000 00000004240 14036331031 013636 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2005-2008 ::: Alec Thomas (alec@swapoff.org) (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ from trac.core import Component, ExtensionPoint, implements from trac.search.api import ISearchSource from trac.search.web_ui import SearchModule from .api import IXMLRPCHandler __all__ = ['SearchRPC'] class SearchRPC(Component): """ Search Trac. """ implements(IXMLRPCHandler) search_sources = ExtensionPoint(ISearchSource) # IXMLRPCHandler methods def xmlrpc_namespace(self): return 'search' def xmlrpc_methods(self): yield ('SEARCH_VIEW', ((list,),), self.getSearchFilters) yield ('SEARCH_VIEW', ((list, str), (list, str, list)), self.performSearch) # Others def getSearchFilters(self, req): """ Retrieve a list of search filters with each element in the form (name, description). """ for source in self.search_sources: for filter in source.get_search_filters(req): yield filter def performSearch(self, req, query, filters=None): """ Perform a search using the given filters. Defaults to all if not provided. Results are returned as a list of tuples in the form (href, title, date, author, excerpt).""" query = SearchModule(self.env)._get_search_terms(query) filters_provided = filters is not None chosen_filters = set(filters or []) available_filters = [] for source in self.search_sources: available_filters += source.get_search_filters(req) filters = [f[0] for f in available_filters if f[0] in chosen_filters] if not filters: if filters_provided: return [] filters = [f[0] for f in available_filters] self.env.log.debug("Searching with %s", filters) results = [] for source in self.search_sources: for result in source.get_search_results(req, query, filters) or []: results.append(['/'.join(req.base_url.split('/')[0:3]) + result[0]] + list(result[1:])) return results trunk/tracrpc/templates/000755 000000 000000 00000000000 14621300126 014016 5ustar00000000 000000 trunk/tracrpc/templates/rpc.html000644 000000 000000 00000007715 14621300126 015502 0ustar00000000 000000 Remote Procedure Call (RPC)

    Remote Procedure Call (RPC)

    Installed API version: ${rpc.version}

    Protocol reference:

    ${ dgettext( domain, "Below you will find a detailed description of all the RPC " "protocols installed in this environment. This includes supported " "content types as well as target URLs for anonymous and " "authenticated access. Use this information to interact with this " "environment from a remote location.") }

    ${ dgettext( domain, "Libraries for remote procedure calls and parsing exists for most " "major languages and platforms - use a tested, standard library " "for consistent results.") }

    ${name}

    For ${name} protocol, use any one of:

    ${wiki_to_html(context, description)}

    RPC exported functions

    ${ns.namespace} - ${ns.description}

    Function Description Permission required
    ${signature} ${description} ${permission or dgettext(domain, "By resource")}
    trunk/tracrpc/templates/rpc_jinja.html000644 000000 000000 00000007656 14621300126 016661 0ustar00000000 000000 # extends 'layout.html' # set _ = partial(dgettext, domain) # set tag_ = partial(dtgettext, domain) # set title = _("Remote Procedure Call (RPC)") # block title ${title} ${ super() } # endblock title # block content

    ${title}

    ${_("Installed API version:")} ${rpc.version}

    ${_("Protocol reference:")}

    ${ _("Below you will find a detailed description of all the RPC " "protocols installed in this environment. This includes supported " "content types as well as target URLs for anonymous and " "authenticated access. Use this information to interact with this " "environment from a remote location.") }

    ${ _("Libraries for remote procedure calls and parsing exists for most " "major languages and platforms - use a tested, standard library " "for consistent results.") }

    # for name, description, paths in rpc.protocols:

    ${name}

    ${_("For %(name)s protocol, use any one of:", name=name)}

      # for ct, ct_group in paths|groupby(1): # with ct_group = ct_group|list
    • ${tag_("%(header)s header with request to:", header=tag.tt('Content-Type: ', ct))}
      • ${_("For anonymous access:")}
          # for h, mimetype in ct_group: # with url = req.abs_href(h)
        • ${url}
        • # endwith # endfor
      • ${_("For authenticated access:")}
          # for h, mimetype in ct_group: # with url = req.abs_href.login(h)
        • ${url}
        • # endwith # endfor
    • # endwith # endfor
    ${wiki_to_html(context, description)}
    # endfor

    ${_("RPC exported functions")}

    # for key in rpc.functions|sort:
    # set ns = rpc.functions[key]

    ${ns.namespace} - ${ns.description}

    # for signature, description, permission in ns.methods: # endfor
    ${_("Function")} ${_("Description")} ${_("Permission required")}
    ${signature} ${description} ${permission or _("By resource")}
    # endfor
    ${ super() } # endblock content trunk/tracrpc/tests/000755 000000 000000 00000000000 14667017475 013210 5ustar00000000 000000 trunk/tracrpc/tests/__init__.py000644 000000 000000 00000026440 14403467513 015315 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2009-2013 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ __all__ = () import base64 import contextlib import os import socket import subprocess import sys import time import unittest if sys.version_info[0] == 2: from urllib2 import BaseHandler, HTTPError, Request, build_opener, urlopen from urllib import urlencode from urlparse import urlparse, urlsplit HTTPBasicAuthHandler = HTTPPasswordMgrWithPriorAuth = None else: from urllib.error import HTTPError from urllib.parse import urlencode, urlparse, urlsplit from urllib.request import (HTTPBasicAuthHandler, HTTPPasswordMgrWithPriorAuth, Request, build_opener, urlopen) from trac.env import Environment from trac.util import create_file from trac.util.compat import close_fds from ..util import to_b try: from trac.test import MockRequest except ImportError: def MockRequest(env): import io from trac.test import MockPerm from trac.util.datefmt import utc from trac.web.api import Request as TracRequest from trac.web.main import FakeSession out = io.BytesIO() environ = {'wsgi.url_scheme': 'http', 'wsgi.input': io.BytesIO(b''), 'REQUEST_METHOD': 'GET', 'SERVER_NAME': 'example.org', 'SERVER_PORT': 80, 'SCRIPT_NAME': '/trac', 'trac.base_url': 'http://example.org/trac'} start_response = lambda status, headers: out.write req = TracRequest(environ, start_response) req.callbacks.update({ 'authname': lambda req: 'anonymous', 'perm': lambda req: MockPerm(), 'session': lambda req: FakeSession(), 'chrome': lambda req: {}, 'tz': lambda req: utc, 'locale': lambda req: None, 'form_token': lambda req: 'A' * 20, }) return req try: from trac.test import rmtree except ImportError: from shutil import rmtree def _get_topdir(): path = os.path.dirname(os.path.abspath(__file__)) suffix = '/tracrpc/tests'.replace('/', os.sep) if not path.endswith(suffix): raise RuntimeError("%r doesn't end with %r" % (path, suffix)) return path[:-len(suffix)] def _get_testdir(): dir_ = os.environ.get('TMP') or _get_topdir() if not os.path.isabs(dir_): raise RuntimeError('Non absolute directory: %s' % repr(dir_)) return os.path.join(dir_, 'rpctestenv') if HTTPPasswordMgrWithPriorAuth: def _build_opener_auth(url, user, password): manager = HTTPPasswordMgrWithPriorAuth() manager.add_password(None, url, user, password, is_authenticated=True) handler = HTTPBasicAuthHandler(manager) return build_opener(handler) else: class HTTPBasicAuthPriorHandler(BaseHandler): def __init__(self, url, user, password): self.url = url self.user = user self.password = password def http_request(self, request): if not request.has_header('Authorization') and \ self.url == request.get_full_url(): cred = '%s:%s' % (self.user, self.password) encoded = b64encode(to_b(cred)) request.add_header('Authorization', 'Basic ' + encoded) return request def _build_opener_auth(url, user, password): handler = HTTPBasicAuthPriorHandler(url, user, password) return build_opener(handler) class RpcTestEnvironment(object): _testdir = _get_testdir() _plugins_dir = os.path.join(_testdir, 'plugins') _devnull = None _log = None _port = None _envpath = None _htpasswd = None _env = None _tracd = None url = None url_anon = None url_auth = None url_user = None url_admin = None def __init__(self): if os.path.isdir(self._testdir): rmtree(self._testdir) os.mkdir(self._testdir) os.mkdir(self._plugins_dir) @property def tracdir(self): return self._envpath def init(self): self._devnull = os.open(os.devnull, os.O_RDWR) self._log = os.open(os.path.join(self._testdir, 'tracd.log'), os.O_WRONLY | os.O_CREAT | os.O_APPEND) self._port = get_ephemeral_port() self.check_call([sys.executable, 'setup.py', 'develop', '-mxd', self._plugins_dir]) self._envpath = os.path.join(self._testdir, 'trac') self.url = 'http://127.0.0.1:%d/%s' % \ (self._port, os.path.basename(self._envpath)) self._htpasswd = os.path.join(self._testdir, 'htpasswd.txt') create_file(self._htpasswd, 'admin:$apr1$CJoMFGDO$W5ERyxnTl6qAUa9BbE0QV1\n' 'user:$apr1$ZQuTwNFe$ReYgDiL/gduTvjO29qdYx0\n') inherit = os.path.join(self._testdir, 'inherit.ini') with open(inherit, 'w') as f: f.write('[inherit]\n' 'plugins_dir = %s\n' '[components]\n' 'tracrpc.* = enabled\n' '[logging]\n' 'log_type = file\n' 'log_level = INFO\n' '[trac]\n' 'base_url = %s\n' % (self._plugins_dir, self.url)) args = [sys.executable, '-m', 'trac.admin.console', self._envpath] with self.popen(args, stdin=subprocess.PIPE) as proc: proc.stdin.write( b'initenv --inherit=%s project sqlite:db/trac.db\n' b'permission add admin TRAC_ADMIN\n' b'permission add anonymous XML_RPC\n' % to_b(inherit)) self.url_anon = '%s/rpc' % self.url self.url_auth = '%s/login/rpc' % self.url self.url_user = '%s/login/xmlrpc' % \ self.url.replace('://', '://user:user@') self.url_admin = '%s/login/xmlrpc' % \ self.url.replace('://', '://admin:admin@') self.start() def cleanup(self): self.stop() if self._env: self._env.shutdown() self._env = None if self._devnull is not None: os.close(self._devnull) self._devnull = None if self._log is not None: os.close(self._log) self._log = None def start(self): if self._tracd and self._tracd.returncode is None: raise RuntimeError('tracd is running') args = [ sys.executable, '-m', 'trac.web.standalone', '--port=%d' % self._port, '--basic-auth=*,%s,realm' % self._htpasswd, self._envpath, ] self._tracd = self.popen(args, stdout=self._log, stderr=self._log) start = time.time() while time.time() - start < 10: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: s.connect(('127.0.0.1', self._port)) except socket.error: time.sleep(0.125) else: break finally: s.close() else: raise RuntimeError('Timed out waiting for tracd to start') def stop(self): if self._tracd: try: self._tracd.terminate() except EnvironmentError: pass self._tracd.wait() self._tracd = None def restart(self): self.stop() self.start() def popen(self, *args, **kwargs): kwargs.setdefault('stdin', self._devnull) kwargs.setdefault('stdout', self._devnull) kwargs.setdefault('stderr', self._devnull) kwargs.setdefault('close_fds', close_fds) return Popen(*args, **kwargs) def check_call(self, *args, **kwargs): kwargs.setdefault('stdin', self._devnull) kwargs.setdefault('stdout', subprocess.PIPE) kwargs.setdefault('stderr', subprocess.PIPE) with self.popen(*args, **kwargs) as proc: stdout, stderr = proc.communicate() if proc.returncode != 0: raise RuntimeError('Exited with %d (stdout %r, stderr %r)' % (proc.returncode, stdout, stderr)) def get_trac_environment(self): if not self._env: self._env = Environment(self._envpath) return self._env def _tracadmin(self, *args): self.check_call((sys.executable, '-m', 'trac.admin.console', self._envpath) + args) if hasattr(subprocess.Popen, '__enter__'): Popen = subprocess.Popen else: class Popen(subprocess.Popen): def __enter__(self): return self def __exit__(self, *args): try: if self.stdin: self.stdin.close() finally: self.wait() for f in (self.stdout, self.stderr): if f: f.close() def get_ephemeral_port(): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(('127.0.0.1', 0)) s.listen(1) return s.getsockname()[1] finally: s.close() _rpc_testenv = None def _new_testenv(): global _rpc_testenv if not _rpc_testenv: _rpc_testenv = RpcTestEnvironment() _rpc_testenv.init() def _del_testenv(): global _rpc_testenv if _rpc_testenv: _rpc_testenv.cleanup() _rpc_testenv = None class TracRpcTestCase(unittest.TestCase): @property def _testenv(self): return _rpc_testenv def _opener_auth(self, url, user, password): return _build_opener_auth(url, user, password) @contextlib.contextmanager def _plugin(self, source, filename): filename = os.path.join(_rpc_testenv.tracdir, 'plugins', filename) create_file(filename, source) try: _rpc_testenv.restart() yield finally: os.unlink(filename) _rpc_testenv.restart() def _grant_perm(self, username, *actions): _rpc_testenv._tracadmin('permission', 'add', username, *actions) _rpc_testenv.restart() def _revoke_perm(self, username, *actions): _rpc_testenv._tracadmin('permission', 'remove', username, *actions) _rpc_testenv.restart() class TracRpcTestSuite(unittest.TestSuite): def run(self, result): if _rpc_testenv: created = False else: _new_testenv() created = True try: return super(TracRpcTestSuite, self).run(result) finally: if created: _del_testenv() def b64encode(s): if not isinstance(s, bytes): s = s.encode('utf-8') rv = base64.b64encode(s) if isinstance(rv, bytes): rv = rv.decode('ascii') return rv def form_urlencoded(data): return to_b(urlencode(data)) def makeSuite(testCaseClass, suiteClass=unittest.TestSuite): loader = unittest.TestLoader() loader.suiteClass = suiteClass return loader.loadTestsFromTestCase(testCaseClass) def test_suite(): suite = TracRpcTestSuite() from . import api, xml_rpc, json_rpc, ticket, wiki, web_ui, search for mod in (api, xml_rpc, json_rpc, ticket, wiki, web_ui, search): suite.addTest(mod.test_suite()) return suite trunk/tracrpc/tests/api.py000644 000000 000000 00000010740 14403467513 014323 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2009-2013 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import unittest from ..xml_rpc import XmlRpcProtocol from . import (HTTPError, Request, TracRpcTestCase, TracRpcTestSuite, urlopen, makeSuite) class ProtocolProviderTestCase(TracRpcTestCase): def setUp(self): TracRpcTestCase.setUp(self) def tearDown(self): TracRpcTestCase.tearDown(self) def test_invalid_content_type(self): req = Request(self._testenv.url_anon, headers={'Content-Type': 'text/plain'}, data=b'Fail! No RPC for text/plain') try: urlopen(req) self.fail("Expected urllib2.HTTPError") except HTTPError as e: self.assertEqual(e.code, 415) self.assertEqual(e.msg, "Unsupported Media Type") self.assertEqual(b"No protocol matching Content-Type 'text/plain' " b"at path '/rpc'.", e.fp.read()) def test_rpc_info(self): # Just try getting the docs for XML-RPC to test, it should always exist xmlrpc = XmlRpcProtocol(self._testenv.get_trac_environment()) name, docs = xmlrpc.rpc_info() self.assertEqual(name, 'XML-RPC') self.assertIn('Content-Type: application/xml', docs) def test_valid_provider(self): # Confirm the request won't work before adding plugin req = Request(self._testenv.url_anon, headers={'Content-Type': 'application/x-tracrpc-test'}, data=b"Fail! No RPC for application/x-tracrpc-test") try: resp = urlopen(req) self.fail("Expected urllib2.HTTPError") except HTTPError as e: self.assertEqual(e.code, 415) # Make a new plugin source = r"""# -*- coding: utf-8 -*- from trac.core import * from tracrpc.api import * class DummyProvider(Component): implements(IRPCProtocol) def rpc_info(self): return ('TEST-RPC', 'No Docs!') def rpc_match(self): yield ('rpc', 'application/x-tracrpc-test') def parse_rpc_request(self, req, content_type): return {'method' : 'system.getAPIVersion'} def send_rpc_error(self, req, e): rpcreq = req.rpc req.send((u'Test failure: %s' % e).encode('utf-8'), rpcreq['mimetype'], 500) def send_rpc_result(self, req, result): rpcreq = req.rpc # raise KeyError('Here') response = b'Got a result!' req.send(response, rpcreq['mimetype'], 200) """ with self._plugin(source, 'DummyProvider.py'): req = Request(self._testenv.url_anon, headers={'Content-Type': 'application/x-tracrpc-test'}) resp = urlopen(req) self.assertEqual(200, resp.code) self.assertEqual(b"Got a result!", resp.read()) self.assertEqual('application/x-tracrpc-test;charset=utf-8', resp.headers['Content-Type']) def test_general_provider_error(self): # Make a new plugin and restart server source = r"""# -*- coding: utf-8 -*- from trac.core import * from tracrpc.api import * from tracrpc.util import to_b class DummyProvider(Component): implements(IRPCProtocol) def rpc_info(self): return ('TEST-RPC', 'No Docs!') def rpc_match(self): yield ('rpc', 'application/x-tracrpc-test') def parse_rpc_request(self, req, content_type): return {'method' : 'system.getAPIVersion'} def send_rpc_error(self, req, e): data = e.message if isinstance(e, RPCError) else b'Test failure' req.send(to_b(data), 'text/plain', 500) def send_rpc_result(self, req, result): raise RPCError('No good.') """ with self._plugin(source, 'DummyProvider.py'): self._testenv.restart() req = Request(self._testenv.url_anon, headers={'Content-Type': 'application/x-tracrpc-test'}) try: urlopen(req) except HTTPError as e: self.assertEqual(500, e.code) self.assertEqual(b"No good.", e.fp.read()) self.assertTrue(e.hdrs['Content-Type'].startswith('text/plain')) else: self.fail('HTTPError not raised') def test_suite(): suite = TracRpcTestSuite() suite.addTest(makeSuite(ProtocolProviderTestCase)) return suite if __name__ == '__main__': unittest.main(defaultTest='test_suite') trunk/tracrpc/tests/json_rpc.py000644 000000 000000 00000041350 14630624614 015367 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ from datetime import datetime import base64 import codecs import io import json import pkg_resources import sys import unittest from trac.test import EnvironmentStub from trac.util.datefmt import FixedOffset, timezone, utc from ..util import to_b, unicode from ..json_rpc import TracRpcJSONDecoder, TracRpcJSONEncoder, json_load from . import (MockRequest, Request, TracRpcTestCase, TracRpcTestSuite, b64encode, urlopen, makeSuite) class JsonTestCase(TracRpcTestCase): def _anon_req(self, data): req = Request(self._testenv.url_anon, data=json_data(data), headers={'Content-Type': 'application/json'}) resp = urlopen(req) return _raw_json_load(resp) def _auth_req(self, data, user='user'): url = self._testenv.url_auth req = Request(url, data=json_data(data), headers={'Content-Type': 'application/json'}) opener = self._opener_auth(url, user, user) resp = opener.open(req) return _raw_json_load(resp) def setUp(self): TracRpcTestCase.setUp(self) def tearDown(self): TracRpcTestCase.tearDown(self) def test_jsonclass(self): image = pkg_resources.resource_string('trac', 'htdocs/feed.png') data = to_b(json.dumps({ 'id': 42, 'method': 'system.getAPIVersion', 'params': [ 1234, 0.125, {'__jsonclass__': ['datetime', '2023-03-01T16:07:59']}, {'__jsonclass__': ['binary', b64encode(image)]}, ], })) body = io.BytesIO(data) env = EnvironmentStub() req = MockRequest(env) req.environ['CONTENT_LENGTH'] = str(len(data)) req.environ['wsgi.input'] = body decoded = json_load(req) self.assertEqual(42, decoded['id']) self.assertEqual('system.getAPIVersion', decoded['method']) params = decoded['params'] self.assertEqual(1234, params[0]) self.assertEqual(0.125, params[1]) self.assertEqual(datetime(2023, 3, 1, 16, 7, 59, tzinfo=utc), params[2]) self.assertEqual(image, params[3]) self.assertEqual(4, len(params)) def test_parse_datetime(self): def test(expected, value): actual = TracRpcJSONDecoder._parse_datetime(value) self.assertEqual(expected, actual) self.assertEqual(expected.tzinfo.utcoffset(None), actual.tzinfo.utcoffset(None)) gmt09 = FixedOffset(540, 'GMT +9:00') gmt0845 = FixedOffset(525, 'GMT +8:45') test(datetime(2023, 3, 6, 0, 0, 0, 0, utc), '2023-03-06') test(datetime(2023, 3, 6, 8, 41, 32, 0, utc), '2023-03-06T08:41:32Z') test(datetime(2023, 3, 6, 8, 41, 32, 700000, utc), '2023-03-06T08:41:32.7Z') test(datetime(2023, 3, 6, 8, 41, 32, 730000, utc), '2023-03-06T08:41:32.73Z') test(datetime(2023, 3, 6, 8, 41, 32, 737000, utc), '2023-03-06T08:41:32.737Z') test(datetime(2023, 3, 6, 8, 41, 32, 737368, utc), '2023-03-06T08:41:32.737368Z') test(datetime(2023, 3, 6, 8, 41, 32, 0, utc), '2023-03-06t08:41:32z') test(datetime(2023, 3, 6, 8, 41, 32, 737000, utc), '2023-03-06t08:41:32.737z') test(datetime(2023, 3, 6, 17, 41, 32, 0, gmt09), '2023-03-06T17:41:32+09:00') test(datetime(2023, 3, 6, 17, 41, 32, 737000, gmt09), '2023-03-06T17:41:32.737+09:00') test(datetime(2023, 3, 6, 17, 41, 32, 737368, gmt09), '2023-03-06T17:41:32.737368+09:00') test(datetime(2023, 3, 6, 17, 41, 32, 0, gmt09), '2023-03-06 17:41:32+09:00') test(datetime(2023, 3, 6, 17, 41, 32, 700000, gmt09), '2023-03-06 17:41:32.7+09:00') test(datetime(2023, 3, 6, 17, 41, 32, 730000, gmt09), '2023-03-06 17:41:32.73+09:00') test(datetime(2023, 3, 6, 17, 41, 32, 737000, gmt09), '2023-03-06 17:41:32.737+09:00') test(datetime(2023, 3, 6, 17, 41, 32, 737368, gmt09), '2023-03-06 17:41:32.737368+09:00') test(datetime(2023, 3, 6, 8, 41, 32, 0, utc), '2023-03-06 08:41:32Z') test(datetime(2023, 3, 6, 8, 41, 32, 0, utc), '2023-03-06_08:41:32Z') test(datetime(2023, 3, 6, 8, 41, 32, 0, utc), '2023-03-06 08:41:32z') test(datetime(2023, 3, 6, 8, 41, 32, 0, utc), '2023-03-06_08:41:32z') test(datetime(2023, 3, 6, 8, 41, 32, 700000, utc), '2023-03-06 08:41:32.7Z') test(datetime(2023, 3, 6, 8, 41, 32, 730000, utc), '2023-03-06 08:41:32.73Z') test(datetime(2023, 3, 6, 8, 41, 32, 737000, utc), '2023-03-06 08:41:32.737Z') test(datetime(2023, 3, 6, 8, 41, 32, 737000, utc), '2023-03-06_08:41:32.737Z') test(datetime(2023, 3, 6, 8, 41, 32, 737368, utc), '2023-03-06 08:41:32.737368Z') test(datetime(2023, 3, 6, 8, 41, 32, 737368, utc), '2023-03-06_08:41:32.737368Z') test(datetime(2023, 3, 6, 8, 41, 32, 737000, utc), '2023-03-06 08:41:32.737z') test(datetime(2023, 3, 6, 8, 41, 32, 737000, utc), '2023-03-06_08:41:32.737z') test(datetime(2023, 3, 6, 8, 41, 32, 737368, utc), '2023-03-06 08:41:32.737368z') test(datetime(2023, 3, 6, 8, 41, 32, 737368, utc), '2023-03-06_08:41:32.737368z') test(datetime(2023, 3, 6, 8, 41, 32, 0, utc), '2023-03-06 08:41:32-00:00') test(datetime(2023, 3, 6, 8, 41, 32, 737000, utc), '2023-03-06 08:41:32.737-00:00') test(datetime(2023, 3, 6, 8, 41, 32, 0, utc), '2023-03-06T08:41:32-00:00') test(datetime(2023, 3, 6, 8, 41, 32, 737000, utc), '2023-03-06T08:41:32.737-00:00') test(datetime(2023, 3, 6, 17, 26, 32, 0, gmt0845), '2023-03-06T17:26:32+08:45') test(datetime(2023, 3, 6, 8, 41, 32, 0, utc), '2023-03-06T08:41:32+00:00') test(datetime(2023, 3, 6, 8, 41, 32, 737000, utc), '2023-03-06T08:41:32.737+00:00') def test_dump_datetime(self): def test(expected, value): actual = json.dumps(value, cls=TracRpcJSONEncoder) self.assertEqual(expected, actual) test('{"__jsonclass__": ["datetime", "2023-03-06T00:00:00"]}', datetime(2023, 3, 6, 0, 0, 0, 0, utc)) test('{"__jsonclass__": ["datetime", "2023-03-06T20:21:00"]}', datetime(2023, 3, 6, 20, 21, 0, 0, utc)) test('{"__jsonclass__": ["datetime", "2023-03-06T20:21:42"]}', datetime(2023, 3, 6, 20, 21, 42, 0, utc)) test('{"__jsonclass__": ["datetime", "2023-03-06T20:21:42.900000"]}', datetime(2023, 3, 6, 20, 21, 42, 900000, utc)) test('{"__jsonclass__": ["datetime", "2023-03-06T20:21:42.975000"]}', datetime(2023, 3, 6, 20, 21, 42, 975000, utc)) test('{"__jsonclass__": ["datetime", "2023-03-06T20:21:42.975321"]}', datetime(2023, 3, 6, 20, 21, 42, 975321, utc)) test('{"__jsonclass__": ["datetime", "2023-03-06T18:21:42.975321"]}', datetime(2023, 3, 6, 20, 21, 42, 975321, timezone('GMT +2:00'))) test('{"__jsonclass__": ["datetime", "2023-03-07T00:21:42.975321"]}', datetime(2023, 3, 6, 20, 21, 42, 975321, timezone('GMT -4:00'))) def test_call(self): result = self._anon_req( {'method': 'system.listMethods', 'params': [], 'id': 244}) self.assertIn('system.methodHelp', result['result']) self.assertEqual(None, result['error']) self.assertEqual(244, result['id']) def test_multicall(self): data = {'method': 'system.multicall', 'params': [ {'method': 'wiki.getAllPages', 'params': [], 'id': 1}, {'method': 'wiki.getPage', 'params': ['WikiStart', 1], 'id': 2}, {'method': 'ticket.status.getAll', 'params': [], 'id': 3}, {'method': 'nonexisting', 'params': []} ], 'id': 233} result = self._anon_req(data) self.assertEqual(None, result['error']) self.assertEqual(4, len(result['result'])) items = result['result'] self.assertEqual(1, items[0]['id']) self.assertEqual(233, items[3]['id']) self.assertIn('WikiStart', items[0]['result']) self.assertEqual(None, items[0]['error']) self.assertIn('Welcome', items[1]['result']) self.assertEqual(['accepted', 'assigned', 'closed', 'new', 'reopened'], items[2]['result']) self.assertEqual(None, items[3]['result']) self.assertEqual('JSONRPCError', items[3]['error']['name']) def test_datetime(self): # read and write datetime values dt_str = "2009-06-19T16:46:00" data = {'method': 'ticket.milestone.update', 'params': ['milestone1', {'due': {'__jsonclass__': ['datetime', dt_str]}}]} result = self._auth_req(data, user='admin') self.assertEqual(None, result['error']) result = self._auth_req({'method': 'ticket.milestone.get', 'params': ['milestone1']}, user='admin') self.assertTrue(result['result']) self.assertEqual(dt_str, result['result']['due']['__jsonclass__'][1]) def test_binary(self): # read and write binaries values image_in = pkg_resources.resource_string('trac', 'htdocs/feed.png') data = {'method': 'wiki.putAttachmentEx', 'params': ['TitleIndex', "feed2.png", "test image", {'__jsonclass__': ['binary', b64encode(image_in)]}]} result = self._auth_req(data, user='admin') self.assertEqual(None, result['error']) self.assertEqual('feed2.png', result['result']) # Now try to get the attachment, and verify it is identical result = self._auth_req({'method': 'wiki.getAttachment', 'params': ['TitleIndex/feed2.png']}, user='admin') self.assertTrue(result['result']) image_out = base64.b64decode(result['result']['__jsonclass__'][1]) self.assertEqual(image_in, image_out) def test_large_file(self): pagename = 'SandBox/LargeJsonrpc' filename = 'large.dat' payload = {'method': 'wiki.putPage', 'params': [pagename, 'attachment:' + filename, {}]} rv = self._auth_req(payload, user='admin') self.assertEqual({'error': None, 'id': None, 'result': True}, rv) content = bytes(bytearray(range(256))) * 4 * 1024 * 4 # 4 MB payload = {'method': 'wiki.putAttachmentEx', 'params': [pagename, filename, 'Large file', {'__jsonclass__': ['binary', b64encode(content)]}]} rv = self._auth_req(payload, user='admin') self.assertEqual({'error': None, 'id': None, 'result': filename}, rv) payload = {'method': 'wiki.getAttachment', 'params': ['%s/%s' % (pagename, filename)]} rv = self._auth_req(payload, user='admin') self.assertEqual(None, rv['error']) result = rv['result'] self.assertIsInstance(result, dict) self.assertEqual(['__jsonclass__'], sorted(result)) self.assertEqual('binary', result['__jsonclass__'][0]) self.assertEqual(content, base64.b64decode(result['__jsonclass__'][1])) self.assertIsInstance(result['__jsonclass__'], list) def test_fragment(self): data = {'method': 'ticket.create', 'params': ['ticket10786', '', {'type': 'enhancement', 'owner': 'A'}]} result = self._auth_req(data, user='admin') self.assertEqual(None, result['error']) tktid = result['result'] data = {'method': 'search.performSearch', 'params': ['ticket10786']} result = self._auth_req(data, user='admin') self.assertEqual(None, result['error']) self.assertEqual('#%d: enhancement: ' 'ticket10786 (new)' % tktid, result['result'][0][1]) self.assertEqual(1, len(result['result'])) data = {'method': 'ticket.delete', 'params': [tktid]} result = self._auth_req(data, user='admin') self.assertEqual(None, result['error']) def test_xmlrpc_permission(self): # Test returned response if not XML_RPC permission self._revoke_perm('anonymous', 'XML_RPC') try: result = self._anon_req({'method': 'system.listMethods', 'id': 'no-perm'}) self.assertEqual(None, result['result']) self.assertEqual('no-perm', result['id']) self.assertEqual(403, result['error']['code']) self.assertIn('XML_RPC', result['error']['message']) finally: # Add back the default permission for further tests self._grant_perm('anonymous', 'XML_RPC') def test_method_not_found(self): result = self._anon_req({'method': 'system.doesNotExist', 'id': 'no-method'}) self.assertTrue(result['error']) self.assertEqual(result['id'], 'no-method') self.assertEqual(None, result['result']) self.assertEqual(-32601, result['error']['code']) self.assertIn('not found', result['error']['message']) def test_wrong_argspec(self): result = self._anon_req({'method': 'system.listMethods', 'params': ['hello'], 'id': 'wrong-args'}) self.assertTrue(result['error']) self.assertEqual(result['id'], 'wrong-args') self.assertEqual(None, result['result']) self.assertEqual(-32603, result['error']['code']) message = result['error']['message'] if sys.version_info[0] == 2: self.assertIn('listMethods() takes exactly 2 arguments', message) else: self.assertIn('listMethods() takes 2 positional arguments but 3 ' 'were given', message) def test_call_permission(self): # Test missing call-specific permission result = self._anon_req({'method': 'ticket.component.delete', 'params': ['component1'], 'id': 2332}) self.assertEqual(None, result['result']) self.assertEqual(2332, result['id']) self.assertEqual(403, result['error']['code']) self.assertIn('TICKET_ADMIN privileges are required to perform this ' 'operation', result['error']['message']) def test_resource_not_found(self): # A Ticket resource result = self._anon_req({'method': 'ticket.get', 'params': [2147483647], 'id': 3443}) self.assertEqual(result['id'], 3443) self.assertEqual(result['error']['code'], 404) self.assertEqual(result['error']['message'], 'Ticket 2147483647 does not exist.') # A Wiki resource result = self._anon_req({'method': 'wiki.getPage', 'params': ["Test", 10], 'id': 3443}) self.assertEqual(result['error']['code'], 404) self.assertEqual(result['error']['message'], 'Wiki page "Test" does not exist at version 10') def test_invalid_json(self): result = self._anon_req('invalid-json') self.assertEqual(result['id'], None) self.assertEqual(result['error']['code'], -32700) self.assertEqual(result['error']['name'], 'JSONRPCError') self.assertIn('JsonProtocolException details: No JSON object could be ' 'decoded', result['error']['message']) def test_not_a_dict(self): result = self._anon_req('42') self.assertEqual(result['id'], None) self.assertEqual(result['error']['code'], -32700) self.assertEqual(result['error']['name'], 'JSONRPCError') self.assertIn('JSON object is not a dict', result['error']['message']) def json_data(data): if isinstance(data, bytes): return data if isinstance(data, unicode): return data.encode('utf-8') return to_b(json.dumps(data)) def _raw_json_load(fp): reader = codecs.getreader('utf-8')(fp) return json.load(reader) def test_suite(): suite = TracRpcTestSuite() suite.addTest(makeSuite(JsonTestCase)) return suite if __name__ == '__main__': unittest.main(defaultTest='test_suite') trunk/tracrpc/tests/search.py000644 000000 000000 00000003777 14377767263 015052 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2013 ::: Jun Omae (jun66j5@gmail.com) """ import unittest from ..util import xmlrpclib from . import TracRpcTestCase, TracRpcTestSuite, makeSuite class RpcSearchTestCase(TracRpcTestCase): def setUp(self): TracRpcTestCase.setUp(self) self.anon = xmlrpclib.ServerProxy(self._testenv.url_anon) self.user = xmlrpclib.ServerProxy(self._testenv.url_user) self.admin = xmlrpclib.ServerProxy(self._testenv.url_admin) def tearDown(self): for proxy in (self.anon, self.user, self.admin): proxy('close')() TracRpcTestCase.tearDown(self) def test_fragment_in_search(self): t1 = self.admin.ticket.create("ticket10786", "", {'type': 'enhancement', 'owner': 'A'}) results = self.user.search.performSearch("ticket10786") self.assertEqual(1, len(results)) self.assertEqual('#%d: enhancement: ' 'ticket10786 (new)' % t1, results[0][1]) self.assertEqual(0, self.admin.ticket.delete(t1)) def test_search_none_result(self): # Some plugins may return None instead of empty iterator # https://trac-hacks.org/ticket/12950 # Add custom plugin to provoke error source = r"""# -*- coding: utf-8 -*- from trac.core import * from trac.search.api import ISearchSource class NoneSearch(Component): implements(ISearchSource) def get_search_filters(self, req): yield ('test', 'Test') def get_search_results(self, req, terms, filters): self.log.debug('Search plugin returning None') return None """ with self._plugin(source, 'NoneSearchPlugin.py'): results = self.user.search.performSearch("nothing_should_be_found") self.assertEqual([], results) def test_suite(): suite = TracRpcTestSuite() suite.addTest(makeSuite(RpcSearchTestCase)) return suite if __name__ == '__main__': unittest.main(defaultTest='test_suite') trunk/tracrpc/tests/ticket.py000644 000000 000000 00000052332 14667017475 015052 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2009-2013 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import datetime import time import unittest from trac.util.datefmt import to_datetime, to_utimestamp, utc from ..util import unicode, xmlrpclib from ..xml_rpc import from_xmlrpc_datetime, to_xmlrpc_datetime from . import (Request, TracRpcTestCase, TracRpcTestSuite, b64encode, urlopen, makeSuite) class RpcTicketTestCase(TracRpcTestCase): def setUp(self): TracRpcTestCase.setUp(self) self.anon = xmlrpclib.ServerProxy(self._testenv.url_anon) self.user = xmlrpclib.ServerProxy(self._testenv.url_user) self.admin = xmlrpclib.ServerProxy(self._testenv.url_admin) def tearDown(self): for proxy in (self.anon, self.user, self.admin): proxy('close')() TracRpcTestCase.tearDown(self) def test_create_get_delete(self): tid = self.admin.ticket.create("create_get_delete", "fooy", {}) tid, time_created, time_changed, attributes = self.admin.ticket.get(tid) self.assertEqual('fooy', attributes['description']) self.assertEqual('create_get_delete', attributes['summary']) self.assertEqual('new', attributes['status']) self.assertEqual('admin', attributes['reporter']) self.admin.ticket.delete(tid) def test_create_empty_summary(self): try: self.admin.ticket.create("", "the description", {}) self.fail("Exception not raised creating ticket with empty summary") except xmlrpclib.Fault as e: self.assertIn("Tickets must contain a summary.", unicode(e)) def test_getActions(self): tid = self.admin.ticket.create("ticket_getActions", "kjsald", {'owner': ''}) try: actions = self.admin.ticket.getActions(tid) finally: self.admin.ticket.delete(tid) default = [['leave', 'leave', '.', []], ['resolve', 'resolve', "The resolution will be set. Next status will be 'closed'.", [['action_resolve_resolve_resolution', 'fixed', ['fixed', 'invalid', 'wontfix', 'duplicate', 'worksforme']]]], ['reassign', 'reassign', "The owner will change from (none). Next status will be 'assigned'.", [['action_reassign_reassign_owner', 'admin', []]]], ['accept', 'accept', "The owner will change from (none) to admin. Next status will be 'accepted'.", []]] # Adjust for trac:changeset:9041 if 'will be changed' in actions[2][2]: default[2][2] = default[2][2].replace('will change', 'will be changed') default[3][2] = default[3][2].replace('will change', 'will be changed') # Adjust for trac:changeset:11777 if not 'from (none).' in actions[2][2]: default[2][2] = default[2][2].replace('from (none).', 'from (none) to the specified user.') # Adjust for trac:changeset:11778 if actions[0][2] != '.': default[0][2] = 'The ticket will remain with no owner.' # Adjust for trac:changeset:13203 and trac:changeset:14393 if '(none)') default[3][2] = default[3][2].replace(' (none)', ' (none)') default[3][2] = default[3][2].replace(' admin', ' admin') self.assertEqual(actions, default) # From sample-plugins/workflow/DeleteTicket.py in Trac source _delete_ticket_action_controller = r"""# -*- coding: utf-8 -*- from trac.core import Component, implements from trac.perm import IPermissionRequestor from trac.ticket.api import ITicketActionController class DeleteTicketActionController(Component): implements(IPermissionRequestor, ITicketActionController) def get_permission_actions(self): return ['TICKET_DELETE'] def get_ticket_actions(self, req, ticket): actions = [] if ticket.exists and 'TICKET_DELETE' in req.perm(ticket.resource): actions.append((0, 'delete')) return actions def get_all_status(self): return [] def render_ticket_action_control(self, req, ticket, action): return 'delete', None, "The ticket will be deleted." def get_ticket_changes(self, req, ticket, action): return {} def apply_action_side_effects(self, req, ticket, action): if action == 'delete': ticket.delete() """ def test_getAvailableActions_DeleteTicket(self): # http://trac-hacks.org/ticket/5387 tktapi = self.admin.ticket env = self._testenv.get_trac_environment() tid = tktapi.create('abc', 'def', {}) try: self.assertNotIn('delete', tktapi.getAvailableActions(tid)) env.config.set('ticket', 'workflow', 'ConfigurableTicketWorkflow,DeleteTicketActionController') env.config.save() with self._plugin(self._delete_ticket_action_controller, 'DeleteTicket.py'): self.assertIn('delete', tktapi.getAvailableActions(tid)) finally: env.config.set('ticket', 'workflow', 'ConfigurableTicketWorkflow') env.config.save() self.assertEqual(0, tktapi.delete(tid)) def test_FineGrainedSecurity(self): self.assertEqual(1, self.admin.ticket.create('abc', '123', {})) self.assertEqual(2, self.admin.ticket.create('def', '456', {})) # First some non-restricted tests for comparison: self.assertRaises(xmlrpclib.Fault, self.anon.ticket.create, 'abc', 'def') self.assertEqual([1,2], self.user.ticket.query()) self.assertTrue(self.user.ticket.get(2)) self.assertTrue(self.user.ticket.update(1, "ok")) self.assertTrue(self.user.ticket.update(2, "ok")) # Enable security policy and test source = r"""# -*- coding: utf-8 -*- from trac.core import Component, implements from trac.perm import IPermissionPolicy class TicketPolicy(Component): implements(IPermissionPolicy) def check_permission(self, action, username, resource, perm): if username == 'user' and resource and resource.id == 2: return False if username == 'anonymous' and action == 'TICKET_CREATE': return True """ env = self._testenv.get_trac_environment() _old_conf = env.config.get('trac', 'permission_policies') env.config.set('trac', 'permission_policies', 'TicketPolicy,' + _old_conf) env.config.save() try: with self._plugin(source, 'TicketPolicy.py'): self._testenv.restart() self.assertEqual([1], self.user.ticket.query()) self.assertTrue(self.user.ticket.get(1)) self.assertRaises(xmlrpclib.Fault, self.user.ticket.get, 2) self.assertTrue(self.user.ticket.update(1, "ok")) self.assertRaises(xmlrpclib.Fault, self.user.ticket.update, 2, "not ok") self.assertEqual(3, self.anon.ticket.create('efg', '789', {})) finally: # Clean, reset and simple verification env.config.set('trac', 'permission_policies', _old_conf) env.config.save() self.assertEqual([1,2,3], self.user.ticket.query()) self.assertEqual(0, self.admin.ticket.delete(1)) self.assertEqual(0, self.admin.ticket.delete(2)) self.assertEqual(0, self.admin.ticket.delete(3)) def test_getRecentChanges(self): tid1 = self.admin.ticket.create("ticket_getRecentChanges", "one", {}) time.sleep(1) tid2 = self.admin.ticket.create("ticket_getRecentChanges", "two", {}) try: _id, created, modified, attributes = self.admin.ticket.get(tid2) changes = self.admin.ticket.getRecentChanges(created) self.assertEqual(changes, [tid2]) finally: self.admin.ticket.delete(tid1) self.admin.ticket.delete(tid2) def test_query_group_order_col(self): t1 = self.admin.ticket.create("1", "", {'type': 'enhancement', 'owner': 'A'}) t2 = self.admin.ticket.create("2", "", {'type': 'task', 'owner': 'B'}) t3 = self.admin.ticket.create("3", "", {'type': 'defect', 'owner': 'A'}) # order self.assertEqual([3,1,2], self.admin.ticket.query("order=type")) self.assertEqual([1,3,2], self.admin.ticket.query("order=owner")) self.assertEqual([2,1,3], self.admin.ticket.query("order=owner&desc=1")) # group self.assertEqual([1,3,2], self.admin.ticket.query("group=owner")) self.assertEqual([2,1,3], self.admin.ticket.query("group=owner&groupdesc=1")) # group + order self.assertEqual([2,3,1], self.admin.ticket.query("group=owner&groupdesc=1&order=type")) # col should just be ignored self.assertEqual([3,1,2], self.admin.ticket.query("order=type&col=status&col=reporter")) # clean self.assertEqual(0, self.admin.ticket.delete(t1)) self.assertEqual(0, self.admin.ticket.delete(t2)) self.assertEqual(0, self.admin.ticket.delete(t3)) def test_query_special_character_escape(self): summary = ["here&now", "maybe|later", r"back\slash"] search = [r"here\&now", r"maybe\|later", r"back\slash"] tids = [] for s in summary: tids.append(self.admin.ticket.create(s, "test_special_character_escape", {})) try: for i in range(0, 3): self.assertEqual([tids[i]], self.admin.ticket.query("summary=%s" % search[i])) self.assertEqual(tids.sort(), self.admin.ticket.query("summary=%s" % "|".join(search)).sort()) finally: for tid in tids: self.admin.ticket.delete(tid) def test_update_author(self): tid = self.admin.ticket.create("ticket_update_author", "one", {}) self.admin.ticket.update(tid, 'comment1', {}) time.sleep(1) self.admin.ticket.update(tid, 'comment2', {}, False, 'foo') time.sleep(1) self.user.ticket.update(tid, 'comment3', {}, False, 'should_be_rejected') changes = self.admin.ticket.changeLog(tid) self.assertEqual(3, len(changes)) for when, who, what, cnum, comment, _tid in changes: self.assertIn(comment, ('comment1', 'comment2', 'comment3')) if comment == 'comment1': self.assertEqual('admin', who) if comment == 'comment2': self.assertEqual('foo', who) if comment == 'comment3': self.assertEqual('user', who) self.admin.ticket.delete(tid) def test_create_at_time(self): now = to_datetime(None, utc) minus1 = to_xmlrpc_datetime(now - datetime.timedelta(days=1)) # create the tickets (user ticket will not be permitted to change time) one = self.admin.ticket.create("create_at_time1", "ok", {}, False, minus1) two = self.user.ticket.create("create_at_time3", "ok", {}, False, minus1) # get the tickets t1 = self.admin.ticket.get(one) t2 = self.admin.ticket.get(two) # check timestamps self.assertTrue(t1[1] < t2[1]) self.admin.ticket.delete(one) self.admin.ticket.delete(two) def test_update_at_time(self): now = to_datetime(None, utc) minus1 = to_xmlrpc_datetime(now - datetime.timedelta(hours=1)) minus2 = to_xmlrpc_datetime(now - datetime.timedelta(hours=2)) tid = self.admin.ticket.create("ticket_update_at_time", "ok", {}) self.admin.ticket.update(tid, 'one', {}, False, '', minus2) self.admin.ticket.update(tid, 'two', {}, False, '', minus1) self.user.ticket.update(tid, 'three', {}, False, '', minus1) time.sleep(1) self.user.ticket.update(tid, 'four', {}) changes = self.admin.ticket.changeLog(tid) self.assertEqual(4, len(changes)) # quick test to make sure each is older than previous self.assertTrue(changes[0][0] < changes[1][0] < changes[2][0]) # margin of 2 seconds for tests justnow = to_xmlrpc_datetime(now - datetime.timedelta(seconds=1)) self.assertTrue(justnow <= changes[2][0]) self.assertTrue(justnow <= changes[3][0]) self.admin.ticket.delete(tid) def test_update_non_existing(self): try: self.admin.ticket.update(3344, "a comment", {}) self.fail("Allowed to update non-existing ticket???") self.admin.ticket.delete(3234) except Exception as e: self.assertIn("Ticket 3344 does not exist.", str(e)) def test_update_basic(self): # Basic update check, no 'action' or 'time_changed' tid = self.admin.ticket.create('test_update_basic1', 'ieidnsj', { 'owner': 'osimons'}) # old-style (deprecated) self.admin.ticket.update(tid, "comment1", {'component': 'component2'}) self.assertEqual(2, len(self.admin.ticket.changeLog(tid))) # new-style with 'action' time.sleep(1) # avoid "columns ticket, time, field are not unique" self.admin.ticket.update(tid, "comment2", {'component': 'component1', 'action': 'leave'}) self.assertEqual(4, len(self.admin.ticket.changeLog(tid))) self.admin.ticket.delete(tid) def test_update_time_changed(self): # Update with collision check tid = self.admin.ticket.create('test_update_time_changed', '...', {}) tid, created, modified, attrs = self.admin.ticket.get(tid) then = from_xmlrpc_datetime(modified) - datetime.timedelta(minutes=1) # Unrestricted old-style update (to be removed soon) try: self.admin.ticket.update(tid, "comment1", {'_ts': str(to_utimestamp(then))}) except Exception as e: self.assertIn("Ticket has been updated since last get", str(e)) # Update with 'action' to test new-style update. try: self.admin.ticket.update(tid, "comment1", {'_ts': str(to_utimestamp(then)), 'action': 'leave'}) except Exception as e: self.assertTrue("Your changes have not been saved" in str(e) or "modified by someone else" in str(e), str(e)) self.admin.ticket.delete(tid) def test_update_time_same(self): # Unrestricted old-style update (to be removed soon) tid = self.admin.ticket.create('test_update_time_same', '...', {}) tid, created, modified, attrs = self.admin.ticket.get(tid) ts = attrs['_ts'] self.admin.ticket.update(tid, "comment1", {'_ts': ts}) self.admin.ticket.delete(tid) # Update with 'action' to test new-style update. tid = self.admin.ticket.create('test_update_time_same', '...', {}) tid, created, modified, attrs = self.admin.ticket.get(tid) ts = attrs['_ts'] self.admin.ticket.update(tid, "comment1", {'_ts': ts, 'action': 'leave'}) self.admin.ticket.delete(tid) def test_update_action(self): # Updating with 'action' in attributes tid = self.admin.ticket.create('test_update_action', 'ss', {'owner': ''}) current = self.admin.ticket.get(tid) self.assertEqual('', current[3].get('owner', '')) updated = self.admin.ticket.update(tid, "comment1", {'action': 'reassign', 'action_reassign_reassign_owner': 'user'}) self.assertEqual('user', updated[3].get('owner')) self.admin.ticket.delete(tid) def test_update_action_non_existing(self): # Updating with non-existing 'action' in attributes tid = self.admin.ticket.create('test_update_action_wrong', 'ss') try: self.admin.ticket.update(tid, "comment1", {'action': 'reassign', 'action_reassign_reassign_owner': 'user'}) except Exception as e: self.assertIn("invalid action", str(e)) self.admin.ticket.delete(tid) def test_update_field_non_existing(self): tid = self.admin.ticket.create('test_update_field_non_existing', 'yw3') rv = self.admin.ticket.get(tid) self.assertEqual('defect', rv[3]['type']) rv = self.admin.ticket.update(tid, "comment1", {'does_not_exist': 'eiwrjoer', 'type': 'enhancement'}) self.assertEqual('enhancement', rv[3]['type']) self.assertFalse('does_not_exist' in rv[3]) rv = self.admin.ticket.update(tid, "comment2", {'action': 'leave', 'does_not_exist': 'eiwrjoer', 'type': 'task'}) self.assertEqual('task', rv[3]['type']) self.assertFalse('does_not_exist' in rv[3]) self.admin.ticket.delete(tid) def test_create_ticket_9096(self): # See http://trac-hacks.org/ticket/9096 body = (b'\n' b'\n' b' ticket.create\n' b' \n' b' test summary\n' b' test desc\n' b' \n' b'') request = Request(self._testenv.url_auth, data=body) request.add_header('Content-Type', 'application/xml') request.add_header('Content-Length', str(len(body))) request.add_header('Authorization', 'Basic %s' % b64encode('admin:admin')) self.assertEqual('POST', request.get_method()) response = urlopen(request) self.assertEqual(200, response.code) self.assertEqual(b"\n" b"\n" b"\n" b"\n" b"1\n" b"\n" b"\n" b"\n", response.read()) self.admin.ticket.delete(1) def test_update_ticket_12430(self): # What if ticket 'time' and 'changetime' are part of attributes? # See https://trac-hacks.org/ticket/12430 tid1 = self.admin.ticket.create('test_update_ticket_12430', 'ok?', { 'owner': 'osimons1'}) try: # Get a fresh full copy tid2, created, changed, values = self.admin.ticket.get(tid1) self.assertIn('time', values, "'time' field not returned?") self.assertIn('changetime', values, "'changetime' field not returned?") self.assertIn('_ts', values, "No _ts in values?") # Update values['action'] = 'leave' values['owner'] = 'osimons2' self.admin.ticket.update(tid2, "updating", values) finally: self.admin.ticket.delete(tid1) class RpcTicketVersionTestCase(TracRpcTestCase): def setUp(self): TracRpcTestCase.setUp(self) self.anon = xmlrpclib.ServerProxy(self._testenv.url_anon) self.user = xmlrpclib.ServerProxy(self._testenv.url_user) self.admin = xmlrpclib.ServerProxy(self._testenv.url_admin) def tearDown(self): for proxy in (self.anon, self.user, self.admin): proxy('close')() TracRpcTestCase.tearDown(self) def test_create(self): dt = to_xmlrpc_datetime(to_datetime(None, utc)) desc = "test version" self.admin.ticket.version.create( '9.99', {'time': dt, 'description': desc}) self.assertIn('9.99', self.admin.ticket.version.getAll()) self.assertEqual({'time': dt, 'description': desc, 'name': '9.99'}, self.admin.ticket.version.get('9.99')) class RpcTicketTypeTestCase(TracRpcTestCase): def setUp(self): TracRpcTestCase.setUp(self) self.anon = xmlrpclib.ServerProxy(self._testenv.url_anon) self.user = xmlrpclib.ServerProxy(self._testenv.url_user) self.admin = xmlrpclib.ServerProxy(self._testenv.url_admin) def tearDown(self): for proxy in (self.anon, self.user, self.admin): proxy('close')() TracRpcTestCase.tearDown(self) def test_getall_default(self): self.assertEqual(['defect', 'enhancement', 'task'], sorted(self.anon.ticket.type.getAll())) self.assertEqual(['defect', 'enhancement', 'task'], sorted(self.admin.ticket.type.getAll())) def test_suite(): suite = TracRpcTestSuite() suite.addTest(makeSuite(RpcTicketTestCase)) suite.addTest(makeSuite(RpcTicketVersionTestCase)) suite.addTest(makeSuite(RpcTicketTypeTestCase)) return suite if __name__ == '__main__': unittest.main(defaultTest='test_suite') trunk/tracrpc/tests/web_ui.py000644 000000 000000 00000011631 14403461411 015013 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import sys import unittest from trac.util.text import exception_to_unicode from ..util import to_b from . import (HTTPError, Request, TracRpcTestCase, TracRpcTestSuite, form_urlencoded, makeSuite) class DocumentationTestCase(TracRpcTestCase): def setUp(self): TracRpcTestCase.setUp(self) self.opener_user = self._opener_auth(self._testenv.url_auth, 'user', 'user') def tearDown(self): TracRpcTestCase.tearDown(self) def test_get_with_content_type(self): req = Request(self._testenv.url_auth, headers={'Content-Type': 'text/html'}) self.assert_rpcdocs_ok(self.opener_user, req) def test_get_no_content_type(self): req = Request(self._testenv.url_auth) self.assert_rpcdocs_ok(self.opener_user, req) def test_post_accept(self): req = Request(self._testenv.url_auth, headers={'Content-Type' : 'text/plain', 'Accept': 'application/x-trac-test,text/html'}, data=b'Pass since client accepts HTML') self.assert_rpcdocs_ok(self.opener_user, req) req = Request(self._testenv.url_auth, headers={'Content-Type' : 'text/plain'}, data=b'Fail! No content type expected') self.assert_unsupported_media_type(self.opener_user, req) def test_form_submit(self): # Explicit content type form_vars = {'result' : 'Fail! __FORM_TOKEN protection activated'} req = Request(self._testenv.url_auth, headers={'Content-Type': 'application/x-www-form-urlencoded'}, data=form_urlencoded(form_vars)) self.assert_form_protect(self.opener_user, req) # Implicit content type req = Request(self._testenv.url_auth, headers={'Accept': 'application/x-trac-test,text/html'}, data=b'Pass since client accepts HTML') self.assert_form_protect(self.opener_user, req) def test_get_dont_accept(self): req = Request(self._testenv.url_auth, headers={'Accept': 'application/x-trac-test'}) self.assert_unsupported_media_type(self.opener_user, req) def test_post_dont_accept(self): req = Request(self._testenv.url_auth, headers={'Content-Type': 'text/plain', 'Accept': 'application/x-trac-test'}, data=b'Fail! Client cannot process HTML') self.assert_unsupported_media_type(self.opener_user, req) # Custom assertions def assert_rpcdocs_ok(self, opener, req): """Determine if RPC docs are ok""" try: resp = opener.open(req) except HTTPError as e: self.fail("Request to '%s' failed (%s) %s" % (e.geturl(), e.code, e.fp.read())) else: self.assertEqual(200, resp.code) body = resp.read() self.assertIn(b'

    XML-RPC

    ', body) self.assertIn(b'

    ', body) def assert_unsupported_media_type(self, opener, req): """Ensure HTTP 415 is returned back to the client""" content_type = req.get_header('Content-type', '') # XXX Content-type header with text/plain is sent even if GET request # in Python 2's urllib2. if not content_type and sys.version_info[0] == 2 and \ req.get_method() == 'GET': content_type = 'text/plain' expected = to_b("No protocol matching Content-Type '%s' at path " "'/login/rpc'." % content_type) try: resp = opener.open(req) except HTTPError as e: self.assertEqual(415, e.code) self.assertEqual(expected, e.fp.read()) except Exception as e: self.fail('Expected HTTP error but %s raised instead: %s' % exception_to_unicode(e)) else: resp.read() self.fail('Expected HTTP error (415) but nothing raised') def assert_form_protect(self, opener, req): try: opener.open(req) except HTTPError as e: self.assertEqual(400, e.code) self.assertIn(b"Missing or invalid form token. Do you have " b"cookies enabled?", e.fp.read()) else: self.fail('HTTPError not raised') def test_suite(): suite = TracRpcTestSuite() suite.addTest(makeSuite(DocumentationTestCase)) return suite if __name__ == '__main__': unittest.main(defaultTest='test_suite') trunk/tracrpc/tests/wiki.py000644 000000 000000 00000010502 14377767263 014530 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import pkg_resources import time import unittest from ..util import xmlrpclib from . import TracRpcTestCase, TracRpcTestSuite, makeSuite class RpcWikiTestCase(TracRpcTestCase): image_in = pkg_resources.resource_string('trac', 'htdocs/feed.png') def setUp(self): TracRpcTestCase.setUp(self) self.anon = xmlrpclib.ServerProxy(self._testenv.url_anon) self.user = xmlrpclib.ServerProxy(self._testenv.url_user) self.admin = xmlrpclib.ServerProxy(self._testenv.url_admin) def tearDown(self): for proxy in (self.anon, self.user, self.admin): proxy('close')() TracRpcTestCase.tearDown(self) def test_attachments(self): # Create attachment self.admin.wiki.putAttachmentEx('TitleIndex', 'feed2.png', 'test image', xmlrpclib.Binary(self.image_in)) self.assertEqual(self.image_in, self.admin.wiki.getAttachment( 'TitleIndex/feed2.png').data) # Update attachment (adding new) self.admin.wiki.putAttachmentEx('TitleIndex', 'feed2.png', 'test image', xmlrpclib.Binary(self.image_in), False) self.assertEqual(self.image_in, self.admin.wiki.getAttachment( 'TitleIndex/feed2.2.png').data) # List attachments self.assertEqual(['TitleIndex/feed2.2.png', 'TitleIndex/feed2.png'], sorted(self.admin.wiki.listAttachments('TitleIndex'))) # Delete both attachments self.admin.wiki.deleteAttachment('TitleIndex/feed2.png') self.admin.wiki.deleteAttachment('TitleIndex/feed2.2.png') # List attachments again self.assertEqual([], self.admin.wiki.listAttachments('TitleIndex')) def test_getRecentChanges(self): self.admin.wiki.putPage('WikiOne', 'content one', {}) time.sleep(1) self.admin.wiki.putPage('WikiTwo', 'content two', {}) attrs2 = self.admin.wiki.getPageInfo('WikiTwo') changes = self.admin.wiki.getRecentChanges(attrs2['lastModified']) self.assertEqual(1, len(changes)) self.assertEqual('WikiTwo', changes[0]['name']) self.assertEqual('admin', changes[0]['author']) self.assertEqual(1, changes[0]['version']) self.admin.wiki.deletePage('WikiOne') self.admin.wiki.deletePage('WikiTwo') def test_getPageHTMLWithImage(self): # Create the wiki page (absolute image reference) self.admin.wiki.putPage('ImageTest', '[[Image(wiki:ImageTest:feed.png, nolink)]]\n', {}) # Create attachment self.admin.wiki.putAttachmentEx('ImageTest', 'feed.png', 'test image', xmlrpclib.Binary(self.image_in)) # Check rendering absolute markup_1 = self.admin.wiki.getPageHTML('ImageTest') self.assertIn((' src="%s/raw-attachment/wiki/ImageTest/feed.png"' % self._testenv.url), markup_1) # Change to relative image reference and check again self.admin.wiki.putPage('ImageTest', '[[Image(feed.png, nolink)]]\n', {}) markup_2 = self.admin.wiki.getPageHTML('ImageTest') self.assertEqual(markup_2, markup_1) def test_getPageHTMLWithManipulator(self): self.admin.wiki.putPage('FooBar', 'foo bar', {}) # Enable wiki manipulator source = r"""# -*- coding: utf-8 -*- from trac.core import * from trac.wiki.api import IWikiPageManipulator class WikiManipulator(Component): implements(IWikiPageManipulator) def prepare_wiki_page(self, req, page, fields): fields['text'] = 'foo bar baz' def validate_wiki_page(req, page): return [] """ with self._plugin(source, 'Manipulator.py'): self.assertEqual('

    \nfoo bar baz\n

    \n' '', self.admin.wiki.getPageHTML('FooBar')) def test_suite(): suite = TracRpcTestSuite() suite.addTest(makeSuite(RpcWikiTestCase)) return suite if __name__ == '__main__': unittest.main(defaultTest='test_suite') trunk/tracrpc/tests/xml_rpc.py000644 000000 000000 00000016053 14403461411 015210 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import sys import unittest from datetime import datetime from trac.util.datefmt import to_datetime, utc from ..util import xmlrpclib from ..xml_rpc import (to_xmlrpc_datetime, from_xmlrpc_datetime, _illegal_unichrs, REPLACEMENT_CHAR) from . import (Request, TracRpcTestCase, TracRpcTestSuite, b64encode, urlopen, makeSuite) class RpcXmlTestCase(TracRpcTestCase): def setUp(self): TracRpcTestCase.setUp(self) self.anon = xmlrpclib.ServerProxy(self._testenv.url_anon) self.user = xmlrpclib.ServerProxy(self._testenv.url_user) self.admin = xmlrpclib.ServerProxy(self._testenv.url_admin) def tearDown(self): for proxy in (self.anon, self.user, self.admin): proxy('close')() TracRpcTestCase.tearDown(self) def test_xmlrpc_permission(self): # Test returned response if not XML_RPC permission self._revoke_perm('anonymous', 'XML_RPC') try: self.anon.system.listMethods() except xmlrpclib.Fault as e: self.assertEqual(403, e.faultCode) self.assertIn('XML_RPC', e.faultString) else: self.fail('xmlrpclib.Fault not raised') finally: self._grant_perm('anonymous', 'XML_RPC') def test_method_not_found(self): try: self.admin.system.doesNotExist() except xmlrpclib.Fault as e: self.assertEqual(-32601, e.faultCode) self.assertIn("not found", e.faultString) else: self.fail('xmlrpclib.Fault not raised') def test_wrong_argspec(self): try: self.admin.system.listMethods("hello") except xmlrpclib.Fault as e: self.assertEqual(1, e.faultCode) if sys.version_info[0] == 2: self.assertIn('listMethods() takes exactly 2 arguments', e.faultString) else: self.assertIn('listMethods() takes 2 positional arguments but ' '3 were given', e.faultString) else: self.fail('xmlrpclib.Fault not raised') def test_content_encoding(self): test_bytes = u'øæåØÆÅàéüoö'.encode('utf-8') body = (b'\n' b'\n' b' ticket.create\n' b' \n' b' %s\n' b' %s\n' b' \n' b'' % (test_bytes, test_bytes[::-1])) request = Request(self._testenv.url_auth, data=body) request.add_header('Content-Type', 'application/xml') request.add_header('Content-Length', str(len(body))) request.add_header('Authorization', 'Basic %s' % b64encode('admin:admin')) self.assertEqual('POST', request.get_method()) response = urlopen(request) self.assertEqual(200, response.code) self.assertIn(b'\n' b'faultCode\n' b'-32700\n' b'', response.read()) def test_to_and_from_datetime(self): now = to_datetime(None, utc) now_timetuple = now.timetuple()[:6] xmlrpc_now = to_xmlrpc_datetime(now) self.assertTrue(isinstance(xmlrpc_now, xmlrpclib.DateTime), "Expected xmlprc_now to be an xmlrpclib.DateTime") self.assertEqual(str(xmlrpc_now), now.strftime("%Y%m%dT%H:%M:%S")) now_from_xmlrpc = from_xmlrpc_datetime(xmlrpc_now) self.assertTrue(isinstance(now_from_xmlrpc, datetime), "Expected now_from_xmlrpc to be a datetime") self.assertEqual(now_from_xmlrpc.timetuple()[:6], now_timetuple) self.assertEqual(now_from_xmlrpc.tzinfo, utc) def test_resource_not_found(self): # A Ticket resource try: self.admin.ticket.get(2147483647) except xmlrpclib.Fault as e: self.assertEqual(e.faultCode, 404) self.assertEqual(e.faultString, 'Ticket 2147483647 does not exist.') else: self.fail('xmlrpclib.Fault not raised') # A Wiki resource try: self.admin.wiki.getPage("Test", 10) except xmlrpclib.Fault as e: self.assertEqual(e.faultCode, 404) self.assertEqual(e.faultString, 'Wiki page "Test" does not exist at version 10') else: self.fail('xmlrpclib.Fault not raised') @unittest.expectedFailure def test_xml_encoding_special_characters(self): tid1 = self.admin.ticket.create( 'One & Two < Four', 'Desc & ription\nLine 2', {}) ticket = self.admin.ticket.get(tid1) try: self.assertEqual('One & Two < Four', ticket[3]['summary']) self.assertEqual('Desc & ription\r\nLine 2', ticket[3]['description']) finally: self.admin.ticket.delete(tid1) def test_xml_encoding_invalid_characters(self): # Enable ticket manipulator source = r"""# -*- coding: utf-8 -*- from trac.core import * from tracrpc.api import IXMLRPCHandler class UniChr(Component): implements(IXMLRPCHandler) def xmlrpc_namespace(self): return 'test_unichr' def xmlrpc_methods(self): yield ('XML_RPC', ((str, int),), self.unichr) def unichr(self, req, code): return (b'\\U%08X' % code).decode('unicode-escape') """ with self._plugin(source, 'InvalidXmlCharHandler.py'): for low, high in _illegal_unichrs: for code in sorted(set([low, low + 1, high - 1, high])): self.assertEqual(REPLACEMENT_CHAR, self.user.test_unichr.unichr(code), "Failed unichr with U+%04X" % code) # surrogate pair on narrow build self.assertEqual(u'\U0001D4C1', self.user.test_unichr.unichr(0x1D4C1)) def test_large_file(self): pagename = 'SandBox/LargeXmlrpc' filename = 'large.dat' rv = self.admin.wiki.putPage(pagename, 'attachment:' + filename, {}) self.assertEqual(True, rv) content = bytes(bytearray(range(256))) * 4 * 1024 * 4 # 4 MB rv = self.admin.wiki.putAttachmentEx(pagename, filename, 'Large file', xmlrpclib.Binary(content)) self.assertEqual(filename, rv) rv = self.admin.wiki.getAttachment('%s/%s' % (pagename, filename)) self.assertIsInstance(rv, xmlrpclib.Binary) self.assertEqual(content, rv.data) def test_suite(): suite = TracRpcTestSuite() suite.addTest(makeSuite(RpcXmlTestCase)) return suite if __name__ == '__main__': unittest.main(defaultTest='test_suite') trunk/tracrpc/ticket.py000644 000000 000000 00000055706 14667017475 013720 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2005-2008 ::: Alec Thomas (alec@swapoff.org) (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import inspect import io from datetime import datetime from trac.attachment import Attachment from trac.core import Component, TracError, implements from trac.resource import Resource, ResourceNotFound from trac.ticket import model, query from trac.ticket.api import TicketSystem from trac.ticket.web_ui import TicketModule from trac.web.chrome import add_warning from trac.util.datefmt import from_utimestamp, to_datetime, to_utimestamp, utc from trac.util.html import Element, Fragment, Markup from trac.util.text import exception_to_unicode, to_unicode try: from trac.notification.api import NotificationSystem except ImportError: from trac.ticket.notification import TicketNotifyEmail NotificationSystem = TicketChangeEvent = None else: from trac.ticket.notification import TicketChangeEvent TicketNotifyEmail = None from .api import IXMLRPCHandler, Binary from .util import iteritems __all__ = ['TicketRPC'] class TicketRPC(Component): """ An interface to Trac's ticketing system. """ implements(IXMLRPCHandler) # IXMLRPCHandler methods def xmlrpc_namespace(self): return 'ticket' def xmlrpc_methods(self): yield (None, ((list,), (list, str)), self.query) yield (None, ((list, datetime),), self.getRecentChanges) yield (None, ((list, int),), self.getAvailableActions) yield (None, ((list, int),), self.getActions) yield (None, ((list, int),), self.get) yield ('TICKET_CREATE', ((int, str, str), (int, str, str, dict), (int, str, str, dict, bool), (int, str, str, dict, bool, datetime)), self.create) yield (None, ((list, int, str), (list, int, str, dict), (list, int, str, dict, bool), (list, int, str, dict, bool, str), (list, int, str, dict, bool, str, datetime)), self.update) yield (None, ((None, int),), self.delete) yield (None, ((dict, int), (dict, int, int)), self.changeLog) yield (None, ((list, int),), self.listAttachments) yield (None, ((Binary, int, str),), self.getAttachment) yield (None, ((str, int, str, str, Binary, bool), (str, int, str, str, Binary)), self.putAttachment) yield (None, ((bool, int, str),), self.deleteAttachment) yield ('TICKET_VIEW', ((list,),), self.getTicketFields) # Exported methods def query(self, req, qstr='status!=closed'): """ Perform a ticket query, returning a list of ticket ID's. All queries will use stored settings for maximum number of results per page and paging options. Use `max=n` to define number of results to receive, and use `page=n` to page through larger result sets. Using `max=0` will turn off paging and return all results. """ q = query.Query.from_string(self.env, qstr) ticket_realm = Resource('ticket') out = [] for t in q.execute(req): tid = t['id'] if 'TICKET_VIEW' in req.perm(ticket_realm(id=tid)): out.append(tid) return out def getRecentChanges(self, req, since): """Returns a list of IDs of tickets that have changed since timestamp.""" since = to_utimestamp(since) query = 'SELECT id FROM ticket WHERE changetime >= %s' if hasattr(self.env, 'db_query'): generator = self.env.db_query(query, (since,)) else: db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute(query, (since,)) generator = cursor result = [] ticket_realm = Resource('ticket') for row in generator: tid = int(row[0]) if 'TICKET_VIEW' in req.perm(ticket_realm(id=tid)): result.append(tid) return result def getAvailableActions(self, req, id): """ Deprecated - will be removed. Replaced by `getActions()`. """ self.log.warning("Rpc ticket.getAvailableActions is deprecated") return [action[0] for action in self.getActions(req, id)] def getActions(self, req, id): """Returns the actions that can be performed on the ticket as a list of `[action, label, hints, [input_fields]]` elements, where `input_fields` is a list of `[name, value, [options]]` for any required action inputs.""" ts = TicketSystem(self.env) t = model.Ticket(self.env, id) actions = [] for action in ts.get_available_actions(req, t): widgets = Fragment() hints = [] first_label = None for controller in ts.action_controllers: if action in [c_action for c_weight, c_action \ in controller.get_ticket_actions(req, t)]: label, widget, hint = \ controller.render_ticket_action_control(req, t, action) widgets.append(widget) hints.append(to_unicode(hint).rstrip('.') + '.') first_label = first_label == None and label or first_label controls = self._extract_action_controls(widgets) actions.append((action, first_label, " ".join(hints), controls)) return actions def get(self, req, id): """ Fetch a ticket. Returns [id, time_created, time_changed, attributes]. """ t = model.Ticket(self.env, id) req.perm(t.resource).require('TICKET_VIEW') changetime = t['changetime'] t['_ts'] = str(to_utimestamp(changetime)) return (t.id, t['time'], changetime, t.values) def create(self, req, summary, description, attributes={}, notify=False, when=None): """ Create a new ticket, returning the ticket ID. Overriding 'when' requires admin permission. """ if not summary: raise TracError("Tickets must contain a summary.") t = model.Ticket(self.env) t['summary'] = summary t['description'] = description t['reporter'] = req.authname for k, v in iteritems(attributes): t[k] = v t['status'] = 'new' t['resolution'] = '' # custom create timestamp? if when and not 'TICKET_ADMIN' in req.perm: self.log.warn("RPC ticket.create: %r not allowed to create with " "non-current timestamp (%r)", req.authname, when) when = to_datetime(None, utc) t.insert(when=when) if notify: self._notify_created_event(t, when, req.authname) return t.id def update(self, req, id, comment, attributes={}, notify=False, author='', when=None): """ Update a ticket, returning the new ticket in the same form as get(). 'New-style' call requires two additional items in attributes: (1) 'action' for workflow support (including any supporting fields as retrieved by getActions()), (2) '_ts' changetime token for detecting update collisions (as received from get() or update() calls). ''Calling update without 'action' and '_ts' changetime token is deprecated, and will raise errors in a future version.'' """ t = model.Ticket(self.env, id) # custom author? if author and not (req.authname == 'anonymous' \ or 'TICKET_ADMIN' in req.perm(t.resource)): # only allow custom author if anonymous is permitted or user is admin self.log.warn("RPC ticket.update: %r not allowed to change author " "to %r for comment on #%d", req.authname, author, id) author = '' author = author or req.authname # custom change timestamp? if when and not 'TICKET_ADMIN' in req.perm(t.resource): self.log.warn("RPC ticket.update: %r not allowed to update #%d " "with non-current timestamp (%r)", author, id, when) when = None when = when or to_datetime(None, utc) # never try to update 'time' and 'changetime' attributes directly if 'time' in attributes: del attributes['time'] if 'changetime' in attributes: del attributes['changetime'] ts = TicketSystem(self.env) all_fields = set(field['name'] for field in ts.get_ticket_fields()) # and action... if not 'action' in attributes: # FIXME: Old, non-restricted update - remove soon! self.log.warning("Rpc ticket.update for ticket %d by user %s " "has no workflow 'action'.", id, req.authname) req.perm(t.resource).require('TICKET_MODIFY') time_changed = attributes.pop('_ts', None) if time_changed and \ str(time_changed) != str(to_utimestamp(t['changetime'])): raise TracError("Ticket has been updated since last get().") for k, v in iteritems(attributes): if k in all_fields: t[k] = v t.save_changes(author, comment, when=when) else: tm = TicketModule(self.env) # TODO: Deprecate update without time_changed timestamp time_changed = attributes.pop('_ts', to_utimestamp(t['changetime'])) try: time_changed = int(time_changed) except ValueError: raise TracError("RPC ticket.update: Wrong '_ts' token " \ "in attributes (%r)." % time_changed) action = attributes.get('action') avail_actions = ts.get_available_actions(req, t) if not action in avail_actions: raise TracError("Rpc: Ticket %d by %s " \ "invalid action '%s'" % (id, req.authname, action)) controllers = list(tm._get_action_controllers(req, t, action)) for k, v in iteritems(attributes): if k in all_fields and k != 'status': t[k] = v # TicketModule reads req.args - need to move things there... req.args.update(attributes) req.args['comment'] = comment # Collision detection: 0.11+0.12 timestamp req.args['ts'] = str(from_utimestamp(time_changed)) # Collision detection: 0.13/1.0+ timestamp req.args['view_time'] = str(time_changed) changes, problems = tm.get_ticket_changes(req, t, action) for warning in problems: add_warning(req, "Rpc ticket.update: %s" % warning) valid = problems and False or tm._validate_ticket(req, t) if not valid: raise TracError( " ".join([warning for warning in req.chrome['warnings']])) else: tm._apply_ticket_changes(t, changes) self.log.debug("Rpc ticket.update save: %r", t.values) t.save_changes(author, comment, when=when) # Apply workflow side-effects for controller in controllers: controller.apply_action_side_effects(req, t, action) if notify: self._notify_changed_event(t, when, author, comment) return self.get(req, t.id) def delete(self, req, id): """ Delete ticket with the given id. """ t = model.Ticket(self.env, id) req.perm(t.resource).require('TICKET_ADMIN') t.delete() def changeLog(self, req, id, when=0): t = model.Ticket(self.env, id) req.perm(t.resource).require('TICKET_VIEW') for date, author, field, old, new, permanent in t.get_changelog(when): yield (date, author, field, old, new, permanent) # Use existing documentation from Ticket model changeLog.__doc__ = inspect.getdoc(model.Ticket.get_changelog) def listAttachments(self, req, ticket): """ Lists attachments for a given ticket. Returns (filename, description, size, time, author) for each attachment.""" for a in Attachment.select(self.env, 'ticket', ticket): if 'ATTACHMENT_VIEW' in req.perm(a.resource): yield (a.filename, a.description, a.size, a.date, a.author) def getAttachment(self, req, ticket, filename): """ returns the content of an attachment. """ attachment = Attachment(self.env, 'ticket', ticket, filename) req.perm(attachment.resource).require('ATTACHMENT_VIEW') return Binary(attachment.open().read()) def putAttachment(self, req, ticket, filename, description, data, replace=True): """ Add an attachment, optionally (and defaulting to) overwriting an existing one. Returns filename.""" if not model.Ticket(self.env, ticket).exists: raise ResourceNotFound('Ticket "%s" does not exist' % ticket) if replace: try: attachment = Attachment(self.env, 'ticket', ticket, filename) req.perm(attachment.resource).require('ATTACHMENT_DELETE') attachment.delete() except TracError: pass attachment = Attachment(self.env, 'ticket', ticket) req.perm(attachment.resource).require('ATTACHMENT_CREATE') attachment.author = req.authname attachment.description = description attachment.insert(filename, io.BytesIO(data.data), len(data.data)) return attachment.filename def deleteAttachment(self, req, ticket, filename): """ Delete an attachment. """ if not model.Ticket(self.env, ticket).exists: raise ResourceNotFound('Ticket "%s" does not exists' % ticket) attachment = Attachment(self.env, 'ticket', ticket, filename) req.perm(attachment.resource).require('ATTACHMENT_DELETE') attachment.delete() return True def getTicketFields(self, req): """ Return a list of all ticket fields fields. """ return TicketSystem(self.env).get_ticket_fields() # Internal methods def _extract_action_controls(self, widgets): def unescape(value): if isinstance(value, Markup): return value.unescape() return value def walk(fragment, controls): for child in fragment.children: if isinstance(child, Element): tag = child.tag if tag == 'input': attrib = child.attrib controls.append((unescape(attrib.get('name')), unescape(attrib.get('value')), [])) elif tag == 'select': selected = '' options = [] for opt in child.children: if opt.tag != 'option': continue if 'value' in opt.attrib: option = unescape(opt.attrib.get('value')) else: option = ''.join(map(unescape, opt.children)) options.append(option) if 'selected' in opt.attrib: selected = option controls.append((unescape(child.attrib.get('name')), selected, options)) continue if isinstance(child, Fragment): walk(child, controls) continue return controls return walk(widgets, []) def _notify_created_event(self, ticket, when, author): try: self._notify_event(ticket, when, author, True, None) except Exception as e: self.log.warning("Failure sending notification on creation of " "ticket #%s: %s", ticket.id, exception_to_unicode(e)) def _notify_changed_event(self, ticket, when, author, comment): try: self._notify_event(ticket, when, author, False, comment) except Exception as e: self.log.warning("Failure sending notification on changed of " "ticket #%s: %s", ticket.id, exception_to_unicode(e)) if NotificationSystem: def _notify_event(self, ticket, when, author, newticket, comment): if newticket: event = TicketChangeEvent('created', ticket, when, author) else: event = TicketChangeEvent('changed', ticket, when, author, comment) NotificationSystem(self.env).notify(event) else: def _notify_event(self, ticket, when, author, newticket, comment): tn = TicketNotifyEmail(self.env) tn.notify(ticket, newticket=newticket, modtime=when) class StatusRPC(Component): """ An interface to Trac ticket status objects. Note: Status is defined by workflow, and all methods except `getAll()` are deprecated no-op methods - these will be removed later. """ implements(IXMLRPCHandler) # IXMLRPCHandler methods def xmlrpc_namespace(self): return 'ticket.status' def xmlrpc_methods(self): yield ('TICKET_VIEW', ((list,),), self.getAll) yield ('TICKET_VIEW', ((dict, str),), self.get) yield ('TICKET_ADMIN', ((None, str,),), self.delete) yield ('TICKET_ADMIN', ((None, str, dict),), self.create) yield ('TICKET_ADMIN', ((None, str, dict),), self.update) def getAll(self, req): """ Returns all ticket states described by active workflow. """ return TicketSystem(self.env).get_all_status() def get(self, req, name): """ Deprecated no-op method. Do not use. """ # FIXME: Remove return '0' def delete(self, req, name): """ Deprecated no-op method. Do not use. """ # FIXME: Remove return 0 def create(self, req, name, attributes): """ Deprecated no-op method. Do not use. """ # FIXME: Remove return 0 def update(self, req, name, attributes): """ Deprecated no-op method. Do not use. """ # FIXME: Remove return 0 def ticketModelFactory(cls, cls_attributes): """ Return a class which exports an interface to trac.ticket.model.. """ class TicketModelImpl(Component): implements(IXMLRPCHandler) def xmlrpc_namespace(self): return 'ticket.' + cls.__name__.lower() def xmlrpc_methods(self): yield ('TICKET_VIEW', ((list,),), self.getAll) yield ('TICKET_VIEW', ((dict, str),), self.get) yield ('TICKET_ADMIN', ((None, str,),), self.delete) yield ('TICKET_ADMIN', ((None, str, dict),), self.create) yield ('TICKET_ADMIN', ((None, str, dict),), self.update) def getAll(self, req): for i in cls.select(self.env): yield i.name getAll.__doc__ = """ Get a list of all ticket %s names. """ % cls.__name__.lower() def get(self, req, name): i = cls(self.env, name) attributes= {} for k, default in iteritems(cls_attributes): v = getattr(i, k) if v is None: v = default attributes[k] = v return attributes get.__doc__ = """ Get a ticket %s. """ % cls.__name__.lower() def delete(self, req, name): cls(self.env, name).delete() delete.__doc__ = """ Delete a ticket %s """ % cls.__name__.lower() def create(self, req, name, attributes): i = cls(self.env) i.name = name for k, v in iteritems(attributes): setattr(i, k, v) i.insert() create.__doc__ = """ Create a new ticket %s with the given attributes. """ % cls.__name__.lower() def update(self, req, name, attributes): self._updateHelper(name, attributes).update() update.__doc__ = """ Update ticket %s with the given attributes. """ % cls.__name__.lower() def _updateHelper(self, name, attributes): i = cls(self.env, name) for k, v in iteritems(attributes): setattr(i, k, v) return i TicketModelImpl.__doc__ = """ Interface to ticket %s objects. """ % cls.__name__.lower() TicketModelImpl.__name__ = '%sRPC' % cls.__name__ return TicketModelImpl def ticketEnumFactory(cls): """ Return a class which exports an interface to one of the Trac ticket abstract enum types. """ class AbstractEnumImpl(Component): implements(IXMLRPCHandler) def xmlrpc_namespace(self): return 'ticket.' + cls.__name__.lower() def xmlrpc_methods(self): yield ('TICKET_VIEW', ((list,),), self.getAll) yield ('TICKET_VIEW', ((str, str),), self.get) yield ('TICKET_ADMIN', ((None, str,),), self.delete) yield ('TICKET_ADMIN', ((None, str, str),), self.create) yield ('TICKET_ADMIN', ((None, str, str),), self.update) def getAll(self, req): for i in cls.select(self.env): yield i.name getAll.__doc__ = """ Get a list of all ticket %s names. """ % cls.__name__.lower() def get(self, req, name): if (cls.__name__ == 'Status'): i = cls(self.env) x = name else: i = cls(self.env, name) x = i.value return x get.__doc__ = """ Get a ticket %s. """ % cls.__name__.lower() def delete(self, req, name): cls(self.env, name).delete() delete.__doc__ = """ Delete a ticket %s """ % cls.__name__.lower() def create(self, req, name, value): i = cls(self.env) i.name = name i.value = value i.insert() create.__doc__ = """ Create a new ticket %s with the given value. """ % cls.__name__.lower() def update(self, req, name, value): self._updateHelper(name, value).update() update.__doc__ = """ Update ticket %s with the given value. """ % cls.__name__.lower() def _updateHelper(self, name, value): i = cls(self.env, name) i.value = value return i AbstractEnumImpl.__doc__ = """ Interface to ticket %s. """ % cls.__name__.lower() AbstractEnumImpl.__name__ = '%sRPC' % cls.__name__ return AbstractEnumImpl ticketModelFactory(model.Component, {'name': '', 'owner': '', 'description': ''}) ticketModelFactory(model.Version, {'name': '', 'time': 0, 'description': ''}) ticketModelFactory(model.Milestone, {'name': '', 'due': 0, 'completed': 0, 'description': ''}) ticketEnumFactory(model.Type) ticketEnumFactory(model.Resolution) ticketEnumFactory(model.Priority) ticketEnumFactory(model.Severity) trunk/tracrpc/util.py000644 000000 000000 00000004303 14621300126 013347 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2005-2008 ::: Alec Thomas (alec@swapoff.org) (c) 2009-2013 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import functools import inspect import sys from trac.util.translation import dgettext, domain_functions # Supported Python versions: PY24 = sys.version_info[:2] == (2, 4) PY25 = sys.version_info[:2] == (2, 5) PY26 = sys.version_info[:2] == (2, 6) PY27 = sys.version_info[:2] == (2, 7) PY3 = sys.version_info[0] > 2 if sys.version_info[0] == 2: unicode = unicode basestring = basestring unichr = unichr iteritems = lambda d: d.iteritems() from itertools import izip import xmlrpclib else: unicode = str basestring = str unichr = chr iteritems = lambda d: d.items() izip = zip from xmlrpc import client as xmlrpclib i18n_domain = 'tracrpc' add_domain, ngettext, tag_ = domain_functions( i18n_domain, ('add_domain', 'ngettext', 'tag_')) # XXX Use directly `dgettext` instead of `gettext` returned from # `domain_functions` because translation doesn't work caused by multiple # white-spaces in msgid are replaced by single space. _ = gettext = functools.partial(dgettext, i18n_domain) try: from trac.util.translation import cleandoc_ except ImportError: cleandoc_ = lambda message: inspect.cleandoc(message).strip() getargspec = inspect.getfullargspec \ if hasattr(inspect, 'getfullargspec') else \ inspect.getargspec try: from trac.web.chrome import web_context except ImportError: from trac.mimeview.api import Context web_context = Context.from_request del Context def accepts_mimetype(req, mimetype): if isinstance(mimetype, basestring): mimetype = (mimetype,) accept = req.get_header('Accept') if accept is None: # Don't make judgements if no MIME type expected and method is GET return req.method == 'GET' else: accept = accept.split(',') return any(x.strip().startswith(y) for x in accept for y in mimetype) def to_b(value): if isinstance(value, unicode): return value.encode('utf-8') if isinstance(value, bytes): return value raise TypeError(str(type(value))) trunk/tracrpc/web_ui.py000644 000000 000000 00000023021 14621300126 013642 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2005-2008 ::: Alec Thomas (alec@swapoff.org) (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import pkg_resources import types from trac.core import Component, ExtensionPoint, TracError, implements from trac.env import IEnvironmentSetupParticipant from trac.perm import PermissionError from trac.resource import ResourceNotFound from trac.util.html import tag from trac.util.text import exception_to_unicode, to_unicode from trac.util.translation import dtgettext from trac.web.api import RequestDone, HTTPUnsupportedMediaType from trac.web.main import IRequestHandler from trac.web.chrome import ITemplateProvider, INavigationContributor, \ add_stylesheet, add_script, Chrome from trac.wiki.formatter import format_to_oneliner from . import __version__ from .api import (XMLRPCSystem, IRPCProtocol, ProtocolException, ServiceException, api_version) from .util import (accepts_mimetype, to_b, i18n_domain, add_domain, _, web_context) try: from trac.web.api import HTTPInternalError as HTTPInternalServerError except ImportError: # Trac 1.3.1+ from trac.web.api import HTTPInternalServerError __all__ = ['RPCWeb'] _use_jinja2 = hasattr(Chrome, 'jenv') class RPCWeb(Component): """ Handle RPC calls from HTTP clients, as well as presenting a list of methods available to the currently logged in user. Browsing to /rpc or /login/rpc will display this list. """ implements(IEnvironmentSetupParticipant, IRequestHandler, ITemplateProvider, INavigationContributor) protocols = ExtensionPoint(IRPCProtocol) def __init__(self): try: locale_dir = pkg_resources.resource_filename(__name__, 'locale') except KeyError: pass else: add_domain(self.env.path, locale_dir) # IEnvironmentSetupParticipant methods def environment_created(self, *args, **kwargs): pass def environment_needs_upgrade(self, *args, **kwargs): return False def upgrade_environment(self, *args, **kwargs): pass # IRequestHandler methods def match_request(self, req): """ Look for available protocols serving at requested path and content-type. """ content_type = req.get_header('Content-Type') if content_type: content_type = content_type.split(';', 1)[0].strip().lower() must_handle_request = req.path_info in ('/rpc', '/login/rpc') for protocol in self.protocols: for p_path, p_type in protocol.rpc_match(): if req.path_info in ['/%s' % p_path, '/login/%s' % p_path]: must_handle_request = True if content_type == p_type: req.args['protocol'] = protocol return True # No protocol call, need to handle for docs or error if handled path return must_handle_request def process_request(self, req): protocol = req.args.get('protocol', None) content_type = req.get_header('Content-Type') or '' if protocol: # Perform the method call self.log.debug("RPC incoming request of content type %r " "dispatched to %r", content_type, protocol) self._rpc_process(req, protocol, content_type) elif accepts_mimetype(req, 'text/html'): return self._dump_docs(req) else: # Attempt at API call gone wrong. Raise a plain-text 415 error body = "No protocol matching Content-Type '%s' at path '%s'." % \ (content_type, req.path_info) self.log.error(body) # Close connection without reading request body req.send_header('Connection', 'close') req.send(to_b(body), 'text/plain', HTTPUnsupportedMediaType.code) # Internal methods def _dump_docs(self, req): self.log.debug("Rendering docs") # Dump RPC documentation req.perm.require('XML_RPC') # Need at least XML_RPC namespaces = {} ctxt = web_context(req) for method in XMLRPCSystem(self.env).all_methods(req): namespace = method.namespace.replace('.', '_') if namespace not in namespaces: namespaces[namespace] = { 'description': format_to_oneliner(self.env, ctxt, method.namespace_description), 'methods': [], 'namespace': method.namespace, } try: namespaces[namespace]['methods'].append( (method.signature, format_to_oneliner(self.env, ctxt, method.description), method.permission)) except Exception as e: raise Exception('%s: %s%s' % (method.name, e, exception_to_unicode(e, traceback=True))) add_stylesheet(req, 'common/css/wiki.css') add_stylesheet(req, 'tracrpc/rpc.css') add_script(req, 'tracrpc/rpc.js') data = { 'rpc': { 'functions': namespaces, 'protocols': [self._rpc_protocol(req, protocol) for protocol in self.protocols], 'version': __version__, }, 'domain': i18n_domain, } if _use_jinja2: data['dtgettext'] = dtgettext return 'rpc_jinja.html', data else: data['tag'] = tag return 'rpc.html', data, None def _rpc_protocol(self, req, protocol): label, desc = protocol.rpc_info() desc %= { 'url_anon': req.abs_href('rpc'), 'url_auth': req.abs_href('login', 'rpc') \ .replace('//', '//%s:your_password@' % req.authname), 'version': list(api_version), } return label, desc, list(protocol.rpc_match()) def _rpc_process(self, req, protocol, content_type): """Process incoming RPC request and finalize response.""" proto_id = protocol.rpc_info()[0] rpcreq = req.rpc = {'mimetype': content_type} self.log.debug("RPC(%s) call by '%s'", proto_id, req.authname) try: if req.path_info.startswith('/login/') and \ req.authname == 'anonymous': raise TracError("Authentication information not available") rpcreq = req.rpc = protocol.parse_rpc_request(req, content_type) rpcreq['mimetype'] = content_type # Important ! Check after parsing RPC request to add # protocol-specific fields in response # (e.g. JSON-RPC response `id`) req.perm.require('XML_RPC') # Need at least XML_RPC method_name = rpcreq.get('method') if method_name is None: raise ProtocolException('Missing method name') args = rpcreq.get('params') or [] self.log.debug("RPC(%s) call by '%s' %s", proto_id, req.authname, method_name) try: result = (XMLRPCSystem(self.env).get_method(method_name)(req, args))[0] if isinstance(result, types.GeneratorType): result = list(result) except (TracError, PermissionError, ResourceNotFound): raise except Exception as e: self.log.error("RPC(%s) [%s] Exception caught while calling " "%s(*%r) by %s%s", proto_id, req.remote_addr, method_name, args, req.authname, exception_to_unicode(e, traceback=True)) raise ServiceException(e) else: protocol.send_rpc_result(req, result) except RequestDone: raise except (TracError, PermissionError, ResourceNotFound) as e: if type(e) is not ServiceException: self.log.warning("RPC(%s) [%s] %s", proto_id, req.remote_addr, exception_to_unicode(e)) try: protocol.send_rpc_error(req, e) except RequestDone: raise except Exception as e: self.log.exception("RPC(%s) Unhandled protocol error", proto_id) self._send_unknown_error(req, e) except Exception as e: self.log.exception("RPC(%s) Unhandled protocol error", proto_id) self._send_unknown_error(req, e) def _send_unknown_error(self, req, e): """Last recourse if protocol cannot handle the RPC request | error""" method_name = req.rpc and req.rpc.get('method') or '(undefined)' body = "Unhandled protocol error calling '%s': %s" % ( method_name, to_unicode(e)) req.send(to_b(body), 'text/plain', HTTPInternalServerError.code) # ITemplateProvider methods def get_htdocs_dirs(self): yield ('tracrpc', pkg_resources.resource_filename(__name__, 'htdocs')) def get_templates_dirs(self): yield pkg_resources.resource_filename(__name__, 'templates') # INavigationContributor methods def get_active_navigation_item(self, req): pass def get_navigation_items(self, req): if req.perm.has_permission('XML_RPC'): yield ('metanav', 'rpc', tag.a(_("API"), href=req.href.rpc(), accesskey=1)) trunk/tracrpc/wiki.py000644 000000 000000 00000023456 14402744352 013361 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2005-2008 ::: Alec Thomas (alec@swapoff.org) (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import io import os from datetime import datetime from trac.attachment import Attachment from trac.core import Component, ExtensionPoint, TracError, implements from trac.resource import Resource, ResourceNotFound from trac.util.datefmt import from_utimestamp, to_utimestamp from trac.util.text import to_unicode from trac.wiki.api import WikiSystem, IWikiPageManipulator from trac.wiki.model import WikiPage from trac.wiki.formatter import format_to_html from .api import IXMLRPCHandler, Binary, unicode from .util import getargspec, web_context __all__ = ['WikiRPC'] if 'remote_addr' in getargspec(WikiPage.save)[0]: def _page_save(page, author, comment, remote_addr=None, t=None): page.save(author, comment, remote_addr=remote_addr, t=t) else: def _page_save(page, author, comment, remote_addr=None, t=None): page.save(author, comment, t=t) class WikiRPC(Component): """Superset of the [http://www.jspwiki.org/Wiki.jsp?page=WikiRPCInterface2 WikiRPC API]. """ implements(IXMLRPCHandler) manipulators = ExtensionPoint(IWikiPageManipulator) def __init__(self): self.wiki = WikiSystem(self.env) def xmlrpc_namespace(self): return 'wiki' def xmlrpc_methods(self): yield (None, ((dict, datetime),), self.getRecentChanges) yield ('WIKI_VIEW', ((int,),), self.getRPCVersionSupported) yield (None, ((str, str), (str, str, int),), self.getPage) yield (None, ((str, str, int),), self.getPage, 'getPageVersion') yield (None, ((str, str), (str, str, int)), self.getPageHTML) yield (None, ((str, str), (str, str, int)), self.getPageHTML, 'getPageHTMLVersion') yield (None, ((list,),), self.getAllPages) yield (None, ((dict, str), (dict, str, int)), self.getPageInfo) yield (None, ((dict, str, int),), self.getPageInfo, 'getPageInfoVersion') yield (None, ((bool, str, str, dict),), self.putPage) yield (None, ((list, str),), self.listAttachments) yield (None, ((Binary, str),), self.getAttachment) yield (None, ((bool, str, Binary),), self.putAttachment) yield (None, ((bool, str, str, str, Binary), (bool, str, str, str, Binary, bool)), self.putAttachmentEx) yield (None, ((bool, str),(bool, str, int)), self.deletePage) yield (None, ((bool, str),), self.deleteAttachment) yield ('WIKI_VIEW', ((list, str),), self.listLinks) yield ('WIKI_VIEW', ((str, str),), self.wikiToHtml) def _fetch_page(self, req, pagename, version=None): # Helper for getting the WikiPage that performs basic checks page = WikiPage(self.env, pagename, version) req.perm(page.resource).require('WIKI_VIEW') if page.exists: return page else: msg = 'Wiki page "%s" does not exist' % pagename if version is not None: msg += ' at version %s' % version raise ResourceNotFound(msg) def _page_info(self, name, when, author, version, comment): return dict(name=name, lastModified=when, author=author, version=int(version), comment=comment) def getRecentChanges(self, req, since): """ Get list of changed pages since timestamp """ since = to_utimestamp(since) wiki_realm = Resource('wiki') query = ('SELECT name, time, author, version, comment ' 'FROM wiki w1 ' 'WHERE time >= %s ' 'AND version = (SELECT MAX(version) ' ' FROM wiki w2 ' ' WHERE w2.name=w1.name) ' 'ORDER BY time DESC') if hasattr(self.env, 'db_query'): generator = self.env.db_query(query, (since,)) else: db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute(query, (since,)) generator = cursor result = [] for name, when, author, version, comment in generator: if 'WIKI_VIEW' in req.perm(wiki_realm(id=name, version=version)): result.append( self._page_info(name, from_utimestamp(when), author, version, comment)) return result def getRPCVersionSupported(self, req): """ Returns 2 with this version of the Trac API. """ return 2 def getPage(self, req, pagename, version=None): """ Get the raw Wiki text of page, latest version. """ page = self._fetch_page(req, pagename, version) return page.text def getPageHTML(self, req, pagename, version=None): """ Return latest version of page as rendered HTML, utf8 encoded. """ page = self._fetch_page(req, pagename, version) fields = {'text': page.text} for manipulator in self.manipulators: manipulator.prepare_wiki_page(req, page, fields) context = web_context(req, page.resource, absurls=True) html = unicode(format_to_html(self.env, context, fields['text'])) return '%s' % html def getAllPages(self, req): """ Returns a list of all pages. The result is an array of utf8 pagenames. """ pages = [] for page in self.wiki.get_pages(): if 'WIKI_VIEW' in req.perm(Resource('wiki', page)): pages.append(page) return pages def getPageInfo(self, req, pagename, version=None): """ Returns information about the given page. """ page = WikiPage(self.env, pagename, version) req.perm(page.resource).require('WIKI_VIEW') if page.exists: for item in page.get_history(): return self._page_info(page.name, item[1], item[2], page.version, page.comment) def putPage(self, req, pagename, content, attributes): """ writes the content of the page. """ page = WikiPage(self.env, pagename) if page.readonly: req.perm(page.resource).require('WIKI_ADMIN') elif not page.exists: req.perm(page.resource).require('WIKI_CREATE') else: req.perm(page.resource).require('WIKI_MODIFY') page.text = content if req.perm(page.resource).has_permission('WIKI_ADMIN'): page.readonly = attributes.get('readonly') and 1 or 0 _page_save(page, attributes.get('author', req.authname), attributes.get('comment'), remote_addr=req.remote_addr) return True def deletePage(self, req, name, version=None): """Delete a Wiki page (all versions) or a specific version by including an optional version number. Attachments will also be deleted if page no longer exists. Returns True for success.""" wp = WikiPage(self.env, name, version) req.perm(wp.resource).require('WIKI_DELETE') try: wp.delete(version) return True except: return False def listAttachments(self, req, pagename): """ Lists attachments on a given page. """ for a in Attachment.select(self.env, 'wiki', pagename): if 'ATTACHMENT_VIEW' in req.perm(a.resource): yield pagename + '/' + a.filename def getAttachment(self, req, path): """ returns the content of an attachment. """ pagename, filename = os.path.split(path) attachment = Attachment(self.env, 'wiki', pagename, filename) req.perm(attachment.resource).require('ATTACHMENT_VIEW') return Binary(attachment.open().read()) def putAttachment(self, req, path, data): """ (over)writes an attachment. Returns True if successful. This method is compatible with WikiRPC. `putAttachmentEx` has a more extensive set of (Trac-specific) features. """ pagename, filename = os.path.split(path) self.putAttachmentEx(req, pagename, filename, None, data) return True def putAttachmentEx(self, req, pagename, filename, description, data, replace=True): """ Attach a file to a Wiki page. Returns the (possibly transformed) filename of the attachment. Use this method if you don't care about WikiRPC compatibility. """ if not WikiPage(self.env, pagename).exists: raise ResourceNotFound('Wiki page "%s" does not exist' % pagename) if replace: try: attachment = Attachment(self.env, 'wiki', pagename, filename) req.perm(attachment.resource).require('ATTACHMENT_DELETE') attachment.delete() except TracError: pass attachment = Attachment(self.env, 'wiki', pagename) req.perm(attachment.resource).require('ATTACHMENT_CREATE') attachment.author = req.authname attachment.description = description attachment.insert(filename, io.BytesIO(data.data), len(data.data)) return attachment.filename def deleteAttachment(self, req, path): """ Delete an attachment. """ pagename, filename = os.path.split(path) if not WikiPage(self.env, pagename).exists: raise ResourceNotFound('Wiki page "%s" does not exist' % pagename) attachment = Attachment(self.env, 'wiki', pagename, filename) req.perm(attachment.resource).require('ATTACHMENT_DELETE') attachment.delete() return True def listLinks(self, req, pagename): """ ''Not implemented'' """ return [] def wikiToHtml(self, req, text): """ Render arbitrary Wiki text as HTML. """ context = web_context(req, absurls=1) return(to_unicode(format_to_html(self.env, context, text))) trunk/tracrpc/xml_rpc.py000644 000000 000000 00000020746 14621300126 014047 0ustar00000000 000000 # -*- coding: utf-8 -*- """ License: BSD (c) 2005-2008 ::: Alec Thomas (alec@swapoff.org) (c) 2009 ::: www.CodeResort.com - BV Network AS (simon-code@bvnetwork.no) """ import datetime import re import sys import time try: import babel except ImportError: babel = None from trac.core import Component, implements from trac.perm import PermissionError from trac.resource import ResourceNotFound from trac.util.datefmt import utc from trac.util.html import Fragment, Markup from trac.util.text import empty, exception_to_unicode, to_unicode from trac.web.api import RequestDone from .api import (IRPCProtocol, Binary, MethodNotFound, ProtocolException, ServiceException) from .util import basestring, cleandoc_, gettext, unichr, xmlrpclib __all__ = ['XmlRpcProtocol'] REPLACEMENT_CHAR = u'\uFFFD' # Unicode replacement character _illegal_unichrs = [(0x00, 0x08), (0x0B, 0x0C), (0x0E, 0x1F), (0x7F, 0x84), (0x86, 0x9F), (0xFDD0, 0xFDDF), (0xFFFE, 0xFFFF)] if sys.maxunicode >= 0x10000: # not narrow build _illegal_unichrs.extend([(0x1FFFE, 0x1FFFF), (0x2FFFE, 0x2FFFF), (0x3FFFE, 0x3FFFF), (0x4FFFE, 0x4FFFF), (0x5FFFE, 0x5FFFF), (0x6FFFE, 0x6FFFF), (0x7FFFE, 0x7FFFF), (0x8FFFE, 0x8FFFF), (0x9FFFE, 0x9FFFF), (0xAFFFE, 0xAFFFF), (0xBFFFE, 0xBFFFF), (0xCFFFE, 0xCFFFF), (0xDFFFE, 0xDFFFF), (0xEFFFE, 0xEFFFF), (0xFFFFE, 0xFFFFF), (0x10FFFE, 0x10FFFF)]) _illegal_ranges = ["%s-%s" % (unichr(low), unichr(high)) for (low, high) in _illegal_unichrs] _illegal_xml_chars_RE = re.compile(u'[%s]' % u''.join(_illegal_ranges)) def to_xmlrpc_datetime(dt): """ Convert a datetime.datetime object to a xmlrpclib DateTime object """ return xmlrpclib.DateTime(dt.utctimetuple()) def from_xmlrpc_datetime(data): """Return datetime (in utc) from XMLRPC datetime string (is always utc)""" t = list(time.strptime(data.value, "%Y%m%dT%H:%M:%S")[0:6]) return datetime.datetime(*t, tzinfo=utc) class XmlRpcProtocol(Component): _description = cleandoc_(r""" There should be XML-RPC client implementations available for all popular programming languages. Example call using `curl`: {{{ user: ~ > cat body.xml wiki.getPage WikiStart user: ~ > curl -H "Content-Type: application/xml" --data @body.xml %(url_anon)s = Welcome to.... }}} The following snippet illustrates how to perform authenticated calls in Python. {{{ >>> try: ... from xmlrpc import client as cli ... except ImportError: ... import xmlrpclib as cli ... >>> p = cli.ServerProxy(%(url_auth)r) >>> p.system.getAPIVersion() %(version)r }}} """) implements(IRPCProtocol) # IRPCProtocol methods def rpc_info(self): return 'XML-RPC', gettext(self._description) def rpc_match(self): # Legacy path xmlrpc provided for backwards compatibility: # Using this order to get better docs yield 'rpc', 'application/xml' yield 'xmlrpc', 'application/xml' yield 'rpc', 'text/xml' yield 'xmlrpc', 'text/xml' def parse_rpc_request(self, req, content_type): """ Parse XML-RPC requests.""" try: args, method = xmlrpclib.loads( req.read(int(req.get_header('Content-Length')))) except Exception as e: self.log.debug("RPC(xml) parse error: %s", to_unicode(e)) raise ProtocolException(xmlrpclib.Fault(-32700, to_unicode(e))) else: self.log.debug("RPC(xml) call by '%s', method '%s' with args: %s", req.authname, method, repr(args)) args = self._normalize_xml_input(args) return {'method': method, 'params': args} def send_rpc_result(self, req, result): """Send the result of the XML-RPC call back to the client.""" rpcreq = req.rpc method = rpcreq.get('method') self.log.debug("RPC(xml) '%s' result: %s", method, repr(result)) result = tuple(self._normalize_xml_output([result])) self._send_response(req, xmlrpclib.dumps(result, methodresponse=True), rpcreq['mimetype']) def send_rpc_error(self, req, e): """Send an XML-RPC fault message back to the caller""" rpcreq = req.rpc fault = None if isinstance(e, ProtocolException): fault = e._exc elif isinstance(e, ServiceException): e = e._exc elif isinstance(e, MethodNotFound): fault = xmlrpclib.Fault(-32601, to_unicode(e)) elif isinstance(e, PermissionError): fault = xmlrpclib.Fault(403, to_unicode(e)) elif isinstance(e, ResourceNotFound): fault = xmlrpclib.Fault(404, to_unicode(e)) if fault is not None: self._send_response(req, xmlrpclib.dumps(fault), rpcreq['mimetype']) else: self.log.error('%s%s', e, exception_to_unicode(e, traceback=True)) err_code = hasattr(e, 'code') and e.code or 1 method = rpcreq.get('method') self._send_response(req, xmlrpclib.dumps( xmlrpclib.Fault(err_code, "'%s' while executing '%s()'" % (str(e), method))), rpcreq['mimetype']) # Internal methods def _send_response(self, req, response, content_type='application/xml'): response = to_unicode(response) response = _illegal_xml_chars_RE.sub(REPLACEMENT_CHAR, response) response = response.encode("utf-8") req.send_response(200) req.send_header('Content-Type', content_type) req.send_header('Content-Length', len(response)) req.end_headers() req.write(response) raise RequestDone def _normalize_xml_input(self, args): """ Normalizes arguments (at any level - traversing dicts and lists): 1. xmlrpc.DateTime is converted to Python datetime 2. tracrpc.api.Binary => xmlrpclib.Binary 2. String line-endings same as from web (`\n` => `\r\n`) """ new_args = [] for arg in args: # self.env.log.debug("arg %s, type %s" % (arg, type(arg))) if isinstance(arg, xmlrpclib.DateTime): new_args.append(from_xmlrpc_datetime(arg)) elif isinstance(arg, xmlrpclib.Binary): arg.__class__ = Binary new_args.append(arg) elif isinstance(arg, basestring): new_args.append(arg.replace("\n", "\r\n")) elif isinstance(arg, dict): for key, val in arg.items(): arg[key], = self._normalize_xml_input([val]) new_args.append(arg) elif isinstance(arg, (list, tuple)): new_args.append(self._normalize_xml_input(arg)) else: new_args.append(arg) return new_args def _normalize_xml_output(self, result): """ Normalizes and converts output (traversing it): 1. None => '' 2. datetime => xmlrpclib.DateTime 3. Binary => xmlrpclib.Binary 4. Fragment|Markup => unicode """ new_result = [] for res in result: if isinstance(res, datetime.datetime): new_result.append(to_xmlrpc_datetime(res)) elif isinstance(res, Binary): res.__class__ = xmlrpclib.Binary new_result.append(res) elif res is None or res is empty: new_result.append('') elif isinstance(res, (Fragment, Markup)): new_result.append(to_unicode(res)) elif babel and isinstance(res, babel.support.LazyProxy): new_result.append(to_unicode(res)) elif isinstance(res, dict): for key, val in res.items(): res[key], = self._normalize_xml_output([val]) new_result.append(res) elif isinstance(res, list) or isinstance(res, tuple): new_result.append(self._normalize_xml_output(res)) else: new_result.append(res) return new_result