from Guide to Hacking on Oct 1, 2023
How to use OSX built-ins from Python
Many of OSX's interesting utilities are locked away behind some combination of XCode, Objective C, and Swift. This makes it challenging to use those APIs in a completely different development environment, such as Python.
In this post, we'll break down that barrier, and you'll learn how to access all sorts of OSX utilities from Python. Here, we'll cover just the address book and a text-to-speech library, as an example. However, there are a slew of other APIs to leverage too, ranging from speech-to-text to image recognition to using the GPU.
In particular, you'll start by using existing Python bindings for different OSX utilities. To really understand the process though, we'll build custom Python bindings ourselves as well, then show how to install and use those bindings.
Getting setup
Create a new directory to house your project. I will create one on my desktop.
mkdir ~/Desktop/pyosx
cd ~/Desktop/pyosx
In this new directory, create a new virtual environment.
python -m venv env
source env/bin/activate
We now need to install pyobjc
, a Python binding for many of the OSX's built-in utilities, which we'll use below.
pip install pyobjc
Let's now dive right into the code.
Step 0: "Hello World" in Python
To start, we'll use the Python package pyobjc
to perform two functions, using only OSX's built-in utilities:
- Access the address book, and display the first person's full name. If you don't already use Apple's address book, open up the Contacts app and add a new contact.
- Speak an arbitrary phrase that you pass in. This text-to-speech model runs locally on your machine, without using an online API; this means that your data stays private.
Starting with the address book utility, here's a script that accesses the address book, grabs the first person, and shows their display name. Write this in a new file book.py
.
from AddressBook import ABAddressBook
def get_address_book_first_entry():
book = ABAddressBook.sharedAddressBook() # get address book
people = book.people() # get people
assert people.count() > 0, "Address book is empty" # check there are people
person = people[0] # get first person
display = person.displayName() # get person's display name
return display
if __name__ == '__main__':
print(get_address_book_first_entry())
You can run this file normally, as you would any other Python script.
python book.py
The above should output the first entry's display name, such as the following
Alvin Wan
Here's another script that speaks an arbitrary phrase. Write this in a new file say.py
.
from AVFoundation import AVSpeechUtterance, AVSpeechSynthesizer
import time
def say(text):
synth = AVSpeechSynthesizer.alloc().init() # init speaker
utterance = AVSpeechUtterance.speechUtteranceWithString_(text) # init utterance
synth.speakUtterance_(utterance) # speak utterance
time.sleep(3) # allow time for speaking to finish
if __name__ == '__main__':
say("I wish oranges came in purple.")
You can run this file normally, as you would any other Python script. Before doing so, check your volume, as this script will speak a phrase.
python say.py
This script will then speak the words "I wish oranges came in purple". This completes our initial foray into using OSX's built-ins, from Python.
Let's say that we'd like to use the speech-to-text API, access our messages, or create a new desktop application. To accomplish any of these tasks, we'd need to refer to official Objective-C documentation, then translate those Objective-C APIs into their corresponding Python bindings. There are three major components to translating Objective-C:
- Add underscores for arguments. Say our Objective-C code is
[synthesizer speakUtterance:utterance];
. This calls thespeakUtterance
method with one argumentutterance
on thesynthesizer
object. The method's Python binding would bespeakUtterance_
— notice the trailing underscore, to denote a single argument. This makes the analogous Python codesynthesizer.speakUtterance_(utterance)
. - Allocate, then init. For instantiating classes, Objective-C allocates memory then initializes classes. The pyobjc bindings follow a similar pattern, so to instantiate a class called
AVSpeechSynthesizer
, we would writeAVSpeechSynthesizer.alloc().init()
. Some Objective-C classes have shortcuts for these two calls, such asNSObject.new()
which is equivalent toNSObject().alloc().init()
. - Use getters and setters. Objective-C discourages accessing instance variables directly, so when working with Objective-C objects, use getters and setters instead, such as
person.displayName()
to get a value.
For more information on how Objective-C APIs are translated into Python, see the official pyobjc documentation "An introduction to PyObjC". These resources will then allow you to translate any Objective-C code sample or documentation into Python equivalents.
The above uses existing Python bindings for Objective-C, available via pyobjc. Let's now dive a bit deeper: How do these bindings work? And more importantly, how can you build your own bindings?
Step 1: "Hello World" in Objective-C
Create a "Hello World" Objective-C script that simply prints "Hello, World!" to the console. Create a file called HelloWorld.m
with the following code.
#import <Foundation/Foundation.h>
void sayHello() {
NSLog(@"Hello, World!");
}
int main(int argc, char** argv)
{
sayHello();
}
The above script is fairly minimal: Define a function that prints "Hello, World!" to the console, then call that function from the main
function. To run this file, build then run.
# Compile the Objective-C code
clang -framework Foundation HelloWorld.m -o helloworld
# Run the executable
./helloworld
You should then see output like the following.
2023-10-01 20:57:26.177 helloworld[39063:475450] Hello, World!
You've now written your very first Objective-C script. Let's now try accessing and running this Objective-C function from Python.
Step 2: Run Objective-C from Python
To start, reduce your Objective-C file to just the below, dropping the main
function entry point and retaining only the function we'll export.
#import <Foundation/Foundation.h>
void sayHello() {
NSLog(@"Hello, World!");
}
Now, export your Objective-C file into a dynamic library by adding the -dynamiclib
flag to your compilation command.
clang -dynamiclib -framework Foundation HelloWorld.m -o HelloWorld.dylib
You will now see a HelloWorld.dylib
in your working directory. This .dylib
is a "dynamic library" — a library that other scripts can load and use at runtime rather than compile time. We'll leverage this now: Create a new Python script called hello_world.py
to load and use your newly-created dynamic library.
import ctypes
import ctypes.util
# Load the dynamic library
hello_lib = ctypes.CDLL('HelloWorld.dylib')
# Initialize the Objective-C runtime
objc = ctypes.CDLL(ctypes.util.find_library('objc'))
# Declare the "prototype" (e.g., datatypes) for sayHello
sayHello = hello_lib.sayHello
sayHello.argtypes = None
sayHello.restype = None
# Call the sayHello function
sayHello()
Just like before, this should now give you the following "Hello, World!"
2023-10-01 21:09:06.675 python[39664:483035] Hello, World!
You've now successfully accessed and run an Objective-C function from the comfort of Python. In summary, this was possible due to our dynamic library object.
With that said, the dynamic library required some setup to use in Python. Let's offload that setup elsewhere, so that we can use this library from Python more easily.
Step 3: Build a Cython extension
Now, write a Cython extension that makes the Objective-C function available as a Python library, directly. To do this, create a new file called HelloWorld.c
and write the following to wrap, load, and run the Objective-C function from C.
#include <dlfcn.h>
#include <Python.h>
#include <stdio.h>
// Create a C function that wraps the Objective-C sayHello
static PyObject* sayHello(PyObject* self, PyObject* args) {
// Load the Objective-C dynamic library
void* handle = dlopen("HelloWorld.dylib", RTLD_LAZY);
if (!handle) {
printf("HelloWorld.dylib shared library was not found.");
return NULL;
}
// Load and call the function
void (*func)() = dlsym(handle, "sayHello");
if (!func) {
printf("sayHello function was not found.");
return NULL;
}
func();
// Cleanup
dlclose(handle);
Py_RETURN_NONE;
}
The above now gives us a C function that wraps the Objective-C function. Let's now define some metadata for this function and the module that contains it. Finish off this C file with the following.
// Define metadata for the *Python sayHello function
static PyMethodDef methods[] = {
{"sayHello", sayHello, METH_NOARGS, "Says 'Hello, World!'"},
};
// Define metadata for the *Python HelloWorld module
static struct PyModuleDef HelloWorld_module = {
PyModuleDef_HEAD_INIT,
"HelloWorld", // Name of the module
NULL, // Documentation string
-1, // Size of per-interpreter state of the module
methods
};
// Initialize both the HelloWorld module and sayHello function
PyMODINIT_FUNC PyInit_HelloWorld(void) {
return PyModule_Create(&HelloWorld_module);
}
This is now a complete Cython extension, which makes the HelloWorld
module and its sayHello
function available to Python. Now, we just need two more files to properly install this Cython extension and make it globally available. Add some project metadata in a file called pyproject.toml
.
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "HelloWorld" # as it would appear on PyPI
version = "1"
Then, link that project to the Cython extension module we just created, using a setup file called setup.py
.
from setuptools import Extension, setup
# Below, 'HelloWorld' is the name you'll import in Python
setup(ext_modules=[Extension("HelloWorld", ["HelloWorld.c"])])
This completes our Cython extension, as well as the Python project that installs it. First, make sure you have your dynamic library from the previous step in your current working directory, called HelloWorld.dylib
. If you don't, go back and rerun the compilation command to create one.
clang -dynamiclib -framework Foundation HelloWorld.m -o HelloWorld.dylib
Then, install your Cython extension, using the following.
pip install .
Now, we can finally use our brand new HelloWorld
Python library, installed via the Cython extension we just created. Update your Python script in hello_world.py
to be the following.
# Load our extension module
import HelloWorld
# Run the Python binding for the Objective-C function
HelloWorld.sayHello()
Finally, run your Python script.
python hello_world.py
This should finally give you the familiar "Hello, World!" we've been seeing.
2023-10-01 21:19:26.124 python[40750:490472] Hello, World!
You've now completed your very first Python binding for an Objective-C function. You can apply this same logic to provide a Python binding for any Objective-C API. Let's show how this can be done for one of our demos in the first step.
Step 4: Write Objective-C for OSX utilities
In our very first step, we wrote Python scripts that show off some OSX utilities, via pyobjc. Let's revisit our first example that accessed the address book.
from AddressBook import ABAddressBook
def get_address_book_first_entry():
book = ABAddressBook.sharedAddressBook() # get address book
people = book.people() # get people
assert people.count() > 0, "Address book is empty" # check there are people
person = people[0] # get first person
display = person.displayName() # get person's display name
return display
if __name__ == '__main__':
print(get_address_book_first_entry())
Let's now rewrite this script in Objective-C, before providing Python bindings for it in the next step. Specifically, write an Objective-C script that accesses the address book, grabs the first person, and shows their display name in a new file book.m
.
#import <Foundation/Foundation.h>
#import <AddressBook/AddressBook.h>
NSString *getAddressBookFirstEntry() {
ABAddressBook *addressBook = [ABAddressBook addressBook]; // get address book
NSArray<ABRecord *> *people = [addressBook people]; // get people
NSCAssert(people.count > 0, @"Address book is empty"); // check there are people
ABRecord *person = people[0]; // get first person
NSString *displayName = [person displayName]; // get person's display name
return displayName;
}
int main(int argc, const char * argv[]) {
@autoreleasepool {
NSLog(@"%@", getAddressBookFirstEntry());
}
return 0;
}
Compare the above Objective-C scripts with the Python scripts from our first step; the two are effectively identical and can be mapped to one another using the rules we discussed in the first step. Compile and run the address book example above.
clang -framework Foundation -framework AddressBook book.m -o book
./book
You should then see output like the following.
Alvin Wan
Let's now repeat the same process but for the other script that would speak. Let's revisit the original Python script that would speak.
from AVFoundation import AVSpeechUtterance, AVSpeechSynthesizer
import time
def say(text):
synth = AVSpeechSynthesizer.alloc().init() # init speaker
utterance = AVSpeechUtterance.speechUtteranceWithString_(text) # init utterance
synth.speakUtterance_(utterance) # speak utterance
time.sleep(3) # allow time for speaking to finish
if __name__ == '__main__':
say("I wish oranges came in purple.")
Just like before, now write its Objective-C equivalent in a new file say.m
. Notice that the two are again nearly identical, save for some syntax differences.
#import <AVFoundation/AVFoundation.h>
void say(NSString* text) {
AVSpeechSynthesizer *synthesizer = [[AVSpeechSynthesizer alloc] init]; // init speaker
AVSpeechUtterance *utterance = [[AVSpeechUtterance alloc] initWithString:string]; // init utterance
[synthesizer speakUtterance:utterance]; // speak utterance
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 3.0, false); // allow time for speaking to finish
}
int main(int argc, const char * argv[]) {
@autoreleasepool {
say(@"I wish oranges came in purple.");
}
return 0;
}
Compile and run the say example above.
clang -framework Foundation -framework AVFoundation say.m -o say
./say
Your script should then, again, say "I wish oranges came in purple" aloud. We've now got working Objective-C scripts for OSX utilities. Let's now write Python bindings so we can repeat this functionality from the comfort of Python, without pyobjc.
Step 5: Python bindings with returned values
Previously, we showed how to provide Python bindings for a function without arguments or returned values. Let's start with the latter: How to build a binding for an Objective-C function that returns a value.
To start, update your Book.m
function to return a string pointer instead of a custom Objective-C string pointer. This means changing NSString
to char
. First, update the function signature.
const char *getAddressBookFirstEntry() {
Then, update the return statement to access the Objective-C string object's internally-stored UTF-8 string.
return [displayName UTF8String];
You can also drop the main
function from before, as we'll now use this file as a library instead of an executable. Your updated Objective C file for address book fetching should now match the following.
#import <Foundation/Foundation.h>
#import <AddressBook/AddressBook.h>
const char *getAddressBookFirstEntry() {
ABAddressBook *addressBook = [ABAddressBook addressBook]; // get address book
NSArray<ABRecord *> *people = [addressBook people]; // get people
NSCAssert(people.count > 0, @"Address book is empty"); // check there are people
ABRecord *person = people[0]; // get first person
NSString *displayName = [person displayName]; // get person's display name
return [displayName UTF8String];
}
Now, you can compile the dynamic library for your Objective C function.
clang -dynamiclib -o Book.dylib Book.m -framework Foundation -framework AddressBook
Next, copy the same boilerplate code from above, for your Cython project and metadata in setup.py
, pyproject.toml
and Book.c
. We'll show the full version of each file down below, after describing the key changes: There are three critical differences in your Cython extension, in Book.c
. First, specify a non-void return type for your lambda function.
const char* (*func)() = dlsym(handle, "getAddressBookFirstEntry");
Second, capture the returned value from your Objective-C function call.
const char* display = func();
Third, return the captured value as a Python string.
return Py_BuildValue("s", display); // Build Python string object
Altogether, your Cython extension should look like the following.
#include <dlfcn.h>
#include <Python.h>
#include <stdio.h>
static PyObject* getFirstEntry(PyObject* self, PyObject* args) {
// Load the Objective-C dynamic library
void* handle = dlopen("Book.dylib", RTLD_LAZY);
if (!handle) {
printf("Book.dylib shared library was not found.");
return NULL;
}
// Load and call the function
const char* (*func)() = dlsym(handle, "getAddressBookFirstEntry");
if (!func) {
printf("getAddressBookFirstEntry function was not found.");
return NULL;
}
const char* display = func();
// Cleanup
dlclose(handle);
return Py_BuildValue("s", display); // Build Python string object
}
static PyMethodDef methods[] = {
{"getFirstEntry", getFirstEntry, METH_VARARGS, "List first entry"},
};
static struct PyModuleDef Book_module = {
PyModuleDef_HEAD_INIT,
"Book", // Name of the module
NULL, // Documentation string
-1, // Size of per-interpreter state of the module
methods
};
PyMODINIT_FUNC PyInit_Book(void) {
return PyModule_Create(&Book_module);
}
The above script creates a new Python module called Book
and makes a function getFirstEntry
in that module available.
Here are my updated versions of the other boilerplate files.
from setuptools import Extension, setup
# Below, 'Say' is the name you'll import in Python
setup(ext_modules=[Extension("Book", ["Book.c"])])
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "Book" # as it would appear on PyPI
version = "1"
At this point, build your Cython extension.
pip install .
Finally, create a Python script that uses this new Book
module.
import Book
print(Book.getFirstEntry())
Now, you can run your new Python script.
python book.py
Just like before, this should now output the display name for the first entry in your address book.
Alvin Wan
This now completes your Python binding for an Objective-C function with a return type.
Step 6: Python bindings with arguments
Let's now add support for arguments to our bindings. For this example, we'll add bindings to our speech example.
To start, update your Objective C script Say.m
to accept a string pointer instead of a custom Objective-C string pointer, again changing from NSString
to char
. This means updating our function signature, then wrapping the inputted string.
void say(char* text) { // accept C-style string
NSString* string = [NSString stringWithFormat:@"%s", text]; // wrap in ObjC-style string
You can also drop the main
function as we'll now use this file as a library instead of an executable. Your updated Objective C file should look like the following.
#import <AVFoundation/AVFoundation.h>
void say(char* text) { // accept C-style string
NSString* string = [NSString stringWithFormat:@"%s", text]; // wrap in ObjC-style string
AVSpeechSynthesizer *synthesizer = [[AVSpeechSynthesizer alloc] init]; // init speaker
AVSpeechUtterance *utterance = [[AVSpeechUtterance alloc] initWithString:string]; // init utterance
[synthesizer speakUtterance:utterance]; // speak utterance
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 3.0, false); // allow time for speaking to finish
}
Now, you can compile the dynamic library for your Objective C function.
clang -dynamiclib -o Say.dylib Say.m -framework Foundation -framework AVFoundation
Next, just like in the last step, copy the same boilerplate code from above, for your Cython project and metadata in setup.py
, pyproject.toml
and Say.c
. We'll show the full version of each file down below, after describing the key changes: There are three critical differences in your Cython extension. First, extract the string argument from input arguments.
// Grab the string argument
char* text;
if (!PyArg_ParseTuple(args, "s", &text)) {
return NULL;
}
Second, update your lambda signature to accept a single string argument.
void (*func)(char*) = dlsym(handle, "say");
Third, update your Objective-C function call to include the string argument.
func(text);
Altogether, your Cython extension should look like the following.
#include <dlfcn.h>
#include <Python.h>
#include <stdio.h>
static PyObject* say(PyObject* self, PyObject* args) {
// Load the Objective-C dynamic library
void* handle = dlopen("Say.dylib", RTLD_LAZY);
// Grab the string argument
char* text;
if (!PyArg_ParseTuple(args, "s", &text)) {
return NULL;
}
// Load and call the function
void (*func)(char*) = dlsym(handle, "say");
func(text);
// Cleanup
dlclose(handle);
Py_RETURN_NONE;
}
static PyMethodDef methods[] = {
{"say", say, METH_VARARGS, "Call Objective-C say."},
};
static struct PyModuleDef Say_module = {
PyModuleDef_HEAD_INIT,
"Say", // Name of the module
NULL, // Documentation string
-1, // Size of per-interpreter state of the module
methods
};
PyMODINIT_FUNC PyInit_Say(void) {
return PyModule_Create(&Say_module);
}
The above script creates a new Python module called Say
and makes a function say
in that module available.
Here are my updated versions of the other boilerplate files.
from setuptools import Extension, setup
# Below, 'Say' is the name you'll import in Python
setup(ext_modules=[Extension("Say", ["Say.c"])])
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "Say" # as it would appear on PyPI
version = "1"
At this point, build your Cython extension.
pip install .
Finally, create a Python script that uses this new Say
module.
import Say
Say.say("I wish watermelons came in purple")
Now, you can run your new Python script.
python say.py
Just like before, this script should now say "I wish oranges came in purple". Now, you can update your Python script to say anything you wish, using your custom-built Python bindings!
Takeaways
In short, Python bindings for Objective-C APIs are available through pyobjc. However, you can build your own bindings as well to access different OSX utilities, and in this post, we saw how to do that. This post covered a lot of ground in accessing OSX utilities from Python. To learn more, here are several resources you can use:
- Documentation for Cython extensions
- Documentation for Apple's OSX utilities
- Documentation for pyobjc bindings
You can now use the above to specifically build Python bindings for any OSX utility. More broadly, you can even use the above to build Python bindings for any C, Objective C, or C-derivative program. To learn how to leverage Apple's GPUs by writing custom kernels, see How to use Apple GPUs from Python.
Want more tips? Drop your email, and I'll keep you in the loop.