"""12-factor interactive self-building lazy configuration.The developer story we are after:- there's nothing to do, just expect ``cli2.cfg['YOUR_ENV_VAR']`` to work one way or another- when there's a minute to be nice to the user, add some help that will be displayed to them: ``cli2.cfg.questions['YOUR_ENV_VAR'] = 'this is a help text that will be display to the user when we prompt them for YOUR_ENV_VAR'``This is the user story we are after:- user runs your cli2 command right after install without any configuration- the user is prompted for a variable- the variable is saved in their ~/.profile in a new export line- the user runs a command again in the same shell: we should find the variable in ~/.profile so he doesn't have to start a new shell for his new configuration to workOf course, if the environment variable is already present in the environmentthen this basically returns it from ``os.environ``."""importfunctoolsimportosimportreimportshleximporttextwrapfrompathlibimportPath
[docs]classConfiguration(dict):""" Configuration object. Wraps around environment variable and can question the user for missing variables and save them in his shell profile. .. py:attribute:: questions A dict of ``ENV_VAR=question_string``, if an env var is missing from configuration then question_string will be used as text to prompt the user. .. py:attribute:: profile_path Path to the shell profile to save/read variables, defaults to ~/.profile which should work in many shells. You can also just work with the module level ("singleton") instance, have scripts like: .. code-block:: python import cli2 cli = cli2.Group() cli2.cfg['API_URL'] = 'What is your API URL?' @cli.cmd def foo(): api_url = cli2.cfg['API_URL'] # when there's no question, it'll use the var name as prompt api_url = cli2.cfg['USERNAME'] """def__init__(self,profile_path=None,**questions):self.questions=questionsself.profile_path=Path(profile_pathoros.getenv('HOME')+'/.profile')self._profile_script=Noneself._profile_variables=dict()self.environ=os.environ.copy()
[docs]definput(self,prompt):""" Wraps around Python's input but adds confirmation. :param prompt: Prompt text to display. """value=input(prompt+'\n> ')confirm=Nonewhileconfirmnotin('','y','Y','n'):confirm=input(f'Confirm value of:\n{value}\n(Y/n) >')ifconfirmin('','y','Y'):# user is satisfiedreturnvalue# ok let's try againreturnself.input(prompt)
def__getitem__(self,key):""" If the key is not in self, call :py:meth:`configure`. :param key: Environment variable name """ifkeynotinself:self[key]=self.configure(key)returnsuper().__getitem__(key)@propertydefprofile_script(self):""" Cached :py:attr:`profile_path` reader. """ifself._profile_script:returnself._profile_scriptifself.profile_path.exists():withself.profile_path.open('r')asf:self._profile_script=f.read()else:self.profile_path.touch()self._profile_script=''returnself._profile_script@functools.cached_propertydefprofile_variables(self):""" Cached environment variable parsing from :py:attr:`profile_path`. """ifself._profile_variables:returnself._profile_variablesforlineinself.profile_script.split('\n'):ifnotline.startswith('export '):continuename,value=re.findall('export ([^=]*)=(.*)',line)[0]value=shlex.split(value)[0]self._profile_variables[name]=valuereturnself._profile_variables
[docs]defconfigure(self,key):""" Core logic to figure a variable. - if present in os.environ: return that - if parsed in profile_variables: return that - otherwise prompt it, with the question if any, then save it to :py:attr:`profile_path` """ifkeyinself.environ:returnself.environ[key]# ok, let's love our user, and try to parse the variable# from self.profile, after all, perhaps they are running# their command for the second time in the same shellifkeyinself.profile_variables:returnself.profile_variables[key]prompt=self.questions.get(key,key)prompt=textwrap.dedent(prompt).strip()value=self.input(prompt)escaped_value=shlex.quote(value)withself.profile_path.open('a')asf:f.write(f'\nexport {key}={escaped_value}')self.print(f'Appended to {self.profile_path}:'f'\nexport {key}={escaped_value}')returnvalue
[docs]defdelete(self,key,reason=None):""" Delete a variable from everywhere, useful if an api key expired. :param key: Env var name to delete :param reason: Reason to print to the user """withself.profile_path.open('r')asf:lines=f.read().split('\n')contents=[lineforlineinlinesifnotline.startswith(f'export {key}=')]iflen(contents)!=len(lines):ifreason:print(reason)print(f'Removing {key} configuration')new_script='\n'.join(contents)withself.profile_path.open('w')asf:f.write(new_script)self._profile_script=new_scriptself._profile_variables.pop(key,None)self.pop(key,None)self.environ.pop(key,None)