Forum Archive

[Share Code] Implemented x-callback-url

lukaskollmer

This morning I asked if it is possible to use x-callback-urls with Pythonista and access data other apps provide.

I did some experimentation and came up with a script that is working perfectly.

What it does:
- Add support for x-callback.urls to Pythonista
- Replace the default UIApplication -openURL:sourceApplication:annotation: method with a custom one (Using @JonB's swizzle.py)
- The custom -openURL: method checks if the url that is opened is the callback from the other app
- If the url is from the other app, a handler function that you provide will be called, with some information about the url as a parameter
- Passed information includes:
- The full url called
- The app that opened the callback-url
- A dictionary containing all parameters and their values from the url (This is where you get data you requested from another app)
- If the url is NOT in response to the x-callback-url, Pythonistas default -openURL: will be called, with all parameters passed to the swizzled one. This ensures that other scripts using the pythonista:// url scheme to open/launch files still work.

How to use it:
(Using Drafts as an example, works with any app that supports x-callback-urls)
```python
import x_callback_url

url = 'drafts4://x-callback-url/get?uuid=YOUR_UUID&x-success=pythonista://'
def handler(url_info):
print(info)
# Do something with the data passed back to Pythonista (available through url_info.parameters)

x_callback_url.open_url(url, handler)
````

Notes:
- You'll also need swizzle.py
- If you don't want to do anything with data passed back to Pythonista, you can omit the handler parameter and just pass the url to x_callback_url.open_url() function
- This currently supports only the x-success parameter, I'll add support for x-source and x-error at a later point (x-success is by far the most important one)
- If you have any questions about this, just ask!

Where you can get this awesome piece of code:
- Just download it from GitHub

I have to say that this is the Pythonista script I'm by far the most proud of. Thanks @omz for making this great app!

JonB

Nice.

I will point out that, at least for drafts, I believe you could have used [[draft]], or [[title]] or [[body]] in the callback url in order to pass data to pythonistas argv:

pythonista://myscript?action=run&argv=[[draft]]

your solution does seem more general for a variety of other apps. kudos!

Subject22

This looks awesome! Unfortunately I'm not able to get it working! Would you mind taking a look at this example and telling me what's going on?

# coding: utf-8
import x_callback_url

url = "working-copy://x-callback-url/status/?repo=MY_REPO&unchanged=1&key=MY_KEY&x-success=pythonista://"

def handler(response):
    print(response)

x_callback_url.open_url(url, handler)

This results in the following output in the console:

Traceback (most recent call last):
  File "_ctypes/callbacks.c", line 314, in 'calling callback function'
  File "/private/var/mobile/Containers/Shared/AppGroup/84B2FC5A-8F6A-4B20-BA21-BE5B5A07629F/Documents/site-packages/x_callback_url.py", line 32, in application_openURL_sourceApplication_annotation_
    url_str = str(ObjCInstance(url))
NameError: global name 'ObjCInstance' is not defined
JonB

what version of pythonista are you using?

This looks like the x callback module was cleared, so its tlobals no longe exist.

You may be able to correct this with a
from objc_utils import ObjCInstance

inside the callback function that is failing (i.e make sure all dependencies of that function are imported locally, and do not rely on closures to expose globals)

Subject22

v2.0

I threw some imports in, but now I'm stuck at:

NameError: global name 'c' is not defined
# coding: utf-8
import x_callback_url
from objc_util import ObjCInstance

_handler = None
_requestID = None

url = "working-copy://x-callback-url/status/?repo=MY_REPO&unchanged=1&key=MY_KEY&x-success=pythonista://"

def handler(response):
    print(response)

x_callback_url.open_url(url, handler)

EDIT: Ahh. Putting a from objc_util import * into my calling module got me over the worst of it. But I still need access to the globals (because with the code above I end up with _requestID and _handler being None).

EDIT2: Well I'm stumped. I wondered if the leading _ was messing with the global variables (something something name mangling?) so I renamed _requestID and _handler to g_requestID and g_handler, respectively and it worked! Everything ran fine after that. A little later I tried reverting that change (the engineer in me likes reproducible errors) and everything still worked fine, despite my earlier problems. So then I tried removing the extra import in my calling module (from objc_utils import *) and everything still worked fine. So it seems my original issue has vanished without a trace and now I cannot reproduce it.

Subject22

Please excuse the double post, but this is about a different issue to my last post.

I note that the OP's version of this script assumes that the x-callback response data will be formatted something like app://xcallbackresponse-REQUEST_ID/?query=value&query=value. But one of the apps I'm working with formats its response like app://xcallbackresponse-REQUEST_IDvalue which causes the URL parsing in this script to break.

Here is a modified version which handles this case a little more gracefully by setting x_callback_response.parameters to None when it can't parse the URL directly and by creating a new x_callback_response.raw_response_data which is simply the response URL without the app://xcallbackresponse-REQUEST_ID bit.

lukaskollmer

@Subject22 thank you! I'll look into it when I find some time

eddo888

This is absolutely fantastic !
have you by any chance tried calling pythonista from another app ?
say workflow->pythonista->callback ?

silverjam

When trying to use this recipe and consequently swizzle.py I'm seeing the follow error:

Traceback (most recent call last):
  File "_ctypes/callbacks.c", line 234, in 'calling callback function'
  File "/private/var/mobile/Containers/Shared/AppGroup/74CC34ED-493E-431F-9C45-5BD2EF3B2AE0/Pythonista3/Documents/firebaseapp/swizzle.py", line 146, in saveData
  File "/var/containers/Bundle/Application/71C9338F-1BD7-4D52-9DAD-EE24DDF5139E/Pythonista3.app/Frameworks/Py3Kit.framework/pylib/site-packages/objc_util.py", line 796, in __call__
    method_name, kwarg_order = resolve_instance_method(obj, self.name, args, kwargs)
  File "/var/containers/Bundle/Application/71C9338F-1BD7-4D52-9DAD-EE24DDF5139E/Pythonista3.app/Frameworks/Py3Kit.framework/pylib/site-packages/objc_util.py", line 403, in resolve_instance_method
    raise AttributeError('No method found for %s' % (name,))
AttributeError: No method found for originalsaveData

Any ideas how I can debug this?

JonB

I posted a github comment -- basically the code was swizzling a subclass's method, but then is passed an instance of the parent class, which does not have the original method. Swizzling the parent class (somewhat manual in this case) resolves the issue

eddo888

was wanting to let you know that x_callback_url is no longer working with the most recent pythonista release woth python 3.6

JonB

In what way? Are you getting exceptions?

eddo888

the callout works, the callback comes back to pythonista
however the swizzled handler doesnt fire

eddo888

no exceptions are showing, and the print statements ive put into the callback dont get called indicating that the swizzel method is not getting called or has changed

JonB

When it comes back, is the app still running? I.e anything you had printed to the console is still there?

JonB

I see that API is depreciated, so likely needs to be updated to use application:openURL:options: instead.

eddo888

ended up using
openPythonistaURL:
instead of
application:openURL:sourceApplication:annotation:

cheers D

lukaskollmer

@eddo888 what do you mean?
did you swizzle openPythonistaURL: instead of swizzling -[UIApplicationDelegate application:openURL:options:]?

eddo888

here is my uodated and sligjtly modified x_callback_url.py

# coding: utf-8
import swizzle
from objc_util import *
import sys, re, os, argparse
import ctypes, json, urllib, uuid, urllib
import webbrowser 

def argue():
    parser = argparse.ArgumentParser()

    parser.add_argument('-v', '--verbose',  action='store_true',  help='verbose mode')
    parser.add_argument('-t', '--test',    action='store_true',  help='run test')

    return parser.parse_args()

def params(data):
    if len(data) == 0:
        return ''
    p = '&'.join(
        map(
            lambda x: 
                '%s=%s'%(x,urllib.quote(data[x])),
            data.keys()
        )
    )
    return '?%s'%p

def reverse(url):
    query = NSURLComponents.componentsWithURL_resolvingAgainstBaseURL_(nsurl(url), False)
    parameters = dict()
    if query.queryItems() is not None:
        for queryItem in query.queryItems():
            parameters[str(queryItem.name())] = str(queryItem.value())
    return parameters

def open_url(url, handler):
    global _handler
    global _requestID
    _requestID = uuid.uuid1()
    _handler = handler
    x_success = urllib.quote('pythonista://?request=%s'%_requestID)
    url_with_uuid = url.replace('?','?x-success=%s&'%x_success)
    #sys.stderr.write('> %s\n'% url_with_uuid)
    webbrowser.open(url_with_uuid)

def openPythonistaURL_(_self, _sel, url):
    url_str = str(ObjCInstance(url))
    #sys.stderr.write('< %s\n'%url_str)
    global _call_me, _handler, _requestID

    if '?request=%s'%_requestID in url_str:
        url_str = url_str.replace('?request=%s&'%_requestID, '?')
        parameters = reverse(url_str)
        if _handler:
            _handler(parameters)
        return True

    elif _call_me in url_str:
        #print url_str
        parameters = reverse(url_str)
        x_parameters = dict()
        for x in [
            'x-source',
            'x-success',
            'x-error',
            'x-cancel',
            'x-script',
        ]:
            if x in parameters.keys():
                x_parameters[x] = parameters[x]
                del parameters[x]

        #print '%s\n%s'%(
        #    json.dumps(x_parameters),
        #    json.dumps(parameters)
        #)

        if 'x-script' not in x_parameters.keys():
            return

        try:
            import importlib
            mod = importlib.import_module(
                x_parameters['x-script']
            )
            res = str(mod.main(parameters))
            url=x_parameters['x-success']+'?args=%s'%urllib.quote(res)
        except:
            error=str(sys.exc_info()[0])
            url=x_parameters['x-error']+'?args=%s'%urllib.quote(error)

        #print url
        webbrowser.open(url)
        return True

    else:
        #print('original url=%s'%url_str)
        obj = ObjCInstance(_self)
        original_method = getattr(obj, 'original'+c.sel_getName(_sel), None)
        if original_method:
            _annotation = ObjCInstance(annotation) if annotation else None
            return original_method(
                ObjCInstance(app), 
                ObjCInstance(url), ObjCInstance(source_app), 
                _annotation
            )
        return

def test():
    data={
        'statement' : 'select * from MyTable'
    }

    url='generaldb://x-callback-url/execute-select-statement' + params(data)
    print url

    def myhandler(parameters):
        print parameters
        for row in parameters['rows'].split('\n'):
            print row
        return

    open_url(url,myhandler)

def setup():
    global NSURLComponents, _call_me, _handler, _requestID
    _call_me = 'pythonista://x-callback-url'
    _handler = None
    _requestID = None
    NSURLComponents = ObjCClass('NSURLComponents')
    appDelegate = UIApplication.sharedApplication().delegate()

    # Do the swizzling
    cls = ObjCInstance(c.object_getClass(appDelegate.ptr))
    swizzle.swizzle(
        cls, 
        'openPythonistaURL:', openPythonistaURL_
    )
    #print 'swizzled'
    return

def main():
    setup()
    args = argue()
    if args.test : test(); return
    print 'setup complete:'#, sys.argv
    #webbrowser.open('workflow://')
    return    

if __name__ == '__main__': main()

lukaskollmer

(fyi, you should wrap the code block with ```, to get proper syntax highlighting. reading the code w/out that is really difficult)

eddo888

cheers thought i was in confluence not markdown :-)

ccc
def params(data):
    '?' + urllib.urlencode(data)

https://docs.python.org/2/library/urllib.html#urllib.urlencode

eddo888
def params(data):
    if len(data) == 0:
        return ''
    p = '&'.join(
        map(
            lambda x: 
                '%s=%s'%(x,urllib.quote(data[x])),
            data.keys()
        )
    )
    print data
    print urllib.urlencode(data)
    print p

    return '?%s'%p

yields

{'statement': 'select * from MyTable'}
statement=select+%2A+from+MyTable
statement=select%20%2A%20from%20MyTable

the client receiving the params preferrs %20 to +

dgelessus

@eddo888 In Python 3 you can do that using the quote_via parameter, like urllib.parse.urlencode({...}, quote_via=urllib.parse.quote). However in Python 2 the quote_via parameter doesn't exist yet. You can manually replace every + with %20, like urllib.parse.urlencode({...}).replace("+", "%20"). This should produce the same result and is much shorter than building the whole parameter string by hand.

eddo888

Thanks for taking the time to respond and guide, I hope you have a fun weekend. Cheers Dave

robertiii

How do I use this and get arguments from workflow?

dc2

This awesome piece of code leads to the famous 404...

https://github.com/lukaskollmer/pythonista-scripts/blob/master/x-callback-url/x_callback_url.py

Someone stil has it available?

I'm trying to load a file from IAwriter to process it in phytonista...

cvp

@dc2 see here a lot of Pythonista scripts, centralized by tdamdouni

cvp

@dc2 said:

I'm trying to load a file from IAwriter to process it in phytonista...

x-callback-url will not help you for that.

If you want to load a file into Pythonista, you have to share it to Pythonista script or to import it into Pythonista via "open external folder", if the app allows it.

dc2

@cvp Thanks, i think that sharing it as input in a script may be the way to go!

After trying to share the file to "Preview Markdown.py", I found that instead of the text, the path of the file is returned by appex.get_text()....

Somewhere the contents gets mixed up, probably in IAwriter...

ccc

Also https://github.com/lukaskollmer/pythonista-scripts/tree/master/scripts

cvp

@dc2 did you try to share to "run Pythonista script", then the standard "import file"?

dc2

@cvp

If i open iAWriter and the file i want to share, then the "share" button, followed by "run Pythonista script" and "import file", the result is that the text file is saved into the script folder.

Not exactly what i was looking for, but from there i could probably manipulate the file and convert it to HTML in accordance with my template.

I was hoping to use the "preview markdown" example and extend that script. But as mentioned before, it only retrieves the path rather that the content.

cvp

@dc2 but, if you get the path, you could open a file with this path in reading mode, and thus get its content, isn't it?

cvp

@dc2 modify the Preview Markdown.py example with

...
def main():
    path = appex.get_text()
    with open(path, mode='rt', encoding='utf-8') as fil:
        text = fil.read()
    converted = markdown(text)
...

I've tested with Welcome.md of Pythonista and that works, but perhaps you could have access problem with file from another app. But, if import file works, it should be ok.

dc2

Brilliant!

In fact opening the text file from Pythonista share didn't work and gave some type mismatch error.

The good news is that sharing from IAwriter directly worked perfectly and produced a markdown to html conversion perfectly!