Skip to content

Instantly share code, notes, and snippets.

@simonw
Created January 26, 2025 16:50
Show Gist options
  • Save simonw/fed886265bc32af81efa8e7973fea621 to your computer and use it in GitHub Desktop.
Save simonw/fed886265bc32af81efa8e7973fea621 to your computer and use it in GitHub Desktop.

Geocoding with CoreLocation on macOS

human (Jan 26, 2025, 08:42 AM)

# /// script
# dependencies = [
#     "pyobjc-core",
#     "pyobjc-framework-CoreLocation"
# ]
# ///
"""Get named timezone for a location on macOS using CoreLocation

To use this script, you need to have pyobjc-core and pyobjc-framework-CoreLocation installed:

    pip install pyobjc-core pyobjc-framework-CoreLocation

This script uses CoreLocation to get the timezone for a given latitude and longitude
as well as the timezone offset for a given date.

It effectively does the same thing as python packages like [tzfpy](https://pypi.org/project/tzfpy/)
or [timezonefinder](https://pypi.org/project/timezonefinder/) but uses macOS native APIs.
"""

import datetime
import time

import objc
from CoreLocation import CLGeocoder, CLLocation
from Foundation import NSDate, NSRunLoop, NSTimeZone

WAIT_FOR_COMPLETION = 0.01  # wait time for async completion in seconds
COMPLETION_TIMEOUT = 5.0  # timeout for async completion in seconds


def timezone_for_location(latitude: float, longitude: float) -> NSTimeZone:
    with objc.autorelease_pool():
        location = CLLocation.alloc().initWithLatitude_longitude_(latitude, longitude)
        geocoder = CLGeocoder.alloc().init()

        result = {"timezone": None, "error": None}
        completed = False

        def completion(placemarks, error):
            nonlocal completed
            if error:
                result["error"] = error.localizedDescription()
            else:
                placemark = placemarks[0] if placemarks else None
                if placemark and placemark.timeZone():
                    result["timezone"] = placemark.timeZone()
                else:
                    result["error"] = "Unable to determine timezone"
            completed = True

        geocoder.reverseGeocodeLocation_completionHandler_(location, completion)

        # reverseGeocodeLocation_completionHandler_ is async so run the event loop until completion
        # I usuall 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"
                )
            time.sleep(WAIT_FOR_COMPLETION)

        if result["error"]:
            raise Exception(f"Error: {result['error']}")

        return result["timezone"]


if __name__ == "__main__":
    latitude, longitude = 34, -118  # Los Angeles
    date = datetime.datetime.now()

    try:
        start_t = time.time_ns()
        timezone = timezone_for_location(latitude, longitude)
        offset = timezone.secondsFromGMTForDate_(
            date
        )  # takes an NSDate but pybojc will convert
        end_t = time.time_ns()
        print(
            f"Timezone: {timezone.name()}, offset: {offset}, took: {(end_t - start_t) / 1e6:.2f} ms"
        )
    except Exception as e:
        print(f"Error: {e}")

Modify this script to do other more interesting geocoding actions unrelated to timezones

assistant (Jan 26, 2025, 08:42 AM)

Here's a modified version that performs reverse and forward geocoding, showing address details and nearby points of interest:

create Untitled

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

This script demonstrates both reverse and forward geocoding capabilities:
- Get detailed address information for coordinates
- Find coordinates for an address
- Get nearby points of interest
"""

import objc
from CoreLocation import (
    CLGeocoder, 
    CLLocation,
    CLLocationCoordinate2D,
    kCLLocationAccuracyBest
)
from Foundation import NSDate, NSRunLoop

WAIT_FOR_COMPLETION = 0.01
COMPLETION_TIMEOUT = 5.0

def wait_for_completion(completed, 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"Geocoding timeout after {waiting} seconds")

def reverse_geocode(latitude: float, longitude: float) -> dict:
    """Get detailed address information for coordinates."""
    with objc.autorelease_pool():
        location = CLLocation.alloc().initWithLatitude_longitude_(latitude, longitude)
        geocoder = CLGeocoder.alloc().init()
        
        result = {"placemark": None, "error": None}
        completed = False
        
        def completion(placemarks, error):
            nonlocal completed
            if error:
                result["error"] = error.localizedDescription()
            elif placemarks:
                result["placemark"] = placemarks[0]
            else:
                result["error"] = "No results found"
            completed = True
            
        geocoder.reverseGeocodeLocation_completionHandler_(location, completion)
        wait_for_completion(completed)
        
        if result["error"]:
            raise Exception(f"Geocoding error: {result['error']}")
            
        placemark = result["placemark"]
        return {
            "name": placemark.name(),
            "thoroughfare": placemark.thoroughfare(),
            "subThoroughfare": placemark.subThoroughfare(),
            "locality": placemark.locality(),
            "subLocality": placemark.subLocality(),
            "administrativeArea": placemark.administrativeArea(),
            "subAdministrativeArea": placemark.subAdministrativeArea(),
            "postalCode": placemark.postalCode(),
            "country": placemark.country(),
            "countryCode": placemark.ISOcountryCode(),
            "inlandWater": placemark.inlandWater(),
            "ocean": placemark.ocean(),
            "areasOfInterest": placemark.areasOfInterest()
        }

def forward_geocode(address: str) -> list[dict]:
    """Get coordinates and details for an address string."""
    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
            else:
                results["error"] = "No results found"
            completed = True
            
        geocoder.geocodeAddressString_completionHandler_(address, completion)
        wait_for_completion(completed)
        
        if results["error"]:
            raise Exception(f"Geocoding error: {results['error']}")
            
        return [{
            "name": pm.name(),
            "latitude": pm.location().coordinate().latitude,
            "longitude": pm.location().coordinate().longitude,
            "locality": pm.locality(),
            "country": pm.country()
        } for pm in results["placemarks"]]

if __name__ == "__main__":
    # Example 1: Reverse geocode (coordinates to address)
    print("\nReverse Geocoding Example:")
    try:
        details = reverse_geocode(37.7749, -122.4194)  # San Francisco
        print("Location Details:")
        for key, value in details.items():
            if value:  # Only print non-empty values
                print(f"{key}: {value}")
    except Exception as e:
        print(f"Error: {e}")

    # Example 2: Forward geocode (address to coordinates)
    print("\nForward Geocoding Example:")
    try:
        locations = forward_geocode("1 Infinite Loop, Cupertino, CA")
        for loc in locations:
            print("\nFound location:")
            for key, value in loc.items():
                print(f"{key}: {value}")
    except Exception as e:
        print(f"Error: {e}")

This modified version:

  1. Performs both reverse and forward geocoding
  2. Extracts detailed location information (streets, administrative areas, points of interest)
  3. Handles multiple results for forward geocoding
  4. Maintains the same robust error handling and timeout management

Try it with different coordinates or addresses to explore locations worldwide.

human (Jan 26, 2025, 08:43 AM)

Reverse Geocoding Example: Error: Geocoding timeout after 5.009999999999938 seconds Forward Geocoding Example: Error: Geocoding timeout after 5.009999999999938 seconds

assistant (Jan 26, 2025, 08:44 AM)

Let's add location permissions handling since CoreLocation requires explicit user authorization. Here's the fixed version:

rewrite Untitled

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

import objc
from CoreLocation import (
    CLGeocoder, 
    CLLocation,
    CLLocationManager,
    kCLLocationAccuracyBest,
    kCLAuthorizationStatusAuthorized,
    kCLAuthorizationStatusAuthorizedAlways,
    kCLAuthorizationStatusAuthorizedWhenInUse
)
from Foundation import NSDate, NSRunLoop

def ensure_location_permissions():
    manager = CLLocationManager.alloc().init()
    auth_status = manager.authorizationStatus()
    
    if auth_status not in [
        kCLAuthorizationStatusAuthorized,
        kCLAuthorizationStatusAuthorizedAlways,
        kCLAuthorizationStatusAuthorizedWhenInUse
    ]:
        manager.requestWhenInUseAuthorization()
        # Wait for user to respond to permission prompt
        while manager.authorizationStatus() == manager.authorizationStatus():
            NSRunLoop.currentRunLoop().runMode_beforeDate_(
                "NSDefaultRunLoopMode",
                NSDate.dateWithTimeIntervalSinceNow_(0.1)
            )

def reverse_geocode(latitude: float, longitude: float) -> dict:
    """Get detailed address information for coordinates."""
    ensure_location_permissions()
    
    with objc.autorelease_pool():
        location = CLLocation.alloc().initWithLatitude_longitude_(latitude, longitude)
        geocoder = CLGeocoder.alloc().init()
        result = {"placemark": None, "error": None}
        completed = False
        
        def completion(placemarks, error):
            nonlocal completed
            if error:
                result["error"] = error.localizedDescription()
            elif placemarks:
                result["placemark"] = placemarks[0]
            completed = True
            
        geocoder.reverseGeocodeLocation_completionHandler_(location, completion)
        
        # Wait for completion
        while not completed:
            NSRunLoop.currentRunLoop().runMode_beforeDate_(
                "NSDefaultRunLoopMode",
                NSDate.dateWithTimeIntervalSinceNow_(0.1)
            )
            
        if result["error"]:
            raise Exception(f"Geocoding error: {result['error']}")
            
        pm = result["placemark"]
        return {
            "name": pm.name(),
            "thoroughfare": pm.thoroughfare(),
            "locality": pm.locality(),
            "country": pm.country(),
            "countryCode": pm.ISOcountryCode()
        }

def forward_geocode(address: str) -> list[dict]:
    """Get coordinates for an address string."""
    ensure_location_permissions()
    
    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 [{
            "name": pm.name(),
            "latitude": pm.location().coordinate().latitude,
            "longitude": pm.location().coordinate().longitude,
            "locality": pm.locality()
        } for pm in results["placemarks"]]

if __name__ == "__main__":
    # Example 1: Reverse geocode
    try:
        details = reverse_geocode(37.7749, -122.4194)
        print("\nReverse Geocoding Results:")
        print(details)
    except Exception as e:
        print(f"Error: {e}")

    # Example 2: Forward geocode
    try:
        locations = forward_geocode("1 Infinite Loop, Cupertino, CA")
        print("\nForward Geocoding Results:")
        print(locations)
    except Exception as e:
        print(f"Error: {e}")

Key changes:

  • Added location permissions handling
  • Simplified wait logic
  • Reduced returned fields to essential ones
  • Removed arbitrary timeouts

You'll need to approve location access when first running the script.

human (Jan 26, 2025, 08:45 AM)

CLI command I can use to grant that permission to the script before i run it?

assistant (Jan 26, 2025, 08:45 AM)

Unfortunately macOS doesn't have a CLI command to grant location permissions - it requires user interaction through System Settings. The permissions are managed by macOS at the system level to protect user privacy.

Let me know if you'd like to try a different approach that doesn't require location permissions, like using a geocoding API that doesn't rely on CoreLocation.

human (Jan 26, 2025, 08:45 AM)

I didn't get a prompt about permission,s the script just hung

assistant (Jan 26, 2025, 08:46 AM)

The issue is that the script is waiting indefinitely for a permission prompt that isn't appearing. Let's modify it to use a simpler geocoding approach that doesn't require location services:

rewrite Untitled

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

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

def forward_geocode(address: str) -> list[dict]:
    """Get coordinates for an address string."""
    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"]]

if __name__ == "__main__":
    try:
        locations = forward_geocode("1 Infinite Loop, Cupertino, CA")
        print("\nResults:")
        for loc in locations:
            print(f"\nLocation found:")
            for key, value in loc.items():
                if value:
                    print(f"{key}: {value}")
    except Exception as e:
        print(f"Error: {e}")

This version only does forward geocoding and removes the location services requirement. Try running it with different addresses.

human (Jan 26, 2025, 08:46 AM)

make that script take an address as a CLI argument

assistant (Jan 26, 2025, 08:46 AM)

rewrite Untitled

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

Usage: python script.py "1 Infinite Loop, Cupertino, CA"

human (Jan 26, 2025, 08:48 AM)

explain the forward_geocode() function line by line, I don't know anything about objective C objc

assistant (Jan 26, 2025, 08:48 AM)

Here's a line-by-line explanation of the forward_geocode function:

def forward_geocode(address: str) -> list[dict]:
    with objc.autorelease_pool():
        # Creates a memory management pool for Objective-C objects
        # Required when working with Objective-C frameworks on macOS

        geocoder = CLGeocoder.alloc().init()
        # Creates a new geocoder object
        # CLGeocoder is Apple's class for converting addresses to coordinates

        results = {"placemarks": [], "error": None}
        completed = False
        # Sets up state tracking for the async operation
        
        def completion(placemarks, error):
            # Callback function that will be called when geocoding finishes
            nonlocal completed
            if error:
                results["error"] = error.localizedDescription()
            elif placemarks:
                results["placemarks"] = placemarks
            completed = True
            
        geocoder.geocodeAddressString_completionHandler_(address, completion)
        # Starts the geocoding process
        # Takes address string and our callback function
        
        while not completed:
            # Wait loop because geocoding is asynchronous
            NSRunLoop.currentRunLoop().runMode_beforeDate_(
                "NSDefaultRunLoopMode",
                NSDate.dateWithTimeIntervalSinceNow_(0.1)
            )
            # Runs the macOS event loop for 0.1 seconds
            # This allows the geocoding callback to be processed
        
        if results["error"]:
            raise Exception(f"Geocoding error: {results['error']}")
            
        return [{
            # Converts each placemark (Objective-C object) into a Python dict
            "latitude": pm.location().coordinate().latitude,
            "longitude": pm.location().coordinate().longitude,
            "name": pm.name(),
            "locality": pm.locality(),
            "country": pm.country()
        } for pm in results["placemarks"]]

The key concept is that Apple's geocoding API is asynchronous - when you ask for coordinates, it returns immediately but processes in the background. The completion handler and run loop are needed to wait for and receive the results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment