From fcb169919b93b95e22630a23817dbcf24e0e7cda Mon Sep 17 00:00:00 2001 From: Troels Nielsen Date: Sun, 9 Sep 2018 18:17:21 +0200 Subject: [PATCH] BUG: Filter out deleted rows from sas7bdat files (#15963) Sas7bdat may contain rows which are actually deleted. If the page_type has bit 128 set, there is a bitmap following the normal row data with a bit set for a given row if it has been deleted. Use that information to not include deleted rows in the resulting dataframe. --- doc/source/whatsnew/v0.24.0.txt | 2 + pandas/io/sas/sas.pyx | 108 +- pandas/io/sas/sas7bdat.py | 33 +- pandas/io/sas/sas_constants.py | 8 +- .../io/sas/data/datetime_deleted_rows.csv | 4 + .../sas/data/datetime_deleted_rows.sas7bdat | Bin 0 -> 5120 bytes pandas/tests/io/sas/data/deleted_rows.csv | 9993 +++++++++++++++++ .../tests/io/sas/data/deleted_rows.sas7bdat | Bin 0 -> 196608 bytes pandas/tests/io/sas/test_sas7bdat.py | 18 +- 9 files changed, 10137 insertions(+), 29 deletions(-) create mode 100644 pandas/tests/io/sas/data/datetime_deleted_rows.csv create mode 100644 pandas/tests/io/sas/data/datetime_deleted_rows.sas7bdat create mode 100644 pandas/tests/io/sas/data/deleted_rows.csv create mode 100644 pandas/tests/io/sas/data/deleted_rows.sas7bdat diff --git a/doc/source/whatsnew/v0.24.0.txt b/doc/source/whatsnew/v0.24.0.txt index 31ef70703e2ca..3f25d03b22cae 100644 --- a/doc/source/whatsnew/v0.24.0.txt +++ b/doc/source/whatsnew/v0.24.0.txt @@ -760,6 +760,8 @@ I/O - :func:`read_sas()` will correctly parse sas7bdat files with many columns (:issue:`22628`) - :func:`read_sas()` will correctly parse sas7bdat files with data page types having also bit 7 set (so page type is 128 + 256 = 384) (:issue:`16615`) - Bug in :meth:`detect_client_encoding` where potential ``IOError`` goes unhandled when importing in a mod_wsgi process due to restricted access to stdout. (:issue:`21552`) +- :func:`read_sas()` will not include rows in sas7bdat files that has been marked as deleted by SAS, but are still present in the file. (:issue:`15963`) + Plotting ^^^^^^^^ diff --git a/pandas/io/sas/sas.pyx b/pandas/io/sas/sas.pyx index a5bfd5866a261..0e1341fbecd05 100644 --- a/pandas/io/sas/sas.pyx +++ b/pandas/io/sas/sas.pyx @@ -204,9 +204,9 @@ cdef enum ColumnTypes: # type the page_data types cdef int page_meta_type = const.page_meta_type -cdef int page_mix_types_0 = const.page_mix_types[0] -cdef int page_mix_types_1 = const.page_mix_types[1] cdef int page_data_type = const.page_data_type +cdef int page_mix_type = const.page_mix_type +cdef int page_type_mask = const.page_type_mask cdef int subheader_pointers_offset = const.subheader_pointers_offset @@ -219,7 +219,7 @@ cdef class Parser(object): int64_t[:] column_types uint8_t[:, :] byte_chunk object[:, :] string_chunk - char *cached_page + uint8_t *cached_page int current_row_on_page_index int current_page_block_count int current_page_data_subheader_pointers_len @@ -231,6 +231,7 @@ cdef class Parser(object): int bit_offset int subheader_pointer_length int current_page_type + int current_page_deleted_rows_bitmap_offset bint is_little_endian const uint8_t[:] (*decompress)(int result_length, const uint8_t[:] inbuff) @@ -253,6 +254,7 @@ cdef class Parser(object): self.subheader_pointer_length = self.parser._subheader_pointer_length self.is_little_endian = parser.byte_order == "<" self.column_types = np.empty(self.column_count, dtype='int64') + self.current_page_deleted_rows_bitmap_offset = -1 # page indicators self.update_next_page() @@ -309,10 +311,55 @@ cdef class Parser(object): self.update_next_page() return done + cdef int calculate_deleted_rows_bitmap_offset(self): + """Calculate where the deleted rows bitmap is located + in the page. It is _current_page_deleted_rows_bitmap_offset's + bytes away from the end of the row values""" + + cdef: + int deleted_rows_bitmap_offset, page_type + int subheader_pointers_length, align_correction + int row_count + + if self.parser._current_page_deleted_rows_bitmap_offset is None: + return -1 + + deleted_rows_bitmap_offset = \ + self.parser._current_page_deleted_rows_bitmap_offset + + page_type = self.current_page_type + subheader_pointers_length = \ + self.subheader_pointer_length * self.current_page_subheaders_count + + if page_type & page_type_mask == page_data_type: + return ( + self.bit_offset + + subheader_pointers_offset + + self.row_length * self.current_page_block_count + + deleted_rows_bitmap_offset) + elif page_type & page_type_mask == page_mix_type: + align_correction = ( + self.bit_offset + + subheader_pointers_offset + + subheader_pointers_length + ) % 8 + row_count = min(self.parser._mix_page_row_count, + self.parser.row_count) + return ( + self.bit_offset + + subheader_pointers_offset + + subheader_pointers_length + + align_correction + + self.row_length * row_count + + deleted_rows_bitmap_offset) + else: + # I have never seen this case. + return -1 + cdef update_next_page(self): # update data for the current page - self.cached_page = self.parser._cached_page + self.cached_page = self.parser._cached_page self.current_row_on_page_index = 0 self.current_page_type = self.parser._current_page_type self.current_page_block_count = self.parser._current_page_block_count @@ -321,11 +368,29 @@ cdef class Parser(object): self.current_page_subheaders_count =\ self.parser._current_page_subheaders_count + self.current_page_deleted_rows_bitmap_offset =\ + self.calculate_deleted_rows_bitmap_offset() + + cdef bint is_row_deleted(self, int row_number): + cdef: + int row_disk + unsigned char byte, row_bit + if self.current_page_deleted_rows_bitmap_offset == -1: + return 0 + row_idx = (row_number + 1) // 8 + row_bit = 1 << (7 - (row_number % 8)) + + byte = self.cached_page[ + self.current_page_deleted_rows_bitmap_offset + row_idx] + + return byte & row_bit + cdef readline(self): cdef: int offset, bit_offset, align_correction int subheader_pointer_length, mn + int block_count bint done, flag bit_offset = self.bit_offset @@ -340,7 +405,7 @@ cdef class Parser(object): # Loop until a data row is read while True: - if self.current_page_type == page_meta_type: + if self.current_page_type & page_type_mask == page_meta_type: flag = self.current_row_on_page_index >=\ self.current_page_data_subheader_pointers_len if flag: @@ -355,8 +420,7 @@ cdef class Parser(object): current_subheader_pointer.offset, current_subheader_pointer.length) return False - elif (self.current_page_type == page_mix_types_0 or - self.current_page_type == page_mix_types_1): + elif self.current_page_type & page_type_mask == page_mix_type: align_correction = (bit_offset + subheader_pointers_offset + self.current_page_subheaders_count * subheader_pointer_length) @@ -365,21 +429,35 @@ cdef class Parser(object): offset += subheader_pointers_offset offset += (self.current_page_subheaders_count * subheader_pointer_length) - offset += self.current_row_on_page_index * self.row_length - self.process_byte_array_with_data(offset, - self.row_length) + + # Skip past rows marked as deleted mn = min(self.parser.row_count, self.parser._mix_page_row_count) + while (self.is_row_deleted(self.current_row_on_page_index) and + self.current_row_on_page_index < mn): + self.current_row_on_page_index += 1 + + if self.current_row_on_page_index < mn: + offset += self.current_row_on_page_index * self.row_length + self.process_byte_array_with_data(offset, self.row_length) if self.current_row_on_page_index == mn: done = self.read_next_page() if done: return True return False - elif self.current_page_type & page_data_type == page_data_type: - self.process_byte_array_with_data( - bit_offset + subheader_pointers_offset + - self.current_row_on_page_index * self.row_length, - self.row_length) + elif self.current_page_type & page_type_mask == page_data_type: + block_count = self.current_page_block_count + + # Skip past rows marked as deleted + while (self.is_row_deleted(self.current_row_on_page_index) and + self.current_row_on_page_index != block_count): + self.current_row_on_page_index += 1 + + if self.current_row_on_page_index < block_count: + self.process_byte_array_with_data( + bit_offset + subheader_pointers_offset + + self.current_row_on_page_index * self.row_length, + self.row_length) flag = (self.current_row_on_page_index == self.current_page_block_count) if flag: diff --git a/pandas/io/sas/sas7bdat.py b/pandas/io/sas/sas7bdat.py index 3582f538c16bf..f948af0ae293f 100644 --- a/pandas/io/sas/sas7bdat.py +++ b/pandas/io/sas/sas7bdat.py @@ -298,12 +298,12 @@ def _parse_metadata(self): def _process_page_meta(self): self._read_page_header() - pt = [const.page_meta_type, const.page_amd_type] + const.page_mix_types - if self._current_page_type in pt: + pt = [const.page_meta_type, const.page_amd_type, const.page_mix_type] + page_type = self._current_page_type + if page_type & const.page_type_mask in pt: self._process_page_metadata() - is_data_page = self._current_page_type & const.page_data_type - is_mix_page = self._current_page_type in const.page_mix_types - return (is_data_page or is_mix_page + pt = [const.page_mix_type, const.page_data_type] + return (page_type & const.page_type_mask in pt or self._current_page_data_subheader_pointers != []) def _read_page_header(self): @@ -313,6 +313,12 @@ def _read_page_header(self): tx = const.block_count_offset + bit_offset self._current_page_block_count = self._read_int( tx, const.block_count_length) + if self._current_page_type & const.page_has_deleted_rows_bitmap: + tx = const.page_deleted_rows_bitmap_offset * self._int_length + self._current_page_deleted_rows_bitmap_offset = self._read_int( + tx, self._int_length) + else: + self._current_page_deleted_rows_bitmap_offset = None tx = const.subheader_count_offset + bit_offset self._current_page_subheaders_count = ( self._read_int(tx, const.subheader_count_length)) @@ -420,6 +426,9 @@ def _process_rowsize_subheader(self, offset, length): offset + const.row_length_offset_multiplier * int_len, int_len) self.row_count = self._read_int( offset + const.row_count_offset_multiplier * int_len, int_len) + self.rows_deleted_count = self._read_int( + offset + const.rows_deleted_count_offset_multiplier * int_len, + int_len) self.col_count_p1 = self._read_int( offset + const.col_count_p1_multiplier * int_len, int_len) self.col_count_p2 = self._read_int( @@ -601,19 +610,20 @@ def _process_format_subheader(self, offset, length): def read(self, nrows=None): + row_count = self.row_count - self.rows_deleted_count if (nrows is None) and (self.chunksize is not None): nrows = self.chunksize elif nrows is None: - nrows = self.row_count + nrows = row_count if len(self._column_types) == 0: self.close() raise EmptyDataError("No columns to parse from file") - if self._current_row_in_file_index >= self.row_count: + if self._current_row_in_file_index >= row_count: return None - m = self.row_count - self._current_row_in_file_index + m = row_count - self._current_row_in_file_index if nrows > m: nrows = m @@ -647,12 +657,11 @@ def _read_next_page(self): self._read_page_header() page_type = self._current_page_type - if page_type == const.page_meta_type: + if page_type & const.page_type_mask == const.page_meta_type: self._process_page_metadata() - is_data_page = page_type & const.page_data_type - pt = [const.page_meta_type] + const.page_mix_types - if not is_data_page and self._current_page_type not in pt: + pt = [const.page_meta_type, const.page_mix_type, const.page_data_type] + if page_type & const.page_type_mask not in pt: return self._read_next_page() return False diff --git a/pandas/io/sas/sas_constants.py b/pandas/io/sas/sas_constants.py index 98502d32d39e8..d1cb42c44b60b 100644 --- a/pandas/io/sas/sas_constants.py +++ b/pandas/io/sas/sas_constants.py @@ -43,6 +43,7 @@ os_name_length = 16 page_bit_offset_x86 = 16 page_bit_offset_x64 = 32 +page_deleted_rows_bitmap_offset = 3 subheader_pointer_length_x86 = 12 subheader_pointer_length_x64 = 24 page_type_offset = 0 @@ -52,11 +53,15 @@ subheader_count_offset = 4 subheader_count_length = 2 page_meta_type = 0 +# If page_type has bit 7 set there may be deleted rows. +# These are marked in a bitmap following the row data. +page_has_deleted_rows_bitmap = 128 page_data_type = 256 page_amd_type = 1024 page_metc_type = 16384 page_comp_type = -28672 -page_mix_types = [512, 640] +page_mix_type = 512 +page_type_mask = (page_data_type | page_mix_type | page_amd_type) subheader_pointers_offset = 8 truncated_subheader_id = 1 compressed_subheader_id = 4 @@ -64,6 +69,7 @@ text_block_size_length = 2 row_length_offset_multiplier = 5 row_count_offset_multiplier = 6 +rows_deleted_count_offset_multiplier = 7 col_count_p1_multiplier = 9 col_count_p2_multiplier = 10 row_count_on_mix_page_offset_multiplier = 15 diff --git a/pandas/tests/io/sas/data/datetime_deleted_rows.csv b/pandas/tests/io/sas/data/datetime_deleted_rows.csv new file mode 100644 index 0000000000000..1687dcda79435 --- /dev/null +++ b/pandas/tests/io/sas/data/datetime_deleted_rows.csv @@ -0,0 +1,4 @@ +Date1,Date2,DateTime,DateTimeHi,Taiw +1960-01-06,1960-01-04,1677-09-21 00:12:44,1677-09-21 00:12:43.145225525,1912-01-01 +1960-01-03,1960-01-05,2262-04-11 23:47:16,1960-01-01 00:00:00.000000000,1960-01-02 +1960-01-06,1960-01-04,1677-09-21 00:12:44,2262-04-11 23:47:16.854774475,1912-01-01 diff --git a/pandas/tests/io/sas/data/datetime_deleted_rows.sas7bdat b/pandas/tests/io/sas/data/datetime_deleted_rows.sas7bdat new file mode 100644 index 0000000000000000000000000000000000000000..a2e25c6fb0b3a8978cc0648798ba81f704c519bd GIT binary patch literal 5120 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 literal 0 HcmV?d00001 diff --git a/pandas/tests/io/sas/test_sas7bdat.py b/pandas/tests/io/sas/test_sas7bdat.py index 705387188438f..4e49db381e198 100644 --- a/pandas/tests/io/sas/test_sas7bdat.py +++ b/pandas/tests/io/sas/test_sas7bdat.py @@ -214,7 +214,23 @@ def test_inconsistent_number_of_rows(datapath): # Regression test for issue #16615. (PR #22628) fname = datapath("io", "sas", "data", "load_log.sas7bdat") df = pd.read_sas(fname, encoding='latin-1') - assert len(df) == 2097 + assert len(df) == 2088 + + +def test_deleted_rows(datapath): + # Regression test for issue #15963. (PR #22650) + TESTS = [['deleted_rows', {}], + ['datetime_deleted_rows', { + 'parse_dates': ['Date1', 'Date2', 'DateTime', + 'DateTimeHi', 'Taiw'] + }]] + for fn, csv_kwargs in TESTS: + fname = datapath("io", "sas", "data", "{}.sas7bdat".format( + fn)) + df = pd.read_sas(fname, encoding='latin-1') + fname = datapath("io", "sas", "data", "{}.csv".format(fn)) + df0 = pd.read_csv(fname, **csv_kwargs) + tm.assert_frame_equal(df, df0) def test_zero_variables(datapath):