Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 118 additions & 12 deletions elodie/geolocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand Down
71 changes: 71 additions & 0 deletions elodie/tests/geolocation_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')}"
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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