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 +54 -0
- ofdkit/__main__.py +5 -0
- ofdkit/_xml.py +120 -0
- ofdkit/batch.py +111 -0
- ofdkit/cli.py +189 -0
- ofdkit/document.py +509 -0
- ofdkit/errors.py +13 -0
- ofdkit/invoice.py +438 -0
- ofdkit/layout.py +84 -0
- ofdkit/markdown.py +100 -0
- ofdkit/markitdown_plugin.py +55 -0
- ofdkit/mcp_server.py +125 -0
- ofdkit/package.py +145 -0
- ofdkit-0.1.0.dist-info/METADATA +186 -0
- ofdkit-0.1.0.dist-info/RECORD +18 -0
- ofdkit-0.1.0.dist-info/WHEEL +4 -0
- ofdkit-0.1.0.dist-info/entry_points.txt +6 -0
- ofdkit-0.1.0.dist-info/licenses/LICENSE +202 -0
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
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())
|