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 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
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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)
@@ -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)