typeline 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.
- CONTRIBUTING.md +94 -0
- LICENSE +21 -0
- typeline/__init__.py +18 -0
- typeline/_data_types.py +21 -0
- typeline/_reader.py +267 -0
- typeline/_writer.py +182 -0
- typeline/py.typed +0 -0
- typeline-0.1.0.dist-info/LICENSE +21 -0
- typeline-0.1.0.dist-info/METADATA +100 -0
- typeline-0.1.0.dist-info/RECORD +11 -0
- typeline-0.1.0.dist-info/WHEEL +4 -0
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Development and Testing
|
|
2
|
+
|
|
3
|
+
## Primary Development Commands
|
|
4
|
+
|
|
5
|
+
To check and resolve linting issues in the codebase, run:
|
|
6
|
+
|
|
7
|
+
```console
|
|
8
|
+
poetry run ruff check --fix
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
To check and resolve formatting issues in the codebase, run:
|
|
12
|
+
|
|
13
|
+
```console
|
|
14
|
+
poetry run ruff format
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
To check the unit tests in the codebase, run:
|
|
18
|
+
|
|
19
|
+
```console
|
|
20
|
+
poetry run pytest
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
To check the typing in the codebase, run:
|
|
24
|
+
|
|
25
|
+
```console
|
|
26
|
+
poetry run mypy
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
To generate a code coverage report after testing locally, run:
|
|
30
|
+
|
|
31
|
+
```console
|
|
32
|
+
poetry run coverage html
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
To check the lock file is up-to-date:
|
|
36
|
+
|
|
37
|
+
```console
|
|
38
|
+
poetry check --lock
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Shortcut Task Commands
|
|
42
|
+
|
|
43
|
+
To be able to run shortcut task commands, first install the Poetry plugin [`poethepoet`](https://poethepoet.natn.io/index.html):
|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
poetry self add 'poethepoet[poetry_plugin]'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
> [!NOTE]
|
|
50
|
+
> Upon the release of Poetry [v2.0.0](https://github.com/orgs/python-poetry/discussions/9793#discussioncomment-11043205), Poetry will automatically support bootstrap installation of [project-specific plugins](https://github.com/python-poetry/poetry/pull/9547) and installation of the task runner will become automatic for this project.
|
|
51
|
+
> The `pyproject.toml` syntax will be:
|
|
52
|
+
>
|
|
53
|
+
> ```toml
|
|
54
|
+
> [tool.poetry]
|
|
55
|
+
> requires-poetry = ">=2.0"
|
|
56
|
+
>
|
|
57
|
+
> [tool.poetry.requires-plugins]
|
|
58
|
+
> poethepoet = ">=0.29"
|
|
59
|
+
> ```
|
|
60
|
+
|
|
61
|
+
### For Running Individual Checks
|
|
62
|
+
|
|
63
|
+
```console
|
|
64
|
+
poetry task check-lock
|
|
65
|
+
poetry task check-format
|
|
66
|
+
poetry task check-lint
|
|
67
|
+
poetry task check-tests
|
|
68
|
+
poetry task check-typing
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### For Running All Checks
|
|
72
|
+
|
|
73
|
+
```console
|
|
74
|
+
poetry task check-all
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### For Running Individual Fixes
|
|
78
|
+
|
|
79
|
+
```console
|
|
80
|
+
poetry task fix-format
|
|
81
|
+
poetry task fix-lint
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### For Running All Fixes
|
|
85
|
+
|
|
86
|
+
```console
|
|
87
|
+
poetry task fix-all
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### For Running All Fixes and Checks
|
|
91
|
+
|
|
92
|
+
```console
|
|
93
|
+
poetry task fix-and-check-all
|
|
94
|
+
```
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright © 2024 Clint Valentine
|
|
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.
|
typeline/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# ruff: noqa: F401
|
|
2
|
+
from ._data_types import RecordType
|
|
3
|
+
from ._reader import CsvStructReader
|
|
4
|
+
from ._reader import DelimitedStructReader
|
|
5
|
+
from ._reader import TsvStructReader
|
|
6
|
+
from ._writer import CsvStructWriter
|
|
7
|
+
from ._writer import DelimitedStructWriter
|
|
8
|
+
from ._writer import TsvStructWriter
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CsvStructReader",
|
|
12
|
+
"DelimitedStructReader",
|
|
13
|
+
"TsvStructReader",
|
|
14
|
+
"CsvStructWriter",
|
|
15
|
+
"DelimitedStructWriter",
|
|
16
|
+
"TsvStructWriter",
|
|
17
|
+
"RecordType",
|
|
18
|
+
]
|
typeline/_data_types.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from dataclasses import Field
|
|
2
|
+
from typing import Any
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
from typing import TypeAlias
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
from typing import runtime_checkable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class DataclassInstance(Protocol):
|
|
12
|
+
"""A protocol for objects that are dataclass instances."""
|
|
13
|
+
|
|
14
|
+
__dataclass_fields__: ClassVar[dict[str, Field[Any]]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
JsonType: TypeAlias = dict[str, "JsonType"] | list["JsonType"] | str | int | float | bool | None
|
|
18
|
+
"""A JSON-like data type."""
|
|
19
|
+
|
|
20
|
+
RecordType = TypeVar("RecordType", bound=DataclassInstance)
|
|
21
|
+
"""A type variable for the type of record (of dataclass type) for reading and writing."""
|
typeline/_reader.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import json
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from abc import abstractmethod
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import AbstractContextManager
|
|
8
|
+
from csv import DictReader
|
|
9
|
+
from dataclasses import Field
|
|
10
|
+
from dataclasses import fields as fields_of
|
|
11
|
+
from dataclasses import is_dataclass
|
|
12
|
+
from io import TextIOWrapper
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import NoneType
|
|
15
|
+
from types import TracebackType
|
|
16
|
+
from types import UnionType
|
|
17
|
+
from typing import Any
|
|
18
|
+
from typing import Generic
|
|
19
|
+
from typing import final
|
|
20
|
+
from typing import get_args
|
|
21
|
+
from typing import get_origin
|
|
22
|
+
|
|
23
|
+
from msgspec import convert
|
|
24
|
+
from typing_extensions import Self
|
|
25
|
+
from typing_extensions import override
|
|
26
|
+
|
|
27
|
+
from ._data_types import JsonType
|
|
28
|
+
from ._data_types import RecordType
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DelimitedStructReader(
|
|
32
|
+
AbstractContextManager["DelimitedStructReader[RecordType]"],
|
|
33
|
+
Iterable[RecordType],
|
|
34
|
+
Generic[RecordType],
|
|
35
|
+
ABC,
|
|
36
|
+
):
|
|
37
|
+
"""A reader for reading delimited data into dataclasses."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
handle: TextIOWrapper,
|
|
42
|
+
record_type: type[RecordType],
|
|
43
|
+
/,
|
|
44
|
+
has_header: bool = True,
|
|
45
|
+
):
|
|
46
|
+
"""Instantiate a new delimited struct reader.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
handle: a file-like object to read records from.
|
|
50
|
+
record_type: the type of the object we will be writing.
|
|
51
|
+
has_header: whether we expect the first line to be a header or not.
|
|
52
|
+
"""
|
|
53
|
+
if not is_dataclass(record_type):
|
|
54
|
+
raise ValueError("record_type is not a dataclass but must be!")
|
|
55
|
+
|
|
56
|
+
self._record_type: type[RecordType] = record_type
|
|
57
|
+
self._handle: TextIOWrapper = handle
|
|
58
|
+
self._fields: tuple[Field[Any], ...] = fields_of(record_type)
|
|
59
|
+
self._header: list[str] = [field.name for field in self._fields]
|
|
60
|
+
self._types: list[type | str | Any] = [field.type for field in self._fields]
|
|
61
|
+
self._reader: DictReader[str] = DictReader(
|
|
62
|
+
self._filter_out_comments(handle),
|
|
63
|
+
fieldnames=self._header if not has_header else None,
|
|
64
|
+
delimiter=self.delimiter,
|
|
65
|
+
quotechar="'",
|
|
66
|
+
quoting=csv.QUOTE_MINIMAL,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if self._reader.fieldnames is not None and set(self._reader.fieldnames) != set(
|
|
70
|
+
self._header
|
|
71
|
+
):
|
|
72
|
+
raise ValueError("Fields of header do not match fields of dataclass!")
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def delimiter(self) -> str:
|
|
77
|
+
"""Delimiter character to use in the output."""
|
|
78
|
+
|
|
79
|
+
@override
|
|
80
|
+
def __enter__(self) -> Self:
|
|
81
|
+
"""Enter this context."""
|
|
82
|
+
_ = super().__enter__()
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
@override
|
|
86
|
+
def __exit__(
|
|
87
|
+
self,
|
|
88
|
+
__exc_type: type[BaseException] | None,
|
|
89
|
+
__exc_value: BaseException | None,
|
|
90
|
+
__traceback: TracebackType | None,
|
|
91
|
+
) -> bool | None:
|
|
92
|
+
"""Close and exit this context."""
|
|
93
|
+
self.close()
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def _filter_out_comments(self, lines: Iterator[str]) -> Iterator[str]:
|
|
97
|
+
"""Yield only lines in an iterator that do not start with a comment character."""
|
|
98
|
+
for line in lines:
|
|
99
|
+
stripped: str = line.strip()
|
|
100
|
+
if stripped and not any(stripped.startswith(char) for char in self.comment_prefixes):
|
|
101
|
+
yield line
|
|
102
|
+
|
|
103
|
+
def _value_to_builtin(self, name: str, value: Any, field_type: type | str | Any) -> Any:
|
|
104
|
+
type_args: tuple[type, ...] = get_args(field_type)
|
|
105
|
+
type_origin: type | None = get_origin(field_type)
|
|
106
|
+
is_union: bool = isinstance(field_type, UnionType)
|
|
107
|
+
|
|
108
|
+
if value is None:
|
|
109
|
+
return f'"{name}":null'
|
|
110
|
+
elif value == "" and is_union and NoneType in type_args:
|
|
111
|
+
return f'"{name}":null'
|
|
112
|
+
elif field_type is bool or (is_union and bool in type_args):
|
|
113
|
+
return f'"{name}":{value.lower()}'
|
|
114
|
+
elif field_type is int or (is_union and int in type_args):
|
|
115
|
+
return f'"{name}":{value}'
|
|
116
|
+
elif field_type is float or (is_union and float in type_args):
|
|
117
|
+
return f'"{name}":{value}'
|
|
118
|
+
elif field_type is str or (is_union and str in type_args):
|
|
119
|
+
return f'"{name}":"{value}"'
|
|
120
|
+
elif type_origin in (dict, frozenset, list, set, tuple):
|
|
121
|
+
return f'"{name}":{value}'
|
|
122
|
+
elif is_union and len(type_args) >= 2 and NoneType in type_args:
|
|
123
|
+
other_types: set[type] = set(type_args) - {NoneType}
|
|
124
|
+
return self._value_to_builtin(name, value, other_types)
|
|
125
|
+
else:
|
|
126
|
+
return f'"{name}":{value}'
|
|
127
|
+
|
|
128
|
+
def _csv_dict_to_json(self, record: dict[str, str]) -> JsonType:
|
|
129
|
+
"""Build a list of builtin-like objects from a string-only dictionary."""
|
|
130
|
+
key_values: list[str] = []
|
|
131
|
+
|
|
132
|
+
for (name, value), field_type in zip(record.items(), self._types, strict=True):
|
|
133
|
+
decoded: Any = self._decode(field_type, value)
|
|
134
|
+
|
|
135
|
+
key_value = self._value_to_builtin(name, decoded, field_type)
|
|
136
|
+
|
|
137
|
+
key_value = key_value.replace("\t", "\\t")
|
|
138
|
+
key_value = key_value.replace("\r", "\\r")
|
|
139
|
+
key_value = key_value.replace("\n", "\\n")
|
|
140
|
+
|
|
141
|
+
key_values.append(key_value)
|
|
142
|
+
|
|
143
|
+
json_string: str = f"{{{','.join(key_values)}}}"
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
as_builtins: JsonType = json.loads(json_string)
|
|
147
|
+
except json.decoder.JSONDecodeError as exception:
|
|
148
|
+
raise json.decoder.JSONDecodeError(
|
|
149
|
+
msg=(
|
|
150
|
+
"Could not load delimited data line into JSON-like format."
|
|
151
|
+
+ f" Built improperly formatted JSON: {json_string}."
|
|
152
|
+
+ f" Originally formatted message: {exception.msg}."
|
|
153
|
+
),
|
|
154
|
+
doc=exception.doc,
|
|
155
|
+
pos=exception.pos,
|
|
156
|
+
) from exception
|
|
157
|
+
|
|
158
|
+
return as_builtins
|
|
159
|
+
|
|
160
|
+
@override
|
|
161
|
+
def __iter__(self) -> Iterator[RecordType]:
|
|
162
|
+
"""Yield converted records from the delimited data file."""
|
|
163
|
+
for record in self._reader:
|
|
164
|
+
as_builtins = self._csv_dict_to_json(record)
|
|
165
|
+
try:
|
|
166
|
+
yield convert(as_builtins, self._record_type, strict=False)
|
|
167
|
+
except ValueError as exception:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"Could not parse {record} as {self._record_type.__name__}!"
|
|
170
|
+
+ f" Intermediate structure formed is: {as_builtins}."
|
|
171
|
+
+ f" Original error: {exception}"
|
|
172
|
+
) from exception
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _decode(record_type: type[Any] | str | Any, item: Any) -> Any: # noqa: ARG004 # pyright: ignore[reportUnusedParameter]
|
|
176
|
+
"""A callback for overriding the decoding of builtin types and custom types."""
|
|
177
|
+
return item
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def comment_prefixes(self) -> set[str]:
|
|
181
|
+
"""Any string that when one prefixes a line, marks it as a comment."""
|
|
182
|
+
return {"#"}
|
|
183
|
+
|
|
184
|
+
def close(self) -> None:
|
|
185
|
+
"""Close all opened resources."""
|
|
186
|
+
self._handle.close()
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def from_path(
|
|
191
|
+
cls,
|
|
192
|
+
path: Path | str,
|
|
193
|
+
record_type: type[RecordType],
|
|
194
|
+
/,
|
|
195
|
+
has_header: bool = True,
|
|
196
|
+
) -> Self:
|
|
197
|
+
"""Construct a delimited struct reader from a file path."""
|
|
198
|
+
reader = cls(Path(path).open("r"), record_type, has_header=has_header)
|
|
199
|
+
return reader
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class CsvStructReader(DelimitedStructReader[RecordType]):
|
|
203
|
+
r"""A reader for reading comma-delimited data into dataclasses.
|
|
204
|
+
|
|
205
|
+
Example:
|
|
206
|
+
```pycon
|
|
207
|
+
>>> from pathlib import Path
|
|
208
|
+
>>> from dataclasses import dataclass
|
|
209
|
+
>>> from tempfile import NamedTemporaryFile
|
|
210
|
+
>>>
|
|
211
|
+
>>> @dataclass
|
|
212
|
+
... class MyData:
|
|
213
|
+
... field1: str
|
|
214
|
+
... field2: float | None
|
|
215
|
+
>>>
|
|
216
|
+
>>> from typeline import CsvStructReader
|
|
217
|
+
>>>
|
|
218
|
+
>>> with NamedTemporaryFile(mode="w+t") as tmpfile:
|
|
219
|
+
... _ = tmpfile.write("field1,field2\nmy-name,0.2\n")
|
|
220
|
+
... _ = tmpfile.flush()
|
|
221
|
+
... with CsvStructReader.from_path(tmpfile.name, MyData) as reader:
|
|
222
|
+
... for record in reader:
|
|
223
|
+
... print(record)
|
|
224
|
+
MyData(field1='my-name', field2=0.2)
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
@override
|
|
231
|
+
@final
|
|
232
|
+
def delimiter(self) -> str:
|
|
233
|
+
return ","
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class TsvStructReader(DelimitedStructReader[RecordType]):
|
|
237
|
+
r"""A reader for reading tab-delimited data into dataclasses.
|
|
238
|
+
|
|
239
|
+
Example:
|
|
240
|
+
```pycon
|
|
241
|
+
>>> from pathlib import Path
|
|
242
|
+
>>> from dataclasses import dataclass
|
|
243
|
+
>>> from tempfile import NamedTemporaryFile
|
|
244
|
+
>>>
|
|
245
|
+
>>> @dataclass
|
|
246
|
+
... class MyData:
|
|
247
|
+
... field1: str
|
|
248
|
+
... field2: float | None
|
|
249
|
+
>>>
|
|
250
|
+
>>> from typeline import TsvStructReader
|
|
251
|
+
>>>
|
|
252
|
+
>>> with NamedTemporaryFile(mode="w+t") as tmpfile:
|
|
253
|
+
... _ = tmpfile.write("field1\tfield2\nmy-name\t0.2\n")
|
|
254
|
+
... _ = tmpfile.flush()
|
|
255
|
+
... with TsvStructReader.from_path(tmpfile.name, MyData) as reader:
|
|
256
|
+
... for record in reader:
|
|
257
|
+
... print(record)
|
|
258
|
+
MyData(field1='my-name', field2=0.2)
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
@override
|
|
265
|
+
@final
|
|
266
|
+
def delimiter(self) -> str:
|
|
267
|
+
return "\t"
|
typeline/_writer.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import json
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from abc import abstractmethod
|
|
5
|
+
from contextlib import AbstractContextManager
|
|
6
|
+
from csv import DictWriter
|
|
7
|
+
from dataclasses import Field
|
|
8
|
+
from dataclasses import fields as fields_of
|
|
9
|
+
from dataclasses import is_dataclass
|
|
10
|
+
from io import TextIOWrapper
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Any
|
|
14
|
+
from typing import Generic
|
|
15
|
+
from typing import cast
|
|
16
|
+
from typing import final
|
|
17
|
+
|
|
18
|
+
from msgspec import to_builtins
|
|
19
|
+
from typing_extensions import Self
|
|
20
|
+
from typing_extensions import override
|
|
21
|
+
|
|
22
|
+
from ._data_types import RecordType
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DelimitedStructWriter(
|
|
26
|
+
AbstractContextManager["DelimitedStructWriter[RecordType]"],
|
|
27
|
+
Generic[RecordType],
|
|
28
|
+
ABC,
|
|
29
|
+
):
|
|
30
|
+
"""A writer for writing dataclasses into delimited data."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, handle: TextIOWrapper, record_type: type[RecordType]) -> None:
|
|
33
|
+
"""Instantiate a new delimited struct writer.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
handle: a file-like object to write records to.
|
|
37
|
+
record_type: the type of the object we will be writing.
|
|
38
|
+
"""
|
|
39
|
+
if not is_dataclass(record_type):
|
|
40
|
+
raise ValueError("record_type is not a dataclass but must be!")
|
|
41
|
+
|
|
42
|
+
self._record_type: type[RecordType] = record_type
|
|
43
|
+
self._handle: TextIOWrapper = handle
|
|
44
|
+
self._fields: tuple[Field[Any], ...] = fields_of(record_type)
|
|
45
|
+
self._header: list[str] = [field.name for field in fields_of(record_type)]
|
|
46
|
+
self._writer: DictWriter[str] = DictWriter(
|
|
47
|
+
handle,
|
|
48
|
+
fieldnames=self._header,
|
|
49
|
+
delimiter=self.delimiter,
|
|
50
|
+
quotechar="'",
|
|
51
|
+
quoting=csv.QUOTE_MINIMAL,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def delimiter(self) -> str:
|
|
57
|
+
"""Delimiter character to use in the output."""
|
|
58
|
+
|
|
59
|
+
@override
|
|
60
|
+
def __enter__(self) -> Self:
|
|
61
|
+
"""Enter this context."""
|
|
62
|
+
_ = super().__enter__()
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
@override
|
|
66
|
+
def __exit__(
|
|
67
|
+
self,
|
|
68
|
+
__exc_type: type[BaseException] | None,
|
|
69
|
+
__exc_value: BaseException | None,
|
|
70
|
+
__traceback: TracebackType | None,
|
|
71
|
+
) -> bool | None:
|
|
72
|
+
"""Close and exit this context."""
|
|
73
|
+
self.close()
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _encode(item: Any) -> Any:
|
|
78
|
+
"""A callback for overriding the encoding of builtin types and custom types."""
|
|
79
|
+
if isinstance(item, tuple):
|
|
80
|
+
return list(item) # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
|
|
81
|
+
return item
|
|
82
|
+
|
|
83
|
+
def write(self, record: RecordType) -> None:
|
|
84
|
+
"""Write the record to the open file-like object."""
|
|
85
|
+
if not isinstance(record, self._record_type):
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"Expected {self._record_type.__name__} but found"
|
|
88
|
+
+ f" {record.__class__.__qualname__}!"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
encoded = {name: self._encode(getattr(record, name)) for name in self._header}
|
|
92
|
+
builtin = {
|
|
93
|
+
name: (json.dumps(value) if not isinstance(value, str) else value)
|
|
94
|
+
for name, value in cast(dict[str, Any], to_builtins(encoded, str_keys=True)).items()
|
|
95
|
+
}
|
|
96
|
+
self._writer.writerow(builtin)
|
|
97
|
+
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def write_header(self) -> None:
|
|
101
|
+
"""Write the header line to the open file-like object."""
|
|
102
|
+
self._writer.writeheader()
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
"""Close all opened resources."""
|
|
107
|
+
self._handle.close()
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_path(
|
|
112
|
+
cls, path: Path | str, record_type: type[RecordType]
|
|
113
|
+
) -> "DelimitedStructWriter[RecordType]":
|
|
114
|
+
"""Construct a delimited struct writer from a file path."""
|
|
115
|
+
writer = cls(Path(path).open("w"), record_type)
|
|
116
|
+
return writer
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class CsvStructWriter(DelimitedStructWriter[RecordType]):
|
|
120
|
+
r"""A writer for writing dataclasses into comma-delimited data.
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
```pycon
|
|
124
|
+
>>> from pathlib import Path
|
|
125
|
+
>>> from dataclasses import dataclass
|
|
126
|
+
>>> from tempfile import NamedTemporaryFile
|
|
127
|
+
>>>
|
|
128
|
+
>>> @dataclass
|
|
129
|
+
... class MyData:
|
|
130
|
+
... field1: str
|
|
131
|
+
... field2: float | None
|
|
132
|
+
>>>
|
|
133
|
+
>>> from typeline import CsvStructWriter
|
|
134
|
+
>>>
|
|
135
|
+
>>> with NamedTemporaryFile(mode="w+t") as tmpfile:
|
|
136
|
+
... with CsvStructWriter.from_path(tmpfile.name, MyData) as writer:
|
|
137
|
+
... writer.write_header()
|
|
138
|
+
... writer.write(MyData(field1="my-name", field2=0.2))
|
|
139
|
+
... Path(tmpfile.name).read_text()
|
|
140
|
+
'field1,field2\nmy-name,0.2\n'
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
@override
|
|
147
|
+
@final
|
|
148
|
+
def delimiter(self) -> str:
|
|
149
|
+
return ","
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TsvStructWriter(DelimitedStructWriter[RecordType]):
|
|
153
|
+
r"""A writer for writing dataclasses into tab-delimited data.
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
```pycon
|
|
157
|
+
>>> from pathlib import Path
|
|
158
|
+
>>> from dataclasses import dataclass
|
|
159
|
+
>>> from tempfile import NamedTemporaryFile
|
|
160
|
+
>>>
|
|
161
|
+
>>> @dataclass
|
|
162
|
+
... class MyData:
|
|
163
|
+
... field1: str
|
|
164
|
+
... field2: float | None
|
|
165
|
+
>>>
|
|
166
|
+
>>> from typeline import TsvStructWriter
|
|
167
|
+
>>>
|
|
168
|
+
>>> with NamedTemporaryFile(mode="w+t") as tmpfile:
|
|
169
|
+
... with TsvStructWriter.from_path(tmpfile.name, MyData) as writer:
|
|
170
|
+
... writer.write_header()
|
|
171
|
+
... writer.write(MyData(field1="my-name", field2=0.2))
|
|
172
|
+
... Path(tmpfile.name).read_text()
|
|
173
|
+
'field1\tfield2\nmy-name\t0.2\n'
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
@override
|
|
180
|
+
@final
|
|
181
|
+
def delimiter(self) -> str:
|
|
182
|
+
return "\t"
|
typeline/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright © 2024 Clint Valentine
|
|
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,100 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: typeline
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Write dataclasses to delimited text formats and read them back again.
|
|
5
|
+
Home-page: https://github.com/clintval/typeline
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: dataclass,msgspec,IO,delimited,CSV,TSV
|
|
8
|
+
Author: Clint Valentine
|
|
9
|
+
Author-email: valentine.clint@gmail.com
|
|
10
|
+
Requires-Python: >=3.10.0,<4.0.0
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Natural Language :: English
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: File Formats
|
|
24
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Dist: msgspec (>=0.18,<0.19)
|
|
28
|
+
Requires-Dist: typing-extensions (>=4.12,<5.0)
|
|
29
|
+
Project-URL: Repository, https://github.com/clintval/typeline
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# typeline
|
|
33
|
+
|
|
34
|
+
[](https://badge.fury.io/py/typeline)
|
|
35
|
+
[](https://github.com/clintval/typeline/actions/workflows/tests.yml?query=branch%3Amain)
|
|
36
|
+
[](https://github.com/clintval/typeline)
|
|
37
|
+
[](https://docs.basedpyright.com/latest/)
|
|
38
|
+
[](https://mypy-lang.org/)
|
|
39
|
+
[](https://python-poetry.org/)
|
|
40
|
+
[](https://docs.astral.sh/ruff/)
|
|
41
|
+
|
|
42
|
+
Write dataclasses to delimited text formats and read them back again.
|
|
43
|
+
|
|
44
|
+
Features type-safe parsing, optional field support, and an intuitive API for working with structured data.
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
The package can be installed with `pip`:
|
|
49
|
+
|
|
50
|
+
```console
|
|
51
|
+
pip install typeline
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Quickstart
|
|
55
|
+
|
|
56
|
+
### Building a Test Dataclass
|
|
57
|
+
|
|
58
|
+
```pycon
|
|
59
|
+
>>> from dataclasses import dataclass
|
|
60
|
+
>>>
|
|
61
|
+
>>> @dataclass
|
|
62
|
+
... class MyData:
|
|
63
|
+
... field1: int
|
|
64
|
+
... field2: str
|
|
65
|
+
... field3: float | None
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Writing
|
|
70
|
+
|
|
71
|
+
```pycon
|
|
72
|
+
>>> from tempfile import NamedTemporaryFile
|
|
73
|
+
>>> from typeline import TsvStructWriter
|
|
74
|
+
>>>
|
|
75
|
+
>>> temp_file = NamedTemporaryFile(mode="w+t", suffix=".txt")
|
|
76
|
+
>>>
|
|
77
|
+
>>> with TsvStructWriter.from_path(temp_file.name, MyData) as writer:
|
|
78
|
+
... writer.write_header()
|
|
79
|
+
... writer.write(MyData(10, "test1", 0.2))
|
|
80
|
+
... writer.write(MyData(20, "test2", None))
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Reading
|
|
85
|
+
|
|
86
|
+
```pycon
|
|
87
|
+
>>> from typeline import TsvStructReader
|
|
88
|
+
>>>
|
|
89
|
+
>>> with TsvStructReader.from_path(temp_file.name, MyData) as reader:
|
|
90
|
+
... for record in reader:
|
|
91
|
+
... print(record)
|
|
92
|
+
MyData(field1=10, field2='test1', field3=0.2)
|
|
93
|
+
MyData(field1=20, field2='test2', field3=None)
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Development and Testing
|
|
98
|
+
|
|
99
|
+
See the [contributing guide](./CONTRIBUTING.md) for more information.
|
|
100
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
CONTRIBUTING.md,sha256=selnIsM_6KTwdFyc0B3u4n2bM_sBAeSbQV-Fd7mzHuE,1803
|
|
2
|
+
LICENSE,sha256=nN9Rgc41QgFz1uJfX7AsXRQJxlcJotVEPTNPwUAqfys,1070
|
|
3
|
+
typeline/__init__.py,sha256=oCm5b8hOuUvXu00MTxJuwGlHMnDF4-PwdBikrmbZhTE,472
|
|
4
|
+
typeline/_data_types.py,sha256=oTJnX5prKrOY1IxSudTdckgZNTIQdbmdc6Hk2lzR75U,659
|
|
5
|
+
typeline/_reader.py,sha256=Mt3SCdF_RYaO0K6SaaAzIrknJr4h0DNNeMpKvBPvTTY,9236
|
|
6
|
+
typeline/_writer.py,sha256=scHpJZQxoJsKq4DOtGB7SxV8TNPEIXNOWEL0frn-jNA,5666
|
|
7
|
+
typeline/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
typeline-0.1.0.dist-info/LICENSE,sha256=nN9Rgc41QgFz1uJfX7AsXRQJxlcJotVEPTNPwUAqfys,1070
|
|
9
|
+
typeline-0.1.0.dist-info/METADATA,sha256=DikIcUX9An9lfDp1ItuhQgIT_wpRV9PLuTl6cIs342o,3408
|
|
10
|
+
typeline-0.1.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
11
|
+
typeline-0.1.0.dist-info/RECORD,,
|