Repository: wemake-services/wemake-python-styleguide

Issue:
False positive for WPS323 on date/time formatting
# Bug report

Some date and time formatting cases are treated as violation:

    some_datetime.strftime('%Y-%m-%d-%H-%M')
    '{0:%Y-%m-%d-%H-%M}'.format(some_datetime)

But others are not:

    some_datetime.strftime('%H-%M')
    '{0:%H-%M}'.format(some_datetime)

## What's wrong

There is no easy to use alternative for custom date/time formatting except C standard codes.

## How is that should be

The "%" symbol should be allowed at least as part of format specification for new-style format strings.



Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676?src=pr&el=h1) Report
> Merging [#1676](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676?src=pr&el=desc) into [master](https://codecov.io/gh/wemake-services/wemake-python-styleguide/commit/3594ec852bd8a1d48c438177e2ff3d0b94a9ce62?el=desc) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676/graphs/tree.svg?width=650&height=150&src=pr&token=yMZCKw0D02)](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master     #1676   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          105       105           
  Lines         5485      5474   -11     
  Branches      1209      1208    -1     
=========================================
- Hits          5485      5474   -11     
```


| [Impacted Files](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [wemake\_python\_styleguide/visitors/ast/builtins.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676/diff?src=pr&el=tree#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL3Zpc2l0b3JzL2FzdC9idWlsdGlucy5weQ==) | `100.00% <100.00%> (ø)` | |
| [wemake\_python\_styleguide/compat/nodes.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676/diff?src=pr&el=tree#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL2NvbXBhdC9ub2Rlcy5weQ==) | `100.00% <0.00%> (ø)` | |
| [wemake\_python\_styleguide/logic/walrus.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676/diff?src=pr&el=tree#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL2xvZ2ljL3dhbHJ1cy5weQ==) | `100.00% <0.00%> (ø)` | |
| [wemake\_python\_styleguide/compat/routing.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676/diff?src=pr&el=tree#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL2NvbXBhdC9yb3V0aW5nLnB5) | `100.00% <0.00%> (ø)` | |
| [wemake\_python\_styleguide/logic/safe\_eval.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676/diff?src=pr&el=tree#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL2xvZ2ljL3NhZmVfZXZhbC5weQ==) | `100.00% <0.00%> (ø)` | |
| [wemake\_python\_styleguide/compat/packaging.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676/diff?src=pr&el=tree#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL2NvbXBhdC9wYWNrYWdpbmcucHk=) | `100.00% <0.00%> (ø)` | |
| [wemake\_python\_styleguide/visitors/ast/functions.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676/diff?src=pr&el=tree#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL3Zpc2l0b3JzL2FzdC9mdW5jdGlvbnMucHk=) | `100.00% <0.00%> (ø)` | |
| [wemake\_python\_styleguide/visitors/ast/operators.py](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676/diff?src=pr&el=tree#diff-d2VtYWtlX3B5dGhvbl9zdHlsZWd1aWRlL3Zpc2l0b3JzL2FzdC9vcGVyYXRvcnMucHk=) | `100.00% <0.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676?src=pr&el=footer). Last update [3594ec8...75a981f](https://codecov.io/gh/wemake-services/wemake-python-styleguide/pull/1676?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.github/ISSUE_TEMPLATE/Bug.md b/.github/ISSUE_TEMPLATE/Bug.md
index 57987c14..e02c0265 100644
--- a/.github/ISSUE_TEMPLATE/Bug.md
+++ b/.github/ISSUE_TEMPLATE/Bug.md
@@ -16,7 +16,7 @@ But, we will need some information about what's wrong to help you.
 
 <!-- Describe what is not working. Please, attach a traceback. -->
 
-## How is that should be
+## How it should be
 
 <!-- Describe how it should work. -->
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 735b36cc..0d861ab6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,7 +27,6 @@ Semantic versioning in our case means:
 - WPS531: Forbids testing conditions to just return booleans when it is possible to simply return the condition itself
 - Forbids to use unsafe infinite loops
 - Forbids to use raw strings `r''` when not necessary
-- Add parameter to `given_function_called` so that it checks if a function has been called by its name regardless of the modules or classes it is in
 - Forbids to use too complex f-strings
 
 ### Bugfixes
@@ -39,6 +38,7 @@ Semantic versioning in our case means:
 - Allowed yield statements in call method
 - Allow to use `^` with `1`
 - Fixes false positives in WPS513
+- Fixes false positives in WPS323
 
 ### Misc
 
diff --git a/wemake_python_styleguide/visitors/ast/builtins.py b/wemake_python_styleguide/visitors/ast/builtins.py
index b1d6cdb6..fa3f7078 100644
--- a/wemake_python_styleguide/visitors/ast/builtins.py
+++ b/wemake_python_styleguide/visitors/ast/builtins.py
@@ -25,7 +25,12 @@ from wemake_python_styleguide.compat.aliases import (
 )
 from wemake_python_styleguide.logic import nodes, safe_eval, source, walk
 from wemake_python_styleguide.logic.naming.name_nodes import extract_name
-from wemake_python_styleguide.logic.tree import attributes, operators, strings
+from wemake_python_styleguide.logic.tree import (
+    attributes,
+    functions,
+    operators,
+    strings,
+)
 from wemake_python_styleguide.types import (
     AnyChainable,
     AnyFor,
@@ -85,6 +90,13 @@ class WrongStringVisitor(base.BaseNodeVisitor):
         flags=re.X,  # flag to ignore comments and whitespaces.
     )
 
+    #: Names of functions in which we allow strings with modulo patterns.
+    _modulo_pattern_exceptions: ClassVar[FrozenSet[str]] = frozenset((
+        'strftime',  # For date, time, and datetime.strftime()
+        'strptime',  # For date, time, and datetime.strptime()
+        'execute',  # For psycopg2's cur.execute()
+    ))
+
     def visit_any_string(self, node: AnyText) -> None:
         """
         Forbids incorrect usage of strings.
@@ -111,6 +123,22 @@ class WrongStringVisitor(base.BaseNodeVisitor):
                 ),
             )
 
+    def _is_modulo_pattern_exception(self, parent: Optional[ast.AST]) -> bool:
+        """
+        Check if the string with modulo patterns is in an exceptional situation.
+
+        Basically we have some function names in which we allow strings with
+        modulo patterns because they must have them for the functions to work
+        properly.
+        """
+        if parent and isinstance(parent, ast.Call):
+            return bool(functions.given_function_called(
+                parent,
+                self._modulo_pattern_exceptions,
+                split_modules=True,
+            ))
+        return False
+
     def _check_modulo_patterns(
         self,
         node: AnyText,
@@ -121,7 +149,10 @@ class WrongStringVisitor(base.BaseNodeVisitor):
             return  # we allow `%s` in docstrings: they cannot be formatted.
 
         if self._modulo_string_pattern.search(text_data):
-            self.add_violation(consistency.ModuloStringFormatViolation(node))
+            if not self._is_modulo_pattern_exception(parent):
+                self.add_violation(
+                    consistency.ModuloStringFormatViolation(node),
+                )
 
 
 @final


Test patch:
diff --git a/tests/test_visitors/test_ast/test_builtins/test_strings/test_modulo_formatting.py b/tests/test_visitors/test_ast/test_builtins/test_strings/test_modulo_formatting.py
index 37385bdc..0a8deb4d 100644
--- a/tests/test_visitors/test_ast/test_builtins/test_strings/test_modulo_formatting.py
+++ b/tests/test_visitors/test_ast/test_builtins/test_strings/test_modulo_formatting.py
@@ -150,6 +150,33 @@ def test_regular_modulo_string(
     ))
 
 
+@pytest.mark.parametrize('code', [
+    'dt.strftime("%A, %d. %B %Y %I:%M%p")',
+    'datetime.strftime("%d-%m-%Y (%H:%M:%S)")',
+    'datetime.strptime("01-01-2020 (10:20:30)", "%d-%m-%Y (%H:%M:%S)")',
+    'date.strftime("%d-%m-%Y (%H:%M:%S)")',
+    'date.strptime("01-01-2020", "%d-%m-%Y")',
+    'time.strftime("%H:%M:%S")',
+    'time.strptime("10:20:30", "%H:%M:%S")',
+    'strptime("01-01-2020 (10:20:30)", "%d-%m-%Y (%H:%M:%S)")',
+    'cur.execute("SELECT * FROM table WHERE column = %s", ("some_column"))',
+    'execute("SELECT * FROM table WHERE column = %s", ("some_column"))',
+])
+def test_functions_modulo_string(
+    assert_errors,
+    parse_ast_tree,
+    code,
+    default_options,
+):
+    """Testing that the strings violate the rules."""
+    tree = parse_ast_tree(code)
+
+    visitor = WrongStringVisitor(default_options, tree=tree)
+    visitor.run()
+
+    assert_errors(visitor, [], (FormattedStringViolation,))
+
+
 @pytest.mark.parametrize('code', [
     'x % 1',
     '"str" % 1',