importloggingimportrefromdataclassesimportdataclass,fieldfromenumimportEnumfromtypingimport(Any,Callable,Dict,Generator,Generic,List,Optional,Tuple,TypeVar,Union,overload,)from._exchangeimportExchange,ExchangeGeneratorfrom.responsesimport(BitMuxFieldInfo,BitOutFieldInfo,BlockInfo,Changes,EnumFieldInfo,ExtOutBitsFieldInfo,ExtOutFieldInfo,FieldInfo,PosMuxFieldInfo,PosOutFieldInfo,ScalarFieldInfo,SubtypeTimeFieldInfo,TableFieldDetails,TableFieldInfo,TimeFieldInfo,UintFieldInfo,)# Define the public API of this module__all__=["Command","CommandException","Raw","Get","GetLine","GetMultiline","Put","Append","Arm","Disarm","GetBlockInfo","GetFieldInfo","GetPcapBitsLabels","ChangeGroup","GetChanges","GetState","SetState",]T=TypeVar("T")T2=TypeVar("T2")T3=TypeVar("T3")T4=TypeVar("T4")# Checks whether the server will interpret cmd as a table command: search for# first of '?', '=', '<', if '<' found first then it's a multiline command.MULTILINE_COMMAND=re.compile(r"^[^?=]*<")defis_multiline_command(cmd:str):returnMULTILINE_COMMAND.match(cmd)isnotNone
[docs]@dataclassclassCommand(Generic[T]):"""Abstract baseclass for all ControlConnection commands to be inherited from"""defexecute(self)->ExchangeGenerator[T]:# A generator that sends lines to the PandA, gets lines back, and returns a# responseraiseNotImplementedError(self)
[docs]classCommandException(Exception):"""Raised if a `Command` receives a mal-formed response"""
# `execute_commands()` actually returns a list with length equal to the number# of tasks passed; however, Tuple is used similar to the annotation for# zip() because typing does not support variadic type variables. See# typeshed PR #1550 for discussion.@overloaddef_execute_commands(c1:Command[T])->ExchangeGenerator[Tuple[T]]:...@overloaddef_execute_commands(c1:Command[T],c2:Command[T2])->ExchangeGenerator[Tuple[T,T2]]:...@overloaddef_execute_commands(c1:Command[T],c2:Command[T2],c3:Command[T3])->ExchangeGenerator[Tuple[T,T2,T3]]:...@overloaddef_execute_commands(c1:Command[T],c2:Command[T2],c3:Command[T3],c4:Command[T4])->ExchangeGenerator[Tuple[T,T2,T3,T4]]:...@overloaddef_execute_commands(*commands:Command[Any],)->ExchangeGenerator[Tuple[Any,...]]:...def_execute_commands(*commands):"""Call the `Command.execute` method on each of the commands to produce some `Exchange` generators, which are yielded back to the connection, then zip together the responses to those exchanges into a tuple"""# If we add type annotations to this function then mypy complains:# Overloaded function implementation does not accept all possible arguments# As we want to type check this, we put the logic in _zip_with_returnret=yield from_zip_with_return([command.execute()forcommandincommands])returnretdef_zip_with_return(generators:List[ExchangeGenerator[Any]],)->ExchangeGenerator[Tuple[Any,...]]:# Sentinel to show what generators are not yet exhaustedpending=object()returns=[pending]*len(generators)whileTrue:yields:List[Exchange]=[]fori,geninenumerate(generators):# If we haven't exhausted the generatorifreturns[i]ispending:try:# Get the exchanges that it wants to fill inexchanges=next(gen)exceptStopIterationase:# Generator is exhausted, store its return valuereturns[i]=e.valueelse:# Add the exchanges to the listifisinstance(exchanges,list):yields+=exchangeselse:yields.append(exchanges)ifyields:# There were some Exchanges yielded, so yield them all up# for the Connection to fill inyieldyieldselse:# All the generators are exhausted, so return the tuple of all# their return valuesreturntuple(returns)
[docs]@dataclassclassRaw(Command[List[str]]):"""Send a raw command Args: inp: The input lines to send For example:: Raw(["PCAP.ACTIVE?"]) -> ["OK =1"] Raw(["SEQ1.TABLE>", "1", "1", "0", "0", ""]) -> ["OK"] Raw(["SEQ1.TABLE?"]) -> ["!1", "!1", "!0", "!0", "."]) """inp:List[str]defexecute(self)->ExchangeGenerator[List[str]]:ex=Exchange(self.inp)yieldexreturnex.received
[docs]@dataclassclassGet(Command[Union[str,List[str]]]):"""Get the value of a field or star command. If the form of the expected return is known, consider using `GetLine` or `GetMultiline` instead. Args: field: The field, attribute, or star command to get For example:: Get("PCAP.ACTIVE") -> "1" Get("SEQ1.TABLE") -> ["1048576", "0", "1000", "1000"] Get("*IDN") -> "PandA 1.1..." """field:strdefexecute(self)->ExchangeGenerator[Union[str,List[str]]]:ex=Exchange(f"{self.field}?")yieldexifex.is_multiline:returnex.multilineelse:# We got OK =valueline=ex.lineassertline.startswith("OK =")returnline[4:]
[docs]@dataclassclassGetLine(Command[str]):"""Get the value of a field or star command, when the result is expected to be a single line. Args: field: The field, attribute, or star command to get For example:: GetLine("PCAP.ACTIVE") -> "1" GetLine("*IDN") -> "PandA 1.1..." """field:strdefexecute(self)->ExchangeGenerator[str]:ex=Exchange(f"{self.field}?")yieldex# Expect "OK =value"line=ex.lineassertline.startswith("OK =")returnline[4:]
[docs]@dataclassclassGetMultiline(Command[List[str]]):"""Get the value of a field or star command, when the result is expected to be a multiline response. Args: field: The field, attribute, or star command to get For example:: GetMultiline("SEQ1.TABLE") -> ["1048576", "0", "1000", "1000"] GetMultiline("*METADATA.*") -> ["LABEL_FILTER1", "APPNAME", ...] """field:strdefexecute(self)->ExchangeGenerator[List[str]]:ex=Exchange(f"{self.field}?")yieldexreturnex.multiline
[docs]@dataclassclassPut(Command[None]):"""Put the value of a field. Args: field: The field, attribute, or star command to put value: The value, either string or list of strings or empty, to put For example:: Put("PCAP.TRIG", "PULSE1.OUT") Put("SEQ1.TABLE", ["1048576", "0", "1000", "1000"]) Put("SFP3_SYNC_IN1.SYNC_RESET") """field:strvalue:Union[str,List[str]]=""defexecute(self)->ExchangeGenerator[None]:ifisinstance(self.value,list):# Multiline table with blank line to terminateex=Exchange([f"{self.field}<"]+self.value+[""])else:ex=Exchange(f"{self.field}={self.value}")yieldexassertex.line=="OK"
[docs]@dataclassclassAppend(Command[None]):"""Append the value of a table field. Args: field: The field, attribute, or star command to append value: The value, list of strings, to append For example:: Append("SEQ1.TABLE", ["1048576", "0", "1000", "1000"]) """field:strvalue:List[str]defexecute(self)->ExchangeGenerator[None]:# Multiline table with blank line to terminateex=Exchange([f"{self.field}<<"]+self.value+[""])yieldexassertex.line=="OK"
[docs]classArm(Command[None]):"""Arm PCAP for an acquisition by sending ``*PCAP.ARM=``"""defexecute(self)->ExchangeGenerator[None]:ex=Exchange("*PCAP.ARM=")yieldexassertex.line=="OK"
[docs]classDisarm(Command[None]):"""Disarm PCAP, stopping acquisition by sending ``*PCAP.DISARM=``"""defexecute(self)->ExchangeGenerator[None]:ex=Exchange("*PCAP.DISARM=")yieldexassertex.line=="OK"
[docs]@dataclassclassGetBlockInfo(Command[Dict[str,BlockInfo]]):"""Get the name, number, and description of each block type in a dictionary, alphabetically ordered Args: skip_description: If `True`, prevents retrieving the description for each Block. This will reduce network calls. For example:: GetBlockInfo() -> { "LUT": BlockInfo(number=8, description="Lookup table"), "PCAP": BlockInfo(number=1, description="Position capture control"), ... } """skip_description:bool=Falsedefexecute(self)->ExchangeGenerator[Dict[str,BlockInfo]]:ex=Exchange("*BLOCKS?")yieldexblocks_list,commands=[],[]forlineinex.multiline:block,num=line.split()blocks_list.append((block,int(num)))commands.append(GetLine(f"*DESC.{block}"))ifself.skip_description:# Must use tuple() to match type returned by _execute_commandsdescription_values=tuple(Nonefor_incommands)else:description_values=yield from_execute_commands(*commands)block_infos={block:BlockInfo(number=num,description=desc)for(block,num),descinsorted(zip(blocks_list,description_values))}returnblock_infos
# The type of the generators used for creating the Get commands for each field# and setting the returned data into the FieldInfo structure_FieldGeneratorType=Generator[Union[Exchange,List[Exchange]],None,Tuple[str,FieldInfo],]
[docs]@dataclassclassGetFieldInfo(Command[Dict[str,FieldInfo]]):"""Get the fields of a block, returning a `FieldInfo` (or appropriate subclass) for each one, ordered to match the definition order in the PandA Args: block: The name of the block type extended_metadata: If `True`, retrieves detailed metadata about a field and all of its attributes. This will cause an additional network round trip. If `False` only the field names and types will be returned. Default `True`. For example:: GetFieldInfo("LUT") -> { "INPA": BitMuxFieldInfo(type='bit_mux', subtype=None, description='Input A', max_delay=5 label=['TTLIN1.VAL', 'TTLIN2.VAL', ...]), ...} """block:strextended_metadata:bool=True_commands_map:Dict[Tuple[str,Optional[str]],Callable[[str,str,Optional[str]],_FieldGeneratorType,],]=field(init=False,repr=False,default_factory=dict)def__post_init__(self):# Map a (type, subtype) to a method that returns the appropriate# subclasss of FieldInfo, and a list of all the Commands to request.# Note that fields that do not have additional attributes are not listed.self._commands_map={# Order matches that of PandA server's Field Types docs("time",None):self._time,("bit_out",None):self._bit_out,("pos_out",None):self._pos_out,("ext_out","timestamp"):self._ext_out,("ext_out","samples"):self._ext_out,("ext_out","bits"):self._ext_out_bits,("bit_mux",None):self._bit_mux,("pos_mux",None):self._pos_mux,("table",None):self._table,("param","uint"):self._uint,("read","uint"):self._uint,("write","uint"):self._uint,("param","int"):self._no_attributes,("read","int"):self._no_attributes,("write","int"):self._no_attributes,("param","scalar"):self._scalar,("read","scalar"):self._scalar,("write","scalar"):self._scalar,("param","bit"):self._no_attributes,("read","bit"):self._no_attributes,("write","bit"):self._no_attributes,("param","action"):self._no_attributes,("read","action"):self._no_attributes,("write","action"):self._no_attributes,("param","lut"):self._no_attributes,("read","lut"):self._no_attributes,("write","lut"):self._no_attributes,("param","enum"):self._enum,("read","enum"):self._enum,("write","enum"):self._enum,("param","time"):self._subtype_time,("read","time"):self._subtype_time,("write","time"):self._subtype_time,}def_get_desc(self,field_name:str)->GetLine:"""Create the Command to retrieve the description"""returnGetLine(f"*DESC.{self.block}.{field_name}")def_uint(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:desc,maximum=yield from_execute_commands(self._get_desc(field_name),GetLine(f"{self.block}1.{field_name}.MAX"),)field_info=UintFieldInfo(field_type,field_subtype,desc,int(maximum))returnfield_name,field_infodef_scalar(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:desc,units,scale,offset=yield from_execute_commands(self._get_desc(field_name),GetLine(f"{self.block}.{field_name}.UNITS"),GetLine(f"{self.block}.{field_name}.SCALE"),GetLine(f"{self.block}.{field_name}.OFFSET"),)field_info=ScalarFieldInfo(field_type,field_subtype,desc,units,float(scale),int(offset))returnfield_name,field_infodef_subtype_time(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:desc,units_labels=yield from_execute_commands(self._get_desc(field_name),GetMultiline(f"*ENUMS.{self.block}.{field_name}.UNITS"),)field_info=SubtypeTimeFieldInfo(field_type,field_subtype,desc,units_labels)returnfield_name,field_infodef_enum(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:desc,labels=yield from_execute_commands(self._get_desc(field_name),GetMultiline(f"*ENUMS.{self.block}.{field_name}"),)field_info=EnumFieldInfo(field_type,field_subtype,desc,labels)returnfield_name,field_infodef_time(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:desc,units,min=yield from_execute_commands(self._get_desc(field_name),GetMultiline(f"*ENUMS.{self.block}.{field_name}.UNITS"),GetLine(f"{self.block}1.{field_name}.MIN"),)field_info=TimeFieldInfo(field_type,field_subtype,desc,units,float(min))returnfield_name,field_infodef_bit_out(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:desc,capture_word,offset=yield from_execute_commands(self._get_desc(field_name),GetLine(f"{self.block}1.{field_name}.CAPTURE_WORD"),GetLine(f"{self.block}1.{field_name}.OFFSET"),)field_info=BitOutFieldInfo(field_type,field_subtype,desc,capture_word,int(offset))returnfield_name,field_infodef_bit_mux(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:desc,max_delay,labels=yield from_execute_commands(self._get_desc(field_name),GetLine(f"{self.block}1.{field_name}.MAX_DELAY"),GetMultiline(f"*ENUMS.{self.block}.{field_name}"),)field_info=BitMuxFieldInfo(field_type,field_subtype,desc,int(max_delay),labels)returnfield_name,field_infodef_pos_mux(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:desc,labels=yield from_execute_commands(self._get_desc(field_name),GetMultiline(f"*ENUMS.{self.block}.{field_name}"),)field_info=PosMuxFieldInfo(field_type,field_subtype,desc,labels)returnfield_name,field_infodef_table(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:# Ignore the ROW_WORDS attribute as it's new and won't be present on all PandAs,# and there's no easy way to try it and catch an error while also running other# Get commands at the same timetable_desc,max_length,fields=yield from_execute_commands(self._get_desc(field_name),GetLine(f"{self.block}1.{field_name}.MAX_LENGTH"),GetMultiline(f"{self.block}1.{field_name}.FIELDS"),)# Keep track of highest bit indexmax_bit_offset:int=0desc_gets:List[GetLine]=[]enum_field_gets:List[GetMultiline]=[]enum_field_names:List[str]=[]fields_dict:Dict[str,TableFieldDetails]={}forfield_detailsinfields:# Fields are of the form <bit_high>:<bit_low> <name> <subtype>bit_range,name,subtype=field_details.split()bit_high_str,bit_low_str=bit_range.split(":")bit_high=int(bit_high_str)bit_low=int(bit_low_str)ifbit_high>max_bit_offset:max_bit_offset=bit_highifsubtype=="enum":enum_field_gets.append(GetMultiline(f"*ENUMS.{self.block}1.{field_name}[].{name}"))enum_field_names.append(name)fields_dict[name]=TableFieldDetails(subtype,bit_low,bit_high)desc_gets.append(GetLine(f"*DESC.{self.block}1.{field_name}[].{name}"))# Calculate the number of 32 bit words that comprises one table rowrow_words=max_bit_offset//32+1# The first len(enum_field_gets) items are enum labels, type List[str]# The second part of the list are descriptions, type strlabels_and_descriptions=yield from_execute_commands(*enum_field_gets,*desc_gets)forname,labelsinzip(enum_field_names,labels_and_descriptions[:len(enum_field_gets)]):fields_dict[name].labels=labelsforname,descinzip(fields_dict.keys(),labels_and_descriptions[len(enum_field_gets):]):fields_dict[name].description=descfield_info=TableFieldInfo(field_type,field_subtype,table_desc,int(max_length),fields_dict,row_words,)returnfield_name,field_infodef_pos_out(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:desc,capture_labels=yield from_execute_commands(self._get_desc(field_name),GetMultiline(f"*ENUMS.{self.block}.{field_name}.CAPTURE"),)field_info=PosOutFieldInfo(field_type,field_subtype,desc,capture_labels)returnfield_name,field_infodef_ext_out(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:desc,capture_labels=yield from_execute_commands(self._get_desc(field_name),GetMultiline(f"*ENUMS.{self.block}.{field_name}.CAPTURE"),)field_info=ExtOutFieldInfo(field_type,field_subtype,desc,capture_labels)returnfield_name,field_infodef_ext_out_bits(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:desc,bits,capture_labels=yield from_execute_commands(self._get_desc(field_name),GetMultiline(f"{self.block}.{field_name}.BITS"),GetMultiline(f"*ENUMS.{self.block}.{field_name}.CAPTURE"),)field_info=ExtOutBitsFieldInfo(field_type,field_subtype,desc,capture_labels,bits)returnfield_name,field_infodef_no_attributes(self,field_name:str,field_type:str,field_subtype:Optional[str])->_FieldGeneratorType:"""Calling this method indicates type-subtype pair is known and has no attributes, so only a description needs to be retrieved"""desc=yield fromself._get_desc(field_name).execute()returnfield_name,FieldInfo(field_type,field_subtype,desc)defexecute(self)->ExchangeGenerator[Dict[str,FieldInfo]]:ex=Exchange(f"{self.block}.*?")yieldexunsorted:Dict[int,Tuple[str,FieldInfo]]={}field_generators:List[ExchangeGenerator]=[]forlineinex.multiline:field_name,index,type_subtype=line.split(maxsplit=2)field_type:strsubtype:Optional[str]# Append "None" to list below so there are always at least 2 elements# so we can always unpack into subtype, even if no split occurs.field_type,subtype,*_=[*type_subtype.split(maxsplit=1),None]# Always create default FieldInfo. If necessary we will replace it later# with a more type-specific version.field_info=FieldInfo(field_type,subtype,None)ifself.extended_metadata:try:# Construct the list of type-specific generatorsfield_generators.append(self._commands_map[(field_type,subtype)](field_name,field_type,subtype))exceptKeyError:# This exception will be hit if PandA ever defines new typeslogging.exception(f"Unknown type {(field_type,subtype)} detected for "f"{field_name}, cannot retrieve extended information for it.")# We can assume the new field will have a description thoughfield_generators.append(self._no_attributes(field_name,field_type,subtype))# Keep track of order of fields as returned by PandA. Important for later# matching descriptions back to their field.unsorted[int(index)]=(field_name,field_info)# Dict keeps insertion order, so insert in the order the server saidfields={name:fieldfor_,(name,field)insorted(unsorted.items())}ifself.extended_metadataisFalse:# Asked to not perform the requests for extra metadata.returnfieldsfield_name_info:Tuple[Tuple[str,FieldInfo],...]field_name_info=yield from_zip_with_return(field_generators)fields.update(field_name_info)returnfields
[docs]classGetPcapBitsLabels(Command):"""Get the labels for the bit fields in PCAP. For example:: GetPcapBitsLabels() -> {"BITS0" : ["TTLIN1.VAL", "TTLIN2.VAL", ...], ...} """defexecute(self)->ExchangeGenerator[Dict[str,List[str]]]:ex=Exchange("PCAP.*?")yieldexbits_fields=[]forlineinex.multiline:split=line.split()iflen(split)==4:field_name,_,field_type,field_subtype=splitiffield_type=="ext_out"andfield_subtype=="bits":bits_fields.append(f"PCAP.{field_name}")exchanges=[Exchange(f"{field}.BITS?")forfieldinbits_fields]yieldexchangesbits={field:ex.multilineforfield,exinzip(bits_fields,exchanges)}returnbits
[docs]classChangeGroup(Enum):"""Which group of values to ask for ``*CHANGES`` on: https://pandablocks-server.readthedocs.io/en/latest/commands.html#system-commands """#: All the groups belowALL=""#: Configuration settingsCONFIG=".CONFIG"#: Bits on the system busBITS=".BITS"#: PositionsPOSN=".POSN"#: Polled read valuesREAD=".READ"#: Attributes (included capture enable flags)ATTR=".ATTR"#: Table changesTABLE=".TABLE"#: Table changesMETADATA=".METADATA"
[docs]@dataclassclassGetChanges(Command[Changes]):"""Get a `Changes` object showing which fields have changed since the last time this was called Args: group: Restrict to a particular `ChangeGroup` get_multiline: If `True`, return values of multiline fields in the `multiline_values` attribute. Note that this will invoke additional network requests. If `False` these fields will instead be returned in the `no_value` attribute. Default value is `False`. For example:: GetChanges() -> Changes( value={"PCAP.TRIG": "PULSE1.OUT"}, no_value=["SEQ1.TABLE"], in_error=["BAD.ENUM"], multiline_values={} ) GetChanges(ChangeGroup.ALL, True) -> Changes( values={"PCAP.TRIG": "PULSE1.OUT"}, no_value=[], in_error=["BAD.ENUM"], multiline_values={"SEQ1.TABLE" : ["1", "2", "3",...]} ) """group:ChangeGroup=ChangeGroup.ALLget_multiline:bool=Falsedefexecute(self)->ExchangeGenerator[Changes]:ex=Exchange(f"*CHANGES{self.group.value}?")yieldexchanges=Changes({},[],[],{})multivalue_get_commands:List[Tuple[str,GetMultiline]]=[]forlineinex.multiline:ifline[-1]=="<":ifself.get_multiline:field=line[0:-1]multivalue_get_commands.append((field,GetMultiline(field)))else:changes.no_value.append(line[:-1])elifline.endswith("(error)"):changes.in_error.append(line.split(" ",1)[0])else:field,value=line.split("=",maxsplit=1)changes.values[field]=valueifself.get_multiline:multiline_vals=yield from_execute_commands(*[item[1]foriteminmultivalue_get_commands])forfield,valueinzip([item[0]foriteminmultivalue_get_commands],multiline_vals):assertisinstance(value,list)changes.multiline_values[field]=valuereturnchanges
[docs]@dataclassclassGetState(Command[List[str]]):"""Get the state of all the fields in a PandA that should be saved as a list of raw lines that could be sent with `SetState`. NOTE: `GetState` may behave unexpectedly if `GetChanges` has previously been called using the same client. The caller should use separate clients to avoid potential issues. For example:: GetState() -> [ "SEQ1.TABLE<B" "234fds0SDklnmnr" "" "PCAP.TRIG=PULSE1.OUT", ] """defexecute(self)->ExchangeGenerator[List[str]]:# TODO: explain in detail how this works# See: references/how-it-worksattr,config,table,metadata=yield from_execute_commands(GetChanges(ChangeGroup.ATTR),GetChanges(ChangeGroup.CONFIG),GetChanges(ChangeGroup.TABLE),GetChanges(ChangeGroup.METADATA),)# Add the single line valuesline_values=dict(**attr.values,**config.values,**metadata.values)state=[f"{k}={v}"fork,vinline_values.items()]# Get the multiline valuesmultiline_keys,commands=[],[]forfield_nameintable.no_value:# Get tables as base64multiline_keys.append(f"{field_name}<B")commands.append(GetMultiline(f"{field_name}.B"))forfield_nameinmetadata.no_value:# Get metadata as string listmultiline_keys.append(f"{field_name}<")commands.append(GetMultiline(f"{field_name}"))multiline_values=yield from_execute_commands(*commands)fork,vinzip(multiline_keys,multiline_values):state+=[k]+v+[""]returnstate
[docs]@dataclassclassSetState(Command[None]):"""Set the state of all the fields in a PandA Args: state: A list of raw lines as produced by `GetState` For example:: SetState([ "SEQ1.TABLE<B" "234fds0SDklnmnr" "" "PCAP.TRIG=PULSE1.OUT", ]) """state:List[str]defexecute(self)->ExchangeGenerator[None]:commands:List[Raw]=[]command_lines:List[str]=[]forlineinself.state:command_lines.append(line)first_line=len(command_lines)==1if(first_lineandnotis_multiline_command(line))ornotline:# If not a multiline command# Or blank line at the end of a multiline commandcommands.append(Raw(command_lines))command_lines=[]returns=yield from_execute_commands(*commands)forcommand,retinzip(commands,returns):ifret!=["OK"]:logging.warning(f"command {command.inp} failed with {ret}")