Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 20 additions & 19 deletions dateutils/dateutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -808,14 +808,13 @@ def find_nth_weekday(year: int, month: int, weekday: int, n: int, from_end: bool
while d.weekday() != weekday:
d -= timedelta(days=1)
return d
else:
# Start from the first day of the month
d = date(year, month, 1)
while d.weekday() != weekday:
d += timedelta(days=1)
# Move to the nth occurrence
d += timedelta(days=7 * (n - 1))
return d
# Start from the first day of the month
d = date(year, month, 1)
while d.weekday() != weekday:
d += timedelta(days=1)
# Move to the nth occurrence
d += timedelta(days=7 * (n - 1))
return d

# 3rd Monday in January (Martin Luther King Jr. Day)
all_holiday_types["MLK_DAY"] = find_nth_weekday(year, 1, 0, 3) # Monday = 0
Expand Down Expand Up @@ -1133,13 +1132,13 @@ def _ts_difference(timestamp: int | datetime | None = None, now_override: int |

if timestamp is None:
return timedelta(0)
elif isinstance(timestamp, int):
if isinstance(timestamp, int):
try:
ts_dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
except (ValueError, OSError, OverflowError) as e:
raise ValueError(f"Invalid timestamp: {timestamp}") from e
return now - ts_dt
elif isinstance(timestamp, datetime):
if isinstance(timestamp, datetime):
# Handle naive datetimes by assuming UTC
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=timezone.utc)
Expand Down Expand Up @@ -1239,14 +1238,14 @@ def pretty_date(timestamp: int | datetime | None = None, now_override: int | Non
if second_diff < 2 * SECONDS_IN_HOUR:
return "in an hour"
return "in " + str(second_diff // SECONDS_IN_HOUR) + " hours"
elif day_diff == 1:
if day_diff == 1:
return "Tomorrow"
elif day_diff < DAYS_IN_WEEK:
if day_diff < DAYS_IN_WEEK:
return "in " + str(day_diff) + " days"
elif day_diff < DAYS_IN_MONTH_MAX:
if day_diff < DAYS_IN_MONTH_MAX:
weeks = day_diff // DAYS_IN_WEEK
return f"in {weeks} week" if weeks == 1 else f"in {weeks} weeks"
elif day_diff < DAYS_IN_YEAR:
if day_diff < DAYS_IN_YEAR:
months = day_diff // DAYS_IN_MONTH_APPROX
return f"in {months} month" if months == 1 else f"in {months} months"
years = day_diff // DAYS_IN_YEAR
Expand All @@ -1271,12 +1270,12 @@ def pretty_date(timestamp: int | datetime | None = None, now_override: int | Non
return str(second_diff // SECONDS_IN_HOUR) + " hours ago"
if day_diff == 1:
return "Yesterday"
elif day_diff < DAYS_IN_WEEK:
if day_diff < DAYS_IN_WEEK:
return str(day_diff) + " days ago"
elif day_diff < DAYS_IN_MONTH_MAX:
if day_diff < DAYS_IN_MONTH_MAX:
weeks = day_diff // DAYS_IN_WEEK
return f"{weeks} week ago" if weeks == 1 else f"{weeks} weeks ago"
elif day_diff < DAYS_IN_YEAR:
if day_diff < DAYS_IN_YEAR:
months = day_diff // DAYS_IN_MONTH_APPROX
return f"{months} month ago" if months == 1 else f"{months} months ago"
years = day_diff // DAYS_IN_YEAR
Expand Down Expand Up @@ -1412,10 +1411,11 @@ def parse_date(
else:
parse_formats = cast(list[str], formats)

# Exception-driven parsing is expected here while trying multiple date layouts.
for fmt in parse_formats:
try:
return datetime.strptime(date_str, fmt).date()
except ValueError:
except ValueError: # noqa: PERF203
continue

# Locale-independent fallback for English month names in the default parser.
Expand Down Expand Up @@ -1494,14 +1494,15 @@ def parse_datetime(datetime_str: str, formats: list[str] | None = None, dayfirst
"%Y/%m/%d %H:%M:%S", # 2023/01/31 14:30:45
]

# Exception-driven parsing is expected here while trying multiple datetime layouts.
for fmt in formats:
try:
dt = datetime.strptime(datetime_str, fmt)
# Handle ISO 8601 format with timezone designator
if datetime_str.endswith("Z"):
dt = dt.replace(tzinfo=timezone.utc)
return dt
except ValueError:
except ValueError: # noqa: PERF203
continue

return None
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ select = [
"PL", # pylint
"RUF", # ruff-specific rules
]
extend-select = ["S", "PT", "RET", "PERF"]
ignore = [
"E501", # line length violations
"PLR0913", # too many arguments
Expand Down
7 changes: 6 additions & 1 deletion scripts/update_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@

import argparse
import re
import shutil
import subprocess
from datetime import datetime
from pathlib import Path


def run_git_command(cmd: list[str]) -> str:
"""Run a git command and return the output."""
git_binary = shutil.which("git")
if git_binary is None:
return ""

try:
result = subprocess.run(["git", *cmd], capture_output=True, text=True, check=True)
result = subprocess.run([git_binary, *cmd], capture_output=True, text=True, check=True) # noqa: S603
return result.stdout.strip()
except subprocess.CalledProcessError:
return ""
Expand Down
10 changes: 5 additions & 5 deletions tests/test_dateutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ def test_workdays_between() -> None:
# End date before start date (should raise ValueError now)
start = datetime.date(2024, 3, 4) # Monday
end = datetime.date(2024, 3, 1) # Friday of previous week
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="must be <= end_date"):
workdays_between(start, end)


Expand Down Expand Up @@ -850,7 +850,7 @@ def test_convert_timezone() -> None:
assert tokyo_from_ny.hour == 21 # 8 EDT = 21 JST

# Test error case with naive datetime
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="timezone information"):
convert_timezone(datetime.datetime(2024, 3, 27, 12, 0, 0), "America/New_York")

# Test error case with invalid timezone name (now raises ValueError instead of ZoneInfoNotFoundError)
Expand Down Expand Up @@ -1861,7 +1861,7 @@ def test_date_add_business_days_properties(date_input: datetime.date) -> None:


@pytest.mark.parametrize(
"year,expected",
("year", "expected"),
[
(2020, True), # Divisible by 4 and 400
(2024, True), # Divisible by 4 but not 100
Expand All @@ -1884,7 +1884,7 @@ def test_is_leap_year_properties(year: int, expected: bool) -> None:


@pytest.mark.parametrize(
"date1,date2,expected_days",
("date1", "date2", "expected_days"),
[
# One week apart, 5 business days
(datetime.date(2024, 3, 25), datetime.date(2024, 3, 29), 5),
Expand All @@ -1904,7 +1904,7 @@ def test_workdays_between_properties(date1: datetime.date, date2: datetime.date,

# Property 2: Reversed order should raise ValueError (with new validation)
if date1 < date2:
with pytest.raises(ValueError):
with pytest.raises(ValueError, match="must be <= end_date"):
workdays_between(date2, date1)

# Property 3: Consistency with next_business_day
Expand Down