JJoeven

Curriculum/Python

with, Files, and Paths

with always closes. pathlib Path names a file. utf-8 turns text into bytes. This is how read and write tools work.

beginner19 min21 / 37

A file is text (or bytes) stored under a name. Agent tools like read_file and write_file are file work. If you open a file and never close it, the handle can stay locked. On a laptop that looks like “the file is busy.” In a long agent, it looks like a leak.

with means: open, do the work, then always close — even if an error happens. That close step is why we use with instead of a bare open.

Open, work, always close
openworkclose

with runs close even if work fails. That is how read and write tools should treat files.

Open, work, always close

pathlib is the standard way to name files. A Path is an object for a file name. You can ask for the name, check if it exists, and read or write text. You do not glue strings with + and hope the slash is right.

with always closes

Think of with as a promise: when the indented block ends, Python runs the close step.

Here is a tiny object that prints open and close so you can see the order:

python
class Box:
    def __enter__(self):
        print(class="tok-s">"open")
        return self

    def __exit__(self, *unused):
        print(class="tok-s">"close")
        return False

with Box():
    print(class="tok-s">"work")

Output order is open, work, close. If work raised an error, close would still run. __enter__ and __exit__ are the protocol. You do not write them for real files. open already has them. The demo is so you believe “always close.”

On a real computer, a file looks like this:

python
with open(class="tok-s">"note.txt", class="tok-s">"w", encoding=class="tok-s">"utf-8") as f:
    f.write(class="tok-s">"goal: find weather" + chr(10))
  • "w" means write (replace the file)
  • "r" means read
  • "a" means append
  • encoding="utf-8" stores letters as bytes the whole world can read

utf-8 is the usual encoding. Text in Python is str. On disk it is bytes. utf-8 is the bridge. If you skip encoding on some systems, a café in the text becomes mojibake.

as f names the file object. f.write writes. f.read reads. Prefer pathlib for whole-file text: Path.write_text / read_text. They still close for you.

pathlib Path

Path("note.txt") is a path object. You do not have to call open yourself for simple text.

CodeWhat it does
Path("note.txt")A path named note.txt
p.nameThe file name
p.exists()True if that file is there
p.write_text(s, encoding="utf-8")Write the whole string
p.read_text(encoding="utf-8")Read the whole string
p.with_suffix(".json")Same name, new ending
p.unlink()Delete the file

/ on a Path joins parts: Path("logs") / "run1.json". It works on Windows and Linux. String glue does not.

Never let a model pick a path like ../secrets.txt without a check. Keep file tools inside one folder you control. Resolve the path, then check that it still starts with the allowed folder. This lesson only warns. The check is a later safety habit: p = (root / name).resolve() then reject if root is not a parent.

Bytes vs text

  • str is text you can print and slice
  • bytes is raw data, like b"hello"
  • s.encode("utf-8") turns text into bytes
  • b.decode("utf-8") turns bytes into text

HTTP bodies and hashes use bytes. Prompts use text. Convert at the edge. Do not pass bytes into a prompt assembler. Do not pass a str into sha256 without encoding.

write_text wants str. write_bytes wants bytes. Mixing them is a TypeError.

Check exists before a read tool returns a surprise. If the file is missing, return {"ok": False, "error": "not found"} instead of letting FileNotFoundError kill the loop. You will wrap that in try in the next lesson. Pathlib still helps: p.exists() is a bool you can test in if.

"a" appends. "w" replaces the whole file. A write tool that was meant to append a log line but opened with "w" will wipe the run. That is a one-character bug. Prefer write_text for replace, and read-modify-write for small files: read the string, add a line, write the whole string. For huge logs, append mode on a laptop is better. Here, whole-string is clearer.

Join paths with / on a Path: root / "traces" / "run.json". Then resolve and check the result still sits under root. Model-supplied ".." is the attack. The check is string prefix or .parents. Do it every time a tool takes a path.

Common mistakes

  • open without with and without close.
  • Forgetting encoding="utf-8".
  • Letting the model choose ../ paths.
  • Using + to join path parts.
  • Reading bytes and treating them as a prompt string.
Live PythonOpen full playgroundpython
Output
Run to execute this in your browser. Nothing is sent to a server.

This editor has a small virtual disk. Writing agent-note.txt is safe here. Then we delete it. On your laptop, wrap real open in with, or use Path.write_text. Both close for you. Catch file errors in the next lesson.

How agents use this

A read tool is Path(name).read_text(encoding="utf-8") plus a folder check. A write tool is write_text. with is how you close a handle if you use open. Convert to bytes only when a library asks for bytes (hashes, some HTTP bodies). If you skip close, files stay locked. If you skip utf-8, accents break.

Traces saved as JSON are files: dump to a string, write the string, later read and load. The JSON lesson and this lesson are one pipeline. The agent should not hold the only copy of a long run in memory if you need to debug after a crash. Write the trace. Close the file. Open it in a test.

Model-chosen paths are a tool-safety problem, not a pathlib problem. Pathlib makes the check easier because you have .resolve() and parts. Still check. A string open(user_path) is how secrets leave the machine.

Check your understanding

Why use with when you open a file?