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.
- kvsections-0.1.0/.gitattributes +5 -0
- kvsections-0.1.0/.github/workflows/ci.yml +61 -0
- kvsections-0.1.0/.github/workflows/release.yml +43 -0
- kvsections-0.1.0/.gitignore +9 -0
- kvsections-0.1.0/.python-version +1 -0
- kvsections-0.1.0/CLAUDE.md +112 -0
- kvsections-0.1.0/LICENSE +21 -0
- kvsections-0.1.0/PKG-INFO +316 -0
- kvsections-0.1.0/README.md +298 -0
- kvsections-0.1.0/pyproject.toml +63 -0
- kvsections-0.1.0/src/kvsections/__init__.py +76 -0
- kvsections-0.1.0/src/kvsections/converters.py +222 -0
- kvsections-0.1.0/src/kvsections/document.py +441 -0
- kvsections-0.1.0/src/kvsections/errors.py +26 -0
- kvsections-0.1.0/src/kvsections/fields.py +216 -0
- kvsections-0.1.0/src/kvsections/layout.py +119 -0
- kvsections-0.1.0/src/kvsections/model.py +190 -0
- kvsections-0.1.0/src/kvsections/py.typed +0 -0
- kvsections-0.1.0/src/kvsections/reader.py +173 -0
- kvsections-0.1.0/src/kvsections/records.py +144 -0
- kvsections-0.1.0/src/kvsections/writer.py +149 -0
- kvsections-0.1.0/tests/conftest.py +15 -0
- kvsections-0.1.0/tests/helpers.py +91 -0
- kvsections-0.1.0/tests/samples/blank_lines.txt +27 -0
- kvsections-0.1.0/tests/samples/golden.txt +14 -0
- kvsections-0.1.0/tests/samples/no_wrap.txt +10 -0
- kvsections-0.1.0/tests/samples/test1a.txt +13 -0
- kvsections-0.1.0/tests/samples/test2a.txt +13 -0
- kvsections-0.1.0/tests/samples/test2d.txt +12 -0
- kvsections-0.1.0/tests/samples/test3a.txt +13 -0
- kvsections-0.1.0/tests/samples/test3d.txt +12 -0
- kvsections-0.1.0/tests/samples/test4a.txt +13 -0
- kvsections-0.1.0/tests/samples/test4d.txt +12 -0
- kvsections-0.1.0/tests/test_converters.py +262 -0
- kvsections-0.1.0/tests/test_document.py +363 -0
- kvsections-0.1.0/tests/test_layout.py +129 -0
- kvsections-0.1.0/tests/test_model.py +249 -0
- kvsections-0.1.0/tests/test_reader.py +239 -0
- kvsections-0.1.0/tests/test_records.py +51 -0
- kvsections-0.1.0/tests/test_samples.py +122 -0
- kvsections-0.1.0/tests/test_writer.py +239 -0
- kvsections-0.1.0/uv.lock +860 -0
|
@@ -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 @@
|
|
|
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.
|
kvsections-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](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`.
|