6
\$\begingroup\$

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 ""
\$\endgroup\$
1
  • \$\begingroup\$ Is SOME DESCRIPTIVE TITLE written in your .po file 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\$ Commented Oct 13 at 14:36

2 Answers 2

5
\$\begingroup\$

Initial Problems

I had some difficulty running your code as since gettext.translate seems to be looking for a .mo file according to my Python 3.12 source and you provided a .po file. To get pass this I defined the _ function to just return its passed argument, i.e. no translation is done.

I also had a problem getting function add_submenu to compile since the type hint for argument menu was menu: Menu and Python says Menu is undefined. I replaced this type hint with Menu: Type('Menu')

Doscstrings and Visibility

You should include a docstring for the module as well as for any classes and/or functions you consider "public". Those classes and functions that you consider "private" should be named with a leading underscore, which you did for a few instances, but I suspect not for all instances you would consider to be private.

Not marking private classes and functions with a leading underscore could tempt users to use code that could change in the future without warning. Also, if somebody executes from calc import * their namespace could be cluttered with globals they won't be using or worse, globals that they are using might be redefined. At the very least consider defining global __all__ with a list of names of globals that are importable when this form of the import statement is used.

Divide by Zero Exception

I tried the Divide operation specifying 0 as the second argument and got a ZeroDivisionError raised. I modified the code thus:

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', True), _('d'))]  # True was not being passed to make_binop

As a result the message "Error: number is equal to zero. Try again..." is now printed, but theZeroDivisionError exception is still being raised.

Also, and this is NOT a major issue, for the test of 0 the error message could say "number may not be zero", which I think is clearer.

The while loop in read_number should be:

    while True:
        try:
            number: Any = _type(read_str(prompt,
                                         stdin=stdin,
                                         stdout=stdout))
            if nonzero and number == 0:
                print_input_error(_('number may not be zero'))
            elif lower is not None and number < lower:
                print_input_error(_('number is too small'))
            elif upper is not None and number > upper:
                print_input_error(_('number is too big'))
            else:
                return number
        except ValueError:
            print_input_error(_('not a number'))
        except (EOFError, KeyboardInterrupt):
            print(_('\nAborted by user.'))
            raise
\$\endgroup\$
3
\$\begingroup\$

Documentation

Code needs documentation, and the PEP 8 style guide recommends adding docstrings for classes and functions. There should also be a docstring at the top of each file to summarize its purpose.

"""
Calculator Application

Here are some of the awesome things it does:


"""

Efficiency

In the while loop in the user_input.py code, it looks like the 3 if statements should be combined into a single if/elif:

        if nonzero and number == 0:
            print_input_error(_('number is equal to zero'))
        elif lower is not None and number < lower:
            print_input_error(_('number is too small'))
        elif upper is not None and number > upper:
            print_input_error(_('number is too big'))

The checks are mutually exclusive. This makes the code more efficient since you don't have to perform the 2nd check if the first is true, etc. Also, this more clearly shows the intent of the code.

Readability

Python prides itself on code that is easy to read and understand, and rightly so. However, I always scratch my head at the compact _() syntax and have to go scurrying off to the internet to remind myself what it means. Adding a brief comment to the code reminding your future self couldn't hurt.

Portability

I was hoping to run the code, but I get a "file not found" error for "python_misc". Perhaps I have not placed all your files in the appropriate directories, or maybe my operating system is different from yours.

\$\endgroup\$
1
  • 2
    \$\begingroup\$ For those who had to look it up, like me (because I only run into this notation once every 1-2 years or so), _ is a translation function that looks terse but is fairly standardized. It's defined in the second line (excl. imports and comments) and seems to be somewhat inspired by GNU gettext. For the local lingo, it's contextually correct and sufficient for any code that has import gettext. \$\endgroup\$ Commented Oct 12 at 18:25

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.