sdbtool 0.3.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.
@@ -0,0 +1,26 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ "main" ]
6
+ pull_request:
7
+ branches: [ "main" ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: windows-latest
12
+
13
+ strategy:
14
+ matrix:
15
+ python-version: ["3.10", "3.11", "3.12"]
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - name: Install the latest version of uv and set the python version
21
+ uses: astral-sh/setup-uv@v6
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+
25
+ - name: Test with python ${{ matrix.python-version }}
26
+ run: uv run --frozen pytest
@@ -0,0 +1,11 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ .env
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mark Jansen
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.
sdbtool-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: sdbtool
3
+ Version: 0.3.0
4
+ Summary: Dump SDB file to xml using apphelp.dll
5
+ Project-URL: Homepage, https://pypi.org/project/sdbtool/
6
+ Project-URL: Changelog, https://github.com/learn-more/sdbtool/releases
7
+ Project-URL: Issues, https://github.com/learn-more/sdbtool/issues
8
+ Project-URL: CI, https://github.com/learn-more/sdbtool/actions
9
+ Project-URL: Repository, https://github.com/learn-more/sdbtool
10
+ Author-email: Mark Jansen <mark.jansen@reactos.org>
11
+ License-Expression: MIT
12
+ License-File: LICENSE.txt
13
+ Keywords: appcompat,apphelp,sdb,sdb2xml,sdbtool,shim,shimeng
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Environment :: Console
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: click>=8.2.1
21
+ Description-Content-Type: text/markdown
22
+
23
+ # sdbtool
24
+
25
+ A tool for converting Microsoft Application Compatibility Database (SDB) files to XML format.
26
+
27
+ ## Table of Contents
28
+
29
+ 1. [Features](#features)
30
+ 1. [Getting Started](#getting-started)
31
+ 1. [Contributing](#contributing)
32
+ 1. [License](#license)
33
+
34
+ ## Features<a id="features"></a>
35
+
36
+ - Parses SDB files used by Windows for application compatibility.
37
+ - Converts SDB data into readable XML.
38
+ - Useful for analysis, migration, or documentation.
39
+
40
+
41
+ ## Getting Started<a id="getting-started"></a>
42
+
43
+ ### Installation
44
+
45
+ Sdbtool is available as [`sdbtool`](https://pypi.org/project/sdbtool/) on PyPI.
46
+
47
+ Invoke sdbtool directly with [`uvx`](https://docs.astral.sh/uv/):
48
+
49
+ ```shell
50
+ uvx sdbtool your.sdb # Convert the file 'your.sdb' to xml, and print it to the console
51
+ uvx sdbtool your.sdb --output your.xml # Convert the file 'your.sdb' to xml, and write it to 'your.xml'
52
+ ```
53
+
54
+ Or install sdbtool with `uv` (recommended), `pip`, or `pipx`:
55
+
56
+ ```shell
57
+ # With uv.
58
+ uv tool install sdbtool@latest # Install sdbtool globally.
59
+
60
+ # With pip.
61
+ pip install sdbtool
62
+
63
+ # With pipx.
64
+ pipx install sdbtool
65
+ ```
66
+
67
+ ## Contributing<a id="contributing"></a>
68
+
69
+ Contributions are welcome! Please open issues or submit pull requests.
70
+
71
+ ## License<a id="license"></a>
72
+
73
+ This project is licensed under the MIT License.
@@ -0,0 +1,51 @@
1
+ # sdbtool
2
+
3
+ A tool for converting Microsoft Application Compatibility Database (SDB) files to XML format.
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [Features](#features)
8
+ 1. [Getting Started](#getting-started)
9
+ 1. [Contributing](#contributing)
10
+ 1. [License](#license)
11
+
12
+ ## Features<a id="features"></a>
13
+
14
+ - Parses SDB files used by Windows for application compatibility.
15
+ - Converts SDB data into readable XML.
16
+ - Useful for analysis, migration, or documentation.
17
+
18
+
19
+ ## Getting Started<a id="getting-started"></a>
20
+
21
+ ### Installation
22
+
23
+ Sdbtool is available as [`sdbtool`](https://pypi.org/project/sdbtool/) on PyPI.
24
+
25
+ Invoke sdbtool directly with [`uvx`](https://docs.astral.sh/uv/):
26
+
27
+ ```shell
28
+ uvx sdbtool your.sdb # Convert the file 'your.sdb' to xml, and print it to the console
29
+ uvx sdbtool your.sdb --output your.xml # Convert the file 'your.sdb' to xml, and write it to 'your.xml'
30
+ ```
31
+
32
+ Or install sdbtool with `uv` (recommended), `pip`, or `pipx`:
33
+
34
+ ```shell
35
+ # With uv.
36
+ uv tool install sdbtool@latest # Install sdbtool globally.
37
+
38
+ # With pip.
39
+ pip install sdbtool
40
+
41
+ # With pipx.
42
+ pipx install sdbtool
43
+ ```
44
+
45
+ ## Contributing<a id="contributing"></a>
46
+
47
+ Contributions are welcome! Please open issues or submit pull requests.
48
+
49
+ ## License<a id="license"></a>
50
+
51
+ This project is licensed under the MIT License.
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "sdbtool"
3
+ version = "0.3.0"
4
+ description = "Dump SDB file to xml using apphelp.dll"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Mark Jansen", email = "mark.jansen@reactos.org" }
9
+ ]
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "click>=8.2.1",
13
+ ]
14
+ keywords = ["sdb", "sdbtool", "sdb2xml", "shim", "apphelp", "appcompat", "shimeng"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Programming Language :: Python :: 3",
19
+ "Operating System :: Microsoft :: Windows",
20
+ "Topic :: Utilities",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://pypi.org/project/sdbtool/"
25
+ Changelog = "https://github.com/learn-more/sdbtool/releases"
26
+ Issues = "https://github.com/learn-more/sdbtool/issues"
27
+ CI = "https://github.com/learn-more/sdbtool/actions"
28
+ Repository = "https://github.com/learn-more/sdbtool"
29
+
30
+ [project.scripts]
31
+ sdbtool = "sdbtool.cli:cli"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.sdist]
38
+ exclude = [
39
+ ".vscode",
40
+ ".python-version",
41
+ "uv.lock",
42
+ "tests"
43
+ ]
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "pytest>=8.4.1",
48
+ ]
49
+
@@ -0,0 +1,131 @@
1
+ '''
2
+ PROJECT: sdbtool
3
+ LICENSE: MIT (https://spdx.org/licenses/MIT)
4
+ PURPOSE: Entrypoint of the sdbtool tool, which converts SDB files to XML format.
5
+ COPYRIGHT: Copyright 2025 Mark Jansen <mark.jansen@reactos.org>
6
+ '''
7
+ import sdbtool.apphelp as apphelp
8
+ from base64 import b64encode
9
+ from xml.sax.saxutils import escape
10
+
11
+ INDENT_DEPTH = 2
12
+
13
+ def tagtype_to_xmltype(tag_type: int) -> str|None:
14
+ switch = {
15
+ apphelp.TAG_TYPE_BYTE: "xs:byte",
16
+ apphelp.TAG_TYPE_WORD: "xs:unsignedShort",
17
+ apphelp.TAG_TYPE_DWORD: "xs:unsignedInt",
18
+ apphelp.TAG_TYPE_QWORD: "xs:unsignedLong",
19
+ apphelp.TAG_TYPE_STRINGREF: "xs:string",
20
+ apphelp.TAG_TYPE_STRING: "xs:string",
21
+ apphelp.TAG_TYPE_BINARY: "xs:base64Binary",
22
+ }
23
+ return switch.get(tag_type, None)
24
+
25
+ class Xml:
26
+ def __init__(self, stream, name, indent, attrib=None, xmltag=False):
27
+ self._stream = stream
28
+ self.name = name
29
+ self.indent = indent
30
+ self.attrib = attrib or {}
31
+ self._has_children = False
32
+ self._xmltag = xmltag
33
+ self._delay = None
34
+ self._closed = False
35
+
36
+ def node(self, name, attrib=None):
37
+ self._has_children = True
38
+ return Xml(self._stream, name, self.indent + 1, attrib)
39
+
40
+ def flush(self):
41
+ if self._delay:
42
+ self._stream.write(self._delay)
43
+ self._delay = None
44
+
45
+ def write(self, text):
46
+ self.flush()
47
+ self._stream.write(text)
48
+
49
+ def close(self):
50
+ assert self._delay is not None, "Xml.close() called when tag has content"
51
+ self._stream.write(" />")
52
+ self._delay = None
53
+ self._closed = True
54
+
55
+ def __enter__(self):
56
+ if self._xmltag:
57
+ self.write('<?xml version="1.0" encoding="utf-8" standalone="yes"?>')
58
+ self._xmltag = False
59
+ self.write("\n")
60
+ if self.indent:
61
+ self.write(" " * (self.indent*INDENT_DEPTH))
62
+ self.write(f"<{self.name}")
63
+ for key, value in self.attrib.items():
64
+ self.write(f' {key}="{value}"')
65
+
66
+ self._delay = ">"
67
+ return self
68
+
69
+ def __exit__(self, exc_type, exc_value, traceback):
70
+ if self._closed:
71
+ return
72
+ if self._has_children:
73
+ self.write("\n")
74
+ if self.indent:
75
+ self.write(" " * (self.indent*INDENT_DEPTH))
76
+ self.write(f"</{self.name}>")
77
+
78
+
79
+ def dump_tag(node, tag):
80
+ if tag.type == apphelp.TAG_TYPE_LIST:
81
+ node.flush()
82
+ for childtag in tag.tags():
83
+ attrs = {}
84
+ if childtag.type != apphelp.TAG_TYPE_LIST and childtag.type != apphelp.TAG_TYPE_NULL:
85
+ typename = tagtype_to_xmltype(childtag.type)
86
+ assert typename is not None, f"Unknown tag type: {childtag.tag}"
87
+ attrs["type"] = typename
88
+ with node.node(childtag.name, attrs) as childnode:
89
+ dump_tag(childnode, childtag)
90
+ return
91
+
92
+ assert next(tag.tags(), None) is None, f"Tag {tag.name} is not a list but has children"
93
+
94
+ if tag.type == apphelp.TAG_TYPE_NULL:
95
+ node.close()
96
+ elif tag.type == apphelp.TAG_TYPE_BYTE:
97
+ node.write("<!-- UNHANDLED BYTE TAG, please report this at https://github.com/learn-more/sdbtool -->")
98
+ elif tag.type == apphelp.TAG_TYPE_WORD:
99
+ val = tag.as_word()
100
+ node.write(f"{val}")
101
+ if node.name in ("INDEX_TAG", "INDEX_KEY"):
102
+ node.write(f"<!-- {apphelp.tag_to_string(val)} -->")
103
+ elif tag.type == apphelp.TAG_TYPE_DWORD:
104
+ node.write(f"{tag.as_dword()}")
105
+ elif tag.type == apphelp.TAG_TYPE_QWORD:
106
+ node.write(f"{tag.as_qword()}")
107
+ elif tag.type in (apphelp.TAG_TYPE_STRINGREF, apphelp.TAG_TYPE_STRING):
108
+ val = escape(tag.as_string())
109
+ node.write(f"{val}")
110
+ elif tag.type == apphelp.TAG_TYPE_BINARY:
111
+ data = tag.as_bytes()
112
+ if not data:
113
+ node.close()
114
+ else:
115
+ base64_data = b64encode(data).decode('utf-8')
116
+ node.write(base64_data)
117
+ else:
118
+ raise ValueError(f"Unknown tag type: {tag.type} for tag {tag.name}")
119
+
120
+
121
+ def convert(input_file: str, output_stream):
122
+ with apphelp.SdbDatabase(input_file, apphelp.PathType.DOS_PATH) as db:
123
+ if not db:
124
+ print(f"Failed to open database at '{input_file}'")
125
+ return
126
+ attrs = {
127
+ "xmlns:xs": "http://www.w3.org/2001/XMLSchema",
128
+ "path": input_file,
129
+ }
130
+ with Xml(output_stream, "SDB", 0, attrs, xmltag=True) as node:
131
+ dump_tag(node, db.root())
@@ -0,0 +1,4 @@
1
+ from sdbtool.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -0,0 +1,174 @@
1
+ '''
2
+ PROJECT: sdbtool
3
+ LICENSE: MIT (https://spdx.org/licenses/MIT)
4
+ PURPOSE: interface to the AppHelp API for reading SDB files.
5
+ COPYRIGHT: Copyright 2025 Mark Jansen <mark.jansen@reactos.org>
6
+ '''
7
+ from ctypes import windll, c_void_p, c_uint16, c_uint32, c_wchar_p, POINTER, c_ubyte, c_uint64
8
+ from enum import IntEnum
9
+
10
+ class PathType(IntEnum):
11
+ DOS_PATH = 0
12
+ NT_PATH = 1
13
+
14
+
15
+ TAGID_NULL = 0x0
16
+ TAGID_ROOT = 0x0
17
+ _TAGID_ROOT = 12
18
+
19
+ TAG_TYPE_MASK = 0xF000
20
+
21
+ TAG_TYPE_NULL = 0x1000
22
+ TAG_TYPE_BYTE = 0x2000
23
+ TAG_TYPE_WORD = 0x3000
24
+ TAG_TYPE_DWORD = 0x4000
25
+ TAG_TYPE_QWORD = 0x5000
26
+ TAG_TYPE_STRINGREF = 0x6000
27
+ TAG_TYPE_LIST = 0x7000
28
+ TAG_TYPE_STRING = 0x8000
29
+ TAG_TYPE_BINARY = 0x9000
30
+ TAG_NULL = 0x0
31
+
32
+
33
+ APPHELP = windll.apphelp
34
+
35
+ # PDB WINAPI SdbOpenDatabase(LPCWSTR path, PATH_TYPE type);
36
+ APPHELP.SdbOpenDatabase.argtypes = [c_wchar_p, c_uint32]
37
+ APPHELP.SdbOpenDatabase.restype = c_void_p
38
+
39
+ # void WINAPI SdbCloseDatabase(PDB);
40
+ APPHELP.SdbCloseDatabase.argtypes = [c_void_p]
41
+
42
+ # TAGID WINAPI SdbGetFirstChild(PDB pdb, TAGID parent);
43
+ APPHELP.SdbGetFirstChild.argtypes = [c_void_p, c_uint32]
44
+ APPHELP.SdbGetFirstChild.restype = c_uint32
45
+
46
+ # TAGID WINAPI SdbGetNextChild(PDB pdb, TAGID parent, TAGID prev_child);
47
+ APPHELP.SdbGetNextChild.argtypes = [c_void_p, c_uint32, c_uint32]
48
+ APPHELP.SdbGetNextChild.restype = c_uint32
49
+
50
+ # TAG WINAPI SdbGetTagFromTagID(PDB pdb, TAGID tagid);
51
+ APPHELP.SdbGetTagFromTagID.argtypes = [c_void_p, c_uint32]
52
+ APPHELP.SdbGetTagFromTagID.restype = c_uint16
53
+
54
+ # LPCWSTR WINAPI SdbTagToString(TAG tag);
55
+ APPHELP.SdbTagToString.argtypes = [c_uint16]
56
+ APPHELP.SdbTagToString.restype = c_wchar_p
57
+
58
+ # WORD WINAPI SdbReadDWORDTag(PDB pdb, TAGID tagid, WORD ret);
59
+ APPHELP.SdbReadWORDTag.argtypes = [c_void_p, c_uint32, c_uint16]
60
+ APPHELP.SdbReadWORDTag.restype = c_uint16
61
+
62
+ # DWORD WINAPI SdbReadDWORDTag(PDB pdb, TAGID tagid, DWORD ret);
63
+ APPHELP.SdbReadDWORDTag.argtypes = [c_void_p, c_uint32, c_uint32]
64
+ APPHELP.SdbReadDWORDTag.restype = c_uint32
65
+
66
+ # QWORD WINAPI SdbReadQWORDTag(PDB pdb, TAGID tagid, QWORD ret);
67
+ APPHELP.SdbReadQWORDTag.argtypes = [c_void_p, c_uint32, c_uint64]
68
+ APPHELP.SdbReadQWORDTag.restype = c_uint64
69
+
70
+ # DWORD WINAPI SdbGetTagDataSize(PDB pdb, TAGID tagid);
71
+ APPHELP.SdbGetTagDataSize.argtypes = [c_void_p, c_uint32]
72
+ APPHELP.SdbGetTagDataSize.restype = c_uint32
73
+
74
+ # BOOL WINAPI SdbReadBinaryTag(PDB pdb, TAGID tagid, PBYTE buffer, DWORD size);
75
+ APPHELP.SdbReadBinaryTag.argtypes = [c_void_p, c_uint32, POINTER(c_ubyte), c_uint32]
76
+ APPHELP.SdbReadBinaryTag.restype = c_uint32
77
+
78
+ # LPWSTR WINAPI SdbGetStringTagPtr(PDB pdb, TAGID tagid);
79
+ APPHELP.SdbGetStringTagPtr.argtypes = [c_void_p, c_uint32]
80
+ APPHELP.SdbGetStringTagPtr.restype = c_wchar_p
81
+
82
+ def _get_tag_type(tag: int) -> int:
83
+ """Extracts the type from a tag."""
84
+ return tag & TAG_TYPE_MASK
85
+
86
+ def tag_to_string(tag: int) -> str:
87
+ """Converts a tag to its string representation."""
88
+ return APPHELP.SdbTagToString(tag)
89
+
90
+ class Tag:
91
+ def __init__(self, db: 'SdbDatabase', tag_id: int):
92
+ self.db = db
93
+ self.tag_id = tag_id
94
+ if tag_id == TAGID_ROOT:
95
+ self.tag = TAG_NULL
96
+ self.name = 'SDB'
97
+ self.type = TAG_TYPE_LIST
98
+ else:
99
+ self.tag = APPHELP.SdbGetTagFromTagID(db.handle, tag_id)
100
+ self.name = APPHELP.SdbTagToString(self.tag)
101
+ self.type = _get_tag_type(self.tag)
102
+
103
+ def tags(self):
104
+ child = APPHELP.SdbGetFirstChild(self.db.handle, self.tag_id)
105
+ while child != 0:
106
+ yield Tag(self.db, child)
107
+ child = APPHELP.SdbGetNextChild(self.db.handle, self.tag_id, child)
108
+
109
+ def as_word(self, default: int = 0) -> int:
110
+ """Returns the tag value as a word (16-bit integer)."""
111
+ if self.type != TAG_TYPE_WORD:
112
+ raise ValueError(f"Tag {self.name} is not a WORD type")
113
+ return APPHELP.SdbReadWORDTag(self.db.handle, self.tag_id, default)
114
+
115
+ def as_dword(self, default: int = 0) -> int:
116
+ """Returns the tag value as a dword (32-bit integer)."""
117
+ if self.type != TAG_TYPE_DWORD:
118
+ raise ValueError(f"Tag {self.name} is not a DWORD type")
119
+ return APPHELP.SdbReadDWORDTag(self.db.handle, self.tag_id, default)
120
+
121
+ def as_qword(self, default: int = 0) -> int:
122
+ """Returns the tag value as a qword (64-bit integer)."""
123
+ if self.type != TAG_TYPE_QWORD:
124
+ raise ValueError(f"Tag {self.name} is not a QWORD type")
125
+ return APPHELP.SdbReadQWORDTag(self.db.handle, self.tag_id, default)
126
+
127
+ def as_bytes(self) -> bytes:
128
+ """Returns the tag value as bytes."""
129
+ if self.type != TAG_TYPE_BINARY:
130
+ raise ValueError(f"Tag {self.name} is not a BINARY type")
131
+ size = APPHELP.SdbGetTagDataSize(self.db.handle, self.tag_id)
132
+ if size == 0:
133
+ return b''
134
+ data = (c_ubyte * size)()
135
+ result = APPHELP.SdbReadBinaryTag(self.db.handle, self.tag_id, data, size)
136
+ if result == 0:
137
+ raise ValueError(f"Failed to read binary tag {self.name}")
138
+ return bytes(data)
139
+
140
+ def as_string(self) -> str:
141
+ """Returns the tag value as a string."""
142
+ if self.type not in (TAG_TYPE_STRING, TAG_TYPE_STRINGREF):
143
+ raise ValueError(f"Tag {self.name} is not a STRING or STRINGREF type")
144
+ ptr = APPHELP.SdbGetStringTagPtr(self.db.handle, self.tag_id)
145
+ return ptr if ptr is not None else ''
146
+
147
+
148
+ class SdbDatabase:
149
+ def __init__(self, path: str, path_type: PathType):
150
+ self.path = path
151
+ self.path_type = path_type
152
+ self.handle = APPHELP.SdbOpenDatabase(path, path_type)
153
+ self._root = None
154
+
155
+ def root(self):
156
+ if self._root is None and self.handle is not None:
157
+ self._root = Tag(self, TAGID_ROOT)
158
+ return self._root
159
+
160
+ def close(self):
161
+ if self.handle:
162
+ APPHELP.SdbCloseDatabase(self.handle)
163
+ self.handle = None
164
+
165
+ def __bool__(self):
166
+ if self.handle is None:
167
+ return False
168
+ return True
169
+
170
+ def __enter__(self):
171
+ return self
172
+
173
+ def __exit__(self, exc_type, exc_value, traceback):
174
+ self.close()
@@ -0,0 +1,14 @@
1
+ from sdbtool import convert
2
+ import click
3
+
4
+ @click.command()
5
+ @click.version_option()
6
+ @click.argument("input_file", type=click.Path(exists=True, dir_okay=False))
7
+ @click.option(
8
+ "--output",
9
+ type=click.File("w", encoding="utf-8"),
10
+ default="-",
11
+ help="Path to the output XML file, or '-' for stdout.",
12
+ )
13
+ def cli(input_file, output):
14
+ convert(input_file, output)