containerio 2.2.0__tar.gz → 2.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: containerio
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Azure Blob Storage handler with unified local/cloud interface
5
5
  Author: Alliance SwissPass
6
6
  Requires-Dist: polars>=1.30.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "containerio"
3
- version = "2.2.0"
3
+ version = "2.3.0"
4
4
  description = "Azure Blob Storage handler with unified local/cloud interface"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -377,7 +377,24 @@ class StorageHandler:
377
377
  )
378
378
  }
379
379
 
380
- def _read_blob_as_bytes(self, blob_name: str) -> bytes:
380
+ def read_blob_as_bytes(self, blob_name: str) -> bytes:
381
+ """Read a blob's raw bytes from local or Azure storage.
382
+
383
+ Args:
384
+ blob_name (str): The path/name of the blob to read.
385
+
386
+ Returns:
387
+ bytes: The blob's raw contents.
388
+
389
+ Raises:
390
+ FileNotFoundError: If the blob does not exist.
391
+ """
392
+ if self.local:
393
+ try:
394
+ return (self.base_path / blob_name).read_bytes()
395
+ except FileNotFoundError as error:
396
+ raise FileNotFoundError(f"Blob not found: {blob_name}") from error
397
+
381
398
  try:
382
399
  return self._get_blob_client(blob_name=blob_name).download_blob().readall()
383
400
  except ResourceNotFoundError as error:
@@ -686,12 +703,8 @@ class StorageHandler:
686
703
  pl.DataFrame: The content of the CSV file as a Polars
687
704
  DataFrame.
688
705
  """
689
- if self.local:
690
- file_path = self.base_path / blob_name
691
- return pl.read_csv(source=file_path, **kwargs)
692
- else:
693
- data = self._read_blob_as_bytes(blob_name)
694
- return pl.read_csv(source=BytesIO(data), **kwargs)
706
+ data = self.read_blob_as_bytes(blob_name=blob_name)
707
+ return pl.read_csv(source=BytesIO(data), **kwargs)
695
708
 
696
709
  def scan_csv_blob(self, blob_name: str, **kwargs: Dict) -> pl.LazyFrame:
697
710
  """Scans a CSV file or multiple files from storage.
@@ -731,12 +744,8 @@ class StorageHandler:
731
744
  pl.DataFrame: The content of the Excel file as a Polars
732
745
  DataFrame.
733
746
  """
734
- if self.local:
735
- file_path = self.base_path / blob_name
736
- df = pl.read_excel(source=file_path, **kwargs)
737
- else:
738
- data = self._read_blob_as_bytes(blob_name)
739
- df = pl.read_excel(source=BytesIO(data), **kwargs)
747
+ data = self.read_blob_as_bytes(blob_name=blob_name)
748
+ df = pl.read_excel(source=BytesIO(data), **kwargs)
740
749
 
741
750
  if not isinstance(df, pl.DataFrame):
742
751
  raise TypeError(
@@ -757,16 +766,10 @@ class StorageHandler:
757
766
  Tuple[pl.DataFrame, Dict]: A tuple containing the DataFrame
758
767
  and the Parquet file's metadata.
759
768
  """
760
- if self.local:
761
- file_path = self.base_path / blob_name
762
- df = pl.read_parquet(source=file_path)
763
- metadata = pl.read_parquet_metadata(source=file_path)
764
- return df, metadata
765
- else:
766
- data = self._read_blob_as_bytes(blob_name)
767
- df = pl.read_parquet(source=BytesIO(data))
768
- metadata = pl.read_parquet_metadata(source=BytesIO(data))
769
- return df, metadata
769
+ data = self.read_blob_as_bytes(blob_name=blob_name)
770
+ df = pl.read_parquet(source=BytesIO(data))
771
+ metadata = pl.read_parquet_metadata(source=BytesIO(data))
772
+ return df, metadata
770
773
 
771
774
  def scan_parquet_blob(
772
775
  self, blob_name: str, **kwargs: Dict
@@ -845,15 +848,43 @@ class StorageHandler:
845
848
 
846
849
  return download_path
847
850
 
848
- def _write_blob_as_bytes(self, blob_name: str, data: bytes) -> Dict[str, Any]:
851
+ def write_blob_as_bytes(
852
+ self, blob_name: str, data: bytes
853
+ ) -> Optional[Dict[str, Any]]:
854
+ """Write raw bytes to a blob in local or Azure storage.
855
+
856
+ Existing blobs are overwritten. For local storage, parent
857
+ directories are created as needed.
858
+
859
+ Args:
860
+ blob_name (str): The path/name of the blob to write.
861
+ data (bytes): The raw bytes to write.
862
+
863
+ Returns:
864
+ Optional[Dict[str, Any]]: The Azure upload response, or
865
+ `None` for local storage.
866
+
867
+ Raises:
868
+ ValueError: If `blob_name` is empty or not a string.
869
+ TypeError: If `data` is not bytes.
870
+ """
849
871
  if not isinstance(blob_name, str) or not blob_name:
850
872
  raise ValueError("Blob name must be str and not empty")
851
873
  if not isinstance(data, bytes):
852
874
  raise TypeError("Data must be bytes")
875
+
876
+ if self.local:
877
+ file_path = self.base_path / blob_name
878
+ file_path.parent.mkdir(parents=True, exist_ok=True)
879
+ file_path.write_bytes(data)
880
+ return None
881
+
853
882
  # Overwrite if the blob exists
854
883
  try:
855
884
  blob_client = self._get_blob_client(blob_name=blob_name)
856
- return blob_client.upload_blob(data, blob_type="BlockBlob", overwrite=True)
885
+ return blob_client.upload_blob(
886
+ data=data, blob_type="BlockBlob", overwrite=True
887
+ )
857
888
  except Exception as e:
858
889
  raise RuntimeError(f"Failed to write blob '{blob_name}': {e}") from e
859
890
 
@@ -893,17 +924,9 @@ class StorageHandler:
893
924
  Returns:
894
925
  None
895
926
  """
896
- if self.local:
897
- file_path = self.base_path / blob_name
898
- file_path.parent.mkdir(parents=True, exist_ok=True)
899
- df.write_csv(file=file_path, **kwargs)
900
- else:
901
- # Write the DataFrame to an in-memory stream
902
- buffer = BytesIO()
903
- df.write_csv(file=buffer, **kwargs)
904
- buffer.seek(0)
905
- # Upload the bytes to Azure Blob Storage
906
- self._write_blob_as_bytes(blob_name, buffer.getvalue())
927
+ buffer = BytesIO()
928
+ df.write_csv(file=buffer, **kwargs)
929
+ self.write_blob_as_bytes(blob_name=blob_name, data=buffer.getvalue())
907
930
 
908
931
  def write_excel_blob(
909
932
  self, blob_name: str, df: pl.DataFrame, **kwargs: Dict
@@ -919,17 +942,9 @@ class StorageHandler:
919
942
  Returns:
920
943
  None
921
944
  """
922
- if self.local:
923
- file_path = self.base_path / blob_name
924
- file_path.parent.mkdir(parents=True, exist_ok=True)
925
- df.write_excel(workbook=file_path, **kwargs)
926
- else:
927
- # Write the DataFrame to an in-memory stream
928
- buffer = BytesIO()
929
- df.write_excel(workbook=buffer, **kwargs)
930
- buffer.seek(0)
931
- # Upload the bytes to Azure Blob Storage
932
- self._write_blob_as_bytes(blob_name, buffer.getvalue())
945
+ buffer = BytesIO()
946
+ df.write_excel(workbook=buffer, **kwargs)
947
+ self.write_blob_as_bytes(blob_name=blob_name, data=buffer.getvalue())
933
948
 
934
949
  def write_parquet_blob(
935
950
  self, blob_name: str, df: pl.DataFrame, **kwargs: Dict
@@ -945,17 +960,9 @@ class StorageHandler:
945
960
  Returns:
946
961
  None
947
962
  """
948
- if self.local:
949
- file_path = self.base_path / blob_name
950
- file_path.parent.mkdir(parents=True, exist_ok=True)
951
- df.write_parquet(file=file_path, **kwargs)
952
- else:
953
- # Write the DataFrame to an in-memory stream
954
- buffer = BytesIO()
955
- df.write_parquet(file=buffer, **kwargs)
956
- buffer.seek(0)
957
- # Upload the bytes to Azure Blob Storage
958
- self._write_blob_as_bytes(blob_name, buffer.getvalue())
963
+ buffer = BytesIO()
964
+ df.write_parquet(file=buffer, **kwargs)
965
+ self.write_blob_as_bytes(blob_name=blob_name, data=buffer.getvalue())
959
966
 
960
967
  def sink_parquet_blob(
961
968
  self, blob_name: str, lf: pl.LazyFrame, **kwargs: Dict
@@ -1049,18 +1056,22 @@ class StorageHandler:
1049
1056
  prefix = blob_name.rstrip("/") + "/"
1050
1057
  children = list(self.container_client.list_blobs(name_starts_with=prefix))
1051
1058
 
1052
- # Try exact blob match
1059
+ # Delete children deepest-first. On hierarchical-namespace
1060
+ # (ADLS Gen2) accounts a directory placeholder cannot be
1061
+ # removed until empty, and a parent path always sorts before
1062
+ # its descendants, so reverse order empties each placeholder
1063
+ # before deleting it. Flat namespaces are order-independent.
1064
+ for blob in sorted(children, key=lambda child: child.name, reverse=True):
1065
+ self._get_blob_client(blob_name=blob.name).delete_blob()
1066
+ deleted.append(blob.name)
1067
+
1068
+ # Delete the exact blob (file or now-empty dir) last.
1053
1069
  try:
1054
1070
  self._get_blob_client(blob_name=blob_name).delete_blob()
1055
1071
  deleted.append(blob_name)
1056
1072
  except ResourceNotFoundError:
1057
1073
  pass
1058
1074
 
1059
- # Delete all blobs under the prefix
1060
- for blob in children:
1061
- self._get_blob_client(blob_name=blob.name).delete_blob()
1062
- deleted.append(blob.name)
1063
-
1064
1075
  if not deleted:
1065
1076
  raise FileNotFoundError(f"Blob not found: {blob_name}")
1066
1077
 
File without changes