From c853b792ad78ffce7c0111c369eb6767dc32c405 Mon Sep 17 00:00:00 2001 From: Jaisen Mathai Date: Tue, 17 Jun 2025 22:40:31 -0400 Subject: [PATCH] =?UTF-8?q?Use=20ExifTool's=20Geolocation=20API=20if=20sup?= =?UTF-8?q?ported.=20=20=20-=20ExifTool=20geolocation=20as=20MapQuest=20al?= =?UTF-8?q?ternative=20when=20no=20API=20key=20is=20configured=20=20=20-?= =?UTF-8?q?=20Comprehensive=20unit=20tests=20=20=20-=20Proper=20fallback?= =?UTF-8?q?=20behavior=20=20=20-=20Priority=20order:=20MapQuest=20(if=20ke?= =?UTF-8?q?y=20exists)=20=E2=86=92=20ExifTool=20=E2=86=92=20Unknown=20Loca?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- elodie/geolocation.py | 130 ++++++++++++++++++++++++++++--- elodie/tests/geolocation_test.py | 71 +++++++++++++++++ requirements.txt | 3 +- 3 files changed, 191 insertions(+), 13 deletions(-) diff --git a/elodie/geolocation.py b/elodie/geolocation.py index 28e215f9..f5659e2b 100644 --- a/elodie/geolocation.py +++ b/elodie/geolocation.py @@ -17,6 +17,7 @@ from elodie import constants from elodie import log from elodie.localstorage import Db +from elodie.external.pyexiftool import ExifTool __KEY__ = None __DEFAULT_LOCATION__ = 'Unknown Location' @@ -134,6 +135,101 @@ def get_prefer_english_names(): __PREFER_ENGLISH_NAMES__ = bool(config['MapQuest']['prefer_english_names']) return __PREFER_ENGLISH_NAMES__ +def exiftool_geolocation(lat, lon): + """Use ExifTool's geolocation database to look up place name from coordinates. + + :param float lat: Latitude coordinate + :param float lon: Longitude coordinate + :returns: dict with location information or None if not found + """ + if lat is None or lon is None: + return None + + # Convert lat/lon to floats + if not isinstance(lat, float): + lat = float(lat) + if not isinstance(lon, float): + lon = float(lon) + + try: + with ExifTool() as et: + # Query the geolocation database directly using -listgeo and find nearest match + # Format: City,Region,Subregion,CountryCode,Country,TimeZone,FeatureCode,Population,Latitude,Longitude + geo_data = et.execute(b"-listgeo", b"-csv").decode('utf-8') + + if not geo_data: + return None + + lines = geo_data.strip().split('\n') + if len(lines) < 2: # Header + at least one data line + return None + + # Skip header line + best_match = None + min_distance = float('inf') + + for line in lines[1:]: # Skip header + try: + parts = line.split(',') + if len(parts) >= 10 and parts[8] != 'Latitude': # Skip header if it appears again + city = parts[0] + region = parts[1] + subregion = parts[2] + country_code = parts[3] + country = parts[4] + db_lat = float(parts[8]) + db_lon = float(parts[9]) + + # Calculate simple distance (not exact but good enough for nearest city) + distance = ((lat - db_lat) ** 2 + (lon - db_lon) ** 2) ** 0.5 + + if distance < min_distance: + min_distance = distance + best_match = { + 'city': city, + 'region': region, + 'subregion': subregion, + 'country': country, + 'country_code': country_code + } + + # If we found a very close match (within ~0.1 degrees), use it + if distance < 0.1: + break + + except (ValueError, IndexError): + continue + + if best_match and min_distance < 2.0: # Within reasonable distance + lookup_place_name = {} + + # Priority order: City > Region > Subregion > Country + if best_match['city'] and best_match['city'].strip(): + lookup_place_name['city'] = best_match['city'] + lookup_place_name['default'] = best_match['city'] + elif best_match['region'] and best_match['region'].strip(): + lookup_place_name['state'] = best_match['region'] + if 'default' not in lookup_place_name: + lookup_place_name['default'] = best_match['region'] + elif best_match['subregion'] and best_match['subregion'].strip(): + lookup_place_name['state'] = best_match['subregion'] + if 'default' not in lookup_place_name: + lookup_place_name['default'] = best_match['subregion'] + + if best_match['country'] and best_match['country'].strip(): + lookup_place_name['country'] = best_match['country'] + if 'default' not in lookup_place_name: + lookup_place_name['default'] = best_match['country'] + + return lookup_place_name if lookup_place_name else None + + except Exception as e: + log.error("ExifTool geolocation failed: {}".format(e)) + return None + + return None + + def place_name(lat, lon): lookup_place_name_default = {'default': __DEFAULT_LOCATION__} if(lat is None or lon is None): @@ -155,18 +251,28 @@ def place_name(lat, lon): return cached_place_name lookup_place_name = {} - geolocation_info = lookup(lat=lat, lon=lon) - if(geolocation_info is not None and 'address' in geolocation_info): - address = geolocation_info['address'] - # gh-386 adds support for town - # taking precedence after city for backwards compatability - for loc in ['city', 'town', 'state', 'country']: - if(loc in address): - lookup_place_name[loc] = address[loc] - # In many cases the desired key is not available so we - # set the most specific as the default. - if('default' not in lookup_place_name): - lookup_place_name['default'] = address[loc] + + # Check if MapQuest key is configured + key = get_key() + if key is not None: + # Use MapQuest if key is available + geolocation_info = lookup(lat=lat, lon=lon) + if(geolocation_info is not None and 'address' in geolocation_info): + address = geolocation_info['address'] + # gh-386 adds support for town + # taking precedence after city for backwards compatability + for loc in ['city', 'town', 'state', 'country']: + if(loc in address): + lookup_place_name[loc] = address[loc] + # In many cases the desired key is not available so we + # set the most specific as the default. + if('default' not in lookup_place_name): + lookup_place_name['default'] = address[loc] + else: + # Use ExifTool geolocation if no MapQuest key is configured + lookup_place_name = exiftool_geolocation(lat, lon) + if not lookup_place_name: + lookup_place_name = {} if(lookup_place_name): db.add_location(lat, lon, lookup_place_name) diff --git a/elodie/tests/geolocation_test.py b/elodie/tests/geolocation_test.py index 113fef46..d02abd5b 100644 --- a/elodie/tests/geolocation_test.py +++ b/elodie/tests/geolocation_test.py @@ -198,3 +198,74 @@ def test_parse_result_with_unknown_lat_lon(): res = geolocation.parse_result(results) assert res is None, res + +@mock.patch('elodie.geolocation.ExifTool') +def test_exiftool_geolocation_success(mock_exiftool_class): + # Mock ExifTool response for Sunnyvale coordinates + mock_et = mock_exiftool_class.return_value.__enter__.return_value + mock_et.execute.return_value = b"Geolocation database:\nCity,Region,Subregion,CountryCode,Country,TimeZone,FeatureCode,Population,Latitude,Longitude\nCupertino,California,Santa Clara County,US,United States,America/Los_Angeles,PPL,60000,37.3229,-122.0322\nSunnyvale,California,Santa Clara County,US,United States,America/Los_Angeles,PPL,152000,37.3688,-122.0363" + + result = geolocation.exiftool_geolocation(37.368, -122.03) + + assert result is not None, "ExifTool geolocation should return result" + assert 'city' in result, "Result should contain city" + assert 'default' in result, "Result should contain default location" + assert result['city'] in ['Cupertino', 'Sunnyvale'], f"City should be Cupertino or Sunnyvale, got {result['city']}" + +@mock.patch('elodie.geolocation.ExifTool') +def test_exiftool_geolocation_no_match(mock_exiftool_class): + # Mock ExifTool response with no nearby cities + mock_et = mock_exiftool_class.return_value.__enter__.return_value + mock_et.execute.return_value = b"Geolocation database:\nCity,Region,Subregion,CountryCode,Country,TimeZone,FeatureCode,Population,Latitude,Longitude\nNew York,New York,New York County,US,United States,America/New_York,PPL,8000000,40.7128,-74.0060" + + result = geolocation.exiftool_geolocation(37.368, -122.03) + + # Should return None since New York is too far from Sunnyvale coordinates + assert result is None, "ExifTool geolocation should return None for distant coordinates" + +@mock.patch('elodie.geolocation.ExifTool') +def test_exiftool_geolocation_exception(mock_exiftool_class): + # Mock ExifTool to raise an exception + mock_exiftool_class.return_value.__enter__.side_effect = Exception("ExifTool error") + + result = geolocation.exiftool_geolocation(37.368, -122.03) + + assert result is None, "ExifTool geolocation should return None on exception" + +def test_exiftool_geolocation_invalid_coordinates(): + result = geolocation.exiftool_geolocation(None, None) + assert result is None, "ExifTool geolocation should return None for None coordinates" + + result = geolocation.exiftool_geolocation(37.368, None) + assert result is None, "ExifTool geolocation should return None for partial coordinates" + +@mock.patch('elodie.geolocation.exiftool_geolocation') +@mock.patch('elodie.geolocation.get_key') +def test_place_name_uses_mapquest_when_key_available(mock_get_key, mock_exiftool_geo): + # Mock MapQuest key to be available + mock_get_key.return_value = 'test_key' + + # This will test that when MapQuest key is available, it uses MapQuest (not ExifTool) + result = geolocation.place_name(37.368, -122.03) + + # Should have checked for MapQuest key first + mock_get_key.assert_called_once() + # Should NOT have called ExifTool since MapQuest key is available + mock_exiftool_geo.assert_not_called() + +@mock.patch('elodie.geolocation.exiftool_geolocation') +@mock.patch('elodie.geolocation.get_key') +def test_place_name_uses_exiftool_when_no_mapquest_key(mock_get_key, mock_exiftool_geo): + # Mock no MapQuest key available + mock_get_key.return_value = None + # Mock ExifTool to return a successful result + mock_exiftool_geo.return_value = {'city': 'Cupertino', 'default': 'Cupertino', 'country': 'United States'} + + result = geolocation.place_name(37.368, -122.03) + + # Should have checked for MapQuest key first + mock_get_key.assert_called_once() + # Should have called ExifTool since no MapQuest key + mock_exiftool_geo.assert_called_once_with(37.368, -122.03) + # Should return the ExifTool result + assert result['city'] == 'Cupertino', f"Expected Cupertino, got {result.get('city')}" diff --git a/requirements.txt b/requirements.txt index 30d0e38d..1a40cc71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,5 +5,6 @@ future==0.16.0 configparser==3.5.0 tabulate==0.7.7 Pillow==6.2.2; python_version == '2.7' -Pillow==9.3; python_version >= '3.6' +Pillow==9.3; python_version >= '3.6' and python_version < '3.13' +Pillow>=10.4; python_version >= '3.13' six==1.9