Skip to content

release: FixedWidthParser 2.0.0 - #82

Merged
GabrielMarquezMatte merged 96 commits into
masterfrom
develop
Oct 26, 2026
Merged

GabrielMarquezMatte merged 96 commits into
masterfrom
develop

Conversation

@GabrielMarquezMatte

Copy link
Copy Markdown
Owner

Summary

Merges develop into master for the 2.0.0 release. Almost all of it is the 2.0 work from #79: a bytes-only API with a single FixedWidth entry point, a NativeAOT C library, and Python and C++ bindings over it. The rest is dependency updates and CI changes.

Full details are in #79, and the user-facing changes, with a 1.x → 2.0 migration table, are in the CHANGELOG.

What's in it

Validation

  • All required checks pass on develop: Build & Test (Linux, Windows, macOS), NativeAOT smoke, CodeQL (C#, Python), Bindings result.
  • 2.0.0-rc.1 published from develop through the release workflow: NuGet 2.0.0-rc.1, PyPI 2.0.0rc1, and native libraries on the GitHub Release.
  • The published RC works when installed fresh:
    • PyPI wheel in a clean venv: read_pandas, read_polars, read_arrow, and a byte-identical write_bytes round trip.
    • NuGet package in a new net10.0 app: the source generator ships and runs, and generated and attribute-layout reads give the same rows.
  • RC used on real workloads with no API changes needed.
  • Final review of PublicAPI.Unshipped.txt.

Release steps

  1. Change the CHANGELOG's "Not released yet." to the release date.
  2. Merge this PR with a merge commit (not squash), so master keeps the history the tag points at.
  3. Tag v2.0.0 on the merge commit. The release workflow publishes to NuGet, PyPI and the GitHub Release.
  4. Merge the chore: promote public API for v2.0.0 PR that the workflow opens into develop.
  5. On develop, re-enable package validation with baseline 2.0.0 (see the note in src/FixedWidthParser/FixedWidthParser.csproj).

dependabot Bot and others added 30 commits July 30, 2026 14:57
Bumps Meziantou.Analyzer from 3.0.123 to 3.0.137
Bumps SonarAnalyzer.CSharp from 10.29.0.143774 to 10.31.0.145097

---
updated-dependencies:
- dependency-name: Meziantou.Analyzer
  dependency-version: 3.0.137
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: analyzers
- dependency-name: SonarAnalyzer.CSharp
  dependency-version: 10.31.0.145097
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: analyzers
...

Signed-off-by: dependabot[bot] <support@github.com>
---
updated-dependencies:
- dependency-name: Microsoft.Testing.Extensions.TrxReport
  dependency-version: 2.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Microsoft.Testing.Extensions.TrxReport
  dependency-version: 2.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
…lop/analyzers-cc99cccae6

Bump the analyzers group with 2 updates
…s/FixedWidthParser.Generator.Tests/develop/multi-664984852b

Bump Microsoft.Testing.Extensions.TrxReport from 2.3.2 to 2.3.3
---
updated-dependencies:
- dependency-name: CsCheck
  dependency-version: 4.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
---
updated-dependencies:
- dependency-name: Microsoft.Testing.Extensions.CodeCoverage
  dependency-version: 18.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: Microsoft.Testing.Extensions.CodeCoverage
  dependency-version: 18.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps xunit.runner.visualstudio from 3.1.5 to 4.0.1
Bumps xunit.v3.mtp-v2 from 3.2.2 to 4.0.2

---
updated-dependencies:
- dependency-name: xunit.runner.visualstudio
  dependency-version: 4.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: test
- dependency-name: xunit.runner.visualstudio
  dependency-version: 4.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: test
- dependency-name: xunit.v3.mtp-v2
  dependency-version: 4.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: test
- dependency-name: xunit.v3.mtp-v2
  dependency-version: 4.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: test
...

Signed-off-by: dependabot[bot] <support@github.com>
---
updated-dependencies:
- dependency-name: System.IO.Pipelines
  dependency-version: 10.0.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
…FixedWidthParser/develop/System.IO.Pipelines-10.0.13

Bump System.IO.Pipelines from 10.0.10 to 10.0.13
…s/FixedWidthParser.Generator.Tests/develop/test-8eaacfb595

Bump the test group with 2 updates
…s/FixedWidthParser.Generator.Tests/develop/multi-203f4b367c

Bump Microsoft.Testing.Extensions.CodeCoverage from 18.9.0 to 18.12.0
…s/FixedWidthParser.Tests/develop/CsCheck-4.9.1

Bump CsCheck from 4.7.0 to 4.9.1
2.0 keeps one parse path. The char parser, FixedWidthUtf8, FixedWidthByteReader,
Utf8FixedWidthParser, IUtf8FixedWidthModel and IUtf8FixedWidthConverter are gone:
there is one parser, one reader, one writer and one generated TryParse/TryFormat,
all over bytes. Column positions are bytes; text columns, dates parsed with a
format and types that only parse from characters are decoded with an
ASCII-compatible encoding, UTF-8 by default. The writer formats straight to the
stream, batched into one pooled buffer, and never splits a multi-byte character
when it truncates. The package targets net10.0 only.

Reads take FixedWidthReaderOptions (format provider, encoding, string pool,
buffer size, MaxLineLength, OnParseError) and the writer takes
FixedWidthWriterOptions (format provider, encoding, new line). Generated code
and IFixedWidthConverter<T> receive a FixedWidthContext by in reference, so a
later setting arrives as a property instead of a new parameter.

A line that fails to parse throws FixedWidthParseException, a FormatException
that names the line, the column, its position and its raw text, or says how
short the line is. The reader learns the column by parsing the failed line again
with a failure sink, so lines that parse pay nothing. OnParseError can skip the
line instead.

MaxLineLength bounds the line buffer on stream, async and pipe reads. A source
without line breaks used to double the buffer until memory ran out.

The generator targets netstandard2.0, reports FWP012 for a non-ASCII Padding or
TrimChar, and its tests now compile the generated code. The library tracks its
public API (PublicAPI.*.txt), validates the package against 1.1.1 with the
deliberate breaks listed in CompatibilitySuppressions.xml, and builds with
warnings as errors.
Packing runs package validation, so a removed or changed public member fails the
pull request instead of the release.
The README describes the byte-only reader and writer, the options, the errors,
encodings and the versioning rules. The changelog separates what shipped in
1.1.0 and 1.1.1 from 2.0, with a migration table. CONTRIBUTING explains the
public API tracking and package validation.
… doubles as scaled integers

IUtf8SpanFormattable columns format directly into the line's slice and are
aligned in place, with no char buffer, transcode or copy. The UTF-8 bytes are
kept when the encoding is UTF-8 or they are ASCII; otherwise the value goes
through the configured encoding as before.

A double with no Format, at most four decimals and a magnitude under 1e9 is
written as a scaled integer, byte-identical to double.TryFormat, for any
culture whose decimal separator is one ASCII character and negative sign is
"-". Left-aligned strings under UTF-8 transcode straight into the slice.
Extract was over MA0051's 90-line limit. The per-member work moves to
ExtractColumn, which returns null for a member that is not a mapped column.
Expression-bodied methods, constructors, operators and local functions and
if without braces warn in the IDE; properties and their accessors are exempt
(IDE0027 off). Private consts are PascalCase. MA0051 is enabled at 90 lines /
50 statements instead of suppressed. .gitattributes pins eol=lf so a blob's
line endings never depend on who committed it.
STYLEGUIDE.md and ARCHITECTURE.md follow ExcelReader's, adapted to this
library's twins (runtime and generated, stream/async/pipe readers) and its
untrusted-input rules, and add the rule to avoid else. docs/ holds the
measurements behind design decisions, starting with the writer. CONTRIBUTING
and SECURITY are rewritten in the same shape, and a code of conduct is added.
FixedWidthReader<T>.AggregateFileAsync and FixedWidth.AggregateFileAsync cut a
file into byte ranges, fold each on a worker thread into an
IRecordAccumulator<TSelf, TModel>, and merge the accumulators in file order.

A line belongs to the range its first byte falls in. Fixed-width lines have no
quoting, so a worker that starts one byte before its range and skips the line
it lands inside finds the exact boundary: every line is parsed exactly once.
Line numbers in errors match a sequential read; they are counted only when a
line fails. One range is the sequential read, so DegreeOfParallelism = 1 and
small files run the same code.
FixedWidthReader<T>.AggregateAsync(Stream) and FixedWidth.AggregateAsync fold a
stream across several threads. A stream cannot be read at an offset, so one
producer reads it into pooled 256 KB chunks cut after the last line break and
hands them through a bounded queue to the workers; the accumulators are merged
in chunk order.

The producer counts the line breaks it hands over, so every chunk knows the
number of its first line and errors carry the sequential line number. A
producer failure lies past every chunk it emitted, so a worker's failure is
thrown first. DegreeOfParallelism = 1 reads with the async enumerator.

LineBufferState.LineNumber becomes a long: a chunk's first line number can pass
int.MaxValue, and so could the sequential reader's count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The dedicated PipeRecordEnumerator read a 104 MB file in 472 ms; the same
pipe through PipeReader.AsStream() into the Stream reader took 187-230 ms. It
also duplicated every line rule LineBufferState implements, so removing it
leaves one line splitter for every reader. Measurements are in
docs/pipe-reader.md.

BREAKING CHANGE: ReadAsync(PipeReader) is removed from FixedWidthReader<T> and
FixedWidth, with FixedWidthPipeRecordEnumerable and PipeRecordEnumerator. Pass
pipeReader.AsStream() to the Stream overloads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
FixedWidthLayout.FromAttributes<T>() builds a model's column parsers and
formatters once and caches them; FixedWidth.TryParse and TryFormat take a
layout. The runtime parser and writer now sit on top of it. A model that only
one side can handle keeps that side's error until the side is first used.
FixedWidth.Read, ReadFile, ReadAsync, ReadFileAsync, AggregateFileAsync and
AggregateAsync gain a layout overload next to each generated one. A
LayoutLineParser<T> strategy replaces ReflectionLineParser<T>, so the
enumerators and both parallel aggregators are unchanged.
FixedWidth.Write, WriteMany, WriteAsync and WriteManyAsync each come as a
generated overload and a layout overload. Batching moves out of
FixedWidthWriter<T> into RecordWriter, generic over an IRecordFormatter<T>
struct like the read side. A generated model has no known line length, so a
record that does not fit flushes the batch and, if still too long, grows it.
Generated models can now write to a stream.
FixedWidthLayoutBuilder<T> binds each column with a ColumnGetter and a
ColumnSetter: Column (string or UTF-8 parsable), DecodedColumn,
NullableColumn, ConvertedColumn and Factory. Per-column settings come in
FixedWidthColumnOptions, with FixedColumnAttribute's defaults. The builder
reaches the same typed parse and format builders as the attribute path, by
generics instead of reflection, so the two produce identical results and Build
stays trimming- and AOT-safe.
FixedWidthParser<T>, FixedWidthReader<T> and FixedWidthWriter<T> are removed.
Attribute-mapped models are read and written through
FixedWidth.*(..., FixedWidthLayout.FromAttributes<T>(), ...), the same
overloads fluent layouts use. Tests and benchmarks are migrated; the package
validation suppressions now list the removed types.

BREAKING CHANGE: replace new FixedWidthReader<T>(options).ReadFile(path) with
FixedWidth.ReadFile(path, FixedWidthLayout.FromAttributes<T>(), options), and
likewise for the parser and the writer.
…write timings

tests/AotSmoke builds a layout with FixedWidthLayout.Build and round-trips
1,000 records through FixedWidth; publishing it with NativeAOT must report no
trimming or AOT warning. docs/fluent-layout.md compares reading and writing
through layouts with the removed classes: every figure is within noise.
README shows reading and writing through FixedWidth with a layout and adds a
section on fluent layouts. ARCHITECTURE describes the layout object, its two
sources and RecordWriter. STYLEGUIDE and CHANGELOG follow the removal of the
runtime classes.
Under the parallel test run, the per-line allocation guard sometimes saw a
one-off ~8 KB allocation on its thread during one read (2.02 B/line over
4,000 extra lines); the baseline commit shows the same kind of failure. The
guard now keeps the smallest of three measurements: a real per-line
allocation still shows in all three, as a deliberately injected 24 B/line
one does.
GabrielMarquezMatte and others added 17 commits October 24, 2026 17:30
- ColumnParserFactory: nine near-identical parse-then-assign builders
  become one Bind over a ColumnValueParser, fed the FixedWidthRuntime
  helpers as method groups; ForString and ForConverter stop forwarding.
- ColumnFormatOptions.WriteInto reuses AlignInPlace instead of repeating
  the alignment and sign-leftmost logic.
- LineBufferState.Fill(Stream) replaces the refill loop copied in the
  sync enumerator and the native table reader.
- ResolveOverflow becomes one switch expression; the unused
  ParserBuilder.ComputeRequiredLineLength is gone.
- FixedColumnAttribute docs no longer mention the removed
  IUtf8FixedWidthConverter<T>.
EpochDay and Pow10 were declared in both ColumnBuilder and
TableFormatter; they move to NativeType. NativeLayout and WriteSettings
each had the same Fail helper; NativeApi.InvalidArgument replaces both.
GrowableNativeBuffer loses Last, CopyTo and ByteLength, which nothing
called.
The hand-written Equals and GetHashCode over fourteen fields become the
compiler's, with an IsExternalInit polyfill for netstandard2.0. ModelInfo
keeps its own equality, since it leaves Location out. Drops a stale
comment on ByteLiteral.
A date or time format made only of fixed-width digit fields (yyyy, MM,
dd, HH, mm, ss) and the literals - . T / : is compiled once per column
into a FastDate that reads the bytes directly, as ExcelReader's FastDate
reads ISO 8601. It steps aside for a non-Gregorian calendar, and for "/"
or ":" when the culture's separators differ. DateTime, DateOnly,
TimeOnly and native TIMESTAMP columns use it: 85 -> 18 ns per value.

int and long columns of plain digits, and the native INT64, implied-
decimal FLOAT64 and DECIMAL columns, go through FastInteger, which
reuses FastDouble's eight-digit step: 14 -> 9 ns per value. A leading
"-" is taken only when the culture's negative sign is "-".

Anything either refuses goes to the BCL. New tests check both against
it in cultures where the fast paths must not apply (th-TH, de-DE,
sv-SE). A single-threaded COTAHIST read through Python drops from 0.920
to 0.678 s; the parallel read barely moves (0.261 -> 0.250 s).
Timed inside the native read, every partition stayed busy but slowed as
threads were added (1.0x at 2, 1.7x at 8, 3x at 16), with an in-memory
source no better than a file. A read writes ~200 MB of columns: 50,000
demand-zero page faults on one thread, 100,000 in parallel because the
concatenation copies everything into fresh memory again.

- A partition sizes its column buffers once from its byte count, which
  fixed-width lines turn into a row count, instead of doubling them
  from 256 elements.
- skip_invalid skips a bad line through an internal OnSkip hook instead
  of building its exception: numbering B3's trailer counted line breaks
  from the start of the file on one thread (~27 ms).
- Publish concatenates one column per thread.

COTAHIST through Python, 1M lines: fw.read 0.250 -> 0.169 s, one thread
0.678 -> 0.580 s.
bench_write.py reads a synthetic COTAHIST file once into pandas, polars
and pyarrow tables, then times each writer turning the table back into
its record lines, asserting every one reproduces the file's body byte
for byte. pandas and polars have no fixed-width writer, so the baselines
are numpy.savetxt with printf fields and polars pad + concat_str.

The generator now caps a day's quantity at 10^9 shares: its 16-digit
volumes did not survive a float64, so no writer could reproduce them.

1M lines: fw.write (pyarrow) 0.259 s, polars 0.722 s, savetxt 4.285 s.
…n parallel

At 16 threads fw_write_file took 240 ms where fw_write_memory took 141,
with the two equal on one thread: Windows runs one synchronous I/O at a
time on a handle, so the parallel batches queued. The file is now opened
for asynchronous I/O; each batch still waits for its write, but they run
together (240 -> 170 ms).

A right-aligned int or long with no format writes its digits from the
right straight into the column, then the padding, with "-" kept leftmost
(19 -> 9 ns): formatting at the start and aligning after cost a copy.
It applies only when the provider's negative sign is "-".

A date or time whose format is only digit fields reuses the reader's
compiled FastDate to write its digits (47 -> 23 ns).

A test checks both against the BCL's char path in four cultures, both
alignments, and zero and space padding. COTAHIST through Python, 1M
lines: fw.write 0.259 -> 0.162 s, one thread 0.838 -> 0.550 s.
A whole-source read allocates the final columns once and lends each
partition the slice its byte offset gives, with the first line's length
as the stride. Numeric, date and time values and STRING bytes are parsed
in place; publishing moves each partition down to close the gaps skipped
lines and trimmed text leave, and hands the buffers over. A partition
that outgrows its slice copies its column out, and that column is
concatenated as before. STRING offsets are still copied.

A parallel COTAHIST read through Python: fw.read 0.184 -> 0.153 s,
fw.read_polars 0.198 -> 0.165 s.
Follows ExcelReader's release workflow. A v* tag now builds the native
library on Windows x64, macOS arm64 and x64, and Linux glibc and musl on
x64 and arm64; tests and builds one wheel per platform with the tag's
version; publishes the NuGet package, the wheels to PyPI through Trusted
Publishing, and the native libraries and an SBOM to the GitHub Release.
A tag with a "-" is a prerelease.

check_wheel_tag.py and build_musl_wheel.sh come from ExcelReader. pyarrow
has no musllinux wheels, so the pandas, polars, Arrow and write tests
skip without it and the musl job tests the NumPy read.
Follows ExcelReader's workflows.

- benchmark-pr is opt-in through the run-benchmarks label, reads its groups
  from tests/Benchmarks/benchmark-groups.json (shared with benchmark-pages),
  guards that every benchmark belongs to a group, and comments a delta
  against the published master series. benchmark-pages runs only when its
  paths change. The stale IntHeavyParserBenchmarks pattern is gone.
- ci filters by path and skips drafts. The required Build & Test jobs
  always start and skip their steps. Linux builds the solution with the
  analyzers, checks whitespace, packs and runs every test with coverage;
  Windows and macOS build and run the library tests without analyzers.
  The dependency audit moves to audit.yml, weekly and on dependency changes.
- codeql filters by path the same way, adds Python, and builds only the
  shipped projects without analyzers.
- native-bindings splits into Python, C++ and musl jobs with their own
  filters, tests every platform a release ships, and builds the wheel.
- codecov.yml flags C# and Python coverage with carryforward;
  coverage.runsettings leaves out generated code and the C ABI exports.
- release promotes PublicAPI.Unshipped to Shipped in a PR after a stable tag.
- global.json pins SDK 10.0.402, which every workflow now installs and caches.
- Dependabot groups follow the test and benchmark packages in use.
feat!: FixedWidthParser 2.0 — bytes-only API, native library and Python package
---
updated-dependencies:
- dependency-name: Microsoft.Testing.Extensions.TrxReport
  dependency-version: 2.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
---
updated-dependencies:
- dependency-name: Microsoft.Testing.Extensions.TrxReport
  dependency-version: 2.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
@codecov

codecov Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.92986% with 135 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.37%. Comparing base (843a06f) to head (85fd766).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
...dWidthParser.Generator/FixedWidthModelGenerator.cs 88.50% 16 Missing and 14 partials ⚠️
src/FixedWidthParser/Infrastructure/FastDouble.cs 90.24% 11 Missing and 9 partials ⚠️
...xedWidthParser/Infrastructure/FixedWidthRuntime.cs 91.74% 11 Missing and 7 partials ⚠️
python/src/fixedwidth/table.py 88.76% 10 Missing ⚠️
python/src/fixedwidth/writer.py 93.67% 10 Missing ⚠️
python/src/fixedwidth/_native.py 91.74% 9 Missing ⚠️
python/src/fixedwidth/reader.py 95.45% 7 Missing ⚠️
...FixedWidthParser/Infrastructure/Utf8StringCache.cs 86.11% 2 Missing and 3 partials ⚠️
...rc/FixedWidthParser/Formatters/FormatterBuilder.cs 91.83% 2 Missing and 2 partials ⚠️
python/src/fixedwidth/arrow_stream.py 96.20% 3 Missing ⚠️
... and 12 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master      #82      +/-   ##
==========================================
+ Coverage   89.18%   94.37%   +5.19%     
==========================================
  Files          37       44       +7     
  Lines        1738     2917    +1179     
  Branches      322      478     +156     
==========================================
+ Hits         1550     2753    +1203     
+ Misses        117      108       -9     
+ Partials       71       56      -15     
Flag Coverage Δ
csharp 94.35% <93.66%> (?)
python 94.43% <94.43%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

GabrielMarquezMatte and others added 11 commits October 24, 2026 23:25
…ve library

Follows ExcelReader's C++ packaging. The wrapper resolves the native
library from FIXEDWIDTH_NATIVE_LIB, then the repo's published copy, then
the GitHub Release asset for FIXEDWIDTH_VERSION (the checked-out tag by
default), saved under the library's own name so the Windows import
library and the Linux SONAME match it. Consumers need no .NET SDK.

FIXEDWIDTH_INSTALL installs the headers, the library, the Windows import
library and a fixedwidth config package; find_package(fixedwidth) never
downloads. cpp/tests/package builds against an installed prefix, and the
C++ CI job now installs and runs it on Linux, Windows and macOS. The
README covers FetchContent, the installed package and the settings.
As ExcelReader does: an enum column reads a member's name in any case or
its number, zero-padded codes included (02), and rejects a number the
enum does not define and a combination of [Flags] members. It writes the
name, or the number with Format = "D".

FixedWidthRuntime.TryEnum ports ExcelReader's EnumCache, one frozen
case-insensitive map of names and numbers per enum, and widens ASCII text
straight to chars, so a column costs what an int column does (~17 ns,
against ~29 ns for Enum.TryParse with Enum.IsDefined). Attribute layouts
and generated models take an enum property as it is, nullable included;
fluent layouts add EnumColumn and NullableEnumColumn.
Follows ExcelReader's xl_stream. An fw_stream is a read callback, which
returns the bytes it copied, 0 at the end or -1 after
fw_set_source_error, and an optional release the library calls once it
is done, including when opening fails. fw_reader_open_stream and
fw_read_arrow_stream_stream read it forward in constant memory, skipping
the header lines through a buffer instead of seeking.

Python's readers take any object with readinto() or read(): read_batches
and read_arrow_stream pull from it as batches are taken, the whole-table
reads read it into memory first, and an io.BytesIO is read in place from
its position. A Python exception in read() reaches the caller with its
message. The C++ wrapper takes a std::istream& in Reader::open_stream,
TypedReader<T>::open_stream and read_arrow_stream.
A fresh 1,000,000-line run against the current build: fw.read takes
0.125 s, 123x pandas.read_fwf, read_pandas 96x and read_polars 114x, and
fw.write 0.148 s. The README and CHANGELOG no longer claim 52x, and the
benchmark page lists the versions it ran with.

The Python and native READMEs now say what holds across threads: every
call releases the GIL for its native work, a layout is immutable and
shared, a failure's message and line number belong to the thread whose
call failed, a reader is used by one thread at a time, and many calls at
once are better off with degree_of_parallelism 1 each.
The partitioned read parses into slices of the final columns sized from
the first line's length, then closes the gaps in place or copies a
column out when a slice overflows. A CsCheck property drives every path
through it with 2,000 random files: invalid, empty and short lines,
trailing bytes and CRLF that change the line length, a byte order mark,
header lines, multi-byte text, and Latin-1 text longer as UTF-8 than its
column. Whatever the chunk size and thread count, the table, the skipped
count and a failure's line and column must be the sequential read's.

Breaking the in-place compaction or the copy out of an outgrown slice
on purpose fails it.
The last copy a partitioned read made was each STRING column's offsets.
The arena now lends them as well: a slice's index 0 is the previous
slice's last offset, so only the first slice writes the column's leading
0, a later one claims its index 0 without writing it, and Truncate knows
row 0 starts at 0 instead of reading it. Publishing rebases the offsets
in place, moving down in order as values do; a slice that outgrows its
room copies out and the column is copied as before.

The copy was ~32 MB on the COTAHIST benchmark: fw.read 0.125 -> 0.121 s,
within noise. The property test crashes when Truncate reads index 0.
A column of few distinct values (a record type, a market code, a
currency) costs far less as a category than as strings. fw_column_spec
gains dictionary, allowed on STRING columns only: the Arrow reads and
streams hand such a column over as dictionary<int32, utf8>, indices in
the order values first appear, so pandas reads it as category and polars
as Categorical. Each column is encoded once at export with an
open-addressing table, the columns in parallel; a stream batch carries a
dictionary of its own. fw_table and fw.read keep plain strings, and
Table.to_arrow() encodes the same columns. Categorical input writes like
strings.

Python takes fw.Column(dictionary=True), C++ ColumnSpec::dictionary. On
the synthetic COTAHIST file with all nine string columns encoded, a read
costs 23-41 ms more and the pandas frame drops from 294 to 209 MiB; its
values are mostly unique, so real low-cardinality columns save more.

fw_column_spec grows from 72 to 80 bytes; the C smoke test pins it.
fw_write_stream pushes the lines to an fw_sink, a write callback that
takes every byte it is given and returns 0, or -1 after
fw_set_source_error. A window of one batch per thread formats in
parallel, then goes to the sink in order from the calling thread, so
memory stays at a window of batches whatever the table's size.

Python's fw.write takes a binary stream as well as a path: an open file,
gzip.open(..., "wb"), a socket's makefile. The standard streams get the
native buffer itself; anything else gets a copy it may keep. Writing to
an open file takes 0.150 s on the 1M-line benchmark against 0.146 s for
its path. A Python exception in write() reaches the caller with its
message. The C++ wrapper has write_stream(std::ostream&, ...).

The C smoke test now also pins fw_stream and fw_sink and checks every
stream export resolves.
…s/FixedWidthParser.Tests/develop/Microsoft.Testing.Extensions.TrxReport-2.5.1

Bump Microsoft.Testing.Extensions.TrxReport from 2.3.3 to 2.5.1
…s/FixedWidthParser.Generator.Tests/develop/Microsoft.Testing.Extensions.TrxReport-2.5.1

Bump Microsoft.Testing.Extensions.TrxReport from 2.3.3 to 2.5.1
@GabrielMarquezMatte
GabrielMarquezMatte merged commit 04f7cab into master Oct 26, 2026
36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants