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

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

שתי גישות לבדיקת קוד מתוזמן ב Rails

19/03/2026

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

context 'when multi-part SMS - first chunk' do
  it 'stores in Redis and schedules delayed job' do
    allow(Vonage::InboundSmsService).to receive(:new).and_return(double(create_message: nil))
    allow(described_class).to receive(:set).and_return(described_class)
    allow(described_class).to receive(:perform_later)

    freeze_time

    described_class.handle_concatenated_chunk(params)

    expect(described_class).to have_received(:set).with(wait: 2.minutes)
  end
end

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

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

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

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

כלומר בגישה הראשונה נכתוב את הבדיקה הזו:

context 'when multi-part SMS - first chunk' do
  it 'stores in Redis and schedules delayed job' do
    allow(Vonage::InboundSmsService).to receive(:new).and_return(double(create_message: nil))
    allow(described_class).to receive(:set).and_return(described_class)
    allow(described_class).to receive(:perform_later)

    described_class.handle_concatenated_chunk(params)

    expect(described_class).to have_received(:set).with(wait: 2.minutes)
  end
end

ובגישה השנייה נכתוב את הבדיקה הזו:

context 'when multi-part SMS - first chunk' do
  it 'stores in Redis and schedules delayed job' do
    freeze_time

    expect do
      described_class.handle_concatenated_chunk(params)
    end.to have_enqueued_job(described_class).at(2.minutes.from_now)
  end
end

והגישה המעורבבת? רק עוד סימן לקוד שנכתב בלי השגחה.

לדבר עם בן אדם

18/03/2026

בני אדם אומרים "אני לא יודע".

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

בני אדם לא נכנסים ללולאה ובדרך כלל לא מוחקים את כל בסיס הנתונים מתוך יאוש.

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

בני אדם מוצאים פתרונות יצירתיים שאף אחד אף פעם לא חשב עליהם.

בני אדם ישאלו "מה התכוונת לעשות שם" לפני שיעזרו לי להתקדם בדרך הלא נכונה.

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

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

סקירת פונקציית TypeScript שכתב סוכן קידוד

17/03/2026

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

https://github.com/skorokithakis/stavrobot

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

נתחיל בהצגת קוד הפונקציה כמו שמצאתי אותה בפרויקט:

export function serializeMessagesForSummary(messages: AgentMessage[]): string {
  const lines: string[] = [];

  for (const message of messages) {
    if (message.role === "user") {
      let textContent: string;
      if (typeof message.content === "string") {
        textContent = message.content;
      } else {
        const content = Array.isArray(message.content) ? message.content : [];
        textContent = content
          .filter((block): block is TextContent => block.type === "text")
          .map((block) => block.text)
          .join("");
      }
      lines.push(`User: ${textContent}`);
    } else if (message.role === "assistant") {
      const content = Array.isArray(message.content) ? message.content : [];
      const textContent = content
        .filter((block): block is TextContent => block.type === "text")
        .map((block) => block.text)
        .join("");
      if (textContent) {
        lines.push(`Assistant: ${textContent}`);
      }
      for (const block of content) {
        if (block.type === "toolCall") {
          const toolCall = block as ToolCall;
          const args = Object.entries(toolCall.arguments)
            .map(([key, value]) => {
              if (typeof value === "string") {
                return `${key}=${JSON.stringify(value)}`;
              }
              if (typeof value === "object" && value !== null) {
                return `${key}=${JSON.stringify(value)}`;
              }
              return `${key}=${String(value)}`;
            })
            .join(", ");
          lines.push(`Assistant called ${toolCall.name}(${args})`);
        }
      }
    } else if (message.role === "toolResult") {
      const content = Array.isArray(message.content) ? message.content : [];
      const textContent = content
        .filter((block): block is TextContent => block.type === "text")
        .map((block) => block.text)
        .join("");
      lines.push(`Tool result (${message.toolName}): ${textContent}`);
    }
  }

  return lines.join("\n");
}

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

המשך קריאה

חוזרים למציאות

16/03/2026

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

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

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

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

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

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

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

טיפול בשגיאות בקוד asyncio

15/03/2026

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

async def worker(..., queue, visited, ...):
    while True:
        try:
            url, depth = await queue.get()
        except asyncio.CancelledError:
            break

        try:
            ...
        except Exception as e:
            logger.error(f"Error processing {url}: {e}")
        finally:
            queue.task_done()
            if stop_event.is_set():
                break

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

המשך קריאה

גם גיטהאב לא בודקים זמינות לפני שמציעים שם לפרויקט

14/03/2026

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

https://github.com/new

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

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

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

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

באבטחת מידע לא שואלים מה כבר יכול לקרות

13/03/2026

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

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

allowed_non_write_users: "*"
claude_args: >-
  --allowedTools "Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch"
prompt: |
  **Issue:** #${{ github.event.issue.number }}
  **Title:** ${{ github.event.issue.title }}

ראיתם את הבעיה? אני בטוח שכן. לוקחים את ה title של ה Issue ושולחים אותו לסוכן שיכול להריץ פקודות Shell ולגשת לאינטרנט.

עכשיו להגנתם הסוכן רץ על מכונה של Github Actions בלי הרשאות לעשות שום דבר. ופה אני שומע בראש את השאלה שבטח גם הם חשבו "מה כבר יכול להישבר?". נו, מסתבר שגם מכונת גיטהאב אקשן בלי הרשאות יכולה לעשות נזק והתוצאה היתה גירסה מלוכלכת של cline שעלתה ל npm.

הסיפור הטכני המלא למתעניינים נמצא בלינק הזה: https://neciudan.dev/cline-ci-got-compromised-here-is-how#what-is-openclaw-and-why-should-you-care

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

הצפנה ב Rails - הטוב, הרע והמכוער

12/03/2026

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

Add this entry to the credentials of the target environment:

active_record_encryption:
  primary_key: YehXdfzxVKpoLvKseJMJIEGs2JxerkB8
  deterministic_key: uhtk2DYS80OweAPnMLtrV2FhYIXaceAy
  key_derivation_salt: g7Q66StqUQDQk9SJ81sWbYZXgiRogBwS

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

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

len   = ActiveSupport::MessageEncryptor.key_len
salt  = SecureRandom.random_bytes(len)
key   = ActiveSupport::KeyGenerator.new('password').generate_key(salt, len) # => "\x89\xE0\x156\xAC..."
crypt = ActiveSupport::MessageEncryptor.new(key)                            # => #<ActiveSupport::MessageEncryptor ...>
encrypted_data = crypt.encrypt_and_sign('my secret data')                   # => "NlFBTTMwOUV5UlA1QlNEN2xkY2d6eThYWWh..."
crypt.decrypt_and_verify(encrypted_data)

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

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

secret_key = Rails.application.key_generator.generate_key("my-secret", 32)

encryptor = ActiveSupport::MessageEncryptor.new(secret_key)

# Encrypt + sign
encrypted = encryptor.encrypt_and_sign("Hello world")

# Decrypt + verify
decrypted = encryptor.decrypt_and_verify(encrypted)

puts encrypted
puts decrypted

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

secret_key_base = Rails.application.secret_key_base

# Derive a key
key_len = ActiveSupport::MessageEncryptor.key_len
salt = "my-encryption-salt"
key = ActiveSupport::KeyGenerator.new(secret_key_base).generate_key(salt, key_len)

encryptor = ActiveSupport::MessageEncryptor.new(key)

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

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

אם קוד נכתב ביער ואף אחד לא משתמש בו

11/03/2026

בפרויקט בשם chatwoot (ריילס) מצאתי את הקוד הבא:

class Api::V1::Accounts::WorkingHoursController < Api::V1::Accounts::BaseController
  before_action :check_authorization
  before_action :fetch_webhook, only: [:update]

  def update
    @working_hour.update!(working_hour_params)
  end

  private

  def working_hour_params
    params.require(:working_hour).permit(:inbox_id, :open_hour, :open_minutes, :close_hour, :close_minutes, :closed_all_day)
  end

  def fetch_working_hour
    @working_hour = Current.account.working_hours.find(params[:id])
  end
end

הקוד לא עובד מכמה סיבות:

  1. הפונקציה fetch_webhook לא קיימת. אי אפשר להפעיל אותה לפני update.

  2. הפונקציה check_authorization מחפשת קובץ בשם working_hours_policy.rb שגם לא קיים.

אין אף בדיקה על הקוד ולא מפעילים אותו משום מקום אבל הוא כן מחובר ל API Endpoint לכן אדם (או סוכן קידוד) שמסתכל רק עליו עלול לקבל את הרושם שהפונקציה והקובץ קיימים. ואם אותו אדם (או יותר סביר סוכן קידוד) יגיע לכתוב עכשיו פיצ'ר חדש הוא עלול להשתמש באותם רכיבים מדומיינים. וכן זאת בעיה של סוכני קידוד הרבה יותר מאשר של בני אדם, וזו הופכת להיות בעיה של בני אדם כשאותם בני אדם מפעילים סוכני קידוד בלי להכיר את הקוד ובלי לעבור מראש על תוכנית העבודה.

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

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

טיפ פייתון: לא צריך לכתוב את כל הקוד ב init

10/03/2026

כשאנחנו כותבים מודול בפייתון מאוד נוח שמשתמשים יכולים לייבא את המודול ולהפעיל פקודות ישירות על שם החבילה, כלומר נניח שיש לי מודול בשם mymodule אז משתמשים מאוד אוהבים לכתוב:

import mymodule

print(mymodule.add(10, 20))

דרך אחת לכתוב את mymodule כדי שזה יעבוד היא פשוט לכתוב קובץ בשם mymodule.py ובתוכו להגדיר את הפונקציה add. אבל אם אתם רוצים לכתוב פרויקט ולשתף עם חברים סיכוי טוב שתרצו לשים את המודול שלכם בתוך תיקייה מסודרת ששמה יהיה כשם הפרויקט. ופה העסק מתחיל להסתבך, כי אם אני יוצר תיקייה בשם mymodule ובתוכה קובץ בשם utils.py עם הפונקציה שלי אז יש לי מבנה תיקיות:

mymodule/
    __init__.py
    utils.py

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

import mymodule.utils

print(mymodule.utils.add(10, 20))

העצלנים יותר ישתמשו ב alias ויכתבו ביבוא:

import mymodule.utils as mymodule

print(mymodule.add(10, 20))

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

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

mymodule/
  __init__.py

ובתוך הקובץ __init__.py תופיע הגדרת הפונקציה add, ואז משתמשים של החבילה יוכלו שוב לכתוב:

import mymodule

print(mymodule.add(10, 20))

אבל כותבים חבילות יותר יצירתיים דווקא מעדיפים להשתמש בשם קובץ מסודר עבור הקודם שלהם. במצב כזה נכתוב קובץ utils.py עם הגדרת הפונקציה add, וקובץ __init__.py שיכיל רק את פקודת היבוא והיצוא מחדש:

from .utils import add

ושוב הכל עובד אבל עכשיו גם מסודר - משתמשים יכולים לייבא את המודול mymodule ולקרוא לפונקציה add ישירות דרך היבוא, אפילו שהפונקציה מוגדרת בקובץ פנימי mymodule/utils.py בתוך החבילה.