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.
- owid/datautils/__init__.py +3 -0
- owid/datautils/checks.py +1 -0
- owid/datautils/common.py +44 -0
- owid/datautils/dataframes.py +739 -0
- owid/datautils/decorators.py +91 -0
- owid/datautils/format/__init__.py +7 -0
- owid/datautils/format/numbers.py +304 -0
- owid/datautils/google/__init__.py +7 -0
- owid/datautils/google/api.py +136 -0
- owid/datautils/google/config.py +117 -0
- owid/datautils/google/sheets.py +134 -0
- owid/datautils/io/__init__.py +14 -0
- owid/datautils/io/archive.py +84 -0
- owid/datautils/io/df.py +163 -0
- owid/datautils/io/json.py +78 -0
- owid/datautils/ui.py +32 -0
- owid/datautils/web.py +106 -0
- owid_datautils-0.6.1.dist-info/METADATA +20 -0
- owid_datautils-0.6.1.dist-info/RECORD +21 -0
- owid_datautils-0.6.1.dist-info/WHEEL +4 -0
- owid_datautils-0.6.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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,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
|