Calculator: history, live results and expression mode

#2
by arshvinw5 - opened
__pycache__/gradio_calculator.cpython-314.pyc ADDED
Binary file (12.9 kB). View file
 
__pycache__/test_calculator.cpython-314-pytest-9.2.0.pyc ADDED
Binary file (19.3 kB). View file
 
gradio_calculator.py CHANGED
@@ -1,41 +1,269 @@
 
 
 
 
 
1
  import gradio as gr
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  def calculate(num1, num2, operation):
 
 
 
 
 
 
 
5
  try:
6
  num1 = float(num1)
7
- num2 = float(num2)
8
-
9
- if operation == "+":
10
- result = num1 + num2
11
- elif operation == "-":
12
- result = num1 - num2
13
- elif operation == "*":
14
- result = num1 * num2
15
- elif operation == "/":
16
- if num2 == 0:
17
- return "Cannot divide by zero"
18
- result = num1 / num2
19
- else:
20
- return "Invalid operation"
21
-
22
- return result
23
- except ValueError:
24
  return "Please enter valid numbers"
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  with gr.Blocks() as demo:
28
  gr.Markdown("# Simple Calculator")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
- with gr.Row():
31
- num1 = gr.Number(label="First number")
32
- operation = gr.Dropdown(["+", "-", "*", "/"], label="Operation", value="+")
33
- num2 = gr.Number(label="Second number")
 
 
 
 
34
 
35
- calc_button = gr.Button("Calculate")
36
- output = gr.Textbox(label="Result")
 
 
 
 
37
 
38
- calc_button.click(fn=calculate, inputs=[num1, num2, operation], outputs=output)
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
 
41
- demo.launch()
 
 
1
+ import ast
2
+ import math
3
+ import operator
4
+ import re
5
+
6
  import gradio as gr
7
 
8
+ OPERATIONS = {
9
+ "+": lambda a, b: a + b,
10
+ "-": lambda a, b: a - b,
11
+ "*": lambda a, b: a * b,
12
+ "/": lambda a, b: a / b,
13
+ "^": lambda a, b: a**b,
14
+ "mod": lambda a, b: a % b,
15
+ "√": lambda a, b: math.sqrt(a),
16
+ "% of": lambda a, b: a / 100 * b,
17
+ }
18
+
19
+ # Operations that only use the first number.
20
+ UNARY_OPS = {"√"}
21
+
22
+ MAX_EXPRESSION_LENGTH = 200
23
+ MAX_HISTORY = 50
24
+
25
+
26
+ def format_result(value):
27
+ """Show 5 instead of 5.0 and strip floating-point noise like 0.30000000000000004."""
28
+ value = round(value, 10) + 0.0 # + 0.0 turns -0.0 into 0.0
29
+ if value.is_integer() and abs(value) < 1e15:
30
+ return str(int(value))
31
+ return str(value)
32
+
33
+
34
+ def check_result(result):
35
+ """Turn a raw numeric result into display text, or an error message."""
36
+ if isinstance(result, complex):
37
+ return "Result is not a real number"
38
+ if not math.isfinite(result):
39
+ return "Result is too large"
40
+ return format_result(result)
41
+
42
 
43
  def calculate(num1, num2, operation):
44
+ if operation not in OPERATIONS:
45
+ return "Invalid operation"
46
+ if num1 is None:
47
+ return "Please enter the first number"
48
+ if num2 is None and operation not in UNARY_OPS:
49
+ return "Please enter both numbers"
50
+
51
  try:
52
  num1 = float(num1)
53
+ num2 = float(num2) if num2 is not None else 0.0
54
+
55
+ if operation == "/" and num2 == 0:
56
+ return "Cannot divide by zero"
57
+ if operation == "mod" and num2 == 0:
58
+ return "Cannot take modulo by zero"
59
+ if operation == "√" and num1 < 0:
60
+ return "Cannot take square root of a negative number"
61
+
62
+ result = OPERATIONS[operation](num1, num2)
63
+ except OverflowError:
64
+ return "Result is too large"
65
+ except (ValueError, TypeError):
 
 
 
 
66
  return "Please enter valid numbers"
67
 
68
+ return check_result(result)
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Expression mode: a safe evaluator built on Python's ast module.
73
+ # Only an allow-list of node types is evaluated; nothing is ever exec'd/eval'd.
74
+ # ---------------------------------------------------------------------------
75
+
76
+ _BINARY_OPS = {
77
+ ast.Add: operator.add,
78
+ ast.Sub: operator.sub,
79
+ ast.Mult: operator.mul,
80
+ ast.Div: operator.truediv,
81
+ ast.Mod: operator.mod,
82
+ ast.Pow: operator.pow,
83
+ }
84
+ _UNARY_OPS = {ast.UAdd: operator.pos, ast.USub: operator.neg}
85
+
86
+
87
+ class _InvalidExpression(Exception):
88
+ pass
89
+
90
+
91
+ class _NegativeSqrt(Exception):
92
+ pass
93
+
94
+
95
+ def _eval_node(node):
96
+ if isinstance(node, ast.Constant):
97
+ # bool is a subclass of int, so reject it explicitly.
98
+ if isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
99
+ return float(node.value) # floats overflow fast instead of building huge ints
100
+ raise _InvalidExpression
101
+ if isinstance(node, ast.BinOp) and type(node.op) in _BINARY_OPS:
102
+ return _BINARY_OPS[type(node.op)](_eval_node(node.left), _eval_node(node.right))
103
+ if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPS:
104
+ return _UNARY_OPS[type(node.op)](_eval_node(node.operand))
105
+ if (
106
+ isinstance(node, ast.Call)
107
+ and isinstance(node.func, ast.Name)
108
+ and node.func.id == "sqrt"
109
+ and len(node.args) == 1
110
+ and not node.keywords
111
+ ):
112
+ value = _eval_node(node.args[0])
113
+ if isinstance(value, complex):
114
+ raise _InvalidExpression
115
+ if value < 0:
116
+ raise _NegativeSqrt
117
+ return math.sqrt(value)
118
+ raise _InvalidExpression
119
+
120
+
121
+ def _normalise_expression(text):
122
+ text = text.strip()
123
+ text = text.replace("^", "**").replace("×", "*").replace("÷", "/")
124
+ text = re.sub(r"\bmod\b", "%", text)
125
+ text = text.replace("√", "sqrt")
126
+ # Allow "sqrt16" / "√16" without parentheses.
127
+ text = re.sub(r"sqrt\s*(\d+(?:\.\d+)?)", r"sqrt(\1)", text)
128
+ return text
129
+
130
+
131
+ def evaluate_expression(text):
132
+ if text is None or not text.strip():
133
+ return ""
134
+ if len(text) > MAX_EXPRESSION_LENGTH:
135
+ return "Invalid expression"
136
+
137
+ try:
138
+ tree = ast.parse(_normalise_expression(text), mode="eval")
139
+ result = _eval_node(tree.body)
140
+ except ZeroDivisionError:
141
+ return "Cannot divide by zero"
142
+ except _NegativeSqrt:
143
+ return "Cannot take square root of a negative number"
144
+ except OverflowError:
145
+ return "Result is too large"
146
+ except (_InvalidExpression, SyntaxError, ValueError, TypeError, RecursionError):
147
+ return "Invalid expression"
148
+
149
+ return check_result(result)
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # History helpers (no Gradio dependency, so they're easy to test).
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ def _is_number(text):
158
+ try:
159
+ float(text)
160
+ return True
161
+ except (TypeError, ValueError):
162
+ return False
163
+
164
+
165
+ def describe_simple(num1, num2, operation):
166
+ """Left-hand side of a history entry for the Simple tab, e.g. '2 + 3'."""
167
+ left = format_result(float(num1))
168
+ if operation in UNARY_OPS:
169
+ return f"{operation} {left}"
170
+ return f"{left} {operation} {format_result(float(num2))}"
171
+
172
+
173
+ def add_to_history(history, entry_left, result):
174
+ """Return a new history list with 'entry_left = result' on top.
175
+
176
+ Errors and blank results are not saved, and neither is a repeat of the latest entry.
177
+ """
178
+ if not _is_number(result):
179
+ return history
180
+ entry = f"{entry_left} = {result}"
181
+ if history and history[0] == entry:
182
+ return history
183
+ return [entry, *history][:MAX_HISTORY]
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # UI
188
+ # ---------------------------------------------------------------------------
189
+
190
+
191
+ def live_calculate(num1, num2, operation):
192
+ """Live result for the Simple tab: stay blank until the inputs are filled in."""
193
+ if num1 is None or (num2 is None and operation not in UNARY_OPS):
194
+ return ""
195
+ return calculate(num1, num2, operation)
196
+
197
+
198
+ def save_simple(history, num1, num2, operation):
199
+ result = live_calculate(num1, num2, operation)
200
+ if _is_number(result):
201
+ history = add_to_history(history, describe_simple(num1, num2, operation), result)
202
+ return history, "\n".join(history)
203
+
204
+
205
+ def save_expression(history, text):
206
+ result = evaluate_expression(text)
207
+ if _is_number(result):
208
+ history = add_to_history(history, text.strip(), result)
209
+ return history, "\n".join(history)
210
+
211
 
212
  with gr.Blocks() as demo:
213
  gr.Markdown("# Simple Calculator")
214
+ history = gr.State([])
215
+
216
+ with gr.Tabs():
217
+ with gr.Tab("Simple"):
218
+ with gr.Row():
219
+ num1 = gr.Number(label="First number")
220
+ operation = gr.Dropdown(list(OPERATIONS), label="Operation", value="+")
221
+ num2 = gr.Number(label="Second number")
222
+ simple_result = gr.Textbox(label="Result", interactive=False)
223
+ simple_save = gr.Button("Save to history")
224
+
225
+ with gr.Tab("Expression"):
226
+ expr = gr.Textbox(
227
+ label="Expression",
228
+ placeholder="e.g. (2 + 3) * 4 / 2, 2^10, sqrt(16), 10 mod 3",
229
+ )
230
+ expr_result = gr.Textbox(label="Result", interactive=False)
231
+ expr_save = gr.Button("Save to history")
232
+
233
+ history_box = gr.Textbox(label="History", lines=8, interactive=False)
234
+ clear_button = gr.Button("Clear history")
235
 
236
+ # Live results
237
+ gr.on(
238
+ triggers=[num1.change, num2.change, operation.change],
239
+ fn=live_calculate,
240
+ inputs=[num1, num2, operation],
241
+ outputs=simple_result,
242
+ )
243
+ expr.change(fn=evaluate_expression, inputs=expr, outputs=expr_result)
244
 
245
+ # Hide the second number for square root
246
+ operation.change(
247
+ fn=lambda op: gr.update(visible=op not in UNARY_OPS),
248
+ inputs=operation,
249
+ outputs=num2,
250
+ )
251
 
252
+ # Save to history: button or Enter
253
+ gr.on(
254
+ triggers=[simple_save.click, num1.submit, num2.submit],
255
+ fn=save_simple,
256
+ inputs=[history, num1, num2, operation],
257
+ outputs=[history, history_box],
258
+ )
259
+ gr.on(
260
+ triggers=[expr_save.click, expr.submit],
261
+ fn=save_expression,
262
+ inputs=[history, expr],
263
+ outputs=[history, history_box],
264
+ )
265
+ clear_button.click(fn=lambda: ([], ""), outputs=[history, history_box])
266
 
267
 
268
+ if __name__ == "__main__":
269
+ demo.launch()
test_calculator.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from gradio_calculator import calculate
4
+
5
+
6
+ @pytest.mark.parametrize(
7
+ "num1, num2, op, expected",
8
+ [
9
+ (2, 3, "+", "5"),
10
+ (0.1, 0.2, "+", "0.3"),
11
+ (5, 8, "-", "-3"),
12
+ (4, 2.5, "*", "10"),
13
+ (7, 2, "/", "3.5"),
14
+ (1, 3, "/", "0.3333333333"),
15
+ (2, 10, "^", "1024"),
16
+ (10, 3, "mod", "1"),
17
+ (16, None, "√", "4"),
18
+ (2, None, "√", "1.4142135624"),
19
+ (15, 240, "% of", "36"),
20
+ (0, -5, "*", "0"),
21
+ ],
22
+ )
23
+ def test_operations(num1, num2, op, expected):
24
+ assert calculate(num1, num2, op) == expected
25
+
26
+
27
+ @pytest.mark.parametrize(
28
+ "num1, num2, op, expected",
29
+ [
30
+ (None, 3, "+", "Please enter the first number"),
31
+ (2, None, "+", "Please enter both numbers"),
32
+ (None, None, "√", "Please enter the first number"),
33
+ (1, 0, "/", "Cannot divide by zero"),
34
+ (1, 0, "mod", "Cannot take modulo by zero"),
35
+ (-4, None, "√", "Cannot take square root of a negative number"),
36
+ (10, 1000, "^", "Result is too large"),
37
+ (1e308, 10, "*", "Result is too large"),
38
+ (-8, 0.5, "^", "Result is not a real number"),
39
+ (1, 2, "?", "Invalid operation"),
40
+ ],
41
+ )
42
+ def test_errors(num1, num2, op, expected):
43
+ assert calculate(num1, num2, op) == expected
44
+
45
+
46
+ # --- Expression mode -------------------------------------------------------
47
+
48
+ from gradio_calculator import ( # noqa: E402
49
+ MAX_HISTORY,
50
+ add_to_history,
51
+ describe_simple,
52
+ evaluate_expression,
53
+ )
54
+
55
+
56
+ @pytest.mark.parametrize(
57
+ "text, expected",
58
+ [
59
+ ("2 + 3 * 4", "14"),
60
+ ("(2 + 3) * 4 / 2", "10"),
61
+ ("2^10", "1024"),
62
+ ("2 ** 3", "8"),
63
+ ("10 mod 3", "1"),
64
+ ("10 % 3", "1"),
65
+ ("-3 + 5", "2"),
66
+ ("sqrt(16)", "4"),
67
+ ("√16", "4"),
68
+ ("√(9) + 1", "4"),
69
+ ("0.1 + 0.2", "0.3"),
70
+ ("6 × 7 ÷ 2", "21"),
71
+ (" 7 ", "7"),
72
+ ("", ""),
73
+ (" ", ""),
74
+ ],
75
+ )
76
+ def test_expression_values(text, expected):
77
+ assert evaluate_expression(text) == expected
78
+
79
+
80
+ @pytest.mark.parametrize(
81
+ "text, expected",
82
+ [
83
+ ("1 / 0", "Cannot divide by zero"),
84
+ ("5 % 0", "Cannot divide by zero"),
85
+ ("sqrt(-4)", "Cannot take square root of a negative number"),
86
+ ("9^9^9", "Result is too large"),
87
+ ("1e308 * 10", "Result is too large"),
88
+ ("(-8)^0.5", "Result is not a real number"),
89
+ ("2 +", "Invalid expression"),
90
+ ],
91
+ )
92
+ def test_expression_errors(text, expected):
93
+ assert evaluate_expression(text) == expected
94
+
95
+
96
+ @pytest.mark.parametrize(
97
+ "text",
98
+ [
99
+ "__import__('os')",
100
+ "open('x')",
101
+ "a + 1",
102
+ "'abc'",
103
+ "(1).__class__",
104
+ "lambda: 1",
105
+ "True + 1",
106
+ "1j * 2",
107
+ "sqrt(4, 2)",
108
+ "pow(2, 3)",
109
+ "1 + " * 60 + "1", # longer than 200 characters
110
+ ],
111
+ )
112
+ def test_expression_rejects_unsafe_input(text):
113
+ assert evaluate_expression(text) == "Invalid expression"
114
+
115
+
116
+ # --- History ---------------------------------------------------------------
117
+
118
+
119
+ def test_describe_simple():
120
+ assert describe_simple(2, 3, "+") == "2 + 3"
121
+ assert describe_simple(16, None, "√") == "√ 16"
122
+ assert describe_simple(15, 240, "% of") == "15 % of 240"
123
+ assert describe_simple(2.5, 4.0, "*") == "2.5 * 4"
124
+
125
+
126
+ def test_history_adds_newest_first():
127
+ history = add_to_history([], "2 + 3", "5")
128
+ history = add_to_history(history, "4 * 5", "20")
129
+ assert history == ["4 * 5 = 20", "2 + 3 = 5"]
130
+
131
+
132
+ def test_history_skips_errors_and_blanks():
133
+ assert add_to_history([], "1 / 0", "Cannot divide by zero") == []
134
+ assert add_to_history([], "", "") == []
135
+
136
+
137
+ def test_history_skips_consecutive_duplicates():
138
+ history = add_to_history([], "2 + 3", "5")
139
+ assert add_to_history(history, "2 + 3", "5") == ["2 + 3 = 5"]
140
+
141
+
142
+ def test_history_does_not_mutate_input():
143
+ original = ["2 + 3 = 5"]
144
+ add_to_history(original, "1 + 1", "2")
145
+ assert original == ["2 + 3 = 5"]
146
+
147
+
148
+ def test_history_is_capped():
149
+ history = []
150
+ for i in range(MAX_HISTORY + 10):
151
+ history = add_to_history(history, f"{i} + 0", str(i))
152
+ assert len(history) == MAX_HISTORY
153
+ assert history[0] == f"{MAX_HISTORY + 9} + 0 = {MAX_HISTORY + 9}"