classTemplate:"""A collection of templated files and things to do with them. Represents both the current state of a template (akin to ``copier``'s ``Template``) and some operational logic against it (akin to ``copier``'s ``Worker``). """ctx:CliContexttemplate_uri:Path|strtemplate_name:TemplateNamesrc_excludes:list[str]copier_template:CopierTemplateref:str|Nonedef__init__(self,ctx:CliContext,template_uri:Path|str,src_excludes:list[str]|None=None,*,template_name:TemplateName|str|None=None,ref:str|None=None,):self.ctx=ctxself.template_uri=template_uriiftemplate_nameisNone:self.template_name=TemplateName.parse(get_template_name_from_uri(template_uri))else:self.template_name=TemplateName.parse(template_name)ifsrc_excludesisnotNoneandlen(src_excludes)==0:self.src_excludes=[]else:self.src_excludes=BASE_SRC_EXCLUDE+(src_excludesor[])self.copier_template=CopierTemplate(url=str(template_uri),ref=ref)self._run_copy=wrappers.log_call(run_copy,logger=ctx.log.info)self._run_update=wrappers.log_call(run_update,logger=ctx.log.info)@classmethoddeffrom_existing(cls,ctx:CliContext,project:Project,app_name:str,template_name:TemplateName|str,src_excludes:list[str]|None=None,)->Self:template_uri=get_template_uri_for_existing_app(project,app_name=app_name,template_name=TemplateName.parse(template_name))ifnottemplate_uri:raiseValueError(f"Can not determine existing template `{template_name}` source for `{app_name}`")returncls(ctx,template_uri=template_uri,template_name=template_name,src_excludes=src_excludes)definstall(self,project:Project,app_name:str,*,version:str|None=None,data:dict[str,str]|None=None,commit:bool=False,)->None:data=(dataor{})|{"app_name":app_name,"template":self.template_name.template_name,"template_name_id":self.template_name.id,}self._checkout_copier_ref(version)self._run_copy(src_path=str(self.template_uri),dst_path=project.dir,answers_file=self.answers_file_rel(app_name),data=data,src_exclude=self.src_excludes,vcs_ref=version,)ifcommit:self._commit_action(project,"install",app_name)defupdate(self,project:Project,app_name:str,*,version:str|None=None,data:dict[str,str]|None=None,commit:bool=False,answers_only:bool=False,force:bool=False,)->None:# save the data as provided for later usagepassed_data=data# but this is really the "data" for the templatedata=(dataor{})|{"app_name":app_name,"template":self.template_name.template_name,"template_name_id":self.template_name.id,}self._check_answers_file(project,app_name)existing_version=get_template_version_for_existing_app(project,app_name,self.template_name)ifnotexisting_version:raiseValueError("Can not find existing version in answers file (or issue reading the file)")# if just updating answers, re-use existing versionifanswers_only:# not necessarily true, could just print a message that we are# ignoring the provided version, but being strict for nowifversionisnotNone:raiseValueError("Can not specify a version and 'answers only'")# not necessarily true either, but should probably be the caseifnotpassed_data:raiseValueError("If 'answers only', must specify some data")version=existing_version.answer_valueself._checkout_copier_ref(version)# if we are already running the version that would be installed, then# skip, unless overriddenbypass_same_version_check=forceor(answers_onlyandpassed_data)ifnotbypass_same_version_checkandself._is_same_version(existing_version):self.ctx.console.print(f"Already up to date ({existing_version.display_str})")returnself.ctx.console.print(f"Current template version: {existing_version.display_str}")update_func=self._run_updateifnotforceelseself._run_copyupdate_func(dst_path=project.dir,# note `src_path` currently has no effect on updates, the path from# answers file is used## https://github.com/navapbc/platform-cli/issues/5src_path=str(self.template_uri),data=data,answers_file=self.answers_file_rel(app_name),src_exclude=self.src_excludes,overwrite=True,skip_answered=True,vcs_ref=version,)ifcommit:self._commit_action(project,"update",app_name)defproject_state_dir_rel(self)->RelativePath:returnproject_state_dir_rel(self.template_name)defanswers_file_rel(self,app_name:str)->RelativePath:returnanswers_file_rel(template_name=self.template_name,app_name=app_name)def_check_answers_file(self,project:Project,app_name:str)->bool:answers_file=project.dir/self.answers_file_rel(app_name)ifnotanswers_file.exists():raiseValueError(f"Answers file does not exist: {answers_file}")returnTruedef_commit_action(self,project:Project,action:Literal["install","update"],app_name:str)->None:msg=self._commit_action_msg(action,app_name)ifproject.git.is_git():self._commit_project(project,msg)else:fromrich.markdownimportMarkdownself.ctx.console.warning.print("Asked to commit, but project is not a git repository. Would have used message:")self.ctx.console.print(Markdown(msg))def_commit_action_msg(self,action:Literal["install","update"],app_name:str)->str:# TODO: include hash of answers file in message body? Since if someone# is just changing answers the "Updating" message points to the same# version? Which is kinda correct.## Or different message if just re-rendering with different answers?msg_prefix=""ifnotself.template_name.is_singular_instance(app_name):msg_prefix=f"{app_name}: "matchaction:case"install":msg=f"{msg_prefix}Install `{self.template_name.id}` at version {self.copier_template.version}"case"update":msg=f"{msg_prefix}Update `{self.template_name.id}` to version {self.copier_template.version}"returnmsg# TODO: move to Project?def_commit_project(self,project:Project,msg:str)->None:ifproject.git.has_merge_conflicts():raiseMergeConflictsDuringUpdateError(commit_msg=msg)ifproject.git.is_clean():self.ctx.console.print("Nothing to commit.")returnresult=project.git.commit_all(msg)ifresult.returncode!=0:self.ctx.console.error.print(result.stderrifresult.stderrelseresult.stdout)self.ctx.exit(2)ifresult.stdout:self.ctx.console.print(result.stdout)def_checkout_copier_ref(self,ref:str|None=None)->None:# only update if the ref is different from existing, this may have some# edge cases if the underlying template repo has changes the caller was# intending to be picked upifself.copier_template.ref==ref:returnNoneifself.copier_template.vcs!="git":returnNoneprev_template=self.copier_template# CopierTemplate caches a lot of info on first access, most of which is# dependant on what version is requested, so create a new copy that will# pickup any changesself.copier_template=dataclasses.replace(self.copier_template,ref=ref)# but if there was an existing checkout, point the updated copy at the# existing temp checkout so it doesn't have to re-fetch remote reposifprev_template._temp_cloneisnotNone:self.copier_template.__dict__["local_abspath"]=prev_template.local_abspathcopier_git=git.GitProject(self.copier_template.local_abspath)# TODO: do a fetch origin first?# might want to more closely mirror upstream behavior and support submodules:# https://github.com/copier-org/copier/blob/2dc1687af389505a708f25b0bc4e37af56179e99/copier/vcs.py#L216ifrefisNone:copier.vcs.checkout_latest_tag(copier_git.dir,use_prereleases=False)elifref=="HEAD":copier_git.checkout("origin/HEAD")else:copier_git.checkout(ref)# remove any dirty changes copier might have previously committed as# draft if not using HEADifrefnotin(None,"HEAD"):# we could just## copier_git.reset("--hard", f"origin/{ref}")## but that's only if `ref` is a tag/branch name and not# something else and maybe safer to be a bit more targeted, so# we'll look for if the latest commit is the expected "dirty# commit" and remove it# x09 is the hex code for tablast_commit_parts=copier_git.log("-1","--pretty=%an%x09%ae%x09%s").stdout[author_name,author_email,commit_subject]=map(lambdas:s.strip(),last_commit_parts.split("\t"))if(author_name==copier.vcs.GIT_USER_NAMEandauthor_email==copier.vcs.GIT_USER_EMAILandcommit_subject=="Copier automated commit for draft changes"):copier_git.reset("--hard","HEAD~1")returnNone@propertydefversion(self)->Version|None:returnself.copier_template.version@propertydefcommit(self)->str|None:"""The value that will be saved in the answers file."""returnself.copier_template.commit@propertydefcommit_hash(self)->str|None:returnself.copier_template.commit_hashdef_is_same_version(self,version_obj:TemplateVersionAnswer)->bool:commit=self.commitifcommitisNone:returnFalsereturncommit==version_obj.answer_value