soft7 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- s7/__init__.py +9 -0
- s7/exceptions.py +35 -0
- s7/factories/__init__.py +8 -0
- s7/factories/datasource_factory.py +347 -0
- s7/factories/entity_factory.py +165 -0
- s7/factories/generated_classes.py +13 -0
- s7/oteapi_plugin/__init__.py +0 -0
- s7/oteapi_plugin/models.py +101 -0
- s7/oteapi_plugin/soft7_function.py +625 -0
- s7/oteapi_plugin/yaml_parser.py +115 -0
- s7/pydantic_models/__init__.py +0 -0
- s7/pydantic_models/_utils.py +268 -0
- s7/pydantic_models/datasource.py +386 -0
- s7/pydantic_models/oteapi.py +86 -0
- s7/pydantic_models/soft7_entity.py +430 -0
- s7/pydantic_models/soft7_instance.py +323 -0
- soft7-0.1.0.dist-info/LICENSE +21 -0
- soft7-0.1.0.dist-info/METADATA +120 -0
- soft7-0.1.0.dist-info/RECORD +21 -0
- soft7-0.1.0.dist-info/WHEEL +4 -0
- soft7-0.1.0.dist-info/entry_points.txt +10 -0
s7/__init__.py
ADDED
s7/exceptions.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""SOFT7 exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class S7Error(Exception):
|
|
7
|
+
"""Base class for all SOFT7 exceptions."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class S7EntityError(S7Error):
|
|
11
|
+
"""Base class for all SOFT7 entity exceptions."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EntityNotFound(S7EntityError, FileNotFoundError):
|
|
15
|
+
"""Raised when an entity is or can not be found."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigsNotFound(S7EntityError, FileNotFoundError):
|
|
19
|
+
"""Raised when the configs are or can not be found."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class S7OTEAPIPluginError(S7Error):
|
|
23
|
+
"""Base class for all SOFT7 OTEAPI plugin exceptions."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SOFT7FunctionError(S7OTEAPIPluginError):
|
|
27
|
+
"""Base class for all JSON to SOFT7 Entity OTEAPI parse strategy exceptions."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InsufficientData(SOFT7FunctionError, ValueError):
|
|
31
|
+
"""Raised when the data is insufficient to generate a SOFT7 entity."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvalidMapping(SOFT7FunctionError, ValueError):
|
|
35
|
+
"""Raised when the mapping is invalid."""
|
s7/factories/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Public factory functions for creating SOFT7-related classes and instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .datasource_factory import create_datasource
|
|
6
|
+
from .entity_factory import create_entity
|
|
7
|
+
|
|
8
|
+
__all__ = ("create_datasource", "create_entity")
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Generate SOFT7 entity instances based on basic information:
|
|
2
|
+
|
|
3
|
+
1. Data source (DB, File, Webpage, ...).
|
|
4
|
+
2. Generic data source parser.
|
|
5
|
+
3. Data source parser configuration.
|
|
6
|
+
4. SOFT7 entity (data model).
|
|
7
|
+
|
|
8
|
+
Parts 2 and 3 are together considered to produce the "specific parser".
|
|
9
|
+
Parts 1 through 3 are provided through a single dictionary based on the
|
|
10
|
+
`ResourceConfig` from `oteapi.models`.
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Optional
|
|
20
|
+
|
|
21
|
+
from otelib import OTEClient
|
|
22
|
+
from pydantic import AnyUrl, Field, create_model
|
|
23
|
+
|
|
24
|
+
from s7.pydantic_models.datasource import (
|
|
25
|
+
DataSourceDimensions,
|
|
26
|
+
SOFT7DataSource,
|
|
27
|
+
parse_input_configs,
|
|
28
|
+
)
|
|
29
|
+
from s7.pydantic_models.soft7_entity import (
|
|
30
|
+
SOFT7Entity,
|
|
31
|
+
parse_identity,
|
|
32
|
+
parse_input_entity,
|
|
33
|
+
)
|
|
34
|
+
from s7.pydantic_models.soft7_instance import (
|
|
35
|
+
generate_dimensions_docstring,
|
|
36
|
+
generate_model_docstring,
|
|
37
|
+
generate_property_type,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
41
|
+
from typing import Any, TypedDict
|
|
42
|
+
|
|
43
|
+
from oteapi.models import GenericConfig
|
|
44
|
+
|
|
45
|
+
from s7.pydantic_models.datasource import (
|
|
46
|
+
GetData,
|
|
47
|
+
GetDataConfigDict,
|
|
48
|
+
)
|
|
49
|
+
from s7.pydantic_models.soft7_entity import PropertyType
|
|
50
|
+
|
|
51
|
+
class SOFT7InstanceDict(TypedDict):
|
|
52
|
+
"""A dictionary representation of a SOFT7 instance."""
|
|
53
|
+
|
|
54
|
+
dimensions: Optional[dict[str, int]]
|
|
55
|
+
properties: dict[str, Any]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
DEFAULT_OTEAPI_SERVICES_BASE_URL = "http://localhost:8080"
|
|
59
|
+
|
|
60
|
+
LOGGER = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
CACHE: dict[int, dict[str, Any]] = {}
|
|
63
|
+
"""A cache of the OTEAPI pipeline results."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_pipeline_id(configs: GetDataConfigDict, url: str) -> int:
|
|
67
|
+
"""Hash given inputs and return it."""
|
|
68
|
+
return hash((*((key, configs[key]) for key in sorted(configs)), url)) # type: ignore[literal-required]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_data(
|
|
72
|
+
config: GetDataConfigDict,
|
|
73
|
+
*,
|
|
74
|
+
url: str | None = None,
|
|
75
|
+
) -> GetData:
|
|
76
|
+
"""Get a datum via an OTEAPI pipeline.
|
|
77
|
+
|
|
78
|
+
The OTEAPI pipeline will look like:
|
|
79
|
+
|
|
80
|
+
DataResource -> Parser -> Mapping -> Function
|
|
81
|
+
|
|
82
|
+
It may be that the Mapping strategy is left out.
|
|
83
|
+
|
|
84
|
+
Everything in the __get_data() function will not be called until the first time
|
|
85
|
+
the attribute `name` is accessed.
|
|
86
|
+
|
|
87
|
+
To do something "lazy", it should be moved to the __get_data() function.
|
|
88
|
+
To instead pre-cache something, it should be moved outside of the __get_data()
|
|
89
|
+
function.
|
|
90
|
+
|
|
91
|
+
Parameters:
|
|
92
|
+
config: Mapping for all necessary OTEAPI strategy configuration.
|
|
93
|
+
url: The base URL of the OTEAPI service instance to use.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
A function that will return the value of a named dimension or property.
|
|
97
|
+
|
|
98
|
+
"""
|
|
99
|
+
# OTEAPI pipeline configuration
|
|
100
|
+
client = OTEClient(url or DEFAULT_OTEAPI_SERVICES_BASE_URL)
|
|
101
|
+
|
|
102
|
+
ote_data_resource = client.create_dataresource(
|
|
103
|
+
**config["dataresource"].model_dump()
|
|
104
|
+
)
|
|
105
|
+
ote_function = client.create_function(**config["function"].model_dump())
|
|
106
|
+
ote_parser = client.create_parser(**config["parser"].model_dump())
|
|
107
|
+
|
|
108
|
+
if "mapping" in config:
|
|
109
|
+
ote_mapping = client.create_mapping(**config["mapping"].model_dump()) # type: ignore[typeddict-item]
|
|
110
|
+
|
|
111
|
+
ote_pipeline = ote_data_resource >> ote_parser >> ote_mapping >> ote_function
|
|
112
|
+
else:
|
|
113
|
+
raise NotImplementedError(
|
|
114
|
+
"Only OTEAPI pipelines with a mapping are supported for now, i.e., "
|
|
115
|
+
"implicit 1:1 mapping is currently not supported."
|
|
116
|
+
)
|
|
117
|
+
# ote_pipeline = ote_data_resource >> ote_parser >> ote_function
|
|
118
|
+
|
|
119
|
+
# Remove unused variables from memory
|
|
120
|
+
del client
|
|
121
|
+
|
|
122
|
+
pipeline_id = create_pipeline_id(config, url or DEFAULT_OTEAPI_SERVICES_BASE_URL)
|
|
123
|
+
|
|
124
|
+
def __get_data(soft7_property: str) -> Any:
|
|
125
|
+
"""Get a named datum (property or dimension) from the data resource.
|
|
126
|
+
|
|
127
|
+
Properties:
|
|
128
|
+
soft7_property: The name of a datum to get, i.e., the SOFT7 data resource
|
|
129
|
+
(property or dimension).
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The value of the SOFT7 data resource (property or dimension).
|
|
133
|
+
|
|
134
|
+
"""
|
|
135
|
+
LOGGER.debug("soft7_property: %r", soft7_property)
|
|
136
|
+
|
|
137
|
+
if pipeline_id not in CACHE:
|
|
138
|
+
LOGGER.debug(
|
|
139
|
+
"Running OTEAPI pipeline: %r (id: %r)", ote_pipeline, pipeline_id
|
|
140
|
+
)
|
|
141
|
+
# Should only run once per pipeline - after that we retrieve from the cache
|
|
142
|
+
pipeline_result: dict[str, Any] = json.loads(ote_pipeline.get())
|
|
143
|
+
CACHE[pipeline_id] = pipeline_result
|
|
144
|
+
else:
|
|
145
|
+
pipeline_result = CACHE[pipeline_id]
|
|
146
|
+
|
|
147
|
+
LOGGER.debug("Pipeline result: %r", pipeline_result)
|
|
148
|
+
|
|
149
|
+
# TODO: Use variable from SOFT7 OTEAPI Function configuration instead of
|
|
150
|
+
# 'soft7_entity_data'. Maybe even consider parsing `pipeline_result` into the
|
|
151
|
+
# eventual Session model to thereby validate the desired keys are present.
|
|
152
|
+
if "soft7_entity_data" not in pipeline_result:
|
|
153
|
+
error_message = (
|
|
154
|
+
"The OTEAPI pipeline did not return the expected data structure."
|
|
155
|
+
)
|
|
156
|
+
LOGGER.error(
|
|
157
|
+
"%s\nsoft7_property: %r\npipeline_result: %r",
|
|
158
|
+
error_message,
|
|
159
|
+
soft7_property,
|
|
160
|
+
pipeline_result,
|
|
161
|
+
)
|
|
162
|
+
raise ValueError(error_message)
|
|
163
|
+
|
|
164
|
+
data: SOFT7InstanceDict = pipeline_result["soft7_entity_data"]
|
|
165
|
+
|
|
166
|
+
if soft7_property in data["properties"]:
|
|
167
|
+
LOGGER.debug(
|
|
168
|
+
"Returning property: %r = %r",
|
|
169
|
+
soft7_property,
|
|
170
|
+
data["properties"][soft7_property],
|
|
171
|
+
)
|
|
172
|
+
return data["properties"][soft7_property]
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
"dimensions" in data
|
|
176
|
+
and data["dimensions"]
|
|
177
|
+
and soft7_property in data["dimensions"]
|
|
178
|
+
):
|
|
179
|
+
LOGGER.debug(
|
|
180
|
+
"Returning dimension: %r = %r",
|
|
181
|
+
soft7_property,
|
|
182
|
+
data["dimensions"][soft7_property],
|
|
183
|
+
)
|
|
184
|
+
return data["dimensions"][soft7_property]
|
|
185
|
+
|
|
186
|
+
error_message = f"{soft7_property!r} could not be determined for the resource."
|
|
187
|
+
LOGGER.error(
|
|
188
|
+
"%s\nsoft7_property: %r\ndata: %r",
|
|
189
|
+
error_message,
|
|
190
|
+
soft7_property,
|
|
191
|
+
data,
|
|
192
|
+
)
|
|
193
|
+
raise AttributeError(error_message)
|
|
194
|
+
|
|
195
|
+
return __get_data
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def create_datasource(
|
|
199
|
+
entity: SOFT7Entity | dict[str, Any] | Path | AnyUrl | str,
|
|
200
|
+
configs: (
|
|
201
|
+
GetDataConfigDict
|
|
202
|
+
| dict[str, GenericConfig | dict[str, Any] | Path | AnyUrl | str]
|
|
203
|
+
| Path
|
|
204
|
+
| AnyUrl
|
|
205
|
+
| str
|
|
206
|
+
),
|
|
207
|
+
oteapi_url: str | None = None,
|
|
208
|
+
) -> SOFT7DataSource:
|
|
209
|
+
"""Create and return an instance of a SOFT7 Data Source wrapped as a pydantic model.
|
|
210
|
+
|
|
211
|
+
TODO: Utilize the `generated_classes` module and check whether we can return an
|
|
212
|
+
already created model based on the inputs given here.
|
|
213
|
+
|
|
214
|
+
TODO: Determine what to do with regards to differing inputs, but similar names.
|
|
215
|
+
|
|
216
|
+
Parameters:
|
|
217
|
+
entity: A SOFT7 entity (data model). It can be supplied as a URL reference,
|
|
218
|
+
path or as a raw JSON/YAML string or Python `dict`.
|
|
219
|
+
configs: A dictionary of the various required OTEAPI strategy configurations
|
|
220
|
+
needed for the underlying OTEAPI pipeline. It can be supplied as a URL
|
|
221
|
+
reference, path or as a raw JSON/YAML string or Python `dict`.
|
|
222
|
+
oteapi_url: The base URL of the OTEAPI service to use.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
An instance of a SOFT7 Data Source wrapped as a pydantic data model.
|
|
226
|
+
|
|
227
|
+
"""
|
|
228
|
+
import s7.factories.generated_classes as module_namespace
|
|
229
|
+
|
|
230
|
+
entity = parse_input_entity(entity)
|
|
231
|
+
configs = parse_input_configs(configs, entity_instance=entity.identity)
|
|
232
|
+
|
|
233
|
+
# Split the identity into its parts
|
|
234
|
+
namespace, version, name = parse_identity(entity.identity)
|
|
235
|
+
|
|
236
|
+
# Setup the OTEAPI pipeline configuration
|
|
237
|
+
get_piped_data = lambda: _get_data(configs, url=oteapi_url) # noqa: E731
|
|
238
|
+
|
|
239
|
+
# Create the dimensions model
|
|
240
|
+
dimensions: dict[str, tuple[type[int], GetData]] = (
|
|
241
|
+
# Value must be a (<type>, <default>) or (<type>, <FieldInfo>) tuple
|
|
242
|
+
# Note, Field() returns a FieldInfo instance (but is set to return an Any type).
|
|
243
|
+
{
|
|
244
|
+
dimension_name: (
|
|
245
|
+
int,
|
|
246
|
+
Field(
|
|
247
|
+
default_factory=get_piped_data,
|
|
248
|
+
description=dimension_description,
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
for dimension_name, dimension_description in entity.dimensions.items()
|
|
252
|
+
}
|
|
253
|
+
if entity.dimensions
|
|
254
|
+
else {}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
DataSourceDimensionsModel = create_model(
|
|
258
|
+
f"{name.replace(' ', '')}DataSourceDimensions",
|
|
259
|
+
__config__=None,
|
|
260
|
+
__doc__=generate_dimensions_docstring(entity),
|
|
261
|
+
__base__=DataSourceDimensions,
|
|
262
|
+
__module__=module_namespace.__name__,
|
|
263
|
+
__validators__=None,
|
|
264
|
+
__cls_kwargs__=None,
|
|
265
|
+
**dimensions,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
data_source_dimensions = DataSourceDimensionsModel()
|
|
269
|
+
|
|
270
|
+
# Create the SOFT7 metadata fields for the data source model
|
|
271
|
+
# All of these fields will be excluded from the data source model representation as
|
|
272
|
+
# well as the serialized JSON schema or Python dictionary.
|
|
273
|
+
soft7_metadata: dict[str, tuple[type | object, Any]] = {
|
|
274
|
+
# Value must be a (<type>, <default>) or (<type>, <FieldInfo>) tuple
|
|
275
|
+
# Note, Field() returns a FieldInfo instance (but is set to return an Any type).
|
|
276
|
+
"dimensions": (
|
|
277
|
+
DataSourceDimensionsModel,
|
|
278
|
+
Field(data_source_dimensions, repr=False, exclude=True),
|
|
279
|
+
),
|
|
280
|
+
"identity": (
|
|
281
|
+
entity.model_fields["identity"].rebuild_annotation(),
|
|
282
|
+
Field(entity.identity, repr=False, exclude=True),
|
|
283
|
+
),
|
|
284
|
+
"namespace": (AnyUrl, Field(namespace, repr=False, exclude=True)),
|
|
285
|
+
"version": (Optional[str], Field(version, repr=False, exclude=True)),
|
|
286
|
+
"name": (str, Field(name, repr=False, exclude=True)),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
# Pre-calculate property types
|
|
290
|
+
property_types: dict[str, type[PropertyType]] = {
|
|
291
|
+
property_name: generate_property_type(property_value, data_source_dimensions)
|
|
292
|
+
for property_name, property_value in entity.properties.items()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
# Create the data source model's properties
|
|
296
|
+
properties: dict[str, tuple[type[PropertyType], GetData]] = {
|
|
297
|
+
# Value must be a (<type>, <default>) or (<type>, <FieldInfo>) tuple
|
|
298
|
+
# Note, Field() returns a FieldInfo instance (but is set to return an Any type).
|
|
299
|
+
property_name: (
|
|
300
|
+
property_types[property_name],
|
|
301
|
+
Field(
|
|
302
|
+
default_factory=get_piped_data,
|
|
303
|
+
description=property_value.description or "",
|
|
304
|
+
title=property_name.replace(" ", "_"),
|
|
305
|
+
json_schema_extra={
|
|
306
|
+
f"x-soft7-{field}": getattr(property_value, field)
|
|
307
|
+
for field in property_value.model_fields
|
|
308
|
+
if (
|
|
309
|
+
field not in ("description", "type")
|
|
310
|
+
and getattr(property_value, field)
|
|
311
|
+
)
|
|
312
|
+
},
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
for property_name, property_value in entity.properties.items()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# Ensure there is no overlap between the SOFT7 metadata fields and the data source
|
|
319
|
+
# model properties and the data source model properties are not trying to hack the
|
|
320
|
+
# attribute retrieval mechanism.
|
|
321
|
+
if any(field.startswith("soft7___") for field in properties):
|
|
322
|
+
raise ValueError(
|
|
323
|
+
"The data model properties are not allowed to overwrite or mock SOFT7 "
|
|
324
|
+
"metadata fields."
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
DataSourceModel = create_model(
|
|
328
|
+
f"{name.replace(' ', '')}DataSource",
|
|
329
|
+
__config__=None,
|
|
330
|
+
__doc__=generate_model_docstring(entity, property_types),
|
|
331
|
+
__base__=SOFT7DataSource,
|
|
332
|
+
__module__=module_namespace.__name__,
|
|
333
|
+
__validators__=None,
|
|
334
|
+
__cls_kwargs__=None,
|
|
335
|
+
**{
|
|
336
|
+
# SOFT7 metadata fields
|
|
337
|
+
**{f"soft7___{name}": value for name, value in soft7_metadata.items()},
|
|
338
|
+
# Data source properties
|
|
339
|
+
**properties,
|
|
340
|
+
},
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Register the classes with the generated_classes globals
|
|
344
|
+
module_namespace.register_class(DataSourceDimensionsModel)
|
|
345
|
+
module_namespace.register_class(DataSourceModel)
|
|
346
|
+
|
|
347
|
+
return DataSourceModel() # type: ignore[call-arg]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Generate SOFT7 entities, wrapped as pydantic data models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Optional
|
|
8
|
+
|
|
9
|
+
from pydantic import AnyUrl, ConfigDict, Field, create_model
|
|
10
|
+
|
|
11
|
+
from s7.pydantic_models.soft7_entity import (
|
|
12
|
+
SOFT7Entity,
|
|
13
|
+
parse_identity,
|
|
14
|
+
parse_input_entity,
|
|
15
|
+
)
|
|
16
|
+
from s7.pydantic_models.soft7_instance import (
|
|
17
|
+
SOFT7EntityInstance,
|
|
18
|
+
generate_dimensions_docstring,
|
|
19
|
+
generate_list_property_type,
|
|
20
|
+
generate_model_docstring,
|
|
21
|
+
generate_properties_docstring,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
25
|
+
from typing import Any, Union
|
|
26
|
+
|
|
27
|
+
from s7.pydantic_models.soft7_entity import (
|
|
28
|
+
ListPropertyType,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
LOGGER = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_entity(
|
|
36
|
+
entity: Union[SOFT7Entity, dict[str, Any], Path, AnyUrl, str],
|
|
37
|
+
) -> type[SOFT7EntityInstance]:
|
|
38
|
+
"""Create and return a SOFT7 entity as a pydantic model.
|
|
39
|
+
|
|
40
|
+
TODO: Utilize the `generated_classes` module and check whether we can return an
|
|
41
|
+
already created model based on the inputs given here.
|
|
42
|
+
|
|
43
|
+
TODO: Determine what to do with regards to differing inputs, but similar names.
|
|
44
|
+
|
|
45
|
+
Parameters:
|
|
46
|
+
entity: A SOFT7 entity (data model). It can be supplied as a URL reference,
|
|
47
|
+
path or as a raw JSON/YAML string or Python `dict`.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A SOFT7 entity as a pydantic model.
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
import s7.factories.generated_classes as module_namespace
|
|
54
|
+
|
|
55
|
+
# Parse the input entity
|
|
56
|
+
entity = parse_input_entity(entity)
|
|
57
|
+
|
|
58
|
+
# Split the identity into its parts
|
|
59
|
+
_, _, name = parse_identity(entity.identity)
|
|
60
|
+
|
|
61
|
+
# Create the entity model's dimensions
|
|
62
|
+
dimensions: dict[str, tuple[Union[type[Optional[int]], object], Any]] = (
|
|
63
|
+
# Value must be a (<type>, <default>) or (<type>, <FieldInfo>) tuple
|
|
64
|
+
# Note, Field() returns a FieldInfo instance (but is set to return an Any type).
|
|
65
|
+
{
|
|
66
|
+
dimension_name: (
|
|
67
|
+
Optional[int],
|
|
68
|
+
Field(None, description=dimension_description),
|
|
69
|
+
)
|
|
70
|
+
for dimension_name, dimension_description in entity.dimensions.items()
|
|
71
|
+
}
|
|
72
|
+
if entity.dimensions
|
|
73
|
+
else {}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if dimensions:
|
|
77
|
+
Dimensions = create_model(
|
|
78
|
+
f"{name.replace(' ', '')}EntityDimensions",
|
|
79
|
+
__config__=ConfigDict(extra="forbid", frozen=True, validate_default=False),
|
|
80
|
+
__doc__=generate_dimensions_docstring(entity),
|
|
81
|
+
__base__=None,
|
|
82
|
+
__module__=module_namespace.__name__,
|
|
83
|
+
__validators__=None,
|
|
84
|
+
__cls_kwargs__=None,
|
|
85
|
+
**dimensions,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Pre-calculate property types
|
|
89
|
+
property_types: dict[str, type[ListPropertyType]] = {
|
|
90
|
+
property_name: generate_list_property_type(property_value)
|
|
91
|
+
for property_name, property_value in entity.properties.items()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Create the entity model's properties
|
|
95
|
+
properties: dict[
|
|
96
|
+
str, tuple[Union[type[Optional[ListPropertyType]], object], Any]
|
|
97
|
+
] = {
|
|
98
|
+
# Value must be a (<type>, <default>) or (<type>, <FieldInfo>) tuple
|
|
99
|
+
# Note, Field() returns a FieldInfo instance (but is set to return an Any type).
|
|
100
|
+
property_name: (
|
|
101
|
+
Optional[property_types[property_name]],
|
|
102
|
+
Field(
|
|
103
|
+
None,
|
|
104
|
+
description=property_value.description or "",
|
|
105
|
+
title=property_name.replace(" ", "_"),
|
|
106
|
+
json_schema_extra={
|
|
107
|
+
f"x-soft7-{field}": getattr(property_value, field)
|
|
108
|
+
for field in property_value.model_fields
|
|
109
|
+
if (
|
|
110
|
+
field not in ("description", "type")
|
|
111
|
+
and getattr(property_value, field)
|
|
112
|
+
)
|
|
113
|
+
},
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
for property_name, property_value in entity.properties.items()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
Properties = create_model(
|
|
120
|
+
f"{name.replace(' ', '')}EntityProperties",
|
|
121
|
+
__config__=ConfigDict(extra="forbid", frozen=True, validate_default=False),
|
|
122
|
+
__doc__=generate_properties_docstring(entity, property_types),
|
|
123
|
+
__base__=None,
|
|
124
|
+
__module__=module_namespace.__name__,
|
|
125
|
+
__validators__=None,
|
|
126
|
+
__cls_kwargs__=None,
|
|
127
|
+
**properties,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Generate the fields_definitions for the final model
|
|
131
|
+
fields_definitions: dict[str, Any] = {
|
|
132
|
+
# Value must be a (<type>, <default>) or (<type>, <FieldInfo>) tuple
|
|
133
|
+
# Note, Field() returns a FieldInfo instance (but is set to return an Any type).
|
|
134
|
+
"properties": (
|
|
135
|
+
Properties,
|
|
136
|
+
Field(description=f"The {name} SOFT7 entity properties."),
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
if dimensions:
|
|
140
|
+
fields_definitions["dimensions"] = (
|
|
141
|
+
Dimensions,
|
|
142
|
+
Field(description=f"The {name} SOFT7 entity dimensions."),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
EntityInstance = create_model(
|
|
146
|
+
f"{name.replace(' ', '')}Entity",
|
|
147
|
+
__config__=None,
|
|
148
|
+
__doc__=generate_model_docstring(entity, property_types),
|
|
149
|
+
__base__=SOFT7EntityInstance,
|
|
150
|
+
__module__=module_namespace.__name__,
|
|
151
|
+
__validators__=None,
|
|
152
|
+
__cls_kwargs__=None,
|
|
153
|
+
**fields_definitions,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Set the entity class variable
|
|
157
|
+
EntityInstance.entity = entity
|
|
158
|
+
|
|
159
|
+
# Register the classes with the generated_classes globals
|
|
160
|
+
if dimensions:
|
|
161
|
+
module_namespace.register_class(Dimensions)
|
|
162
|
+
module_namespace.register_class(Properties)
|
|
163
|
+
module_namespace.register_class(EntityInstance)
|
|
164
|
+
|
|
165
|
+
return EntityInstance
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""A module to import certain factory-generated classes from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def register_class(cls: type) -> None:
|
|
7
|
+
"""Register a class with the module's globals.
|
|
8
|
+
|
|
9
|
+
Parameters:
|
|
10
|
+
cls: The class to register.
|
|
11
|
+
|
|
12
|
+
"""
|
|
13
|
+
globals()[cls.__name__] = cls
|
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Pydantic data models for the SOFT7 OTEAPI plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Annotated, Any, Optional
|
|
7
|
+
|
|
8
|
+
if sys.version_info >= (3, 10):
|
|
9
|
+
from typing import Literal
|
|
10
|
+
else:
|
|
11
|
+
from typing_extensions import Literal
|
|
12
|
+
|
|
13
|
+
from oteapi.models import AttrDict
|
|
14
|
+
from oteapi.strategies.mapping.mapping import MappingStrategyConfig
|
|
15
|
+
from pydantic import Field, field_validator
|
|
16
|
+
|
|
17
|
+
from s7.exceptions import EntityNotFound
|
|
18
|
+
from s7.factories import create_entity
|
|
19
|
+
from s7.pydantic_models.oteapi import HashableFunctionConfig
|
|
20
|
+
from s7.pydantic_models.soft7_entity import parse_identity, parse_input_entity
|
|
21
|
+
from s7.pydantic_models.soft7_instance import SOFT7EntityInstance
|
|
22
|
+
|
|
23
|
+
PrefixesType: Any = MappingStrategyConfig.model_fields["prefixes"].rebuild_annotation()
|
|
24
|
+
TriplesType: Any = MappingStrategyConfig.model_fields["triples"].rebuild_annotation()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SOFT7GeneratorConfig(AttrDict):
|
|
28
|
+
"""SOFT7 Generator strategy-specific configuration.
|
|
29
|
+
|
|
30
|
+
Inherit from the MappingStrategyConfig to include the prefixes and triples fields,
|
|
31
|
+
as well as any connected validation or serialization functionality that may be part
|
|
32
|
+
of the MappingStrategyConfig.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
entity: Annotated[
|
|
36
|
+
type[SOFT7EntityInstance],
|
|
37
|
+
Field(description="The SOFT7 entity to be used for the generator."),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Data mapping information
|
|
41
|
+
# Field added from a mapping strategy.
|
|
42
|
+
prefixes: Annotated[
|
|
43
|
+
Optional[PrefixesType],
|
|
44
|
+
Field(description=MappingStrategyConfig.model_fields["prefixes"].description),
|
|
45
|
+
] = None
|
|
46
|
+
triples: Annotated[
|
|
47
|
+
Optional[TriplesType],
|
|
48
|
+
Field(description=MappingStrategyConfig.model_fields["triples"].description),
|
|
49
|
+
] = None
|
|
50
|
+
|
|
51
|
+
# Parsed data content
|
|
52
|
+
# Field added from a parser strategy.
|
|
53
|
+
content: Annotated[
|
|
54
|
+
Optional[dict],
|
|
55
|
+
Field(description="The parsed data content to be used for the generator."),
|
|
56
|
+
] = None
|
|
57
|
+
|
|
58
|
+
@field_validator("entity", mode="before")
|
|
59
|
+
@classmethod
|
|
60
|
+
def ensure_entity_is_cls(cls, value: Any) -> type[SOFT7EntityInstance]:
|
|
61
|
+
"""Ensure the given entity is a SOFT7EntityInstance class."""
|
|
62
|
+
if isinstance(value, type) and issubclass(value, SOFT7EntityInstance):
|
|
63
|
+
# The entity is already a SOFT7EntityInstance (or subclass) type.
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
# The entity is not a SOFT7EntityInstance (or subclass) type.
|
|
67
|
+
# Try to parse it as a SOFT7 Entity.
|
|
68
|
+
try:
|
|
69
|
+
entity = parse_input_entity(value)
|
|
70
|
+
except (TypeError, EntityNotFound) as exc:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Invalid value {value!r} for the 'entity' field in "
|
|
73
|
+
f"SOFT7GeneratorConfig. Internal error: {exc}"
|
|
74
|
+
) from exc
|
|
75
|
+
|
|
76
|
+
# Try to retrieve the SOFT7EntityInstance class from the generated_classes
|
|
77
|
+
# module. If unsuccessful, we create it.
|
|
78
|
+
_, _, name = parse_identity(entity.identity)
|
|
79
|
+
name = name.replace(" ", "")
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
import s7.factories.generated_classes as lookup_module
|
|
83
|
+
|
|
84
|
+
return getattr(lookup_module, name)
|
|
85
|
+
except AttributeError:
|
|
86
|
+
return create_entity(entity)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SOFT7FunctionConfig(HashableFunctionConfig):
|
|
90
|
+
"""SOFT7 OTEAPI Function strategy configuration."""
|
|
91
|
+
|
|
92
|
+
functionType: Annotated[
|
|
93
|
+
Literal["soft7", "SOFT7"],
|
|
94
|
+
Field(
|
|
95
|
+
description=HashableFunctionConfig.model_fields["functionType"].description,
|
|
96
|
+
),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
configuration: Annotated[
|
|
100
|
+
SOFT7GeneratorConfig, Field(description=SOFT7GeneratorConfig.__doc__)
|
|
101
|
+
]
|