1

I'm building a Sublime Text 3 plugin to shorten URLs using the goo.gl API. Bear in mind that the following code is hacked together from other plugins and tutorial code. I have no previous experience with Python.

The plugin does actually work as it is. The URL is shortened and replaced inline. Here is the plugin code:

import sublime
import sublime_plugin
import urllib.request
import urllib.error
import json
import threading


class ShortenUrlCommand(sublime_plugin.TextCommand):

    def run(self, edit):
        sels = self.view.sel()

        threads = []
        for sel in sels:
            url = self.view.substr(sel)
            thread = GooglApiCall(sel, url, 5)  # Send the selection, the URL and timeout to the class
            threads.append(thread)
            thread.start()

        # Wait for threads
        for thread in threads:
            thread.join()

        self.view.sel().clear()
        self.handle_threads(edit, threads, sels)

    def handle_threads(self, edit, threads, sels, offset=0, i=0, dir=1):
        next_threads = []
        for thread in threads:
            sel = thread.sel
            result = thread.result
            if thread.is_alive():
                next_threads.append(thread)
                continue
            if thread.result == False:
                continue
            offset = self.replace(edit, thread, sels, offset)
        thread = next_threads

        if len(threads):
            before = i % 8
            after = (7) - before
            if not after:
                dir = -1
            if not before:
                dir = 1
            i += dir
            self.view.set_status("shorten_url", "[%s=%s]" % (" " * before, " " * after))
            sublime.set_timeout(lambda: self.handle_threads(edit, threads, sels, offset, i, dir), 100)
            return

        self.view.erase_status("shorten_url")
        selections = len(self.view.sel())
        sublime.status_message("URL shortener successfully ran on %s URL%s" %
            (selections, "" if selections == 1 else "s"))

    def replace(self, edit, thread, sels, offset):
        sel = thread.sel
        result = thread.result
        if offset:
            sel = sublime.Region(edit, thread.sel.begin() + offset, thread.sel.end() + offset)
        self.view.replace(edit, sel, result)
        return


class GooglApiCall(threading.Thread):
    def __init__(self, sel, url, timeout):
        self.sel = sel
        self.url = url
        self.timeout = timeout
        self.result = None
        threading.Thread.__init__(self)

    def run(self):
        try:
            apiKey = "xxxxxxxxxxxxxxxxxxxxxxxx"
            requestUrl = "https://www.googleapis.com/urlshortener/v1/url"
            data = json.dumps({"longUrl": self.url})
            binary_data = data.encode("utf-8")
            headers = {
                "User-Agent": "Sublime URL Shortener",
                "Content-Type": "application/json"
            }
            request = urllib.request.Request(requestUrl, binary_data, headers)
            response = urllib.request.urlopen(request, timeout=self.timeout)
            self.result = json.loads(response.read().decode())
            self.result = self.result["id"]
            return

        except (urllib.error.HTTPError) as e:
            err = "%s: HTTP error %s contacting API. %s." % (__name__, str(e.code), str(e.reason))
        except (urllib.error.URLError) as e:
            err = "%s: URL error %s contacting API" % (__name__, str(e.reason))

        sublime.error_message(err)
        self.result = False


The problem is that I get the following error in the console every time the plugin runs:

Traceback (most recent call last):
  File "/Users/joejoinerr/Library/Application Support/Sublime Text 3/Packages/URL Shortener/url_shortener.py", line 51, in <lambda>
    sublime.set_timeout(lambda: self.handle_threads(edit, threads, sels, offset, i, dir), 100)
  File "/Users/joejoinerr/Library/Application Support/Sublime Text 3/Packages/URL Shortener/url_shortener.py", line 39, in handle_threads
    offset = self.replace(edit, thread, sels, offset)
  File "/Users/joejoinerr/Library/Application Support/Sublime Text 3/Packages/URL Shortener/url_shortener.py", line 64, in replace
    self.view.replace(edit, sel, result)
  File "/Applications/Sublime Text.app/Contents/MacOS/sublime.py", line 657, in replace
    raise ValueError("Edit objects may not be used after the TextCommand's run method has returned")
ValueError: Edit objects may not be used after the TextCommand's run method has returned


I'm not sure what the problem is from that error. I have done some research and I understand that the solution may be held in the answer to this question, but due to my lack of Python knowledge I can't figure out how to adapt it to my use case.

Community
  • 1
  • 1
JoeJ
  • 920
  • 1
  • 6
  • 17

1 Answers1

2

I was searching for a Python autocompletion plugin for Sublime and found this question. I like your plugin idea. Did you ever get it working? The ValueError is telling you that you are trying to use the edit argument to ShortenUrlCommand.run after ShortenUrlCommand.run has returned. I think you could do this in Sublime Text 2 using begin_edit and end_edit, but in 3 your plugin has to finish all of its edits before run returns (https://www.sublimetext.com/docs/3/porting_guide.html).

In your code, the handle_threads function is checking the GoogleApiCall threads every 100 ms and executing the replacement for any thread that has finished. But handle_threads has a typo that causes it to run forever: thread = next_threads where it should be threads = next_threads. This means that finished threads are never removed from the list of active threads and all threads get processed in each invocation of handle_threads (eventually throwing the exception that you see).

You actually don't need to worry about whether the GoogleApiCall treads are finished in handle_threads, though, because you call join on each one before calling handle_threads (see the python threading docs for more detail on join: https://docs.python.org/2/library/threading.html). You know the threads are finished, so you can just do something like:

def handle_threads(self, edit, threads, sels):
    offset = 0
    for thread in threads:
        if thread.result:
            offset = self.replace(edit, thread, sels, offset)
    selections = len(threads)
    sublime.status_message("URL shortener successfully ran on %s URL%s" %
        (selections, "" if selections == 1 else "s"))

This still has problems: it does not properly handle multiple selections and it blocks the UI thread in Sublime.

Multiple Selections

When you replace multiple selections you have to consider that the replacement text might not be the same length as the text it replaces. This shifts the text after it and you have to adjust the indexes for subsequent selected regions. For example, suppose the URLs are selected in the following text and that you are replacing them with shortened URLs:

          1         2         3         4         5         6         7   
01234567890123456789012345678901234567890123456789012345678901234567890123
blah blah http://example.com/long blah blah http://example.com/longer blah

The second URL occupies indexes 44 to 68. After replacing the first URL we have:

          1         2         3         4         5         6         7
01234567890123456789012345678901234567890123456789012345678901234567890123
blah blah http://goo.gl/abc blah blah http://example.com/longer blah

Now the second URL occupies indexes 38 to 62. It is shifted by -6: the difference between the length of the string we just replaced and the length of the string we replaced it with. You need keep track of that difference and update it after each replacement as you go along. It looks like you had this in mind with your offset argument, but never got around to implementing it.

def handle_threads(self, edit, threads, sels):
    offset = 0
    for thread in threads:
        if thread.result:
            offset = self.replace(edit, thread.sel, thread.result, offset)
    selections = len(threads)
    sublime.status_message("URL shortener successfully ran on %s URL%s" %
        (selections, "" if selections == 1 else "s"))

def replace(self, edit, selection, replacement_text, offset):
    # Adjust the selection region to account for previous replacements
    adjusted_selection = sublime.Region(selection.begin() + offset,
                                        selection.end() + offset)
    self.view.replace(edit, adjusted_selection, replacement_text)

    # Update the offset for the next replacement
    old_len = selection.size()
    new_len = len(replacement_text)
    delta = new_len - old_len
    new_offset = offset + delta
    return new_offset

Blocking the UI Thread

I'm not familiar with Sublime plugins, so I looked at how this is handled in the Gist plugin (https://github.com/condemil/Gist). They block the UI thread for the duration of the HTTP requests. This seems undesirable, but I think there might be a problem if you don't block: the user could change the text buffer and invalidate the selection indexes before your plugin finishes its updates. If you want to go down this road, you might try moving the URL shortening calls into a WindowCommand. Then once you have the replacement text you could execute a replacement command on the current view for each one. This example gets the current view and executes ShortenUrlCommand on it. You will have to move the code that collects the shortened URLs out into ShortenUrlWrapperCommand.run:

class ShortenUrlWrapperCommand(sublime_plugin.WindowCommand):
    def run(self):
        view = self.window.active_view()
        view.run_command("shorten_url")
aschmied
  • 908
  • 2
  • 10
  • 26