הבלוג של ינון פרק

טיפים קצרים וחדשות למתכנתים

עזבו אדומות, בואו נגלה מי הערים הירוקות בעזרת פייתון

07/09/2020

כולם מדברים רק על הערים האדומות ואני יכול להבין את זה: אדום זה סכנה, זה מפחיד, זה סגר, זה קורונה. אבל אולי דווקא אתם הטיפוסים שמעדיפים לשמוע שהעיר שלכם ירוקה? או לפחות לחפש איזו עיר ירוקה לברוח אליה?

אז משרד הבריאות יצא בסדר והשיק יופי של אתר עם API מאחוריו כדי שאנחנו נוכל לשחק עם הנתונים בצורה שאנחנו רוצים. כתובת האתר היא:

https://www.gov.il/he/Departments/DynamicCollectors/cities-ramzor

אבל אותנו יעניין ה API שמאחוריו. עכשיו מסיבה שאני לא לגמרי מבין שירותי ממשלה שכבר טורחים ומפתחים API לא ממש להוטים לספר עליו. מכל מקום אפשר להשתמש בכלי הפיתוח של הדפדפן כדי לראות את המידע שעובר ברשת בגלישה באתר וזה הוביל אותי לכתובת:

https://www.gov.il/he/api/DataGovProxy/GetDGResults

שכבר החזירה לי את הנתונים בתוך JSON מסודר. עכשיו עם המידע הזה נוכל להתקדם ולכתוב קוד פייתון כדי להוריד ולהציג את הנתונים בצורה ידידותית, ולגלות את רשימת כל הערים הירוקות.

המשך קריאה

פייתון, פסיק ושגיאות לא הגיוניות

05/09/2020

אפילו שפות תכנות שמתיימרות להיות נחמדות עלולות לדקור אתכם מאחור כשאתם לא מסתכלים (ואז לצחוק כשאתם שוכבים מדממים על הריצפה). הנה פייתון באקט של חוסר התחשבות מוחלט:

def first_letter(text):
    return text[0]

text = "I can see a mountain",
print(first_letter(text))

הצלחתם לנחש מה הקוד מדפיס? נריץ ונבדוק אם צדקנו:

$ python3 ouch.py 
I can see a mountain

את כל הטקסט??? פייתון...

הבעיה היא כמובן הפסיק בסוף שורת ההשמה:

text = "I can see a mountain",

הפסיק הזה גורם לפייתון להבין ש text הוא למעשה tuple של איבר אחד, והאיבר הראשון (והיחיד) ב tuple הוא המחרוזת שלנו. כשהעברנו את ה tuple לפונקציה first_letter היא הדפיסה את האיבר הראשון מה tuple, כלומר את כל הטקסט.

וזאת אולי הסיבה המרכזית שכדאי להוסיף Type Hints לקוד שלכם. הכתיב הבא:

def first_letter(text: str):
    return text[0]

text = "I can see a mountain",
print(first_letter(text))

כבר נותן לפייתון את כל הכלים לזהות שאנחנו מעבירים משהו אחר ממה שהתכוונו ואף להודיע לנו (כשנבקש כמובן) על הטעות:

$ python3 -m mypy ouch.py
ouch.py:5: error: Argument 1 to "first_letter" has incompatible type "Tuple[str]"; expected "str"
Found 1 error in 1 file (checked 1 source file)

(מאיפה לומדים על Type Hints אתם שואלים? נו, תשמחו לשמוע שכבר בניתי קורס וידאו בדיוק על זה ועל עוד המון פיצ'רים מדליקים של פייתון שיחסכו לכם זמן בפיתוח קוד בעולם האמיתי. הוא נקרא פייתון מתקדם וכולל אינספור טיפים לפיתוח פייתון לאנשים שכבר יודעים את הבסיס).

איך לשלוח אימייל מתוכנית פייתון

03/07/2020

נכון אימייל אולי פחות מגניב מ WhatsApp אבל עדיין קיים ואנשים מבוגרים וצעירים עדיין משתמשים בו די הרבה. בואו נראה איך לשלוח אימייל מ Python כדי שהסקריפטים שלכם יוכלו בקלות לדבר עם העולם.

המשך קריאה

היום למדתי: אפשר לשרשר השוואות ב Python

02/07/2020

אני יודע הבעיה אצלי, פשוט לא חשבתי לנסות כי לא האמנתי שזה יעבוד. זה לא עובד ב JavaScript. זה לא עובד ב Ruby. וככה פתאום, לגמרי בטעות הקלדתי את הביטוי הזה בתוך מפענח פייתון:

>>> 2 == 2 == 2

והוא ענה לי True.

אז הלכתי לכתוב לו שיר אהבה קצר מרוב אושר, ופייתון אפילו חייך אליי בחזרה:

x = 30
if 20 < x < 50:
    print("I \U00002764 Python")

איך קוד מונחה עצמים הרס לי את החיים

22/06/2020

בקורס Python היום ניסינו לעשות מירוץ שליחים בגירסת המתכנתים - כל אחד קיבל את המקלדת ל-5 דקות וניסה לכתוב קוד כשכל האחרים צועקים כל מיני רמזים (שלפעמים עזרו ולפעמים רק הפריעו). היה כיף ובסוף הצלחנו לפתור יחד תרגיל מ Advent Of Code, והפוסט היום ידבר על אחת השאלות שעלו בדרך, ועל ההבדל בין ביצועים שרואים לבין ביצועים אמיתיים. אבל קודם התרגיל.

המטרה שלנו היתה לספור כמה מספרים יש בטווח מספרים מסוים שעונים על שני תנאים: במספר צריכות להיות שתי ספרות צמודות זהות, וכל הספרות במספר צריכות להיות סדרה מונוטונית עולה, כלומר כל סיפרה צריכה להיות גדולה או שווה לספרה שמשמאלה.

הכיוון הראשון שעבד נראה כך:

input_range = range(145852, 616942)
total = 0

for candidate in input_range:
    flag_all_up = True
    flag_same = False
    cand_str = str(candidate)
    for i in range(5):
        if cand_str[i] == cand_str[i+1]:
            flag_same = True
        elif cand_str[i] > cand_str[i+1]:
            flag_all_up = False
            break

    if flag_all_up and flag_same:
        total += 1

print(total)

וזה עבד לא רע אבל לא נראה יפה במיוחד. הרבה קוד בתוך הלולאה ובלי שמות משמעותיים לכל פעולה. אז הלכנו לפצל את התוכנית ולהוסיף שתי פונקציות:

def all_up(candidate):
    cand_str = str(candidate)
    for i in range(5):
        if cand_str[i] > cand_str[i+1]:
            return False

    return True

def has_two_adjacent_digits(candidate):
    cand_str = str(candidate)
    for i in range(5):
        if cand_str[i] == cand_str[i+1]:
            return True
    return False

total = sum(
        map(lambda candidate: all_up(candidate) and has_two_adjacent_digits(candidate),
            input_range))
print(total)

כאן החלק האחרון של הקוד כבר נראה הרבה יותר יפה, אבל עלתה שאלה: האם שיפור המראה הזה לא קלקל לנו את התוכנית? הרי בגירסא הקודמת הלולאה שרצה על כל מספר בדקה את שני התנאים באותן 5 איטרציות, ואילו עכשיו לכל מספר אנחנו מפעילים שתי לולאות, אחת לכל פונקציית פרדיקט.

לפני שנפרסם את זמני הריצה בחזרה לכותרת הפוסט. דרך אחת להתמודד עם העיוות לכאורה כאן היא להוסיף את הדגלים מהגירסא הראשונה פנימה לתוך פונקציות הפרדיקט. אנחנו יודעים שפונקציה יחד עם משתנה State שווה אוביקט ולכן יצרתי מכל פרדיקט מחלקה:

class TwoAdjacentDigitsPredicate:
    def __init__(self):
        self.result = False

    def process(self, a, b):
        if a == b:
            self.result = True

class AllUpPredicate:
    def __init__(self):
        self.result = True

    def process(self, a, b):
        if a > b:
            self.result = False

def apply_predicates(val, *predicates):
    val = str(val)
    for i in range(5):
        for p in predicates:
            p.process(val[i], val[i+1])
    return all([p.result for p in predicates])

total = filter(lambda candidate: apply_predicates(
            candidate,
            TwoAdjacentDigitsPredicate(), AllUpPredicate()), input_range)

print(sum(1 if x else 0 for x in total))

ושוב יש לנו בסוף קוד נקי שרק מתאר איזה תנאים צריך לבדוק, ועיקר התוכנית מורכבת מהגדרת הפרדיקטים והפונקציה שמפעילה אותם.

יש לכם כבר ניחוש מה זמני הריצה לכל גירסא? הנה זה בא:

# First version - single function
python3 aoc.py  0.81s user 0.02s system 85% cpu 0.965 total
# Second version - multiple functions
python3 aoc.py  0.54s user 0.01s system 97% cpu 0.568 total
# Third version - Object Oriented
python3 aoc.py  2.52s user 0.04s system 83% cpu 3.072 total

אז כן כצפוי גירסת ה Object Oriented היא האיטית ביותר. ליצור אוביקט חדש כל פעם זו פעולה יקרה ובזבזנית. מפתיע לראות שהדבר שחשבנו שיאט אותנו (הרצה הלולאה של 5 איטרציות פעמיים) בכלל לא הפריע לפייתון והגירסא השניה היא גם הנקיה ביותר וגם המהירה ביותר.

הסיבה היא כנראה שיש מספיק מספרים בהם אפשר לברוח מוקדם יותר מהלולאות. דווקא בגירסא שמאפשרת לכל פרדיקט לבדוק רק כמה שהוא צריך אנחנו יכולים באמת לצאת מכל לולאה בזמן ולסיים את כל הבדיקות מהר יותר.

והמסקנה? בביצועים כמו בביצועים כדאי לבדוק כל דבר. תכנות מונחה עצמים כמעט תמיד יאט אתכם (אבל לפעמים הוא שווה את המחיר) ובכל מקרה מומלץ לא ליצור יותר מדי אוביקטים.

שלום עולם ב Python עם טוויסט

16/06/2020

אחד הדברים שהופכים את Python למלהיבה כל כך הוא התחביר שלה. השפה פשוטה לקריאה ולכתיבה ותוכניות Python נראות הרבה פעמים כמו קוד מספר לימוד, ולא כמו שאולי היינו מצפים מתוכנית מחשב שתיראה. זה כמעט סיפור באנגלית.

הדבר השני שאני אוהב ב Python הוא הפופולריות שלה. כשהשפה פופולרית זה אומר שכמעט כל מה שאתם רוצים לעשות מישהו כבר עשה לפניכם, והרבה פעמים המישהו הזה גם השאיר את הקוד שלו פתוח כספריה לשימוש הציבור. כך אנחנו יכולים להתמקד בבעיה ובאתגר הספציפי שלנו, בלי להיתקע על כתיבת קוד תשתית.

לכן בפוסט היום ארצה להציע תוכנית "שלום עולם" ראשונה בפייתון אבל עם טוויסט: במקום לכתוב על המסך "שלום עולם" אנחנו ניעזר בגוגל כדי ממש להגיד את המשפט. ולא רק "שלום עולם", עם העזרה של גוגל נוכל להגיד כל משפט ובכל שפה.

הנה התוכנית ב-4 שורות.

from gtts import gTTS

google_voice = gTTS(text="Hello world", lang='en')
google_voice.save('sound.mp3')

לפני שנעבור על הקוד שורה אחרי שורה קחו שתי דקות להקשיב לו. הכנסו לקישור: https://repl.it/@ynonp/AjarFatalCallbacks#main.py

שם תמצאו עותק של התוכנית אונליין באתר שנקרא Repl.it. אתר זה מאפשר לנו לכתוב תוכניות פייתון ולהריץ אותן בלי לצאת מהדפדפן ובלי שנצטרך להתקין שום דבר על המכונה. זה מאוד נוח כשלומדים שפה חדשה (למרות שבשביל ללמוד באמת אי אפשר לברוח מהתקנה גם על המחשב שלכם).

בתוך הרפליט תוכלו ללחוץ על כפתור Run כדי להריץ את התוכנית, ואז בצד שמאל תראו שנוצר קובץ חדש בשם sound.mp3. לחצו עליו ותשמעו את גוגל אומר Hello world. ואפשר גם לשחק עם הקוד: שנו את הטקסט בתוכנית מ Hello world לכל משפט אחר, הריצו מחדש ותוכלו לשמוע את גוגל אומר את המשפט החדש.

המשך קריאה

שני מסלולי בוטקמפ חדשים

25/05/2020

לא משנה כמה אנחנו אוהבים את האונליין, יש כיף בללמוד יחד עם עוד אנשים: כשיש לך את מי לשתף בפיתרון המדליק שכתבת, כשיש את מי לשאול כשנתקעים, כשיש פידבק ממרצה וגם מחברים על העבודה שלך - פידבק שעוזר להשתפר.

מי שעוקב פה יודע שאני הרבה זמן מחפש את הנוסחא שתאפשר ללמוד יחד בכיף גם מחוץ לכיתה, ועכשיו עם הקורונה מסביב החיפוש הזה הפך הרבה יותר חשוב. בשביל זה פתחתי היום רישום לשני מסלולי לימוד חדשים שאני חושב שיכולים לעניין גם אתכם.

המסלולים מיועדים לאנשים שרוצים ללמוד עם עוד חברים, רוצים תוכנית לימודים מסודרת, שמחים להשקיע שעות בכתיבת קוד ובשיחה עליו אבל בינתיים לא מצאו את המסגרת המתאימה. אנשים שיודעים שההשקעה היא חשובה ושהתמדה היא המפתח להצלחה, אבל כל פעם שמתיישבים ללמוד או לתרגל נתקעים מהר ומתקשים למצוא את הכוחות להמשיך לריב עם התרגיל עד שיעבוד.

המסלול הראשון מיועד ללימוד React עבור מתכנתי ווב שיודעים כבר JavaScript. הוא יימשך שלושה שבועות במהלכם אנחנו נעבוד יחד כל יום לפי תוכנית עבודה מסודרת. פעמיים בשבוע נקיים מפגשי אונליין בהם תציגו את העבודות שלכם ותוכלו להעלות שאלות, ובשאר השבוע נתקשר דרך קבוצת צ'ט. המסלול מבוסס על תכני הוידאו והתרגילים של קורס React כאן באתר, והוא מתאים גם אם ראיתם כבר את הסרטים אבל לא פתרתם עדיין את כל התרגילים והפרויקטים.

המסלול השני מיועד ללימוד Python והוא יעבוד במתכונת דומה. גם הוא יימשך שלושה שבועות ויכלול עבודה יומית לפי תוכנית מסודרת, לימוד דרך סרטי הוידאו המוקלטים באתר, קבוצת צ'ט לתרגול ושאלות כל השבוע ומפגשי אונליין לשיתוף ידע והצגת העבודות שלכם כדי שתוכלו לקבל פידבק מחברים וממני.

שני המסלולים לא מיועדים למי שרוצה "ללמוד ריאקט תוך כדי שינה", אלא למי שרוצה לשבת ללמוד דרך התמודדות עם בעיות ובליווי מנחה וקבוצת חברים. אני חושב ששניהם יהיו מהנים מאוד. עכשיו לפרטים הטכניים:

מסלול בוטקמפ ללימוד ריאקט ייפתח בתאריך 14.06.2020 למשך שלושה שבועות. מפגשי העבודה יתקיימו בימי ראשון ורביעי בין השעות 9:00 ל-11:00 בבוקר בתאריכים: 14/06, 17/06, 21/06, 24/06, 28/06, 01/07. מחיר הקורס 970 ש"ח וזה כולל חודש מנוי לתקופה של הקורס (אם אתם כבר מנויים החודש הזה יתווסף לכם אוטומטית לתקופת המנוי). הצטרפות לבוטקמפ בקישור: https://www.tocode.co.il/quickjoin2?term=15.

מסלול בוטקמפ ללימוד פייתון יפתח בתאריך 14.06.2020 למשך שלושה שבועות. מפגשי העבודה יתקיימו בימי ראשון ורביעי בין השעות 13:00 ל-15:00 בצהריים בתאריכים: 14/06, 17/06, 21/06, 24/06, 28/06, 01/07. מחיר הקורס 970 ש"ח וזה כולל חודש מנוי לתקופה של הקורס (אם אתם כבר מנויים החודש מתווסף אוטומטית לתקופת המנוי). הצטרפות לבוטקמפ פייתון בקישור: https://www.tocode.co.il/quickjoin2?term=16.

מוזמנים לשלוח מייל או להשאיר הודעה באתר לכל שאלה.

מימוש אלגוריתם BFS ב Python בגישת Object Oriented

14/04/2020

אלגוריתמים שקשורים לגרפים הם אחד הנושאים האהובים בראיונות עבודה ובחידות תכנות בכלל. אחד האלגוריתמים הכי בסיסיים נקרא BFS שזה ראשי תיבות של Breadth-First Search, ואנחנו משתמשים בו כדי לסרוק את הגרף. בעקבות שאלה מתלמיד חשבתי להקדיש פוסט להסבר של מה זה בכלל גרף, מה זה אומר לסרוק אותו וכמובן איך עובד אלגוריתם BFS עצמו. קחו קפה, תמצאו ספה נוחה ובואו נצא לדרך.

המשך קריאה

שתי שיטות ערבוב ב Python

03/01/2020

המודול random של Python מציע שתי דרכים לערבב אלמנטים ברשימה: הראשונה היא הפונקציה sample והשניה הפונקציה shuffle. בואו נראה את ההבדל ומתי נשתמש בכל שיטה.

בפייתון ברוב הפעמים שנרצה לשנות רשימה נשתמש במתודות של אוביקט הרשימה עצמו - לדוגמא arr.append, arr.reverse ו arr.clear. אחת הפקודות המעניינות של רשימות בהקשר זה היא [].sort שממיינת את הרשימה בסדר עולה:

arr = [10, 20, 7, 30] 
arr.sort()
arr

Out[23]: [7, 10, 20, 30]

והחברה שלה היא הפונקציה sorted שמקבלת רשימה ומחזירה רשימה חדשה ממוימת:

arr = [10, 20, 7, 30]
sorted(arr)
Out[30]: [7, 10, 20, 30]

הפקודה arr.sort שינתה את הרשימה, ולעומתה sorted(arr) לא שינתה את הרשימה המקורית ורק החזירה רשימה חדשה.

באותו האופן אבל בתחביר קצת שונה יש לנו שתי דרכים לערבב את האלמנטים ברשימה. כאן במקום להכניס את shuffle כפונקציה של מערך ואת shuffled כפונקציה גלובלית נפרדת בחרו ב Python לייצר שתי פונקציות שונות בתוך המודול random.

הפונקציה הראשונה random.shuffle מקבלת רשימה ומשנה אותה במקום, כלומר:

In [31]: arr = [10, 20, 7, 30]                                           
In [32]: random.shuffle(arr)                                             
In [33]: arr                                                             

Out[33]: [20, 30, 10, 7]

זה עובד בדיוק כמו המתודה sort של מערך, אפילו שהמערך עובר כפרמטר לפונקציה.

הפונקציה random.sample לעומתה יודעת להחזיר רשימה של "מדגם" בכל אורך מתוך מערך. הפרמטר k קובע את גודל המדגם. הנה כמה דוגמאות:

In [34]: random.sample(arr, k=2)                                         
Out[34]: [10, 7]

In [35]: random.sample(arr, k=3)                                         
Out[35]: [30, 20, 10]

In [36]: random.sample(arr, k=2)                                         
Out[36]: [10, 30]
In [37]: random.sample(arr, k=2)                                         
Out[37]: [7, 30]

זאת הסיבה שבתיעוד של random ממליצים לנו להשתמש בפונקציה זו אם אנחנו רוצים לקבל את איברי הרשימה בסדר אקראי בלי לפגוע ברשימה המקורית. כל מה שצריך זה להעביר בתור ערך של k את אורך הרשימה:

In [38]: random.sample(arr, k=len(arr))
Out[38]: [30, 7, 10, 20]

ומה לגבי זמן ריצה? לא מפתיע לגלות שהגירסא שמשנה את הרשימה In Place היא קצת יותר מהירה בעבודה על רשימות גדולות:

In [43]: a = [random.randint(0, 100) for _ in range(1_000_000)]          

In [44]: %timeit random.sample(a, k=len(a))                              
629 ms ± 24.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [45]: %timeit random.shuffle(a)                                       
538 ms ± 20.9 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

אז במי נבחר? כמו הרבה פעמים בתכנות התשובה הנכונה היא "זה תלוי" - אם יש לכם רשימה שערבוב האיברים בה הוא חלק מסדר של כמה פעולות שצריך לעשות ואחרי זה אף אחד לא יצטרך עוד את המידע אפשר להשתמש ב shuffle. לעומת זאת אם אתם צריכים לערבב חלק מרשימה שקיבלתם אבל יש עוד מקומות בקוד שעובדים עם אותו מידע סיכוי טוב שתעדיפו לא לשנות את המידע המקורי ולספוג את הפגיעה הקלה מאוד בזמן הריצה (ממילא כנראה לא תרגישו את זה).

על השגיאה got Future attached to a different loop ב Python

31/12/2019

הרבה פעמים בתכנות יש שגיאות שנראות ממבט ראשון לא קשורות לחיים, אבל אחרי שאנחנו מבינים מה הקוד שלנו באמת עושה אנחנו מגלים שהשגיאה היא תיאור מדויק של מה שקרה לא בסדר, ושל הדרך לתקן אותו.

כך השגיאה שבכותרת ששמחתי לפגוש היום בקוד פייתון שכתבתי. התחלתי עם גירסא פשוטה לפונקציה download הבאה שלוקחת נתיב ושומרת אותו לקובץ מקומי על המחשב באמצעות Python ו asyncio:

import aiohttp, asyncio, aiofiles

async def download(url, to):
    print(f"Download {url} to {to}")
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            if resp.status == 200:
                f = await aiofiles.open(to, mode='wb')
                await f.write(await resp.read())
                await f.close()

הפונקציה עובדת ואפילו אפשר להשתמש בה כדי להוריד כמה URL-ים במקביל, למשל הקוד הבא מוריד מספר פוסטים מהבלוג:

async def main():
    await asyncio.wait([
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-git-rm-vs-reset', 'post1.html')),
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-keep-on-learning', 'post2.html')),
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-python-memory-management', 'post3.html')),
        ])

asyncio.run(main())

אבל אתם יודעים איך זה כשלוקחים קוד דוגמא ומעבירים אותו לעולם האמיתי אנחנו צריכים להתחיל להלביש עליו גם בגדים של העולם האמיתי. במקרה של asyncio ותקשורת הבגדים האלה נקראים Throttling, שזה הרעיון שאנחנו לא רוצים להוציא יותר מדי בקשות רשת במקביל. גם בגלל שזה לא בריא למחשב שלנו, אבל בעיקר בגלל שזה לא בריא לשרת וגם לא מנומס. פה בטוקוד יש מנגנון אוטומטי שחוסם את כתובת ה IP שלכם לכמה שעות אם אתם שולחים יותר מדי בקשות במקביל (הכנסתי אותו אחרי שכמה טורקים הפילו לי את האתר פעם אחר פעם עם כלים אוטומטיים שחיפשו פירצות אבטחה).

בכל מקרה ובחזרה לפייתון חשבתי להשתמש ב asyncio.Semaphore כדי לשלוט בכמות הבקשות שנשלחות במקביל: כל בקשה לוקחת אסימון מהסמפור, ואם אין אסימון פנוי מחכים שבקשה אחרת תסיים ותחזיר אחד. הניסיון הראשון שלי לכתוב את זה נראה כך:

# DO NOT USE - CODE WITH BUG
import aiohttp, asyncio, aiofiles

throttle = asyncio.Semaphore(2)

async def download(url, to):
    async with throttle:
        print(f"Download {url} to {to}")
        async with aiohttp.ClientSession() as session:
            async with session.get(url) as resp:
                if resp.status == 200:
                    f = await aiofiles.open(to, mode='wb')
                    await f.write(await resp.read())
                    await f.close()

async def main():
    await asyncio.wait([
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-git-rm-vs-reset', 'post1.html')),
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-keep-on-learning', 'post2.html')),
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-python-memory-management', 'post3.html')),
        ])

asyncio.run(main())

והשגיאה לא איחרה לבוא:

Download https://www.tocode.co.il/blog/2019-12-git-rm-vs-reset to post1.html
Download https://www.tocode.co.il/blog/2019-12-keep-on-learning to post2.html
Task exception was never retrieved
future: <Task finished name='Task-4' coro=<download() done, defined at post.py:5> exception=RuntimeError("Task <Task pending name='Task-4' coro=<download() running at post.py:6> cb=[_wait.<locals>._on_completion() at /Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/asyncio/tasks.py:507]> got Future <Future pending> attached to a different loop")>
Traceback (most recent call last):
  File "post.py", line 6, in download
    async with throttle:
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/asyncio/locks.py", line 97, in __aenter__
    await self.acquire()
  File "/Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/asyncio/locks.py", line 496, in acquire
    await fut
RuntimeError: Task <Task pending name='Task-4' coro=<download() running at post.py:6> cb=[_wait.<locals>._on_completion() at /Library/Frameworks/Python.framework/Versions/3.8/lib/python3.8/asyncio/tasks.py:507]> got Future <Future pending> attached to a different loop

מה קרה כאן? ההודעה אומרת שיש לנו Future Object שמחובר ל Event Loop אחר. פה המקום להזכיר ש asyncio מאפשר לנו ליצור מספר Event Loops במקביל. כל פעם שאנחנו יוצרים Task או Future Object אותו אוביקט מחובר לאיזושהי Event Loop. הקוד הראשי:

asyncio.run(main())

יוצר את ה Event Loop הראשון במערכת ומריץ בתוכה את הפונקציה main.

בחזרה לשגיאה - אנחנו יכולים לראות שהיא הופיעה רק בקובץ השלישי שאנחנו מנסים להוריד, לכן בוודאות היא הגיעה מה Semaphore, וספציפית מהניסיון להמתין על ה Semaphore (כי שתי הבקשות הראשונות עברו חלק). מזה קל להסיק שהדבר שאנחנו מנסים להמתין עליו אבל לא מחובר ל Event Loop הנוכחי הוא בדיוק ה Semaphore.

ואיך Semaphore (או כל אוביקט אחר) יודע לאיזה Event Loop הוא מחובר? ל asyncio יש פונקציה גלובלית בשם get_event_loop() שמחזירה בדיוק את זה. הבנאי של Semaphore קורא לפונקציה זו ושומר את ה Event Loop. אני מעדכן קצת את הקוד כדי לראות מה ה Event Loop שאותו בנאי יקבל ולהשוות אותה ל Event Loop הראשית של התוכנית:

import aiohttp, asyncio, aiofiles

print("Event Loop when creating the semaphore: ", id(asyncio.get_event_loop()))
throttle = asyncio.Semaphore(2)

async def main():
    print("Event Loop in main()", id(asyncio.get_event_loop()))

asyncio.run(main())

והנה התוצאה:

Event Loop when creating the semaphore:  4516635360
Event Loop in main() 4533746416

עכשיו הסיפור ברור: יצרתי את ה Semaphore מוקדם מדי ולכן הוא מחובר ל Event Loop שונה מזו של ה main. בשביל לתקן את הקוד צריך רק להזיז את יצירת ה Semaphore פנימה לתוך ה main:

import aiohttp, asyncio, aiofiles

throttle = None

async def download(url, to):
    async with throttle:
        print(f"Download {url} to {to}")
        async with aiohttp.ClientSession() as session:
            async with session.get(url) as resp:
                if resp.status == 200:
                    f = await aiofiles.open(to, mode='wb')
                    await f.write(await resp.read())
                    await f.close()

async def main():
    global throttle
    throttle = asyncio.Semaphore(2)
    await asyncio.wait([
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-git-rm-vs-reset', 'post1.html')),
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-keep-on-learning', 'post2.html')),
        asyncio.create_task(download('https://www.tocode.co.il/blog/2019-12-python-memory-management', 'post3.html')),
        ])

asyncio.run(main())

נ.ב. הרבה פעמים נרצה מנגנונים יותר מתוחכמים כדי להאט את התקשורת, למשל נרצה להגדיר שלא נוציא יותר מ-5 בקשות במקביל אבל גם בשום מקרה לא נוציא יותר מ 10 בקשות בשניה (גם אם השרת עונה ממש מהר לשאילתות שלנו). ספריה נחמדה שעוזרת ליצור Throttling מתוחכם יותר ל asyncio נקראת asyncio-throttle ושווה לבדוק אותה לפני שמתחילים לבנות מנגנון לבד בעולם האמיתי.