mtsv 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.
- mtsv-0.1.0/LICENSE +22 -0
- mtsv-0.1.0/MANIFEST.in +1 -0
- mtsv-0.1.0/PKG-INFO +122 -0
- mtsv-0.1.0/README.md +106 -0
- mtsv-0.1.0/pyproject.toml +21 -0
- mtsv-0.1.0/setup.cfg +4 -0
- mtsv-0.1.0/src/mtsv/__init__.py +52 -0
- mtsv-0.1.0/src/mtsv/_generator.py +106 -0
- mtsv-0.1.0/src/mtsv/_parser.py +147 -0
- mtsv-0.1.0/src/mtsv/integrations/__init__.py +0 -0
- mtsv-0.1.0/src/mtsv/integrations/arrow.py +147 -0
- mtsv-0.1.0/src/mtsv/integrations/ods.py +479 -0
- mtsv-0.1.0/src/mtsv/py.typed +0 -0
- mtsv-0.1.0/src/mtsv.egg-info/PKG-INFO +122 -0
- mtsv-0.1.0/src/mtsv.egg-info/SOURCES.txt +16 -0
- mtsv-0.1.0/src/mtsv.egg-info/dependency_links.txt +1 -0
- mtsv-0.1.0/src/mtsv.egg-info/requires.txt +3 -0
- mtsv-0.1.0/src/mtsv.egg-info/top_level.txt +1 -0
mtsv-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Demos Ra
|
|
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.
|
|
22
|
+
|
mtsv-0.1.0/MANIFEST.in
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
prune tests
|
mtsv-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mtsv
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python implementation of Multi-Sheet Tab-Separated Values (MTSV)
|
|
5
|
+
Author-email: Demos Ra <demos_ra@hotmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: source, https://github.com/demos-ra/mtsv
|
|
8
|
+
Project-URL: issues, https://github.com/demos-ra/mtsv/issues
|
|
9
|
+
Keywords: tsv,tabular data,media type
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Provides-Extra: arrow
|
|
14
|
+
Requires-Dist: pyarrow>=16; extra == "arrow"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# MTSV for Python
|
|
18
|
+
|
|
19
|
+
A parser and a generator for Multi-Sheet Tab-Separated Values (MTSV), with
|
|
20
|
+
integrations for spreadsheets (ODS) and data tools (Apache Arrow). The
|
|
21
|
+
version is the `version` field of `pyproject.toml`.
|
|
22
|
+
|
|
23
|
+
* [Specification](https://github.com/demos-ra/mtsv-spec)
|
|
24
|
+
* [Repository](https://github.com/demos-ra/mtsv)
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
From the repository. A Python that an operating system manages does not
|
|
29
|
+
accept packages directly, so install into a virtual environment. Run these
|
|
30
|
+
from the root of the repository, one folder above this one:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
python3 -m venv .venv
|
|
34
|
+
.venv/bin/pip install ./python
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For the Arrow integration, which also installs pyarrow:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
.venv/bin/pip install "./python[arrow]"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Read and write MTSV
|
|
44
|
+
|
|
45
|
+
Sheets are a list of dictionaries with `"sheet name"`, `"header"`, and
|
|
46
|
+
`"records"`, the same shape as the
|
|
47
|
+
[conformance results](https://github.com/demos-ra/mtsv/blob/main/conformance/README.md).
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import mtsv
|
|
51
|
+
|
|
52
|
+
with open("book.mtsv", "rb") as file:
|
|
53
|
+
sheets = mtsv.load(file)
|
|
54
|
+
|
|
55
|
+
with open("book.mtsv", "wb") as file:
|
|
56
|
+
mtsv.dump(sheets, file)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`loads` and `dumps` work on strings.
|
|
60
|
+
|
|
61
|
+
## Spreadsheets (ODS)
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from mtsv.integrations import ods
|
|
65
|
+
|
|
66
|
+
with open("book.ods", "wb") as file:
|
|
67
|
+
ods.dump(sheets, file)
|
|
68
|
+
|
|
69
|
+
with open("book.ods", "rb") as file:
|
|
70
|
+
sheets = ods.load(file)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
From the command line:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
python -m mtsv.integrations.ods book.mtsv book.ods
|
|
77
|
+
python -m mtsv.integrations.ods book.ods book.mtsv
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Data tools (Apache Arrow)
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from mtsv.integrations import arrow
|
|
84
|
+
|
|
85
|
+
tables = arrow.to_arrow(sheets)
|
|
86
|
+
sheets = arrow.from_arrow(tables)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`tables` is a list of `(sheet name, pyarrow.Table)` pairs.
|
|
90
|
+
|
|
91
|
+
## What is left behind
|
|
92
|
+
|
|
93
|
+
MTSV holds sheets, names, rows, and text. Going out to ODS or Arrow keeps all
|
|
94
|
+
of it, with two ODS exceptions: characters that XML 1.0 does not allow raise
|
|
95
|
+
`ValueError`, and empty rows and columns at the edge of a sheet do not come
|
|
96
|
+
back from ODS, so a sheet of only empty fields comes back as an empty sheet.
|
|
97
|
+
Coming back in, anything else (formatting, formulas, types, missing values)
|
|
98
|
+
raises `ValueError` by default. To confirm and leave it behind, pass
|
|
99
|
+
`errors="ignore"`, or `--errors ignore` on the command line. Text that MTSV
|
|
100
|
+
cannot hold, such as a tab or line break inside a value, always raises
|
|
101
|
+
`ValueError`. So does an Arrow column whose values cannot be text at all, such
|
|
102
|
+
as binary, a list, or a struct.
|
|
103
|
+
|
|
104
|
+
## Layout
|
|
105
|
+
|
|
106
|
+
| Path | Contents |
|
|
107
|
+
|--------------------------|---------------------------------------------|
|
|
108
|
+
| `src/mtsv/` | the interface, the parser, and the generator |
|
|
109
|
+
| `src/mtsv/integrations/` | one module per target standard |
|
|
110
|
+
| `tests/` | the test suite, run against the install |
|
|
111
|
+
|
|
112
|
+
## Test
|
|
113
|
+
|
|
114
|
+
From the root of the repository:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
.venv/bin/python -m unittest discover -s python/tests
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
[MIT](https://github.com/demos-ra/mtsv/blob/main/LICENSE)
|
mtsv-0.1.0/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# MTSV for Python
|
|
2
|
+
|
|
3
|
+
A parser and a generator for Multi-Sheet Tab-Separated Values (MTSV), with
|
|
4
|
+
integrations for spreadsheets (ODS) and data tools (Apache Arrow). The
|
|
5
|
+
version is the `version` field of `pyproject.toml`.
|
|
6
|
+
|
|
7
|
+
* [Specification](https://github.com/demos-ra/mtsv-spec)
|
|
8
|
+
* [Repository](https://github.com/demos-ra/mtsv)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
From the repository. A Python that an operating system manages does not
|
|
13
|
+
accept packages directly, so install into a virtual environment. Run these
|
|
14
|
+
from the root of the repository, one folder above this one:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
python3 -m venv .venv
|
|
18
|
+
.venv/bin/pip install ./python
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For the Arrow integration, which also installs pyarrow:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
.venv/bin/pip install "./python[arrow]"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Read and write MTSV
|
|
28
|
+
|
|
29
|
+
Sheets are a list of dictionaries with `"sheet name"`, `"header"`, and
|
|
30
|
+
`"records"`, the same shape as the
|
|
31
|
+
[conformance results](https://github.com/demos-ra/mtsv/blob/main/conformance/README.md).
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import mtsv
|
|
35
|
+
|
|
36
|
+
with open("book.mtsv", "rb") as file:
|
|
37
|
+
sheets = mtsv.load(file)
|
|
38
|
+
|
|
39
|
+
with open("book.mtsv", "wb") as file:
|
|
40
|
+
mtsv.dump(sheets, file)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`loads` and `dumps` work on strings.
|
|
44
|
+
|
|
45
|
+
## Spreadsheets (ODS)
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from mtsv.integrations import ods
|
|
49
|
+
|
|
50
|
+
with open("book.ods", "wb") as file:
|
|
51
|
+
ods.dump(sheets, file)
|
|
52
|
+
|
|
53
|
+
with open("book.ods", "rb") as file:
|
|
54
|
+
sheets = ods.load(file)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
From the command line:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
python -m mtsv.integrations.ods book.mtsv book.ods
|
|
61
|
+
python -m mtsv.integrations.ods book.ods book.mtsv
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Data tools (Apache Arrow)
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from mtsv.integrations import arrow
|
|
68
|
+
|
|
69
|
+
tables = arrow.to_arrow(sheets)
|
|
70
|
+
sheets = arrow.from_arrow(tables)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`tables` is a list of `(sheet name, pyarrow.Table)` pairs.
|
|
74
|
+
|
|
75
|
+
## What is left behind
|
|
76
|
+
|
|
77
|
+
MTSV holds sheets, names, rows, and text. Going out to ODS or Arrow keeps all
|
|
78
|
+
of it, with two ODS exceptions: characters that XML 1.0 does not allow raise
|
|
79
|
+
`ValueError`, and empty rows and columns at the edge of a sheet do not come
|
|
80
|
+
back from ODS, so a sheet of only empty fields comes back as an empty sheet.
|
|
81
|
+
Coming back in, anything else (formatting, formulas, types, missing values)
|
|
82
|
+
raises `ValueError` by default. To confirm and leave it behind, pass
|
|
83
|
+
`errors="ignore"`, or `--errors ignore` on the command line. Text that MTSV
|
|
84
|
+
cannot hold, such as a tab or line break inside a value, always raises
|
|
85
|
+
`ValueError`. So does an Arrow column whose values cannot be text at all, such
|
|
86
|
+
as binary, a list, or a struct.
|
|
87
|
+
|
|
88
|
+
## Layout
|
|
89
|
+
|
|
90
|
+
| Path | Contents |
|
|
91
|
+
|--------------------------|---------------------------------------------|
|
|
92
|
+
| `src/mtsv/` | the interface, the parser, and the generator |
|
|
93
|
+
| `src/mtsv/integrations/` | one module per target standard |
|
|
94
|
+
| `tests/` | the test suite, run against the install |
|
|
95
|
+
|
|
96
|
+
## Test
|
|
97
|
+
|
|
98
|
+
From the root of the repository:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
.venv/bin/python -m unittest discover -s python/tests
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
[MIT](https://github.com/demos-ra/mtsv/blob/main/LICENSE)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mtsv"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python implementation of Multi-Sheet Tab-Separated Values (MTSV)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Demos Ra", email = "demos_ra@hotmail.com" }]
|
|
14
|
+
keywords = ["tsv", "tabular data", "media type"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
source = "https://github.com/demos-ra/mtsv"
|
|
18
|
+
issues = "https://github.com/demos-ra/mtsv/issues"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
arrow = ["pyarrow>=16"]
|
mtsv-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Multi-Sheet Tab-Separated Values (MTSV), draft-demosra-mtsv-00.
|
|
2
|
+
|
|
3
|
+
Functions:
|
|
4
|
+
dump -- write MTSV sheets to a binary file, encoded as UTF-8
|
|
5
|
+
dumps -- return MTSV sheets as a string
|
|
6
|
+
load -- read MTSV sheets from a binary file, decoded as UTF-8
|
|
7
|
+
loads -- read MTSV sheets from a string
|
|
8
|
+
|
|
9
|
+
Exceptions:
|
|
10
|
+
MTSVDecodeError -- raised for text that is not an MTSV file
|
|
11
|
+
|
|
12
|
+
Subpackages:
|
|
13
|
+
integrations -- conversions between MTSV and other standards
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__all__ = ["dump", "dumps", "load", "loads", "MTSVDecodeError"]
|
|
17
|
+
|
|
18
|
+
from typing import Any, BinaryIO
|
|
19
|
+
|
|
20
|
+
from mtsv import _generator, _parser
|
|
21
|
+
from mtsv._parser import MTSVDecodeError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def dump(obj: list[dict[str, Any]], fp: BinaryIO) -> None:
|
|
25
|
+
"""Write MTSV sheets to a binary file object, encoded as UTF-8."""
|
|
26
|
+
fp.write(dumps(obj).encode("utf-8"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def dumps(obj: list[dict[str, Any]]) -> str:
|
|
30
|
+
"""Return MTSV sheets as an MTSV string."""
|
|
31
|
+
return _generator.mtsv_file(obj)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load(fp: BinaryIO, /) -> list[dict[str, Any]]:
|
|
35
|
+
"""Read MTSV sheets from a binary file object, decoded as UTF-8."""
|
|
36
|
+
b = fp.read()
|
|
37
|
+
try:
|
|
38
|
+
s = b.decode("utf-8")
|
|
39
|
+
except AttributeError:
|
|
40
|
+
raise TypeError(
|
|
41
|
+
"File must be opened in binary mode,"
|
|
42
|
+
" e.g. use `open('foo.mtsv', 'rb')`"
|
|
43
|
+
) from None
|
|
44
|
+
return loads(s)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def loads(s: str, /) -> list[dict[str, Any]]:
|
|
48
|
+
"""Read MTSV sheets from an MTSV string."""
|
|
49
|
+
if not isinstance(s, str):
|
|
50
|
+
raise TypeError(f"Expected str object, not '{type(s).__qualname__}'")
|
|
51
|
+
_, sheets = _parser.mtsv_file(s, 0)
|
|
52
|
+
return sheets
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Generate MTSV text, following draft-demosra-mtsv-00, Section 6."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
HTAB = chr(0x09)
|
|
6
|
+
LF = chr(0x0A)
|
|
7
|
+
FF = chr(0x0C)
|
|
8
|
+
SIGNATURE = chr(0xFEFF)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def mtsv_file(sheets: list[dict[str, Any]]) -> str:
|
|
12
|
+
"""Generate: mtsv-file = unnamed-sheet *named-sheet."""
|
|
13
|
+
parts: list[str] = []
|
|
14
|
+
for index, sheet in enumerate(sheets):
|
|
15
|
+
if sheet["sheet name"] is None:
|
|
16
|
+
parts.append(unnamed_sheet(sheet, index))
|
|
17
|
+
else:
|
|
18
|
+
parts.append(named_sheet(sheet))
|
|
19
|
+
return "".join(parts)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def unnamed_sheet(sheet: dict[str, Any], index: int) -> str:
|
|
23
|
+
"""Generate: unnamed-sheet = sheet-body."""
|
|
24
|
+
if index != 0 or sheet["header"] is None:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
"an MTSV file has an unnamed sheet only if the file contains"
|
|
27
|
+
" at least one line before the first FF"
|
|
28
|
+
)
|
|
29
|
+
if sheet["header"] and sheet["header"][0].startswith(SIGNATURE):
|
|
30
|
+
raise ValueError(
|
|
31
|
+
"the first field of the unnamed sheet cannot begin with U+FEFF,"
|
|
32
|
+
" because a parser treats that character as an encoding"
|
|
33
|
+
" signature"
|
|
34
|
+
)
|
|
35
|
+
return sheet_body(sheet)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def named_sheet(sheet: dict[str, Any]) -> str:
|
|
39
|
+
"""Generate: named-sheet = FF sheet-name eol sheet-body."""
|
|
40
|
+
return FF + sheet_name(sheet["sheet name"]) + eol() + sheet_body(sheet)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sheet_body(sheet: dict[str, Any]) -> str:
|
|
44
|
+
"""Generate: sheet-body = [header *record]."""
|
|
45
|
+
header_fields = sheet["header"]
|
|
46
|
+
if header_fields is None:
|
|
47
|
+
if sheet["records"]:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"a sheet with no lines is an empty sheet;"
|
|
50
|
+
" it has neither a header nor records"
|
|
51
|
+
)
|
|
52
|
+
return ""
|
|
53
|
+
lines = [header(header_fields)]
|
|
54
|
+
for record_fields in sheet["records"]:
|
|
55
|
+
if len(record_fields) != len(header_fields):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"each record in a sheet must have the same number of fields"
|
|
58
|
+
" as the header of that sheet"
|
|
59
|
+
)
|
|
60
|
+
lines.append(record(record_fields))
|
|
61
|
+
return "".join(lines)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def header(fields: list[str]) -> str:
|
|
65
|
+
"""Generate: header = record."""
|
|
66
|
+
return record(fields)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def record(fields: list[str]) -> str:
|
|
70
|
+
"""Generate: record = field *(HTAB field) eol."""
|
|
71
|
+
if not fields:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
"a record must match: record = field *(HTAB field) eol"
|
|
74
|
+
)
|
|
75
|
+
return HTAB.join(field(value) for value in fields) + eol()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def field(value: str) -> str:
|
|
79
|
+
"""Generate: field = *field-char."""
|
|
80
|
+
if not all(field_char(char) for char in value):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"a field or sheet name that contains HT, LF, FF, or CR"
|
|
83
|
+
" cannot be represented in MTSV"
|
|
84
|
+
)
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def sheet_name(value: str) -> str:
|
|
89
|
+
"""Generate: sheet-name = *field-char."""
|
|
90
|
+
if not all(field_char(char) for char in value):
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"a field or sheet name that contains HT, LF, FF, or CR"
|
|
93
|
+
" cannot be represented in MTSV"
|
|
94
|
+
)
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def field_char(char: str) -> bool:
|
|
99
|
+
"""Match: field-char = %x00-08 / %x0B / %x0E-10FFFF."""
|
|
100
|
+
code = ord(char)
|
|
101
|
+
return 0x00 <= code <= 0x08 or code == 0x0B or 0x0E <= code <= 0x10FFFF
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def eol() -> str:
|
|
105
|
+
"""Generate: eol = LF / CRLF, writing LF."""
|
|
106
|
+
return LF
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Parse MTSV text, following draft-demosra-mtsv-00, Section 5."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
HTAB = chr(0x09)
|
|
6
|
+
LF = chr(0x0A)
|
|
7
|
+
FF = chr(0x0C)
|
|
8
|
+
CR = chr(0x0D)
|
|
9
|
+
CRLF = CR + LF
|
|
10
|
+
SIGNATURE = chr(0xFEFF)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MTSVDecodeError(ValueError):
|
|
14
|
+
"""Subclass of ValueError with the following additional properties:
|
|
15
|
+
|
|
16
|
+
msg: The unformatted error message
|
|
17
|
+
doc: The MTSV document being parsed
|
|
18
|
+
pos: The start index of doc where parsing failed
|
|
19
|
+
lineno: The line corresponding to pos
|
|
20
|
+
colno: The column corresponding to pos
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, msg: str, doc: str, pos: int) -> None:
|
|
24
|
+
"""Store msg, doc and pos, and the line and column of pos."""
|
|
25
|
+
lineno = doc.count(LF, 0, pos) + 1
|
|
26
|
+
colno = pos - doc.rfind(LF, 0, pos)
|
|
27
|
+
super().__init__(f"{msg}: line {lineno} column {colno} (char {pos})")
|
|
28
|
+
self.msg = msg
|
|
29
|
+
self.doc = doc
|
|
30
|
+
self.pos = pos
|
|
31
|
+
self.lineno = lineno
|
|
32
|
+
self.colno = colno
|
|
33
|
+
|
|
34
|
+
def __reduce__(self) -> tuple[type, tuple[str, str, int]]:
|
|
35
|
+
"""Return how to rebuild this error when it is unpickled."""
|
|
36
|
+
return self.__class__, (self.msg, self.doc, self.pos)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def mtsv_file(src: str, pos: int) -> tuple[int, list[dict[str, Any]]]:
|
|
40
|
+
"""Parse: mtsv-file = unnamed-sheet *named-sheet.
|
|
41
|
+
|
|
42
|
+
Skip a U+FEFF at the start of the file, which is an encoding
|
|
43
|
+
signature (Section 5).
|
|
44
|
+
"""
|
|
45
|
+
if pos == 0 and src.startswith(SIGNATURE):
|
|
46
|
+
pos = 1
|
|
47
|
+
pos, sheet = unnamed_sheet(src, pos)
|
|
48
|
+
sheets: list[dict[str, Any]] = [] if sheet is None else [sheet]
|
|
49
|
+
while pos < len(src):
|
|
50
|
+
pos, sheet = named_sheet(src, pos)
|
|
51
|
+
sheets.append(sheet)
|
|
52
|
+
return pos, sheets
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def unnamed_sheet(src: str, pos: int) -> tuple[int, dict[str, Any] | None]:
|
|
56
|
+
"""Parse: unnamed-sheet = sheet-body."""
|
|
57
|
+
pos, (header_fields, records) = sheet_body(src, pos)
|
|
58
|
+
if header_fields is None:
|
|
59
|
+
return pos, None
|
|
60
|
+
return pos, {
|
|
61
|
+
"sheet name": None,
|
|
62
|
+
"header": header_fields,
|
|
63
|
+
"records": records,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def named_sheet(src: str, pos: int) -> tuple[int, dict[str, Any]]:
|
|
68
|
+
"""Parse: named-sheet = FF sheet-name eol sheet-body."""
|
|
69
|
+
if not src.startswith(FF, pos):
|
|
70
|
+
raise MTSVDecodeError("expected FF", src, pos)
|
|
71
|
+
pos, name = sheet_name(src, pos + 1)
|
|
72
|
+
pos, _ = eol(src, pos)
|
|
73
|
+
pos, (header_fields, records) = sheet_body(src, pos)
|
|
74
|
+
return pos, {
|
|
75
|
+
"sheet name": name,
|
|
76
|
+
"header": header_fields,
|
|
77
|
+
"records": records,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def sheet_body(
|
|
82
|
+
src: str, pos: int
|
|
83
|
+
) -> tuple[int, tuple[list[str] | None, list[list[str]]]]:
|
|
84
|
+
"""Parse: sheet-body = [header *record]."""
|
|
85
|
+
if pos == len(src) or src.startswith(FF, pos):
|
|
86
|
+
return pos, (None, [])
|
|
87
|
+
pos, header_fields = header(src, pos)
|
|
88
|
+
records: list[list[str]] = []
|
|
89
|
+
while pos < len(src) and not src.startswith(FF, pos):
|
|
90
|
+
start = pos
|
|
91
|
+
pos, record_fields = record(src, pos)
|
|
92
|
+
if len(record_fields) != len(header_fields):
|
|
93
|
+
raise MTSVDecodeError(
|
|
94
|
+
"each record in a sheet must have the same number of fields"
|
|
95
|
+
" as the header of that sheet",
|
|
96
|
+
src,
|
|
97
|
+
start,
|
|
98
|
+
)
|
|
99
|
+
records.append(record_fields)
|
|
100
|
+
return pos, (header_fields, records)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def header(src: str, pos: int) -> tuple[int, list[str]]:
|
|
104
|
+
"""Parse: header = record."""
|
|
105
|
+
return record(src, pos)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def record(src: str, pos: int) -> tuple[int, list[str]]:
|
|
109
|
+
"""Parse: record = field *(HTAB field) eol."""
|
|
110
|
+
pos, value = field(src, pos)
|
|
111
|
+
fields = [value]
|
|
112
|
+
while src.startswith(HTAB, pos):
|
|
113
|
+
pos, value = field(src, pos + 1)
|
|
114
|
+
fields.append(value)
|
|
115
|
+
pos, _ = eol(src, pos)
|
|
116
|
+
return pos, fields
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def field(src: str, pos: int) -> tuple[int, str]:
|
|
120
|
+
"""Parse: field = *field-char."""
|
|
121
|
+
start = pos
|
|
122
|
+
while pos < len(src) and field_char(src[pos]):
|
|
123
|
+
pos += 1
|
|
124
|
+
return pos, src[start:pos]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def sheet_name(src: str, pos: int) -> tuple[int, str]:
|
|
128
|
+
"""Parse: sheet-name = *field-char."""
|
|
129
|
+
start = pos
|
|
130
|
+
while pos < len(src) and field_char(src[pos]):
|
|
131
|
+
pos += 1
|
|
132
|
+
return pos, src[start:pos]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def field_char(char: str) -> bool:
|
|
136
|
+
"""Match: field-char = %x00-08 / %x0B / %x0E-10FFFF."""
|
|
137
|
+
code = ord(char)
|
|
138
|
+
return 0x00 <= code <= 0x08 or code == 0x0B or 0x0E <= code <= 0x10FFFF
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def eol(src: str, pos: int) -> tuple[int, str]:
|
|
142
|
+
"""Parse: eol = LF / CRLF."""
|
|
143
|
+
if src.startswith(LF, pos):
|
|
144
|
+
return pos + 1, LF
|
|
145
|
+
if src.startswith(CRLF, pos):
|
|
146
|
+
return pos + 2, CRLF
|
|
147
|
+
raise MTSVDecodeError("expected LF or CRLF", src, pos)
|
|
File without changes
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Convert between MTSV sheets and Apache Arrow tables.
|
|
2
|
+
|
|
3
|
+
The module exports from_arrow and to_arrow.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__all__ = ["from_arrow", "to_arrow"]
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import pyarrow as pa
|
|
11
|
+
import pyarrow.compute as pc
|
|
12
|
+
|
|
13
|
+
import mtsv
|
|
14
|
+
|
|
15
|
+
# The types whose slots hold one scalar, and so have a text form.
|
|
16
|
+
_SCALAR = (
|
|
17
|
+
pa.types.is_null,
|
|
18
|
+
pa.types.is_boolean,
|
|
19
|
+
pa.types.is_integer,
|
|
20
|
+
pa.types.is_floating,
|
|
21
|
+
pa.types.is_decimal,
|
|
22
|
+
pa.types.is_date,
|
|
23
|
+
pa.types.is_time,
|
|
24
|
+
pa.types.is_timestamp,
|
|
25
|
+
pa.types.is_interval,
|
|
26
|
+
pa.types.is_duration,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# The layouts that encode a value type, rather than types of their own.
|
|
30
|
+
_ENCODINGS = (pa.types.is_dictionary, pa.types.is_run_end_encoded)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def from_arrow(
|
|
34
|
+
tables: list[tuple[str | None, pa.Table]],
|
|
35
|
+
/,
|
|
36
|
+
errors: str = "strict",
|
|
37
|
+
) -> list[dict[str, Any]]:
|
|
38
|
+
"""Create MTSV sheets from (sheet name, Arrow table) pairs.
|
|
39
|
+
|
|
40
|
+
With errors="strict", raise ValueError if anything outside MTSV
|
|
41
|
+
would be left behind. With errors="ignore", leave it behind.
|
|
42
|
+
"""
|
|
43
|
+
if errors not in ("strict", "ignore"):
|
|
44
|
+
raise LookupError(f"unknown error handler name {errors!r}")
|
|
45
|
+
extras: set[str] = set()
|
|
46
|
+
sheets = [_sheet(name, table, extras) for name, table in tables]
|
|
47
|
+
if errors == "strict" and extras:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"these would be left behind: " + ", ".join(sorted(extras))
|
|
50
|
+
)
|
|
51
|
+
mtsv.dumps(sheets)
|
|
52
|
+
return sheets
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def to_arrow(
|
|
56
|
+
obj: list[dict[str, Any]], /
|
|
57
|
+
) -> list[tuple[str | None, pa.Table]]:
|
|
58
|
+
"""Create (sheet name, Arrow table) pairs from MTSV sheets."""
|
|
59
|
+
mtsv.dumps(obj)
|
|
60
|
+
return [(sheet["sheet name"], _table(sheet)) for sheet in obj]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _sheet(
|
|
64
|
+
name: str | None, table: pa.Table, extras: set[str]
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
"""Create a sheet from a table, recording anything outside MTSV."""
|
|
67
|
+
if table.schema.metadata:
|
|
68
|
+
extras.add("table metadata")
|
|
69
|
+
if table.num_columns == 0:
|
|
70
|
+
if table.num_rows:
|
|
71
|
+
extras.add("rows of a table without columns")
|
|
72
|
+
return {"sheet name": name, "header": None, "records": []}
|
|
73
|
+
columns = [
|
|
74
|
+
_values(field, column, extras)
|
|
75
|
+
for field, column in zip(table.schema, table.columns)
|
|
76
|
+
]
|
|
77
|
+
return {
|
|
78
|
+
"sheet name": name,
|
|
79
|
+
"header": table.column_names,
|
|
80
|
+
"records": [list(fields) for fields in zip(*columns)],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _values(
|
|
85
|
+
field: pa.Field, column: pa.ChunkedArray, extras: set[str]
|
|
86
|
+
) -> list[str]:
|
|
87
|
+
"""Read a column as text, recording anything outside MTSV."""
|
|
88
|
+
if field.metadata:
|
|
89
|
+
extras.add(f"metadata of column {field.name!r}")
|
|
90
|
+
if pa.types.is_dictionary(field.type) and field.type.ordered:
|
|
91
|
+
extras.add(f"ordering of column {field.name!r}")
|
|
92
|
+
data_type = _value_type(field.type)
|
|
93
|
+
if not _text_type(data_type):
|
|
94
|
+
if not any(is_scalar(data_type) for is_scalar in _SCALAR):
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"column {field.name!r} of type {field.type}"
|
|
97
|
+
" cannot be represented in MTSV"
|
|
98
|
+
)
|
|
99
|
+
extras.add(f"type {field.type} of column {field.name!r}")
|
|
100
|
+
column = _cast(column)
|
|
101
|
+
if column.null_count:
|
|
102
|
+
extras.add(f"missing values in column {field.name!r}")
|
|
103
|
+
return ["" if value is None else value for value in column.to_pylist()]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _value_type(data_type: pa.DataType) -> pa.DataType:
|
|
107
|
+
"""Look through the dictionary and run-end encodings.
|
|
108
|
+
|
|
109
|
+
Any array can be dictionary-encoded or run-end encoded, so an
|
|
110
|
+
encoding carries the values of its value type.
|
|
111
|
+
"""
|
|
112
|
+
while any(is_encoding(data_type) for is_encoding in _ENCODINGS):
|
|
113
|
+
data_type = data_type.value_type
|
|
114
|
+
return data_type
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _text_type(data_type: pa.DataType) -> bool:
|
|
118
|
+
"""Match the Arrow text types: Utf8, Large Utf8, Utf8 View."""
|
|
119
|
+
return (
|
|
120
|
+
pa.types.is_string(data_type)
|
|
121
|
+
or pa.types.is_large_string(data_type)
|
|
122
|
+
or pa.types.is_string_view(data_type)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cast(column: pa.ChunkedArray) -> pa.ChunkedArray:
|
|
127
|
+
"""Cast a column to text, in Python where Arrow has no cast."""
|
|
128
|
+
try:
|
|
129
|
+
return pc.cast(column, pa.string())
|
|
130
|
+
except (pa.ArrowInvalid, pa.ArrowNotImplementedError):
|
|
131
|
+
values = [
|
|
132
|
+
None if value is None else str(value)
|
|
133
|
+
for value in column.to_pylist()
|
|
134
|
+
]
|
|
135
|
+
return pa.chunked_array([pa.array(values, pa.string())])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _table(sheet: dict[str, Any]) -> pa.Table:
|
|
139
|
+
"""Create a table whose columns are the header fields, as strings."""
|
|
140
|
+
header_fields = sheet["header"]
|
|
141
|
+
if header_fields is None:
|
|
142
|
+
return pa.Table.from_arrays([], names=[])
|
|
143
|
+
columns = [
|
|
144
|
+
pa.array([fields[index] for fields in sheet["records"]], pa.string())
|
|
145
|
+
for index in range(len(header_fields))
|
|
146
|
+
]
|
|
147
|
+
return pa.Table.from_arrays(columns, names=header_fields)
|
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Convert between MTSV sheets and OpenDocument spreadsheets, ODF 1.3.
|
|
2
|
+
|
|
3
|
+
Functions:
|
|
4
|
+
dump -- write MTSV sheets to a binary file as an ODF spreadsheet
|
|
5
|
+
load -- read MTSV sheets from a binary ODF spreadsheet file
|
|
6
|
+
main -- convert a .mtsv file to .ods, or an .ods file to .mtsv
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__all__ = ["dump", "load", "main"]
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import io
|
|
13
|
+
import re
|
|
14
|
+
import zipfile
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from itertools import groupby
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, BinaryIO
|
|
19
|
+
from xml.etree import ElementTree
|
|
20
|
+
from xml.sax.saxutils import escape, quoteattr
|
|
21
|
+
|
|
22
|
+
import mtsv
|
|
23
|
+
|
|
24
|
+
_MEDIA_TYPE = "application/vnd.oasis.opendocument.spreadsheet"
|
|
25
|
+
_PACKAGE_FILES = ("mimetype", "META-INF/manifest.xml", "content.xml")
|
|
26
|
+
_MANIFEST = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"
|
|
27
|
+
_OFFICE = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
|
28
|
+
_TABLE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
|
29
|
+
_TEXT = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
|
30
|
+
_PREFIXES = {
|
|
31
|
+
_MANIFEST: "manifest",
|
|
32
|
+
_OFFICE: "office",
|
|
33
|
+
_TABLE: "table",
|
|
34
|
+
_TEXT: "text",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_DOCUMENT_CONTENT = "{" + _OFFICE + "}document-content"
|
|
38
|
+
_VERSION = "{" + _OFFICE + "}version"
|
|
39
|
+
_BODY = "{" + _OFFICE + "}body"
|
|
40
|
+
_SPREADSHEET = "{" + _OFFICE + "}spreadsheet"
|
|
41
|
+
_VALUE_TYPE = "{" + _OFFICE + "}value-type"
|
|
42
|
+
_STRING_VALUE = "{" + _OFFICE + "}string-value"
|
|
43
|
+
_TABLE_TABLE = "{" + _TABLE + "}table"
|
|
44
|
+
_NAME = "{" + _TABLE + "}name"
|
|
45
|
+
_COLUMN = "{" + _TABLE + "}table-column"
|
|
46
|
+
_COLUMNS = "{" + _TABLE + "}table-columns"
|
|
47
|
+
_ROW = "{" + _TABLE + "}table-row"
|
|
48
|
+
_ROWS = "{" + _TABLE + "}table-rows"
|
|
49
|
+
_WRAPPERS = (
|
|
50
|
+
"{" + _TABLE + "}table-header-rows",
|
|
51
|
+
"{" + _TABLE + "}table-row-group",
|
|
52
|
+
"{" + _TABLE + "}table-header-columns",
|
|
53
|
+
"{" + _TABLE + "}table-column-group",
|
|
54
|
+
)
|
|
55
|
+
_CELL = "{" + _TABLE + "}table-cell"
|
|
56
|
+
_COVERED_CELL = "{" + _TABLE + "}covered-table-cell"
|
|
57
|
+
_COLUMNS_REPEATED = "{" + _TABLE + "}number-columns-repeated"
|
|
58
|
+
_ROWS_REPEATED = "{" + _TABLE + "}number-rows-repeated"
|
|
59
|
+
_P = "{" + _TEXT + "}p"
|
|
60
|
+
_H = "{" + _TEXT + "}h"
|
|
61
|
+
_S = "{" + _TEXT + "}s"
|
|
62
|
+
_C = "{" + _TEXT + "}c"
|
|
63
|
+
_TAB = "{" + _TEXT + "}tab"
|
|
64
|
+
_LINE_BREAK = "{" + _TEXT + "}line-break"
|
|
65
|
+
_RUBY = "{" + _TEXT + "}ruby"
|
|
66
|
+
_RUBY_BASE = "{" + _TEXT + "}ruby-base"
|
|
67
|
+
_PARAGRAPH_CONTENT = (
|
|
68
|
+
"{" + _TEXT + "}a",
|
|
69
|
+
"{" + _TEXT + "}meta",
|
|
70
|
+
"{" + _TEXT + "}meta-field",
|
|
71
|
+
"{" + _TEXT + "}span",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
_HTAB = chr(0x09)
|
|
75
|
+
_LF = chr(0x0A)
|
|
76
|
+
_CR = chr(0x0D)
|
|
77
|
+
_SPACE = chr(0x20)
|
|
78
|
+
_SPACES = re.compile(_SPACE + "+")
|
|
79
|
+
_XML_SPACE = _SPACE + _HTAB + _CR + _LF
|
|
80
|
+
_DIGITS = re.compile("[0-9]+")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def dump(obj: list[dict[str, Any]], fp: BinaryIO) -> None:
|
|
84
|
+
"""Write MTSV sheets to a binary file as an ODF spreadsheet.
|
|
85
|
+
|
|
86
|
+
Raise ValueError if the sheets are not MTSV, or if a field or sheet
|
|
87
|
+
name holds a character that XML 1.0 does not allow.
|
|
88
|
+
"""
|
|
89
|
+
mtsv.dumps(obj)
|
|
90
|
+
for sheet in obj:
|
|
91
|
+
values = [sheet["sheet name"] or ""]
|
|
92
|
+
for fields in [sheet["header"] or [], *sheet["records"]]:
|
|
93
|
+
values.extend(fields)
|
|
94
|
+
for value in values:
|
|
95
|
+
if not all(_xml_char(char) for char in value):
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"a field or sheet name that contains a character not"
|
|
98
|
+
" allowed in XML 1.0 cannot be represented in ODS"
|
|
99
|
+
)
|
|
100
|
+
with zipfile.ZipFile(fp, "w") as package:
|
|
101
|
+
package.writestr(
|
|
102
|
+
zipfile.ZipInfo("mimetype"), _MEDIA_TYPE, zipfile.ZIP_STORED
|
|
103
|
+
)
|
|
104
|
+
package.writestr(
|
|
105
|
+
zipfile.ZipInfo("META-INF/manifest.xml"),
|
|
106
|
+
_manifest(),
|
|
107
|
+
zipfile.ZIP_DEFLATED,
|
|
108
|
+
)
|
|
109
|
+
package.writestr(
|
|
110
|
+
zipfile.ZipInfo("content.xml"),
|
|
111
|
+
_document_content(obj),
|
|
112
|
+
zipfile.ZIP_DEFLATED,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load(fp: BinaryIO, /, errors: str = "strict") -> list[dict[str, Any]]:
|
|
117
|
+
"""Read MTSV sheets from a binary ODF spreadsheet file.
|
|
118
|
+
|
|
119
|
+
With errors="strict", raise ValueError if anything outside MTSV
|
|
120
|
+
would be left behind. With errors="ignore", leave it behind. Raise
|
|
121
|
+
ValueError for a file that is not an ODF spreadsheet.
|
|
122
|
+
"""
|
|
123
|
+
if errors not in ("strict", "ignore"):
|
|
124
|
+
raise LookupError(f"unknown error handler name {errors!r}")
|
|
125
|
+
extras: set[str] = set()
|
|
126
|
+
try:
|
|
127
|
+
with zipfile.ZipFile(fp) as package:
|
|
128
|
+
for name in package.namelist():
|
|
129
|
+
if name not in _PACKAGE_FILES:
|
|
130
|
+
extras.add(name)
|
|
131
|
+
root = ElementTree.fromstring(package.read("content.xml"))
|
|
132
|
+
except (zipfile.BadZipFile, KeyError, ElementTree.ParseError) as error:
|
|
133
|
+
raise ValueError("the file is not an ODF package") from error
|
|
134
|
+
sheets = _spreadsheet(root, extras)
|
|
135
|
+
if errors == "strict" and extras:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
"these would be left behind: " + ", ".join(sorted(extras))
|
|
138
|
+
)
|
|
139
|
+
mtsv.dumps(sheets)
|
|
140
|
+
return sheets
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv: list[str] | None = None) -> None:
|
|
144
|
+
"""Convert a .mtsv file to .ods, or an .ods file to .mtsv."""
|
|
145
|
+
parser = argparse.ArgumentParser(
|
|
146
|
+
prog="python -m mtsv.integrations.ods",
|
|
147
|
+
description="Convert a .mtsv file to .ods, or an .ods file to .mtsv.",
|
|
148
|
+
)
|
|
149
|
+
parser.add_argument("input", type=Path)
|
|
150
|
+
parser.add_argument("output", type=Path)
|
|
151
|
+
parser.add_argument(
|
|
152
|
+
"--errors", choices=["strict", "ignore"], default="strict"
|
|
153
|
+
)
|
|
154
|
+
args = parser.parse_args(argv)
|
|
155
|
+
suffixes = (args.input.suffix, args.output.suffix)
|
|
156
|
+
buffer = io.BytesIO()
|
|
157
|
+
try:
|
|
158
|
+
if suffixes == (".mtsv", ".ods"):
|
|
159
|
+
with args.input.open("rb") as source:
|
|
160
|
+
dump(mtsv.load(source), buffer)
|
|
161
|
+
elif suffixes == (".ods", ".mtsv"):
|
|
162
|
+
with args.input.open("rb") as source:
|
|
163
|
+
mtsv.dump(load(source, args.errors), buffer)
|
|
164
|
+
else:
|
|
165
|
+
parser.error("convert a .mtsv file to .ods, or .ods to .mtsv")
|
|
166
|
+
except ValueError as error:
|
|
167
|
+
raise SystemExit(error)
|
|
168
|
+
args.output.write_bytes(buffer.getvalue())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _manifest() -> str:
|
|
172
|
+
"""Generate META-INF/manifest.xml, per ODF 1.3 Part 2, 3.2."""
|
|
173
|
+
return (
|
|
174
|
+
"<?xml version='1.0' encoding='UTF-8'?>"
|
|
175
|
+
f"<manifest:manifest xmlns:manifest='{_MANIFEST}'"
|
|
176
|
+
" manifest:version='1.3'>"
|
|
177
|
+
"<manifest:file-entry manifest:full-path='/'"
|
|
178
|
+
f" manifest:media-type='{_MEDIA_TYPE}'/>"
|
|
179
|
+
"<manifest:file-entry manifest:full-path='content.xml'"
|
|
180
|
+
" manifest:media-type='text/xml'/>"
|
|
181
|
+
"</manifest:manifest>"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _document_content(sheets: list[dict[str, Any]]) -> str:
|
|
186
|
+
"""Generate content.xml with one table:table per sheet."""
|
|
187
|
+
tables = "".join(_table(sheet) for sheet in sheets)
|
|
188
|
+
return (
|
|
189
|
+
"<?xml version='1.0' encoding='UTF-8'?>"
|
|
190
|
+
"<office:document-content"
|
|
191
|
+
f" xmlns:office='{_OFFICE}'"
|
|
192
|
+
f" xmlns:table='{_TABLE}'"
|
|
193
|
+
f" xmlns:text='{_TEXT}'"
|
|
194
|
+
" office:version='1.3'>"
|
|
195
|
+
f"<office:body><office:spreadsheet>{tables}"
|
|
196
|
+
"</office:spreadsheet></office:body>"
|
|
197
|
+
"</office:document-content>"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _table(sheet: dict[str, Any]) -> str:
|
|
202
|
+
"""Generate table:table; an empty sheet gets one empty cell."""
|
|
203
|
+
name = sheet["sheet name"]
|
|
204
|
+
attribute = "" if name is None else " table:name=" + quoteattr(name)
|
|
205
|
+
if sheet["header"] is None:
|
|
206
|
+
rows = [[""]]
|
|
207
|
+
else:
|
|
208
|
+
rows = [sheet["header"], *sheet["records"]]
|
|
209
|
+
column = (
|
|
210
|
+
"<table:table-column"
|
|
211
|
+
f" table:number-columns-repeated='{len(rows[0])}'/>"
|
|
212
|
+
)
|
|
213
|
+
cells = "".join(_row(fields) for fields in rows)
|
|
214
|
+
return f"<table:table{attribute}>{column}{cells}</table:table>"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _row(fields: list[str]) -> str:
|
|
218
|
+
"""Generate table:table-row."""
|
|
219
|
+
cells = "".join(_cell(value) for value in fields)
|
|
220
|
+
return f"<table:table-row>{cells}</table:table-row>"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _cell(value: str) -> str:
|
|
224
|
+
"""Generate table:table-cell; an empty field is an empty cell."""
|
|
225
|
+
if not value:
|
|
226
|
+
return "<table:table-cell/>"
|
|
227
|
+
return (
|
|
228
|
+
"<table:table-cell office:value-type='string'>"
|
|
229
|
+
f"<text:p>{_paragraph(value)}</text:p>"
|
|
230
|
+
"</table:table-cell>"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _paragraph(value: str) -> str:
|
|
235
|
+
"""Generate text:p content, with spaces marked per ODF 6.1.2-6.1.3.
|
|
236
|
+
|
|
237
|
+
A single space between other characters stays a space; every other
|
|
238
|
+
space is written with text:s, so collapsing cannot remove it.
|
|
239
|
+
"""
|
|
240
|
+
groups = [
|
|
241
|
+
(is_space, "".join(chars))
|
|
242
|
+
for is_space, chars in groupby(value, lambda char: char == _SPACE)
|
|
243
|
+
]
|
|
244
|
+
parts = []
|
|
245
|
+
for index, (is_space, chars) in enumerate(groups):
|
|
246
|
+
if not is_space:
|
|
247
|
+
parts.append(escape(chars))
|
|
248
|
+
continue
|
|
249
|
+
count = len(chars)
|
|
250
|
+
if 0 < index < len(groups) - 1:
|
|
251
|
+
parts.append(_SPACE)
|
|
252
|
+
count -= 1
|
|
253
|
+
if count == 1:
|
|
254
|
+
parts.append("<text:s/>")
|
|
255
|
+
elif count > 1:
|
|
256
|
+
parts.append(f"<text:s text:c='{count}'/>")
|
|
257
|
+
return "".join(parts)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _xml_char(char: str) -> bool:
|
|
261
|
+
"""Match XML 1.0 Char.
|
|
262
|
+
|
|
263
|
+
Char ::= #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD]
|
|
264
|
+
| [#x10000-#x10FFFF]
|
|
265
|
+
"""
|
|
266
|
+
code = ord(char)
|
|
267
|
+
return (
|
|
268
|
+
code in (0x09, 0x0A, 0x0D)
|
|
269
|
+
or 0x20 <= code <= 0xD7FF
|
|
270
|
+
or 0xE000 <= code <= 0xFFFD
|
|
271
|
+
or 0x10000 <= code <= 0x10FFFF
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _prefixed(name: str) -> str:
|
|
276
|
+
"""Return {namespace}local as prefix:local for known prefixes."""
|
|
277
|
+
if not name.startswith("{"):
|
|
278
|
+
return name
|
|
279
|
+
namespace, local = name[1:].split("}", 1)
|
|
280
|
+
prefix = _PREFIXES.get(namespace)
|
|
281
|
+
return name if prefix is None else f"{prefix}:{local}"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _note_attributes(
|
|
285
|
+
element: ElementTree.Element, allowed: tuple[str, ...], extras: set[str]
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Record each attribute outside the MTSV mapping as left behind."""
|
|
288
|
+
for key in element.attrib:
|
|
289
|
+
if key not in allowed:
|
|
290
|
+
extras.add(_prefixed(key))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _count(value: str, minimum: int) -> int:
|
|
294
|
+
"""Read a positiveInteger (minimum 1) or nonNegativeInteger (0)."""
|
|
295
|
+
digits = value.strip(_XML_SPACE)
|
|
296
|
+
if not _DIGITS.fullmatch(digits) or int(digits) < minimum:
|
|
297
|
+
raise ValueError(f"not a valid ODF count: {value!r}")
|
|
298
|
+
return int(digits)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _spreadsheet(
|
|
302
|
+
root: ElementTree.Element, extras: set[str]
|
|
303
|
+
) -> list[dict[str, Any]]:
|
|
304
|
+
"""Parse office:document-content into MTSV sheets."""
|
|
305
|
+
if root.tag != _DOCUMENT_CONTENT:
|
|
306
|
+
raise ValueError("content.xml is not office:document-content")
|
|
307
|
+
_note_attributes(root, (_VERSION,), extras)
|
|
308
|
+
spreadsheet = None
|
|
309
|
+
for child in root:
|
|
310
|
+
if child.tag == _BODY:
|
|
311
|
+
spreadsheet = child.find(_SPREADSHEET)
|
|
312
|
+
else:
|
|
313
|
+
extras.add(_prefixed(child.tag))
|
|
314
|
+
if spreadsheet is None:
|
|
315
|
+
raise ValueError("the document is not an office:spreadsheet")
|
|
316
|
+
_note_attributes(spreadsheet, (), extras)
|
|
317
|
+
sheets = []
|
|
318
|
+
for child in spreadsheet:
|
|
319
|
+
if child.tag != _TABLE_TABLE:
|
|
320
|
+
extras.add(_prefixed(child.tag))
|
|
321
|
+
continue
|
|
322
|
+
sheets.append(_sheet(child, extras))
|
|
323
|
+
return sheets
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _sheet(element: ElementTree.Element, extras: set[str]) -> dict[str, Any]:
|
|
327
|
+
"""Parse table:table into a sheet, reading only its used area."""
|
|
328
|
+
_note_attributes(element, (_NAME,), extras)
|
|
329
|
+
name = element.get(_NAME)
|
|
330
|
+
lines: list[list[str]] = []
|
|
331
|
+
pending = 0
|
|
332
|
+
for count, values in _rows(element, extras):
|
|
333
|
+
if values:
|
|
334
|
+
lines.extend([] for _ in range(pending))
|
|
335
|
+
pending = 0
|
|
336
|
+
lines.extend(list(values) for _ in range(count))
|
|
337
|
+
else:
|
|
338
|
+
pending += count
|
|
339
|
+
if not lines:
|
|
340
|
+
return {"sheet name": name, "header": None, "records": []}
|
|
341
|
+
width = max(len(values) for values in lines)
|
|
342
|
+
padded = [values + [""] * (width - len(values)) for values in lines]
|
|
343
|
+
return {"sheet name": name, "header": padded[0], "records": padded[1:]}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _rows(
|
|
347
|
+
element: ElementTree.Element, extras: set[str]
|
|
348
|
+
) -> Iterator[tuple[int, list[str]]]:
|
|
349
|
+
"""Yield (repeat count, values) per row, reading into wrappers."""
|
|
350
|
+
for child in element:
|
|
351
|
+
if child.tag == _ROW:
|
|
352
|
+
_note_attributes(child, (_ROWS_REPEATED,), extras)
|
|
353
|
+
count = _count(child.get(_ROWS_REPEATED, "1"), 1)
|
|
354
|
+
yield count, _cells(child, extras)
|
|
355
|
+
elif child.tag == _COLUMN:
|
|
356
|
+
_note_attributes(child, (_COLUMNS_REPEATED,), extras)
|
|
357
|
+
_count(child.get(_COLUMNS_REPEATED, "1"), 1)
|
|
358
|
+
elif child.tag in (_ROWS, _COLUMNS):
|
|
359
|
+
yield from _rows(child, extras)
|
|
360
|
+
elif child.tag in _WRAPPERS:
|
|
361
|
+
extras.add(_prefixed(child.tag))
|
|
362
|
+
_note_attributes(child, (), extras)
|
|
363
|
+
yield from _rows(child, extras)
|
|
364
|
+
else:
|
|
365
|
+
extras.add(_prefixed(child.tag))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _cells(row: ElementTree.Element, extras: set[str]) -> list[str]:
|
|
369
|
+
"""Read a row's cell texts, without its trailing empty cells."""
|
|
370
|
+
values: list[str] = []
|
|
371
|
+
pending = 0
|
|
372
|
+
for child in row:
|
|
373
|
+
if child.tag == _CELL:
|
|
374
|
+
text = _cell_value(child, extras)
|
|
375
|
+
elif child.tag == _COVERED_CELL:
|
|
376
|
+
extras.add(_prefixed(child.tag))
|
|
377
|
+
text = ""
|
|
378
|
+
else:
|
|
379
|
+
extras.add(_prefixed(child.tag))
|
|
380
|
+
continue
|
|
381
|
+
count = _count(child.get(_COLUMNS_REPEATED, "1"), 1)
|
|
382
|
+
if text:
|
|
383
|
+
values.extend([""] * pending)
|
|
384
|
+
pending = 0
|
|
385
|
+
values.extend([text] * count)
|
|
386
|
+
else:
|
|
387
|
+
pending += count
|
|
388
|
+
return values
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _cell_value(cell: ElementTree.Element, extras: set[str]) -> str:
|
|
392
|
+
"""Read a cell's text, from office:string-value when it is given."""
|
|
393
|
+
is_string = cell.get(_VALUE_TYPE) == "string"
|
|
394
|
+
for key in cell.attrib:
|
|
395
|
+
if key == _COLUMNS_REPEATED:
|
|
396
|
+
continue
|
|
397
|
+
if is_string and key in (_VALUE_TYPE, _STRING_VALUE):
|
|
398
|
+
continue
|
|
399
|
+
extras.add(_prefixed(key))
|
|
400
|
+
text = _cell_text(cell, extras)
|
|
401
|
+
value = cell.get(_STRING_VALUE)
|
|
402
|
+
if not is_string or value is None:
|
|
403
|
+
return text
|
|
404
|
+
if text and text != value:
|
|
405
|
+
extras.add(_prefixed(_P))
|
|
406
|
+
return value
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _cell_text(cell: ElementTree.Element, extras: set[str]) -> str:
|
|
410
|
+
"""Read a cell's paragraphs, joined by line breaks."""
|
|
411
|
+
paragraphs = []
|
|
412
|
+
for child in cell:
|
|
413
|
+
if child.tag in (_P, _H):
|
|
414
|
+
if child.tag == _H:
|
|
415
|
+
extras.add(_prefixed(child.tag))
|
|
416
|
+
_note_attributes(child, (), extras)
|
|
417
|
+
paragraphs.append(_paragraph_text(child, extras))
|
|
418
|
+
else:
|
|
419
|
+
extras.add(_prefixed(child.tag))
|
|
420
|
+
return _LF.join(paragraphs)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _paragraph_text(paragraph: ElementTree.Element, extras: set[str]) -> str:
|
|
424
|
+
"""Read a paragraph per the white space algorithm, ODF 6.1.2."""
|
|
425
|
+
tokens: list[tuple[bool, str]] = []
|
|
426
|
+
_collect(paragraph, tokens, extras)
|
|
427
|
+
merged: list[tuple[bool, str]] = []
|
|
428
|
+
for kept, text in tokens:
|
|
429
|
+
if not kept and merged and not merged[-1][0]:
|
|
430
|
+
merged[-1] = (False, merged[-1][1] + text)
|
|
431
|
+
else:
|
|
432
|
+
merged.append((kept, text))
|
|
433
|
+
if merged and not merged[0][0]:
|
|
434
|
+
merged[0] = (False, merged[0][1].lstrip(_SPACE))
|
|
435
|
+
if merged and not merged[-1][0]:
|
|
436
|
+
merged[-1] = (False, merged[-1][1].rstrip(_SPACE))
|
|
437
|
+
return "".join(
|
|
438
|
+
text if kept else _SPACES.sub(_SPACE, text) for kept, text in merged
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _collect(
|
|
443
|
+
element: ElementTree.Element,
|
|
444
|
+
tokens: list[tuple[bool, str]],
|
|
445
|
+
extras: set[str],
|
|
446
|
+
) -> None:
|
|
447
|
+
"""Flatten paragraph content per steps 1 to 4 of ODF 1.3, 6.1.2."""
|
|
448
|
+
if element.text:
|
|
449
|
+
tokens.append((False, _to_spaces(element.text)))
|
|
450
|
+
for child in element:
|
|
451
|
+
if child.tag == _S:
|
|
452
|
+
tokens.append((True, _SPACE * _count(child.get(_C, "1"), 0)))
|
|
453
|
+
elif child.tag == _TAB:
|
|
454
|
+
tokens.append((True, _HTAB))
|
|
455
|
+
elif child.tag == _LINE_BREAK:
|
|
456
|
+
tokens.append((True, _LF))
|
|
457
|
+
elif child.tag == _RUBY:
|
|
458
|
+
extras.add(_prefixed(child.tag))
|
|
459
|
+
base = child.find(_RUBY_BASE)
|
|
460
|
+
if base is not None:
|
|
461
|
+
_collect(base, tokens, extras)
|
|
462
|
+
elif child.tag in _PARAGRAPH_CONTENT:
|
|
463
|
+
extras.add(_prefixed(child.tag))
|
|
464
|
+
_collect(child, tokens, extras)
|
|
465
|
+
else:
|
|
466
|
+
extras.add(_prefixed(child.tag))
|
|
467
|
+
if child.tail:
|
|
468
|
+
tokens.append((False, _to_spaces(child.tail)))
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _to_spaces(text: str) -> str:
|
|
472
|
+
"""Replace HT, CR, and LF with a space, step 4 of ODF 1.3, 6.1.2."""
|
|
473
|
+
text = text.replace(_HTAB, _SPACE)
|
|
474
|
+
text = text.replace(_CR, _SPACE)
|
|
475
|
+
return text.replace(_LF, _SPACE)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
if __name__ == "__main__":
|
|
479
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mtsv
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python implementation of Multi-Sheet Tab-Separated Values (MTSV)
|
|
5
|
+
Author-email: Demos Ra <demos_ra@hotmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: source, https://github.com/demos-ra/mtsv
|
|
8
|
+
Project-URL: issues, https://github.com/demos-ra/mtsv/issues
|
|
9
|
+
Keywords: tsv,tabular data,media type
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Provides-Extra: arrow
|
|
14
|
+
Requires-Dist: pyarrow>=16; extra == "arrow"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# MTSV for Python
|
|
18
|
+
|
|
19
|
+
A parser and a generator for Multi-Sheet Tab-Separated Values (MTSV), with
|
|
20
|
+
integrations for spreadsheets (ODS) and data tools (Apache Arrow). The
|
|
21
|
+
version is the `version` field of `pyproject.toml`.
|
|
22
|
+
|
|
23
|
+
* [Specification](https://github.com/demos-ra/mtsv-spec)
|
|
24
|
+
* [Repository](https://github.com/demos-ra/mtsv)
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
From the repository. A Python that an operating system manages does not
|
|
29
|
+
accept packages directly, so install into a virtual environment. Run these
|
|
30
|
+
from the root of the repository, one folder above this one:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
python3 -m venv .venv
|
|
34
|
+
.venv/bin/pip install ./python
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
For the Arrow integration, which also installs pyarrow:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
.venv/bin/pip install "./python[arrow]"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Read and write MTSV
|
|
44
|
+
|
|
45
|
+
Sheets are a list of dictionaries with `"sheet name"`, `"header"`, and
|
|
46
|
+
`"records"`, the same shape as the
|
|
47
|
+
[conformance results](https://github.com/demos-ra/mtsv/blob/main/conformance/README.md).
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import mtsv
|
|
51
|
+
|
|
52
|
+
with open("book.mtsv", "rb") as file:
|
|
53
|
+
sheets = mtsv.load(file)
|
|
54
|
+
|
|
55
|
+
with open("book.mtsv", "wb") as file:
|
|
56
|
+
mtsv.dump(sheets, file)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`loads` and `dumps` work on strings.
|
|
60
|
+
|
|
61
|
+
## Spreadsheets (ODS)
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from mtsv.integrations import ods
|
|
65
|
+
|
|
66
|
+
with open("book.ods", "wb") as file:
|
|
67
|
+
ods.dump(sheets, file)
|
|
68
|
+
|
|
69
|
+
with open("book.ods", "rb") as file:
|
|
70
|
+
sheets = ods.load(file)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
From the command line:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
python -m mtsv.integrations.ods book.mtsv book.ods
|
|
77
|
+
python -m mtsv.integrations.ods book.ods book.mtsv
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Data tools (Apache Arrow)
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from mtsv.integrations import arrow
|
|
84
|
+
|
|
85
|
+
tables = arrow.to_arrow(sheets)
|
|
86
|
+
sheets = arrow.from_arrow(tables)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`tables` is a list of `(sheet name, pyarrow.Table)` pairs.
|
|
90
|
+
|
|
91
|
+
## What is left behind
|
|
92
|
+
|
|
93
|
+
MTSV holds sheets, names, rows, and text. Going out to ODS or Arrow keeps all
|
|
94
|
+
of it, with two ODS exceptions: characters that XML 1.0 does not allow raise
|
|
95
|
+
`ValueError`, and empty rows and columns at the edge of a sheet do not come
|
|
96
|
+
back from ODS, so a sheet of only empty fields comes back as an empty sheet.
|
|
97
|
+
Coming back in, anything else (formatting, formulas, types, missing values)
|
|
98
|
+
raises `ValueError` by default. To confirm and leave it behind, pass
|
|
99
|
+
`errors="ignore"`, or `--errors ignore` on the command line. Text that MTSV
|
|
100
|
+
cannot hold, such as a tab or line break inside a value, always raises
|
|
101
|
+
`ValueError`. So does an Arrow column whose values cannot be text at all, such
|
|
102
|
+
as binary, a list, or a struct.
|
|
103
|
+
|
|
104
|
+
## Layout
|
|
105
|
+
|
|
106
|
+
| Path | Contents |
|
|
107
|
+
|--------------------------|---------------------------------------------|
|
|
108
|
+
| `src/mtsv/` | the interface, the parser, and the generator |
|
|
109
|
+
| `src/mtsv/integrations/` | one module per target standard |
|
|
110
|
+
| `tests/` | the test suite, run against the install |
|
|
111
|
+
|
|
112
|
+
## Test
|
|
113
|
+
|
|
114
|
+
From the root of the repository:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
.venv/bin/python -m unittest discover -s python/tests
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
[MIT](https://github.com/demos-ra/mtsv/blob/main/LICENSE)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
src/mtsv/__init__.py
|
|
6
|
+
src/mtsv/_generator.py
|
|
7
|
+
src/mtsv/_parser.py
|
|
8
|
+
src/mtsv/py.typed
|
|
9
|
+
src/mtsv.egg-info/PKG-INFO
|
|
10
|
+
src/mtsv.egg-info/SOURCES.txt
|
|
11
|
+
src/mtsv.egg-info/dependency_links.txt
|
|
12
|
+
src/mtsv.egg-info/requires.txt
|
|
13
|
+
src/mtsv.egg-info/top_level.txt
|
|
14
|
+
src/mtsv/integrations/__init__.py
|
|
15
|
+
src/mtsv/integrations/arrow.py
|
|
16
|
+
src/mtsv/integrations/ods.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mtsv
|