Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Build Alfred Workflows into .alfredworkflow (zip) files
#!/usr/bin/python
# encoding: utf-8
#
# Copyright (c) 2013 deanishe@deanishe.net.
#
# MIT Licence. See http://opensource.org/licenses/MIT
#
# Created on 2013-11-01
#
"""workflow-build [options] <workflow-dir>
Build Alfred Workflows.
Compile contents of <workflow-dir> to a ZIP file (with extension
`.alfredworkflow`).
The name of the output file is generated from the workflow name,
which is extracted from the workflow's `info.plist`. If a `version`
file is contained within the workflow directory, it's contents
will be appended to the compiled workflow's filename.
Usage:
workflow-build [-v|-q|-d] [-f] [-o <outputdir>] <workflow-dir>...
workflow-build (-h|--version)
Options:
-o, --output=<outputdir> directory to save workflow(s) to
default is current working directory
-f, --force overwrite existing files
-h, --help show this message and exit
-V, --version show version number and exit
-q, --quiet only show errors and above
-v, --verbose show info messages and above
-d, --debug show debug messages
"""
from __future__ import print_function
from contextlib import contextmanager
from fnmatch import fnmatch
import logging
import os
import plistlib
import re
import shutil
import string
from subprocess import check_call, CalledProcessError
import sys
from tempfile import mkdtemp
from unicodedata import normalize
from docopt import docopt
__version__ = "0.6"
__author__ = "Dean Jackson <deanishe@deanishe.net>"
DEFAULT_LOG_LEVEL = logging.WARNING
# Characters permitted in workflow filenames
OK_CHARS = set(string.ascii_letters + string.digits + '-.')
EXCLUDE_PATTERNS = [
'.*',
'*.pyc',
'*.log',
'*.acorn',
'*.swp',
'*.bak',
'*.sublime-project',
'*.sublime-workflow',
'*.git',
'*.dist-info',
'*.egg-info',
'__pycache__',
]
log = logging.getLogger('[%(levelname)s] %(message)s')
logging.basicConfig(format='', level=logging.DEBUG)
@contextmanager
def chdir(dirpath):
"""Context-manager to change working directory."""
startdir = os.path.abspath(os.curdir)
os.chdir(dirpath)
log.debug('cwd=%s', dirpath)
yield
os.chdir(startdir)
log.debug('cwd=%s', startdir)
@contextmanager
def tempdir():
"""Context-manager to create and cd to a temporary directory."""
startdir = os.path.abspath(os.curdir)
dirpath = mkdtemp()
try:
yield dirpath
finally:
shutil.rmtree(dirpath)
def safename(name):
"""Make name filesystem and web-safe."""
if isinstance(name, str):
name = unicode(name, 'utf-8')
# remove non-ASCII
s = normalize('NFKD', name)
b = s.encode('us-ascii', 'ignore')
clean = []
for c in b:
if c in OK_CHARS:
clean.append(c)
else:
clean.append('-')
return re.sub(r'-+', '-', ''.join(clean)).strip('-')
def build_workflow(workflow_dir, outputdir, overwrite=False, verbose=False):
"""Create an .alfredworkflow file from the contents of `workflow_dir`."""
with tempdir() as dirpath:
tmpdir = os.path.join(dirpath, 'workflow')
shutil.copytree(workflow_dir, tmpdir,
ignore=shutil.ignore_patterns(*EXCLUDE_PATTERNS))
with chdir(tmpdir):
# ------------------------------------------------------------
# Read workflow metadata from info.plist
info = plistlib.readPlist(u'info.plist')
version = info.get('version')
name = safename(info['name'])
zippath = os.path.join(outputdir, name)
if version:
zippath = '{}-{}'.format(zippath, version)
zippath += '.alfredworkflow'
# ------------------------------------------------------------
# Remove unexported vars from info.plist
for k in info.get('variablesdontexport', {}):
info['variables'][k] = ''
plistlib.writePlist(info, 'info.plist')
# ------------------------------------------------------------
# Build workflow
if os.path.exists(zippath):
if overwrite:
log.info('overwriting existing workflow')
os.unlink(zippath)
else:
log.error('File "%s" exists. Use -f to overwrite', zippath)
return False
# build workflow
command = ['zip', '-r']
if not verbose:
command.append(u'-q')
command.extend([zippath, '.'])
log.debug('command=%r', command)
try:
check_call(command)
except CalledProcessError as err:
log.error('zip exited with %d', err.returncode)
return False
log.info('wrote %s', zippath)
return True
def main(args=None):
"""Run CLI."""
# ------------------------------------------------------------
# CLI flags
args = docopt(__doc__, version=__version__)
if args.get('--verbose'):
log.setLevel(logging.INFO)
elif args.get('--quiet'):
log.setLevel(logging.ERROR)
elif args.get('--debug'):
log.setLevel(logging.DEBUG)
else:
log.setLevel(DEFAULT_LOG_LEVEL)
log.debug('log level=%s', logging.getLevelName(log.level))
log.debug('args=%r', args)
# Build options
force = args['--force']
outputdir = os.path.abspath(args['--output'] or os.curdir)
workflow_dirs = [os.path.abspath(p) for p in args['<workflow-dir>']]
verbose = log.level == logging.DEBUG
log.debug(u'outputdir=%r, workflow_dirs=%r', outputdir, workflow_dirs)
# ------------------------------------------------------------
# Build workflow(s)
errors = False
for path in workflow_dirs:
ok = build_workflow(path, outputdir, force, verbose)
if not ok:
errors = True
if errors:
return 1
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
@deanishe

This comment has been minimized.

Copy link
Owner Author

@deanishe deanishe commented Dec 26, 2016

Note: Script currently doesn't remove un-exported variables from info.plist

@ejsuncy

This comment has been minimized.

Copy link

@ejsuncy ejsuncy commented Oct 25, 2017

Maybe it's just me, but trying ./workflow-build.py -d workflow-dir shows that it is actually zipping the .git file. In the exclude patterns, I changed '*.git' to '*.git*' and it correctly excluded the file. Perhaps it would be useful to change all the patterns that way?

@deanishe

This comment has been minimized.

Copy link
Owner Author

@deanishe deanishe commented Dec 17, 2017

Perhaps it would be useful to change all the patterns that way?

I've changed the way excludes are handled. It should work much better now.

@chrisbro

This comment has been minimized.

Copy link

@chrisbro chrisbro commented Jan 17, 2018

Might be too tired to see what's up right now, but smacking into this which seems close to what you were just editing:

DEBUG    - [.*] .idea
DEBUG    - [.*] .git
DEBUG    - [*.git] .git
Traceback (most recent call last):
  File "./workflow-build.py", line 326, in <module>
    sys.exit(main(sys.argv[1:]))
  File "./workflow-build.py", line 315, in main
    ok = build_workflow(path, outputdir, force, verbose, dry_run)
  File "./workflow-build.py", line 241, in build_workflow
    wffiles = get_workflow_files('.')
  File "./workflow-build.py", line 197, in get_workflow_files
    del dirnames[i]
IndexError: list assignment index out of range
@duanemay

This comment has been minimized.

Copy link

@duanemay duanemay commented Jan 21, 2018

@chrisbro, that looks like the same problem I am having.
My .git directory is getting matched by both the .* and the .git exclude patterns.
This removes 2 entries from the list of directories and causing one of my library directories to be missed.

@deanishe
Just need to add a break after line #209 the del dirnems[i]
and seems to work fine now

https://gist.github.com/duanemay/6663ae0a429bc0f78a0ab16b8065a47b

@xavdid

This comment has been minimized.

Copy link

@xavdid xavdid commented Jul 11, 2021

@deanishe this is a great script! I noticed it was using python 2 functions (unicode, pslistlib.readPlist, etc). I'm happy to update it, myself, but I figured I'd ask if you had a py3 version handy before I do that.

Thanks!

@xavdid

This comment has been minimized.

Copy link

@xavdid xavdid commented Jul 11, 2021

Actually, the changes were just a couple of lines:

diff --git a/bin/workflow-build b/bin/workflow-build
index 9c7b24c..dc76468 100755
--- a/bin/workflow-build
+++ b/bin/workflow-build
@@ -37,7 +37,6 @@ Options:
 from __future__ import print_function
 
 from contextlib import contextmanager
-from fnmatch import fnmatch
 import logging
 import os
 import plistlib
@@ -104,8 +103,6 @@ def tempdir():
 
 def safename(name):
     """Make name filesystem and web-safe."""
-    if isinstance(name, str):
-        name = unicode(name, "utf-8")
 
     # remove non-ASCII
     s = normalize("NFKD", name)
@@ -113,8 +110,9 @@ def safename(name):
 
     clean = []
     for c in b:
-        if c in OK_CHARS:
-            clean.append(c)
+        char = chr(c)
+        if char in OK_CHARS:
+            clean.append(char)
         else:
             clean.append("-")
 
@@ -132,7 +130,8 @@ def build_workflow(workflow_dir, outputdir, overwrite=False, verbose=False):
         with chdir(tmpdir):
             # ------------------------------------------------------------
             # Read workflow metadata from info.plist
-            info = plistlib.readPlist(u"info.plist")
+            with open("info.plist", "rb") as fp:
+                info = plistlib.load(fp)
             version = info.get("version")
             name = safename(info["name"])
             zippath = os.path.join(outputdir, name)
@@ -147,7 +146,8 @@ def build_workflow(workflow_dir, outputdir, overwrite=False, verbose=False):
             for k in info.get("variablesdontexport", {}):
                 info["variables"][k] = ""
 
-            plistlib.writePlist(info, "info.plist")
+            with open("info.plist", "wb") as fp:
+                plistlib.dump(info, fp)
 
             # ------------------------------------------------------------
             # Build workflow
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment