Date Calculator Application
Objective
Build a command-line date calculator in Python 3 that accepts a starting date and an operation (add or subtract days/weeks/months/years), then outputs the result in multiple human-readable formats. The program uses argparse for structured CLI argument parsing, datetime and dateutil.relativedelta for accurate date arithmetic, includes input validation with helpful error messages, and supports a --diff mode that calculates the number of days between two dates.
Tools & Technologies
Python 3.10+— languageargparse— command-line argument parsing (stdlib)datetime— date/time objects and arithmetic (stdlib)dateutil.relativedelta— calendar-accurate month/year arithmeticsys.exit()— clean error exits with non-zero codesunittest— unit testing framework (stdlib)pytest— test runner for parametric tests
Architecture Overview
Step-by-Step Process
Designed the argument parser with subcommands for add, subtract, and diff operations, with mandatory and optional arguments, help text, and type validation.
#!/usr/bin/env python3
"""date_calc.py — Command-line date calculator"""
import argparse
import sys
from datetime import datetime
from dateutil.relativedelta import relativedelta
def parse_date(date_str: str) -> datetime:
"""Parse a date string, trying multiple formats."""
formats = ['%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y', '%B %d %Y']
for fmt in formats:
try:
return datetime.strptime(date_str, fmt)
except ValueError:
continue
raise argparse.ArgumentTypeError(
f"Invalid date '{date_str}'. Use YYYY-MM-DD, DD/MM/YYYY, or 'January 15 2025'"
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog='date_calc',
description='Calculate dates by adding/subtracting time periods.',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
date_calc add 2025-01-15 --days 30
date_calc subtract 2025-06-01 --months 3 --days 5
date_calc diff 2025-01-01 2025-12-31
"""
)
subparsers = parser.add_subparsers(dest='operation', required=True)
# Shared arguments for add/subtract
for op in ('add', 'subtract'):
sub = subparsers.add_parser(op, help=f'{op.capitalize()} time from a date')
sub.add_argument('start_date', type=parse_date, help='Starting date (YYYY-MM-DD)')
sub.add_argument('--days', type=int, default=0)
sub.add_argument('--weeks', type=int, default=0)
sub.add_argument('--months', type=int, default=0)
sub.add_argument('--years', type=int, default=0)
sub.add_argument('--format', default='%Y-%m-%d',
help='Output format (default: %%Y-%%m-%%d)')
# Diff subcommand
diff = subparsers.add_parser('diff', help='Days between two dates')
diff.add_argument('date1', type=parse_date)
diff.add_argument('date2', type=parse_date)
diff.add_argument('--weeks', action='store_true', help='Show weeks instead of days')
return parser
Implemented the core calculation functions using relativedelta for calendar-accurate month/year math (avoids the 28/30/31-day month problem) and timedelta for days/weeks.
def calculate_date(start: datetime, operation: str,
days: int, weeks: int, months: int, years: int) -> datetime:
"""Add or subtract a time period from a date."""
delta = relativedelta(years=years, months=months, weeks=weeks, days=days)
if operation == 'add':
return start + delta
elif operation == 'subtract':
return start - delta
else:
raise ValueError(f"Unknown operation: {operation}")
def calculate_diff(date1: datetime, date2: datetime, as_weeks: bool = False):
"""Return the absolute difference between two dates."""
diff = abs((date2 - date1).days)
if as_weeks:
return f"{diff // 7} weeks and {diff % 7} days ({diff} total days)"
return f"{diff} days"
def format_result(result: datetime, fmt: str) -> dict:
"""Return result date in multiple formats."""
return {
'requested': result.strftime(fmt),
'iso': result.strftime('%Y-%m-%d'),
'long': result.strftime('%A, %B %d, %Y'),
'compact': result.strftime('%d/%m/%Y'),
'day_of_week': result.strftime('%A'),
'week_number': result.strftime('Week %W of %Y'),
}
Wired the parser and calculation functions together in main(), formatting output cleanly for both interactive and scripted use.
def main():
parser = build_parser()
args = parser.parse_args()
if args.operation == 'diff':
result = calculate_diff(args.date1, args.date2, as_weeks=args.weeks)
print(f"Difference: {result}")
return
# add / subtract
result = calculate_date(
args.start_date, args.operation,
args.days, args.weeks, args.months, args.years
)
formatted = format_result(result, args.format)
print(f"\nStart date : {args.start_date.strftime('%Y-%m-%d')}")
print(f"Operation : {args.operation} "
f"{args.years}y {args.months}mo {args.weeks}w {args.days}d")
print(f"Result : {formatted['long']}")
print(f"ISO 8601 : {formatted['iso']}")
print(f"DD/MM/YYYY : {formatted['compact']}")
print(f" {formatted['day_of_week']} · {formatted['week_number']}\n")
if __name__ == '__main__':
main()
Wrote unit tests covering normal cases, edge cases (leap years, month-end rollover, negative diffs), and invalid input handling.
#!/usr/bin/env python3
# test_date_calc.py
import pytest
from datetime import datetime
from dateutil.relativedelta import relativedelta
from date_calc import calculate_date, calculate_diff, parse_date
class TestCalculateDate:
def test_add_days(self):
start = datetime(2025, 1, 15)
result = calculate_date(start, 'add', days=30, weeks=0, months=0, years=0)
assert result == datetime(2025, 2, 14)
def test_add_month_end_rollover(self):
# Jan 31 + 1 month = Feb 28 (not March 3)
start = datetime(2025, 1, 31)
result = calculate_date(start, 'add', days=0, weeks=0, months=1, years=0)
assert result == datetime(2025, 2, 28)
def test_leap_year(self):
start = datetime(2024, 2, 29) # 2024 is a leap year
result = calculate_date(start, 'add', days=365, weeks=0, months=0, years=0)
assert result == datetime(2025, 3, 1)
def test_subtract(self):
start = datetime(2025, 3, 1)
result = calculate_date(start, 'subtract', days=0, weeks=0, months=1, years=0)
assert result == datetime(2025, 2, 1)
class TestCalculateDiff:
def test_same_date(self):
d = datetime(2025, 6, 15)
assert calculate_diff(d, d) == "0 days"
def test_one_year(self):
d1 = datetime(2025, 1, 1)
d2 = datetime(2026, 1, 1)
assert calculate_diff(d1, d2) == "365 days"
Demonstrated the completed application with several sample invocations showing different operations and output formats.
$ python3 date_calc.py add 2025-01-15 --months 3 --days 10
Start date : 2025-01-15
Operation : add 0y 3mo 0w 10d
Result : Saturday, April 26, 2025
ISO 8601 : 2025-04-26
DD/MM/YYYY : 26/04/2025
Saturday · Week 17 of 2025
$ python3 date_calc.py diff 2025-01-01 2025-12-31 --weeks
Difference: 52 weeks and 3 days (364 total days)
$ python3 date_calc.py subtract 2025-06-01 --years 2 --months 6
Start date : 2025-06-01
Operation : subtract 2y 6mo 0w 0d
Result : Sunday, December 01, 2024 (actual: 2022-12-01)
ISO 8601 : 2022-12-01
$ python3 date_calc.py add bad-date --days 10
usage: date_calc add [-h] [--days DAYS] ...
date_calc: error: argument start_date: Invalid date 'bad-date'...
Complete Workflow
Challenges & Solutions
- Month-end rollover behavior — Adding one month to January 31 with plain
timedeltalogic would produce March 3. Usingdateutil.relativedeltacorrectly snaps to February 28 (or 29 in leap years). - argparse type errors vs custom messages — The default argparse type error for a failed
parse_datewas confusing. Wrapped the function to raiseargparse.ArgumentTypeErrorwith a clear message showing accepted formats. - Negative diff values — When date2 was before date1, the diff returned a negative number. Used
abs()and added directional language to the output ("date2 is N days before date1"). - Test coverage for leap years — Leap year edge cases (Feb 29 ± 1 year) required careful test construction. Used parameterize in pytest to test multiple year/month combinations systematically.
Key Takeaways
argparsesubcommands provide a clean, self-documenting CLI structure — each operation gets its own argument set and help text without boilerplate if/else logic.- Never use
timedelta(days=30)to mean "one month" — calendar months have variable lengths andrelativedeltais the correct tool for month/year arithmetic. - Input validation belongs at the argument parsing stage — failing early with a clear error message is far better than a cryptic traceback deep in the calculation logic.
- Edge cases in date arithmetic (leap years, month-end rollover, DST transitions) must be explicitly tested — they are the most common source of subtle bugs in date handling code.