Two ways to cut text into lines, and they disagree on exactly the inputs real files contain. Read all four outputs.
print('a\nb\n'.split('\n')) # -> ['a', 'b', ''] a line that is not there
print('a\nb\n'.splitlines()) # -> ['a', 'b']
print('a\r\nb'.split('\n')) # -> ['a\r', 'b'] stray carriage return
print('a\r\nb'.splitlines()) # -> ['a', 'b']
lines = ['a', 'b', 'c']
print(lines[-2:], lines[-0:]) # -> ['b', 'c'] ['a', 'b', 'c']
- A trailing newline ends a line, it does not start one
Text files conventionally end with a newline, so 'a\nb\n' is two lines and not three. split('\n') treats the separator literally and hands you a phantom empty string at the end — which then shows up as a blank line in your output, on almost every real file.
- splitlines handles the endings you did not think about
It understands \n, \r\n and \r, so a file written on Windows does not leave carriage returns glued to the end of every line. Splitting on '\n' by hand means every value silently carries a '\r' that only shows up as strange alignment much later.
- There is no negative zero
lines[-2:] is the last two, so it is tempting to write lines[-count:] and be done. When count is 0 that becomes lines[0:], which is everything. The bug is invisible in testing because nobody asks for zero lines by hand — but a variable can, and then a 'show nothing' request dumps the whole file.
Two steps: turn the text into lines, then take the end of that list. Both steps have a version that looks right and misbehaves on real input — the worked example shows both.
Use text.splitlines() rather than split('\n'). For the slice, lines[-count:] is right for every count except one, and that one is the reason this exercise exists.
Guard the negative count. Split with splitlines. Then handle zero explicitly — return [] before you slice — because -0 and 0 are the same number and the slice cannot tell them apart. Otherwise return lines[-count:].
def tail_lines(text, count): if count < 0: raise ValueError(...) lines = text.____() if count == ____: return [] return lines[____:]
def tail_lines(text, count):
if count < 0:
raise ValueError(f"count cannot be negative, got {count}")
lines = text.splitlines()
if count == 0:
return []
return lines[-count:]
This is the rare situation where an explicit special case is not a failure of imagination — it is unavoidable. Slices have no way to express 'negative zero', so -0 collapses to 0 and the slice means 'from the start'. Every other count works; only this one inverts the answer, which is precisely why it survives review.
It handles the trailing newline, the three different line-ending conventions, and the empty-string case — all of which would otherwise be separate lines of code and separate bugs. Three of the cases here exist only to distinguish it from split('\n').
lines[1:] for count of -1 is perfectly valid Python and returns something plausible, which is what makes it dangerous. When a wrong input produces a wrong *answer* rather than an error, the guard is doing more work than it looks like — the same argument as the swapped bounds in clamp.
This takes the whole text as a string, so it has already read the entire file into memory — fine for a log excerpt, wasteful for a 2GB file when you wanted the last twenty lines. A real tail seeks to the end of the file and reads backwards in blocks; collections.deque(file, maxlen=count) is the neat middle ground, keeping only count lines in memory while still reading forwards. Reach for this when you already have the text.
- Write the deque(file, maxlen=count) version and work out why it needs no slicing and no zero special case.
- Add head_lines, then notice it does not have the -0 problem at all — and be able to say why in one sentence.
- Return the line numbers alongside the lines, so a caller can print ' 1042 | ...' the way grep does.