diff --git a/mathparse/__init__.py b/mathparse/__init__.py index 05a962c..dd526e7 100644 --- a/mathparse/__init__.py +++ b/mathparse/__init__.py @@ -2,4 +2,4 @@ mathparse is a library for solving mathematical equations contained in strings """ -__version__ = '0.2.5' +__version__ = '0.2.6' diff --git a/mathparse/mathparse.py b/mathparse/mathparse.py index 9e572b5..f7e6893 100644 --- a/mathparse/mathparse.py +++ b/mathparse/mathparse.py @@ -18,16 +18,19 @@ def is_int(string: str) -> bool: """ Return true if string is an integer. """ - try: - int(string) - return True - except ValueError: - return False + # Allow leading minus sign + if string[0] == '-': + # Must have at least one digit after the minus + return len(string) > 1 and string[1:].isdigit() + else: + # Must be all digits + return string.isdigit() def is_float(string: str) -> bool: """ Return true if the string is a float. + Returns False for non-strings or strings without a decimal point. """ try: float(string) @@ -79,6 +82,27 @@ def is_word(word: str, language: str) -> bool: return word in words +def to_number(val) -> Union[int, float, str, Decimal]: + """ + Convert a string to an int or float if possible. + """ + # If already a number (int, float, Decimal), return as-is + if isinstance(val, (int, float, Decimal)): + return val + + # Check if it's a constant and convert + if is_constant(val): + return mathwords.CONSTANTS[val] + + # Otherwise, try to convert string to number + if is_int(val): + return int(val) + elif is_float(val): + return float(val) + + return val + + def create_unicode_word_boundary_pattern(word: str) -> str: """ Create a regex pattern with Unicode-aware word boundaries. @@ -459,11 +483,11 @@ def to_postfix(tokens: list) -> list: for token in tokens: if is_int(token): - postfix.append(int(token)) + postfix.append(token) elif is_float(token): - postfix.append(float(token)) + postfix.append(token) elif token in mathwords.CONSTANTS: - postfix.append(mathwords.CONSTANTS[token]) + postfix.append(token) elif is_unary(token): opstack.append(token) elif token == '(': @@ -516,6 +540,8 @@ def evaluate_postfix(tokens: list) -> Union[int, float, str, Decimal]: stack.append(token) elif is_unary(token): a = stack.pop() + # Convert token (string) to number for unary function evaluation + a = to_number(a) total = mathwords.UNARY_FUNCTIONS[token](a) elif len(stack) == 1: raise PostfixTokenEvaluationException( @@ -526,14 +552,15 @@ def evaluate_postfix(tokens: list) -> Union[int, float, str, Decimal]: elif len(stack) > 1: b = stack.pop() a = stack.pop() + if token == '+': - total = a + b + total = to_number(a) + to_number(b) elif token == '-': - total = a - b + total = to_number(a) - to_number(b) elif token == '*': - total = a * b + total = to_number(a) * to_number(b) elif token == '^': - total = a ** b + total = to_number(a) ** to_number(b) elif token == '/': if Decimal(str(b)) == 0: total = 'undefined' @@ -543,19 +570,33 @@ def evaluate_postfix(tokens: list) -> Union[int, float, str, Decimal]: # Treat decimal points as a binary operator that combines the # integer and fractional part of two numbers # Example: 53 . 25 = 53.25, -3 . 5 = -3.5 - if b == 0: - total = Decimal(a) + + # Convert b to number + numeric_b = to_number(b) + + if numeric_b == 0: + # Convert a to number + numeric_a = to_number(a) + total = Decimal(numeric_a) else: - # Count the digits in b to determine the divisor - digits = len(str(int(b))) + # Check if 'a' has a negative sign (handles -0 case) + is_negative = str(a).startswith('-') + + # Convert a to number for calculation + numeric_a = to_number(a) + + # Count the digits in the original string b to preserve + # leading zeros (e.g., "01" has 2 digits, not 1) + digits = len(str(b)) divisor = 10 ** digits - fractional_part = b / divisor - # Handle negative numbers correctly: -3 . 5 should be -3.5, - # not -2.5 - if a < 0: - total = a - fractional_part + fractional_part = numeric_b / divisor + + # Handle negatives: -3 . 5 = -3.5, not -2.5 + # Also -0 . 5 = -0.5 (check string since -0 == 0) + if is_negative: + total = numeric_a - fractional_part else: - total = a + fractional_part + total = numeric_a + fractional_part else: raise PostfixTokenEvaluationException( 'Unknown token "{}"'.format(token) @@ -570,7 +611,16 @@ def evaluate_postfix(tokens: list) -> Union[int, float, str, Decimal]: 'The postfix expression resulted in an empty stack' ) - return stack.pop() + result = stack.pop() + + # Convert final result from string to number if needed + if isinstance(result, str): + if is_int(result): + result = int(result) + elif is_float(result): + result = float(result) + + return result def tokenize(string: str, language: str = None, escape: str = '___') -> list: @@ -822,4 +872,10 @@ def extract_expression(dirty_string: str, language: str) -> str: else: end_index -= 1 - return ' '.join(tokens[start_index:end_index]) + result = ' '.join(tokens[start_index:end_index]) + + # Remove spaces around decimal points for cleaner output + # Replace " . " with "." to convert "-100 . 5" to "-100.5" + result = result.replace(' . ', '.') + + return result diff --git a/tests/test_prefix_unary_operations.py b/tests/test_prefix_unary_operations.py index 2104389..70073d1 100644 --- a/tests/test_prefix_unary_operations.py +++ b/tests/test_prefix_unary_operations.py @@ -231,6 +231,15 @@ def test_extract_pure_subtraction(self): result = mathparse.extract_expression('5 - 3', language='ENG') self.assertEqual(result, '5 - 3') + def test_extract_negative_decimal(self): + """ + Test: Extract leading negative decimal from sentence + """ + result = mathparse.extract_expression( + 'What is -100.5 * 20?', language='ENG' + ) + self.assertEqual(result, '-100.5 * 20') + def test_extract_negative_in_parentheses_with_words(self): """ Test: Extract expression with negative in parentheses from sentence @@ -241,3 +250,148 @@ def test_extract_negative_in_parentheses_with_words(self): # NOTE: Spaces are currently added, but ideally these will be removed # in the future self.assertEqual(result, '( -3 ) + 5') + + +class NegativeDecimalsLessThanOneTestCase(TestCase): + """ + Test cases for negative decimal numbers between -1 and 0. + + This addresses a bug where negative decimals less than 1 + (e.g., -0.2, -0.5) were losing their negative sign during parsing. + The issue was caused by Python's -0 == 0 equality, where int('-0') + returns 0, losing sign information. + """ + + def test_negative_zero_point_two(self): + """ + Test: -0.2 should equal -0.2 + """ + result = mathparse.parse('-0.2') + self.assertEqual(result, -0.2) + + def test_negative_zero_point_five(self): + """ + Test: -0.5 should equal -0.5 + """ + result = mathparse.parse('-0.5') + self.assertEqual(result, -0.5) + + def test_negative_zero_point_nine(self): + """ + Test: -0.9 should equal -0.9 + """ + result = mathparse.parse('-0.9') + self.assertEqual(result, -0.9) + + def test_negative_zero_point_one(self): + """ + Test: -0.1 should equal -0.1 + """ + result = mathparse.parse('-0.1') + self.assertEqual(result, -0.1) + + def test_negative_zero_point_ninety_nine(self): + """ + Test: -0.99 should equal -0.99""" + result = mathparse.parse('-0.99') + self.assertEqual(result, -0.99) + + def test_negative_zero_point_zero_one(self): + """ + Test: -0.01 should equal -0.01 + """ + result = mathparse.parse('-0.01') + self.assertEqual(result, -0.01) + + def test_negative_zero_point_nine_nine_nine(self): + """ + Test: -0.999 should equal -0.999 + """ + result = mathparse.parse('-0.999') + self.assertEqual(result, -0.999) + + def test_negative_decimal_in_addition(self): + """ + Test: -0.5 + 1 should equal 0.5 + """ + result = mathparse.parse('-0.5 + 1') + self.assertEqual(result, 0.5) + + def test_negative_decimal_in_subtraction(self): + """ + Test: 2 - -0.5 should equal 2.5 + """ + result = mathparse.parse('2 - -0.5') + self.assertEqual(result, 2.5) + + def test_negative_decimal_in_multiplication(self): + """ + Test: -0.5 * 4 should equal -2.0 + """ + result = mathparse.parse('-0.5 * 4') + self.assertEqual(result, -2.0) + + def test_negative_decimal_in_division(self): + """ + Test: -0.8 / 2 should equal -0.4 + """ + result = mathparse.parse('-0.8 / 2') + self.assertEqual(float(result), -0.4) + + def test_multiple_negative_decimals(self): + """ + Test: -0.3 + -0.2 should equal -0.5 + """ + result = mathparse.parse('-0.3 + -0.2') + self.assertAlmostEqual(result, -0.5, places=10) + + def test_negative_decimal_with_parentheses(self): + """ + Test: (-0.5) should equal -0.5 + """ + result = mathparse.parse('(-0.5)') + self.assertEqual(result, -0.5) + + def test_negative_decimal_in_complex_expression(self): + """ + Test: (-0.5 + 1) * 2 should equal 1.0 + """ + result = mathparse.parse('(-0.5 + 1) * 2') + self.assertEqual(result, 1.0) + + def test_positive_decimals_still_work(self): + """ + Test: 0.5 should equal 0.5 + """ + result = mathparse.parse('0.5') + self.assertEqual(result, 0.5) + + def test_negative_integers_still_work(self): + """ + Test: -5 should equal -5 + """ + result = mathparse.parse('-5') + self.assertEqual(result, -5) + + def test_negative_decimals_greater_than_one_still_work(self): + """ + Test: -1.5 should equal -1.5 + """ + result = mathparse.parse('-1.5') + self.assertEqual(result, -1.5) + + def test_temperature_conversion_use_case(self): + """ + -0.2 Celsius to Fahrenheit formula: (-0.2 * 9/5) + 32 = 31.64 + (Example calculation) + """ + result = mathparse.parse('-0.2 * 9 / 5 + 32') + self.assertAlmostEqual(float(result), 31.64, places=2) + + def test_financial_calculation_use_case(self): + """ + Balance: $100, transaction: -$0.25, expected: $99.75 + (Example calculation) + """ + result = mathparse.parse('100 + -0.25') + self.assertEqual(result, 99.75) diff --git a/tests/test_utils.py b/tests/test_utils.py index 8d26f01..b632d87 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,12 +11,18 @@ def test_is_integer(self): def test_is_not_integer(self): self.assertFalse(mathparse.is_int('42.2')) + def test_is_not_integer_negative(self): + self.assertFalse(mathparse.is_int('-42.2')) + def test_is_float(self): self.assertTrue(mathparse.is_float('0.5')) def test_is_not_float(self): self.assertFalse(mathparse.is_float('5')) + def test_is_not_float_negative(self): + self.assertTrue(mathparse.is_float('-0.5')) + def test_is_constant(self): self.assertTrue(mathparse.is_constant('pi'))