Repository: pallets/click

Issue:
Sanitization for click inputs
I would like to see some method of easy sanitation after an input. I guess I could define a type and require that type for the input but that seems a little extravagant if I want to make sure that say a field entered is a valid email address during input or to strip out spaces automatically. I know I can do this in the code but it should be possible inside prompt.  



Discussion:
Can you not use our callback system for this?

Oh I see. We might only invoke the type conversion but not the callbacks for prompts currently.  I wonder if that can be changed though.

It would be a good feature indeed.

Yeah in the code I wrote I ended up just calling strip to sanitize it somewhat and prevent spaces at the beginning and the end but that does not actually validate the data. It does fix the issue I had of people using copy and paste and grabbing a space but not the underlying problem of allowing a complete input sanitization check.

`Parameter.callback` is already called for the result of `prompt` (or any other parameter source), but it's called *after* `prompt` returns, not inside it. So while it's possible to perform extra processing, if that processing raises a `click.UsageError` it exits instead of prompting again.

I'm not sure if this was always the case or if it was due to recent refactors to the type processing pipeline. I think we can extend that refactoring a bit further though to address this issue. `prompt` is passed `self.process_value`, which only does type conversion. However, `handle_parse_result` calls `full_process_value` (which calls `process_value`) then applies the callback separately. There's no reason to have that separate anymore, and along with that there's no reason to have both `process_value` and `full_process_value`.

1. Inline `Parameter.process_value` into `Parameter.full_process_value`.
2. Rename `full_process_value` to `process_value`.
2. Move the `if self.callback:` block from `Parameter.handle_parse_result` to the bottom of `process_value`.

Resolving patch:
diff --git a/src/click/core.py b/src/click/core.py
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -1769,9 +1769,10 @@ class Parameter:
     :param default: the default value if omitted.  This can also be a callable,
                     in which case it's invoked when the default is needed
                     without any arguments.
-    :param callback: a callback that should be executed after the parameter
-                     was matched.  This is called as ``fn(ctx, param,
-                     value)`` and needs to return the value.
+    :param callback: A function to further process or validate the value
+        after type conversion. It is called as ``f(ctx, param, value)``
+        and must return the value. It is called for all sources,
+        including prompts.
     :param nargs: the number of arguments to match.  If not ``1`` the return
                   value is a tuple instead of single value.  The default for
                   nargs is ``1`` (except if the type is a tuple, then it's
@@ -1792,6 +1793,12 @@ class Parameter:
         of :class:`~click.shell_completion.CompletionItem` or a list of
         strings.
 
+    .. versionchanged:: 8.0
+        ``process_value`` validates required parameters and bounded
+        ``nargs``, and invokes the parameter callback before returning
+        the value. This allows the callback to validate prompts.
+        ``full_process_value`` is removed.
+
     .. versionchanged:: 8.0
         ``autocompletion`` is renamed to ``shell_complete`` and has new
         semantics described above. The old name is deprecated and will
@@ -2008,17 +2015,6 @@ def _convert(value, level):
 
         return _convert(value, (self.nargs != 1) + bool(self.multiple))
 
-    def process_value(self, ctx, value):
-        """Given a value and context this runs the logic to convert the
-        value as necessary.
-        """
-        # If the value we were given is None we do nothing.  This way
-        # code that calls this can easily figure out if something was
-        # not provided.  Otherwise it would be converted into an empty
-        # tuple for multiple invocations which is inconvenient.
-        if value is not None:
-            return self.type_cast_value(ctx, value)
-
     def value_is_missing(self, value):
         if value is None:
             return True
@@ -2026,8 +2022,9 @@ def value_is_missing(self, value):
             return True
         return False
 
-    def full_process_value(self, ctx, value):
-        value = self.process_value(ctx, value)
+    def process_value(self, ctx, value):
+        if value is not None:
+            value = self.type_cast_value(ctx, value)
 
         if self.required and self.value_is_missing(value):
             raise MissingParameter(ctx=ctx, param=self)
@@ -2049,6 +2046,9 @@ def full_process_value(self, ctx, value):
                 f" {len(value)} {were} given."
             )
 
+        if self.callback is not None:
+            value = self.callback(ctx, self, value)
+
         return value
 
     def resolve_envvar_value(self, ctx):
@@ -2081,10 +2081,7 @@ def handle_parse_result(self, ctx, opts, args):
             ctx.set_parameter_source(self.name, source)
 
             try:
-                value = self.full_process_value(ctx, value)
-
-                if self.callback is not None:
-                    value = self.callback(ctx, self, value)
+                value = self.process_value(ctx, value)
             except Exception:
                 if not ctx.resilient_parsing:
                     raise


Test patch:
diff --git a/tests/test_arguments.py b/tests/test_arguments.py
--- a/tests/test_arguments.py
+++ b/tests/test_arguments.py
@@ -228,7 +228,7 @@ def test_missing_argument_string_cast():
     ctx = click.Context(click.Command(""))
 
     with pytest.raises(click.MissingParameter) as excinfo:
-        click.Argument(["a"], required=True).full_process_value(ctx, None)
+        click.Argument(["a"], required=True).process_value(ctx, None)
 
     assert str(excinfo.value) == "missing parameter: a"
 
diff --git a/tests/test_options.py b/tests/test_options.py
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -376,6 +376,22 @@ def cmd(foo):
     assert result.output == "42\n"
 
 
+def test_callback_validates_prompt(runner, monkeypatch):
+    def validate(ctx, param, value):
+        if value < 0:
+            raise click.BadParameter("should be positive")
+
+        return value
+
+    @click.command()
+    @click.option("-a", type=int, callback=validate, prompt=True)
+    def cli(a):
+        click.echo(a)
+
+    result = runner.invoke(cli, input="-12\n60\n")
+    assert result.output == "A: -12\nError: should be positive\nA: 60\n60\n"
+
+
 def test_winstyle_options(runner):
     @click.command()
     @click.option("/debug;/no-debug", help="Enables or disables debug mode.")
@@ -409,7 +425,7 @@ def test_missing_option_string_cast():
     ctx = click.Context(click.Command(""))
 
     with pytest.raises(click.MissingParameter) as excinfo:
-        click.Option(["-a"], required=True).full_process_value(ctx, None)
+        click.Option(["-a"], required=True).process_value(ctx, None)
 
     assert str(excinfo.value) == "missing parameter: a"