@@ -27,30 +27,38 @@ use arrow::util::display::{ArrayFormatter, FormatOptions};
2727use datafusion:: arrow:: datatypes:: Schema ;
2828use datafusion:: arrow:: pyarrow:: { PyArrowType , ToPyArrow } ;
2929use datafusion:: arrow:: util:: pretty;
30- use datafusion:: common:: UnnestOptions ;
31- use datafusion:: config:: { CsvOptions , TableParquetOptions } ;
30+ use datafusion:: common:: stats:: Precision ;
31+ use datafusion:: common:: { DFSchema , DataFusionError , Statistics , UnnestOptions } ;
32+ use datafusion:: common:: tree_node:: { Transformed , TreeNode } ;
33+ use datafusion:: config:: { ConfigOptions , CsvOptions , TableParquetOptions } ;
3234use datafusion:: dataframe:: { DataFrame , DataFrameWriteOptions } ;
3335use datafusion:: datasource:: TableProvider ;
36+ use datafusion:: execution:: runtime_env:: RuntimeEnvBuilder ;
37+ use datafusion:: datasource:: physical_plan:: ParquetExec ;
3438use datafusion:: execution:: SendableRecordBatchStream ;
3539use datafusion:: parquet:: basic:: { BrotliLevel , Compression , GzipLevel , ZstdLevel } ;
40+ use datafusion:: physical_plan:: { ExecutionPlan , ExecutionPlanProperties } ;
3641use datafusion:: prelude:: * ;
42+
43+ use datafusion_proto:: physical_plan:: { AsExecutionPlan , PhysicalExtensionCodec } ;
44+ use datafusion_proto:: protobuf:: PhysicalPlanNode ;
45+ use deltalake:: delta_datafusion:: DeltaPhysicalCodec ;
46+ use prost:: Message ;
3747use pyo3:: exceptions:: PyValueError ;
3848use pyo3:: prelude:: * ;
3949use pyo3:: pybacked:: PyBackedStr ;
4050use pyo3:: types:: { PyCapsule , PyTuple , PyTupleMethods } ;
4151use tokio:: task:: JoinHandle ;
4252
4353use crate :: catalog:: PyTable ;
54+ use crate :: common:: df_schema:: PyDFSchema ;
4455use crate :: errors:: { py_datafusion_err, PyDataFusionError } ;
4556use crate :: expr:: sort_expr:: to_sort_expressions;
4657use crate :: physical_plan:: PyExecutionPlan ;
4758use crate :: record_batch:: PyRecordBatchStream ;
4859use crate :: sql:: logical:: PyLogicalPlan ;
4960use crate :: utils:: { get_tokio_runtime, validate_pycapsule, wait_for_future} ;
50- use crate :: {
51- errors:: PyDataFusionResult ,
52- expr:: { sort_expr:: PySortExpr , PyExpr } ,
53- } ;
61+ use crate :: { errors:: PyDataFusionResult , expr:: { sort_expr:: PySortExpr , PyExpr } } ;
5462
5563// https://github.com/apache/datafusion-python/pull/1016#discussion_r1983239116
5664// - we have not decided on the table_provider approach yet
@@ -697,6 +705,131 @@ impl PyDataFrame {
697705 fn count ( & self , py : Python ) -> PyDataFusionResult < usize > {
698706 Ok ( wait_for_future ( py, self . df . as_ref ( ) . clone ( ) . count ( ) ) ?)
699707 }
708+
709+ fn distributed_plan ( & self , py : Python < ' _ > ) -> PyResult < DistributedPlan > {
710+ let future_plan = self . df . as_ref ( ) . clone ( ) . create_physical_plan ( ) ;
711+ wait_for_future ( py, future_plan)
712+ . map ( DistributedPlan :: new)
713+ . map_err ( py_datafusion_err)
714+ }
715+
716+ }
717+
718+ #[ pyclass( get_all) ]
719+ #[ derive( Debug , Clone ) ]
720+ pub struct DistributedPlan {
721+ physical_plan : PyExecutionPlan ,
722+ }
723+
724+ #[ pymethods]
725+ impl DistributedPlan {
726+
727+ fn serialize ( & self ) -> PyResult < Vec < u8 > > {
728+ PhysicalPlanNode :: try_from_physical_plan ( self . plan ( ) . clone ( ) , codec ( ) )
729+ . map ( |node| node. encode_to_vec ( ) )
730+ . map_err ( py_datafusion_err)
731+ }
732+
733+ fn partition_count ( & self ) -> usize {
734+ self . plan ( ) . output_partitioning ( ) . partition_count ( )
735+ }
736+
737+ fn num_bytes ( & self ) -> Option < usize > {
738+ self . stats_field ( |stats| stats. total_byte_size )
739+ }
740+
741+ fn num_rows ( & self ) -> Option < usize > {
742+ self . stats_field ( |stats| stats. num_rows )
743+ }
744+
745+ fn schema ( & self ) -> PyResult < PyDFSchema > {
746+ DFSchema :: try_from ( self . plan ( ) . schema ( ) )
747+ . map ( PyDFSchema :: from)
748+ . map_err ( py_datafusion_err)
749+ }
750+
751+ fn set_desired_parallelism ( & mut self , desired_parallelism : usize ) -> PyResult < ( ) > {
752+ if self . plan ( ) . output_partitioning ( ) . partition_count ( ) == desired_parallelism {
753+ return Ok ( ( ) )
754+ }
755+ let updated_plan = self . plan ( ) . clone ( ) . transform_up ( |node| {
756+ if let Some ( parquet) = node. as_any ( ) . downcast_ref :: < ParquetExec > ( ) {
757+ // Remove redundant ranges from partition files because ParquetExec refuses to repartition
758+ // if any file has a range defined (even when the range actually covers the entire file).
759+ // The EnforceDistribution optimizer rule adds ranges for both full and partial files,
760+ // so this tries to rever that to trigger a repartition when no files are actually split.
761+ let mut file_groups = parquet. base_config ( ) . file_groups . clone ( ) ;
762+ for group in file_groups. iter_mut ( ) {
763+ for file in group. iter_mut ( ) {
764+ if let Some ( range) = & file. range {
765+ if range. start == 0 && range. end == file. object_meta . size as i64 {
766+ file. range = None ; // remove redundant range
767+ }
768+ }
769+ }
770+ }
771+ if let Some ( repartitioned) = parquet. clone ( ) . into_builder ( ) . with_file_groups ( file_groups)
772+ . build_arc ( )
773+ . repartitioned ( desired_parallelism, & ConfigOptions :: default ( ) ) ? {
774+ Ok ( Transformed :: yes ( repartitioned) )
775+ } else {
776+ Ok ( Transformed :: no ( node) )
777+ }
778+ } else {
779+ Ok ( Transformed :: no ( node) )
780+ }
781+ } ) . map_err ( py_datafusion_err) ?. data ;
782+ self . physical_plan = PyExecutionPlan :: new ( updated_plan) ;
783+ Ok ( ( ) )
784+ }
785+ }
786+
787+ impl DistributedPlan {
788+
789+ fn new ( plan : Arc < dyn ExecutionPlan > ) -> Self {
790+ Self {
791+ physical_plan : PyExecutionPlan :: new ( plan)
792+ }
793+ }
794+
795+ fn plan ( & self ) -> & Arc < dyn ExecutionPlan > {
796+ & self . physical_plan . plan
797+ }
798+
799+ fn stats_field ( & self , field : fn ( Statistics ) -> Precision < usize > ) -> Option < usize > {
800+ if let Ok ( stats) = self . physical_plan . plan . statistics ( ) {
801+ match field ( stats) {
802+ Precision :: Exact ( n) => Some ( n) ,
803+ _ => None ,
804+ }
805+ } else {
806+ None
807+ }
808+ }
809+
810+ }
811+
812+ #[ pyfunction]
813+ pub fn partition_stream ( serialized_plan : & [ u8 ] , partition : usize , py : Python ) -> PyResult < PyRecordBatchStream > {
814+ deltalake:: ensure_initialized ( ) ;
815+ let node = PhysicalPlanNode :: decode ( serialized_plan)
816+ . map_err ( |e| DataFusionError :: External ( Box :: new ( e) ) )
817+ . map_err ( py_datafusion_err) ?;
818+ let ctx = SessionContext :: new ( ) ;
819+ let plan = node. try_into_physical_plan ( & ctx, ctx. runtime_env ( ) . as_ref ( ) , codec ( ) )
820+ . map_err ( py_datafusion_err) ?;
821+ let stream_with_runtime = get_tokio_runtime ( ) . 0 . spawn ( async move {
822+ plan. execute ( partition, ctx. task_ctx ( ) )
823+ } ) ;
824+ wait_for_future ( py, stream_with_runtime)
825+ . map_err ( py_datafusion_err) ?
826+ . map ( PyRecordBatchStream :: new)
827+ . map_err ( py_datafusion_err)
828+ }
829+
830+ fn codec ( ) -> & ' static dyn PhysicalExtensionCodec {
831+ static CODEC : DeltaPhysicalCodec = DeltaPhysicalCodec { } ;
832+ & CODEC
700833}
701834
702835/// Print DataFrame
0 commit comments