arshvinw5 commited on
Commit
392b1a9
·
1 Parent(s): 61b7afa

history, live results and expression mode

Browse files
__pycache__/gradio_calculator.cpython-314.pyc CHANGED
Binary files a/__pycache__/gradio_calculator.cpython-314.pyc and b/__pycache__/gradio_calculator.cpython-314.pyc differ
 
__pycache__/test_calculator.cpython-314-pytest-9.2.0.pyc CHANGED
Binary files a/__pycache__/test_calculator.cpython-314-pytest-9.2.0.pyc and b/__pycache__/test_calculator.cpython-314-pytest-9.2.0.pyc differ
 
gradio_calculator.py CHANGED
@@ -1,4 +1,7 @@
 
1
  import math
 
 
2
 
3
  import gradio as gr
4
 
@@ -16,6 +19,9 @@ OPERATIONS = {
16
  # Operations that only use the first number.
17
  UNARY_OPS = {"√"}
18
 
 
 
 
19
 
20
  def format_result(value):
21
  """Show 5 instead of 5.0 and strip floating-point noise like 0.30000000000000004."""
@@ -25,6 +31,15 @@ def format_result(value):
25
  return str(value)
26
 
27
 
 
 
 
 
 
 
 
 
 
28
  def calculate(num1, num2, operation):
29
  if operation not in OPERATIONS:
30
  return "Invalid operation"
@@ -50,30 +65,204 @@ def calculate(num1, num2, operation):
50
  except (ValueError, TypeError):
51
  return "Please enter valid numbers"
52
 
53
- if isinstance(result, complex):
54
- return "Result is not a real number"
55
- if not math.isfinite(result):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  return "Result is too large"
57
- return format_result(result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
 
60
  with gr.Blocks() as demo:
61
  gr.Markdown("# Simple Calculator")
 
62
 
63
- with gr.Row():
64
- num1 = gr.Number(label="First number")
65
- operation = gr.Dropdown(list(OPERATIONS), label="Operation", value="+")
66
- num2 = gr.Number(label="Second number")
 
 
 
 
67
 
68
- calc_button = gr.Button("Calculate")
69
- output = gr.Textbox(label="Result")
 
 
 
 
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  operation.change(
72
  fn=lambda op: gr.update(visible=op not in UNARY_OPS),
73
  inputs=operation,
74
  outputs=num2,
75
  )
76
- calc_button.click(fn=calculate, inputs=[num1, num2, operation], outputs=output)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
 
79
  if __name__ == "__main__":
 
1
+ import ast
2
  import math
3
+ import operator
4
+ import re
5
 
6
  import gradio as gr
7
 
 
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."""
 
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"
 
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__":
test_calculator.py CHANGED
@@ -41,3 +41,113 @@ def test_operations(num1, num2, op, expected):
41
  )
42
  def test_errors(num1, num2, op, expected):
43
  assert calculate(num1, num2, op) == expected
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}"