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.
Files changed (125) hide show
  1. stbkit/__main__.py +10 -0
  2. stbkit/api/__init__.py +48 -0
  3. stbkit/api/experimental/__init__.py +19 -0
  4. stbkit/api/experimental/repository/__init__.py +30 -0
  5. stbkit/api/py.typed +0 -0
  6. stbkit/api/stb_latest.py +7 -0
  7. stbkit/api/stb_v2_0_2.py +11 -0
  8. stbkit/api/stb_v2_1_1.py +9 -0
  9. stbkit/cli/__init__.py +17 -0
  10. stbkit/cli/_internal/cli_main.py +114 -0
  11. stbkit/cli/_internal/common.py +126 -0
  12. stbkit/cli/_internal/constants_exit_code.py +59 -0
  13. stbkit/cli/_internal/converter.py +693 -0
  14. stbkit/cli/_internal/diff.py +213 -0
  15. stbkit/cli/_internal/entry_points.py +25 -0
  16. stbkit/cli/_internal/global_parent.py +70 -0
  17. stbkit/cli/_internal/show_versions.py +36 -0
  18. stbkit/cli/_internal/validate.py +310 -0
  19. stbkit/cli/commands/convert.py +16 -0
  20. stbkit/cli/commands/diff.py +16 -0
  21. stbkit/cli/commands/validate.py +16 -0
  22. stbkit/cli/py.typed +0 -0
  23. stbkit/tools/__init__.py +12 -0
  24. stbkit/tools/_internal/__init__.py +5 -0
  25. stbkit/tools/_internal/constants.py +21 -0
  26. stbkit/tools/_internal/data_model/building_geometry.py +1208 -0
  27. stbkit/tools/_internal/data_model/building_geometry_web.py +106 -0
  28. stbkit/tools/_internal/data_model/element_data.py +93 -0
  29. stbkit/tools/_internal/data_model/ifc_data/entity_data.py +67 -0
  30. stbkit/tools/_internal/data_model/ifc_data/entity_names.py +32 -0
  31. stbkit/tools/_internal/data_model/shape_data.py +1089 -0
  32. stbkit/tools/_internal/diff/__init__.py +5 -0
  33. stbkit/tools/_internal/diff/common.py +50 -0
  34. stbkit/tools/_internal/diff/diff_xml.py +796 -0
  35. stbkit/tools/_internal/matrix.py +51 -0
  36. stbkit/tools/_internal/optional_dependencies/__init__.py +5 -0
  37. stbkit/tools/_internal/optional_dependencies/ifc_common.py +28 -0
  38. stbkit/tools/_internal/optional_dependencies/ifc_exporter/ifc_entity_exporter.py +473 -0
  39. stbkit/tools/_internal/optional_dependencies/ifc_exporter/ifc_profile_exporter.py +267 -0
  40. stbkit/tools/_internal/optional_dependencies/ifc_exporter/ifc_representation_exporter.py +178 -0
  41. stbkit/tools/_internal/optional_dependencies/ifc_pipeline.py +133 -0
  42. stbkit/tools/_internal/optional_dependencies/ifc_to_building_geometry.py +64 -0
  43. stbkit/tools/_internal/stb_io.py +116 -0
  44. stbkit/tools/_internal/stb_repair/_common.py +24 -0
  45. stbkit/tools/_internal/stb_repair/_stb_repair.py +197 -0
  46. stbkit/tools/_internal/stb_repair/_stb_v2_0_2_repair.py +53 -0
  47. stbkit/tools/_internal/stb_repair/_stb_v2_1_0_repair.py +109 -0
  48. stbkit/tools/_internal/stb_repair/_stb_v2_1_1_repair.py +109 -0
  49. stbkit/tools/_internal/studio_report.py +49 -0
  50. stbkit/tools/_internal/utils/atomic_writer.py +57 -0
  51. stbkit/tools/_internal/utils/float_rounding.py +49 -0
  52. stbkit/tools/_internal/utils/girder_utils.py +91 -0
  53. stbkit/tools/_internal/utils/guid_utils.py +30 -0
  54. stbkit/tools/_internal/utils/mesh_utils.py +1305 -0
  55. stbkit/tools/_internal/utils/node_utils.py +50 -0
  56. stbkit/tools/_internal/utils/unit_utils.py +23 -0
  57. stbkit/tools/_internal/vectors.py +222 -0
  58. stbkit/tools/converters/__init__.py +39 -0
  59. stbkit/tools/converters/_internal/__init__.py +5 -0
  60. stbkit/tools/converters/_internal/building_geometry_to_ply_text.py +32 -0
  61. stbkit/tools/converters/_internal/coord_converter.py +151 -0
  62. stbkit/tools/converters/_internal/element_data_to_building_geometry.py +549 -0
  63. stbkit/tools/converters/_internal/ifc_to_ply_text.py +21 -0
  64. stbkit/tools/converters/_internal/name_converter.py +23 -0
  65. stbkit/tools/converters/_internal/pipeline/__init__.py +5 -0
  66. stbkit/tools/converters/_internal/pipeline/data.py +188 -0
  67. stbkit/tools/converters/_internal/pipeline/execution.py +569 -0
  68. stbkit/tools/converters/_internal/pipeline/ply_pipeline.py +52 -0
  69. stbkit/tools/converters/_internal/pipeline/stb_pipeline.py +226 -0
  70. stbkit/tools/converters/_internal/pipeline/stb_pipeline_latest.py +39 -0
  71. stbkit/tools/converters/_internal/pipeline/step.py +139 -0
  72. stbkit/tools/converters/_internal/stb_latest_to_element_data/__init__.py +17 -0
  73. stbkit/tools/converters/_internal/stb_latest_to_element_data/_stb_latest_to_element_data.py +186 -0
  74. stbkit/tools/converters/_internal/stb_latest_to_element_data/member/member_footing.py +49 -0
  75. stbkit/tools/converters/_internal/stb_latest_to_element_data/member/member_line.py +533 -0
  76. stbkit/tools/converters/_internal/stb_latest_to_element_data/member/member_pile.py +100 -0
  77. stbkit/tools/converters/_internal/stb_latest_to_element_data/member/member_plane.py +103 -0
  78. stbkit/tools/converters/_internal/stb_latest_to_element_data/open.py +70 -0
  79. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec.py +214 -0
  80. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_beam_rc.py +67 -0
  81. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_beam_s.py +97 -0
  82. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_beam_src.py +28 -0
  83. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_brace_s.py +49 -0
  84. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_column_cft.py +217 -0
  85. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_column_rc.py +41 -0
  86. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_column_s.py +127 -0
  87. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_column_src.py +173 -0
  88. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_foundation.py +225 -0
  89. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_parapet_rc.py +53 -0
  90. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_pile_rc.py +106 -0
  91. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_pile_s.py +100 -0
  92. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_steel.py +432 -0
  93. stbkit/tools/converters/_internal/stb_to_building_geometry.py +44 -0
  94. stbkit/tools/converters/_internal/stb_to_ifc_data/__init__.py +5 -0
  95. stbkit/tools/converters/_internal/stb_to_ifc_data/_stb_to_ifc_data.py +59 -0
  96. stbkit/tools/converters/_internal/stb_to_ifc_data/convert_option.py +22 -0
  97. stbkit/tools/converters/_internal/stb_to_ply_text.py +22 -0
  98. stbkit/tools/py.typed +0 -0
  99. stbkit/tools/upgrade/__init__.py +13 -0
  100. stbkit/tools/upgrade/_internal/__init__.py +5 -0
  101. stbkit/tools/upgrade/_internal/common.py +205 -0
  102. stbkit/tools/upgrade/_internal/upgrade.py +228 -0
  103. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/__init__.py +60 -0
  104. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_common.py +62 -0
  105. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_joint_beam_shape_h.py +44 -0
  106. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_sec_pile_rc.py +101 -0
  107. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_sec_pile_s.py +78 -0
  108. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_sec_slab_deck.py +56 -0
  109. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_sec_slab_rc.py +128 -0
  110. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/post_girder.py +126 -0
  111. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/post_open.py +110 -0
  112. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/post_sec_beam_rc.py +227 -0
  113. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/post_sec_beam_s.py +1153 -0
  114. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hooks.py +22 -0
  115. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/post.py +42 -0
  116. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/tables.py +99 -0
  117. stbkit/tools/upgrade/_internal/v2_1_0_to_v2_1_1/__init__.py +58 -0
  118. stbkit/tools/upgrade/_internal/v2_1_0_to_v2_1_1/hooks.py +32 -0
  119. stbkit/tools/upgrade/_internal/v2_1_0_to_v2_1_1/tables.py +56 -0
  120. stbkit-0.1.0a9.dev1.dist-info/METADATA +196 -0
  121. stbkit-0.1.0a9.dev1.dist-info/RECORD +125 -0
  122. stbkit-0.1.0a9.dev1.dist-info/WHEEL +5 -0
  123. stbkit-0.1.0a9.dev1.dist-info/entry_points.txt +2 -0
  124. stbkit-0.1.0a9.dev1.dist-info/licenses/LICENSE +373 -0
  125. 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)