Skip to content

Commit 3911d1f

Browse files
authored
Merge pull request #1159 from james-garner-canonical/2024-10/style/type-hint-constraints
#1159 #### Description While looking into #1105, I found it useful to type annotate `juju/constraints.py` while figuring out how things work. #### QA Steps There should be no runtime changes in behaviour. Unit and integration tests should continue to pass, especially `tests/unit/test_constraints.py`. #### Notes & Discussion I also broke the longer regexes down across multiple lines and added comments to help me understand them. I don't mind cutting this PR back to use more minimal type annotations, etc if that's deemed more appropriate in review (e.g. Dict[str, Union[int, float, bool]] instead of the custom TypedDicts).
2 parents 927dd12 + c49e474 commit 3911d1f

1 file changed

Lines changed: 90 additions & 21 deletions

File tree

juju/constraints.py

Lines changed: 90 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
#
2020

2121
import re
22+
from typing import Dict, List, Optional, TypedDict, Union
23+
from typing_extensions import Required, NotRequired
24+
2225

2326
# Matches on a string specifying memory size
2427
MEM = re.compile('^[1-9][0-9]*[MGTP]$')
@@ -52,15 +55,37 @@
5255
"spaces",
5356
"virt_type",
5457
"zones",
55-
"allocate_public_ip"]
58+
"allocate_public_ip",
59+
]
5660

5761
LIST_KEYS = {'tags', 'spaces', 'zones'}
5862

5963
SNAKE1 = re.compile(r'(.)([A-Z][a-z]+)')
6064
SNAKE2 = re.compile('([a-z0-9])([A-Z])')
6165

6266

63-
def parse(constraints):
67+
ParsedValue = Union[int, bool, str]
68+
69+
70+
class ConstraintsDict(TypedDict, total=False):
71+
allocate_public_ip: ParsedValue
72+
arch: ParsedValue
73+
container: ParsedValue
74+
cores: ParsedValue
75+
cpu_cores: ParsedValue
76+
cpu_power: ParsedValue
77+
instance_role: ParsedValue
78+
instance_type: ParsedValue
79+
mem: ParsedValue
80+
root_disk: ParsedValue
81+
root_dist_source: ParsedValue
82+
spaces: List[ParsedValue]
83+
tags: List[ParsedValue]
84+
virt_type: ParsedValue
85+
zones: List[ParsedValue]
86+
87+
88+
def parse(constraints: Union[str, ConstraintsDict]) -> Optional[ConstraintsDict]:
6489
"""
6590
Constraints must be expressed as a string containing only spaces
6691
and key value pairs joined by an '='.
@@ -69,23 +94,26 @@ def parse(constraints):
6994
if not constraints:
7095
return None
7196

72-
if type(constraints) is dict:
97+
if isinstance(constraints, dict):
7398
# Fowards compatibilty: already parsed
7499
return constraints
75100

76-
normalized_constraints = {}
101+
normalized_constraints: ConstraintsDict = {}
77102
for s in constraints.split(" "):
78103
if "=" not in s:
79-
raise Exception("malformed constraint %s" % s)
104+
raise ValueError("malformed constraint %s" % s)
80105

81106
k, v = s.split("=")
82-
normalized_constraints[normalize_key(k)] = normalize_list_value(v) if\
83-
k in LIST_KEYS else normalize_value(v)
107+
normalized_constraints[normalize_key(k)] = (
108+
normalize_list_value(v)
109+
if k in LIST_KEYS
110+
else normalize_value(v)
111+
)
84112

85113
return normalized_constraints
86114

87115

88-
def normalize_key(orig_key):
116+
def normalize_key(orig_key: str) -> str:
89117
key = orig_key.strip()
90118

91119
key = key.replace("-", "_") # Our _client lib wants "_" in place of "-"
@@ -95,11 +123,11 @@ def normalize_key(orig_key):
95123
key = SNAKE2.sub(r'\1_\2', key).lower()
96124

97125
if key not in SUPPORTED_KEYS:
98-
raise Exception("unknown constraint in %s" % orig_key)
126+
raise ValueError("unknown constraint in %s" % orig_key)
99127
return key
100128

101129

102-
def normalize_value(value):
130+
def normalize_value(value: str) -> Union[int, bool, str]:
103131
value = value.strip()
104132

105133
if MEM.match(value):
@@ -117,22 +145,43 @@ def normalize_value(value):
117145
return value
118146

119147

120-
def normalize_list_value(value):
148+
def normalize_list_value(value: str) -> List[ParsedValue]:
121149
values = value.strip().split(',')
122150
return [normalize_value(value) for value in values]
123151

124152

125153
STORAGE = re.compile(
126-
'(?:(?:^|(?<=,))(?:|(?P<pool>[a-zA-Z]+[-?a-zA-Z0-9]*)|(?P<count>-?[0-9]+)|(?:(?P<size>-?[0-9]+(?:\\.[0-9]+)?)(?P<size_exp>[MGTPEZY])(?:i?B)?))(?:$|,))')
127-
128-
129-
def parse_storage_constraint(constraint):
130-
storage = {'count': 1}
154+
# original regex:
155+
# '(?:(?:^|(?<=,))(?:|(?P<pool>[a-zA-Z]+[-?a-zA-Z0-9]*)|(?P<count>-?[0-9]+)|(?:(?P<size>-?[0-9]+(?:\\.[0-9]+)?)(?P<size_exp>[MGTPEZY])(?:i?B)?))(?:$|,))'
156+
# with formatting and explanation -- note that this regex is used with re.finditer:
157+
'(?:'
158+
'(?:^|(?<=,))' # start of string or previous match ends with ','
159+
'(?:' # match one of the following:
160+
'|(?P<pool>[a-zA-Z]+[-?a-zA-Z0-9]*)' # * pool: a sequence starting with a letter, ending with a letter or number,
161+
# ------- and including letters, numbers and hyphens (no more than one in a row)
162+
'|(?P<count>-?[0-9]+)' # * count: an optional minus sign followed by one or more digits
163+
'|(?:' # * size (number) and size_exp (units):
164+
'(?P<size>-?[0-9]+(?:\\.[0-9]+)?)' # -- * an optional minus sign followed by one or more digits, optionally with decimal point and more digits
165+
'(?P<size_exp>[MGTPEZY])(?:i?B)?)' # -- * one of MGTPEZY, optionally followed by iB or B, for example 1M or 2.0MB or -3.3MiB
166+
')'
167+
'(?:$|,)' # end of string or ','
168+
')'
169+
)
170+
171+
172+
class StorageConstraintDict(TypedDict):
173+
count: Required[int] # >= 1
174+
pool: NotRequired[str]
175+
size: NotRequired[int]
176+
177+
178+
def parse_storage_constraint(constraint: str) -> StorageConstraintDict:
179+
storage: StorageConstraintDict = {'count': 1}
131180
for m in STORAGE.finditer(constraint):
132181
pool = m.group('pool')
133182
if pool:
134183
if 'pool' in storage:
135-
raise Exception("pool already specified")
184+
raise ValueError("pool already specified")
136185
storage['pool'] = pool
137186
count = m.group('count')
138187
if count:
@@ -145,15 +194,35 @@ def parse_storage_constraint(constraint):
145194

146195

147196
DEVICE = re.compile(
148-
'^(?P<count>[0-9]+)?(?:^|,)(?P<type>[^,]+)(?:$|,(?!$))(?P<attrs>(?:[^=]+=[^;]+)+)*$')
197+
# original regex:
198+
# '^(?P<count>[0-9]+)?(?:^|,)(?P<type>[^,]+)(?:$|,(?!$))(?P<attrs>(?:[^=]+=[^;]+)+)*$'
199+
# with formatting and explanation -- note this regex is used with re.match:
200+
'^' # start of string
201+
'(?P<count>[0-9]+)?' # count is 1+ digits, and is optional
202+
'(?:^|,)' # match start of string or a comma
203+
# -- so type can be at the start or comma separated from count
204+
'(?P<type>[^,]+)' # type is 1+ anything not a comma (including digits), and is required
205+
'(?:$|,(?!$))' # match end of string | or a non-trailing comma
206+
# -- so type can be at the end or followed by attrs
207+
'(?P<attrs>(?:[^=]+=[^;]+)+)*' # attrs is any number of semicolon separated key=value items
208+
# -- value can have spare '=' inside, possible not intended
209+
# -- attrs will be matched with ATTR.finditer afterwards in parse_device_constraint
210+
'$' # end of string
211+
)
149212
ATTR = re.compile(';?(?P<key>[^=]+)=(?P<value>[^;]+)')
150213

151214

152-
def parse_device_constraint(constraint):
215+
class DeviceConstraintDict(TypedDict):
216+
count: Required[int]
217+
type: Required[str]
218+
attributes: NotRequired[Dict[str, str]]
219+
220+
221+
def parse_device_constraint(constraint: str) -> DeviceConstraintDict:
153222
m = DEVICE.match(constraint)
154223
if m is None:
155-
raise Exception("device constraint does not match")
156-
device = {}
224+
raise ValueError("device constraint does not match")
225+
device: DeviceConstraintDict = {}
157226
count = m.group('count')
158227
if count:
159228
count = int(count)

0 commit comments

Comments
 (0)