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 creates strong passwords. You can choose the length, the character types, and whether to leave out characters that look alike (like 0 and O). You'll also learn the security difference between the random and secrets modules.

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

Why this is useful: you can use this password generator for your own accounts. Most password managers have one built in, but here you will understand exactly how yours works.


Step 1: The Simplest Version

Pick N random characters from the letters of the alphabet.

python
import random
import string

length = 12
chars = string.ascii_letters    # a-z and 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, and you get a different password each time.

string.ascii_letters is a constant in the string module: '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 passwords like qK7$mPx@v9.Lne!2.


Step 3: Guarantee Each Category

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

The fix: pick one character from each required pool, fill the rest with random characters, 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 * makes use_digits and use_symbols keyword-only. You have to write use_digits=False. You can't pass False as a positional argument. This makes each call easier to read.


Step 4: Exclude Confusing Characters

Some characters look almost the same, which makes a password hard to read and type: 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))

str.maketrans("", "", AMBIGUOUS) creates a translation table that deletes every character in AMBIGUOUS. We apply it to each pool to remove the lookalike characters.


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: the same starting value (the seed) always gives the same sequence of numbers. An attacker who sees enough of your "random" outputs can work out the next ones. For passwords, that is a serious security hole.

The secrets module uses the operating system's secure random source, which is 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, because `random.shuffle` is not safe enough for secrets
    # We do it by picking new secure random positions
    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 with secrets.randbelow(). random.shuffle() isn't cryptographically secure, even if every character in the list was chosen with secrets.

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, it is the number of guesses an attacker needs to find 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 - 5,  # AMBIGUOUS contains 5 letters: O, l, I, S, Z
}

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)}")

The numbers show why length matters so much: each extra character multiplies the number of possible passwords by the size of the character set.


Stretch Goals

1. Memorable mode: instead of random characters, pick 4 random words from a dictionary (the XKCD method). Such a password is easier to remember, and often stronger than a short password full of symbols.
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, with the site name as the key. (Encrypting the file with a master password is a much bigger project.)
5. Strength scorer: input any existing password and rate it.


🎯 Your Turn: Build a Password Strength Checker

Now do the reverse. 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 character 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"))      # very strong by this formula, but it is "password" with swaps: real crackers try those first
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 you need it for passwords
  • Entropy as a measure of password strength

Remember the most important rule from this project: never use random for passwords, tokens or anything else that must be secret. Use secrets.

Next: Word Frequency Counter.

Found this useful? Share it with a friend who’s learning Python.