Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bccc8c9e7 | ||
|
|
afdffdf564 | ||
|
|
db477d99fb | ||
|
|
142a027e5c | ||
|
|
6d99810e30 | ||
|
|
9030510773 |
@@ -1,2 +1,92 @@
|
||||
# 010-calc
|
||||
# 🧮 Calculator
|
||||
|
||||
A beginner-friendly Python console program that adds, subtracts, multiplies and divides numbers, and lets you keep calculating with the previous result.
|
||||
|
||||
## 📚 Table of Contents
|
||||
|
||||
- [Overview](#-overview)
|
||||
- [Requirements](#-requirements)
|
||||
- [Setup](#-setup)
|
||||
- [Run](#-run)
|
||||
- [Tests](#-tests)
|
||||
- [License](#-license)
|
||||
- [Links](#-links)
|
||||
|
||||
## 🧭 Overview
|
||||
|
||||
Final project of Day 10 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*. The program shows an ASCII calculator with the title beside it, then asks for a number, an operation (`+`, `-`, `*`, `/`) and a second number.
|
||||
|
||||
The operations are functions stored in a dictionary (`OPERATIONS` in `src/calc/operations.py`) and called through it. After each result you can continue with it (`y`), start a new calculation (`n`, which calls `calculator()` again through recursion) or quit (`q`). Invalid input is asked for again, and dividing by zero starts over with a message.
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- Python 3.13 or newer
|
||||
- No runtime dependencies
|
||||
- `pytest` for the tests (installed with the `dev` extra)
|
||||
- Optional: [Doxygen](https://www.doxygen.nl/) to build the API documentation
|
||||
|
||||
## 🛠️ Setup
|
||||
|
||||
Create a local virtual environment in the project folder:
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
```
|
||||
|
||||
Activate it:
|
||||
|
||||
```bash
|
||||
# Windows (PowerShell)
|
||||
.venv\Scripts\Activate.ps1
|
||||
|
||||
# Windows (Git Bash)
|
||||
source .venv/Scripts/activate
|
||||
|
||||
# Linux / macOS
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
Upgrade pip and install the project with the test tools:
|
||||
|
||||
```bash
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Leave the environment with `deactivate`. The `.env` file in the repository is for personal tooling only; the project never reads or imports it.
|
||||
|
||||
## ▶️ Run
|
||||
|
||||
With the environment active:
|
||||
|
||||
```bash
|
||||
python -m calc
|
||||
```
|
||||
|
||||
or, since the install adds a console script, simply:
|
||||
|
||||
```bash
|
||||
calc
|
||||
```
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Build the API documentation from the Doxygen comments (written to `docs/doxygen/`):
|
||||
|
||||
```bash
|
||||
doxygen Doxyfile
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
|
||||
GNU Affero General Public License v3.0, see [LICENSE](LICENSE).
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- [Repository](https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code)
|
||||
- [Documentation](docs/doxygen/html/index.html) (generated with `doxygen Doxyfile`)
|
||||
- [Issue tracker](https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/010-calc/issues)
|
||||
|
||||
@@ -30,10 +30,10 @@ Schedule the four phases that deliver the console calculator of Udemy's "100 Day
|
||||
|
||||
| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| Project scaffold | [MIL-001] | 2026-10-05 | 2026-10-05 | S01 | | `pyproject.toml` | |
|
||||
| Calculator core | [MIL-002] | 2026-10-06 | 2026-10-06 | S01 | | `src/calc/constants.py` and `src/calc/operations.py` | |
|
||||
| Console interface | [MIL-003] | 2026-10-07 | 2026-10-07 | S01 | | `src/calc/calculator.py` and `src/calc/__main__.py` | |
|
||||
| Documentation and release | [MIL-004] | 2026-10-08 | 2026-10-08 | S01 | | `README.md` following the project template | |
|
||||
| Project scaffold | [MIL-001] | 2026-10-05 | 2026-10-05 | S01 | | `pyproject.toml` | [Milestone 30] |
|
||||
| Calculator core | [MIL-002] | 2026-10-06 | 2026-10-06 | S01 | | `src/calc/constants.py` and `src/calc/operations.py` | [Milestone 31] |
|
||||
| Console interface | [MIL-003] | 2026-10-07 | 2026-10-07 | S01 | | `src/calc/calculator.py` and `src/calc/__main__.py` | [Milestone 32] |
|
||||
| Documentation and release | [MIL-004] | 2026-10-08 | 2026-10-08 | S01 | | `README.md` following the project template | [Milestone 33] |
|
||||
|
||||
```plantuml
|
||||
@startgantt
|
||||
@@ -86,3 +86,7 @@ A No-Go moves every later date by the time needed to fix the failed criterion.
|
||||
[SA-001]: ./stakeholder-analysis.md
|
||||
[BC-001]: ./business-case.md
|
||||
[8ccefff]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/010-calc/commit/8ccefffef7ec1f9ecbf548085a5c92fae75e1341
|
||||
[Milestone 30]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/010-calc/milestone/30
|
||||
[Milestone 31]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/010-calc/milestone/31
|
||||
[Milestone 32]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/010-calc/milestone/32
|
||||
[Milestone 33]: https://git.tirsystem.com/Tirsvad-Udemy-100_days_of_code/010-calc/milestone/33
|
||||
|
||||
@@ -14,6 +14,9 @@ dependencies = []
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8"]
|
||||
|
||||
[project.scripts]
|
||||
calc = "calc.calculator:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""!
|
||||
@file __main__.py
|
||||
@brief Allows running the calculator with `python -m calc`.
|
||||
"""
|
||||
|
||||
from calc.calculator import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""!
|
||||
@file calculator.py
|
||||
@brief The interactive console calculator.
|
||||
"""
|
||||
|
||||
from calc.constants import (
|
||||
ANSWER_CONTINUE,
|
||||
ANSWER_NEW,
|
||||
ANSWER_QUIT,
|
||||
ASCII_TITLE,
|
||||
MESSAGE_DIVIDE_BY_ZERO,
|
||||
MESSAGE_GOODBYE,
|
||||
MESSAGE_INVALID_ANSWER,
|
||||
MESSAGE_INVALID_NUMBER,
|
||||
MESSAGE_INVALID_OPERATION,
|
||||
PROMPT_AGAIN,
|
||||
PROMPT_FIRST_NUMBER,
|
||||
PROMPT_NEXT_NUMBER,
|
||||
PROMPT_OPERATION,
|
||||
)
|
||||
from calc.operations import OPERATIONS
|
||||
|
||||
|
||||
def format_number(value: float) -> str:
|
||||
"""!
|
||||
@brief Format a number without a trailing ".0" for whole values.
|
||||
@param value The number to format.
|
||||
@return The text to show the user.
|
||||
"""
|
||||
return str(int(value)) if value.is_integer() else str(value)
|
||||
|
||||
|
||||
def read_number(prompt: str) -> float:
|
||||
"""!
|
||||
@brief Ask for a number until the user types a valid one.
|
||||
@param prompt The text shown to the user.
|
||||
@return The number typed by the user.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
return float(input(prompt))
|
||||
except ValueError:
|
||||
print(MESSAGE_INVALID_NUMBER)
|
||||
|
||||
|
||||
def read_operation() -> str:
|
||||
"""!
|
||||
@brief Show the available operations and ask for one until it is valid.
|
||||
@return The symbol of the chosen operation, a key of OPERATIONS.
|
||||
"""
|
||||
for symbol in OPERATIONS:
|
||||
print(symbol)
|
||||
while True:
|
||||
symbol = input(PROMPT_OPERATION).strip()
|
||||
if symbol in OPERATIONS:
|
||||
return symbol
|
||||
print(MESSAGE_INVALID_OPERATION)
|
||||
|
||||
|
||||
def read_answer(result: float) -> str:
|
||||
"""!
|
||||
@brief Ask whether to continue with the result, start anew or quit.
|
||||
@param result The result of the last calculation.
|
||||
@return One of ANSWER_CONTINUE, ANSWER_NEW or ANSWER_QUIT.
|
||||
"""
|
||||
while True:
|
||||
answer = input(PROMPT_AGAIN.format(result=format_number(result)))
|
||||
answer = answer.strip().lower()
|
||||
if answer in (ANSWER_CONTINUE, ANSWER_NEW, ANSWER_QUIT):
|
||||
return answer
|
||||
print(MESSAGE_INVALID_ANSWER)
|
||||
|
||||
|
||||
def calculator() -> None:
|
||||
"""!
|
||||
@brief Run one calculation session.
|
||||
|
||||
The user may continue with the previous result. Starting a new
|
||||
calculation calls this function again (recursion), which gives a clean
|
||||
restart. A division by zero also restarts the calculator.
|
||||
"""
|
||||
num1 = read_number(PROMPT_FIRST_NUMBER)
|
||||
while True:
|
||||
symbol = read_operation()
|
||||
num2 = read_number(PROMPT_NEXT_NUMBER)
|
||||
try:
|
||||
result = OPERATIONS[symbol](num1, num2)
|
||||
except ZeroDivisionError:
|
||||
print(MESSAGE_DIVIDE_BY_ZERO)
|
||||
calculator()
|
||||
return
|
||||
print(
|
||||
f"{format_number(num1)} {symbol} {format_number(num2)} "
|
||||
f"= {format_number(result)}"
|
||||
)
|
||||
answer = read_answer(result)
|
||||
if answer == ANSWER_CONTINUE:
|
||||
num1 = result
|
||||
elif answer == ANSWER_NEW:
|
||||
calculator()
|
||||
return
|
||||
else:
|
||||
print(MESSAGE_GOODBYE)
|
||||
return
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""!
|
||||
@brief Program entry point: show the ASCII title and start the calculator.
|
||||
"""
|
||||
print(ASCII_TITLE)
|
||||
calculator()
|
||||
+6
-15
@@ -29,26 +29,17 @@ MESSAGE_INVALID_ANSWER: str = "Please answer with one of the listed letters."
|
||||
MESSAGE_DIVIDE_BY_ZERO: str = "You cannot divide by zero."
|
||||
MESSAGE_GOODBYE: str = "Goodbye!"
|
||||
|
||||
## ASCII title shown when the program starts.
|
||||
## ASCII title shown at start: a calculator with the lettering to its right.
|
||||
ASCII_TITLE: str = r"""
|
||||
____ _ _ _
|
||||
/ ___|__ _ | | ___ _ _| | __ _| |_ ___ _ __
|
||||
| | / _` | | |/ __| | | | |/ _` | __/ _ \| '__|
|
||||
| |__| (_| | | | (__| |_| | | (_| | || (_) | |
|
||||
\____\__,_| |_|\___|\__,_|_|\__,_|\__\___/|_|
|
||||
"""
|
||||
|
||||
## ASCII calculator shown under the title.
|
||||
ASCII_CALCULATOR: str = r"""
|
||||
_____________________
|
||||
| _________________ |
|
||||
| | 0 | |
|
||||
| |_________________| |
|
||||
| ___ ___ ___ ___ |
|
||||
| | 7 | 8 | 9 | | + | |
|
||||
| |___|___|___| |___| |
|
||||
| | 4 | 5 | 6 | | - | |
|
||||
| |___|___|___| |___| |
|
||||
| ___ ___ ___ ___ | ____ _ _ _
|
||||
| | 7 | 8 | 9 | | + | | / ___|__ _ | | ___ _ _| | __ _| |_ ___ _ __
|
||||
| |___|___|___| |___| | | | / _` | | |/ __| | | | |/ _` | __/ _ \| '__|
|
||||
| | 4 | 5 | 6 | | - | | | |__| (_| | | | (__| |_| | | (_| | || (_) | |
|
||||
| |___|___|___| |___| | \____\__,_| |_|\___|\__,_|_|\__,_|\__\___/|_|
|
||||
| | 1 | 2 | 3 | | x | |
|
||||
| |___|___|___| |___| |
|
||||
| | . | 0 | = | | / | |
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""!
|
||||
@file test_calculator.py
|
||||
@brief Tests for the interactive calculator loop.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from calc.calculator import calculator, format_number, main
|
||||
|
||||
|
||||
def feed(monkeypatch: pytest.MonkeyPatch, answers: list[str]) -> None:
|
||||
"""!
|
||||
@brief Make input() return the given answers in order.
|
||||
@param monkeypatch The pytest monkeypatch fixture.
|
||||
@param answers The lines the "user" types.
|
||||
"""
|
||||
lines = iter(answers)
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(lines))
|
||||
|
||||
|
||||
def test_format_number_drops_trailing_zero() -> None:
|
||||
assert format_number(5.0) == "5"
|
||||
assert format_number(2.5) == "2.5"
|
||||
|
||||
|
||||
def test_calculator_adds_then_quits(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
feed(monkeypatch, ["2", "+", "3", "q"])
|
||||
calculator()
|
||||
assert "2 + 3 = 5" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_calculator_continues_with_previous_result(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
feed(monkeypatch, ["2", "+", "3", "y", "*", "4", "q"])
|
||||
calculator()
|
||||
assert "5 * 4 = 20" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_calculator_starts_new_calculation(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
feed(monkeypatch, ["2", "+", "3", "n", "10", "/", "4", "q"])
|
||||
calculator()
|
||||
assert "10 / 4 = 2.5" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_calculator_reprompts_on_invalid_input(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
feed(monkeypatch, ["abc", "1", "^", "+", "x", "1", "?", "q"])
|
||||
calculator()
|
||||
out = capsys.readouterr().out
|
||||
assert "not a number" in out
|
||||
assert "not a valid operation" in out
|
||||
assert "Please answer" in out
|
||||
assert "1 + 1 = 2" in out
|
||||
|
||||
|
||||
def test_calculator_restarts_after_division_by_zero(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
feed(monkeypatch, ["1", "/", "0", "6", "-", "1", "q"])
|
||||
calculator()
|
||||
out = capsys.readouterr().out
|
||||
assert "cannot divide by zero" in out
|
||||
assert "6 - 1 = 5" in out
|
||||
|
||||
|
||||
def test_main_shows_ascii_art(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
feed(monkeypatch, ["1", "+", "1", "q"])
|
||||
main()
|
||||
out = capsys.readouterr().out
|
||||
assert "|_____________________|" in out
|
||||
assert "Goodbye!" in out
|
||||
Reference in New Issue
Block a user