@@ -13,6 +13,7 @@ from libc.stdint cimport (
1313from libc.stdlib cimport (
1414 calloc,
1515 free,
16+ realloc,
1617)
1718from libc.string cimport (
1819 memcpy,
@@ -91,6 +92,41 @@ cdef buf_free(Buffer buf):
9192 if buf.data != NULL :
9293 free(buf.data)
9394
95+
96+ cdef struct StrColumn:
97+ # Growable utf-8 value buffer backing one string column.
98+ #
99+ # Grown by doubling rather than preallocated to the declared column width
100+ # times nrows: SAS pads character fields out to a fixed width, so the
101+ # worst case badly overstates how many bytes the data actually needs.
102+ uint8_t * data
103+ int64_t size
104+ int64_t capacity
105+
106+
107+ cdef bint strcol_reserve(StrColumn * col, int64_t extra) except 0 :
108+ """ Ensure `col` has room for `extra` more bytes, growing by doubling."""
109+ cdef:
110+ int64_t needed = col.size + extra
111+ int64_t capacity = col.capacity
112+ uint8_t * data
113+
114+ # A zero capacity means data is still NULL, so allocate even when nothing is
115+ # needed: an empty first cell would otherwise leave callers memcpy-ing into
116+ # NULL, which is undefined behavior even for a length of zero.
117+ if needed <= capacity and capacity > 0 :
118+ return True
119+ if capacity == 0 :
120+ capacity = 4096
121+ while capacity < needed:
122+ capacity *= 2
123+ data = < uint8_t * > realloc(col.data, capacity)
124+ if data is NULL :
125+ raise MemoryError (f" Failed to allocate {capacity} bytes" )
126+ col.data = data
127+ col.capacity = capacity
128+ return True
129+
94130# rle_decompress decompresses data using a Run Length Encoding
95131# algorithm. It is partially documented here:
96132#
@@ -269,6 +305,12 @@ cdef:
269305 int page_data_type = const.page_data_type
270306 int subheader_pointers_offset = const.subheader_pointers_offset
271307
308+ # See sas_constants.py for what each string mode means. string_mode_table
309+ # needs no alias here: it is whatever the other two are not.
310+ int string_mode_object = const.string_mode_object
311+ int string_mode_utf8 = const.string_mode_utf8
312+ uint8_t undefined_byte = const.undefined_byte
313+
272314 # Copy of subheader_signature_to_index that allows for much faster lookups.
273315 # Lookups are done in lookup_subheader_index_32/64. The C structures are
274316 # initialized in _init_subheader_signatures().
@@ -482,6 +524,24 @@ cdef class Parser:
482524 int64_t[:] column_types
483525 uint8_t[:, :] byte_chunk
484526 object [:, :] string_chunk
527+ # Output for the pyarrow string fast path (string_mode_utf8/_table):
528+ # str_cols[js] accumulates column js's utf-8 bytes, str_offsets[js, r+1]
529+ # records the end of row r within it, and str_valid[js, r] is 0 for a
530+ # null cell. Unused when str_mode is string_mode_object.
531+ int str_mode
532+ StrColumn * str_cols
533+ int n_str_cols
534+ int64_t[:, ::1 ] str_offsets
535+ uint8_t[:, ::1 ] str_valid
536+ # str_table[b * str_table_width : ...] holds the utf-8 for source byte
537+ # b, str_table_len[b] its length (undefined_byte if b is not a valid
538+ # character in str_encoding). Only set for string_mode_table. const
539+ # because these are shared, read-only arrays cached across readers.
540+ const uint8_t[::1 ] str_table
541+ const uint8_t[::1 ] str_table_len
542+ int str_table_width
543+ bint str_ascii_identity
544+ object str_encoding
485545 # (offset, length) of each compressed data subheader on the current
486546 # page, filled in by collect_page_subheaders and indexed by
487547 # current_row_on_page_index in readline().
@@ -510,6 +570,8 @@ cdef class Parser:
510570 def __cinit__ (self , object parser ):
511571 self .decompress = NULL
512572 self .decompressed_buf = Buffer(NULL , 0 )
573+ self .str_cols = NULL
574+ self .n_str_cols = 0
513575
514576 @ cython.wraparound (False )
515577 @ cython.boundscheck (False )
@@ -526,6 +588,22 @@ cdef class Parser:
526588 self .offsets = parser.column_data_offsets()
527589 self .byte_chunk = parser._byte_chunk
528590 self .string_chunk = parser._string_chunk
591+ # sas7bdat.py always supplies these, using empty arrays for whichever
592+ # of the two string outputs is inactive, so the memoryview assignments
593+ # below never see None.
594+ self .str_mode = parser._str_mode
595+ self .str_offsets = parser._str_offsets
596+ self .str_valid = parser._str_valid
597+ self .str_table = parser._str_table
598+ self .str_table_len = parser._str_table_len
599+ self .str_table_width = parser._str_table_width
600+ self .str_ascii_identity = parser._str_ascii_identity
601+ self .str_encoding = parser._str_encoding
602+ if self .str_mode != string_mode_object:
603+ self .n_str_cols = self .str_offsets.shape[0 ]
604+ self .str_cols = < StrColumn * > calloc(self .n_str_cols, sizeof(StrColumn))
605+ if self .str_cols is NULL :
606+ raise MemoryError (" Failed to allocate string column buffers" )
529607 self .data_subheader_offsets = parser._data_subheader_offsets
530608 self .data_subheader_lengths = parser._data_subheader_lengths
531609 self .row_length = parser.row_length
@@ -566,7 +644,34 @@ cdef class Parser:
566644 self .current_row_on_page_index = parser._current_row_on_page_index
567645
568646 def __dealloc__ (self ):
647+ cdef Py_ssize_t js
569648 buf_free(self .decompressed_buf)
649+ if self .str_cols is not NULL :
650+ for js in range (self .n_str_cols):
651+ free(self .str_cols[js].data)
652+ free(self .str_cols)
653+
654+ def string_values (self ):
655+ """
656+ Return one exact-size uint8 array of utf-8 bytes per string column.
657+
658+ Copies out of the growable parse buffers so the returned arrays do not
659+ pin their (up to 2x) spare capacity for the lifetime of the result.
660+ """
661+ cdef:
662+ Py_ssize_t js
663+ StrColumn * col
664+ uint8_t[::1 ] out
665+
666+ result = []
667+ for js in range (self .n_str_cols):
668+ col = & self .str_cols[js]
669+ values = np.empty(col.size, dtype = np.uint8)
670+ if col.size > 0 :
671+ out = values
672+ memcpy(& out[0 ], col.data, col.size)
673+ result.append(values)
674+ return result
570675
571676 def read (self , int nrows ):
572677 cdef:
@@ -719,13 +824,22 @@ cdef class Parser:
719824 cdef:
720825 Py_ssize_t j
721826 int s, k, m, jb, js, current_row, rpos
827+ int bi, blen, table_width
722828 int64_t lngt, start, ct
829+ uint8_t bval
830+ bint ascii_identity, mode_object, mode_utf8
723831 Buffer source
832+ StrColumn * col
724833 int64_t[:] column_types
725834 int64_t[:] lengths
726835 int64_t[:] offsets
727836 uint8_t[:, :] byte_chunk
728837 object [:, :] string_chunk
838+ StrColumn * str_cols
839+ int64_t[:, ::1 ] str_offsets
840+ uint8_t[:, ::1 ] str_valid
841+ const uint8_t[::1 ] str_table
842+ const uint8_t[::1 ] str_table_len
729843 bint compressed
730844
731845 assert offset + length <= self .cached_page_len, " Out of bounds read"
@@ -747,6 +861,17 @@ cdef class Parser:
747861 offsets = self .offsets
748862 byte_chunk = self .byte_chunk
749863 string_chunk = self .string_chunk
864+ # Resolved once per row so that the per-cell branches below test a
865+ # local rather than reloading the module-level mode constants.
866+ mode_object = self .str_mode == string_mode_object
867+ mode_utf8 = self .str_mode == string_mode_utf8
868+ str_cols = self .str_cols
869+ str_offsets = self .str_offsets
870+ str_valid = self .str_valid
871+ str_table = self .str_table
872+ str_table_len = self .str_table_len
873+ table_width = self .str_table_width
874+ ascii_identity = self .str_ascii_identity
750875 s = 8 * self .current_row_in_chunk_index
751876 js = 0
752877 jb = 0
@@ -777,10 +902,57 @@ cdef class Parser:
777902 # .rstrip(b"\x00 ") but without Python call overhead.
778903 while lngt > 0 and buf_get(source, start + lngt - 1 ) in b" \x00 " :
779904 lngt -= 1
780- if lngt == 0 and self .blank_missing:
781- string_chunk[js, current_row] = np_nan
905+ if mode_object:
906+ if lngt == 0 and self .blank_missing:
907+ string_chunk[js, current_row] = np_nan
908+ else :
909+ string_chunk[js, current_row] = buf_as_bytes(
910+ source, start, lngt
911+ )
782912 else :
783- string_chunk[js, current_row] = buf_as_bytes(source, start, lngt)
913+ col = & str_cols[js]
914+ if lngt > 0 or not self .blank_missing:
915+ # Spell out the bound the raw-pointer reads below rely
916+ # on; the rstrip loop's buf_get established it.
917+ assert start + lngt <= < int64_t> source.length, \
918+ " Out of bounds read"
919+ # Cells start null (str_valid is zero-initialized), so
920+ # only non-null ones need marking.
921+ str_valid[js, current_row] = 1
922+ strcol_reserve(col, lngt * table_width)
923+ if mode_utf8:
924+ memcpy(col.data + col.size, & source.data[start], lngt)
925+ col.size += lngt
926+ else :
927+ k = 0
928+ if ascii_identity:
929+ # Bytes below 0x80 are their own utf-8 in this
930+ # encoding, so an all-ascii cell (the common
931+ # case) costs one memcpy instead of a byte loop.
932+ while k < lngt and source.data[start + k] < 0x80 :
933+ k += 1
934+ memcpy(col.data + col.size, & source.data[start], k)
935+ col.size += k
936+ while k < lngt:
937+ bval = source.data[start + k]
938+ blen = str_table_len[bval]
939+ if blen == undefined_byte:
940+ # Re-decode the cell so the error matches
941+ # the UnicodeDecodeError that the per-cell
942+ # strict decode used to raise.
943+ buf_as_bytes(source, start, lngt).decode(
944+ self .str_encoding
945+ )
946+ raise AssertionError (
947+ " decoding an undefined byte should have raised"
948+ )
949+ for bi in range (blen):
950+ col.data[col.size + bi] = (
951+ str_table[bval * table_width + bi]
952+ )
953+ col.size += blen
954+ k += 1
955+ str_offsets[js, current_row + 1 ] = col.size
784956 js += 1
785957
786958 self .current_row_on_page_index += 1
0 commit comments