Skip to content

Commit b33c6cf

Browse files
committed
feat!: Implement 'in' operator and chunksize overide for StepParameterSpaceIterator
* The in operator and validate_containment function of the StepParameterSpaceIterator will allow the openjd-cli to validate that input task parameters are within the parameter space of steps that it is running. The latter * The purpose of this change is to support chunked iteration. This validation helps ensure the `openjd run` command only accepts correct chunk, in addition to adding validation that was marked as a TODO in the openjd-cli codebase. Having validate_containment raise an exception with a message about why the task parameters are not in the parameter space results in better error messages. * The chunksize override lets a caller iterate over every task of a parameter space regardless of the space's chunk size or adaptivity. This is useful for callers to evaluate the whole task space so that they can perform their own chunking logic later. * Add a property chunks_parameter_name to the StepParameterSpaceIterator so that code using it can easily access the chunked parameter and its properties without having to perform its own loop over all of them. * Use an IntRangeExpr instead of a string to hold the range expression of a RangeExpressionTaskParameterDefinition, the class used to instantiate a parameter definition template into a job. * Found that negative steps in IntRange have bugs in corner cases. Code that is processing the list of IntRanges includes assumptions that the step is positive. To fix this, chose to normalize the step to be positive instead of adjusting the code to handle persistence of negative steps, as that is much simpler to get right. * Note that the IntRangeExpr was already not preserving the input order of a range expression, because it sorted all the components. * Removed _BaseMessageError and used ValueError instead as a base class for the exceptions. This reduces custom code and interoperates with Pydantic better. BREAKING CHANGE: The IntRangeExpr class now normalizes the steps of individual range components like "3-1:-2" to be positive like "1-3:2", so anything depending on the prior behavior needs to be updated. Signed-off-by: Mark Wiebe <399551+mwiebe@users.noreply.github.com>
1 parent 96c2b83 commit b33c6cf

8 files changed

Lines changed: 643 additions & 235 deletions

File tree

src/openjd/model/_errors.py

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,7 @@
1010
]
1111

1212

13-
class _BaseMessageError(Exception):
14-
"""A base class for exceptions that have an error message"""
15-
16-
msg: str
17-
"""The error message"""
18-
19-
def __init__(self, msg: str) -> None:
20-
self.msg = msg
21-
super(_BaseMessageError, self).__init__(msg)
22-
23-
def __str__(self) -> str:
24-
return self.msg
25-
26-
27-
class UnsupportedSchema(_BaseMessageError):
13+
class UnsupportedSchema(ValueError):
2814
"""Error raised when an attempt is made to decode a template with
2915
an unknown or otherwise nonvalid schema identification.
3016
"""
@@ -36,29 +22,23 @@ def __init__(self, version: str):
3622
super().__init__(f"Unsupported schema version: {self._version}")
3723

3824

39-
class DecodeValidationError(_BaseMessageError):
25+
class DecodeValidationError(ValueError):
4026
"""Error raised when an decoding error is encountered while decoding
4127
a template.
4228
"""
4329

44-
pass
4530

46-
47-
class ModelValidationError(_BaseMessageError):
31+
class ModelValidationError(ValueError):
4832
"""Error raised when a validation error is encountered while validating
4933
a model.
5034
"""
5135

52-
pass
53-
5436

55-
class ExpressionError(_BaseMessageError):
37+
class ExpressionError(ValueError):
5638
"""Error raised when there is an error in the form of an expression that is being
5739
parsed.
5840
"""
5941

60-
pass
61-
6242

6343
class TokenError(ExpressionError):
6444
"""Error raised when performing lexical analysis on an expression for parsing."""
@@ -68,9 +48,7 @@ def __init__(self, expression: str, token_value: str, position: int):
6848
super().__init__(msg)
6949

7050

71-
class CompatibilityError(_BaseMessageError):
51+
class CompatibilityError(ValueError):
7252
"""Error raised when a check that two, or more, models are compatible determines that
7353
there are non-compatibilities between the models.
7454
"""
75-
76-
pass

src/openjd/model/_range_expr.py

Lines changed: 67 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
from __future__ import annotations
44

5-
from bisect import bisect
5+
from bisect import bisect, bisect_left
66
from collections.abc import Iterator, Sized
7-
from functools import total_ordering
87
from itertools import chain
9-
from typing import Tuple
8+
from typing import Any, Tuple
9+
10+
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
11+
from pydantic.json_schema import JsonSchemaValue
12+
from pydantic_core import core_schema
1013

1114
from ._errors import ExpressionError, TokenError
1215
from ._tokenstream import Token, TokenStream, TokenType
@@ -15,15 +18,17 @@
1518
class IntRangeExpr(Sized):
1619
"""An Int Range Expression is a set of integer values represented as a sorted list of IntRange objects."""
1720

18-
_start: int
19-
_end: int
21+
_starts: list[int]
22+
_ends: list[int]
2023
_ranges: list[IntRange]
2124
_length: int
2225
_range_length_indicies: list[int]
2326

2427
def __init__(self, ranges: list[IntRange]):
28+
if len(ranges) <= 0:
29+
raise ExpressionError("Range expression cannot be empty")
2530
# Sort the ranges, then combine them where possible
26-
sorted_ranges = sorted(ranges)
31+
sorted_ranges = sorted(ranges, key=lambda v: (v._start, v._end, v._step))
2732
self._ranges = [sorted_ranges[0]]
2833
for range in sorted_ranges[1:]:
2934
if (
@@ -33,8 +38,8 @@ def __init__(self, ranges: list[IntRange]):
3338
self._ranges[-1] = IntRange(self._ranges[-1].start, range.end, range.step)
3439
else:
3540
self._ranges.append(range)
36-
self._start = self.ranges[0].start
37-
self._end = self.ranges[-1].end
41+
self._starts = [v.start for v in self.ranges]
42+
self._ends = [v.end for v in self.ranges]
3843

3944
# used to binary search ranges for __getitem__
4045
# ie. [32, 100, 132]
@@ -95,7 +100,7 @@ def __str__(self) -> str:
95100
return ",".join(str(range) for range in self.ranges)
96101

97102
def __repr__(self) -> str:
98-
return f"{type(self).__name__}({self.ranges})"
103+
return f"{type(self).__name__}.from_str({str(self)})"
99104

100105
def __iter__(self) -> Iterator[int]:
101106
return chain(*self.ranges)
@@ -120,15 +125,23 @@ def __getitem__(self, index: int) -> int:
120125
actual_index = index - self._range_length_indicies[range_index - 1]
121126
return self.ranges[range_index][actual_index]
122127

128+
def __contains__(self, value: object) -> bool:
129+
if not isinstance(value, int):
130+
return False
131+
range_index = bisect_left(self._ends, value)
132+
if range_index >= len(self._ends):
133+
return False
134+
return value in self.ranges[range_index]._range
135+
123136
@property
124137
def start(self) -> int:
125138
"""The smallest value in the range expression."""
126-
return self._start
139+
return self._starts[0]
127140

128141
@property
129142
def end(self) -> int:
130143
"""The largest value in the range expression"""
131-
return self._end
144+
return self._ends[-1]
132145

133146
@property
134147
def ranges(self) -> list[IntRange]:
@@ -137,10 +150,6 @@ def ranges(self) -> list[IntRange]:
137150

138151
def _validate(self) -> None:
139152
"""raises: ValueError - if not valid"""
140-
141-
if len(self) <= 0:
142-
raise ValueError("range expression cannot be empty")
143-
144153
# Validate that the ranges are not overlapping
145154
prev_range: IntRange | None = None
146155
for range_ in self.ranges:
@@ -156,31 +165,58 @@ def _validate(self) -> None:
156165
)
157166
prev_range = range_
158167

168+
@classmethod
169+
def _pydantic_validate(cls, value: Any) -> Any:
170+
if isinstance(value, IntRangeExpr):
171+
return value
172+
elif isinstance(value, str):
173+
return IntRangeExpr.from_str(value)
174+
elif isinstance(value, list):
175+
return IntRangeExpr.from_list(value)
176+
else:
177+
raise ValueError("Value must be an integer range expression or a list of integers.")
178+
179+
@classmethod
180+
def __get_pydantic_core_schema__(
181+
cls, source_type: type[Any], handler: GetCoreSchemaHandler
182+
) -> core_schema.CoreSchema:
183+
return core_schema.no_info_plain_validator_function(cls._pydantic_validate)
184+
185+
@classmethod
186+
def __get_pydantic_json_schema__(
187+
cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
188+
) -> JsonSchemaValue:
189+
return {"type": "string"}
190+
159191

160-
@total_ordering
161192
class IntRange(Sized):
162-
"""Inclusive on the start and end value"""
193+
"""A linear sequence of integers.
194+
195+
Both _start and _end are always included in the set of values, and _step is always positive."""
163196

164197
_start: int
165198
_end: int
166199
_step: int
167200
_range: range
168201

169202
def __init__(self, start: int, end: int, step: int = 1):
170-
self._start = start
171-
self._end = end
172-
self._step = step
173-
174-
# makes the range inclusive on end value
175-
offset = 0
176-
if self._step > 0:
177-
offset = 1
178-
elif self._step < 0:
179-
offset = -1
180-
181-
self._range = range(self._start, self._end + offset, self._step)
182-
183-
self._validate()
203+
if step > 0:
204+
if start > end:
205+
raise ValueError("Range: a descending range must have a negative step")
206+
self._range = range(start, end + 1, step)
207+
self._start = start
208+
self._end = self._range[-1]
209+
self._step = step
210+
elif step < 0:
211+
if start < end:
212+
raise ValueError("Range: an ascending range must have a positive step")
213+
# Reverse the range if the step is negative
214+
self._range = range(start, end - 1, step)
215+
self._start = self._range[-1]
216+
self._end = start
217+
self._step = -step
218+
else:
219+
raise ValueError("Range: step must not be zero")
184220

185221
def __str__(self) -> str:
186222
len_self = len(self)
@@ -204,11 +240,6 @@ def __eq__(self, other: object) -> bool:
204240
raise NotImplementedError
205241
return (self.start, self.end, self.step) == (other.start, other.end, other.step)
206242

207-
def __lt__(self, other: object) -> bool:
208-
if not isinstance(other, IntRange):
209-
raise NotImplementedError
210-
return (self.start, self.end, self.step) < (other.start, other.end, other.step)
211-
212243
def __iter__(self) -> Iterator[int]:
213244
return iter(self._range)
214245

@@ -232,21 +263,6 @@ def step(self) -> int:
232263
"""read-only property"""
233264
return self._step
234265

235-
def _validate(self) -> None:
236-
"""raises: ValueError - if not valid"""
237-
238-
if self._step == 0:
239-
raise ValueError("Range: step must not be zero")
240-
241-
if self._start < self._end and self._step < 0:
242-
raise ValueError("Range: an ascending range must have a positive step")
243-
244-
if self._start > self._end and self._step > 0:
245-
raise ValueError("Range: a descending range must have a negative step")
246-
247-
if len(self) <= 0:
248-
raise ValueError("Range: cannot be empty")
249-
250266

251267
class PosIntToken(Token):
252268
"""A positive integer"""

0 commit comments

Comments
 (0)