fixedwidthparser 2.0.0rc1__py3-none-musllinux_1_2_aarch64.whl

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.
fixedwidth/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """Read and write fixed-width files through the FixedWidthParser native library.
2
+
3
+ import fixedwidth as fw
4
+
5
+ layout = fw.Layout([
6
+ fw.Column("codneg", 12, 12, fw.String),
7
+ fw.Column("preabe", 56, 13, fw.Float64, scale=2),
8
+ ])
9
+ df = fw.read_pandas("COTAHIST_A2023.TXT", layout, fw.ReadOptions(header_lines=1, skip_invalid=True))
10
+ """
11
+
12
+ from fixedwidth.arrow_stream import ArrowStream
13
+ from fixedwidth.layout import Layout
14
+ from fixedwidth.reader import read, read_arrow, read_arrow_stream, read_batches, read_pandas, read_polars
15
+ from fixedwidth.table import Table
16
+ from fixedwidth.types import (
17
+ Alignment,
18
+ Column,
19
+ ColumnType,
20
+ FixedWidthError,
21
+ LayoutOptions,
22
+ Overflow,
23
+ ParseError,
24
+ ReaderOptions,
25
+ ReadOptions,
26
+ TrimMode,
27
+ WriteOptions,
28
+ )
29
+ from fixedwidth.writer import write, write_bytes
30
+
31
+ String = ColumnType.STRING
32
+ Int64 = ColumnType.INT64
33
+ Float64 = ColumnType.FLOAT64
34
+ Decimal = ColumnType.DECIMAL
35
+ Date = ColumnType.DATE
36
+ Time = ColumnType.TIME
37
+ Timestamp = ColumnType.TIMESTAMP
38
+
39
+ __all__ = [
40
+ "Alignment",
41
+ "ArrowStream",
42
+ "Column",
43
+ "ColumnType",
44
+ "Date",
45
+ "Decimal",
46
+ "FixedWidthError",
47
+ "Float64",
48
+ "Int64",
49
+ "Layout",
50
+ "LayoutOptions",
51
+ "Overflow",
52
+ "ParseError",
53
+ "ReadOptions",
54
+ "ReaderOptions",
55
+ "String",
56
+ "Table",
57
+ "Time",
58
+ "Timestamp",
59
+ "TrimMode",
60
+ "WriteOptions",
61
+ "read",
62
+ "read_arrow",
63
+ "read_arrow_stream",
64
+ "read_batches",
65
+ "read_pandas",
66
+ "read_polars",
67
+ "write",
68
+ "write_bytes",
69
+ ]
File without changes
fixedwidth/_native.py ADDED
@@ -0,0 +1,248 @@
1
+ """ctypes binding to the FixedWidthParser NativeAOT shared library.
2
+
3
+ Everything here mirrors src/FixedWidthParser.Native/include/fixedwidth.h. If you change one, change both.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ctypes
9
+ import os
10
+ import platform
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+
14
+ FW_OK = 0
15
+ FW_EOF = -1
16
+ FW_BUFFER_TOO_SMALL = -2
17
+ FW_INVALID_HANDLE = -3
18
+ FW_INVALID_ARGUMENT = -4
19
+ FW_ERROR = -5
20
+ FW_PARSE_ERROR = -6
21
+
22
+ FW_ABI_VERSION = 1
23
+
24
+ FW_OPT_DEFAULT = 0
25
+ FW_OPT_FALSE = 1
26
+ FW_OPT_TRUE = 2
27
+
28
+ _u8p = ctypes.POINTER(ctypes.c_uint8)
29
+
30
+
31
+ class ColumnSpec(ctypes.Structure):
32
+ _fields_ = [
33
+ ("struct_size", ctypes.c_int32),
34
+ ("name", _u8p),
35
+ ("name_len", ctypes.c_int32),
36
+ ("start", ctypes.c_int32),
37
+ ("length", ctypes.c_int32),
38
+ ("type", ctypes.c_int32),
39
+ ("nullable", ctypes.c_int32),
40
+ ("scale", ctypes.c_int32),
41
+ ("format", _u8p),
42
+ ("format_len", ctypes.c_int32),
43
+ ("trim_char", ctypes.c_int32),
44
+ ("trim_mode", ctypes.c_int32),
45
+ ("alignment", ctypes.c_int32),
46
+ ("padding", ctypes.c_int32),
47
+ ("overflow", ctypes.c_int32),
48
+ ]
49
+
50
+
51
+ class LayoutOptions(ctypes.Structure):
52
+ _fields_ = [("struct_size", ctypes.c_int32), ("line_length", ctypes.c_int32), ("filler", ctypes.c_int32)]
53
+
54
+
55
+ class ReadOptions(ctypes.Structure):
56
+ _fields_ = [
57
+ ("struct_size", ctypes.c_int32),
58
+ ("header_lines", ctypes.c_int64),
59
+ ("skip_invalid", ctypes.c_int32),
60
+ ("degree_of_parallelism", ctypes.c_int32),
61
+ ("encoding", ctypes.c_int32),
62
+ ("max_line_length", ctypes.c_int32),
63
+ ("skip_byte_order_mark", ctypes.c_int32),
64
+ ]
65
+
66
+
67
+ class ReaderOptions(ctypes.Structure):
68
+ _fields_ = [("struct_size", ctypes.c_int32), ("read", ReadOptions), ("batch_rows", ctypes.c_int64)]
69
+
70
+
71
+ class Column(ctypes.Structure):
72
+ _fields_ = [
73
+ ("type", ctypes.c_int32),
74
+ ("scale", ctypes.c_int32),
75
+ ("length", ctypes.c_int64),
76
+ ("values", ctypes.c_void_p),
77
+ ("validity", ctypes.c_void_p),
78
+ ("data", ctypes.c_void_p),
79
+ ("data_len", ctypes.c_int64),
80
+ ]
81
+
82
+
83
+ class Table(ctypes.Structure):
84
+ _fields_ = [("column_count", ctypes.c_int32), ("row_count", ctypes.c_int64), ("columns", ctypes.POINTER(Column))]
85
+
86
+
87
+ class ReadResult(ctypes.Structure):
88
+ _fields_ = [("struct_size", ctypes.c_int32), ("lines_read", ctypes.c_int64), ("lines_skipped", ctypes.c_int64)]
89
+
90
+
91
+ class ParseErrorInfo(ctypes.Structure):
92
+ _fields_ = [("struct_size", ctypes.c_int32), ("line_number", ctypes.c_int64), ("column", ctypes.c_int32)]
93
+
94
+
95
+ class WriteOptions(ctypes.Structure):
96
+ _fields_ = [
97
+ ("struct_size", ctypes.c_int32),
98
+ ("encoding", ctypes.c_int32),
99
+ ("newline", _u8p),
100
+ ("newline_len", ctypes.c_int32),
101
+ ("degree_of_parallelism", ctypes.c_int32),
102
+ ("batch_rows", ctypes.c_int64),
103
+ ]
104
+
105
+
106
+ class Buffer(ctypes.Structure):
107
+ _fields_ = [("data", ctypes.c_void_p), ("length", ctypes.c_int64)]
108
+
109
+
110
+ class ArrowSchema(ctypes.Structure):
111
+ pass
112
+
113
+
114
+ class ArrowArray(ctypes.Structure):
115
+ pass
116
+
117
+
118
+ _ArrowSchemaRelease = ctypes.CFUNCTYPE(None, ctypes.POINTER(ArrowSchema))
119
+ _ArrowArrayRelease = ctypes.CFUNCTYPE(None, ctypes.POINTER(ArrowArray))
120
+
121
+ ArrowSchema._fields_ = [
122
+ ("format", ctypes.c_char_p),
123
+ ("name", ctypes.c_char_p),
124
+ ("metadata", ctypes.c_char_p),
125
+ ("flags", ctypes.c_int64),
126
+ ("n_children", ctypes.c_int64),
127
+ ("children", ctypes.POINTER(ctypes.POINTER(ArrowSchema))),
128
+ ("dictionary", ctypes.POINTER(ArrowSchema)),
129
+ ("release", _ArrowSchemaRelease),
130
+ ("private_data", ctypes.c_void_p),
131
+ ]
132
+ ArrowArray._fields_ = [
133
+ ("length", ctypes.c_int64),
134
+ ("null_count", ctypes.c_int64),
135
+ ("offset", ctypes.c_int64),
136
+ ("n_buffers", ctypes.c_int64),
137
+ ("n_children", ctypes.c_int64),
138
+ ("buffers", ctypes.POINTER(ctypes.c_void_p)),
139
+ ("children", ctypes.POINTER(ctypes.POINTER(ArrowArray))),
140
+ ("dictionary", ctypes.POINTER(ArrowArray)),
141
+ ("release", _ArrowArrayRelease),
142
+ ("private_data", ctypes.c_void_p),
143
+ ]
144
+
145
+
146
+ class ArrowArrayStream(ctypes.Structure):
147
+ pass
148
+
149
+
150
+ _ArrowStreamGetSchema = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(ArrowArrayStream), ctypes.POINTER(ArrowSchema))
151
+ _ArrowStreamGetNext = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.POINTER(ArrowArrayStream), ctypes.POINTER(ArrowArray))
152
+ _ArrowStreamGetLastError = ctypes.CFUNCTYPE(ctypes.c_char_p, ctypes.POINTER(ArrowArrayStream))
153
+ _ArrowStreamRelease = ctypes.CFUNCTYPE(None, ctypes.POINTER(ArrowArrayStream))
154
+
155
+ ArrowArrayStream._fields_ = [
156
+ ("get_schema", _ArrowStreamGetSchema),
157
+ ("get_next", _ArrowStreamGetNext),
158
+ ("get_last_error", _ArrowStreamGetLastError),
159
+ ("release", _ArrowStreamRelease),
160
+ ("private_data", ctypes.c_void_p),
161
+ ]
162
+
163
+ _P = ctypes.POINTER
164
+ _SIGNATURES: dict[str, tuple[object, list[object]]] = {
165
+ "fw_abi_version": (ctypes.c_int32, []),
166
+ "fw_last_error": (ctypes.c_int32, [_u8p, ctypes.c_int32, _P(ctypes.c_int32)]),
167
+ "fw_last_parse_error": (ctypes.c_int32, [_P(ParseErrorInfo)]),
168
+ "fw_layout_create": (ctypes.c_int32, [_P(ColumnSpec), ctypes.c_int32, _P(LayoutOptions), _P(ctypes.c_void_p)]),
169
+ "fw_layout_free": (None, [ctypes.c_void_p]),
170
+ "fw_read_file": (ctypes.c_int32, [_u8p, ctypes.c_int32, ctypes.c_void_p, _P(ReadOptions), _P(Table), _P(ReadResult)]),
171
+ "fw_read_memory": (ctypes.c_int32, [ctypes.c_void_p, ctypes.c_int64, ctypes.c_void_p, _P(ReadOptions), _P(Table), _P(ReadResult)]),
172
+ "fw_free_table": (None, [_P(Table)]),
173
+ "fw_reader_open_file": (ctypes.c_int32, [_u8p, ctypes.c_int32, ctypes.c_void_p, _P(ReaderOptions), _P(ctypes.c_void_p)]),
174
+ "fw_reader_open_memory": (ctypes.c_int32, [ctypes.c_void_p, ctypes.c_int64, ctypes.c_void_p, _P(ReaderOptions), _P(ctypes.c_void_p)]),
175
+ "fw_reader_next": (ctypes.c_int32, [ctypes.c_void_p, _P(Table)]),
176
+ "fw_reader_close": (None, [ctypes.c_void_p]),
177
+ "fw_read_arrow_file": (ctypes.c_int32, [_u8p, ctypes.c_int32, ctypes.c_void_p, _P(ReadOptions), _P(ArrowArray), _P(ArrowSchema), _P(ReadResult)]),
178
+ "fw_read_arrow_memory": (ctypes.c_int32, [ctypes.c_void_p, ctypes.c_int64, ctypes.c_void_p, _P(ReadOptions), _P(ArrowArray), _P(ArrowSchema), _P(ReadResult)]),
179
+ "fw_read_arrow_stream_file": (ctypes.c_int32, [_u8p, ctypes.c_int32, ctypes.c_void_p, _P(ReaderOptions), _P(ArrowArrayStream)]),
180
+ "fw_read_arrow_stream_memory": (ctypes.c_int32, [ctypes.c_void_p, ctypes.c_int64, ctypes.c_void_p, _P(ReaderOptions), _P(ArrowArrayStream)]),
181
+ "fw_write_file": (ctypes.c_int32, [_u8p, ctypes.c_int32, _P(Table), ctypes.c_void_p, _P(WriteOptions)]),
182
+ "fw_write_memory": (ctypes.c_int32, [_P(Table), ctypes.c_void_p, _P(WriteOptions), _P(Buffer)]),
183
+ "fw_free_buffer": (None, [_P(Buffer)]),
184
+ }
185
+
186
+
187
+ def library_filename() -> str:
188
+ system = platform.system()
189
+ if system == "Windows":
190
+ return "FixedWidthParser.Native.dll"
191
+ if system == "Darwin":
192
+ return "FixedWidthParser.Native.dylib"
193
+ return "FixedWidthParser.Native.so"
194
+
195
+
196
+ def _candidate_paths() -> list[Path]:
197
+ paths = []
198
+ override = os.environ.get("FIXEDWIDTH_NATIVE_LIB")
199
+ if override:
200
+ paths.append(Path(override))
201
+ paths.append(Path(__file__).parent / "_lib" / library_filename())
202
+ return paths
203
+
204
+
205
+ @lru_cache(maxsize=1)
206
+ def load_library() -> ctypes.CDLL:
207
+ """Loads the bundled library (or FIXEDWIDTH_NATIVE_LIB) once and checks its ABI version."""
208
+ tried = []
209
+ for path in _candidate_paths():
210
+ if not path.exists():
211
+ tried.append(str(path))
212
+ continue
213
+ lib = ctypes.CDLL(str(path))
214
+ for name, (restype, argtypes) in _SIGNATURES.items():
215
+ function = getattr(lib, name)
216
+ function.restype = restype
217
+ function.argtypes = argtypes
218
+ version = lib.fw_abi_version()
219
+ if version != FW_ABI_VERSION:
220
+ raise OSError(f"{path} has ABI version {version}; this package needs {FW_ABI_VERSION}")
221
+ return lib
222
+ raise OSError(
223
+ "FixedWidthParser.Native was not found (looked in " + ", ".join(tried) + "). "
224
+ "Build it with `python python/scripts/build_native.py` or set FIXEDWIDTH_NATIVE_LIB."
225
+ )
226
+
227
+
228
+ def last_error() -> str:
229
+ lib = load_library()
230
+ buffer = (ctypes.c_uint8 * 4096)()
231
+ length = ctypes.c_int32()
232
+ status = lib.fw_last_error(buffer, len(buffer), ctypes.byref(length))
233
+ if status == FW_BUFFER_TOO_SMALL:
234
+ buffer = (ctypes.c_uint8 * length.value)()
235
+ lib.fw_last_error(buffer, len(buffer), ctypes.byref(length))
236
+ return bytes(buffer[: length.value]).decode("utf-8", "replace")
237
+
238
+
239
+ def last_parse_error() -> tuple[int, int]:
240
+ info = ParseErrorInfo(struct_size=ctypes.sizeof(ParseErrorInfo))
241
+ load_library().fw_last_parse_error(ctypes.byref(info))
242
+ return int(info.line_number), int(info.column)
243
+
244
+
245
+ def utf8(text: str) -> tuple[ctypes.Array, int]:
246
+ """A UTF-8 buffer and its length, for a pointer + length argument."""
247
+ encoded = text.encode("utf-8")
248
+ return (ctypes.c_uint8 * max(1, len(encoded))).from_buffer_copy(encoded.ljust(1, b"\0")), len(encoded)
@@ -0,0 +1,127 @@
1
+ """An Arrow C stream exposed through the Arrow PyCapsule interface, with no pyarrow dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+
7
+ from fixedwidth import _native
8
+ from fixedwidth.types import FixedWidthError
9
+
10
+ _CAPSULE_NAME = b"arrow_array_stream"
11
+
12
+ _capsule_new = ctypes.pythonapi.PyCapsule_New
13
+ _capsule_new.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p]
14
+ _capsule_new.restype = ctypes.py_object
15
+
16
+ _capsule_pointer = ctypes.pythonapi.PyCapsule_GetPointer
17
+ _capsule_pointer.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
18
+ _capsule_pointer.restype = ctypes.c_void_p
19
+
20
+ _ReadFn = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
21
+ _ErrorFn = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p)
22
+ _ReleaseFn = ctypes.CFUNCTYPE(None, ctypes.c_void_p)
23
+
24
+
25
+ class _Target:
26
+ """The native stream behind a proxy, re-typed so ctypes releases the GIL in its callbacks, and the
27
+ caller's bytes a memory read reads lazily."""
28
+
29
+ def __init__(self, stream: _native.ArrowArrayStream, keep: object) -> None:
30
+ self.stream = stream
31
+ self.keep = keep
32
+ self.address = ctypes.addressof(stream)
33
+ self.get_schema = _ReadFn(_address_of(stream.get_schema))
34
+ self.get_next = _ReadFn(_address_of(stream.get_next))
35
+ self.get_last_error = _ErrorFn(_address_of(stream.get_last_error))
36
+
37
+
38
+ def _address_of(function: object) -> int:
39
+ return ctypes.cast(function, ctypes.c_void_p).value
40
+
41
+
42
+ _streams: dict[int, _Target] = {}
43
+ _proxies: dict[int, _native.ArrowArrayStream] = {}
44
+
45
+
46
+ def _target(stream: int) -> _Target:
47
+ return _streams[_native.ArrowArrayStream.from_address(stream).private_data]
48
+
49
+
50
+ @_ReadFn
51
+ def _get_schema(stream: int, out: int) -> int:
52
+ target = _target(stream)
53
+ return target.get_schema(target.address, out)
54
+
55
+
56
+ @_ReadFn
57
+ def _get_next(stream: int, out: int) -> int:
58
+ target = _target(stream)
59
+ return target.get_next(target.address, out)
60
+
61
+
62
+ @_ErrorFn
63
+ def _get_last_error(stream: int) -> int | None:
64
+ target = _target(stream)
65
+ return target.get_last_error(target.address)
66
+
67
+
68
+ def _close(proxy: _native.ArrowArrayStream) -> None:
69
+ _release(_streams.pop(proxy.private_data).stream)
70
+ proxy.release = _native._ArrowStreamRelease()
71
+
72
+
73
+ @_ReleaseFn
74
+ def _release_proxy(stream: int) -> None:
75
+ _close(_native.ArrowArrayStream.from_address(stream))
76
+
77
+
78
+ def _release(stream: _native.ArrowArrayStream) -> None:
79
+ if stream.release:
80
+ stream.release(ctypes.byref(stream))
81
+
82
+
83
+ def _proxy(stream: _native.ArrowArrayStream, keep: object) -> _native.ArrowArrayStream:
84
+ target = _Target(stream, keep)
85
+ _streams[id(target)] = target
86
+ proxy = _native.ArrowArrayStream()
87
+ proxy.get_schema = ctypes.cast(_get_schema, _native._ArrowStreamGetSchema)
88
+ proxy.get_next = ctypes.cast(_get_next, _native._ArrowStreamGetNext)
89
+ proxy.get_last_error = ctypes.cast(_get_last_error, _native._ArrowStreamGetLastError)
90
+ proxy.release = ctypes.cast(_release_proxy, _native._ArrowStreamRelease)
91
+ proxy.private_data = id(target)
92
+ return proxy
93
+
94
+
95
+ def _destroy_capsule(capsule: int) -> None:
96
+ proxy = _proxies.pop(_capsule_pointer(capsule, _CAPSULE_NAME))
97
+ if proxy.release:
98
+ _close(proxy)
99
+
100
+
101
+ _destroy_capsule_callback = ctypes.CFUNCTYPE(None, ctypes.c_void_p)(_destroy_capsule)
102
+
103
+
104
+ class ArrowStream:
105
+ """A single-use stream of Arrow record batches implementing `__arrow_c_stream__`.
106
+
107
+ Hand it to any consumer of the Arrow PyCapsule interface — `polars.DataFrame(stream)`,
108
+ `pyarrow.RecordBatchReader.from_stream(stream)`. Once a consumer takes it, the stream is spent;
109
+ dropping it unconsumed releases the native read.
110
+ """
111
+
112
+ def __init__(self, stream: _native.ArrowArrayStream, keep: object = None) -> None:
113
+ self._stream: _native.ArrowArrayStream | None = stream
114
+ self._keep = keep
115
+
116
+ def __arrow_c_stream__(self, requested_schema: object = None) -> object:
117
+ if self._stream is None:
118
+ raise FixedWidthError("this Arrow stream was already consumed")
119
+ stream, self._stream = self._stream, None
120
+ proxy = _proxy(stream, self._keep)
121
+ address = ctypes.addressof(proxy)
122
+ _proxies[address] = proxy
123
+ return _capsule_new(address, _CAPSULE_NAME, _destroy_capsule_callback)
124
+
125
+ def __del__(self) -> None:
126
+ if getattr(self, "_stream", None) is not None:
127
+ _release(self._stream)
fixedwidth/layout.py ADDED
@@ -0,0 +1,94 @@
1
+ """Layout: the columns of a line, validated once by the native library and shared by every call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ from collections.abc import Sequence
7
+
8
+ from fixedwidth import _native
9
+ from fixedwidth.types import Column, ColumnType, FixedWidthError, LayoutOptions, ParseError
10
+
11
+
12
+ def check(status: int) -> None:
13
+ """Raises the error a native status reports."""
14
+ if status == _native.FW_OK:
15
+ return
16
+ message = _native.last_error() or f"native status {status}"
17
+ if status == _native.FW_PARSE_ERROR:
18
+ line_number, column = _native.last_parse_error()
19
+ raise ParseError(message, line_number, column)
20
+ raise FixedWidthError(message)
21
+
22
+
23
+ def ascii_option(value: str | None, what: str) -> int:
24
+ if value is None:
25
+ return 0
26
+ if len(value) != 1 or ord(value) > 127:
27
+ raise ValueError(f"{what} must be one ASCII character, got {value!r}")
28
+ return ord(value)
29
+
30
+
31
+ class Layout:
32
+ """How the columns of a line map to names and types; pass it to every read and write.
33
+
34
+ The native layout is immutable and may be shared across threads.
35
+ """
36
+
37
+ def __init__(self, columns: Sequence[Column], options: LayoutOptions | None = None) -> None:
38
+ self._lib = _native.load_library()
39
+ self._handle: int | None = None
40
+ self._columns = list(columns)
41
+ specs = (_native.ColumnSpec * max(1, len(self._columns)))()
42
+ keep = [self._fill(specs[i], column) for i, column in enumerate(self._columns)]
43
+ raw = None
44
+ if options is not None:
45
+ raw = _native.LayoutOptions(
46
+ struct_size=ctypes.sizeof(_native.LayoutOptions),
47
+ line_length=options.line_length,
48
+ filler=ascii_option(options.filler, "filler"),
49
+ )
50
+ handle = ctypes.c_void_p()
51
+ check(self._lib.fw_layout_create(specs, len(self._columns), ctypes.byref(raw) if raw else None, ctypes.byref(handle)))
52
+ self._handle = handle.value
53
+ del keep
54
+
55
+ @staticmethod
56
+ def _fill(spec: _native.ColumnSpec, column: Column) -> tuple[object, ...]:
57
+ name, name_len = _native.utf8(column.name)
58
+ fmt, fmt_len = _native.utf8(column.format) if column.format is not None else (None, 0)
59
+ spec.struct_size = ctypes.sizeof(_native.ColumnSpec)
60
+ spec.name = ctypes.cast(name, ctypes.POINTER(ctypes.c_uint8))
61
+ spec.name_len = name_len
62
+ spec.start = column.start
63
+ spec.length = column.length
64
+ spec.type = int(ColumnType(column.type))
65
+ spec.nullable = int(column.nullable)
66
+ spec.scale = column.scale
67
+ spec.format = ctypes.cast(fmt, ctypes.POINTER(ctypes.c_uint8)) if fmt is not None else None
68
+ spec.format_len = fmt_len
69
+ spec.trim_char = ascii_option(column.trim_char, "trim_char")
70
+ spec.trim_mode = int(column.trim_mode or 0)
71
+ spec.alignment = int(column.alignment or 0)
72
+ spec.padding = ascii_option(column.padding, "padding")
73
+ spec.overflow = int(column.overflow or 0)
74
+ return name, fmt
75
+
76
+ @property
77
+ def columns(self) -> list[Column]:
78
+ return list(self._columns)
79
+
80
+ @property
81
+ def names(self) -> list[str]:
82
+ return [column.name for column in self._columns]
83
+
84
+ @property
85
+ def handle(self) -> int:
86
+ return self._handle or 0
87
+
88
+ def __del__(self) -> None:
89
+ if getattr(self, "_handle", None):
90
+ self._lib.fw_layout_free(self._handle)
91
+ self._handle = None
92
+
93
+ def __repr__(self) -> str:
94
+ return f"Layout({len(self._columns)} columns: {', '.join(self.names)})"
fixedwidth/py.typed ADDED
File without changes
fixedwidth/reader.py ADDED
@@ -0,0 +1,157 @@
1
+ """Reading: whole sources into a Table, Arrow, pandas or polars, and batched reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import os
7
+ from collections.abc import Iterator
8
+ from typing import Any, Union
9
+
10
+ from fixedwidth import _native
11
+ from fixedwidth.arrow_stream import ArrowStream
12
+ from fixedwidth.layout import Layout, check
13
+ from fixedwidth.table import Table, _Owner
14
+ from fixedwidth.types import ReaderOptions, ReadOptions, code_page
15
+
16
+ Source = Union[str, "os.PathLike[str]", bytes, bytearray, memoryview]
17
+
18
+
19
+ class _Input:
20
+ """A file path or a buffer, as the pointer + length arguments the native calls take."""
21
+
22
+ def __init__(self, source: Source) -> None:
23
+ self.keep: object = None
24
+ if isinstance(source, (str, os.PathLike)):
25
+ self.path, self.path_len = _native.utf8(os.fspath(source))
26
+ return
27
+ self.path = None
28
+ view = memoryview(source)
29
+ if view.readonly or not view.contiguous:
30
+ data = bytes(view)
31
+ self.keep = data
32
+ self.address = ctypes.cast(ctypes.c_char_p(data), ctypes.c_void_p).value
33
+ else:
34
+ buffer = (ctypes.c_char * view.nbytes).from_buffer(view)
35
+ self.keep = (view, buffer)
36
+ self.address = ctypes.addressof(buffer)
37
+ self.length = view.nbytes
38
+
39
+ @property
40
+ def is_file(self) -> bool:
41
+ return self.path is not None
42
+
43
+
44
+ def _read_options(options: ReadOptions | None) -> _native.ReadOptions | None:
45
+ if options is None:
46
+ return None
47
+ bom = options.skip_byte_order_mark
48
+ return _native.ReadOptions(
49
+ struct_size=ctypes.sizeof(_native.ReadOptions),
50
+ header_lines=options.header_lines,
51
+ skip_invalid=int(options.skip_invalid),
52
+ degree_of_parallelism=options.degree_of_parallelism,
53
+ encoding=code_page(options.encoding),
54
+ max_line_length=options.max_line_length,
55
+ skip_byte_order_mark=_native.FW_OPT_DEFAULT if bom is None else (_native.FW_OPT_TRUE if bom else _native.FW_OPT_FALSE),
56
+ )
57
+
58
+
59
+ def _reader_options(options: ReaderOptions | None) -> _native.ReaderOptions | None:
60
+ if options is None:
61
+ return None
62
+ raw = _native.ReaderOptions(struct_size=ctypes.sizeof(_native.ReaderOptions), batch_rows=options.batch_rows)
63
+ read = _read_options(options.read)
64
+ if read is not None:
65
+ raw.read = read
66
+ return raw
67
+
68
+
69
+ def _ref(value: object) -> object:
70
+ return ctypes.byref(value) if value is not None else None
71
+
72
+
73
+ def read(source: Source, layout: Layout, options: ReadOptions | None = None) -> Table:
74
+ """Reads a whole file or buffer into a Table (see `ReadOptions` for header lines, skipping and threads)."""
75
+ lib = _native.load_library()
76
+ data = _Input(source)
77
+ raw = _read_options(options)
78
+ table = _native.Table()
79
+ result = _native.ReadResult(struct_size=ctypes.sizeof(_native.ReadResult))
80
+ if data.is_file:
81
+ status = lib.fw_read_file(data.path, data.path_len, layout.handle, _ref(raw), ctypes.byref(table), ctypes.byref(result))
82
+ else:
83
+ status = lib.fw_read_memory(data.address, data.length, layout.handle, _ref(raw), ctypes.byref(table), ctypes.byref(result))
84
+ check(status)
85
+ return Table(_Owner(table), layout.names, int(result.lines_read), int(result.lines_skipped))
86
+
87
+
88
+ def read_arrow(source: Source, layout: Layout, options: ReadOptions | None = None) -> Any:
89
+ """Reads a whole file or buffer into a `pyarrow.Table`, handed over without a copy. Requires pyarrow."""
90
+ import pyarrow
91
+
92
+ lib = _native.load_library()
93
+ data = _Input(source)
94
+ raw = _read_options(options)
95
+ array = _native.ArrowArray()
96
+ schema = _native.ArrowSchema()
97
+ if data.is_file:
98
+ status = lib.fw_read_arrow_file(data.path, data.path_len, layout.handle, _ref(raw), ctypes.byref(array), ctypes.byref(schema), None)
99
+ else:
100
+ status = lib.fw_read_arrow_memory(data.address, data.length, layout.handle, _ref(raw), ctypes.byref(array), ctypes.byref(schema), None)
101
+ check(status)
102
+ batch = pyarrow.RecordBatch._import_from_c(ctypes.addressof(array), ctypes.addressof(schema))
103
+ return pyarrow.Table.from_batches([batch])
104
+
105
+
106
+ def read_pandas(source: Source, layout: Layout, options: ReadOptions | None = None) -> Any:
107
+ """`read_arrow()` as a `pandas.DataFrame`; each Arrow buffer is freed as pandas takes it. Requires pyarrow and pandas."""
108
+ return read_arrow(source, layout, options).to_pandas(self_destruct=True, split_blocks=True)
109
+
110
+
111
+ def read_polars(source: Source, layout: Layout, options: ReadOptions | None = None) -> Any:
112
+ """`read_arrow()` as a `polars.DataFrame`, zero-copy. Without pyarrow, reads sequentially through an Arrow stream."""
113
+ import polars
114
+
115
+ try:
116
+ return polars.from_arrow(read_arrow(source, layout, options))
117
+ except ImportError:
118
+ return polars.DataFrame(read_arrow_stream(source, layout, ReaderOptions(read=options or ReadOptions())))
119
+
120
+
121
+ def read_batches(source: Source, layout: Layout, options: ReaderOptions | None = None) -> Iterator[Table]:
122
+ """Reads sequentially, yielding a Table of at most `options.batch_rows` rows at a time."""
123
+ lib = _native.load_library()
124
+ data = _Input(source)
125
+ raw = _reader_options(options)
126
+ reader = ctypes.c_void_p()
127
+ if data.is_file:
128
+ status = lib.fw_reader_open_file(data.path, data.path_len, layout.handle, _ref(raw), ctypes.byref(reader))
129
+ else:
130
+ status = lib.fw_reader_open_memory(data.address, data.length, layout.handle, _ref(raw), ctypes.byref(reader))
131
+ check(status)
132
+ try:
133
+ while True:
134
+ table = _native.Table()
135
+ status = lib.fw_reader_next(reader, ctypes.byref(table))
136
+ if status == _native.FW_EOF:
137
+ return
138
+ check(status)
139
+ yield Table(_Owner(table), layout.names)
140
+ finally:
141
+ lib.fw_reader_close(reader)
142
+ del data
143
+
144
+
145
+ def read_arrow_stream(source: Source, layout: Layout, options: ReaderOptions | None = None) -> ArrowStream:
146
+ """The batches of `read_batches()` as a single-use Arrow PyCapsule stream, for `polars.DataFrame(stream)`
147
+ or `pyarrow.RecordBatchReader.from_stream(stream)`. Needs no third-party package to create."""
148
+ lib = _native.load_library()
149
+ data = _Input(source)
150
+ raw = _reader_options(options)
151
+ stream = _native.ArrowArrayStream()
152
+ if data.is_file:
153
+ status = lib.fw_read_arrow_stream_file(data.path, data.path_len, layout.handle, _ref(raw), ctypes.byref(stream))
154
+ else:
155
+ status = lib.fw_read_arrow_stream_memory(data.address, data.length, layout.handle, _ref(raw), ctypes.byref(stream))
156
+ check(status)
157
+ return ArrowStream(stream, keep=data)
fixedwidth/table.py ADDED
@@ -0,0 +1,135 @@
1
+ """Table: a columnar read whose numeric columns are NumPy views over the native buffers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ from collections.abc import Callable
7
+ from decimal import Decimal
8
+ from typing import Any
9
+
10
+ from fixedwidth import _native
11
+ from fixedwidth.types import ColumnType
12
+
13
+ _ELEMENTS = {
14
+ ColumnType.INT64: (ctypes.c_int64, "int64"),
15
+ ColumnType.FLOAT64: (ctypes.c_double, "float64"),
16
+ ColumnType.DECIMAL: (ctypes.c_int64, "int64"),
17
+ ColumnType.DATE: (ctypes.c_int32, "int32"),
18
+ ColumnType.TIME: (ctypes.c_int64, "int64"),
19
+ ColumnType.TIMESTAMP: (ctypes.c_int64, "int64"),
20
+ }
21
+
22
+
23
+ class _Owner:
24
+ """Owns one native fw_table; frees it once nothing (the Table or any view) references it."""
25
+
26
+ def __init__(self, table: _native.Table) -> None:
27
+ self.table = table
28
+ self._on_free: Callable[[bool], object] | None = None
29
+
30
+ def view(self, address: int, element: type, count: int) -> ctypes.Array:
31
+ buffer = (element * count).from_address(address)
32
+ buffer._owner = self # type: ignore[attr-defined] # a view keeps the native memory alive
33
+ return buffer
34
+
35
+ def __del__(self) -> None:
36
+ _native.load_library().fw_free_table(ctypes.byref(self.table))
37
+ if self._on_free is not None:
38
+ self._on_free(True)
39
+
40
+
41
+ class Table:
42
+ """One read: `table[name]` is a NumPy view for numeric and date/time columns (as stored: int64,
43
+ float64, DECIMAL's unscaled int64, DATE's int32 days, TIME/TIMESTAMP's int64 microseconds) and a
44
+ list of `str` for strings. Null rows are marked by `validity(name)`. Views stay valid after the
45
+ Table is gone; the native memory is freed when the last one is."""
46
+
47
+ def __init__(self, owner: _Owner, names: list[str], lines_read: int = 0, lines_skipped: int = 0) -> None:
48
+ self._owner = owner
49
+ self._names = names
50
+ self.lines_read = lines_read
51
+ self.lines_skipped = lines_skipped
52
+
53
+ @property
54
+ def num_rows(self) -> int:
55
+ return int(self._owner.table.row_count)
56
+
57
+ @property
58
+ def column_names(self) -> list[str]:
59
+ return list(self._names)
60
+
61
+ def __len__(self) -> int:
62
+ return self.num_rows
63
+
64
+ def _column(self, name: str) -> _native.Column:
65
+ try:
66
+ return self._owner.table.columns[self._names.index(name)]
67
+ except ValueError:
68
+ raise KeyError(name) from None
69
+
70
+ def __getitem__(self, name: str) -> Any:
71
+ column = self._column(name)
72
+ kind = ColumnType(column.type)
73
+ if kind is ColumnType.STRING:
74
+ return _strings(column)
75
+ import numpy
76
+
77
+ element, dtype = _ELEMENTS[kind]
78
+ if column.length == 0:
79
+ return numpy.empty(0, dtype=dtype)
80
+ return numpy.frombuffer(self._owner.view(column.values, element, column.length), dtype=dtype)
81
+
82
+ def validity(self, name: str) -> Any:
83
+ """A NumPy bool array, True where the row holds a value, or None when the column has no nulls."""
84
+ column = self._column(name)
85
+ if not column.validity:
86
+ return None
87
+ import numpy
88
+
89
+ bits = numpy.frombuffer(ctypes.string_at(column.validity, (column.length + 7) // 8), dtype=numpy.uint8)
90
+ return numpy.unpackbits(bits, bitorder="little")[: column.length].astype(bool)
91
+
92
+ def to_arrow(self) -> Any:
93
+ """The same columns as a `pyarrow.Table`, sharing the native buffers. Requires pyarrow."""
94
+ import pyarrow
95
+
96
+ arrays = [self._arrow_column(name, pyarrow) for name in self._names]
97
+ return pyarrow.Table.from_arrays(arrays, names=self._names)
98
+
99
+ def _arrow_column(self, name: str, pa: Any) -> Any:
100
+ column = self._column(name)
101
+ kind = ColumnType(column.type)
102
+ n = int(column.length)
103
+ validity = pa.foreign_buffer(column.validity, (n + 7) // 8, base=self._owner) if column.validity else None
104
+ if kind is ColumnType.DECIMAL:
105
+ # ponytail: decimals go through Python objects; fw.read_arrow() hands them over natively.
106
+ mask = self.validity(name)
107
+ values = self[name]
108
+ decimals = [None if mask is not None and not mask[i] else Decimal(int(v)).scaleb(-column.scale) for i, v in enumerate(values)]
109
+ return pa.array(decimals, type=pa.decimal128(18, column.scale))
110
+ if kind is ColumnType.STRING:
111
+ offsets = pa.foreign_buffer(column.values, (n + 1) * 4, base=self._owner)
112
+ data = pa.foreign_buffer(column.data, column.data_len, base=self._owner) if column.data else pa.py_buffer(b"")
113
+ return pa.Array.from_buffers(pa.string(), n, [validity, offsets, data])
114
+ arrow_type = {
115
+ ColumnType.INT64: pa.int64(),
116
+ ColumnType.FLOAT64: pa.float64(),
117
+ ColumnType.DATE: pa.date32(),
118
+ ColumnType.TIME: pa.time64("us"),
119
+ ColumnType.TIMESTAMP: pa.timestamp("us"),
120
+ }[kind]
121
+ width = 4 if kind is ColumnType.DATE else 8
122
+ values = pa.foreign_buffer(column.values, n * width, base=self._owner) if n else pa.py_buffer(b"")
123
+ return pa.Array.from_buffers(arrow_type, n, [validity, values])
124
+
125
+ def __repr__(self) -> str:
126
+ return f"Table({self.num_rows} rows: {', '.join(self._names)})"
127
+
128
+
129
+ def _strings(column: _native.Column) -> list[str]:
130
+ n = int(column.length)
131
+ if n == 0:
132
+ return []
133
+ offsets = (ctypes.c_int32 * (n + 1)).from_address(column.values)
134
+ data = ctypes.string_at(column.data, column.data_len) if column.data else b""
135
+ return [data[offsets[i] : offsets[i + 1]].decode("utf-8") for i in range(n)]
fixedwidth/types.py ADDED
@@ -0,0 +1,136 @@
1
+ """The public value types: column types, column descriptions, options and errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import KW_ONLY, dataclass, field
7
+ from enum import IntEnum
8
+
9
+
10
+ class ColumnType(IntEnum):
11
+ """FW_T_*: how a column is parsed and what its values are."""
12
+
13
+ STRING = 0
14
+ INT64 = 1
15
+ FLOAT64 = 2
16
+ DECIMAL = 3
17
+ DATE = 4
18
+ TIME = 5
19
+ TIMESTAMP = 6
20
+
21
+
22
+ class TrimMode(IntEnum):
23
+ BOTH = 0
24
+ TRAILING = 1
25
+ LEADING = 2
26
+
27
+
28
+ class Alignment(IntEnum):
29
+ DEFAULT = 0
30
+ LEFT = 1
31
+ RIGHT = 2
32
+
33
+
34
+ class Overflow(IntEnum):
35
+ THROW = 0
36
+ TRUNCATE = 1
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Column:
41
+ """One column of a layout: its name, zero-based byte `start`, byte `length` and `type`.
42
+
43
+ - `nullable`: a blank column (only `trim_char` and spaces) reads as null instead of failing.
44
+ - `scale`: implied decimals. FLOAT64 divides digits-only text by 10**scale (B3's `N(11)V99`);
45
+ DECIMAL keeps the unscaled integer.
46
+ - `format`: the exact date/time format (.NET syntax, e.g. `yyyyMMdd`), or a number format on write.
47
+ - `trim_char`, `trim_mode`: what is stripped before parsing; a null is written as `trim_char`.
48
+ - `alignment`, `padding`, `overflow`: how a value is written. `padding='0'` keeps a sign leftmost.
49
+ """
50
+
51
+ name: str
52
+ start: int
53
+ length: int
54
+ type: ColumnType
55
+ _: KW_ONLY
56
+ nullable: bool = False
57
+ scale: int = 0
58
+ format: str | None = None
59
+ trim_char: str | None = None
60
+ trim_mode: TrimMode | None = None
61
+ alignment: Alignment | None = None
62
+ padding: str | None = None
63
+ overflow: Overflow | None = None
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class LayoutOptions:
68
+ """`line_length`: 0 ends lines at the last column; longer pads them with `filler` on write."""
69
+
70
+ line_length: int = 0
71
+ filler: str | None = None
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class ReadOptions:
76
+ """Mirrors fw_read_options. `encoding` is a code page or a codec name (`"latin-1"`, `"cp1252"`)."""
77
+
78
+ header_lines: int = 0
79
+ skip_invalid: bool = False
80
+ degree_of_parallelism: int = 0
81
+ encoding: int | str | None = None
82
+ max_line_length: int = 0
83
+ skip_byte_order_mark: bool | None = None
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class ReaderOptions:
88
+ """Mirrors fw_reader_options: a sequential read in tables of `batch_rows` rows (0 = 65,536)."""
89
+
90
+ read: ReadOptions = field(default_factory=ReadOptions)
91
+ batch_rows: int = 0
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class WriteOptions:
96
+ """Mirrors fw_write_options."""
97
+
98
+ encoding: int | str | None = None
99
+ newline: str = "\n"
100
+ degree_of_parallelism: int = 0
101
+ batch_rows: int = 0
102
+
103
+
104
+ class FixedWidthError(Exception):
105
+ """Raised when the native library reports a failure."""
106
+
107
+
108
+ class ParseError(FixedWidthError):
109
+ """A line failed to parse. `line_number` is 1-based; `column` is the layout index, -1 for a short line."""
110
+
111
+ def __init__(self, message: str, line_number: int, column: int) -> None:
112
+ super().__init__(message)
113
+ self.line_number = line_number
114
+ self.column = column
115
+
116
+
117
+ _CODE_PAGES = {
118
+ "utf-8": 0, "utf8": 0, "ascii": 20127, "us-ascii": 20127,
119
+ "latin-1": 28591, "latin1": 28591, "iso-8859-1": 28591, "iso8859-1": 28591,
120
+ "windows-1252": 1252, "cp1252": 1252, "iso-8859-15": 28605, "latin-9": 28605,
121
+ }
122
+
123
+
124
+ def code_page(encoding: int | str | None) -> int:
125
+ """The code page the native options take for `encoding`."""
126
+ if encoding is None:
127
+ return 0
128
+ if isinstance(encoding, int):
129
+ return encoding
130
+ name = encoding.strip().lower().replace("_", "-")
131
+ if name in _CODE_PAGES:
132
+ return _CODE_PAGES[name]
133
+ match = re.fullmatch(r"(?:cp|windows-)(\d+)", name)
134
+ if match:
135
+ return int(match.group(1))
136
+ raise ValueError(f"unknown encoding {encoding!r}; pass a code page number")
fixedwidth/writer.py ADDED
@@ -0,0 +1,183 @@
1
+ """Writing: a pandas, polars or pyarrow table through a layout, to a file or to bytes. Requires pyarrow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import os
7
+ from typing import Any
8
+
9
+ from fixedwidth import _native
10
+ from fixedwidth.layout import Layout, check
11
+ from fixedwidth.types import (
12
+ Column,
13
+ ColumnType,
14
+ FixedWidthError,
15
+ WriteOptions,
16
+ code_page,
17
+ )
18
+
19
+
20
+ def write(data: Any, path: str | os.PathLike[str], layout: Layout, options: WriteOptions | None = None) -> None:
21
+ """Writes every row of `data` (pandas, polars, pyarrow, or anything with `__arrow_c_stream__`) to `path`.
22
+
23
+ Columns are matched to the layout by name. A null writes as its column filled with `trim_char`."""
24
+ lib = _native.load_library()
25
+ table, keep = _native_table(data, layout)
26
+ raw, newline = _write_options(options)
27
+ name, name_len = _native.utf8(os.fspath(path))
28
+ check(lib.fw_write_file(name, name_len, ctypes.byref(table), layout.handle, ctypes.byref(raw) if raw else None))
29
+ del keep, newline
30
+
31
+
32
+ def write_bytes(data: Any, layout: Layout, options: WriteOptions | None = None) -> bytes:
33
+ """Same as `write()`, returning the bytes."""
34
+ lib = _native.load_library()
35
+ table, keep = _native_table(data, layout)
36
+ raw, newline = _write_options(options)
37
+ buffer = _native.Buffer()
38
+ check(lib.fw_write_memory(ctypes.byref(table), layout.handle, ctypes.byref(raw) if raw else None, ctypes.byref(buffer)))
39
+ try:
40
+ return ctypes.string_at(buffer.data, buffer.length) if buffer.length else b""
41
+ finally:
42
+ lib.fw_free_buffer(ctypes.byref(buffer))
43
+ del keep, newline
44
+
45
+
46
+ def _write_options(options: WriteOptions | None) -> tuple[_native.WriteOptions | None, object]:
47
+ if options is None:
48
+ return None, None
49
+ newline, newline_len = _native.utf8(options.newline)
50
+ raw = _native.WriteOptions(
51
+ struct_size=ctypes.sizeof(_native.WriteOptions),
52
+ encoding=code_page(options.encoding),
53
+ newline=ctypes.cast(newline, ctypes.POINTER(ctypes.c_uint8)),
54
+ newline_len=newline_len,
55
+ degree_of_parallelism=options.degree_of_parallelism,
56
+ batch_rows=options.batch_rows,
57
+ )
58
+ return raw, newline
59
+
60
+
61
+ def _arrow_table(data: Any) -> Any:
62
+ import pyarrow
63
+
64
+ if isinstance(data, pyarrow.RecordBatch):
65
+ return pyarrow.Table.from_batches([data])
66
+ if isinstance(data, pyarrow.Table):
67
+ return data
68
+ if type(data).__module__.startswith("pandas"):
69
+ return pyarrow.Table.from_pandas(data, preserve_index=False)
70
+ if hasattr(data, "__arrow_c_stream__"):
71
+ return pyarrow.table(data)
72
+ raise TypeError(f"cannot write {type(data).__name__}: pass a pandas, polars or pyarrow table")
73
+
74
+
75
+ def _native_table(data: Any, layout: Layout) -> tuple[_native.Table, list[object]]:
76
+ import pyarrow
77
+
78
+ arrow = _arrow_table(data).combine_chunks()
79
+ columns = (_native.Column * max(1, len(layout.columns)))()
80
+ keep: list[object] = [columns]
81
+ for index, spec in enumerate(layout.columns):
82
+ if spec.name not in arrow.column_names:
83
+ raise FixedWidthError(f"column '{spec.name}' of the layout is not in the data")
84
+ chunked = arrow.column(spec.name)
85
+ array = chunked.chunk(0) if chunked.num_chunks else pyarrow.array([], type=chunked.type)
86
+ _fill(columns[index], spec, array, keep)
87
+ table = _native.Table(column_count=len(layout.columns), row_count=arrow.num_rows, columns=columns)
88
+ return table, keep
89
+
90
+
91
+ def _fill(target: _native.Column, spec: Column, array: Any, keep: list[object]) -> None:
92
+ import numpy
93
+ import pyarrow
94
+
95
+ kind = ColumnType(spec.type)
96
+ target.type = int(kind)
97
+ target.scale = spec.scale if kind is ColumnType.DECIMAL else 0
98
+ target.length = len(array)
99
+ if array.null_count:
100
+ valid = numpy.packbits(~array.is_null().to_numpy(zero_copy_only=False), bitorder="little")
101
+ keep.append(valid)
102
+ target.validity = valid.ctypes.data
103
+ if kind is ColumnType.STRING:
104
+ _fill_strings(target, array.cast(pyarrow.string()).fill_null(""), keep)
105
+ return
106
+ values = numpy.ascontiguousarray(_values(kind, spec, array, pyarrow, numpy))
107
+ keep.append(values)
108
+ target.values = values.ctypes.data if len(values) else None
109
+
110
+
111
+ def _values(kind: ColumnType, spec: Column, array: Any, pa: Any, np: Any) -> Any:
112
+ if kind is ColumnType.INT64:
113
+ return array.cast(pa.int64()).fill_null(0).to_numpy(zero_copy_only=False)
114
+ if kind is ColumnType.FLOAT64:
115
+ return array.cast(pa.float64()).fill_null(0).to_numpy(zero_copy_only=False)
116
+ if kind is ColumnType.DECIMAL:
117
+ return _decimals(spec, array, pa, np)
118
+ if kind is ColumnType.DATE:
119
+ return array.cast(pa.date32()).cast(pa.int32()).fill_null(0).to_numpy(zero_copy_only=False)
120
+ if kind is ColumnType.TIME:
121
+ return array.cast(pa.time64("us")).cast(pa.int64()).fill_null(0).to_numpy(zero_copy_only=False)
122
+ if pa.types.is_timestamp(array.type) and array.type.tz is not None:
123
+ array = array.cast(pa.timestamp(array.type.unit))
124
+ return array.cast(pa.timestamp("us")).cast(pa.int64()).fill_null(0).to_numpy(zero_copy_only=False)
125
+
126
+
127
+ def _decimals(spec: Column, array: Any, pa: Any, np: Any) -> Any:
128
+ """The unscaled integers a DECIMAL column stores, each of which must fit an int64.
129
+
130
+ Decimals and integers scale exactly, and a decimal with more digits than the scale fails. Floats, binary
131
+ approximations to begin with, round half to even to the scale; NaN and infinity fail."""
132
+ if pa.types.is_decimal(array.type):
133
+ return np.array([_unscaled(v, spec) for v in array.to_pylist()], dtype=np.int64)
134
+ factor = 10**spec.scale
135
+ if pa.types.is_integer(array.type):
136
+ import pyarrow.compute
137
+
138
+ bounds = pyarrow.compute.min_max(array).as_py()
139
+ for value in (bounds["min"], bounds["max"]):
140
+ if value is not None:
141
+ _check_int64(value * factor, value, spec)
142
+ return array.cast(pa.int64()).fill_null(0).to_numpy(zero_copy_only=False) * factor
143
+ floats = array.cast(pa.float64()).fill_null(0).to_numpy(zero_copy_only=False)
144
+ with np.errstate(over="ignore", invalid="ignore"):
145
+ scaled = np.round(floats * factor)
146
+ # Written so NaN fails too; 2**63 is exact in a float64, the int64 range is not.
147
+ outside = ~((scaled >= -(2.0**63)) & (scaled < 2.0**63))
148
+ if outside.any():
149
+ value = floats[int(np.argmax(outside))]
150
+ raise FixedWidthError(f"value {value} of column '{spec.name}' is not finite or does not fit an int64 at scale {spec.scale}")
151
+ return scaled.astype(np.int64)
152
+
153
+
154
+ def _unscaled(value: Any, spec: Column) -> int:
155
+ """A decimal as the integer the column stores; more decimals than its scale would be lost, so they fail."""
156
+ if value is None:
157
+ return 0
158
+ scaled = value.scaleb(spec.scale)
159
+ if scaled != scaled.to_integral_value():
160
+ raise FixedWidthError(f"value {value} of column '{spec.name}' has more decimals than its scale {spec.scale}")
161
+ return _check_int64(int(scaled), value, spec)
162
+
163
+
164
+ def _check_int64(unscaled: int, value: Any, spec: Column) -> int:
165
+ if not -(2**63) <= unscaled < 2**63:
166
+ raise FixedWidthError(f"value {value} of column '{spec.name}' does not fit an int64 at scale {spec.scale}")
167
+ return unscaled
168
+
169
+
170
+ def _fill_strings(target: _native.Column, array: Any, keep: list[object]) -> None:
171
+ import numpy
172
+
173
+ buffers = array.buffers()
174
+ n = len(array)
175
+ offsets = numpy.frombuffer(buffers[1], dtype=numpy.int32)[array.offset : array.offset + n + 1] if n else numpy.zeros(1, numpy.int32)
176
+ base = int(offsets[0])
177
+ rebased = numpy.ascontiguousarray(offsets - base, dtype=numpy.int32)
178
+ keep.append(rebased)
179
+ keep.append(array)
180
+ target.values = rebased.ctypes.data
181
+ data_len = int(rebased[-1])
182
+ target.data_len = data_len
183
+ target.data = buffers[2].address + base if data_len and buffers[2] is not None else None
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.5
2
+ Name: fixedwidthparser
3
+ Version: 2.0.0-rc.1
4
+ Summary: Read and write fixed-width files through the FixedWidthParser NativeAOT library
5
+ Project-URL: Homepage, https://github.com/GabrielMarquezMatte/FixedWidthParser
6
+ Author: Gabriel Matte
7
+ License: MIT
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.10
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=7.0; extra == 'dev'
13
+ Provides-Extra: numpy
14
+ Requires-Dist: numpy>=1.21; extra == 'numpy'
15
+ Provides-Extra: pandas
16
+ Requires-Dist: pandas>=2.0; extra == 'pandas'
17
+ Requires-Dist: pyarrow>=14.0; extra == 'pandas'
18
+ Provides-Extra: polars
19
+ Requires-Dist: polars>=1.0; extra == 'polars'
20
+ Provides-Extra: pyarrow
21
+ Requires-Dist: pyarrow>=14.0; extra == 'pyarrow'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # fixedwidthparser
25
+
26
+ Read and write fixed-width files from Python through FixedWidthParser's NativeAOT library: no .NET runtime,
27
+ parallel reads, results as NumPy views, Arrow, pandas or polars.
28
+
29
+ ```bash
30
+ pip install "fixedwidthparser[numpy]" # fw.read: NumPy views
31
+ pip install "fixedwidthparser[pandas]" # or [pyarrow], [polars]
32
+ ```
33
+
34
+ Wheels ship for Windows x64, macOS (arm64, x64) and Linux (glibc and musl, x64 and arm64). The package installs
35
+ as `fixedwidthparser` and imports as `fixedwidth`. pyarrow has no musllinux wheels, so on Alpine and other
36
+ musl systems Arrow, pandas, polars and writing need a pyarrow built for them.
37
+
38
+ ```python
39
+ import fixedwidth as fw
40
+
41
+ # B3's COTAHIST quote history: 245-byte lines, a header (00) and a trailer (99) around the quotes (01).
42
+ layout = fw.Layout([
43
+ fw.Column("tipreg", 0, 2, fw.String),
44
+ fw.Column("data_pregao", 2, 8, fw.Date, format="yyyyMMdd"),
45
+ fw.Column("codneg", 12, 12, fw.String),
46
+ fw.Column("preabe", 56, 13, fw.Float64, scale=2), # N(11)V99: two implied decimals
47
+ fw.Column("quatot", 152, 18, fw.Int64),
48
+ ])
49
+ options = fw.ReadOptions(header_lines=1, skip_invalid=True) # the trailer fails to parse and is skipped
50
+
51
+ df = fw.read_pandas("COTAHIST_A2023.TXT", layout, options)
52
+ ```
53
+
54
+ ## Reading
55
+
56
+ | Function | Returns |
57
+ |---|---|
58
+ | `fw.read(source, layout, options)` | `fw.Table`: NumPy views over the native columns, no copy |
59
+ | `fw.read_arrow(source, layout, options)` | `pyarrow.Table` (needs pyarrow) |
60
+ | `fw.read_pandas(source, layout, options)` | `pandas.DataFrame` (needs pyarrow and pandas) |
61
+ | `fw.read_polars(source, layout, options)` | `polars.DataFrame` |
62
+ | `fw.read_batches(source, layout, fw.ReaderOptions(batch_rows=...))` | an iterator of `fw.Table` |
63
+ | `fw.read_arrow_stream(source, layout, fw.ReaderOptions(...))` | an Arrow PyCapsule stream for `polars.DataFrame(...)` or `pyarrow.RecordBatchReader.from_stream(...)` |
64
+
65
+ `source` is a path, or `bytes`/`bytearray`/`memoryview` to read from memory. Every optional setting lives in
66
+ `fw.ReadOptions`: `header_lines`, `skip_invalid`, `degree_of_parallelism` (0 = one thread per processor,
67
+ 1 = sequential), `encoding` (a code page or `"latin-1"`, `"cp1252"`, ...), `max_line_length` and
68
+ `skip_byte_order_mark`. A parallel read gives the same rows, in the same order, as a sequential one.
69
+
70
+ A line that fails to parse raises `fw.ParseError` with `line_number` and `column`, unless `skip_invalid` is set.
71
+
72
+ `fw.Table` maps a column name to a NumPy array (a list for strings), and has `num_rows`, `column_names`,
73
+ `lines_read`, `lines_skipped`, `validity(name)` (a bool array, `True` where the row holds a value, or `None`
74
+ when the column has no nulls) and `to_arrow()`, which shares the native buffers. Without pyarrow,
75
+ `read_polars` reads sequentially through the Arrow stream.
76
+
77
+ ## Columns
78
+
79
+ `fw.Column(name, start, length, type, *, nullable, scale, format, trim_char, trim_mode, alignment, padding, overflow)`.
80
+ Positions are bytes. Types: `fw.String`, `fw.Int64`, `fw.Float64`, `fw.Decimal` (int64 unscaled, Arrow
81
+ `decimal128(18, scale)`), `fw.Date`, `fw.Time`, `fw.Timestamp`. `scale` is the number of implied decimals:
82
+ `0000000123456` with `scale=2` reads as `1234.56`. `format` uses .NET date syntax (`yyyyMMdd`, `HHmmss`).
83
+ `trim_mode`, `alignment` and `overflow` take `fw.TrimMode`, `fw.Alignment` and `fw.Overflow`.
84
+
85
+ `fw.Layout(columns, fw.LayoutOptions(line_length=..., filler=...))` sets how written lines are laid out:
86
+ `line_length` past the last column pads each line, and `filler` (default a space) fills the gaps between
87
+ columns.
88
+
89
+ ## Writing
90
+
91
+ ```python
92
+ fw.write(df, "out.txt", layout, fw.WriteOptions(degree_of_parallelism=0))
93
+ data = fw.write_bytes(df, layout)
94
+ ```
95
+
96
+ Takes pandas, polars, pyarrow or anything with `__arrow_c_stream__` (needs pyarrow), matching columns by name.
97
+ `padding="0"` writes zero-padded numbers with the sign leftmost; a null writes as its column filled with
98
+ `trim_char`. Parallel writes are byte-identical to serial ones. `fw.WriteOptions` also takes `encoding`,
99
+ `newline` (default `"\n"`) and `batch_rows`.
100
+
101
+ A `fw.Decimal` column stores its value times `10**scale` as an int64. Decimals and integers are scaled
102
+ exactly, and a decimal with more digits than `scale` raises. Floats round half to even to `scale`.
103
+ A value that is NaN, infinite or outside int64 once scaled raises before anything is written.
104
+
105
+ ## Building from source
106
+
107
+ python python/scripts/build_native.py # requires the .NET 10 SDK; publishes the library into src/fixedwidth/_lib
108
+ pip install -e "python[dev,pandas,polars]"
109
+ pytest python/tests
110
+
111
+ To point at a library built elsewhere, set `FIXEDWIDTH_NATIVE_LIB` to its full path.
@@ -0,0 +1,14 @@
1
+ fixedwidth/__init__.py,sha256=Yo5En8tVtsIsJ3Vhjbev8VVfiar-0yV836M5XZZfVT0,1545
2
+ fixedwidth/_native.py,sha256=IsamIha2UfosWu8JudFpZ7dD6sJQv50F7ISj8nP0Ba0,9061
3
+ fixedwidth/arrow_stream.py,sha256=Ojc-ib_QiDwnR1a5fjsEQwnIySTG34GaYgd8hEilZkY,4363
4
+ fixedwidth/layout.py,sha256=inffqA57gvwvgmai-V04TzFbtz5ubaAYRR1frh8XPLs,3589
5
+ fixedwidth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ fixedwidth/reader.py,sha256=Q72J1HSWBM5caXb8Lv0iKrqOkyzKoSKXUs03finGIqo,6650
7
+ fixedwidth/table.py,sha256=OSZpckLf0ASSMfAENsDftELQZS9wmcuNJFCo6jKvmS0,5577
8
+ fixedwidth/types.py,sha256=ngVbA_dbQ5w-LYt34xDhXKEdisBQhNkcPGvvRR8551c,3805
9
+ fixedwidth/writer.py,sha256=-J1eGoM8-5YB_4hHRns-vrKSbsnsdvpNBkijFAGVkBw,7920
10
+ fixedwidth/_lib/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ fixedwidth/_lib/FixedWidthParser.Native.so,sha256=JS-FpvwERrTTU_9hIb6VkqATLhNjagm1VeA6yAozJUI,2835112
12
+ fixedwidthparser-2.0.0rc1.dist-info/METADATA,sha256=tSU9wrY_K82WZ6_p8rlhgGwaMD6_9bgI2iyH9laf36E,5493
13
+ fixedwidthparser-2.0.0rc1.dist-info/WHEEL,sha256=nSd-eRkXZTOqTvd1bvymP_AHnC4IPCw2ZzZa9_zk8tQ,106
14
+ fixedwidthparser-2.0.0rc1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-musllinux_1_2_aarch64