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.
- comcheck_api/DISCLAIMER.md +24 -0
- comcheck_api/__init__.py +99 -0
- comcheck_api/ai/__init__.py +30 -0
- comcheck_api/ai/skill/SKILL.md +285 -0
- comcheck_api/ai/skill/__init__.py +5 -0
- comcheck_api/ai/skill/reference/operations.md +101 -0
- comcheck_api/ai/skill/reference/simulation.md +99 -0
- comcheck_api/ai/skill/reference/types.md +90 -0
- comcheck_api/ai/skill/scripts/__init__.py +1 -0
- comcheck_api/ai/skill/scripts/validate_code.py +210 -0
- comcheck_api/api/__init__.py +1 -0
- comcheck_api/api/api_services.py +273 -0
- comcheck_api/cli.py +136 -0
- comcheck_api/client/__init__.py +1 -0
- comcheck_api/client/comcheck_client.py +335 -0
- comcheck_api/constants/__init__.py +0 -0
- comcheck_api/constants/building_area_constants.py +35 -0
- comcheck_api/constants/common_constants.py +116 -0
- comcheck_api/constants/envelope_constants.py +250 -0
- comcheck_api/defaults.py +150 -0
- comcheck_api/exceptions.py +54 -0
- comcheck_api/introspection.py +188 -0
- comcheck_api/managers/__init__.py +0 -0
- comcheck_api/managers/components/__init__.py +0 -0
- comcheck_api/managers/components/building_area.py +11 -0
- comcheck_api/managers/components/envelope/__init__.py +0 -0
- comcheck_api/managers/components/envelope/ag_wall.py +97 -0
- comcheck_api/managers/components/envelope/bg_wall.py +39 -0
- comcheck_api/managers/components/envelope/door.py +11 -0
- comcheck_api/managers/components/envelope/floor.py +11 -0
- comcheck_api/managers/components/envelope/roof.py +30 -0
- comcheck_api/managers/components/envelope/skylight.py +11 -0
- comcheck_api/managers/components/envelope/window.py +11 -0
- comcheck_api/managers/data_manager.py +369 -0
- comcheck_api/project_operations/__init__.py +8 -0
- comcheck_api/project_operations/project_building_area_operations.py +107 -0
- comcheck_api/project_operations/project_envelope_operations.py +899 -0
- comcheck_api/schemas/comCheck.schema.json +6463 -0
- comcheck_api/types/__init__.py +49 -0
- comcheck_api/types/api_types.py +127 -0
- comcheck_api/types/common_types.py +32 -0
- comcheck_api/types/core_types.py +4198 -0
- comcheck_api/types/custom_base_model.py +314 -0
- comcheck_api/utilities/__init__.py +5 -0
- comcheck_api/utilities/common.py +50 -0
- comcheck_api/utilities/envelope_utilities.py +46 -0
- comcheck_api/utilities/id_registry.py +79 -0
- comcheck_api/utilities/project_utilities.py +60 -0
- comcheck_api/validation.py +64 -0
- comcheck_api-1.0.0.dist-info/METADATA +244 -0
- comcheck_api-1.0.0.dist-info/RECORD +54 -0
- comcheck_api-1.0.0.dist-info/WHEEL +4 -0
- comcheck_api-1.0.0.dist-info/entry_points.txt +2 -0
- 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,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"]
|