@@ -69,6 +69,25 @@ class InputPathNotFoundError(Exception):
6969 '''Indicates that a requested studio input path does not exist.'''
7070
7171
72+ def load_yaml_file (filename ):
73+ '''Loads YAML and converts parser failures into concise input errors.'''
74+ try :
75+ with open (filename , encoding = 'utf8' ) as f :
76+ return yaml .safe_load (f )
77+ except yaml .YAMLError as err :
78+ problem = getattr (err , 'problem' , None ) or 'invalid YAML syntax'
79+ context = getattr (err , 'context' , None )
80+ if context :
81+ problem = f'{ context } ; { problem } '
82+ mark = (getattr (err , 'context_mark' , None )
83+ or getattr (err , 'problem_mark' , None ))
84+ location = ''
85+ if mark is not None :
86+ location = f' near line { mark .line + 1 } , column { mark .column + 1 } '
87+ raise ValueError (
88+ f"Invalid YAML in { filename } { location } : { problem } " ) from None
89+
90+
7291# CHANGE_SIGNATURE
7392# - substring used in workspace and change control names,
7493# used to identify changes automated by this script
@@ -204,46 +223,52 @@ def mergeInputs(root=None, path=None, inputs=None):
204223 return root
205224
206225
207- def get_input_paths (filename ):
208- '''Loads and validates one or more input paths from a YAML file.'''
209- with open (filename , encoding = 'utf8' ) as f :
210- config = yaml .safe_load (f )
226+ def get_input_paths (filename , args_studio_id ):
227+ '''Loads and validates one or more Studio IDs and input paths from YAML.'''
228+ config = load_yaml_file (filename )
211229
212230 configs = config if isinstance (config , list ) else [config ]
213231 if not configs :
214232 raise ValueError (f"Invalid YAML format in { filename } . "
215233 "Expected at least one path." )
216234
217- paths = []
235+ studio_paths = []
218236 for i , cfg in enumerate (configs , start = 1 ):
219- if not isinstance (cfg , dict ) or set (cfg ) != {'path' }:
237+ if (not isinstance (cfg , dict ) or 'path' not in cfg
238+ or not set (cfg ).issubset ({'studioId' , 'path' })):
239+ raise ValueError (f"Invalid YAML format in { filename } "
240+ f"for config at index { i } . "
241+ "Expected a dict containing 'path' and optionally "
242+ "'studioId'." )
243+ sid = cfg .get ('studioId' , args_studio_id )
244+ if not isinstance (sid , str ) or not sid :
220245 raise ValueError (f"Invalid YAML format in { filename } "
221246 f"for config at index { i } . "
222- "Expected a dict containing only a 'path' key ." )
247+ "The 'studioId' value must be a non-empty string ." )
223248 path = cfg ['path' ]
224249 if (not isinstance (path , list )
225250 or not all (isinstance (elem , str ) for elem in path )):
226251 raise ValueError (f"Invalid YAML format in { filename } "
227252 f"for config at index { i } . "
228253 "The 'path' value must be a list of strings." )
229- paths .append (path )
230- return paths , isinstance (config , list )
254+ studio_paths .append (( sid , path ) )
255+ return studio_paths , isinstance (config , list )
231256
232257
233- async def get_inputs (channel , filename , path_filename = None ):
258+ async def get_inputs (channel , args_studio_id , filename , path_filename = None ):
234259 '''
235260 Gets studio inputs from the mainline, defaulting to the root path,
236261 but optionally at paths in a YAML file.
237262 Dumps them into a file named <studio_id>_inputs.yaml.
238263 '''
239- sid = studio_id
264+ sid = args_studio_id
240265 if path_filename is not None :
241- paths , multiple = get_input_paths (path_filename )
266+ studio_paths , multiple = get_input_paths (path_filename , sid )
242267 stub = studio .InputsServiceStub (channel )
243268 path_inputs = []
244- for path in paths :
269+ for path_sid , path in studio_paths :
245270 key = studio .InputsKey (
246- studio_id = sid ,
271+ studio_id = path_sid ,
247272 workspace_id = MAINLINE_WS_ID ,
248273 path = fmp .RepeatedString (values = path ),
249274 )
@@ -252,9 +277,11 @@ async def get_inputs(channel, filename, path_filename=None):
252277 resp = await stub .get_one (req , timeout = RPC_TIMEOUT )
253278 except GRPCError as err :
254279 if err .status == Status .NOT_FOUND :
255- raise InputPathNotFoundError (path ) from None
280+ raise InputPathNotFoundError (
281+ f"studio { path_sid !r} , path { path } " ) from None
256282 raise
257283 path_inputs .append ({
284+ 'studioId' : path_sid ,
258285 'path' : path ,
259286 'inputs' : json .loads (resp .value .inputs ),
260287 })
@@ -274,7 +301,11 @@ async def get_inputs(channel, filename, path_filename=None):
274301 path = resp .value .key .path .values
275302 split = json .loads (resp .value .inputs )
276303 mergedinputs = mergeInputs (mergedinputs , path , split )
277- jsonPathInputs = {'path' : [], 'inputs' : mergedinputs }
304+ jsonPathInputs = {
305+ 'studioId' : sid ,
306+ 'path' : [],
307+ 'inputs' : mergedinputs ,
308+ }
278309 with open (filename , 'w' , encoding = 'utf8' ) as f :
279310 yaml .dump (jsonPathInputs , f )
280311
@@ -398,14 +429,15 @@ def getActionTriggers(filename):
398429 return input_path , dyn_names , dyn_values
399430
400431
401- async def update_inputs_via_autofill (channel , ws_id , path , dyn_names , dyn_value ):
432+ async def update_inputs_via_autofill (
433+ channel , ws_id , args_studio_id , action_id , path , dyn_names , dyn_value ):
402434 '''
403435 Sets inputs to the studio using autofill action.
404436 '''
405437 exec_id = str (uuid .uuid4 ())
406438 dynamicArgs = {
407439 "InputPath" : path ,
408- "StudioID" : studio_id ,
440+ "StudioID" : args_studio_id ,
409441 "WorkspaceID" : ws_id ,
410442 }
411443 for dyn_name in dyn_names :
@@ -441,13 +473,15 @@ async def update_inputs_via_autofill(channel, ws_id, path, dyn_names, dyn_value)
441473 break
442474
443475
444- async def update_inputs_via_yaml (channel , ws_id , filename , dev_ids ):
476+ async def update_inputs_via_yaml (
477+ channel , ws_id , args_studio_id , filename , dev_ids ):
445478 '''
446479 Adds or removes studio inputs using the yaml file.
447480 Optionally assigns studio to a set of devices.
448481
449482 Supports two formats:
450483 1. Single input update:
484+ studioId: ... # Optional; defaults to --studio-id
451485 path: [...]
452486 inputs: {...}
453487
@@ -462,8 +496,7 @@ async def update_inputs_via_yaml(channel, ws_id, filename, dev_ids):
462496 remove: true
463497 '''
464498 # convert YAML input file to json inputs.
465- with open (f'{ filename } ' , encoding = 'utf8' ) as f :
466- config = yaml .safe_load (f )
499+ config = load_yaml_file (filename )
467500
468501 # Determine if this is a single input update or multiple updates.
469502 configs = []
@@ -486,6 +519,12 @@ async def update_inputs_via_yaml(channel, ws_id, filename, dev_ids):
486519 f"for config at index { i } . "
487520 "Expected a dict with a 'path' key." )
488521
522+ config_studio_id = cfg .get ('studioId' , args_studio_id )
523+ if not isinstance (config_studio_id , str ) or not config_studio_id :
524+ raise ValueError (f"Invalid YAML format in { filename } "
525+ f"for config at index { i } . "
526+ "The 'studioId' value must be a non-empty string." )
527+
489528 has_inputs = 'inputs' in cfg
490529 remove = cfg .get ('remove' , False )
491530 if not isinstance (remove , bool ):
@@ -509,7 +548,7 @@ async def update_inputs_via_yaml(channel, ws_id, filename, dev_ids):
509548 "The 'path' value must be a list of strings." )
510549 key = studio .InputsKey (
511550 workspace_id = ws_id ,
512- studio_id = studio_id ,
551+ studio_id = config_studio_id ,
513552 path = fmp .RepeatedString (values = path )
514553 )
515554 if remove :
@@ -529,8 +568,8 @@ async def update_inputs_via_yaml(channel, ws_id, filename, dev_ids):
529568 value = value
530569 )
531570 await stub .set (req , timeout = RPC_TIMEOUT )
532- logger .info ('\t Studio inputs %s (%d/%d) - path: %s' ,
533- operation , i , len (configs ), path )
571+ logger .info ('\t Studio inputs %s (%d/%d) - studio: %s, path: %s' ,
572+ operation , i , len (configs ), config_studio_id , path )
534573
535574 logger .info ('All studio input updates applied from yaml: %s' , filename )
536575
@@ -542,7 +581,7 @@ async def update_inputs_via_yaml(channel, ws_id, filename, dev_ids):
542581 value = studio .AssignedTagsConfig (
543582 key = studio .StudioKey (
544583 workspace_id = ws_id ,
545- studio_id = studio_id
584+ studio_id = args_studio_id
546585 ),
547586 query = f'device:{ "," .join (dev_ids )} '
548587 )
@@ -1009,14 +1048,14 @@ async def main(args, client):
10091048 with client as channel :
10101049 # Get Inputs
10111050 if args .operation == 'get' :
1012- filename = f'{ studio_id } -inputs.yaml'
1051+ filename = f'{ args . studio_id } -inputs.yaml'
10131052 path_filename = args .yaml_file .name if args .yaml_file else None
1014- await get_inputs (channel , filename , path_filename )
1053+ await get_inputs (channel , args . studio_id , filename , path_filename )
10151054 logger .info ('Mainline inputs have been written to: %s' , filename )
10161055 return
10171056 # Set Inputs in Multiple Steps
10181057 # Create a workspace.
1019- workspace_name = f'{ studio_id } { CHANGE_SIGNATURE } '
1058+ workspace_name = f'{ args . studio_id } { CHANGE_SIGNATURE } '
10201059 if args .wsid :
10211060 ws_id = args .wsid
10221061 else :
@@ -1026,15 +1065,16 @@ async def main(args, client):
10261065 actionInvoked = False
10271066 if args .yaml_file :
10281067 await update_inputs_via_yaml (
1029- channel , ws_id , args .yaml_file .name , "*" )
1068+ channel , ws_id , args .studio_id , args . yaml_file .name , "*" )
10301069 inputSet = True
10311070 # Update the studio with autofill action
10321071 dyn_values = []
10331072 if args .action_file :
10341073 input_path , dyn_names , dyn_values = getActionTriggers (args .action_file .name )
10351074 for dyn_value in dyn_values :
10361075 await update_inputs_via_autofill (
1037- channel , ws_id , input_path , dyn_names , dyn_value )
1076+ channel , ws_id , args .studio_id , args .action_id ,
1077+ input_path , dyn_names , dyn_value )
10381078 actionInvoked = True
10391079 if not inputSet and not actionInvoked :
10401080 return
@@ -1139,7 +1179,8 @@ def check_cloudvision_version():
11391179 " Example:\n "
11401180 " - path: ['tenants', '[name=Arista1]', 'vlans', '[vlanId=100]']\n "
11411181 " remove: true\n "
1142- " - path: ['tenants', '[name=Arista1]', 'vlans', '[vlanId=101]']\n "
1182+ " - studioId: enterprise-routing-studio\n "
1183+ " path: ['tenants', '[name=Arista1]', 'vlans', '[vlanId=101]']\n "
11431184 " inputs:\n "
11441185 " name: vlan101\n "
11451186 " vlanId: 101\n "
@@ -1178,12 +1219,12 @@ def check_cloudvision_version():
11781219 parser .add_argument ("--insecure" , action = "store_true" , default = False ,
11791220 help = "skip TLS certificate verification" )
11801221 pargs = parser .parse_args ()
1181- studio_id = pargs .studio_id
1182- if pargs .action_id :
1183- action_id = pargs .action_id
11841222 conn = create_client (pargs )
11851223 try :
11861224 asyncio .run (main (pargs , conn ))
11871225 except InputPathNotFoundError as err :
11881226 logger .error ("Studio inputs path does not exist: %s" , err )
11891227 sys .exit (1 )
1228+ except ValueError as err :
1229+ logger .error ("%s" , err )
1230+ sys .exit (1 )
0 commit comments