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 clickimport objcfrom CoreLocation import CLGeocoderfrom 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.777717longitude: -122.42504name: 500 Grove Stlocality: San Franciscocountry: 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 foreverwaiting = 0while 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'