Repository: pallets/click

Issue:
Case Insensitive Choice Type
I've been converting a sprawling argparse CLI over to Click (and enjoying the improvements, btw), and I've run into a bit of an incompatibility between the two that I think should be easy for click to solve.
In short, this is a feature request for a case insensitive variant of `click.Choice`, or a general solution that handles that use case well.

I had case insensitive choice options like this before:

``` python
parser.add_argument('--foo', type=str.lower, choices=('apple', 'orange'))
```

Which means we could do commands like

``` shell
$ mycmd --foo ORANGE
...
$ mycmd --foo apple
```

As far as I can tell, `click.Choices` doesn't support case insensitivity on the strings you use to choose.

This is easily implemented with a small custom type:

``` python
class CaseInsensitiveChoice(click.Choice):
    def convert(self, value, param, ctx):
        if value is None:
            return None
        return super(CaseInsensitiveChoice, self).convert(
            value.lower(), param, ctx)
```

However, I was a bit surprised that this was necessary -- I would expect Click to handle this with a keyword argument of some kind, off the shelf.

This could be a special case for doing string preprocessing, but it's maybe more justifiable as a feature on `ParamType`, something like `argument_preprocessor=func`, which would allow all `ParamType`s to hook in some kind of user-supplied function.

I'm thinking of a final result something like `click.Choice(('apple', 'orange'), argument_preprocessor=str.lower)` for my own use case, but also supporting other potentially interesting things.



Discussion:
You can already implement this globally by passing extra options to your main command/group:

```
@click.command(context_settings=dict(token_normalize_func=str.lower)))
def cli():
    pass
```

Perhaps `token_normalize_func` should additionally be a parameter on `click.Choice`, though I am not sure if you really want this configuration on a per-option basis.

Found myself wanting something a bit like this, just now - I tried this:

```
def passfail(s):
    return {"pass": True, "fail": False}[s]

@click.option("--force-result", default=None, type=click.Choice(['fail', 'pass']), context_settings=dict(token_normalize_func=passfail))
```

But option doesn't take the context_settings_func

Though in my case, maybe being able to pass a dict like `{"pass": True, "fail": False}` to Choice would work too.

Please re-read what I wrote above.

I had a look, but only want it on the one option, not all options of the command.

Will go with subclassing Choice :)

I assumed you misread and thought context_settings_func was a kwarg for `click.option`.

Subclassing is definetly the best solution so far.

np, here's what I came up with (based on code @ the start of this ticket)

``` python
class DictChoice(click.Choice):
    def __init__(self, choices):
        self.choices_dict = choices
        click.Choice.__init__(self, choices.keys())

    def convert(self, value, param, ctx):
        if value is None:
            return None
        initial_value = super(DictChoice, self).convert(value, param, ctx)
        return self.choices_dict.get(initial_value)
```

Oh, and example usage:

``` python
@click.option("--force-result", default=None, type=DictChoice({'fail': False, 'pass': True}))
```

Sorry for not checking back on this sooner. I forgot about it. :sweat_smile:

@untitaker I think there are legitimate use-cases for being case insensitive some places, but not others. That rules out changing the tokenizer as an option.
My particular need pertains to a CLI which wraps a REST API I'm working with.
As much as possible I want to be case-insensitive, but rarely I need to consume strings destined for the backend API.

Being able to patch over the tokenizer used for a given option would be perfect, and be a bit more flexible than case-insensitivity specifically for Choice types.
It also sounds harder to implement though.

I think a good number of people probably have `type=str.lower` in their Argparse structure, but require case-sensitivity elsewhere.
I don't like that transitioning those options required implementing my own `ParamType`.

I guess this depends a bit on how important feature parity with Argparse is. I don't think that custom types count for that -- that's an extension point of Click, not "out of the box" functionality.


Resolving patch:
diff --git a/click/types.py b/click/types.py
--- a/click/types.py
+++ b/click/types.py
@@ -133,11 +133,15 @@ class Choice(ParamType):
     generators) may lead to surprising results.
 
     See :ref:`choice-opts` for an example.
+
+    :param case_sensitive: Set to false to make choices case insensitive.
+    Defaults to true.
     """
     name = 'choice'
 
-    def __init__(self, choices):
+    def __init__(self, choices, case_sensitive=True):
         self.choices = choices
+        self.case_sensitive = case_sensitive
 
     def get_metavar(self, param):
         return '[%s]' % '|'.join(self.choices)
@@ -150,13 +154,25 @@ def convert(self, value, param, ctx):
         if value in self.choices:
             return value
 
-        # Match through normalization
+        # Match through normalization and case sensitivity
+        # first do token_normalize_func, then lowercase
+        # preserve original `value` to produce an accurate message in
+        # `self.fail`
+        normed_value = value
+        normed_choices = self.choices
+
         if ctx is not None and \
            ctx.token_normalize_func is not None:
-            value = ctx.token_normalize_func(value)
-            for choice in self.choices:
-                if ctx.token_normalize_func(choice) == value:
-                    return choice
+            normed_value = ctx.token_normalize_func(value)
+            normed_choices = [ctx.token_normalize_func(choice) for choice in
+                              self.choices]
+
+        if not self.case_sensitive:
+            normed_value = normed_value.lower()
+            normed_choices = [choice.lower() for choice in normed_choices]
+
+        if normed_value in normed_choices:
+            return normed_value
 
         self.fail('invalid choice: %s. (choose from %s)' %
                   (value, ', '.join(self.choices)), param, ctx)


Test patch:
diff --git a/tests/test_options.py b/tests/test_options.py
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -283,6 +283,37 @@ def cmd(foo):
         in result.output
 
 
+def test_case_insensitive_choice(runner):
+    @click.command()
+    @click.option('--foo', type=click.Choice(
+        ['Orange', 'Apple'], case_sensitive=False))
+    def cmd(foo):
+        click.echo(foo)
+
+    result = runner.invoke(cmd, ['--foo', 'apple'])
+    assert result.exit_code == 0
+
+    result = runner.invoke(cmd, ['--foo', 'oRANGe'])
+    assert result.exit_code == 0
+
+    result = runner.invoke(cmd, ['--foo', 'Apple'])
+    assert result.exit_code == 0
+
+    @click.command()
+    @click.option('--foo', type=click.Choice(['Orange', 'Apple']))
+    def cmd2(foo):
+        click.echo(foo)
+
+    result = runner.invoke(cmd2, ['--foo', 'apple'])
+    assert result.exit_code == 2
+
+    result = runner.invoke(cmd2, ['--foo', 'oRANGe'])
+    assert result.exit_code == 2
+
+    result = runner.invoke(cmd2, ['--foo', 'Apple'])
+    assert result.exit_code == 0
+
+
 def test_multiline_help(runner):
     @click.command()
     @click.option('--foo', help="""