• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

jmathai / elodie / b24ad695-cf06-4bc7-95e9-9808246ed215

30 Oct 2025 08:05PM UTC coverage: 90.584% (-0.5%) from 91.085%
b24ad695-cf06-4bc7-95e9-9808246ed215

push

circleci

web-flow
Add gelocation fallback to Exiftool if no MapQuest API key is present (gh-467) (#488)

Closes gh-418

73 of 86 new or added lines in 2 files covered. (84.88%)

1 existing line in 1 file now uncovered.

1366 of 1508 relevant lines covered (90.58%)

0.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.61
/elodie/geolocation.py
1
"""Look up geolocation information for media objects."""
2
from __future__ import print_function
1✔
3
from __future__ import division
1✔
4

5
from os import path
1✔
6

7
import requests
1✔
8
import urllib.request
1✔
9
import urllib.parse
1✔
10
import urllib.error
1✔
11

12
from elodie.config import load_config
1✔
13
from elodie import constants
1✔
14
from elodie import log
1✔
15
from elodie.localstorage import Db
1✔
16
from elodie.external.pyexiftool import ExifTool
1✔
17

18
__KEY__ = None
1✔
19
__DEFAULT_LOCATION__ = 'Unknown Location'
1✔
20
__PREFER_ENGLISH_NAMES__ = None
1✔
21
__EXIFTOOL_AVAILABLE__ = None
1✔
22

23

24
def coordinates_by_name(name):
1✔
25
    # Try to get cached location first
26
    db = Db()
1✔
27
    cached_coordinates = db.get_location_coordinates(name)
1✔
28
    if(cached_coordinates is not None):
1✔
29
        return {
×
30
            'latitude': cached_coordinates[0],
31
            'longitude': cached_coordinates[1]
32
        }
33

34
    # Use MapQuest if key is available, otherwise use ExifTool
35
    key = get_key()
1✔
36
    if key is not None:
1✔
37
        # Use MapQuest
38
        geolocation_info = lookup(location=name)
1✔
39

40
        if(geolocation_info is not None):
1✔
41
            if(
1✔
42
                'results' in geolocation_info and
43
                len(geolocation_info['results']) != 0 and
44
                'locations' in geolocation_info['results'][0] and
45
                len(geolocation_info['results'][0]['locations']) != 0
46
            ):
47

48
                # By default we use the first entry unless we find one with
49
                #   geocodeQuality=city.
50
                geolocation_result = geolocation_info['results'][0]
1✔
51
                use_location = geolocation_result['locations'][0]['latLng']
1✔
52
                # Loop over the locations to see if we come accross a
53
                #   geocodeQuality=city.
54
                # If we find a city we set that to the use_location and break
55
                for location in geolocation_result['locations']:
1✔
56
                    if(
1✔
57
                        'latLng' in location and
58
                        'lat' in location['latLng'] and
59
                        'lng' in location['latLng'] and
60
                        location['geocodeQuality'].lower() == 'city'
61
                    ):
62
                        use_location = location['latLng']
1✔
63
                        break
1✔
64

65
                return {
1✔
66
                    'latitude': use_location['lat'],
67
                    'longitude': use_location['lng']
68
                }
69
    else:
70
        # Use ExifTool as alternative when MapQuest key is not configured
71
        exiftool_result = exiftool_coordinates_by_name(name)
1✔
72
        if exiftool_result is not None:
1✔
73
            return exiftool_result
1✔
74

75
    return None
×
76

77

78
def decimal_to_dms(decimal):
1✔
79
    decimal = float(decimal)
1✔
80
    decimal_abs = abs(decimal)
1✔
81
    minutes, seconds = divmod(decimal_abs*3600, 60)
1✔
82
    degrees, minutes = divmod(minutes, 60)
1✔
83
    degrees = degrees
1✔
84
    sign = 1 if decimal >= 0 else -1
1✔
85
    return (degrees, minutes, seconds, sign)
1✔
86

87

88
def dms_to_decimal(degrees, minutes, seconds, direction=' '):
1✔
89
    sign = 1
1✔
90
    if(direction[0] in 'WSws'):
1✔
91
        sign = -1
1✔
92
    return (
1✔
93
        float(degrees) + (float(minutes) / 60) +
94
        (float(seconds) / 3600)
95
    ) * sign
96

97

98
def dms_string(decimal, type='latitude'):
1✔
99
    # Example string -> 38 deg 14' 27.82" S
100
    dms = decimal_to_dms(decimal)
1✔
101
    if type == 'latitude':
1✔
102
        direction = 'N' if decimal >= 0 else 'S'
1✔
103
    elif type == 'longitude':
1✔
104
        direction = 'E' if decimal >= 0 else 'W'
1✔
105
    return '{} deg {}\' {}" {}'.format(dms[0], dms[1], dms[2], direction)
1✔
106

107

108
def is_exiftool_available():
1✔
109
    """Check if ExifTool geolocation functionality is available."""
110
    global __EXIFTOOL_AVAILABLE__
111
    if __EXIFTOOL_AVAILABLE__ is not None:
1✔
112
        return __EXIFTOOL_AVAILABLE__
1✔
113
    
114
    try:
1✔
115
        et = ExifTool()
1✔
116
        # Test if geolocation database is available by doing a simple lookup
117
        result = et.execute_json(b"-api", b"geolocation=40.7128,-74.0060")  # NYC coordinates
1✔
118
        __EXIFTOOL_AVAILABLE__ = result and len(result) > 0 and 'ExifTool:GeolocationCity' in result[0]
1✔
NEW
119
    except Exception:
×
NEW
120
        __EXIFTOOL_AVAILABLE__ = False
×
121
    
122
    return __EXIFTOOL_AVAILABLE__
1✔
123

124

125
def exiftool_coordinates_by_name(name):
1✔
126
    """Look up coordinates for a location name using ExifTool's geolocation API."""
127
    if not is_exiftool_available():
1✔
128
        return None
1✔
129
    
130
    try:
1✔
131
        et = ExifTool()
1✔
132
        result = et.execute_json(b"-api", f"geolocation={name}".encode('utf-8'))
1✔
133
        if result and len(result) > 0 and 'ExifTool:GeolocationPosition' in result[0]:
1✔
134
            position = result[0]['ExifTool:GeolocationPosition']
1✔
135
            # Position format is "lat lon"
136
            lat, lon = position.split()
1✔
137
            return {
1✔
138
                'latitude': float(lat),
139
                'longitude': float(lon)
140
            }
NEW
141
    except Exception as e:
×
NEW
142
        log.error(f"ExifTool geolocation lookup failed: {e}")
×
143
    
144
    return None
1✔
145

146

147
def exiftool_place_name(lat, lon):
1✔
148
    """Look up place name for coordinates using ExifTool's geolocation API."""
149
    if not is_exiftool_available():
1✔
150
        return None
1✔
151
    
152
    try:
1✔
153
        et = ExifTool()
1✔
154
        # Use ExifTool's reverse geolocation API
155
        result = et.execute_json(b"-api", f"geolocation={lat},{lon}".encode('utf-8'))
1✔
156
        if result and len(result) > 0:
1✔
157
            data = result[0]
1✔
158
            location_data = {}
1✔
159
            
160
            # Build location data following the priority: City, Region, Subregion, Country
161
            if 'ExifTool:GeolocationCity' in data and data['ExifTool:GeolocationCity'].strip():
1✔
162
                location_data['city'] = data['ExifTool:GeolocationCity']
1✔
163
                if 'default' not in location_data:
1✔
164
                    location_data['default'] = data['ExifTool:GeolocationCity']
1✔
165
            
166
            if 'ExifTool:GeolocationRegion' in data and data['ExifTool:GeolocationRegion'].strip():
1✔
167
                location_data['state'] = data['ExifTool:GeolocationRegion']
1✔
168
                if 'default' not in location_data:
1✔
NEW
169
                    location_data['default'] = data['ExifTool:GeolocationRegion']
×
170
            
171
            if 'ExifTool:GeolocationCountry' in data and data['ExifTool:GeolocationCountry'].strip():
1✔
172
                location_data['country'] = data['ExifTool:GeolocationCountry']
1✔
173
                if 'default' not in location_data:
1✔
NEW
174
                    location_data['default'] = data['ExifTool:GeolocationCountry']
×
175
            
176
            if location_data:
1✔
177
                return location_data
1✔
178
                
NEW
179
    except Exception as e:
×
NEW
180
        log.error(f"ExifTool place name lookup failed: {e}")
×
181
    
NEW
182
    return None
×
183

184

185
def get_key():
1✔
186
    global __KEY__
187
    if __KEY__ is not None:
1✔
188
        return __KEY__
1✔
189

190
    if constants.mapquest_key is not None:
1✔
191
        __KEY__ = constants.mapquest_key
×
192
        return __KEY__
×
193

194
    config = load_config()
1✔
195
    if('MapQuest' not in config):
1✔
196
        return None
×
197

198
    __KEY__ = config['MapQuest']['key']
1✔
199
    return __KEY__
1✔
200

201
def get_prefer_english_names():
1✔
202
    global __PREFER_ENGLISH_NAMES__
203
    if __PREFER_ENGLISH_NAMES__ is not None:
1✔
204
        return __PREFER_ENGLISH_NAMES__
1✔
205

206
    config_file = '%s/config.ini' % constants.application_directory()
1✔
207
    if not path.exists(config_file):
1✔
208
        return False
×
209

210
    config = load_config()
1✔
211
    if('MapQuest' not in config):
1✔
212
        return False
×
213

214
    if('prefer_english_names' not in config['MapQuest']):
1✔
215
        return False
×
216

217
    __PREFER_ENGLISH_NAMES__ = bool(config['MapQuest']['prefer_english_names'])
1✔
218
    return __PREFER_ENGLISH_NAMES__
1✔
219

220
def place_name(lat, lon):
1✔
221
    lookup_place_name_default = {'default': __DEFAULT_LOCATION__}
1✔
222
    if(lat is None or lon is None):
1✔
223
        return lookup_place_name_default
1✔
224

225
    # Convert lat/lon to floats
226
    if(not isinstance(lat, float)):
1✔
227
        lat = float(lat)
1✔
228
    if(not isinstance(lon, float)):
1✔
229
        lon = float(lon)
1✔
230

231
    # Try to get cached location first
232
    db = Db()
1✔
233
    # 3km distace radious for a match
234
    cached_place_name = db.get_location_name(lat, lon, 3000)
1✔
235
    # We check that it's a dict to coerce an upgrade of the location
236
    #  db from a string location to a dictionary. See gh-160.
237
    if(isinstance(cached_place_name, dict)):
1✔
238
        return cached_place_name
1✔
239

240
    lookup_place_name = {}
1✔
241
    
242
    # Use MapQuest if key is available, otherwise use ExifTool
243
    key = get_key()
1✔
244
    if key is not None:
1✔
245
        # Use MapQuest
246
        geolocation_info = lookup(lat=lat, lon=lon)
1✔
247
        if(geolocation_info is not None and 'address' in geolocation_info):
1✔
248
            address = geolocation_info['address']
1✔
249
            # gh-386 adds support for town
250
            # taking precedence after city for backwards compatability
251
            for loc in ['city', 'town', 'state', 'country']:
1✔
252
                if(loc in address):
1✔
253
                    lookup_place_name[loc] = address[loc]
1✔
254
                    # In many cases the desired key is not available so we
255
                    #  set the most specific as the default.
256
                    if('default' not in lookup_place_name):
1✔
257
                        lookup_place_name['default'] = address[loc]
1✔
258
    else:
259
        # Use ExifTool as alternative when MapQuest key is not configured
NEW
260
        exiftool_result = exiftool_place_name(lat, lon)
×
NEW
261
        if exiftool_result is not None:
×
NEW
262
            lookup_place_name = exiftool_result
×
263

264
    if(lookup_place_name):
1✔
265
        db.add_location(lat, lon, lookup_place_name)
1✔
266
        # TODO: Maybe this should only be done on exit and not for every write.
267
        db.update_location_db()
1✔
268

269
    if('default' not in lookup_place_name):
1✔
270
        lookup_place_name = lookup_place_name_default
1✔
271

272
    return lookup_place_name
1✔
273

274

275
def lookup(**kwargs):
1✔
276
    if(
1✔
277
        'location' not in kwargs and
278
        'lat' not in kwargs and
279
        'lon' not in kwargs
280
    ):
281
        return None
×
282

283
    if('lat' in kwargs and 'lon' in kwargs):
1✔
284
        kwargs['location'] = '{},{}'.format(kwargs['lat'], kwargs['lon'])
1✔
285

286
    key = get_key()
1✔
287
    prefer_english_names = get_prefer_english_names()
1✔
288

289
    # Only proceed with MapQuest if key is available
290
    if(key is None):
1✔
291
        return None
×
292

293
    try:
1✔
294
        headers = {}
1✔
295
        params = {'format': 'json', 'key': key}
1✔
296
        if(prefer_english_names):
1✔
297
            headers = {'Accept-Language':'en-EN,en;q=0.8'}
1✔
298
            params['locale'] = 'en_US'
1✔
299
        params.update(kwargs)
1✔
300
        path = '/geocoding/v1/address'
1✔
301
        if('lat' in kwargs and 'lon' in kwargs):
1✔
302
            path = '/geocoding/v1/reverse'
1✔
303
        url = '%s%s?%s' % (
1✔
304
                    constants.mapquest_base_url,
305
                    path,
306
                    urllib.parse.urlencode(params)
307
              )
308
        # log the MapQuest url gh-446
309
        log.info('MapQuest url: %s' % (url))
1✔
310
        r = requests.get(url, headers=headers)
1✔
311
        return parse_result(r.json())
1✔
312
    except requests.exceptions.RequestException as e:
1✔
313
        log.error(e)
1✔
314
        return None
1✔
315
    except ValueError as e:
×
316
        log.error(r.text)
×
317
        log.error(e)
×
318
        return None
×
319

320

321
def parse_result(result):
1✔
322
    # gh-421
323
    # Return None if statusCode is not 0
324
    #   https://developer.mapquest.com/documentation/geocoding-api/status-codes/
325
    if( 'info' not in result or
1✔
326
        'statuscode' not in result['info'] or
327
        result['info']['statuscode'] != 0
328
       ):
329
        return None
1✔
330

331
    if( 'results' in result and
1✔
332
        len(result['results']) > 0 and
333
        'locations' in result['results'][0] and
334
        len(result['results'][0]['locations']) > 0 and
335
        # Return None if source is FALLBACK (invalid location)
336
        'source' in result['results'][0]['locations'][0] and
337
        result['results'][0]['locations'][0]['source'] == 'FALLBACK'
338
       ):
339
        return None
1✔
340

341
    address = parse_result_address(result)
1✔
342
    if(address is None):
1✔
343
        return None
1✔
344

345
    result['address'] = address
1✔
346
    result['latLng'] = parse_result_latlon(result)
1✔
347

348
    return result
1✔
349

350
def parse_result_address(result):
1✔
351
    # We want to store the city, state and country
352
    # The only way determined to identify an unfound address is 
353
    #   that none of the indicies were found
354
    if( 'results' not in result or
1✔
355
        len(result['results']) == 0 or
356
        'locations' not in result['results'][0] or
357
        len(result['results'][0]['locations']) == 0
358
        ):
359
        return None
×
360

361
    index_found = False
1✔
362
    addresses = {'city': None, 'state': None, 'country': None}
1✔
363
    result_compat = {}
1✔
364
    result_compat['address'] = {}
1✔
365

366

367
    locations = result['results'][0]['locations'][0]
1✔
368
    # We are looping over locations to find the adminAreaNType key which
369
    #   has a value of City, State or Country.
370
    # Once we find it then we obtain the value from the key adminAreaN
371
    #   where N is a numeric index.
372
    # For example
373
    #   * adminArea1Type = 'City'
374
    #   * adminArea1 = 'Sunnyvale'
375
    for key in locations:
1✔
376
        # Check if the key is of the form adminArea1Type
377
        if(key[-4:] == 'Type'):
1✔
378
            # If it's a type then check if it corresponds to one we are intereated in
379
            #   and store the index by parsing the key
380
            key_prefix = key[:-4]
1✔
381
            key_index = key[-5:-4]
1✔
382
            if(locations[key].lower() in addresses):
1✔
383
                addresses[locations[key].lower()] = locations[key_prefix]
1✔
384
                index_found = True
1✔
385

386
    if(index_found is False):
1✔
387
        return None
1✔
388

389
    return addresses
1✔
390

391
def parse_result_latlon(result):
1✔
392
    if( 'results' not in result or
1✔
393
        len(result['results']) == 0 or
394
        'locations' not in result['results'][0] or
395
        len(result['results'][0]['locations']) == 0 or
396
        'latLng' not in result['results'][0]['locations'][0]
397
        ):
398
        return None
×
399

400
    latLng = result['results'][0]['locations'][0]['latLng'];
1✔
401

402
    return {'lat': latLng['lat'], 'lon': latLng['lng']}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc