openadr3-client-gac-compliance 1.4.0__tar.gz → 3.0.0a1__tar.gz

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 (15) hide show
  1. {openadr3_client_gac_compliance-1.4.0 → openadr3_client_gac_compliance-3.0.0a1}/PKG-INFO +12 -5
  2. {openadr3_client_gac_compliance-1.4.0 → openadr3_client_gac_compliance-3.0.0a1}/README.md +7 -2
  3. openadr3_client_gac_compliance-3.0.0a1/openadr3_client_gac_compliance/__init__.py +22 -0
  4. {openadr3_client_gac_compliance-1.4.0 → openadr3_client_gac_compliance-3.0.0a1}/openadr3_client_gac_compliance/gac20/__init__.py +1 -1
  5. {openadr3_client_gac_compliance-1.4.0 → openadr3_client_gac_compliance-3.0.0a1}/openadr3_client_gac_compliance/gac20/event_gac_compliant.py +61 -75
  6. openadr3_client_gac_compliance-3.0.0a1/openadr3_client_gac_compliance/gac20/plugin.py +43 -0
  7. {openadr3_client_gac_compliance-1.4.0 → openadr3_client_gac_compliance-3.0.0a1}/openadr3_client_gac_compliance/gac20/program_gac_compliant.py +31 -32
  8. openadr3_client_gac_compliance-3.0.0a1/openadr3_client_gac_compliance/gac20/ven_gac_compliant.py +49 -0
  9. openadr3_client_gac_compliance-3.0.0a1/pyproject.toml +82 -0
  10. openadr3_client_gac_compliance-1.4.0/openadr3_client_gac_compliance/__init__.py +0 -6
  11. openadr3_client_gac_compliance-1.4.0/openadr3_client_gac_compliance/config.py +0 -26
  12. openadr3_client_gac_compliance-1.4.0/openadr3_client_gac_compliance/gac20/ven_gac_compliant.py +0 -55
  13. openadr3_client_gac_compliance-1.4.0/pyproject.toml +0 -28
  14. {openadr3_client_gac_compliance-1.4.0 → openadr3_client_gac_compliance-3.0.0a1}/LICENSE.md +0 -0
  15. {openadr3_client_gac_compliance-1.4.0 → openadr3_client_gac_compliance-3.0.0a1}/openadr3_client_gac_compliance/gac21/__init__.py +0 -0
@@ -1,14 +1,16 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: openadr3-client-gac-compliance
3
- Version: 1.4.0
3
+ Version: 3.0.0a1
4
4
  Summary:
5
+ License-File: LICENSE.md
5
6
  Author: Nick van der Burgt
6
7
  Author-email: nick.van.der.burgt@elaad.nl
7
8
  Requires-Python: >=3.12, <4
8
9
  Classifier: Programming Language :: Python :: 3
9
10
  Classifier: Programming Language :: Python :: 3.12
10
11
  Classifier: Programming Language :: Python :: 3.13
11
- Requires-Dist: openadr3-client (>=0.0.7,<1.0.0)
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: openadr3-client (>=0.0.12a1,<0.0.13)
12
14
  Requires-Dist: pycountry (>=24.6.1,<25.0.0)
13
15
  Requires-Dist: pydantic (>=2.11.2,<3.0.0)
14
16
  Description-Content-Type: text/markdown
@@ -17,8 +19,13 @@ Description-Content-Type: text/markdown
17
19
 
18
20
  This repository contains a plugin for the [OpenADR3-client](https://github.com/ElaadNL/openadr3-client) library that adds additional pydantic validators to the OpenADR3 domain models to ensure GAC compliance. Since GAC compliance is a superset of OpenADR3, adding validation rules on top of the OpenADR3 models is sufficient to ensure compliance.
19
21
 
20
- To use this plugin, the package must be imported once globally. We recommend doing this in your root directories `__init__.py` file.
22
+ Registering the plugin is done using the global ValidatorPluginRegistry class:
21
23
 
22
24
  ```python
23
- import openadr3_client_gac_compliance # noqa: F401 (in case you use ruff)
25
+ from openadr3_client.plugin import ValidatorPluginRegistry, ValidatorPlugin
26
+ from openadr3_client_gac_compliance.gac20.plugin import Gac20ValidatorPlugin
27
+
28
+ ValidatorPluginRegistry.register_plugin(
29
+ Gac20ValidatorPlugin().setup()
30
+ )
24
31
  ```
@@ -2,8 +2,13 @@
2
2
 
3
3
  This repository contains a plugin for the [OpenADR3-client](https://github.com/ElaadNL/openadr3-client) library that adds additional pydantic validators to the OpenADR3 domain models to ensure GAC compliance. Since GAC compliance is a superset of OpenADR3, adding validation rules on top of the OpenADR3 models is sufficient to ensure compliance.
4
4
 
5
- To use this plugin, the package must be imported once globally. We recommend doing this in your root directories `__init__.py` file.
5
+ Registering the plugin is done using the global ValidatorPluginRegistry class:
6
6
 
7
7
  ```python
8
- import openadr3_client_gac_compliance # noqa: F401 (in case you use ruff)
8
+ from openadr3_client.plugin import ValidatorPluginRegistry, ValidatorPlugin
9
+ from openadr3_client_gac_compliance.gac20.plugin import Gac20ValidatorPlugin
10
+
11
+ ValidatorPluginRegistry.register_plugin(
12
+ Gac20ValidatorPlugin().setup()
13
+ )
9
14
  ```
@@ -0,0 +1,22 @@
1
+ """
2
+ OpenADR3 GAC Compliance Plugin.
3
+
4
+ This package provides validation plugins for OpenADR3 models to ensure compliance
5
+ with the Grid Aware Charging (GAC) specification.
6
+
7
+ The main entry point is the Gac20ValidatorPlugin which can be registered
8
+ with the OpenADR3 client's validator plugin registry.
9
+
10
+ Example:
11
+ ```python
12
+ from openadr3_client.plugin import ValidatorPluginRegistry
13
+ from openadr3_client_gac_compliance.plugin import Gac20ValidatorPlugin
14
+ from openadr3_client_gac_compliance.gac20.gac_plugin import GacVersion
15
+
16
+ # Register the GAC validation plugin
17
+ ValidatorPluginRegistry.register_plugin(
18
+ Gac20ValidatorPlugin.setup(gac_version=GacVersion.VERSION_2_0)
19
+ )
20
+ ```
21
+
22
+ """
@@ -1,4 +1,4 @@
1
1
  # This module imports all the GAC 3.0 compliance validators.
2
2
 
3
+ import openadr3_client_gac_compliance.gac20.event_gac_compliant
3
4
  import openadr3_client_gac_compliance.gac20.program_gac_compliant # noqa: F401
4
- import openadr3_client_gac_compliance.gac20.event_gac_compliant # noqa: F401
@@ -1,4 +1,5 @@
1
- """Module which implements GAC compliance validators for the event OpenADR3 types.
1
+ """
2
+ Module which implements GAC compliance validators for the event OpenADR3 types.
2
3
 
3
4
  This module validates all the object constraints and requirements on the OpenADR3 events resource
4
5
  as specified in the Grid aware charging (GAC) specification.
@@ -8,37 +9,37 @@ pydantic validators. Namely, the requirement that a safe mode event MUST be pres
8
9
 
9
10
  As the pydantic validator works on the scope of a single Event Object, it is not possible to validate
10
11
  that a safe mode event is present in a program. And it cannot be validated on the Program object,
11
- as the program object does not contain the events, these are stored seperately in the VTN.
12
+ as the program object does not contain the events, these are stored separately in the VTN.
12
13
  """
13
14
 
14
- from itertools import pairwise
15
15
  import re
16
- from typing import Tuple
17
- from openadr3_client.models.model import ValidatorRegistry, Model as ValidatorModel
16
+ from itertools import pairwise
17
+
18
18
  from openadr3_client.models.event.event import Event
19
19
  from openadr3_client.models.event.event_payload import EventPayloadType
20
-
21
- from pydantic import ValidationError
22
20
  from pydantic_core import InitErrorDetails, PydanticCustomError
23
21
 
22
+ INTERVAL_PERIOD_ERROR_MESSAGE = "'interval_period' must either be set on the event-level, or for each interval."
23
+
24
24
 
25
- def _continuous_or_seperated(self: Event) -> Tuple[Event, list[InitErrorDetails]]:
26
- """Enforces that events either have consistent interval definitions compliant with GAC.
25
+ def _continuous_or_separated(self: Event) -> list[InitErrorDetails]:
26
+ """
27
+ Validates that events have consistent interval definitions GAC compliant.
27
28
 
28
- the Grid aware charging (GAC) specification allows for two types of (mutually exclusive)
29
+ The Grid aware charging (GAC) specification allows for two types of (mutually exclusive)
29
30
  interval definitions:
30
31
 
31
32
  1. Continuous
32
- 2. Seperated
33
+ 2. Separated
33
34
 
34
- The continious implementation can be used when all intervals have the same duration.
35
- In this case, only the top-level intervalPeriod of the event can be used, and the intervalPeriods
35
+ The continuous implementation can be used when all intervals have the same duration.
36
+ In this case, only the top-level intervalPeriod of the event may be used, and the intervalPeriods
36
37
  of the individual intervals must be None.
37
38
 
38
- In the seperated intervalDefinition approach, the intervalPeriods must be set on each individual intervals,
39
- and the top-level intervalPeriod of the event must be None. This seperated approach is used when events have differing
39
+ In the separated implementation, the intervalPeriods must be set on each individual intervals,
40
+ and the top-level intervalPeriod of the event must be None. This separated implementation allows events to have differing
40
41
  durations.
41
- """
42
+ """ # noqa: E501
42
43
  validation_errors: list[InitErrorDetails] = []
43
44
 
44
45
  intervals = self.intervals or ()
@@ -52,7 +53,7 @@ def _continuous_or_seperated(self: Event) -> Tuple[Event, list[InitErrorDetails]
52
53
  InitErrorDetails(
53
54
  type=PydanticCustomError(
54
55
  "value_error",
55
- "Either 'interval_period' must be set on the event once, or every interval must have its own 'interval_period'.",
56
+ INTERVAL_PERIOD_ERROR_MESSAGE,
56
57
  ),
57
58
  loc=("intervals",),
58
59
  input=self.intervals,
@@ -62,15 +63,13 @@ def _continuous_or_seperated(self: Event) -> Tuple[Event, list[InitErrorDetails]
62
63
  else:
63
64
  # interval period set at top level of the event.
64
65
  # Ensure that all intervals do not have the interval_period defined, to comply with the GAC specification.
65
- duplicate_interval_period = [
66
- i for i in intervals if i.interval_period is not None
67
- ]
66
+ duplicate_interval_period = [i for i in intervals if i.interval_period is not None]
68
67
  if duplicate_interval_period:
69
68
  validation_errors.append(
70
69
  InitErrorDetails(
71
70
  type=PydanticCustomError(
72
71
  "value_error",
73
- "Either 'interval_period' must be set on the event once, or every interval must have its own 'interval_period'.",
72
+ INTERVAL_PERIOD_ERROR_MESSAGE,
74
73
  ),
75
74
  loc=("intervals",),
76
75
  input=self.intervals,
@@ -78,18 +77,19 @@ def _continuous_or_seperated(self: Event) -> Tuple[Event, list[InitErrorDetails]
78
77
  )
79
78
  )
80
79
 
81
- return self, validation_errors
80
+ return validation_errors
82
81
 
83
82
 
84
- def _targets_compliant(self: Event) -> Tuple[Event, list[InitErrorDetails]]:
85
- """Enforces that the targets of the event are compliant with GAC.
83
+ def _targets_compliant(self: Event) -> list[InitErrorDetails]:
84
+ """
85
+ Validates that the targets of the event are GAC compliant.
86
86
 
87
- GAC enforces the following constraints for targets:
87
+ The following constraints are enforced for targets:
88
88
 
89
89
  - The event must contain a POWER_SERVICE_LOCATION target.
90
90
  - The POWER_SERVICE_LOCATION target value must be a list of 'EAN18' values.
91
91
  - The event must contain a VEN_NAME target.
92
- - The VEN_NAME target value must be a list of 'ven object name' values (between 1 and 128 characters).
92
+ - The VEN_NAME target value must be a list of 'VEN name' values (between 1 and 128 characters).
93
93
  """
94
94
  validation_errors: list[InitErrorDetails] = []
95
95
  targets = self.targets or ()
@@ -141,7 +141,7 @@ def _targets_compliant(self: Event) -> Tuple[Event, list[InitErrorDetails]]:
141
141
  InitErrorDetails(
142
142
  type=PydanticCustomError(
143
143
  "value_error",
144
- "The event must contain only one VEN_NAME target.",
144
+ "The event must contain exactly one VEN_NAME target.",
145
145
  ),
146
146
  loc=("targets",),
147
147
  input=self.targets,
@@ -149,12 +149,7 @@ def _targets_compliant(self: Event) -> Tuple[Event, list[InitErrorDetails]]:
149
149
  )
150
150
  )
151
151
 
152
- if (
153
- power_service_locations
154
- and ven_names
155
- and len(power_service_locations) == 1
156
- and len(ven_names) == 1
157
- ):
152
+ if power_service_locations and ven_names and len(power_service_locations) == 1 and len(ven_names) == 1:
158
153
  power_service_location = power_service_locations[0]
159
154
  ven_name = ven_names[0]
160
155
 
@@ -163,7 +158,7 @@ def _targets_compliant(self: Event) -> Tuple[Event, list[InitErrorDetails]]:
163
158
  InitErrorDetails(
164
159
  type=PydanticCustomError(
165
160
  "value_error",
166
- "The POWER_SERVICE_LOCATION target value cannot be empty.",
161
+ "The POWER_SERVICE_LOCATION target value may not be empty.",
167
162
  ),
168
163
  loc=("targets",),
169
164
  input=self.targets,
@@ -171,9 +166,7 @@ def _targets_compliant(self: Event) -> Tuple[Event, list[InitErrorDetails]]:
171
166
  )
172
167
  )
173
168
 
174
- if not all(
175
- re.fullmatch(r"^EAN\d{15}$", v) for v in power_service_location.values
176
- ):
169
+ if not all(re.fullmatch(r"^\d{18}$", v) for v in power_service_location.values):
177
170
  validation_errors.append(
178
171
  InitErrorDetails(
179
172
  type=PydanticCustomError(
@@ -191,7 +184,7 @@ def _targets_compliant(self: Event) -> Tuple[Event, list[InitErrorDetails]]:
191
184
  InitErrorDetails(
192
185
  type=PydanticCustomError(
193
186
  "value_error",
194
- "The VEN_NAME target value cannot be empty.",
187
+ "The VEN_NAME target value may not be empty.",
195
188
  ),
196
189
  loc=("targets",),
197
190
  input=self.targets,
@@ -199,12 +192,12 @@ def _targets_compliant(self: Event) -> Tuple[Event, list[InitErrorDetails]]:
199
192
  )
200
193
  )
201
194
 
202
- if not all(1 <= len(v) <= 128 for v in ven_name.values):
195
+ if not all(1 <= len(v) <= 128 for v in ven_name.values): # noqa: PLR2004
203
196
  validation_errors.append(
204
197
  InitErrorDetails(
205
198
  type=PydanticCustomError(
206
199
  "value_error",
207
- "The VEN_NAME target value must be a list of 'ven object name' values (between 1 and 128 characters).",
200
+ "The VEN_NAME target value must be a list of 'VEN name' values (between 1 and 128 characters).",
208
201
  ),
209
202
  loc=("targets",),
210
203
  input=self.targets,
@@ -212,17 +205,18 @@ def _targets_compliant(self: Event) -> Tuple[Event, list[InitErrorDetails]]:
212
205
  )
213
206
  )
214
207
 
215
- return self, validation_errors
208
+ return validation_errors
216
209
 
217
210
 
218
- def _payload_descriptor_gac_compliant(
211
+ def _payload_descriptors_gac_compliant(
219
212
  self: Event,
220
- ) -> Tuple[Event, list[InitErrorDetails]]:
221
- """Enforces that the payload descriptor is GAC compliant.
213
+ ) -> list[InitErrorDetails]:
214
+ """
215
+ Validates that the payload descriptor is GAC compliant.
222
216
 
223
- GAC enforces the following constraints for payload descriptors:
217
+ The following constraints are enforced for payload descriptors:
224
218
 
225
- - The event interval must exactly one payload descriptor.
219
+ - The event interval must contain exactly one payload descriptor.
226
220
  - The payload descriptor must have a payload type of 'IMPORT_CAPACITY_LIMIT'
227
221
  - The payload descriptor must have a units of 'KW' (case sensitive).
228
222
  """
@@ -255,9 +249,9 @@ def _payload_descriptor_gac_compliant(
255
249
  )
256
250
  )
257
251
 
258
- payload_descriptor = self.payload_descriptors[0]
252
+ payload_descriptors = self.payload_descriptors[0]
259
253
 
260
- if payload_descriptor.payload_type != EventPayloadType.IMPORT_CAPACITY_LIMIT:
254
+ if payload_descriptors.payload_type != EventPayloadType.IMPORT_CAPACITY_LIMIT:
261
255
  validation_errors.append(
262
256
  InitErrorDetails(
263
257
  type=PydanticCustomError(
@@ -270,7 +264,7 @@ def _payload_descriptor_gac_compliant(
270
264
  )
271
265
  )
272
266
 
273
- if payload_descriptor.units != "KW":
267
+ if payload_descriptors.units != "KW":
274
268
  validation_errors.append(
275
269
  InitErrorDetails(
276
270
  type=PydanticCustomError(
@@ -283,13 +277,14 @@ def _payload_descriptor_gac_compliant(
283
277
  )
284
278
  )
285
279
 
286
- return self, validation_errors
280
+ return validation_errors
287
281
 
288
282
 
289
- def _event_interval_gac_compliant(self: Event) -> Tuple[Event, list[InitErrorDetails]]:
290
- """Enforces that the event interval is GAC compliant.
283
+ def _event_interval_gac_compliant(self: Event) -> list[InitErrorDetails]:
284
+ """
285
+ Validates that the event interval is GAC compliant.
291
286
 
292
- GAC enforces the following constraints for event intervals:
287
+ The following constraints are enforced for event intervals:
293
288
 
294
289
  - The event interval must have an id value that is strictly increasing.
295
290
  - The event interval must have exactly one payload.
@@ -365,21 +360,21 @@ def _event_interval_gac_compliant(self: Event) -> Tuple[Event, list[InitErrorDet
365
360
  )
366
361
  )
367
362
 
368
- return self, validation_errors
363
+ return validation_errors
369
364
 
370
365
 
371
- @ValidatorRegistry.register(Event, ValidatorModel())
372
- def event_gac_compliant(self: Event) -> Event:
373
- """Enforces that events are GAC compliant.
366
+ def validate_event_gac_compliant(event: Event) -> list[InitErrorDetails] | None:
367
+ """
368
+ Validates that events are GAC compliant.
374
369
 
375
- GAC enforces the following constraints for events:
370
+ The following constraints are enforced for events:
376
371
 
377
372
  - The event must not have a priority set.
378
- - The event must have either a continuous or seperated interval definition.
373
+ - The event must have either a continuous or separated interval definition.
379
374
  """
380
375
  validation_errors: list[InitErrorDetails] = []
381
376
 
382
- if self.priority is not None:
377
+ if event.priority is not None:
383
378
  validation_errors.append(
384
379
  InitErrorDetails(
385
380
  type=PydanticCustomError(
@@ -387,30 +382,21 @@ def event_gac_compliant(self: Event) -> Event:
387
382
  "The event must not have a priority set for GAC 2.0 compliance",
388
383
  ),
389
384
  loc=("priority",),
390
- input=self.priority,
385
+ input=event.priority,
391
386
  ctx={},
392
387
  )
393
388
  )
394
389
 
395
- interval_periods_validated, errors = _continuous_or_seperated(self)
390
+ errors = _continuous_or_separated(event)
396
391
  validation_errors.extend(errors)
397
392
 
398
- targets_validated, errors = _targets_compliant(interval_periods_validated)
393
+ errors = _targets_compliant(event)
399
394
  validation_errors.extend(errors)
400
395
 
401
- payload_descriptor_validated, errors = _payload_descriptor_gac_compliant(
402
- targets_validated
403
- )
396
+ errors = _payload_descriptors_gac_compliant(event)
404
397
  validation_errors.extend(errors)
405
398
 
406
- event_interval_validated, errors = _event_interval_gac_compliant(
407
- payload_descriptor_validated
408
- )
399
+ errors = _event_interval_gac_compliant(event)
409
400
  validation_errors.extend(errors)
410
401
 
411
- if validation_errors:
412
- raise ValidationError.from_exception_data(
413
- title=self.__class__.__name__, line_errors=validation_errors
414
- )
415
-
416
- return event_interval_validated
402
+ return validation_errors if validation_errors else None
@@ -0,0 +1,43 @@
1
+ """GAC compliance plugin for OpenADR3 client."""
2
+
3
+ from typing import Any
4
+
5
+ from openadr3_client.models.event.event import Event
6
+ from openadr3_client.models.program.program import Program
7
+ from openadr3_client.models.ven.ven import Ven
8
+ from openadr3_client.plugin import ValidatorPlugin
9
+
10
+ from openadr3_client_gac_compliance.gac20.event_gac_compliant import validate_event_gac_compliant
11
+ from openadr3_client_gac_compliance.gac20.program_gac_compliant import validate_program_gac_compliant
12
+ from openadr3_client_gac_compliance.gac20.ven_gac_compliant import validate_ven_gac_compliant
13
+
14
+
15
+ class Gac20ValidatorPlugin(ValidatorPlugin):
16
+ """Plugin that validates OpenADR3 models for GAC compliance."""
17
+
18
+ def __init__(self) -> None:
19
+ """Initialize the GAC validator plugin."""
20
+ super().__init__()
21
+
22
+ @staticmethod
23
+ def setup(*_args: Any, **_kwargs: Any) -> "Gac20ValidatorPlugin": # noqa: ANN401
24
+ """
25
+ Set up the GAC validator plugin.
26
+
27
+ Args:
28
+ *args: Positional arguments (unused).
29
+ **kwargs: Keyword arguments containing configuration.
30
+ Expected keys:
31
+ - gac_version: The GAC version to validate against.
32
+
33
+ Returns:
34
+ GacValidatorPlugin: Configured plugin instance.
35
+
36
+ """
37
+ plugin = Gac20ValidatorPlugin()
38
+
39
+ plugin.register_model_validator(Event, validate_event_gac_compliant)
40
+ plugin.register_model_validator(Program, validate_program_gac_compliant)
41
+ plugin.register_model_validator(Ven, validate_ven_gac_compliant)
42
+
43
+ return plugin
@@ -1,31 +1,37 @@
1
1
  """Module which implements GAC compliance validators for the program OpenADR3 types."""
2
2
 
3
- from openadr3_client.models.program.program import Program
4
- from openadr3_client.models.model import ValidatorRegistry, Model as ValidatorModel
5
-
6
3
  import re
7
4
 
8
- from pydantic import ValidationError
5
+ from openadr3_client.models.program.program import Program
9
6
  from pydantic_core import InitErrorDetails, PydanticCustomError
10
7
 
11
8
 
12
- @ValidatorRegistry.register(Program, ValidatorModel())
13
- def program_gac_compliant(self: Program) -> Program:
14
- """Enforces that the program is GAC compliant.
9
+ def validate_program_gac_compliant(program: Program) -> list[InitErrorDetails] | None:
10
+ """
11
+ Validates that the program is GAC compliant.
15
12
 
16
- GAC enforces the following constraints for programs:
13
+ The following constraints are enforced for programs:
17
14
  - The program must have a retailer name
18
15
  - The retailer name must be between 2 and 128 characters long.
19
16
  - The program MUST have a programType.
20
17
  - The programType MUST equal "DSO_CPO_INTERFACE-x.x.x, where x.x.x is the version as defined in the GAC specification.
21
- - The program MUST have bindingEvents set to True.
22
- are allowed there.
23
- """
18
+ - The program MUST have bindingEvents set to true.
19
+
20
+ """ # noqa: E501
24
21
  validation_errors: list[InitErrorDetails] = []
25
22
 
26
- program_type_regex = r"^DSO_CPO_INTERFACE-(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
23
+ program_type_regex = (
24
+ r"^DSO_CPO_INTERFACE-"
25
+ r"(0|[1-9]\d*)\."
26
+ r"(0|[1-9]\d*)\."
27
+ r"(0|[1-9]\d*)"
28
+ r"(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
29
+ r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))"
30
+ r"?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?"
31
+ r"$"
32
+ )
27
33
 
28
- if self.retailer_name is None:
34
+ if program.retailer_name is None:
29
35
  validation_errors.append(
30
36
  InitErrorDetails(
31
37
  type=PydanticCustomError(
@@ -33,13 +39,13 @@ def program_gac_compliant(self: Program) -> Program:
33
39
  "The program must have a retailer name.",
34
40
  ),
35
41
  loc=("retailer_name",),
36
- input=self.retailer_name,
42
+ input=program.retailer_name,
37
43
  ctx={},
38
44
  )
39
45
  )
40
46
 
41
- if self.retailer_name is not None and (
42
- len(self.retailer_name) < 2 or len(self.retailer_name) > 128
47
+ if program.retailer_name is not None and (
48
+ len(program.retailer_name) < 2 or len(program.retailer_name) > 128 # noqa: PLR2004
43
49
  ):
44
50
  validation_errors.append(
45
51
  InitErrorDetails(
@@ -48,12 +54,12 @@ def program_gac_compliant(self: Program) -> Program:
48
54
  "The retailer name must be between 2 and 128 characters long.",
49
55
  ),
50
56
  loc=("retailer_name",),
51
- input=self.retailer_name,
57
+ input=program.retailer_name,
52
58
  ctx={},
53
59
  )
54
60
  )
55
61
 
56
- if self.program_type is None:
62
+ if program.program_type is None:
57
63
  validation_errors.append(
58
64
  InitErrorDetails(
59
65
  type=PydanticCustomError(
@@ -61,13 +67,11 @@ def program_gac_compliant(self: Program) -> Program:
61
67
  "The program must have a program type.",
62
68
  ),
63
69
  loc=("program_type",),
64
- input=self.program_type,
70
+ input=program.program_type,
65
71
  ctx={},
66
72
  )
67
73
  )
68
- if self.program_type is not None and not re.fullmatch(
69
- program_type_regex, self.program_type
70
- ):
74
+ if program.program_type is not None and not re.fullmatch(program_type_regex, program.program_type):
71
75
  validation_errors.append(
72
76
  InitErrorDetails(
73
77
  type=PydanticCustomError(
@@ -75,27 +79,22 @@ def program_gac_compliant(self: Program) -> Program:
75
79
  "The program type must follow the format DSO_CPO_INTERFACE-x.x.x.",
76
80
  ),
77
81
  loc=("program_type",),
78
- input=self.program_type,
82
+ input=program.program_type,
79
83
  ctx={},
80
84
  )
81
85
  )
82
86
 
83
- if self.binding_events is False:
87
+ if program.binding_events is False:
84
88
  validation_errors.append(
85
89
  InitErrorDetails(
86
90
  type=PydanticCustomError(
87
91
  "value_error",
88
- "The program must have bindingEvents set to True.",
92
+ "The program must have bindingEvents set to true.",
89
93
  ),
90
94
  loc=("binding_events",),
91
- input=self.binding_events,
95
+ input=program.binding_events,
92
96
  ctx={},
93
97
  )
94
98
  )
95
99
 
96
- if validation_errors:
97
- raise ValidationError.from_exception_data(
98
- title=self.__class__.__name__, line_errors=validation_errors
99
- )
100
-
101
- return self
100
+ return validation_errors if validation_errors else None
@@ -0,0 +1,49 @@
1
+ import re
2
+
3
+ import pycountry
4
+ from openadr3_client.models.ven.ven import Ven
5
+ from pydantic_core import InitErrorDetails, PydanticCustomError
6
+
7
+
8
+ def validate_ven_gac_compliant(ven: Ven) -> list[InitErrorDetails] | None:
9
+ """
10
+ Validates that the VEN is GAC compliant.
11
+
12
+ The following constraints are enforced for VENs:
13
+ - The VEN must have a VEN name
14
+ - The VEN name must be an eMI3 identifier.
15
+
16
+ """
17
+ validation_errors: list[InitErrorDetails] = []
18
+
19
+ emi3_identifier_regex = r"^[A-Z]{2}-?[A-Z0-9]{3}$"
20
+
21
+ if not re.fullmatch(emi3_identifier_regex, ven.ven_name):
22
+ validation_errors.append(
23
+ InitErrorDetails(
24
+ type=PydanticCustomError(
25
+ "value_error",
26
+ "The VEN name must be formatted as an eMI3 identifier.",
27
+ ),
28
+ loc=("ven_name",),
29
+ input=ven.ven_name,
30
+ ctx={},
31
+ )
32
+ )
33
+
34
+ alpha_2_country = pycountry.countries.get(alpha_2=ven.ven_name[:2])
35
+
36
+ if alpha_2_country is None:
37
+ validation_errors.append(
38
+ InitErrorDetails(
39
+ type=PydanticCustomError(
40
+ "value_error",
41
+ "The first two characters of the VEN name must be a valid ISO 3166-1 alpha-2 country code.",
42
+ ),
43
+ loc=("ven_name",),
44
+ input=ven.ven_name,
45
+ ctx={},
46
+ )
47
+ )
48
+
49
+ return validation_errors if validation_errors else None
@@ -0,0 +1,82 @@
1
+ [project]
2
+ name = "openadr3-client-gac-compliance"
3
+ version = "3.0.0a1"
4
+ description = ""
5
+ authors = [
6
+ {name = "Nick van der Burgt", email = "nick.van.der.burgt@elaad.nl"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.12, <4"
10
+ dependencies = [
11
+ "pydantic (>=2.11.2,<3.0.0)",
12
+ "openadr3-client (>=0.0.12a1,<0.0.13)",
13
+ "pycountry (>=24.6.1,<25.0.0)",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
18
+ build-backend = "poetry.core.masonry.api"
19
+
20
+ [tool.poetry.dependencies]
21
+ openadr3-client = {version = "0.0.12a1", allow-prereleases = true}
22
+
23
+ [tool.poetry.group.dev.dependencies]
24
+ mypy = "^1.15.0"
25
+ ruff = "^0.11.4"
26
+ pytest = "^8.3.5"
27
+ pytest-cov = "^6.1.1"
28
+ taskipy = "^1.14.1"
29
+
30
+ [[tool.mypy.overrides]]
31
+ module = ["openadr3_client"]
32
+ ignore_missing_imports = true
33
+
34
+ [tool.taskipy.tasks]
35
+ local-ci = "task check && task format && task mypy && task test"
36
+ fix = "task check-fix && task format-fix && task mypy"
37
+ check = "ruff check ."
38
+ check-fix = "ruff check --fix ."
39
+ check-ci = "ruff check --output-format=github --no-cache ."
40
+ mypy = "mypy . --check-untyped-defs"
41
+ format = "ruff format . --diff"
42
+ format-fix = "ruff format ."
43
+ format-ci = "ruff format --diff --no-cache ."
44
+ test = "pytest --cov"
45
+
46
+ [tool.ruff]
47
+ line-length = 120
48
+
49
+ [tool.ruff.lint]
50
+ select = ["ALL"]
51
+
52
+ # Do not require docstrings for functions, methods and classes
53
+ ignore = [
54
+ # From:https://github.com/wemake-services/wemake-django-template/blob/master/pyproject.toml
55
+ "A005", # allow to shadow stdlib and builtin module names
56
+ "COM812", # trailing comma, conflicts with `ruff format`
57
+ # Different doc rules that we don't really care about:
58
+ "D100",
59
+ "D104",
60
+ "D106",
61
+ "D203",
62
+ "D212",
63
+ "D401",
64
+ "D404",
65
+ "D405",
66
+ "ISC001", # implicit string concat conflicts with `ruff format`
67
+ "ISC003", # prefer explicit string concat over implicit concat
68
+ "PLR09", # we have our own complexity rules
69
+ # "PLR2004", # do not report magic numbers -> I do want to report magic numbers
70
+ "PLR6301", # do not require classmethod / staticmethod when self not used
71
+ "TRY003", # long exception messages from `tryceratops`
72
+ # Start of own error codes
73
+ "ANN002", # Ignore unused *args
74
+ "ANN003", # Ignore unused **kwargs
75
+ "D107", # Allow undocumented __init__
76
+ "TC001", # Move to type checking block (does not play nice with pydantic models)
77
+ "TC002", # Move to type checking block (does not play nice with pydantic models)
78
+ ]
79
+
80
+ # Ignores S101 ("Use of 'assert' detected") for the tests.
81
+ [tool.ruff.lint.per-file-ignores]
82
+ "tests/*" = ["S101", "ANN201", "SLF001", "S106", "S105", "S311", "PLR2004", "PT018"]
@@ -1,6 +0,0 @@
1
- from openadr3_client_gac_compliance.config import GAC_VERSION
2
-
3
- if GAC_VERSION == "2.0":
4
- import openadr3_client_gac_compliance.gac20.program_gac_compliant # noqa: F401
5
- import openadr3_client_gac_compliance.gac20.event_gac_compliant # noqa: F401
6
- import openadr3_client_gac_compliance.gac20.ven_gac_compliant # noqa: F401
@@ -1,26 +0,0 @@
1
- """Contains configuration variables used by the OpenADR3 GAC compliance plugin."""
2
-
3
- from decouple import config
4
-
5
- VALID_GAC_VERSIONS: list[str] = ["2.0"]
6
-
7
-
8
- def _gac_version_cast(value: str) -> str:
9
- """Cast the GAC version to a string.
10
-
11
- Args:
12
- value (str): The GAC version to cast.
13
-
14
- Raises:
15
- ValueError: If the GAC version is not a valid GAC version.
16
-
17
- Returns:
18
- str: The GAC version.
19
- """
20
- if value not in VALID_GAC_VERSIONS:
21
- raise ValueError(f"Invalid GAC version: {value}")
22
- return value
23
-
24
-
25
- # The GAC version to use for the compliance validators.
26
- GAC_VERSION = config("GAC_VERSION", default="2.0", cast=_gac_version_cast)
@@ -1,55 +0,0 @@
1
- import re
2
- from openadr3_client.models.model import ValidatorRegistry, Model as ValidatorModel
3
- from openadr3_client.models.ven.ven import Ven
4
- import pycountry
5
-
6
- from pydantic import ValidationError
7
- from pydantic_core import InitErrorDetails, PydanticCustomError
8
-
9
-
10
- @ValidatorRegistry.register(Ven, ValidatorModel())
11
- def ven_gac_compliant(self: Ven) -> Ven:
12
- """Enforces that the ven is GAC compliant.
13
-
14
- GAC enforces the following constraints for vens:
15
- - The ven must have a ven name
16
- - The ven name must be an eMI3 identifier.
17
- """
18
- validation_errors: list[InitErrorDetails] = []
19
-
20
- emi3_identifier_regex = r"^[A-Z]{2}-?[A-Z0-9]{3}$"
21
-
22
- if not re.fullmatch(emi3_identifier_regex, self.ven_name):
23
- validation_errors.append(
24
- InitErrorDetails(
25
- type=PydanticCustomError(
26
- "value_error",
27
- "The ven name must be formatted as an eMI3 identifier.",
28
- ),
29
- loc=("ven_name",),
30
- input=self.ven_name,
31
- ctx={},
32
- )
33
- )
34
-
35
- alpha_2_country = pycountry.countries.get(alpha_2=self.ven_name[:2])
36
-
37
- if alpha_2_country is None:
38
- validation_errors.append(
39
- InitErrorDetails(
40
- type=PydanticCustomError(
41
- "value_error",
42
- "The first two characters of the ven name must be a valid ISO 3166-1 alpha-2 country code.",
43
- ),
44
- loc=("ven_name",),
45
- input=self.ven_name,
46
- ctx={},
47
- )
48
- )
49
-
50
- if validation_errors:
51
- raise ValidationError.from_exception_data(
52
- title=self.__class__.__name__, line_errors=validation_errors
53
- )
54
-
55
- return self
@@ -1,28 +0,0 @@
1
- [project]
2
- name = "openadr3-client-gac-compliance"
3
- version = "1.4.0"
4
- description = ""
5
- authors = [
6
- {name = "Nick van der Burgt", email = "nick.van.der.burgt@elaad.nl"}
7
- ]
8
- readme = "README.md"
9
- requires-python = ">=3.12, <4"
10
- dependencies = [
11
- "pydantic (>=2.11.2,<3.0.0)",
12
- "openadr3-client (>=0.0.7,<1.0.0)",
13
- "pycountry (>=24.6.1,<25.0.0)",
14
- ]
15
-
16
- [build-system]
17
- requires = ["poetry-core>=2.0.0,<3.0.0"]
18
- build-backend = "poetry.core.masonry.api"
19
-
20
- [tool.poetry.group.dev.dependencies]
21
- mypy = "^1.15.0"
22
- ruff = "^0.11.4"
23
- pytest = "^8.3.5"
24
- pytest-cov = "^6.1.1"
25
-
26
- [[tool.mypy.overrides]]
27
- module = ["openadr3_client", "decouple"]
28
- ignore_missing_imports = true