Practical infra coding

Build a tail -n clone with argparse

mediumCLI tools Must-do

Problem statement

Write tail.py, a small clone of tail -n, using argparse:

  • tail.py [-n N] FILE [FILE ...] prints the last N lines of each file (default 10).
  • N must 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 way tail does.
  • A file that cannot be opened prints tail.py: cannot open 'NAME': REASON to 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

TEXT
2026-09-24 09:00:01 INFO worker started pid=4121
2026-09-24 09:00:05 INFO job 881 picked up
2026-09-24 09:00:09 WARN job 881 slow: 3.2s
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

deploy.log

TEXT
deploy 41 started
deploy 41 finished

deploy.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. status records 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.

ComplexityTime O(L)Space O(n)
Python
import argparse
import sys
from 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 -f to 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 5 works.

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.