PythonMastery
beginner 25 min read · lesson 4 of 15 in Projects

Project: Password Generator

1 · The lesson

read

You'll build a password generator that produces strong, configurable passwords — length, character types, exclusion of confusing characters (like 0 vs O). And we'll learn the important security difference between random and secrets.

What you'll practice: string module constants, random.choice, list comprehensions, functions with defaults, the secrets module (security-grade randomness).

Why this is useful: a real password generator is something you'll genuinely use. Most password managers have one, but yours is yours.


Step 1 — The Simplest Version

Pick N random characters from the alphabet.

python
import random
import string

length = 12
chars = string.ascii_letters    # a-z + A-Z

password = "".join(random.choice(chars) for _ in range(length))
print(password)

That's a 12-character random password. Run it a few times — you get a different one each time.

The string.ascii_letters is a built-in constant: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'. The string module has several of these:

python
import string

print(string.ascii_lowercase)   # 'abc...xyz'
print(string.ascii_uppercase)   # 'ABC...XYZ'
print(string.digits)            # '0123456789'
print(string.punctuation)       # '!"#$%&\'()*+,-./...'

Step 2 — Mix Character Types

Real passwords mix letters, digits, and symbols.

python
import random
import string

length = 16
charset = string.ascii_letters + string.digits + string.punctuation

password = "".join(random.choice(charset) for _ in range(length))
print(password)

Run it a few times — you'll see something like qK7$mPx@v9.Lne!2.


Step 3 — Guarantee Each Category

There's a problem: random.choice() could (by bad luck) give you 16 letters and no digits. Real password rules usually require "at least one of each".

The fix: pick one from each required pool, then fill the rest randomly, then shuffle.

python
import random
import string

def generate_password(length=16, *, use_digits=True, use_symbols=True):
    """Generate a password of the given length, guaranteeing each requested type."""
    pools = [string.ascii_lowercase, string.ascii_uppercase]
    if use_digits:
        pools.append(string.digits)
    if use_symbols:
        pools.append(string.punctuation)

    if length < len(pools):
        raise ValueError(f"length must be at least {len(pools)} to include one of each type")

    # 1. Take one mandatory char from each pool
    chars = [random.choice(p) for p in pools]

    # 2. Fill the rest from the combined pool
    combined = "".join(pools)
    chars.extend(random.choice(combined) for _ in range(length - len(pools)))

    # 3. Shuffle so the mandatory chars aren't always in the same positions
    random.shuffle(chars)
    return "".join(chars)

# Test
for _ in range(5):
    print(generate_password())

# Without symbols (some sites reject them)
print(generate_password(use_symbols=False))

# Long, letters-only
print(generate_password(20, use_symbols=False, use_digits=False))

Notice the * in the signature: def generate_password(length=16, *, use_digits=True, use_symbols=True). The * forces use_digits and use_symbols to be keyword-only — you have to write use_digits=False, you can't just pass False positionally. This makes call sites obvious.


Step 4 — Exclude Confusing Characters

Some characters look almost identical and cause password-typing pain: 0/O, 1/l/I, 5/S. Real password generators usually have an "avoid lookalikes" option.

python
import random
import string

AMBIGUOUS = "0O1lI5S2Z"

def generate_password(length=16, *, use_digits=True, use_symbols=True, no_ambiguous=False):
    pools = [string.ascii_lowercase, string.ascii_uppercase]
    if use_digits:
        pools.append(string.digits)
    if use_symbols:
        pools.append(string.punctuation)

    if no_ambiguous:
        pools = [p.translate(str.maketrans("", "", AMBIGUOUS)) for p in pools]

    if length < len(pools):
        raise ValueError(f"length must be ≥ {len(pools)}")

    chars = [random.choice(p) for p in pools]
    combined = "".join(pools)
    chars.extend(random.choice(combined) for _ in range(length - len(pools)))
    random.shuffle(chars)
    return "".join(chars)

# Compare
print("With ambiguous:    ", generate_password(16))
print("Without ambiguous: ", generate_password(16, no_ambiguous=True))

The trick: str.maketrans("", "", AMBIGUOUS) creates a translation table that deletes every character in AMBIGUOUS. We apply it to each pool to strip out the lookalikes.


Step 5 — Use secrets Instead of random (Important!)

random is for games and simulations. secrets is for passwords, tokens, and anything an attacker might want to predict.

The random module uses a deterministic algorithm seeded by system state. A clever attacker who sees a few of your "random" outputs could theoretically predict the next one. For passwords, that's catastrophic.

The secrets module uses the OS's secure random source — designed for cryptography.

python
import secrets
import string

def generate_secure_password(length=16, *, no_ambiguous=False):
    AMBIGUOUS = "0O1lI5S2Z"
    pools = [
        string.ascii_lowercase,
        string.ascii_uppercase,
        string.digits,
        string.punctuation,
    ]
    if no_ambiguous:
        pools = [p.translate(str.maketrans("", "", AMBIGUOUS)) for p in pools]

    if length < len(pools):
        raise ValueError(f"length must be ≥ {len(pools)}")

    chars = [secrets.choice(p) for p in pools]
    combined = "".join(pools)
    chars.extend(secrets.choice(combined) for _ in range(length - len(pools)))

    # Shuffle securely too — `random.shuffle` isn't secrets-grade
    # We do it by drawing fresh secure indices
    n = len(chars)
    for i in range(n - 1, 0, -1):
        j = secrets.randbelow(i + 1)
        chars[i], chars[j] = chars[j], chars[i]

    return "".join(chars)

print(generate_secure_password(20))
print(generate_secure_password(16, no_ambiguous=True))

Notice the manual Fisher-Yates shuffle using secrets.randbelow() — random.shuffle() isn't cryptographically secure even if every choice that fed into it was.

Rule: any time the password could end up protecting something (an account, a vault, a session token), use secrets.


Step 6 — Calculate Password Strength

How "strong" is a password? Roughly: how many guesses to crack it.

python
import math
import string

def password_entropy_bits(length, charset_size):
    """Return Shannon entropy in bits — log2(charset^length)."""
    return length * math.log2(charset_size)

def describe_strength(bits):
    if bits < 40:   return "weak (seconds to hours)"
    if bits < 60:   return "moderate (hours to days)"
    if bits < 80:   return "strong (months to years)"
    return "very strong (centuries+)"

# Examples
charsets = {
    "lowercase only":      26,
    "letters + digits":    62,
    "+ punctuation":       94,
    "letters only, no amb": 26 + 26 - 6,
}

for length in (8, 12, 16, 20):
    print(f"\nLength {length}:")
    for label, size in charsets.items():
        bits = password_entropy_bits(length, size)
        print(f"  {label:<22} {bits:>5.1f} bits → {describe_strength(bits)}")

This isn't security theatre — it tells you why "16 characters with all types" is the modern recommendation.


Stretch Goals

1. Memorable mode: instead of random characters, pick 4 random words from a dictionary (the XKCD method). Often more memorable AND stronger.
2. Pronounceable: alternate consonants and vowels so the password is sort-of speakable: "kalita-pen-44".
3. Pattern matching: a --pattern Lllll-dddd syntax to control structure precisely.
4. Save with labels: store generated passwords in a JSON file keyed by site name (with a master password to encrypt — but that's a much bigger project!).
5. Strength scorer: input any existing password and rate it.


🎯 Your Turn — Build a Password Strength Checker

Reverse the project. Given an existing password, rate its strength.

python
import math, string

def rate_password(password):
    """Return a tuple (entropy_bits, label).

    Steps:
      1. Determine which char classes the password uses:
         lowercase, uppercase, digits, punctuation.
      2. Compute the charset size as the SUM of the classes used.
      3. Entropy = len(password) * log2(charset_size).
      4. Map entropy to a label: <40 weak, <60 moderate, <80 strong, else very strong.
    """
    # TODO 1: detect which classes are used (build a set or four booleans)
    # TODO 2: sum up the charset size from the classes
    # TODO 3: compute entropy bits
    # TODO 4: return (bits, label)
    pass

# Test
print(rate_password("password"))           # weak
print(rate_password("Password1"))          # moderate
print(rate_password("Pa$$w0rd!2024"))      # strong-ish
print(rate_password("xK3#mP9!qLn2$vR4"))   # very strong
Hint 1 — Detecting classes with any() any(c.islower() for c in password) → True if any character is lowercase. Same with .isupper(), .isdigit(). For punctuation: c in string.punctuation.
Hint 2 — Mapping bits → label A simple if/elif ladder: if bits < 40: return bits, "weak" elif bits < 60: return bits, "moderate" elif bits < 80: return bits, "strong" else: return bits, "very strong"
Show full solution
python
import math
import string

def rate_password(password):
    has_lower = any(c.islower() for c in password)
    has_upper = any(c.isupper() for c in password)
    has_digit = any(c.isdigit() for c in password)
    has_punct = any(c in string.punctuation for c in password)

    charset = 0
    if has_lower: charset += 26
    if has_upper: charset += 26
    if has_digit: charset += 10
    if has_punct: charset += 32

    if charset == 0 or len(password) == 0:
        return 0, "empty"

    bits = len(password) * math.log2(charset)
    if bits < 40:   label = "weak"
    elif bits < 60: label = "moderate"
    elif bits < 80: label = "strong"
    else:           label = "very strong"
    return round(bits, 1), label

for pw in ["password", "Password1", "Pa$$w0rd!2024", "xK3#mP9!qLn2$vR4"]:
    bits, label = rate_password(pw)
    print(f"  {pw:<20} {bits:>5} bits  →  {label}")

What You Learned

  • The string module's constants (ascii_letters, digits, punctuation)
  • random.choice for picking from a collection
  • Keyword-only arguments (the * in the signature)
  • str.maketrans and translate for character substitution/deletion
  • The secrets module for security-grade randomness — and why it matters
  • Entropy as a measure of password strength

You also know something most coders don't: never use random for security-sensitive randomness. That alone separates intermediate developers from advanced ones.

Next: Word Frequency Counter.