Skip to content

Commit 993e4fe

Browse files
authored
Merge pull request #43 from gunthercox/fix
Fix output for extracted decimals
2 parents 2752a81 + b5bac05 commit 993e4fe

File tree

4 files changed

+241
-25
lines changed

4 files changed

+241
-25
lines changed

mathparse/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
mathparse is a library for solving mathematical equations contained in strings
33
"""
44

5-
__version__ = '0.2.5'
5+
__version__ = '0.2.6'

mathparse/mathparse.py

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,19 @@ def is_int(string: str) -> bool:
1818
"""
1919
Return true if string is an integer.
2020
"""
21-
try:
22-
int(string)
23-
return True
24-
except ValueError:
25-
return False
21+
# Allow leading minus sign
22+
if string[0] == '-':
23+
# Must have at least one digit after the minus
24+
return len(string) > 1 and string[1:].isdigit()
25+
else:
26+
# Must be all digits
27+
return string.isdigit()
2628

2729

2830
def is_float(string: str) -> bool:
2931
"""
3032
Return true if the string is a float.
33+
Returns False for non-strings or strings without a decimal point.
3134
"""
3235
try:
3336
float(string)
@@ -79,6 +82,27 @@ def is_word(word: str, language: str) -> bool:
7982
return word in words
8083

8184

85+
def to_number(val) -> Union[int, float, str, Decimal]:
86+
"""
87+
Convert a string to an int or float if possible.
88+
"""
89+
# If already a number (int, float, Decimal), return as-is
90+
if isinstance(val, (int, float, Decimal)):
91+
return val
92+
93+
# Check if it's a constant and convert
94+
if is_constant(val):
95+
return mathwords.CONSTANTS[val]
96+
97+
# Otherwise, try to convert string to number
98+
if is_int(val):
99+
return int(val)
100+
elif is_float(val):
101+
return float(val)
102+
103+
return val
104+
105+
82106
def create_unicode_word_boundary_pattern(word: str) -> str:
83107
"""
84108
Create a regex pattern with Unicode-aware word boundaries.
@@ -459,11 +483,11 @@ def to_postfix(tokens: list) -> list:
459483

460484
for token in tokens:
461485
if is_int(token):
462-
postfix.append(int(token))
486+
postfix.append(token)
463487
elif is_float(token):
464-
postfix.append(float(token))
488+
postfix.append(token)
465489
elif token in mathwords.CONSTANTS:
466-
postfix.append(mathwords.CONSTANTS[token])
490+
postfix.append(token)
467491
elif is_unary(token):
468492
opstack.append(token)
469493
elif token == '(':
@@ -516,6 +540,8 @@ def evaluate_postfix(tokens: list) -> Union[int, float, str, Decimal]:
516540
stack.append(token)
517541
elif is_unary(token):
518542
a = stack.pop()
543+
# Convert token (string) to number for unary function evaluation
544+
a = to_number(a)
519545
total = mathwords.UNARY_FUNCTIONS[token](a)
520546
elif len(stack) == 1:
521547
raise PostfixTokenEvaluationException(
@@ -526,14 +552,15 @@ def evaluate_postfix(tokens: list) -> Union[int, float, str, Decimal]:
526552
elif len(stack) > 1:
527553
b = stack.pop()
528554
a = stack.pop()
555+
529556
if token == '+':
530-
total = a + b
557+
total = to_number(a) + to_number(b)
531558
elif token == '-':
532-
total = a - b
559+
total = to_number(a) - to_number(b)
533560
elif token == '*':
534-
total = a * b
561+
total = to_number(a) * to_number(b)
535562
elif token == '^':
536-
total = a ** b
563+
total = to_number(a) ** to_number(b)
537564
elif token == '/':
538565
if Decimal(str(b)) == 0:
539566
total = 'undefined'
@@ -543,19 +570,33 @@ def evaluate_postfix(tokens: list) -> Union[int, float, str, Decimal]:
543570
# Treat decimal points as a binary operator that combines the
544571
# integer and fractional part of two numbers
545572
# Example: 53 . 25 = 53.25, -3 . 5 = -3.5
546-
if b == 0:
547-
total = Decimal(a)
573+
574+
# Convert b to number
575+
numeric_b = to_number(b)
576+
577+
if numeric_b == 0:
578+
# Convert a to number
579+
numeric_a = to_number(a)
580+
total = Decimal(numeric_a)
548581
else:
549-
# Count the digits in b to determine the divisor
550-
digits = len(str(int(b)))
582+
# Check if 'a' has a negative sign (handles -0 case)
583+
is_negative = str(a).startswith('-')
584+
585+
# Convert a to number for calculation
586+
numeric_a = to_number(a)
587+
588+
# Count the digits in the original string b to preserve
589+
# leading zeros (e.g., "01" has 2 digits, not 1)
590+
digits = len(str(b))
551591
divisor = 10 ** digits
552-
fractional_part = b / divisor
553-
# Handle negative numbers correctly: -3 . 5 should be -3.5,
554-
# not -2.5
555-
if a < 0:
556-
total = a - fractional_part
592+
fractional_part = numeric_b / divisor
593+
594+
# Handle negatives: -3 . 5 = -3.5, not -2.5
595+
# Also -0 . 5 = -0.5 (check string since -0 == 0)
596+
if is_negative:
597+
total = numeric_a - fractional_part
557598
else:
558-
total = a + fractional_part
599+
total = numeric_a + fractional_part
559600
else:
560601
raise PostfixTokenEvaluationException(
561602
'Unknown token "{}"'.format(token)
@@ -570,7 +611,16 @@ def evaluate_postfix(tokens: list) -> Union[int, float, str, Decimal]:
570611
'The postfix expression resulted in an empty stack'
571612
)
572613

573-
return stack.pop()
614+
result = stack.pop()
615+
616+
# Convert final result from string to number if needed
617+
if isinstance(result, str):
618+
if is_int(result):
619+
result = int(result)
620+
elif is_float(result):
621+
result = float(result)
622+
623+
return result
574624

575625

576626
def tokenize(string: str, language: str = None, escape: str = '___') -> list:
@@ -822,4 +872,10 @@ def extract_expression(dirty_string: str, language: str) -> str:
822872
else:
823873
end_index -= 1
824874

825-
return ' '.join(tokens[start_index:end_index])
875+
result = ' '.join(tokens[start_index:end_index])
876+
877+
# Remove spaces around decimal points for cleaner output
878+
# Replace " . " with "." to convert "-100 . 5" to "-100.5"
879+
result = result.replace(' . ', '.')
880+
881+
return result

tests/test_prefix_unary_operations.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,15 @@ def test_extract_pure_subtraction(self):
231231
result = mathparse.extract_expression('5 - 3', language='ENG')
232232
self.assertEqual(result, '5 - 3')
233233

234+
def test_extract_negative_decimal(self):
235+
"""
236+
Test: Extract leading negative decimal from sentence
237+
"""
238+
result = mathparse.extract_expression(
239+
'What is -100.5 * 20?', language='ENG'
240+
)
241+
self.assertEqual(result, '-100.5 * 20')
242+
234243
def test_extract_negative_in_parentheses_with_words(self):
235244
"""
236245
Test: Extract expression with negative in parentheses from sentence
@@ -241,3 +250,148 @@ def test_extract_negative_in_parentheses_with_words(self):
241250
# NOTE: Spaces are currently added, but ideally these will be removed
242251
# in the future
243252
self.assertEqual(result, '( -3 ) + 5')
253+
254+
255+
class NegativeDecimalsLessThanOneTestCase(TestCase):
256+
"""
257+
Test cases for negative decimal numbers between -1 and 0.
258+
259+
This addresses a bug where negative decimals less than 1
260+
(e.g., -0.2, -0.5) were losing their negative sign during parsing.
261+
The issue was caused by Python's -0 == 0 equality, where int('-0')
262+
returns 0, losing sign information.
263+
"""
264+
265+
def test_negative_zero_point_two(self):
266+
"""
267+
Test: -0.2 should equal -0.2
268+
"""
269+
result = mathparse.parse('-0.2')
270+
self.assertEqual(result, -0.2)
271+
272+
def test_negative_zero_point_five(self):
273+
"""
274+
Test: -0.5 should equal -0.5
275+
"""
276+
result = mathparse.parse('-0.5')
277+
self.assertEqual(result, -0.5)
278+
279+
def test_negative_zero_point_nine(self):
280+
"""
281+
Test: -0.9 should equal -0.9
282+
"""
283+
result = mathparse.parse('-0.9')
284+
self.assertEqual(result, -0.9)
285+
286+
def test_negative_zero_point_one(self):
287+
"""
288+
Test: -0.1 should equal -0.1
289+
"""
290+
result = mathparse.parse('-0.1')
291+
self.assertEqual(result, -0.1)
292+
293+
def test_negative_zero_point_ninety_nine(self):
294+
"""
295+
Test: -0.99 should equal -0.99"""
296+
result = mathparse.parse('-0.99')
297+
self.assertEqual(result, -0.99)
298+
299+
def test_negative_zero_point_zero_one(self):
300+
"""
301+
Test: -0.01 should equal -0.01
302+
"""
303+
result = mathparse.parse('-0.01')
304+
self.assertEqual(result, -0.01)
305+
306+
def test_negative_zero_point_nine_nine_nine(self):
307+
"""
308+
Test: -0.999 should equal -0.999
309+
"""
310+
result = mathparse.parse('-0.999')
311+
self.assertEqual(result, -0.999)
312+
313+
def test_negative_decimal_in_addition(self):
314+
"""
315+
Test: -0.5 + 1 should equal 0.5
316+
"""
317+
result = mathparse.parse('-0.5 + 1')
318+
self.assertEqual(result, 0.5)
319+
320+
def test_negative_decimal_in_subtraction(self):
321+
"""
322+
Test: 2 - -0.5 should equal 2.5
323+
"""
324+
result = mathparse.parse('2 - -0.5')
325+
self.assertEqual(result, 2.5)
326+
327+
def test_negative_decimal_in_multiplication(self):
328+
"""
329+
Test: -0.5 * 4 should equal -2.0
330+
"""
331+
result = mathparse.parse('-0.5 * 4')
332+
self.assertEqual(result, -2.0)
333+
334+
def test_negative_decimal_in_division(self):
335+
"""
336+
Test: -0.8 / 2 should equal -0.4
337+
"""
338+
result = mathparse.parse('-0.8 / 2')
339+
self.assertEqual(float(result), -0.4)
340+
341+
def test_multiple_negative_decimals(self):
342+
"""
343+
Test: -0.3 + -0.2 should equal -0.5
344+
"""
345+
result = mathparse.parse('-0.3 + -0.2')
346+
self.assertAlmostEqual(result, -0.5, places=10)
347+
348+
def test_negative_decimal_with_parentheses(self):
349+
"""
350+
Test: (-0.5) should equal -0.5
351+
"""
352+
result = mathparse.parse('(-0.5)')
353+
self.assertEqual(result, -0.5)
354+
355+
def test_negative_decimal_in_complex_expression(self):
356+
"""
357+
Test: (-0.5 + 1) * 2 should equal 1.0
358+
"""
359+
result = mathparse.parse('(-0.5 + 1) * 2')
360+
self.assertEqual(result, 1.0)
361+
362+
def test_positive_decimals_still_work(self):
363+
"""
364+
Test: 0.5 should equal 0.5
365+
"""
366+
result = mathparse.parse('0.5')
367+
self.assertEqual(result, 0.5)
368+
369+
def test_negative_integers_still_work(self):
370+
"""
371+
Test: -5 should equal -5
372+
"""
373+
result = mathparse.parse('-5')
374+
self.assertEqual(result, -5)
375+
376+
def test_negative_decimals_greater_than_one_still_work(self):
377+
"""
378+
Test: -1.5 should equal -1.5
379+
"""
380+
result = mathparse.parse('-1.5')
381+
self.assertEqual(result, -1.5)
382+
383+
def test_temperature_conversion_use_case(self):
384+
"""
385+
-0.2 Celsius to Fahrenheit formula: (-0.2 * 9/5) + 32 = 31.64
386+
(Example calculation)
387+
"""
388+
result = mathparse.parse('-0.2 * 9 / 5 + 32')
389+
self.assertAlmostEqual(float(result), 31.64, places=2)
390+
391+
def test_financial_calculation_use_case(self):
392+
"""
393+
Balance: $100, transaction: -$0.25, expected: $99.75
394+
(Example calculation)
395+
"""
396+
result = mathparse.parse('100 + -0.25')
397+
self.assertEqual(result, 99.75)

tests/test_utils.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,18 @@ def test_is_integer(self):
1111
def test_is_not_integer(self):
1212
self.assertFalse(mathparse.is_int('42.2'))
1313

14+
def test_is_not_integer_negative(self):
15+
self.assertFalse(mathparse.is_int('-42.2'))
16+
1417
def test_is_float(self):
1518
self.assertTrue(mathparse.is_float('0.5'))
1619

1720
def test_is_not_float(self):
1821
self.assertFalse(mathparse.is_float('5'))
1922

23+
def test_is_not_float_negative(self):
24+
self.assertTrue(mathparse.is_float('-0.5'))
25+
2026
def test_is_constant(self):
2127
self.assertTrue(mathparse.is_constant('pi'))
2228

0 commit comments

Comments
 (0)