# /// script
# requires-python = ">=3.12,<3.15"
# dependencies = [
#     "marimo",
# ]
# ///
"""Python Handbook, Chapter A: Essentials.
"""

import marimo

__generated_with = "0.24.0"
app = marimo.App(width="medium", sql_output="polars")


@app.cell
def _():
    import marimo as mo

    return (mo,)


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    # Essentials

    Chapter A of the Python Handbook. 10 sections, each one concept. Read this chapter and you can write a program.

    You can read this straight through, or open it when you need one thing. Every
    section ends with three questions that have no answers written down, because those
    are the ones asked out loud in class.

    To run the code yourself, download the `.py` into your project and open it with
    `uv run marimo edit`.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## 1. Variables and values

    A name, which most books and every job posting call a **variable**, holds a value.
    Everything else in Python is built on this. Once a value has a name you can use it
    somewhere else, change it in one place, and see what depended on it.

    📖 **Used in:** Getting everything running
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### `=` puts a value into a name

    Read `freight = 16.75` as **put 16.75 into freight**. This is an **assignment**, and
    it is an instruction, which is why `freight = freight + 1` is legal.
    """)
    return


@app.cell
def _():
    freight = 16.75
    freight
    return (freight,)


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** change 16.75 to 99.99 and run the cell. Then put your own name on a
    number you care about at work.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Giving a name a new value

    Nothing remembers what was there before. If you need both, use two names.
    """)
    return


@app.cell
def _():
    shipping_cost = 16.75
    shipping_cost = 22.25
    shipping_cost
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** add a third line, `shipping_cost = shipping_cost * 2`, and predict
    the answer before you run it.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Names in expressions

    This is where names earn their keep. `freight` is defined once above, and the cell
    below uses it.
    """)
    return


@app.cell
def _(freight):
    freight_with_handling = freight + 3.50
    freight_with_handling
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** go back up and change `freight`. Watch this cell change on its own.
    You did not run it.

    **The trap.** Naming something does not copy it into every place you used it. It is
    one value with one name, and everything reading that name sees the change.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### ✔ Check your understanding

    1. What does `total = total + 5` do, given that `=` does not mean equality?
    2. You give a name a new value. What happens to the value it held before?
    3. Two cells both use `freight`. You change `freight` in one place. How many values
       of `freight` exist now?
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## 2. Types

    Every value has a **type**, and the type decides what can be done to it. Python
    will not guess for you.

    📖 **Used in:** Reading an error message
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### The types you will meet constantly

    `int` a whole number. `float` a number with a decimal point. `str` text, always in
    quotes. `bool`, short for **boolean**, either `True` or `False`, with capital letters.

    `type()` tells you what you are holding.
    """)
    return


@app.cell
def _():
    quantity = 12
    unit_price = 14.00
    product = "Queso Cabrales"
    discontinued = False

    type(quantity), type(unit_price), type(product), type(discontinued)
    return product, quantity, unit_price


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** add `type(12 / 4)` and predict it before you run. The result is
    three, so is it an `int`?
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### `"5"` and `5` are different things

    Quotes make it text. Text and numbers behave differently under the same `+`, which
    **concatenates** two strings and adds two numbers.

    ```python
    "5" + "3"      # '53'
    5 + 3          # 8
    5 + "3"        # TypeError: unsupported operand type(s) for +: 'int' and 'str'
    ```

    **The trap.** The first one does not fail. It succeeds and gives you `'53'`, which
    is how a wrong number reaches a slide. Anything read from a file, a form, or a web
    page arrives as `str` until you convert it.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** run all three lines above in a cell of your own, one at a time. The
    third one is supposed to fail. Read what it says.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Converting between types

    `int()`, `float()` and `str()` take a value and hand back a new one of that type.
    Converting like this is called **casting**.
    """)
    return


@app.cell
def _():
    quantity_from_form = "12"
    quantity_as_number = int(quantity_from_form)

    quantity_as_number + 3
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** what does `int("12.5")` do? Predict, then run. Then try
    `int(float("12.5"))`.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### The difference between `/` and `//`

    `/` always gives a `float`. `//` is **floor division**, which throws away the
    remainder, and `%` is **modulo**, which keeps it.
    """)
    return


@app.cell
def _():
    17 / 5, 17 // 5, 17 % 5
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** you have 17 units and a case holds 5. How many full cases, and how
    many left over? Write it with the two operators above.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### In Excel terms

    Excel converts for you and mostly guesses right, which is why a leading zero
    disappears from a product code and a date turns into 45231. Python refuses instead
    of guessing, so the failure arrives while you are writing the line.

    ### ✔ Check your understanding

    1. `"5" + "3"` gives `'53'` and `5 + "3"` raises an error. Which of the two is more
       dangerous in a report, and why?
    2. A CSV column of prices comes in as `str`. What has to happen before you can add
       them up?
    3. `10 / 2` gives `5.0` and `10 // 2` gives `5`. When does the difference matter?
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## 3. Expressions and operators

    An expression is anything that produces a value. Operators are what combine
    values, and the order they apply in is fixed.

    📖 **Used in:** Reading an error message
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Arithmetic

    `+` `-` `*` `/` you know. `**` is **exponentiation**, and `%` is the remainder.
    """)
    return


@app.cell
def _(quantity, unit_price):
    line_total = quantity * unit_price
    line_total
    return (line_total,)


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** compute the line total with a 15 percent discount applied. There is
    more than one way to write it.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Precedence and parentheses

    `*` and `/` happen before `+` and `-`, the same rule as arithmetic class and the
    same rule as Excel. The two lines below are different calculations.
    """)
    return


@app.cell
def _(line_total):
    without_parens = line_total + 16.75 * 0.5
    with_parens = (line_total + 16.75) * 0.5

    without_parens, with_parens
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** write the same calculation in a way that needs no parentheses at all.
    Which version would a colleague read faster?

    **The trap.** Nothing warns you. Both lines run, and only one of them is the number
    you meant.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Comparisons produce `True` or `False`

    `==` `!=` `<` `<=` `>` `>=`. Note that comparison is `==`, with two signs, because
    one sign already means assignment.
    """)
    return


@app.cell
def _(line_total):
    is_large_order = line_total > 100
    is_large_order
    return (is_large_order,)


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** write a comparison that is `True` for this order and `False` for one
    worth 50.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### `and`, `or`, `not`

    These combine comparisons. `and` needs both sides true; `or` needs one.
    """)
    return


@app.cell
def _(is_large_order, product):
    ships_free = is_large_order and not product.startswith("Perishable")
    ships_free
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** both sides are `True` here, so `and` and `or` agree and swapping them
    proves nothing. Make one side false: replace `is_large_order` with `line_total > 200`,
    run it, then swap `and` for `or`. Now they differ, and the difference is the point.

    ### ✔ Check your understanding

    1. Why does Python need `==` for comparison instead of reusing `=`?
    2. `2 + 3 * 4` gives 14. What would you write to get 20?
    3. `a and b` is `False`. What do you know about `a`? What do you know about `b`?
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## 4. Conditionals

    A conditional runs one block of code or another depending on a comparison. It is
    the first place where code stops being a calculator.

    📖 **Used in:** What one row means
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### `if`, `elif`, `else`

    The line ends in a colon and the block underneath is indented. The indentation is
    not decoration; it is how Python knows what belongs to the `if`.
    """)
    return


@app.cell
def _():
    order_value = 240.00

    if order_value >= 500:
        tier = "A"
    elif order_value >= 200:
        tier = "B"
    else:
        tier = "C"

    tier
    return (order_value,)


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** set `order_value` to 500, then to 499.99, then to 200. Predict the
    tier each time before you run.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Choosing the boundary

    `>= 500` and `> 500` differ for exactly one order value, and that difference is a
    business rule. Whoever writes the comparison has made the call.

    **The trap.** An agent asked to "tier orders above 500" will pick one of the two
    without telling you which. Read the operator.
    """)
    return


@app.cell
def _(order_value):
    order_value >= 500, order_value > 500
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** set `order_value` to exactly 500 and run the cell above. Now decide
    which of the two lines matches the rule your company actually uses.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Why the order of conditions matters

    Python checks the branches from the top and stops at the first one that is `True`.
    Put the narrowest condition first.

    ```python
    # Wrong. Every order over 200 is caught here, including the ones over 500.
    if order_value >= 200:
        tier = "B"
    elif order_value >= 500:
        tier = "A"
    ```

    Nothing errors. Tier A simply never happens.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** copy that broken version into a cell, set `order_value` to 900, and
    confirm that it says B. **Rename `tier` to `tier_broken` in your copy**: a name is
    defined in exactly one cell here, so a second `tier` is a multiple-definitions error
    rather than the lesson.

    ### ✔ Check your understanding

    1. What tells Python which lines belong to an `if` block?
    2. Your rule is "over 500 gets tier A". Which comparison is that, and what happens
       to an order of exactly 500?
    3. A chain has four branches and the third one never runs. What would you look at
       first?
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## 5. Strings

    Text is the type most business data arrives in: names, cities, product codes,
    anything typed by a person. Cleaning it is most of the work in the first hour of a
    real dataset.

    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Quotes, length, and one character at a time

    Single or double quotes, and it makes no difference which. `len()` counts
    characters. A position in brackets, its **index**, picks one out, **counting from
    zero**, and a negative index counts from the end.
    """)
    return


@app.cell
def _():
    customer = "Alfreds Futterkiste"

    len(customer), customer[0], customer[-1]
    return (customer,)


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** which position holds the space? Find it by guessing, then check.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Slicing takes a range

    `customer[0:7]` reads as **from 0 up to but not including 7**. Leave a side empty
    to mean "from the start" or "to the end".
    """)
    return


@app.cell
def _(customer):
    customer[0:7], customer[8:], customer[:7]
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** pull out the last four characters two different ways, one with a
    negative number and one without.

    **The trap.** The end position is not included. `customer[0:7]` gives you seven
    characters, not eight.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Methods do the cleaning

    A method is written after a dot. These three cover most of what a messy column
    needs, and `len()` from a moment ago is the fourth thing you will reach for.
    """)
    return


@app.cell
def _():
    city_raw = "  Berlin  "

    city_raw.strip(), city_raw.strip().lower(), "Sales-Q3".replace("-", " ")
    return (city_raw,)


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** `"  BERLIN ".strip().lower()` and `"Berlin".lower().strip()` give the
    same answer. Is that always true for any pair of methods?
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Strings do not change

    A string is **immutable**, so a method hands back a **new** string and leaves the
    original alone. This catches everybody once.
    """)
    return


@app.cell
def _(city_raw):
    city_raw.strip()
    city_raw
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    The second line still shows the spaces. Nothing was saved.

    👉 **Try it:** fix it by putting the result into a name:
    `city_clean = city_raw.strip()`.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### From one field to a list and back

    One string often holds several values with something between them, which is how a
    spreadsheet column and an API both hand you a list without calling it one.
    `.split()` cuts at the separator and gives back a **list**.
    """)
    return


@app.cell
def _():
    watchlist = "AAPL,MSFT,GOOG,AMZN"
    tickers = watchlist.split(",")

    tickers, len(tickers)
    return (tickers,)


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    `.join()` is the return trip, and it is written on the separator rather than on the
    list, which reads backwards the first time you see it. The string in front is what
    goes **between** the items, so it appears three times in a list of four and never at
    either end.
    """)
    return


@app.cell
def _(tickers):
    ", ".join(tickers), " | ".join(tickers)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** split `"AAPL, MSFT, GOOG"`, which has a space after each comma, and
    look at what the second ticker actually is. Fix it two ways, one by splitting on a
    different separator and one with `.strip()`.

    **The trap.** A separator that is not there gives you a list of one, not an error:
    `"AAPL,MSFT".split(";")` returns `['AAPL,MSFT']`. And `.split()` with nothing in the
    brackets is a different operation, splitting on any run of whitespace, which is what
    you want for `"Alfreds Futterkiste".split()` and never what you want for a comma.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Asking a string a question

    Three of these answer yes or no, and they are how a column gets filtered before
    anything is computed from it. `in` is not a method and needs no dot.
    """)
    return


@app.cell
def _():
    product_code = "PROD-1084"

    (
        product_code.startswith("PROD"),
        product_code.endswith("84"),
        "1084" in product_code,
        product_code.isdigit(),
    )
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    **The trap.** `.isdigit()` is `True` only when **every** character is a digit, so
    `"16.75".isdigit()` is `False` because of the dot and `"-4".isdigit()` is `False`
    because of the minus sign. Both of those go into `float()` without complaint. It is a
    narrower question than "is this a number", and reaching for it to decide whether a
    column can be converted throws away the decimals.

    👉 **Try it:** run `"16.75".isdigit()` and then `float("16.75")`. One says no and the
    other works. Explain in one sentence what each of them is asking.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### In Excel terms

    `LEN` is `len()`, `TRIM` is `.strip()`, `LOWER` is `.lower()`, `SUBSTITUTE` is
    `.replace()`, `MID` is slicing, `TEXTSPLIT` is `.split()` and `TEXTJOIN` is
    `.join()`. The one real difference is that Excel counts from 1 and Python counts
    from 0.

    ### ✔ Check your understanding

    1. `customer[0:7]` returns how many characters, and why is that not eight?
    2. You call `.strip()` on a name and the spaces are still there when you print it.
       What did you forget?
    3. Excel's `MID(A1, 1, 7)` and Python's `customer[0:7]` return the same text. Why do
       the numbers differ?
    4. `", ".join(tickers)` puts a comma and a space between the tickers. How many times
       does it appear in a list of four, and where does it not appear?
    5. `"16.75".isdigit()` is `False` and `float("16.75")` works. What is each one asking?
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## 6. f-strings

    An f-string builds a sentence with values in it. Every number you report to
    somebody goes through one, and the formatting is where a raw number becomes
    readable.

    📖 **Used in:** Reading an error message
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### The `f` and the braces

    Anything inside `{}` is evaluated and dropped into the text.
    """)
    return


@app.cell
def _(customer, line_total):
    f"{customer} ordered {line_total} of product."
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** put a calculation inside the braces, such as `{line_total * 2}`.

    **The trap.** Forget the `f` and you get the braces printed literally, with no
    error at all:

    ```python
    "{customer} ordered {line_total}"     # '{customer} ordered {line_total}'
    ```
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Formatting after a colon

    `:.2f` fixes two decimal places. `:,` adds thousands separators. `:.1%` turns
    0.153 into `15.3%`.
    """)
    return


@app.cell
def _(line_total):
    f"{line_total:.2f}", f"{line_total * 1000:,.2f}", f"{0.153:.1%}"
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** write one line that reads `Freight: $16.75` using a name instead of
    typing the number.

    ### ✔ Check your understanding

    1. You print a line and see `{total}` in the output. What is missing?
    2. What does `:.2f` change about the value, and what does it change about the
       stored number?
    3. When would you want `:,` and when would it be wrong?
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## 7. Dates and times

    Every business question with the word *when* in it runs through a date. How long did
    that take, was it late, how many shipped last month. Dates are the type that behaves
    least like you expect, so they get their own section before you meet a whole column of
    them.

    📖 **Used in:** Building and querying your database · Building your product
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Dates as a type

    `"2016-07-04"` is a string. It is ten characters that happen to read as a date, and
    Python will let you compare it, sort it and add it to another string.
    """)
    return


@app.cell
def _():
    from datetime import date

    ordered = date(2016, 7, 4)
    ordered, type(ordered)
    return date, ordered


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** run `type("2016-07-04")` beside it. Both print something that reads like
    a date. Only one of them knows what a month is.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Parsing text into dates

    A file, a form and an API all hand you a string. `date.fromisoformat` turns
    `YYYY-MM-DD` into a real date.

    **The trap.** Comparing the strings instead works most of the time, which is what makes
    it dangerous. `"2016-07-04" < "2016-07-16"` is `True` and correct. Change the format
    and it stops being correct while still being `True` or `False`.
    """)
    return


@app.cell
def _(date):
    shipped = date.fromisoformat("2016-07-16")

    shipped, "9/9/2016" < "10/1/2016"
    return (shipped,)


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    The second answer is `False`. As text, `"9"` comes after `"1"`, so September beats
    October. As dates it is obviously the other way round, and nothing raised.

    👉 **Try it:** parse both of those with `date.fromisoformat` and compare them again.
    You will have to rewrite them first, which is the point.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Subtracting dates

    Order `10248` was placed on 4 July and shipped on 16 July. The difference is a
    `timedelta`, and `.days` gets the number out of it.
    """)
    return


@app.cell
def _(ordered, shipped):
    took = shipped - ordered

    took, type(took), took.days
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** the other four orders in that week took 5, 4, 7 and 2 days. Compute the
    slowest one as a `timedelta` and then as a plain number of days.

    **The trap.** `took` prints as `12 days, 0:00:00` and is not the number 12. Put it in a
    sentence and you get the whole thing. `took.days` is the number.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Timestamps against dates

    `date` holds a day. `datetime` holds a day **and a time**, and it prints in a way that
    hides the difference when the time happens to be midnight.

    In this course's warehouse, `OrderDate` is a timestamp. **15,452 of its 16,282 orders
    carry a real clock time and 830 do not**, so a comparison against a bare date matches
    only the 830.
    """)
    return


@app.cell
def _(date):
    from datetime import datetime

    stamped = datetime(2016, 7, 4, 15, 40, 46)
    plain = date(2016, 7, 4)

    stamped.date() == plain, stamped == datetime(2016, 7, 4)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    The first is `True`, because `.date()` throws the clock away. The second is `False`,
    because 15:40:46 is not midnight. **Neither raised.**

    👉 **Try it:** change `15, 40, 46` to `0, 0, 0` and run it again. The second answer flips, and that
    is exactly why a filter can look correct on the rows you checked.

    ### ✔ Check your understanding

    1. `"2016-07-04"` and `date(2016, 7, 4)` both print as a date. Name one thing you can
       do with the second that you cannot do with the first.
    2. `shipped - ordered` gives `12 days, 0:00:00`. What do you write to get `12`?
    3. A filter on `OrderDate = 2016-07-04` returns six rows out of a table you know is
       larger. Give one explanation that does not involve the data being wrong.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## 8. Functions

    A function is a calculation with a name. You write it once and call it wherever you
    need it, and the name is what somebody else reads instead of the arithmetic.

    It is also the unit an agent works in. *"Write a function that takes an order's lines
    and returns the total"* is a request that can be checked. *"Clean up my data"* is not.

    📖 **Used in:** Getting data from an API
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### `def`, a parameter, and `return`

    The line ends in a colon and the body is indented, the same shape as an `if`. The
    names in the definition are the **parameters**, and the values you hand it when you
    call it are the **arguments**.
    """)
    return


@app.cell
def _():
    def line_amount(qty, price):
        return qty * price

    line_amount(12, 14.00)
    return (line_amount,)


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    That is the Queso Cabrales line on order `10248`, and 168.00 is the number you would
    check by hand.

    👉 **Try it:** call it for the other two lines, 5 at 34.80 and 10 at 9.80. Then call it
    with the arguments the other way round and see that nothing stops you.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### `return` sends a value back. `print` puts it on the screen

    They look the same in a notebook and they are not the same at all. A function that
    prints has no **return value**, so it gives you `None`, and `None` is what the next
    calculation receives.
    """)
    return


@app.cell
def _():
    def shows_the_total(qty, price):
        print(qty * price)

    captured = shows_the_total(12, 14.00)
    captured
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    The 168.0 above is the `print` firing. The cell's own value is `None`, and nothing
    warned you.

    **The trap.** This is the most common mistake in agent-written code, and it is silent.
    The function looks right, the notebook shows the number, and the total three cells
    later is `None`.

    👉 **Try it:** write `captured * 2` in a new cell and read the error. That is the error
    you would actually meet, three cells away from the cause.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### A parameter can have a default

    Give a parameter a value in the definition, a **default argument**, and the caller may
    leave it out. Put the ones with defaults last.
    """)
    return


@app.cell
def _():
    def order_amount(goods, freight_charge=0.0):
        return goods + freight_charge

    order_amount(440.00), order_amount(440.00, 16.75)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** call it with `freight_charge=16.75` written out.
    That is a **keyword argument**, and naming it at the call site costs four seconds and
    tells the next reader what 16.75 is.

    **The trap, and it is a real one from a real course.** A default of `6.25` for a tax
    rate, used as `amount * rate`, gives you 625 percent tax. The function runs, returns a
    number, and the number is wrong by a factor of 100. **A default is a decision you made
    once and will not see again**, so write the units into the name: `rate_percentage`, not `rate`.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### A name made inside a function does not exist outside it

    The parameters and anything you create in the body are gone when the function returns.
    Those names are local to the function, which is what **scope** means. That is a
    feature: it means a function cannot quietly change something you were using.
    """)
    return


@app.cell
def _(line_amount):
    def order_lines_total():
        subtotal = line_amount(12, 14.00) + line_amount(5, 34.80) + line_amount(10, 9.80)
        return subtotal

    order_lines_total()
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    440.00, which is order `10248` before freight.

    👉 **Try it:** add a cell with just `subtotal` in it and read the error. The name was
    real, it did its job, and it is gone.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### A **docstring** says what the function is for

    A string on the first line of the body is the function's **docstring**. It is not a
    comment: Python keeps it, `help()` prints it, and your editor shows it when you hover
    the name. Every `help()` you have ever read was somebody else's docstring.
    """)
    return


@app.cell
def _():
    def order_amount_with_tax(goods, freight_charge=0.0, rate_percentage=7.0):
        """Total for one order, in dollars.

        Assumes one row per order, and a rate in percent, not a fraction.
        """
        return (goods + freight_charge) * (1 + rate_percentage / 100)

    help(order_amount_with_tax)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    **Write the assumption, not the obvious.** A docstring reading *"Adds the numbers"*
    repeats the code.
    *"Assumes one row per order"* and *"a rate in percent, not a fraction"* are the two
    things a reader cannot see and the two that go wrong, and the second one is the 625
    percent tax from a moment ago, written down where it can be checked.

    **This is also the one place your agent reads before it uses your function.** It gets
    the docstring along with the code, so a sentence naming the grain your function expects
    is how it stops handing you a total computed at the wrong one. Cheap insurance in both
    directions: the reader is sometimes the agent, and sometimes you in November.

    👉 **Try it:** delete the second line of the docstring, run `help()` again, and read
    what a reader would now have to guess.

    ### ✔ Check your understanding

    1. A function computes the right number, prints it, and has no `return`. What does the
       caller get?
    2. Why write `order_amount(440.00, freight_charge=16.75)` when
       `order_amount(440.00, 16.75)` does the same thing?
    3. You define `subtotal` inside a function and use it outside. What happens, and why is
       that the behavior you want?
    4. Your function returns a total and its docstring says *"Adds the numbers"*. Name one
       thing a reader still cannot check, and write the sentence that would let them.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## 9. Modules and `import`

    A **module** is a file of code somebody already wrote, and a **package** is a folder of
    them that you install. `import` is how you reach either. Every line of Python you will
    read outside this course starts with a few of them, and the three ways of writing one
    are not interchangeable.

    📖 **Used in:** Reading an error message · Building and querying your database
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### `import X`

    Python ships with a large standard library. Nothing is installed here; `math` came with
    the language.
    """)
    return


@app.cell
def _():
    import math

    cases_needed = math.ceil(17 / 5)
    cases_needed, math.pi
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    Seventeen units, five to a case, four cases. `math.pi` is an **attribute**, a value you
    reach the same way.

    👉 **Try it:** run `math` on its own line, then `import json` and `json`. `math` says
    `built-in`, because it is compiled into Python; `json` names the file it came from.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### `from X import Y`

    Same module, different import, different call.
    """)
    return


@app.cell
def _():
    from statistics import mean

    mean([16.75, 22.25, 25.00, 20.25, 36.25])
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    That is the average freight on orders `10248` to `10252`.

    The square brackets make a **list**, which is Chapter B's subject. It is the one place
    this chapter needs one, because handing `mean` five numbers at once is what it is for.

    **Which to use.** `import X` keeps the module's name in front of every call, so a
    reader can see where `ceil` came from. `from X import Y` is shorter and loses that.
    Prefer the first for anything unfamiliar.

    👉 **Try it:** write `statistics.mean(...)` after that import and read the error.
    Importing one name does not bring the module in with it.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### `import X as Y`

    The short name is an **alias**. Nobody writes `polars.DataFrame`; everybody writes
    `pl.DataFrame`, as pandas code writes `pd.DataFrame`, and the reason is one line at the
    top of the file that most tutorials never explain.
    """)
    return


@app.cell
def _():
    import statistics as stats

    stats.median([16.75, 22.25, 25.00, 20.25, 36.25])
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** the mean of those five is 24.10 and the median is 22.25. Which one would
    you put in a sentence about typical freight, and what would the other one let somebody
    argue?
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### A file you wrote is a module too

    Put a function in a file next to your notebook and import it by the file's name without
    `.py`. This is how a calculation stops being copied into four notebooks.

    ```python
    # orders.py, sitting beside your notebook
    def line_amount(qty, price):
        return qty * price
    ```

    ```python
    # in the notebook
    from orders import line_amount
    ```

    **The trap.** Python looks for `orders.py` **in the folder you started from**. That is the
    same folder the notebook sits in only if you opened the project the way this course
    tells you to. When it is not found you get a `ModuleNotFoundError`, and
    §10 is about reading it.

    **The second trap.** Importing a file **runs everything in it**. A file with
    `print(line_amount(12, 14.00))` at the bottom prints 168.0 the moment anybody imports
    it. Keep files you import to definitions.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** make `orders.py` beside your notebook with that one function in it,
    import it, and call it. Then add a `print(...)` line at the bottom of `orders.py` and
    import it again in a fresh cell.

    ### ✔ Check your understanding

    1. After `from statistics import mean`, does `statistics.median(...)` work? Why?
    2. You write `import polars as pl`. What have you actually changed?
    3. Your own `orders.py` sits beside the notebook and the import still fails. Name the
       first thing to check.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## 10. Reading a traceback

    When Python cannot finish running your code it stops and prints a **traceback**: a
    block of red text, several lines long, ending in the name of what went wrong. It
    looks like a wall. It is a list, and it has a shape.

    📖 **Used in:** Reading an error message
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### Reading a traceback

    ```python
    customer = "Alfreds Futterkiste"
    print(customer[50])
    ```

    ```
    Traceback (most recent call last):
      File "orders.py", line 2, in <module>
        print(customer[50])
              ~~~~~~~~^^^^
    IndexError: string index out of range
    ```

    - **`Traceback (most recent call last)`** is a header. It never changes.
    - **The indented block** is the route Python took to get there. When your code calls
      something that calls something else, there is one of these per step, oldest first.
    - **The last line** names what went wrong. `IndexError` is the kind of problem, and an
      error Python raises this way is an **exception**. `string index out of range` is the
      detail.

    **Read the last line first.** The lines above it show how Python got there.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** run those two lines in a cell. Then change 50 to 5 and run again.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### When a `SyntaxError` happens

    The other three errors happen while your code runs. A `SyntaxError` happens before
    any of it runs, because Python could not read the file at all. Nothing in the cell
    executed, which is why fixing one often fixes several complaints at once.

    ```python
    freight_charges = (16.75, 22.25
    print(freight_charges)
    ```

    ```
      File "orders.py", line 1
        freight_charges = (16.75, 22.25
                          ^
    SyntaxError: '(' was never closed
    ```

    **Modern Python is good at this one.** It names the bracket, points at it, and gives
    the line it was opened on. An unterminated quote reads the same way:
    `unterminated string literal (detected at line 1)`.

    Older tutorials and older answers on the web show a bare `SyntaxError: invalid
    syntax` blaming the line *after* the mistake, because that is what Python did before
    3.10. If you meet that message, the fix is to look one line up. On the Python this
    course installs you will usually get the better one.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    👉 **Try it:** delete a closing quote somewhere and read what it says. Then delete a
    closing bracket. In both, the caret under the line marks the character left open.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### The errors you will meet most

    | what it says | what it usually means |
    |---|---|
    | `ModuleNotFoundError` | the package is not installed in this environment, or the name is misspelled |
    | `FileNotFoundError` | the file is not where the code thinks it is. Check which folder you are in |
    | `NameError` | you used a name before anything put a value in it, or you misspelled it |
    | `SyntaxError` | Python could not read the code, so none of it ran. The caret under the line marks where it stopped |

    👉 **Try it:** cause each of the four on purpose, one per cell. `import pandsa`;
    `open("sales.csv")`; `print(totl)`; a bracket you never close. Read the last line of
    each before you fix it.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ### ✔ Check your understanding

    1. A traceback is 11 lines long. Which line tells you what went wrong?
    2. Your code says `import pandas` and you get a `ModuleNotFoundError`. Name two
       different reasons that could happen.
    3. A `SyntaxError` and a `NameError` in the same cell. Which one happened first, and
       how do you know without running anything?
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## Chapter challenge

    **Write the shipping line.** Nothing here is due. It exists because the sections above each teach one thing and
    a real line of work needs all of them at once.

    **Build one sentence a colleague would accept**, from these four facts and nothing else:

    - a customer name, typed by a person, with stray spaces and inconsistent case
    - an order value
    - a freight charge
    - a country

    The sentence has to say who ordered, what it came to, what shipping cost, and whether
    this order qualifies for free shipping. Format the money properly.

    **Three decisions are yours, and the notebook cannot make them.**

    1. What is the free shipping threshold, and is it `>` or `>=`? Write the operator you
       mean.
    2. What does the sentence say when the freight charge is 0? Free shipping and no
       shipping are different things.
    3. Does the customer name appear as typed, or cleaned up? Explain why.

    **Done looks like** one cell that produces one sentence, plus a markdown cell naming
    the three decisions you made and what the alternative would have been.

    **Going further.** Take the order value in as text, the way a form would hand it to
    you, and make it work. Then make it survive a value of `"1,240.00"` with the comma
    in it. Then decide what should happen when somebody passes `"n/a"`, and defend the
    answer.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## 🤖 Ask your agent

    Two different uses, and the second one is the one this course grades.

    ### Ask it about what this chapter left out

    Every one of these is real Python that had no room above.

    - `7 ^ 2` gives 5, which is not 49. Ask what the bitwise operators are and what `^`
      does to two integers.
    - `round()` takes a second argument. Ask how to round pi to three decimal places.
    - Python lets you write `0 < order_value < 100`. Ask whether other languages do, and
      what it means.
    - Ask what `f"{freight=}"` prints, and when that form is useful.
    - Ask what `divmod(17, 5)` returns and how it relates to `//` and `%`.

    Test whatever it gives you. Paste it into a cell and run it.

    ### Watch what it decides without telling you

    Give it this, exactly:

    > *"Write Python that tiers an order value into A, B or C."*

    Then read what came back and answer three questions.

    1. **What are the thresholds?** You did not give any.
    2. **Is each boundary `>` or `>=`?** Read the operators. Ignore the prose around them.
    3. **What happens to a negative order value, or to text?**

    You asked for one thing and got four or five decisions. That gap is the subject of
    this course, and it does not go away as the models get better. It gets quieter.

    👉 **Try it:** send the same sentence twice, in two separate conversations. Compare the
    thresholds.
    """)
    return


@app.cell(hide_code=True)
def _(mo):
    mo.md(r"""
    ---
    ## Chapter A checklist

    - [ ] I can put a value in a name and use it somewhere else
    - [ ] I can name the four types I will meet constantly, and ask what type a value has
    - [ ] I can predict what `"5" + "3"` gives, and what `5 + "3"` gives
    - [ ] I can convert text to a number and know why I had to
    - [ ] I know which operator runs first, and I use parentheses when I am not sure
    - [ ] I can write a comparison and read the `True` or `False` it produces
    - [ ] I can write an `if` chain and predict which branch a given value lands in
    - [ ] I can spot a boundary decision hidden in `>=` against `>`
    - [ ] I can take a piece out of a string, clean it, and keep the result
    - [ ] I can print a sentence with a formatted number in it
    - [ ] I can turn a piece of text into a real date, and explain why that matters
    - [ ] I can subtract two dates and get a number of days out of the result
    - [ ] I know why a timestamp does not equal the date it falls on
    - [ ] I can write a function with parameters and a `return`
    - [ ] I can state what a function that forgets to return gives back
    - [ ] I can give a parameter a default, and I write the units into its name
    - [ ] I can read `import x`, `from x import y` and `import x as y` and explain what each did
    - [ ] I can put a function in a file of my own and import it
    - [ ] I can read a traceback and name the line at fault

    **Chapter B is Collections**: a name holding several things at once, and the shape
    you choose for a table.
    """)
    return


if __name__ == "__main__":
    app.run()
