/// script

January 26, 2025 ยท View on GitHub

"""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 or timezonefinder but uses macOS native APIs.

"""

/// script

dependencies = [

"pyobjc-core",

"pyobjc-framework-CoreLocation"

]

///

import datetime

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

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

    return result["timezone"]

if name == "main": import argparse import time

def parse_date(date_str):
    try:
        return datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
    except ValueError:
        return datetime.datetime.strptime(date_str, "%Y-%m-%d")

parser = argparse.ArgumentParser(description="Get timezone for a location")
parser.add_argument("latitude", type=float, help="Latitude of location")
parser.add_argument("longitude", type=float, help="Longitude of location")
parser.add_argument(
    "--date",
    type=lambda d: parse_date(d),
    default=datetime.datetime.now(),
    help="Date/time for timezone offset in format 'YYYY-MM-DD HH:MM:SS' or 'YYYY-MM-DD'",
)
args = parser.parse_args()

try:
    start_t = time.time_ns()
    timezone = timezone_for_location(args.latitude, args.longitude)
    offset = timezone.secondsFromGMTForDate_(
        args.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}")