owid-datautils 0.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ """Library decorators."""
2
+
3
+ import functools
4
+ import tempfile
5
+ from typing import Any, Callable, Optional
6
+
7
+ from owid.datautils.web import download_file_from_url
8
+
9
+
10
+ def enable_file_download(path_arg_name: Optional[str] = None) -> Callable[[Any], Any]:
11
+ """Decorator that allows functions expecting local file paths to accept URLs and S3 paths.
12
+
13
+ This decorator automatically downloads remote files to temporary storage before calling
14
+ the decorated function, making any file-processing function work transparently with:
15
+ - Local file paths (unchanged behavior)
16
+ - HTTP/HTTPS URLs (downloaded via web request)
17
+ - S3 paths (downloaded via S3 client)
18
+
19
+ Args:
20
+ path_arg_name: Name of the parameter containing the file path. If None,
21
+ uses the first positional argument.
22
+
23
+ Example:
24
+ @enable_file_download(path_arg_name="file_path")
25
+ def load_data(file_path):
26
+ with open(file_path, 'r') as f:
27
+ return f.read()
28
+
29
+ # Now works with all of these:
30
+ load_data("/local/file.txt") # Local file
31
+ load_data("https://example.com/data.txt") # HTTP download
32
+ load_data("s3://bucket/data.txt") # S3 download
33
+
34
+ Warning:
35
+ Downloads entire files to temporary storage on every call. For large files
36
+ or frequent access, consider explicit caching or streaming approaches.
37
+ """
38
+ # Download options, add them as needed (value: str, key: Tuple[str])
39
+ prefixes = {
40
+ "url": (
41
+ "http://",
42
+ "https://",
43
+ ),
44
+ "s3": ("s3://",),
45
+ }
46
+ # Get list of prefixes as a flat tuple
47
+ prefixes_flat = tuple(prefix for prefixes_list in prefixes.values() for prefix in prefixes_list)
48
+
49
+ def _enable_file_download(func: Callable[[Any], Any]) -> Callable[[Any], Any]:
50
+ @functools.wraps(func)
51
+ def wrapper_download(*args: Any, **kwargs: Any) -> Any:
52
+ # Get path to file
53
+ _used_args = False
54
+ if args:
55
+ args = list(args) # type: ignore
56
+ path = args[0]
57
+ _used_args = True
58
+ else:
59
+ path = kwargs.get(path_arg_name) # type: ignore
60
+ if path is None:
61
+ raise ValueError(f"Filename was not found in args or kwargs ({path_arg_name}!")
62
+ # Check if download is needed and download
63
+ path = str(path)
64
+ if path.startswith(prefixes_flat): # Download from URL and run function
65
+ with tempfile.NamedTemporaryFile() as temp_file:
66
+ # Download file from URL
67
+ if path.startswith(prefixes["url"]):
68
+ download_file_from_url(path, temp_file.name) # TODO: Add custom args here
69
+ # Download file from S3 (need credentials)
70
+ elif path.startswith(prefixes["s3"]):
71
+ try:
72
+ # Import here to avoid circular dependency
73
+ from owid.catalog import s3_utils # type: ignore
74
+ except ImportError as e:
75
+ raise ImportError("owid-catalog is required for S3 downloads. ") from e
76
+
77
+ s3_utils.download(path, temp_file.name, quiet=True) # TODO: Add custom args here
78
+
79
+ # Modify args/kwargs
80
+ if _used_args:
81
+ args[0] = temp_file.name # type: ignore
82
+ else:
83
+ kwargs[path_arg_name] = temp_file.name # type: ignore
84
+ # Call function
85
+ return func(*args, **kwargs)
86
+ else: # Run function on local file
87
+ return func(*args, **kwargs)
88
+
89
+ return wrapper_download
90
+
91
+ return _enable_file_download
@@ -0,0 +1,7 @@
1
+ """Utils for the processing of different data formats."""
2
+
3
+ from owid.datautils.format.numbers import format_number
4
+
5
+ __all__ = [
6
+ "format_number",
7
+ ]
@@ -0,0 +1,304 @@
1
+ """Numeric formatting."""
2
+
3
+ import re
4
+ from typing import Any, Dict, Set, Union
5
+
6
+
7
+ class IntegerNumber:
8
+ """Wrapper around integer numbers."""
9
+
10
+ def __init__(self, number: Union[str, int]) -> None:
11
+ self.number_raw = number
12
+ self.number = self.init_clean(number)
13
+
14
+ @classmethod
15
+ def init_clean(cls, number: Union[int, str]) -> str:
16
+ """First number cleaning steps.
17
+
18
+ Parameters
19
+ ----------
20
+ number : Union[int, str]
21
+ Raw number as given.
22
+
23
+ Returns
24
+ -------
25
+ int
26
+ Number as a string, wihtout repeated whitespaces.
27
+ """
28
+ # Force string
29
+ number = num_to_str(number)
30
+ # Remove multiple spaces
31
+ number = remove_multiple_whitespaces(number)
32
+ return number
33
+
34
+ def clean(self) -> int:
35
+ """Clean number."""
36
+ if self.number.isnumeric(): # already a number
37
+ return int(self.number)
38
+ elif IntegerNumberWithWords.is_valid(self.number): # contains words
39
+ return IntegerNumberWithWords(self.number).clean()
40
+ elif IntegerNumberWithSeparators.is_valid(self.number): # has zero-separator
41
+ return IntegerNumberWithSeparators(self.number).clean()
42
+ else: # error
43
+ raise ValueError(f"Invalid number format {self.number}")
44
+
45
+
46
+ class IntegerNumberWithSeparators:
47
+ """Class for numbers with separators.
48
+
49
+ Accepted separators are: '.', ',' and ' '.
50
+ """
51
+
52
+ accepted_separators = [r"\.", ",", r"\s"]
53
+
54
+ def __init__(self, number_raw: str) -> None:
55
+ self.number_raw = number_raw
56
+
57
+ @classmethod
58
+ def regex_number_with_separator(cls) -> str:
59
+ """Regex expression for number with separators."""
60
+ accepted_separators_str = "|".join(rf"(({sep}\d{{3}})+)" for sep in cls.accepted_separators)
61
+ regex_number_with_separator: str = rf"\d{{1,3}}({accepted_separators_str})"
62
+ return regex_number_with_separator
63
+
64
+ @classmethod
65
+ def is_valid(cls, number: str) -> bool:
66
+ """Check if given number has valid separators.
67
+
68
+ Parameters
69
+ ----------
70
+ number : str
71
+ Raw number.
72
+
73
+ Returns
74
+ -------
75
+ bool:
76
+ True if valid syntax.
77
+ """
78
+ return bool(re.fullmatch(cls.regex_number_with_separator(), number))
79
+
80
+ def clean(self) -> int:
81
+ """Clean number.
82
+
83
+ Returns
84
+ -------
85
+ int
86
+ Cleaned number (i.e. without separators).
87
+
88
+ Raises
89
+ ------
90
+ ValueError:
91
+ If provided number was not correct (e.g. does not contain a separator).
92
+ """
93
+ if self.is_valid(self.number_raw):
94
+ n = re.sub(r"[^0-9]", "", str(self.number_raw))
95
+ return int(n)
96
+ raise ValueError(f"Given number {self.number_raw} is not valid!")
97
+
98
+
99
+ class IntegerNumberWithWords:
100
+ """Class for numbers with words.
101
+
102
+ Words suported can be found in class attribute `numeric_words`.
103
+
104
+ Examples of accepted formats:
105
+
106
+ - 1 million ten thousand
107
+ - 20 thousand 1 hondred
108
+
109
+ Examples of not supported formats:
110
+
111
+ - 1 hundred thousand
112
+ - one hundred
113
+ - 1,22 thousand
114
+
115
+ Use with caution!
116
+ """
117
+
118
+ numeric_words: Dict[str, Dict[str, Any]] = {
119
+ "million": {
120
+ "words": [
121
+ "million",
122
+ "millió",
123
+ "millón",
124
+ "millones",
125
+ "millions",
126
+ "millionen",
127
+ "milioni",
128
+ "milione",
129
+ "miljoen",
130
+ "milhão",
131
+ "milhões",
132
+ ],
133
+ "factor": 1e6,
134
+ },
135
+ "ten_thousand": {
136
+ "words": ["万"],
137
+ "factor": 1e4,
138
+ },
139
+ "thousand": {
140
+ "words": [
141
+ "thousand",
142
+ "ezren",
143
+ "mil",
144
+ "duizend",
145
+ "mila",
146
+ "mille",
147
+ "tausend",
148
+ ],
149
+ "factor": 1e3,
150
+ },
151
+ "hundred": {
152
+ "words": [
153
+ "hundred",
154
+ "cien",
155
+ "cientos",
156
+ "cent",
157
+ "hundert",
158
+ "honderd",
159
+ "cem",
160
+ "cento",
161
+ ],
162
+ "factor": 1e2,
163
+ },
164
+ "one": {"words": [""], "factor": 1},
165
+ }
166
+ regex_number_verbose_template: str = r"(?:(?P<{}>[1-9]\d{{,2}})\s?(?:{}))?"
167
+
168
+ def __init__(self, number_raw: str) -> None:
169
+ self.number_raw = number_raw
170
+ self.number = self.init_clean(number_raw)
171
+
172
+ @classmethod
173
+ def init_clean(cls, number_raw: str) -> str:
174
+ """Clean raw number."""
175
+ return number_raw.replace(" and ", " ")
176
+
177
+ @classmethod
178
+ def regex_number_verbose(cls) -> str:
179
+ """Build regex for a number with words.
180
+
181
+ Returns
182
+ -------
183
+ str
184
+ Regex.
185
+ """
186
+ regex = [
187
+ cls.regex_number_verbose_template.format(k, "|".join(v["words"])) for k, v in cls.numeric_words.items()
188
+ ]
189
+ return r"\s?".join(regex)
190
+
191
+ @classmethod
192
+ def numeric_words_list(cls) -> Set[str]:
193
+ """Return list of all numeric words (flattened)."""
194
+ words = set(word for value in cls.numeric_words.values() for word in value["words"] if word != "")
195
+ return words
196
+
197
+ def _match_numeric_words(self) -> Dict[str, Union[str, int]]:
198
+ """Match number with words."""
199
+ match = re.search(self.regex_number_verbose(), self.number)
200
+ if match:
201
+ numbers = match.groupdict(default=0)
202
+ return numbers
203
+ else:
204
+ raise ValueError("Number may not contain numeric words. Please review!")
205
+
206
+ @classmethod
207
+ def _build_IntegerNumber(cls, numbers: Dict[str, Union[str, int]]) -> int:
208
+ """Build number from dictionary."""
209
+ value = 0
210
+ for k, v in numbers.items():
211
+ value += float(v) * cls.numeric_words[k]["factor"]
212
+ return int(value)
213
+
214
+ @classmethod
215
+ def is_valid(cls, number: str) -> bool:
216
+ """Check if number contains numeric words.
217
+
218
+ Parameters
219
+ ----------
220
+ number : str
221
+ Candidate number.
222
+
223
+ Returns
224
+ -------
225
+ bool
226
+ True if number contains numeric valid words.
227
+ """
228
+ # return any(word in number for word in cls.numeric_words_list())
229
+ return bool(re.fullmatch(cls.regex_number_verbose(), number))
230
+
231
+ def clean(self) -> int:
232
+ """Clean number.
233
+
234
+ Returns:
235
+ --------
236
+ int:
237
+ Cleaned number.
238
+ """
239
+ if self.is_valid(self.number_raw):
240
+ number_dix = self._match_numeric_words()
241
+ number = self._build_IntegerNumber(number_dix)
242
+ return number
243
+ raise ValueError(f"Given number {self.number_raw} is not valid!")
244
+
245
+
246
+ def num_to_str(num_as_str: Union[int, str]) -> str:
247
+ """Convert number to string.
248
+
249
+ Parameters
250
+ ----------
251
+ num_as_str: Union[int, str]
252
+ Raw number.
253
+
254
+ Returns:
255
+ --------
256
+ str:
257
+ Raw number as string.
258
+ """
259
+ if not isinstance(num_as_str, str):
260
+ return str(num_as_str)
261
+ return num_as_str
262
+
263
+
264
+ def remove_multiple_whitespaces(text: str) -> str:
265
+ """Remove excess of whitespaces.
266
+
267
+ ' ' -> ' '.
268
+
269
+ Parameters
270
+ ----------
271
+ text : str
272
+ Raw text.
273
+
274
+ Returns
275
+ -------
276
+ str
277
+ String without duplicated whitespaces.
278
+ """
279
+ return re.sub(r"\s+", " ", text)
280
+
281
+
282
+ def format_number(number: Union[int, str]) -> int:
283
+ """Format number.
284
+
285
+ Only supports integer conversion.
286
+
287
+ Some examples of transformations are:
288
+
289
+ - '1 000 000' -> 1000000
290
+ - '1 million 1 hundred' -> 1000100
291
+ - '2,000' -> 2000
292
+
293
+ Parameters
294
+ ----------
295
+ number : Union[int, str]
296
+ Input raw number to be formatted.
297
+
298
+ Returns
299
+ -------
300
+ int
301
+ Formatted number.
302
+ """
303
+ number_ = IntegerNumber(number)
304
+ return number_.clean()
@@ -0,0 +1,7 @@
1
+ """Google utils."""
2
+
3
+ from owid.datautils.google.api import GoogleApi
4
+
5
+ __all__ = [
6
+ "GoogleApi",
7
+ ]
@@ -0,0 +1,136 @@
1
+ """Google API class."""
2
+
3
+ from typing import Any, Optional
4
+
5
+ import gdown
6
+ from pydrive2.auth import GoogleAuth
7
+ from pydrive2.drive import GoogleDrive
8
+ from pydrive2.files import GoogleDriveFileList
9
+
10
+ from owid.datautils.google.config import (
11
+ CLIENT_SECRETS_PATH,
12
+ CREDENTIALS_PATH,
13
+ SETTINGS_PATH,
14
+ google_config_init,
15
+ is_google_config_init,
16
+ )
17
+ from owid.datautils.google.sheets import GSheetsApi
18
+
19
+
20
+ class GoogleApi:
21
+ """API for Google Drive."""
22
+
23
+ def __init__(self, clients_secrets_file: Optional[str] = None) -> None:
24
+ """Initialise Google API.
25
+
26
+ To obtain `client_secrets_file`, follow the instructions from:
27
+ https://medium.com/analytics-vidhya/how-to-connect-google-drive-to-python-using-pydrive-9681b2a14f20
28
+
29
+ IMPORTANT:
30
+ - Additionally, make sure to add yourself in Test users, as noted in:
31
+ https://stackoverflow.com/questions/65980758/pydrive-quickstart-and-error-403-access-denied
32
+ - Select Desktop App instead of Web Application as the application type.
33
+
34
+ Parameters
35
+ ----------
36
+ clients_secrets_file : str, optional
37
+ Path to client_secrets file.
38
+
39
+ Examples
40
+ --------
41
+ First time calling the function should look similar to:
42
+
43
+ >>> from owid.datautils.google.api import GoogleApi
44
+ >>> api = GoogleApi("path/to/credentials.json")
45
+
46
+ New calls can then be made as follows:
47
+
48
+ >>> api = GoogleApi()
49
+ """
50
+ if not is_google_config_init():
51
+ if not clients_secrets_file:
52
+ # No clients_secrets, can't initialize Google configuration!
53
+ raise ValueError("No value for `clients_secrets_file` was provided!")
54
+ else:
55
+ google_config_init(clients_secrets_file)
56
+
57
+ self.sheets = GSheetsApi(CLIENT_SECRETS_PATH, CREDENTIALS_PATH)
58
+
59
+ @classmethod
60
+ def download_folder(cls, url: str, output: str, quiet: bool = True, **kwargs: Any) -> None:
61
+ """Download a folder from Google Drive.
62
+
63
+ The folderm must be public, otherwise this function won't work.
64
+
65
+ Parameters
66
+ ----------
67
+ url : str
68
+ URL to the folder on Google Drive (must be public).
69
+ output : str
70
+ Local path to save the downloaded folder.
71
+ quiet: bool, optional
72
+ Suppress terminal output. Default is False.
73
+ """
74
+ gdown.download_folder(url, output=output, quiet=quiet, use_cookies=False, **kwargs)
75
+
76
+ @classmethod
77
+ def download_file(
78
+ cls,
79
+ output: str,
80
+ url: Optional[str] = None,
81
+ file_id: Optional[str] = None,
82
+ quiet: bool = True,
83
+ **kwargs: Any,
84
+ ) -> None:
85
+ """Download a file from Google Drive.
86
+
87
+ The file must be public, otherwise this function won't work.
88
+
89
+ Parameters
90
+ ----------
91
+ output : str
92
+ Local path to save the downloaded file.
93
+ url : str, optional
94
+ URL to the file on Google Drive (it must be public), by default None
95
+ file_id : str, optional
96
+ ID of the file on Google Drive (the file must be public), by default None.
97
+ quiet: bool, optional
98
+ Suppress terminal output. Default is False.
99
+
100
+ Raises
101
+ ------
102
+ ValueError
103
+ If neither `url` nor `id` are provided.
104
+ """
105
+ if url:
106
+ gdown.download(url=url, output=output, fuzzy=True, quiet=quiet, **kwargs)
107
+ elif file_id:
108
+ gdown.download(id=file_id, output=output, quiet=quiet, **kwargs)
109
+ else:
110
+ raise ValueError("You must provide a `url` or `file_id`")
111
+
112
+ @property
113
+ def drive(self) -> GoogleDrive:
114
+ """Google Drive object."""
115
+ gauth = GoogleAuth(settings_file=SETTINGS_PATH)
116
+ # gauth.LocalWebserverAuth()
117
+ drive = GoogleDrive(gauth)
118
+ return drive
119
+
120
+ def list_files(self, parent_id: str) -> GoogleDriveFileList:
121
+ """List files in a Google Drive folder.
122
+
123
+ Parameters
124
+ ----------
125
+ parent_id : str
126
+ Google Drive folder ID.
127
+
128
+ Returns
129
+ -------
130
+ List
131
+ Files
132
+ """
133
+ request = f"'{parent_id}' in parents and trashed=false"
134
+ # Get list of files
135
+ files = self.drive.ListFile({"q": request}).GetList()
136
+ return files # type: ignore[reportReturnType]
@@ -0,0 +1,117 @@
1
+ """Google configuration functions."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from shutil import copyfile
6
+ from typing import Union
7
+
8
+ import yaml
9
+ from pydrive2.auth import GoogleAuth
10
+
11
+ # PATHS
12
+ CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "owid")
13
+ CLIENT_SECRETS_PATH = os.path.join(CONFIG_DIR, "google_client_secrets.json")
14
+ SETTINGS_PATH = os.path.join(CONFIG_DIR, "google_settings.yaml")
15
+ CREDENTIALS_PATH = os.path.join(CONFIG_DIR, "google_credentials.json")
16
+
17
+
18
+ def google_config_init(client_secrets_file: Union[str, Path], encoding: str = "utf8") -> GoogleAuth:
19
+ """Initialise Google configuration files.
20
+
21
+ Uses `clients_secrets` to generate all the required Google configuration files: `SETTINGS_PATH`, `CREDENTIALS_PATH`.
22
+ Also, it creates a copy of `clients_secrets` in `CLIENT_SECRETS_PATH`, so the original file can be safely deleted
23
+ after running this method.
24
+
25
+ This should only run once before reading a file from Google Drive the first time. Subsequent executions should run
26
+ seamlessly.
27
+
28
+ To obtain `client_secrets_file`, follow the instructions from:
29
+ https://medium.com/analytics-vidhya/how-to-connect-google-drive-to-python-using-pydrive-9681b2a14f20
30
+
31
+ IMPORTANT:
32
+ - Additionally, make sure to add yourself in Test users, as noted in:
33
+ https://stackoverflow.com/questions/65980758/pydrive-quickstart-and-error-403-access-denied
34
+ - Select Desktop App instead of Web Application as the application type.
35
+
36
+ Method partly sourced from:
37
+ https://github.com/lucasrodes/whatstk/blob/bcb9cf7c256df1c9e270aab810b74ab0f7329436/whatstk/utils/gdrive.py#L38
38
+
39
+ Parameters
40
+ ----------
41
+ client_secrets_file : str
42
+ Path to client_secrets file.
43
+ encoding : str, optional
44
+ Encoding of the text in `client_secrets` file, by default "utf8"
45
+
46
+ Returns
47
+ -------
48
+ GoogleAuth
49
+ Google authenticator object.
50
+ """
51
+ # Check client_secrets
52
+ if not os.path.isfile(client_secrets_file):
53
+ raise ValueError(f"Credentials not found at {client_secrets_file}. Please provide a valid" " path!")
54
+ # Check or create config directory
55
+ if not os.path.isdir(CONFIG_DIR):
56
+ os.makedirs(CONFIG_DIR, exist_ok=True)
57
+
58
+ # Copy credentials to config folder
59
+ copyfile(client_secrets_file, CLIENT_SECRETS_PATH)
60
+
61
+ # Create settings.yaml file
62
+ dix = {
63
+ "client_config_backend": "file",
64
+ "client_config_file": CLIENT_SECRETS_PATH,
65
+ "save_credentials": True,
66
+ "save_credentials_backend": "file",
67
+ "save_credentials_file": CREDENTIALS_PATH,
68
+ "get_refresh_token": True,
69
+ "oauth_scope": [
70
+ "https://www.googleapis.com/auth/drive",
71
+ "https://www.googleapis.com/auth/drive.install",
72
+ ],
73
+ }
74
+ with open(SETTINGS_PATH, "w", encoding=encoding) as f:
75
+ yaml.dump(dix, f)
76
+
77
+ # credentials.json
78
+ gauth = GoogleAuth(settings_file=SETTINGS_PATH)
79
+ gauth.CommandLineAuth()
80
+ return gauth
81
+
82
+
83
+ def _check_google_config() -> None:
84
+ """Run checks agains Google configuration files.
85
+
86
+ Raises
87
+ ------
88
+ FileNotFoundError
89
+ If owid.datautils.google.config.CONFIG_DIR directory does not exist.
90
+ FileNotFoundError
91
+ If any of the Google configuration files is missing. These include:
92
+ - owid.datautils.google.config.CLIENT_SECRETS_PATH
93
+ - owid.datautils.google.config.SETTINGS_PATH
94
+ - owid.datautils.google.config.CREDENTIALS_PATH
95
+ """
96
+ if not os.path.isdir(CONFIG_DIR):
97
+ raise FileNotFoundError(
98
+ f"{CONFIG_DIR} folder is not created. Please check you have run" " `google_config_init`!"
99
+ )
100
+ for f in [CLIENT_SECRETS_PATH, CREDENTIALS_PATH, SETTINGS_PATH]:
101
+ if not os.path.isfile(f):
102
+ raise FileNotFoundError(f"{f} file was not found. Please check you have run" " `google_config_init`!")
103
+
104
+
105
+ def is_google_config_init() -> bool:
106
+ """Check if Google configuration files were created.
107
+
108
+ Returns
109
+ -------
110
+ bool
111
+ True if Google configuration files were created, False otherwise.
112
+ """
113
+ try:
114
+ _check_google_config()
115
+ except FileNotFoundError:
116
+ return False
117
+ return True