diff --git a/alfred-workflow-1.17.4.zip b/alfred-workflow-1.17.4.zip new file mode 100644 index 00000000..0efcec5b Binary files /dev/null and b/alfred-workflow-1.17.4.zip differ diff --git a/docs/user-manual/update.rst b/docs/user-manual/update.rst index 9ed2d1d4..c9502d03 100644 --- a/docs/user-manual/update.rst +++ b/docs/user-manual/update.rst @@ -21,16 +21,10 @@ Users can turn off automatic checks for updates with the ``workflow:noautoupdate :ref:`magic argument ` and back on again with ``workflow:autoupdate``. -.. danger:: - - If you are not careful, you might accidentally overwrite a local version of - the workflow you're working on and lose all your changes! It's a good idea - to make sure you increase the version number *before* you start making any - changes. - Currently, only updates from `GitHub releases`_ are supported. + GitHub releases =============== @@ -39,9 +33,10 @@ For your workflow to be able to recognise and download newer versions, the be one of the versions (i.e. tags) in the corresponding GitHub repo's releases list. See :ref:`version-numbers` for more information. -There must be **one (and only one)** ``.alfredworkflow`` binary attached to a -release otherwise the release will be ignored. This is the file that will be -downloaded and installed via Alfred's default installation mechanism. +There must be **one (and only one)** ``.alfredworkflow`` and/or **one (and only +one)** ``.alfred3workflow`` binary attached to a release otherwise the release +will be ignored. This is the file that will be downloaded and installed via +Alfred's default installation mechanism. .. important:: @@ -50,6 +45,27 @@ downloaded and installed via Alfred's default installation mechanism. been enabled or the ``prereleases`` key is set to ``True`` in the ``update_settings`` :class:`dict`. + +Supporting Alfred 2 and Alfred 3 +-------------------------------- + +Alfred 3 workflows are fundamentally incompatible with Alfred 2. + +If you want to make a new release of an existing workflow that breaks +compatibility with Alfred 2, it's important that you use the alternate +``.alfred3workflow`` file extension for your release binaries to prevent Alfred +2 installations trying to update themselves to death. + +You can have both an ``.alfredworkflow`` file and an ``.alfred3workflow`` file +in the same release. If Alfred-Workflow is running under Alfred 3, it will +prefer the ``.alfred3workflow`` if present. Under Alfred 2, or if the release +contains no ``.alfred3workflow`` file, Alfred-Workflow will use the +``.alfredworkflow`` file. + +There may only be one file of each type, however, or the release will be +considered invalid. + + Configuration ============= diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 00000000..8ef03839 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,5 @@ +pytest==2.8.7 +pytest-cov==2.2.1 +pytest-httpbin==0.2.0 +pytest-localserver==0.3.5 +tox==2.3.1 diff --git a/tests/data/info.plist.test b/tests/data/info.plist.alfred2 similarity index 100% rename from tests/data/info.plist.test rename to tests/data/info.plist.alfred2 diff --git a/tests/data/info.plist.alfred3 b/tests/data/info.plist.alfred3 new file mode 100644 index 00000000..bdf8aa30 --- /dev/null +++ b/tests/data/info.plist.alfred3 @@ -0,0 +1,78 @@ + + + + + bundleid + net.deanishe.alfred-workflow + connections + + createdby + Dean Jackson + description + Test alfred-workflow library + disabled + + name + Alfred-Workflow Test + objects + + + config + + alfredfiltersresults + + argumenttype + 0 + escaping + 102 + keyword + wftest + queuedelaycustom + 1 + queuedelayimmediatelyinitially + + queuedelaymode + 0 + queuemode + 1 + runningsubtext + Doin' stuff… + script + python test.py "$@" + scriptargtype + 1 + scriptfile + + subtext + Test alfred-workflow Python lib + title + Alfred-Workflow Test + type + 0 + withspace + + + type + alfred.workflow.input.scriptfilter + uid + 5F480F88-2088-4D34-B621-ACEBCB5E6753 + version + 2 + + + readme + + uidata + + 5F480F88-2088-4D34-B621-ACEBCB5E6753 + + xpos + 30 + ypos + 30 + + + webaddress + + + diff --git a/tests/info.plist.test b/tests/info.plist.test deleted file mode 100644 index a4a416af..00000000 --- a/tests/info.plist.test +++ /dev/null @@ -1,62 +0,0 @@ - - - - - bundleid - net.deanishe.alfred-workflow - connections - - createdby - Dean Jackson - description - Test alfred-workflow library - disabled - - name - Alfred-Workflow Test - objects - - - config - - argumenttype - 0 - escaping - 102 - keyword - wftest - runningsubtext - Doin' stuff… - script - python test.py "{query}" - subtext - Test alfred-workflow Python lib - title - Alfred-Workflow Test - type - 0 - withspace - - - type - alfred.workflow.input.scriptfilter - uid - 5F480F88-2088-4D34-B621-ACEBCB5E6753 - version - 0 - - - readme - - uidata - - 5F480F88-2088-4D34-B621-ACEBCB5E6753 - - ypos - 10 - - - webaddress - - - diff --git a/tests/test_update.py b/tests/test_update.py index d09a5d42..5810f41c 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -84,6 +84,8 @@ def info(request): """Ensure `info.plist` exists in the working directory.""" create_info_plist() + os.environ['alfred_version'] = '2.4' + update._wf = None request.addfinalizer(delete_info_plist) diff --git a/tests/test_update_alfred3.py b/tests/test_update_alfred3.py new file mode 100644 index 00000000..9241ec58 --- /dev/null +++ b/tests/test_update_alfred3.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright (c) 2014 Fabio Niephaus , +# Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2014-08-16 +# + +# TODO: Offline tests using pytest_localserver + +""" +test_update_alfred3.py +""" + +from __future__ import print_function + +import os + +import pytest + +from util import create_info_plist, delete_info_plist, INFO_PLIST_TEST3 +from workflow import update + + +@pytest.fixture(scope='module') +def info(request): + """Ensure `info.plist` exists in the working directory.""" + create_info_plist() + os.environ['alfred_version'] = '2.4' + update._wf = None + request.addfinalizer(delete_info_plist) + + +@pytest.fixture(scope='module') +def info3(request): + """Ensure `info.plist` exists in the working directory.""" + create_info_plist(INFO_PLIST_TEST3) + os.environ['alfred_version'] = '3.0.2' + update._wf = None + request.addfinalizer(delete_info_plist) + + +def test_valid_releases_alfred2(info): + """Valid releases for Alfred 2.""" + # Valid release for 2 & 3 + r = update._validate_release({'tag_name': 'v1.2', 'assets': [ + {'browser_download_url': + 'blah.alfredworkflow'}], + 'prerelease': False}) + + assert r is not None + assert r['prerelease'] is False + assert r['download_url'] == 'blah.alfredworkflow' + + # Valid release for 3 only + r = update._validate_release({'tag_name': 'v1.2', 'assets': [ + {'browser_download_url': + 'blah.alfred3workflow'}], + 'prerelease': False}) + + assert r is None + + # Invalid release + r = update._validate_release({'tag_name': 'v1.2', 'assets': [ + {'browser_download_url': + 'blah.alfred3workflow'}, + {'browser_download_url': + 'blah2.alfred3workflow'}], + 'prerelease': False}) + + assert r is None + + # Valid for 2 & 3 with separate workflows + r = update._validate_release({'tag_name': 'v1.2', 'assets': [ + {'browser_download_url': + 'blah.alfredworkflow'}, + {'browser_download_url': + 'blah.alfred3workflow'}], + 'prerelease': False}) + + assert r is not None + assert r['version'] == 'v1.2' + assert r['download_url'] == 'blah.alfredworkflow' + + +def test_valid_releases_alfred3(info3): + """Valid releases for Alfred 3.""" + # Valid release for 2 & 3 + r = update._validate_release({'tag_name': 'v1.2', 'assets': [ + {'browser_download_url': + 'blah.alfredworkflow'}], + 'prerelease': False}) + + assert r is not None + assert r['download_url'] == 'blah.alfredworkflow' + + # Valid release for 3 only + print('alfred_version=', os.environ['alfred_version']) + print('version=', update.wf().alfred_version) + r = update._validate_release({'tag_name': 'v1.2', 'assets': [ + {'browser_download_url': + 'blah.alfred3workflow'}], + 'prerelease': False}) + + assert r is not None + assert r['download_url'] == 'blah.alfred3workflow' + + # Invalid release + r = update._validate_release({'tag_name': 'v1.2', 'assets': [ + {'browser_download_url': + 'blah.alfred3workflow'}, + {'browser_download_url': + 'blah2.alfred3workflow'}], + 'prerelease': False}) + + assert r is None + + # Valid for 2 & 3 with separate workflows + r = update._validate_release({'tag_name': 'v1.2', 'assets': [ + {'browser_download_url': + 'blah.alfredworkflow'}, + {'browser_download_url': + 'blah.alfred3workflow'}], + 'prerelease': False}) + + assert r is not None + assert r['version'] == 'v1.2' + assert r['download_url'] == 'blah.alfred3workflow' + + +if __name__ == '__main__': # pragma: no cover + pytest.main([__file__]) diff --git a/tests/test_update_versions.py b/tests/test_update_versions.py index a57434fe..197d8803 100644 --- a/tests/test_update_versions.py +++ b/tests/test_update_versions.py @@ -7,9 +7,8 @@ # # Created on 2014-08-16 # -""" -test_versions.py -""" + +"""Test `update.Version` class.""" from __future__ import print_function diff --git a/tests/test_web.py b/tests/test_web.py index f71cc6c4..632996e9 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -209,9 +209,12 @@ def test_no_encoding(self): def test_xml_encoding(self): """XML is decoded""" - url = self.httpbin.url + '/response-headers' + # Why doesn't this work with a local httpbin? + # url = self.httpbin.url + '/response-headers' + url = 'http://httpbin.org/response-headers' params = {'Content-Type': 'text/xml;charset=UTF-8'} r = web.get(url, params) + r.raise_for_status() self.assertEqual(r.encoding, 'utf-8') self.assert_(isinstance(r.text, unicode)) @@ -224,11 +227,14 @@ def test_html_encoding(self): def test_default_encoding(self): """Default encodings for mimetypes""" - url = self.httpbin.url + '/response-headers' + # Why doesn't this work with a local httpbin? + # url = self.httpbin.url + '/response-headers' + url = 'http://httpbin.org/response-headers' # params = {'Content-Type': 'application/json'} # httpbin returns JSON by default. web.py should automatically # set `encoding` to UTF-8 when mimetype = 'application/json' r = web.get(url) + r.raise_for_status() self.assertEqual(r.encoding, 'utf-8') self.assert_(isinstance(r.text, unicode)) diff --git a/tests/test_web_broken.py b/tests/test_web_broken.py new file mode 100644 index 00000000..70fa6c44 --- /dev/null +++ b/tests/test_web_broken.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +# encoding: utf-8 +# +# Copyright (c) 2016 Dean Jackson +# +# MIT Licence. See http://opensource.org/licenses/MIT +# +# Created on 2016-02-23 +# +""" +test_web2.py + +""" + +from __future__ import print_function, unicode_literals + + +import os + +import pytest +import pytest_httpbin + +from workflow import web + + +DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') + + +# Broken. Why doesn't this work with the local httpbin? +def no_test_default_encoding(httpbin): + """Default encodings for mimetypes.""" + url = httpbin.url + '/response-headers' + r = web.get(url) + r.raise_for_status() + # httpbin returns JSON by default. web.py should automatically + # set `encoding` to UTF-8 when mimetype = 'application/json' + assert r.encoding == 'utf-8' + assert isinstance(r.text, unicode) + + +# Broken. Why doesn't this work with the local httpbin? +def no_test_xml_encoding(httpbin): + """XML is decoded.""" + url = httpbin.url + '/response-headers' + params = {'Content-Type': 'text/xml; charset=UTF-8'} + r = web.get(url, params) + r.raise_for_status() + assert r.encoding == 'utf-8' + assert isinstance(r.text, unicode) + + +def test_default_encoding_remote(httpbin): + """Default encodings for mimetypes.""" + url = 'http://httpbin.org/response-headers' + r = web.get(url) + r.raise_for_status() + # httpbin returns JSON by default. web.py should automatically + # set `encoding` to UTF-8 when mimetype = 'application/json' + assert r.encoding == 'utf-8' + assert isinstance(r.text, unicode) + + +def test_xml_encoding_remote(httpbin): + """XML is decoded.""" + url = 'http://httpbin.org/response-headers' + params = {'Content-Type': 'text/xml; charset=UTF-8'} + r = web.get(url, params) + r.raise_for_status() + assert r.encoding == 'utf-8' + assert isinstance(r.text, unicode) + +if __name__ == '__main__': # pragma: no cover + pytest.main([__file__]) diff --git a/tests/test_workflow.py b/tests/test_workflow.py index 7de43787..ac2d69cf 100644 --- a/tests/test_workflow.py +++ b/tests/test_workflow.py @@ -1161,6 +1161,11 @@ def cb(wf): self.assertEqual(wf.last_version_run, Version(vstr)) wf.reset() + def test_alfred_version(self): + """Workflow: alfred_version correct.""" + wf = Workflow() + self.assertEqual(wf.alfred_version, Version('2.4')) + #################################################################### # Helpers #################################################################### diff --git a/tests/util.py b/tests/util.py index aa89f591..d3192c19 100644 --- a/tests/util.py +++ b/tests/util.py @@ -22,7 +22,11 @@ import tempfile INFO_PLIST_TEST = os.path.join(os.path.abspath(os.path.dirname(__file__)), - 'info.plist.test') + 'data/info.plist.alfred2') + +INFO_PLIST_TEST3 = os.path.join(os.path.abspath(os.path.dirname(__file__)), + 'data/info.plist.alfred3') + INFO_PLIST_PATH = os.path.join(os.path.abspath(os.getcwdu()), 'info.plist') diff --git a/tox.ini b/tox.ini index 7936d3ab..d8dc811c 100644 --- a/tox.ini +++ b/tox.ini @@ -10,6 +10,7 @@ addopts = --tb=long --color=auto --cov-report="" + --doctest-modules [tox] envlist=py26, py27 diff --git a/workflow/update.py b/workflow/update.py index 28f0f0ca..7094838a 100644 --- a/workflow/update.py +++ b/workflow/update.py @@ -49,16 +49,37 @@ def wf(): class Version(object): - """Mostly semantic versioning + """Mostly semantic versioning. The main difference to proper :ref:`semantic versioning ` is that this implementation doesn't require a minor or patch version. + + Version strings may also be prefixed with "v", e.g.: + + >>> v = Version('v1.1.1') + >>> v.tuple + (1, 1, 1, '') + + >>> v = Version('2.0') + >>> v.tuple + (2, 0, 0, '') + + >>> Version('3.1-beta').tuple + (3, 1, 0, 'beta') + + >>> Version('1.0.1') > Version('0.0.1') + True """ #: Match version and pre-release/build information in version strings match_version = re.compile(r'([0-9\.]+)(.+)?').match def __init__(self, vstr): + """Create new `Version` object. + + Args: + vstr (basestring): Semantic version string. + """ self.vstr = vstr self.major = 0 self.minor = 0 @@ -101,7 +122,7 @@ def _parse(self, vstr): # wf().logger.debug('version str `{}` -> {}'.format(vstr, repr(self))) def _parse_dotted_string(self, s): - """Parse string ``s`` into list of ints and strings""" + """Parse string ``s`` into list of ints and strings.""" parsed = [] parts = s.split('.') for p in parts: @@ -112,8 +133,7 @@ def _parse_dotted_string(self, s): @property def tuple(self): - """Version number as a tuple of major, minor, patch, pre-release""" - + """Version number as a tuple of major, minor, patch, pre-release.""" return (self.major, self.minor, self.patch, self.suffix) def __lt__(self, other): @@ -195,21 +215,74 @@ def download_workflow(url): def build_api_url(slug): - """Generate releases URL from GitHub slug + """Generate releases URL from GitHub slug. :param slug: Repo name in form ``username/repo`` :returns: URL to the API endpoint for the repo's releases - """ - + """ if len(slug.split('/')) != 2: raise ValueError('Invalid GitHub slug : {0}'.format(slug)) return RELEASES_BASE.format(slug) +def _validate_release(release): + """Return release for running version of Alfred.""" + alf3 = wf().alfred_version.major == 3 + + downloads = {'.alfredworkflow': [], '.alfred3workflow': []} + dl_count = 0 + version = release['tag_name'] + + for asset in release.get('assets', []): + url = asset.get('browser_download_url') + if not url: # pragma: nocover + continue + + ext = os.path.splitext(url)[1].lower() + if ext not in downloads: + continue + + # Ignore Alfred 3-only files if Alfred 2 is running + if ext == '.alfred3workflow' and not alf3: + continue + + downloads[ext].append(url) + dl_count += 1 + + # download_urls.append(url) + + if dl_count == 0: + wf().logger.warning( + 'Invalid release {0} : No workflow file'.format(version)) + return None + + for k in downloads: + if len(downloads[k]) > 1: + wf().logger.warning( + 'Invalid release %s : multiple %s files'.format(version, k)) + return None + + # Prefer .alfred3workflow file if there is one and Alfred 3 is + # running. + if alf3 and len(downloads['.alfred3workflow']): + download_url = downloads['.alfred3workflow'][0] + + else: + download_url = downloads['.alfredworkflow'][0] + + wf().logger.debug('Release `{0}` : {1}'.format(version, download_url)) + + return { + 'version': version, + 'download_url': download_url, + 'prerelease': release['prerelease'] + } + + def get_valid_releases(github_slug, prereleases=False): - """Return list of all valid releases + """Return list of all valid releases. :param github_slug: ``username/repo`` for workflow's GitHub repo :param prereleases: Whether to include pre-releases. @@ -224,7 +297,6 @@ def get_valid_releases(github_slug, prereleases=False): ``v`` will be stripped. """ - api_url = build_api_url(github_slug) releases = [] @@ -239,34 +311,58 @@ def retrieve_releases(): slug = github_slug.replace('/', '-') for release in wf().cached_data('gh-releases-{0}'.format(slug), retrieve_releases): - version = release['tag_name'] - download_urls = [] - for asset in release.get('assets', []): - url = asset.get('browser_download_url') - if not url or not url.endswith('.alfredworkflow'): - continue - download_urls.append(url) - - # Validate release - if release['prerelease'] and not prereleases: - wf().logger.warning( - 'Invalid release {0} : pre-release detected'.format(version)) - continue - if not download_urls: - wf().logger.warning( - 'Invalid release {0} : No workflow file'.format(version)) + + wf().logger.debug('Release : %r', release) + + release = _validate_release(release) + if release is None: + wf().logger.debug('Invalid release') continue - if len(download_urls) > 1: - wf().logger.warning( - 'Invalid release {0} : multiple workflow files'.format(version)) + + elif release['prerelease'] and not prereleases: + wf().logger.debug('Ignoring prerelease : %s', release['version']) continue - wf().logger.debug('Release `{0}` : {1}'.format(version, url)) - releases.append({ - 'version': version, - 'download_url': download_urls[0], - 'prerelease': release['prerelease'] - }) + releases.append(release) + + # else: + + # try: + # valid = _validate_release(release) + # except Exception as err: + # wf().logger.exception(err) + # raise err + # else: + # wf().logger.debug('valid=%r', valid) + + # version = release['tag_name'] + # download_urls = [] + # for asset in release.get('assets', []): + # url = asset.get('browser_download_url') + # if not url or not url.endswith('.alfredworkflow'): + # continue + # download_urls.append(url) + + # # Validate release + # if release['prerelease'] and not prereleases: + # wf().logger.warning( + # 'Invalid release {0} : pre-release detected'.format(version)) + # continue + # if not download_urls: + # wf().logger.warning( + # 'Invalid release {0} : No workflow file'.format(version)) + # continue + # if len(download_urls) > 1: + # wf().logger.warning( + # 'Invalid release {0} : multiple workflow files'.format(version)) + # continue + + # wf().logger.debug('Release `{0}` : {1}'.format(version, url)) + # releases.append({ + # 'version': version, + # 'download_url': download_urls[0], + # 'prerelease': release['prerelease'] + # }) return releases diff --git a/workflow/version b/workflow/version index 021c75ee..250f3597 100644 --- a/workflow/version +++ b/workflow/version @@ -1 +1 @@ -1.17.3 \ No newline at end of file +1.17.4 \ No newline at end of file diff --git a/workflow/web.py b/workflow/web.py index 3f63bdec..488f1aba 100644 --- a/workflow/web.py +++ b/workflow/web.py @@ -7,9 +7,7 @@ # Created on 2014-02-15 # -""" -A lightweight HTTP library with a requests-like interface. -""" +"""Lightweight HTTP library with a requests-like interface.""" from __future__ import print_function @@ -28,7 +26,7 @@ import zlib -USER_AGENT = u'Alfred-Workflow/1.17 (+http://www.deanishe.net/alfred-workflow)' +USER_AGENT = u'Alfred-Workflow/1.17.4 (+http://www.deanishe.net/alfred-workflow)' # Valid characters for multipart form data boundaries BOUNDARY_CHARS = string.digits + string.ascii_letters @@ -79,7 +77,7 @@ def str_dict(dic): - """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str` + """Convert keys and values in ``dic`` into UTF-8-encoded :class:`str`. :param dic: :class:`dict` of Unicode strings :returns: :class:`dict` @@ -99,7 +97,7 @@ def str_dict(dic): class NoRedirectHandler(urllib2.HTTPRedirectHandler): - """Prevent redirections""" + """Prevent redirections.""" def redirect_request(self, *args): return None @@ -572,6 +570,7 @@ def request(method, url, params=None, data=None, headers=None, cookies=None, query = urllib.urlencode(str_dict(params), doseq=True) url = urlparse.urlunsplit((scheme, netloc, path, query, fragment)) + print('URL={0!r}\nDATA={1!r}\nHEADERS={2!r}'.format(url, data, headers)) req = urllib2.Request(url, data, headers) return Response(req, stream) diff --git a/workflow/workflow.py b/workflow/workflow.py index 4c4f6bb9..6e68e965 100644 --- a/workflow/workflow.py +++ b/workflow/workflow.py @@ -1029,7 +1029,6 @@ def setdefault(self, key, value=None): self.save() return ret - class Workflow(object): """Create new :class:`Workflow` instance. @@ -1128,6 +1127,11 @@ def __init__(self, default_settings=None, update_settings=None, # info.plist contents and alfred_* environment variables ---------- + @property + def alfred_version(self): + from update import Version + return Version(self.alfred_env.get('version')) + @property def alfred_env(self): """Alfred's environmental variables minus the ``alfred_`` prefix.