Repository: pallets/click

Issue:
Differentiate defaults from explicitly passed values
Unless I've missed something it seems there is currently no way to differentiate between an explicitly passed value and a default value.

This is problematic for establishing value precedence when dealing with other sources of parameter values (a config file in our case).
We're currently using the default values to detect if a given option was passed on the command line but this is imperfect and leads to the following problem:

- `@option('--some-option', default=1)`
- Some other way of specifying option values (e.g. a config file) sets `some-option = 2`
- User calls cli with `--some-option 1`

Under the above workaround this makes it appear as if the config file value (`2`) should have precedence even though the user explicitly requested value `1`.



Discussion:
How would you propose we distinguish these? I'm not sure if click should even do this, although the info is probably somewhere in the context or could be handled with an extension.

Resolving patch:
diff --git a/click/__init__.py b/click/__init__.py
--- a/click/__init__.py
+++ b/click/__init__.py
@@ -14,7 +14,7 @@
 
 # Core classes
 from .core import Context, BaseCommand, Command, MultiCommand, Group, \
-     CommandCollection, Parameter, Option, Argument
+     CommandCollection, Parameter, Option, Argument, ParameterSource
 
 # Globals
 from .globals import get_current_context
diff --git a/click/core.py b/click/core.py
--- a/click/core.py
+++ b/click/core.py
@@ -129,6 +129,35 @@ def sort_key(item):
 
     return sorted(declaration_order, key=sort_key)
 
+class ParameterSource(object):
+    """This is an enum that indicates the source of a command line parameter.
+
+    The enum has one of the following values: COMMANDLINE,
+    ENVIRONMENT, DEFAULT, DEFAULT_MAP.  The DEFAULT indicates that the
+    default value in the decorator was used.  This class should be
+    converted to an enum when Python 2 support is dropped.
+    """
+
+    COMMANDLINE = "COMMANDLINE"
+    ENVIRONMENT = "ENVIRONMENT"
+    DEFAULT = "DEFAULT"
+    DEFAULT_MAP = "DEFAULT_MAP"
+    
+    VALUES = {COMMANDLINE, ENVIRONMENT, DEFAULT, DEFAULT_MAP}
+    
+    @classmethod
+    def validate(cls, value):
+        """Validate that the specified value is a valid enum.
+
+        This method will raise a ValueError if the value is
+        not a valid enum.
+
+        :param value: the string value to verify
+        """
+        if value not in cls.VALUES:
+            raise ValueError("Invalid ParameterSource value: '{}'. Valid "
+                             "values are: {}".format(value, ",".join(cls.VALUES)))
+
 
 class Context(object):
     """The context is a special internal object that holds state relevant
@@ -339,7 +368,8 @@ def __init__(self, command, parent=None, info_name=None, obj=None,
 
         self._close_callbacks = []
         self._depth = 0
-
+        self._source_by_paramname = {}
+        
     def __enter__(self):
         self._depth += 1
         push_context(self)
@@ -572,6 +602,35 @@ def forward(*args, **kwargs):
 
         return self.invoke(cmd, **kwargs)
 
+    def set_parameter_source(self, name, source):
+        """Set the source of a parameter.
+
+        This indicates the location from which the value of the
+        parameter was obtained.
+
+        :param name: the name of the command line parameter
+        :param source: the source of the command line parameter, which
+                       should be a valid ParameterSource value
+        """
+        ParameterSource.validate(source)
+        self._source_by_paramname[name] = source
+
+    def get_parameter_source(self, name):
+        """Get the source of a parameter.
+
+        This indicates the location from which the value of the
+        parameter was obtained.  This can be useful for determining
+        when a user specified an option on the command line that is
+        the same as the default.  In that case, the source would be
+        ParameterSource.COMMANDLINE, even though the value of the
+        parameter was equivalent to the default.
+
+        :param name: the name of the command line parameter
+        :returns: the source
+        :rtype: ParameterSource
+        """
+        return self._source_by_paramname[name]
+
 
 class BaseCommand(object):
     """The base command implements the minimal API contract of commands.
@@ -1394,10 +1453,15 @@ def add_to_parser(self, parser, ctx):
 
     def consume_value(self, ctx, opts):
         value = opts.get(self.name)
+        source = ParameterSource.COMMANDLINE
         if value is None:
             value = self.value_from_envvar(ctx)
+            source = ParameterSource.ENVIRONMENT
         if value is None:
             value = ctx.lookup_default(self.name)
+            source = ParameterSource.DEFAULT_MAP
+        if value is not None:
+            ctx.set_parameter_source(self.name, source)
         return value
 
     def type_cast_value(self, ctx, value):
@@ -1444,6 +1508,8 @@ def full_process_value(self, ctx, value):
 
         if value is None and not ctx.resilient_parsing:
             value = self.get_default(ctx)
+            if value is not None:
+                ctx.set_parameter_source(self.name, ParameterSource.DEFAULT)
 
         if self.required and self.value_is_missing(value):
             raise MissingParameter(ctx=ctx, param=self)


Test patch:
diff --git a/tests/test_context.py b/tests/test_context.py
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 import click
-
+import pytest
 
 def test_ensure_context_objects(runner):
     class Foo(object):
@@ -256,3 +256,62 @@ def cli(ctx):
         ctx.exit(0)
 
     assert cli.main([], 'test_exit_not_standalone', standalone_mode=False) == 0
+
+def test_parameter_source_default():
+    @click.command()
+    @click.pass_context
+    @click.option("-o", "--option", default=1)
+    def cli(ctx, option):
+        assert ctx.get_parameter_source("option") == click.ParameterSource.DEFAULT
+        ctx.exit(1)
+
+    assert cli.main([], "test_parameter_source_default", standalone_mode=False) == 1
+
+def test_parameter_source_default_map():
+    @click.command()
+    @click.pass_context
+    @click.option("-o", "--option", default=1)
+    def cli(ctx, option):
+        assert ctx.get_parameter_source("option") == click.ParameterSource.DEFAULT_MAP
+        ctx.exit(1)
+
+    assert cli.main([], "test_parameter_source_default", standalone_mode=False, default_map={ "option": 1}) == 1
+    
+
+def test_parameter_source_commandline():
+    @click.command()
+    @click.pass_context
+    @click.option("-o", "--option", default=1)
+    def cli(ctx, option):
+        assert ctx.get_parameter_source("option") == click.ParameterSource.COMMANDLINE
+        ctx.exit(1)
+        
+    assert cli.main(["-o", "1"], "test_parameter_source_commandline", standalone_mode=False) == 1
+    assert cli.main(["--option", "1"], "test_parameter_source_default", standalone_mode=False) == 1
+
+
+def test_parameter_source_environment(runner):
+    @click.command()
+    @click.pass_context
+    @click.option("-o", "--option", default=1)
+    def cli(ctx, option):
+        assert ctx.get_parameter_source("option") == click.ParameterSource.ENVIRONMENT
+        sys.exit(1)
+        
+    assert runner.invoke(cli, [], prog_name="test_parameter_source_environment", env={"TEST_OPTION": "1"}, auto_envvar_prefix="TEST").exit_code == 1
+
+
+def test_parameter_source_environment_variable_specified(runner):
+    @click.command()
+    @click.pass_context
+    @click.option("-o", "--option", default=1, envvar="NAME")
+    def cli(ctx, option):
+        assert ctx.get_parameter_source("option") == click.ParameterSource.ENVIRONMENT
+        sys.exit(1)
+        
+    assert runner.invoke(cli, [], prog_name="test_parameter_source_environment", env={"NAME": "1"}).exit_code == 1
+
+
+def test_validate_parameter_source():
+    with pytest.raises(ValueError):
+        click.ParameterSource.validate("NOT_A_VALID_PARAMETER_SOURCE")