What do you think about my calculator application in Python? Are there any hidden bugs that I haven't noticed yet? Let me know about them.
Note: to be able to run this program you need first to compile python_misc.po.
Run this in the directory locale/en/LC_MESSAGES:
msgfmt python_misc.po -o python_misc.mo
calc.py
#!/usr/bin/env python3
import gettext
import os
from operator import add as op_add, sub, mul, truediv
from typing import Any, Callable
from ui.menu import Menu, MenuItem
from ui.user_input import read_number
# mypy and pylint don't work well with gettext.install
LOCALE = os.getenv('LANG', 'en')
_ = gettext.translation('python_misc',
localedir='locale',
languages=[LOCALE]).gettext
def make_binop(fn: Callable[[Any, Any], Any], name: str,
nonzero_second=False) -> Callable[[], None]:
def _op():
a = read_number(_('Enter the first operand: '))
b = read_number(_('Enter the second operand: '),
nonzero=nonzero_second)
print(_('The result is {0}.').format(fn(a, b)))
_op.__name__ = name
return _op
if __name__ == '__main__':
items = [MenuItem(_('Add'), make_binop(op_add, 'add'), _('a')),
MenuItem(_('Subtract'), make_binop(sub, 'subtract'), _('s')),
MenuItem(_('Multiply'), make_binop(mul, 'multiply'), _('m')),
MenuItem(_('Divide'), make_binop(truediv, 'divide'), _('d'))]
menu = Menu(_('Calculator'), items)
menu.loop()
print(_('Good bye!'))
ui/user_input.py
# module ui.user_input
import gettext
import os
import sys
from contextlib import contextmanager
from typing import Any, IO, Iterator, Optional, TextIO, overload
from numbers import Number
# mypy and pylint don't work well with gettext.install
LOCALE = os.getenv('LANG', 'en')
_ = gettext.translation('python_misc',
localedir='locale',
languages=[LOCALE]).gettext
@contextmanager
def redirect_stdio(stdin: IO[Any],
stdout: IO[Any],
stderr: IO[Any] = sys.stderr) -> Iterator[None]:
old_stdin = sys.stdin
old_stdout = sys.stdout
old_stderr = sys.stderr
sys.stdin = stdin
sys.stdout = stdout
sys.stderr = stderr
yield
sys.stdin = old_stdin
sys.stdout = old_stdout
sys.stderr = old_stderr
def read_str(prompt: str = '', *,
stdin: TextIO = sys.stdin,
stdout: TextIO = sys.stdout) -> str:
stdout.write(prompt)
stdout.flush()
user_input = stdin.readline()
if not user_input:
raise EOFError
return user_input
def wait_for_enter(*,
stdin: TextIO = sys.stdin,
stdout: TextIO = sys.stdout) -> None:
read_str(_('\nPress ENTER to continue...\n'), stdin=stdin, stdout=stdout)
def print_input_error(message: str) -> None:
print(_('Error: {0}. Try again...').format(message))
def _get_default_prompt(lower: Optional[Number], upper: Optional[Number],
nonzero: bool):
prompt = _('Enter the number')
if lower is not None or upper is not None or nonzero:
prompt += ' ('
if lower is not None:
prompt += _('starting from {0}').format(lower)
if upper is not None:
prompt += _('up to {0}').format(upper)
if nonzero:
if lower is not None or upper is not None:
prompt += ', '
prompt += _('cannot be zero')
if lower is not None or upper is not None or nonzero:
prompt += ')'
prompt += ': '
return prompt
@overload
def read_number[T: Number](prompt: str = ..., *,
stdin: TextIO = ...,
stdout: TextIO = ...,
lower: Optional[T] = ...,
upper: Optional[T] = ...,
nonzero: bool = ...,
_type: type[T]) -> T: ...
@overload
def read_number[T: Number](prompt: str = ..., *,
stdin: TextIO = ...,
stdout: TextIO = ...,
lower: Optional[T] = ...,
upper: Optional[T] = ...,
nonzero: bool = ...) -> float: ...
def read_number[T: Number](prompt: str = '', *,
stdin: TextIO = sys.stdin,
stdout: TextIO = sys.stdout,
lower: Optional[T] = None,
upper: Optional[T] = None,
nonzero: bool = False,
_type: type = float):
if not prompt:
prompt = _get_default_prompt(lower, upper, nonzero)
while True:
try:
number: Any = _type(read_str(prompt,
stdin=stdin,
stdout=stdout))
if nonzero and number == 0:
print_input_error(_('number is equal to zero'))
if lower is not None and number < lower:
print_input_error(_('number is too small'))
if upper is not None and number > upper:
print_input_error(_('number is too big'))
return number
except ValueError:
print_input_error(_('not a number'))
except (EOFError, KeyboardInterrupt):
print(_('\nAborted by user.'))
raise
ui/menu.py
# module ui.menu
from __future__ import annotations
import gettext
import os
import sys
from dataclasses import dataclass
from typing import Callable, Optional, TextIO
from .user_input import print_input_error, redirect_stdio, wait_for_enter
# mypy and pylint don't work well with gettext.install
LOCALE = os.getenv('LANG', 'en')
_ = gettext.translation('python_misc',
localedir='locale',
languages=[LOCALE]).gettext
@dataclass(frozen=True)
class MenuItem:
name: str
action: Callable[[], None]
key: Optional[str] = None
visible: bool = True
enabled: bool = True
def __str__(self):
return self.name
class Menu:
nesting_level = -1
def __init__(self,
title: str,
items: list[MenuItem], *,
fin: TextIO = sys.stdin,
fout: TextIO = sys.stdout):
self.title = title
self.items = list(items)
if Menu.nesting_level > 0:
quit_item_title = _('Back')
else:
quit_item_title = _('Quit')
quit_item = MenuItem(quit_item_title, self._quit, _('q'))
self._check_for_duplicates(quit_item)
self.items.append(quit_item)
self.active = False
self.stdin = fin
self.stdout = fout
@property
def visible_items(self):
return [item for item in self.items if item.visible]
def add_item(self,
name: str,
action: Callable[[], None],
key: Optional[str] = None) -> None:
item = MenuItem(name, action, key)
self._check_for_duplicates(item)
self.items.insert(-1, item)
def remove_item(self, name: str) -> bool:
for idx, item in enumerate(self.items):
if item.name == name:
del self.items[idx]
return True
return False
def add_submenu(self,
name: str,
menu: 'Menu',
key: Optional[str] = None) -> None:
item = MenuItem(name, menu.loop, key)
self._check_for_duplicates(item)
self.items.append(item)
def loop(self) -> None:
with redirect_stdio(self.stdin, self.stdout):
Menu.nesting_level += 1
self.active = True
while self.active:
user_choice = self._read_choice()
if user_choice.action.__name__ == self._quit.__name__:
self._quit()
else:
try:
user_choice.action()
wait_for_enter()
except (EOFError, KeyboardInterrupt):
pass
def _check_for_duplicates(self, candidate: MenuItem) -> None:
for item in self.items:
if (candidate.name == item.name
or candidate.action == item.action
or candidate.key == item.key):
raise ValueError('duplicate menu item')
def _print_title(self) -> None:
line = '-' * (len(self.title) + 4)
print(line, file=self.stdout)
print(f'| {self.title} |', file=self.stdout)
print(line, file=self.stdout)
def _read_choice(self) -> MenuItem:
with redirect_stdio(self.stdin, self.stdout):
while True:
self._print_title()
for idx, item in enumerate(self.visible_items, start=1):
print(f'{idx}) {item}')
try:
user_input = input(_('Your choice: ')).strip().lower()
except (EOFError, KeyboardInterrupt):
print(_('\nExiting...'))
return self.items[-1]
try:
if user_input.isdigit():
idx = int(user_input)
if not 1 <= idx <= len(self.visible_items):
raise ValueError
candidate = self.visible_items[idx - 1]
else:
for idx, item in enumerate(self.visible_items):
if user_input == item.key:
candidate = self.visible_items[idx]
break
else:
raise ValueError
if not candidate.enabled:
print_input_error(_('invalid operation'))
wait_for_enter()
else:
return candidate
except ValueError:
print_input_error(_('invalid choice'))
def _quit(self) -> None:
self.active = False
Menu.nesting_level -= 1
locale/en/LC_MESSAGES/python_misc.po:
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2025-10-12 17:28+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <[email protected]>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: pygettext.py 1.5\n"
#: calc.py:19
msgid "Enter the first operand: "
msgstr ""
#: calc.py:20
msgid "Enter the second operand: "
msgstr ""
#: calc.py:22
msgid "The result is {0}."
msgstr ""
#: calc.py:28
msgid "Add"
msgstr ""
#: calc.py:28
msgid "a"
msgstr ""
#: calc.py:29
msgid "Subtract"
msgstr ""
#: calc.py:29
msgid "s"
msgstr ""
#: calc.py:30
msgid "Multiply"
msgstr ""
#: calc.py:30
msgid "m"
msgstr ""
#: calc.py:31
msgid "Divide"
msgstr ""
#: calc.py:31
msgid "d"
msgstr ""
#: calc.py:32
msgid "Calculator"
msgstr ""
#: calc.py:34
msgid "Good bye!"
msgstr ""
#: ui/menu.py:39
msgid "Back"
msgstr ""
#: ui/menu.py:41
msgid "Quit"
msgstr ""
#: ui/menu.py:42
msgid "q"
msgstr ""
#: ui/menu.py:111
msgid "Your choice: "
msgstr ""
#: ui/menu.py:113
msgid ""
"\n"
"Exiting..."
msgstr ""
#: ui/menu.py:130
msgid "invalid operation"
msgstr ""
#: ui/menu.py:135
msgid "invalid choice"
msgstr ""
#: ui/user_input.py:47
msgid ""
"\n"
"Press ENTER to continue...\n"
msgstr ""
#: ui/user_input.py:51
msgid "Error: {0}. Try again..."
msgstr ""
#: ui/user_input.py:56
msgid "Enter the number"
msgstr ""
#: ui/user_input.py:60
msgid "starting from {0}"
msgstr ""
#: ui/user_input.py:62
msgid "up to {0}"
msgstr ""
#: ui/user_input.py:66
msgid "cannot be zero"
msgstr ""
#: ui/user_input.py:107
msgid "number is equal to zero"
msgstr ""
#: ui/user_input.py:109
msgid "number is too small"
msgstr ""
#: ui/user_input.py:111
msgid "number is too big"
msgstr ""
#: ui/user_input.py:114
msgid "not a number"
msgstr ""
#: ui/user_input.py:116
msgid ""
"\n"
"Aborted by user."
msgstr ""
SOME DESCRIPTIVE TITLEwritten in your.pofile verbatim (same q for next ~10 lines)? Consider either removing the header if you consider it unnecessary (I'd probably do just that) or replacing placeholders with some meaningful text. \$\endgroup\$