From 83bc4bfc57b2c640d15816902100cceeb4bcb81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Marschollek?= Date: Mon, 17 Oct 2022 22:18:06 +0200 Subject: [PATCH] Fix determining the workflows location Workflows are stored in the `Alfred.alfredrpreferences/workflows` directory, by default in ~/Library/Application Support/Alfred/`. The user can override this by setting a sync folder in the app, but if that was never done, the respective config entry didn't exist, causing the `link` command of thie CLI to fail. This chanke makes sure we first search in the override directory, and only then in the default location. --- docs/pyfred/cli.html | 950 ++++++++++++++++++++------------------- pyfred/cli.py | 16 +- pyfred/tests/test_cli.py | 9 +- setup.cfg | 2 +- 4 files changed, 500 insertions(+), 477 deletions(-) diff --git a/docs/pyfred/cli.html b/docs/pyfred/cli.html index cbeca7f..f41eb40 100644 --- a/docs/pyfred/cli.html +++ b/docs/pyfred/cli.html @@ -96,11 +96,11 @@

30 return decorator 31 32 - 33def _get_sync_directory() -> Path: + 33def _get_sync_directory() -> Optional[Path]: 34 """ 35 :return: The path to Alfred's sync directory 36 """ - 37 prefs_path = Path.home().joinpath("Library/Preferences/com.runningwithcrayons.Alfred-Preferences.plist") + 37 prefs_path = Path.home() / "Library" / "Preferences" / "com.runningwithcrayons.Alfred-Preferences.plist" 38 39 if not prefs_path.exists(): 40 raise ValueError("Alfred doesn't appear to be installed") @@ -109,412 +109,420 @@

43 pl = plistlib.load(f) 44 45 if "syncfolder" not in pl: - 46 raise ValueError("Alfred's synchronisation directory not set") - 47 - 48 sync_dir = Path(pl["syncfolder"]).expanduser() - 49 - 50 if not sync_dir.exists(): - 51 raise OSError("Cannot find workflow directory") - 52 - 53 return sync_dir.expanduser() - 54 + 46 logging.debug("Alfred's synchronisation directory not set") + 47 return None + 48 + 49 sync_dir = Path(pl["syncfolder"]).expanduser() + 50 + 51 if not sync_dir.exists(): + 52 raise OSError("Cannot find workflow directory") + 53 + 54 return sync_dir.expanduser() 55 - 56def _get_workflows_directory() -> Path: - 57 """ - 58 Get the directory where Alfred stores workflows - 59 - 60 :return: The path to the directory with Alfred's workflows - 61 """ - 62 return _get_sync_directory() / "Alfred.alfredpreferences" / "workflows" + 56 + 57def _get_workflows_directory() -> Path: + 58 """ + 59 Get the directory where Alfred stores workflows + 60 + 61 Finds the Alfred.alfredpreferences dir either in the sync location set in the Alfred settings or in the default + 62 location. 63 - 64 - 65def _make_plist( - 66 name: str, keyword: str, bundle_id: str, author: Optional[str], website: Optional[str], description: Optional[str] - 67) -> dict: - 68 """ - 69 Create a dictionary representation of the Info.plist file describing the workflow - 70 - 71 :param name: - 72 The name of the workflow - 73 :param keyword: - 74 The keyword to trigger the workflow - 75 :param bundle_id: - 76 The bundle ID to identify the workflow. Should use reverse-DNS notation - 77 :param author: - 78 The name of the author - 79 :param website: - 80 The website of the workflow - 81 :param description: - 82 The description of the workflow. Will be shown to the user when importing - 83 :return: a dictionary representation of the Info.plist file - 84 """ - 85 script_uuid = str(uuid4()) - 86 clipboard_uuid = str(uuid4()) - 87 - 88 return { - 89 "name": name, - 90 "description": description or "", - 91 "bundleid": bundle_id, - 92 "createdby": author or "", - 93 "connections": {script_uuid: [{"destinationuid": clipboard_uuid}]}, - 94 "uidata": [], - 95 # Environment variables - 96 # Add the vendored directory to the PYTHONPATH so that we're also searching there for dependencies - 97 "variables": {"PYTHONPATH": ".:vendored"}, - 98 # The workflow version - 99 "version": "0.0.1", -100 # The contact website -101 "webaddress": website or "", -102 "objects": [ -103 {"uid": clipboard_uuid, "type": "alfred.workflow.output.clipboard", "config": {"clipboardtext": "{query}"}}, -104 { -105 "uid": script_uuid, -106 "type": "alfred.workflow.input.scriptfilter", -107 "config": { -108 "keyword": keyword, -109 "scriptfile": "workflow.py", -110 # Keyword should be followed by whitespace -111 "withspace": True, -112 # Argument optional -113 "argumenttype": 1, -114 # Placeholder title -115 "title": "Search", -116 # "Please wait" subtext -117 "runningsubtext": "Loading...", -118 # External script -119 "type": 8, -120 # Terminate previous script -121 "queuemode": 2, -122 # Always run immediately for first typed character -123 "queuedelayimmediatelyinitially": True, -124 # Don't set argv when empty -125 "argumenttreatemptyqueryasnil": True, -126 }, -127 }, -128 ], -129 } -130 -131 -132def _zip_dir(directory: Path, output_file: Path): -133 """ -134 Zip the contents of the provided directory recursively -135 -136 :param directory: The directory to compress -137 :param output_file: The target file -138 """ + 64 :return: The path to the directory with Alfred's workflows + 65 """ + 66 + 67 sync_dir = _get_sync_directory() + 68 prefs_dir = sync_dir or Path.home() / "Library" / "Application Support" / "Alfred/" + 69 + 70 return prefs_dir / "Alfred.alfredpreferences" / "workflows" + 71 + 72 + 73def _make_plist( + 74 name: str, keyword: str, bundle_id: str, author: Optional[str], website: Optional[str], description: Optional[str] + 75) -> dict: + 76 """ + 77 Create a dictionary representation of the Info.plist file describing the workflow + 78 + 79 :param name: + 80 The name of the workflow + 81 :param keyword: + 82 The keyword to trigger the workflow + 83 :param bundle_id: + 84 The bundle ID to identify the workflow. Should use reverse-DNS notation + 85 :param author: + 86 The name of the author + 87 :param website: + 88 The website of the workflow + 89 :param description: + 90 The description of the workflow. Will be shown to the user when importing + 91 :return: a dictionary representation of the Info.plist file + 92 """ + 93 script_uuid = str(uuid4()) + 94 clipboard_uuid = str(uuid4()) + 95 + 96 return { + 97 "name": name, + 98 "description": description or "", + 99 "bundleid": bundle_id, +100 "createdby": author or "", +101 "connections": {script_uuid: [{"destinationuid": clipboard_uuid}]}, +102 "uidata": [], +103 # Environment variables +104 # Add the vendored directory to the PYTHONPATH so that we're also searching there for dependencies +105 "variables": {"PYTHONPATH": ".:vendored"}, +106 # The workflow version +107 "version": "0.0.1", +108 # The contact website +109 "webaddress": website or "", +110 "objects": [ +111 {"uid": clipboard_uuid, "type": "alfred.workflow.output.clipboard", "config": {"clipboardtext": "{query}"}}, +112 { +113 "uid": script_uuid, +114 "type": "alfred.workflow.input.scriptfilter", +115 "config": { +116 "keyword": keyword, +117 "scriptfile": "workflow.py", +118 # Keyword should be followed by whitespace +119 "withspace": True, +120 # Argument optional +121 "argumenttype": 1, +122 # Placeholder title +123 "title": "Search", +124 # "Please wait" subtext +125 "runningsubtext": "Loading...", +126 # External script +127 "type": 8, +128 # Terminate previous script +129 "queuemode": 2, +130 # Always run immediately for first typed character +131 "queuedelayimmediatelyinitially": True, +132 # Don't set argv when empty +133 "argumenttreatemptyqueryasnil": True, +134 }, +135 }, +136 ], +137 } +138 139 -140 with ZipFile(output_file, "w", ZIP_DEFLATED) as zip_file: -141 for entry in directory.rglob("**/*"): -142 if entry.is_file(): -143 logging.debug("Adding to package: %s", entry) -144 zip_file.write(entry, entry.relative_to(directory)) -145 logging.info("Produced package at %s", output_file) -146 +140def _zip_dir(directory: Path, output_file: Path): +141 """ +142 Zip the contents of the provided directory recursively +143 +144 :param directory: The directory to compress +145 :param output_file: The target file +146 """ 147 -148def find_workflow_link(target: Path) -> Optional[Path]: -149 """ -150 Finds a link to the workflow in Alfred's workflows directory -151 -152 :param target: The path to the workflow we're looking for -153 :return: The path if found; `None` otherwise -154 """ -155 target = target.expanduser() -156 workflows = _get_workflows_directory() -157 -158 for wf in workflows.iterdir(): -159 if wf.is_symlink() and wf.readlink().expanduser() == target: -160 return wf -161 -162 return None -163 -164 -165def new(args: argparse.Namespace): -166 """ -167 Entry point for the `new` command. Creates a new Alfred workflow. -168 -169 This creates a directory of the name in the `name` argument and links it into Alfred's workflows directory. The -170 workflow shows in the Alfred Preferences app and can still be easily edited with an external editor. +148 with ZipFile(output_file, "w", ZIP_DEFLATED) as zip_file: +149 for entry in directory.rglob("**/*"): +150 if entry.is_file(): +151 logging.debug("Adding to package: %s", entry) +152 zip_file.write(entry, entry.relative_to(directory)) +153 logging.info("Produced package at %s", output_file) +154 +155 +156def find_workflow_link(target: Path) -> Optional[Path]: +157 """ +158 Finds a link to the workflow in Alfred's workflows directory +159 +160 :param target: The path to the workflow we're looking for +161 :return: The path if found; `None` otherwise +162 """ +163 target = target.expanduser() +164 workflows = _get_workflows_directory() +165 +166 for wf in workflows.iterdir(): +167 if wf.is_symlink() and wf.readlink().expanduser() == target: +168 return wf +169 +170 return None 171 -172 ``` -173 usage: pyfred new [-h] -k KEYWORD -b BUNDLE_ID [--author AUTHOR] [--website WEBSITE] [--description DESCRIPTION] [--git | --no-git] name -174 -175 positional arguments: -176 name Name of the new workflow -177 -178 options: -179 -h, --help show this help message and exit -180 -k KEYWORD, --keyword KEYWORD -181 The keyword to trigger the workflow -182 -b BUNDLE_ID, --bundle-id BUNDLE_ID -183 The bundle identifier, usually in reverse DNS notation -184 --author AUTHOR Name of the author -185 --website WEBSITE The workflow website -186 --description DESCRIPTION -187 A description for the workflow -188 --git, --no-git Whether to create a git repository (default: True) -189 ``` -190 """ # noqa: E501 -191 name = args.name -192 logging.info("Creating new workflow: %s", name) -193 -194 root_dir = Path.cwd().joinpath(name) -195 wf_dir = root_dir.joinpath("workflow") -196 -197 try: -198 logging.debug("Copying template") -199 template_dir = Path(pathlib.os.path.dirname(__file__)).joinpath("template") # type: ignore -200 logging.debug("Copying %s to %s", template_dir, root_dir) -201 shutil.copytree(template_dir, root_dir) -202 except OSError as e: -203 logging.error("Cannot create workflow: %s", e) -204 exit(1) -205 -206 wf_file_path = wf_dir.joinpath("workflow.py") -207 -208 logging.debug("Adding +x permission to workflow") -209 wf_file_path.chmod(wf_file_path.stat().st_mode | stat.S_IEXEC) -210 -211 if args.git: -212 logging.debug("Initialising git repository") -213 if subprocess.call(["git", "init", args.name]) != 0: -214 logging.warning("Failed to create git repository. Ignoring.") +172 +173def new(args: argparse.Namespace): +174 """ +175 Entry point for the `new` command. Creates a new Alfred workflow. +176 +177 This creates a directory of the name in the `name` argument and links it into Alfred's workflows directory. The +178 workflow shows in the Alfred Preferences app and can still be easily edited with an external editor. +179 +180 ``` +181 usage: pyfred new [-h] -k KEYWORD -b BUNDLE_ID [--author AUTHOR] [--website WEBSITE] [--description DESCRIPTION] [--git | --no-git] name +182 +183 positional arguments: +184 name Name of the new workflow +185 +186 options: +187 -h, --help show this help message and exit +188 -k KEYWORD, --keyword KEYWORD +189 The keyword to trigger the workflow +190 -b BUNDLE_ID, --bundle-id BUNDLE_ID +191 The bundle identifier, usually in reverse DNS notation +192 --author AUTHOR Name of the author +193 --website WEBSITE The workflow website +194 --description DESCRIPTION +195 A description for the workflow +196 --git, --no-git Whether to create a git repository (default: True) +197 ``` +198 """ # noqa: E501 +199 name = args.name +200 logging.info("Creating new workflow: %s", name) +201 +202 root_dir = Path.cwd().joinpath(name) +203 wf_dir = root_dir.joinpath("workflow") +204 +205 try: +206 logging.debug("Copying template") +207 template_dir = Path(pathlib.os.path.dirname(__file__)).joinpath("template") # type: ignore +208 logging.debug("Copying %s to %s", template_dir, root_dir) +209 shutil.copytree(template_dir, root_dir) +210 except OSError as e: +211 logging.error("Cannot create workflow: %s", e) +212 exit(1) +213 +214 wf_file_path = wf_dir.joinpath("workflow.py") 215 -216 logging.debug("Creating Info.plist") -217 with wf_dir.joinpath("Info.plist").open(mode="wb") as f: -218 plistlib.dump( -219 _make_plist( -220 name=name, -221 keyword=args.keyword, -222 bundle_id=args.bundle_id, -223 author=args.author, -224 website=args.website, -225 description=args.description, -226 ), -227 f, -228 sort_keys=True, -229 ) -230 _vendor(root_dir) -231 _link(relink=True, same_path=False, wf_dir=wf_dir) -232 -233 -234@_must_be_run_from_workflow_project_root -235def link(args: argparse.Namespace): -236 """ -237 Entry point for the `link` command. Links or relinks the workflow into Alfred's workflows directory. -238 -239 ``` -240 usage: pyfred link [-h] [--relink | --no-relink] [--same-path | --no-same-path] +216 logging.debug("Adding +x permission to workflow") +217 wf_file_path.chmod(wf_file_path.stat().st_mode | stat.S_IEXEC) +218 +219 if args.git: +220 logging.debug("Initialising git repository") +221 if subprocess.call(["git", "init", args.name]) != 0: +222 logging.warning("Failed to create git repository. Ignoring.") +223 +224 logging.debug("Creating Info.plist") +225 with wf_dir.joinpath("Info.plist").open(mode="wb") as f: +226 plistlib.dump( +227 _make_plist( +228 name=name, +229 keyword=args.keyword, +230 bundle_id=args.bundle_id, +231 author=args.author, +232 website=args.website, +233 description=args.description, +234 ), +235 f, +236 sort_keys=True, +237 ) +238 _vendor(root_dir) +239 _link(relink=True, same_path=False, wf_dir=wf_dir) +240 241 -242 options: -243 -h, --help show this help message and exit -244 --relink, --no-relink -245 Whether to delete (if exists) and recreate the link (default: False) -246 --same-path, --no-same-path -247 Whether to reuse (if exists) the previous path for the link (default: False) -248 ``` -249 """ -250 try: -251 _link(relink=args.relink, same_path=args.same_path, wf_dir=Path.cwd().joinpath("workflow")) -252 except ValueError as e: -253 logging.error("Error creating link: %s", e) -254 exit(1) -255 -256 -257def _link(relink: bool, same_path: bool, wf_dir: Path): -258 """ -259 Create a link to the workflow in Alfred's workflows directory -260 -261 :param relink: -262 Whether to recreate the link if it exists -263 :param same_path: -264 Whether to reuse the same link if one exists -265 :param wf_dir: -266 The directory to link to -267 :return: -268 """ -269 if not wf_dir.exists(): -270 raise ValueError(f"{wf_dir} doesn't exist") -271 -272 if not wf_dir.is_dir(): -273 raise ValueError(f"{wf_dir} is not a directory") -274 -275 existing_link = find_workflow_link(wf_dir) -276 -277 if existing_link: -278 if not relink: -279 logging.debug("Found link: %s", existing_link) -280 return -281 -282 logging.debug("Removing existing link: %s", existing_link) -283 existing_link.unlink() +242@_must_be_run_from_workflow_project_root +243def link(args: argparse.Namespace): +244 """ +245 Entry point for the `link` command. Links or relinks the workflow into Alfred's workflows directory. +246 +247 ``` +248 usage: pyfred link [-h] [--relink | --no-relink] [--same-path | --no-same-path] +249 +250 options: +251 -h, --help show this help message and exit +252 --relink, --no-relink +253 Whether to delete (if exists) and recreate the link (default: False) +254 --same-path, --no-same-path +255 Whether to reuse (if exists) the previous path for the link (default: False) +256 ``` +257 """ +258 try: +259 _link(relink=args.relink, same_path=args.same_path, wf_dir=Path.cwd().joinpath("workflow")) +260 except ValueError as e: +261 logging.error("Error creating link: %s", e) +262 exit(1) +263 +264 +265def _link(relink: bool, same_path: bool, wf_dir: Path): +266 """ +267 Create a link to the workflow in Alfred's workflows directory +268 +269 :param relink: +270 Whether to recreate the link if it exists +271 :param same_path: +272 Whether to reuse the same link if one exists +273 :param wf_dir: +274 The directory to link to +275 :return: +276 """ +277 if not wf_dir.exists(): +278 raise ValueError(f"{wf_dir} doesn't exist") +279 +280 if not wf_dir.is_dir(): +281 raise ValueError(f"{wf_dir} is not a directory") +282 +283 existing_link = find_workflow_link(wf_dir) 284 -285 logging.info("Creating link to workflow directory %s", wf_dir) -286 -287 if same_path and existing_link: -288 source = existing_link -289 else: -290 workflow_id = str(uuid4()).upper() -291 source = _get_workflows_directory().joinpath(f"user.workflow.{workflow_id}") +285 if existing_link: +286 if not relink: +287 logging.debug("Found link: %s", existing_link) +288 return +289 +290 logging.debug("Removing existing link: %s", existing_link) +291 existing_link.unlink() 292 -293 logging.debug("Creating link: %s", source) -294 source.symlink_to(wf_dir) -295 -296 if not source.exists(): -297 logging.error("Error linking from %s to %s", source, wf_dir) -298 source.unlink(missing_ok=True) -299 +293 logging.info("Creating link to workflow directory %s", wf_dir) +294 +295 if same_path and existing_link: +296 source = existing_link +297 else: +298 workflow_id = str(uuid4()).upper() +299 source = _get_workflows_directory().joinpath(f"user.workflow.{workflow_id}") 300 -301@_must_be_run_from_workflow_project_root -302def vendor(_args: argparse.Namespace): -303 """ -304 Entry point for the `vendor` command -305 -306 Downloads dependencies specified in the `requirements.txt` file into the workflow's `vendored` directory. -307 This way, the dependencies don't need to be installed into the system Python interpreter. +301 logging.debug("Creating link: %s", source) +302 source.symlink_to(wf_dir) +303 +304 if not source.exists(): +305 logging.error("Error linking from %s to %s", source, wf_dir) +306 source.unlink(missing_ok=True) +307 308 -309 The workflow sets the `PYTHONPATH` environment variable to `.:vendored`, making the interpreter search for -310 dependencies in that directory, in addition to the workflow directory. -311 -312 ``` -313 usage: pyfred vendor [-h] -314 -315 options: -316 -h, --help show this help message and exit -317 ``` -318 """ -319 _vendor(root_path=Path.cwd()) -320 -321 -322def _vendor(root_path: Path) -> bool: -323 """ -324 Download dependencies from `requirements.txt` -325 -326 :param root_path: The root path of the workflow project -327 :return: whether the download was successful -328 """ +309@_must_be_run_from_workflow_project_root +310def vendor(_args: argparse.Namespace): +311 """ +312 Entry point for the `vendor` command +313 +314 Downloads dependencies specified in the `requirements.txt` file into the workflow's `vendored` directory. +315 This way, the dependencies don't need to be installed into the system Python interpreter. +316 +317 The workflow sets the `PYTHONPATH` environment variable to `.:vendored`, making the interpreter search for +318 dependencies in that directory, in addition to the workflow directory. +319 +320 ``` +321 usage: pyfred vendor [-h] +322 +323 options: +324 -h, --help show this help message and exit +325 ``` +326 """ +327 _vendor(root_path=Path.cwd()) +328 329 -330 vendored_path = root_path / "workflow" / "vendored" -331 vendored_path.mkdir(parents=True, exist_ok=True) -332 -333 import subprocess -334 -335 pip_command = [ -336 sys.executable, -337 "-m", -338 "pip", -339 "install", -340 "-r", -341 f"{root_path}/requirements.txt", -342 f"--target={vendored_path}", -343 ] -344 logging.debug("Running pip: python %s", " ".join(pip_command[1:])) -345 -346 return subprocess.call(pip_command) == 0 -347 -348 -349@_must_be_run_from_workflow_project_root -350def package(_args: argparse.Namespace): -351 """ -352 Entry point for the `package` command. Creates a package for distribution. +330def _vendor(root_path: Path) -> bool: +331 """ +332 Download dependencies from `requirements.txt` +333 +334 :param root_path: The root path of the workflow project +335 :return: whether the download was successful +336 """ +337 +338 vendored_path = root_path / "workflow" / "vendored" +339 vendored_path.mkdir(parents=True, exist_ok=True) +340 +341 import subprocess +342 +343 pip_command = [ +344 sys.executable, +345 "-m", +346 "pip", +347 "install", +348 "-r", +349 f"{root_path}/requirements.txt", +350 f"--target={vendored_path}", +351 ] +352 logging.debug("Running pip: python %s", " ".join(pip_command[1:])) 353 -354 Packages the workflow into a `workflow.alfredworkflow` file in the `dist` directory. -355 Users can import the package by double-clicking the file. +354 return subprocess.call(pip_command) == 0 +355 356 -357 ``` -358 usage: pyfred package [-h] -359 -360 options: -361 -h, --help show this help message and exit -362 ``` -363 """ -364 root_dir = Path.cwd() -365 -366 if not _vendor(Path.cwd()): -367 logging.error("Failed to download dependencies. Exiting") -368 exit(1) -369 -370 output = root_dir / "dist" -371 output.mkdir(exist_ok=True) -372 -373 _zip_dir(root_dir / "workflow", output / "workflow.alfredworkflow") -374 -375 -376def _cli(): -377 """ -378 The entry point for the CLI. -379 -380 ``` -381 usage: pyfred [-h] {new,vendor,link,package} ... +357@_must_be_run_from_workflow_project_root +358def package(_args: argparse.Namespace): +359 """ +360 Entry point for the `package` command. Creates a package for distribution. +361 +362 Packages the workflow into a `workflow.alfredworkflow` file in the `dist` directory. +363 Users can import the package by double-clicking the file. +364 +365 ``` +366 usage: pyfred package [-h] +367 +368 options: +369 -h, --help show this help message and exit +370 ``` +371 """ +372 root_dir = Path.cwd() +373 +374 if not _vendor(Path.cwd()): +375 logging.error("Failed to download dependencies. Exiting") +376 exit(1) +377 +378 output = root_dir / "dist" +379 output.mkdir(exist_ok=True) +380 +381 _zip_dir(root_dir / "workflow", output / "workflow.alfredworkflow") 382 -383 Build Python workflows for Alfred with ease -384 -385 positional arguments: -386 {new,vendor,link,package} -387 new Create a new workflow -388 vendor Install workflow dependencies -389 link Create a symbolic link to this workflow in Alfred -390 package Package the workflow for distribution -391 -392 options: -393 -h, --help show this help message and exit -394 --debug, --no-debug Whether to enable debug logging (default: False) -395 ``` -396 -397 """ -398 parser = argparse.ArgumentParser(prog="pyfred", description="Build Python workflows for Alfred with ease") -399 parser.add_argument( -400 "--debug", action=argparse.BooleanOptionalAction, default=False, help="Whether to enable debug logging" -401 ) -402 subparsers = parser.add_subparsers(required=True) -403 -404 new_parser = subparsers.add_parser("new", help="Create a new workflow") -405 new_parser.add_argument("name", type=str, help="Name of the new workflow") -406 new_parser.add_argument("-k", "--keyword", type=str, required=True, help="The keyword to trigger the workflow") -407 new_parser.add_argument( -408 "-b", "--bundle-id", type=str, required=True, help="The bundle identifier, usually in reverse DNS notation" +383 +384def _cli(): +385 """ +386 The entry point for the CLI. +387 +388 ``` +389 usage: pyfred [-h] {new,vendor,link,package} ... +390 +391 Build Python workflows for Alfred with ease +392 +393 positional arguments: +394 {new,vendor,link,package} +395 new Create a new workflow +396 vendor Install workflow dependencies +397 link Create a symbolic link to this workflow in Alfred +398 package Package the workflow for distribution +399 +400 options: +401 -h, --help show this help message and exit +402 --debug, --no-debug Whether to enable debug logging (default: False) +403 ``` +404 +405 """ +406 parser = argparse.ArgumentParser(prog="pyfred", description="Build Python workflows for Alfred with ease") +407 parser.add_argument( +408 "--debug", action=argparse.BooleanOptionalAction, default=False, help="Whether to enable debug logging" 409 ) -410 new_parser.add_argument("--author", type=str, help="Name of the author") -411 new_parser.add_argument("--website", type=str, help="The workflow website") -412 new_parser.add_argument("--description", type=str, help="A description for the workflow") -413 new_parser.add_argument( -414 "--git", action=argparse.BooleanOptionalAction, default=True, help="Whether to create a git repository" -415 ) -416 new_parser.set_defaults(func=new) -417 -418 vendor_parser = subparsers.add_parser("vendor", help="Install workflow dependencies") -419 vendor_parser.set_defaults(func=vendor) -420 -421 link_parser = subparsers.add_parser("link", help="Create a symbolic link to this workflow in Alfred") -422 link_parser.add_argument( -423 "--relink", -424 action=argparse.BooleanOptionalAction, -425 default=False, -426 help="Whether to delete (if exists) and recreate the link", -427 ) -428 link_parser.add_argument( -429 "--same-path", -430 action=argparse.BooleanOptionalAction, -431 default=False, -432 help="Whether to reuse (if exists) the previous path for the link", -433 ) -434 link_parser.set_defaults(func=link) -435 -436 package_parser = subparsers.add_parser("package", help="Package the workflow for distribution") -437 package_parser.set_defaults(func=package) -438 -439 args = parser.parse_args() -440 -441 logging.basicConfig( -442 format="%(asctime)s %(levelname)-8s %(message)s", -443 level=logging.DEBUG if args.debug else logging.INFO, -444 datefmt="%Y-%m-%d %H:%M:%S", -445 ) +410 subparsers = parser.add_subparsers(required=True) +411 +412 new_parser = subparsers.add_parser("new", help="Create a new workflow") +413 new_parser.add_argument("name", type=str, help="Name of the new workflow") +414 new_parser.add_argument("-k", "--keyword", type=str, required=True, help="The keyword to trigger the workflow") +415 new_parser.add_argument( +416 "-b", "--bundle-id", type=str, required=True, help="The bundle identifier, usually in reverse DNS notation" +417 ) +418 new_parser.add_argument("--author", type=str, help="Name of the author") +419 new_parser.add_argument("--website", type=str, help="The workflow website") +420 new_parser.add_argument("--description", type=str, help="A description for the workflow") +421 new_parser.add_argument( +422 "--git", action=argparse.BooleanOptionalAction, default=True, help="Whether to create a git repository" +423 ) +424 new_parser.set_defaults(func=new) +425 +426 vendor_parser = subparsers.add_parser("vendor", help="Install workflow dependencies") +427 vendor_parser.set_defaults(func=vendor) +428 +429 link_parser = subparsers.add_parser("link", help="Create a symbolic link to this workflow in Alfred") +430 link_parser.add_argument( +431 "--relink", +432 action=argparse.BooleanOptionalAction, +433 default=False, +434 help="Whether to delete (if exists) and recreate the link", +435 ) +436 link_parser.add_argument( +437 "--same-path", +438 action=argparse.BooleanOptionalAction, +439 default=False, +440 help="Whether to reuse (if exists) the previous path for the link", +441 ) +442 link_parser.set_defaults(func=link) +443 +444 package_parser = subparsers.add_parser("package", help="Package the workflow for distribution") +445 package_parser.set_defaults(func=package) 446 -447 args.func(args) +447 args = parser.parse_args() 448 -449 -450if __name__ == "__main__": -451 _cli() +449 logging.basicConfig( +450 format="%(asctime)s %(levelname)-8s %(message)s", +451 level=logging.DEBUG if args.debug else logging.INFO, +452 datefmt="%Y-%m-%d %H:%M:%S", +453 ) +454 +455 args.func(args) +456 +457 +458if __name__ == "__main__": +459 _cli() @@ -530,21 +538,21 @@

-
149def find_workflow_link(target: Path) -> Optional[Path]:
-150    """
-151    Finds a link to the workflow in Alfred's workflows directory
-152
-153    :param target: The path to the workflow we're looking for
-154    :return: The path if found; `None` otherwise
-155    """
-156    target = target.expanduser()
-157    workflows = _get_workflows_directory()
-158
-159    for wf in workflows.iterdir():
-160        if wf.is_symlink() and wf.readlink().expanduser() == target:
-161            return wf
-162
-163    return None
+            
157def find_workflow_link(target: Path) -> Optional[Path]:
+158    """
+159    Finds a link to the workflow in Alfred's workflows directory
+160
+161    :param target: The path to the workflow we're looking for
+162    :return: The path if found; `None` otherwise
+163    """
+164    target = target.expanduser()
+165    workflows = _get_workflows_directory()
+166
+167    for wf in workflows.iterdir():
+168        if wf.is_symlink() and wf.readlink().expanduser() == target:
+169            return wf
+170
+171    return None
 
@@ -576,73 +584,73 @@
Returns
-
166def new(args: argparse.Namespace):
-167    """
-168    Entry point for the `new` command. Creates a new Alfred workflow.
-169
-170    This creates a directory of the name in the `name` argument and links it into Alfred's workflows directory. The
-171    workflow shows in the Alfred Preferences app and can still be easily edited with an external editor.
-172
-173    ```
-174    usage: pyfred new [-h] -k KEYWORD -b BUNDLE_ID [--author AUTHOR] [--website WEBSITE] [--description DESCRIPTION] [--git | --no-git] name
-175
-176    positional arguments:
-177      name                  Name of the new workflow
-178
-179    options:
-180      -h, --help            show this help message and exit
-181      -k KEYWORD, --keyword KEYWORD
-182                            The keyword to trigger the workflow
-183      -b BUNDLE_ID, --bundle-id BUNDLE_ID
-184                            The bundle identifier, usually in reverse DNS notation
-185      --author AUTHOR       Name of the author
-186      --website WEBSITE     The workflow website
-187      --description DESCRIPTION
-188                            A description for the workflow
-189      --git, --no-git       Whether to create a git repository (default: True)
-190    ```
-191    """  # noqa: E501
-192    name = args.name
-193    logging.info("Creating new workflow: %s", name)
-194
-195    root_dir = Path.cwd().joinpath(name)
-196    wf_dir = root_dir.joinpath("workflow")
-197
-198    try:
-199        logging.debug("Copying template")
-200        template_dir = Path(pathlib.os.path.dirname(__file__)).joinpath("template")  # type: ignore
-201        logging.debug("Copying %s to %s", template_dir, root_dir)
-202        shutil.copytree(template_dir, root_dir)
-203    except OSError as e:
-204        logging.error("Cannot create workflow: %s", e)
-205        exit(1)
-206
-207    wf_file_path = wf_dir.joinpath("workflow.py")
-208
-209    logging.debug("Adding +x permission to workflow")
-210    wf_file_path.chmod(wf_file_path.stat().st_mode | stat.S_IEXEC)
-211
-212    if args.git:
-213        logging.debug("Initialising git repository")
-214        if subprocess.call(["git", "init", args.name]) != 0:
-215            logging.warning("Failed to create git repository. Ignoring.")
+            
174def new(args: argparse.Namespace):
+175    """
+176    Entry point for the `new` command. Creates a new Alfred workflow.
+177
+178    This creates a directory of the name in the `name` argument and links it into Alfred's workflows directory. The
+179    workflow shows in the Alfred Preferences app and can still be easily edited with an external editor.
+180
+181    ```
+182    usage: pyfred new [-h] -k KEYWORD -b BUNDLE_ID [--author AUTHOR] [--website WEBSITE] [--description DESCRIPTION] [--git | --no-git] name
+183
+184    positional arguments:
+185      name                  Name of the new workflow
+186
+187    options:
+188      -h, --help            show this help message and exit
+189      -k KEYWORD, --keyword KEYWORD
+190                            The keyword to trigger the workflow
+191      -b BUNDLE_ID, --bundle-id BUNDLE_ID
+192                            The bundle identifier, usually in reverse DNS notation
+193      --author AUTHOR       Name of the author
+194      --website WEBSITE     The workflow website
+195      --description DESCRIPTION
+196                            A description for the workflow
+197      --git, --no-git       Whether to create a git repository (default: True)
+198    ```
+199    """  # noqa: E501
+200    name = args.name
+201    logging.info("Creating new workflow: %s", name)
+202
+203    root_dir = Path.cwd().joinpath(name)
+204    wf_dir = root_dir.joinpath("workflow")
+205
+206    try:
+207        logging.debug("Copying template")
+208        template_dir = Path(pathlib.os.path.dirname(__file__)).joinpath("template")  # type: ignore
+209        logging.debug("Copying %s to %s", template_dir, root_dir)
+210        shutil.copytree(template_dir, root_dir)
+211    except OSError as e:
+212        logging.error("Cannot create workflow: %s", e)
+213        exit(1)
+214
+215    wf_file_path = wf_dir.joinpath("workflow.py")
 216
-217    logging.debug("Creating Info.plist")
-218    with wf_dir.joinpath("Info.plist").open(mode="wb") as f:
-219        plistlib.dump(
-220            _make_plist(
-221                name=name,
-222                keyword=args.keyword,
-223                bundle_id=args.bundle_id,
-224                author=args.author,
-225                website=args.website,
-226                description=args.description,
-227            ),
-228            f,
-229            sort_keys=True,
-230        )
-231    _vendor(root_dir)
-232    _link(relink=True, same_path=False, wf_dir=wf_dir)
+217    logging.debug("Adding +x permission to workflow")
+218    wf_file_path.chmod(wf_file_path.stat().st_mode | stat.S_IEXEC)
+219
+220    if args.git:
+221        logging.debug("Initialising git repository")
+222        if subprocess.call(["git", "init", args.name]) != 0:
+223            logging.warning("Failed to create git repository. Ignoring.")
+224
+225    logging.debug("Creating Info.plist")
+226    with wf_dir.joinpath("Info.plist").open(mode="wb") as f:
+227        plistlib.dump(
+228            _make_plist(
+229                name=name,
+230                keyword=args.keyword,
+231                bundle_id=args.bundle_id,
+232                author=args.author,
+233                website=args.website,
+234                description=args.description,
+235            ),
+236            f,
+237            sort_keys=True,
+238        )
+239    _vendor(root_dir)
+240    _link(relink=True, same_path=False, wf_dir=wf_dir)
 
diff --git a/pyfred/cli.py b/pyfred/cli.py index bf54ef0..88ffcd8 100644 --- a/pyfred/cli.py +++ b/pyfred/cli.py @@ -30,11 +30,11 @@ def decorator(args: argparse.Namespace): return decorator -def _get_sync_directory() -> Path: +def _get_sync_directory() -> Optional[Path]: """ :return: The path to Alfred's sync directory """ - prefs_path = Path.home().joinpath("Library/Preferences/com.runningwithcrayons.Alfred-Preferences.plist") + prefs_path = Path.home() / "Library" / "Preferences" / "com.runningwithcrayons.Alfred-Preferences.plist" if not prefs_path.exists(): raise ValueError("Alfred doesn't appear to be installed") @@ -43,7 +43,8 @@ def _get_sync_directory() -> Path: pl = plistlib.load(f) if "syncfolder" not in pl: - raise ValueError("Alfred's synchronisation directory not set") + logging.debug("Alfred's synchronisation directory not set") + return None sync_dir = Path(pl["syncfolder"]).expanduser() @@ -57,9 +58,16 @@ def _get_workflows_directory() -> Path: """ Get the directory where Alfred stores workflows + Finds the Alfred.alfredpreferences dir either in the sync location set in the Alfred settings or in the default + location. + :return: The path to the directory with Alfred's workflows """ - return _get_sync_directory() / "Alfred.alfredpreferences" / "workflows" + + sync_dir = _get_sync_directory() + prefs_dir = sync_dir or Path.home() / "Library" / "Application Support" / "Alfred/" + + return prefs_dir / "Alfred.alfredpreferences" / "workflows" def _make_plist( diff --git a/pyfred/tests/test_cli.py b/pyfred/tests/test_cli.py index 5694476..fb304f4 100644 --- a/pyfred/tests/test_cli.py +++ b/pyfred/tests/test_cli.py @@ -6,7 +6,7 @@ import pytest -from pyfred.cli import link, new, package, vendor +from pyfred.cli import _get_workflows_directory, link, new, package, vendor from pyfred.model import Data, Icon, Key, OutputItem, ScriptFilterOutput, Text, Type @@ -53,6 +53,13 @@ def test_new(tmpdir): assert installed_workflows[0].readlink() == tmpdir / "test_wf" / "workflow" +def test_get_workflows_directory(): + expected = Path.home() / "Library/Application Support/Alfred/Alfred.alfredpreferences/workflows" + + with patch("pyfred.cli._get_sync_directory", return_value=None): + assert _get_workflows_directory() == expected + + def test_full_model_serialises_to_json(): output = ScriptFilterOutput( rerun=4.2, diff --git a/setup.cfg b/setup.cfg index 91f573c..b02222c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pyfred-cli -version = 0.1.1 +version = 0.1.2 author = Björn Marschollek project_urls= Source Code = https://github.com/muffix/pyfred-cli