The Story That Explains Strings
But here is the catch: this is a museum piece. You are not allowed to change a bead once it's on the string. If you want to change 'H' to 'J' and get "JELLO", you must make a brand-new string from scratch. The original "HELLO" stays untouched forever.
That is exactly how Python strings work. They are sequences of characters that you can inspect, slice, and combine — but never modify in place. This property is called immutability, and it is one of the most important design decisions in Python.
A string (str) is a sequence of characters enclosed in quotes. Strings represent all text data in Python — names, messages, filenames, URLs, JSON, HTML, everything. Strings are the data type you will use most often in real programs.
1. A string is a sequence — an ordered collection of characters,
each at a numbered position (index). This is why indexing and slicing work.
2. A string is immutable — once created, its contents cannot
change. Every "modification" produces a brand-new string.
Creating Strings — Four Ways
Python offers four distinct ways to create a string. Each has a specific purpose — knowing when to use which is the first mark of a fluent Python developer.
# ─── FOUR WAYS TO CREATE STRINGS ────────────────────────
# 1. Single quotes
name = 'Alice'
quote = 'She said "Python is fun"' # double quotes inside — no escaping
# 2. Double quotes (identical behaviour)
message = "Hello, World!"
apostrophe = "it's a wonderful day" # apostrophe inside — no escaping
# 3. Triple quotes — multi-line, preserves formatting
poem = """Roses are red,
Violets are blue,
Python is awesome,
And so are you."""
# Used for docstrings too
def greet(name):
"""Return a greeting for the given name."""
return f"Hello, {name}!"
# 4. Raw strings — backslashes are literal
windows_path = r"C:\Users\Alice\new_folder"
regex_pattern = r"\d+\.\d+" # matches decimal numbers
print(name, message)
print(poem)
print(windows_path)
"" and '' are both perfectly valid strings — just with zero characters. They are falsy in boolean contexts, which is why if my_string: is the Pythonic way to check for "not empty".
Immutability — What It Really Means
Python strings are printed books. Once created, the sequence of characters inside the string never changes. Any operation that appears to "modify" a string is actually creating a brand-new string and leaving the original untouched.
This is the exact meaning of immutability: the internal state of the object cannot change after it is created.
Immutability in Python has a precise, technical meaning: the object's data cannot be modified after creation. You can still reassign the variable name to a different string — that just points the label at a new object. The original string remains unchanged and is eventually garbage-collected.
Immutability in Action — See It for Yourself
# ─── ATTEMPT TO MODIFY A STRING (WILL FAIL) ─────────────
word = "Python"
# Try to change the first character:
# word[0] = "J" → TypeError: 'str' object does not support item assignment
# ─── PROOF THAT STRING METHODS CREATE NEW STRINGS ───────
original = "hello"
uppercase = original.upper()
print(original) # hello — original unchanged!
print(uppercase) # HELLO — brand new string
print(id(original)) # e.g. 140234563 (memory address)
print(id(uppercase)) # e.g. 140234789 (different address!)
# ─── REASSIGNMENT IS NOT MUTATION ───────────────────────
name = "Alice"
print(id(name)) # e.g. 140000
name = name + " Smith" # looks like modification, but…
print(id(name)) # e.g. 140099 ← different object!
# The original "Alice" still existed briefly — just no name pointed at it.
# Python's garbage collector cleaned it up.
Mutable vs Immutable — Side by Side
| Property | Behaviour |
|---|---|
| Change in place | Impossible |
| Modification | Creates a new object |
| Memory address | Changes after each op |
| Can be dict key? | Yes |
| Thread-safe | Yes |
| Example op | s + "!" → new string |
| Property | Behaviour |
|---|---|
| Change in place | Yes, allowed |
| Modification | Same object, new content |
| Memory address | Same after ops |
| Can be dict key? | No |
| Thread-safe | No — needs locks |
| Example op | lst.append(1) — no new list |
Why Immutability Exists — The Design Rationale
This is not an arbitrary decision or an oversight — Python's designers made strings immutable on purpose. There are four powerful reasons behind it. Understanding these reasons makes you a better programmer and clarifies when to use strings vs lists.
Proof: String Interning in Action
# ─── PYTHON REUSES SHORT STRINGS AUTOMATICALLY ──────────
a = "hello"
b = "hello"
print(a is b) # True — same object in memory!
print(id(a) == id(b)) # True — same address
# ─── HASHABILITY: STRINGS AS DICT KEYS ──────────────────
user_ages = {
"alice": 30, # string keys — works!
"bob": 25,
"carol": 35
}
print(user_ages["alice"]) # 30 — O(1) lookup thanks to hashing
# TRY THIS WITH A LIST (fails):
# bad = {[1,2,3]: "value"} → TypeError: unhashable type: 'list'
Immutability costs a tiny amount of memory (creating a new string for each modification) but delivers huge wins in performance, safety, and predictability. For text data — which is inspected far more often than modified — the trade-off is overwhelmingly worth it. This is why every major language (Java, C#, Swift, JavaScript) also makes strings immutable.
String Indexing — Accessing Individual Characters
Because a string is a sequence, every character has a position number (index). Python uses zero-based indexing — the first character is at index 0, not 1. You can also use negative indices to count backwards from the end.
# ─── POSITIVE INDEXING (left to right, from 0) ──────────
word = "PYTHON"
print(word[0]) # P first character
print(word[1]) # Y second character
print(word[5]) # N sixth character (last)
# ─── NEGATIVE INDEXING (right to left, from -1) ─────────
print(word[-1]) # N last character
print(word[-2]) # O second to last
print(word[-6]) # P first character (also)
# ─── LENGTH FUNCTION ────────────────────────────────────
print(len(word)) # 6
# ─── OUT-OF-RANGE INDEXING CRASHES ──────────────────────
# word[10] → IndexError: string index out of range
# word[-10] → IndexError: string index out of range
# ─── PRACTICAL: LAST CHARACTER WITHOUT LEN() ────────────
name = "Alexander"
last_letter = name[-1] # r — shorter than name[len(name)-1]
first_letter = name[0] # A
print(f"{name} starts with '{first_letter}' and ends with '{last_letter}'")
In C or Java, getting the last character of a string requires word[word.length - 1] — ugly and error-prone. In Python, word[-1] just works. Use negative indices liberally — they make code cleaner and match how humans naturally think ("the second to last item").
String Slicing — Grabbing Substrings
Slicing extracts a portion of a string. The syntax is string[start:stop:step]. All three parts are optional, giving you incredible flexibility with minimal code.
stop = index to end (exclusive!) — default end
step = how many chars to skip — default 1
# ─── BASIC SLICING ──────────────────────────────────────
s = "Python Programming"
# 0123456789...
print(s[0:6]) # "Python" indices 0,1,2,3,4,5 (NOT 6!)
print(s[7:18]) # "Programming" indices 7 through 17
# ─── OMITTED START — DEFAULTS TO 0 ──────────────────────
print(s[:6]) # "Python"
# ─── OMITTED STOP — DEFAULTS TO END ─────────────────────
print(s[7:]) # "Programming"
# ─── OMITTED BOTH — COPIES THE STRING ───────────────────
print(s[:]) # "Python Programming"
# ─── NEGATIVE INDICES IN SLICES ─────────────────────────
print(s[-11:]) # "Programming" last 11 characters
print(s[:-12]) # "Python" everything except last 12
# ─── STEP: EVERY Nth CHARACTER ──────────────────────────
print(s[::2]) # "Pto rgamn" every 2nd character
print(s[0:6:2]) # "Pto" every 2nd, from 0 to 5
# ─── REVERSE A STRING (classic trick) ───────────────────
print(s[::-1]) # "gnimmargorP nohtyP" step = -1 reverses
# ─── SAFE: SLICING NEVER CRASHES ────────────────────────
print(s[100:200]) # "" — returns empty string, no error!
Slicing Cheat Sheet
| Pattern | Meaning | Example on "PYTHON" |
|---|---|---|
| s[a:b] | Characters from index a to b-1 | s[1:4] → "YTH" |
| s[:n] | First n characters | s[:3] → "PYT" |
| s[n:] | Everything from index n to end | s[3:] → "HON" |
| s[-n:] | Last n characters | s[-3:] → "HON" |
| s[:-n] | Everything except last n characters | s[:-3] → "PYT" |
| s[::-1] | Reverse the entire string | s[::-1] → "NOHTYP" |
| s[::2] | Every 2nd character | s[::2] → "PTO" |
| s[:] | Copy of the entire string | s[:] → "PYTHON" |
String Methods — The Essential Toolkit
Python strings come with 50+ built-in methods. You don't need to memorise them all — but the following categories cover 95% of real-world string work. Remember: every method returns a new string (the original is never modified).
Case Methods
# ─── CHANGING CASE ──────────────────────────────────────
text = "hello WORLD from Python"
print(text.upper()) # HELLO WORLD FROM PYTHON
print(text.lower()) # hello world from python
print(text.title()) # Hello World From Python
print(text.capitalize()) # Hello world from python
print(text.swapcase()) # HELLO world FROM pYTHON
# The original is unchanged
print(text) # hello WORLD from Python (unchanged!)
Whitespace & Cleaning Methods
# ─── STRIPPING WHITESPACE ───────────────────────────────
messy = " Hello, World! \n"
print(repr(messy.strip())) # 'Hello, World!' (both ends)
print(repr(messy.lstrip())) # 'Hello, World! \n' (left only)
print(repr(messy.rstrip())) # ' Hello, World!' (right only)
# Strip specific characters (not just whitespace)
url = "https://example.com/"
print(url.strip("/")) # https://example.com
print(url.rstrip("/")) # https://example.com
Search & Replace Methods
# ─── FINDING SUBSTRINGS ─────────────────────────────────
sentence = "Python is great, Python is easy"
print(sentence.find("Python")) # 0 — index of first match
print(sentence.find("Java")) # -1 — not found (safe)
print(sentence.rfind("Python")) # 17 — index of last match
# index() is like find() but raises ValueError if not found
print(sentence.index("is")) # 7
# sentence.index("Java") → ValueError!
# count occurrences
print(sentence.count("Python")) # 2
print(sentence.count(" ")) # 5 (spaces)
# ─── REPLACING SUBSTRINGS ───────────────────────────────
print(sentence.replace("Python", "Ruby"))
# Ruby is great, Ruby is easy
# Replace only the first N occurrences
print(sentence.replace("Python", "Ruby", 1))
# Ruby is great, Python is easy
Split & Join Methods — The Power Couple
# ─── SPLIT — string → list ──────────────────────────────
csv = "apple,banana,cherry,date"
fruits = csv.split(",")
print(fruits) # ['apple', 'banana', 'cherry', 'date']
# Split by whitespace (any amount)
sentence = " hello world from python "
words = sentence.split() # no argument = split on whitespace
print(words) # ['hello', 'world', 'from', 'python']
# Split with a limit
print(csv.split(",", 2)) # ['apple', 'banana', 'cherry,date']
# Split by lines
poem = "line 1\nline 2\nline 3"
print(poem.splitlines()) # ['line 1', 'line 2', 'line 3']
rsplit — Splitting from the Right
rsplit() is the mirror image of split(). It splits the string from the right side instead of the left. Without a maxsplit limit, both methods produce identical results — the difference only shows up when you use the maxsplit argument, which controls how many splits to perform.
| Behaviour | Result |
|---|---|
| Splits from | Left → Right |
| First N splits | Left side |
| Leftover ends up | On the right |
| Use when | You care about the start |
| Behaviour | Result |
|---|---|
| Splits from | Right → Left |
| First N splits | Right side |
| Leftover ends up | On the left |
| Use when | You care about the end |
# ─── SPLIT VS RSPLIT — NO MAXSPLIT ──────────────────────
# Without a limit, both give the same result
csv = "apple,banana,cherry,date"
print(csv.split(",")) # ['apple', 'banana', 'cherry', 'date']
print(csv.rsplit(",")) # ['apple', 'banana', 'cherry', 'date'] (identical)
# ─── THE DIFFERENCE APPEARS WITH MAXSPLIT ───────────────
# split — leftover stays on the RIGHT
print(csv.split(",", 1))
# ['apple', 'banana,cherry,date']
# rsplit — leftover stays on the LEFT
print(csv.rsplit(",", 1))
# ['apple,banana,cherry', 'date']
# With maxsplit=2
print(csv.split(",", 2))
# ['apple', 'banana', 'cherry,date']
print(csv.rsplit(",", 2))
# ['apple,banana', 'cherry', 'date']
# ─── REAL-WORLD USE 1: FILENAME & EXTENSION ─────────────
# A filename can contain dots — only the LAST one separates the extension
filename = "my.backup.v2.tar.gz"
# split fails — grabs the FIRST dot
print(filename.split(".", 1))
# ['my', 'backup.v2.tar.gz'] ← wrong split!
# rsplit gets it right — grabs the LAST dot
name, ext = filename.rsplit(".", 1)
print(f"Name: {name}, Extension: {ext}")
# Name: my.backup.v2.tar, Extension: gz
# ─── REAL-WORLD USE 2: FULL NAME PARSING ────────────────
# Assume last word is surname, rest is first/middle names
full_name = "Mary Jane Watson Parker"
first_names, surname = full_name.rsplit(" ", 1)
print(f"First names: {first_names}") # Mary Jane Watson
print(f"Surname: {surname}") # Parker
# ─── REAL-WORLD USE 3: FILE PATH — GET FILENAME ─────────
path = "/home/alice/projects/python/hello.py"
folder, file = path.rsplit("/", 1)
print(f"Folder: {folder}") # /home/alice/projects/python
print(f"File: {file}") # hello.py
# ─── REAL-WORLD USE 4: URL — GET LAST SEGMENT ───────────
url = "https://blog.example.com/2024/python/strings-tutorial"
base, slug = url.rsplit("/", 1)
print(f"Slug: {slug}") # strings-tutorial
Use rsplit(sep, 1) whenever the last occurrence of a separator matters — file extensions, surnames, URL slugs, log timestamps, module paths ("a.b.c.func".rsplit(".", 1) → ["a.b.c", "func"]). Whenever you catch yourself writing s.split(x)[-1] or using a negative index to grab the tail, rsplit(x, 1) is usually the cleaner intent — and slightly faster because Python stops as soon as it has what it needs.
# ─── JOIN — list → string ───────────────────────────────
# "separator".join(iterable)
words = ["Python", "is", "awesome"]
print(" ".join(words)) # Python is awesome
print("-".join(words)) # Python-is-awesome
print(", ".join(words)) # Python, is, awesome
print("".join(words)) # Pythonisawesome
# ─── SPLIT + JOIN PATTERN (common) ──────────────────────
# Standardise a messy string:
messy = " hello world from python "
clean = " ".join(messy.split()) # split on whitespace, rejoin with single space
print(repr(clean)) # 'hello world from python'
Checking Methods (all return bool)
# ─── STARTS/ENDS ────────────────────────────────────────
filename = "report_2024.pdf"
print(filename.startswith("report")) # True
print(filename.endswith(".pdf")) # True
print(filename.endswith((".pdf", ".doc"))) # True (tuple of options)
# ─── CONTENT CHECKS ─────────────────────────────────────
print("12345".isdigit()) # True — all digits
print("12.34".isdigit()) # False — the dot isn't a digit
print("hello".isalpha()) # True — all letters
print("hello123".isalnum()) # True — letters and digits
print(" ".isspace()) # True — only whitespace
print("Hello".isupper()) # False
print("HELLO".isupper()) # True
print("hello".islower()) # True
Padding & Alignment
# ─── PADDING FOR FIXED-WIDTH OUTPUT ─────────────────────
name = "Alice"
print(name.ljust(15, ".")) # Alice..........
print(name.rjust(15, ".")) # ..........Alice
print(name.center(15, "-")) # -----Alice-----
# zfill — pad numbers with leading zeros
print("42".zfill(5)) # 00042
print("-42".zfill(5)) # -0042 (sign kept at front)
Complete Methods Reference
| Category | Method | What It Does |
|---|---|---|
| Case | .upper() | ALL UPPERCASE |
| .lower() | all lowercase | |
| .title() | Title Case Each Word | |
| .capitalize() | First letter capital only | |
| .swapcase() | Invert all cases | |
| Cleaning | .strip() | Remove whitespace from both ends |
| .lstrip() / .rstrip() | Strip only left / right | |
| .replace(old, new) | Replace all occurrences | |
| Search | .find(sub) | Index of first match, -1 if not found |
| .index(sub) | Same as find, but raises error if not found | |
| .count(sub) | How many times substring appears | |
| "x" in s | Boolean membership check | |
| Split/Join | .split(sep, n) | String → list; first n splits from the left |
| .rsplit(sep, n) | String → list; first n splits from the right | |
| .splitlines() | Split by newlines | |
| sep.join(list) | List → string with separator | |
| Checks | .startswith(pre) | Does string start with pre? |
| .endswith(suf) | Does string end with suf? | |
| .isdigit() / .isalpha() | All digits / all letters? | |
| .isupper() / .islower() | All uppercase / all lowercase? | |
| Padding | .ljust(n) / .rjust(n) | Left / right pad to width n |
| .center(n) | Center within width n | |
| .zfill(n) | Zero-pad numbers |
Concatenation, Repetition & Membership
# ─── CONCATENATION WITH + ───────────────────────────────
first = "Hello"
last = "World"
greeting = first + ", " + last + "!"
print(greeting) # Hello, World!
# You cannot concatenate a string with a number directly
age = 25
# msg = "I am " + age → TypeError: can only concatenate str to str
msg = "I am " + str(age) # must convert first
# Or better: use an f-string (see next section!)
# ─── REPETITION WITH * ──────────────────────────────────
line = "-" * 40 # 40-character horizontal rule
print(line)
print("HA" * 3) # HAHAHA
print("> " * 5) # > > > > >
# ─── MEMBERSHIP WITH in / not in ────────────────────────
sentence = "The quick brown fox jumps over the lazy dog"
print("fox" in sentence) # True — case-sensitive substring check
print("cat" not in sentence) # True
print("FOX" in sentence) # False — capital F not present
# Case-insensitive check
print("FOX".lower() in sentence.lower()) # True
f-Strings — Modern String Formatting
f-strings (formatted string literals) are Python's mail-merge. You write curly braces {} containing any Python expression, prefix the string with the letter f, and Python fills in the values at runtime. They are fast, readable, and powerful. Introduced in Python 3.6, they've replaced every older formatting method.
Basic f-Strings
# ─── SIMPLE VARIABLE INSERTION ──────────────────────────
name = "Alice"
age = 30
print(f"Hello, {name}. You are {age} years old.")
# ─── EXPRESSIONS INSIDE THE BRACES ──────────────────────
print(f"Next year: {age + 1}") # arithmetic
print(f"Loud: {name.upper()}") # method calls
print(f"Initial: {name[0]}") # indexing
print(f"Full: {name} ({age})") # multiple vars
# ─── MULTI-LINE F-STRINGS ───────────────────────────────
report = f"""
── User Report ────────────────
Name: {name}
Age: {age}
Adult: {age >= 18}
"""
print(report)
Format Specifiers — The Real Power
After a colon inside the braces, you can specify how to format the value: padding, alignment, decimal places, number bases, and more.
| Spec | Meaning | Example | Result |
|---|---|---|---|
| .2f | 2 decimal places (float) | f"{3.14159:.2f}" | 3.14 |
| .0f | No decimals (round) | f"{3.99:.0f}" | 4 |
| , | Thousands separator | f"{1000000:,}" | 1,000,000 |
| ,.2f | Thousands + 2 decimals | f"{1234.5:,.2f}" | 1,234.50 |
| % | Percentage | f"{0.856:.1%}" | 85.6% |
| e | Scientific notation | f"{1234567:.2e}" | 1.23e+06 |
| b | Binary | f"{10:b}" | 1010 |
| o | Octal | f"{10:o}" | 12 |
| x / X | Hex (lower/upper) | f"{255:X}" | FF |
| 05d | Zero-pad to width 5 | f"{42:05d}" | 00042 |
| <10 | Left-align, width 10 | f"{'hi':<10}|" | "hi |" |
| >10 | Right-align, width 10 | f"{'hi':>10}|" | " hi|" |
| ^10 | Center, width 10 | f"{'hi':^10}|" | " hi |" |
# ─── FORMATTING IN ACTION ───────────────────────────────
# Money formatting
price = 12345.678
print(f"Price: ${price:,.2f}") # $12,345.68
# Percentage
pass_rate = 0.847
print(f"Pass rate: {pass_rate:.1%}") # 84.7%
# Padded table columns
print(f"{'NAME':<10}{'AGE':>5}{'SCORE':>10}")
print(f"{'-'*25}")
print(f"{'Alice':<10}{30:>5}{95.5:>10.1f}")
print(f"{'Bob':<10}{25:>5}{88.2:>10.1f}")
print(f"{'Carol':<10}{35:>5}{92.7:>10.1f}")
# Debugging trick: = shows both name and value
x = 42
print(f"{x=}") # x=42
# Date formatting
from datetime import datetime
now = datetime.now()
print(f"Today: {now:%Y-%m-%d}") # Today: 2026-07-09
print(f"Time: {now:%H:%M:%S}") # Time: 14:23:45
Old Formatting Styles — For Reference Only
| Style | Example |
|---|---|
| % formatting | "Hi, %s" % name |
| .format() | "Hi, {}".format(name) |
| Concatenation | "Hi, " + name |
| Style | Example |
|---|---|
| f-string | f"Hi, {name}" |
| Fast | ~3× faster than .format() |
| Readable | Variables inline, no positional args |
Escape Characters & Special Sequences
Sometimes you need to include characters in a string that would otherwise confuse Python — quotes inside quotes, newlines, tabs, or actual backslashes. That is where escape sequences come in. They all start with a backslash \.
| Escape | Meaning | Example | Output |
|---|---|---|---|
| \n | Newline | "Line1\nLine2" | Line1 Line2 |
| \t | Tab | "A\tB" | A B |
| \\ | Literal backslash | "C:\\Users" | C:\Users |
| \' | Single quote | 'it\'s' | it's |
| \" | Double quote | "he said \"hi\"" | he said "hi" |
| \r | Carriage return | Rare — mostly Windows | — |
| \0 | Null character | Low-level binary data | — |
| \u00e9 | Unicode char | "\u00e9" | é |
# ─── ESCAPING SPECIAL CHARACTERS ────────────────────────
print("Line 1\nLine 2\nLine 3") # three lines
print("Name:\tAlice") # tab between colon and Alice
print("She said \"hi\"") # She said "hi"
print('It\'s fine') # It's fine
print("C:\\Users\\Alice") # C:\Users\Alice
# ─── RAW STRINGS DISABLE ESCAPING ───────────────────────
print(r"C:\Users\new_folder") # C:\Users\new_folder (literal!)
print("C:\Users\new_folder") # C:\Users
# ew_folder — \n interpreted!
# ─── UNICODE CHARACTERS ─────────────────────────────────
print("caf\u00e9") # café
print("\N{GREEK SMALL LETTER PI}") # π
print("\U0001F600") # 😀 (emoji)
For Windows file paths, always use raw strings: r"C:\Users\Alice". Without the r, Python interprets \n, \t, \U etc. as escape sequences and your path silently breaks. Alternative: use forward slashes — they work on Windows too: "C:/Users/Alice".
Iterating Over Strings
Since strings are sequences, you can loop over them character by character using a for loop. This is one of the most common patterns in string processing.
# ─── BASIC ITERATION ────────────────────────────────────
word = "Python"
for letter in word:
print(letter)
# P
# y
# t
# h
# o
# n
# ─── ITERATION WITH INDEX (enumerate) ───────────────────
for i, letter in enumerate(word):
print(f"Index {i}: {letter}")
# ─── COUNTING VOWELS ────────────────────────────────────
sentence = "The quick brown fox jumps"
vowels = "aeiouAEIOU"
count = 0
for char in sentence:
if char in vowels:
count += 1
print(f"Vowel count: {count}") # Vowel count: 6
# ─── PYTHONIC VERSION USING SUM() ───────────────────────
count = sum(1 for c in sentence if c in vowels)
print(f"Vowel count: {count}") # 6
Complete Practice Program — String Toolkit
This program combines every concept from this tutorial: creation, indexing, slicing, methods, iteration, and f-strings — into one working script.
# string_toolkit.py — All string concepts in one program
def analyse_text(text):
"""Analyse a string and return a report dictionary."""
return {
"length": len(text),
"words": len(text.split()),
"upper": text.upper(),
"lower": text.lower(),
"reversed": text[::-1],
"first_word": text.split()[0] if text.strip() else "",
"last_word": text.split()[-1] if text.strip() else "",
"vowels": sum(1 for c in text.lower() if c in "aeiou"),
"digits": sum(1 for c in text if c.isdigit()),
"spaces": text.count(" "),
}
def format_report(text, stats):
"""Build a nicely formatted report using f-strings."""
line = "═" * 50
return f"""
{line}
STRING ANALYSIS REPORT
{line}
Input: "{text[:40]}{'...' if len(text) > 40 else ''}"
Length: {stats['length']:>5} characters
Words: {stats['words']:>5}
Vowels: {stats['vowels']:>5}
Digits: {stats['digits']:>5}
Spaces: {stats['spaces']:>5}
First word: {stats['first_word']}
Last word: {stats['last_word']}
Uppercase: {stats['upper'][:40]}
Reversed: {stats['reversed'][:40]}
{line}
"""
def main():
# Sample text — try changing this!
sample = "Python is a POWERFUL programming language, released in 1991."
# Analyse and display
stats = analyse_text(sample)
print(format_report(sample, stats))
# Bonus: check some properties using string methods
print(f" Contains 'Python'? {'Python' in sample}")
print(f" Starts with 'Py'? {sample.startswith('Py')}")
print(f" Ends with '.'? {sample.endswith('.')}")
# Clean and rebuild sentence — the split+join pattern
normalised = " ".join(sample.lower().split())
print(f"\n Normalised: {normalised}")
if __name__ == "__main__":
main()