Repository: cookiecutter/cookiecutter

Issue:
[Discussion] Dropping jinja2-time as dependency
* Cookiecutter version: 2.1.1
* Template project url: -
* Python version: 3.10
* Operating System: Void Linux

### Description:

I recently tried to package cookiecutter for Void Linux (https://github.com/void-linux/void-packages/pull/38176#issuecomment-1199183537), but was refused by the maintainers because of the jinja2-time dependency and its questionable state.

I'd like to ask if there's a solution on cookiecutter's side. I know it's a lot to ask to replace a dependency, but maybe this could benefit the project in general.


Discussion:
It is 65 LOC https://github.com/hackebrot/jinja2-time/blob/main/jinja2_time/jinja2_time.py (and some tests). 
One option is to copy it over here and update it if needed. 
I am -1 for removal.

Resolving patch:
diff --git a/cookiecutter/environment.py b/cookiecutter/environment.py
--- a/cookiecutter/environment.py
+++ b/cookiecutter/environment.py
@@ -26,8 +26,8 @@ def __init__(self, **kwargs):
             'cookiecutter.extensions.JsonifyExtension',
             'cookiecutter.extensions.RandomStringExtension',
             'cookiecutter.extensions.SlugifyExtension',
+            'cookiecutter.extensions.TimeExtension',
             'cookiecutter.extensions.UUIDExtension',
-            'jinja2_time.TimeExtension',
         ]
         extensions = default_extensions + self._read_extensions(context)
 
diff --git a/cookiecutter/extensions.py b/cookiecutter/extensions.py
--- a/cookiecutter/extensions.py
+++ b/cookiecutter/extensions.py
@@ -1,9 +1,11 @@
 """Jinja2 extensions."""
+import arrow
 import json
 import string
 import uuid
 from secrets import choice
 
+from jinja2 import nodes
 from jinja2.ext import Extension
 from slugify import slugify as pyslugify
 
@@ -64,3 +66,65 @@ def uuid4():
             return str(uuid.uuid4())
 
         environment.globals.update(uuid4=uuid4)
+
+
+class TimeExtension(Extension):
+    """Jinja2 Extension for dates and times."""
+
+    tags = {'now'}
+
+    def __init__(self, environment):
+        """Jinja2 Extension constructor."""
+        super().__init__(environment)
+
+        environment.extend(datetime_format='%Y-%m-%d')
+
+    def _datetime(self, timezone, operator, offset, datetime_format):
+        d = arrow.now(timezone)
+
+        # parse shift params from offset and include operator
+        shift_params = {}
+        for param in offset.split(','):
+            interval, value = param.split('=')
+            shift_params[interval.strip()] = float(operator + value.strip())
+        d = d.shift(**shift_params)
+
+        if datetime_format is None:
+            datetime_format = self.environment.datetime_format
+        return d.strftime(datetime_format)
+
+    def _now(self, timezone, datetime_format):
+        if datetime_format is None:
+            datetime_format = self.environment.datetime_format
+        return arrow.now(timezone).strftime(datetime_format)
+
+    def parse(self, parser):
+        """Parse datetime template and add datetime value."""
+        lineno = next(parser.stream).lineno
+
+        node = parser.parse_expression()
+
+        if parser.stream.skip_if('comma'):
+            datetime_format = parser.parse_expression()
+        else:
+            datetime_format = nodes.Const(None)
+
+        if isinstance(node, nodes.Add):
+            call_method = self.call_method(
+                '_datetime',
+                [node.left, nodes.Const('+'), node.right, datetime_format],
+                lineno=lineno,
+            )
+        elif isinstance(node, nodes.Sub):
+            call_method = self.call_method(
+                '_datetime',
+                [node.left, nodes.Const('-'), node.right, datetime_format],
+                lineno=lineno,
+            )
+        else:
+            call_method = self.call_method(
+                '_now',
+                [node, datetime_format],
+                lineno=lineno,
+            )
+        return nodes.Output([call_method], lineno=lineno)
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -11,9 +11,9 @@
     'Jinja2>=2.7,<4.0.0',
     'click>=7.0,<9.0.0',
     'pyyaml>=5.3.1',
-    'jinja2-time>=0.2.0',
     'python-slugify>=4.0.0',
     'requests>=2.23.0',
+    'arrow',
 ]
 
 setup(


Test patch:
diff --git a/tests/test_environment.py b/tests/test_environment.py
--- a/tests/test_environment.py
+++ b/tests/test_environment.py
@@ -18,8 +18,8 @@ def test_env_should_raise_for_unknown_extension():
 def test_env_should_come_with_default_extensions():
     """Verify default extensions loaded with StrictEnvironment."""
     env = StrictEnvironment(keep_trailing_newline=True)
-    assert 'jinja2_time.jinja2_time.TimeExtension' in env.extensions
     assert 'cookiecutter.extensions.JsonifyExtension' in env.extensions
     assert 'cookiecutter.extensions.RandomStringExtension' in env.extensions
     assert 'cookiecutter.extensions.SlugifyExtension' in env.extensions
+    assert 'cookiecutter.extensions.TimeExtension' in env.extensions
     assert 'cookiecutter.extensions.UUIDExtension' in env.extensions
diff --git a/tests/test_time_extension.py b/tests/test_time_extension.py
new file mode 100644
--- /dev/null
+++ b/tests/test_time_extension.py
@@ -0,0 +1,79 @@
+"""Tests for the TimeExtension Jinja2 extension."""
+import freezegun
+import pytest
+
+from jinja2 import Environment, exceptions
+
+
+@pytest.fixture
+def environment():
+    """Fixture. Add tested extension to environment."""
+    return Environment(extensions=['cookiecutter.extensions.TimeExtension'])
+
+
+@pytest.fixture(autouse=True)
+def freeze():
+    """Fixture. Freeze time for all tests."""
+    freezer = freezegun.freeze_time("2015-12-09 23:33:01")
+    freezer.start()
+    yield
+    freezer.stop()
+
+
+def test_tz_is_required(environment):
+    """Verify template parsing fails without a timezone."""
+    with pytest.raises(exceptions.TemplateSyntaxError):
+        environment.from_string('{% now %}')
+
+
+def test_utc_default_datetime_format(environment):
+    """Verify default datetime format can be parsed."""
+    template = environment.from_string("{% now 'utc' %}")
+
+    assert template.render() == "2015-12-09"
+
+
+@pytest.mark.parametrize("valid_tz", ['utc', 'local', 'Europe/Berlin'])
+def test_accept_valid_timezones(environment, valid_tz):
+    """Verify that valid timezones are accepted."""
+    template = environment.from_string(f"{{% now '{valid_tz}', '%Y-%m' %}}")
+
+    assert template.render() == '2015-12'
+
+
+def test_environment_datetime_format(environment):
+    """Verify datetime format can be parsed from environment."""
+    environment.datetime_format = '%a, %d %b %Y %H:%M:%S'
+
+    template = environment.from_string("{% now 'utc' %}")
+
+    assert template.render() == "Wed, 09 Dec 2015 23:33:01"
+
+
+def test_add_time(environment):
+    """Verify that added time offset can be parsed."""
+    environment.datetime_format = '%a, %d %b %Y %H:%M:%S'
+
+    template = environment.from_string("{% now 'utc' + 'hours=2,seconds=30' %}")
+
+    assert template.render() == "Thu, 10 Dec 2015 01:33:31"
+
+
+def test_substract_time(environment):
+    """Verify that substracted time offset can be parsed."""
+    environment.datetime_format = '%a, %d %b %Y %H:%M:%S'
+
+    template = environment.from_string("{% now 'utc' - 'minutes=11' %}")
+
+    assert template.render() == "Wed, 09 Dec 2015 23:22:01"
+
+
+def test_offset_with_format(environment):
+    """Verify that offset works together with datetime format."""
+    environment.datetime_format = '%d %b %Y %H:%M:%S'
+
+    template = environment.from_string(
+        "{% now 'utc' - 'days=2,minutes=33,seconds=1', '%d %b %Y %H:%M:%S' %}"
+    )
+
+    assert template.render() == "07 Dec 2015 23:00:00"