THIS PAGE CONTAINS THE NOTES I TOOK ALONG THE COURSE; IT CONTAINS ALL THE EXAMPLE PROGRAMS, AND THE NECESSARY DOCUMENTATION (EXPLANATIONS) IS WRITTEN IN THE FORM OF COMMENTS.

INSTRUCTIONS TO UNPACK:

1- ALL THE CODE IS EMBEDDED INSIDE COMMENTS (”””), SO FOR YOU TO USE THE REQUIRED PART OF THE CODE, COPY THAT PART AND RUN IT IN IDE.

2- THE WHOLE PAGE CODE WON’T WORK AS A WHOLE BECAUSE IT CONTAINS MANY SMALL PROGRAMS FOR EACH TOPIC THAT YOU WILL BE LEARNING.

3- THE TWO PROGRAMS ARE DIFFERENTIATED WITH EITHER “OR” OR BY THE HEADINGS FOR THE NEW TOPIC OR PROGRAM (HEADINGS ARE DENOTED BY “#”.

GOOD LUCK WITH THE COURSE

FROM: MUSLIH DURRANI

"""
name = input("What is your name? ").strip().title()  # or you can write it like this

# remove whitespace from str
# name = name.strip()

# capitalize user's name
# name= name.capitalize()   it only capitalises the first letter it can't capitalize the second name if there is one
# name = name.title()  # it capitalises no matter how many names there are

#  Split user's name into first and last last name based on the whitespace between the name
first, last = name.split(" ")

print(f"Hello, {last}!")

# elif stands for else if, which stops the repeatition of if statements elif will only be initiated if the above if statement is wrong.

 # loop
i = 3
while i != 0:
    print("meow!")
    i = i - 1

or

i = 0
while i <= 2 :
print("meow!")
i += 1

for i in [0, 1, 2]: 
    print("meow!")

for _ in range(3): # it is more efficient cux if there is a  million you can just write million. And it prints whatever you need from 1 not 0 for u but backend it starts from 0.
    print("meow!") # u can use _ instead of i or any variable name not important

print("meow!\\n" * 3, end="") # end="" we override the absolute \\n in the print syntax and shortened the line like it ends with our \\n

while True:
    n = int(input("What's n?"))
    if n < 0:
        continue
    else:
        break

for _ in range(n):
    print("meow!")

    or
    
while True:
    n = int(input("What's n?"))
    if n > 0:

        break

for _ in range(n):
    print("meow!")

def main():
    number = get_number()
    meow(number)

    def get_number():
        while True:
            n = int(input("What's n? "))
            if n > 0:
                break

    def meow(n):
        for _ in range(n):

            main()
 u can use range only for numbers like if n stands for a number u cant use it in lists. 
 
students = ["Hermoine", "Harry", "Ron"]
 print(students[0])
print(students[1])
print(students[2]) 
or
for student in students:
    print(student)

range doesnt take students it takes number if i use len function which will
 represent the number of values inside students

students = ["Hermoine", "Harry", "Ron"]
for i in range(len(students)):
    print(
        students[i]
    )  # if i write i, students[i] in print function it will write the the serial numbers for each name by default it will start with 0 but if we write i+1 then will start with 1.
       # i can write any variable like student instead if i doesnt matter

# dict or dictionaries are used to associate values with other values
students = {
    "Hermoine": "Gryffindor",
    "Harry": "Gryffindor",
    "Ron": "Gryffindor",
    "Draco": "Slytherin",
}
# unlike lists here you can print anythin from dictionary by the words or the keys here the hermoine, harry etc are keys and gryffindor etc are values so u can print the values by their keys.
# print(students["Harry"])
# by default for loop for dict prints keys but we can manipulate it to print both
# sep=", " changes the separation syntax for print function.
for student in students:
    print(student, students[student], sep=", ")

# students is a list and there are dicts in it
students = [
    {"name": "Hermoine", "house": "Gryffindor", "patronus": "Otter"},
    {"name": "Harry", "house": "Gryffindor", "patronus": "Stag"},
    {"name": "Ron", "house": "Gryffindor", "patronus": "Jack Russell Terrier"},
    {
        "name": "Draco",
        "house": "Slytherin",
        "patronus": None,
    },  # none is without commas cux there is a keyword None in python
]
for student in students:
    print(student["name"], student["house"], student["patronus"], sep=", ")

    
# bricks vertical
def main():
    print_column(3)

def print_column(height):   # or simply print("#\\n * height, end="") 
    for _ in range(height):
        print("#")

main()

# bricks horizontal
def main():
    print_row(4)

def print_row(width):
    print("?" * width)

main()

# error handling
# catching ValueError
try:
    x = int(input("What's x? "))
    print(f"x is {x}")
except ValueError:
    print("x is not an integer")
or

# catching NameError
# while and break loop is for retry
while True:
    try:
     x = int(input("What's x? "))

    except ValueError:
      print("x is not an integer")
    else:
         break
print(f"x is {x}")

or

def main():
    x = get_int()
    print(f"x is {x}")

def get_int():
    while True:
        try:
            return int(inout("What's x? "))
        except ValueError:
            print("x is not an integer")
main()

def main():
    x = get_int()
    print(f"x is {x}")

def get_int():
    while True:
        try:
            return int(input("What's x? "))
        except ValueError:
            pass

main()

# modules and libraries. random is library. import gets everything from libs.
# to what values arguments or sequences a function takes check it documentation on google or chagpt.
# random.choice gives u random value 
import random

coin = random.choice(["heads", "tails"])
print(coin)

# if we use (from) then we dont need to write random with each function

from random import choice

coin = choice(["heads", "tails"])
print(coin)

# random.randint(a, b)
from random import randint

number = randint(1, 10)
print(number)

# random.shuffle(x)
from random import shuffle

cards = ["jack", "queen", "king"]
shuffle(cards)
for card in cards:
    print(card)
    
# statistics lib
import statistics

print (statistics.mean([100, 90]))

# sys lib sys stands for system. argv accepts values in the terminal
# argv store the names or data i type for 0 it stores the cs50.py
import sys
try:
    print("hello, my name is", sys.argv[1])
except IndexError:
    print("Too few arguments")

or

import sys

if len(sys.argv) < 2:
    print("Too few arguments")
elif len(sys.argv) > 2:
    print("Too many arguments")
else:
   print("hello, my name is", sys.argv[1]) # now if i write my full name with space b/w it will say too many argu... but if i write full name in "" it'll display correctly and without quotes like normal full name
or

import sys

if len(sys.argv) < 2:
    sys.exit("Too few arguments")
elif len(sys.argv) > 2:
    sys.exit("Too many arguments")

print("hello, my name is", sys.argv[1])

# this time if i type many names it'll display em all, argv[1:] means that itll start at 1 till end instead of 1 so it doesnt print cs50.py.
# you can write argv[1:-1] to remove the last name
import sys

if len(sys.argv) < 2:
    sys.exit("Too few arguments")

for arg in sys.argv[1:]:
  print ("hello, my name is", arg)

#packages are 3rd party folders or somethin
# pip install cowsay in terminal is gonna install cowsay package, with pip u can install many things in python at terminal

import cowsay
import sys

if len(sys.argv) == 2:
    cowsay.cow("hello, " + sys.argv[1])
    cowsay.trex("hello, " + sys.argv[1])

# APIs
# pip install requests
# the following requests data of songs and stuff for a band and displays the data as json file and python converts it to a dict but the data is same.
# json package comes as default with python no need to install it, json.dump formats the json files.
# song&limit=1 in the link means give me only the name of one song, limit is changeable.

import json
import requests
import sys

if len(sys.argv) != 2:
    sys.exit()

response = requests.get("<https://itunes.apple.com/search?entity=song&limit=1&term=>" = sys.argv[1])
# print(json.dumps(response.json(), indent=2))

o = response.json()
for result in o["results"]:
    print(result["trackName"])

# creating our own libs for codes we use alot so we dont have to type again

def main():
    hello("world")
    goodbye("world")

def hello(name):
    print(f"hello, {name}")

def goodbye(name):
    print(f"goodbye, {name}")

if (
    __name__ == "__main__"
):  # this condition doesnt call main() if im calling this in another file .
    main()

# the following should be written in another file if i wanna call.

import sys

from cs50 import hello

if len(sys.argv) == 2:
    hello(sys.argv[1])

# unit tests
# calculator
def main():
    x = int(input("what's x? "))
    print("x squared is", square(x))

def square(n):
    return n * n

if __name__ == "__main__":
    main()

# write the following code in another file for testing the above calculator

from cs50 import square

def main():
    test_square()

def test_square():

    #if square(2) != 4:
    #    print("2 squared was not 4")
    #if square(3) != 9:
    #   print("3 squared was not 9")

    try:
        assert square(2) == 4
    except AssertionError:
        print("2 squared was not 4")

    try:
        assert (
            square(3) == 9
        )  # it will show assertionError if somethin is wrong it shows u the line that has a mistake.
    except (
        AssertionError
    ):  # try and except are to catch the errors. now it'll only show print... instead of showing AssertionError like in case with try and except.
        print("3 squared was not 9")

    try:
        assert square(-2) == 4
    except AssertionError:
        print("-2 squared was not 4")

    try:
        assert square(-3) == 9
    except AssertionError:
        print("-3 squared was not 9")

    try:
        assert square(0) == 0
    except AssertionError:
        print("0 squared was not 0")

if __name__ == "__main__":
    main()

    # pytest it needs less coding than normal unit tests, its a lib and i installed it.
# u will have to run pytest in terminal istead of python
# in the following code there is no try or except
from cs50 import square

def test_square():
    assert square(2) == 4
    assert square(3) == 9
    assert square(-2) == 4
    assert square(-3) == 9
    assert square(0) == 0

# if i divide the assertions now it it'll run them simultaneously, & a bug at one assertion wont stop the others from running.

import pytest
from cs50 import square

def test_positive():
    assert square(2) == 4
    assert square(3) == 9

def test_negative():
    assert square(-2) == 4
    assert square(-3) == 9

def test_zero():
    assert square(0) == 0

def test_str():
    with pytest.raises(TypeError):
        square("cat")

# testing hello().

def main():
    name = input("what is your name? ")
    print(hello(name))

def hello(to="world"):
    return f"hello, {to}"

if __name__ == "__main__":
    main()

# the following should be run on different program.

# U CAN TEST A WHOLE FOLDER THAT HAS FILES OF TEST BY PYTEST.
from cs50 import hello

def test_default():
    assert hello() == "hello, world"

def test_argument():
    assert (
        hello("Muslih") == "hello, Muslih"
    )  # if there is == intest then there must be return function not print in the code that is tested. u cant test print like this.
    # in that test folder u should also have an empty folder named __init__.py which will tell python that it is not only a module it is a package which means u should treat it as whole.

# sorting names alphabetical order
names = []

for _ in range(3):
    names.append(input("what's your name?"))

for name in sorted(names):
    print(f"hello, {name}")

# File I/O
name = input("what's your name? ")
# file = open("names.txt", "w") "w" writes and removes and when rerun the program it writes again, like it removes the old data
# file = open("names.txt", "a") # "a" stands for append
# file.write(f"{name}\\n")
# file.close()

# if u use with then u dont have to write file.close, it does it automatically
with open("names.txt", "a") as file:
    file.write(f"{name}\\n")

# reading from files

with open("names.txt", "r") as file:
    lines = file.readlines()

for line in lines:
    print("hello,", line.rstrip())

# or more elegant

with open("names.txt", "r") as file:
    for line in file:
        print("hello,", line.rstrip())

# if u want to take data from .txt file and do somethin to it in following ex (sorting), dpo it the following way make an empty list append data from file to it and thn do what u wanna do with it.
names = []

with open("names.txt") as file:
    for line in file:
        names.append(line.rstrip())

for name in sorted(names, reverse=True): # for descending order
    print(f"hello, {name}")

# .csv are files that stores more values,like if there are names and then their homes both are separated by commas.
with open("names.csv") as file:
    for line in file:
        name, house = line.rstrip().split(
            ","
        )  # it split lines by lookin for "," in file
        print(f"{name} is in {house} ")

# for sorted
students = []

with open("names.csv") as file:
    for line in file:
        name, house = line.rstrip().split(",")
        student = {"name": name, "house": house}
        students.append(student)

def get_name(student):
    return student["name"]

# we declared get_name() function to name and then we changed assigned it to key in sorted function so it'll sort the data on the student name.
for student in sorted(students, key=get_name):

# or if u dont wanna use get_name function, actually better is:
# for student in sorted(students, key=lambda student: student["name"]):
# the lambda says this function is nameless and we can assign any name to it we assigned student, which returns student['name']
    
    print(f"{student['name']} is in {student['house']}")

# if there are more commas and spaces in the file
# with DictReader u can still read even if the columns are flipped in the csv file.
import csv

students = []

with open("names.csv") as file:
    reader = csv.DictReader(file)
    for row in reader:
        students.append({"name": row["name"], "home": row["home"]})
        # or students.append(row)
        
for student in sorted(students, key=lambda student: student["name"]):
    print(f"{student['name']} is from {student['home']}")

# writing to csv file
import csv

name = input("What's your name?")
home = input("What's your home?")

with open("names.csv", "a") as file:
    writer = csv.writer(file)
    writer.writerow([name, home])

# DictWriter
import csv

name = input("What's your name?")
home = input("What's your home?")

with open("names.csv", "a") as file:
    writer = csv.DictWriter(file, fieldnames=["name", "home"])
    writer.writerow({"name": name, "home": home}) # the sequence here don't matter since we wrote the sequence as second argument (feildnames...) to DictWriter.

# code costume1.gif in terminal
# code costume2.gif as well
# PIL stands for pillow library
# loop=0 means infinite loop, 200 milliseconds

import sys
from PIL import Image

images = []
for arg in sys.argv[1:]:
    image = Image.open(arg)
    images.append(image)
images[0].save(
    "costumes.gif" , save_all=True, append_images=[images[1]], duration=200, loop=0
)
# in terminal run cs50.py costume1.gif costume2.gif 
# and then  code costumes.gif

# regexes
# re lib for regexes
# regular expressions confirms or sees if the user wrote the exact thing that has been asked.
# r means raw string which tells the python not to run backslash  its just an escape character. in re lib backslash is used to differentiate the seq..
import re

# NOTE: SWAP ALL THE BACKSLASH WORDS WITH BACKSLASH SYMBOLS, ITS JUST WRITTEN LIKE THIS TO AVOID ERRORS. AS ALL THIS IS IN COMMENTS.
# THERE MUST BE NO SPACES WHEN REPLACING THE BACKSLASH WORDS WITH SYMBOLS.

email = input("What's your email? ").strip()

# . in re means any character + means 1 or more (repetitions of .) characters * means 0 or more reps of "."
# {m} number of charcters that must be entered can be any number
# {m, n} between two number of characters
# * 0 or more characters
# ? 0 or 1 character
# + 1 or more characters
# ^ start of string
# $ end of string
# | stand for or
# [] (no commas or spaces inside to separate) set of characters, the characters inside can be accepted only.
# [^] complementing the set, it means if we write any letter or symbol in this, they should not be accepted.
# backslash w (any word character) is equal to [a-zA-Z0-9_] (alphanumeric and _)
# backslash W not a word character
# backslash d decimal digit
# backslash D not a decimal digit
# backslash s whitespace characters
# backslash S not a whitespace character
# re.IGNORECASE works the same as .lower but it doesnt change what the user writes it treats it case insensitive.
# warning: if u want any symbol like . ? + ^ $ then u must write backslash before that symbol to literally write that symbol, instead of using it for what it means in re.

if re.search(
    r"^(backslash w|backslash.)+@(backslash w+backslash .)?backslash w+backslash .(com|edu|gov|net|org|tr)$", email, re.IGNORECASE
):  # email.lower will change the uppercase to lowercase to avoid errors
    
    print("valid")
else:
    print("invalid")

# formating data; in this case names
# in re the (...) is used not only for grouping but u can capture or return the data inside.
# (?: ...) while this means i'm not returning i'm only grouping.
import re

name = input("What's your name? ").strip()

# Correct the regex by removing unnecessary escape
if matches:= re.search(r"^(.+), *(.+)$", name): # if u want to assign and use if, elif etc at same time, u must use := after the function name.
    name = matches.group(2) + " " + matches.group(1)

print(f"Hello, {name}")

# asking users for twitter urls and extracting the username from it.
# in .replace u replace the first argument by the second argument.
# re.sub also replaces but it takes more arguments and makes ur life easier

import re

url = input("URL: ").strip()

username = re.sub(r"^(https?://)?(www backslash.)?twitter backslash.com/", "", url)
print(f"Username: {username}")

# or more sophisticated

import re

url = input("URL: ").strip()

if matches := re.search(r"^https?://(?:www backslash.)?twitter backslash.com/([a-z0-9_]+)", url, re.IGNORECASE):
    print(f"Username:", matches.group(1))

# OBJECT ORIENTED PROGRAMMING

# tuple contains many values but returns them as 1 value, as in case of name, house is a tuple it is one value.
# tuple is almost a list, like u can call values in it by student[0...], but u can't change the values inside a tuple unlike a list.
# a tuple is returned either iside () or nothing, but there should be commas between the values.
# a list is returned by values inside [].
# dicts are mutable like lists unlike tubles.
# u call the contents in dict by strings inside quotes inside brackets unlike tuple and lists which does it by int inside brackets.

def main():   
    student = get_student()
    print(f"{student[0]} from {student[1]}")

def get_student():
    name= input("Name:")
    house = input("House: ")
    return (name, house)

if __name__ == "__main__":
    main()

   or 

def main():
    student = get_student()
    print(f"{student['name']} from {student['house']}")

def get_student():
   # student = {}
   # student["name"] = input("Name:")
   # student["house"] = input("House:")

   # return student
   # or
    
   name = input("Name: ")
   house = input("House: ")
   return {"name": name, "house": house}

if __name__ == "__main__":
    main()

# to know the type of a variable use type() function, for ex: print(type(name)) will tell u the type of name variable, like if it is a string or int etc.
# basically lists, ints, strings etc are classes and the values inside them are objects.
# u can also know the type like this: print(type(dict())), print(type([])), print(type(list())) etc.

# CLASSSES

# u call the contents in class using .
# u call the contents in dict by strings inside quotes inside brackets unlike tuple and lists which does it by int inside brackets.

class Student:
    ...

def main():
    student = get_student()
    print(f"{student.name} from {student.house}")

def get_student():
    # student = Student()
    # student.name = input("Name: ")
    # student.house = input("House: ")
    # return student
    # or
    name = input("Name: ")
    house = input("House: ")
    student = Student(name, house)
    return student

if __name__ == "__main__":
    main()

# or

class Student:
    def __init__(self, name, house, patronus):
        if not name:
            raise ValueError("Missing name")
        if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slyutherin"]:
            raise ValueError("Invalid house")
        self.name = name
        self.house = house
        self.patronus = patronus

    def __str__(self):
        return f"{self.name} from {self.house}"

    def charm(self):
        match self.patronus:
            case "Stag":
                return "🐴"
            case "Otter":
                return "🦦"
            case "Jack Russell Terrier":
                return "🐕‍🦺"
            case _:
                return "🪄"

def main():
    student = get_student()
    print("Expecto Patronum!")
    print(student.charm())

def get_student():

    name = input("Name: ")
    house = input("House: ")
    patronus = input("Patronus: ")

    return Student(name, house, patronus)

if __name__ == "__main__":
    main()
    
# or

# the properties or getter and setter are used to make the code more secure and efficient. It is used to make sure that the user inputs the correct data and the data is not changed by the user.
# the _ is used only so the python doesnt confuse it with getter and setter names, as both are same so _ removes the confusion.
# and the _ is used to make the variable private so the user can't change it. but u can change it just use _ before the variable name. just like u used self before the variable name, inside getter and setter.

class Student:
    def __init__(self, name, house):
       
        self.name = name
        self.house = house

    def __str__(self):
        return f"{self.name} from {self.house}"

    # Getter
    @property
    def house(self):
        return self._house
    
    # Setter
    @house.setter
    def house(self, house):
        if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slyutherin"]:
            raise ValueError("Invalid house")
        self._house = house

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, name):
        if not name:
            raise ValueError("Missing name")
        self._name = name

def main():
    student = get_student()
    print(student)

def get_student():

    name = input("Name: ")
    house = input("House: ")
    return Student(name, house)

if __name__ == "__main__":
    main()

# class methods
# Sorting Hat
# class methods are used to manipulate the class itself not the objects of the class.
# cls is used to refer to the class itself.

import random

class Hat:
    houses = ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]

    @classmethod
    def sort(cls, name):
        print(name, "is in", random.choice(cls.houses))

Hat.sort("Harry") 
 

# more sophisticated and designed code for the above code
# now everything for Student is in Student class.

class Student:
    def __init__(self, name, house):
        self.name = name
        self.house = house

    def __str__(self):
        return f"{self.name} from {self.house}"

    @classmethod
    def get(cls):
        name = input("Name: ")
        house = input("House: ")
        return cls(name, house)

def main():
    student = Student.get()
    print(student)

if __name__ == "__main__":
    main()

# inheritance
# inheritance is used to inherit the properties of a class to another class.

class Wizard:
    def __init__(self, name):
        if not name:
            raise ValueError("Missing name")
        self.name = name

class Student(Wizard):
    def __init__(self, name, house):
        super().__init__(name)
        self.house = house

class Professor(Wizard):
    def __init__(self, name, subject):
        super().__init__(name)
        self.subject = subject

wizard = Wizard("Albus")
student = Student("Harry", "Gryffindor")
professor = Professor("Severus", "Defense Against the Dark Arts")

# OPERATOR OVERLOADING
# operator overloading is used to change the behavior of operators like +, -, *, / etc.
# for ex: if u use + operator for strings it will concatenate them but if u use + operator for numbers it will add them.

class Vault:
    def __init__(self, galleons=0, sickles=0, knuts=0):
        self.galleons = galleons
        self.sickles = sickles
        self.knuts = knuts

    def __str__(self):
        return f"{self.galleons} Galleons, {self.sickles} Sickles, {self.knuts} Knuts"
    
# in the following code potter is assigned as self and weasley as other, as in left and right in total function.
# the __add__() is used to add the values of the two objects, so we don't have to menually add all the contents in them separately.
 
    def __add__(self, other):
        galleons = self.galleons + other.galleons
        sickles = self.sickles + other.sickles
        knuts = self.knuts + other.knuts
        return Vault(galleons, sickles, knuts)
    
potter = Vault(7, 21, 42)
print(potter)

weasley = Vault(3, 7, 21)
print(weasley)
 
total = potter + weasley
print(total)

# SETS
# sets are used to store unique values, like if u add a value to a set that is already in the set it will not be added again.
# sets also don't print duplicate values.

students = [
    {"name": "Hermoine", "house": "Gryffindor"},
    {"name": "Harry", "house": "Gryffindor"},
    {"name": "Ron", "house": "Gryffindor"},
    {"name": "Draco", "house": "Slytherin"},
    {"name": "Padma", "house": "Ravenclaw"},
]

houses = set()
for student in students:
    houses.add(student["house"])

for house in houses:
    print(house)

# GL0BAL VARIABLES

balance = 0

def main():
    print("Balance:", balance)
    deposit(100)
    withdraw(50)
    print("Balance:", balance)

def deposit(n):
    global balance
    balance += n

def withdraw(n):
    global balance
    balance -= n

if __name__ == "__main__":
    main()

#########

class Account:
    def __init__(self):
        self._balance = 0

    @property
    def balance(self):
        return self._balance

    def deposit(self, n):
        self._balance += n

    def withdraw(self, n):
        self._balance -= n

def mmain():
    account = Account()
    print("Balance:", account.balance)
    account.deposit(100)
    account.withdraw(50)
    print("Balance:", account.balance)

if __name__ == "__main__":
    main()

# CONSTANTS: python do not enforce constants but we can use them by writing the variable name in all capitals just to indicate to us or programmer it should not be touched.

# TYPE HINTS: tells u if the variables are strings, ints etc. it is not enforced by python but it is used to make the code more readable and understandable.
# use mypy to check the type hints in the code, if it has any errors or something, run mypy filename.py in terminal.
def meow(n: int):
    for _ in range(n):
        print("meow!")

n: int = int(input("Number: "))
meow(n)

# u can also use -> to show the return type of a function. for ex: def meow(n: int) -> None: or def meow(n: int) -> str: etc.
def meow(n) -> str:
    return "meow\\n" * n

n: int = int(input("Number: "))
meows: str = meow(n)
print(meows, end="")

# DOCSTRINGS: used to write the documentation of the code, like what the code does, what the function does etc.
# docstrings are written as comments (only single or double quotes), so when called it shows the explanation written as comments.

# to get HELP on  a specific function
# python -m pydoc filename.funcion name (in terminal)

# If you want to see the docstrings of all functions, classes, and methods in a file, use:
# import my_module
# help(my_module)

# for functions = print(function_name.__doc__)
# for classes = print(class_name.__doc__)

def meow(n) -> str:
    '''
    Meow n times.

    :parameter n: number of meows
    :type n: int
    :raise ValueError: if not an int
    :return: A string of n meows, one per line
    :rtype: str
    '''
    return "meow\\n" * n

n: int = int(input("Number: "))
meows: str = meow(n)
print(meows, end="")

# -n in terminal means the number of times the meow will be printed.
# if u write -n 3 in terminal it will print meow 3 times, by the following code.
import sys

if len(sys.argv) == 1:
    print("Meow!")

elif len(sys.argv) == 3 and sys.argv[1] == "-n":
    n = int(sys.argv[2])
    for _ in range(n):
        print("Meow!")

else:
    print("Usage:  cs50.py")

# MORE COMPLICATED MECHANISMS
# ARGPARSE will accept in the terminal, the arguments i give it.
# help and description are used to give the user a hint of what the code does. when user writes -h or --help in terminal.

import argparse

parser = argparse.ArgumentParser(description="Meow like a cat.")
parser.add_argument("-n,default=1", help="Number of meows", type=int)
args = parser.parse_args()

for _ in range(args.n): # args.n is the integer that the user will give after -n and space in terminal, in the following code the defaulty is 1.
    print("Meow!")

# UNPACKING
# unpacking is used to unpack the values of a list, tuple or dict etc.
# * is used to unpack the values of a list.
# * unpacks the tuple partially, cant unpack completely.
# ** is used to unpack the values of a dict.

def total(galleons, sickles, knuts):
    return (galleons * 17 + sickles) * 29 + knuts

coins = [100, 50, 25]

print(total(*coins), "knuts") # we can unpack coins list directly using *, instead of writing coins[0], coins[1], coins[2] etc.

# DICTIONARY UNPACKING

# NOTE: if in arguments to any function we assign the default values of variables to 0, then we can skip them as it has a default value of 0.For ex: in following code we write def total(galleons=0, sickles=0, knuts=0):, now we can skip the values of galleons, sickles, knuts in coins dict(can be for list or anything) as they have a default value of 0.
# but if it dont have a default value then we must pass the values in the following case (dictionary), otherwise it will give errors.

def total(galleons, sickles, knuts):
    return (galleons * 17 + sickles) * 29 + knuts

coins = {"galleons": 100, "sickles": 50, "knuts": 25}

print(
    total(**coins), "knuts"
)  # we can unpack coins dict directly using **, instead of writing coins["galleons"], coins["sickles"], coins["knuts"] etc.

# *args, **kwargs
# *args is used to accept multiple or variable number of values in a function, called positional arguments. (it accepts only values).
# **kwargs is used to accept multiple or variable number of names and values in a function, called named arguments. (it accepts both keys and values).

# u can name the args and kwargs anything u want, but the * and ** are necessary.
# u can also use *args and **kwargs together in a function.

def f(*args, **kwargs):
    print("Positional:", args)

f(100, 50, 25)

def f(*args, **kwargs):
    print("Named:", kwargs)

f(galleons=100, sickles=50, knuts=25)

# OR

def main():
    yell("This", "is", "CS50")

def yell(*words): # *words is used to accept variable values in a function.   
    uppercased = []
    for word in words:
        uppercased.append(word.upper())
    print(*uppercased) # the * is to unpack the list, so it prints the list without the brackets and commas.

if __name__ == "__main__":
    main()
    

# MAP
# map is used to apply a function to all the values in a list or tuple etc. we didnt write () with str.upper because we are not calling the function we are just passing it to map. it will call the function itself.
def main():
    yell("This", "is", "CS50")

def yell(*words):
    uppercased = map(str.upper, words) 
    print(*uppercased)

if __name__ == "__main__":
    main()

    

# LIST COMPREHENSIONS
# list comprehensions are used to create a list from an existing list or tuple etc.

def main():
    yell("This", "is", "CS50")

def yell(*words):
    uppercased = [word.upper() for word in words]
    print(*uppercased)

if __name__ == "__main__":
    main()

    
# FILTERING WITH LIST COMPREHENSIONS
students = [
    {"name": "Hermoine", "house": "Gryffindor"},
    {"name": "Harry", "house": "Gryffindor"},
    {"name": "Ron", "house": "Gryffindor"},
    {"name": "Draco", "house": "Slytherin"},
]

gryffindors = [
    student["name"] for student in students if student["house"] == "Gryffindor"]

for gryffindor in sorted(gryffindors):
    print(gryffindor)  

    
# filtering with FILTER
students = [
    {"name": "Hermoine", "house": "Gryffindor"},
    {"name": "Harry", "house": "Gryffindor"},
    {"name": "Ron", "house": "Gryffindor"},
    {"name": "Draco", "house": "Slytherin"},
]

gryffindors = filter(lambda s: s["house"] == "Gryffindor", students)

for gryffindor in sorted(gryffindors, key=lambda s: s["name"]):
    print(gryffindor["name"])

# DICTIONARY COMPREHENSIONS
# dictionary comprehensions are used to create a dictionary from an existing dictionary or list etc.

students = ["Hermoine", "Harry", "Ron"]

gryffindors = {student: "Gryffindor" for student in students}
print(gryffindors) 

# ENUMERATE 
# enumerate is used to give the index of the values in a list or tuple etc.
students = ["Hermoine", "Harry", "Ron"]

for i, student in enumerate(students):
    print(i + 1, student)

# GENERATORS
# generators are used to generate values one by one, instead of generating all the values at once.
# generators are used to save memory and time.
# generators are used to generate values in a loop, instead of generating all the values at once.

# yeild is used to generate values in a generator, instead of return.
# yield is used to return the values one by one in a generator.
# yield is used to pause the function and return the value to the caller.
# yield is used to resume the function from where it was paused.
# for ex: with yield u can output million sheeps one by one, instead of outputting all at once. but with return u can't output million sheeps cux the memory will be full.

def main():
    n = int(input("What's n? "))
    for s in sheep(n):
        print(s)

def sheep(n):
    for i in range(n):
        yield "🐏" * i
      
if __name__ == "__main__":
    main()

# THE END

import cowsay
import pyttsx3  # text to speech library

engine = pyttsx3.init()
this = "The End"
cowsay.cow(this)
engine.say(this)
engine.runAndWait()
"""