├── .gitignore ├── README.md ├── lib ├── README.md └── xcall.app │ └── Contents │ ├── Info.plist │ ├── MacOS │ └── xcall │ ├── PkgInfo │ ├── Resources │ └── Base.lproj │ │ └── Main.storyboardc │ │ ├── Info.plist │ │ └── MainMenu.nib │ └── _CodeSignature │ └── CodeResources ├── tests ├── __init__.py └── test_xcall.py └── xcall.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .tox/* 3 | *.pyc 4 | .cache/* 5 | *.egg-info* 6 | /.project 7 | /.pydevproject 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-xcall 2 | 3 | A Python [x-callback-url](http://x-callback-url.com) client for bi-directional 4 | communication with x-callback-url enabled macOS applications. python-xcall supports callbacks from appplications. It wraps the handy 5 | [xcall](https://github.com/martinfinke/xcall) command line tool. 6 | 7 | It is used by: 8 | - [python-ulysses-client](https://github.com/robwalton/ulysses-python-client) 9 | 10 | ## Software compatability 11 | Requires: 12 | - macOS 13 | - python 2.7 14 | - Uses [xcall](https://github.com/martinfinke/xcall) (included). 15 | - Needs pytest and mock for testing 16 | 17 | ## Installation 18 | Check it out: 19 | ```bash 20 | $ git clone https://github.com/robwalton/python-xcall.git 21 | Cloning into 'python-xcall'... 22 | ``` 23 | 24 | ## Basic use 25 | Call a scheme (ulysses) with an action (get-version): 26 | ```python 27 | >>> import xcall 28 | >>> xcall.xcall('ulysses', 'get-version') 29 | {u'apiVersion': u'2', u'buildNumber': u'33542'} 30 | ``` 31 | An x-success reply will be utf-8 un-encoded, then url unquoted, and then un-marshaled using json into Python objects and returned. 32 | 33 | A dictionary of action parameters can also be provided (each value is utf-8 34 | encoded and then url quoted before sending): 35 | ```python 36 | >>> xcall.xcall('ulysses', 'new-sheet', {'text':'My new sheet', 'index':'2'}) 37 | ``` 38 | If the application calls back with an x-error, an `XCallbackError` will be raised: 39 | ```python 40 | >>> xcall.xcall('ulysses', 'an-invalid-action') 41 | Traceback (most recent call last): 42 | File "", line 1, in 43 | ... 44 | XCallbackError: x-error callback: '{ 45 | "errorMessage" : "Invalid Action", 46 | "errorCode" : "100" 47 | } 48 | ' (in response to url: 'ulysses://x-callback-url/an-invalid-action') 49 | ``` 50 | 51 | ## More control 52 | For more control create an instance of `xcall.XCallClient`, specifying the scheme to use, whether responses should be un-marshaled using json, and an x-error handler. For example: 53 | ```python 54 | class UlyssesError(XCallbackError): 55 | pass 56 | 57 | def ulysses_xerror_handler(xerror, requested_url): 58 | error_message = eval(xerror)['errorMessage'] 59 | error_code = eval(xerror)['errorCode'] 60 | raise UlyssesError( 61 | ("%(error_message)s. Code=%(error_code)s. " 62 | "In response to sending the url '%(requested_url)s'") % locals()) 63 | 64 | ulysses_client = XCallClient( 65 | 'ulysses', on_xerror_handler=ulysses_xerror_handler, json_decode_success=True) 66 | 67 | ``` 68 | Make calls using: 69 | ```python 70 | >>> ulysses_client.xcall('get-version') 71 | ``` 72 | or just: 73 | ```python 74 | >>> ulysses_client('get-version') 75 | ``` 76 | 77 | ## Logging 78 | As logger output just goes directly to the terminal, it is disabled by default. To enable more verbose logging use: 79 | ```python 80 | >>> import xcall 81 | >>> xcall.enable_verbose_logging() 82 | ``` 83 | 84 | ## On thread/process safety 85 | Call to this module are __probably__ not thread/process safe. An attempt is made 86 | to ensure that `xcall` is not already running, but there is 20-30ms window in which 87 | multiple calls to this module will result in multiple xcall processes running 88 | and the chance of replies being mixed up. 89 | 90 | ## Testing 91 | Running the tests requires the `pytest` and `mock` packages. Some optional integration 92 | tests currently require [Ulysses](https://ulyssesapp.com). Code your 93 | access-token into the top of `test_calls.py`. Obtain the access token string by removing the @skip 94 | marker from `test_authorise()` in `test_calls.py` and running the tests. 95 | 96 | From the root package folder call: 97 | ```bash 98 | MacBook:python-xcall walton$ pytest 99 | ... 100 | ``` 101 | ## Licensing & Thanks 102 | 103 | The code and the documentation are released under the MIT and Creative Commons 104 | Attribution-NonCommercial licences respectively. 105 | 106 | Thanks to: 107 | - [Martin Finke](https://github.com/martinfinke) for his handy [xcall](https://github.com/martinfinke/xcall) application. 108 | - [Dean Jackson](https://github.com/deanishe) for suggestions 109 | 110 | ## Todo 111 | 112 | - Upload PyPi after working out how distrubute the lib folder containing xcall.app. 113 | - Logs could go somewhere more sensible that stdout. 114 | -------------------------------------------------------------------------------- /lib/README.md: -------------------------------------------------------------------------------- 1 | # xcall 2 | Call X-Callback-URLs From the Command Line. Outputs the `x-success` and `x-error` responses to `stdout`/`stderr`. 3 | 4 | ## Download 5 | 6 | [Click here to download xcall v1.0](https://github.com/martinfinke/xcall/releases/download/v1.0/xcall.app.zip). 7 | 8 | ## Usage 9 | ```bash 10 | xcall.app/Contents/MacOS/xcall -url "someapp://x-callback-url/some-action" 11 | ``` 12 | 13 | ## Authorizing 14 | This application is unsigned. Before using it you must authorise it by right clicking and selecting open or by calling 15 | ```bash 16 | xattr -dr com.apple.quarantine "xcall.app" 17 | ``` 18 | 19 | ## License 20 | Copyright (c) 2017 Martin Finke 21 | 22 | Permission is hereby granted, free of charge, to any person obtaining a copy 23 | of this software and associated documentation files (the "Software"), to deal 24 | in the Software without restriction, including without limitation the rights 25 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 26 | copies of the Software, and to permit persons to whom the Software is 27 | furnished to do so, subject to the following conditions: 28 | 29 | The above copyright notice and this permission notice shall be included in all 30 | copies or substantial portions of the Software. 31 | 32 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 33 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 34 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 35 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 36 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 37 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 38 | SOFTWARE. 39 | -------------------------------------------------------------------------------- /lib/xcall.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 16E195 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | xcall 11 | CFBundleIdentifier 12 | de.martin-finke.xcall 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | xcall 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | 1.0 21 | CFBundleSupportedPlatforms 22 | 23 | MacOSX 24 | 25 | CFBundleURLTypes 26 | 27 | 28 | CFBundleTypeRole 29 | Viewer 30 | CFBundleURLSchemes 31 | 32 | xcall066958CA 33 | 34 | 35 | 36 | CFBundleVersion 37 | 1 38 | DTCompiler 39 | com.apple.compilers.llvm.clang.1_0 40 | DTPlatformBuild 41 | 8E162 42 | DTPlatformVersion 43 | GM 44 | DTSDKBuild 45 | 16E185 46 | DTSDKName 47 | macosx10.12 48 | DTXcode 49 | 0830 50 | DTXcodeBuild 51 | 8E162 52 | LSBackgroundOnly 53 | 54 | LSMinimumSystemVersion 55 | 10.8 56 | NSHumanReadableCopyright 57 | Copyright © 2017 Martin Finke. All rights reserved. 58 | NSMainStoryboardFile 59 | Main 60 | NSPrincipalClass 61 | NSApplication 62 | 63 | 64 | -------------------------------------------------------------------------------- /lib/xcall.app/Contents/MacOS/xcall: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robwalton/python-xcall/8649a7e86c613c76b09a0a5fb132ae6dbd19185b/lib/xcall.app/Contents/MacOS/xcall -------------------------------------------------------------------------------- /lib/xcall.app/Contents/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /lib/xcall.app/Contents/Resources/Base.lproj/Main.storyboardc/Info.plist: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robwalton/python-xcall/8649a7e86c613c76b09a0a5fb132ae6dbd19185b/lib/xcall.app/Contents/Resources/Base.lproj/Main.storyboardc/Info.plist -------------------------------------------------------------------------------- /lib/xcall.app/Contents/Resources/Base.lproj/Main.storyboardc/MainMenu.nib: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robwalton/python-xcall/8649a7e86c613c76b09a0a5fb132ae6dbd19185b/lib/xcall.app/Contents/Resources/Base.lproj/Main.storyboardc/MainMenu.nib -------------------------------------------------------------------------------- /lib/xcall.app/Contents/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | files 6 | 7 | Resources/Base.lproj/Main.storyboardc/Info.plist 8 | 9 | M1rS+Tcas1ZdM4/iibTlduTJ22M= 10 | 11 | Resources/Base.lproj/Main.storyboardc/MainMenu.nib 12 | 13 | Iiw88bivJdVunQI4TtXBh9TsYiY= 14 | 15 | 16 | files2 17 | 18 | Resources/Base.lproj/Main.storyboardc/Info.plist 19 | 20 | hash 21 | 22 | M1rS+Tcas1ZdM4/iibTlduTJ22M= 23 | 24 | hash2 25 | 26 | 6/2HagpKuzGhxFgQU55Lc/bxgR30qm5eqHSV+p9e4/4= 27 | 28 | 29 | Resources/Base.lproj/Main.storyboardc/MainMenu.nib 30 | 31 | hash 32 | 33 | Iiw88bivJdVunQI4TtXBh9TsYiY= 34 | 35 | hash2 36 | 37 | PawTg0hStx+2CRVIIA9+03eVrCParobuC0K207J+A/0= 38 | 39 | 40 | 41 | rules 42 | 43 | ^Resources/ 44 | 45 | ^Resources/.*\.lproj/ 46 | 47 | optional 48 | 49 | weight 50 | 1000 51 | 52 | ^Resources/.*\.lproj/locversion.plist$ 53 | 54 | omit 55 | 56 | weight 57 | 1100 58 | 59 | ^Resources/Base\.lproj/ 60 | 61 | weight 62 | 1010 63 | 64 | ^version.plist$ 65 | 66 | 67 | rules2 68 | 69 | .*\.dSYM($|/) 70 | 71 | weight 72 | 11 73 | 74 | ^(.*/)?\.DS_Store$ 75 | 76 | omit 77 | 78 | weight 79 | 2000 80 | 81 | ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ 82 | 83 | nested 84 | 85 | weight 86 | 10 87 | 88 | ^.* 89 | 90 | ^Info\.plist$ 91 | 92 | omit 93 | 94 | weight 95 | 20 96 | 97 | ^PkgInfo$ 98 | 99 | omit 100 | 101 | weight 102 | 20 103 | 104 | ^Resources/ 105 | 106 | weight 107 | 20 108 | 109 | ^Resources/.*\.lproj/ 110 | 111 | optional 112 | 113 | weight 114 | 1000 115 | 116 | ^Resources/.*\.lproj/locversion.plist$ 117 | 118 | omit 119 | 120 | weight 121 | 1100 122 | 123 | ^Resources/Base\.lproj/ 124 | 125 | weight 126 | 1010 127 | 128 | ^[^/]+$ 129 | 130 | nested 131 | 132 | weight 133 | 10 134 | 135 | ^embedded\.provisionprofile$ 136 | 137 | weight 138 | 20 139 | 140 | ^version\.plist$ 141 | 142 | weight 143 | 20 144 | 145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robwalton/python-xcall/8649a7e86c613c76b09a0a5fb132ae6dbd19185b/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_xcall.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2016 Rob Walton 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2017-04-17 8 | # 9 | 10 | 11 | import time 12 | import pytest 13 | import subprocess 14 | import os 15 | 16 | from mock.mock import Mock 17 | 18 | import xcall 19 | from collections import OrderedDict 20 | import urllib 21 | import json 22 | from threading import Thread 23 | 24 | 25 | TEST_STRING = ur""" -- () ? & ' " ‘quoted text’ _x_y_z_ a://b.c/d?e=f&g=h""" 26 | 27 | # string -> json -> utf8 -> quote 28 | ENCODED_TEST_STRING = ( 29 | urllib.quote(json.dumps(TEST_STRING).encode('utf8'))) 30 | 31 | 32 | def ulysses_installed(): 33 | return not subprocess.call('open -Ra "UlyssesMac"', shell=True) 34 | 35 | 36 | def create_mock_Popen(x_success='', x_error=''): 37 | """Return mock subprocess.Popen class. 38 | 39 | stdout and stderr will be returned by communicate() of its returned 40 | instance. 41 | """ 42 | mock_Popen_instance = Mock() 43 | mock_Popen_instance.communicate = Mock(return_value=(x_success, x_error)) 44 | mock_Popen = Mock(return_value=mock_Popen_instance) 45 | return mock_Popen 46 | 47 | 48 | def test_string_coding_and_deencodin(): 49 | assert (json.loads(urllib.unquote(ENCODED_TEST_STRING).decode('utf8')) == 50 | TEST_STRING) 51 | 52 | 53 | def test_xcall_path_correct(): 54 | assert os.path.isfile(xcall.XCALL_PATH) 55 | 56 | 57 | def test_Popen_mocking(): 58 | mock_Popen = create_mock_Popen('x_success', 'x_error') 59 | 60 | process = mock_Popen( 61 | ['a', 'b'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) 62 | stdout, stderr = process.communicate() 63 | 64 | popen_args = mock_Popen.call_args[0][0] 65 | assert popen_args == ['a', 'b'] 66 | assert stdout, stderr == ('x_success', 'x_error') 67 | 68 | 69 | def test_xcall__no_parameters(monkeypatch): 70 | mock_Popen = create_mock_Popen('"ignored success response"', '') 71 | monkeypatch.setattr(subprocess, 'Popen', mock_Popen) 72 | 73 | xcall.xcall('scheme', 'action') 74 | 75 | popen_args = mock_Popen.call_args[0][0] 76 | assert popen_args == [xcall.XCALL_PATH, '-url', 77 | '"scheme://x-callback-url/action"'] 78 | 79 | 80 | def test_xcall__no_parameters_with_activation(monkeypatch): 81 | mock_Popen = create_mock_Popen('"ignored success response"', '') 82 | monkeypatch.setattr(subprocess, 'Popen', mock_Popen) 83 | 84 | xcall.xcall('scheme', 'action', activate_app=True) 85 | 86 | popen_args = mock_Popen.call_args[0][0] 87 | assert popen_args == [xcall.XCALL_PATH, '-url', 88 | '"scheme://x-callback-url/action"', 89 | '-activateApp', 'YES'] 90 | 91 | 92 | def test_xcall__with_parameters(monkeypatch): 93 | mock_Popen = create_mock_Popen('"ignored success response"', '') 94 | monkeypatch.setattr(subprocess, 'Popen', mock_Popen) 95 | 96 | action_parameters = OrderedDict([('key1', 'val1'), ('key2', 'val2')]) 97 | xcall.xcall('scheme', 'action', action_parameters) 98 | 99 | popen_args = mock_Popen.call_args[0][0] 100 | assert popen_args == [ 101 | xcall.XCALL_PATH, '-url', 102 | '"scheme://x-callback-url/action?key1=val1&key2=val2"'] 103 | 104 | 105 | def test_xcall__with_unicode_and_unsafe_html_parameters(monkeypatch): 106 | mock_Popen = create_mock_Popen('"ignored success response"', '') 107 | monkeypatch.setattr(subprocess, 'Popen', mock_Popen) 108 | 109 | xcall.xcall('scheme', 'action', {'key1': TEST_STRING}) 110 | 111 | popen_args = mock_Popen.call_args[0][0] 112 | encoded_test_string = urllib.quote(TEST_STRING.encode('utf8')) 113 | assert popen_args == [ 114 | xcall.XCALL_PATH, '-url', 115 | u'"scheme://x-callback-url/action?key1=%s"' % encoded_test_string] 116 | 117 | 118 | def test_xcall__success(monkeypatch): 119 | mock_Popen = create_mock_Popen('"success response"', '') 120 | monkeypatch.setattr(subprocess, 'Popen', mock_Popen) 121 | 122 | assert xcall.xcall('scheme', 'action') == 'success response' 123 | 124 | 125 | def test_xcall__success_with_unicode_and_unsafe_html_parameters(monkeypatch): 126 | mock_Popen = create_mock_Popen(ENCODED_TEST_STRING, '') 127 | monkeypatch.setattr(subprocess, 'Popen', mock_Popen) 128 | 129 | assert xcall.xcall('scheme', 'action') == TEST_STRING 130 | 131 | 132 | def test_xcall__xerror_response(monkeypatch): 133 | mock_Popen = create_mock_Popen('', 'x-error response') 134 | monkeypatch.setattr(subprocess, 'Popen', mock_Popen) 135 | 136 | with pytest.raises(xcall.XCallbackError) as excinfo: 137 | xcall.xcall('scheme', 'action') 138 | assert ("x-error callback: 'x-error response' (in response to url: " 139 | "'scheme://x-callback-url/action')" in excinfo.value) 140 | 141 | 142 | @pytest.mark.skipif("not ulysses_installed()") 143 | def test_xcall_to_ulysses(): 144 | d = xcall.xcall('ulysses', 'get-version') 145 | assert d['apiVersion'] >= 2 146 | 147 | 148 | @pytest.mark.skipif("not ulysses_installed()") 149 | def test_xcall_to_ulysses_error(): 150 | with pytest.raises(xcall.XCallbackError): 151 | xcall.xcall('ulysses', 'not-a-valid-action') 152 | 153 | 154 | @pytest.mark.skipif("not ulysses_installed()") 155 | def test_speed_or_urlcall(): 156 | t_start = time.time() 157 | # Run once to ensure ulysses is open 158 | xcall.xcall('ulysses', 'get-version') 159 | n = 10 160 | for i in range(n): # @UnusedVariable 161 | xcall.xcall('ulysses', 'get-version') 162 | dt = time.time() - t_start 163 | time_per_run = dt / n 164 | assert time_per_run < 0.15 165 | 166 | 167 | def test_get_pid_of_running_xcall_processes__non_running(): 168 | assert xcall.get_pid_of_running_xcall_processes() == [] 169 | 170 | 171 | def test_get_pid_of_running_xcall_processes(): 172 | # There is 20-30ms delay before xcall is started (hence the sleep) 173 | # The solid way to prevent more than one running at once would be with 174 | # a persistent lock on disk. 175 | 176 | # 177 | for _ in range(10): 178 | assert len(xcall.get_pid_of_running_xcall_processes()) == 0 179 | t = Thread(target=xcall.xcall, args=('ulysses', 'get-version')) 180 | t.start() 181 | time.sleep(.05) # 182 | assert len(xcall.get_pid_of_running_xcall_processes()) == 1 183 | with pytest.raises(AssertionError): 184 | xcall.xcall('ulysses', 'get-version') 185 | t.join() 186 | -------------------------------------------------------------------------------- /xcall.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # 3 | # Copyright (c) 2016 Rob Walton 4 | # 5 | # MIT Licence. See http://opensource.org/licenses/MIT 6 | # 7 | # Created on 2017-04-17 8 | 9 | 10 | """ 11 | A Python x-callback-url client used to communicate with an application's 12 | x-callback-url scheme registered with macOS. 13 | 14 | Uses `xcall`. `xcall` is command line macOS application providing generic 15 | access to applications with x-callback-url schemes: 16 | 17 | https://github.com/martinfinke/xcall 18 | 19 | Call to this module are _probably_ not thread/process safe. An ettempt is made 20 | to ensure that `xcall` is not running, but there is 20-30ms window in which 21 | multiple calls to this module will result in multiple xcall processes running; 22 | and the chance of replies being mixed up. 23 | 24 | """ 25 | 26 | import json 27 | import urllib 28 | import logging 29 | import os 30 | import subprocess 31 | 32 | 33 | __all__ = ['XCallClient', 'xcall', 'XCallbackError'] 34 | 35 | XCALL_PATH = (os.path.dirname(os.path.abspath(__file__)) + 36 | '/lib/xcall.app/Contents/MacOS/xcall') 37 | 38 | 39 | logging.basicConfig(format='%(asctime)s %(levelname)s:%(message)s', 40 | level=logging.WARNING) 41 | logger = logging.getLogger(__name__) 42 | 43 | def enable_verbose_logging(): 44 | logger.setLevel(logging.DEBUG) 45 | 46 | 47 | class XCallbackError(Exception): 48 | """Exception representing an x-error callback from xcall. 49 | """ 50 | def __init__(self, *args, **kwargs): 51 | Exception.__init__(self, *args, **kwargs) 52 | 53 | 54 | def default_xerror_handler(xerror, requested_url): 55 | """Handle an x-error callback by raising a generic XCallbackError 56 | 57 | xerror -- utf-8 un-encoded and then url unquoted x-error reply 58 | requested_url -- the encoded url sent to application 59 | 60 | (Note: this doc forms part of XCallClient API) 61 | """ 62 | msg = "x-error callback: '%s'" % xerror 63 | if requested_url: 64 | msg += " (in response to url: '%s')" % requested_url 65 | raise XCallbackError(msg) 66 | 67 | 68 | def xcall(scheme, action, action_parameters={}, 69 | activate_app=False): 70 | """Perform action and return un-marshalled result. 71 | 72 | scheme -- scheme name of application to target 73 | action -- the name of the application action to perform 74 | action_parameters -- dictionary of parameters to pass with call. None 75 | entries will be removed before sending. Values 76 | will be utf-8 encoded and then url quoted. 77 | activate_app -- bring target application to foreground if True 78 | 79 | An x-success reply will be utf-8 un-encoded, then url unquoted, 80 | and then unmarshalled using json into python objects before being 81 | returned. 82 | 83 | An x-error reply will result in an XCallbackError being raised. 84 | """ 85 | client = XCallClient(scheme) 86 | return client.xcall(action, action_parameters, activate_app) 87 | 88 | 89 | class XCallClient(object): 90 | """A client used for communicating with a particular application. 91 | """ 92 | 93 | def __init__(self, scheme_name, on_xerror_handler=default_xerror_handler, 94 | json_decode_success=True): 95 | """Create an xcall client for a particular application. 96 | 97 | scheme_name -- the url scheme name, as registered with macOS 98 | on_xerror_handler -- callable to handle x-error callbacks. 99 | See xcall.default_xerror_handler 100 | json_decode_success -- unmarshal x-success calls if True 101 | """ 102 | self.scheme_name = scheme_name 103 | self.on_xerror_handler = on_xerror_handler 104 | self.json_decode_success = json_decode_success 105 | 106 | def xcall(self, action, action_parameters={}, activate_app=False): 107 | """Perform action and return result across xcall. 108 | 109 | action -- the name of the application action to perform 110 | action_parameters -- dictionary of parameters to pass with call. None 111 | entries will be removed before sending. Values 112 | will be utf-8 encoded and then url quoted. 113 | activate_app -- bring target application to foreground if True 114 | 115 | An x-success reply will be utf-8 un-encoded, then url unquoted, 116 | and then (if configured) unmarshalled using json into python objects 117 | before being returned. 118 | 119 | An x-error reply will result in a call to the configured 120 | on_xerror_handler. 121 | """ 122 | 123 | for key in list(action_parameters): 124 | if action_parameters[key] is None: 125 | del action_parameters[key] 126 | 127 | pid_list = get_pid_of_running_xcall_processes() 128 | if pid_list: 129 | raise AssertionError('xcall processe(s) already running. pid(s): ' + str(pid_list)) 130 | cmdurl = self._build_url(action, action_parameters) 131 | logger.debug('--> ' + cmdurl) 132 | result = self._xcall(cmdurl, activate_app) 133 | logger.debug('<-- ' + unicode(result) + '\n') 134 | 135 | return result 136 | 137 | __call__ = xcall 138 | 139 | def _build_url(self, action, action_parameter_dict): 140 | url = '%s://x-callback-url/%s' % (self.scheme_name, action) 141 | 142 | if action_parameter_dict: 143 | par_list = [] 144 | for k, v in action_parameter_dict.iteritems(): 145 | par_list.append( 146 | k + '=' + urllib.quote(unicode(v).encode('utf8'))) 147 | url = url + '?' + '&'.join(par_list) 148 | return url 149 | 150 | def _xcall(self, url, activate_app): 151 | args = [XCALL_PATH, '-url', '"%s"' % url] 152 | if activate_app: 153 | args += ['-activateApp', 'YES'] 154 | 155 | logger.info('Making bash call: "%s"' % ' '.join(args)) 156 | 157 | 158 | p = subprocess.Popen( 159 | args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 160 | stdout, stderr = p.communicate() 161 | 162 | # Assert that reply had output on one, and only one of stdout and stderr 163 | if (stdout != '') and (stderr != ''): 164 | raise AssertionError( 165 | 'xcall utility replied unexpectedly on *both* stdout and stderr.' 166 | '\nstdout: "%s"\nstderr: "%s"\n' 167 | 'Try xcall directly from terminal with: "%s" ' % (stdout, stderr, ' '.join(args))) 168 | if (stdout == '') and (stderr == ''): 169 | raise AssertionError( 170 | 'xcall utility unexpectedly replied on *neither* stdout nor stderr' 171 | 'Try xcall directly from terminal with: "%s"' % ' '.join(args)) 172 | 173 | if stdout: 174 | response = urllib.unquote(stdout).decode('utf8') 175 | if self.json_decode_success: 176 | return json.loads(response) 177 | else: 178 | return response 179 | elif stderr: 180 | self.on_xerror_handler(stderr, url) 181 | 182 | 183 | 184 | def get_pid_of_running_xcall_processes(): 185 | try: 186 | reply = subprocess.check_output(['pgrep', 'xcall']) 187 | except subprocess.CalledProcessError: 188 | return [] 189 | pid_list = reply.strip().split('\n') 190 | if '' in pid_list: 191 | pid_list.remove('') 192 | return pid_list 193 | --------------------------------------------------------------------------------