#!/usr/bin/env python # Copyright (C) 2012 Aleksey Lim # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import os import re import sys import shlex import types import locale import gettext from json import dumps, loads from os.path import join, exists, isfile from gevent import monkey from sugar_network import db, client, toolkit from sugar_network.model import RESOURCES from sugar_network.client import IPCConnection, Connection from sugar_network.client.routes import ClientRoutes from sugar_network.toolkit.router import Router, Request, Response from sugar_network.toolkit import application, coroutine from sugar_network.toolkit import Option, BUFFER_SIZE, enforce gettext.textdomain('sugar-network') quiet = Option( 'turn off any output', default=False, type_cast=Option.bool_cast, action='store_true', name='quiet') porcelain = Option( 'give the output in an easy-to-parse format for scripts', default=False, type_cast=Option.bool_cast, action='store_true', short_option='-P', name='porcelain') post_data = Option( 'send content as a string from POST or PUT command', name='post_data', short_option='-d') post_file = Option( 'send content of the specified file from POST or PUT command', name='post_file', short_option='-f') json = Option( 'treat POST or PUT command content as a JSON data', name='json', short_option='-j', default=False, type_cast=Option.bool_cast, action='store_true') offline = Option( 'do not connect to Sugar Network server', default=False, type_cast=Option.bool_cast, action='store_true', name='offline') _ESCAPE_VALUE_RE = re.compile(r'([^\[\]\{\}0-9][^\]\[\{\}]+)') _LIST_RE = re.compile(r'\s*[;,:]+\s*') class ClientRouter(Router, ClientRoutes): def __init__(self): home = db.Volume(client.path('db'), RESOURCES) Router.__init__(self, self) ClientRoutes.__init__(self, home, client.api_url.value if not offline.value else None, no_subscription=True) if not offline.value: for __ in self.subscribe(event='inline', state='online'): break coroutine.dispatch() server = coroutine.WSGIServer( ('localhost', client.ipc_port.value), self) coroutine.spawn(server.serve_forever) coroutine.dispatch() class Application(application.Application): def __init__(self, **kwargs): application.Application.__init__(self, **kwargs) application.rundir.value = join(client.local_root.value, 'run') if not exists(toolkit.cachedir.value): os.makedirs(toolkit.cachedir.value) @application.command( 'launch a Sugar activity; the COMMAND-LINE-ARGUMENTS might ' 'include arguments supported by sugar-activity application', args='BUNDLE_ID [COMMAND-LINE-ARGUMENTS]', interspersed_args=False, ) def launch(self): enforce(self.check_for_instance(), 'No sugar-network-client session') ipc = IPCConnection() enforce(self.args, 'BUNDLE_ID was not specified') bundle_id = self.args.pop(0) params = {} if self.args: params['args'] = self.args ipc.get(['context', bundle_id], cmd='launch', **params) @application.command( 'upload new release for a context; if BUNDLE_PATH points ' 'not to a .xo bundle, specify all release PROPERTYs for the ' 'new release (at least context and version)', args='BUNDLE_PATH [PROPERTY=VALUE]', ) def release(self): enforce(self.args, 'BUNDLE_PATH was not specified') path = self.args.pop(0) enforce(isfile(path), 'Cannot open bundle') props = {} self._parse_args(props) if 'license' in props: value = [i for i in _LIST_RE.split(props['license'].strip()) if i] props['license'] = value if self.check_for_instance(): conn = IPCConnection() else: conn = Connection(client.api_url.value) guid = conn.upload(['release'], path, cmd='submit', **props) if porcelain.value: self._print(guid, '\n') else: self._print('-- Uploaded %s release' % guid, '\n') @application.command( 'send raw API POST request; ' 'specifies all ARGUMENTs the particular API call requires', args='PATH [ARGUMENT=VALUE]') def POST(self): self._request('POST', True, Response()) @application.command( 'send raw API PUT request; ' 'specifies all ARGUMENTs the particular API call requires', args='PATH [ARGUMENT=VALUE]') def PUT(self): self._request('PUT', True, Response()) @application.command( 'send raw API DELETE request', args='PATH') def DELETE(self): self._request('DELETE', False, Response()) @application.command( 'send raw API GET request; ' 'specifies all ARGUMENTs the particular API call requires', args='PATH [ARGUMENT=VALUE]') def GET(self): self._request('GET', False, Response()) @application.command( 'send raw API HEAD request; ' 'specifies all ARGUMENTs the particular API call requires', args='PATH [ARGUMENT=VALUE]') def HEAD(self): response = Response() self._request('HEAD', False, response) result = {} result.update(response) self._dump(result) def _request(self, method, post, response): request = Request(method=method) request.allow_redirects = True request.accept_encoding = '' if post: if post_data.value is None and post_file.value is None: json.value = True post_data.value = sys.stdin.read() if post_data.value: request.content = post_data.value.strip() elif post_file.value: with file(post_file.value, 'rb') as f: # TODO Avoid loading entire file request.content = f.read() request.content_type = 'application/octet-stream' if json.value: try: request.content = loads(request.content) request.content_type = 'application/json' except Exception: # TODO pass self._parse_path(request) self._parse_args(request) pid_path = None cp = None try: if self.check_for_instance(): cp = IPCConnection() else: pid_path = self.new_instance() cp = ClientRouter() result = cp.call(request, response) if result is None: pass elif response.content_type == 'application/json': self._dump(result) elif isinstance(result, types.GeneratorType): for chunk in result: self._dump(chunk) elif hasattr(result, 'read'): if response.content_type == 'text/event-stream': while True: chunk = toolkit.readline(result) if not chunk: break if chunk.startswith('data: '): self._dump(loads(chunk[6:])) else: while True: chunk = result.read(BUFFER_SIZE) if not chunk: break self._print(chunk) else: self._print(result, '\n') finally: if cp is not None: cp.close() if pid_path: os.unlink(pid_path) def _parse_path(self, request): if self.args and self.args[0].startswith('/'): request.path = self.args.pop(0).strip('/').split('/') def _parse_args(self, props): for arg in self.args: arg = shlex.split(arg) if not arg: continue arg = arg[0] if '=' in arg: arg, value = arg.split('=', 1) else: arg = arg value = 1 arg = arg.strip() enforce(arg, 'No argument name in %r expression', arg) if arg in props: if isinstance(props[arg], basestring): props[arg] = [props[arg]] props[arg].append(value) else: props[arg] = value def _dump(self, result): if not porcelain.value: self._print(dumps(result, indent=2, ensure_ascii=False), '\n') return def porcelain_dump(value): if type(value) is dict: if len(value) == 1: porcelain_dump(value.values()[0]) else: for i in sorted(value.items()): self._print('%-18s%s' % i, '\n') else: if type(value) not in (list, tuple): value = [value] term = '\n' if len(value) > 5 else '\t' for n, i in enumerate(value): if n: self._print(term) if type(i) is dict and len(i) == 1: i = i.values()[0] self._print('%s' % i) self._print('\n') if type(result) in (list, tuple): for i in result: porcelain_dump(i) elif type(result) is dict and \ 'total' in result and 'result' in result: for i in result['result']: porcelain_dump(i) else: porcelain_dump(result) def _print(self, *data): if not quiet.value: sys.stdout.write(''.join(data)) # Let toolkit.http work in concurrence monkey.patch_socket() monkey.patch_select() monkey.patch_ssl() monkey.patch_time() # New defaults application.debug.value = client.logger_level() # If tmpfs is mounted to /tmp, `os.fstat()` will return 0 free space # and will brake offline synchronization logic toolkit.cachedir.value = client.profile_path('tmp') Option.seek('main', [ application.debug, quiet, porcelain, post_data, post_file, json, offline, ]) Option.seek('main', [toolkit.cachedir]) Option.seek('client', client) Option.seek('db', db) locale.setlocale(locale.LC_ALL, '') app = Application( name='sugar-network-client', description='Sugar Network client utility', epilog='See http://wiki.sugarlabs.org/go/Sugar_Network ' 'for details.', config_files=[ '/etc/sweets.d', '/etc/sweets.conf', '~/.config/sweets/config', client.profile_path('sweets.conf'), ], ) app.start()