Last active
May 9, 2025 23:05
-
-
Save dalepotter/19a49f861e170b66ae7555e6f40a239d to your computer and use it in GitHub Desktop.
A CLI tool for date arithmetic supporting days, weeks, months, and years using only the Python standard library.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Test using `$ pytest date_add.py ` | |
import calendar | |
import sys | |
import unittest | |
from datetime import date, timedelta | |
def parse_input_arithmetic(inputs): | |
""" | |
Parses a list of arithmetic time adjustments and returns a dictionary of time units and net values. | |
Each input string must follow the format: {+,-}<integer>{d,w,m,y}, where: | |
- '+' or '-' indicates addition or subtraction, | |
- the integer is the duration, | |
- and the suffix denotes the time unit: | |
'd' = days, 'w' = weeks, 'm' = months, 'y' = years. | |
Example: | |
inputs = ['+3d', '-1w', '+2m'] | |
returns: {'days': 3, 'weeks': -1, 'months': 2} | |
Parameters: | |
inputs (list[str]): List of strings representing time adjustments. | |
Returns: | |
dict[str, int]: Dictionary with keys as time unit names ('days', 'weeks', 'months', 'years') | |
and integer values representing net change. | |
Raises: | |
ValueError: If input format is invalid or uses unsupported time units. | |
""" | |
mapping_input_to_timedelta_arg = { | |
"d": "days", | |
"w": "weeks", | |
"m": "months", | |
"y": "years" | |
} | |
output = dict() | |
for input in inputs: | |
operator = input[0] | |
time_period = input[-1:] | |
raw_duration = input.replace(operator, "").replace(time_period, "") | |
if operator not in ["+", "-"]: | |
raise ValueError(f'Operator {operator} not supported. Only "+" and "-" are allowed.') | |
try: | |
timedelta_kwarg = mapping_input_to_timedelta_arg[time_period] | |
except KeyError: | |
raise ValueError(f'Period {time_period} not supported. Only {", ".join(mapping_input_to_timedelta_arg.keys())} are allowed.') | |
try: | |
duration = int(raw_duration) | |
except ValueError: | |
raise ValueError(f'Duration {raw_duration} not supported. Only integers are allowed.') | |
current_time_period_value = output.get(timedelta_kwarg, 0) | |
output[timedelta_kwarg] = eval(f"{current_time_period_value} {operator} {duration}") | |
return output | |
def add_months_and_years(start_date, months=0, years=0): | |
""" | |
Returns a new date by adding the specified number of months and years to a start date. | |
This function accounts for varying month lengths and leap years. | |
If the resulting day would be invalid in the target month (e.g., February 30), | |
it is adjusted to the last valid day of that month. | |
Parameters: | |
start_date (date): The initial date to adjust. | |
months (int): Number of months to add (can be negative). | |
years (int): Number of years to add (can be negative). | |
Returns: | |
date: The adjusted date with months and years applied. | |
""" | |
# Convert total months and years into an absolute number of months | |
new_month = start_date.month + months + (years * 12) # There are 12 months in a year | |
# Calculate the resulting year and month from total months | |
year = start_date.year + (new_month - 1) // 12 # Account for overflow into future years | |
month = (new_month - 1) % 12 + 1 # Normalize month to 1–12 | |
# Get the last valid day of the resulting month (e.g., 28/29 for February) | |
last_day_of_month = calendar.monthrange(year, month)[1] | |
# Use the smaller of the original day or the last day of the new month | |
day = min(start_date.day, last_day_of_month) | |
return date(year, month, day) | |
def date_add(): | |
""" | |
Command-line entry point to compute a date by applying arithmetic time adjustments. | |
Accepts an ISO-format start date followed by one or more adjustment arguments in the form: | |
{+,-}<integer>{d,w,m,y} | |
Supported units: | |
d = days | |
w = weeks | |
m = months | |
y = years | |
Example usage: | |
python date_add.py 2024-01-01 +2w -3d +1m -1y | |
The function: | |
- Parses command-line arguments, | |
- Computes cumulative date changes, | |
- Prints the resulting date to stdout. | |
Raises: | |
SystemExit: If incorrect arguments are provided. | |
ValueError: If an individual time adjustment is malformed. | |
""" | |
if len(sys.argv) < 3: | |
print( | |
"Usage: python date_add.py <ISO-format-date> <{+,-}1{d, w, m, y}> ...\n" | |
"Example: python date_add.py 2000-01-01 +9m +18d -1y" | |
) | |
sys.exit(1) | |
start_date = date.fromisoformat(sys.argv[1]) | |
additions = sys.argv[2:] | |
total_additions = parse_input_arithmetic(additions) | |
# Handle days and weeks using timedelta | |
days = total_additions.get("days", 0) | |
weeks = total_additions.get("weeks", 0) | |
interim_date = start_date + timedelta(days=days, weeks=weeks) | |
# Handle months and years manually | |
months = total_additions.get("months", 0) | |
years = total_additions.get("years", 0) | |
final_date = add_months_and_years(interim_date, months=months, years=years) | |
print(final_date) | |
if __name__ == "__main__": | |
try: | |
date_add() | |
except (KeyError, ValueError) as exc: | |
print(exc.args[0]) | |
class UnitTests(unittest.TestCase): | |
def test_parse_input_arithmetic_simple_cases(self): | |
"""A simple case addition should return a dict.""" | |
param_list = [(["+1d"], {"days": 1}), (["+1m"], {"months": 1})] | |
for input, expected_result in param_list: | |
with self.subTest(input): | |
result = parse_input_arithmetic(input) | |
self.assertEqual(result, expected_result) | |
def test_parse_input_arithmetic_exception_for_invalid_inputs(self): | |
"""Invalid inputs raise an error.""" | |
param_list = [ | |
["1d"], # Missing operator symbol | |
["+1"], # Missing day/week/month/year | |
["/1d"], # Bad operator | |
["+1q"], # Bad period | |
["+1d", "2d"], # Multiple inputs. one missing operator symbol | |
["+1d", "+2"], # Multiple inputs. one missing day/week/month/year | |
] | |
for input in param_list: | |
with self.subTest(input), self.assertRaises(ValueError) as context: | |
parse_input_arithmetic(input) | |
self.assertTrue('not supported' in context.exception.args[0]) | |
def test_parse_input_arithmetic_complex_logic(self): | |
"""A complex case addition should return a dict.""" | |
result = parse_input_arithmetic(["+1d", "+2d", "-3d", "+5d", "+1w", "+6m","-1m", "+1y"]) | |
self.assertEqual(result, {"days": 5, "weeks": 1, "months": 5, "years": 1}) | |
class AddMonthsAndYearsTests(unittest.TestCase): | |
def test_add_months_within_same_year(self): | |
result = add_months_and_years(date(2023, 1, 15), months=2) | |
self.assertEqual(result, date(2023, 3, 15)) | |
def test_add_months_across_year_boundary(self): | |
result = add_months_and_years(date(2023, 11, 10), months=3) | |
self.assertEqual(result, date(2024, 2, 10)) | |
def test_subtract_months_across_year_boundary(self): | |
result = add_months_and_years(date(2023, 1, 10), months=-2) | |
self.assertEqual(result, date(2022, 11, 10)) | |
def test_add_month_to_31st_day(self): | |
# Jan 31 + 1 month = Feb 28 (non-leap year) | |
result = add_months_and_years(date(2023, 1, 31), months=1) | |
self.assertEqual(result, date(2023, 2, 28)) | |
def test_add_years_from_feb_29_leap_year(self): | |
# Feb 29, 2020 + 4 years = Feb 29, 2024 (leap year to leap year) | |
result = add_months_and_years(date(2020, 2, 29), years=4) | |
self.assertEqual(result, date(2024, 2, 29)) | |
def test_add_years_to_non_leap_year(self): | |
# Feb 29, 2020 + 1 year = Feb 28, 2021 (adjusted to last valid day) | |
result = add_months_and_years(date(2020, 2, 29), years=1) | |
self.assertEqual(result, date(2021, 2, 28)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment