# Build a Password Strength Checker (Python)

> Build a password strength checker in Python - length and character-class rules, a score, and helpful feedback - runnable in your browser on a set of sample passwords.


---

# Build a Password Strength Checker (Python)

We're going to build the thing that lives behind every "your password is weak" message you've ever seen. By the end you'll have a single Python function: hand it a password, get back a score, a label, and a short list of what to fix. It's the kind of code that ships in real signup forms, and it's small enough to finish in an afternoon.

Here's the good part: every block of code in this project runs right here in your browser. You don't install anything. You don't open a terminal. You hit run, you see output, you change a value, you run it again. That tight loop is the whole point - you'll feel each rule working before we glue them together.

## What you'll build

A password checker with four parts, built one phase at a time:

1. **The rules** - small functions that each answer one yes/no question: is it long enough? Does it have a digit? A symbol?
2. **A score** - combine those answers into a number from 0 to 5 and a human label like "weak" or "strong".
3. **Feedback** - tell the user exactly what to add, in plain words.
4. **A blocklist** - catch the passwords everyone already uses (`password`, `123456`) and reject them no matter how the rules score them.

Stack-wise this is pure Python - no libraries, no frameworks. We use the standard library and nothing else, on purpose: a strength checker that depends on a pile of packages is a strength checker nobody trusts. The whole thing fits on one screen when we're done.

## The shape of it

Here's how the pieces connect. Each phase fills in one box, and the last phase wires them into a single `check_password` call.

```mermaid
flowchart TD
    A[password text] --> B[Rules: length, lower, upper, digit, symbol]
    A --> E[Common-password blocklist]
    B --> C[Score 0 to 5 + label]
    C --> D[Feedback: what to fix]
    E --> D
    D --> F[result: score, label, feedback]
```

## What you'll learn

Real, reusable stuff, not toy stuff:

- How to break a fuzzy idea ("strong password") into testable rules.
- How to turn boolean checks into a score and a label without a tangle of `if` statements.
- How to write feedback a normal person can act on.
- Where a hobby checker stops and a production one begins - entropy, breach databases, and why length beats clever substitutions.

## How to work through it

| Phase | You'll end with |
|-------|-----------------|
| 1. The Rules | Functions that test each property of a password |
| 2. Turning Rules into a Score | A 0–5 score and a weak/ok/strong label |
| 3. Useful Feedback | A list of specific fixes per password |
| 4. Catching Common Passwords | A blocklist and the full `check_password` function |

Rough time: 60 to 90 minutes if you run every block and poke at the inputs. Don't skim - change the sample passwords and rerun. The bugs you'll hit (a rule that's too strict, a symbol set that misses `@`) are exactly the ones real teams argue about.

This is a **run-along** project: everything runs in your browser. Let's go build the rules.


---

# The Rules

Before we can score a password we have to decide what we're even measuring. "Strong" is a feeling. Code can't act on a feeling. So we turn it into a handful of yes/no questions, one function each:

- Is it long enough?
- Does it contain a lowercase letter?
- An uppercase letter?
- A digit?
- A symbol (anything that isn't a letter or digit)?

Five questions. Five tiny functions. Each one takes a password and returns `True` or `False`. That's the whole phase. Keeping them separate matters - later we'll count how many passed, and we'll tell the user which one failed. If we mashed them into one big check we couldn't do either.

## One rule at a time

Start with length. Pick a minimum and compare. People love arguing about the number; we'll use 8, because it's the lowest bar most real systems accept. You can change it later in one place.

```python runnable
def long_enough(password, minimum=8):
    return len(password) >= minimum

print(long_enough("cat"))          # too short
print(long_enough("correcthorse")) # plenty long
print(long_enough("12345678"))     # exactly 8 -> True
```

See how the function says nothing about whether `12345678` is a *good* password? It only answers the one question it was asked. That's deliberate. Each rule stays dumb and plain; the smarts come from combining them.

## Checking for a kind of character

Now the character classes. We need to know if a password contains *at least one* lowercase letter, uppercase letter, and digit. Python strings have methods that test a single character: `"a".islower()`, `"A".isupper()`, `"5".isdigit()`. We loop over the password and ask if *any* character passes.

`any(...)` is the right tool here. It walks a sequence and returns `True` the moment one item is true, `False` if none are. Compare that to writing a loop with a flag variable - `any` says what we mean in one line.

```python runnable
def has_lower(password):
    return any(c.islower() for c in password)

def has_upper(password):
    return any(c.isupper() for c in password)

def has_digit(password):
    return any(c.isdigit() for c in password)

print(has_lower("ABC123"))  # no lowercase -> False
print(has_upper("ABC123"))  # has A B C    -> True
print(has_digit("ABC123"))  # has 1 2 3    -> True
print(has_digit("abcdef"))  # no digits    -> False
```

## The tricky one: symbols

A symbol is "not a letter, not a digit". You could try to list every symbol - `!@#$%...` - but you'll forget some, and different keyboards have different ones. Don't enumerate.

**Your turn.** Write `has_symbol` yourself: it should return `True` if the password has at least one character that is neither a letter nor a digit. Fill in the function and hit Run - the checks underneath tell you whether it works. My version is in the next block whenever you want it.

```python runnable
def has_symbol(password):
    # Return True if `password` has at least one character that is
    # neither a letter nor a digit (that's a "symbol", including
    # punctuation and spaces).
    pass


# --- checks: fix your function until this prints "All good." ---
assert has_symbol("abc123") == False, f"'abc123' has no symbols, got {has_symbol('abc123')}"
assert has_symbol("abc-123") == True, f"the dash is a symbol, got {has_symbol('abc-123')}"
assert has_symbol("hi there") == True, f"the space counts as a symbol, got {has_symbol('hi there')}"
assert has_symbol("p@ssw0rd") == True, f"the @ is a symbol, got {has_symbol('p@ssw0rd')}"
print("All good.")
```

Stuck? Python has one string method that tells you if a character IS a letter-or-digit, in one shot. Negate it and you never have to list a single symbol.

### One way to write it

```python runnable
def has_symbol(password):
    return any(not c.isalnum() for c in password)

print(has_symbol("abc123"))    # all letters/digits -> False
print(has_symbol("abc-123"))   # the dash           -> True
print(has_symbol("hi there"))  # the space counts    -> True
print(has_symbol("p@ssw0rd"))  # the @              -> True
```

Define a symbol as *the absence of letter-and-digit-ness*. A character is a symbol if it isn't alphanumeric: `not c.isalnum()`.

One catch: a space is also "not alphanumeric", and so is a tab. For a password checker that's fine - a space is a perfectly good password character and many people use passphrases with spaces. So we'll count anything non-alphanumeric, including spaces, as a symbol. If you ever want to exclude spaces, that's a one-line change you can see in the code.

## All five rules together

Here's everything from this phase in one block, run against a small set of sample passwords so you can see the rules light up differently for each. This is the first time you'll feel the whole picture: weak passwords fail most rules, strong ones pass most.

Before you run this, guess which sample fails the most rules and which one fails none. Then check.

```python runnable
def long_enough(password, minimum=8):
    return len(password) >= minimum

def has_lower(password):
    return any(c.islower() for c in password)

def has_upper(password):
    return any(c.isupper() for c in password)

def has_digit(password):
    return any(c.isdigit() for c in password)

def has_symbol(password):
    return any(not c.isalnum() for c in password)

samples = ["cat", "password", "Password1", "P@ssw0rd!", "correct horse battery"]

for p in samples:
    print(f"{p!r:26}  long={long_enough(p)!s:5} lower={has_lower(p)!s:5} "
          f"upper={has_upper(p)!s:5} digit={has_digit(p)!s:5} symbol={has_symbol(p)}")
```

Run it. Look at the table. `"cat"` fails almost everything. `"P@ssw0rd!"` passes everything - even though, as we'll see in the last phase, it's a terrible password that any cracking tool guesses in seconds. That gap is the lesson of this whole project: passing the rules and being safe are not the same thing. The rules are a floor, not a guarantee.

## Try it yourself

Edit the `samples` list. Add your own passwords (don't use real ones). Watch which rules pass. A few things worth poking at:

- Add `"12345678"`. It's long but fails every character class except `digit`. The rules already tell you it's lopsided.
- Lower the `minimum` to 6 in `long_enough` and notice nothing else has to change - that's the payoff of one function per rule.

Next phase we stop reading a table of booleans by eye and let the code do the judging: we'll turn these five `True`/`False` answers into a single score and a label.


---

# Turning Rules into a Score

Last phase left us reading a row of `True`/`False` by eye. That's fine for five passwords and useless for a real form. We need one number that sums it all up, and one word that tells a tired user where they stand.

The plan is small: run all five rules, count how many passed, and call that count the score. Five rules means a score from 0 to 5. Then map ranges of that score to a label - `weak`, `ok`, `strong` - so the number means something without explanation.

## Counting the passes

Bring the rules along (every runnable block here starts fresh, so we redefine them - that's normal). Now write `score`: run all five rules against the password and return how many of them passed.

**Your turn.** This is the point of the phase, so have a go before you read on. Fill it in and hit Run: the checks underneath tell you whether it works. My version is in the next block whenever you want it.

```python runnable
def long_enough(password, minimum=8):
    return len(password) >= minimum

def has_lower(password):
    return any(c.islower() for c in password)

def has_upper(password):
    return any(c.isupper() for c in password)

def has_digit(password):
    return any(c.isdigit() for c in password)

def has_symbol(password):
    return any(not c.isalnum() for c in password)

def score(password):
    # Run all five rules against `password` and return how many
    # of them passed, as an integer from 0 to 5.
    pass


# --- checks: fix your function until this prints "All good." ---
assert score("cat") == 1, f"'cat' should score 1, got {score('cat')}"
assert score("password") == 2, f"'password' should score 2, got {score('password')}"
assert score("Password1") == 4, f"'Password1' should score 4, got {score('Password1')}"
assert score("P@ssw0rd!") == 5, f"'P@ssw0rd!' should score 5, got {score('P@ssw0rd!')}"
print("All good.")
```

Stuck? `True` and `False` are numbers in Python (`1` and `0`). There's a built-in that adds up a sequence of numbers without a loop or a counter variable.

### One way to write it

```python runnable
def long_enough(password, minimum=8):
    return len(password) >= minimum

def has_lower(password):
    return any(c.islower() for c in password)

def has_upper(password):
    return any(c.isupper() for c in password)

def has_digit(password):
    return any(c.isdigit() for c in password)

def has_symbol(password):
    return any(not c.isalnum() for c in password)

def score(password):
    rules = [long_enough, has_lower, has_upper, has_digit, has_symbol]
    return sum(rule(password) for rule in rules)

print(score("cat"))         # only lower passes -> 1
print(score("password"))    # long + lower      -> 2
print(score("Password1"))   # long+lower+upper+digit -> 4
print(score("P@ssw0rd!"))   # all five          -> 5
```

The trick is that `True` is `1` and `False` is `0` in Python, so you can `sum()` a list of booleans and get the count of true ones for free. No counter variable, no loop with `+= 1`. If you wrote a `for` loop with a counter that goes up by one each time a rule passes, that works exactly as well - `sum` just gets there in one line instead of four.

Putting the rules in a list and looping over them is what keeps `score` short. Want a sixth rule someday? Add it to the list. The `sum` line never changes. That's the difference between code that grows cleanly and code that turns into a wall of `if`.

## From number to word

A 0-to-5 number is precise but cold. People want a verdict. So we slice the range: 0–2 is `weak`, 3 is `ok`, 4–5 is `strong`. Where you draw those lines is a judgment call - this split says "you need more than the bare basics to be ok, and you need length plus variety to be strong". Adjust to taste; it's three numbers in one function.

Before you run this, guess which score is the first one to earn "strong". Then check.

```python runnable
def label(score_value):
    if score_value <= 2:
        return "weak"
    if score_value == 3:
        return "ok"
    return "strong"

for s in range(6):           # try every possible score 0..5
    print(s, "->", label(s))
```

Notice we looped `range(6)` instead of hand-checking each case. When a function maps inputs to outputs, the fastest way to trust it is to feed it every input it can ever get and read the whole table at once. Here there are only six, so we see all of them.

## Score and label together

Now the two halves meet. We compute the score, hand it to `label`, and print both for each sample. This is the heart of any password meter - the colored bar you've seen is doing exactly this underneath.

```python runnable
def long_enough(password, minimum=8):
    return len(password) >= minimum

def has_lower(password):
    return any(c.islower() for c in password)

def has_upper(password):
    return any(c.isupper() for c in password)

def has_digit(password):
    return any(c.isdigit() for c in password)

def has_symbol(password):
    return any(not c.isalnum() for c in password)

def score(password):
    rules = [long_enough, has_lower, has_upper, has_digit, has_symbol]
    return sum(rule(password) for rule in rules)

def label(score_value):
    if score_value <= 2:
        return "weak"
    if score_value == 3:
        return "ok"
    return "strong"

samples = ["cat", "password", "sunshine", "Password1", "P@ssw0rd!", "correct horse battery staple"]

for p in samples:
    s = score(p)
    bar = "#" * s + "." * (5 - s)   # a tiny text strength bar
    print(f"{p!r:32} [{bar}] {s}/5  {label(p) if False else label(s)}")
```

Run it. You get a little ASCII strength bar plus the verdict - the same information a real signup form shows, drawn in characters instead of pixels.

## A wrinkle worth seeing

Look closely at the output and something should bug you. `"correct horse battery staple"` - a long, memorable passphrase that's genuinely hard to crack - scores lower than `"P@ssw0rd!"`, a short string a cracking tool guesses fast. Our score rewards *variety of character types* and barely rewards *length*. Real cracking difficulty depends far more on length and unpredictability than on whether you remembered to add a `$`.

We won't rebuild the whole scoring model here - for a beginner checker, "more character classes plus a length floor" is a straightforward, common heuristic, and it's what most forms actually do. But keep that wrinkle in mind. In the final phase we'll add the one thing that fixes the worst false-positives: catching passwords that are common no matter how they score.

## Try it yourself

- Add `"aB3"` to the samples - short but uses three classes. Watch it score 3 (`ok`) despite being three characters. That's a real flaw in pure class-counting, and it's why the length rule is a *floor* you should weight heavily.
- Change `label` so 5 returns `"excellent"` and 4 returns `"strong"`. One edit, and every sample re-grades. That's the value of keeping the label logic in its own function.

Next phase: instead of a label, we tell the user what to actually *do* - "add a number", "make it longer" - built straight from the rules that failed.


---

# Useful Feedback

A score tells someone *where they are*. It doesn't tell them *what to do next*. "Your password is weak (2/5)" is the digital equivalent of a shrug. The useful version is: "Make it longer. Add an uppercase letter. Add a symbol." Three concrete actions, and the user is unstuck.

Good news - we already did the hard part. Each failed rule maps to exactly one fix. The length rule failed? "Make it longer." The symbol rule failed? "Add a symbol." This phase is mostly bookkeeping: pair every rule with the sentence the user should see when that rule fails, then collect the sentences for the rules that didn't pass.

## Pairing rules with advice

**Your turn.** This function is the point of the phase, so have a go before you read on. Build `feedback`: it should run the five rules and return the list of fix messages for whichever ones fail, in rule order, using the exact wording in the stub's comment. A password that passes everything gets an empty list back. Fill it in and hit Run - the checks underneath tell you whether it works. My version is in the next block whenever you want it.

```python runnable
def long_enough(password, minimum=8):
    return len(password) >= minimum

def has_lower(password):
    return any(c.islower() for c in password)

def has_upper(password):
    return any(c.isupper() for c in password)

def has_digit(password):
    return any(c.isdigit() for c in password)

def has_symbol(password):
    return any(not c.isalnum() for c in password)

def feedback(password):
    # Return a list of fix messages, one for each rule the password
    # fails. Use these exact messages, in this order:
    #   long_enough -> "Make it at least 8 characters long"
    #   has_lower   -> "Add a lowercase letter"
    #   has_upper   -> "Add an uppercase letter"
    #   has_digit   -> "Add a number"
    #   has_symbol  -> "Add a symbol like ! or @"
    # A password that passes everything gets an empty list back.
    pass


# --- checks: fix your function until this prints "All good." ---
assert feedback("Password1!") == [], f"a strong password needs no fixes, got {feedback('Password1!')}"
assert feedback("PASSWORD") == [
    "Add a lowercase letter", "Add a number", "Add a symbol like ! or @"
], f"got: {feedback('PASSWORD')}"
assert feedback("cat") == [
    "Make it at least 8 characters long", "Add an uppercase letter",
    "Add a number", "Add a symbol like ! or @"
], f"got: {feedback('cat')}"
print("All good.")
```

Stuck? Try pairing each rule function with its message in one list of `(rule, message)` tuples, then walk that list once.

### One way to write it

```python runnable
def long_enough(password, minimum=8):
    return len(password) >= minimum

def has_lower(password):
    return any(c.islower() for c in password)

def has_upper(password):
    return any(c.isupper() for c in password)

def has_digit(password):
    return any(c.isdigit() for c in password)

def has_symbol(password):
    return any(not c.isalnum() for c in password)

def feedback(password):
    checks = [
        (long_enough, "Make it at least 8 characters long"),
        (has_lower,   "Add a lowercase letter"),
        (has_upper,   "Add an uppercase letter"),
        (has_digit,   "Add a number"),
        (has_symbol,  "Add a symbol like ! or @"),
    ]
    return [message for rule, message in checks if not rule(password)]

print(feedback("PASSWORD"))     # missing lower, digit, symbol
print(feedback("Password1!"))   # passes everything -> []
```

The clean way to do this is a list of `(rule_function, message)` pairs. We walk the list, run each rule, and whenever a rule returns `False` we keep its message. Read the `feedback` function once and the whole design clicks. It's a list comprehension that keeps a message only when its rule fails (`if not rule(password)`). One pass, no flags, no nested `if`. Add a rule to the `checks` list and its advice shows up automatically - same pattern as the score in the last phase, which is the point: the rules are the single source of truth and everything hangs off them.

## When there's nothing to fix

Notice `feedback("Password1!")` returns an empty list. That's not a bug, it's information: an empty list means "nothing to fix". When we print results we'll translate that into a friendly "Looks good!" rather than showing the user a blank space. Handling the empty case on purpose is the difference between code that feels finished and code that feels half-done.

## Feedback for every sample

Let's run it over a spread of passwords and print each one's fixes as a tidy list. This is what you'd render under a password field as the user types.

Before you run this, guess which sample is the only one to print "Looks good!". Then check.

```python runnable
def long_enough(password, minimum=8):
    return len(password) >= minimum

def has_lower(password):
    return any(c.islower() for c in password)

def has_upper(password):
    return any(c.isupper() for c in password)

def has_digit(password):
    return any(c.isdigit() for c in password)

def has_symbol(password):
    return any(not c.isalnum() for c in password)

def feedback(password):
    checks = [
        (long_enough, "Make it at least 8 characters long"),
        (has_lower,   "Add a lowercase letter"),
        (has_upper,   "Add an uppercase letter"),
        (has_digit,   "Add a number"),
        (has_symbol,  "Add a symbol like ! or @"),
    ]
    return [message for rule, message in checks if not rule(password)]

samples = ["cat", "password", "PASSWORD", "Password1", "P@ssw0rd!", "correct horse battery staple"]

for p in samples:
    fixes = feedback(p)
    print(f"\n{p!r}")
    if not fixes:
        print("  Looks good!")
    else:
        for fix in fixes:
            print(f"  - {fix}")
```

Run it. Each password gets a little checklist. `"cat"` gets a stack of fixes; `"correct horse battery staple"` gets "Looks good!" because a long lowercase passphrase clears the length floor and the lowercase class - which matters more than scattering symbols around. The feedback is now something a real human can act on in five seconds.

## Folding score, label, and feedback together

We now have all three outputs - score, label, feedback. Here's a preview of the combined result, the shape we'll finish in the last phase. We return a dictionary so a caller (a web form, an API) can pick out whatever piece it needs.

```python runnable
def long_enough(password, minimum=8):
    return len(password) >= minimum

def has_lower(password):  return any(c.islower() for c in password)
def has_upper(password):  return any(c.isupper() for c in password)
def has_digit(password):  return any(c.isdigit() for c in password)
def has_symbol(password): return any(not c.isalnum() for c in password)

RULES = [
    (long_enough, "Make it at least 8 characters long"),
    (has_lower,   "Add a lowercase letter"),
    (has_upper,   "Add an uppercase letter"),
    (has_digit,   "Add a number"),
    (has_symbol,  "Add a symbol like ! or @"),
]

def label(score_value):
    if score_value <= 2: return "weak"
    if score_value == 3: return "ok"
    return "strong"

def check_password(password):
    passed = [rule(password) for rule, _ in RULES]
    s = sum(passed)
    fixes = [msg for (rule, msg), ok in zip(RULES, passed) if not ok]
    return {"score": s, "label": label(s), "feedback": fixes}

for p in ["password", "Password1", "P@ssw0rd!"]:
    print(p, "->", check_password(p))
```

We pulled the rule list out to a module-level `RULES` so both the score and the feedback read from the same source - no chance of them disagreeing. We run each rule once into `passed`, then reuse that list for both the count and the messages. Running the rules twice would be wasteful and, worse, a place for the score and the advice to drift apart.

## Try it yourself

- Reword a message. Change "Add a number" to "Throw in a digit or two". The fix flows straight to the output - copy lives in one place.
- Add a password with a leading space like `" hunter2"`. It passes `has_symbol` (the space) - a reminder that our symbol rule is permissive on purpose.

There's still a hole, and it's a big one. `"P@ssw0rd!"` scores 5 and gets "Looks good!" - but it's one of the most-guessed passwords on earth. No rule we've written can catch it, because it genuinely passes all of them. The fix isn't another rule; it's a blocklist. That's the final phase.


---

# Catching Common Passwords

Here's the uncomfortable truth the last three phases led us to: a password can pass every rule, score a perfect 5, and still be one an attacker tries first. `P@ssw0rd!` looks varied - upper, lower, digit, symbol, long enough - but it's a textbook pattern. Cracking tools have the common substitutions (`a` to `@`, `o` to `0`, `s` to `$`) built in. They guess it almost as fast as `password`.

No character-class rule can catch this, because the password genuinely satisfies them all. The only fix is to *know* which passwords are common and refuse them outright. That's a blocklist: a set of known-bad passwords that get rejected regardless of score.

## A small built-in blocklist

For a hobby checker, a short hardcoded set covers the worst offenders. Store them as a Python `set`, not a list - checking membership in a set is instant no matter how big it gets, while a list has to scan item by item. We also lowercase the password before checking, so `Password` and `password` both get caught.

Before you run this, guess which of the four calls below come back `True`. Then check.

```python runnable
COMMON = {
    "password", "123456", "123456789", "qwerty", "abc123",
    "111111", "12345678", "iloveyou", "admin", "letmein",
    "welcome", "monkey", "dragon", "sunshine", "password1",
}

def is_common(password):
    return password.lower() in COMMON

print(is_common("password"))   # True
print(is_common("Password"))   # True  (lowercased before checking)
print(is_common("hunter2"))    # False
print(is_common("QWERTY"))     # True
```

The lowercasing matters. Attackers don't care about your capitalization tricks - `Qwerty` is the same guess as `qwerty` to them. Folding case before the lookup means our one-line set already covers the obvious variants.

## Catching the substitution trick

`P@ssw0rd!` won't be in our set as written, because it's `password` wearing a disguise: `@` for `a`, `0` for `o`, and a trailing `!` for decoration. To catch it, undo the disguise before checking - swap the common substitute characters back to letters, drop decorative punctuation off the end, then look up the result in `COMMON`.

**Your turn.** This is the point of the phase, so have a go before you read on. Write `is_common` so it catches disguised passwords too, not just exact matches. Fill it in and hit Run - the checks underneath tell you whether it works. My version is in the next block whenever you want it.

```python runnable
COMMON = {"password", "123456", "qwerty", "admin", "letmein", "welcome", "iloveyou"}

def is_common(password):
    # Return True if `password` is a disguised version of something
    # in COMMON. Undo these substitutions before checking:
    #   @ -> a   0 -> o   1 -> i   3 -> e   $ -> s   5 -> s
    # Also strip these trailing decoration characters: !?.*#
    # Compare case-insensitively (lowercase before checking).
    pass


# --- checks: fix your function until this prints "All good." ---
assert is_common("P@ssw0rd!") == True, f"disguised 'password', got {is_common('P@ssw0rd!')}"
assert is_common("w3lc0me") == True, f"disguised 'welcome', got {is_common('w3lc0me')}"
assert is_common("hunter2") == False, f"genuinely uncommon, got {is_common('hunter2')}"
assert is_common("QWERTY") == True, f"already in COMMON (case-folded), got {is_common('QWERTY')}"
print("All good.")
```

Stuck on the substitution part? Python strings have a `.translate()` method that swaps characters according to a mapping table built with `str.maketrans(...)`.

### One way to write it

```python runnable
COMMON = {"password", "123456", "qwerty", "admin", "letmein", "welcome", "iloveyou"}

LEET = str.maketrans({"@": "a", "0": "o", "1": "i", "3": "e", "$": "s", "5": "s"})

def normalize(password):
    cleaned = password.lower().translate(LEET)
    return cleaned.strip("!?.*#")   # drop common trailing decoration

def is_common(password):
    return normalize(password) in COMMON

print(is_common("P@ssw0rd!"))  # disguised 'password' -> True
print(is_common("Welcome1"))   # 1 -> i ... still not 'welcome'; trailing 1 isn't stripped
print(is_common("w3lc0me"))    # -> 'welcome' -> True
print(is_common("hunter2"))    # genuinely not common -> False
```

This is a small `str.translate` table - we're not trying to reverse every trick, only the handful that catch the bulk of real-world cases. Run it. `P@ssw0rd!` is caught now. Notice `Welcome1` slips through this naive normalizer - a digit in the *middle* of the strip set isn't removed, and we don't strip trailing digits. That's a real limit, and it's the clear signal that hand-rolled normalization only gets you so far. Catching everything is what a real wordlist and a real breach database are for, which we'll get to.

## The full checker

Now we assemble everything from all four phases. The blocklist overrides the score: a common password is forced to `weak` and gets a blunt message, no matter how many character classes it has. Everything else flows through the score-and-feedback path from before.

```python runnable
def long_enough(password, minimum=8):
    return len(password) >= minimum

def has_lower(password):  return any(c.islower() for c in password)
def has_upper(password):  return any(c.isupper() for c in password)
def has_digit(password):  return any(c.isdigit() for c in password)
def has_symbol(password): return any(not c.isalnum() for c in password)

RULES = [
    (long_enough, "Make it at least 8 characters long"),
    (has_lower,   "Add a lowercase letter"),
    (has_upper,   "Add an uppercase letter"),
    (has_digit,   "Add a number"),
    (has_symbol,  "Add a symbol like ! or @"),
]

COMMON = {"password", "123456", "qwerty", "admin", "letmein", "welcome", "iloveyou", "sunshine"}
LEET = str.maketrans({"@": "a", "0": "o", "1": "i", "3": "e", "$": "s", "5": "s"})

def is_common(password):
    cleaned = password.lower().translate(LEET).strip("!?.*#")
    return cleaned in COMMON

def label(s):
    if s <= 2: return "weak"
    if s == 3: return "ok"
    return "strong"

def check_password(password):
    if is_common(password):
        return {"score": 0, "label": "weak",
                "feedback": ["This is a commonly used password - pick something unique"]}
    passed = [rule(password) for rule, _ in RULES]
    s = sum(passed)
    fixes = [msg for (rule, msg), ok in zip(RULES, passed) if not ok]
    if not fixes:
        fixes = ["Looks good!"]
    return {"score": s, "label": label(s), "feedback": fixes}

samples = ["cat", "password", "P@ssw0rd!", "Password1", "correct horse battery staple", "Tr0ub4dour&3xtra"]

for p in samples:
    r = check_password(p)
    print(f"\n{p!r}  ->  {r['label']} ({r['score']}/5)")
    for line in r["feedback"]:
        print(f"   - {line}")
```

Run it. This is the finished tool. `password` and `P@ssw0rd!` are both slapped down as common no matter how they'd otherwise score. `correct horse battery staple` and `Tr0ub4dour&3xtra` come through as strong with "Looks good!". You can drop `check_password` into any Python project as-is - it takes a string and returns a dictionary, the shape an API or web form wants.

## Wiring a real wordlist on your machine

Eight entries is a demo. Real attackers work from lists of millions of leaked passwords. On your own machine you'd load one from a file instead of hardcoding it. Grab a public list (the "rockyou" wordlist is the classic teaching example, and SecLists on GitHub collects many) and read it into the same `set`:

```python
def load_common(path):
    with open(path, encoding="utf-8", errors="ignore") as f:
        return {line.strip().lower() for line in f if line.strip()}

COMMON = load_common("rockyou.txt")   # a few hundred thousand to millions of entries
```

A `set` of a million strings still answers `in` in a microsecond, so this scales fine. The only thing to watch is memory - a huge wordlist loads entirely into RAM. For really large lists, production systems keep them in a database or a Bloom filter instead, but a `set` is the right call up to a few million entries.

## Where real systems go further

Our checker is upfront about what it is: a good floor and a friendly nudge. Production password checking does more, and it's worth knowing the names so you can reach for the right tool later.

| Idea | What it adds | Why ours doesn't |
|------|--------------|------------------|
| **Entropy / zxcvbn** | Estimates actual guessability - patterns, dates, keyboard walks, repeated chars | Class-counting can't see that `aaaaaaaa1A!` is bad |
| **Breach checks (HaveIBeenPwned)** | Tests if the exact password appeared in a real leak, via a privacy-preserving hash range query | We only know our own small list |
| **Length-weighted scoring** | Rewards long passphrases properly | Our score barely rewards length past the floor |
| **Rate limiting + hashing** | Protects the stored password even if your DB leaks | That's storage, not strength checking |

The single biggest upgrade, if you only do one: stop rewarding clever substitutions and start rewarding length. A 20-character lowercase passphrase beats an 8-character `P@$$w0rd` by a wide margin, every time. Tools like zxcvbn encode exactly that wisdom, and dropping one in is the natural next step past what we built.

## What you built

You started with a fuzzy idea - "strong password" - and ended with a function that scores it, labels it, tells the user what to fix, and refuses the obvious bad ones. Every piece runs, every piece is small, and the whole thing reads top to bottom in under sixty lines. That's a real tool, and the patterns in it - one function per rule, a list of rules as the single source of truth, a set for fast lookups, a dict as the return shape - are the same ones you'll reuse far beyond passwords.
