code: def copartition_datasets(self, axis, other, left_func, right_func):
          
          if left_func is None:
              new_self = self
          else:
              new_self = self.map_across_full_axis(axis, left_func)
  
          # This block of code will only shuffle if absolutely necessary. If we do need to
          # shuffle, we use the identity function and then reshuffle.
          if right_func is None:
              if axis == 0 and not np.array_equal(
                  other.block_lengths, new_self.block_lengths
              ):
                  new_other = other.manual_shuffle(
                      axis, lambda x: x, new_self.block_lengths
                  )
              elif axis == 1 and not np.array_equal(
                  other.block_widths, new_self.block_widths
              ):
                  new_other = other.manual_shuffle(
                      axis, lambda x: x, new_self.block_widths
                  )
              else:
                  new_other = other
          # Most of the time, we will be given an operation to do. We perform that with
          # manual_shuffle.
          else:
              new_other = other.manual_shuffle(
                  axis,
                  right_func,
                  new_self.block_lengths if axis == 0 else new_self.block_widths,
              )
          return new_self, new_other
docs: Copartition two BlockPartitions objects.
  
          Args:
              axis: The axis to copartition.
              other: The other BlockPartitions object to copartition with.
              left_func: The function to apply to left. If None, just use the dimension
                  of self (based on axis).
              right_func: The function to apply to right. If None, check the dimensions of
                  other and use the identity function if splitting needs to happen.
  
          Returns:
              A tuple of BlockPartitions objects, left and right.