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.
Files changed (54) hide show
  1. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/.readthedocs.yaml +1 -1
  2. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/PKG-INFO +12 -4
  3. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/common.py +8 -9
  4. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/dataframes.py +37 -38
  5. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/decorators.py +8 -7
  6. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/format/numbers.py +11 -11
  7. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/google/api.py +6 -6
  8. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/google/config.py +4 -7
  9. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/google/sheets.py +11 -7
  10. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/io/archive.py +8 -8
  11. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/io/df.py +6 -10
  12. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/io/json.py +6 -5
  13. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/ui.py +1 -1
  14. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/web.py +7 -7
  15. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/pyproject.toml +32 -11
  16. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/format/test_numbers.py +4 -4
  17. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/google/test_config.py +4 -4
  18. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/google/test_sheets.py +6 -6
  19. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/io/test_archive.py +5 -6
  20. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/io/test_df.py +2 -2
  21. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/mocks.py +2 -2
  22. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/test_dataframes.py +2 -2
  23. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/test_decorators.py +1 -1
  24. owid_datautils-0.6.3/uv.lock +2872 -0
  25. owid_datautils-0.6.2/uv.lock +0 -2254
  26. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/.bumpversion.cfg +0 -0
  27. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/.codecov.yml +0 -0
  28. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/.github/workflows/python-package.yml +0 -0
  29. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/.gitignore +0 -0
  30. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/.pre-commit-config.yaml +0 -0
  31. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/LICENSE +0 -0
  32. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/Makefile +0 -0
  33. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/README.md +0 -0
  34. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/Makefile +0 -0
  35. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/_static/favicon.ico +0 -0
  36. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/_static/owid.png +0 -0
  37. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/apidoc-templates/module.rst_t +0 -0
  38. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/apidoc-templates/package.rst_t +0 -0
  39. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/apidoc-templates/toc.rst_t +0 -0
  40. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/conf.py +0 -0
  41. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/index.rst +0 -0
  42. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/docs/make.bat +0 -0
  43. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/__init__.py +0 -0
  44. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/checks.py +0 -0
  45. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/format/__init__.py +0 -0
  46. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/google/__init__.py +0 -0
  47. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/owid/datautils/io/__init__.py +0 -0
  48. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/__init__.py +0 -0
  49. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/format/__init__.py +0 -0
  50. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/google/__init__.py +0 -0
  51. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/google/test_api.py +0 -0
  52. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/io/test_json.py +0 -0
  53. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/test_ui.py +0 -0
  54. {owid_datautils-0.6.2 → owid_datautils-0.6.3}/tests/test_web.py +0 -0
@@ -6,7 +6,7 @@ version: 2
6
6
 
7
7
  # Set the version of Python and other tools you might need
8
8
  build:
9
- os: ubuntu-20.04
9
+ os: ubuntu-24.04
10
10
  tools: {python: "3.8"}
11
11
  jobs:
12
12
  pre_create_environment:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owid-datautils
3
- Version: 0.6.2
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: gdown>=4.5.2
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: pandas>=2.2.3
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: urllib3<2
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, List, Set, Union, cast
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: Union[str, None] = None, *args: Any):
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: Union[str, None] = None, *args: Any, **kwargs: Any):
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
- print(warning_message)
40
- print("\n".join(["* " + str(entity) for entity in list_of_entities]))
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 typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast
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: Optional[List[str]] = None,
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, # type: ignore
139
- df2[col].values, # type: ignore
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 # type: ignore
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
- ) -> Tuple[bool, pd.DataFrame]:
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" f" {len(all_columns)} distinct columns."
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" " input dataframes)."
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() # type: ignore
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" " tolerances."
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: Union[List[str], str],
314
- aggregations: Optional[Dict[str, Any]] = None,
315
- num_allowed_nans: Optional[int] = None,
316
- frac_allowed_nans: Optional[float] = None,
317
- min_num_values: Optional[int] = None,
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) # type: ignore
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 # type: ignore
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) # type: ignore
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() # type: ignore
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] # type: ignore
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] # type: ignore
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) # type: ignore
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: List[str], **kwargs: Any) -> pd.DataFrame:
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: List[pd.DataFrame], on: Union[List[str], str], how: str = "inner") -> pd.DataFrame:
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) # type: ignore
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: Dict[Any, Any],
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] # type: ignore[reportCallIssue]
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: Dict[Any, Any]) -> pd.Series:
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: Dict[Any, Any] = {}
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: List[pd.DataFrame], **kwargs: Any) -> pd.DataFrame:
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: List[pd.Series], func: Callable[..., str]) -> pd.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: Optional[List[str]] = None,
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" " reset_index().")
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 typing import Any, Callable, Optional
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: Optional[str] = None) -> Callable[[Any], Any]:
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) # type: ignore
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) # type: ignore
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 # type: ignore
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 # type: ignore
85
+ args[0] = temp_file.name # ty: ignore
85
86
  else:
86
- kwargs[path_arg_name] = temp_file.name # type: ignore
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, Dict, Set, Union
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: Union[str, int]) -> None:
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: Union[int, str]) -> str:
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: Dict[str, Dict[str, Any]] = {
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) -> Set[str]:
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 # type: ignore
181
+ return words # ty: ignore
182
182
 
183
- def _match_numeric_words(self) -> Dict[str, Union[str, int]]:
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 # type: ignore
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: Dict[str, Union[str, int]]) -> int:
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: Union[int, str]) -> 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: Union[int, str]) -> int:
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, Optional
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: Optional[str] = None) -> None:
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: Optional[str] = None,
78
- file_id: Optional[str] = None,
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, fuzzy=True, quiet=quiet, **kwargs)
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 # type: ignore[reportReturnType]
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: Union[str, Path], encoding: str = "utf8") -> GoogleAuth:
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" " path!")
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" " `google_config_init`!")
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, Optional, Union
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: Optional[int] = None) -> Union[SpreadSheet, WorkSheet]:
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) # type: ignore[reportReturnType]
54
- return ssheet # type: ignore[reportReturnType]
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: Optional[str] = None,
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(output_path, encoding=encoding, **kwargs) # type: ignore[reportCallIssue]
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() # type: ignore[reportAttributeAccessIssue]
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 Union, cast
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: Union[str, Path],
16
- output_folder: Union[str, Path],
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: Union[str, Path],
45
- output_folder: Union[str, Path],
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: Union[str, Path],
65
- output_folder: Union[str, Path],
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" " overwrite=True.")
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.