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:
Workaround can be: if there's also any date-specific `%` modifier as `%Y` or `%m` we can ignore this case.
If I'm allowed to, I'd like to try and find some way to solve this issue.
You are more than welcome! In case you have any questions, please ask me 🙂 
How difficult would it be to extend this to also ignore SQL statements?  For example, with [psycopg2](https://www.psycopg.org) passing parameters to SQL queries is done by using `%s` markers in the query (as in `SELECT * FROM table WHERE column = %s`, as per [the documentation](https://www.psycopg.org/docs/usage.html#passing-parameters-to-sql-queries)).  

With code doing a lot of SQL stuff it may become sort of tedious to add all the `noqa` markers on each and every query (multiline or not)...
I have not used raw psycopg2 so I am not sure about what I am about to say: if the only function that uses `%s` is `cur.execute()`, I can ignore those `%s` as long as they are being used inside of that function. In other cases, let's say that you save the query in a variable and then use it in `cur.execute()`, it will be pretty difficult for me to come up with a way to ignore that because I would need to check the entire AST of a project to finally see if that variable is being used in that function or it is simply being formatted with the `%` operator.

To sum it up: adding simple exceptions such as that `%s` is being used inside `cur.execute()` or that `%Y`, `%m`... are for datetime formatting is possible, but not 100% reliable, as you might expect. 
Fair enough, I was just wondering on the feasibility :)
Well it is feasible, I just can't tell if the solution I proposed will be accepted because it's just a workaround after all, but as for now I can't think of a better way to do it.

Resolving patch:
diff --git a/wemake_python_styleguide/visitors/ast/builtins.py b/wemake_python_styleguide/visitors/ast/builtins.py
--- a/wemake_python_styleguide/visitors/ast/builtins.py
+++ b/wemake_python_styleguide/visitors/ast/builtins.py
@@ -25,7 +25,12 @@
 )
 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 @@ def _check_is_alphatbet(
                 ),
             )
 
+    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 @@ def _check_modulo_patterns(
             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
--- 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',