owid-datautils 0.6.2__tar.gz → 0.6.3__tar.gz
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-0.6.2 → owid_datautils-0.6.3}/.readthedocs.yaml +1 -1
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/PKG-INFO +12 -4
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/common.py +8 -9
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/dataframes.py +37 -38
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/decorators.py +8 -7
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/format/numbers.py +11 -11
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/google/api.py +6 -6
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/google/config.py +4 -7
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/google/sheets.py +11 -7
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/io/archive.py +8 -8
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/io/df.py +6 -10
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/io/json.py +6 -5
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/ui.py +1 -1
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/web.py +7 -7
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/pyproject.toml +32 -11
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/format/test_numbers.py +4 -4
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/google/test_config.py +4 -4
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/google/test_sheets.py +6 -6
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/io/test_archive.py +5 -6
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/io/test_df.py +2 -2
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/mocks.py +2 -2
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/test_dataframes.py +2 -2
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/test_decorators.py +1 -1
- owid_datautils-0.6.3/uv.lock +2872 -0
- owid_datautils-0.6.2/uv.lock +0 -2254
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/.bumpversion.cfg +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/.codecov.yml +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/.github/workflows/python-package.yml +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/.gitignore +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/.pre-commit-config.yaml +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/LICENSE +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/Makefile +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/README.md +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/Makefile +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/_static/favicon.ico +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/_static/owid.png +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/apidoc-templates/module.rst_t +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/apidoc-templates/package.rst_t +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/apidoc-templates/toc.rst_t +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/conf.py +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/index.rst +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/make.bat +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/__init__.py +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/checks.py +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/format/__init__.py +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/google/__init__.py +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/io/__init__.py +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/__init__.py +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/format/__init__.py +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/google/__init__.py +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/google/test_api.py +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/io/test_json.py +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/test_ui.py +0 -0
- {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/test_web.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: owid-datautils
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
4
4
|
Summary: Data utils library by the Data Team at Our World in Data
|
|
5
5
|
Author-email: Our World in Data <tech@ourworldindata.org>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -8,13 +8,21 @@ License-File: LICENSE
|
|
|
8
8
|
Keywords: data cleaning,data processing,data utils,our world in data
|
|
9
9
|
Requires-Python: <4.0,>=3.10
|
|
10
10
|
Requires-Dist: boto3>=1.38.23
|
|
11
|
+
Requires-Dist: brotli>=1.2.0
|
|
11
12
|
Requires-Dist: click>=8.1.7
|
|
12
13
|
Requires-Dist: colorama>=0.4.4
|
|
13
|
-
Requires-Dist:
|
|
14
|
+
Requires-Dist: cryptography>=46.0.3
|
|
15
|
+
Requires-Dist: gdown>=5.2.2
|
|
14
16
|
Requires-Dist: gsheets>=0.6.1
|
|
15
|
-
Requires-Dist:
|
|
17
|
+
Requires-Dist: jinja2>=3.1.6
|
|
18
|
+
Requires-Dist: pandas<3,>=2.2.3
|
|
19
|
+
Requires-Dist: protobuf>=6.33.4
|
|
16
20
|
Requires-Dist: py7zr>=0.22.0
|
|
17
21
|
Requires-Dist: pyarrow>=18.0.0
|
|
22
|
+
Requires-Dist: pyasn1>=0.6.3
|
|
18
23
|
Requires-Dist: pydrive2>=1.15.0
|
|
24
|
+
Requires-Dist: pyopenssl>=26.0.0
|
|
25
|
+
Requires-Dist: requests>=2.32.5
|
|
19
26
|
Requires-Dist: structlog>=21.5.0
|
|
20
|
-
Requires-Dist:
|
|
27
|
+
Requires-Dist: tornado>=6.5.5
|
|
28
|
+
Requires-Dist: urllib3<3
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
"""Common objects shared by other modules."""
|
|
2
2
|
|
|
3
3
|
import warnings
|
|
4
|
-
from typing import Any,
|
|
4
|
+
from typing import Any, cast
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class ExceptionFromDocstring(Exception):
|
|
8
8
|
"""Exception that returns its own docstring, if no message is explicitly given."""
|
|
9
9
|
|
|
10
|
-
def __init__(self, exception_message:
|
|
10
|
+
def __init__(self, exception_message: str | None = None, *args: Any):
|
|
11
11
|
super().__init__(exception_message or self.__doc__, *args)
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class ExceptionFromDocstringWithKwargs(Exception):
|
|
15
15
|
"""Exception that returns its own docstring, if no message is explicitly given."""
|
|
16
16
|
|
|
17
|
-
def __init__(self, exception_message:
|
|
17
|
+
def __init__(self, exception_message: str | None = None, *args: Any, **kwargs: Any):
|
|
18
18
|
text = cast(str, exception_message or self.__doc__)
|
|
19
19
|
if kwargs:
|
|
20
20
|
additional_text = ", ".join([f"{key}: {value}" for key, value in kwargs.items()])
|
|
@@ -23,9 +23,7 @@ class ExceptionFromDocstringWithKwargs(Exception):
|
|
|
23
23
|
super().__init__(text, *args)
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def warn_on_list_of_entities(
|
|
27
|
-
list_of_entities: Union[List[Any], Set[Any]], warning_message: str, show_list: bool
|
|
28
|
-
) -> None:
|
|
26
|
+
def warn_on_list_of_entities(list_of_entities: list[Any] | set[Any], warning_message: str, show_list: bool) -> None:
|
|
29
27
|
"""Raise a warning with a custom message, and optionally print a list of affected elements.
|
|
30
28
|
|
|
31
29
|
Args:
|
|
@@ -34,7 +32,8 @@ def warn_on_list_of_entities(
|
|
|
34
32
|
show_list: True to print a list of affected entities.
|
|
35
33
|
|
|
36
34
|
"""
|
|
37
|
-
warnings.warn(warning_message)
|
|
38
35
|
if show_list:
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
entity_list = "\n".join(["* " + str(entity) for entity in list_of_entities])
|
|
37
|
+
warnings.warn(f"{warning_message}\n{entity_list}")
|
|
38
|
+
else:
|
|
39
|
+
warnings.warn(warning_message)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Objects related to pandas dataframes."""
|
|
2
2
|
|
|
3
3
|
import warnings
|
|
4
|
-
from
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any, cast
|
|
5
6
|
|
|
6
7
|
import numpy as np
|
|
7
8
|
import pandas as pd
|
|
@@ -71,7 +72,7 @@ class ObjectsAreNotDataframes(ExceptionFromDocstring):
|
|
|
71
72
|
def compare(
|
|
72
73
|
df1: pd.DataFrame,
|
|
73
74
|
df2: pd.DataFrame,
|
|
74
|
-
columns:
|
|
75
|
+
columns: list[str] | None = None,
|
|
75
76
|
absolute_tolerance: float = 1e-8,
|
|
76
77
|
relative_tolerance: float = 1e-8,
|
|
77
78
|
) -> pd.DataFrame:
|
|
@@ -135,13 +136,13 @@ def compare(
|
|
|
135
136
|
else:
|
|
136
137
|
# For numeric data, consider them equal within certain absolute and relative tolerances.
|
|
137
138
|
compared_row = np.isclose(
|
|
138
|
-
df1[col].values, #
|
|
139
|
-
df2[col].values, #
|
|
139
|
+
df1[col].values, # ty: ignore
|
|
140
|
+
df2[col].values, # ty: ignore
|
|
140
141
|
atol=absolute_tolerance,
|
|
141
142
|
rtol=relative_tolerance,
|
|
142
143
|
)
|
|
143
144
|
# Treat nans as equal.
|
|
144
|
-
compared_row[pd.isnull(df1[col].values) & pd.isnull(df2[col].values)] = True #
|
|
145
|
+
compared_row[pd.isnull(df1[col].values) & pd.isnull(df2[col].values)] = True # ty: ignore
|
|
145
146
|
compared[col] = compared_row
|
|
146
147
|
|
|
147
148
|
return compared
|
|
@@ -153,7 +154,7 @@ def are_equal(
|
|
|
153
154
|
absolute_tolerance: float = 1e-8,
|
|
154
155
|
relative_tolerance: float = 1e-8,
|
|
155
156
|
verbose: bool = True,
|
|
156
|
-
) ->
|
|
157
|
+
) -> tuple[bool, pd.DataFrame]:
|
|
157
158
|
"""Check if two DataFrames are equal with detailed comparison report.
|
|
158
159
|
|
|
159
160
|
Comprehensive equality check that compares structure, dtypes, and values
|
|
@@ -230,12 +231,10 @@ def are_equal(
|
|
|
230
231
|
equal = False
|
|
231
232
|
for col in common_columns:
|
|
232
233
|
if df1[col].dtype != df2[col].dtype:
|
|
233
|
-
summary +=
|
|
234
|
-
f" * Column {col} is of type {df1[col].dtype} for df1, but type" f" {df2[col].dtype} for df2."
|
|
235
|
-
)
|
|
234
|
+
summary += f" * Column {col} is of type {df1[col].dtype} for df1, but type {df2[col].dtype} for df2."
|
|
236
235
|
equal = False
|
|
237
236
|
else:
|
|
238
|
-
summary += f"\n* Only {len(common_columns)} common columns out of
|
|
237
|
+
summary += f"\n* Only {len(common_columns)} common columns out of {len(all_columns)} distinct columns."
|
|
239
238
|
equal = False
|
|
240
239
|
|
|
241
240
|
if not can_be_compared:
|
|
@@ -245,7 +244,7 @@ def are_equal(
|
|
|
245
244
|
else:
|
|
246
245
|
# Check if indexes are equal.
|
|
247
246
|
if (df1.index != df2.index).any():
|
|
248
|
-
summary += "\n* Dataframes have different indexes (consider resetting indexes of
|
|
247
|
+
summary += "\n* Dataframes have different indexes (consider resetting indexes of input dataframes)."
|
|
249
248
|
equal = False
|
|
250
249
|
|
|
251
250
|
# Dataframes can be compared cell by cell (two nans on the same cell are considered equal).
|
|
@@ -256,9 +255,9 @@ def are_equal(
|
|
|
256
255
|
absolute_tolerance=absolute_tolerance,
|
|
257
256
|
relative_tolerance=relative_tolerance,
|
|
258
257
|
)
|
|
259
|
-
all_values_equal = compared.all().all() #
|
|
258
|
+
all_values_equal = compared.all().all() # ty: ignore
|
|
260
259
|
if not all_values_equal:
|
|
261
|
-
summary += "\n* Values differ by more than the given absolute and relative
|
|
260
|
+
summary += "\n* Values differ by more than the given absolute and relative tolerances."
|
|
262
261
|
|
|
263
262
|
# Dataframes are equal only if all previous checks have passed.
|
|
264
263
|
equal = equal & all_values_equal
|
|
@@ -273,7 +272,7 @@ def are_equal(
|
|
|
273
272
|
# Optionally print the summary of the comparison.
|
|
274
273
|
print(summary)
|
|
275
274
|
|
|
276
|
-
return equal, compared
|
|
275
|
+
return equal, compared # ty: ignore[invalid-return-type]
|
|
277
276
|
|
|
278
277
|
|
|
279
278
|
def _calculate_weighted_mean(
|
|
@@ -310,11 +309,11 @@ def _calculate_weighted_mean(
|
|
|
310
309
|
|
|
311
310
|
def groupby_agg(
|
|
312
311
|
df: pd.DataFrame,
|
|
313
|
-
groupby_columns:
|
|
314
|
-
aggregations:
|
|
315
|
-
num_allowed_nans:
|
|
316
|
-
frac_allowed_nans:
|
|
317
|
-
min_num_values:
|
|
312
|
+
groupby_columns: list[str] | str,
|
|
313
|
+
aggregations: dict[str, Any] | None = None,
|
|
314
|
+
num_allowed_nans: int | None = None,
|
|
315
|
+
frac_allowed_nans: float | None = None,
|
|
316
|
+
min_num_values: int | None = None,
|
|
318
317
|
) -> pd.DataFrame:
|
|
319
318
|
"""Group DataFrame with intelligent NaN handling during aggregation.
|
|
320
319
|
|
|
@@ -423,7 +422,7 @@ def groupby_agg(
|
|
|
423
422
|
|
|
424
423
|
# Handle regular aggregations first (if any)
|
|
425
424
|
if regular_aggregations:
|
|
426
|
-
grouped = df.groupby(groupby_columns, **groupby_kwargs).agg(regular_aggregations) #
|
|
425
|
+
grouped = df.groupby(groupby_columns, **groupby_kwargs).agg(regular_aggregations) # ty: ignore
|
|
427
426
|
else:
|
|
428
427
|
# Create empty DataFrame with proper groupby index for weighted-only case
|
|
429
428
|
grouped = (
|
|
@@ -445,10 +444,10 @@ def groupby_agg(
|
|
|
445
444
|
),
|
|
446
445
|
include_groups=False,
|
|
447
446
|
)
|
|
448
|
-
grouped[col] = weighted_results #
|
|
447
|
+
grouped[col] = weighted_results # ty: ignore
|
|
449
448
|
else:
|
|
450
449
|
# No weighted aggregations; use standard grouping logic
|
|
451
|
-
grouped = df.groupby(groupby_columns, **groupby_kwargs).agg(aggregations) #
|
|
450
|
+
grouped = df.groupby(groupby_columns, **groupby_kwargs).agg(aggregations) # ty: ignore
|
|
452
451
|
|
|
453
452
|
# Calculate a few necessary parameters related to the number of nans and valid elements.
|
|
454
453
|
if (num_allowed_nans is not None) or (frac_allowed_nans is not None) or (min_num_values is not None):
|
|
@@ -456,16 +455,16 @@ def groupby_agg(
|
|
|
456
455
|
num_nans_detected = count_missing_in_groups(df, groupby_columns, **groupby_kwargs)
|
|
457
456
|
if (frac_allowed_nans is not None) or (min_num_values is not None):
|
|
458
457
|
# Count number of total elements in each group (counting both nans and non-nan values).
|
|
459
|
-
num_elements = df.groupby(groupby_columns, **groupby_kwargs).size() #
|
|
458
|
+
num_elements = df.groupby(groupby_columns, **groupby_kwargs).size() # ty: ignore
|
|
460
459
|
|
|
461
460
|
# Apply conditions sequentially.
|
|
462
461
|
if num_allowed_nans is not None:
|
|
463
462
|
# Make nan any aggregation where there were too many missing values.
|
|
464
|
-
grouped = grouped[num_nans_detected <= num_allowed_nans] #
|
|
463
|
+
grouped = grouped[num_nans_detected <= num_allowed_nans] # ty: ignore
|
|
465
464
|
|
|
466
465
|
if frac_allowed_nans is not None:
|
|
467
466
|
# Make nan any aggregation where there were too many missing values.
|
|
468
|
-
grouped = grouped[num_nans_detected.divide(num_elements, axis="index") <= frac_allowed_nans] #
|
|
467
|
+
grouped = grouped[num_nans_detected.divide(num_elements, axis="index") <= frac_allowed_nans] # ty: ignore
|
|
469
468
|
|
|
470
469
|
if min_num_values is not None:
|
|
471
470
|
# Make nan any aggregation where there were too few valid (non-nan) values.
|
|
@@ -477,13 +476,13 @@ def groupby_agg(
|
|
|
477
476
|
# Therefore, we impose that either the number of valid values is >= min_num_values, or that there are no nans
|
|
478
477
|
# (and hence all values are valid).
|
|
479
478
|
grouped = grouped[
|
|
480
|
-
(-num_nans_detected.subtract(num_elements, axis="index") >= min_num_values) | (num_nans_detected == 0) #
|
|
479
|
+
(-num_nans_detected.subtract(num_elements, axis="index") >= min_num_values) | (num_nans_detected == 0) # ty: ignore
|
|
481
480
|
]
|
|
482
481
|
|
|
483
482
|
return cast(pd.DataFrame, grouped)
|
|
484
483
|
|
|
485
484
|
|
|
486
|
-
def count_missing_in_groups(df: pd.DataFrame, groupby_columns:
|
|
485
|
+
def count_missing_in_groups(df: pd.DataFrame, groupby_columns: list[str], **kwargs: Any) -> pd.DataFrame:
|
|
487
486
|
"""Count the number of missing values in each group.
|
|
488
487
|
|
|
489
488
|
This is equivalent but faster than:
|
|
@@ -502,7 +501,7 @@ def count_missing_in_groups(df: pd.DataFrame, groupby_columns: List[str], **kwar
|
|
|
502
501
|
return cast(pd.DataFrame, num_nans_detected)
|
|
503
502
|
|
|
504
503
|
|
|
505
|
-
def multi_merge(dfs:
|
|
504
|
+
def multi_merge(dfs: list[pd.DataFrame], on: list[str] | str, how: str = "inner") -> pd.DataFrame:
|
|
506
505
|
"""Merge multiple DataFrames on common columns.
|
|
507
506
|
|
|
508
507
|
Convenience function for merging more than two DataFrames sequentially.
|
|
@@ -535,14 +534,14 @@ def multi_merge(dfs: List[pd.DataFrame], on: Union[List[str], str], how: str = "
|
|
|
535
534
|
"""
|
|
536
535
|
merged = dfs[0].copy()
|
|
537
536
|
for df in dfs[1:]:
|
|
538
|
-
merged = pd.merge(merged, df, how=how, on=on) #
|
|
537
|
+
merged = pd.merge(merged, df, how=how, on=on) # ty: ignore
|
|
539
538
|
|
|
540
539
|
return merged
|
|
541
540
|
|
|
542
541
|
|
|
543
542
|
def map_series(
|
|
544
543
|
series: pd.Series,
|
|
545
|
-
mapping:
|
|
544
|
+
mapping: dict[Any, Any],
|
|
546
545
|
make_unmapped_values_nan: bool = False,
|
|
547
546
|
warn_on_missing_mappings: bool = False,
|
|
548
547
|
warn_on_unused_mappings: bool = False,
|
|
@@ -644,7 +643,7 @@ def map_series(
|
|
|
644
643
|
missing = series_mapped.isnull() & (~series.isin(values_mapped_to_nan))
|
|
645
644
|
if missing.any():
|
|
646
645
|
# Replace those nans by their original values.
|
|
647
|
-
series_mapped.loc[missing] = series[missing] #
|
|
646
|
+
series_mapped.loc[missing] = series[missing] # ty: ignore[call-non-callable]
|
|
648
647
|
|
|
649
648
|
if warn_on_missing_mappings:
|
|
650
649
|
unmapped = set(series) - set(mapping)
|
|
@@ -667,7 +666,7 @@ def map_series(
|
|
|
667
666
|
return series_mapped
|
|
668
667
|
|
|
669
668
|
|
|
670
|
-
def rename_categories(series: pd.Series, mapping:
|
|
669
|
+
def rename_categories(series: pd.Series, mapping: dict[Any, Any]) -> pd.Series:
|
|
671
670
|
"""Alternative to pd.Series.cat.rename_categories which supports non-unique categories.
|
|
672
671
|
|
|
673
672
|
We do that by replacing non-unique categories first and then mapping with unique categories.
|
|
@@ -679,7 +678,7 @@ def rename_categories(series: pd.Series, mapping: Dict[Any, Any]) -> pd.Series:
|
|
|
679
678
|
|
|
680
679
|
series = series.copy()
|
|
681
680
|
|
|
682
|
-
new_mapping:
|
|
681
|
+
new_mapping: dict[Any, Any] = {}
|
|
683
682
|
for map_from, map_to in mapping.items():
|
|
684
683
|
# Map nulls right away
|
|
685
684
|
if pd.isnull(map_to):
|
|
@@ -699,7 +698,7 @@ def rename_categories(series: pd.Series, mapping: Dict[Any, Any]) -> pd.Series:
|
|
|
699
698
|
)
|
|
700
699
|
|
|
701
700
|
|
|
702
|
-
def concatenate(objs:
|
|
701
|
+
def concatenate(objs: list[pd.DataFrame], **kwargs: Any) -> pd.DataFrame:
|
|
703
702
|
"""Concatenate while preserving categorical columns.
|
|
704
703
|
|
|
705
704
|
Original source code from https://stackoverflow.com/a/57809778/1275818.
|
|
@@ -719,7 +718,7 @@ def concatenate(objs: List[pd.DataFrame], **kwargs: Any) -> pd.DataFrame:
|
|
|
719
718
|
return pd.concat(objs, **kwargs)
|
|
720
719
|
|
|
721
720
|
|
|
722
|
-
def apply_on_categoricals(cat_series:
|
|
721
|
+
def apply_on_categoricals(cat_series: list[pd.Series], func: Callable[..., str]) -> pd.Series:
|
|
723
722
|
"""Apply a function across multiple categorical Series efficiently.
|
|
724
723
|
|
|
725
724
|
High-performance operation that applies a function to categorical Series
|
|
@@ -770,13 +769,13 @@ def apply_on_categoricals(cat_series: List[pd.Series], func: Callable[..., str])
|
|
|
770
769
|
# use existing category
|
|
771
770
|
codes.append(seen[cat_codes])
|
|
772
771
|
|
|
773
|
-
return cast(pd.Series, pd.Categorical.from_codes(codes, categories=categories))
|
|
772
|
+
return cast(pd.Series, pd.Categorical.from_codes(codes, categories=pd.Index(categories)))
|
|
774
773
|
|
|
775
774
|
|
|
776
775
|
def combine_two_overlapping_dataframes(
|
|
777
776
|
df1: pd.DataFrame,
|
|
778
777
|
df2: pd.DataFrame,
|
|
779
|
-
index_columns:
|
|
778
|
+
index_columns: list[str] | None = None,
|
|
780
779
|
keep_column_order: bool = False,
|
|
781
780
|
) -> pd.DataFrame:
|
|
782
781
|
"""Combine two DataFrames with overlapping columns, prioritizing the first.
|
|
@@ -835,7 +834,7 @@ def combine_two_overlapping_dataframes(
|
|
|
835
834
|
if index_columns is not None:
|
|
836
835
|
# Ensure dataframes have a dummy index.
|
|
837
836
|
if not ((df1.index.names == [None]) and (df2.index.names == [None])):
|
|
838
|
-
warnings.warn("If index_columns is given, dataframes should have a dummy index. Use
|
|
837
|
+
warnings.warn("If index_columns is given, dataframes should have a dummy index. Use reset_index().")
|
|
839
838
|
# Set index columns.
|
|
840
839
|
df1 = df1.set_index(index_columns)
|
|
841
840
|
df2 = df2.set_index(index_columns)
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import functools
|
|
4
4
|
import tempfile
|
|
5
|
-
from
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
6
7
|
|
|
7
8
|
from owid.datautils.web import download_file_from_url
|
|
8
9
|
|
|
9
10
|
|
|
10
|
-
def enable_file_download(path_arg_name:
|
|
11
|
+
def enable_file_download(path_arg_name: str | None = None) -> Callable[[Any], Any]:
|
|
11
12
|
"""Decorator that allows functions expecting local file paths to accept URLs and S3 paths.
|
|
12
13
|
|
|
13
14
|
This decorator automatically downloads remote files to temporary storage before calling
|
|
@@ -55,11 +56,11 @@ def enable_file_download(path_arg_name: Optional[str] = None) -> Callable[[Any],
|
|
|
55
56
|
# Get path to file
|
|
56
57
|
_used_args = False
|
|
57
58
|
if args:
|
|
58
|
-
args = list(args) #
|
|
59
|
+
args = list(args) # ty: ignore
|
|
59
60
|
path = args[0]
|
|
60
61
|
_used_args = True
|
|
61
62
|
else:
|
|
62
|
-
path = kwargs.get(path_arg_name) #
|
|
63
|
+
path = kwargs.get(path_arg_name) # ty: ignore
|
|
63
64
|
if path is None:
|
|
64
65
|
raise ValueError(f"Filename was not found in args or kwargs ({path_arg_name}!")
|
|
65
66
|
# Check if download is needed and download
|
|
@@ -73,7 +74,7 @@ def enable_file_download(path_arg_name: Optional[str] = None) -> Callable[[Any],
|
|
|
73
74
|
elif path.startswith(prefixes["s3"]):
|
|
74
75
|
try:
|
|
75
76
|
# Import here to avoid circular dependency
|
|
76
|
-
from owid.catalog import s3_utils #
|
|
77
|
+
from owid.catalog import s3_utils # ty: ignore
|
|
77
78
|
except ImportError as e:
|
|
78
79
|
raise ImportError("owid-catalog is required for S3 downloads. ") from e
|
|
79
80
|
|
|
@@ -81,9 +82,9 @@ def enable_file_download(path_arg_name: Optional[str] = None) -> Callable[[Any],
|
|
|
81
82
|
|
|
82
83
|
# Modify args/kwargs
|
|
83
84
|
if _used_args:
|
|
84
|
-
args[0] = temp_file.name #
|
|
85
|
+
args[0] = temp_file.name # ty: ignore
|
|
85
86
|
else:
|
|
86
|
-
kwargs[path_arg_name] = temp_file.name #
|
|
87
|
+
kwargs[path_arg_name] = temp_file.name # ty: ignore
|
|
87
88
|
# Call function
|
|
88
89
|
return func(*args, **kwargs)
|
|
89
90
|
else: # Run function on local file
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
"""Numeric formatting."""
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class IntegerNumber:
|
|
8
8
|
"""Wrapper around integer numbers."""
|
|
9
9
|
|
|
10
|
-
def __init__(self, number:
|
|
10
|
+
def __init__(self, number: str | int) -> None:
|
|
11
11
|
self.number_raw = number
|
|
12
12
|
self.number = self.init_clean(number)
|
|
13
13
|
|
|
14
14
|
@classmethod
|
|
15
|
-
def init_clean(cls, number:
|
|
15
|
+
def init_clean(cls, number: int | str) -> str:
|
|
16
16
|
"""First number cleaning steps.
|
|
17
17
|
|
|
18
18
|
Args:
|
|
@@ -103,7 +103,7 @@ class IntegerNumberWithWords:
|
|
|
103
103
|
Use with caution!
|
|
104
104
|
"""
|
|
105
105
|
|
|
106
|
-
numeric_words:
|
|
106
|
+
numeric_words: dict[str, dict[str, Any]] = {
|
|
107
107
|
"million": {
|
|
108
108
|
"words": [
|
|
109
109
|
"million",
|
|
@@ -175,22 +175,22 @@ class IntegerNumberWithWords:
|
|
|
175
175
|
return r"\s?".join(regex)
|
|
176
176
|
|
|
177
177
|
@classmethod
|
|
178
|
-
def numeric_words_list(cls) ->
|
|
178
|
+
def numeric_words_list(cls) -> set[str]:
|
|
179
179
|
"""Return list of all numeric words (flattened)."""
|
|
180
180
|
words = set(word for value in cls.numeric_words.values() for word in value["words"] if word != "")
|
|
181
|
-
return words #
|
|
181
|
+
return words # ty: ignore
|
|
182
182
|
|
|
183
|
-
def _match_numeric_words(self) ->
|
|
183
|
+
def _match_numeric_words(self) -> dict[str, str | int]:
|
|
184
184
|
"""Match number with words."""
|
|
185
185
|
match = re.search(self.regex_number_verbose(), self.number)
|
|
186
186
|
if match:
|
|
187
187
|
numbers = match.groupdict(default=0)
|
|
188
|
-
return numbers #
|
|
188
|
+
return numbers # ty: ignore
|
|
189
189
|
else:
|
|
190
190
|
raise ValueError("Number may not contain numeric words. Please review!")
|
|
191
191
|
|
|
192
192
|
@classmethod
|
|
193
|
-
def _build_IntegerNumber(cls, numbers:
|
|
193
|
+
def _build_IntegerNumber(cls, numbers: dict[str, str | int]) -> int:
|
|
194
194
|
"""Build number from dictionary."""
|
|
195
195
|
value = 0
|
|
196
196
|
for k, v in numbers.items():
|
|
@@ -223,7 +223,7 @@ class IntegerNumberWithWords:
|
|
|
223
223
|
raise ValueError(f"Given number {self.number_raw} is not valid!")
|
|
224
224
|
|
|
225
225
|
|
|
226
|
-
def num_to_str(num_as_str:
|
|
226
|
+
def num_to_str(num_as_str: int | str) -> str:
|
|
227
227
|
"""Convert number to string.
|
|
228
228
|
|
|
229
229
|
Args:
|
|
@@ -251,7 +251,7 @@ def remove_multiple_whitespaces(text: str) -> str:
|
|
|
251
251
|
return re.sub(r"\s+", " ", text)
|
|
252
252
|
|
|
253
253
|
|
|
254
|
-
def format_number(number:
|
|
254
|
+
def format_number(number: int | str) -> int:
|
|
255
255
|
"""Format number string to integer.
|
|
256
256
|
|
|
257
257
|
Only supports integer conversion. Handles various formats including
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Google API class."""
|
|
2
2
|
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any
|
|
4
4
|
|
|
5
5
|
import gdown
|
|
6
6
|
from pydrive2.auth import GoogleAuth
|
|
@@ -20,7 +20,7 @@ from owid.datautils.google.sheets import GSheetsApi
|
|
|
20
20
|
class GoogleApi:
|
|
21
21
|
"""API for Google Drive."""
|
|
22
22
|
|
|
23
|
-
def __init__(self, clients_secrets_file:
|
|
23
|
+
def __init__(self, clients_secrets_file: str | None = None) -> None:
|
|
24
24
|
"""Initialise Google API.
|
|
25
25
|
|
|
26
26
|
To obtain `client_secrets_file`, follow the instructions from:
|
|
@@ -74,8 +74,8 @@ class GoogleApi:
|
|
|
74
74
|
def download_file(
|
|
75
75
|
cls,
|
|
76
76
|
output: str,
|
|
77
|
-
url:
|
|
78
|
-
file_id:
|
|
77
|
+
url: str | None = None,
|
|
78
|
+
file_id: str | None = None,
|
|
79
79
|
quiet: bool = True,
|
|
80
80
|
**kwargs: Any,
|
|
81
81
|
) -> None:
|
|
@@ -93,7 +93,7 @@ class GoogleApi:
|
|
|
93
93
|
ValueError: If neither `url` nor `id` are provided.
|
|
94
94
|
"""
|
|
95
95
|
if url:
|
|
96
|
-
gdown.download(url=url, output=output,
|
|
96
|
+
gdown.download(url=url, output=output, quiet=quiet, **kwargs)
|
|
97
97
|
elif file_id:
|
|
98
98
|
gdown.download(id=file_id, output=output, quiet=quiet, **kwargs)
|
|
99
99
|
else:
|
|
@@ -119,4 +119,4 @@ class GoogleApi:
|
|
|
119
119
|
request = f"'{parent_id}' in parents and trashed=false"
|
|
120
120
|
# Get list of files
|
|
121
121
|
files = self.drive.ListFile({"q": request}).GetList()
|
|
122
|
-
return files #
|
|
122
|
+
return files # ty: ignore[invalid-return-type]
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import os
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from shutil import copyfile
|
|
6
|
-
from typing import Union
|
|
7
6
|
|
|
8
7
|
import yaml
|
|
9
8
|
from pydrive2.auth import GoogleAuth
|
|
@@ -15,7 +14,7 @@ SETTINGS_PATH = os.path.join(CONFIG_DIR, "google_settings.yaml")
|
|
|
15
14
|
CREDENTIALS_PATH = os.path.join(CONFIG_DIR, "google_credentials.json")
|
|
16
15
|
|
|
17
16
|
|
|
18
|
-
def google_config_init(client_secrets_file:
|
|
17
|
+
def google_config_init(client_secrets_file: str | Path, encoding: str = "utf8") -> GoogleAuth:
|
|
19
18
|
"""Initialise Google configuration files.
|
|
20
19
|
|
|
21
20
|
Uses `clients_secrets` to generate all the required Google configuration files: `SETTINGS_PATH`, `CREDENTIALS_PATH`.
|
|
@@ -45,7 +44,7 @@ def google_config_init(client_secrets_file: Union[str, Path], encoding: str = "u
|
|
|
45
44
|
"""
|
|
46
45
|
# Check client_secrets
|
|
47
46
|
if not os.path.isfile(client_secrets_file):
|
|
48
|
-
raise ValueError(f"Credentials not found at {client_secrets_file}. Please provide a valid
|
|
47
|
+
raise ValueError(f"Credentials not found at {client_secrets_file}. Please provide a valid path!")
|
|
49
48
|
# Check or create config directory
|
|
50
49
|
if not os.path.isdir(CONFIG_DIR):
|
|
51
50
|
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
@@ -87,12 +86,10 @@ def _check_google_config() -> None:
|
|
|
87
86
|
- owid.datautils.google.config.CREDENTIALS_PATH
|
|
88
87
|
"""
|
|
89
88
|
if not os.path.isdir(CONFIG_DIR):
|
|
90
|
-
raise FileNotFoundError(
|
|
91
|
-
f"{CONFIG_DIR} folder is not created. Please check you have run" " `google_config_init`!"
|
|
92
|
-
)
|
|
89
|
+
raise FileNotFoundError(f"{CONFIG_DIR} folder is not created. Please check you have run `google_config_init`!")
|
|
93
90
|
for f in [CLIENT_SECRETS_PATH, CREDENTIALS_PATH, SETTINGS_PATH]:
|
|
94
91
|
if not os.path.isfile(f):
|
|
95
|
-
raise FileNotFoundError(f"{f} file was not found. Please check you have run
|
|
92
|
+
raise FileNotFoundError(f"{f} file was not found. Please check you have run `google_config_init`!")
|
|
96
93
|
|
|
97
94
|
|
|
98
95
|
def is_google_config_init() -> bool:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Google Sheet utils."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any
|
|
5
5
|
|
|
6
6
|
import pandas as pd
|
|
7
7
|
from gsheets import Sheets
|
|
@@ -35,7 +35,7 @@ class GSheetsApi:
|
|
|
35
35
|
if not os.path.isdir(credentials_folder):
|
|
36
36
|
os.makedirs(credentials_folder, exist_ok=True)
|
|
37
37
|
|
|
38
|
-
def get(self, spreadsheet_id: str, worksheet_id:
|
|
38
|
+
def get(self, spreadsheet_id: str, worksheet_id: int | None = None) -> SpreadSheet | WorkSheet:
|
|
39
39
|
"""Get a spreadsheet or worksheet from a Google sheet.
|
|
40
40
|
|
|
41
41
|
If only `spreadsheet_id` is provided, this will return the entire spreadsheet. Otherwise,
|
|
@@ -50,14 +50,14 @@ class GSheetsApi:
|
|
|
50
50
|
"""
|
|
51
51
|
ssheet = self.sheets.get(spreadsheet_id)
|
|
52
52
|
if worksheet_id:
|
|
53
|
-
return ssheet.get(worksheet_id) #
|
|
54
|
-
return ssheet #
|
|
53
|
+
return ssheet.get(worksheet_id) # ty: ignore[invalid-return-type]
|
|
54
|
+
return ssheet # ty: ignore[invalid-return-type]
|
|
55
55
|
|
|
56
56
|
def download_worksheet(
|
|
57
57
|
self,
|
|
58
58
|
spreadsheet_id: str,
|
|
59
59
|
worksheet_id: int,
|
|
60
|
-
output_path:
|
|
60
|
+
output_path: str | None = None,
|
|
61
61
|
encoding: str = "utf-8",
|
|
62
62
|
**kwargs: Any,
|
|
63
63
|
) -> None:
|
|
@@ -74,7 +74,11 @@ class GSheetsApi:
|
|
|
74
74
|
"""
|
|
75
75
|
sheet = self.get(spreadsheet_id, worksheet_id)
|
|
76
76
|
if output_path:
|
|
77
|
-
sheet.to_csv(
|
|
77
|
+
sheet.to_csv(
|
|
78
|
+
output_path,
|
|
79
|
+
encoding=encoding,
|
|
80
|
+
**kwargs, # ty: ignore[call-non-callable]
|
|
81
|
+
)
|
|
78
82
|
else:
|
|
79
83
|
make_filename = "%(title)s.csv"
|
|
80
84
|
sheet.to_csv(make_filename=make_filename, encoding=encoding, **kwargs)
|
|
@@ -109,5 +113,5 @@ class GSheetsApi:
|
|
|
109
113
|
Dataframe with the data from the worksheet.
|
|
110
114
|
"""
|
|
111
115
|
ws = self.get(spreadsheet_id, worksheet_id)
|
|
112
|
-
df: pd.DataFrame = ws.to_frame() #
|
|
116
|
+
df: pd.DataFrame = ws.to_frame() # ty: ignore[unresolved-attribute]
|
|
113
117
|
return df
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import tarfile
|
|
4
4
|
import zipfile
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import
|
|
6
|
+
from typing import cast
|
|
7
7
|
|
|
8
8
|
from py7zr import SevenZipFile
|
|
9
9
|
|
|
@@ -12,8 +12,8 @@ from owid.datautils.decorators import enable_file_download
|
|
|
12
12
|
|
|
13
13
|
@enable_file_download(path_arg_name="input_file")
|
|
14
14
|
def decompress_file(
|
|
15
|
-
input_file:
|
|
16
|
-
output_folder:
|
|
15
|
+
input_file: str | Path,
|
|
16
|
+
output_folder: str | Path,
|
|
17
17
|
overwrite: bool = False,
|
|
18
18
|
) -> None:
|
|
19
19
|
"""Extract a zip or tar file.
|
|
@@ -41,8 +41,8 @@ def decompress_file(
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def _decompress_zip_file(
|
|
44
|
-
input_file:
|
|
45
|
-
output_folder:
|
|
44
|
+
input_file: str | Path,
|
|
45
|
+
output_folder: str | Path,
|
|
46
46
|
overwrite: bool = False,
|
|
47
47
|
) -> None:
|
|
48
48
|
"""Unpack zip file."""
|
|
@@ -61,8 +61,8 @@ def _decompress_zip_file(
|
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def _decompress_tar_file(
|
|
64
|
-
input_file:
|
|
65
|
-
output_folder:
|
|
64
|
+
input_file: str | Path,
|
|
65
|
+
output_folder: str | Path,
|
|
66
66
|
overwrite: bool = False,
|
|
67
67
|
) -> None:
|
|
68
68
|
"""Unpack tar file."""
|
|
@@ -72,7 +72,7 @@ def _decompress_tar_file(
|
|
|
72
72
|
# Path to new file to be created.
|
|
73
73
|
new_file = Path(output_folder) / Path(tar_file.getnames()[0])
|
|
74
74
|
if new_file.exists() and not overwrite:
|
|
75
|
-
raise FileExistsError("Output already exists. Either change output_folder or use
|
|
75
|
+
raise FileExistsError("Output already exists. Either change output_folder or use overwrite=True.")
|
|
76
76
|
|
|
77
77
|
# Unzip the file and save it in the local output folder.
|
|
78
78
|
# Note that, if output_folder path does not exist, the following command will create it.
|