DEV Community

Uya
Uya

Posted on • Originally published at zenn.dev

Building a Weight Tracker CLI in Python — Type Hints, Pure Functions, and pytest

Introduction

This is my eighth article as a Java engineer learning TypeScript and Python from scratch.

My first seven articles were all small TypeScript CLIs. In the previous one, I built an HTTP client in TypeScript that calls a FastAPI (Python) server I wrote myself — that was my first contact with Python (FastAPI), but only as "the thing the client calls."

In other words, Python played a supporting role last time. From here, I start Python properly from "project 1." For this first one I go back to the basics: a weight tracker CLI.

What I focused on this time:

  • Python basics (# comments, def, snake_case, f-strings)
  • Type hints (list[float], tuple[float, float, float]) and how they differ from Java Generics
  • Input validation with try / except ValueError
  • Extracting the calculation logic as a "pure function" (separation of concerns, testability)
  • Unit testing with pytest (normal, boundary, and error cases)
  • The habits an ex-Java engineer slips into — and correcting them

As always, I write honestly about where I got stuck, what I thought through, and what I asked AI for.

📝 Where this sits in the series

This is the first article in my Python series. It's also a chance to check whether the mindset I picked up in the TypeScript series — "think in types," "separate logic from I/O," "write tests" — still works when the language changes.


My Learning Style (AI Transparency)

💡 Learning companions & how this article is written

I use Claude Pro (design discussions and Q&A) and Cursor Pro (coding support) as learning companions. Beyond the code, I also collaborate with AI on writing this article itself. Stating the facts plainly here.

Division of roles:

  • Tech selection, design, implementation, and code verification → me
  • Article structure, outline, draft prose, and translation → in collaboration with Claude
  • All content is checked and revised by me before publishing → me

My guiding principle is "the thinking is mine, the wording is AI-assisted, and I verify all of it." I write the code myself (I never ask AI to write code for me), I use AI for hints, spec clarification, and bug spotting, and I make sure I understand why before moving on — and I apply that same stance to the writing process.

In this article, I continue to clearly separate "what I implemented myself" from "what I asked AI for."


What I Built

Enter a menu number, and the CLI records a weight, lists recorded weights, or computes statistics (average, max, min).

--------------------------------
1. Record weight
2. Display the recorded weights
3. Display the weight calculation result
4. Exit
--------------------------------
Enter your choice: 1
Enter your weight(kg): 70.5
--------------------------------
Enter your choice: 3
Weight calculation result:
Average weight: 70.5
Max weight: 70.5
Min weight: 70.5
Enter fullscreen mode Exit fullscreen mode

Data is held in memory and resets when you exit (a deliberate simplification for local learning). I'm saving persistence for the next stage.

📦 Repository: https://github.com/uya0526-design/weight_tracker_py


File Structure and Tech Stack

The structure is very simple.

weight_tracker_py/
├── tests/
│   ├── __init__.py
│   └── test_main.py     # unit tests
├── main.py              # entry point
└── requirements.txt     # dependencies
Enter fullscreen mode Exit fullscreen mode

Tech stack:

  • Python 3.12
  • pytest 9.0.3

The flow from creating a virtual environment (venv) to running tests:

python -m venv venv               # project-local isolated environment
.\venv\Scripts\Activate.ps1       # activate (Windows PowerShell)
pip install pytest                # install the test tool
pip freeze > requirements.txt     # record dependencies
python main.py                    # run the app
pytest                            # run the tests
Enter fullscreen mode Exit fullscreen mode

I understood venv as "per-project isolation," close to a Maven local repository or Node's node_modules.


What I Implemented Myself

main.py — The menu loop and function split

I run the menu with while True + if / elif, dispatching to a function per choice, and break on 4 to exit.

def main() -> None:
    while True:
        choice = display_main_menu()
        if choice == "1":
            record_weight()
        elif choice == "2":
            display_recorded_weights()
        elif choice == "3":
            display_weight_calculation_result()
        elif choice == "4":
            break
        else:
            print("Invalid choice")

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

I understood if __name__ == "__main__": as a declaration of "this is the program's entry point," close to Java's public static void main(String[] args). It doesn't run when imported — only when the file is run directly.

Input validation — try / except ValueError

The weight is read as a string, so I check whether it can be converted to a number.

def record_weight() -> None:
    weight = input("Enter your weight(kg): ")
    # Check if the weight is a number
    try:
        weight = float(weight)
    except ValueError:
        print("Invalid weight")
        return
    recorded_weights.append(weight)
Enter fullscreen mode Exit fullscreen mode

At first I tried to judge "is this a number?" with isdigit() and got stuck (more below). Structurally this is almost the same as Java's try-catch: a string that can't be converted by float() raises ValueError (close to Java's NumberFormatException).

Listing — enumerate and an empty-list check

I used enumerate to list items with numbers.

def display_recorded_weights() -> None:
    print("Recorded weights: ")
    if len(recorded_weights) == 0:
        print("No weights recorded")
        return
    for index, weight in enumerate(recorded_weights):
        print(f"{index + 1}. {weight}")
Enter fullscreen mode Exit fullscreen mode

enumerate is a loop that gives you the index and the element at once, letting me write what I used to write as for (int i = 0; i < list.size(); i++) in Java much more cleanly. I wanted the display to start at 1, so I print index + 1.

Statistics — extracting a pure function (my biggest lesson this time)

This was the design highlight of the project. At first I bundled display and calculation into one function, but I extracted just the calculation logic as a "pure function that takes arguments and only returns values."

# display side (reads the global list → hard to test)
def display_weight_calculation_result() -> None:
    print("Weight calculation result: ")
    if len(recorded_weights) == 0:
        print("No weights recorded")
        return
    average_weight, max_weight, min_weight = calculate_weight_calculation_result(recorded_weights)
    print(f"Average weight: {average_weight}")
    print(f"Max weight: {max_weight}")
    print(f"Min weight: {min_weight}")

# calculation side (takes arguments, only returns values → easy to test)
def calculate_weight_calculation_result(weights: list[float]) -> tuple[float, float, float]:
    average_weight = round(sum(weights) / len(weights), 2)
    max_weight = max(weights)
    min_weight = min(weights)
    return average_weight, max_weight, min_weight
Enter fullscreen mode Exit fullscreen mode

Two points here.

1. Don't read the global variable directly — take it as an argument

The display_... side reads the module-level recorded_weights. That depends on the result of input(), which makes it hard to test. The calculate_... side takes weights as an argument, so a test can pass any list it likes. This is separation of concerns — the same idea I kept coming back to in the TypeScript series, and it worked just as well in Python.

2. Return multiple values as a tuple

return average_weight, max_weight, min_weight returns three values at once. The caller can unpack them with a, b, c = .... In Java, "multiple return values" meant a dedicated class or an array, so writing it with plain syntax felt fresh.

The return type hint -> tuple[float, float, float] feels close to Generics like List<Float> in Java. The big difference is that it's not enforced at runtime (returning a value that doesn't match the type isn't an error), so I understood it as information for the reader and the IDE.

tests/test_main.py — normal, boundary, and error cases

Because I'd extracted a pure function, the tests were very straightforward to write.

import pytest
from main import calculate_weight_calculation_result

# normal: multiple items
def test_calculate_weight_calculation_result():
    weights = [70.12345, 75, 80, 85, 90]
    average_weight, max_weight, min_weight = calculate_weight_calculation_result(weights)
    assert average_weight == 80.02
    assert max_weight == 90.0
    assert min_weight == 70.12345

# error: empty list → division by zero
def test_calculate_weight_calculation_result_empty_list():
    with pytest.raises(ZeroDivisionError):
        calculate_weight_calculation_result([])

# boundary: a single item
def test_calculate_weight_calculation_result_one_weight():
    weights = [70.12345]
    average_weight, max_weight, min_weight = calculate_weight_calculation_result(weights)
    assert average_weight == 70.12
    assert max_weight == 70.12345
    assert min_weight == 70.12345
Enter fullscreen mode Exit fullscreen mode

pytest.raises(ZeroDivisionError) corresponds to Java's assertThrows(ArithmeticException.class, ...). It's interesting that "this should throw an exception" can be written as a test too.


What I Asked AI For

Topic What AI helped with
Spec clarification Surfacing unclear points: input validation, display format, how to exit, menu design
Comment syntax Pointing out that Python uses #, not //
Validation Pointing out that isdigit() can't handle decimals
Conversion handling Pointing out I was appending the raw string, and that double-converting with float() was unnecessary
Design suggestion Suggesting I pass the global variable as an argument, for testability
Type hints Pointing out a mismatch between -> None and -> list[float]
Testing The pytest.raises syntax, and the ideal folder structure
Dependencies Explaining requirements.txt (direct deps only vs. pinning everything)
README Tidying it up in both Japanese and English

Note: I found and fixed the wrong test expectation (below) myself.


Where I Got Stuck

1. I wrote comments with //

Situation: I wrote a comment with // and it wouldn't run.

Root cause: A Java / TypeScript habit. Python comments use #.

Fix: Changed them all to #.

Takeaway: When the language changes, the first differences show up in the tiniest syntax. A small thing, but it reliably reminds me "you're writing a different language now."

2. isdigit() can't reject decimals

Situation: I used "70.5".isdigit() for input validation, and a valid weight got rejected.

Root cause: isdigit() returns True only when every character is a digit. A decimal point . makes it False, so a value like 70.5 doesn't pass.

Fix: I dropped the character-type check and switched to trying a float() conversion, catching failures with try / except ValueError.

# NG: a decimal point makes it False → rejects a valid weight
if weight.isdigit():
    ...

# OK: try to convert, reject on failure
try:
    weight = float(weight)
except ValueError:
    print("Invalid weight")
    return
Enter fullscreen mode Exit fullscreen mode

Takeaway: If you want to know "can this be used as a number?", actually trying to convert it is more reliable than inspecting character types. I learned to pick the check that fits the goal.

3. ZeroDivisionError on an empty list

Situation: Computing the average with zero recorded weights crashed the program.

Root cause: In sum(weights) / len(weights), len became 0, causing a division by zero (ZeroDivisionError).

Fix and design decision: I added an early return if len(recorded_weights) == 0 on the display side, returning "No weights recorded" to the user. On the other hand, I deliberately left the calculation function (calculate_...) to raise ZeroDivisionError as-is, and verified that with a test.

Takeaway: "Where to prevent an error" and "where to let an exception through" are separate decisions. I block early near the UI, while letting the pure function raise naturally and pinning that behavior with a test.

4. Can't import main.py from tests/

Situation: from main import ... in tests/test_main.py raised ModuleNotFoundError.

Root cause: tests/ wasn't recognized as a package, so the import couldn't be resolved.

Fix: Adding tests/__init__.py (an empty file) resolved it.

Takeaway: Folder structure is part of "the spec for making things run." Tracking down the cause myself was a small confidence boost.

5. My test expectation was wrong

Situation: A test failed. The assert for average_weight didn't match.

Root cause: It wasn't the implementation — my own expected value was wrong. I'd written the round() result from my head without actually computing it.

Fix: The average of [70.12345, 75, 80, 85, 90] is 400.12345 / 5 = 80.02469, and round(..., 2) makes it 80.02. I recomputed it by hand and corrected the expectation.

Takeaway: "A failing test" doesn't always mean "the implementation is wrong." Catching a wrong expectation is also part of a test's value. Experiencing the real-world cycle in miniature — a test fails, then you separate whether the cause is the implementation or the expectation — was my biggest gain this time.


What I Learned

Python basics (mapped to Java)

Topic Key takeaway
Comments Use # (// is Java / JavaScript)
Function definition def name(): (corresponds to Java's static void)
Naming snake_case record_weight (vs. Java's camelCase)
Multiple return values return a, b, c returns a tuple (Java needs a dedicated class or array)
String formatting f-strings (f"{variable}")

Type hints

  • You can annotate arguments and return values (list[float], tuple[float, float, float])
  • Feels close to Java Generics (List<Float>)
  • But it's not enforced at runtime — it's information for readability and IDE support

Exception handling

  • try / except has the same structure as Java's try-catch
  • A string that float() can't convert raises ValueError (close to Java's NumberFormatException)

Testability (separation of concerns)

  • Functions containing input() or print() are hard to test
  • Extracting the calculation logic as a pure function that takes arguments and returns values makes it testable
  • This is exactly the "separation of concerns" that matters in real work

Unit testing (pytest)

Topic Key takeaway
Position pytest corresponds to Java's JUnit
Exception tests with pytest.raises(...) corresponds to assertThrows(...)
Coverage Covering normal, boundary, and error cases is the baseline
Value When a test fails, it can also reveal a wrong expectation

Project structure

  • Putting __init__.py in tests/ resolves pytest's import
  • requirements.txt can list "direct deps only" or "pin everything." When reproducibility matters, list everything

Reflection

I built this the same way as before: write the code myself and have AI review it. As my first Python project, three things stood out.

The habit of thinking in types carried straight over: The "think in types first" I repeated in the TypeScript series came naturally with Python's type hints. There's the difference that they're not enforced at runtime, but understanding the goal — "write them for the reader and the IDE" — was a real gain.

Separation of concerns transcends the language: Extracting the calculation logic as a pure function made the tests surprisingly easy to write. "Separate I/O from logic" works the same way in TypeScript and in Python.

The experience of a test failing is itself a lesson: Recomputing and fixing my own expectation taught me that "a test isn't just a tool for doubting the implementation." Separating whether a failure comes from the implementation or the expectation was the most satisfying part.

The ex-Java habits (// comments, isdigit()) did surface, but I corrected them along with the reasons, so I should be able to leave them behind for the next Python project.


Wrapping Up

This was my record of building a weight tracker CLI in Python, centered on type hints, pure functions, and pytest — my first article in the Python series.

Continuous progress from the TypeScript series:

  1. Picked up Python basics while staying aware of the differences from Java (#, def, snake_case, tuple multiple returns)
  2. Wrote type hints while understanding that they're not enforced at runtime
  3. Extracted the calculation logic as a pure function, practicing separation of concerns in Python too
  4. Covered normal, boundary, and error cases with pytest
  5. Found and fixed a wrong test expectation myself, experiencing the same cycle as real work

Next, I want to move into persistence with file saving and JSON, and graduate from this project's "data disappears when you exit" simplification.

The full learning log is in the repository:


This article is part of my public learning journey using AI tools (Claude Pro / Cursor Pro). The thinking and all code are mine; I collaborate with AI on the writing (structure, drafting, translation) and verify every line before publishing.

Top comments (0)