├── .editorconfig ├── .flake8 ├── .gitattributes ├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .python-version ├── .vscode ├── extensions.json └── settings.json ├── LICENSE.txt ├── README.md ├── awp ├── __init__.py ├── argparse_extras.py ├── data │ └── config-schema.json ├── main.py ├── packager.py └── validator.py ├── pyproject.toml └── requirements.txt /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig (http://editorconfig.org) 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.{json,yml}] 14 | indent_size = 2 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=88 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.py diff=python 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build the Python project and build a wheel of the current 2 | # release (*without* publishing to PyPI). For more information see: 3 | # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 4 | 5 | name: build 6 | 7 | on: 8 | push: 9 | branches: ["*"] 10 | pull_request: 11 | branches: ["*"] 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | 24 | - name: Install Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v3 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | python -m pip install -r requirements.txt 33 | 34 | - name: Install pypa/build 35 | run: python -m pip install build --user 36 | 37 | - name: Build a binary wheel and a source tarball 38 | run: python -m build --sdist --wheel --outdir dist/ . 39 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build the Python project and publish the current tagged 2 | # release to PyPI; it will only run if the triggering commit has been tagged 3 | # For more information see: 4 | # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 5 | 6 | name: publish 7 | 8 | on: 9 | push: 10 | tags: ["*"] 11 | 12 | jobs: 13 | publish: 14 | runs-on: ubuntu-latest 15 | environment: release 16 | permissions: 17 | id-token: write 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | with: 22 | submodules: recursive 23 | 24 | - name: Install Python 3 25 | uses: actions/setup-python@v3 26 | with: 27 | python-version: "3.11" 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | python -m pip install -r requirements.txt 33 | 34 | - name: Install pypa/build 35 | run: python -m pip install build --user 36 | 37 | - name: Build a binary wheel and a source tarball 38 | run: python -m build --sdist --wheel --outdir dist/ . 39 | 40 | - name: Publish distribution to Test PyPI 41 | uses: pypa/gh-action-pypi-publish@release/v1 42 | with: 43 | repository_url: https://test.pypi.org/legacy/ 44 | # The pypa/gh-action-pypi-publish action sources attestations from the 45 | # same source, so leaving attestations enabled (the default behavior) 46 | # for both steps will cause the production PyPI step to fail; however, 47 | # disabling attestations on the test PyPI step should allow the 48 | # production PyPI step to succeed 49 | attestations: false 50 | 51 | - name: Publish distribution to PyPI 52 | uses: pypa/gh-action-pypi-publish@release/v1 53 | with: 54 | repository_url: https://upload.pypi.org/legacy/ 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files generated by OS 2 | .DS_Store 3 | 4 | # Python bytecode 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | # Packaging 9 | build/ 10 | dist/ 11 | *.egg-info/ 12 | 13 | # Unit test / coverage reports 14 | cover/ 15 | htmlcov/ 16 | .coverage 17 | coverage.xml 18 | nosetests.xml 19 | 20 | # Required packages 21 | .virtualenv 22 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "EditorConfig.EditorConfig", 4 | "ms-python.black-formatter", 5 | "ms-python.flake8" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.extraPaths": [".virtualenv"], 3 | "[python]": { 4 | "editor.formatOnSave": true, 5 | "editor.defaultFormatter": "ms-python.black-formatter", 6 | "editor.codeActionsOnSave": { 7 | "source.organizeImports": "explicit" 8 | } 9 | }, 10 | "black-formatter.importStrategy": "fromEnvironment", 11 | "isort.args": [ 12 | "--profile", "black" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2025 Caleb Evans 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Alfred Workflow Packager 2 | 3 | *Copyright 2016-2025 Caleb Evans* 4 | *Released under the MIT license* 5 | 6 | Alfred Workflow Packager is a command-line utility which makes the process of 7 | packaging and exporting an [Alfred](https://www.alfredapp.com/) workflow 8 | incredibly quick and easy. The utility supports Alfred 3 and up, on projects running Python 3 (Python 2 is no longer supported). 9 | 10 | ## Setup 11 | 12 | You can install the utility via `pip3`, either globally or within a virtualenv: 13 | 14 | ```sh 15 | pip3 install alfred-workflow-packager 16 | ``` 17 | 18 | ## Usage 19 | 20 | ### 1. Create configuration file 21 | 22 | Once you've installed AWP, you must configure it for every project where you 23 | wish to use it. To do so, create a `packager.json` file in the root directory of 24 | your project; this file configures AWP for that particular project. 25 | 26 | #### Example 27 | 28 | ```json 29 | { 30 | "export_files": [ 31 | "Fruit.alfredworkflow" 32 | ], 33 | "bundle_id": "com.yourname.fruit", 34 | "readme": "README.txt", 35 | "resources": [ 36 | "icon.png", 37 | "src/*.py", 38 | "src/data/*.json" 39 | ] 40 | } 41 | ``` 42 | 43 | #### Required settings 44 | 45 | ##### export_files 46 | 47 | The paths of the exported workflows (relative to your project directory). 48 | 49 | ##### bundle_id 50 | 51 | The unique bundle ID of your workflow. You must have one set in the installed 52 | workflow, or AWP will not be able to find your workflow when packaging. 53 | 54 | ##### resources 55 | 56 | A list of zero or more files/folder patterns representing files or folders to 57 | copy from your project to the installed workflow. The directory structures and 58 | filenames are preserved when copying. 59 | 60 | *Local project:* 61 | 62 | ``` 63 | - icon.png 64 | - fruit 65 | - apple.py 66 | - banana.applescript 67 | - orange.php 68 | ``` 69 | 70 | *Installed workflow (before running utility):* 71 | 72 | ``` 73 | - info.plist 74 | - special_file.json 75 | ``` 76 | 77 | *packager.json resources:* 78 | 79 | ```json 80 | [ 81 | "icon.png", 82 | "fruit/*.json" 83 | ] 84 | ``` 85 | 86 | *Installed workflow (after running utility):* 87 | 88 | ``` 89 | - info.plist 90 | - icon.png 91 | - special_file.json 92 | - fruit 93 | - apple.py 94 | - banana.applescript 95 | - orange.php 96 | ``` 97 | 98 | Note that files and folders already present in the installed workflow are *not* 99 | touched if they are not in the *resources* list. 100 | 101 | #### Optional settings 102 | 103 | ##### readme 104 | 105 | The path to the README file to use for this workflow; the *About this Workflow* 106 | field in your workflow is populated with the contents of this file. 107 | 108 | ### 2. Run utility 109 | 110 | You can run the utility via the `awp` command: 111 | 112 | ```sh 113 | awp 114 | ``` 115 | 116 | Running `awp` will copy those project resources listed in `packager.json` to 117 | the installed workflow (in their respective locations), but only if their 118 | contents or permissions have changed. If you ever need to ignore this equality 119 | check, you can force the copying of all files/directories by passing `--force` 120 | / `-f`. 121 | 122 | ```sh 123 | awp --force 124 | ``` 125 | 126 | ```sh 127 | awp -f 128 | ``` 129 | 130 | #### Setting the workflow version 131 | 132 | Passing the `--version` option (also `-v`) to `awp` allows you to set the 133 | version of the installed workflow directly. I highly recommend using [semantic 134 | versioning](http://semver.org/) to version your workflow releases. 135 | 136 | ```sh 137 | awp --version 1.2.0 138 | ``` 139 | 140 | ```sh 141 | awp -v 1.2.0 142 | ``` 143 | 144 | #### Exporting the workflow 145 | 146 | When you're pleased with your work and you're ready to publish a new release, 147 | you can export the installed workflow to your project directory by passing the 148 | `--export` flag (or `-e`) to `awp`. 149 | 150 | ```sh 151 | awp --export 152 | ``` 153 | 154 | ```sh 155 | awp -e 156 | ``` 157 | 158 | Note that you can set the version and export the workflow simultaneously: 159 | 160 | ```sh 161 | awp -v 1.2.0 -e 162 | ``` 163 | 164 | **New in AWP v1.1.0:** If you wish to temporarily export the workflow to a 165 | different file (different from `export_files` in `packager.json`), you can 166 | pass one or more optional paths to `--export`: 167 | 168 | ```sh 169 | awp -v 1.3.0-beta.1 -e ~/Desktop/fruit-beta-alfred-5.alfredworkflow 170 | ``` 171 | 172 | ### 4. Configure workflow objects 173 | 174 | The last important step is to update any script objects in your workflow (*i.e.* 175 | objects of type **Script Filter**, **Run Script**, *etc.*) to reference the 176 | files copied to the installed workflow directory. 177 | 178 | You should set the *Language* to `/bin/bash` and use the appropriate shell 179 | command to call your script. Use `"$@"` if your input is passed as argv, or 180 | `"{query}"` if your input is passed as {query}. 181 | 182 | #### Python 183 | 184 | ```sh 185 | /usr/bin/python3 -m fruit.apple "$@" 186 | ``` 187 | 188 | ```sh 189 | /usr/bin/python3 -m fruit.apple "{query}" 190 | ``` 191 | 192 | #### AppleScript 193 | 194 | ```sh 195 | /usr/bin/osascript fruit/banana.applescript "$@" 196 | ``` 197 | 198 | ```sh 199 | /usr/bin/osascript fruit/banana.applescript "{query}" 200 | ``` 201 | 202 | #### PHP 203 | 204 | ```sh 205 | /usr/bin/php fruit/orange.php "$@" 206 | ``` 207 | 208 | ```sh 209 | /usr/bin/php fruit/orange.php "{query}" 210 | ``` 211 | -------------------------------------------------------------------------------- /awp/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding=utf-8 3 | -------------------------------------------------------------------------------- /awp/argparse_extras.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | 4 | # By default, the `const` parameter to argparse.ArgumentParser.add_argument() 5 | # only supports `nargs="?"`; it does not natively support `nargs="*"`; however, 6 | # we can use a custom action to achieve this behavior (source: 7 | # ) 8 | class constForNargsStar(argparse.Action): 9 | """ 10 | Customized argparse action, will set the 11 | value in the following way: 12 | 13 | 1) If no option_string is supplied: set to None 14 | 15 | 2) If option_string is supplied: 16 | 17 | 2A) If values are supplied: 18 | set to list of values 19 | 20 | 2B) If no values are supplied: 21 | set to default value (`self.const`) 22 | 23 | NOTES: 24 | If `const` is not set, default value (2A) will be None 25 | """ 26 | 27 | def __call__(self, parser, namespace, values, option_string=None): 28 | if option_string: 29 | setattr(namespace, self.dest, self.const) 30 | elif not values: 31 | setattr(namespace, self.dest, None) 32 | else: 33 | setattr(namespace, self.dest, values) 34 | -------------------------------------------------------------------------------- /awp/data/config-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "description": "The utility configuration for a particular project", 4 | "type": "object", 5 | "properties": { 6 | "export_files": { 7 | "description": "The paths to the Alfred workflow files to export", 8 | "type": "array", 9 | "items": { 10 | "type": "string", 11 | "pattern": "^(.*?)\\.alfred\\d?workflow$" 12 | } 13 | }, 14 | "readme": { 15 | "description": "The path to the README file to include in the workflow", 16 | "type": "string" 17 | }, 18 | "bundle_id": { 19 | "description": "The workflow's unique bundle ID", 20 | "type": "string" 21 | }, 22 | "resources": { 23 | "description": "A list of file patterns to include in the packaged workflow", 24 | "type": "array", 25 | "items": { 26 | "type": "string" 27 | } 28 | } 29 | }, 30 | "required": ["export_files", "bundle_id", "resources"], 31 | "additionalProperties": false 32 | } 33 | -------------------------------------------------------------------------------- /awp/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding=utf-8 3 | 4 | import argparse 5 | import json 6 | 7 | import jsonschema 8 | 9 | import awp.packager 10 | import awp.validator 11 | from awp.argparse_extras import constForNargsStar 12 | 13 | 14 | # Parse arguments given via command-line interface 15 | def parse_cli_args(): 16 | 17 | parser = argparse.ArgumentParser() 18 | parser.add_argument( 19 | "--force", 20 | "-f", 21 | action="store_true", 22 | help="forces the copying of all files and directories", 23 | ) 24 | parser.add_argument( 25 | "--export", 26 | "-e", 27 | nargs="*", 28 | action=constForNargsStar, 29 | const=[], 30 | default=None, 31 | help="exports the installed workflow to the local project directory", 32 | ) 33 | parser.add_argument( 34 | "--version", "-v", help="the new version number to use for the workflow" 35 | ) 36 | return parser.parse_args() 37 | 38 | 39 | # Locate and parse the configuration for the utility 40 | def get_utility_config(): 41 | with open("packager.json", "r") as config_file: 42 | return json.load(config_file) 43 | 44 | 45 | def main(): 46 | 47 | cli_args = parse_cli_args() 48 | config = get_utility_config() 49 | 50 | try: 51 | awp.validator.validate_config(config) 52 | awp.packager.package_workflow( 53 | config, 54 | version=cli_args.version, 55 | export_files=cli_args.export, 56 | force=cli_args.force, 57 | ) 58 | except jsonschema.exceptions.ValidationError as error: 59 | print("awp invalid config (from packager.json): {}".format(error.message)) 60 | 61 | 62 | if __name__ == "__main__": 63 | main() 64 | -------------------------------------------------------------------------------- /awp/packager.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding=utf-8 3 | 4 | import contextlib 5 | import filecmp 6 | import glob 7 | import os 8 | import os.path 9 | import plistlib 10 | import re 11 | import shutil 12 | import xml 13 | from zipfile import ZIP_DEFLATED, ZipFile 14 | 15 | 16 | # Create parent directories for the given path if they don't exist 17 | def create_parent_dirs(path): 18 | parent_path = os.path.dirname(path) 19 | if parent_path: 20 | try: 21 | os.makedirs(parent_path) 22 | except OSError: 23 | pass 24 | 25 | 26 | # Read a .plist file from the given path and return a dictionary representing 27 | # the contents 28 | def read_plist_from_path(plist_path): 29 | with open(plist_path, "rb") as plist_file: 30 | try: 31 | return plistlib.load(plist_file) 32 | # For whatever reason, the plist-writing process can sometimes add some 33 | # extraneous junk to the end of the file which causes the XML to be 34 | # malformed and raises an error; to solve this, we catch that error and 35 | # properly parse out the valid XML 36 | except xml.parsers.expat.ExpatError: 37 | plist_file.seek(0) 38 | plist_contents = plist_file.read() 39 | junk_marker = b"" 40 | plist_contents = plist_contents[ 41 | : plist_contents.index(junk_marker) + len(junk_marker) 42 | ] 43 | return plistlib.loads(plist_contents) 44 | 45 | 46 | # Retrieve correct path to directory containing Alfred's user preferences 47 | def get_user_prefs_dir(): 48 | 49 | library_dir = os.path.join(os.path.expanduser("~"), "Library") 50 | try: 51 | core_prefs = read_plist_from_path( 52 | os.path.join( 53 | library_dir, 54 | "Preferences", 55 | "com.runningwithcrayons.Alfred-Preferences-3.plist", 56 | ) 57 | ) 58 | except IOError: 59 | core_prefs = read_plist_from_path( 60 | os.path.join( 61 | library_dir, 62 | "Preferences", 63 | "com.runningwithcrayons.Alfred-Preferences.plist", 64 | ) 65 | ) 66 | 67 | # If user is syncing their preferences using a syncing service 68 | if core_prefs.get("syncfolder"): 69 | return os.path.expanduser(core_prefs["syncfolder"]) 70 | else: 71 | return os.path.join(library_dir, "Application Support", "Alfred") 72 | 73 | 74 | # Retrieve path to and info.plist object for installed workflow 75 | def get_installed_workflow(workflow_bundle_id): 76 | 77 | # Retrieve list of the directories for all installed workflows 78 | workflow_dirs = glob.iglob( 79 | os.path.join(get_user_prefs_dir(), "Alfred.alfredpreferences", "workflows", "*") 80 | ) 81 | 82 | # Find workflow whose bundle ID matches this workflow's 83 | for workflow_dir in workflow_dirs: 84 | info_path = os.path.join(workflow_dir, "info.plist") 85 | info = read_plist_from_path(info_path) 86 | if info["bundleid"] == workflow_bundle_id: 87 | return workflow_dir, info 88 | 89 | # Assume workflow is not installed at this point 90 | raise OSError("Workflow is not installed locally") 91 | 92 | 93 | # Retrieve the octal file permissions for the given file as a base-10 integer 94 | def get_permissions(file_path): 95 | return os.stat(file_path).st_mode 96 | 97 | 98 | # Return True if the permissions of the two files are equal; return False 99 | # otherwise 100 | def cmp_permissions(src_file_path, dest_file_path): 101 | return get_permissions(src_file_path) == get_permissions(dest_file_path) 102 | 103 | 104 | # Return True if the item counts for the given directories match; otherwise, 105 | # return False 106 | def check_dir_item_count_match(dir_path, dest_dir_path, dirs_cmp): 107 | 108 | return ( 109 | not dirs_cmp.left_only and not dirs_cmp.right_only and not dirs_cmp.funny_files 110 | ) 111 | 112 | 113 | # Return True if the contents of all files in the given directories match; 114 | # otherwise, return False 115 | def check_dir_file_content_match(dir_path, dest_dir_path, dirs_cmp): 116 | 117 | match, mismatch, errors = filecmp.cmpfiles( 118 | dir_path, dest_dir_path, dirs_cmp.common_files, shallow=False 119 | ) 120 | return not mismatch and not errors 121 | 122 | 123 | # Return True if the contents of all subdirectories (found recursively) match; 124 | # otherwise, return False 125 | def check_subdir_content_match(dir_path, dest_dir_path, dirs_cmp): 126 | 127 | for common_dir in dirs_cmp.common_dirs: 128 | new_dir_path = os.path.join(dir_path, common_dir) 129 | new_dest_dir_path = os.path.join(dest_dir_path, common_dir) 130 | if not dirs_are_equal(new_dir_path, new_dest_dir_path): 131 | return False 132 | return True 133 | 134 | 135 | # Recursively check if two directories are exactly equal in terms of content 136 | def dirs_are_equal(dir_path, dest_dir_path): 137 | 138 | dirs_cmp = filecmp.dircmp(dir_path, dest_dir_path) 139 | 140 | if not check_dir_item_count_match(dir_path, dest_dir_path, dirs_cmp): 141 | return False 142 | if not check_dir_file_content_match(dir_path, dest_dir_path, dirs_cmp): 143 | return False 144 | if not check_subdir_content_match(dir_path, dest_dir_path, dirs_cmp): 145 | return False 146 | 147 | return True 148 | 149 | 150 | # Return True if the resource (file or directory) is equal to the destination 151 | # resource; return False otherwise 152 | def resources_are_equal(resource_path, dest_resource_path): 153 | 154 | try: 155 | return dirs_are_equal(resource_path, dest_resource_path) 156 | except OSError: 157 | # Compare files if they are not directories 158 | try: 159 | return filecmp.cmp(resource_path, dest_resource_path) and cmp_permissions( 160 | resource_path, dest_resource_path 161 | ) 162 | except OSError: 163 | # Resources are not equal if either does not exist 164 | return False 165 | 166 | 167 | # Copy package resource to corresponding destination path 168 | def copy_resource(resource_path, dest_resource_path, force=False): 169 | 170 | if force or not resources_are_equal(resource_path, dest_resource_path): 171 | try: 172 | shutil.copy_tree(resource_path, dest_resource_path) 173 | except Exception: 174 | with contextlib.suppress(FileNotFoundError): 175 | os.remove(dest_resource_path) 176 | shutil.copy(resource_path, dest_resource_path) 177 | print("Copied {file}".format(file=resource_path)) 178 | return True 179 | else: 180 | return False 181 | 182 | 183 | # Copy all package resources to installed workflow 184 | def copy_pkg_resources(workflow_path, workflow_resources, force=False): 185 | 186 | copied_any = False 187 | for resource_patt in workflow_resources: 188 | for resource_path in glob.iglob(resource_patt): 189 | create_parent_dirs(os.path.join(workflow_path, resource_path)) 190 | dest_resource_path = os.path.join(workflow_path, resource_path) 191 | copied = copy_resource(resource_path, dest_resource_path, force=force) 192 | if copied: 193 | copied_any = True 194 | if not copied_any: 195 | print("Nothing to copy; workflow is already up-to-date") 196 | 197 | 198 | # Update the workflow README with the current project README 199 | def update_workflow_readme(info, readme_path): 200 | 201 | orig_readme = info["readme"] 202 | with open(readme_path, "r") as readme_file: 203 | new_readme = readme_file.read() 204 | if orig_readme != new_readme: 205 | info["readme"] = new_readme 206 | print("Updated workflow README") 207 | 208 | 209 | # Set the workflow version to a new version number if one is given 210 | def update_workflow_version(info, new_version_num): 211 | if new_version_num: 212 | info["version"] = re.sub(r"^v", "", new_version_num) 213 | print("Set version to v{version}".format(version=info["version"])) 214 | 215 | 216 | # Write installed workflow subdirectory files to the given zip file 217 | def zip_workflow_dir_files(workflow_path, zip_file, root, relative_root, files): 218 | for file_name in files: 219 | file_path = os.path.join(root, file_name) 220 | # Get path to current file relative to workflow directory 221 | relative_file_path = os.path.join(relative_root, file_name) 222 | zip_file.write(file_path, relative_file_path) 223 | 224 | 225 | # Write installed workflow subdirectories to the given zip file 226 | def zip_workflow_dirs(workflow_path, zip_file): 227 | # Traverse installed workflow directory 228 | for root, dirs, files in os.walk(workflow_path): 229 | # Get current subdirectory path relative to workflow directory 230 | relative_root = os.path.relpath(root, workflow_path) 231 | # Add subdirectory to archive and add files within 232 | zip_file.write(root, relative_root) 233 | zip_workflow_dir_files(workflow_path, zip_file, root, relative_root, files) 234 | 235 | 236 | # Export installed workflow to project directory 237 | def export_workflow(workflow_path, archive_path): 238 | 239 | # Create new Alfred workflow archive in project directory 240 | # Overwrite any existing archive 241 | create_parent_dirs(archive_path) 242 | with ZipFile(archive_path, "w", compression=ZIP_DEFLATED) as zip_file: 243 | zip_workflow_dirs(workflow_path, zip_file) 244 | 245 | 246 | # Package installed workflow by copying resources from project, updating 247 | # README, and optionally exporting workflow 248 | def package_workflow(config, version, export_files, force=False): 249 | 250 | workflow_path, info = get_installed_workflow(config["bundle_id"]) 251 | 252 | copy_pkg_resources(workflow_path, config["resources"], force=force) 253 | if "readme" in config: 254 | update_workflow_readme(info, config["readme"]) 255 | update_workflow_version(info, version) 256 | plist_path = os.path.join(workflow_path, "info.plist") 257 | with open(plist_path, "rb+") as plist_file: 258 | plistlib.dump(info, plist_file) 259 | 260 | # Do not export anything if --export/-e option is not supplied 261 | if export_files is None: 262 | return 263 | 264 | # If --export/-e is supplied but without any arguments, default to the 265 | # export_files list defined in packager.json (this must match the 'const' 266 | # parameter in the argument definition for --export/-e) 267 | if export_files == []: 268 | export_files = config.get("export_files", []) 269 | 270 | for export_file in export_files: 271 | project_path = os.getcwd() 272 | export_workflow(workflow_path, os.path.join(project_path, export_file)) 273 | print( 274 | "Exported v{version} to {file}".format( 275 | version=info["version"], file=export_file 276 | ) 277 | ) 278 | -------------------------------------------------------------------------------- /awp/validator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding=utf-8 3 | 4 | import json 5 | import os 6 | import os.path 7 | 8 | import jsonschema 9 | 10 | 11 | # Validate the given utility configuration JSON against the schema 12 | def validate_config(config): 13 | 14 | schema_path = os.path.join(os.path.dirname(__file__), "data", "config-schema.json") 15 | with open(schema_path, "r") as schema_file: 16 | jsonschema.validate(config, json.load(schema_file)) 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "alfred-workflow-packager" 3 | version = "3.2.0" 4 | description = "A CLI utility for packaging and exporting Alfred workflows" 5 | readme = "README.md" 6 | requires-python = ">=3.9" 7 | authors = [ 8 | {name = "Caleb Evans", email = "caleb@calebevans.me"} 9 | ] 10 | maintainers = [ 11 | {name = 'Caleb Evans', email = 'caleb@calebevans.me'} 12 | ] 13 | license = "MIT" 14 | keywords = ["alfred", "workflow", "package", "export"] 15 | dependencies=[ 16 | "jsonschema>=4,<5" 17 | ] 18 | 19 | [project.scripts] 20 | awp = "awp.main:main" 21 | 22 | [tool.setuptools.package-data] 23 | awp = ["data/*.json"] 24 | 25 | [project.urls] 26 | homepage = "https://github.com/caleb531/alfred-workflow-packager" 27 | documentation = "https://github.com/caleb531/alfred-workflow-packager#readme" 28 | repository = "https://github.com/caleb531/alfred-workflow-packager" 29 | changelog = "https://github.com/caleb531/alfred-workflow-packager/releases" 30 | 31 | [build-system] 32 | requires = ["setuptools"] 33 | build-backend = "setuptools.build_meta" 34 | 35 | [tool.flake8] 36 | # Black compatibility 37 | max-line-length = 88 38 | extend-ignore = ["E203", "W503"] 39 | exclude = [".git", "build", "__pycache__", "*.egg-info", ".virtualenv"] 40 | 41 | # Per 42 | [tool.isort] 43 | profile = "black" 44 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==25.3.0 2 | black==25.1.0 3 | build==1.2.2.post1 4 | click==8.1.8 5 | flake8==7.2.0 6 | flake8-black==0.3.6 7 | flake8-isort==6.1.2 8 | isort==6.0.1 9 | jsonschema==4.23.0 10 | jsonschema-specifications==2024.10.1 11 | mccabe==0.7.0 12 | mypy-extensions==1.0.0 13 | packaging==24.2 14 | pathspec==0.12.1 15 | platformdirs==4.3.7 16 | pycodestyle==2.13.0 17 | pyflakes==3.3.2 18 | pyproject_hooks==1.2.0 19 | referencing==0.36.2 20 | rpds-py==0.24.0 21 | tomli==2.2.1 22 | typing_extensions==4.13.2 23 | --------------------------------------------------------------------------------