DEV Community

Uya
Uya

Posted on • Originally published at zenn.dev

Building a Password Generator CLI in Python — string, random, any, and Testing Randomness

Introduction

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

Last time I moved into the Python series, and the first project was a weight tracker CLI. Centered on type hints, pure functions, and pytest, it confirmed that "separate logic from I/O" still works in Python.

For the second Python project, this time it's a password generator CLI. You specify the length and whether to include digits and symbols, and it generates a random password. Where the last one was about "separating calculation logic," this one centers on string manipulation and randomness.

What I focused on this time:

  • Building a character pool by concatenating string constants (ascii_letters, digits)
  • Picking one character at a time with random.choice()
  • any() to check whether an iterable contains an element that meets a condition (close to Java's anyMatch)
  • Extracting validation logic into independent functions (reused from both main and tests)
  • A testing strategy for randomness (how to avoid probabilistic failures)
  • Package recognition with __init__.py, and resolving pytest's imports

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 second article in my Python series. It's also a chance to check whether the habits I learned last time — "separation of concerns" and "write tests" — hold up with a subject that involves randomness.


My Learning Style (AI Transparency)

💡 Learning companions & how this article is written

I use Claude Pro (design discussions, Q&A, and article drafting) and Cursor Pro (coding support).

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

For the code, I set myself these rules: 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. In this article I clearly separate "what I implemented myself" from "what I asked AI for." My line is "the thinking and decisions are mine, the wording is AI-assisted, and I verify the final content myself." This isn't an apology — just stating the facts.


What I Built

Enter a menu number, and the CLI generates a password or lists generated ones. You can interactively specify the length and whether to include digits and special characters, and generated passwords are kept as history during the session.

---------------------------------
1. Generate a password
2. List generated passwords
3. Exit
---------------------------------
Enter your choice: 1
Enter the length of the password:
20
Should I include digits? (y/n):
y
Should I include special characters? (y/n):
y
Generated password: BRBK3%n%D2Tm1y4-DG1[
Password generated successfully.
---------------------------------
1. Generate a password
2. List generated passwords
3. Exit
---------------------------------
Enter your choice: 2
1. BRBK3%n%D2Tm1y4-DG1[
---------------------------------
Enter your choice: 3
Exiting the program...
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 a later stage.

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


File Structure and Tech Stack

Last time it was a single file (main.py), but this time I split the logic (password.py) from the entry point (main.py).

password_generator_py/
├── src/
│   ├── __init__.py
│   ├── main.py          # entry point
│   └── password.py      # password generation logic
├── tests/
│   ├── __init__.py
│   └── test_password.py # unit tests
├── requirements.txt     # dependencies
├── LEARNING_LOG.md      # learning log
└── README.md
Enter fullscreen mode Exit fullscreen mode

Tech stack:

  • Python 3.12
  • pytest 9.0.3

I split the logic (password.py) from the I/O (main.py) at the file level because I wanted to take last time's "separation of concerns" one step further. In Java terms, it feels close to separating a Service class that holds the logic from the class that holds the main entry point.


What I Implemented Myself

password.py — Building a character pool and picking randomly

This is the heart of password generation. Starting from string.ascii_letters, I concatenate digits and symbols as strings depending on the flags, then pick one character at a time from that pool.

import random
import string

special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
generated_passwords = []

def generate_password(length: int, use_digits: bool, use_special_chars: bool) -> str:
    """A password is generated based on the result selected from the menu"""
    password = ""
    random_string = string.ascii_letters
    if use_digits:
        random_string = random_string + string.digits
    if use_special_chars:
        random_string = random_string + special_chars
    for _ in range(length):
        password += random.choice(random_string)
    generated_passwords.append(password)
    return password
Enter fullscreen mode Exit fullscreen mode

Three points here.

1. The string module's character constants

string.ascii_letters (upper- and lowercase letters) and string.digits (0 to 9) are provided as strings out of the box. Where in Java I'd loop to build them or write out the constants by hand, here the standard library just hands them over. I concatenate only the character types whose flags are set, with +, to build the "pool of allowed characters."

2. for _ in range(length) without a loop variable

This is a case of "I just want to repeat length times and don't use the loop variable's value." In Python, the convention is to put _. The same situation as a Java for (int i = 0; i < length; i++) where i is unused, but here I can make "unused" explicit with _, which felt fresh.

3. Picking one element with random.choice()

random.choice(seq) returns one random element from a sequence. What I used to write as list.get(random.nextInt(list.size())) in Java fits into a single function.

password.py — Extracting validation logic into independent functions

I prepared functions to confirm "does the generated password really contain the specified character types?", made independent of the main logic.

def get_digits(password: str) -> bool:
    """Check if the password contains digits"""
    return any(char.isdigit() for char in password)

def get_special_chars(password: str) -> bool:
    """Check if the password contains special characters"""
    return any(char in special_chars for char in password)
Enter fullscreen mode Exit fullscreen mode

This is where I used any() for the first time. any() returns True if the iterable contains at least one True. So any(char.isdigit() for char in password) expresses "is there at least one digit in the password?" in a single line.

In Java's Stream it's close to password.chars().anyMatch(Character::isDigit): the classic "loop and return true partway through" becomes a declarative one-liner, which felt great.

And the important part is that I extracted these validation functions as independent functions. Last time I learned to split calculation logic into pure functions; this time, by splitting the validation logic, I could reuse the same functions from both the main process (the warning display) and the test code. That pays off in the next section's tests.

password.py — Listing, and the start argument of enumerate

For listing the history, I improved on the enumerate I'd used last time.

def list_generated_passwords() -> None:
    """List the generated passwords"""
    if not generated_passwords:
        print("No passwords have been generated yet.")
        return
    for i, password in enumerate(generated_passwords, 1):
        print(f"{i}. {password}")
Enter fullscreen mode Exit fullscreen mode

Last time I wrote index + 1 to make the display start at 1, but this time I learned you can pass a start number as the second argument, like enumerate(generated_passwords, 1). A small improvement, but a spot where I felt "I wrote this a little more cleanly than last time."

For the empty check I also wrote if not generated_passwords: instead of len(...) == 0. In Python an empty list evaluates as false, so this reads more naturally — something I was taught.

main.py — The menu loop and input validation

The entry point main.py runs the menu with while True and guards numeric input with try / except.

choice = input("Enter your choice: ")
if choice == "1":
    print("Enter the length of the password: ")
    try:
        length = int(input())
    except ValueError:
        print("Invalid input. Please enter a valid number.")
        continue
Enter fullscreen mode Exit fullscreen mode

The try / except ValueError I learned in the weight tracker CLI carried straight over. Input that int() can't convert raises ValueError (close to Java's NumberFormatException), so I catch it and continue back to the top of the menu.

main.py — Showing the warning just once with remind_flag

Something I tweaked this time is the warning for "when the specified character type didn't happen to be included." Since we pick with random, at around 20 characters it's possible to draw no digits at all. So after generating, I check the contents with the validation functions and warn if something is missing.

remind_flag = False
if include_digits and not get_digits(password):
    print("Digits are not included in the password.")
    remind_flag = True
if include_special_chars and not get_special_chars(password):
    print("Special characters are not included in the password.")
    remind_flag = True
if remind_flag:
    print("If you mind, please generate a password again.")
Enter fullscreen mode Exit fullscreen mode

I keep a single remind_flag so that "if either digits or symbols are missing, show 'generate again if you mind' just once at the end." I'm reusing the get_digits / get_special_chars I extracted earlier right here.

📝 A note for next time

In LEARNING_LOG I noted that "instead of a flag variable, you could collect the warning messages into a list and show them all at the end." remind_flag was enough this time, but if warnings grow, the list approach would be easier to extend.

tests/test_password.py — How to test randomness

The part that took the most thought this time was testing. A function that uses random produces different results every run, so a naive test becomes one that "happens to pass / happens to fail."

from src.password import get_digits, get_special_chars, generate_password

def test_get_digits():
    assert get_digits("password123") == True
    assert get_digits("password") == False

def test_generate_password():
    assert isinstance(generate_password(10, True, True), str)
    assert len(generate_password(10, True, True)) == 10

def test_generate_password_with_digits():
    """Check for randomness by checking 1000 length passwords"""
    password = generate_password(1000, True, False)
    assert get_digits(password) == True
Enter fullscreen mode Exit fullscreen mode

What I worked out is test_generate_password_with_digits. Even when I specify "include digits," a short password might by chance contain no digits at all. That makes the test unstable.

So I decided to generate a length-1000 password and verify that. With 1000 characters, the probability of drawing zero from the digit pool is effectively near zero. Reaching the idea "if you can't remove randomness, crush the probabilistic failure with a large enough number of trials" on my own was the win this time.

For the verification, I could reuse the get_digits I'd extracted earlier. Because I'd split the validation logic, the production code and the test could share the same check. The isinstance(..., str) test to confirm the return type also added one more tool to my belt, following last time's pytest.raises.


What I Asked AI For

Topic What AI helped with
Breaking down the steps Suggesting the order: password.py first, then main.py, then test_password.py
The string module Telling me about character constants like ascii_lowercase, ascii_uppercase, digits, punctuation
Random selection options Hinting at the difference between random.choices() and random.choice() (I chose choice() myself)
Type hints Suggesting I add type hints to function signatures
Package structure The role of __init__.py and how pytest resolves imports
README Tidying it up in both Japanese and English

Note: the idea to test randomness with a long string was mine, and I decided the validation-function split myself.


Where I Got Stuck

1. AttributeError: 'str' object has no attribute 'get_digits'

Situation: When calling the validation, I wrote password.get_digits(password) and got an error.

Root cause: get_digits is a module-level function defined in password.py, but I was calling it as if it were a method of password (a string variable). A string has no method called get_digits, hence AttributeError.

Fix: I changed it to a plain function call, get_digits(password).

# NG: trying to call a method on the string password
password.get_digits(password)

# OK: pass password to the module's function
get_digits(password)
Enter fullscreen mode Exit fullscreen mode

Takeaway: "a.b() (method call)" and "b(a) (pass to a function)" are different things. In Java basically everything is a class method, so I realized my hands aren't yet used to the Python sense of being able to put functions at the module top level. It also helped that reading the error message spelled out the cause clearly.

2. ModuleNotFoundError — pytest can't find the module

Situation: When I ran pytest, the test file's import raised ModuleNotFoundError.

Root cause and trial-and-error:

  • First from password import ... gave No module named 'password'
  • Changing to from src.password import ... then gave No module named 'src'

Neither src/ nor tests/ was recognized by Python as a package; they were "just folders."

Fix: I placed src/__init__.py and tests/__init__.py (both empty files) so the folders are recognized as packages, which resolved it. I'd solved a similar problem with tests/__init__.py in the weight tracker CLI, so the instinct "when imports don't resolve, first suspect package recognition" was starting to kick in.

Takeaway: Folder structure is part of "the spec for making things run." The way the presence of __init__.py changes import resolution felt a little like the correspondence between Java's package declarations and directory structure.


What I Learned

Python knowledge

Topic Key takeaway
string module Character constants like ascii_letters, digits, punctuation can be concatenated as strings
random.choice(seq) Picks one random element from a sequence (close to Java's list.get(random.nextInt(...)))
any() Returns True if at least one element of an iterable meets a condition (close to Java Stream's anyMatch)
for _ in range(n) Use _ to make "unused" explicit when you don't need the loop variable
enumerate(seq, 1) The second argument sets the start number (more natural than index + 1)
Empty check An empty list is false; if not list: expresses "if empty"
__init__.py Makes a folder recognized as a Python package

Testing knowledge

Topic Key takeaway
Testing randomness Make the number of trials (characters) large enough to crush probabilistic failures
isinstance(value, type) Useful for tests that confirm the return type
Sharing validation functions The production code and the test can reuse the same check function

Design thinking

  • Splitting validation logic into a separate function lets both the main logic (the warning display) and the test code reuse it. This became an applied version of separation of concerns, following last time's "splitting calculation logic."
  • Instead of a flag variable (remind_flag), you could collect the warning messages into a list and show them all at the end (an option for next time if I extend this).

Reflection

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

Separation of concerns went one level deeper: Last time it was "extract calculation logic as a pure function"; this time I went as far as "extract validation logic and share it between production and tests." The feeling of the same principle widening its range of application was a satisfying sense of continuous growth.

I learned how to deal with randomness and testing: "How do you verify a process whose result changes every time?" was a worry that the deterministic processes so far never had. Reaching the idea "make the number of trials large to crush probabilistic failures" on my own was the biggest gain this time.

The pleasure of writing declaratively with any(): A process that loops and return trues partway through becomes a single any(...) line. The same "from procedural to declarative" feeling I had when learning Java's Stream came back in Python.

The ex-Java habit (confusing a method call with a function call) did surface, but I corrected it along with the reason, from the error message.


Wrapping Up

This was my record of building a password generator CLI in Python, centered on string, random, and any — my second article in the Python series.

Continuous progress from last time:

  1. Picked up the basics of building strings with string and random.choice()
  2. Learned to write "is at least one condition met?" declaratively with any()
  3. Practiced a design that extracts validation logic and shares it between production code and tests
  4. Tested randomness with the idea of "crushing probabilistic failures by making the number of trials large"
  5. Resolved package recognition with __init__.py on my own, drawing on last time's experience

Next, I want to move into persistence — saving generated passwords to a file so they can be listed across sessions — and gradually 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)