Is there an idiomatic way, using the Python Click library, to create a command where one option depends on a value set by a previous option?
A concrete example (my use case) would be that a command takes an option of type click.File as input, but also an encoding option which specifies the encoding of the input stream:
import click
#click.command()
#click.option("--encoding", type=str, default="utf-8")
#click.option("--input",
type=click.File("r", encoding="CAN I SET THIS DYNAMICALLY BASED ON --encoding?"))
def cli(encoding, input):
pass
I guess it would have to involve some kind of deferred evaluation using a callable, but I'm not sure if it's even possible given the current Click API.
I've figured out I can do something along the following lines:
import click
#click.command()
#click.pass_context
#click.option("--encoding", type=str, default="utf-8")
#click.option("--input", type=str, default="-")
def cli(ctx, encoding, input):
input = click.File("r", encoding=encoding)(input, ctx=ctx)
But it somehow feels less readable / maintainable to decouple the option decorator from the semantically correct type constraint that applies to it, and put str in there instead as a dummy. So if there's a way to keep these two together, please enlighten me.
A proposed workaround:
I guess I could use the click.File type twice, making it lazy in the decorator so that the file isn't actually left opened, the first time around:
#click.option("--input", type=click.File("r", lazy=True), default="-")
This feels semantically more satisfying, but also redundant.
It is possible to inherit from the click.File class and override the .convert() method to allow it to gather the encoding value from the context.
Using a Custom Class
It should look something like:
#click.command()
#click.option("--my_encoding", type=str, default="utf-8")
#click.option("--in_file", type=CustomFile("r", encoding_option_name="my_encoding"))
def cli(my_encoding, in_file):
....
CustomFile should allow the user to specify whichever name they want for the parameter from which the encoding value should be collected, but there can be a reasonable default such as "encoding".
Custom File Class
This CustomFile class can be used in association with an encoding option:
import click
class CustomFile(click.File):
"""
A custom `click.File` class which will set its encoding to
a parameter.
:param encoding_option_name: The 'name' of the encoding parameter
"""
def __init__(self, *args, encoding_option_name="encoding", **kwargs):
# enforce a lazy file, so that opening the file is deferred until after
# all of the command line parameters have been processed (--encoding
# might be specified after --in_file)
kwargs['lazy'] = True
# Python 3 can use just super()
super(CustomFile, self).__init__(*args, **kwargs)
self.lazy_file = None
self.encoding_option_name = encoding_option_name
def convert(self, value, param, ctx):
"""During convert, get the encoding from the context."""
if self.encoding_option_name not in ctx.params:
# if the encoding option has not been processed yet, wrap its
# convert hook so that it also retroactively modifies the encoding
# attribute on self and self.lazy_file
encoding_opt = [
c for c in ctx.command.params
if self.encoding_option_name == c.human_readable_name]
assert encoding_opt, \
"option '{}' not found for encoded_file".format(
self.encoding_option_name)
encoding_type = encoding_opt[0].type
encoding_convert = encoding_type.convert
def encoding_convert_hook(*convert_args):
encoding_type.convert = encoding_convert
self.encoding = encoding_type.convert(*convert_args)
self.lazy_file.encoding = self.encoding
return self.encoding
encoding_type.convert = encoding_convert_hook
else:
# if it has already been processed, just use the value
self.encoding = ctx.params[self.encoding_option_name]
# Python 3 can use just super()
self.lazy_file = super(CustomFile, self).convert(value, param, ctx)
return self.lazy_file
Related
I need help para to beautify this code :)
The method definesAction will call a Class, based on the args. There is some way to generalizing this piece of code, taking into account that the Class's are similar.
Thanks in advance
Main Class
def defineAction(args):
if args.classabc is not None:
for host in config.getList('ABC', 'hosts'):
class_abc = ClassABC(config.getConfigs('ABC', host), args.version[0], user, password)
class_abc.action(args.classabc)
if args.classxyz is not None:
for host in config.getList('XYZ', 'hosts'):
class_xyz = ClassXYZ(config.getConfigs('XYZ', host), args.version[0], user, password)
class_xyz.action(args.classxyz)
# ...
def main():
parser.add_argument('--classabc', choices=['cmd'])
parser.add_argument('--classxyz', choices=['cmd'])
# ...
args = parser.parse_args()
defineAction(args)
SubClasses
class ClassABC:
def __init__(self, configs, user, password):
self.hostConfigs = configs['host']
self.host_username = user
self.host_password = password
def a_method(self):
# This Method is equal in all subclasses
def b_method(self):
# This Method is different all subclasses
def action(self, action):
self.a_method()
self.b_method()
if action == 'cmd':
self.execute_cmd()
Config FILE
[ABC]
hosts=abc_host1
var_abc=value1
[XYZ]
hosts=xyz_host1,xyz_host2
var_xyz=value2
I'm working the assumption that the switches are mutually exclusive (in which case you really want to use a mutually exclusive argument group).
You want the argparser action to set the class. If your command-line switch doesn't need to take any arguments, then I'd use action="store_const" here:
parser.add_argument(
'--classabc', dest="class_", const=ClassABC,
action="store_const")
parser.add_argument(
'--classxyz', dest="class_", const=ClassXYZ,
action="store_const")
On parsing, the above actions set args.class_ to ClassABC or ClassXYZ when one or the other switch is used. Give the classes a class method or an attribute to determine what configuration section to look in, do not hardcode those names anywhere else.
For instance, if both classes have a config_section attribute, (set to 'ABC' for ClassABC and 'XYZ' for ClassXZY), then you can use that attribute in your loop creating instances:
if args.class_:
for host in config.getList(class_.config_section, 'hosts'):
instance = args.class_(config.getConfig(class_.config_section, host), ...)
The idea is to not switch based on args attributes, you can leave this to argparse as it is already determining the different options for you.
If both command-line switches require an additional argument, then create a custom Action subclass:
class StoreClassAction(argparse.Action):
def __call__(self, parser, namespace, values, **kwargs):
setattr(namespace, self.dest, (self.const, values)
then use this as:
parser.add_argument(
'--classabc', dest="class_", choices=['cmd'], const=ClassABC,
action=StoreClassAction)
parser.add_argument(
'--classxyz', dest="class_", choices=['cmd'], const=ClassXYZ,
action=StoreClassAction)
Now the args.class_ argument is set to (classobject, argumentvalue), so you can use:
if args.class_:
cls, action = args.class_
for host in config.getList(cls.config_section, 'hosts'):
instance = args.class_(config.getConfig(cls.config_section, host), ...)
instance.action(action)
In click, I'm defining this command:
#click.command('time', short_help='Timesheet Generator')
#click.argument('time_command', type=click.Choice(['this', 'last']))
#click.argument('data_mode', type=click.Choice(['excel', 'exchange']), default='exchange')
#click.option('--password', prompt=True, hide_input=True, confirmation_prompt=False)
#pass_context
def cli(ctx, time_command, data_mode, password):
The issue I have is that I only want the password to prompt if the data_mode argument equals exchange. How can I pull this off?
We can remove the need for a prompt if another parameter does not match a specific value by building a custom class derived from click.Option, and in that class over riding the click.Option.handle_parse_result() method like:
Custom Class:
import click
def PromptIf(arg_name, arg_value):
class Cls(click.Option):
def __init__(self, *args, **kwargs):
kwargs['prompt'] = kwargs.get('prompt', True)
super(Cls, self).__init__(*args, **kwargs)
def handle_parse_result(self, ctx, opts, args):
assert any(c.name == arg_name for c in ctx.command.params), \
"Param '{}' not found for option '{}'".format(
arg_name, self.name)
if arg_name not in opts:
raise click.UsageError(
"Illegal usage: `%s` is a required parameter with" % (
arg_name))
# remove prompt from
if opts[arg_name] != arg_value:
self.prompt = None
return super(Cls, self).handle_parse_result(ctx, opts, args)
return Cls
Using Custom Class:
To use the custom class, pass the cls parameter to click.option decorator like:
#click.option('--an_option', cls=PromptIf('an_argument', 'an_arg_value'))
pass in the name of the parameter to examine for the desired value, and the value to check for.
How does this work?
This works because click is a well designed OO framework. The #click.option() decorator usually instantiates a click.Option object but allows this behavior to be overridden with the cls parameter. So it is a relatively easy matter to inherit from click.Option in our own class and over ride the desired methods.
In this case we over ride click.Option.handle_parse_result() and disable the need to prompt if the other specified parameter does not match the desired.
Note: This answer was inspired by this answer.
Test Code:
#click.command()
#click.argument('an_argument', type=click.Choice(['excel', 'exchange']),
default='exchange')
#click.option('--password', hide_input=True, confirmation_prompt=False,
cls=PromptIf('an_argument', 'exchange'))
def cli(an_argument, password):
click.echo(an_argument)
click.echo(password)
cli('exchange'.split())
You can try splitting this up to multiple commands.
For example, time would be the entry point command. Either time_excel or time_exchange would then be invoked by time based on the value of data_mode. One could have a password prompt while the other wouldn't.
See Invoking Other Commands in Click's documentation.
I want to parse some command line arguments with Python's Click library and save the provided values in an object.
My first guess would be to do it like this:
import click
class Configuration(object):
def __init__(self):
# configuration variables
self.MyOption = None
# method call
self.parseCommandlineArguments()
#click.command()
#click.option('--myoption', type=click.INT, default=5)
def parseCommandlineArguments(self, myoption):
# save option's value in the object
self.MyOption = myoption
# create an instance
configuration = Configuration()
print(configuration.MyOption)
However, this does not work, instead I get:
TypeError: parseCommandlineArguments() takes exactly 2 arguments (1 given)
Apparently, passing self to the decorated function is not the correct way to do it. If I remove self from the method arguments then I can e.g. do print(myoption) and it will print 5 on the screen but the value will not be known to any instances of my Configuration() class.
What is the correct way to handle this? I assume it has something to do with context handling in Click but I cannot get it working based on the provided examples.
If I'm understanding you correctly, you want a command line tool that will take configuration options and then do something with those options. If this is your objective then have a look at the example I posted. This example uses command groups and passes a context object through each command. Click has awesome documentation, be sure to read it.
import click
import json
class Configuration(object):
"""
Having a custom context class is usually not needed.
See the complex application documentation:
http://click.pocoo.org/5/complex/
"""
my_option = None
number = None
is_awesome = False
uber_var = 900
def make_conf(self):
self.uber_var = self.my_option * self.number
pass_context = click.make_pass_decorator(Configuration, ensure=True)
#click.group(chain=True)
#click.option('-m', '--myoption', type=click.INT, default=5)
#click.option('-n', '--number', type=click.INT, default=0)
#click.option('-a', '--is-awesome', is_flag=True)
#pass_context
def cli(ctx, myoption, number, is_awesome):
"""
this is where I will save the configuration
and do whatever processing that is required
"""
ctx.my_option = myoption
ctx.number = number
ctx.is_awesome = is_awesome
ctx.make_conf()
pass
#click.command('save')
#click.argument('output', type=click.File('wb'))
#pass_context
def save(ctx, output):
"""save the configuration to a file"""
json.dump(ctx.__dict__, output, indent=4, sort_keys=True)
return click.secho('configuration saved', fg='green')
#click.command('show')
#pass_context
def show(ctx):
"""print the configuration to stdout"""
return click.echo(json.dumps(ctx.__dict__, indent=4, sort_keys=True))
cli.add_command(save)
cli.add_command(show)
After this is installed your can run commands like this:
mycli -m 30 -n 40 -a show
mycli -m 30 -n 40 -a save foo.json
mycli -m 30 -n 40 -a show save foo.json
The complex example is an excellent demo for developing a highly configurable multi chaining command line tool.
I love the argparse module. argparse.FileType is helpful too, unless you want the default to be something other than sys.std* since the default output file is created even if you supply a
value.
For example:
parser.add_argument('--outfile', type=FileType('w'), default="out.txt")
will create out.txt even if you specify a file with --outfile.
The best I can come up with is:
class MagicFileType(object):
def __init__(self, *args, **kwargs):
# save args/kwargs and set filetype to None
self.filetype = None
self.args = args
self.kwargs = kwargs
def __getattr__(self, attr):
""" Delegate everything to the filetype """
# If we haven't created it, now is the time to do so
if self.filetype is None:
self.filetype = FileType(*self.args, **self.kwargs)
self.filetype = self.filetype(self.filename)
return getattr(self.filetype, attr)
def __call__(self, filename):
""" Just cache the filename """
# This is called when the default is created
# Just cache the filename for now.
self.filename = filename
return self
But if feels like this should be easier, am I missing something?
There was a relatively recent change in argparse, http://bugs.python.org/issue12776 (Aug 2012), that delays the evaluation of the default value. Originally a string default would be passed through type (via _get_value) at the start of parsing, resulting in the opening (and creation) of a FileType file (whether it would be needed or not). In this patch, the string is written to the Namespace, but not evaluated until the end of parsing, when it can determine whether another value was provided or not. Basically, this line was moved from early in parse_known_args to the end of _parse_known_args
default = self._get_value(action, action.default)
In http://bugs.python.org/issue13824 I proposed a patch that provides a FileContext type. Its main difference from FileType is that it wraps the open(file...) in a partial. That way the file isn't opened (or created) until actually used in a with args.output() as f: context.
That patch deals with some other things like testing whether the file can be created or not (using os.access) and wrapping stdin/out in a dummy context so it does not try to close it.
Without testing, you could modify FileType like this:
class FileOpener(argparse.FileType):
# delayed FileType;
# sample use:
# with args.input.open() as f: f.read()
def __call__(self, string):
# optionally test string
self.filename = string
return self
def open(self):
return super(FileOpener,self).__call__(self.filename)
file = property(open, None, None, 'open file property')
I have the following function signature, and it looks really ugly, what can I do to make it look cleaner ?
def contact(
request, sender=settings.DEFAULT_FROM_EMAIL,
subj_tmpl='contato/subject.txt',msg_tmpl='contato/msg.html',
template='contato/contato.html', success_template='contato/success.html',
success_redir='/',append_message=None,):
if i were you i think i will do it like this:
def contact(request, sender=None, append_message=None, context=None):
if not sender:
sender = settings.DEFAULT_FROM_EMAIL # i hope that you can access settings here
# The context arg is a dictionary where you can put all the others argument and
# you can use it like so :
subj_tmpl = context.get('subj_tmpl', 'contato/subject.txt')
# ....
hope this will help you.
My proposal is to drop parameters. Do you really need to be able to specify all the templates separately? Wouldn't it be sufficient to just specify the template folder, and then mandate that it has subject.txt, msg.html, etc in it?
If you just want to improve readability, reformat it to have one parameter per line:
def contact(
request,
sender=settings.DEFAULT_FROM_EMAIL,
subj_tmpl='contato/subject.txt',
msg_tmpl='contato/msg.html',
template='contato/contato.html',
success_template='contato/success.html',
success_redir='/',
append_message=None,):
This will allow a reader to more quickly grasp what the parameter names are.
You could rewrite it as:
def contact( request, **kargs):
try:
sender = kwargs.pop ('sender')
except KeyError:
sender=settings.DEFAULT_FROM_EMAIL
try:
subj_tmpl = kwargs.pop ('subj_tmpl')
except KeyError:
subj_tmpl='contato/subject.txt'
# ...
# and so on
# ...
def contact(request, **kwargs):
sender = kwargs.get('sender', settings.DEFAULT_FROM_EMAIL)
subj_template = kwargs.get('subj_template', 'contato/subject.txt')
..
With that said, I think your current solution is waaay better than using **kwargs.
this does not seem so ugly to me: you have a function, you have enough parameters to modify the way the function behave, and you have sensible default values so that you don't need to specify all arguments at each function call.
there is the possibility to package the function in a class: in the class constructor, you specify all those values which are part of the parameter list, and you have a special method without arguments to execute the core feature.
something like this:
class ContactForm(object):
def __init__( self,
subj_tmpl='contato/subject.txt',
msg_tmpl='contato/msg.html',
template='contato/contato.html',
success_template='contato/success.html',
success_redir='/',
append_message=None):
self.subj_tmpl = subj_tmpl
self.msg_tmpl = msg_tmpl
self.template = template
self.success_template = success_template
self.success_redir = success_redir
self.append_message = append_message
def __call__( self, request, sender=settings.DEFAULT_FROM_EMAIL ):
# do something
# use case:
contact = ContactForm()
contact( req, sndr )
(i guessed which values is site specific and which is user specific from the name of the parameters. i don't know your specific application, adapt it the way you want)