Setting the file. One moment.
Model Discovery · Foundry Iq · microsoft/azure-skills · Skills Docs
ContentsBack to the top of the page File Cu Canary
— line 229
This file
Number 10.55
Position 55 of 77
Type Python
Size 24 KB
Lines 451 helpers/ model_discovery.py
Python · 451 lines · 24 KB
12
from
._common
import
HelperFailure, canonical_bytes
13 from .bootstrap_azure import _error
14 except ImportError :
15 from _bootstrap_io import MAX_BYTES , failure, read_json, run_cli
16 from _common import HelperFailure, canonical_bytes
17 from bootstrap_azure import _error
18
19 API = "2024-10-01"
20 HOST = "https://management.azure.com"
21 GUID = r " [ 0-9a-fA-F ] {8} (?: - [ 0-9a-fA-F ] {4} ) {3} - [ 0-9a-fA-F ] {12} "
22 NAME = r " [ A-Za-z0-9 ][ A-Za-z0-9_.()- ] {0,89} "
23 ACCOUNT = re.compile(
24 rf "/subscriptions/( { GUID } )/resourceGroups/([^/] {{ 1,90 }} )"
25 rf "/providers/Microsoft.CognitiveServices/accounts/( { NAME } )" , re.I | re. ASCII
26 )
27 DEPLOYMENT = re.compile( ACCOUNT .pattern + rf "/deployments/( { NAME } )" , ACCOUNT .flags)
28 ENDPOINT_SUFFIXES = {
29 "embedding" : ( "openai.azure.com" , "services.ai.azure.com" , "cognitiveservices.azure.com" ),
30 "chat" : ( "openai.azure.com" , "services.ai.azure.com" , "cognitiveservices.azure.com" ),
31 "cu" : ( "services.ai.azure.com" ,),
32 }
33 LIMITS = { "pages" : 10 , "rows" : 200 , "endpoints" : 10 , "command_seconds" : 20 , "total_seconds" : 120 }
34 BOOLEAN_CAPABILITIES = ( "embeddings" , "chatCompletion" )
35 # Public examples: https://github.com/Azure/azure-cli/blob/dev/src/azure-cli/azure/cli/command_modules/cognitiveservices/tests/latest/recordings/test_cognitiveservices_deployment.yaml
36 TOKEN_CAPABILITIES = ( "maxContextToken" , "maxOutputToken" )
37 MAX_TOKEN_LIMIT = 2147483647
38 ACCOUNT_INVENTORY = " {id:id,name:name,kind:kind,location:location} "
39 ACCOUNT_METADATA = (
40 "{id:id,name:name,kind:kind,location:location,properties:"
41 "(type(properties) == 'object' && "
42 "{endpoint:properties.endpoint,endpoints:((contains(keys(properties), 'endpoints') && "
43 "[properties.endpoints] || [` {} `]) | [0]),"
44 "provisioningState:properties.provisioningState} || `false`)}"
45 )
46
47
48 def capability_query ():
49 fields = []
50 for key in BOOLEAN_CAPABILITIES :
51 value = "properties.capabilities." + key
52 # JMESPath 0.9.5 equates 1.0/0.0 with true/false; check types before equality.
53 fields.append( f " { key } :((type( { value } ) == 'string' && { value } == 'true' || "
54 f "type( { value } ) == 'boolean' && { value } == `true`) && 'true' || "
55 f "(type( { value } ) == 'string' && { value } == 'false' || "
56 f "type( { value } ) == 'boolean' && { value } == `false`) && 'false' || `null`)" )
57 for key in TOKEN_CAPABILITIES :
58 value = "properties.capabilities." + key
59 number = f "to_number( { value } )"
60 # Canonical decimal integers only; never echo a string based on its field name.
61 fields.append( f " { key } :( { number } > `0` && { number } <= ` { MAX_TOKEN_LIMIT } ` && "
62 f "to_string( { number } ) == to_string( { value } ) && "
63 f "!contains(to_string( { number } ), '.') && to_string( { number } ) || `null`)" )
64 return ( "(type(properties.capabilities) == 'object' && {"
65 + "," .join(fields) + "} || ` {} `)" )
66
67
68 DEPLOYMENT_METADATA = (
69 "{id:id,name:name,properties:(type(properties) == 'object' && "
70 "{model:((type(properties.model) == 'object' || !contains(keys(properties), 'model')) && "
71 " {name:properties.model.name,version:properties.model.version,format:properties.model.format} || `false`),"
72 "capabilities:" + capability_query() + ",provisioningState:properties.provisioningState} || `false`)}"
73 )
74 WARNINGS = [
75 "Metadata candidates only: not CU defaults/configuration, capacity, effective RBAC, network or model-call proof." ,
76 "Only the selected/default subscription and optional group were searched; no cross-subscription discovery." ,
77 "Public Azure ARM only; bounded discovery is not proof of absence outside its scope." ,
78 "Missing/invalid purpose capabilities are unknown; selection is not model suitability." ,
79 ]
80
81
82 def invalid (message = "Use exactly the documented discovery fields and selectors." ):
83 return failure( "discovery-input-invalid" , message)
84
85
86 def scalar (value):
87 if value is None :
88 return None
89 if not isinstance (value, str ) or not re.fullmatch( r " [ A-Za-z0-9_. ()/- ] {1,256} " , value):
90 raise failure( "discovery-readback-invalid" , "Metadata text is missing or unsafe; raw values withheld." )
91 return value
92
93
94 def public_capabilities (value):
95 if not isinstance (value, dict ):
96 return {}
97 projected = {}
98 for key in BOOLEAN_CAPABILITIES :
99 child = value.get(key)
100 if type (child) is bool :
101 projected[key] = "true" if child else "false"
102 elif isinstance (child, str ) and child in ( "true" , "false" ):
103 projected[key] = child
104 for key in TOKEN_CAPABILITIES :
105 child = value.get(key)
106 if type (child) is int :
107 child = str (child)
108 if ( isinstance (child, str ) and re.fullmatch( r " [ 1-9 ][ 0-9 ] {0,9} " , child)
109 and int (child) <= MAX_TOKEN_LIMIT ):
110 projected[key] = child
111 return projected
112
113
114 def safe_result (result):
115 """Defend the public boundary as well as the CLI/input normalization boundary."""
116 for row in result[ "deployments" ]:
117 row[ "capabilities" ] = public_capabilities(row[ "capabilities" ])
118 if result[ "selected" ] and result[ "selected" ][ "deployment" ]:
119 row = result[ "selected" ][ "deployment" ]
120 row[ "capabilities" ] = public_capabilities(row[ "capabilities" ])
121 if len (canonical_bytes(result)) + 1 > MAX_BYTES :
122 result.update( status = "blocked" , accounts = [], deployments = [], selected = None )
123 result[ "first_failure" ] = {
124 "code" : "discovery-limit" , "status" : None ,
125 "message" : "Required output exceeds one MiB; supply resource_group or an exact ID. No absence conclusion." ,
126 "request_id" : None , "message_digest" : None ,
127 }
128 return result
129
130
131 def resource_group (value):
132 # Microsoft.Resources: Unicode letters/decimal digits and _-().; no final period.
133 return ( isinstance (value, str ) and 1 <= len (value) <= 90 and not value.endswith( "." )
134 and all (c in "_-()." or c.isalpha() or c.isdecimal() for c in value))
135
136
137 def resource_match (value, * , deployment = False ):
138 pattern = DEPLOYMENT if deployment else ACCOUNT
139 match = pattern.fullmatch(value) if isinstance (value, str ) else None
140 return match if match and resource_group(match.group( 2 )) else None
141
142
143 def identity_key (value):
144 # Do not merge distinct Unicode names through multi-character folds (sharp-s -> ss).
145 return "" .join(folded if len (folded := c.casefold()) == 1 else c.lower() for c in value)
146
147
148 def origin (value, purpose):
149 if not isinstance (value, str ):
150 return None
151 suffixes = "|" .join(re.escape(suffix) for suffix in ENDPOINT_SUFFIXES .get(purpose, ()))
152 if suffixes and re.fullmatch( r "https:// [ a-z0-9 ][ a-z0-9- ] {0,62} \. (?: " + suffixes + r ")/ ? " , value):
153 return value.rstrip( "/" )
154 return None
155
156
157 def validate (request):
158 keys = { "schema_version" , "purpose" , "subscription_id" , "resource_group" ,
159 "account_id" , "account_name" , "deployment" }
160 if not isinstance (request, dict ) or not keys <= set (request) or set (request) - keys - { "endpoint" }:
161 raise invalid()
162 if request[ "schema_version" ] != "1.0" or request[ "purpose" ] not in ( "none" , "embedding" , "chat" , "cu" ):
163 raise invalid()
164 for field, pattern in (( "subscription_id" , GUID ), ( "account_name" , NAME )):
165 value = request[field]
166 if value is not None and ( not isinstance (value, str ) or not re.fullmatch(pattern, value)):
167 raise invalid()
168 if request[ "resource_group" ] is not None and not resource_group(request[ "resource_group" ]):
169 raise invalid()
170 aid, deployment = request[ "account_id" ], request[ "deployment" ]
171 if aid is not None and not resource_match(aid):
172 raise invalid()
173 if deployment is not None and (
174 not isinstance (deployment, str ) or not (re.fullmatch( NAME , deployment) or resource_match(deployment, deployment = True ))
175 ):
176 raise invalid()
177 if request[ "purpose" ] in ( "none" , "cu" ) and deployment is not None :
178 raise invalid()
179 dep = resource_match(deployment, deployment = True )
180 if dep:
181 parent = deployment.rsplit( "/" , 2 )[ 0 ]
182 if aid and identity_key(aid) != identity_key(parent):
183 raise invalid( "Deployment and account selectors disagree." )
184 aid = parent
185 if aid:
186 sub, group, name = resource_match(aid).groups()
187 for field, observed in (( "subscription_id" , sub), ( "resource_group" , group), ( "account_name" , name)):
188 if request[field] and identity_key(request[field]) != identity_key(observed):
189 raise invalid( "Explicit scope and exact resource selector disagree." )
190 if request.get( "endpoint" ) is not None and (
191 not origin(request[ "endpoint" ], request[ "purpose" ])
192 or not (aid or (request[ "account_name" ] and request[ "resource_group" ]))
193 ):
194 raise invalid( "An endpoint selector requires a supported origin and an exact account selector." )
195 return aid, dep.group( 4 ) if dep else deployment
196
197
198 def endpoint (properties, purpose, selected = None ):
199 """Endpoint map keys are not a documented CU discriminator."""
200 values = [( "properties.endpoint" , properties.get( "endpoint" ))]
201 mapping = properties.get( "endpoints" , {})
202 if not isinstance (mapping, dict ):
203 raise failure( "discovery-readback-invalid" , "ARM endpoints metadata is malformed." )
204 values += [( "properties.endpoints" , value) for value in mapping.values()]
205 candidates = {}
206 for source, value in values:
207 if value is None :
208 continue
209 if not isinstance (value, str ):
210 raise failure( "discovery-readback-invalid" , "ARM endpoint metadata is malformed." )
211 # Match an observed origin only; never rewrite hosts, append paths or fetch it.
212 normalized = origin(value, purpose)
213 if normalized:
214 candidates.setdefault(normalized, set ()).add(source)
215 if len (candidates) > LIMITS [ "endpoints" ]:
216 raise failure( "discovery-limit" , "More than ten supported endpoint origins; no partial choices." )
217 choices = sorted (candidates)
218 if selected is not None :
219 value = origin(selected, purpose)
220 if value not in candidates:
221 return None , [], "endpoint-selection-not-observed" , choices
222 elif len (candidates) != 1 :
223 return None , [], "endpoint-missing-or-ambiguous" , choices
224 else :
225 value = choices[ 0 ]
226 return value, sorted (candidates[value]), "candidate-only" , choices
227
228
229 class Reader :
230 def __init__ (self, cli, clock, * , aggregate = False ):
231 self .cli, self .clock, self .start = cli, clock, clock()
232 self .page_count, self .row_count, self .byte_count = 0 , 0 , 0
233 self .aggregate = aggregate
234
235 def call (self, arguments):
236 remaining = LIMITS [ "total_seconds" ] - ( self .clock() - self .start)
237 if remaining <= 0 :
238 raise failure( "discovery-limit" , "Discovery deadline reached; narrow scope or supply an exact ID." )
239 code, out, err = self .cli(arguments, min ( LIMITS [ "command_seconds" ], remaining))
240 if len (out) > MAX_BYTES or len (err) > MAX_BYTES :
241 raise failure( "discovery-limit" , "CLI output exceeded one MiB; narrow scope. No absence conclusion." )
242 if code:
243 raw = err or out
244 error = _error(raw)
245 match = re.search( r "request [\s - ] ? id [ \" ' \s : ] + ( " + GUID + ")" ,
246 raw.decode( "utf-8" , errors = "replace" ), re.I)
247 if match:
248 error.request_id = match.group( 1 )
249 raise error
250 self .byte_count += len (out) + len (err)
251 if self .aggregate and self .byte_count > MAX_BYTES :
252 raise failure( "discovery-limit" , "Aggregate CLI output exceeds one MiB; supply resource_group or an exact ID." )
253 try :
254 return json.loads(out)
255 except ( ValueError , UnicodeError , RecursionError ):
256 raise failure( "discovery-readback-invalid" , "CLI did not return valid JSON; no absence conclusion." )
257
258 def get (self, path, sub, projection = ACCOUNT_METADATA ):
259 return self .call([ "rest" , "--method" , "get" , "--url" , HOST + quote(path, safe = "/" ) + "?api-version=" + API ,
260 "--query" , projection, "--subscription" , sub])
261
262 def pages (self, path, sub, projection = ACCOUNT_INVENTORY ):
263 encoded = quote(path, safe = "/" )
264 url, rows, seen = HOST + encoded + "?api-version=" + API , [], set ()
265 # map preserves null/malformed entries and raw counts, unlike a filtering projection.
266 projection_query = "{value:map(&" + projection + ",value),nextLink:nextLink}"
267 while self .page_count < LIMITS [ "pages" ]:
268 if url in seen:
269 raise failure( "discovery-limit" , "Repeated ARM page; discovery incomplete." )
270 seen.add(url)
271 self .page_count += 1
272 result = self .call([ "rest" , "--method" , "get" , "--url" , url,
273 "--query" , projection_query, "--subscription" , sub])
274 if not isinstance (result, dict ) or not isinstance (result.get( "value" ), list ):
275 raise failure( "discovery-readback-invalid" , "ARM listing is incomplete or malformed." )
276 rows.extend(result[ "value" ])
277 self .row_count += len (result[ "value" ])
278 if self .row_count > LIMITS [ "rows" ]:
279 raise failure( "discovery-limit" , "More than 200 aggregate rows; supply resource_group or an exact ID." )
280 url = result.get( "nextLink" )
281 if not url:
282 return rows
283 if not isinstance (url, str ):
284 raise failure( "discovery-readback-invalid" , "ARM nextLink is malformed." )
285 try :
286 parts = urlsplit(url)
287 decoded_path = unquote(parts.path, errors = "strict" )
288 except ( ValueError , UnicodeError ):
289 raise failure( "discovery-readback-invalid" , "ARM nextLink is malformed." )
290 query = parse_qs(parts.query, keep_blank_values = True )
291 if ( any ( ord (c) < 33 or ord (c) == 127 or c.isspace() for c in url)
292 or parts.scheme != "https" or parts.netloc != "management.azure.com"
293 or identity_key(decoded_path) != identity_key(path) or parts.fragment
294 or query.get( "api-version" ) != [ API ]
295 or set (query) - { "api-version" , "$skiptoken" , "skiptoken" }):
296 raise failure( "discovery-scope-mismatch" , "ARM nextLink escaped the selected collection; not followed." )
297 url = HOST + encoded + "?" + parts.query
298 raise failure( "discovery-limit" , "Ten aggregate pages reached; supply resource_group or an exact ID. No partial choices." )
299
300
301 def account_row (raw, sub, group, purpose, selected_endpoint = None , * , inventory = False ):
302 if not isinstance (raw, dict ):
303 raise failure( "discovery-readback-invalid" , "Account readback is malformed." )
304 match = resource_match(raw.get( "id" ))
305 if not match or match.group( 1 ).casefold() != sub.casefold() or (
306 group and identity_key(match.group( 2 )) != identity_key(group)
307 ):
308 raise failure( "discovery-scope-mismatch" , "Account readback does not match the selected scope." )
309 if not isinstance (raw.get( "name" ), str ) or raw[ "name" ].casefold() != match.group( 3 ).casefold():
310 raise failure( "discovery-readback-invalid" , "Account name does not match its ARM ID." )
311 kind = scalar(raw.get( "kind" ))
312 if kind not in (( "AIServices" ,) if purpose == "cu" else ( "AIServices" , "OpenAI" )):
313 return None
314 props = {} if inventory else raw.get( "properties" , {})
315 if not isinstance (props, dict ):
316 raise failure( "discovery-readback-invalid" , "Account properties are malformed." )
317 uri, sources, state, candidates = endpoint(props, purpose, selected_endpoint)
318 if inventory:
319 state = "not-assessed"
320 return { "account_id" : raw[ "id" ], "name" : match.group( 3 ), "resource_group" : match.group( 2 ),
321 "location" : scalar(raw.get( "location" )), "kind" : kind,
322 "provisioning_state" : scalar(props.get( "provisioningState" )),
323 "endpoint" : uri, "endpoint_sources" : sources, "endpoint_state" : state,
324 "endpoint_candidates" : candidates, "selection_input" : None }
325
326
327 def discover (request, * , cli = run_cli, clock = time.monotonic):
328 result = { "schema_version" : "1.0" , "status" : "blocked" , "purpose" : None , "scope" : None ,
329 "accounts" : [], "deployments" : [], "selected" : None ,
330 "limits" : dict ( LIMITS ), "warnings" : list ( WARNINGS ), "first_failure" : None ,
331 "writes_performed" : []}
332 endpoint_unresolved = False
333 try :
334 aid, deployment = validate(request)
335 result[ "purpose" ] = purpose = request[ "purpose" ]
336 if purpose == "none" :
337 result.update( status = "skipped" , warnings = [ "Model-free route: zero CLI or Azure calls." ])
338 return result
339 reader = Reader(cli, clock, aggregate = True )
340 sub, group = request[ "subscription_id" ], request[ "resource_group" ]
341 if aid:
342 sub, group, _ = resource_match(aid).groups()
343 if sub is None :
344 context = reader.call([ "account" , "show" , "--query" , " {id:id} " ])
345 if not isinstance (context, dict ) or not re.fullmatch( GUID , str (context.get( "id" , "" ))):
346 raise failure( "discovery-context-unavailable" , "No valid signed-in default subscription; no account scan." )
347 sub = context[ "id" ]
348 result[ "scope" ] = { "subscription_id" : sub, "resource_group" : group}
349 name = request[ "account_name" ]
350 base = "/subscriptions/" + sub
351 if group:
352 base += "/resourceGroups/" + group
353 if aid is None and name and group:
354 aid = base + "/providers/Microsoft.CognitiveServices/accounts/" + name
355 if aid is None :
356 rows = reader.pages(base + "/providers/Microsoft.CognitiveServices/accounts" , sub)
357 accounts = [account_row(row, sub, group, purpose, inventory = True ) for row in rows]
358 accounts = [row for row in accounts if row and ( not name or row[ "name" ].casefold() == name.casefold())]
359 for candidate in accounts:
360 candidate[ "selection_input" ] = dict (
361 request, subscription_id = sub, resource_group = candidate[ "resource_group" ],
362 account_id = candidate[ "account_id" ], account_name = candidate[ "name" ],
363 endpoint = candidate[ "endpoint" ])
364 if len ({identity_key(row[ "account_id" ]) for row in accounts}) != len (accounts):
365 raise failure( "discovery-readback-invalid" , "Duplicate account IDs; selection is ambiguous." )
366 result[ "accounts" ] = sorted (accounts, key =lambda row: identity_key(row[ "account_id" ]))
367 if not name or len (accounts) != 1 :
368 result[ "status" ] = "account-choice-required" if accounts else "no-candidates"
369 return safe_result(result)
370 aid = accounts[ 0 ][ "account_id" ]
371 group = accounts[ 0 ][ "resource_group" ]
372 raw = reader.get(aid, sub)
373 row = account_row(raw, sub, group, purpose, request.get( "endpoint" ))
374 if row is None or identity_key(row[ "account_id" ]) != identity_key(aid):
375 raise failure( "discovery-scope-mismatch" , "Exact account is not a matching model/CU candidate." )
376 result[ "accounts" ] = [row]
377 row[ "selection_input" ] = dict (request, subscription_id = sub, resource_group = row[ "resource_group" ],
378 account_id = aid, account_name = row[ "name" ],
379 endpoint = row[ "endpoint" ] or request.get( "endpoint" ))
380 if row[ "endpoint" ] is None :
381 endpoint_unresolved = True
382 raise failure( "discovery-endpoint-unresolved" ,
383 "Resolve the endpoint using observed endpoint_candidates and selection_input; no deployment read, never guess." )
384 if purpose == "cu" :
385 result.update( status = "selected" , selected = { "account" : row, "deployment" : None })
386 return safe_result(result)
387 path = aid + "/deployments"
388 raws = ([reader.get(path + "/" + deployment, sub, DEPLOYMENT_METADATA )] if deployment
389 else reader.pages(path, sub, DEPLOYMENT_METADATA ))
390 for raw in raws:
391 if not isinstance (raw, dict ) or not isinstance (raw.get( "id" ), str ):
392 raise failure( "discovery-readback-invalid" , "Deployment metadata is malformed." )
393 match = resource_match(raw[ "id" ], deployment = True )
394 if not match or identity_key(raw[ "id" ].rsplit( "/" , 2 )[ 0 ]) != identity_key(aid) or (
395 deployment and match.group( 4 ).casefold() != deployment.casefold()
396 ):
397 raise failure( "discovery-scope-mismatch" , "Deployment readback escaped the selected account/name." )
398 if not isinstance (raw.get( "name" ), str ) or raw[ "name" ].casefold() != match.group( 4 ).casefold():
399 raise failure( "discovery-readback-invalid" , "Deployment name does not match its ARM ID." )
400 props = raw.get( "properties" )
401 if not isinstance (props, dict ):
402 raise failure( "discovery-readback-invalid" , "Deployment properties metadata is malformed." )
403 model = props.get( "model" , {})
404 if not isinstance (model, dict ):
405 raise failure( "discovery-readback-invalid" , "Deployment model metadata is malformed." )
406 capabilities = public_capabilities(props.get( "capabilities" ))
407 item = { "deployment_id" : raw[ "id" ], "name" : match.group( 4 ),
408 "model_name" : scalar(model.get( "name" )), "model_version" : scalar(model.get( "version" )),
409 "model_format" : scalar(model.get( "format" )), "capabilities" : capabilities,
410 "provisioning_state" : scalar(props.get( "provisioningState" )),
411 "selection_input" : dict (request, subscription_id = sub, resource_group = group,
412 account_id = aid, account_name = row[ "name" ], deployment = match.group( 4 ),
413 endpoint = row[ "endpoint" ])}
414 result[ "deployments" ].append(item)
415 result[ "deployments" ].sort( key =lambda row: identity_key(row[ "deployment_id" ]))
416 if len ({identity_key(row[ "deployment_id" ]) for row in result[ "deployments" ]}) != len (result[ "deployments" ]):
417 raise failure( "discovery-readback-invalid" , "Duplicate deployment IDs; selection is ambiguous." )
418 if deployment:
419 result.update( status = "selected" , selected = { "account" : row, "deployment" : result[ "deployments" ][ 0 ]})
420 else :
421 result[ "status" ] = "deployment-choice-required" if raws else "no-candidates"
422 except HelperFailure as exc:
423 result.update( status = "blocked" , selected = None , deployments = [])
424 if exc.code == "bootstrap-cli-output-limit" :
425 result[ "warnings" ].append(
426 "Required metadata exceeded discovery bounds; supply resource_group or an exact ID. No absence conclusion." )
427 if not endpoint_unresolved:
428 result[ "accounts" ] = []
429 result[ "first_failure" ] = { "code" : exc.code, "status" : exc.http_status, "message" : exc.message,
430 "request_id" : exc.request_id, "message_digest" : getattr (exc, "message_digest" , None )}
431 result[ "warnings" ].extend(exc.warnings)
432 return safe_result(result)
433
434
435 def main ():
436 parser = argparse.ArgumentParser( description = __doc__ )
437 parser.add_argument( "--input" , required = True )
438 args = parser.parse_args()
439 try :
440 request = read_json(args.input)
441 except HelperFailure as exc:
442 result = discover({})
443 result[ "first_failure" ].update( code = exc.code, message = exc.message)
444 else :
445 result = discover(request)
446 print (json.dumps(safe_result(result), sort_keys = True , separators = ( "," , ":" )))
447 return 2 if result[ "status" ] == "blocked" else 0
448
449
450 if __name__ == "__main__" :
451 raise SystemExit (main())