Run it
The half-open version, and the inclusive one that stops making progress.
The invariant, stated as the thing that is true on every iteration.
The overflow idiom Python does not need, and what you would actually use.
The invariant does the work
Binary search is three lines and most people can write a version that usually works. What separates that from a correct one is being able to say what is true at the top of every iteration: if the target is present, it is in [lo, hi).
Everything follows. The loop condition is lo < hi, because an empty range means the answer is not there. When a[mid] < target, mid cannot be the answer, so lo = mid + 1 excludes it. Otherwise mid might be the answer, so hi = mid keeps it in a half-open range. Neither branch can leave the range the same size, which is termination.
The two combinations that hang
Pair an inclusive hi = len(a) - 1 with hi = mid and you have a loop that can stop making progress: when hi = lo + 1, mid equals lo, and setting hi = mid changes nothing. The editor below runs that version under a step cap so you can see it exhaust its budget rather than hanging the page.
The mirror error is lo = mid instead of lo = mid + 1, which hangs for the same reason from the other side. The rule that avoids both: the branch that keeps mid as a candidate must be the one that moves the exclusive end.
The overflow line, and why Python does not need it
You will see mid = lo + (hi - lo) // 2 in every C and Java implementation, and the reason is real: lo + hi can exceed the integer width on a large array, a bug that sat in the JDK for nine years.
Python integers are arbitrary precision, so (lo + hi) // 2 is safe here — and the editor prints both forms agreeing at 262 to show it. Knowing why the idiom exists is worth more than using it: it is a good answer to "is there anything else to say about this line?"
What to use instead
In real Python, bisect. bisect_left(a, x) is exactly this loop, written in C, and it returns the insertion point — which is strictly more useful than a boolean, because it answers "where would it go" as well as "is it there". The next question is entirely about that.
Write the loop when asked to, and say that you would reach for bisect in production. Both halves of that answer are being listened for.
Edge cases to raise
Volunteering these is most of what separates a correct answer from a good one.
The empty array. hi = 0, the loop body never runs, and lo is 0 - which is the correct insertion point. A version starting at len(a) - 1 gets hi = -1 and needs a guard.
Targets outside the range. The answer is 0 or len(a), both of which are valid insertion points and neither of which is a valid index. The final bounds check is what separates them.
Duplicates. The plain loop returns an arbitrary one. If that matters, you want a boundary search, not this.