Geocoding from Python on macOS using pyobjc-framework-CoreLocation

Rhet Turnbull shared this short script for looking up the named timezone for a given location from Python on macOS using objc and the CoreLocation framework. It uses the objc package and pyobjc-framework-CoreLocation.

This piqued my interest, so I conversed with Claude about other things I could do with that same framework. Here's the script we came up with, for geocoding an address passed to it using Core Location's CLGeocoder.geocodeAddressString() method:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "pyobjc-core",
#     "pyobjc-framework-CoreLocation",
#     "click"
# ]
# ///
"""Basic geocoding using CoreLocation on macOS."""

import click
import objc
from CoreLocation import CLGeocoder
from Foundation import NSRunLoop, NSDate

def forward_geocode(address: str) -> list[dict]:
    with objc.autorelease_pool():
        geocoder = CLGeocoder.alloc().init()
        results = {"placemarks": [], "error": None}
        completed = False
        
        def completion(placemarks, error):
            nonlocal completed
            if error:
                results["error"] = error.localizedDescription()
            elif placemarks:
                results["placemarks"] = placemarks
            completed = True
            
        geocoder.geocodeAddressString_completionHandler_(address, completion)
        
        while not completed:
            NSRunLoop.currentRunLoop().runMode_beforeDate_(
                "NSDefaultRunLoopMode",
                NSDate.dateWithTimeIntervalSinceNow_(0.1)
            )
        
        if results["error"]:
            raise Exception(f"Geocoding error: {results['error']}")
            
        return [{
            "latitude": pm.location().coordinate().latitude,
            "longitude": pm.location().coordinate().longitude,
            "name": pm.name(),
            "locality": pm.locality(),
            "country": pm.country()
        } for pm in results["placemarks"]]

@click.command()
@click.argument('address')
def main(address):
    try:
        locations = forward_geocode(address)
        for loc in locations:
            click.echo("\nLocation found:")
            for key, value in loc.items():
                if value:
                    click.echo(f"{key}: {value}")
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        raise click.Abort()

if __name__ == "__main__":
    main()

This can be run using uv run like this:

uv run geocode.py '500 Grove St, San Francisco, CA'

Example output:

Location found:
latitude: 37.777717
longitude: -122.42504
name: 500 Grove St
locality: San Francisco
country: United States 

I tried this without a network connection and it failed, demonstrating that Core Location uses some form of network-based API to geocode addresses.

There are a few new-to-me tricks in this script.

with objc.autorelease_pool() is a neat memory management pattern provided by PyObjC for establishing an autorelease memory pool for the duration of a Python with block. Everything allocated by Objective C should be automatically cleaned up at the end of that block.

The geocodeAddressString method takes a completion handler. In this code we're setting that to a Python function that communicates state using shared variables:

results = {"placemarks": [], "error": None}
completed = False

def completion(placemarks, error):
    nonlocal completed
    if error:
        results["error"] = error.localizedDescription()
    elif placemarks:
        results["placemarks"] = placemarks
    completed = True

We start that running like so:

geocoder = CLGeocoder.alloc().init()
geocoder.geocodeAddressString_completionHandler_(address, completion)

Then the clever bit:

while not completed:
    NSRunLoop.currentRunLoop().runMode_beforeDate_(
        "NSDefaultRunLoopMode",
        NSDate.dateWithTimeIntervalSinceNow_(0.1)
    )

Where did this code come from? It turns out Claude lifted that from the Rhet Turnbull script I fed into it earlier. Here's that code with Rhet's comments:

WAIT_FOR_COMPLETION = 0.01  # wait time for async completion in seconds
# ...

# reverseGeocodeLocation_completionHandler_ is async so run the event loop until completion
# I usually use threading.Event for this type of thing in pyobjc but the the thread blocked forever
waiting = 0
while not completed:
    NSRunLoop.currentRunLoop().runMode_beforeDate_(
        "NSDefaultRunLoopMode",
        NSDate.dateWithTimeIntervalSinceNow_(WAIT_FOR_COMPLETION),
    )
    waiting += WAIT_FOR_COMPLETION
    if waiting >= COMPLETION_TIMEOUT:
        raise TimeoutError(
            f"Timeout waiting for completion of reverseGeocodeLocation_completionHandler_: {waiting} seconds"
        )

Is this the best pattern for my own, simpler script? I don't know for sure, but it works. Approach with caution!

Since my script has inline script dependencies and I've published it to a Gist you can run it directly with uv run without first installing anything else like this:

uv run https://gist.githubusercontent.com/simonw/178ea93ac035293744bde97270d6a7a0/raw/88c817e4103034579ec7523d8591bf60aa11fa67/geocode.py \
  '500 Grove St, San Francisco, CA'

Created 2025-01-26T09:25:48-08:00 · Edit