Author SHA1 Message Date
Tirsvad 0904993de5 Merge pull request 'Link git host milestones in the project plan' (#34) from docs/milestone-links into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 3s
Reviewed-on: #34
2026-10-04 15:12:52 +02:00
Tirsvad 6bccc8c9e7 Link git host milestones in the project plan
Fill the Milestone column of the Gateway Schedule in
docs/project-plan.md
with links to Milestones 30 to 33, which sync-project.sh created for
MIL-001 to MIL-004.

No issue closed: documentation-only change.
2026-10-04 21:11:57 +08:00
Tirsvad afdffdf564 Merge pull request 'MIL-004: Add README and verify documentation' (#33) from mil-004-documentation-and-release into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 4s
Reviewed-on: #33
2026-10-04 15:10:18 +02:00
Tirsvad db477d99fb Add README with setup, run and test instructions
MIL-004 Documentation and release.

- Write README.md from the project template: overview, requirements,
  setup with a local .venv and pip upgrade, run, tests, AGPL license
  and links
- Document building the API docs with doxygen Doxyfile
- Verified: doxygen builds without warnings, and a clean copy installs,
  runs and passes the 13 tests following only the README

Closes #26
Closes #27
Closes #28

Task: MIL-004#1
Task: MIL-004#2
Task: MIL-004#3
2026-10-04 21:06:50 +08:00
Tirsvad 142a027e5c Merge pull request 'MIL-003: Add console interface' (#32) from mil-003-console-interface into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 5s
Reviewed-on: #32
2026-10-04 15:01:14 +02:00
Tirsvad 6d99810e30 Add console interface: calculator loop, ASCII title and tests
MIL-003 Console interface.

- Add calculator() with recursive restart: y continues with the result,
  n starts a new calculation, q quits
- Show one ASCII title at start: a calculator with the lettering to
  its right
- Re-prompt on invalid numbers, operations and answers; restart on
  division by zero
- Add __main__.py for python -m calc and the calc console script
- Add interface tests with input() fed through monkeypatch

Closes #22
Closes #23
Closes #24
Closes #25

Task: MIL-003#1
Task: MIL-003#2
Task: MIL-003#3
Task: MIL-003#4
2026-10-04 20:59:53 +08:00
Tirsvad 9030510773 Merge pull request 'MIL-002: Add calculator core' (#31) from mil-002-calculator-core into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
Reviewed-on: #31
2026-10-04 14:51:02 +02:00
Tirsvad 5e1067b4f3 Add calculator core: constants, operations and tests
MIL-002 Calculator core.

- Add constants.py with operation symbols, answers, prompts, messages
  and the ASCII title and calculator
- Add add, subtract, multiply and divide, and the OPERATIONS dictionary
  that maps each symbol to its function without calling it
- Add tests for each operation, division by zero and the dictionary

Closes #19
Closes #20
Closes #21

Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
2026-10-04 20:49:38 +08:00
Tirsvad c90e5afb7c Merge pull request 'MIL-001: Add project scaffold' (#30) from mil-001-project-scaffold into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 4s
Reviewed-on: #30
2026-10-04 14:42:25 +02:00
9 changed files with 446 additions and 5 deletions
+91 -1
View File
@@ -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)
+8 -4
View File
@@ -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
+3
View File
@@ -14,6 +14,9 @@ dependencies = []
[project.optional-dependencies]
dev = ["pytest>=8"]
[project.scripts]
calc = "calc.calculator:main"
[tool.setuptools.packages.find]
where = ["src"]
+9
View File
@@ -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()
+112
View File
@@ -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()
+48
View File
@@ -0,0 +1,48 @@
"""!
@file constants.py
@brief Constants used by the console calculator.
"""
## Operation symbols, in the order they are offered to the user.
SYMBOL_ADD: str = "+"
SYMBOL_SUBTRACT: str = "-"
SYMBOL_MULTIPLY: str = "*"
SYMBOL_DIVIDE: str = "/"
## Answers accepted after a calculation.
ANSWER_CONTINUE: str = "y"
ANSWER_NEW: str = "n"
ANSWER_QUIT: str = "q"
## Prompts and messages shown to the user.
PROMPT_FIRST_NUMBER: str = "What's the first number?: "
PROMPT_NEXT_NUMBER: str = "What's the next number?: "
PROMPT_OPERATION: str = "Pick an operation: "
PROMPT_AGAIN: str = (
f"Type '{ANSWER_CONTINUE}' to continue calculating with {{result}}, "
f"'{ANSWER_NEW}' to start a new calculation, "
f"or '{ANSWER_QUIT}' to quit: "
)
MESSAGE_INVALID_NUMBER: str = "That is not a number, please try again."
MESSAGE_INVALID_OPERATION: str = "That is not a valid operation, please try again."
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 at start: a calculator with the lettering to its right.
ASCII_TITLE: str = r"""
_____________________
| _________________ |
| | 0 | |
| |_________________| |
| ___ ___ ___ ___ | ____ _ _ _
| | 7 | 8 | 9 | | + | | / ___|__ _ | | ___ _ _| | __ _| |_ ___ _ __
| |___|___|___| |___| | | | / _` | | |/ __| | | | |/ _` | __/ _ \| '__|
| | 4 | 5 | 6 | | - | | | |__| (_| | | | (__| |_| | | (_| | || (_) | |
| |___|___|___| |___| | \____\__,_| |_|\___|\__,_|_|\__,_|\__\___/|_|
| | 1 | 2 | 3 | | x | |
| |___|___|___| |___| |
| | . | 0 | = | | / | |
| |___|___|___| |___| |
|_____________________|
"""
+63
View File
@@ -0,0 +1,63 @@
"""!
@file operations.py
@brief Arithmetic operations and the dictionary that maps symbols to them.
"""
from collections.abc import Callable
from calc.constants import (
SYMBOL_ADD,
SYMBOL_DIVIDE,
SYMBOL_MULTIPLY,
SYMBOL_SUBTRACT,
)
def add(n1: float, n2: float) -> float:
"""!
@brief Add two numbers.
@param n1 The first number.
@param n2 The second number.
@return The sum of @p n1 and @p n2.
"""
return n1 + n2
def subtract(n1: float, n2: float) -> float:
"""!
@brief Subtract the second number from the first.
@param n1 The first number.
@param n2 The second number.
@return The difference @p n1 minus @p n2.
"""
return n1 - n2
def multiply(n1: float, n2: float) -> float:
"""!
@brief Multiply two numbers.
@param n1 The first number.
@param n2 The second number.
@return The product of @p n1 and @p n2.
"""
return n1 * n2
def divide(n1: float, n2: float) -> float:
"""!
@brief Divide the first number by the second.
@param n1 The dividend.
@param n2 The divisor.
@return The quotient @p n1 divided by @p n2.
@throws ZeroDivisionError When @p n2 is zero.
"""
return n1 / n2
## Maps an operation symbol to its function. The functions are stored, not called.
OPERATIONS: dict[str, Callable[[float, float], float]] = {
SYMBOL_ADD: add,
SYMBOL_SUBTRACT: subtract,
SYMBOL_MULTIPLY: multiply,
SYMBOL_DIVIDE: divide,
}
+79
View File
@@ -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
+33
View File
@@ -0,0 +1,33 @@
"""!
@file test_operations.py
@brief Tests for the arithmetic operations.
"""
import pytest
from calc.operations import OPERATIONS, add, divide, multiply, subtract
def test_add_returns_sum() -> None:
assert add(2, 3) == 5
def test_subtract_returns_difference() -> None:
assert subtract(2, 3) == -1
def test_multiply_returns_product() -> None:
assert multiply(4, 2.5) == 10
def test_divide_returns_quotient() -> None:
assert divide(9, 3) == 3
def test_divide_when_divisor_is_zero() -> None:
with pytest.raises(ZeroDivisionError):
divide(1, 0)
def test_operations_maps_symbols_to_functions() -> None:
assert OPERATIONS == {"+": add, "-": subtract, "*": multiply, "/": divide}