Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mathparse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
mathparse is a library for solving mathematical equations contained in strings
"""

__version__ = '0.2.5'
__version__ = '0.2.6'
104 changes: 80 additions & 24 deletions mathparse/mathparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 == '(':
Expand Down Expand Up @@ -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(
Expand All @@ -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'
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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
154 changes: 154 additions & 0 deletions tests/test_prefix_unary_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
6 changes: 6 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))

Expand Down