Repository: wemake-services/wemake-python-styleguide

Issue:
Enforce consistent octal, binary, and hex numbers
# Rule request

<!-- Thanks for coming up with a proposal! -->

## Thesis

We should enforce `0x0` over `0X0`, `0b0` over `0B0`, and `0e0` over `0E0`.
Why? For consistency. 



Discussion:
Hello, I will work on this issue.

Resolving patch:
diff --git a/wemake_python_styleguide/violations/consistency.py b/wemake_python_styleguide/violations/consistency.py
--- a/wemake_python_styleguide/violations/consistency.py
+++ b/wemake_python_styleguide/violations/consistency.py
@@ -40,6 +40,7 @@
    RequiredBaseClassViolation
    MultipleIfsInComprehensionViolation
    ConstantComparisonViolation
+   BadNumberSuffixViolation
 
 Consistency checks
 ------------------
@@ -53,6 +54,7 @@
 .. autoclass:: RequiredBaseClassViolation
 .. autoclass:: MultipleIfsInComprehensionViolation
 .. autoclass:: ConstantComparisonViolation
+.. autoclass:: BadNumberSuffixViolation
 
 """
 
@@ -350,3 +352,52 @@ class ConstantComparisonViolation(ASTViolation):
     #: Error message shown to the user.
     error_template = 'Found constant comparison'
     code = 308
+
+
+class BadNumberSuffixViolation(TokenizeViolation):
+    """
+    Forbids to use capital (``XOBE``) in numbers.
+
+    Reasoning:
+        Octal, hex, binary and scientific notation suffixes could
+        be written in two possible notations: lowercase and uppercase.
+        Which brings confusion and decreases code consistency and readability.
+
+        It is possible to write ``0xFF`` in two different ways:
+        ``0xFF``, ``0XFF``.
+        It is possible to write ``0o11`` in two different ways:
+        ``0o11``, ``0O11``.
+        It is possible to write ``0b1001`` in two different ways:
+        ``0b1001``, ``0B1001``.
+        It is possible to write ``1.5e+10`` in two different ways:
+        ``1.5e+10``, ``1.5E+10``.
+
+        We enforce a single way to write numbers with suffixes:
+        suffix without capital chars.
+
+    Solution:
+        Octal, hex, binary and scientific notation suffixes in numbers
+        should be written lowercase.
+
+    Example::
+
+        # Correct:
+        hex_number = 0xFF
+        octal_number = 0o11
+        binary_number = 0b1001
+        number_with_scientific_notation = 1.5e+10
+
+        # Wrong:
+        hex_number = 0XFF
+        octal_number = 0O11
+        binary_number = 0B1001
+        number_with_scientific_notation = 1.5E+10
+
+    Note:
+        Returns Z309 as error code
+
+    """
+
+    code = 309
+    #: Error message shown to the user.
+    error_template = 'Found underscored number: {0}'
diff --git a/wemake_python_styleguide/visitors/tokenize/primitives.py b/wemake_python_styleguide/visitors/tokenize/primitives.py
--- a/wemake_python_styleguide/visitors/tokenize/primitives.py
+++ b/wemake_python_styleguide/visitors/tokenize/primitives.py
@@ -3,6 +3,7 @@
 import tokenize
 
 from wemake_python_styleguide.violations.consistency import (
+    BadNumberSuffixViolation,
     PartialFloatViolation,
     UnderscoredNumberViolation,
     UnicodeStringViolation,
@@ -13,6 +14,8 @@
 class WrongPrimitivesVisitor(BaseTokenVisitor):
     """Visits primitive types to find incorrect usages."""
 
+    _bad_number_suffixes = ('X', 'O', 'B', 'E')
+
     def _check_underscored_number(self, token: tokenize.TokenInfo) -> None:
         if '_' in token.string:
             self.add_violation(
@@ -23,6 +26,12 @@ def _check_partial_float(self, token: tokenize.TokenInfo) -> None:
         if token.string.startswith('.') or token.string.endswith('.'):
             self.add_violation(PartialFloatViolation(token, text=token.string))
 
+    def _check_bad_number_suffixes(self, token: tokenize.TokenInfo) -> None:
+        if any(char in token.string for char in self._bad_number_suffixes):
+            self.add_violation(
+                BadNumberSuffixViolation(token, text=token.string),
+            )
+
     def visit_string(self, token: tokenize.TokenInfo) -> None:
         """
         Checks string declarations.
@@ -44,7 +53,9 @@ def visit_number(self, token: tokenize.TokenInfo) -> None:
         Raises:
             UnderscoredNumberViolation
             PartialFloatViolation
+            BadNumberSuffixViolation
 
         """
         self._check_underscored_number(token)
         self._check_partial_float(token)
+        self._check_bad_number_suffixes(token)


Test patch:
diff --git a/tests/fixtures/noqa.py b/tests/fixtures/noqa.py
--- a/tests/fixtures/noqa.py
+++ b/tests/fixtures/noqa.py
@@ -50,3 +50,8 @@ class Nested:  # noqa: Z306,Z431
 
 
 magic_numbers = 13.2 + 50  # noqa: Z432
+
+hex_number = 0XFF  # noqa: Z309
+octal_number = 0O11  # noqa: Z309
+binary_number = 0B1001  # noqa: Z309
+number_with_scientific_notation = 1.5E+10  # noqa: Z309
diff --git a/tests/test_visitors/test_tokenize/test_wrong_primitives/test_number_suffixes.py b/tests/test_visitors/test_tokenize/test_wrong_primitives/test_number_suffixes.py
new file mode 100644
--- /dev/null
+++ b/tests/test_visitors/test_tokenize/test_wrong_primitives/test_number_suffixes.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+
+import pytest
+
+from wemake_python_styleguide.violations.consistency import (
+    BadNumberSuffixViolation,
+)
+from wemake_python_styleguide.visitors.tokenize.primitives import (
+    WrongPrimitivesVisitor,
+)
+
+
+@pytest.mark.parametrize('code', [
+    'x = 0XFF',
+    'print(1.5E+10)',
+    '0O11 + 5',
+    'y = 0B1001',
+])
+def test_bad_number_suffixes(
+    parse_tokens,
+    assert_errors,
+    default_options,
+    code,
+):
+    """Ensures that numbers with siffix not in lowercase raise a warning."""
+    file_tokens = parse_tokens(code)
+
+    visitor = WrongPrimitivesVisitor(default_options, file_tokens=file_tokens)
+    visitor.run()
+
+    assert_errors(visitor, [BadNumberSuffixViolation])
+
+
+@pytest.mark.parametrize('code', [
+    'x = 0xFF',
+    'print(1.5e+10)',
+    '0o11 + 5',
+    'y = 0b1001',
+    'print("0XFF")',
+])
+def test_correct_number_suffixes(
+    parse_tokens,
+    assert_errors,
+    default_options,
+    code,
+):
+    """Ensures that correct numbers are fine."""
+    file_tokens = parse_tokens(code)
+
+    visitor = WrongPrimitivesVisitor(default_options, file_tokens=file_tokens)
+    visitor.run()
+
+    assert_errors(visitor, [])