Skip to content

Commit c8ff8b9

Browse files
author
tsunglung
committed
Replace pkg_resources usage with importlib.resources functions
1 parent 0743d21 commit c8ff8b9

1 file changed

Lines changed: 103 additions & 160 deletions

File tree

Lines changed: 103 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,96 @@
11
#!/usr/bin/env python
22
# -*- coding: utf-8 -*-
33

4+
import os
45
import bz2
5-
from pkg_resources import resource_filename
6+
import sqlite3
7+
import sys
8+
import tempfile
9+
if sys.version_info >= (3, 12):
10+
from importlib.resources import as_file, files
11+
else:
12+
from importlib_resources import as_file, files
613
from ..weatherapi12.location import Location
714

8-
9-
CITY_ID_FILES_PATH = 'cityids/%03d-%03d.txt.bz2'
15+
CITY_ID_DB_PATH = 'cityids/cities.db.bz2'
1016

1117

1218
class CityIDRegistry:
1319

1420
MATCHINGS = {
15-
'exact': lambda city_name, toponym: city_name == toponym,
16-
'nocase': lambda city_name, toponym: city_name.lower() == toponym.lower(),
17-
'like': lambda city_name, toponym: city_name.lower() in toponym.lower(),
18-
'startswith': lambda city_name, toponym: toponym.lower().startswith(city_name.lower())
21+
'exact': "SELECT city_id, name, country, state, lat, lon FROM city WHERE name=?",
22+
'like': r"SELECT city_id, name, country, state, lat, lon FROM city WHERE name LIKE ?"
1923
}
2024

21-
def __init__(self, filepath_regex):
22-
"""
23-
Initialise a registry that can be used to lookup info about cities.
24-
25-
:param filepath_regex: Python format string that gives the path of the files
26-
that store the city IDs information.
27-
Eg: ``folder1/folder2/%02d-%02d.txt``
28-
:type filepath_regex: str
29-
:returns: a *CityIDRegistry* instance
30-
31-
"""
32-
self._filepath_regex = filepath_regex
25+
def __init__(self, sqlite_db_path: str):
26+
self.connection = self.__decompress_db_to_memory(sqlite_db_path)
3327

3428
@classmethod
3529
def get_instance(cls):
3630
"""
3731
Factory method returning the default city ID registry
3832
:return: a `CityIDRegistry` instance
3933
"""
40-
return CityIDRegistry(CITY_ID_FILES_PATH)
34+
return CityIDRegistry(CITY_ID_DB_PATH)
4135

42-
def ids_for(self, city_name, country=None, matching='nocase'):
36+
def __decompress_db_to_memory(self, sqlite_db_path: str):
4337
"""
44-
Returns a list of tuples in the form (long, str, str) corresponding to
45-
the int IDs and relative toponyms and 2-chars country of the cities
46-
matching the provided city name.
47-
The rule for identifying matchings is according to the provided
48-
`matching` parameter value.
38+
Decompresses to memory the SQLite database at the provided path
39+
:param sqlite_db_path: str
40+
:return: None
41+
"""
42+
# https://stackoverflow.com/questions/3850022/how-to-load-existing-db-file-to-memory-in-python-sqlite3
43+
# https://stackoverflow.com/questions/32681761/how-can-i-attach-an-in-memory-sqlite-database-in-python
44+
# https://pymotw.com/2/bz2/
45+
46+
# read and uncompress data from compressed DB
47+
with as_file(files(__name__) / sqlite_db_path) as res_name:
48+
bz2_db = bz2.BZ2File(res_name)
49+
decompressed_data = bz2_db.read()
50+
51+
# dump decompressed data to a temp DB
52+
try:
53+
with tempfile.NamedTemporaryFile(mode='wb', delete=False) as tmpf:
54+
tmpf.write(decompressed_data)
55+
tmpf_name = tmpf.name
56+
57+
# read temp DB to memory and return handle
58+
src_conn = sqlite3.connect(tmpf_name)
59+
dest_conn = sqlite3.connect(':memory:')
60+
src_conn.backup(dest_conn)
61+
src_conn.close()
62+
return dest_conn
63+
finally:
64+
os.remove(tmpf_name)
65+
66+
def __query(self, sql_query: str, *args):
67+
"""
68+
Queries the DB with the specified SQL query
69+
:param sql_query: str
70+
:return: list of tuples
71+
"""
72+
cursor = self.connection.cursor()
73+
try:
74+
return cursor.execute(sql_query, args).fetchall()
75+
finally:
76+
cursor.close()
77+
78+
def ids_for(self, city_name, country=None, state=None, matching='like'):
79+
"""
80+
Returns a list of tuples in the form (city_id, name, country, state, lat, lon )
81+
The rule for querying follows the provided `matching` parameter value.
4982
If `country` is provided, the search is restricted to the cities of
50-
the specified country.
83+
the specified country, and an even stricter search when `state` is provided as well
84+
:param city_name: the string toponym of the city to search
5185
:param country: two character str representing the country where to
5286
search for the city. Defaults to `None`, which means: search in all
5387
countries.
54-
:param matching: str. Default is `nocase`. Possible values:
55-
`exact` - literal, case-sensitive matching,
56-
`nocase` - literal, case-insensitive matching,
88+
:param state: two character str representing the state where to
89+
search for the city. Defaults to `None`. When not `None` also `state` must be specified
90+
:param matching: str. Default is `like`. Possible values:
91+
`exact` - literal, case-sensitive matching
5792
`like` - matches cities whose name contains, as a substring, the string
5893
fed to the function, case-insensitive,
59-
`startswith` - matches cities whose names start with the string fed
60-
to the function, case-insensitive.
6194
:raises ValueError if the value for `matching` is unknown
6295
:return: list of tuples
6396
"""
@@ -68,43 +101,49 @@ def ids_for(self, city_name, country=None, matching='nocase'):
68101
"allowed values are %s" % ", ".join(self.MATCHINGS))
69102
if country is not None and len(country) != 2:
70103
raise ValueError("Country must be a 2-char string")
71-
splits = self._filter_matching_lines(city_name, country, matching)
72-
return [(int(item[1]), item[0], item[4]) for item in splits]
104+
if state is not None and country is None:
105+
raise ValueError("A country must be specified whenever a state is specified too")
106+
107+
q = self.MATCHINGS[matching]
108+
if matching == 'exact':
109+
params = [city_name]
110+
else:
111+
params = ['%' + city_name + '%']
112+
113+
if country is not None:
114+
q = q + ' AND country=?'
115+
params.append(country)
116+
117+
if state is not None:
118+
q = q + ' AND state=?'
119+
params.append(state)
120+
121+
rows = self.__query(q, *params)
122+
return rows
73123

74-
def locations_for(self, city_name, country=None, matching='nocase'):
124+
def locations_for(self, city_name, country=None, state=None, matching='like'):
75125
"""
76-
Returns a list of Location objects corresponding to
77-
the int IDs and relative toponyms and 2-chars country of the cities
78-
matching the provided city name.
79-
The rule for identifying matchings is according to the provided
80-
`matching` parameter value.
126+
Returns a list of `Location` objects
127+
The rule for querying follows the provided `matching` parameter value.
81128
If `country` is provided, the search is restricted to the cities of
82-
the specified country.
129+
the specified country, and an even stricter search when `state` is provided as well
130+
:param city_name: the string toponym of the city to search
83131
:param country: two character str representing the country where to
84132
search for the city. Defaults to `None`, which means: search in all
85133
countries.
86-
:param matching: str. Default is `nocase`. Possible values:
87-
`exact` - literal, case-sensitive matching,
88-
`nocase` - literal, case-insensitive matching,
134+
:param state: two character str representing the state where to
135+
search for the city. Defaults to `None`. When not `None` also `state` must be specified
136+
:param matching: str. Default is `like`. Possible values:
137+
`exact` - literal, case-sensitive matching
89138
`like` - matches cities whose name contains, as a substring, the string
90139
fed to the function, case-insensitive,
91-
`startswith` - matches cities whose names start with the string fed
92-
to the function, case-insensitive.
93140
:raises ValueError if the value for `matching` is unknown
94-
:return: list of `weatherapi25.location.Location` objects
141+
:return: list of `Location` objects
95142
"""
96-
if not city_name:
97-
return []
98-
if matching not in self.MATCHINGS:
99-
raise ValueError("Unknown type of matching: "
100-
"allowed values are %s" % ", ".join(self.MATCHINGS))
101-
if country is not None and len(country) != 2:
102-
raise ValueError("Country must be a 2-char string")
103-
splits = self._filter_matching_lines(city_name, country, matching)
104-
return [Location(item[0], float(item[3]), float(item[2]),
105-
int(item[1]), item[4]) for item in splits]
143+
items = self.ids_for(city_name, country=country, state=state, matching=matching)
144+
return [Location(item[1], item[5], item[4], item[0], country=item[2]) for item in items]
106145

107-
def geopoints_for(self, city_name, country=None, matching='nocase'):
146+
def geopoints_for(self, city_name, country=None, state=None, matching='like'):
108147
"""
109148
Returns a list of ``pyowm.utils.geo.Point`` objects corresponding to
110149
the int IDs and relative toponyms and 2-chars country of the cities
@@ -113,114 +152,18 @@ def geopoints_for(self, city_name, country=None, matching='nocase'):
113152
`matching` parameter value.
114153
If `country` is provided, the search is restricted to the cities of
115154
the specified country.
155+
:param city_name: the string toponym of the city to search
116156
:param country: two character str representing the country where to
117157
search for the city. Defaults to `None`, which means: search in all
118158
countries.
159+
:param state: two character str representing the state where to
160+
search for the city. Defaults to `None`. When not `None` also `state` must be specified
119161
:param matching: str. Default is `nocase`. Possible values:
120-
`exact` - literal, case-sensitive matching,
121-
`nocase` - literal, case-insensitive matching,
162+
`exact` - literal, case-sensitive matching
122163
`like` - matches cities whose name contains, as a substring, the string
123164
fed to the function, case-insensitive,
124-
`startswith` - matches cities whose names start with the string fed
125-
to the function, case-insensitive.
126165
:raises ValueError if the value for `matching` is unknown
127166
:return: list of `pyowm.utils.geo.Point` objects
128167
"""
129-
locations = self.locations_for(city_name, country, matching=matching)
130-
return [loc.to_geopoint() for loc in locations]
131-
132-
# helper functions
133-
134-
def _filter_matching_lines(self, city_name, country, matching):
135-
"""
136-
Returns an iterable whose items are the lists of split tokens of every
137-
text line matched against the city ID files according to the provided
138-
combination of city_name, country and matching style
139-
:param city_name: str
140-
:param country: str or `None`
141-
:param matching: str
142-
:return: list of lists
143-
"""
144-
result = []
145-
146-
# find the right file to scan and extract its lines. Upon "like"
147-
# matchings, just read all files
148-
if matching == 'like':
149-
lines = [l.strip() for l in self._get_all_lines()]
150-
else:
151-
filename = self._assess_subfile_from(city_name)
152-
lines = [l.strip() for l in self._get_lines(filename)]
153-
154-
# look for toponyms matching the specified city_name and according to
155-
# the specified matching style
156-
for line in lines:
157-
tokens = line.split(",")
158-
# sometimes city names have one or more inner commas
159-
if len(tokens) > 5:
160-
tokens = [','.join(tokens[:-4]), *tokens[-4:]]
161-
# check country
162-
if country is not None and tokens[4] != country:
163-
continue
164-
165-
# check city_name
166-
if self._city_name_matches(city_name, tokens[0], matching):
167-
result.append(tokens)
168-
169-
return result
170-
171-
def _city_name_matches(self, city_name, toponym, matching):
172-
comparison_function = self.MATCHINGS[matching]
173-
return comparison_function(city_name, toponym)
174-
175-
def _lookup_line_by_city_name(self, city_name):
176-
filename = self._assess_subfile_from(city_name)
177-
lines = self._get_lines(filename)
178-
return self._match_line(city_name, lines)
179-
180-
def _assess_subfile_from(self, city_name):
181-
c = ord(city_name.lower()[0])
182-
if c < 97: # not a letter
183-
raise ValueError('Error: city name must start with a letter')
184-
elif c in range(97, 103): # from a to f
185-
return self._filepath_regex % (97, 102)
186-
elif c in range(103, 109): # from g to l
187-
return self._filepath_regex % (103, 108)
188-
elif c in range(109, 115): # from m to r
189-
return self._filepath_regex % (109, 114)
190-
elif c in range(115, 123): # from s to z
191-
return self._filepath_regex % (115, 122)
192-
else:
193-
raise ValueError('Error: city name must start with a letter')
194-
195-
def _get_lines(self, filename):
196-
res_name = resource_filename(__name__, filename)
197-
with bz2.open(res_name, mode='rb') as fh:
198-
lines = fh.readlines()
199-
if type(lines[0]) is bytes:
200-
lines = map(lambda l: l.decode("utf-8"), lines)
201-
return lines
202-
203-
def _get_all_lines(self):
204-
all_lines = []
205-
for city_name in ['a', 'g', 'm', 's']: # all available city ID files
206-
filename = self._assess_subfile_from(city_name)
207-
all_lines.extend(self._get_lines(filename))
208-
return all_lines
209-
210-
def _match_line(self, city_name, lines):
211-
"""
212-
The lookup is case insensitive and returns the first matching line,
213-
stripped.
214-
:param city_name: str
215-
:param lines: list of str
216-
:return: str
217-
"""
218-
for line in lines:
219-
toponym = line.split(',')[0]
220-
if toponym.lower() == city_name.lower():
221-
return line.strip()
222-
return None
223-
224-
def __repr__(self):
225-
return "<%s.%s - filepath_regex=%s>" % (__name__, \
226-
self.__class__.__name__, self._filepath_regex)
168+
locations = self.locations_for(city_name, country=country, state=state, matching=matching)
169+
return [loc.to_geopoint() for loc in locations]

0 commit comments

Comments
 (0)