Repository: Textualize/textual

Issue:
Integer validator does not work as expected permitting floating point numbers
The culprit lies in this piece of code:

```python
        # We know it's a number, but is that number an integer?
        is_integer = float(value).is_integer()
        if not is_integer:
            return ValidationResult.failure([Integer.NotAnInteger(self, value)])
```

The problem is that `float("1.").is_integer()` and `float("1.0").is_integer()` are all `True` (thankfully `float("1.1").is_integer()` is not).

This issue is not purely cosmetic either. `int("1.")` or `int("1.0")`  all raise exceptions:

```
>>> int("1.0")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: '1.0'
>>> int("1.")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: '1.'
```

Therefore a validator will validate a string as an integer that cannot be parsed as an integer by the `int` type.

Discussion:
@merriam: Hmm.... Funky.

This is an error, meaning the code should be wrong.   On the other hand, there is no way to cause the error to express, because `_input.py` restricts the dot.   That is, you cannot type the '.' because the `_RESTRICT_TYPES` regular expression for integers is just `r"[-+]?\d*"`.    This means "000032" is fine, but "1_000_000" is not.   In practice, there is no sequence of +/- followed by digits in Python that is not an integer.

There is also this error code as well for in the float validator,`if float_value in {math.nan, math.inf, -math.inf}...`which will not trigger for a NaN, as `float('NaN') != float('NaN')`.  It too is an error, but not expressible because of regex.
 
Let me cogitate on input validation for a minute, and, baring insight, I'll put in a PR next week.@github-actions: Don't forget to [star](https://github.com/Textualize/textual) the repository!

Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index faefa5a9b0..9a34988faa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
+## [Unreleased]
+
+### Added
+
+- Input validation for floats and integers accept embedded underscores, e.g., "1_234_567" is valid. https://github.com/Textualize/textual/pull/4784
+
+### Changed
+
+- Input validation for integers no longer accepts scientific notation like '1.5e2'; must be castable to int. https://github.com/Textualize/textual/pull/4784
+
+### Fixed
+
+- Input validation of floats no longer accepts NaN (not a number). https://github.com/Textualize/textual/pull/4784
+
 ## [0.79.1] - 2024-08-31
 
 ### Fixed
diff --git a/src/textual/validation.py b/src/textual/validation.py
index 52a29c1396..438016b1a3 100644
--- a/src/textual/validation.py
+++ b/src/textual/validation.py
@@ -294,7 +294,7 @@ def validate(self, value: str) -> ValidationResult:
         except ValueError:
             return ValidationResult.failure([Number.NotANumber(self, value)])
 
-        if float_value in {math.nan, math.inf, -math.inf}:
+        if math.isnan(float_value) or math.isinf(float_value):
             return ValidationResult.failure([Number.NotANumber(self, value)])
 
         if not self._validate_range(float_value):
@@ -354,10 +354,10 @@ def validate(self, value: str) -> ValidationResult:
             return number_validation_result
 
         # We know it's a number, but is that number an integer?
-        is_integer = float(value).is_integer()
-        if not is_integer:
+        try:
+            int_value = int(value)
+        except ValueError:
             return ValidationResult.failure([Integer.NotAnInteger(self, value)])
-
         return self.success()
 
     def describe_failure(self, failure: Failure) -> str | None:
diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py
index d0baff1e3b..cd6f3c29b4 100644
--- a/src/textual/widgets/_input.py
+++ b/src/textual/widgets/_input.py
@@ -34,10 +34,9 @@
 _POSSIBLE_VALIDATE_ON_VALUES = {"blur", "changed", "submitted"}
 """Set literal with the legal values for the type `InputValidationOn`."""
 
-
 _RESTRICT_TYPES = {
-    "integer": r"[-+]?\d*",
-    "number": r"[-+]?\d*\.?\d*[eE]?[-+]?\d*",
+    "integer": r"[-+]?(?:\d*|\d+_)*",
+    "number": r"[-+]?(?:\d*|\d+_)*\.?(?:\d*|\d+_)*(?:\d[eE]?[-+]?(?:\d*|\d+_)*)?",
     "text": None,
 }
 InputType = Literal["integer", "number", "text"]


Test patch:
diff --git a/tests/input/test_input_restrict.py b/tests/input/test_input_restrict.py
index 12100811ac..48d9d50ee7 100644
--- a/tests/input/test_input_restrict.py
+++ b/tests/input/test_input_restrict.py
@@ -8,23 +8,35 @@
 
 
 def test_input_number_type():
-    """Test number type regex."""
+    """Test number type regex, value should be number or the prefix of a valid number"""
     number = _RESTRICT_TYPES["number"]
     assert re.fullmatch(number, "0")
     assert re.fullmatch(number, "0.")
     assert re.fullmatch(number, ".")
+    assert re.fullmatch(number, "-")
+    assert re.fullmatch(number, "+")
     assert re.fullmatch(number, ".0")
     assert re.fullmatch(number, "1.1")
+    assert re.fullmatch(number, "1_")
+    assert re.fullmatch(number, "1_2")
+    assert re.fullmatch(number, "-000_123_456.78e01_234")
     assert re.fullmatch(number, "1e1")
+    assert re.fullmatch(number, "1")
+    assert re.fullmatch(number, "1.")
+    assert re.fullmatch(number, "1.2")
     assert re.fullmatch(number, "1.2e")
-    assert re.fullmatch(number, "1.2e10")
-    assert re.fullmatch(number, "1.2E10")
     assert re.fullmatch(number, "1.2e-")
+    assert re.fullmatch(number, "1.2e-1")
     assert re.fullmatch(number, "1.2e-10")
+    assert re.fullmatch(number, "1.2E10")
     assert not re.fullmatch(number, "1.2e10e")
+    assert not re.fullmatch(number, "-000_123_456.78e01_234.")
+    assert not re.fullmatch(number, "e")  # float("e23") is not valid
     assert not re.fullmatch(number, "1f2")
     assert not re.fullmatch(number, "inf")
     assert not re.fullmatch(number, "nan")
+    assert not re.fullmatch(number, "-inf")
+
 
 
 def test_input_integer_type():
@@ -38,6 +50,13 @@ def test_input_integer_type():
     assert re.fullmatch(integer, "+")
     assert re.fullmatch(integer, "-1")
     assert re.fullmatch(integer, "+2")
+    assert re.fullmatch(integer, "+0")
+    assert re.fullmatch(integer, "+0_")
+    assert re.fullmatch(integer, "+0_1")
+    assert re.fullmatch(integer, "+0_12")
+    assert re.fullmatch(integer, "+0_123")
+    assert not re.fullmatch(integer, "+_123")
+    assert not re.fullmatch(integer, "123.")
     assert not re.fullmatch(integer, "+2e")
     assert not re.fullmatch(integer, "foo")
 
@@ -125,9 +144,9 @@ def compose(self) -> ComposeResult:
 
         integer_input.focus()
         await pilot.press("a")
-        assert integer_input.value == ""
-
+        assert not integer_input.value
         await pilot.press("-")
+        assert integer_input.value == "-"
         assert integer_input.is_valid is False
 
         await pilot.press("1")
diff --git a/tests/test_validation.py b/tests/test_validation.py
index c72a64472e..38be8b38e9 100644
--- a/tests/test_validation.py
+++ b/tests/test_validation.py
@@ -114,6 +114,12 @@ def test_Failure_description_describe_and_description_inside_validate():
         ("99", 100, 200, False),  # valid number but not in range
         ("201", 100, 200, False),  # valid number but not in range
         ("1.23e4", 0, 50000, True),  # valid scientific notation within range
+        ("inf", None, None, False),  # infinity never valid
+        ("nan", None, None, False),  # nan never valid
+        ("-inf", None, None, False),  # nan never valid
+        ("-4", 0, 5, False),  # valid negative number, out of range with zero
+        ("2", -3, 0, False),  # valid number out of range with zero
+        ("-2", -3, 0, True),  # negative in range
     ],
 )
 def test_Number_validate(value, minimum, maximum, expected_result):
@@ -154,7 +160,11 @@ def test_Regex_validate(regex, value, expected_result):
         ("123", 100, 200, True),  # valid integer within range
         ("99", 100, 200, False),  # valid integer but not in range
         ("201", 100, 200, False),  # valid integer but not in range
-        ("1.23e4", None, None, True),  # valid integer in scientific notation
+        ("1.23e4", None, None, False),  # valid scientific notation, even resolving to an integer, is not valid
+        ("123.", None, None, False),  # periods not valid in integers
+        ("123_456", None, None, True),  # underscores are valid python
+        ("_123_456", None, None, False),  # leading underscores are not valid python
+        ("-123", -123, -123, True),  # valid negative number in minimal range
     ],
 )
 def test_Integer_validate(value, minimum, maximum, expected_result):