לאחרונה התחלתי לעבוד על תכנון גרסה חדשה ל-SMD, תוכנת הטרמינל שלי שחגגה לא מזמן תשע(!) שנים. הפיצ'ר החדש החשוב ביותר בגרסה העתידית, שתיכתב בשפת Python, יהיה תוספים "תוצרת בית" לניתוח תקשורת, שהמשתמש יוכל ליצור ולערוך כרצונו. איך גורמים לקוד הראשי לזהות תוספים חיצוניים ולהשתמש בהם?
חשוב מאוד: אני אתייחס כאן רק לנושא של זיהוי והרצת תוספים שכבר קיימים בתיקייה מקומית. הורדה של תוספים מהאינטרנט לא רלוונטית לתוכנה שלי ואם זה מה שאתם מחפשים, תצטרכו למצוא את הפתרון לבד 😉
רקע ומהות הבעיה
קוד יכול לבצע רק מה שנכתב בו. אם כתבתי תוכנה שמציירת רק ריבועים ומשולשים, אני לא אוכל "לשכנע" אותה תוך כדי ריצה לצייר פתאום עיגולים. הדרך המעשית היחידה לבצע טריק כזה היא לתת לה מראש יכולת להריץ קוד חיצוני, ואז בזמן הריצה לומר לה "הנה קוד חיצוני שמצייר עיגול, הריצי אותו".
קוד חיצוני יכול להיות עצמאי ומקומפל, כגון קובץ exe ב-Windows. במקרה כזה, הקוד הראשי ייגש למערכת ההפעלה ויבקש ממנה להריץ את ה-exe, ממש כמו דאבל-קליק עם העכבר. בטכניקה הזו משתמשים, למשל, כשרוצים שהתוכנה שלנו תפתח קובץ HTML או קישור. הרי לא נכתוב לבד דפדפן! במקום זה, התוכנה מעבירה URL למערכת ההפעלה, כדי שזו תפתח אותו עם דפדפן ברירת המחדל שמוגדר אצלה.
הפתרון הזה מתאים למעט מאוד מצבים, כי הוא מסורבל וגם מוגבל מאוד מבחינת היכולת להעביר ולשתף מידע בין שני קטעי הקוד. שיטה אחרת, גמישה הרבה יותר, היא שימוש בסקריפטים שמפוענחים בזמן ריצה. בשביל זה צריך כמובן משהו שיפענח את הסקריפטים, ויותר ויותר תוכנות – במיוחד כאלה שכתובות בפייתון – מסתמכות לשם כך על המפענח הקיים של פייתון. הבעיה העקרונית הופכת, אם כן, לשאלה טכנית: איך מריצים קוד פייתון מתוך קוד פייתון?
הפתרון הפחות-טוב: exec
הפונקציה המובנית exec קיימת בדיוק בשביל להריץ קוד מתוך קוד. אם התוסף שלנו הוא קובץ עם קוד פייתון תקני, עלינו רק לקרוא את התוכן שלו לתוך משתנה, ולהעביר את המשתנה הזה כפרמטר ל-exec. בעזרת הפרמטר האופציונלי locals אנחנו יכולים אפילו להעביר לקוד החיצוני ולקבל ממנו נתונים דרך משתני פייתון רגילים. למי שרוצה לראות איך עושים דבר כזה תכל'ס, העליתי ל-Pastebin דוגמה עובדת (שימו לב שהטקסט שמופיע שם מתאר שני קבצים שונים, שתצטרכו ליצור לבד במחשב שלכם).
אני אתעלם כאן מסיכון האבטחה, שקיים בכל פעם שמריצים בצורה עיוורת קוד חיצוני. הוא תקף גם ל-exec וגם לפתרונות אחרים. עדיין, exec הוא לא אופטימלי, כי הוא מריץ את כל הקוד מההתחלה עד הסוף, במכה אחת, וזה לא תמיד מה שאנחנו רוצים. למשל, במקרה שלי, אני רוצה שכל תוסף יכיל תיאור (docstring או משהו בסגנון) שהקוד הראשי יוכל להציג למשתמש גם בלי להפעיל את הפונקציה העיקרית של התוסף. או אולי כל תוסף יכלול מספר פונקציות נפרדות, שהקוד הראשי יוכל להפעיל בהתאם להקשר. נדרש כאן משהו דומה למה שעושה הפקודה import, רק בזמן ריצה במקום בתחילת התוכנית.
importlib.import_module
בפעם הראשונה שנתקלתי ב-importlib, זו הייתה הפנייה לתיעוד הרשמי והוא היה מאוד לא ברור. זהו תיאור טכני "פנימי", לא הוראות שימוש. אבל המשכתי לחפש ומצאתי דוגמאות קוד, שהראו שזה בדיוק מה שאני צריך: גרסה של import שעובדת דינאמית, לפי דרישה ובזמן ריצה.
בקוד הראשי, נתחיל כמובן בייבוא רגיל של הספרייה הרלוונטית:
import importlib
נניח שיש, באותה תיקייה כמו הקוד הראשי, קובץ פייתון בשם my_addon.py, ושמוגדר בו משתנה בשם my_addon_var. כדי לייבא את הקובץ באופן דינמי ולהציג את המשתנה שבו, נדרשות רק שתי פקודות:
myaddon = importlib.import_module("my_addon")
print(myaddon.my_addon_var)
שימו לב שבפרמטר לפונקציה import_module לא כללתי את הסיומת .py של שם הקובץ. זה חייב להיות ככה: לפחות ברמה שהבנתי את השיטה הזאת, ניתן לייבא רק קבצים עם סיומת py ולכן אין צורך לציין אותה.
שימו לב גם שבניגוד לפעולת import רגילה, הספרייה המיובאת לא מופיעה "מעצמה" בהמשך הקוד – צריך לבצע השמה מפורשת לתוך אובייקט חדש, במקרה הזה myaddon.
מה עושים אם קובץ התוסף לא נמצא באותה תיקייה כמו הקוד הראשי, אלא בתת-תיקייה? אפשר היה לחשוב שפשוט מוסיפים לפני שם הקובץ את הנתיב היחסי, אבל לא, הדרך הנכונה היא להחליף את הלוכסנים של הנתיב בתווי נקודה ("."). בחיי. זה נהיה עוד יותר מורכב אם רוצים נתיב מוחלט, או כזה שכולל חזרה לאחור, אם כי סביר להניח שאלה מקרים נדירים יותר אז נעזוב אותם כאן. ויש עוד בעיה: אם התוספים הם עניין דינמי, איך התוכנה תוכל לדעת את שמות הקבצים כדי להעביר אותם ל-import_module? הפתרון שמצאתי לזה הוא הפונקציה glob בספרייה glob, שמחזירה את שמות הקבצים בתיקייה, שכותבים את הנתיב שלה עם לוכסנים בסגנון לינוקס ("/" ולא "\"). כן, פייתון היא שפה עקומה לגמרי. בכל אופן…
קוד שעובד
הנה דוגמת קוד שניגשת לתת-התיקייה "plugins", מייבאת את כל קובצי py שהיא מוצאת שם לתוך רשימה של מודולים, ולאחר מכן מדפיסה את שמות המשתנים והפונקציות שבכל מודול, חוץ מאלה של ברירת המחדל (שמתחילים ב-"__"). שמתי כאן כתמונה כי זה רחב מדי בשביל תבנית הבלוג, אפשר למצוא את קוד המקור גם ב-Pastebin: