Skip to content

Commit 9747ec3

Browse files
authored
convert Len to GroupedMetadata, add MinLen and MaxLen (#21)
* convert Len to GroupedMetadata * oops, fix test case * simplify tests, fix linting * add MinLen, MaxLen to README
1 parent cf619a3 commit 9747ec3

File tree

4 files changed

+59
-19
lines changed

4 files changed

+59
-19
lines changed

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,14 @@ it easy to cause silent data corruption due to floating-point imprecision.
8787

8888
We encourage libraries to carefully document which interpretation they implement.
8989

90-
### Len
90+
### MinLen, MaxLen, Len
9191

9292
`Len()` implies that `min_inclusive <= len(value) < max_exclusive`.
93+
94+
As well as `Len()` which can optionally include upper and lower bounds, we also
95+
provide `MinLen(x)` and `MaxLen(y)` which are equivalent to `Len(min_inclusive=x)`
96+
and `Len(max_exclusive=y)` respectively.
97+
9398
We recommend that libraries interpret `slice` objects identically
9499
to `Len()`, making all the following cases equivalent:
95100

@@ -99,12 +104,13 @@ to `Len()`, making all the following cases equivalent:
99104
* `Annotated[list, slice(0, 10)]`
100105
* `Annotated[list, Len(0, 10)]`
101106
* `Annotated[list, Len(max_exclusive=10)]`
107+
* `Annotated[list, MaxLen(10)]`
102108

103-
And of course you can describe lists of three or more elements (`Len(min_inclusive=3)`),
109+
And of course you can describe lists of three or more elements (`Len(min_inclusive=3)` or `MinLen(3)`),
104110
four, five, or six elements (`Len(4, 7)` - note exclusive-maximum!) or *exactly*
105111
eight elements (`Len(8, 9)`).
106112

107-
Implementors: note that Len() should always have an integer value for
113+
Implementors: note that `Len()` should always have an integer value for
108114
`min_inclusive`, but `slice` objects can also have `start=None`.
109115

110116
### Timezone
@@ -167,7 +173,7 @@ class Field(GroupedMetadata):
167173

168174
Libraries consuming annotated-types constraints should check for `GroupedMetadata` and unpack it by iterating over the object and treating the results as if they had been "unpacked" in the `Annotated` type. The same logic should be applied to the [PEP 646 `Unpack` type](https://peps.python.org/pep-0646/), so that `Annotated[T, Field(...)]`, `Annotated[T, Unpack[Field(...)]]` and `Annotated[T, *Field(...)]` are all treated consistently.
169175

170-
Our own `annotated_types.Interval` class is a `GroupedMetadata` which unpacks itself into `Gt`, `Lt`, etc., so this is not an abstract concern.
176+
Our own `annotated_types.Interval` class is a `GroupedMetadata` which unpacks itself into `Gt`, `Lt`, etc., so this is not an abstract concern. Similarly, `annotated_types.Len` is a `GroupedMetadata` which unpacks itself into `MinLen` (optionally) and `MaxLen`.
171177

172178
### Consuming metadata
173179

annotated_types/__init__.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
'Le',
3434
'Interval',
3535
'MultipleOf',
36+
'MinLen',
37+
'MaxLen',
3638
'Len',
3739
'Timezone',
3840
'Predicate',
@@ -219,7 +221,29 @@ class MultipleOf(BaseMetadata):
219221

220222

221223
@dataclass(frozen=True, **SLOTS)
222-
class Len(BaseMetadata):
224+
class MinLen(BaseMetadata):
225+
"""
226+
MinLen() implies minimum inclusive length.
227+
228+
For more details, see ``Len()`` below.
229+
"""
230+
231+
min_inclusive: Annotated[int, Ge(0)]
232+
233+
234+
@dataclass(frozen=True, **SLOTS)
235+
class MaxLen(BaseMetadata):
236+
"""
237+
MaxLen() implies maximum exclusive length.
238+
239+
For more details, see ``Len()`` below.
240+
"""
241+
242+
max_exclusive: Annotated[int, Ge(0)]
243+
244+
245+
@dataclass(frozen=True, **SLOTS)
246+
class Len(GroupedMetadata):
223247
"""Len() implies that ``min_inclusive <= len(value) < max_exclusive``.
224248
225249
We also recommend that libraries interpret ``slice`` objects identically
@@ -239,6 +263,13 @@ class Len(BaseMetadata):
239263
min_inclusive: Annotated[int, Ge(0)] = 0
240264
max_exclusive: Optional[Annotated[int, Ge(0)]] = None
241265

266+
def __iter__(self) -> Iterator[BaseMetadata]:
267+
"""Unpack a Len into zone or more single-bounds."""
268+
if self.min_inclusive > 0:
269+
yield MinLen(self.min_inclusive)
270+
if self.max_exclusive is not None:
271+
yield MaxLen(self.max_exclusive)
272+
242273

243274
@dataclass(frozen=True, **SLOTS)
244275
class Timezone(BaseMetadata):

annotated_types/test_cases.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,18 @@ def cases() -> Iterable[Case]:
8080

8181
# lengths
8282

83+
yield Case(Annotated[str, at.MinLen(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
8384
yield Case(Annotated[str, at.Len(3)], ('123', '1234', 'x' * 10), ('', '1', '12'))
8485
yield Case(Annotated[str, 3:], ('123', '1234', 'x' * 10), ('', '1', '12'))
8586
yield Case(Annotated[str, 3:None], ('123', '1234', 'x' * 10), ('', '1', '12'))
87+
yield Case(Annotated[List[int], at.MinLen(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
8688
yield Case(Annotated[List[int], at.Len(3)], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
8789
yield Case(Annotated[List[int], 3:], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
8890
yield Case(Annotated[List[int], 3:None], ([1, 2, 3], [1, 2, 3, 4], [1] * 10), ([], [1], [1, 2]))
8991

92+
yield Case(Annotated[str, at.MaxLen(4)], ('', '123'), ('1234', 'x' * 10))
9093
yield Case(Annotated[str, at.Len(0, 4)], ('', '123'), ('1234', 'x' * 10))
94+
yield Case(Annotated[List[str], at.MaxLen(4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 4, ['b'] * 5))
9195
yield Case(Annotated[List[str], at.Len(0, 4)], ([], ['a', 'bcdef'], ['a', 'b', 'c']), (['a'] * 4, ['b'] * 5))
9296
yield Case(Annotated[str, 0:4], ('', '123'), ('1234', 'x' * 10))
9397
yield Case(Annotated[str, :4], ('', '123'), ('1234', 'x' * 10))

tests/test_main.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,14 @@ def check_multiple_of(constraint: Constraint, val: Any) -> bool:
4343
return val % constraint.multiple_of == 0
4444

4545

46-
def check_len(constraint: Constraint, val: Any) -> bool:
47-
if isinstance(constraint, slice):
48-
constraint = annotated_types.Len(constraint.start or 0, constraint.stop)
49-
assert isinstance(constraint, annotated_types.Len)
50-
if constraint.min_inclusive is None:
51-
raise TypeError
52-
if len(val) < constraint.min_inclusive:
53-
return False
54-
if constraint.max_exclusive is not None and len(val) >= constraint.max_exclusive:
55-
return False
56-
return True
46+
def check_min_len(constraint: Constraint, val: Any) -> bool:
47+
assert isinstance(constraint, annotated_types.MinLen)
48+
return len(val) >= constraint.min_inclusive
49+
50+
51+
def check_max_len(constraint: Constraint, val: Any) -> bool:
52+
assert isinstance(constraint, annotated_types.MaxLen)
53+
return len(val) < constraint.max_exclusive
5754

5855

5956
def check_predicate(constraint: Constraint, val: Any) -> bool:
@@ -84,9 +81,9 @@ def check_timezone(constraint: Constraint, val: Any) -> bool:
8481
annotated_types.Le: check_le,
8582
annotated_types.MultipleOf: check_multiple_of,
8683
annotated_types.Predicate: check_predicate,
87-
annotated_types.Len: check_len,
84+
annotated_types.MinLen: check_min_len,
85+
annotated_types.MaxLen: check_max_len,
8886
annotated_types.Timezone: check_timezone,
89-
slice: check_len,
9087
}
9188

9289

@@ -96,10 +93,12 @@ def get_constraints(tp: type) -> Iterator[Constraint]:
9693
args = iter(get_args(tp))
9794
next(args)
9895
for arg in args:
99-
if isinstance(arg, (annotated_types.BaseMetadata, slice)):
96+
if isinstance(arg, annotated_types.BaseMetadata):
10097
yield arg
10198
elif isinstance(arg, annotated_types.GroupedMetadata):
10299
yield from arg
100+
elif isinstance(arg, slice):
101+
yield from annotated_types.Len(arg.start or 0, arg.stop)
103102

104103

105104
def is_valid(tp: type, value: Any) -> bool:

0 commit comments

Comments
 (0)