Merge lp:~cjwatson/launchpad/geoip-ip-address-handling into lp:launchpad

Proposed by Colin Watson
Status: Merged
Merged at revision: 19025
Proposed branch: lp:~cjwatson/launchpad/geoip-ip-address-handling
Merge into: lp:launchpad
Diff against target: 507 lines (+88/-189)
8 files modified
lib/lp/answers/browser/tests/views.txt (+6/-6)
lib/lp/answers/stories/question-search-multiple-languages.txt (+17/-4)
lib/lp/services/geoip/configure.zcml (+1/-6)
lib/lp/services/geoip/doc/geoip.txt (+26/-70)
lib/lp/services/geoip/helpers.py (+25/-18)
lib/lp/services/geoip/interfaces.py (+3/-19)
lib/lp/services/geoip/model.py (+9/-66)
setup.py (+1/-0)
To merge this branch: bzr merge lp:~cjwatson/launchpad/geoip-ip-address-handling
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+369729@code.launchpad.net

Commit message

Revamp GeoIP IP address handling.

Description of the change

This removes the weird special case mapping private addresses to South Africa, and switches to the ipaddress module rather than ad-hoc private IP address detection, theoretically allowing us to handle IPv6 addresses too (although real IPv6 support requires switching to GeoIP2 data).

The ipaddress module was already in our virtualenv as an indirect dependency.

To post a comment you must log in.
Revision history for this message
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/lp/answers/browser/tests/views.txt'
--- lib/lp/answers/browser/tests/views.txt 2018-12-07 13:24:51 +0000
+++ lib/lp/answers/browser/tests/views.txt 2019-07-04 18:55:19 +0000
@@ -573,14 +573,14 @@
573will contain languages from the HTTP request, or the most likely573will contain languages from the HTTP request, or the most likely
574interesting languages based on GeoIP information.574interesting languages based on GeoIP information.
575575
576For example, if the user doesn't log in and their browser is configured to576For example, if the user doesn't log in, their browser is configured to
577accept Brazilian Portuguese, the vocabulary will contain the languages577accept Brazilian Portuguese, and their request appears to come from a South
578spoken in South Africa (because the 127.0.0.1 IP address is mapped to578African IP address, the vocabulary will contain the languages spoken in
579South Africa in the tests).579South Africa.
580580
581 >>> login(ANONYMOUS)581 >>> login(ANONYMOUS)
582 >>> request = LaunchpadTestRequest(582 >>> request = LaunchpadTestRequest(
583 ... HTTP_ACCEPT_LANGUAGE='pt_BR')583 ... HTTP_ACCEPT_LANGUAGE='pt_BR', REMOTE_ADDR='196.36.161.227')
584 >>> from lp.answers.browser.question import (584 >>> from lp.answers.browser.question import (
585 ... QuestionLanguageVocabularyFactory)585 ... QuestionLanguageVocabularyFactory)
586 >>> view = getMultiAdapter((firefox, request), name='+addticket')586 >>> view = getMultiAdapter((firefox, request), name='+addticket')
@@ -676,7 +676,7 @@
676database.676database.
677677
678 >>> request = LaunchpadTestRequest(678 >>> request = LaunchpadTestRequest(
679 ... HTTP_ACCEPT_LANGUAGE='fr, en_CA')679 ... HTTP_ACCEPT_LANGUAGE='fr, en_CA', REMOTE_ADDR='196.36.161.227')
680680
681 >>> login(ANONYMOUS)681 >>> login(ANONYMOUS)
682 >>> view = UserSupportLanguagesView(None, request)682 >>> view = UserSupportLanguagesView(None, request)
683683
=== modified file 'lib/lp/answers/stories/question-search-multiple-languages.txt'
--- lib/lp/answers/stories/question-search-multiple-languages.txt 2019-05-22 14:57:45 +0000
+++ lib/lp/answers/stories/question-search-multiple-languages.txt 2019-07-04 18:55:19 +0000
@@ -3,6 +3,12 @@
3By default, only questions written in English or one of the user3By default, only questions written in English or one of the user
4preferred languages are listed and searched.4preferred languages are listed and searched.
55
6Make the test browsers look like they're coming from an arbitrary South
7African IP address, since we'll use that later.
8
9 >>> anon_browser.addHeader('X_FORWARDED_FOR', '196.36.161.227')
10 >>> user_browser.addHeader('X_FORWARDED_FOR', '196.36.161.227')
11
612
7== Anonymous searching ==13== Anonymous searching ==
814
@@ -29,10 +35,9 @@
29 ... print(question.find('a').renderContents())35 ... print(question.find('a').renderContents())
30 Installation failed36 Installation failed
3137
32The questions match the languages inferred from by GeoIP38The questions match the languages inferred from GeoIP (South Africa in
33127.0.0.1 is mapped to South Africa in the test suite. The language39this case). The language control shows the intersection of the user's
34control shows the intersection of the user's languages and the40languages and the languages of the target's questions. In this example:
35languages of the target's questions. In this example:
36set(['af', 'en', 'st', 'xh', 'zu']) & set(['en', 'es']) == set(en).41set(['af', 'en', 'st', 'xh', 'zu']) & set(['en', 'es']) == set(en).
3742
38 >>> sorted(anon_browser.getControl(name='field.language').options)43 >>> sorted(anon_browser.getControl(name='field.language').options)
@@ -113,6 +118,10 @@
113anonymous user makes a request from a GeoIP that has no languages118anonymous user makes a request from a GeoIP that has no languages
114mapped, we assume they speak the default language of English.119mapped, we assume they speak the default language of English.
115120
121 >>> for pos, (key, _) in enumerate(anon_browser.mech_browser.addheaders):
122 ... if key == 'X_FORWARDED_FOR':
123 ... del anon_browser.mech_browser.addheaders[pos]
124 ... break
116 >>> anon_browser.addHeader('X_FORWARDED_FOR', '172.16.1.1')125 >>> anon_browser.addHeader('X_FORWARDED_FOR', '172.16.1.1')
117 >>> anon_browser.open(126 >>> anon_browser.open(
118 ... 'http://launchpad.test/ubuntu/+source/mozilla-firefox/+questions')127 ... 'http://launchpad.test/ubuntu/+source/mozilla-firefox/+questions')
@@ -166,6 +175,10 @@
166When the project languages are just English, and the user speaks175When the project languages are just English, and the user speaks
167that language, we do not show the language controls.176that language, we do not show the language controls.
168177
178 >>> for pos, (key, _) in enumerate(user_browser.mech_browser.addheaders):
179 ... if key == 'X_FORWARDED_FOR':
180 ... del user_browser.mech_browser.addheaders[pos]
181 ... break
169 >>> user_browser.addHeader('X_FORWARDED_FOR', '172.16.1.1')182 >>> user_browser.addHeader('X_FORWARDED_FOR', '172.16.1.1')
170 >>> user_browser.open(183 >>> user_browser.open(
171 ... 'http://launchpad.test/ubuntu/+source/mozilla-firefox/+questions')184 ... 'http://launchpad.test/ubuntu/+source/mozilla-firefox/+questions')
172185
=== modified file 'lib/lp/services/geoip/configure.zcml'
--- lib/lp/services/geoip/configure.zcml 2010-10-25 05:33:20 +0000
+++ lib/lp/services/geoip/configure.zcml 2019-07-04 18:55:19 +0000
@@ -1,4 +1,4 @@
1<!-- Copyright 2009-2010 Canonical Ltd. This software is licensed under the1<!-- Copyright 2009-2019 Canonical Ltd. This software is licensed under the
2 GNU Affero General Public License version 3 (see the file LICENSE).2 GNU Affero General Public License version 3 (see the file LICENSE).
3-->3-->
44
@@ -34,9 +34,4 @@
34 factory="lp.services.geoip.helpers.request_country"34 factory="lp.services.geoip.helpers.request_country"
35 provides="lp.services.worlddata.interfaces.country.ICountry" />35 provides="lp.services.worlddata.interfaces.country.ICountry" />
3636
37 <adapter
38 for="zope.publisher.interfaces.browser.IBrowserRequest"
39 factory="lp.services.geoip.model.GeoIPRequest"
40 provides="lp.services.geoip.interfaces.IGeoIPRecord" />
41
42</configure>37</configure>
4338
=== modified file 'lib/lp/services/geoip/doc/geoip.txt'
--- lib/lp/services/geoip/doc/geoip.txt 2016-01-26 15:47:37 +0000
+++ lib/lp/services/geoip/doc/geoip.txt 2019-07-04 18:55:19 +0000
@@ -15,26 +15,30 @@
15 u'Brazil'15 u'Brazil'
1616
17When running tests the IP address will start with '127.', and GeoIP17When running tests the IP address will start with '127.', and GeoIP
18would, obviously, fail to find the country for, so we use a South18would, obviously, fail to find the country for that, so we return None.
19African IP address in that case.
2019
21 >>> geoip.getCountryByAddr('127.0.0.88').name20 >>> print(geoip.getCountryByAddr('127.0.0.88'))
22 u'South Africa'21 None
2322
24We do the same trick for any IP addresses on private networks.23We do the same trick for any IP addresses on private networks.
2524
26 >>> geoip.getCountryByAddr('10.0.0.88').name25 >>> print(geoip.getCountryByAddr('10.0.0.88'))
27 u'South Africa'26 None
2827
29 >>> geoip.getCountryByAddr('192.168.0.7').name28 >>> print(geoip.getCountryByAddr('192.168.0.7'))
30 u'South Africa'29 None
3130
32 >>> geoip.getCountryByAddr('172.16.0.1').name31 >>> print(geoip.getCountryByAddr('172.16.0.1'))
33 u'South Africa'32 None
3433
35IGeoIP also provides a getRecordByAddress() method, which returns a34 >>> print(geoip.getCountryByAddr('::1'))
36GeoIPRecord object, containing a bunch more information about that IP's35 None
37location.36
37 >>> print(geoip.getCountryByAddr('fc00::1'))
38 None
39
40IGeoIP also provides a getRecordByAddress() method, which returns a dict
41containing a bunch more information about that IP's location.
3842
39 >>> record = geoip.getRecordByAddress('201.13.165.145')43 >>> record = geoip.getRecordByAddress('201.13.165.145')
40 >>> for key, value in sorted(record.items()):44 >>> for key, value in sorted(record.items()):
@@ -52,61 +56,13 @@
52 region_name: ...56 region_name: ...
53 time_zone: ...57 time_zone: ...
5458
55And again we'll use a South African IP if the address starts with '127.'.59And again we'll return None if the address is private.
5660
57 >>> record = geoip.getRecordByAddress('127.0.0.1')61 >>> print(geoip.getRecordByAddress('127.0.0.1'))
58 >>> for key, value in sorted(record.items()):62 None
59 ... print "%s: %s" % (key, value)63
60 area_code: ...64If it can't find a GeoIP record for the given IP address, it will return
61 city: ...
62 country_code: ZA
63 country_code3: ZAF
64 country_name: South Africa
65 dma_code: ...
66 latitude: ...
67 longitude: ...
68 postal_code: ...
69 region: ...
70 region_name: ...
71 time_zone: ...
72
73If it can't find a GeoIPRecord for the given IP address, it will return
74None.65None.
7566
76 >>> print geoip.getRecordByAddress('255.255.255.255')67 >>> print geoip.getRecordByAddress('255.255.255.255')
77 None68 None
78
79We also have GeoIPRequest, which adapts an IBrowserRequest into an
80IGeoIPRecord, providing the latitude, longitude and time zone of the
81request's originating IP address.
82
83 >>> from lp.services.webapp.servers import LaunchpadTestRequest
84 >>> from lp.services.geoip.interfaces import IGeoIPRecord
85 >>> request = LaunchpadTestRequest()
86
87Since our request won't have an originating IP address, we'll use that
88same South African IP address.
89
90 >>> from lp.services.geoip.helpers import ipaddress_from_request
91 >>> print ipaddress_from_request(request)
92 None
93 >>> geoip_request = IGeoIPRecord(request)
94 >>> geoip_request.latitude
95 -33...
96 >>> geoip_request.longitude
97 18...
98 >>> geoip_request.time_zone
99 'Africa/Johannesburg'
100
101If the request had an originating IP address, though, it'd be used when
102we adapted it into an IGeoIPRecord.
103
104 >>> request = LaunchpadTestRequest(
105 ... environ={'REMOTE_ADDR': '201.13.165.145'})
106 >>> geoip_request = IGeoIPRecord(request)
107 >>> geoip_request.latitude
108 -23...
109 >>> geoip_request.longitude
110 -45...
111 >>> geoip_request.time_zone in ('Brazil/Acre', 'America/Sao_Paulo')
112 True
11369
=== modified file 'lib/lp/services/geoip/helpers.py'
--- lib/lp/services/geoip/helpers.py 2012-01-06 11:08:30 +0000
+++ lib/lp/services/geoip/helpers.py 2019-07-04 18:55:19 +0000
@@ -1,10 +1,10 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the1# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__metaclass__ = type4__metaclass__ = type
55
6import re6import ipaddress
77import six
8from zope.component import getUtility8from zope.component import getUtility
99
10from lp.services.geoip.interfaces import IGeoIP10from lp.services.geoip.interfaces import IGeoIP
@@ -12,6 +12,7 @@
1212
13__all__ = [13__all__ = [
14 'request_country',14 'request_country',
15 'ipaddress_is_global',
15 'ipaddress_from_request',16 'ipaddress_from_request',
16 ]17 ]
1718
@@ -25,39 +26,48 @@
25 This information is not reliable and trivially spoofable - use it only26 This information is not reliable and trivially spoofable - use it only
26 for selecting sane defaults.27 for selecting sane defaults.
27 """28 """
28 ipaddress = ipaddress_from_request(request)29 ip_address = ipaddress_from_request(request)
29 if ipaddress is not None:30 if ip_address is not None:
30 return getUtility(IGeoIP).getCountryByAddr(ipaddress)31 return getUtility(IGeoIP).getCountryByAddr(ip_address)
31 return None32 return None
3233
3334
34_ipaddr_re = re.compile('\d\d?\d?\.\d\d?\d?\.\d\d?\d?\.\d\d?\d?')35def ipaddress_is_global(addr):
36 """Return True iff the IP address is on a global public network."""
37 try:
38 return ipaddress.ip_address(six.ensure_text(addr)).is_global
39 except ValueError:
40 return False
3541
3642
37def ipaddress_from_request(request):43def ipaddress_from_request(request):
38 """Determine the IP address for this request.44 """Determine the IP address for this request.
3945
40 Returns None if the IP address cannot be determined or is localhost.46 Returns None if the IP address cannot be determined or is not on a
47 global public network.
4148
42 The remote IP address is determined by the X-Forwarded-For: header,49 The remote IP address is determined by the X-Forwarded-For: header,
43 or failing that, the REMOTE_ADDR CGI environment variable.50 or failing that, the REMOTE_ADDR CGI environment variable.
4451
45 Because this information is unreliable and trivially spoofable, we52 Because this information is unreliable and trivially spoofable, we
46 don't bother to do much error checking to ensure the IP address is at all53 don't bother to do much error checking to ensure the IP address is at
47 valid.54 all valid, beyond what the ipaddress module gives us.
4855
49 >>> google = '66.102.7.104'
50 >>> ipaddress_from_request({'REMOTE_ADDR': '1.1.1.1'})56 >>> ipaddress_from_request({'REMOTE_ADDR': '1.1.1.1'})
51 '1.1.1.1'57 '1.1.1.1'
52 >>> ipaddress_from_request({58 >>> ipaddress_from_request({
53 ... 'HTTP_X_FORWARDED_FOR': '666.666.666.666',59 ... 'HTTP_X_FORWARDED_FOR': '66.66.66.66',
54 ... 'REMOTE_ADDR': '1.1.1.1'60 ... 'REMOTE_ADDR': '1.1.1.1'
55 ... })61 ... })
56 '666.666.666.666'62 '66.66.66.66'
57 >>> ipaddress_from_request({'HTTP_X_FORWARDED_FOR':63 >>> ipaddress_from_request({'HTTP_X_FORWARDED_FOR':
58 ... 'localhost, 127.0.0.1, 255.255.255.255,1.1.1.1'64 ... 'localhost, 127.0.0.1, 255.255.255.255,1.1.1.1'
59 ... })65 ... })
60 '255.255.255.255'66 '1.1.1.1'
67 >>> ipaddress_from_request({
68 ... 'HTTP_X_FORWARDED_FOR': 'nonsense',
69 ... 'REMOTE_ADDR': '1.1.1.1'
70 ... })
61 """71 """
62 ipaddresses = request.get('HTTP_X_FORWARDED_FOR')72 ipaddresses = request.get('HTTP_X_FORWARDED_FOR')
6373
@@ -70,10 +80,7 @@
70 # We actually get a comma separated list of addresses. We need to throw80 # We actually get a comma separated list of addresses. We need to throw
71 # away the obvious duds, such as loopback addresses81 # away the obvious duds, such as loopback addresses
72 ipaddresses = [addr.strip() for addr in ipaddresses.split(',')]82 ipaddresses = [addr.strip() for addr in ipaddresses.split(',')]
73 ipaddresses = [83 ipaddresses = [addr for addr in ipaddresses if ipaddress_is_global(addr)]
74 addr for addr in ipaddresses
75 if not (addr.startswith('127.')
76 or _ipaddr_re.search(addr) is None)]
7784
78 if ipaddresses:85 if ipaddresses:
79 # If we have more than one, have a guess.86 # If we have more than one, have a guess.
8087
=== modified file 'lib/lp/services/geoip/interfaces.py'
--- lib/lp/services/geoip/interfaces.py 2013-01-07 02:40:55 +0000
+++ lib/lp/services/geoip/interfaces.py 2019-07-04 18:55:19 +0000
@@ -1,15 +1,11 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the1# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4from zope.interface import (4from zope.interface import Interface
5 Attribute,
6 Interface,
7 )
85
96
10__all__ = [7__all__ = [
11 'IGeoIP',8 'IGeoIP',
12 'IGeoIPRecord',
13 'IRequestLocalLanguages',9 'IRequestLocalLanguages',
14 'IRequestPreferredLanguages',10 'IRequestPreferredLanguages',
15 ]11 ]
@@ -19,7 +15,7 @@
19 """The GeoIP utility, which represents the GeoIP database."""15 """The GeoIP utility, which represents the GeoIP database."""
2016
21 def getRecordByAddress(ip_address):17 def getRecordByAddress(ip_address):
22 """Return the IGeoIPRecord for the given IP address, or None."""18 """Return the GeoIP record for the given IP address, or None."""
2319
24 def getCountryByAddr(ip_address):20 def getCountryByAddr(ip_address):
25 """Find and return an ICountry based on the given IP address.21 """Find and return an ICountry based on the given IP address.
@@ -29,18 +25,6 @@
29 """25 """
3026
3127
32class IGeoIPRecord(Interface):
33 """A single record in the GeoIP database.
34
35 A GeoIP record gathers together all of the relevant information for a
36 single IP address or machine name in the DNS.
37 """
38
39 latitude = Attribute("The geographic latitude, in real degrees.")
40 latitude = Attribute("The geographic longitude, in real degrees.")
41 time_zone = Attribute("The time zone.")
42
43
44class IRequestLocalLanguages(Interface):28class IRequestLocalLanguages(Interface):
4529
46 def getLocalLanguages():30 def getLocalLanguages():
4731
=== modified file 'lib/lp/services/geoip/model.py'
--- lib/lp/services/geoip/model.py 2015-07-08 16:05:11 +0000
+++ lib/lp/services/geoip/model.py 2019-07-04 18:55:19 +0000
@@ -1,9 +1,8 @@
1# Copyright 2009 Canonical Ltd. This software is licensed under the1# Copyright 2009-2019 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4__all__ = [4__all__ = [
5 'GeoIP',5 'GeoIP',
6 'GeoIPRequest',
7 'RequestLocalLanguages',6 'RequestLocalLanguages',
8 'RequestPreferredLanguages',7 'RequestPreferredLanguages',
9 ]8 ]
@@ -16,10 +15,12 @@
16from zope.interface import implementer15from zope.interface import implementer
1716
18from lp.services.config import config17from lp.services.config import config
19from lp.services.geoip.helpers import ipaddress_from_request18from lp.services.geoip.helpers import (
19 ipaddress_from_request,
20 ipaddress_is_global,
21 )
20from lp.services.geoip.interfaces import (22from lp.services.geoip.interfaces import (
21 IGeoIP,23 IGeoIP,
22 IGeoIPRecord,
23 IRequestLocalLanguages,24 IRequestLocalLanguages,
24 IRequestPreferredLanguages,25 IRequestPreferredLanguages,
25 )26 )
@@ -42,7 +43,8 @@
4243
43 def getRecordByAddress(self, ip_address):44 def getRecordByAddress(self, ip_address):
44 """See `IGeoIP`."""45 """See `IGeoIP`."""
45 ip_address = ensure_address_is_not_private(ip_address)46 if not ipaddress_is_global(ip_address):
47 return None
46 try:48 try:
47 return self._gi.record_by_addr(ip_address)49 return self._gi.record_by_addr(ip_address)
48 except SystemError:50 except SystemError:
@@ -53,7 +55,8 @@
5355
54 def getCountryByAddr(self, ip_address):56 def getCountryByAddr(self, ip_address):
55 """See `IGeoIP`."""57 """See `IGeoIP`."""
56 ip_address = ensure_address_is_not_private(ip_address)58 if not ipaddress_is_global(ip_address):
59 return None
57 geoip_record = self.getRecordByAddress(ip_address)60 geoip_record = self.getRecordByAddress(ip_address)
58 if geoip_record is None:61 if geoip_record is None:
59 return None62 return None
@@ -68,44 +71,6 @@
68 return country71 return country
6972
7073
71@implementer(IGeoIPRecord)
72class GeoIPRequest:
73 """An adapter for a BrowserRequest into an IGeoIPRecord."""
74
75 def __init__(self, request):
76 self.request = request
77 ip_address = ipaddress_from_request(self.request)
78 if ip_address is None:
79 # This happens during page testing, when the REMOTE_ADDR is not
80 # set by Zope.
81 ip_address = '127.0.0.1'
82 ip_address = ensure_address_is_not_private(ip_address)
83 self.ip_address = ip_address
84 self.geoip_record = getUtility(IGeoIP).getRecordByAddress(
85 self.ip_address)
86
87 @property
88 def latitude(self):
89 """See `IGeoIPRecord`."""
90 if self.geoip_record is None:
91 return None
92 return self.geoip_record['latitude']
93
94 @property
95 def longitude(self):
96 """See `IGeoIPRecord`."""
97 if self.geoip_record is None:
98 return None
99 return self.geoip_record['longitude']
100
101 @property
102 def time_zone(self):
103 """See `IGeoIPRecord`."""
104 if self.geoip_record is None:
105 return None
106 return self.geoip_record['time_zone']
107
108
109@implementer(IRequestLocalLanguages)74@implementer(IRequestLocalLanguages)
110class RequestLocalLanguages(object):75class RequestLocalLanguages(object):
11176
@@ -168,27 +133,5 @@
168 return sorted(languages, key=lambda x: x.englishname)133 return sorted(languages, key=lambda x: x.englishname)
169134
170135
171def ensure_address_is_not_private(ip_address):
172 """Return the given IP address if it doesn't start with '127.'.
173
174 If it does start with '127.' then we return a South African IP address.
175 Notice that we have no specific reason for using a South African IP
176 address here -- we could have used any other non-private IP address.
177 """
178 private_prefixes = (
179 '127.',
180 '192.168.',
181 '172.16.',
182 '10.',
183 )
184
185 for prefix in private_prefixes:
186 if ip_address.startswith(prefix):
187 # This is an arbitrary South African IP which was handy at the
188 # time of writing; it's not special in any way.
189 return '196.36.161.227'
190 return ip_address
191
192
193class NoGeoIPDatabaseFound(Exception):136class NoGeoIPDatabaseFound(Exception):
194 """No GeoIP database was found."""137 """No GeoIP database was found."""
195138
=== modified file 'setup.py'
--- setup.py 2019-06-18 16:52:56 +0000
+++ setup.py 2019-07-04 18:55:19 +0000
@@ -164,6 +164,7 @@
164 'gunicorn[gthread]',164 'gunicorn[gthread]',
165 'html5browser',165 'html5browser',
166 'importlib-resources',166 'importlib-resources',
167 'ipaddress',
167 'ipython',168 'ipython',
168 'jsautobuild',169 'jsautobuild',
169 'launchpad-buildd',170 'launchpad-buildd',