stbkit 0.1.0a9.dev1__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.
- stbkit/__main__.py +10 -0
- stbkit/api/__init__.py +48 -0
- stbkit/api/experimental/__init__.py +19 -0
- stbkit/api/experimental/repository/__init__.py +30 -0
- stbkit/api/py.typed +0 -0
- stbkit/api/stb_latest.py +7 -0
- stbkit/api/stb_v2_0_2.py +11 -0
- stbkit/api/stb_v2_1_1.py +9 -0
- stbkit/cli/__init__.py +17 -0
- stbkit/cli/_internal/cli_main.py +114 -0
- stbkit/cli/_internal/common.py +126 -0
- stbkit/cli/_internal/constants_exit_code.py +59 -0
- stbkit/cli/_internal/converter.py +693 -0
- stbkit/cli/_internal/diff.py +213 -0
- stbkit/cli/_internal/entry_points.py +25 -0
- stbkit/cli/_internal/global_parent.py +70 -0
- stbkit/cli/_internal/show_versions.py +36 -0
- stbkit/cli/_internal/validate.py +310 -0
- stbkit/cli/commands/convert.py +16 -0
- stbkit/cli/commands/diff.py +16 -0
- stbkit/cli/commands/validate.py +16 -0
- stbkit/cli/py.typed +0 -0
- stbkit/tools/__init__.py +12 -0
- stbkit/tools/_internal/__init__.py +5 -0
- stbkit/tools/_internal/constants.py +21 -0
- stbkit/tools/_internal/data_model/building_geometry.py +1208 -0
- stbkit/tools/_internal/data_model/building_geometry_web.py +106 -0
- stbkit/tools/_internal/data_model/element_data.py +93 -0
- stbkit/tools/_internal/data_model/ifc_data/entity_data.py +67 -0
- stbkit/tools/_internal/data_model/ifc_data/entity_names.py +32 -0
- stbkit/tools/_internal/data_model/shape_data.py +1089 -0
- stbkit/tools/_internal/diff/__init__.py +5 -0
- stbkit/tools/_internal/diff/common.py +50 -0
- stbkit/tools/_internal/diff/diff_xml.py +796 -0
- stbkit/tools/_internal/matrix.py +51 -0
- stbkit/tools/_internal/optional_dependencies/__init__.py +5 -0
- stbkit/tools/_internal/optional_dependencies/ifc_common.py +28 -0
- stbkit/tools/_internal/optional_dependencies/ifc_exporter/ifc_entity_exporter.py +473 -0
- stbkit/tools/_internal/optional_dependencies/ifc_exporter/ifc_profile_exporter.py +267 -0
- stbkit/tools/_internal/optional_dependencies/ifc_exporter/ifc_representation_exporter.py +178 -0
- stbkit/tools/_internal/optional_dependencies/ifc_pipeline.py +133 -0
- stbkit/tools/_internal/optional_dependencies/ifc_to_building_geometry.py +64 -0
- stbkit/tools/_internal/stb_io.py +116 -0
- stbkit/tools/_internal/stb_repair/_common.py +24 -0
- stbkit/tools/_internal/stb_repair/_stb_repair.py +197 -0
- stbkit/tools/_internal/stb_repair/_stb_v2_0_2_repair.py +53 -0
- stbkit/tools/_internal/stb_repair/_stb_v2_1_0_repair.py +109 -0
- stbkit/tools/_internal/stb_repair/_stb_v2_1_1_repair.py +109 -0
- stbkit/tools/_internal/studio_report.py +49 -0
- stbkit/tools/_internal/utils/atomic_writer.py +57 -0
- stbkit/tools/_internal/utils/float_rounding.py +49 -0
- stbkit/tools/_internal/utils/girder_utils.py +91 -0
- stbkit/tools/_internal/utils/guid_utils.py +30 -0
- stbkit/tools/_internal/utils/mesh_utils.py +1305 -0
- stbkit/tools/_internal/utils/node_utils.py +50 -0
- stbkit/tools/_internal/utils/unit_utils.py +23 -0
- stbkit/tools/_internal/vectors.py +222 -0
- stbkit/tools/converters/__init__.py +39 -0
- stbkit/tools/converters/_internal/__init__.py +5 -0
- stbkit/tools/converters/_internal/building_geometry_to_ply_text.py +32 -0
- stbkit/tools/converters/_internal/coord_converter.py +151 -0
- stbkit/tools/converters/_internal/element_data_to_building_geometry.py +549 -0
- stbkit/tools/converters/_internal/ifc_to_ply_text.py +21 -0
- stbkit/tools/converters/_internal/name_converter.py +23 -0
- stbkit/tools/converters/_internal/pipeline/__init__.py +5 -0
- stbkit/tools/converters/_internal/pipeline/data.py +188 -0
- stbkit/tools/converters/_internal/pipeline/execution.py +569 -0
- stbkit/tools/converters/_internal/pipeline/ply_pipeline.py +52 -0
- stbkit/tools/converters/_internal/pipeline/stb_pipeline.py +226 -0
- stbkit/tools/converters/_internal/pipeline/stb_pipeline_latest.py +39 -0
- stbkit/tools/converters/_internal/pipeline/step.py +139 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/__init__.py +17 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/_stb_latest_to_element_data.py +186 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/member/member_footing.py +49 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/member/member_line.py +533 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/member/member_pile.py +100 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/member/member_plane.py +103 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/open.py +70 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec.py +214 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_beam_rc.py +67 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_beam_s.py +97 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_beam_src.py +28 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_brace_s.py +49 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_column_cft.py +217 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_column_rc.py +41 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_column_s.py +127 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_column_src.py +173 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_foundation.py +225 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_parapet_rc.py +53 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_pile_rc.py +106 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_pile_s.py +100 -0
- stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_steel.py +432 -0
- stbkit/tools/converters/_internal/stb_to_building_geometry.py +44 -0
- stbkit/tools/converters/_internal/stb_to_ifc_data/__init__.py +5 -0
- stbkit/tools/converters/_internal/stb_to_ifc_data/_stb_to_ifc_data.py +59 -0
- stbkit/tools/converters/_internal/stb_to_ifc_data/convert_option.py +22 -0
- stbkit/tools/converters/_internal/stb_to_ply_text.py +22 -0
- stbkit/tools/py.typed +0 -0
- stbkit/tools/upgrade/__init__.py +13 -0
- stbkit/tools/upgrade/_internal/__init__.py +5 -0
- stbkit/tools/upgrade/_internal/common.py +205 -0
- stbkit/tools/upgrade/_internal/upgrade.py +228 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/__init__.py +60 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_common.py +62 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_joint_beam_shape_h.py +44 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_sec_pile_rc.py +101 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_sec_pile_s.py +78 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_sec_slab_deck.py +56 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_sec_slab_rc.py +128 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/post_girder.py +126 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/post_open.py +110 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/post_sec_beam_rc.py +227 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/post_sec_beam_s.py +1153 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hooks.py +22 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/post.py +42 -0
- stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/tables.py +99 -0
- stbkit/tools/upgrade/_internal/v2_1_0_to_v2_1_1/__init__.py +58 -0
- stbkit/tools/upgrade/_internal/v2_1_0_to_v2_1_1/hooks.py +32 -0
- stbkit/tools/upgrade/_internal/v2_1_0_to_v2_1_1/tables.py +56 -0
- stbkit-0.1.0a9.dev1.dist-info/METADATA +196 -0
- stbkit-0.1.0a9.dev1.dist-info/RECORD +125 -0
- stbkit-0.1.0a9.dev1.dist-info/WHEEL +5 -0
- stbkit-0.1.0a9.dev1.dist-info/entry_points.txt +2 -0
- stbkit-0.1.0a9.dev1.dist-info/licenses/LICENSE +373 -0
- stbkit-0.1.0a9.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
# Copyright 2026 TAISEI CORPORATION
|
|
2
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
4
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from decimal import Decimal, InvalidOperation
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from stbkit.core.data_model._internal.filter_option import FilterOption, NameFilter
|
|
18
|
+
from stbkit.core.stb_io._internal.stream_reader import check_no_doctype
|
|
19
|
+
|
|
20
|
+
from .common import CompareOption, CompareResult, DiffItem
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(kw_only=True)
|
|
24
|
+
class XmlCompareOption(CompareOption):
|
|
25
|
+
"""
|
|
26
|
+
XML比較オプション
|
|
27
|
+
|
|
28
|
+
Attribute:
|
|
29
|
+
ignore_order_children(bool): 同一タグの子要素の順序を無視するか
|
|
30
|
+
compare_tag_order(bool): 異なるタグを含む子要素のタグの並び順を差分として検出するか
|
|
31
|
+
ignore_empty_attributes(bool): 値が空の属性を無視するか
|
|
32
|
+
ignore_empty_elements(bool): 属性も子要素も内容もない要素を無視するか
|
|
33
|
+
numeric_equivalent_attributes(bool): 数値として等価な属性値を同一とみなすか
|
|
34
|
+
(例: True の場合、"1" と "1.0" を同一とみなす。Falseの場合は文字列として異なるため、差分となる)
|
|
35
|
+
filter_option(FilterOption): 比較対象の要素や属性を絞り込むためのフィルターオプション
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
ignore_order_children: bool = True
|
|
39
|
+
compare_tag_order: bool = False
|
|
40
|
+
ignore_empty_attributes: bool = False
|
|
41
|
+
ignore_empty_elements: bool = False
|
|
42
|
+
ignore_name_space: bool = True
|
|
43
|
+
numeric_equivalent_attributes: bool = True
|
|
44
|
+
filter_option: FilterOption = field(default_factory=FilterOption)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, kw_only=True)
|
|
48
|
+
class XmlCompareResult(CompareResult):
|
|
49
|
+
def to_reason(self) -> str:
|
|
50
|
+
if self.equal:
|
|
51
|
+
return "XMLは等価です"
|
|
52
|
+
return "\n".join(
|
|
53
|
+
f"{diff.path}: {diff.display_message}\n "
|
|
54
|
+
f"{_Side.LEFT.print()}: {diff.left}\n {_Side.RIGHT.print()}: {diff.right}"
|
|
55
|
+
for diff in self.diffs
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def combine_attribute_diffs(self) -> XmlCompareResult:
|
|
59
|
+
"""同じ要素の複数属性の差分を、1件へ集約する"""
|
|
60
|
+
|
|
61
|
+
combined_diffs: list[DiffItem] = _combine_attribute_diffs(self.diffs)
|
|
62
|
+
return XmlCompareResult(equal=self.equal, diffs=combined_diffs)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _Side(StrEnum):
|
|
66
|
+
LEFT = "left"
|
|
67
|
+
RIGHT = "right"
|
|
68
|
+
|
|
69
|
+
def print(self) -> str:
|
|
70
|
+
match self:
|
|
71
|
+
case _Side.LEFT:
|
|
72
|
+
return "左"
|
|
73
|
+
case _Side.RIGHT:
|
|
74
|
+
return "右"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _try_parse_decimal(value: str) -> Decimal | None:
|
|
78
|
+
try:
|
|
79
|
+
return Decimal(value)
|
|
80
|
+
except (InvalidOperation, ValueError):
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _delete_namespace(tag: str) -> str:
|
|
85
|
+
return tag.split("}")[-1] if "}" in tag else tag
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _name_for_compare(
|
|
89
|
+
name: str,
|
|
90
|
+
option: XmlCompareOption,
|
|
91
|
+
) -> str:
|
|
92
|
+
if option.ignore_name_space:
|
|
93
|
+
return _delete_namespace(name)
|
|
94
|
+
return name
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _normalize_content(text: str | None) -> str:
|
|
98
|
+
if text is None:
|
|
99
|
+
return ""
|
|
100
|
+
return " ".join(text.strip().split())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _apply_filter(name: str, name_filter: NameFilter | None) -> bool:
|
|
104
|
+
if name_filter is None:
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
# exclude 優先
|
|
108
|
+
if name in name_filter.exclude:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
if name_filter.include:
|
|
112
|
+
return name in name_filter.include
|
|
113
|
+
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_empty_element(
|
|
118
|
+
element: ET.Element,
|
|
119
|
+
compare_option: XmlCompareOption,
|
|
120
|
+
) -> bool:
|
|
121
|
+
if _normalize_content(element.text) != "":
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
for value in element.attrib.values():
|
|
125
|
+
if not compare_option.ignore_empty_attributes or value.strip() != "":
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
for child in element:
|
|
129
|
+
if not _is_empty_element(child, compare_option):
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_child_target(
|
|
136
|
+
parent_tag: str,
|
|
137
|
+
child: ET.Element,
|
|
138
|
+
compare_option: XmlCompareOption,
|
|
139
|
+
) -> bool:
|
|
140
|
+
filter_option: FilterOption = compare_option.filter_option
|
|
141
|
+
|
|
142
|
+
child_tag_name: str = (
|
|
143
|
+
_delete_namespace(child.tag) if compare_option.ignore_name_space else child.tag
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if not _apply_filter(
|
|
147
|
+
child_tag_name,
|
|
148
|
+
filter_option.global_element_filter,
|
|
149
|
+
):
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
parent_tag_name: str = (
|
|
153
|
+
_delete_namespace(parent_tag)
|
|
154
|
+
if compare_option.ignore_name_space
|
|
155
|
+
else parent_tag
|
|
156
|
+
)
|
|
157
|
+
child_filter: NameFilter | None = filter_option.element_child_filters.get(
|
|
158
|
+
parent_tag_name
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if not _apply_filter(child_tag_name, child_filter):
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
return not (
|
|
165
|
+
compare_option.ignore_empty_elements
|
|
166
|
+
and _is_empty_element(child, compare_option)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _filtered_children(
|
|
171
|
+
element: ET.Element,
|
|
172
|
+
compare_option: XmlCompareOption,
|
|
173
|
+
) -> list[ET.Element]:
|
|
174
|
+
return [
|
|
175
|
+
child
|
|
176
|
+
for child in element
|
|
177
|
+
if _is_child_target(element.tag, child, compare_option)
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _filtered_attributes(
|
|
182
|
+
element: ET.Element,
|
|
183
|
+
compare_option: XmlCompareOption,
|
|
184
|
+
) -> dict[str, list[str]]:
|
|
185
|
+
filter_option: FilterOption = compare_option.filter_option
|
|
186
|
+
tag_name: str = _name_for_compare(element.tag, compare_option)
|
|
187
|
+
|
|
188
|
+
per_element_filter: NameFilter | None = filter_option.element_attribute_filters.get(
|
|
189
|
+
tag_name
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
result: dict[str, list[str]] = defaultdict(list)
|
|
193
|
+
|
|
194
|
+
for raw_name, value in element.attrib.items():
|
|
195
|
+
attr_name: str = _name_for_compare(raw_name, compare_option)
|
|
196
|
+
|
|
197
|
+
if compare_option.ignore_empty_attributes and value == "":
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if not _apply_filter(
|
|
201
|
+
attr_name,
|
|
202
|
+
filter_option.global_attribute_filter,
|
|
203
|
+
):
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if not _apply_filter(attr_name, per_element_filter):
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
result[attr_name].append(value)
|
|
210
|
+
|
|
211
|
+
return dict(result)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _matching_key(
|
|
215
|
+
element: ET.Element,
|
|
216
|
+
compare_option: XmlCompareOption,
|
|
217
|
+
) -> tuple[str, str] | None:
|
|
218
|
+
"""
|
|
219
|
+
順序無視時に比較対象を探すキー。
|
|
220
|
+
|
|
221
|
+
比較対象の属性からguid、idの順に選ぶ。
|
|
222
|
+
"""
|
|
223
|
+
global_attribute_filter: NameFilter | None = (
|
|
224
|
+
compare_option.filter_option.global_attribute_filter
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
for key_name in ("guid", "id"):
|
|
228
|
+
if not _apply_filter(key_name, global_attribute_filter):
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
values: list[str] = [
|
|
232
|
+
value.strip()
|
|
233
|
+
for attr_name, value in element.attrib.items()
|
|
234
|
+
if _name_for_compare(attr_name, compare_option) == key_name
|
|
235
|
+
and value.strip() != ""
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
# 同じキーが複数ある場合は曖昧なので使わない
|
|
239
|
+
if len(values) == 1:
|
|
240
|
+
return key_name, values[0]
|
|
241
|
+
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _group_by_tag(
|
|
246
|
+
children: list[ET.Element],
|
|
247
|
+
compare_option: XmlCompareOption,
|
|
248
|
+
) -> dict[str, list[ET.Element]]:
|
|
249
|
+
grouped: dict[str, list[ET.Element]] = defaultdict(list)
|
|
250
|
+
|
|
251
|
+
for child in children:
|
|
252
|
+
tag: str = _name_for_compare(child.tag, compare_option)
|
|
253
|
+
grouped[tag].append(child)
|
|
254
|
+
|
|
255
|
+
return dict(grouped)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _attribute_values_equal(
|
|
259
|
+
left_value: str,
|
|
260
|
+
right_value: str,
|
|
261
|
+
compare_option: XmlCompareOption,
|
|
262
|
+
) -> bool:
|
|
263
|
+
if left_value == right_value:
|
|
264
|
+
return True
|
|
265
|
+
|
|
266
|
+
if not compare_option.numeric_equivalent_attributes:
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
left_number: Decimal | None = _try_parse_decimal(left_value)
|
|
270
|
+
right_number: Decimal | None = _try_parse_decimal(right_value)
|
|
271
|
+
|
|
272
|
+
if left_number is not None and right_number is not None:
|
|
273
|
+
return left_number == right_number
|
|
274
|
+
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _attribute_value_groups_equal(
|
|
279
|
+
left_values: list[str],
|
|
280
|
+
right_values: list[str],
|
|
281
|
+
compare_option: XmlCompareOption,
|
|
282
|
+
) -> bool:
|
|
283
|
+
if len(left_values) != len(right_values):
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
unmatched: list[str] = list(right_values)
|
|
287
|
+
|
|
288
|
+
for left_value in left_values:
|
|
289
|
+
for index, right_value in enumerate(unmatched):
|
|
290
|
+
if _attribute_values_equal(
|
|
291
|
+
left_value,
|
|
292
|
+
right_value,
|
|
293
|
+
compare_option,
|
|
294
|
+
):
|
|
295
|
+
unmatched.pop(index)
|
|
296
|
+
break
|
|
297
|
+
else:
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
return True
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _build_xpath(
|
|
304
|
+
root_element: ET.Element,
|
|
305
|
+
compare_option: XmlCompareOption,
|
|
306
|
+
) -> dict[ET.Element, str]:
|
|
307
|
+
paths: dict[ET.Element, str] = {
|
|
308
|
+
root_element: f"/{_name_for_compare(root_element.tag, compare_option)}"
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
def _step(parent: ET.Element) -> None:
|
|
312
|
+
counts: dict[str, int] = defaultdict(int)
|
|
313
|
+
parent_path = paths[parent]
|
|
314
|
+
|
|
315
|
+
for child in parent:
|
|
316
|
+
tag_name = _name_for_compare(child.tag, compare_option)
|
|
317
|
+
counts[tag_name] += 1
|
|
318
|
+
paths[child] = f"{parent_path}/{tag_name}[{counts[tag_name]}]"
|
|
319
|
+
_step(child)
|
|
320
|
+
|
|
321
|
+
_step(root_element)
|
|
322
|
+
return paths
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass(frozen=True, kw_only=True)
|
|
326
|
+
class _CompareContext:
|
|
327
|
+
option: XmlCompareOption
|
|
328
|
+
left_paths: dict[ET.Element, str]
|
|
329
|
+
right_paths: dict[ET.Element, str]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _clean_linebreaks(element: ET.Element) -> str:
|
|
333
|
+
xml: str = ET.tostring(element, encoding="unicode")
|
|
334
|
+
return re.sub(r">\s+<", "><", xml).strip()
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _compare_attributes(
|
|
338
|
+
left: ET.Element,
|
|
339
|
+
right: ET.Element,
|
|
340
|
+
path: str,
|
|
341
|
+
compare_option: XmlCompareOption,
|
|
342
|
+
diffs: list[DiffItem],
|
|
343
|
+
*,
|
|
344
|
+
match_reason: str | None,
|
|
345
|
+
) -> None:
|
|
346
|
+
left_attrs: dict[str, list[str]] = _filtered_attributes(left, compare_option)
|
|
347
|
+
right_attrs: dict[str, list[str]] = _filtered_attributes(right, compare_option)
|
|
348
|
+
|
|
349
|
+
for attr_name in sorted(set(left_attrs) | set(right_attrs)):
|
|
350
|
+
if attr_name not in left_attrs:
|
|
351
|
+
diffs.append(
|
|
352
|
+
DiffItem(
|
|
353
|
+
path=path,
|
|
354
|
+
message=(f"属性[{attr_name}]が{_Side.LEFT.print()}に存在しません"),
|
|
355
|
+
left=None,
|
|
356
|
+
right=right_attrs[attr_name],
|
|
357
|
+
attribute_name=attr_name,
|
|
358
|
+
match_reason=match_reason,
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
elif attr_name not in right_attrs:
|
|
362
|
+
diffs.append(
|
|
363
|
+
DiffItem(
|
|
364
|
+
path=path,
|
|
365
|
+
message=(f"属性[{attr_name}]が{_Side.RIGHT.print()}に存在しません"),
|
|
366
|
+
left=left_attrs[attr_name],
|
|
367
|
+
right=None,
|
|
368
|
+
attribute_name=attr_name,
|
|
369
|
+
match_reason=match_reason,
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
elif not _attribute_value_groups_equal(
|
|
373
|
+
left_attrs[attr_name], right_attrs[attr_name], compare_option
|
|
374
|
+
):
|
|
375
|
+
diffs.append(
|
|
376
|
+
DiffItem(
|
|
377
|
+
path=path,
|
|
378
|
+
message=f"属性[{attr_name}]の値が異なります",
|
|
379
|
+
left=left_attrs[attr_name],
|
|
380
|
+
right=right_attrs[attr_name],
|
|
381
|
+
attribute_name=attr_name,
|
|
382
|
+
match_reason=match_reason,
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _compare_content(
|
|
388
|
+
left: ET.Element,
|
|
389
|
+
right: ET.Element,
|
|
390
|
+
path: str,
|
|
391
|
+
diffs: list[DiffItem],
|
|
392
|
+
*,
|
|
393
|
+
match_reason: str | None,
|
|
394
|
+
) -> None:
|
|
395
|
+
left_text: str = _normalize_content(left.text)
|
|
396
|
+
right_text: str = _normalize_content(right.text)
|
|
397
|
+
|
|
398
|
+
if left_text != right_text:
|
|
399
|
+
diffs.append(
|
|
400
|
+
DiffItem(
|
|
401
|
+
path=path,
|
|
402
|
+
message="内容が異なります",
|
|
403
|
+
left=left_text,
|
|
404
|
+
right=right_text,
|
|
405
|
+
match_reason=match_reason,
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _append_missing_elements(
|
|
411
|
+
*,
|
|
412
|
+
missing_side: _Side,
|
|
413
|
+
elements: list[ET.Element],
|
|
414
|
+
context: _CompareContext,
|
|
415
|
+
diffs: list[DiffItem],
|
|
416
|
+
match_reason: str | None,
|
|
417
|
+
) -> None:
|
|
418
|
+
for elem in elements:
|
|
419
|
+
if missing_side == _Side.LEFT:
|
|
420
|
+
path: str = context.right_paths[elem]
|
|
421
|
+
left: str | None = None
|
|
422
|
+
right: str | None = _clean_linebreaks(elem)
|
|
423
|
+
else:
|
|
424
|
+
path = context.left_paths[elem]
|
|
425
|
+
left = _clean_linebreaks(elem)
|
|
426
|
+
right = None
|
|
427
|
+
|
|
428
|
+
diffs.append(
|
|
429
|
+
DiffItem(
|
|
430
|
+
path=path,
|
|
431
|
+
message=f"{missing_side.print()}に対応要素が存在しません",
|
|
432
|
+
left=left,
|
|
433
|
+
right=right,
|
|
434
|
+
match_reason=match_reason,
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _append_tag_order_diff(
|
|
440
|
+
left: ET.Element,
|
|
441
|
+
left_children: list[ET.Element],
|
|
442
|
+
right_children: list[ET.Element],
|
|
443
|
+
context: _CompareContext,
|
|
444
|
+
diffs: list[DiffItem],
|
|
445
|
+
*,
|
|
446
|
+
match_reason: str | None,
|
|
447
|
+
) -> None:
|
|
448
|
+
option: XmlCompareOption = context.option
|
|
449
|
+
left_tags: list[str] = [
|
|
450
|
+
_name_for_compare(child.tag, option) for child in left_children
|
|
451
|
+
]
|
|
452
|
+
right_tags: list[str] = [
|
|
453
|
+
_name_for_compare(child.tag, option) for child in right_children
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
# 要素の追加・削除は別の差分で示すため、タグ数が同じ場合だけ差分とする。
|
|
457
|
+
if left_tags == right_tags or sorted(left_tags) != sorted(right_tags):
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
diffs.append(
|
|
461
|
+
DiffItem(
|
|
462
|
+
path=context.left_paths[left],
|
|
463
|
+
message="子要素のタグの並び順が異なります",
|
|
464
|
+
left=", ".join(left_tags),
|
|
465
|
+
right=", ".join(right_tags),
|
|
466
|
+
match_reason=match_reason,
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _compare_same_tag_ordered(
|
|
472
|
+
left_list: list[ET.Element],
|
|
473
|
+
right_list: list[ET.Element],
|
|
474
|
+
context: _CompareContext,
|
|
475
|
+
diffs: list[DiffItem],
|
|
476
|
+
*,
|
|
477
|
+
match_reason: str | None,
|
|
478
|
+
) -> None:
|
|
479
|
+
common_count: int = min(len(left_list), len(right_list))
|
|
480
|
+
|
|
481
|
+
for index in range(common_count):
|
|
482
|
+
_compare_element(
|
|
483
|
+
left_list[index],
|
|
484
|
+
right_list[index],
|
|
485
|
+
context,
|
|
486
|
+
diffs,
|
|
487
|
+
match_reason=match_reason,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
_append_missing_elements(
|
|
491
|
+
missing_side=_Side.RIGHT,
|
|
492
|
+
elements=left_list[common_count:],
|
|
493
|
+
context=context,
|
|
494
|
+
diffs=diffs,
|
|
495
|
+
match_reason=match_reason,
|
|
496
|
+
)
|
|
497
|
+
_append_missing_elements(
|
|
498
|
+
missing_side=_Side.LEFT,
|
|
499
|
+
elements=right_list[common_count:],
|
|
500
|
+
context=context,
|
|
501
|
+
diffs=diffs,
|
|
502
|
+
match_reason=match_reason,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _group_same_tag_by_match_key(
|
|
507
|
+
children: list[ET.Element],
|
|
508
|
+
compare_option: XmlCompareOption,
|
|
509
|
+
) -> tuple[dict[tuple[str, str], list[ET.Element]], list[ET.Element]]:
|
|
510
|
+
keyed: dict[tuple[str, str], list[ET.Element]] = defaultdict(list)
|
|
511
|
+
unkeyed: list[ET.Element] = []
|
|
512
|
+
|
|
513
|
+
for child in children:
|
|
514
|
+
key: tuple[str, str] | None = _matching_key(child, compare_option)
|
|
515
|
+
if key is None:
|
|
516
|
+
unkeyed.append(child)
|
|
517
|
+
else:
|
|
518
|
+
keyed[key].append(child)
|
|
519
|
+
|
|
520
|
+
return dict(keyed), unkeyed
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _match_reason_for_pair(
|
|
524
|
+
*,
|
|
525
|
+
key_name: str,
|
|
526
|
+
left: ET.Element,
|
|
527
|
+
right: ET.Element,
|
|
528
|
+
context: _CompareContext,
|
|
529
|
+
inherited_reason: str | None,
|
|
530
|
+
) -> str | None:
|
|
531
|
+
if context.left_paths[left] == context.right_paths[right]:
|
|
532
|
+
return inherited_reason
|
|
533
|
+
return f"{key_name}一致で対応付け"
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _compare_same_tag_unordered(
|
|
537
|
+
left_list: list[ET.Element],
|
|
538
|
+
right_list: list[ET.Element],
|
|
539
|
+
context: _CompareContext,
|
|
540
|
+
diffs: list[DiffItem],
|
|
541
|
+
*,
|
|
542
|
+
match_reason: str | None,
|
|
543
|
+
) -> None:
|
|
544
|
+
option: XmlCompareOption = context.option
|
|
545
|
+
left_keyed, left_unkeyed = _group_same_tag_by_match_key(left_list, option)
|
|
546
|
+
right_keyed, right_unkeyed = _group_same_tag_by_match_key(right_list, option)
|
|
547
|
+
|
|
548
|
+
# guid,idを持つ要素はキーで対応付ける
|
|
549
|
+
for key_name, key_value in sorted(set(left_keyed) | set(right_keyed)):
|
|
550
|
+
left_group: list[ET.Element[str]] = left_keyed.get((key_name, key_value), [])
|
|
551
|
+
right_group: list[ET.Element[str]] = right_keyed.get((key_name, key_value), [])
|
|
552
|
+
common_count: int = min(len(left_group), len(right_group))
|
|
553
|
+
|
|
554
|
+
for index in range(common_count):
|
|
555
|
+
left_elem: ET.Element[str] = left_group[index]
|
|
556
|
+
right_elem: ET.Element[str] = right_group[index]
|
|
557
|
+
pair_reason: str | None = _match_reason_for_pair(
|
|
558
|
+
key_name=key_name,
|
|
559
|
+
left=left_elem,
|
|
560
|
+
right=right_elem,
|
|
561
|
+
context=context,
|
|
562
|
+
inherited_reason=match_reason,
|
|
563
|
+
)
|
|
564
|
+
_compare_element(
|
|
565
|
+
left_elem,
|
|
566
|
+
right_elem,
|
|
567
|
+
context,
|
|
568
|
+
diffs,
|
|
569
|
+
match_reason=pair_reason,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
_append_missing_elements(
|
|
573
|
+
missing_side=_Side.RIGHT,
|
|
574
|
+
elements=left_group[common_count:],
|
|
575
|
+
context=context,
|
|
576
|
+
diffs=diffs,
|
|
577
|
+
match_reason=match_reason,
|
|
578
|
+
)
|
|
579
|
+
_append_missing_elements(
|
|
580
|
+
missing_side=_Side.LEFT,
|
|
581
|
+
elements=right_group[common_count:],
|
|
582
|
+
context=context,
|
|
583
|
+
diffs=diffs,
|
|
584
|
+
match_reason=match_reason,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
# キーを持たない要素は、出現順で比較する。
|
|
588
|
+
_compare_same_tag_ordered(
|
|
589
|
+
left_unkeyed,
|
|
590
|
+
right_unkeyed,
|
|
591
|
+
context,
|
|
592
|
+
diffs,
|
|
593
|
+
match_reason=match_reason,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _compare_children(
|
|
598
|
+
left: ET.Element,
|
|
599
|
+
right: ET.Element,
|
|
600
|
+
context: _CompareContext,
|
|
601
|
+
diffs: list[DiffItem],
|
|
602
|
+
*,
|
|
603
|
+
match_reason: str | None,
|
|
604
|
+
) -> None:
|
|
605
|
+
compare_option: XmlCompareOption = context.option
|
|
606
|
+
left_children: list[ET.Element[str]] = _filtered_children(left, compare_option)
|
|
607
|
+
right_children: list[ET.Element[str]] = _filtered_children(right, compare_option)
|
|
608
|
+
|
|
609
|
+
if compare_option.compare_tag_order:
|
|
610
|
+
_append_tag_order_diff(
|
|
611
|
+
left,
|
|
612
|
+
left_children,
|
|
613
|
+
right_children,
|
|
614
|
+
context,
|
|
615
|
+
diffs,
|
|
616
|
+
match_reason=match_reason,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
left_grouped: dict[str, list[ET.Element[str]]] = _group_by_tag(
|
|
620
|
+
left_children, compare_option
|
|
621
|
+
)
|
|
622
|
+
right_grouped: dict[str, list[ET.Element[str]]] = _group_by_tag(
|
|
623
|
+
right_children, compare_option
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
for tag in sorted(set(left_grouped) | set(right_grouped)):
|
|
627
|
+
left_list: list[ET.Element[str]] = left_grouped.get(tag, [])
|
|
628
|
+
right_list: list[ET.Element[str]] = right_grouped.get(tag, [])
|
|
629
|
+
|
|
630
|
+
if compare_option.ignore_order_children:
|
|
631
|
+
_compare_same_tag_unordered(
|
|
632
|
+
left_list,
|
|
633
|
+
right_list,
|
|
634
|
+
context,
|
|
635
|
+
diffs,
|
|
636
|
+
match_reason=match_reason,
|
|
637
|
+
)
|
|
638
|
+
else:
|
|
639
|
+
_compare_same_tag_ordered(
|
|
640
|
+
left_list,
|
|
641
|
+
right_list,
|
|
642
|
+
context,
|
|
643
|
+
diffs,
|
|
644
|
+
match_reason=match_reason,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _compare_element(
|
|
649
|
+
left: ET.Element,
|
|
650
|
+
right: ET.Element,
|
|
651
|
+
context: _CompareContext,
|
|
652
|
+
diffs: list[DiffItem],
|
|
653
|
+
*,
|
|
654
|
+
match_reason: str | None = None,
|
|
655
|
+
) -> None:
|
|
656
|
+
compare_option: XmlCompareOption = context.option
|
|
657
|
+
path: str = context.left_paths[left]
|
|
658
|
+
left_tag: str = _name_for_compare(left.tag, compare_option)
|
|
659
|
+
right_tag: str = _name_for_compare(right.tag, compare_option)
|
|
660
|
+
|
|
661
|
+
if left_tag != right_tag:
|
|
662
|
+
diffs.append(
|
|
663
|
+
DiffItem(
|
|
664
|
+
path=path,
|
|
665
|
+
message="タグ名が異なります",
|
|
666
|
+
left=left_tag,
|
|
667
|
+
right=right_tag,
|
|
668
|
+
match_reason=match_reason,
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
_compare_attributes(
|
|
673
|
+
left,
|
|
674
|
+
right,
|
|
675
|
+
path,
|
|
676
|
+
compare_option,
|
|
677
|
+
diffs,
|
|
678
|
+
match_reason=match_reason,
|
|
679
|
+
)
|
|
680
|
+
_compare_content(
|
|
681
|
+
left,
|
|
682
|
+
right,
|
|
683
|
+
path,
|
|
684
|
+
diffs,
|
|
685
|
+
match_reason=match_reason,
|
|
686
|
+
)
|
|
687
|
+
_compare_children(
|
|
688
|
+
left,
|
|
689
|
+
right,
|
|
690
|
+
context,
|
|
691
|
+
diffs,
|
|
692
|
+
match_reason=match_reason,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _format_attribute_value(value: Any) -> str:
|
|
697
|
+
if value is None:
|
|
698
|
+
return "(なし)"
|
|
699
|
+
if isinstance(value, list):
|
|
700
|
+
values: list[str] = [" ".join(str(item).split()) for item in value]
|
|
701
|
+
if len(values) == 1:
|
|
702
|
+
return values[0]
|
|
703
|
+
return f"[{', '.join(values)}]"
|
|
704
|
+
return " ".join(str(value).split())
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _format_combined_attribute_values(
|
|
708
|
+
diffs: list[DiffItem],
|
|
709
|
+
*,
|
|
710
|
+
use_left: bool,
|
|
711
|
+
) -> str:
|
|
712
|
+
values: list[str] = []
|
|
713
|
+
|
|
714
|
+
for diff in diffs:
|
|
715
|
+
if diff.attribute_name is None:
|
|
716
|
+
continue
|
|
717
|
+
value = diff.left if use_left else diff.right
|
|
718
|
+
values.append(f"{diff.attribute_name}={_format_attribute_value(value)}")
|
|
719
|
+
|
|
720
|
+
return ", ".join(values)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _make_combined_attribute_diff(diffs: list[DiffItem]) -> DiffItem:
|
|
724
|
+
first: DiffItem = diffs[0]
|
|
725
|
+
attribute_names: list[str] = [
|
|
726
|
+
f"'{diff.attribute_name}'" for diff in diffs if diff.attribute_name is not None
|
|
727
|
+
]
|
|
728
|
+
return DiffItem(
|
|
729
|
+
path=first.path,
|
|
730
|
+
message=f"属性に差異があります: {', '.join(attribute_names)}",
|
|
731
|
+
left=_format_combined_attribute_values(diffs, use_left=True),
|
|
732
|
+
right=_format_combined_attribute_values(diffs, use_left=False),
|
|
733
|
+
match_reason=first.match_reason,
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _combine_attribute_diffs(diffs: list[DiffItem]) -> list[DiffItem]:
|
|
738
|
+
"""比較済みの差分から、同じ要素の複数属性の結果を集約する"""
|
|
739
|
+
|
|
740
|
+
group_map: dict[tuple[str, str | None], list[DiffItem]] = defaultdict(list)
|
|
741
|
+
for diff in diffs:
|
|
742
|
+
if diff.attribute_name is not None:
|
|
743
|
+
group_map[(diff.path, diff.match_reason)].append(diff)
|
|
744
|
+
|
|
745
|
+
combined_keys: set[tuple[str, str | None]] = {
|
|
746
|
+
key for key, group in group_map.items() if len(group) >= 2
|
|
747
|
+
}
|
|
748
|
+
emitted_keys: set[tuple[str, str | None]] = set()
|
|
749
|
+
result: list[DiffItem] = []
|
|
750
|
+
|
|
751
|
+
for diff in diffs:
|
|
752
|
+
if diff.attribute_name is None:
|
|
753
|
+
result.append(diff)
|
|
754
|
+
continue
|
|
755
|
+
|
|
756
|
+
key: tuple[str, str | None] = (diff.path, diff.match_reason)
|
|
757
|
+
if key not in combined_keys:
|
|
758
|
+
result.append(diff)
|
|
759
|
+
continue
|
|
760
|
+
if key in emitted_keys:
|
|
761
|
+
continue
|
|
762
|
+
|
|
763
|
+
result.append(_make_combined_attribute_diff(group_map[key]))
|
|
764
|
+
emitted_keys.add(key)
|
|
765
|
+
|
|
766
|
+
return result
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def compare_xml_str(
|
|
770
|
+
left_xml: str,
|
|
771
|
+
right_xml: str,
|
|
772
|
+
compare_option: XmlCompareOption | None = None,
|
|
773
|
+
) -> XmlCompareResult:
|
|
774
|
+
"""2つのXML文字列を比較します。
|
|
775
|
+
|
|
776
|
+
Raises:
|
|
777
|
+
UnsafeXmlError: いずれかにDOCTYPE宣言が含まれる場合。
|
|
778
|
+
xml.etree.ElementTree.ParseError: XMLとして解析できない場合。
|
|
779
|
+
"""
|
|
780
|
+
compare_option = compare_option or XmlCompareOption()
|
|
781
|
+
|
|
782
|
+
# 危険なXMLのチェック
|
|
783
|
+
check_no_doctype(left_xml)
|
|
784
|
+
check_no_doctype(right_xml)
|
|
785
|
+
|
|
786
|
+
left_root: ET.Element = ET.fromstring(left_xml)
|
|
787
|
+
right_root: ET.Element = ET.fromstring(right_xml)
|
|
788
|
+
context: _CompareContext = _CompareContext(
|
|
789
|
+
option=compare_option,
|
|
790
|
+
left_paths=_build_xpath(left_root, compare_option),
|
|
791
|
+
right_paths=_build_xpath(right_root, compare_option),
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
diffs: list[DiffItem] = []
|
|
795
|
+
_compare_element(left_root, right_root, context, diffs)
|
|
796
|
+
return XmlCompareResult(equal=(len(diffs) == 0), diffs=diffs)
|