Project: Password Generator
1 · The lesson
readYou'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.
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:
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.
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.
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.
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!)
randomis for games and simulations.secretsis 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.
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.
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.
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
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
stringmodule's constants (ascii_letters,digits,punctuation) random.choicefor picking from a collection- Keyword-only arguments (the
*in the signature) str.maketransandtranslatefor character substitution/deletion- The
secretsmodule 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.