xta-tool 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.
xta_tool/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ # SPDX-FileCopyrightText: 2026 Jan Zickermann <janz@noreply.codeberg.org>
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ from .client import XtaClient
6
+ from .config import Config, get_config, init_global_config
7
+ from .main import main as cli
8
+ from .model import (
9
+ BASE64_PREFIX,
10
+ XtaCode,
11
+ XtaFile,
12
+ XtaIdentifier,
13
+ XtaMessage,
14
+ XtaMetadata,
15
+ XtaTransportReport,
16
+ )
17
+
18
+ _ = (
19
+ get_config,
20
+ Config,
21
+ init_global_config,
22
+ XtaClient,
23
+ XtaFile,
24
+ XtaCode,
25
+ XtaIdentifier,
26
+ XtaMetadata,
27
+ XtaMessage,
28
+ XtaTransportReport,
29
+ BASE64_PREFIX,
30
+ cli,
31
+ )
xta_tool/client.py ADDED
@@ -0,0 +1,198 @@
1
+ # SPDX-FileCopyrightText: 2026 Jan Zickermann <janz@noreply.codeberg.org>
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ from contextlib import contextmanager
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import click
11
+ import httpx
12
+ from click import ClickException
13
+
14
+ from . import config as cfg
15
+ from . import file, soap
16
+ from .model import (
17
+ XtaFile,
18
+ XtaMessage,
19
+ XtaMetadata,
20
+ XtaTransportReport,
21
+ )
22
+
23
+
24
+ @dataclass
25
+ class XtaClient:
26
+ client: httpx.Client
27
+ config: cfg.Config
28
+ output_directory: Optional[Path]
29
+
30
+ def new(self) -> str:
31
+ resp = self._request(self._prepare_new())
32
+ return resp.text("{*}Body/{*}MessageID")
33
+
34
+ def _prepare_new(self) -> soap.XtaSoapRequest:
35
+ return soap.XtaSoapRequest(
36
+ url=self.config.management_port_url,
37
+ action="http://www.xta.de/XTA/CreateMessageID",
38
+ header_template="Author.xml",
39
+ context=self._config_context(),
40
+ )
41
+
42
+ def send(self, message: XtaMessage):
43
+ self._request(self._prepare_send(message))
44
+
45
+ def _prepare_send(self, message: XtaMessage) -> soap.XtaSoapRequest:
46
+ context = self._prepare_send_context(message)
47
+ return soap.XtaSoapRequest(
48
+ url=self.config.send_port_url,
49
+ action="http://www.xta.de/XTA/SendMessage",
50
+ header_template="MessageMetaData.xml",
51
+ body_template="GenericContentContainer.xml",
52
+ attachments=self._prepare_send_attachments(message, context),
53
+ context=context,
54
+ )
55
+
56
+ def _prepare_send_attachments(
57
+ self, message: XtaMessage, context: dict
58
+ ) -> list[soap.AttachmentResource]:
59
+ return [
60
+ self._prepare_file(xta_file, message.source_directory, context)
61
+ for xta_file in [message.message] + message.attachments
62
+ ]
63
+
64
+ def _prepare_file(
65
+ self, xta_file: XtaFile, source_directory: Optional[Path], context: dict
66
+ ) -> soap.AttachmentResource:
67
+ return soap.AttachmentResource(
68
+ content_id=xta_file.content_id,
69
+ content=file.load_xta_file_content(
70
+ xta_file.content, source_directory, context
71
+ ),
72
+ )
73
+
74
+ def _prepare_send_context(self, message: XtaMessage) -> dict:
75
+ return {**message.model_dump(), **self._config_context()}
76
+
77
+ def get(self, message_id: str) -> XtaMessage:
78
+ resp = self._request(self._prepare_get(message_id))
79
+ xta_message = self._map_xta_message(resp)
80
+ return file.save_xta_message(
81
+ xta_message, resp.attachments, self.config.max_save_base64_size
82
+ )
83
+
84
+ def _prepare_get(self, message_id: str) -> soap.XtaSoapRequest:
85
+ return soap.XtaSoapRequest(
86
+ url=self.config.msg_box_port_url,
87
+ action="http://www.osci.eu/ws/2008/05/transport/urn/messageTypes/MsgBoxFetchRequest",
88
+ header_template="Author.xml",
89
+ body_template="MsgBoxFetchRequest.xml",
90
+ context=self._prepare_get_context(message_id),
91
+ )
92
+
93
+ def _prepare_get_context(self, message_id: str):
94
+ return {"config": cfg.get_config(), "message_id": message_id}
95
+
96
+ def _map_xta_message(self, resp: soap.IncomingSoapMessage) -> XtaMessage:
97
+ return XtaMessage.from_xml(
98
+ resp.select("{*}Header/{*}MessageMetaData"),
99
+ resp.select("{*}Body/{*}GenericContentContainer"),
100
+ (
101
+ self.output_directory / "xta-message.yaml"
102
+ if self.output_directory
103
+ else None
104
+ ),
105
+ )
106
+
107
+ def ls(self) -> list[XtaMetadata]:
108
+ resp = self._request(self._prepare_ls())
109
+ body = resp.select("{*}Body/{*}MsgStatusList")
110
+ return [
111
+ XtaMetadata.from_xml(metadata_element)
112
+ for metadata_element in body.findall("{*}MessageMetaData")
113
+ ]
114
+
115
+ def _prepare_ls(self):
116
+ return soap.XtaSoapRequest(
117
+ url=self.config.msg_box_port_url,
118
+ action="http://www.osci.eu/ws/2008/05/transport/urn/messageTypes/MsgBoxStatusListRequest",
119
+ header_template="Author.xml",
120
+ body_template="MsgBoxStatusListRequest.xml",
121
+ context=self._prepare_ls_context(),
122
+ )
123
+
124
+ def _prepare_ls_context(self):
125
+ return {"config": cfg.get_config()}
126
+
127
+ def inspect(self, message_id: str) -> XtaTransportReport:
128
+ resp = self._request(self._prepare_inspect(message_id))
129
+ report_element = resp.select("{*}Body/{*}TransportReport")
130
+ return XtaTransportReport.from_xml(report_element)
131
+
132
+ def _prepare_inspect(self, message_id: str) -> soap.XtaSoapRequest:
133
+ return soap.XtaSoapRequest(
134
+ url=self.config.management_port_url,
135
+ action="http://www.xta.de/XTA/GetTransportReport",
136
+ header_template="Author.xml",
137
+ body_template="MessageID.xml",
138
+ context=self._config_and_message_id_context(message_id),
139
+ )
140
+
141
+ def close(self, message_id: str):
142
+ self._request(self._prepare_close(message_id))
143
+
144
+ def _prepare_close(self, message_id: str) -> soap.XtaSoapRequest:
145
+ return soap.XtaSoapRequest(
146
+ url=self.config.msg_box_port_url,
147
+ action="http://www.osci.eu/ws/2008/05/transport/urn/messageTypes/MsgBoxCloseRequest",
148
+ header_template="Author.xml",
149
+ body_template="MsgBoxCloseRequest.xml",
150
+ context=self._config_and_message_id_context(message_id),
151
+ )
152
+
153
+ def _config_and_message_id_context(self, message_id):
154
+ return {**self._config_context(), "message_id": message_id}
155
+
156
+ def _config_context(self):
157
+ return {"config": self.config}
158
+
159
+ def _request(self, soap_request: soap.XtaSoapRequest) -> soap.IncomingSoapMessage:
160
+ request = soap_request.prepare()
161
+ request.headers["User-Agent"] = self.config.user_agent
162
+
163
+ # send request
164
+ try:
165
+ http_response = self.client.send(request, stream=True)
166
+ except httpx.RequestError as e:
167
+ raise ClickException(f"{e} - [{request.method}] {request.url}")
168
+
169
+ # process response
170
+ if self.config.verbose:
171
+ header_lines = "\n".join(
172
+ f"{key}: {value}" for key, value in http_response.headers.items()
173
+ )
174
+ click.echo(f"{header_lines}\n", err=True)
175
+ soap_response = soap.receive(
176
+ http_response.headers.get("content-type", ""),
177
+ http_response.iter_bytes(),
178
+ self.config.max_response_envelope_size,
179
+ )
180
+
181
+ if self.config.verbose:
182
+ click.echo(soap_response.envelope_xml + "\n", err=True)
183
+
184
+ # handle fault response
185
+ if soap_response.fault:
186
+ raise soap.SoapError(soap_response.fault)
187
+ try:
188
+ http_response.raise_for_status()
189
+ except httpx.HTTPStatusError as e:
190
+ raise ClickException(f"{e} - [{request.method}] {request.url}")
191
+
192
+ return soap_response
193
+
194
+
195
+ @contextmanager
196
+ def create_xta_client(output_directory: Optional[Path] = None):
197
+ with cfg.create_http_client() as client:
198
+ yield XtaClient(client, cfg.get_config(), output_directory)
xta_tool/config.py ADDED
@@ -0,0 +1,123 @@
1
+ # SPDX-FileCopyrightText: 2026 Jan Zickermann <janz@noreply.codeberg.org>
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import ssl
6
+ import tomllib
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+
10
+ import httpx
11
+ from click import ClickException
12
+ from pydantic import BaseModel
13
+
14
+ from . import util
15
+ from .model import XtaIdentifier
16
+
17
+ DEFAULT_CONFIG_PATH = Path.home() / ".xta" / "config.toml"
18
+
19
+
20
+ class Config(BaseModel):
21
+ base_url: str = "https://localhost:8443"
22
+ management_port_path: str = "/services/XTAService/ManagementPort"
23
+ msg_box_port_path: str = "/services/XTAService/MsgBoxPort"
24
+ send_port_path: str = "/services/XTAService/SendXtaPort"
25
+ verify: bool = True
26
+ cafile: str = "~/.xta/ca.crt"
27
+ certfile: str = "~/.xta/tls.crt"
28
+ keyfile: str = "~/.xta/tls.key"
29
+ self_identifier: XtaIdentifier = XtaIdentifier(value="*")
30
+ user_agent: str = "Apache-CXF/4.0.7"
31
+ verbose: bool = False
32
+ max_save_base64_size: int = 1 << 20 # 1 MiB
33
+ max_response_envelope_size: int = 10 << 20 # 10 MiB
34
+ max_list_items: int = 100
35
+
36
+ @property
37
+ def management_port_url(self):
38
+ return self.base_url + self.management_port_path
39
+
40
+ @property
41
+ def msg_box_port_url(self):
42
+ return self.base_url + self.msg_box_port_path
43
+
44
+ @property
45
+ def send_port_url(self):
46
+ return self.base_url + self.send_port_path
47
+
48
+
49
+ CONFIG: Config | None = None
50
+
51
+ LOADED_CONFIG_FILES = []
52
+
53
+
54
+ def get_config() -> Config:
55
+ if CONFIG is None:
56
+ raise RuntimeError("init_global_config not called?")
57
+ return CONFIG
58
+
59
+
60
+ def init_global_config(config_path: Path, profiles: str):
61
+ global CONFIG
62
+ LOADED_CONFIG_FILES.clear()
63
+ config_dict = _load_default_toml_config(config_path)
64
+ for profile_str in profiles.split(","):
65
+ profile = profile_str.strip()
66
+ if profile:
67
+ config_dict = util.merge_dict(
68
+ config_dict,
69
+ _load_profile_toml_config(config_path, profile),
70
+ )
71
+ CONFIG = Config(**config_dict)
72
+
73
+
74
+ def _load_default_toml_config(config_path: Path):
75
+ if config_path.is_file():
76
+ return _load_toml_config(config_path)
77
+ elif config_path.absolute() != DEFAULT_CONFIG_PATH.absolute():
78
+ raise ClickException("Config file '{config_path}' does not exist!")
79
+ return dict()
80
+
81
+
82
+ def _load_profile_toml_config(config_path: Path, profile: str):
83
+ profile_config_path = _to_profile_path(config_path, profile)
84
+ if profile_config_path.is_file():
85
+ return _load_toml_config(profile_config_path)
86
+ else:
87
+ raise ClickException(
88
+ f"Profile config file '{profile_config_path}' does not exist!"
89
+ )
90
+
91
+
92
+ def _load_toml_config(config_path: Path):
93
+ LOADED_CONFIG_FILES.append(config_path)
94
+ with open(config_path, "rb") as file:
95
+ return util.translate_dict_to_snake_case(tomllib.load(file))
96
+
97
+
98
+ def _to_profile_path(config_path: Path, profile: str) -> Path:
99
+ name = config_path.name
100
+ suffix = ""
101
+ parts = name.rsplit(".", 1)
102
+ if len(parts) == 2:
103
+ name, extension = parts
104
+ suffix = f".{extension}"
105
+ return config_path.parent / f"{name}-{profile}{suffix}"
106
+
107
+
108
+ @contextmanager
109
+ def create_http_client():
110
+ cfg = get_config()
111
+ ctx = ssl.create_default_context(
112
+ cafile=Path(cfg.cafile).expanduser() if cfg.cafile else None
113
+ )
114
+ ctx.verify_mode = (
115
+ ssl.VerifyMode.CERT_REQUIRED if cfg.verify else ssl.VerifyMode.CERT_NONE
116
+ )
117
+ if cfg.certfile:
118
+ ctx.load_cert_chain(
119
+ certfile=Path(cfg.certfile).expanduser(),
120
+ keyfile=Path(cfg.keyfile).expanduser() if cfg.keyfile else None,
121
+ )
122
+ with httpx.Client(verify=ctx) as client:
123
+ yield client
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: 2026 Jan Zickermann <janz@noreply.codeberg.org>
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+
6
+ from .load import load_xta_file_content, load_xta_message
7
+ from .save import save_xta_message
8
+
9
+ _ = (load_xta_message, load_xta_file_content, save_xta_message)
xta_tool/file/load.py ADDED
@@ -0,0 +1,72 @@
1
+ # SPDX-FileCopyrightText: 2026 Jan Zickermann <janz@noreply.codeberg.org>
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import base64
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import yaml
10
+ from click import ClickException
11
+ from pydantic import ValidationError
12
+
13
+ from ..model import BASE64_PREFIX, XtaMessage
14
+ from ..util import (
15
+ Chunks,
16
+ merge_dict,
17
+ translate_dict_to_snake_case,
18
+ )
19
+ from . import template
20
+ from .zip import create_zip_content
21
+
22
+
23
+ def load_xta_file_content(
24
+ file_content: str, source_directory: Optional[Path], context: dict
25
+ ) -> Chunks:
26
+ if file_content.startswith(BASE64_PREFIX):
27
+ return lambda: iter([base64.b64decode(file_content[len(BASE64_PREFIX) :])])
28
+ else:
29
+ if source_directory is None or not source_directory.is_dir():
30
+ raise ClickException(
31
+ f"Source directory '{source_directory}' of '{file_content}' does not exist!"
32
+ )
33
+ content_path = source_directory / file_content
34
+ if content_path.is_dir():
35
+ return lambda: create_zip_content(content_path, context)
36
+ if not content_path.is_file():
37
+ raise ClickException(f"Content file '{content_path}' does not exist!")
38
+
39
+ return lambda: template.load_file_content(content_path, context)
40
+
41
+
42
+ def load_xta_message(message_id: str, message_path: Path) -> XtaMessage:
43
+ message_file_path = _get_yaml_message_file_path(message_path)
44
+ message_dict = _load_yaml_message_file(message_file_path)
45
+ try:
46
+ return XtaMessage(
47
+ **merge_dict(
48
+ message_dict,
49
+ dict(
50
+ metadata=dict(message_id=message_id), source_path=message_file_path
51
+ ),
52
+ ),
53
+ )
54
+ except ValidationError as e:
55
+ raise ClickException(f"{message_path} invalid!\n{e}")
56
+
57
+
58
+ def _get_yaml_message_file_path(path: Path) -> Path:
59
+ return path / "xta-message.yaml" if path.is_dir() else path
60
+
61
+
62
+ def _load_yaml_message_file(file_path: Path) -> dict:
63
+ with file_path.open() as file:
64
+ try:
65
+ message = yaml.safe_load(file)
66
+ except yaml.YAMLError as e:
67
+ raise ClickException(f"yaml message '{file_path}' invalid! {e}")
68
+ if not isinstance(message, dict):
69
+ raise ClickException(
70
+ f"yaml message '{file_path}' must contain key-value pairs!"
71
+ )
72
+ return translate_dict_to_snake_case(message)
xta_tool/file/save.py ADDED
@@ -0,0 +1,95 @@
1
+ # SPDX-FileCopyrightText: 2026 Jan Zickermann <janz@noreply.codeberg.org>
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import base64
6
+ import io
7
+ from pathlib import Path
8
+ from typing import Callable, Iterator
9
+
10
+ from click import ClickException
11
+
12
+ from ..model import BASE64_PREFIX, XtaFile, XtaMessage
13
+ from ..soap import mtom
14
+
15
+
16
+ def save_xta_message(
17
+ xta_message: XtaMessage,
18
+ soap_attachments: Iterator[mtom.ReadablePart],
19
+ max_base64_size: int,
20
+ ) -> XtaMessage:
21
+ output_directory = (
22
+ xta_message.source_path.parent if xta_message.source_path else None
23
+ )
24
+
25
+ def download_content(xta_file: XtaFile, chunks: Iterator[bytes]) -> str:
26
+ return (
27
+ _download_to_output_directory(xta_file, chunks, output_directory)
28
+ if output_directory is not None
29
+ else _download_to_base64_string(xta_file, chunks, max_base64_size)
30
+ )
31
+
32
+ return _with_downloaded_file_content(
33
+ xta_message, soap_attachments, download_content
34
+ )
35
+
36
+
37
+ def _with_downloaded_file_content(
38
+ xta_message: XtaMessage,
39
+ soap_attachments: Iterator[mtom.ReadablePart],
40
+ download_content: Callable[[XtaFile, Iterator[bytes]], str],
41
+ ) -> XtaMessage:
42
+ xta_files = {
43
+ file.content_id: file
44
+ for file in [xta_message.message] + xta_message.attachments
45
+ }
46
+ for soap_attachment in soap_attachments:
47
+ xta_file = xta_files.get(soap_attachment.content_id)
48
+ if xta_file:
49
+ xta_files[soap_attachment.content_id] = xta_file.model_copy(
50
+ update=(
51
+ dict(content=download_content(xta_file, soap_attachment.chunks))
52
+ )
53
+ )
54
+ return xta_message.model_copy(
55
+ update=dict(
56
+ message=xta_files[xta_message.message.content_id],
57
+ attachments=[
58
+ xta_files[xta_file.content_id] for xta_file in xta_message.attachments
59
+ ],
60
+ )
61
+ )
62
+
63
+
64
+ def _download_to_output_directory(
65
+ xta_file: XtaFile, chunks: Iterator[bytes], output_directory: Path
66
+ ) -> str:
67
+ output_path = output_directory / xta_file.safe_file_name
68
+ with open(output_path, "wb") as file:
69
+ for chunk in chunks:
70
+ file.write(chunk)
71
+ return str(output_path.relative_to(output_directory))
72
+
73
+
74
+ def _download_to_base64_string(
75
+ xta_file: XtaFile, chunks: Iterator[bytes], max_size: int
76
+ ) -> str:
77
+ base64_str = io.StringIO()
78
+
79
+ def write_as_base64(chunk):
80
+ base64_str.write(base64.b64encode(chunk).decode())
81
+
82
+ size = 0
83
+ chunk_residual = b""
84
+ for new_chunk in chunks:
85
+ size += len(new_chunk)
86
+ if size > max_size:
87
+ raise ClickException(
88
+ f"Received file '{xta_file.file_name}' is too large! download_base64_size_limit={max_size}"
89
+ )
90
+ chunk = chunk_residual + new_chunk
91
+ base64_encodable_size = 3 * (len(chunk) // 3)
92
+ chunk_residual = chunk[base64_encodable_size:]
93
+ write_as_base64(chunk[:base64_encodable_size])
94
+ write_as_base64(chunk_residual)
95
+ return BASE64_PREFIX + base64_str.getvalue()
@@ -0,0 +1,40 @@
1
+ # SPDX-FileCopyrightText: 2026 Jan Zickermann <janz@noreply.codeberg.org>
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import io
6
+ from pathlib import Path
7
+ from typing import Iterator
8
+
9
+ import jinja2
10
+ from click import ClickException
11
+
12
+ ENV = jinja2.Environment(loader=jinja2.FileSystemLoader("/"))
13
+
14
+ JINJA_FILE_NAME_EXTENSION = ".jinja"
15
+
16
+
17
+ def remove_jinja_extension(content_path: Path) -> Path:
18
+ return content_path.with_name(
19
+ content_path.name.removesuffix(JINJA_FILE_NAME_EXTENSION)
20
+ )
21
+
22
+
23
+ def load_file_content(content_path: Path, context: dict) -> Iterator[bytes]:
24
+ if content_path.name.endswith(JINJA_FILE_NAME_EXTENSION):
25
+ return _render_template_content(content_path, context)
26
+ return _load_direct_file_contents(content_path)
27
+
28
+
29
+ def _render_template_content(template_path: Path, context: dict) -> Iterator[bytes]:
30
+ try:
31
+ for part in ENV.get_template(str(template_path.absolute())).stream(**context):
32
+ yield part.encode()
33
+ except jinja2.TemplateError as e:
34
+ raise ClickException(f"[Template {template_path}] {e}")
35
+
36
+
37
+ def _load_direct_file_contents(path: Path) -> Iterator[bytes]:
38
+ with open(path, "rb") as file:
39
+ while chunk := file.read(io.DEFAULT_BUFFER_SIZE):
40
+ yield chunk
xta_tool/file/zip.py ADDED
@@ -0,0 +1,46 @@
1
+ # SPDX-FileCopyrightText: 2026 Jan Zickermann <janz@noreply.codeberg.org>
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import atexit
6
+ import io
7
+ import zipfile
8
+ from pathlib import Path
9
+ from tempfile import SpooledTemporaryFile
10
+ from typing import Iterator
11
+
12
+ from . import template
13
+
14
+ ZIP_BUFFER_SIZE = 1 << 20 # 1 MiB
15
+
16
+
17
+ def create_zip_content(source_directory: Path, context: dict) -> Iterator[bytes]:
18
+ with SpooledTemporaryFile(max_size=ZIP_BUFFER_SIZE) as file:
19
+ with zipfile.ZipFile(file, "w") as archive:
20
+ _write_zip_content(archive, source_directory, context)
21
+ file.seek(0)
22
+
23
+ def close():
24
+ file.close()
25
+
26
+ atexit.register(close)
27
+ while chunk := file.read(io.DEFAULT_BUFFER_SIZE):
28
+ yield chunk
29
+ atexit.unregister(close)
30
+
31
+
32
+ def _write_zip_content(archive: zipfile.ZipFile, source_directory: Path, context: dict):
33
+ for entry_path in source_directory.glob("**/*"):
34
+ if entry_path.is_file():
35
+ _write_zip_entry(archive, entry_path, source_directory, context)
36
+
37
+
38
+ def _write_zip_entry(
39
+ archive: zipfile.ZipFile, entry_path: Path, source_directory: Path, context: dict
40
+ ):
41
+ entry_name = str(
42
+ template.remove_jinja_extension(entry_path).relative_to(source_directory)
43
+ )
44
+ with archive.open(entry_name, "w") as entry:
45
+ for chunk in template.load_file_content(entry_path, context):
46
+ entry.write(chunk)