comcheck-api 1.0.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.
Files changed (54) hide show
  1. comcheck_api/DISCLAIMER.md +24 -0
  2. comcheck_api/__init__.py +99 -0
  3. comcheck_api/ai/__init__.py +30 -0
  4. comcheck_api/ai/skill/SKILL.md +285 -0
  5. comcheck_api/ai/skill/__init__.py +5 -0
  6. comcheck_api/ai/skill/reference/operations.md +101 -0
  7. comcheck_api/ai/skill/reference/simulation.md +99 -0
  8. comcheck_api/ai/skill/reference/types.md +90 -0
  9. comcheck_api/ai/skill/scripts/__init__.py +1 -0
  10. comcheck_api/ai/skill/scripts/validate_code.py +210 -0
  11. comcheck_api/api/__init__.py +1 -0
  12. comcheck_api/api/api_services.py +273 -0
  13. comcheck_api/cli.py +136 -0
  14. comcheck_api/client/__init__.py +1 -0
  15. comcheck_api/client/comcheck_client.py +335 -0
  16. comcheck_api/constants/__init__.py +0 -0
  17. comcheck_api/constants/building_area_constants.py +35 -0
  18. comcheck_api/constants/common_constants.py +116 -0
  19. comcheck_api/constants/envelope_constants.py +250 -0
  20. comcheck_api/defaults.py +150 -0
  21. comcheck_api/exceptions.py +54 -0
  22. comcheck_api/introspection.py +188 -0
  23. comcheck_api/managers/__init__.py +0 -0
  24. comcheck_api/managers/components/__init__.py +0 -0
  25. comcheck_api/managers/components/building_area.py +11 -0
  26. comcheck_api/managers/components/envelope/__init__.py +0 -0
  27. comcheck_api/managers/components/envelope/ag_wall.py +97 -0
  28. comcheck_api/managers/components/envelope/bg_wall.py +39 -0
  29. comcheck_api/managers/components/envelope/door.py +11 -0
  30. comcheck_api/managers/components/envelope/floor.py +11 -0
  31. comcheck_api/managers/components/envelope/roof.py +30 -0
  32. comcheck_api/managers/components/envelope/skylight.py +11 -0
  33. comcheck_api/managers/components/envelope/window.py +11 -0
  34. comcheck_api/managers/data_manager.py +369 -0
  35. comcheck_api/project_operations/__init__.py +8 -0
  36. comcheck_api/project_operations/project_building_area_operations.py +107 -0
  37. comcheck_api/project_operations/project_envelope_operations.py +899 -0
  38. comcheck_api/schemas/comCheck.schema.json +6463 -0
  39. comcheck_api/types/__init__.py +49 -0
  40. comcheck_api/types/api_types.py +127 -0
  41. comcheck_api/types/common_types.py +32 -0
  42. comcheck_api/types/core_types.py +4198 -0
  43. comcheck_api/types/custom_base_model.py +314 -0
  44. comcheck_api/utilities/__init__.py +5 -0
  45. comcheck_api/utilities/common.py +50 -0
  46. comcheck_api/utilities/envelope_utilities.py +46 -0
  47. comcheck_api/utilities/id_registry.py +79 -0
  48. comcheck_api/utilities/project_utilities.py +60 -0
  49. comcheck_api/validation.py +64 -0
  50. comcheck_api-1.0.0.dist-info/METADATA +244 -0
  51. comcheck_api-1.0.0.dist-info/RECORD +54 -0
  52. comcheck_api-1.0.0.dist-info/WHEEL +4 -0
  53. comcheck_api-1.0.0.dist-info/entry_points.txt +2 -0
  54. comcheck_api-1.0.0.dist-info/licenses/LICENSE +24 -0
@@ -0,0 +1,314 @@
1
+ import logging
2
+ import re
3
+ from typing import (
4
+ Any,
5
+ Optional,
6
+ TypeVar,
7
+ get_type_hints,
8
+ )
9
+
10
+ from pydantic.main import _model_construction
11
+ from pydantic import BaseModel
12
+ from comcheck_api.managers.data_manager import DataManager
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ T = TypeVar("T")
17
+ S = TypeVar("S")
18
+
19
+
20
+ class CustomBaseModel(BaseModel):
21
+ """Base model providing structured, type-safe manipulation of nested data components.
22
+
23
+ Extends Pydantic's :class:`~pydantic.BaseModel` with convenience methods
24
+ for appending, updating, and removing items in list-valued sub-component
25
+ fields (e.g. windows on a wall, skylights on a roof). Subclasses
26
+ automatically gain ``add_<field>`` helper methods for every field whose
27
+ type is itself a :class:`~pydantic.BaseModel`.
28
+ """
29
+
30
+ _identifier: str = "id"
31
+
32
+ @classmethod
33
+ def __pydantic_init_subclass__(cls, **kwargs):
34
+ """Automatically generate `add_<field>` methods for BaseModel-typed fields on subclasses."""
35
+ super().__pydantic_init_subclass__(**kwargs)
36
+ try:
37
+ from typing import get_type_hints
38
+
39
+ hints = get_type_hints(cls)
40
+ except Exception:
41
+ return
42
+
43
+ for field_name, field_type in hints.items():
44
+ try:
45
+ if isinstance(field_type, type) and issubclass(field_type, BaseModel):
46
+
47
+ def make_adder(fn, ft):
48
+ """Create an adder method that sets a single BaseModel-typed field."""
49
+
50
+ def adder(self, instance, fn=fn, ft=ft):
51
+ """Set the field `fn` to `instance` on this model."""
52
+ setattr(self, fn, instance)
53
+ logger.debug("Added %s: %s", fn, instance)
54
+
55
+ adder.__name__ = f"add_{fn}"
56
+ return adder
57
+
58
+ setattr(
59
+ cls, f"add_{field_name}", make_adder(field_name, field_type)
60
+ )
61
+ except TypeError:
62
+ continue
63
+
64
+ def append_subcomponent(
65
+ self,
66
+ subcomponent: S | dict,
67
+ subcomponent_name: Optional[str] = None,
68
+ ) -> T:
69
+ """
70
+ Append a subcomponent to the corresponding subcomponent list.
71
+
72
+ Args:
73
+ subcomponent: The subcomponent instance to add.
74
+ subcomponent_name: Name of the subcomponent attribute (e.g., ``"door"``).
75
+
76
+ Returns:
77
+ Item with the new subcomponent added.
78
+ """
79
+
80
+ subcomponent_type, subcomponent_name = self._get_normalized_subcomponent_info(
81
+ subcomponent=subcomponent, subcomponent_name=subcomponent_name
82
+ )
83
+
84
+ current_subcomponents = self._get_subcomponent_list(subcomponent_name)
85
+ subcomponent_manager = DataManager[subcomponent_type](
86
+ initial_data=current_subcomponents, model_type=subcomponent_type
87
+ )
88
+
89
+ updated_list = subcomponent_manager.add_new(subcomponent)
90
+
91
+ setattr(self, subcomponent_name, updated_list)
92
+
93
+ return self
94
+
95
+ def update_subcomponent_list(
96
+ self,
97
+ *,
98
+ subcomponent_updates: Optional[S | dict] = None,
99
+ subcomponent_id: str = None,
100
+ subcomponent_name: str = None,
101
+ ) -> T:
102
+ """
103
+ Append a subcomponent to the corresponding subcomponent list.
104
+
105
+ Args:
106
+ subcomponent_updates: Partial updates (dict) or full model object to apply to the item.
107
+ subcomponent_id: The unique identifier of the subcomponent to remove
108
+ subcomponent_name: Name of the subcomponent attribute (e.g., "door")
109
+
110
+ Returns:
111
+ Item with the new subcomponent added.
112
+ """
113
+
114
+ subcomponent_type, subcomponent_name = self._get_normalized_subcomponent_info(
115
+ subcomponent=subcomponent_updates, subcomponent_name=subcomponent_name
116
+ )
117
+
118
+ current_subcomponents = self._get_subcomponent_list(subcomponent_name)
119
+ subcomponent_manager = DataManager[subcomponent_type](
120
+ initial_data=current_subcomponents, model_type=subcomponent_type
121
+ )
122
+
123
+ subcomponent_manager.modify_one(
124
+ id_value=subcomponent_id, updates=subcomponent_updates
125
+ )
126
+
127
+ setattr(self, subcomponent_name, subcomponent_manager.get_all())
128
+
129
+ return self
130
+
131
+ def remove_from_subcomponent_list(
132
+ self,
133
+ *,
134
+ subcomponent: Optional[S | dict] = None,
135
+ subcomponent_id: Optional[str] = None,
136
+ subcomponent_name: Optional[str] = None,
137
+ ) -> T:
138
+ """
139
+ Remove a subcomponent from a list-valued attribute by instance or ID.
140
+
141
+ Args:
142
+ subcomponent: The subcomponent instance to remove
143
+ subcomponent_id: The unique identifier of the subcomponent to remove
144
+ subcomponent_name: Name of the subcomponent attribute (e.g., "door")
145
+
146
+ Returns:
147
+ Item with the subcomponent removed.
148
+ """
149
+ if subcomponent is None and (subcomponent_name and subcomponent_id) is None:
150
+ raise ValueError(
151
+ "Must provide either subcomponent instance or subcomponent_id and subcomponent_name"
152
+ )
153
+
154
+ subcomponent_type, subcomponent_name = self._get_normalized_subcomponent_info(
155
+ subcomponent=subcomponent,
156
+ subcomponent_name=subcomponent_name,
157
+ )
158
+
159
+ current_subcomponents = self._get_subcomponent_list(subcomponent_name)
160
+ subcomponent_manager = DataManager[subcomponent_type](
161
+ initial_data=current_subcomponents, model_type=subcomponent_type
162
+ )
163
+
164
+ subcomponent_identifier = subcomponent_manager._identifier
165
+
166
+ target_id = (
167
+ getattr(subcomponent, subcomponent_identifier)
168
+ if subcomponent is not None
169
+ else subcomponent_id
170
+ )
171
+
172
+ was_deleted = subcomponent_manager.delete_one(target_id)
173
+
174
+ if not was_deleted:
175
+ raise ValueError(
176
+ f"Subcomponent with id {target_id} not found in '{subcomponent_name}'"
177
+ )
178
+
179
+ setattr(self, subcomponent_name, subcomponent_manager.get_all())
180
+
181
+ return self
182
+
183
+ def _get_normalized_subcomponent_info(
184
+ self, subcomponent: S, subcomponent_name: str | None
185
+ ):
186
+ """Resolve the concrete type and attribute name for a subcomponent.
187
+
188
+ When *subcomponent_name* is provided the type is looked up from
189
+ ``core_types`` by capitalising the name. Otherwise the type and
190
+ name are derived from the *subcomponent* instance itself.
191
+
192
+ Args:
193
+ subcomponent: A model instance or dict representing the subcomponent.
194
+ subcomponent_name: Optional explicit attribute name (e.g. ``"door"``).
195
+
196
+ Returns:
197
+ A ``(subcomponent_type, resolved_subcomponent_name)`` tuple.
198
+
199
+ Raises:
200
+ ValueError: If the name cannot be resolved to a known type, or if
201
+ *subcomponent* is a dict and no *subcomponent_name* is given.
202
+ TypeError: If *subcomponent* is neither a dict nor a
203
+ :class:`CustomBaseModel`.
204
+ """
205
+ if subcomponent_name:
206
+ from comcheck_api.types import core_types
207
+
208
+ class_name = subcomponent_name[0].upper() + subcomponent_name[1:]
209
+ cls = getattr(core_types, class_name, None)
210
+ if cls is None:
211
+ raise ValueError(f"Unknown subcomponent type for name '{class_name}'")
212
+
213
+ return cls, subcomponent_name
214
+ else:
215
+ if isinstance(subcomponent, dict):
216
+ raise ValueError(
217
+ "subcomponent_name is required when subcomponent is a dict"
218
+ )
219
+ elif isinstance(subcomponent, CustomBaseModel):
220
+ subcomponent_name = subcomponent.json_key()
221
+ return type(subcomponent), subcomponent_name
222
+ else:
223
+ raise TypeError(
224
+ f"subcomponent must be a dict or CustomBaseModel instance, "
225
+ f"got {type(subcomponent).__name__}"
226
+ )
227
+
228
+ def _get_subcomponent_list(self, subcomponent_name: str):
229
+ """Return the current list for a named subcomponent attribute.
230
+
231
+ Args:
232
+ subcomponent_name: Name of the list-valued field on this model.
233
+
234
+ Returns:
235
+ The current list value for the given attribute.
236
+
237
+ Raises:
238
+ AttributeError: If the field does not exist on this model.
239
+ TypeError: If the field value is not a list.
240
+ """
241
+ if subcomponent_name not in self.__class__.model_fields:
242
+ raise AttributeError(
243
+ f"{self.__class__.__name__} has no subcomponent list '{subcomponent_name}'"
244
+ )
245
+
246
+ current_subcomponents = getattr(self, subcomponent_name, [])
247
+
248
+ if not isinstance(current_subcomponents, list):
249
+ raise TypeError(
250
+ f"{self.__class__.__name__}.{subcomponent_name} "
251
+ f"must be a list, got {type(current_subcomponents).__name__}"
252
+ )
253
+
254
+ return current_subcomponents
255
+
256
+ def get_by_path(self, path: str, default: Any = None) -> Any | None:
257
+ """Traverse nested attributes/list indices using a dot-bracket path expression.
258
+
259
+ Args:
260
+ path: Dot-separated path string with optional bracket indexing,
261
+ e.g. ``"envelope.roof[0].assemblyType"``.
262
+ default: Value to return if any segment of the path is missing.
263
+
264
+ Returns:
265
+ The value at the given path, or ``default`` if not found.
266
+ """
267
+ current = self
268
+
269
+ # Split on dots that are not inside brackets
270
+ parts = re.split(r"\.(?![^\[]*\])", path)
271
+
272
+ try:
273
+ for part in parts:
274
+ # Handle object
275
+ attr_match = re.match(r"^([A-Za-z_]\w*)", part)
276
+ if attr_match:
277
+ attr = attr_match.group(1)
278
+ if not hasattr(current, attr):
279
+ return default
280
+ current = getattr(current, attr)
281
+ remainder = part[len(attr) :]
282
+ else:
283
+ remainder = part
284
+
285
+ # Handle brackets for possible list
286
+ for m in re.finditer(r"\[(.*?)\]", remainder):
287
+ idx_str = m.group(1).strip()
288
+ idx = int(idx_str)
289
+ current = current[idx]
290
+ except Exception:
291
+ return default
292
+
293
+ return current
294
+
295
+ def require_attribute(self, path: str) -> None:
296
+ """Assert that a nested attribute exists and is truthy.
297
+
298
+ Args:
299
+ path: Dot-bracket path to the attribute (see ``get_by_path``).
300
+
301
+ Raises:
302
+ ValueError: If the attribute is falsy or not found.
303
+ """
304
+ if not self.get_by_path(path):
305
+ raise ValueError(f"'{path}' is required in project")
306
+
307
+ @classmethod
308
+ def json_key(cls) -> str:
309
+ """Return the camelCase JSON key derived from the class name.
310
+
311
+ Returns:
312
+ Class name with the first character lowercased (e.g. ``AgWall`` → ``agWall``).
313
+ """
314
+ return cls.__name__[0].lower() + cls.__name__[1:]
@@ -0,0 +1,5 @@
1
+ """Utility functions module."""
2
+
3
+ from .common import export_to_json
4
+
5
+ __all__ = ["export_to_json"]
@@ -0,0 +1,50 @@
1
+ """Common utility helpers used across the package."""
2
+
3
+ import json
4
+ import logging
5
+ import random
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def get_random_number(min_value: int = 0, max_value: int = 100) -> int:
13
+ """Return a pseudo-random integer in the range [*min_value*, *max_value*].
14
+
15
+ Args:
16
+ min_value: Lower bound (inclusive).
17
+ max_value: Upper bound (inclusive).
18
+
19
+ Returns:
20
+ A random integer.
21
+ """
22
+ return random.randint(min_value, max_value)
23
+
24
+
25
+ def export_to_json(data: Any, output_file: str | Path | None = None) -> str:
26
+ """Convert and optionally save data to JSON.
27
+
28
+ Args:
29
+ data: The data to convert to JSON.
30
+ output_file: Optional file path to save JSON data.
31
+
32
+ Returns:
33
+ JSON string representation of the data.
34
+
35
+ Raises:
36
+ Exception: If there's an error exporting data to JSON.
37
+ """
38
+ try:
39
+ json_data = json.dumps(data, indent=2, ensure_ascii=False)
40
+
41
+ if output_file:
42
+ output_path = Path(output_file).resolve()
43
+ output_path.parent.mkdir(parents=True, exist_ok=True)
44
+ output_path.write_text(json_data, encoding="utf-8")
45
+ logger.info("Data exported to %s", output_file)
46
+
47
+ return json_data
48
+ except Exception as error:
49
+ logger.error("Error exporting data to JSON: %s", error, exc_info=True)
50
+ raise
@@ -0,0 +1,46 @@
1
+ """Envelope-related utility functions for COMcheck projects."""
2
+
3
+ from typing import Any, Dict
4
+
5
+ from comcheck_api.constants.envelope_constants import DEFAULT_ASSEMBLIES
6
+ from comcheck_api.types.common_types import AssemblyType
7
+
8
+
9
+ def type_map_description(type_name: str) -> str:
10
+ """Map assembly type to a human-readable description.
11
+
12
+ Args:
13
+ type_name: The assembly type identifier.
14
+
15
+ Returns:
16
+ A formatted description string.
17
+ """
18
+ mapping = {
19
+ "AgWall": "Ext Wall",
20
+ "BgWall": "Basement",
21
+ }
22
+ return mapping.get(type_name, type_name)
23
+
24
+
25
+ def generate_assembly(
26
+ bldg_use_key: str, name: str, assembly_type: AssemblyType
27
+ ) -> Dict[str, Any]:
28
+ """Generate an assembly configuration by combining defaults with custom values.
29
+
30
+ Args:
31
+ bldg_use_key: The building use key identifier.
32
+ name: The custom name for the assembly.
33
+ assembly_type: The type of assembly (e.g., 'Window', 'AgWall', etc.).
34
+
35
+ Returns:
36
+ A dictionary containing the complete assembly configuration.
37
+ """
38
+ default_assembly = DEFAULT_ASSEMBLIES[assembly_type]
39
+ description = type_map_description(assembly_type)
40
+
41
+ result: Dict[str, Any] = {
42
+ **default_assembly,
43
+ "bldgUseKey": bldg_use_key,
44
+ "assemblyType": f"{description}:{name}",
45
+ }
46
+ return result
@@ -0,0 +1,79 @@
1
+ """In-memory registry for unique component identifiers.
2
+
3
+ The registry tracks every ID that has been issued or imported so that
4
+ :func:`generate_id_with_prefix` never produces duplicates within a session.
5
+ Call :func:`reset_registry` between test runs or independent sessions.
6
+ """
7
+
8
+ _used_ids: set[str] = set()
9
+ _prefix_counters: dict[str, int] = {}
10
+
11
+
12
+ def register_existing_id(id: str):
13
+ """Register a pre-existing ID so it is not re-issued by the generator.
14
+
15
+ Args:
16
+ id: The existing ID string to reserve.
17
+ """
18
+ if id in _used_ids:
19
+ # ID already registered, skip silently
20
+ return
21
+ _used_ids.add(id)
22
+
23
+ prefix, number = _parse_prefix_number(id)
24
+ if prefix:
25
+ current_max = _prefix_counters.get(prefix, 0)
26
+ if number and number > current_max:
27
+ _prefix_counters[prefix] = number
28
+
29
+
30
+ def generate_id_with_prefix(prefix: str) -> str:
31
+ """Generate a unique ID of the form ``"<prefix> <n>"`` for the given prefix.
32
+
33
+ Args:
34
+ prefix: The prefix string (e.g. ``"Door:Door"``).
35
+
36
+ Returns:
37
+ A new unique ID string that has not been previously issued.
38
+ """
39
+ counter = _prefix_counters.get(prefix, 0) + 1
40
+
41
+ while True:
42
+ candidate = f"{prefix} {counter}"
43
+ if candidate not in _used_ids:
44
+ _used_ids.add(candidate)
45
+ _prefix_counters[prefix] = counter
46
+ return candidate
47
+ counter += 1
48
+
49
+
50
+ def release_id(id: str):
51
+ """Release a previously registered ID so it may be reused.
52
+
53
+ Args:
54
+ id: The ID string to release.
55
+ """
56
+ _used_ids.discard(id)
57
+ # Note: Not adjusting _prefix_counters for simplicity
58
+
59
+
60
+ def reset_registry():
61
+ """Clear all registered IDs and prefix counters, resetting the registry to empty."""
62
+ _used_ids.clear()
63
+ _prefix_counters.clear()
64
+
65
+
66
+ def _parse_prefix_number(id: str):
67
+ """Parse a prefix and optional trailing number from a composite ID.
68
+
69
+ Args:
70
+ id: An ID string such as ``"Door:Door 5"``.
71
+
72
+ Returns:
73
+ A ``(prefix, number)`` tuple where *number* is ``None`` when the ID
74
+ has no trailing integer.
75
+ """
76
+ parts = id.rsplit(" ", 1)
77
+ if len(parts) == 2 and parts[1].isdigit():
78
+ return parts[0], int(parts[1])
79
+ return id, None
@@ -0,0 +1,60 @@
1
+ """Internal helpers for validating and querying COMcheck project structures."""
2
+
3
+ from typing import List
4
+ from comcheck_api.types.custom_base_model import CustomBaseModel
5
+ from comcheck_api.types.core_types import ComBuilding
6
+ from comcheck_api.managers.data_manager import DataManager, get_model_info
7
+
8
+
9
+ def _require_building_area(project: ComBuilding, building_area_key: str) -> None:
10
+ """
11
+ Ensure that project.lighting.wholeBldgUse exists and contains the given key.
12
+ """
13
+ whole_use = project.get_by_path("lighting.wholeBldgUse")
14
+
15
+ if not isinstance(whole_use, list):
16
+ raise ValueError("No building area (wholeBldgUse) found in project.")
17
+
18
+ if not any(getattr(area, "key", None) == building_area_key for area in whole_use):
19
+ raise ValueError(
20
+ f"Building area key '{building_area_key}' not found in lighting.wholeBldgUse."
21
+ )
22
+
23
+
24
+ def find_component_in_component_list(
25
+ components: List[CustomBaseModel], component_id: str
26
+ ):
27
+ """Find a component by its identifier within a list of components.
28
+
29
+ Args:
30
+ components: List of model instances to search through.
31
+ component_id: The identifier value to look up.
32
+
33
+ Returns:
34
+ The matching component, or None if not found or the list is empty.
35
+ """
36
+ if not components:
37
+ return None
38
+
39
+ component_type = type(components[0])
40
+ component_manager = DataManager[component_type](
41
+ initial_data=components, model_type=component_type
42
+ )
43
+
44
+ return component_manager.get_by_identifier(component_id)
45
+
46
+
47
+ def get_id_from_component(
48
+ component: CustomBaseModel,
49
+ ) -> str:
50
+ """Retrieve the unique identifier value of a component.
51
+
52
+ Args:
53
+ component: A model instance whose identifier should be extracted.
54
+
55
+ Returns:
56
+ The identifier string, or ``None`` if the identifier attribute is unset.
57
+ """
58
+
59
+ identifier, _ = get_model_info(type(component))
60
+ return getattr(component, identifier, None)
@@ -0,0 +1,64 @@
1
+ """Project-data validation helpers.
2
+
3
+ Discoverable from the package root:
4
+
5
+ >>> from comcheck_api import validate_project
6
+ >>> result = validate_project(project_data)
7
+ >>> if not result.ok:
8
+ ... for err in result.errors:
9
+ ... print(err.loc, err.msg)
10
+
11
+ No network calls. Validates against the SDK's ``ComBuilding`` Pydantic
12
+ model.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ import pydantic
20
+ from pydantic import BaseModel
21
+
22
+ from comcheck_api.types.core_types import ComBuilding
23
+
24
+
25
+ class ValidationError(BaseModel):
26
+ """One Pydantic validation failure with a dotted location path."""
27
+
28
+ loc: str
29
+ msg: str
30
+ type: str
31
+
32
+
33
+ class ValidationResult(BaseModel):
34
+ """Outcome of validating a project against ``ComBuilding``."""
35
+
36
+ ok: bool
37
+ errors: list[ValidationError] = []
38
+
39
+
40
+ def validate_project(data: dict[str, Any] | ComBuilding) -> ValidationResult:
41
+ """Validate ``data`` against the :class:`ComBuilding` Pydantic model.
42
+
43
+ Accepts a dict or an existing ``ComBuilding`` (round-trips through
44
+ ``model_dump`` so the same code path runs in both cases).
45
+ """
46
+ payload = data.model_dump() if isinstance(data, ComBuilding) else data
47
+ try:
48
+ ComBuilding.model_validate(payload)
49
+ except pydantic.ValidationError as e:
50
+ return ValidationResult(
51
+ ok=False,
52
+ errors=[
53
+ ValidationError(
54
+ loc=".".join(str(x) for x in err["loc"]),
55
+ msg=err["msg"],
56
+ type=err["type"],
57
+ )
58
+ for err in e.errors()
59
+ ],
60
+ )
61
+ return ValidationResult(ok=True, errors=[])
62
+
63
+
64
+ __all__ = ["ValidationError", "ValidationResult", "validate_project"]