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 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
@@ -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__})")