kvsections 0.1.0__py3-none-any.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.
- kvsections/__init__.py +76 -0
- kvsections/converters.py +222 -0
- kvsections/document.py +441 -0
- kvsections/errors.py +26 -0
- kvsections/fields.py +216 -0
- kvsections/layout.py +119 -0
- kvsections/model.py +190 -0
- kvsections/py.typed +0 -0
- kvsections/reader.py +173 -0
- kvsections/records.py +144 -0
- kvsections/writer.py +149 -0
- kvsections-0.1.0.dist-info/METADATA +316 -0
- kvsections-0.1.0.dist-info/RECORD +15 -0
- kvsections-0.1.0.dist-info/WHEEL +4 -0
- kvsections-0.1.0.dist-info/licenses/LICENSE +21 -0
kvsections/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Read and write files made of named sections holding ``KEY=VALUE`` pairs.
|
|
2
|
+
|
|
3
|
+
A file is a sequence of fixed-width records. The section name sits at the
|
|
4
|
+
start of a record; indented records continue the previous section. The rest
|
|
5
|
+
of each record holds space-separated ``KEY=VALUE`` pairs, or free text for
|
|
6
|
+
comment sections::
|
|
7
|
+
|
|
8
|
+
HEADER VERSION=1 FORMAT=TEXT CREATED=20240101 REVISION=003
|
|
9
|
+
OWNER=NOBODY
|
|
10
|
+
SCHEDULE START=080000 STOP=173000 DAYS=MON,TUE,WED,THU,FRI
|
|
11
|
+
|
|
12
|
+
Typical use::
|
|
13
|
+
|
|
14
|
+
import kvsections
|
|
15
|
+
|
|
16
|
+
doc = kvsections.read("input.txt")
|
|
17
|
+
doc["HEADER"]["REVISION"] # '003'
|
|
18
|
+
doc["SCHEDULE"]["DAYS"] # 'MON,TUE,WED,THU,FRI'
|
|
19
|
+
doc["HEADER"]["REVISION"] = "004"
|
|
20
|
+
kvsections.write(doc, "out.txt")
|
|
21
|
+
|
|
22
|
+
The reader tolerates malformed input and lists what it tolerated in
|
|
23
|
+
``doc.warnings``; the writer is strict and raises ``ValueError`` for content
|
|
24
|
+
that would not read back. Subclass :class:`Document` and :class:`Section` with
|
|
25
|
+
:class:`SectionField` and :class:`Field` descriptors to describe a specific
|
|
26
|
+
file with typed attributes. Values are always strings; a field declared with
|
|
27
|
+
a ``list[T]`` type splits comma-separated values, and ready-made converters
|
|
28
|
+
for dates, times, zero-padded numbers and flags live in
|
|
29
|
+
:mod:`kvsections.converters`.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
35
|
+
|
|
36
|
+
from .document import Document, SectionField
|
|
37
|
+
from .errors import ParseError, ParseWarning
|
|
38
|
+
from .fields import Converter, Field
|
|
39
|
+
from .layout import reorder_records, wrap_records
|
|
40
|
+
from .model import BaseSection, Section, TextSection
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
__version__ = version("kvsections")
|
|
44
|
+
except PackageNotFoundError: # pragma: no cover - only when not installed
|
|
45
|
+
__version__ = "0+unknown"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"BaseSection",
|
|
49
|
+
"Converter",
|
|
50
|
+
"Document",
|
|
51
|
+
"Field",
|
|
52
|
+
"ParseError",
|
|
53
|
+
"ParseWarning",
|
|
54
|
+
"Section",
|
|
55
|
+
"SectionField",
|
|
56
|
+
"TextSection",
|
|
57
|
+
"dump",
|
|
58
|
+
"dumps",
|
|
59
|
+
"load",
|
|
60
|
+
"loads",
|
|
61
|
+
"read",
|
|
62
|
+
"write",
|
|
63
|
+
"reorder_records",
|
|
64
|
+
"wrap_records",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# The module-level functions are the generic Document's methods: ``loads``,
|
|
68
|
+
# ``load`` and ``read`` parse into a plain Document, and ``dumps``, ``dump``
|
|
69
|
+
# and ``write`` take the document as their first argument. A schema class
|
|
70
|
+
# offers the same methods for typed documents.
|
|
71
|
+
loads = Document.loads
|
|
72
|
+
load = Document.load
|
|
73
|
+
read = Document.read
|
|
74
|
+
dumps = Document.dumps
|
|
75
|
+
dump = Document.dump
|
|
76
|
+
write = Document.write
|
kvsections/converters.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Ready-made :class:`Converter` pairs for common value encodings.
|
|
2
|
+
|
|
3
|
+
Pass one as the type of a :class:`~kvsections.Field`::
|
|
4
|
+
|
|
5
|
+
from kvsections import Field, Section
|
|
6
|
+
from kvsections.converters import HHMMSS, YYYYMMDD, zero_padded
|
|
7
|
+
|
|
8
|
+
class HeaderSection(Section):
|
|
9
|
+
section_name = "HEADER"
|
|
10
|
+
created = Field("CREATED", YYYYMMDD) # datetime.date
|
|
11
|
+
start = Field("START", HHMMSS) # datetime.time
|
|
12
|
+
revision = Field("REVISION", zero_padded(3))
|
|
13
|
+
|
|
14
|
+
Every converter validates on both sides: a value the text does not fit
|
|
15
|
+
raises ``ValueError`` when read, and a value that cannot be written raises
|
|
16
|
+
``ValueError`` when assigned.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import operator
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from datetime import date, datetime, time
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from typing import Any, TypeVar
|
|
26
|
+
|
|
27
|
+
from .fields import Converter
|
|
28
|
+
|
|
29
|
+
E = TypeVar("E", bound=Enum)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"date_format",
|
|
33
|
+
"time_format",
|
|
34
|
+
"datetime_format",
|
|
35
|
+
"YYMMDD",
|
|
36
|
+
"YYYYMMDD",
|
|
37
|
+
"HHMMSS",
|
|
38
|
+
"HHMM",
|
|
39
|
+
"YYYYMMDDHHMMSS",
|
|
40
|
+
"zero_padded",
|
|
41
|
+
"flag",
|
|
42
|
+
"YES_NO",
|
|
43
|
+
"Y_N",
|
|
44
|
+
"ON_OFF",
|
|
45
|
+
"TRUE_FALSE",
|
|
46
|
+
"one_of",
|
|
47
|
+
"enum_by_value",
|
|
48
|
+
"enum_by_name",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# -- dates and times ---------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _checked(fmt: str, parse: Callable[[str], Any]) -> Callable[[Any], str]:
|
|
56
|
+
"""A ``strftime`` formatter that refuses values which would not read back."""
|
|
57
|
+
|
|
58
|
+
def format(value: Any) -> str:
|
|
59
|
+
text: str = value.strftime(fmt)
|
|
60
|
+
if parse(text) != value:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"{value!r} cannot be written with {fmt!r}: {text!r} reads back "
|
|
63
|
+
f"as {parse(text)!r}"
|
|
64
|
+
)
|
|
65
|
+
return text
|
|
66
|
+
|
|
67
|
+
return format
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def date_format(fmt: str, name: str | None = None) -> Converter[date]:
|
|
71
|
+
"""Dates written with a ``strftime`` pattern, e.g. ``date_format("%y%m%d")``.
|
|
72
|
+
|
|
73
|
+
Two-digit years follow Python's rule: ``69`` to ``99`` are 1969 to 1999
|
|
74
|
+
and ``00`` to ``68`` are 2000 to 2068, in both directions. Writing a date
|
|
75
|
+
the pattern cannot represent raises ``ValueError``.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def parse(text: str) -> date:
|
|
79
|
+
return datetime.strptime(text, fmt).date()
|
|
80
|
+
|
|
81
|
+
return Converter(parse, _checked(fmt, parse), name or f"date_format({fmt!r})")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def time_format(fmt: str, name: str | None = None) -> Converter[time]:
|
|
85
|
+
"""Times of day written with a ``strftime`` pattern, e.g. ``"%H%M%S"``.
|
|
86
|
+
|
|
87
|
+
Writing a time the pattern cannot represent, such as one with microseconds
|
|
88
|
+
under ``%H%M%S``, raises ``ValueError``.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def parse(text: str) -> time:
|
|
92
|
+
return datetime.strptime(text, fmt).time()
|
|
93
|
+
|
|
94
|
+
return Converter(parse, _checked(fmt, parse), name or f"time_format({fmt!r})")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def datetime_format(fmt: str, name: str | None = None) -> Converter[datetime]:
|
|
98
|
+
"""Timestamps written with a ``strftime`` pattern, e.g. ``"%Y%m%d%H%M%S"``.
|
|
99
|
+
|
|
100
|
+
Two-digit years follow the same rule as :func:`date_format`, and values
|
|
101
|
+
the pattern cannot represent raise ``ValueError`` when written.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def parse(text: str) -> datetime:
|
|
105
|
+
return datetime.strptime(text, fmt)
|
|
106
|
+
|
|
107
|
+
return Converter(parse, _checked(fmt, parse), name or f"datetime_format({fmt!r})")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
#: Two-digit year with Python's pivot: ``240101`` is 2024-01-01 and ``690101``
|
|
111
|
+
#: is 1969-01-01. Years outside 1969 to 2068 cannot be written.
|
|
112
|
+
YYMMDD = date_format("%y%m%d", "YYMMDD")
|
|
113
|
+
#: Four-digit year, e.g. ``20240101``.
|
|
114
|
+
YYYYMMDD = date_format("%Y%m%d", "YYYYMMDD")
|
|
115
|
+
#: Time of day to the second, e.g. ``080000`` is 08:00:00.
|
|
116
|
+
HHMMSS = time_format("%H%M%S", "HHMMSS")
|
|
117
|
+
#: Time of day to the minute, e.g. ``1730``.
|
|
118
|
+
HHMM = time_format("%H%M", "HHMM")
|
|
119
|
+
#: Full timestamp, e.g. ``20240101080000``.
|
|
120
|
+
YYYYMMDDHHMMSS = datetime_format("%Y%m%d%H%M%S", "YYYYMMDDHHMMSS")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# -- numbers -----------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def zero_padded(width: int) -> Converter[int]:
|
|
127
|
+
"""Non-negative integers padded with zeros to ``width`` digits, e.g. ``003``.
|
|
128
|
+
|
|
129
|
+
Reading requires digits only and at most ``width`` of them, so a value
|
|
130
|
+
written without its padding still reads. Writing requires an integer
|
|
131
|
+
that fits in ``width`` digits.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def parse(text: str) -> int:
|
|
135
|
+
if not (text.isascii() and text.isdigit()) or len(text) > width:
|
|
136
|
+
raise ValueError(f"expected up to {width} digits, got {text!r}")
|
|
137
|
+
return int(text)
|
|
138
|
+
|
|
139
|
+
def format(value: int) -> str:
|
|
140
|
+
number = operator.index(value)
|
|
141
|
+
if number < 0:
|
|
142
|
+
raise ValueError(f"{number} is negative")
|
|
143
|
+
text = f"{number:0{width}d}"
|
|
144
|
+
if len(text) > width:
|
|
145
|
+
raise ValueError(f"{number} does not fit in {width} digits")
|
|
146
|
+
return text
|
|
147
|
+
|
|
148
|
+
return Converter(parse, format, f"zero_padded({width})")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# -- flags and choices -------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def flag(true: str = "YES", false: str = "NO") -> Converter[bool]:
|
|
155
|
+
"""Booleans written as one of two words, e.g. ``flag("ON", "OFF")``.
|
|
156
|
+
|
|
157
|
+
Reading accepts exactly the two words. Writing accepts a ``bool`` or one
|
|
158
|
+
of the two words themselves; anything else raises.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def parse(text: str) -> bool:
|
|
162
|
+
if text == true:
|
|
163
|
+
return True
|
|
164
|
+
if text == false:
|
|
165
|
+
return False
|
|
166
|
+
raise ValueError(f"expected {true} or {false}, got {text!r}")
|
|
167
|
+
|
|
168
|
+
def format(value: Any) -> str:
|
|
169
|
+
if isinstance(value, bool):
|
|
170
|
+
return true if value else false
|
|
171
|
+
if isinstance(value, str):
|
|
172
|
+
parse(value)
|
|
173
|
+
return value
|
|
174
|
+
raise TypeError(f"expected a bool or {true!r}/{false!r}, got {value!r}")
|
|
175
|
+
|
|
176
|
+
return Converter(parse, format, f"flag({true!r}, {false!r})")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
YES_NO = flag("YES", "NO")
|
|
180
|
+
Y_N = flag("Y", "N")
|
|
181
|
+
ON_OFF = flag("ON", "OFF")
|
|
182
|
+
TRUE_FALSE = flag("TRUE", "FALSE")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def one_of(*allowed: str) -> Converter[str]:
|
|
186
|
+
"""Strings restricted to a fixed set of spellings, kept as strings."""
|
|
187
|
+
choices = tuple(allowed)
|
|
188
|
+
|
|
189
|
+
def check(text: str) -> str:
|
|
190
|
+
if text not in choices:
|
|
191
|
+
raise ValueError(f"expected one of {', '.join(choices)}, got {text!r}")
|
|
192
|
+
return text
|
|
193
|
+
|
|
194
|
+
return Converter(check, check, f"one_of{choices!r}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def enum_by_value(enum_type: type[E]) -> Converter[E]:
|
|
198
|
+
"""Enum members written as their ``value``, which must be a string."""
|
|
199
|
+
|
|
200
|
+
def format(member: Enum) -> str:
|
|
201
|
+
return str(enum_type(member).value)
|
|
202
|
+
|
|
203
|
+
return Converter(enum_type, format, f"enum_by_value({enum_type.__name__})")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def enum_by_name(enum_type: type[E]) -> Converter[E]:
|
|
207
|
+
"""Enum members written as their ``name``."""
|
|
208
|
+
|
|
209
|
+
def parse(text: str) -> E:
|
|
210
|
+
try:
|
|
211
|
+
return enum_type[text]
|
|
212
|
+
except KeyError:
|
|
213
|
+
names = ", ".join(member.name for member in enum_type)
|
|
214
|
+
raise ValueError(f"expected one of {names}, got {text!r}") from None
|
|
215
|
+
|
|
216
|
+
def format(member: Any) -> str:
|
|
217
|
+
if isinstance(member, str):
|
|
218
|
+
parse(member)
|
|
219
|
+
return member
|
|
220
|
+
return enum_type(member).name
|
|
221
|
+
|
|
222
|
+
return Converter(parse, format, f"enum_by_name({enum_type.__name__})")
|