Skip to content

Instantly share code, notes, and snippets.

@benspaulding
Last active April 20, 2024 14:11
Show Gist options
  • Save benspaulding/275e05d46afb503897b1d66a60ee49cd to your computer and use it in GitHub Desktop.
Save benspaulding/275e05d46afb503897b1d66a60ee49cd to your computer and use it in GitHub Desktop.
Script to assign a bunch of file types to a particular macOS app
#!/usr/bin/env python3
"""
Assign file types to be opened by a particular application.
Requires the ``duti`` program::
> brew install duti
> ./appfiles.py --help
TODO
----
- try/except around each call, save errors to reprint at the end
- use text format for filetypes (maybe like duti?) to allow customization
- offer to install duti if homebrew is found but duti is not
"""
import argparse
import subprocess
import sys
import typing as t
from types import SimpleNamespace
class FType(SimpleNamespace):
"""
An object that contains file association / type information.
Attrs:
ext (str): Space-separated list of extenstions, e.g., ``'.yml .yaml'``.
mime (str): Space-separated list of MIME types, e.g.,
``'text/xml application/xml'``.
uti (str): Space-separated list of UTIs, e.g., ``'public.css'``.
role (str): A role the text editor will use, e.g. ``'editor'``;
defaults to ``'all'``.
"""
# Attributes whose values can be split on whitespaces.
splitable: t.ClassVar[t.Iterable[str]] = ('ext', 'mime', 'uti')
def __init__(self,
ext: str = '',
mime: str = '',
uti: str = '',
role: str = 'all',
**kwargs: t.Dict[str, t.Any],
) -> None:
self.ext = ext
self.mime = mime
self.uti = uti
self.role = role
super(FType, self).__init__(**kwargs)
def __str__(self) -> str:
fmt = ' <6'
out = []
for attr in self.splitable:
for value in getattr(self, attr).split():
out.append(f'{attr:{fmt}} {value}')
out.append(f'{"role":{fmt}} {self.role}')
return '\n'.join(out)
FTYPES = [
FType('.bash'),
FType('.bat', 'application/x-msdownload'),
FType('.c', 'text/x-c', 'public.c-source'),
FType('.cfg'),
FType('.coffee'),
FType('.conf'),
FType('.cpp', uti='public.c-plus-plus-source'),
FType('.csh', uti='public.csh-script'),
FType('.cson'),
FType('.css', 'text/css', 'public.css'),
FType('.diff'),
FType('.fish'),
FType('.go'),
FType('.h', 'text/x-c', 'public.c-header'),
FType('.hpp', uti='public.c-plus-plus-header'),
# For some reason I get an error with this one. I believe it has
# something to do with macOS handling the default browser.
# FType('.html .htm', 'text/html', 'public.html', role='editor'),
FType('.in'),
FType('.info'),
FType('.ini'),
FType('.j2'),
FType('.java', uti='com.sun.java-source'),
FType('.js', 'text/javascript application/x-javascript', 'com.netscape.javascript-source'),
FType('.json', 'text/json application/json'),
FType('.jsx'),
FType('.less'),
FType('.lock'),
FType('.map'),
FType('.md .mdown .markdown' 'text/x-markdown', 'net.daringfireball.markdown'),
FType('.patch', uti='public.patch-file'),
FType('.php .php3 .php4 .ph3 .ph4 .phtml', 'text/x-php-script text/php application/php',
'public.php-script'),
FType('.pl .pm', 'text/x-perl text/x-perl-script', 'public.perl-script'),
FType('.po'),
FType('.properties'),
FType('.py', 'text/x-python text/x-python-script', 'public.python-script'),
FType('.rb .rbw', 'text/ruby text/ruby-script', 'public.ruby-script'),
FType('.rs', 'application/rls-services+xml'),
FType('.rst'),
FType('.sass'),
FType('.sed'),
FType('.sh', 'application/x-sh', 'public.shell-script'),
FType('.sql', 'application/x-sql'),
FType('.toml'),
FType('.txt', 'text/plain', 'public.plain-text public.text public.data'),
FType('.vim'),
FType('.wsgi'),
FType('.xhtml', 'application/xhtml', 'public.xhtml', role='editor'),
FType('.xml', 'text/xml application/xml', 'public.xml'),
FType('.yml .yaml', 'text/x-yaml application/x-yaml'),
FType('.zsh'),
]
def assign(ftype: FType,
app_id: str,
dry_run: bool = False,
) -> None:
"""
Assign files to a given application.
Args:
ftype (FType): An object with ext, mime, uti, and role attributes.
app_id (str): The bundle ID for a macOS application.
dry_run (bool): If True only print commands that would be run.
Returns:
None
"""
for attr in ftype.splitable:
for arg in getattr(ftype, attr).split():
args = ['duti', '-s', app_id, arg, ftype.role]
print(' '.join(args[2:]))
if not dry_run:
subprocess.run(args, check=True)
def get_parser() -> argparse.ArgumentParser:
"""Configure parser and return argument parser."""
parser = argparse.ArgumentParser(
description='Associate file types listed herein withe the given application.',
epilog=('This application requires the `duti` program to be installed. '
'The simplest way is via homebrew, a la `brew install duti`.'))
parser.add_argument('app', nargs='?', default='',
help=('The name, path, or code of an application. E.g., `Code`, '
'`~/Applications/Visual Studio Code.app`, or `com.microsoft.VSCode`. '
'If none is provided it will be read from stdin.'))
parser.add_argument('-y', '--noinput', action='store_false', dest='confirm', default=True,
help='Do not prompt for any kind of input or confirmation.')
parser.add_argument('-n', '--dry-run', action='store_true', dest='dry_run', default=False,
help='Do everything but actually change file assignments.')
parser.add_argument('-l', '--list-types', action='store_true', dest='list_types', default=False,
help=('List the file types %(prog)s acts on and exit. '
'(If you want to change these, edit the script.'))
return parser
def validate_args(parser: argparse.ArgumentParser) -> argparse.Namespace:
"""
Validate and transform arguments beyond what argparse does.
Args:
parser (ArgumentParser): A parser to validate.
Returns:
Namespace: The parsed, validated arguments.
"""
args = parser.parse_args()
if args.list_types:
parser.exit(message='\n\n'.join(str(ftype) for ftype in FTYPES))
# Capture stdout just to keep it from printing to screen.
if subprocess.run(['which', 'duti'], stdout=subprocess.PIPE).returncode:
parser.error('The commandline utililty `duti` cannot be found. Is it installed?')
if not args.app:
if not sys.stdin.isatty():
while '\n' not in args.app:
args.app = f'{args.app}{sys.stdin.read(1)}'
elif args.confirm:
try:
prompt = [
'No application was provided -- a name, path, or package is required.',
'Enter one now? [Y/n]: ',
]
if input('\n'.join(prompt)).lower() not in ['', 'y', 'yes', 'yep', 'yeah']:
parser.exit('\nExiting.')
while not args.app:
args.app = input('> ')
except KeyboardInterrupt:
parser.exit(message='\nExiting.')
if not args.app:
parser.error('No application was provided.')
appid = subprocess.run(['appid.applescript', args.app], stdout=subprocess.PIPE)
args.app = appid.stdout.decode('utf8').strip() # Is utf-8 correct?
return args
def main() -> None:
parser = get_parser()
args = validate_args(parser)
for ftype in FTYPES:
assign(ftype, args.app, args.dry_run)
if __name__ == '__main__':
main()
#!/usr/bin/env osascript
(* Returns the bundle ID for the given apps, or an empty string if none is
found. The apps can be a name, a path, or left empty to be prompted with a
picker. For example:
> ./appid.applescript "Visual Studio Code"
> ./appid.applescript "/Users/benspaulding/Applications/BBEdit.app"
> ./appid.applescript "com.apple.textedit"
> ./appid.applescript
Information is written to stderr and the id of each app (or an empty string
if no app is found) is written to stdout, one ID per line, in the order the
apps were given.
*)
on run(argv)
-- To show a GUI alert of results if the user GUI-selects one.
set prompted to false
-- To store the bundle ID of each argument passed in.
set appIDs to {}
-- If no arguments were provided, prompt for one.
if argv is equal to {} then
log "No arguments provided -- prompting for input ..."
set prompted to true
set cTitle to "Select application(s) to see bundle ID"
set cPrompt to "Select one or more apps for which to get a bundle ID:"
set argv to choose application with title cTitle with prompt cPrompt ¬
with multiple selections allowed without importing
end if
-- Iterate on input to try to find a bundle ID for each item.
repeat with appIsh in argv
set appId to ""
try
set appId to getAppId(appIsh)
end try
set end of appIDs to appId
set msg to ¬
"Given: " & appIsh & linefeed & ¬
"Found: " & appId & linefeed
log msg
if prompted then display alert msg
end repeat
set text item delimiters of AppleScript to {linefeed}
return appIDs as text
end run
on getAppId(appIsh)
-- appIsh may be an application object, ID, name, or path.
-- We want to look up by any of the above criteria without prompting the
-- user to find an application we cannot, or opening apps that we check.
-- All thanks to https://stackoverflow.com/a/40346045, the way to do it
-- is by running a shell script that calls AppleScript. Not intuitive, but
-- the only way I have found that works.
set appId to do shell script ¬
"osascript -e " & quoted form of ("id of application id " & quote & appIsh & quote) & " || " &¬
"osascript -e " & quoted form of ("id of application " & quote & appIsh & quote) & " || :"
if (appId is not "") then
return appId
else
-- Raise a reasonable error for this situation.
return application id appIsh
end if
end getAppId
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment