Build a tail -n clone with argparse
Problem statement
Write tail.py, a small clone of tail -n, using argparse:
tail.py [-n N] FILE [FILE ...]prints the lastNlines of each file (default 10).Nmust be a whole number>= 0; anything else is a usage error (exit 2, like every argparse error).- With more than one file, print a
==> name <==header before each, with a blank line between files, the waytaildoes. - A file that cannot be opened prints
tail.py: cannot open 'NAME': REASONto stderr; the other files are still printed and the exit code is 1. - A last line with no trailing newline is still printed on its own line.
- It must work on a 20 GB log without loading it into memory.
app.log
2026-09-24 09:00:01 INFO worker started pid=41212026-09-24 09:00:05 INFO job 881 picked up2026-09-24 09:00:09 WARN job 881 slow: 3.2s2026-09-24 09:00:12 INFO job 881 done2026-09-24 09:00:15 INFO job 882 picked up2026-09-24 09:00:16 ERROR job 882 failed: timeout talking to redisdeploy.log
deploy 41 starteddeploy 41 finisheddeploy.log has no newline after its last line.
Examples
Example 1
Input: python tail.py -n 3 app.log
Output: 2026-09-24 09:00:12 INFO job 881 done
2026-09-24 09:00:15 INFO job 882 picked up
2026-09-24 09:00:16 ERROR job 882 failed: timeout talking to redis
Example 2
Input: python tail.py -n 1 app.log missing.log deploy.log; echo "exit: $?"
Output: ==> app.log <==
2026-09-24 09:00:16 ERROR job 882 failed: timeout talking to redis
tail.py: cannot open 'missing.log': No such file or directory
==> deploy.log <==
deploy 41 finished
exit: 1
Explanation: The missing file goes to stderr (shown here in order) and does not stop the other files. The exit code reports that something failed.
Example 3
Input: python tail.py -n -2 app.log; echo "exit: $?"
Output: usage: tail.py [-h] [-n N] FILE [FILE ...]
tail.py: error: argument -n/--lines: must be >= 0, got -2
exit: 2
Hints
Approach
Arguments. argparse handles -n/--lines, the default, one or more FILEs, -h, and usage errors. The non_negative_int type function raises ArgumentTypeError, which argparse reports as tail.py: error: argument -n/--lines: ... with exit code 2. Validation lives in one place and every error looks the same.
Keeping the last N lines. Iterating over a file object yields one line at a time. deque(f, maxlen=n) appends each line and silently drops the oldest once it holds n, so memory is bounded by n lines no matter how large the file is. With n=0 the deque stays empty, which is the correct answer.
Robustness.
- Each file is opened inside its own
try, so one missing or unreadable file prints a message on stderr and the loop continues.statusrecords the failure and becomes the exit code. errors="replace"stops a stray invalid byte in a log from crashing the tool.- A last line without a newline gets one added, so the next header does not end up glued to it.
- Headers only appear with more than one file, with a blank line between groups, matching
tail.
main(argv=None) returns an exit code instead of calling sys.exit inside, which makes it easy to call from tests with a list of arguments.
O(L)Space O(n)import argparseimport sysfrom collections import deque def non_negative_int(text): try: n = int(text) except ValueError: raise argparse.ArgumentTypeError(f"not a number: {text!r}") if n < 0: raise argparse.ArgumentTypeError(f"must be >= 0, got {n}") return n def last_lines(f, n): # deque with maxlen keeps only the newest n lines: O(n) memory for any file size. return deque(f, maxlen=n) def main(argv=None): parser = argparse.ArgumentParser(prog="tail.py", description="Print the last N lines of files.") parser.add_argument("-n", "--lines", type=non_negative_int, default=10, metavar="N", help="number of lines to print (default 10)") parser.add_argument("files", nargs="+", metavar="FILE") args = parser.parse_args(argv) status = 0 show_headers = len(args.files) > 1 for i, path in enumerate(args.files): try: with open(path, encoding="utf-8", errors="replace") as f: lines = last_lines(f, args.lines) except OSError as e: print(f"tail.py: cannot open '{path}': {e.strerror}", file=sys.stderr) status = 1 continue if show_headers: print(("\n" if i else "") + f"==> {path} <==") for line in lines: sys.stdout.write(line if line.endswith("\n") else line + "\n") return status if __name__ == "__main__": sys.exit(main())Follow-up questions
- Add
-fto keep the file open and print new lines as they are appended. How do you handle the file being rotated? - Implement the backwards-seek version and compare it on a 1 GB file.
- Read from stdin when no file is given, so
journalctl | tail.py -n 5works.
Frequently asked questions
Small CLI tools around logs are daily work, and a tail clone packs the key skills into one exercise: argument parsing, input validation, streaming a file that might be huge, per-file error handling and correct exit codes.
Yes. For a very large file, seek to the end and read fixed-size blocks backwards until you have counted n newlines, then print from there. That touches only the end of the file, which is what GNU tail does. The deque version is simpler and memory-safe, so it is a good first answer; mention the seek version as the optimization.
It is the Unix convention argparse follows: 2 means the command was called wrongly, 1 means it ran but something failed. Scripts that call your tool can tell a typo in their own arguments apart from a real failure.