"""Adapted from https://github.com/noahmorrison/chevronMIT License."""from__future__importannotationsimportloggingfromcollections.abcimportIterator,Mapping,SequencefromtypesimportMappingProxyTypefromtypingimport(TYPE_CHECKING,Any,Literal,Optional,Union,cast,)ifTYPE_CHECKING:fromtyping_extensionsimportTypeAliaslogger=logging.getLogger(__name__)Scopes:TypeAlias=list[Union[Literal[False,0],Mapping[str,Any]]]# Globals_CURRENT_LINE=1_LAST_TAG_LINE=None
[docs]classChevronError(SyntaxError):"""Custom exception for Chevron errors."""
## Helper functions#
[docs]defgrab_literal(template:str,l_del:str)->tuple[str,str]:"""Parse a literal from the template. Args: template: The template to parse. l_del: The left delimiter. Returns: Tuple[str, str]: The literal and the template. """global_CURRENT_LINEtry:# Look for the next tag and move the template to itliteral,template=template.split(l_del,1)_CURRENT_LINE+=literal.count("\n")# There are no more tags in the template?exceptValueError:# Then the rest of the template is a literalreturn(template,"")return(literal,template)
[docs]defl_sa_check(template:str,literal:str,is_standalone:bool)->bool:"""Do a preliminary check to see if a tag could be a standalone. Args: template: The template. (Not used.) literal: The literal. is_standalone: Whether the tag is standalone. Returns: bool: Whether the tag could be a standalone. """# If there is a newline, or the previous tag was a standaloneifliteral.find("\n")!=-1oris_standalone:padding=literal.split("\n")[-1]# If all the characters since the last newline are spaces# Then the next tag could be a standalone# Otherwise it can't bereturnpadding.isspace()orpadding==""else:returnFalse
[docs]defr_sa_check(template:str,tag_type:str,is_standalone:bool)->bool:"""Do a final check to see if a tag could be a standalone. Args: template: The template. tag_type: The type of the tag. is_standalone: Whether the tag is standalone. Returns: bool: Whether the tag could be a standalone. """# Check right side if we might be a standaloneifis_standaloneandtag_typenotin["variable","no escape"]:on_newline=template.split("\n",1)# If the stuff to the right of us are spaces we're a standalonereturnon_newline[0].isspace()ornoton_newline[0]# If we're a tag can't be a standaloneelse:returnFalse
[docs]defparse_tag(template:str,l_del:str,r_del:str)->tuple[tuple[str,str],str]:"""Parse a tag from a template. Args: template: The template. l_del: The left delimiter. r_del: The right delimiter. Returns: Tuple[Tuple[str, str], str]: The tag and the template. Raises: ChevronError: If the tag is unclosed. ChevronError: If the set delimiter tag is unclosed. """tag_types={"!":"comment","#":"section","^":"inverted section","/":"end",">":"partial","=":"set delimiter?","{":"no escape?","&":"no escape",}# Get the tagtry:tag,template=template.split(r_del,1)exceptValueErrorase:msg=f"unclosed tag at line {_CURRENT_LINE}"raiseChevronError(msg)frome# Find the type meaning of the first charactertag_type=tag_types.get(tag[0],"variable")# If the type is not a variableiftag_type!="variable":# Then that first character is not neededtag=tag[1:]# If we might be a set delimiter tagiftag_type=="set delimiter?":# Double check to make sure we areiftag.endswith("="):tag_type="set delimiter"# Remove the equal signtag=tag[:-1]# Otherwise we should complainelse:msg=f"unclosed set delimiter tag\nat line {_CURRENT_LINE}"raiseChevronError(msg)elif(# If we might be a no html escape tagtag_type=="no escape?"# And we have a third curly brace# (And are using curly braces as delimiters)andl_del=="{{"andr_del=="}}"andtemplate.startswith("}")):# Then we are a no html escape tagtemplate=template[1:]tag_type="no escape"# Strip the whitespace off the key and returnreturn((tag_type,tag.strip()),template)
## The main tokenizing function#
[docs]deftokenize(template:str,def_ldel:str="{{",def_rdel:str="}}")->Iterator[tuple[str,str]]:"""Tokenize a mustache template. Tokenizes a mustache template in a generator fashion, using file-like objects. It also accepts a string containing the template. Args: template: a file-like object, or a string of a mustache template def_ldel: The default left delimiter ("{{" by default, as in spec compliant mustache) def_rdel: The default right delimiter ("}}" by default, as in spec compliant mustache) Returns: A generator of mustache tags in the form of a tuple (tag_type, tag_key) Where tag_type is one of: * literal * section * inverted section * end * partial * no escape And tag_key is either the key or in the case of a literal tag, the literal itself. """global_CURRENT_LINE,_LAST_TAG_LINE_CURRENT_LINE=1_LAST_TAG_LINE=Noneis_standalone=Trueopen_sections=[]l_del=def_ldelr_del=def_rdelwhiletemplate:literal,template=grab_literal(template,l_del)# If the template is completedifnottemplate:# Then yield the literal and leaveyield("literal",literal)break# Do the first check to see if we could be a standaloneis_standalone=l_sa_check(template,literal,is_standalone)# Parse the tagtag,template=parse_tag(template,l_del,r_del)tag_type,tag_key=tag# Special tag logic# If we are a set delimiter tagiftag_type=="set delimiter":# Then get and set the delimitersdels=tag_key.strip().split(" ")l_del,r_del=dels[0],dels[-1]# If we are a section tageliftag_typein["section","inverted section"]:# Then open a new sectionopen_sections.append(tag_key)_LAST_TAG_LINE=_CURRENT_LINE# If we are an end tageliftag_type=="end":# Then check to see if the last opened section# is the same as ustry:last_section=open_sections.pop()exceptIndexErrorase:msg=(f'Trying to close tag "{tag_key}"\n'"Looks like it was not opened.\n"f"line {_CURRENT_LINE+1}")raiseChevronError(msg)fromeiftag_key!=last_section:# Otherwise we need to complainmsg=(f'Trying to close tag "{tag_key}"\n'f'last open tag is "{last_section}"\n'f"line {_CURRENT_LINE+1}")raiseChevronError(msg)# Do the second check to see if we're a standaloneis_standalone=r_sa_check(template,tag_type,is_standalone)# Which if we areifis_standalone:# Remove the stuff before the newlinetemplate=template.split("\n",1)[-1]# Partials need to keep the spaces on their leftiftag_type!="partial":# But other tags don'tliteral=literal.rstrip(" ")# Start yielding# Ignore literals that are emptyifliteral!="":yield("literal",literal)# Ignore comments and set delimitersiftag_typenotin["comment","set delimiter?"]:yield(tag_type,tag_key)# If there are any open sections when we're doneifopen_sections:# Then we need to complainmsg=("Unexpected EOF\n"f'the tag "{open_sections[-1]}" was never closed\n'f"was opened at line {_LAST_TAG_LINE}")raiseChevronError(msg)
## Helper functions#def_html_escape(string:str)->str:"""HTML escape all of these " & < >."""html_codes={'"':""","<":"<",">":">",}# & must be handled firststring=string.replace("&","&")forcharinhtml_codes:string=string.replace(char,html_codes[char])returnstringdef_get_key(key:str,scopes:Scopes,warn:bool,keep:bool,def_ldel:str,def_rdel:str,)->Any:"""Get a key from the current scope."""# If the key is a dotifkey==".":# Then just return the current scopereturnscopes[0]# Loop through the scopesforscopeinscopes:try:# Return an empty string if falsy, with two exceptions# 0 should return 0, and False should return Falseifscopein(0,False):returnscoperesolved_scope=scope# For every dot separated keyforchildinkey.split("."):# Return an empty string if falsy, with two exceptions# 0 should return 0, and False should return Falseifresolved_scopein(0,False):returnresolved_scope# Move into the scopetry:# Try subscripting (Normal dictionaries)resolved_scope=cast("dict[str, Any]",resolved_scope)[child]except(TypeError,AttributeError):try:resolved_scope=getattr(resolved_scope,child)except(TypeError,AttributeError):# Try as a listresolved_scope=resolved_scope[int(child)]# type: ignoretry:# This allows for custom falsy data types# https://github.com/noahmorrison/chevron/issues/35ifresolved_scope._CHEVRON_return_scope_when_falsy:# type: ignorereturnresolved_scopeexceptAttributeError:ifresolved_scopein(0,False):returnresolved_scopereturnresolved_scopeor""except(AttributeError,KeyError,IndexError,ValueError):# We couldn't find the key in the current scope# We'll try again on the next passpass# We couldn't find the key in any of the scopesifwarn:logger.warn(f"Could not find key '{key}'")ifkeep:returnf"{def_ldel}{key}{def_rdel}"return""def_get_partial(name:str,partials_dict:Mapping[str,str])->str:"""Load a partial."""try:# Maybe the partial is in the dictionaryreturnpartials_dict[name]exceptKeyError:return""## The main rendering function#g_token_cache:dict[str,list[tuple[str,str]]]={}EMPTY_DICT:MappingProxyType[str,str]=MappingProxyType({})
[docs]defrender(template:Union[str,list[tuple[str,str]]]="",data:Mapping[str,Any]=EMPTY_DICT,partials_dict:Mapping[str,str]=EMPTY_DICT,padding:str="",def_ldel:str="{{",def_rdel:str="}}",scopes:Optional[Scopes]=None,warn:bool=False,keep:bool=False,)->str:"""Render a mustache template. Renders a mustache template with a data scope and inline partial capability. Args: template: A file-like object or a string containing the template. data: A python dictionary with your data scope. partials_path: The path to where your partials are stored. If set to None, then partials won't be loaded from the file system (defaults to '.'). partials_ext: The extension that you want the parser to look for (defaults to 'mustache'). partials_dict: A python dictionary which will be search for partials before the filesystem is. {'include': 'foo'} is the same as a file called include.mustache (defaults to {}). padding: This is for padding partials, and shouldn't be used (but can be if you really want to). def_ldel: The default left delimiter ("{{" by default, as in spec compliant mustache). def_rdel: The default right delimiter ("}}" by default, as in spec compliant mustache). scopes: The list of scopes that get_key will look through. warn: Log a warning when a template substitution isn't found in the data keep: Keep unreplaced tags when a substitution isn't found in the data. Returns: A string containing the rendered template. """# If the template is a sequence but not derived from a stringifisinstance(template,Sequence)andnotisinstance(template,str):# Then we don't need to tokenize it# But it does need to be a generatortokens:Iterator[tuple[str,str]]=(tokenfortokenintemplate)else:iftemplateing_token_cache:tokens=(tokenfortokening_token_cache[template])else:# Otherwise make a generatortokens=tokenize(template,def_ldel,def_rdel)output=""ifscopesisNone:scopes=[data]# Run through the tokensfortag,keyintokens:# Set the current scopecurrent_scope=scopes[0]# If we're an end tagiftag=="end":# Pop out of the latest scopedelscopes[0]# If the current scope is falsy and not the only scopeelifnotcurrent_scopeandlen(scopes)!=1:iftagin["section","inverted section"]:# Set the most recent scope to a falsy valuescopes.insert(0,False)# If we're a literal tageliftag=="literal":# Add padding to the key and add it to the outputoutput+=key.replace("\n","\n"+padding)# If we're a variable tageliftag=="variable":# Add the html escaped key to the outputthing=_get_key(key,scopes,warn=warn,keep=keep,def_ldel=def_ldel,def_rdel=def_rdel)ifthingisTrueandkey==".":# if we've coerced into a boolean by accident# (inverted tags do this)# then get the un-coerced object (next in the stack)thing=scopes[1]ifnotisinstance(thing,str):thing=str(thing)output+=_html_escape(thing)# If we're a no html escape tageliftag=="no escape":# Just lookup the key and add itthing=_get_key(key,scopes,warn=warn,keep=keep,def_ldel=def_ldel,def_rdel=def_rdel)ifnotisinstance(thing,str):thing=str(thing)output+=thing# If we're a section tageliftag=="section":# Get the sections scopescope=_get_key(key,scopes,warn=warn,keep=keep,def_ldel=def_ldel,def_rdel=def_rdel)# If the scope is a callable (as described in# https://mustache.github.io/mustache.5.html)ifcallable(scope):# Generate template text from tagstext=""tags:list[tuple[str,str]]=[]fortokenintokens:iftoken==("end",key):breaktags.append(token)tag_type,tag_key=tokeniftag_type=="literal":text+=tag_keyeliftag_type=="no escape":text+=f"{def_ldel}& {tag_key}{def_rdel}"else:text+="{}{}{}{}".format(def_ldel,{"comment":"!","section":"#","inverted section":"^","end":"/","partial":">","set delimiter":"=","no escape":"&","variable":"",}[tag_type],tag_key,def_rdel,)g_token_cache[text]=tagsrend=scope(text,lambdatemplate,data=None:render(template,data={},partials_dict=partials_dict,padding=padding,def_ldel=def_ldel,def_rdel=def_rdel,scopes=dataand[data]+scopesorscopes,warn=warn,keep=keep,),)output+=rend# If the scope is a sequence, an iterator or generator but not# derived from a stringelifisinstance(scope,(Sequence,Iterator))andnotisinstance(scope,str):# Then we need to do some looping# Gather up all the tags inside the section# (And don't be tricked by nested end tags with the same key)# TODO: This feels like it still has edge cases, no?tags=[]tags_with_same_key=0fortokenintokens:iftoken==("section",key):tags_with_same_key+=1iftoken==("end",key):tags_with_same_key-=1iftags_with_same_key<0:breaktags.append(token)# For every item in the scopeforthinginscope:# Append it as the most recent scope and rendernew_scope=[thing]+scopesrend=render(template=tags,scopes=new_scope,padding=padding,partials_dict=partials_dict,def_ldel=def_ldel,def_rdel=def_rdel,warn=warn,keep=keep,)output+=rendelse:# Otherwise we're just a scope sectionscopes.insert(0,scope)# If we're an inverted sectioneliftag=="inverted section":# Add the flipped scope to the scopesscope=_get_key(key,scopes,warn=warn,keep=keep,def_ldel=def_ldel,def_rdel=def_rdel)scopes.insert(0,cast("Literal[False]",notscope))# If we're a partialeliftag=="partial":# Load the partialpartial=_get_partial(key,partials_dict)# Find what to pad the partial withleft=output.rpartition("\n")[2]part_padding=paddingifleft.isspace():part_padding+=left# Render the partialpart_out=render(template=partial,partials_dict=partials_dict,def_ldel=def_ldel,def_rdel=def_rdel,padding=part_padding,scopes=scopes,warn=warn,keep=keep,)# If the partial was indentedifleft.isspace():# then remove the spaces from the endpart_out=part_out.rstrip(" \t")# Add the partials output to the outputoutput+=part_outreturnoutput