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:

  1. 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.
  2. 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.

pyosx/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.

pyosx/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:

  1. Add underscores for arguments. Say our Objective-C code is [synthesizer speakUtterance:utterance];. This calls the speakUtterance method with one argument utterance on the synthesizer object. The method's Python binding would be speakUtterance_ — notice the trailing underscore, to denote a single argument. This makes the analogous Python code synthesizer.speakUtterance_(utterance).
  2. 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 write AVSpeechSynthesizer.alloc().init(). Some Objective-C classes have shortcuts for these two calls, such as NSObject.new() which is equivalent to NSObject().alloc().init().
  3. 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.

pyosx/HelloWorld.m

#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.

pyosx/HelloWorld.m

#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.

pyosx/hello_world.py

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.

pyosx/HelloWorld.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;
}
// Define metadata for the *Python sayHello function static PyMethodDef methods[] = { {"sayHello", sayHello, METH_NOARGS, "Says 'Hello, World!'"}, };

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.

pyosx/HelloWorld.c

// Cleanup dlclose(handle); Py_RETURN_NONE; }
// 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.

pyosx/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.

pyosx/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.

pyosx/hello_world.py

# 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.

pyosx/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())

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.

pyosx/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.

pyosx/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.")

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.

pyosx/say.m

#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.

pyosx/Book.m

#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

Then, update the return statement to access the Objective-C string object's internally-stored UTF-8 string.

pyosx/Book.m

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];
} // This below main function is optional. This is useful if you'd like to debug // and ensure that the above function operates correctly. This way, you can // run the `getAddressBookFirstEntry` function in Objective-C, independent of

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.

pyosx/Book.m

#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];
}
// This below main function is optional. This is useful if you'd like to debug // and ensure that the above function operates correctly. This way, you can // run the `getAddressBookFirstEntry` function in Objective-C, independent of // the Python binding code.

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.

pyosx/Book.c

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();

Second, capture the returned value from your Objective-C function call.

pyosx/Book.c

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 }

Third, return the captured value as a Python string.

pyosx/Book.c

} 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"}, };

Altogether, your Cython extension should look like the following.

pyosx/Book.c

#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.

pyosx/setup.py

from setuptools import Extension, setup

# Below, 'Say' is the name you'll import in Python
setup(ext_modules=[Extension("Book", ["Book.c"])])

pyosx/pyproject.toml

[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.

pyosx/book.py

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.

pyosx/Say.m

#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 }

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.

pyosx/Say.m

#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
}
// This below main function is optional. This is useful if you'd like to debug // and ensure that the above function operates correctly. This way, you can // run the `getAddressBookFirstEntry` function in Objective-C, independent of // the Python binding code.

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.

pyosx/Say.c

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);

Second, update your lambda signature to accept a single string argument.

pyosx/Say.c

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;

Third, update your Objective-C function call to include the string argument.

pyosx/Say.c

return NULL; } // Load and call the function void (*func)(char*) = dlsym(handle, "say");
func(text);
// Cleanup dlclose(handle); Py_RETURN_NONE; }

Altogether, your Cython extension should look like the following.

pyosx/Say.c

#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.

pyosx/setup.py

from setuptools import Extension, setup

# Below, 'Say' is the name you'll import in Python
setup(ext_modules=[Extension("Say", ["Say.c"])])

pyosx/pyproject.toml

[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.

pyosx/say.py

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:

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.


back to Guide to Hacking