kvsections 0.1.0__tar.gz

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.
Files changed (42) hide show
  1. kvsections-0.1.0/.gitattributes +5 -0
  2. kvsections-0.1.0/.github/workflows/ci.yml +61 -0
  3. kvsections-0.1.0/.github/workflows/release.yml +43 -0
  4. kvsections-0.1.0/.gitignore +9 -0
  5. kvsections-0.1.0/.python-version +1 -0
  6. kvsections-0.1.0/CLAUDE.md +112 -0
  7. kvsections-0.1.0/LICENSE +21 -0
  8. kvsections-0.1.0/PKG-INFO +316 -0
  9. kvsections-0.1.0/README.md +298 -0
  10. kvsections-0.1.0/pyproject.toml +63 -0
  11. kvsections-0.1.0/src/kvsections/__init__.py +76 -0
  12. kvsections-0.1.0/src/kvsections/converters.py +222 -0
  13. kvsections-0.1.0/src/kvsections/document.py +441 -0
  14. kvsections-0.1.0/src/kvsections/errors.py +26 -0
  15. kvsections-0.1.0/src/kvsections/fields.py +216 -0
  16. kvsections-0.1.0/src/kvsections/layout.py +119 -0
  17. kvsections-0.1.0/src/kvsections/model.py +190 -0
  18. kvsections-0.1.0/src/kvsections/py.typed +0 -0
  19. kvsections-0.1.0/src/kvsections/reader.py +173 -0
  20. kvsections-0.1.0/src/kvsections/records.py +144 -0
  21. kvsections-0.1.0/src/kvsections/writer.py +149 -0
  22. kvsections-0.1.0/tests/conftest.py +15 -0
  23. kvsections-0.1.0/tests/helpers.py +91 -0
  24. kvsections-0.1.0/tests/samples/blank_lines.txt +27 -0
  25. kvsections-0.1.0/tests/samples/golden.txt +14 -0
  26. kvsections-0.1.0/tests/samples/no_wrap.txt +10 -0
  27. kvsections-0.1.0/tests/samples/test1a.txt +13 -0
  28. kvsections-0.1.0/tests/samples/test2a.txt +13 -0
  29. kvsections-0.1.0/tests/samples/test2d.txt +12 -0
  30. kvsections-0.1.0/tests/samples/test3a.txt +13 -0
  31. kvsections-0.1.0/tests/samples/test3d.txt +12 -0
  32. kvsections-0.1.0/tests/samples/test4a.txt +13 -0
  33. kvsections-0.1.0/tests/samples/test4d.txt +12 -0
  34. kvsections-0.1.0/tests/test_converters.py +262 -0
  35. kvsections-0.1.0/tests/test_document.py +363 -0
  36. kvsections-0.1.0/tests/test_layout.py +129 -0
  37. kvsections-0.1.0/tests/test_model.py +249 -0
  38. kvsections-0.1.0/tests/test_reader.py +239 -0
  39. kvsections-0.1.0/tests/test_records.py +51 -0
  40. kvsections-0.1.0/tests/test_samples.py +122 -0
  41. kvsections-0.1.0/tests/test_writer.py +239 -0
  42. kvsections-0.1.0/uv.lock +860 -0
@@ -0,0 +1,5 @@
1
+ # Source and docs are LF everywhere; ruff enforces it and Windows checkouts
2
+ # must not convert them. Sample files are fixtures whose line endings are
3
+ # part of the test, so git must store and check them out verbatim.
4
+ * text=auto eol=lf
5
+ tests/samples/** -text
@@ -0,0 +1,61 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ lint:
14
+ name: ruff and mypy
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0 # hatch-vcs derives the version from tags
20
+ - uses: astral-sh/setup-uv@v5
21
+ with:
22
+ python-version: "3.9"
23
+ enable-cache: true
24
+ - run: uv sync --locked --group dev
25
+ - run: uv run ruff check
26
+ - run: uv run ruff format --check
27
+ - run: uv run mypy
28
+
29
+ test:
30
+ name: pytest (${{ matrix.os }}, ${{ matrix.python }})
31
+ strategy:
32
+ fail-fast: false
33
+ matrix:
34
+ os: [ubuntu-latest, windows-latest, macos-latest]
35
+ python: ["3.9", "3.10", "3.11", "3.12", "3.13"]
36
+ runs-on: ${{ matrix.os }}
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+ with:
40
+ fetch-depth: 0 # hatch-vcs derives the version from tags
41
+ - uses: astral-sh/setup-uv@v5
42
+ with:
43
+ python-version: ${{ matrix.python }}
44
+ enable-cache: true
45
+ - run: uv sync --locked --group dev
46
+ - run: uv run pytest --cov
47
+
48
+ build:
49
+ name: build and import the wheel
50
+ runs-on: ubuntu-latest
51
+ steps:
52
+ - uses: actions/checkout@v4
53
+ with:
54
+ fetch-depth: 0 # hatch-vcs derives the version from tags
55
+ - uses: astral-sh/setup-uv@v5
56
+ with:
57
+ enable-cache: true
58
+ - run: uv build
59
+ - run: >
60
+ uv run --isolated --no-project --with dist/*.whl
61
+ python -c "import kvsections; print(kvsections.__version__)"
@@ -0,0 +1,43 @@
1
+ name: Release
2
+
3
+ # Publishes to PyPI through trusted publishing (OIDC): no API token is stored
4
+ # anywhere. One-time setup on PyPI: add a trusted publisher for this project
5
+ # with owner "jolsten", repository "kvsections", workflow "release.yml" and
6
+ # environment "pypi", and create the "pypi" environment in the GitHub repo
7
+ # settings. Then a release is just a tag: `git tag vX.Y.Z && git push --tags`.
8
+ # The version is derived from that tag by hatch-vcs at build time.
9
+
10
+ on:
11
+ push:
12
+ tags: ["v*"]
13
+
14
+ jobs:
15
+ build:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ with:
20
+ fetch-depth: 0 # hatch-vcs derives the version from tags
21
+ - uses: astral-sh/setup-uv@v5
22
+ - run: uv build
23
+ - name: Show the version that will be published
24
+ run: ls dist/
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: dist
28
+ path: dist/
29
+
30
+ publish:
31
+ needs: build
32
+ runs-on: ubuntu-latest
33
+ environment: pypi
34
+ permissions:
35
+ id-token: write
36
+ contents: read
37
+ steps:
38
+ - uses: actions/download-artifact@v4
39
+ with:
40
+ name: dist
41
+ path: dist/
42
+ - uses: astral-sh/setup-uv@v5
43
+ - run: uv publish
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ dist/
6
+ *.egg-info/
7
+ .coverage
8
+ .ruff_cache/
9
+ .mypy_cache/
@@ -0,0 +1 @@
1
+ 3.9
@@ -0,0 +1,112 @@
1
+ # kvsections
2
+
3
+ A zero-dependency Python 3.9+ library that reads and writes a fixed-width text
4
+ format: records of (conventionally) 80 columns, a section name in column 1,
5
+ indented continuation records, and `KEY=VALUE` pairs or free text after the
6
+ name. `tests/samples/golden.txt` is the reference file. MIT licensed.
7
+
8
+ ## Commands
9
+
10
+ Run all four before calling a change done. Coverage fails below 95% and mypy
11
+ runs in strict mode; ruff enforces LF line endings on Python files.
12
+
13
+ ```
14
+ uv run --group dev pytest --cov
15
+ uv run --group dev ruff check
16
+ uv run --group dev ruff format --check
17
+ uv run --group dev mypy
18
+ ```
19
+
20
+ ## Layout
21
+
22
+ - `src/kvsections/records.py`: `Record.from_line` (header vs continuation vs
23
+ blank), `split_records`, the `TOKEN_CHARS`/`TEXT_CHARS` alphabets, `pack`
24
+ (a `textwrap` wrapper), `plan_order`. Every other module builds on these so
25
+ the rules exist once.
26
+ - `model.py`: `BaseSection`, `Section` (a `MutableMapping[str, str]`),
27
+ `TextSection`, `convert_section`.
28
+ - `fields.py`: `Field[T]` descriptor, `Converter[T]`, `MISSING`.
29
+ - `errors.py`: `ParseWarning`, `ParseError`.
30
+ - `reader.py`: tolerant parser. `writer.py`: strict renderer.
31
+ - `document.py`: `Document` (a `MutableMapping[str, BaseSection]`),
32
+ `SectionField`, schema registry and alias merging, `reorder`, I/O methods.
33
+ - `layout.py`: `wrap_records` and `reorder_records`, text-level and
34
+ byte-preserving. `converters.py`: ready-made parse/format pairs.
35
+ - `__init__.py`: `loads`/`load`/`read`/`dumps`/`dump`/`write` are bound
36
+ straight from `Document`; do not re-wrap them.
37
+ - `tests/`: one file per module, shared fixtures in `helpers.py`,
38
+ `conftest.py` parametrizes `sample` over `tests/samples/*.txt` and
39
+ `golden_sample` over `golden*.txt`.
40
+
41
+ ## Design decisions (confirmed with the owner)
42
+
43
+ Do not reverse these without asking.
44
+
45
+ 1. Values are always strings, exactly as in the file. Nothing is split on
46
+ commas unless a typed field declares `list[T]`.
47
+ 2. Section names and keys are upper-cased on insertion; lookups are
48
+ case-insensitive.
49
+ 3. Assigning `None` removes a key or a declared section.
50
+ 4. The generic `Document` reads every section as pairs. Free text is a
51
+ schema declaration: `SectionField(TextSection, "COMMENTS")`. There is no
52
+ default list of free-text names.
53
+ 5. The reader is maximally tolerant: every tolerated problem goes into
54
+ `doc.warnings` with a line number, `strict=True` raises at the first.
55
+ Duplicate sections merge, duplicate keys keep the last value, bare tokens
56
+ get an empty value, undecodable bytes and BOMs are reported.
57
+ 6. The writer is strict about content (character set, upper case, section
58
+ kind) but record width is a convention: over-long pairs or words go on a
59
+ record of their own and are never refused.
60
+ 7. The writer normalizes layout (indent = longest name + 1, single spaces,
61
+ padding to 80 with a one-column margin) and never preserves the input's
62
+ layout or order. Byte-preserving edits belong to `wrap_records` and
63
+ `reorder_records`, which never parse.
64
+ 8. Line endings: read CRLF, LF or CR; write LF unless `newline` says
65
+ otherwise. `dump` writes through a text handle's buffer so platform
66
+ newline translation cannot interfere.
67
+ 9. Aliases exist only in schemas, never on the generic `Document`. The
68
+ document is keyed by canonical name; a section keeps the spelling it was
69
+ read with; equality is spelling-sensitive. A class's own declarations
70
+ override inherited ones (`_merge_schema`).
71
+ 10. Typed fields are lazy views over the raw strings: parse on read, format
72
+ on assignment, nothing validated at load time. Converters validate both
73
+ directions; `%y` follows Python's 1969 pivot.
74
+ 11. Reading a declared section the document lacks raises `AttributeError`,
75
+ exactly like a missing typed key. Reading never mutates. Create with
76
+ `doc.header = {}` or `doc.add(...)`.
77
+ 12. Section order is not part of the format; `reorder` and `section_order`
78
+ are opt-in and the writer never reorders.
79
+ 13. Python 3.9 minimum, zero runtime dependencies, no pydantic/attrs, stdlib
80
+ where it fits (`textwrap`). No CLI; the old entry point was removed on
81
+ purpose.
82
+
83
+ ## CI and releases
84
+
85
+ - `.github/workflows/ci.yml` runs the four commands above on every push and
86
+ pull request: lint and mypy on Ubuntu, pytest across Ubuntu, Windows and
87
+ macOS on Python 3.9 to 3.13, plus a wheel build and import. It uses
88
+ `uv sync --locked`, so commit `uv.lock` whenever `pyproject.toml` changes.
89
+ - `.github/workflows/release.yml` publishes to PyPI on a `v*` tag through
90
+ trusted publishing (no token). Release = `git tag vX.Y.Z && git push --tags`.
91
+ - The version is derived from git tags by hatch-vcs (hatchling backend);
92
+ nothing in the repo states it, `__version__` reads installed metadata, and
93
+ `uv version` does not apply. Checkouts that build need the tags
94
+ (`fetch-depth: 0` in workflows).
95
+ - `.gitattributes` stores source as LF and `tests/samples/**` verbatim.
96
+ The owner's git uses `core.autocrlf=input`, so without that rule CRLF
97
+ samples would be committed as LF and the byte-exact tests would fail on
98
+ a fresh clone.
99
+
100
+ ## Conventions and pitfalls
101
+
102
+ - Sample files: any `tests/samples/*.txt` is picked up automatically and
103
+ must parse, round-trip through the model, and survive the layout helpers.
104
+ Files named `golden*` must also reproduce byte for byte from their detected
105
+ layout. Samples keep their own line endings (`golden.txt` is CRLF).
106
+ - Source files are LF. When writing files from a script on Windows, open
107
+ them with `newline="\n"`; a plain text-mode write converts to CRLF
108
+ silently, and this has bitten the project before.
109
+ - Keep README examples runnable against `tests/samples/golden.txt`; they
110
+ have been executed after every change so far.
111
+ - The README and the docstrings are the user documentation; update both
112
+ when behaviour changes.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jonathan Olsten
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,316 @@
1
+ Metadata-Version: 2.4
2
+ Name: kvsections
3
+ Version: 0.1.0
4
+ Summary: Read and write fixed-width text files made of named sections of KEY=VALUE pairs.
5
+ Author-email: Jonathan Olsten <jonathan.olsten@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: key-value,parser,sections
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Text Processing
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+
19
+ # kvsections
20
+
21
+ [![CI](https://github.com/jolsten/kvsections/actions/workflows/ci.yml/badge.svg)](https://github.com/jolsten/kvsections/actions/workflows/ci.yml)
22
+
23
+ Read and write files made of named sections holding `KEY=VALUE` pairs.
24
+
25
+ ```
26
+ HEADER VERSION=1 FORMAT=TEXT CREATED=20240101 REVISION=003 AUTHOR=EXAMPLE
27
+ OWNER=NOBODY
28
+ CONFIG NAME=DEFAULT MODE=NORMAL LEVEL=2 ENABLED=YES TIMEOUT=30 RETRIES=3
29
+ BUFFER=4096 VERBOSE=NO
30
+ SCHEDULE START=080000 STOP=173000 INTERVAL=15 DAYS=MON,TUE,WED,THU,FRI
31
+ COMMENTS Free text, which may run onto
32
+ as many lines as it needs.
33
+ ```
34
+
35
+ ## The format
36
+
37
+ - A file is a sequence of records, conventionally 80 columns wide and padded
38
+ with spaces. Any of CRLF, LF or CR terminates a record on input; the
39
+ writer emits LF unless told otherwise.
40
+ - A record that starts in column 1 begins a section; its first token is the
41
+ section name. A record that starts with whitespace continues the section
42
+ above it.
43
+ - The rest of a record holds space-separated `KEY=VALUE` pairs. A pair never
44
+ spans records. Values contain no spaces; lists are written as
45
+ comma-separated values.
46
+ - Section names and keys are upper-case. Each section name appears once per
47
+ file and each key once per section.
48
+ - A section may hold free text instead of pairs, conventionally `COMMENTS`;
49
+ a schema declares which ones. The text starts at its first non-blank
50
+ character: leading whitespace on the first line, blank lines at either
51
+ end and trailing spaces are layout, not content, and are not preserved.
52
+
53
+ ## Usage
54
+
55
+ ```python
56
+ import kvsections
57
+ from kvsections import Document, SectionField, TextSection
58
+
59
+
60
+ class Doc(Document):
61
+ comments = SectionField(TextSection, "COMMENTS") # free text, not pairs
62
+
63
+
64
+ doc = Doc.read("tests/samples/golden.txt")
65
+
66
+ doc["CONFIG"]["BUFFER"] # '4096' (values are strings)
67
+ doc["SOURCE"]["SIZE"] # '001024' (leading zeros are kept)
68
+ doc["OPTIONS"]["FLAGS"] # 'A,B,C,D' (lists stay strings here)
69
+ list(doc) # ['HEADER', 'CONFIG', 'SOURCE', ...]
70
+ doc["COMMENTS"].text # 'This is a freeform comment section.'
71
+
72
+ doc["CONFIG"]["BUFFER"] = "8192"
73
+ doc["OPTIONS"]["FLAGS"] = ["A", "B"] # a list is joined with commas
74
+ doc["CONFIG"]["VERBOSE"] = None # assigning None removes a key
75
+ kvsections.write(doc, "out.txt")
76
+ ```
77
+
78
+ `kvsections.read` does the same with the generic `Document`, which reads every
79
+ section, `COMMENTS` included, as key/value pairs; free text is something a
80
+ schema declares. `Document` and `Section` are mutable mappings, so `in`, `get`, `items`,
81
+ `del` and friends all work. Keys and section names are normalised to upper
82
+ case on the way in, and lookups are case-insensitive. Values are always
83
+ strings, commas included; splitting them into lists is the job of typed
84
+ fields (below). `loads`/`dumps` work on strings and `load`/`dump` on open
85
+ files.
86
+
87
+ ### Reading is tolerant
88
+
89
+ The reader never rejects a file. Anything it had to guess about is listed in
90
+ `doc.warnings` as `(lineno, message)` pairs:
91
+
92
+ ```python
93
+ doc = kvsections.loads("header owner=nobody\nheader x=1")
94
+ for warning in doc.warnings:
95
+ print(warning)
96
+ # line 1: section name 'header' is not upper-case; normalized
97
+ # line 1: key 'owner' is not upper-case; normalized
98
+ # line 2: section name 'header' is not upper-case; normalized
99
+ # line 2: duplicate section HEADER; merged into the earlier one
100
+ # line 2: key 'x' is not upper-case; normalized
101
+ ```
102
+
103
+ Line length, padding and line endings are not checked, and the layout is
104
+ inferred rather than assumed, so any indent width reads correctly, and a
105
+ UTF-8 byte order mark is dropped with a warning, as is any character that
106
+ could not be written back, such as the replacement character an undecodable
107
+ byte becomes. Pass `strict=True` to raise `ParseError` at the first problem
108
+ instead.
109
+
110
+ ### Writing is strict
111
+
112
+ `dumps`/`write` raise `ValueError` for anything that would not read back:
113
+ lower-case or empty names and keys, whitespace or non-ASCII characters in
114
+ values, or a section of the wrong kind for its name, such as pairs under
115
+ `COMMENT`. Record width is a convention rather than a limit: a pair or word
116
+ that cannot fit is written on a record of its own, longer than `width`.
117
+
118
+ Layout options, all keyword-only:
119
+
120
+ | option | default | meaning |
121
+ |-----------|----------|----------------------------------------------------------------|
122
+ | `width` | `80` | padded record length; `None` disables padding and wrapping |
123
+ | `margin` | `1` | columns left blank at the end of every record |
124
+ | `indent` | `None` | column where content starts; default is longest name plus one |
125
+ | `newline` | `"\n"` | record terminator; pass `"\r\n"` for consumers that need CRLF |
126
+
127
+ Pairs that would cross `width - margin` wrap onto an indented continuation
128
+ record. Long free-text lines are word-wrapped. With the defaults the sample
129
+ file is written with an 11-column indent, because its longest section name
130
+ is `PARAMETERS`; `indent=12, newline="\r\n"` reproduces the file byte for
131
+ byte.
132
+
133
+ `dump` takes a file opened in text or binary mode. A text file may be opened
134
+ with a plain `open(path, "w")`: the records are written through the file's
135
+ underlying buffer, so newline translation never alters the terminators.
136
+
137
+ ### Fixing layout without parsing
138
+
139
+ `wrap_records` takes the text of a file whose records are too long and
140
+ re-flows only those records onto continuation records, leaving every other
141
+ byte as it was. It does not parse, so names, keys, values, spelling and
142
+ order are untouched, and it never raises for content. Pass `pad=True` to
143
+ also pad every record to `width`.
144
+
145
+ ```python
146
+ from kvsections import wrap_records
147
+
148
+ text = path.read_text(newline="")
149
+ path.write_text(wrap_records(text, width=80, pad=True), newline="")
150
+ ```
151
+
152
+ `reorder_records` moves whole sections into a prescribed order the same way:
153
+ a block starts at each header record, the order has the same form as for
154
+ `Document.reorder` (see below), and every byte inside a block is kept. Pass
155
+ a schema class as `document_type` to resolve aliases.
156
+
157
+ ```python
158
+ from kvsections import reorder_records
159
+
160
+ moved = reorder_records(
161
+ text, ["HEADER", ..., "COMMENTS"], document_type=ExampleDocument
162
+ )
163
+ ```
164
+
165
+ ## Typed documents
166
+
167
+ For a known file layout, describe the sections you care about and get typed
168
+ attributes instead of strings:
169
+
170
+ ```python
171
+ from kvsections import Document, Section, TextSection, Field, SectionField
172
+ from kvsections.converters import HHMMSS, YYYYMMDD, zero_padded
173
+
174
+
175
+ class HeaderSection(Section):
176
+ section_name = "HEADER"
177
+ version = Field("VERSION", int)
178
+ created = Field("CREATED", YYYYMMDD) # datetime.date
179
+ revision = Field("REVISION", zero_padded(3)) # keeps the leading zeros
180
+ author = Field("AUTHOR")
181
+ owner = Field("OWNER", default="NOBODY")
182
+
183
+
184
+ class ScheduleSection(Section):
185
+ section_name = "SCHEDULE"
186
+ interval = Field("INTERVAL", int)
187
+ start = Field("START", HHMMSS) # datetime.time
188
+ days = Field("DAYS", list[str])
189
+
190
+
191
+ class ExampleDocument(Document):
192
+ header = SectionField(HeaderSection)
193
+ schedule = SectionField(ScheduleSection)
194
+ comments = SectionField(TextSection, "COMMENTS", aliases=("COMMENT",))
195
+
196
+
197
+ doc = ExampleDocument.read("tests/samples/golden.txt")
198
+ doc.header.version # 1
199
+ doc.schedule.days # ['MON', 'TUE', 'WED', 'THU', 'FRI']
200
+ doc["CONFIG"]["BUFFER"] # sections you did not describe stay generic
201
+
202
+ new = ExampleDocument()
203
+ new.header = {} # sections are created explicitly; reading never creates
204
+ new.header.version = 7
205
+ new.header.revision = 12 # written as REVISION=012
206
+ new.comments = "Built in code."
207
+ new.write("new.txt")
208
+ ```
209
+
210
+ - `Field(key, type)` is shorthand for `parse=type, format=str`. Pass `parse`
211
+ and `format` explicitly when the text form matters, or pass a `Converter`,
212
+ which bundles both and can also be used inside `list[...]`.
213
+ - Reading a missing key raises `AttributeError` unless `default` is given,
214
+ and so does reading a declared section the document lacks. Assigning
215
+ `None` removes the key or the section.
216
+ - `Field(key, list[T])` splits the value on commas, converts each item with
217
+ `T`, and joins on assignment; `parse` and `format` then apply per item.
218
+ An empty value is an empty list. The list is a copy, so assign a new
219
+ list rather than appending to the old one.
220
+ - Declaring a `SectionField` registers its class for that name, so the
221
+ reader instantiates it and a generic `Section` added under that name is
222
+ converted. Registrations are inherited by subclasses, including through
223
+ multiple inheritance.
224
+ - `aliases` gives a section alternative spellings. With the declaration
225
+ above, a file may say either `COMMENTS` or `COMMENT`: `doc.comments`,
226
+ `doc["COMMENTS"]` and `doc["COMMENT"]` all find it, `list(doc)` reports
227
+ the canonical name, and the section keeps the spelling it was read with
228
+ so a rewrite preserves it. A file containing both spellings is treated as
229
+ a duplicate and merged with a warning. Aliases can also be declared as a
230
+ `section_aliases` table on the class; subclasses inherit and extend them.
231
+ The generic `Document` has no aliases, so for it `COMMENT` and `COMMENTS`
232
+ are two different sections.
233
+ - The generic `Document` reads every section as pairs. A schema declares
234
+ free-text sections with `SectionField(TextSection, "COMMENTS")`, as
235
+ `ExampleDocument` does above.
236
+
237
+ ### Section order
238
+
239
+ The format itself imposes no order, but some files expect one. `reorder` puts
240
+ the sections into a prescribed order in place. Names are listed in the wanted
241
+ order, `...` stands for every section not named (kept in their current
242
+ relative order), and names after `...` go last:
243
+
244
+ ```python
245
+ doc = kvsections.read("tests/samples/golden.txt")
246
+ doc.reorder(["OUTPUT", "HEADER", ..., "COMMENTS"])
247
+ list(doc) # ['OUTPUT', 'HEADER', 'CONFIG', 'SOURCE', ..., 'COMMENTS']
248
+ ```
249
+
250
+ Names absent from the document are ignored, aliases resolve, and without
251
+ `...` the unnamed sections follow the named ones. A schema can declare the
252
+ order once as `section_order`, after which `doc.reorder()` needs no argument:
253
+
254
+ ```python
255
+ class ExampleDocument(Document):
256
+ section_order = ["HEADER", "SCHEDULE", ..., "COMMENTS"]
257
+ ```
258
+
259
+ `reorder` works on a parsed document, so a read, reorder, write sequence
260
+ normalizes the layout as well. To move sections and change nothing else,
261
+ use `reorder_records` from the layout helpers above.
262
+
263
+ ### Ready-made converters
264
+
265
+ `kvsections.converters` bundles parse/format pairs for encodings this
266
+ format tends to use. Each validates in both directions and raises
267
+ `ValueError` for text or values it cannot handle.
268
+
269
+ | converter | text | Python value |
270
+ |---|---|---|
271
+ | `YYMMDD`, `YYYYMMDD` | `240101`, `20240101` | `datetime.date`; `%y` follows Python's rule, 69 to 99 being 19xx |
272
+ | `HHMMSS`, `HHMM` | `080000`, `0800` | `datetime.time` |
273
+ | `YYYYMMDDHHMMSS` | `20240101080000` | `datetime.datetime` |
274
+ | `date_format(fmt)`, `time_format(fmt)`, `datetime_format(fmt)` | any `strftime` pattern | as above |
275
+ | `zero_padded(width)` | `003` | non-negative `int`; digits only |
276
+ | `YES_NO`, `Y_N`, `ON_OFF`, `TRUE_FALSE`, `flag(true, false)` | `YES` | `bool`; writing also accepts the two words |
277
+ | `one_of("A", "B")` | `A` | `str`, restricted to the choices |
278
+ | `enum_by_value(E)`, `enum_by_name(E)` | the member's value or name | member of `E` |
279
+
280
+ The date and time converters also refuse values that would not read back,
281
+ such as a year outside 1969 to 2068 for `YYMMDD` or a time with
282
+ microseconds for `HHMMSS`.
283
+
284
+ A custom pair is one line: `Converter(parse, format)`.
285
+
286
+ ## Development
287
+
288
+ ```
289
+ uv run --group dev pytest
290
+ uv run --group dev pytest --cov # branch coverage; fails below 95%
291
+ uv run --group dev ruff check
292
+ uv run --group dev ruff format --check
293
+ uv run --group dev mypy # strict type checking
294
+ ```
295
+
296
+ Tests are split by module under `tests/`, with shared fixtures in
297
+ `tests/helpers.py`. Every `tests/samples/*.txt` file is picked up
298
+ automatically and must parse without raising, round-trip through the model,
299
+ and survive the layout helpers unchanged in content; a sample whose name
300
+ starts with `golden` must also be reproduced byte for byte from its detected
301
+ layout. Drop a file into the directory to add it to the suite.
302
+
303
+ CI runs the same four commands on every push and pull request, across
304
+ Linux, Windows and macOS on Python 3.9 to 3.13, and builds the wheel.
305
+
306
+ ### Releasing
307
+
308
+ Releases publish to PyPI from GitHub Actions through trusted publishing, so
309
+ no API token is stored. The version is derived from the git tag by
310
+ hatch-vcs, so nothing in the repository needs bumping. To cut one:
311
+
312
+ ```
313
+ git tag v0.2.0 && git push --tags
314
+ ```
315
+
316
+ Untagged commits build as development versions such as `0.2.1.dev3+g1a2b3c4`.