xml2table 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.
- xml2table/__init__.py +71 -0
- xml2table/__main__.py +4 -0
- xml2table/cli.py +186 -0
- xml2table/converter.py +168 -0
- xml2table/exceptions.py +28 -0
- xml2table/options.py +66 -0
- xml2table/parser.py +228 -0
- xml2table/pdf_converter.py +97 -0
- xml2table/pdf_extract.py +213 -0
- xml2table/pdf_options.py +54 -0
- xml2table/pdf_text_extract.py +70 -0
- xml2table/pdf_xml_writer.py +71 -0
- xml2table/py.typed +0 -0
- xml2table/writers/__init__.py +4 -0
- xml2table/writers/csv_writer.py +50 -0
- xml2table/writers/excel_writer.py +114 -0
- xml2table-0.1.0.dist-info/METADATA +353 -0
- xml2table-0.1.0.dist-info/RECORD +22 -0
- xml2table-0.1.0.dist-info/WHEEL +5 -0
- xml2table-0.1.0.dist-info/entry_points.txt +2 -0
- xml2table-0.1.0.dist-info/licenses/LICENSE +21 -0
- xml2table-0.1.0.dist-info/top_level.txt +1 -0
xml2table/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""xml2table: convert XML and PDF documents to CSV, Excel, XML, and text.
|
|
2
|
+
|
|
3
|
+
Quick start (XML -> CSV/Excel)::
|
|
4
|
+
|
|
5
|
+
from xml2table import xml_to_csv, xml_to_excel, FlattenOptions
|
|
6
|
+
|
|
7
|
+
xml_to_csv("orders.xml", "orders.csv")
|
|
8
|
+
xml_to_excel("orders.xml", "orders.xlsx", record_path="Order")
|
|
9
|
+
|
|
10
|
+
# Or, for repeated calls against the same document:
|
|
11
|
+
from xml2table import XMLConverter
|
|
12
|
+
|
|
13
|
+
converter = XMLConverter("orders.xml", options=FlattenOptions(record_path="Order"))
|
|
14
|
+
converter.to_csv("orders.csv")
|
|
15
|
+
converter.to_excel("orders.xlsx")
|
|
16
|
+
rows = converter.to_records()
|
|
17
|
+
|
|
18
|
+
Quick start (PDF -> XML/text)::
|
|
19
|
+
|
|
20
|
+
from xml2table import pdf_to_xml, pdf_to_text
|
|
21
|
+
|
|
22
|
+
# paragraphs + tables, in reading order (pdfplumber; requires xml2table[pdf])
|
|
23
|
+
pdf_to_xml("invoice.pdf", "invoice.xml")
|
|
24
|
+
|
|
25
|
+
# fast reading-order plain text (pypdf; requires xml2table[pdf-text])
|
|
26
|
+
pdf_to_text("invoice.pdf", "invoice.txt")
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from .converter import (
|
|
30
|
+
XMLConverter,
|
|
31
|
+
xml_to_csv,
|
|
32
|
+
xml_to_dataframe,
|
|
33
|
+
xml_to_excel,
|
|
34
|
+
xml_to_records,
|
|
35
|
+
)
|
|
36
|
+
from .exceptions import (
|
|
37
|
+
MissingOptionalDependencyError,
|
|
38
|
+
PDFExtractionError,
|
|
39
|
+
RecordPathNotFoundError,
|
|
40
|
+
XMLConversionError,
|
|
41
|
+
XMLParseError,
|
|
42
|
+
)
|
|
43
|
+
from .options import FlattenOptions
|
|
44
|
+
from .pdf_converter import PDFConverter, pdf_to_text, pdf_to_xml
|
|
45
|
+
from .pdf_extract import Document, Page, Paragraph, Table
|
|
46
|
+
from .pdf_options import PDFOptions
|
|
47
|
+
|
|
48
|
+
__version__ = "0.1.0"
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"XMLConverter",
|
|
52
|
+
"FlattenOptions",
|
|
53
|
+
"xml_to_csv",
|
|
54
|
+
"xml_to_excel",
|
|
55
|
+
"xml_to_records",
|
|
56
|
+
"xml_to_dataframe",
|
|
57
|
+
"PDFConverter",
|
|
58
|
+
"PDFOptions",
|
|
59
|
+
"pdf_to_xml",
|
|
60
|
+
"pdf_to_text",
|
|
61
|
+
"Document",
|
|
62
|
+
"Page",
|
|
63
|
+
"Paragraph",
|
|
64
|
+
"Table",
|
|
65
|
+
"XMLConversionError",
|
|
66
|
+
"XMLParseError",
|
|
67
|
+
"RecordPathNotFoundError",
|
|
68
|
+
"MissingOptionalDependencyError",
|
|
69
|
+
"PDFExtractionError",
|
|
70
|
+
"__version__",
|
|
71
|
+
]
|
xml2table/__main__.py
ADDED
xml2table/cli.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Command-line interface for xml2table.
|
|
2
|
+
|
|
3
|
+
Examples:
|
|
4
|
+
xml2table csv orders.xml orders.csv --record-path Order
|
|
5
|
+
xml2table excel orders.xml orders.xlsx --array-mode explode
|
|
6
|
+
xml2table excel orders.xml orders.xlsx --sheet orders=Order --sheet items=".//Item"
|
|
7
|
+
xml2table pdf invoice.pdf invoice.xml --to xml
|
|
8
|
+
xml2table pdf invoice.pdf invoice.txt --to text
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Dict, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
from .converter import xml_to_csv, xml_to_excel
|
|
18
|
+
from .exceptions import XMLConversionError
|
|
19
|
+
from .options import FlattenOptions
|
|
20
|
+
from .pdf_converter import pdf_to_text, pdf_to_xml
|
|
21
|
+
from .pdf_options import PDFOptions
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _add_common_flags(parser: argparse.ArgumentParser) -> None:
|
|
25
|
+
parser.add_argument("input", help="Path to the input XML file")
|
|
26
|
+
parser.add_argument("output", help="Path to the output file")
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--record-path",
|
|
29
|
+
default=None,
|
|
30
|
+
help="ElementTree-style path to the repeated record element, e.g. 'Order' or './/Item'",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--array-mode",
|
|
34
|
+
choices=["join", "explode", "index"],
|
|
35
|
+
default="join",
|
|
36
|
+
help="How repeated sibling elements are represented (default: join)",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument("--join-separator", default="; ", help="Separator used when --array-mode=join")
|
|
39
|
+
parser.add_argument("--separator", default=".", help="Separator used for nested key paths (default: '.')")
|
|
40
|
+
parser.add_argument("--attribute-prefix", default="@", help="Prefix for columns derived from XML attributes")
|
|
41
|
+
parser.add_argument("--no-attributes", action="store_true", help="Exclude XML attributes from the output")
|
|
42
|
+
parser.add_argument("--keep-namespaces", action="store_true", help="Keep XML namespace URIs in tag names")
|
|
43
|
+
parser.add_argument("--encoding", default="utf-8", help="Encoding for input/output text (default: utf-8)")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _options_from_args(args: argparse.Namespace) -> FlattenOptions:
|
|
47
|
+
return FlattenOptions(
|
|
48
|
+
record_path=args.record_path,
|
|
49
|
+
attribute_prefix=args.attribute_prefix,
|
|
50
|
+
separator=args.separator,
|
|
51
|
+
array_mode=args.array_mode,
|
|
52
|
+
join_separator=args.join_separator,
|
|
53
|
+
include_attributes=not args.no_attributes,
|
|
54
|
+
strip_namespaces=not args.keep_namespaces,
|
|
55
|
+
encoding=args.encoding,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_sheet_spec(spec: str) -> tuple:
|
|
60
|
+
if "=" not in spec:
|
|
61
|
+
raise argparse.ArgumentTypeError(
|
|
62
|
+
f"invalid --sheet value {spec!r}; expected NAME=RECORD_PATH"
|
|
63
|
+
)
|
|
64
|
+
name, path = spec.split("=", 1)
|
|
65
|
+
if not name or not path:
|
|
66
|
+
raise argparse.ArgumentTypeError(
|
|
67
|
+
f"invalid --sheet value {spec!r}; expected NAME=RECORD_PATH"
|
|
68
|
+
)
|
|
69
|
+
return name, path
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _add_pdf_flags(parser: argparse.ArgumentParser) -> None:
|
|
73
|
+
parser.add_argument("input", help="Path to the input PDF file")
|
|
74
|
+
parser.add_argument("output", help="Path to the output file")
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--to",
|
|
77
|
+
choices=["xml", "text"],
|
|
78
|
+
required=True,
|
|
79
|
+
help="Output format: 'xml' (paragraphs + tables, via pdfplumber) or "
|
|
80
|
+
"'text' (fast reading-order plain text, via pypdf)",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--line-tolerance",
|
|
84
|
+
type=float,
|
|
85
|
+
default=3.0,
|
|
86
|
+
help="XML only. Max vertical gap (points) for words to be treated as the same line (default: 3.0)",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--paragraph-gap",
|
|
90
|
+
type=float,
|
|
91
|
+
default=6.0,
|
|
92
|
+
help="XML only. Min vertical gap (points) between lines to start a new paragraph (default: 6.0)",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--cell-na", default="", help="XML only. String used for empty table cells (default: '')"
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--page-separator",
|
|
99
|
+
default="\n----- Page {page} -----\n",
|
|
100
|
+
help="Text only. Inserted between pages; '{page}' is replaced with the page number",
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--no-layout",
|
|
104
|
+
action="store_true",
|
|
105
|
+
help="Text only. Use plain, whitespace-normalized text instead of "
|
|
106
|
+
"preserving the PDF's visual layout",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _pdf_options_from_args(args: argparse.Namespace) -> PDFOptions:
|
|
111
|
+
return PDFOptions(
|
|
112
|
+
line_tolerance=args.line_tolerance,
|
|
113
|
+
paragraph_gap=args.paragraph_gap,
|
|
114
|
+
cell_na=args.cell_na,
|
|
115
|
+
page_separator=args.page_separator,
|
|
116
|
+
keep_layout=not args.no_layout,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
121
|
+
parser = argparse.ArgumentParser(
|
|
122
|
+
prog="xml2table", description="Convert XML to CSV/Excel, or PDF to XML/text."
|
|
123
|
+
)
|
|
124
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
125
|
+
|
|
126
|
+
csv_parser = subparsers.add_parser("csv", help="Convert XML to CSV")
|
|
127
|
+
_add_common_flags(csv_parser)
|
|
128
|
+
csv_parser.add_argument("--delimiter", default=",", help="Field delimiter (default: ',')")
|
|
129
|
+
|
|
130
|
+
excel_parser = subparsers.add_parser("excel", help="Convert XML to Excel (.xlsx)")
|
|
131
|
+
_add_common_flags(excel_parser)
|
|
132
|
+
excel_parser.add_argument("--sheet-name", default="Sheet1", help="Sheet name for single-sheet output")
|
|
133
|
+
excel_parser.add_argument(
|
|
134
|
+
"--sheet",
|
|
135
|
+
action="append",
|
|
136
|
+
dest="sheets",
|
|
137
|
+
default=None,
|
|
138
|
+
metavar="NAME=RECORD_PATH",
|
|
139
|
+
type=_parse_sheet_spec,
|
|
140
|
+
help="Add a named sheet built from its own record path; repeatable. "
|
|
141
|
+
"When given, produces a multi-sheet workbook and ignores --record-path/--sheet-name.",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
pdf_parser = subparsers.add_parser(
|
|
145
|
+
"pdf", help="Convert PDF to XML or text, preserving paragraphs and tables"
|
|
146
|
+
)
|
|
147
|
+
_add_pdf_flags(pdf_parser)
|
|
148
|
+
|
|
149
|
+
return parser
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
153
|
+
parser = build_parser()
|
|
154
|
+
args = parser.parse_args(argv)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
if args.command == "csv":
|
|
158
|
+
options = _options_from_args(args)
|
|
159
|
+
path = xml_to_csv(args.input, args.output, options=options, delimiter=args.delimiter)
|
|
160
|
+
elif args.command == "excel":
|
|
161
|
+
options = _options_from_args(args)
|
|
162
|
+
sheets: Optional[Dict[str, str]] = dict(args.sheets) if args.sheets else None
|
|
163
|
+
path = xml_to_excel(
|
|
164
|
+
args.input,
|
|
165
|
+
args.output,
|
|
166
|
+
options=options,
|
|
167
|
+
sheet_name=args.sheet_name,
|
|
168
|
+
sheets=sheets,
|
|
169
|
+
)
|
|
170
|
+
elif args.command == "pdf":
|
|
171
|
+
pdf_options = _pdf_options_from_args(args)
|
|
172
|
+
convert = pdf_to_xml if args.to == "xml" else pdf_to_text
|
|
173
|
+
path = convert(args.input, args.output, options=pdf_options)
|
|
174
|
+
else: # pragma: no cover - argparse enforces valid choices
|
|
175
|
+
parser.error(f"unknown command: {args.command}")
|
|
176
|
+
return 2
|
|
177
|
+
except XMLConversionError as exc:
|
|
178
|
+
print(f"xml2table: error: {exc}", file=sys.stderr)
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
print(f"Wrote {path}")
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
sys.exit(main())
|
xml2table/converter.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""High-level, user-facing conversion API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Mapping, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .exceptions import MissingOptionalDependencyError
|
|
9
|
+
from .options import FlattenOptions
|
|
10
|
+
from .parser import XmlSource, parse_to_records, union_columns
|
|
11
|
+
from .writers import write_csv, write_excel, write_workbook
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class XMLConverter:
|
|
15
|
+
"""Convert one XML document into rows, CSV, Excel, or a DataFrame.
|
|
16
|
+
|
|
17
|
+
Parsing happens once, lazily, on first access and is cached; call
|
|
18
|
+
:meth:`to_records` (or any of the write methods) as many times as you
|
|
19
|
+
like without re-parsing.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
>>> converter = XMLConverter("orders.xml", options=FlattenOptions(record_path="Order"))
|
|
23
|
+
>>> converter.to_csv("orders.csv")
|
|
24
|
+
>>> converter.to_excel("orders.xlsx")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, source: XmlSource, options: Optional[FlattenOptions] = None) -> None:
|
|
28
|
+
self._source = source
|
|
29
|
+
self.options = options or FlattenOptions()
|
|
30
|
+
self._records: Optional[List[Dict[str, Any]]] = None
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_string(cls, xml_string: Union[str, bytes], options: Optional[FlattenOptions] = None) -> "XMLConverter":
|
|
34
|
+
"""Build a converter from an in-memory XML string or bytes."""
|
|
35
|
+
return cls(xml_string, options=options)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_file(cls, path: Union[str, Path], options: Optional[FlattenOptions] = None) -> "XMLConverter":
|
|
39
|
+
"""Build a converter from a file path."""
|
|
40
|
+
return cls(Path(path), options=options)
|
|
41
|
+
|
|
42
|
+
def to_records(self) -> List[Dict[str, Any]]:
|
|
43
|
+
"""Return the flattened rows as a list of plain ``dict`` objects."""
|
|
44
|
+
if self._records is None:
|
|
45
|
+
self._records = parse_to_records(self._source, self.options)
|
|
46
|
+
return self._records
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def columns(self) -> List[str]:
|
|
50
|
+
"""Column names, in first-seen order across all rows."""
|
|
51
|
+
return union_columns(self.to_records())
|
|
52
|
+
|
|
53
|
+
def to_dataframe(self):
|
|
54
|
+
"""Return the flattened rows as a :class:`pandas.DataFrame`.
|
|
55
|
+
|
|
56
|
+
Requires the optional ``pandas`` dependency
|
|
57
|
+
(``pip install xml2table[pandas]``).
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
import pandas as pd
|
|
61
|
+
except ImportError as exc: # pragma: no cover - exercised only without pandas
|
|
62
|
+
raise MissingOptionalDependencyError(
|
|
63
|
+
"pandas is required for to_dataframe(); install it with "
|
|
64
|
+
"'pip install xml2table[pandas]'"
|
|
65
|
+
) from exc
|
|
66
|
+
return pd.DataFrame(self.to_records(), columns=self.columns)
|
|
67
|
+
|
|
68
|
+
def to_csv(
|
|
69
|
+
self,
|
|
70
|
+
destination: Union[str, Path],
|
|
71
|
+
*,
|
|
72
|
+
columns: Optional[List[str]] = None,
|
|
73
|
+
delimiter: str = ",",
|
|
74
|
+
encoding: Optional[str] = None,
|
|
75
|
+
) -> Path:
|
|
76
|
+
"""Write the flattened rows to a CSV file and return its path."""
|
|
77
|
+
return write_csv(
|
|
78
|
+
self.to_records(),
|
|
79
|
+
destination,
|
|
80
|
+
columns=columns,
|
|
81
|
+
delimiter=delimiter,
|
|
82
|
+
encoding=encoding or self.options.encoding,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def to_excel(
|
|
86
|
+
self,
|
|
87
|
+
destination: Union[str, Path],
|
|
88
|
+
*,
|
|
89
|
+
sheet_name: str = "Sheet1",
|
|
90
|
+
columns: Optional[List[str]] = None,
|
|
91
|
+
) -> Path:
|
|
92
|
+
"""Write the flattened rows to a single-sheet ``.xlsx`` file and return its path."""
|
|
93
|
+
return write_excel(self.to_records(), destination, sheet_name=sheet_name, columns=columns)
|
|
94
|
+
|
|
95
|
+
def __len__(self) -> int:
|
|
96
|
+
return len(self.to_records())
|
|
97
|
+
|
|
98
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
99
|
+
return f"XMLConverter(options={self.options!r})"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def xml_to_records(source: XmlSource, *, options: Optional[FlattenOptions] = None, record_path: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
103
|
+
"""Parse XML into a list of flat row dicts. See :class:`FlattenOptions` for tuning."""
|
|
104
|
+
opts = _resolve_options(options, record_path)
|
|
105
|
+
return XMLConverter(source, options=opts).to_records()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def xml_to_dataframe(source: XmlSource, *, options: Optional[FlattenOptions] = None, record_path: Optional[str] = None):
|
|
109
|
+
"""Parse XML directly into a :class:`pandas.DataFrame` (requires ``pandas``)."""
|
|
110
|
+
opts = _resolve_options(options, record_path)
|
|
111
|
+
return XMLConverter(source, options=opts).to_dataframe()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def xml_to_csv(
|
|
115
|
+
source: XmlSource,
|
|
116
|
+
destination: Union[str, Path],
|
|
117
|
+
*,
|
|
118
|
+
options: Optional[FlattenOptions] = None,
|
|
119
|
+
record_path: Optional[str] = None,
|
|
120
|
+
columns: Optional[List[str]] = None,
|
|
121
|
+
delimiter: str = ",",
|
|
122
|
+
) -> Path:
|
|
123
|
+
"""Convert an XML document straight to a CSV file.
|
|
124
|
+
|
|
125
|
+
``record_path`` is a convenience shortcut for ``options.record_path``
|
|
126
|
+
when you don't need to customize anything else.
|
|
127
|
+
"""
|
|
128
|
+
opts = _resolve_options(options, record_path)
|
|
129
|
+
return XMLConverter(source, options=opts).to_csv(destination, columns=columns, delimiter=delimiter)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def xml_to_excel(
|
|
133
|
+
source: XmlSource,
|
|
134
|
+
destination: Union[str, Path],
|
|
135
|
+
*,
|
|
136
|
+
options: Optional[FlattenOptions] = None,
|
|
137
|
+
record_path: Optional[str] = None,
|
|
138
|
+
sheet_name: str = "Sheet1",
|
|
139
|
+
columns: Optional[List[str]] = None,
|
|
140
|
+
sheets: Optional[Mapping[str, str]] = None,
|
|
141
|
+
) -> Path:
|
|
142
|
+
"""Convert an XML document straight to an ``.xlsx`` file.
|
|
143
|
+
|
|
144
|
+
Pass ``sheets`` (a mapping of sheet name -> record path) to produce a
|
|
145
|
+
multi-sheet workbook from several independent record paths within the
|
|
146
|
+
*same* document, e.g.::
|
|
147
|
+
|
|
148
|
+
xml_to_excel(
|
|
149
|
+
"orders.xml", "orders.xlsx",
|
|
150
|
+
sheets={"orders": "Order", "items": ".//Order/Items/Item"},
|
|
151
|
+
)
|
|
152
|
+
"""
|
|
153
|
+
if sheets:
|
|
154
|
+
base_options = options or FlattenOptions()
|
|
155
|
+
sheet_rows = {
|
|
156
|
+
name: parse_to_records(source, base_options.with_record_path(path))
|
|
157
|
+
for name, path in sheets.items()
|
|
158
|
+
}
|
|
159
|
+
return write_workbook(sheet_rows, destination)
|
|
160
|
+
|
|
161
|
+
opts = _resolve_options(options, record_path)
|
|
162
|
+
return XMLConverter(source, options=opts).to_excel(destination, sheet_name=sheet_name, columns=columns)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _resolve_options(options: Optional[FlattenOptions], record_path: Optional[str]) -> FlattenOptions:
|
|
166
|
+
if record_path is None:
|
|
167
|
+
return options or FlattenOptions()
|
|
168
|
+
return (options or FlattenOptions()).with_record_path(record_path)
|
xml2table/exceptions.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Exception hierarchy for xml2table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class XMLConversionError(Exception):
|
|
7
|
+
"""Base class for all errors raised by xml2table."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class XMLParseError(XMLConversionError):
|
|
11
|
+
"""The input could not be parsed as XML."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RecordPathNotFoundError(XMLConversionError):
|
|
15
|
+
"""No unambiguous set of record elements could be found.
|
|
16
|
+
|
|
17
|
+
Raised when ``record_path`` is not supplied and the document's structure
|
|
18
|
+
is ambiguous (e.g. more than one repeated child element at the root),
|
|
19
|
+
or when a supplied ``record_path`` matches nothing.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MissingOptionalDependencyError(XMLConversionError):
|
|
24
|
+
"""An optional dependency (e.g. pandas) is required but not installed."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PDFExtractionError(XMLConversionError):
|
|
28
|
+
"""A PDF document could not be opened or parsed."""
|
xml2table/options.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Configuration for how XML is flattened into rows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
_VALID_ARRAY_MODES = ("join", "explode", "index")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class FlattenOptions:
|
|
13
|
+
"""Controls how nested/repeated XML structure becomes flat rows.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
record_path: An :mod:`xml.etree.ElementTree`-style path (e.g.
|
|
17
|
+
``"Order"``, ``"Orders/Order"``, ``".//Item"``) identifying the
|
|
18
|
+
elements that become one row each. If ``None``, xml2table tries
|
|
19
|
+
to infer the repeated element automatically and raises
|
|
20
|
+
:class:`~xml2table.exceptions.RecordPathNotFoundError` if the
|
|
21
|
+
structure is ambiguous.
|
|
22
|
+
attribute_prefix: Prefix used for keys derived from XML attributes.
|
|
23
|
+
text_key: Key used for an element's own text when the element also
|
|
24
|
+
has attributes or children (so the text isn't ambiguous with a
|
|
25
|
+
child field).
|
|
26
|
+
separator: Separator used when joining nested key paths, e.g.
|
|
27
|
+
``"address.city"``.
|
|
28
|
+
array_mode: How repeated sibling elements are represented:
|
|
29
|
+
|
|
30
|
+
* ``"join"`` (default) - collapse repeated values into a single
|
|
31
|
+
cell, joined by ``join_separator``. Cheapest, one row per
|
|
32
|
+
record, lossy for structured children.
|
|
33
|
+
* ``"index"`` - keep every repeated value but disambiguate keys
|
|
34
|
+
with a numeric index, e.g. ``item.0.name``, ``item.1.name``.
|
|
35
|
+
One row per record, no data loss, wide output.
|
|
36
|
+
* ``"explode"`` - cartesian-expand repeated children into extra
|
|
37
|
+
rows (similar to a SQL join). No data loss, tall output.
|
|
38
|
+
join_separator: Separator used to join values when ``array_mode`` is
|
|
39
|
+
``"join"``.
|
|
40
|
+
include_attributes: Whether XML attributes are included as columns.
|
|
41
|
+
strip_namespaces: Whether XML namespace URIs (``{uri}tag``) are
|
|
42
|
+
stripped from tag and attribute names.
|
|
43
|
+
encoding: Text encoding used when reading/writing files.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
record_path: Optional[str] = None
|
|
47
|
+
attribute_prefix: str = "@"
|
|
48
|
+
text_key: str = "#text"
|
|
49
|
+
separator: str = "."
|
|
50
|
+
array_mode: str = "join"
|
|
51
|
+
join_separator: str = "; "
|
|
52
|
+
include_attributes: bool = True
|
|
53
|
+
strip_namespaces: bool = True
|
|
54
|
+
encoding: str = "utf-8"
|
|
55
|
+
|
|
56
|
+
def __post_init__(self) -> None:
|
|
57
|
+
if self.array_mode not in _VALID_ARRAY_MODES:
|
|
58
|
+
raise ValueError(
|
|
59
|
+
f"array_mode must be one of {_VALID_ARRAY_MODES!r}, got {self.array_mode!r}"
|
|
60
|
+
)
|
|
61
|
+
if not self.separator:
|
|
62
|
+
raise ValueError("separator must be a non-empty string")
|
|
63
|
+
|
|
64
|
+
def with_record_path(self, record_path: Optional[str]) -> "FlattenOptions":
|
|
65
|
+
"""Return a copy of these options with ``record_path`` overridden."""
|
|
66
|
+
return replace(self, record_path=record_path)
|