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+ — language
  • argparse — command-line argument parsing (stdlib)
  • datetime — date/time objects and arithmetic (stdlib)
  • dateutil.relativedelta — calendar-accurate month/year arithmetic
  • sys.exit() — clean error exits with non-zero codes
  • unittest — unit testing framework (stdlib)
  • pytest — test runner for parametric tests

Architecture Overview

flowchart TD CLI[Command Line\nargv input] --> Parser[argparse\nArgumentParser] Parser --> Validate[Input Validation\ndate format + range] Validate -->|invalid| Error[Error Message\nsys.exit 1] Validate -->|valid| Calc[Date Calculator\ndatetime + relativedelta] Calc --> AddSub[add / subtract mode\nresult date] Calc --> Diff[diff mode\ndays between dates] AddSub --> Format[Output Formatter\nmultiple date formats] Diff --> Format Format --> Output[Stdout\nformatted result] style CLI fill:#1a1a2e,stroke:#00d4ff,color:#e0e0e0 style Output fill:#1a1a2e,stroke:#00ff88,color:#e0e0e0 style Error fill:#181818,stroke:#1e1e1e,color:#888 style Parser fill:#181818,stroke:#1e1e1e,color:#888 style Validate fill:#181818,stroke:#1e1e1e,color:#888 style Calc fill:#181818,stroke:#1e1e1e,color:#888 style Format fill:#181818,stroke:#1e1e1e,color:#888

Step-by-Step Process

01
argparse CLI Design

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
02
Date Arithmetic Implementation

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'),
    }
03
Main Entry Point & Output

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()
04
Unit Tests

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"
05
Sample CLI Output

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

flowchart LR A[Design CLI\nargparse subcommands] --> B[Implement\nparse_date validator] B --> C[Core calculation\nrelativedelta logic] C --> D[Output formatter\nmultiple date formats] D --> E[Write unit tests\npytest edge cases] E --> F[Test all\nCLI argument combos] F --> G[Error handling\nvalidation messages] style A fill:#1a1a2e,stroke:#00d4ff,color:#e0e0e0 style G fill:#1a1a2e,stroke:#00ff88,color:#e0e0e0 style B fill:#181818,stroke:#1e1e1e,color:#888 style C fill:#181818,stroke:#1e1e1e,color:#888 style D fill:#181818,stroke:#1e1e1e,color:#888 style E fill:#181818,stroke:#1e1e1e,color:#888 style F fill:#181818,stroke:#1e1e1e,color:#888

Challenges & Solutions

  • Month-end rollover behavior — Adding one month to January 31 with plain timedelta logic would produce March 3. Using dateutil.relativedelta correctly snaps to February 28 (or 29 in leap years).
  • argparse type errors vs custom messages — The default argparse type error for a failed parse_date was confusing. Wrapped the function to raise argparse.ArgumentTypeError with 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

  • argparse subcommands 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 and relativedelta is 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.