11#!/usr/bin/env python
22# -*- coding: utf-8 -*-
33
4+ import os
45import 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
613from ..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
1218class 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