#!/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 sugar_network.toolkit import coroutine coroutine.inject() 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 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.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.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)) # 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()