Repository: pallets/click
Pull request #1099 (2018-08-27 23:06:06)

Rebase of #423 to add docs, hopefully ready for merge
#423 is pending on a documentation addition.
As with many other old PRs, I don't think we should necessarily expect the original author to come back to things. Trying to cleanup the backlog.

I considered pushing to the #423 branch, but I wanted to drop the commit which makes things py2.6 compatible (since it's no longer relevant) and dropping a commit from someone else's PR is bad form.

So, to restate:
- adds `click.DateTime` type with a couple of tests
- adds documentation for the above on the param types page
- adds another ISO 8601 format string to the defaults: `%Y-%m-%d %H:%M:%S`, which is pretty commonly used in my experience
- add a note to the changelog containing this new type

closes #423


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 4601053..d56bbff 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -15,6 +15,7 @@ Unreleased
 -   Usage errors now hint at the ``--help`` option. (`#393`_, `#557`_)
 -   Implement streaming pager. (`#409`_, `#889`_)
 -   Extract bar formatting to its own method. (`#414`_)
+-   Add ``DateTime`` type for converting input in given date time formats. (`#423`_)
 -   ``secho``'s first argument can now be ``None``, like in ``echo``. (`#424`_)
 -   Fixes a ``ZeroDivisionError`` in ``ProgressBar.make_step``, when the arg passed to the first call of ``ProgressBar.update`` is 0. (`#447`_, `#1012`_)
 -   Show progressbar only if total execution time is visible. (`#487`_)
@@ -93,6 +94,7 @@ Unreleased
 .. _#393: https://github.com/pallets/click/issues/393
 .. _#409: https://github.com/pallets/click/issues/409
 .. _#414: https://github.com/pallets/click/pull/414
+.. _#423: https://github.com/pallets/click/pull/423
 .. _#424: https://github.com/pallets/click/pull/424
 .. _#447: https://github.com/pallets/click/issues/447
 .. _#487: https://github.com/pallets/click/pull/487
diff --git a/click/__init__.py b/click/__init__.py
index 6de314b..fc40c7e 100644
--- a/click/__init__.py
+++ b/click/__init__.py
@@ -28,7 +28,7 @@ from .decorators import pass_context, pass_obj, make_pass_decorator, \
 
 # Types
 from .types import ParamType, File, Path, Choice, IntRange, Tuple, \
-     STRING, INT, FLOAT, BOOL, UUID, UNPROCESSED, FloatRange
+     DateTime, STRING, INT, FLOAT, BOOL, UUID, UNPROCESSED, FloatRange
 
 # Utilities
 from .utils import echo, get_binary_stream, get_text_stream, open_file, \
@@ -65,8 +65,9 @@ __all__ = [
     'version_option', 'help_option',
 
     # Types
-    'ParamType', 'File', 'Path', 'Choice', 'IntRange', 'Tuple', 'STRING',
-    'INT', 'FLOAT', 'BOOL', 'UUID', 'UNPROCESSED', 'FloatRange',
+    'ParamType', 'File', 'Path', 'Choice', 'IntRange', 'Tuple',
+    'DateTime', 'STRING', 'INT', 'FLOAT', 'BOOL', 'UUID', 'UNPROCESSED',
+    'FloatRange',
 
     # Utilities
     'echo', 'get_binary_stream', 'get_text_stream', 'open_file',
diff --git a/click/types.py b/click/types.py
index 0d4b122..1f88032 100644
--- a/click/types.py
+++ b/click/types.py
@@ -1,5 +1,6 @@
 import os
 import stat
+from datetime import datetime
 
 from ._compat import open_stream, text_type, filename_to_ui, \
     get_filesystem_encoding, get_streerror, _get_argv_encoding, PY2
@@ -182,6 +183,59 @@ class Choice(ParamType):
         return 'Choice(%r)' % list(self.choices)
 
 
+class DateTime(ParamType):
+    """The DateTime type converts date strings into `datetime` objects.
+
+    The format strings which are checked are configurable, but default to some
+    common (non-timezone aware) ISO 8601 formats.
+
+    When specifying *DateTime* formats, you should only pass a list or a tuple.
+    Other iterables, like generators, may lead to surprising results.
+
+    The format strings are processed using ``datetime.strptime``, and this
+    consequently defines the format strings which are allowed.
+
+    Parsing is tried using each format, in order, and the first format which
+    parses successfully is used.
+
+    :param formats: A list or tuple of date format strings, in the order in
+                    which they should be tried. Defaults to
+                    ``'%Y-%m-%d'``, ``'%Y-%m-%dT%H:%M:%S'``,
+                    ``'%Y-%m-%d %H:%M:%S'``.
+    """
+    name = 'datetime'
+
+    def __init__(self, formats=None):
+        self.formats = formats or [
+            '%Y-%m-%d',
+            '%Y-%m-%dT%H:%M:%S',
+            '%Y-%m-%d %H:%M:%S'
+        ]
+
+    def get_metavar(self, param):
+        return '[{}]'.format('|'.join(self.formats))
+
+    def _try_to_convert_date(self, value, format):
+        try:
+            return datetime.strptime(value, format)
+        except ValueError:
+            return None
+
+    def convert(self, value, param, ctx):
+        # Exact match
+        for format in self.formats:
+            dtime = self._try_to_convert_date(value, format)
+            if dtime:
+                return dtime
+
+        self.fail(
+            'invalid datetime format: {}. (choose from {})'.format(
+                value, ', '.join(self.formats)))
+
+    def __repr__(self):
+        return 'DateTime'
+
+
 class IntParamType(ParamType):
     name = 'integer'
 
diff --git a/docs/parameters.rst b/docs/parameters.rst
index 112e8fa..9ca3208 100644
--- a/docs/parameters.rst
+++ b/docs/parameters.rst
@@ -70,6 +70,9 @@ different behavior and some are supported out of the box:
 .. autoclass:: FloatRange
   :noindex:
 
+.. autoclass:: DateTime
+   :noindex:
+
 Custom parameter types can be implemented by subclassing
 :class:`click.ParamType`.  For simple cases, passing a Python function that
 fails with a `ValueError` is also supported, though discouraged.


Test patch:
diff --git a/tests/test_basic.py b/tests/test_basic.py
index 8ba251f..8de6301 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -332,6 +332,44 @@ def test_choice_option(runner):
     assert '--method [foo|bar|baz]' in result.output
 
 
+def test_datetime_option_default(runner):
+
+    @click.command()
+    @click.option('--start_date', type=click.DateTime())
+    def cli(start_date):
+        click.echo(start_date.strftime('%Y-%m-%dT%H:%M:%S'))
+
+    result = runner.invoke(cli, ['--start_date=2015-09-29'])
+    assert not result.exception
+    assert result.output == '2015-09-29T00:00:00\n'
+
+    result = runner.invoke(cli, ['--start_date=2015-09-29T09:11:22'])
+    assert not result.exception
+    assert result.output == '2015-09-29T09:11:22\n'
+
+    result = runner.invoke(cli, ['--start_date=2015-09'])
+    assert result.exit_code == 2
+    assert ('Invalid value for "--start_date": '
+            'invalid datetime format: 2015-09. '
+            '(choose from %Y-%m-%d, %Y-%m-%dT%H:%M:%S, %Y-%m-%d %H:%M:%S)'
+            ) in result.output
+
+    result = runner.invoke(cli, ['--help'])
+    assert '--start_date [%Y-%m-%d|%Y-%m-%dT%H:%M:%S|%Y-%m-%d %H:%M:%S]' in result.output
+
+
+def test_datetime_option_custom(runner):
+    @click.command()
+    @click.option('--start_date',
+                  type=click.DateTime(formats=['%A %B %d, %Y']))
+    def cli(start_date):
+        click.echo(start_date.strftime('%Y-%m-%dT%H:%M:%S'))
+
+    result = runner.invoke(cli, ['--start_date=Wednesday June 05, 2010'])
+    assert not result.exception
+    assert result.output == '2010-06-05T00:00:00\n'
+
+
 def test_int_range_option(runner):
     @click.command()
     @click.option('--x', type=click.IntRange(0, 5))
diff --git a/tests/test_imports.py b/tests/test_imports.py
index f400fa8..8e9a97d 100644
--- a/tests/test_imports.py
+++ b/tests/test_imports.py
@@ -32,7 +32,7 @@ click.echo(json.dumps(rv))
 ALLOWED_IMPORTS = set([
     'weakref', 'os', 'struct', 'collections', 'sys', 'contextlib',
     'functools', 'stat', 're', 'codecs', 'inspect', 'itertools', 'io',
-    'threading', 'colorama', 'errno', 'fcntl'
+    'threading', 'colorama', 'errno', 'fcntl', 'datetime'
 ])
 
 if WIN: