-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlessons.py
More file actions
1636 lines (1392 loc) · 66.5 KB
/
Copy pathlessons.py
File metadata and controls
1636 lines (1392 loc) · 66.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
# lessons.py — full version with detailed explanations and extra examples
# Each topic: level, text, code, exercise, answer, explain_answer
lessons = {
# ----------------------------- Beginner level -----------------------------
"Getting Started (print, input, comments)": {
"level": "Beginner",
"text": """print() displays output to the screen -- it's how a Python program
"talks" to whoever is running it. You can pass it multiple values
separated by commas, and it prints them separated by a space by default.
input() pauses the program and waits for the user to type something and
press Enter. The critical thing to know: input() ALWAYS returns a
string, even if the user types a number. If you need a number, you must
convert it yourself with int() or float() -- forgetting this is one of
the most common beginner mistakes (e.g. trying to do age + 1 when age is
still the string "25" instead of the number 25).
Comments start with # and are ignored when the code runs. They exist
purely for humans reading the code -- to explain *why* something is done
a certain way, not just *what* it does (the code itself already shows
what it does). Everything after a # on a line is a comment, and nothing
is required to follow any particular format.""",
"code": """# This is a comment -- it has no effect on the program
print("Hello, world!")
# print() can take multiple values, separated by commas
print("Sum:", 2 + 2)
# You can control the separator and ending with sep and end
print("a", "b", "c", sep=" - ") # a - b - c
print("no newline after this", end=" -> ")
print("this continues on the same line")
# input() ALWAYS returns a string
age_text = input("Enter your age: ") # e.g. user types 25
# age_text is "25" (a string), not 25 (a number)
age = int(age_text) # now it's a real number
print("Next year you'll be", age + 1)""",
"exercise": "Using a single print() call with the sep argument, print the three words 'Python', 'is', 'fun' separated by a hyphen (-) instead of a space.",
"answer": """print("Python", "is", "fun", sep="-")""",
"explain_answer": "The sep argument controls what's placed between each value passed to print(), replacing the default single space."
},
"Variables and Data Types": {
"level": "Beginner",
"text": """Variables are containers for holding data. In Python you don't need to
declare a variable's type in advance; the language engine infers the type
from the value you assign (Dynamic Typing). This also means the same
variable name can be reassigned to a completely different type later --
x = 5 followed by x = "hello" is legal, even though it's rarely good style.
Common types:
- int: whole numbers (e.g. 0, 10, -5)
- float: decimal numbers (e.g. 3.14, -0.001)
- str: text string (wrapped in ' ' or " ")
- bool: logical values True/False
- NoneType: the "nothing" value for absence of a value (None)
Key notes:
- A variable name must start with a letter or underscore (_) and contain no spaces.
- Python's naming convention for variables is snake_case (e.g. total_price).
- A variable's value can be changed while the program runs (mutable binding
-- this refers to the name being reassignable, not the value itself
necessarily being mutable; see the Lists lesson for that distinction).
- Use type() to check a value's current type at any point, and isinstance()
when you need to check it inside a condition (e.g. if isinstance(x, int)).""",
"code": """# Defining and printing different data types
x = 10 # int
pi = 3.14159 # float
name = "Ali" # str
is_active = True # bool
nothing = None # NoneType
print(type(x), type(pi), type(name), type(is_active), type(nothing))
# Type casting
age_str = "25"
age = int(age_str) # '25' -> 25
height = float("1.82") # '1.82' -> 1.82
print(age + 5, height + 0.18)
# Multiple assignment
a, b, c = 1, 2.5, "hello"
print(a, b, c)""",
"exercise": "Create three variables name, age, and score; assign them a string, an integer, and a decimal respectively, and print them all with a single print call.",
"answer": """name = "Sara"
age = 22
score = 95.5
print(name, age, score)""",
"explain_answer": "Three variables of the appropriate types are defined and displayed together with one print call."
},
"Operators": {
"level": "Beginner",
"text": """Operators perform arithmetic, comparison, and logical operations.
Categories:
- Arithmetic: +, -, *, /, // (integer division), % (remainder), ** (power)
- Comparison: ==, !=, >, <, >=, <=
- Logical: and, or, not
- Compound assignment: +=, -=, *=, /=, ...
Note: division / always produces a float result; use // for integer division.
Be careful with // and negative numbers -- it rounds toward negative
infinity, not toward zero, so -7 // 2 is -4, not -3.
Python also supports chained comparisons, which read naturally: 0 < x < 10
checks both conditions at once, equivalent to (0 < x) and (x < 10).
A quick note on precedence: ** binds tighter than unary minus in front of
it (so -2 ** 2 is -4, not 4 -- the ** applies before the negation), and
arithmetic operators bind tighter than comparisons, which bind tighter
than logical operators. When in doubt, use parentheses -- they cost
nothing and make intent explicit.""",
"code": """a, b = 10, 3
print(a + b, a - b, a * b)
print(a / b, a // b, a % b, a ** b)
# Comparison
print(a == b, a != b, a > b, a <= b)
# Logical
x = 5
print(x > 0 and x < 10) # between 0 and 10
print(not (x == 5))""",
"exercise": "Define a number n such that its square is greater than 100, and show the check with a logical print.",
"answer": """n = 11
print(n ** 2 > 100)""",
"explain_answer": "With n=11, the square is 121, which is greater than 100; the comparison returns True."
},
"Conditionals (if/elif/else)": {
"level": "Beginner",
"text": """The conditional structure is used to make decisions based on conditions.
Multiple conditions can be chained with elif, and a default case can be
covered with else.
Note: indentation is meaningful in Python and defines blocks.
Any value can be used directly as a condition, not just True/False --
Python treats some values as "falsy" (0, 0.0, "", [], {}, None, and False
itself) and everything else as "truthy". So `if my_list:` is a common and
idiomatic way to check "is this list non-empty?" instead of writing
`if len(my_list) > 0:`.
For simple cases, Python also has a one-line conditional expression
(sometimes called a ternary): result = "Adult" if age >= 18 else "Minor".
This is meant for short, simple choices -- for anything with multiple
branches or side effects, a regular if/elif/else block is clearer.""",
"code": """x = int(7)
if x > 10:
print("Greater than 10")
elif x == 10:
print("Exactly 10")
else:
print("Less than 10")
# Nested and compound conditions
age = 18
if 0 <= age <= 120:
if age >= 18:
print("Adult")
else:
print("Minor")
else:
print("Invalid age")""",
"exercise": "Write a program that prints 'Fizz' if the input number is a multiple of 3, 'Buzz' if a multiple of 5, and 'FizzBuzz' if both; otherwise print the number itself.",
"answer": """n = 15
if n % 3 == 0 and n % 5 == 0:
print("FizzBuzz")
elif n % 3 == 0:
print("Fizz")
elif n % 5 == 0:
print("Buzz")
else:
print(n)""",
"explain_answer": "The shared case (multiple of both 3 and 5) is checked first to avoid conflicting with the later conditions."
},
"The for Loop": {
"level": "Beginner",
"text": """The for loop iterates over sequences (lists, strings, range, etc.).
The range(start, stop, step) function is used to generate a numeric range
-- stop is never included (range(1, 6) produces 1, 2, 3, 4, 5), and step
defaults to 1 but can be negative to count downward (range(10, 0, -2)).
Two keywords change a loop's flow from within: break exits the loop
immediately (even if more items remain), and continue skips straight to
the next iteration without running the rest of the loop body.
A for loop is generally preferred over manually managing an index with a
while loop when you already know what you're iterating over (a sequence,
a range) -- it's shorter, and there's no separate counter variable to
accidentally forget to update.""",
"code": """# Iterating over a range
for i in range(1, 6):
print(i, end=" ") # 1 2 3 4 5
print()
# Iterating over a list
names = ["Ali", "Sara", "Reza"]
for name in names:
print("Hi", name)
# enumerate: getting index + value
for idx, name in enumerate(names, start=1):
print(idx, name)""",
"exercise": "Using for and range, compute and print the sum of the numbers 1 to 100.",
"answer": """total = 0
for i in range(1, 101):
total += i
print(total)""",
"explain_answer": "The loop iterates from 1 to 100, and at each step the value of i is added to total."
},
"The while Loop": {
"level": "Beginner",
"text": """The while loop repeats as long as its condition holds.
Watch out for infinite loops; the condition must move toward False during execution.
The most common bug with while loops is forgetting to update the variable
the condition depends on -- if nothing inside the loop changes it, the
condition never becomes False and the loop runs forever.
Like for loops, while supports break (exit immediately) and continue
(skip to the next check of the condition). A common pattern is
while True: combined with a break inside, used when the exit condition is
easier to check partway through the loop body than at the very top:
while True:
answer = input("Continue? (y/n): ")
if answer == "n":
break""",
"code": """i = 5
while i > 0:
print(i)
i -= 1
print("Boom!")""",
"exercise": "Use while to print the even numbers less than 10.",
"answer": """i = 0
while i < 10:
print(i)
i += 2""",
"explain_answer": "Starting from zero and incrementing by 2 at a time, only even numbers less than 10 are printed."
},
"Strings": {
"level": "Beginner",
"text": """Strings are sequences of characters, and they are immutable -- once
created, a string's characters can't be changed in place (s[0] = "X" is
an error). Any "modification" (like .upper() or .replace()) actually
returns a brand-new string, leaving the original unchanged.
Common operations:
- Indexing and slicing (s[0], s[-1], s[2:5])
- Slicing also accepts a step: s[::2] takes every second character, and
s[::-1] is a common idiom for reversing a string entirely.
- Concatenation with + and repetition with *
- Key methods: upper/lower/strip/replace/split/join/startswith/endswith
- Formatting: f-strings, format() -- see the String Formatting In Depth
lesson for controlling decimal places, alignment, and padding.
Special characters use escape sequences: \\n (newline), \\t (tab), \\\\
(a literal backslash), \\" (a literal quote inside a double-quoted
string). Triple-quoted strings span multiple lines without needing \\n
and are also used for docstrings.""",
"code": """s = " Python 3.11 "
print(s.strip().upper()) # 'PYTHON 3.11'
print(s.replace("3.11", "3.12"))
# Slicing and indexing
t = "hello"
print(t[0], t[-1], t[1:4]) # h o ell
# f-string
name, score = "Sara", 97
print(f"Hello {name}, score={score}")""",
"exercise": "Take a string and count how many times the letter 'a' appears in it.",
"answer": """text = "banana"
count_a = text.count("a")
print(count_a)""",
"explain_answer": "The count method on a string counts the occurrences of the given substring."
},
"Lists": {
"level": "Beginner",
"text": """Lists are mutable collections -- unlike strings, a list's contents can
be changed in place after creation (nums[0] = 99 is valid).
Key methods:
append, extend, insert, pop, remove, index, sort, reverse, copy
Slicing operations like list slicing are also supported, the same way as
with strings (nums[1:3], nums[::-1], etc.).
Negative indices count from the end (nums[-1] is the last element), which
is often more convenient than computing len(nums) - 1 by hand.
An important distinction: nums.copy() creates a shallow copy -- a new
list object with the same elements. For a list of simple values (numbers,
strings) this behaves exactly like an independent copy. But if the list
contains other mutable objects (like nested lists), both the original and
the copy would still point to those same inner objects -- a detail that
matters more once you're working with nested data structures.""",
"code": """nums = [3, 1, 4]
nums.append(1)
nums.extend([5, 9])
nums.sort()
print(nums) # [1,1,3,4,5,9]
# Shallow copy
b = nums.copy()
b.pop()
print(nums, b)""",
"exercise": "Create a list of grades and compute its average.",
"answer": """scores = [18, 17.5, 19, 20]
avg = sum(scores) / len(scores)
print(avg)""",
"explain_answer": "sum gives the total, and dividing by the list's length computes the average."
},
"Tuples and Sets (Tuple/Set)": {
"level": "Beginner",
"text": """Tuples are like lists but "immutable" -- once created, their contents
can't be changed (no append, no item assignment). Because they're
immutable, tuples are hashable and can be used as dictionary keys or set
elements, which regular lists cannot (a list can't be a dict key -- this
is a common early error: "unhashable type: 'list'").
Tuples are also what's behind multiple assignment: a, b = 1, 2 is
actually unpacking a tuple (1, 2) into two variables.
Sets remove duplicates automatically and support set-theory operations:
union (|), intersection (&), difference (-), and symmetric difference (^).
Like tuples, sets require their elements to be hashable -- so a set can
contain numbers, strings, or tuples, but not lists or other sets.
Membership testing (x in my_set) is also significantly faster on a set
than on a list, since sets are backed by a hash table rather than a
plain sequence.""",
"code": """t = (1, 2, 3)
# t[0] = 10 # error: a tuple cannot be modified
s1 = {1, 2, 3, 3}
s2 = {3, 4}
print(s1) # {1,2,3}
print(s1 | s2) # union
print(s1 & s2) # intersection
print(s1 - s2) # difference""",
"exercise": "Build a set from the list [1,1,2,2,3] so it contains only unique elements.",
"answer": """lst = [1,1,2,2,3]
unique = set(lst)
print(unique)""",
"explain_answer": "Building a set from a list removes duplicate values."
},
"Dictionaries (Dict)": {
"level": "Beginner",
"text": """A dictionary is a mapping from key to value. The key must be hashable
(such as str, int, tuple -- the same requirement sets have, and for the
same reason). Since Python 3.7, dictionaries also preserve insertion
order, so iterating over one visits keys in the order they were added.
Key methods: get, keys, values, items, update, pop.
A very common beginner mistake: accessing a missing key directly with
square brackets (user["email"]) raises a KeyError and crashes the
program if that key doesn't exist. Two safer alternatives:
- user.get("email") returns None instead of raising an error.
- user.get("email", "not provided") returns a custom default value
instead of None.
To check whether a key exists without retrieving its value, use
"email" in user, which returns True/False.
Dictionaries can be nested -- a value can itself be a dictionary (or a
list), which is how more complex, structured data is typically
represented in Python:
person = {
"name": "Sara",
"address": {"city": "Tehran", "zip": "12345"}
}
print(person["address"]["city"]) # accessing a nested value
Similar to list comprehensions, dictionaries also support a compact
comprehension syntax: squares = {n: n**2 for n in range(5)} builds
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16} in one line -- useful once you're
comfortable with basic dict operations.""",
"code": """user = {"name": "Ali", "age": 21}
print(user.get("name"))
user["country"] = "IR"
for k, v in user.items():
print(k, v)""",
"exercise": "Create a dictionary with the keys name and age, and increment the age value by 1.",
"answer": """person = {"name": "Sara", "age": 20}
person["age"] += 1
print(person)""",
"explain_answer": "The numeric value of the 'age' key is incremented by one, and the structure is printed."
},
"Reading Error Messages (Tracebacks)": {
"level": "Beginner",
"text": """When Python code fails, it prints a "traceback" -- and the most
important habit to build is reading it from the BOTTOM up, not the top
down. The very last line is almost always the one that matters most: it
names the exception type and gives a short message about what went
wrong. The lines above it just show the chain of function calls that
led there, which matters more once your programs get larger.
Common exception types you'll run into constantly:
- SyntaxError: the code isn't valid Python at all (a typo, a missing
colon, mismatched parentheses) -- the program can't even start running.
- NameError: you used a variable that was never defined (often a typo in
the variable's name).
- TypeError: an operation was used on a type it doesn't support (e.g.
"5" + 5 -- you can't add a string and an int directly).
- ValueError: the type is right, but the value itself doesn't make sense
for the operation (e.g. int("abc") -- "abc" isn't a valid number).
- IndexError: you tried to access a list position that doesn't exist
(e.g. my_list[10] on a 3-item list).
- KeyError: you tried to access a dictionary key that doesn't exist
(see the Dictionaries lesson for get() as the safer alternative).
- ZeroDivisionError: exactly what it sounds like -- dividing by zero.
Reading the last line of a traceback like TypeError: can only
concatenate str (not "int") to str tells you almost everything you
need: the type of mistake, and often which values were involved.""",
"code": """# Each of these lines, if uncommented one at a time, produces a
# different, instructive error. Try them one by one and read the
# LAST line of the traceback each time.
# print(total) # NameError: name 'total' is not defined
# print("Age: " + 25) # TypeError: can only concatenate str (not "int") to str
# print(int("twenty")) # ValueError: invalid literal for int() with base 10: 'twenty'
# my_list = [1, 2, 3]
# print(my_list[10]) # IndexError: list index out of range
# person = {"name": "Ali"}
# print(person["age"]) # KeyError: 'age'
# print(10 / 0) # ZeroDivisionError: division by zero
# A safe example that runs without error, for comparison:
print("No errors here!")""",
"exercise": "Without running it, identify what exception type this code would raise: numbers = [1, 2, 3]; print(numbers[5]). Then write code that safely prints the value at index 5 if it exists, or prints 'Not found' otherwise, using an if/else check with len().",
"answer": """numbers = [1, 2, 3]
if len(numbers) > 5:
print(numbers[5])
else:
print("Not found")""",
"explain_answer": "Checking len(numbers) > 5 before accessing index 5 avoids the IndexError entirely, since the condition guarantees the index exists before it's used."
},
# ----------------------------- Intermediate level -----------------------------
"Functions": {
"level": "Intermediate",
"text": """Functions are reusable units. Types of parameters:
- positional, keyword
- default values
- *args and **kwargs for a variable number of parameters
Return is used to hand back a result. If a function has no return
statement (or a bare return with no value), it returns None implicitly
-- this is a common source of confusion when a function's result is
printed and unexpectedly shows None.
A well-known Python trap: never use a mutable object (like a list or
dict) as a default parameter value. Default values are evaluated ONCE,
when the function is defined -- not each time it's called -- so a
mutable default gets silently shared and accumulated across every call
that doesn't pass its own value:
def add_item(item, basket=[]): # DANGEROUS
basket.append(item)
return basket
The safe pattern is to default to None and create the mutable object
inside the function body:
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
You can also require some arguments to be passed by keyword only, by
placing a bare * before them in the signature: def greet(name, *, formal=False).""",
"code": """def area(w, h=1):
return w * h
print(area(5, 2))
print(area(5)) # h defaults to 1
def show(*args, **kwargs):
print(args, kwargs)
show(1, 2, x=10, y=20)""",
"exercise": "Write a function that computes the average of a variable number of input numbers (use *args).",
"answer": """def avg(*nums):
return sum(nums) / len(nums)
print(avg(1,2,3,4))""",
"explain_answer": "*args collects the numbers into a tuple, and sum/len return the average."
},
"Variable Scope": {
"level": "Intermediate",
"text": """The LEGB Rule describes the order Python searches for a variable name:
Local (inside the current function), Enclosing (an outer function, if
this function is nested inside another -- see the Closures lesson),
Global (the module's top level), Built-in (Python's own names like len
or print). Python stops at the first scope where it finds the name.
A very common beginner error: assigning to a variable inside a function
automatically makes it local to that function, even if a global variable
with the same name exists. This means:
x = 10
def show():
print(x) # this reads the global x -- fine, no assignment happens
def broken():
print(x) # UnboundLocalError!
x = 5 # this line makes x local to the WHOLE function,
# including the print() line above it
To modify (not just read) a variable from an outer scope, you need an
explicit declaration: global for a module-level variable, nonlocal for
a variable in an enclosing function. Relying on global is generally
discouraged in real code -- passing values in as parameters and getting
results back via return is usually clearer and easier to test.""",
"code": """x = 10 # global
def outer():
x = 20 # enclosing
def inner():
nonlocal x
x += 1
print("inner x:", x)
inner()
print("outer x:", x)
outer()
print("global x:", x)""",
"exercise": "Write a function nested inside another function that uses nonlocal to increment a counter by one and print the new value.",
"answer": """def outer():
count = 0
def inc():
nonlocal count
count += 1
print(count)
inc()
outer()""",
"explain_answer": "With nonlocal, the enclosing function's local variable is modified, not a new one created."
},
"Error Handling (try/except)": {
"level": "Intermediate",
"text": """try/except is used to prevent a crash when an error occurs.
You can have multiple except blocks, along with else and finally blocks.
- Multiple except blocks let you handle different exception types
differently: except ValueError: ... except TypeError: ... A single
except block can also catch several types at once with a tuple:
except (ValueError, TypeError): ...
- else runs only if the try block completed with no exception at all --
useful for code that should run after success but that you don't want
wrapped in the try itself (so it isn't accidentally caught if IT fails).
- finally always runs, whether an exception occurred or not, and even if
the exception wasn't caught -- typically used for cleanup (like closing
a file or a network connection) that must happen no matter what.
Avoid a bare except: (with nothing after it) except as a last resort --
it catches everything, including errors you didn't anticipate and
genuine bugs in your own code, silently hiding problems that should
have been visible. Prefer catching the specific exception type(s) you
actually expect and know how to handle.""",
"code": """try:
x = int("12a")
print("OK")
except ValueError:
print("Invalid conversion")
else:
print("No error")
finally:
print("Always runs")""",
"exercise": "Convert user input to int; if it fails, print an appropriate message.",
"answer": """s = "123x"
try:
n = int(s)
print("Number:", n)
except ValueError:
print("Invalid number")""",
"explain_answer": "Whenever converting a string to a number isn't possible, a ValueError is raised and an appropriate message is printed."
},
"Reading and Writing Files": {
"level": "Intermediate",
"text": """with open(...) as f: safely opens a file and closes it automatically
when the block ends -- even if an error happens partway through, which
plain f = open(...) / f.close() doesn't guarantee (an exception between
the two lines would leave the file open).
Modes:
- 'r' read (default; errors if the file doesn't exist)
- 'w' write (creates the file if missing, ERASES existing content first)
- 'a' append (creates the file if missing, adds to the end otherwise)
- 'x' exclusive creation (errors if the file already exists -- useful to
avoid accidentally overwriting something)
- add 'b' for binary mode (e.g. 'rb', 'wb') when working with non-text
files like images
Reading a file line by line with for line in f: is memory-efficient for
large files, since it doesn't load the whole file at once. f.read()
loads everything into one string; f.readlines() loads everything into a
list of lines -- both are fine for small files but wasteful for huge ones.
It's good practice to pass an explicit encoding (usually "utf-8") when
opening text files, since the default can vary by operating system and
cause subtle bugs when a file is read on a different machine than it
was written on.""",
"code": """# Writing
with open("data.txt", "w", encoding="utf-8") as f:
f.write("Hello\\nPython")
# Reading
with open("data.txt", "r", encoding="utf-8") as f:
content = f.read()
print(content)""",
"exercise": "Create a file and write 3 lines to it; then read it and print the content.",
"answer": """lines = ["first\\n", "second\\n", "third\\n"]
with open("out.txt", "w", encoding="utf-8") as f:
f.writelines(lines)
with open("out.txt", "r", encoding="utf-8") as f:
print(f.read())""",
"explain_answer": "writelines writes several lines at once, then read reads back the entire content."
},
"Modules and Packages": {
"level": "Intermediate",
"text": """Every .py file is a module. A folder containing __init__.py is
considered a package (this file can be empty -- its presence is what
tells Python "treat this folder as an importable package").
import is used to reuse code. Use as for an alias and from to import a
specific member:
- import math -> math.sqrt(4)
- import math as m -> m.sqrt(4)
- from math import sqrt -> sqrt(4) directly, no prefix needed
A very common idiom you'll see at the bottom of many .py files:
if __name__ == "__main__":
main()
When a file is run directly (python script.py), Python sets its
built-in __name__ variable to "__main__". When that same file is
imported from somewhere else instead, __name__ is set to the module's
name. This idiom lets a file define reusable functions/classes AND have
its own "run this when executed directly" logic, without that logic
accidentally running just because someone imported it elsewhere.""",
"code": """import math as m
from random import randint
print(m.sqrt(16))
print(randint(1, 3))""",
"exercise": "Print today's date using the datetime module.",
"answer": """from datetime import date
print(date.today())""",
"explain_answer": "With from, only the needed member is imported, and then called."
},
"Object-Oriented Programming (basic class/inheritance)": {
"level": "Intermediate",
"text": """OOP is used to model real-world concepts. Key concepts: class, object,
attribute, method, inheritance.
__init__ is the constructor, called automatically when a new instance is
created (Dog("Rex")). self refers to the current instance -- it's the
mechanism by which a method accesses that particular object's own
attributes rather than some other instance's. self is always the first
parameter of a regular method, but Python passes it automatically; you
never write it yourself when calling the method.
There's a distinction between instance attributes (set inside __init__
via self.x = ..., unique to each object) and class attributes (defined
directly in the class body, shared by every instance unless a specific
instance overrides it).
Inheritance lets a class reuse and extend another class's behavior:
class Dog(Animal): makes Dog inherit everything Animal defines. Inside
a subclass, super().__init__(...) calls the parent class's constructor,
which is the standard way to extend (rather than completely replace)
the parent's setup logic.
Two "dunder" (double-underscore) methods worth knowing early:
__str__ controls what print(obj) or str(obj) displays, and __eq__
controls what obj1 == obj2 checks -- without defining __eq__, two
objects are only considered equal if they're literally the same object
in memory, even if all their attributes match.""",
"code": """class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return "Woof!"
d = Dog("Rex")
print(d.name, d.speak())""",
"exercise": "Write a Rectangle class with width and height attributes and an area method.",
"answer": """class Rectangle:
def __init__(self, w, h):
self.w = w
self.h = h
def area(self):
return self.w * self.h
r = Rectangle(3,4)
print(r.area())""",
"explain_answer": "__init__ sets up the attributes, and the area method returns the area."
},
"Recursion": {
"level": "Intermediate",
"text": """A recursive function is one that calls itself to solve a smaller
version of the same problem. Every recursive function needs:
- a base case that stops the recursion
- a recursive case that moves toward the base case
Caution: Python has a default recursion limit (usually 1000 calls deep);
very deep recursion raises a RecursionError. For simple counting or
summing tasks, a loop is often more efficient, but recursion is a natural
fit for problems with a repeating, self-similar structure (like tree
traversal).""",
"code": """def factorial(n):
if n <= 1: # base case
return 1
return n * factorial(n - 1) # recursive case
print(factorial(5)) # 120
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
print([fibonacci(i) for i in range(7)])""",
"exercise": "Write a recursive function that returns the sum of all numbers from 1 to n.",
"answer": """def recursive_sum(n):
if n <= 0:
return 0
return n + recursive_sum(n - 1)
print(recursive_sum(10))""",
"explain_answer": "The base case is n <= 0, returning 0; otherwise the function adds n to the sum of everything below it, until the base case is reached."
},
"Regular Expressions": {
"level": "Intermediate",
"text": """The re module matches patterns in text. Key functions:
- re.match: checks for a match only at the start of the string
- re.search: finds the first match anywhere in the string
- re.findall: returns all non-overlapping matches as a list
- re.sub: replaces matches with another string
Patterns are usually written as raw strings (r"...") so backslashes
aren't interpreted by Python itself. Common tokens: \\d (digit), \\w (word
character), \\s (whitespace), + (one or more), * (zero or more).""",
"code": """import re
text = "Order #1234 shipped on 2024-05-01, order #5678 pending"
# Find all order numbers
order_ids = re.findall(r"#(\\d+)", text)
print(order_ids) # ['1234', '5678']
# Check if the text contains a date-like pattern
match = re.search(r"\\d{4}-\\d{2}-\\d{2}", text)
print(match.group() if match else "No date found")
# Replace all digits with 'X'
print(re.sub(r"\\d", "X", "abc123"))""",
"exercise": "Use re.findall to extract every number from the string 'I have 3 cats, 12 fish, and 1 dog' and print the resulting list.",
"answer": """import re
text = "I have 3 cats, 12 fish, and 1 dog"
numbers = re.findall(r"\\d+", text)
print(numbers)""",
"explain_answer": "\\d+ matches one or more consecutive digits, so findall returns every number in the text as a list of strings."
},
"Raising Exceptions and Custom Exceptions": {
"level": "Intermediate",
"text": """Besides catching exceptions with except, you can raise them
yourself with raise — useful for rejecting invalid input early with a
clear error instead of letting the program fail confusingly later.
You can also define your own exception classes by inheriting from
Exception (or a more specific built-in exception). This lets calling
code catch your specific error type instead of a generic one.""",
"code": """class NegativeNumberError(Exception):
pass
def take_square_root(n):
if n < 0:
raise NegativeNumberError(f"Cannot take the square root of {n}")
return n ** 0.5
try:
take_square_root(-4)
except NegativeNumberError as e:
print("Caught:", e)
print(take_square_root(16))""",
"exercise": "Define a custom exception called EmptyListError, and write a function get_first(lst) that raises it if the list is empty; otherwise it returns the first element. Call it with an empty list inside a try/except and print the caught message.",
"answer": """class EmptyListError(Exception):
pass
def get_first(lst):
if not lst:
raise EmptyListError("The list is empty")
return lst[0]
try:
get_first([])
except EmptyListError as e:
print("Caught:", e)""",
"explain_answer": "The custom exception class carries a specific, descriptive name; raise triggers it manually when the list is empty, and except catches exactly that type."
},
"Type Hints": {
"level": "Intermediate",
"text": """Type hints (introduced in PEP 484) let you annotate the expected
types of function parameters and return values. Python remains
dynamically typed at runtime — hints are not enforced automatically —
but they make code more self-documenting and let editors/tools (like
mypy) catch type mistakes before running the code.
Syntax: `def f(x: int, y: str = "a") -> bool:`
For more complex types, use the typing module: List, Dict, Optional, Union.""",
"code": """from typing import List
def total_price(prices: List[float], tax_rate: float = 0.1) -> float:
subtotal = sum(prices)
return subtotal * (1 + tax_rate)
print(total_price([10.0, 20.0, 5.0]))
def greet(name: str) -> str:
return f"Hello, {name}!"
print(greet("Sara"))""",
"exercise": "Write a function named is_adult with a type-hinted parameter age: int and a return type of bool, that returns True if age is 18 or older. Call it with 20 and print the result.",
"answer": """def is_adult(age: int) -> bool:
return age >= 18
print(is_adult(20))""",
"explain_answer": "The `age: int` hint documents the expected parameter type, and `-> bool` documents the return type; Python still runs the function normally regardless of the hints."
},
"Virtual Environments and pip": {
"level": "Intermediate",
"text": """A virtual environment is an isolated, per-project Python installation,
so each project can have its own package versions without conflicting
with other projects or the system-wide Python.
Typical commands (run in your terminal, not inside a Python script):
- `python -m venv venv` — create a virtual environment named "venv"
- `venv\\Scripts\\activate` — activate it on Windows
- `source venv/bin/activate` — activate it on macOS/Linux
- `pip install <package>` — install a package into the active environment
- `pip freeze > requirements.txt` — save the exact installed versions
- `pip install -r requirements.txt` — install everything listed in that file
From within a running Python script, you can check whether you're
currently inside a virtual environment by comparing sys.prefix (the
active environment's path) to sys.base_prefix (the system Python's path);
they're equal outside a virtual environment and different inside one.""",
"code": """import sys
in_virtual_env = sys.prefix != sys.base_prefix
print("Running inside a virtual environment:", in_virtual_env)
print("Python executable:", sys.executable)""",
"exercise": "Write a script that prints whether the current interpreter is running inside a virtual environment (compare sys.prefix to sys.base_prefix).",
"answer": """import sys
print(sys.prefix != sys.base_prefix)""",
"explain_answer": "sys.prefix points to the currently active environment; sys.base_prefix points to the underlying system installation. They differ only when a virtual environment is active."
},
"Lambda Functions": {
"level": "Intermediate",
"text": """A lambda is a small, anonymous function defined in a single
expression -- no def, no name, no explicit return keyword (the
expression's value is returned automatically).
Syntax: lambda arguments: expression
Lambdas are most useful as a short callback passed directly into
another function (like sorted, map, or filter), where writing a full
def just for one throwaway use would be overkill. For anything more
than a one-liner, a regular function is clearer.""",
"code": """# A lambda equivalent to a regular function
square = lambda x: x * x
print(square(5)) # 25
# Typical use: as the 'key' for sorted()
people = [("Sara", 25), ("Ali", 19), ("Reza", 31)]
by_age = sorted(people, key=lambda person: person[1])
print(by_age)
# Typical use: with filter() and map()
nums = [1, 2, 3, 4, 5, 6]
evens = list(filter(lambda n: n % 2 == 0, nums))
doubled = list(map(lambda n: n * 2, nums))
print(evens, doubled)""",
"exercise": "Use sorted() with a lambda as the key to sort the list ['banana', 'kiwi', 'fig', 'apple'] by string length (shortest first), and print the result.",
"answer": """words = ["banana", "kiwi", "fig", "apple"]
by_length = sorted(words, key=lambda w: len(w))
print(by_length)""",
"explain_answer": "The lambda extracts each word's length, and sorted() uses that value to order the list without needing a separate named function."
},
"Unpacking and zip()": {
"level": "Intermediate",
"text": """Unpacking lets you assign the elements of a sequence to multiple
variables in one line. The star operator (*) collects "everything
else" into a list during unpacking.
zip() pairs up elements from two or more sequences, position by
position, stopping at the shortest one -- useful for looping over
multiple lists together instead of indexing them manually.""",
"code": """# Basic unpacking
a, b, c = [1, 2, 3]
print(a, b, c)
# Star unpacking: collect the "rest" into a list
first, *middle, last = [1, 2, 3, 4, 5]
print(first, middle, last) # 1 [2, 3, 4] 5
# zip(): looping over two lists together
names = ["Ali", "Sara", "Reza"]
scores = [85, 92, 78]
for name, score in zip(names, scores):
print(name, score)
# zip() also works to "transpose" pairs back apart
pairs = list(zip(names, scores))
names_again, scores_again = zip(*pairs)
print(names_again, scores_again)""",
"exercise": "Given prices = [10, 20, 30] and items = ['pen', 'book', 'bag'], use zip to print each item with its price, formatted like 'pen: 10'.",
"answer": """prices = [10, 20, 30]
items = ["pen", "book", "bag"]
for item, price in zip(items, prices):
print(f"{item}: {price}")""",
"explain_answer": "zip() pairs each item with its corresponding price by position, so the loop can print them together without manual indexing."
},
"String Formatting In Depth": {
"level": "Intermediate",
"text": """f-strings support a format spec after a colon for controlling how a
value is displayed -- decimal places, minimum width, alignment, and
thousands separators.
Common specs:
- :.2f -- fixed to 2 decimal places
- :5d -- pad to a minimum width of 5 characters (numbers)
- :<10 / :>10 / :^10 -- left / right / center align within width 10
- :, -- add thousands separators (e.g. 1,000,000)
- :05d -- pad with leading zeros to width 5""",
"code": """price = 1234.5
print(f"{price:.2f}") # 1234.50
print(f"{price:,.2f}") # 1,234.50
count = 7
print(f"{count:03d}") # 007
label = "Total"
print(f"{label:<10}|") # 'Total |'
print(f"{label:>10}|") # ' Total|'
print(f"{label:^10}|") # ' Total |'""",
"exercise": "Given a value pi = 3.14159265, print it rounded to 3 decimal places using an f-string format spec.",
"answer": """pi = 3.14159265
print(f"{pi:.3f}")""",
"explain_answer": "The .3f format spec rounds the float to exactly 3 digits after the decimal point."
},
# ----------------------------- Advanced level -----------------------------
"List Comprehensions": {
"level": "Advanced",
"text": """List comprehensions are a compact syntax for building a new list from
a sequence. A condition can also be added.
The general shape is [expression for item in iterable if condition],
which is equivalent to writing a for loop that appends each qualifying
(transformed) item to a new list -- just condensed onto one line.
Nested comprehensions (a comprehension inside another) are possible but
hurt readability quickly -- once you need more than one level of nesting
or the condition/expression gets complicated, a regular for loop with
clear variable names is usually easier for someone else (or future you)