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 +31 -0
- xta_tool/client.py +198 -0
- xta_tool/config.py +123 -0
- xta_tool/file/__init__.py +9 -0
- xta_tool/file/load.py +72 -0
- xta_tool/file/save.py +95 -0
- xta_tool/file/template.py +40 -0
- xta_tool/file/zip.py +46 -0
- xta_tool/main.py +118 -0
- xta_tool/model.py +191 -0
- xta_tool/soap/__init__.py +18 -0
- xta_tool/soap/model.py +34 -0
- xta_tool/soap/mtom.py +317 -0
- xta_tool/soap/request.py +42 -0
- xta_tool/soap/response.py +110 -0
- xta_tool/soap/template.py +24 -0
- xta_tool/soap/templates/Author.xml +3 -0
- xta_tool/soap/templates/GenericContentContainer.xml +13 -0
- xta_tool/soap/templates/MessageID.xml +1 -0
- xta_tool/soap/templates/MessageMetaData.xml +27 -0
- xta_tool/soap/templates/MsgBoxCloseRequest.xml +3 -0
- xta_tool/soap/templates/MsgBoxFetchRequest.xml +5 -0
- xta_tool/soap/templates/MsgBoxStatusListRequest.xml +1 -0
- xta_tool/soap/templates/envelope.xml +14 -0
- xta_tool/util.py +65 -0
- xta_tool-0.1.0.dist-info/METADATA +172 -0
- xta_tool-0.1.0.dist-info/RECORD +31 -0
- xta_tool-0.1.0.dist-info/WHEEL +4 -0
- xta_tool-0.1.0.dist-info/entry_points.txt +3 -0
- xta_tool-0.1.0.dist-info/licenses/LICENSES/AGPL-3.0-or-later.txt +235 -0
- xta_tool-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +121 -0
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)
|