ofdkit 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.
ofdkit/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """ofdkit: read OFD documents (GB/T 33190) and Chinese e-invoices.
2
+
3
+ >>> import ofdkit
4
+ >>> with ofdkit.open("invoice.ofd") as doc: # doctest: +SKIP
5
+ ... print(doc.text())
6
+ ... print(doc.invoice())
7
+ """
8
+
9
+ from importlib.metadata import PackageNotFoundError
10
+ from importlib.metadata import version as _version
11
+
12
+ from .document import DocInfo, OFDDocument, Page, TextRun
13
+ from .errors import OFDError, OFDFormatError, OFDLimitError
14
+ from .invoice import Invoice, LineItem, Party
15
+ from .package import Source
16
+
17
+ try:
18
+ __version__ = _version("ofdkit")
19
+ except PackageNotFoundError: # running from a source checkout that is not installed
20
+ __version__ = "0.0.0+unknown"
21
+
22
+ __all__ = [
23
+ "DocInfo",
24
+ "Invoice",
25
+ "LineItem",
26
+ "OFDDocument",
27
+ "OFDError",
28
+ "OFDFormatError",
29
+ "OFDLimitError",
30
+ "Page",
31
+ "Party",
32
+ "TextRun",
33
+ "__version__",
34
+ "extract_invoice",
35
+ "extract_text",
36
+ "open",
37
+ ]
38
+
39
+
40
+ def open(source: Source) -> OFDDocument: # noqa: A001 - mirrors builtins.open on purpose
41
+ """Open an OFD file from a path, bytes, or a binary file object."""
42
+ return OFDDocument(source)
43
+
44
+
45
+ def extract_text(source: Source, page_separator: str = "\n\n") -> str:
46
+ """Return all text of an OFD file in reading order."""
47
+ with OFDDocument(source) as document:
48
+ return document.text(page_separator)
49
+
50
+
51
+ def extract_invoice(source: Source) -> Invoice | None:
52
+ """Return the invoice data of an OFD e-invoice, or None if the file holds none."""
53
+ with OFDDocument(source) as document:
54
+ return document.invoice()
ofdkit/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
ofdkit/_xml.py ADDED
@@ -0,0 +1,120 @@
1
+ """XML helpers: safe parsing, recovery from common producer bugs, namespace-free lookups.
2
+
3
+ OFD files in the wild use several namespace URIs (``http://www.ofdspec.org/2016``,
4
+ ``http://www.ofdspec.org``, the e-invoice schemas, or none at all), and some producers
5
+ write malformed XML such as ``<:eInvoice xmlns:="">`` or undeclared prefixes. All lookups
6
+ here therefore match on local names only, and parsing falls back to a repaired copy of
7
+ the text when strict parsing fails.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import codecs
13
+ import re
14
+ import xml.etree.ElementTree as ET
15
+ from collections.abc import Iterator
16
+
17
+ from .errors import OFDFormatError, OFDLimitError
18
+
19
+ # OFD never needs a DTD; refusing them rules out entity-expansion attacks.
20
+ _FORBIDDEN = re.compile(r"<!DOCTYPE|<!ENTITY", re.IGNORECASE)
21
+ _DECLARED_ENCODING = re.compile(rb"""^\s*<\?xml[^>]*?encoding\s*=\s*["']([A-Za-z0-9._-]+)["']""")
22
+ _XML_DECLARATION = re.compile(r"^\s*<\?xml[^>]*\?>")
23
+ _TAG = re.compile(r"<[^<>]*>")
24
+ _ELEMENT_PREFIX = re.compile(r"^<(/?)[^\s<>/=\"':]*:")
25
+ _XMLNS_ATTRIBUTE = re.compile(r"""\s+xmlns(?::[^\s=]*)?\s*=\s*(?:"[^"]*"|'[^']*')""")
26
+ _ATTRIBUTE_PREFIX = re.compile(r"""(\s)[^\s<>/="':]+:(?=[^\s<>/="':]+\s*=)""")
27
+
28
+ # Chinese producers often declare GB2312 or GBK; GB18030 is a superset of both.
29
+ _ENCODING_ALIASES = {"gb2312": "gb18030", "gbk": "gb18030", "cp936": "gb18030"}
30
+
31
+
32
+ def local_name(tag: object) -> str:
33
+ """Return the tag name without its namespace (comments and PIs give '')."""
34
+ if not isinstance(tag, str):
35
+ return ""
36
+ return tag.rsplit("}", 1)[-1]
37
+
38
+
39
+ def children(element: ET.Element, name: str) -> Iterator[ET.Element]:
40
+ """Direct children with the given local name."""
41
+ for child_element in element:
42
+ if local_name(child_element.tag) == name:
43
+ yield child_element
44
+
45
+
46
+ def grandchildren(element: ET.Element | None, container: str, name: str) -> Iterator[ET.Element]:
47
+ """Children named ``name`` of the first child named ``container``, if any."""
48
+ holder = child(element, container)
49
+ if holder is not None:
50
+ yield from children(holder, name)
51
+
52
+
53
+ def child(element: ET.Element | None, name: str) -> ET.Element | None:
54
+ """First direct child with the given local name, or None."""
55
+ if element is None:
56
+ return None
57
+ return next(children(element, name), None)
58
+
59
+
60
+ def child_text(element: ET.Element | None, name: str) -> str | None:
61
+ """Stripped text of the first direct child with the given local name, or None."""
62
+ found = child(element, name)
63
+ if found is None or found.text is None:
64
+ return None
65
+ text = found.text.strip()
66
+ return text or None
67
+
68
+
69
+ def parse_xml(data: bytes, name: str = "XML") -> ET.Element:
70
+ """Parse an XML part strictly, falling back to a repaired copy for broken files."""
71
+ try:
72
+ text = _decode(data)
73
+ except UnicodeError as exc:
74
+ raise OFDFormatError(f"{name}: cannot decode XML: {exc}") from exc
75
+ if _FORBIDDEN.search(text):
76
+ raise OFDLimitError(f"{name}: DTDs and entity declarations are not allowed")
77
+ text = _XML_DECLARATION.sub("", text, count=1)
78
+ try:
79
+ return ET.fromstring(text)
80
+ except ET.ParseError as first_error:
81
+ try:
82
+ return ET.fromstring(_repair(text))
83
+ except ET.ParseError:
84
+ raise OFDFormatError(f"{name}: invalid XML: {first_error}") from first_error
85
+
86
+
87
+ def _decode(data: bytes) -> str:
88
+ if data.startswith(codecs.BOM_UTF8):
89
+ return data[len(codecs.BOM_UTF8):].decode("utf-8", errors="replace")
90
+ if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
91
+ return data.decode("utf-16")
92
+ declared = _DECLARED_ENCODING.match(data)
93
+ if declared:
94
+ encoding = declared.group(1).decode("ascii").lower()
95
+ encoding = _ENCODING_ALIASES.get(encoding, encoding)
96
+ try:
97
+ codec_name = codecs.lookup(encoding).name
98
+ except LookupError: # unknown declaration: fall through to detection
99
+ codec_name = "utf-8"
100
+ if codec_name != "utf-8":
101
+ return data.decode(codec_name, errors="replace")
102
+ try:
103
+ return data.decode("utf-8")
104
+ except UnicodeDecodeError:
105
+ # Undeclared non-UTF-8 text from Chinese producers is almost always GBK.
106
+ return data.decode("gb18030", errors="replace")
107
+
108
+
109
+ def _repair(text: str) -> str:
110
+ """Drop namespace declarations and prefixes so that unbound or empty prefixes parse."""
111
+ return _TAG.sub(_repair_tag, text)
112
+
113
+
114
+ def _repair_tag(match: re.Match[str]) -> str:
115
+ tag = match.group(0)
116
+ if tag.startswith(("<?", "<!")):
117
+ return tag
118
+ tag = _ELEMENT_PREFIX.sub(r"<\1", tag, count=1)
119
+ tag = _XMLNS_ATTRIBUTE.sub("", tag)
120
+ return _ATTRIBUTE_PREFIX.sub(r"\1", tag)
ofdkit/batch.py ADDED
@@ -0,0 +1,111 @@
1
+ """Many files at once: find OFD files and summarise their invoices as rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from collections.abc import Iterable, Sequence
7
+ from decimal import Decimal, InvalidOperation
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .document import OFDDocument
12
+ from .errors import OFDError
13
+ from .invoice import Invoice
14
+
15
+ COLUMNS = [
16
+ "file",
17
+ "invoice_number",
18
+ "invoice_code",
19
+ "issue_date",
20
+ "buyer_name",
21
+ "buyer_tax_id",
22
+ "seller_name",
23
+ "seller_tax_id",
24
+ "amount_without_tax",
25
+ "tax_amount",
26
+ "total_amount",
27
+ "items",
28
+ "warnings",
29
+ "error",
30
+ ]
31
+
32
+ NO_INVOICE = "no invoice data"
33
+
34
+
35
+ def find_ofd_files(paths: Iterable[str | Path], recursive: bool = False) -> tuple[list[Path], list[str]]:
36
+ """OFD files in ``paths`` (files are kept, folders are searched), plus paths that do not exist."""
37
+ found: list[Path] = []
38
+ missing: list[str] = []
39
+ for raw in paths:
40
+ path = Path(raw)
41
+ if path.is_dir():
42
+ candidates = path.rglob("*") if recursive else path.glob("*")
43
+ found.extend(sorted(p for p in candidates if p.is_file() and p.suffix.lower() == ".ofd"))
44
+ elif path.exists():
45
+ found.append(path)
46
+ else:
47
+ missing.append(str(raw))
48
+ return found, missing
49
+
50
+
51
+ def invoice_rows(files: Sequence[Path]) -> list[dict[str, Any]]:
52
+ """One row per file, with duplicate invoice numbers flagged in ``warnings``."""
53
+ rows = [invoice_row(path) for path in files]
54
+ numbers = Counter(row["invoice_number"] for row in rows if row["invoice_number"])
55
+ for row in rows:
56
+ if numbers.get(row["invoice_number"], 0) > 1:
57
+ row["warnings"] = "; ".join(filter(None, [row["warnings"], "duplicate invoice number"]))
58
+ return rows
59
+
60
+
61
+ def invoice_row(path: Path) -> dict[str, Any]:
62
+ row: dict[str, Any] = dict.fromkeys(COLUMNS, "")
63
+ row["file"] = str(path)
64
+ try:
65
+ with OFDDocument(path) as document:
66
+ invoice = document.invoice()
67
+ except (OFDError, OSError) as exc:
68
+ row["error"] = str(exc)
69
+ return row
70
+ if invoice is None:
71
+ row["error"] = NO_INVOICE
72
+ return row
73
+ row.update(_invoice_columns(invoice))
74
+ return row
75
+
76
+
77
+ def summary(rows: Sequence[dict[str, Any]]) -> dict[str, Any]:
78
+ """Counts and the sum of ``total_amount`` over rows that are invoices."""
79
+ invoices = [row for row in rows if not row["error"]]
80
+ total = sum((_decimal(row["total_amount"]) for row in invoices), Decimal(0))
81
+ return {
82
+ "files": len(rows),
83
+ "invoices": len(invoices),
84
+ "without_invoice_data": sum(1 for row in rows if row["error"] == NO_INVOICE),
85
+ "errors": sum(1 for row in rows if row["error"] and row["error"] != NO_INVOICE),
86
+ "total_amount_sum": str(total),
87
+ }
88
+
89
+
90
+ def _invoice_columns(invoice: Invoice) -> dict[str, Any]:
91
+ return {
92
+ "invoice_number": invoice.invoice_number or "",
93
+ "invoice_code": invoice.invoice_code or "",
94
+ "issue_date": invoice.issue_date or "",
95
+ "buyer_name": invoice.buyer.name or "",
96
+ "buyer_tax_id": invoice.buyer.tax_id or "",
97
+ "seller_name": invoice.seller.name or "",
98
+ "seller_tax_id": invoice.seller.tax_id or "",
99
+ "amount_without_tax": invoice.amount_without_tax or "",
100
+ "tax_amount": invoice.tax_amount or "",
101
+ "total_amount": invoice.total_amount or "",
102
+ "items": len(invoice.items),
103
+ "warnings": "; ".join(invoice.warnings),
104
+ }
105
+
106
+
107
+ def _decimal(value: str) -> Decimal:
108
+ try:
109
+ return Decimal(value)
110
+ except (InvalidOperation, TypeError):
111
+ return Decimal(0)
ofdkit/cli.py ADDED
@@ -0,0 +1,189 @@
1
+ """Command-line interface for ofdkit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import json
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from . import __version__
14
+ from .batch import COLUMNS, find_ofd_files, invoice_rows, summary
15
+ from .document import OFDDocument
16
+ from .errors import OFDError
17
+ from .markdown import document_markdown
18
+
19
+ EXIT_OK = 0
20
+ EXIT_NO_INVOICE = 1
21
+ EXIT_ERROR = 2
22
+
23
+ # Kept for backward compatibility with code that imported it from here.
24
+ CSV_COLUMNS = COLUMNS
25
+
26
+
27
+ def build_parser() -> argparse.ArgumentParser:
28
+ parser = argparse.ArgumentParser(
29
+ prog="ofdkit",
30
+ description="Read OFD documents (GB/T 33190) and Chinese e-invoices.",
31
+ )
32
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
33
+ commands = parser.add_subparsers(dest="command", metavar="COMMAND")
34
+
35
+ info = commands.add_parser("info", help="show pages, metadata and whether the file is an invoice")
36
+ info.add_argument("file", help="OFD file")
37
+ info.add_argument("--json", action="store_true", help="print JSON")
38
+
39
+ text = commands.add_parser("text", help="print the text in reading order")
40
+ text.add_argument("file", help="OFD file")
41
+ text.add_argument("-o", "--output", help="write to this file (UTF-8) instead of the screen")
42
+ text.add_argument("--markdown", action="store_true", help="print Markdown, with an invoice table for e-invoices")
43
+
44
+ invoice = commands.add_parser("invoice", help="print invoice fields as JSON")
45
+ invoice.add_argument("file", help="OFD file")
46
+ invoice.add_argument("--raw", action="store_true", help="also print every value found, by element path")
47
+
48
+ batch = commands.add_parser("batch", help="list the invoices in files and folders as CSV")
49
+ batch.add_argument("paths", nargs="+", metavar="PATH", help="OFD files or folders")
50
+ batch.add_argument("-o", "--output", help="CSV file to write (UTF-8 with BOM, so Excel shows Chinese correctly)")
51
+ batch.add_argument("-r", "--recursive", action="store_true", help="also look in subfolders")
52
+
53
+ mcp = commands.add_parser("mcp", help='run an MCP server over stdio for AI tools (needs "ofdkit[mcp]")')
54
+ mcp.add_argument("--root", help="only allow reading files inside this folder")
55
+ return parser
56
+
57
+
58
+ def main(argv: Sequence[str] | None = None) -> int:
59
+ _replace_unencodable_output()
60
+ parser = build_parser()
61
+ args = parser.parse_args(argv)
62
+ if args.command is None:
63
+ parser.print_help()
64
+ return EXIT_OK
65
+ commands = {"info": _info, "text": _text, "invoice": _invoice, "batch": _batch, "mcp": _mcp}
66
+ try:
67
+ return commands[args.command](args)
68
+ except (OFDError, OSError) as exc:
69
+ print(f"ofdkit: error: {exc}", file=sys.stderr)
70
+ return EXIT_ERROR
71
+
72
+
73
+ def _info(args: argparse.Namespace) -> int:
74
+ with OFDDocument(args.file) as document:
75
+ data = file_info(document, args.file)
76
+ if args.json:
77
+ print(json.dumps(data, ensure_ascii=False, indent=2))
78
+ return EXIT_OK
79
+
80
+ info = data["info"]
81
+ sizes = sorted({(page["width_mm"], page["height_mm"]) for page in data["pages"]})
82
+ size_text = ", ".join(f"{w:g} x {h:g} mm" for w, h in sizes)
83
+ lines = [("File", args.file), ("Pages", f"{len(data['pages'])} ({size_text})" if sizes else "0")]
84
+ for label, key in (("Title", "title"), ("Author", "author"), ("Created", "creation_date"), ("Modified", "mod_date")):
85
+ if info[key]:
86
+ lines.append((label, info[key]))
87
+ if info["creator"]:
88
+ creator = info["creator"] + (f" {info['creator_version']}" if info["creator_version"] else "")
89
+ lines.append(("Creator", creator))
90
+ lines.append(("Invoice", "yes" if data["is_invoice"] else "no"))
91
+ lines.extend(("Warning", warning) for warning in data["warnings"])
92
+ width = max(len(label) for label, _ in lines) + 1
93
+ for label, value in lines:
94
+ print(f"{label + ':':<{width}} {value}")
95
+ return EXIT_OK
96
+
97
+
98
+ def file_info(document: OFDDocument, name: str) -> dict[str, Any]:
99
+ """Pages, metadata and whether the document is an invoice, as plain data."""
100
+ invoice = document.invoice()
101
+ return {
102
+ "file": name,
103
+ "pages": [
104
+ {"number": page.index + 1, "id": page.id, "width_mm": page.width, "height_mm": page.height}
105
+ for page in document.pages
106
+ ],
107
+ "info": document.info.to_dict(),
108
+ "is_invoice": invoice is not None,
109
+ "warnings": list(document.warnings),
110
+ }
111
+
112
+
113
+ def _text(args: argparse.Namespace) -> int:
114
+ with OFDDocument(args.file) as document:
115
+ text = document_markdown(document).rstrip("\n") if args.markdown else document.text()
116
+ warnings = list(document.warnings)
117
+ if args.output:
118
+ Path(args.output).write_text(text + "\n", encoding="utf-8")
119
+ else:
120
+ print(text)
121
+ for warning in warnings:
122
+ print(f"ofdkit: warning: {warning}", file=sys.stderr)
123
+ return EXIT_OK
124
+
125
+
126
+ def _invoice(args: argparse.Namespace) -> int:
127
+ with OFDDocument(args.file) as document:
128
+ invoice = document.invoice()
129
+ if invoice is None:
130
+ print(f"ofdkit: no invoice data found in {args.file}", file=sys.stderr)
131
+ return EXIT_NO_INVOICE
132
+ print(json.dumps(invoice.to_dict(include_raw=args.raw), ensure_ascii=False, indent=2))
133
+ return EXIT_OK
134
+
135
+
136
+ def _batch(args: argparse.Namespace) -> int:
137
+ files, missing = find_ofd_files(args.paths, args.recursive)
138
+ for path in missing:
139
+ print(f"ofdkit: warning: {path} does not exist", file=sys.stderr)
140
+ if not files:
141
+ print("ofdkit: error: no .ofd files found", file=sys.stderr)
142
+ return EXIT_ERROR
143
+
144
+ rows = invoice_rows(files)
145
+ if args.output:
146
+ with open(args.output, "w", newline="", encoding="utf-8-sig") as stream:
147
+ _write_csv(stream, rows)
148
+ else:
149
+ _write_csv(sys.stdout, rows)
150
+
151
+ totals = summary(rows)
152
+ print(
153
+ f"ofdkit: {totals['files']} files, {totals['invoices']} invoices, "
154
+ f"{totals['files'] - totals['invoices']} without invoice data; "
155
+ f"total_amount sum {totals['total_amount_sum']}",
156
+ file=sys.stderr,
157
+ )
158
+ return EXIT_OK
159
+
160
+
161
+ def _mcp(args: argparse.Namespace) -> int:
162
+ try:
163
+ from .mcp_server import create_server
164
+ except ImportError as exc:
165
+ print(f'ofdkit: error: MCP support needs the "mcp" extra: pip install "ofdkit[mcp]" ({exc})', file=sys.stderr)
166
+ return EXIT_ERROR
167
+ create_server(root=args.root).run("stdio")
168
+ return EXIT_OK
169
+
170
+
171
+ def _write_csv(stream: Any, rows: list[dict[str, Any]]) -> None:
172
+ writer = csv.DictWriter(stream, fieldnames=COLUMNS)
173
+ writer.writeheader()
174
+ writer.writerows(rows)
175
+
176
+
177
+ def _replace_unencodable_output() -> None:
178
+ """Never crash on a character the console cannot show (old Windows code pages)."""
179
+ for stream in (sys.stdout, sys.stderr):
180
+ reconfigure = getattr(stream, "reconfigure", None)
181
+ if reconfigure is not None:
182
+ try:
183
+ reconfigure(errors="replace")
184
+ except (ValueError, OSError):
185
+ pass
186
+
187
+
188
+ if __name__ == "__main__":
189
+ sys.exit(main())