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
5 changes: 0 additions & 5 deletions .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
[bumpversion]
current_version = 0.5.6
commit = False
tag = False
tag_name = v{new_version}
Expand All @@ -8,7 +7,3 @@ message = Bump version: {current_version} → {new_version}
[bumpversion:file:pyproject.toml]
search = version = "{current_version}"
replace = version = "{new_version}"

[bumpversion:file:dateutils/__init__.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
9 changes: 5 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,13 @@ doctest: ## Run doctests to verify documentation examples
@echo "${GREEN}✓ Doctests passed${NC}"

# Comprehensive Checks
check: ## Run all checks (lint, format-check, typecheck, test)
check: ## Run all checks (lint, format-check, typecheck, test, doctest)
@echo "${BLUE}Running comprehensive checks...${NC}"
@$(MAKE) --no-print-directory lint
@$(MAKE) --no-print-directory format-check
@$(MAKE) --no-print-directory typecheck
@$(MAKE) --no-print-directory test
@$(MAKE) --no-print-directory doctest
@echo "${GREEN}✓ All checks passed${NC}"

# Development Workflow
Expand Down Expand Up @@ -149,9 +150,9 @@ update-changelog: ## Update CHANGELOG.md with new version info
@echo "${GREEN}✓ CHANGELOG.md updated${NC}"

bump-version = @echo "${BLUE}Bumping $(1) version...${NC}"; \
OLD_VERSION=$$(grep '^current_version' .bumpversion.cfg | cut -d' ' -f3); \
uv run bump2version --allow-dirty $(1); \
NEW_VERSION=$$(grep '^current_version' .bumpversion.cfg | cut -d' ' -f3); \
OLD_VERSION=$$(grep '^version = ' pyproject.toml | cut -d'"' -f2); \
uv run bump2version --allow-dirty --current-version $$OLD_VERSION $(1); \
NEW_VERSION=$$(grep '^version = ' pyproject.toml | cut -d'"' -f2); \
$(MAKE) --no-print-directory update-changelog VERSION=$$NEW_VERSION; \
uv lock; \
echo "${BLUE}Staging all changes...${NC}"; \
Expand Down
30 changes: 29 additions & 1 deletion dateutils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
DateUtils Library Entry Point
"""

import re
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _distribution_version
from pathlib import Path

# Re-export public functions and constants for easier access
from .dateutils import (
DAYS_IN_MONTH_APPROX,
Expand Down Expand Up @@ -137,4 +142,27 @@
"workdays_between",
]

__version__ = "0.5.6"

def _version_from_pyproject() -> str | None:
"""Read the project version from pyproject.toml when distribution metadata is unavailable."""
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
version_pattern = re.compile(r'^version = "([^"]+)"$')
try:
for line in pyproject_path.read_text(encoding="utf-8").splitlines():
match = version_pattern.match(line.strip())
if match:
return match.group(1)
except OSError:
return None
return None


def _resolve_version() -> str:
"""Resolve package version from distribution metadata, falling back to pyproject in source checkouts."""
try:
return _distribution_version("dateutils-python")
except PackageNotFoundError:
return _version_from_pyproject() or "0+unknown"


__version__ = _resolve_version()
34 changes: 33 additions & 1 deletion dateutils/dateutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1449,7 +1449,11 @@ def _parse_english_textual_date(date_str: str) -> date | None:

def parse_datetime(datetime_str: str, formats: list[str] | None = None, dayfirst: bool = False) -> datetime | None:
"""
Parse a datetime string using multiple possible formats
Parse a datetime string using multiple possible formats.

Supports timezone-aware values in default parsing, including:
- UTC designator `Z`
- Numeric offsets with or without a colon (for example `+02:00`, `-0500`)

Args:
datetime_str: The datetime string to parse
Expand All @@ -1469,29 +1473,57 @@ def parse_datetime(datetime_str: str, formats: list[str] | None = None, dayfirst
# European/international style: day before month
formats = [
"%Y-%m-%d %H:%M:%S", # 2023-01-31 14:30:45
"%Y-%m-%d %H:%M:%S%z", # 2023-01-31 14:30:45+02:00
"%Y-%m-%d %H:%M:%S %z", # 2023-01-31 14:30:45 +02:00
"%Y-%m-%dT%H:%M:%S", # 2023-01-31T14:30:45
"%Y-%m-%dT%H:%M:%S%z", # 2023-01-31T14:30:45+02:00
"%Y-%m-%dT%H:%M:%S.%f", # 2023-01-31T14:30:45.123456
"%Y-%m-%dT%H:%M:%S.%f%z", # 2023-01-31T14:30:45.123456+02:00
"%Y-%m-%dT%H:%M:%SZ", # 2023-01-31T14:30:45Z
"%Y-%m-%dT%H:%M:%S.%fZ", # 2023-01-31T14:30:45.123456Z
"%d/%m/%Y %H:%M:%S", # 31/01/2023 14:30:45
"%d/%m/%Y %H:%M:%S%z", # 31/01/2023 14:30:45+02:00
"%d/%m/%Y %H:%M:%S %z", # 31/01/2023 14:30:45 +02:00
"%m/%d/%Y %H:%M:%S", # 01/31/2023 14:30:45 (fallback for US)
"%m/%d/%Y %H:%M:%S%z", # 01/31/2023 14:30:45+02:00
"%m/%d/%Y %H:%M:%S %z", # 01/31/2023 14:30:45 +02:00
"%d-%m-%Y %H:%M:%S", # 31-01-2023 14:30:45
"%d-%m-%Y %H:%M:%S%z", # 31-01-2023 14:30:45+02:00
"%d-%m-%Y %H:%M:%S %z", # 31-01-2023 14:30:45 +02:00
"%m-%d-%Y %H:%M:%S", # 01-31-2023 14:30:45 (fallback for US)
"%m-%d-%Y %H:%M:%S%z", # 01-31-2023 14:30:45+02:00
"%m-%d-%Y %H:%M:%S %z", # 01-31-2023 14:30:45 +02:00
"%Y/%m/%d %H:%M:%S", # 2023/01/31 14:30:45
"%Y/%m/%d %H:%M:%S%z", # 2023/01/31 14:30:45+02:00
"%Y/%m/%d %H:%M:%S %z", # 2023/01/31 14:30:45 +02:00
]
else:
# US style (default): month before day
formats = [
"%Y-%m-%d %H:%M:%S", # 2023-01-31 14:30:45
"%Y-%m-%d %H:%M:%S%z", # 2023-01-31 14:30:45+02:00
"%Y-%m-%d %H:%M:%S %z", # 2023-01-31 14:30:45 +02:00
"%Y-%m-%dT%H:%M:%S", # 2023-01-31T14:30:45
"%Y-%m-%dT%H:%M:%S%z", # 2023-01-31T14:30:45+02:00
"%Y-%m-%dT%H:%M:%S.%f", # 2023-01-31T14:30:45.123456
"%Y-%m-%dT%H:%M:%S.%f%z", # 2023-01-31T14:30:45.123456+02:00
"%Y-%m-%dT%H:%M:%SZ", # 2023-01-31T14:30:45Z
"%Y-%m-%dT%H:%M:%S.%fZ", # 2023-01-31T14:30:45.123456Z
"%m/%d/%Y %H:%M:%S", # 01/31/2023 14:30:45
"%m/%d/%Y %H:%M:%S%z", # 01/31/2023 14:30:45+02:00
"%m/%d/%Y %H:%M:%S %z", # 01/31/2023 14:30:45 +02:00
"%d/%m/%Y %H:%M:%S", # 31/01/2023 14:30:45 (fallback for European)
"%d/%m/%Y %H:%M:%S%z", # 31/01/2023 14:30:45+02:00
"%d/%m/%Y %H:%M:%S %z", # 31/01/2023 14:30:45 +02:00
"%m-%d-%Y %H:%M:%S", # 01-31-2023 14:30:45
"%m-%d-%Y %H:%M:%S%z", # 01-31-2023 14:30:45+02:00
"%m-%d-%Y %H:%M:%S %z", # 01-31-2023 14:30:45 +02:00
"%d-%m-%Y %H:%M:%S", # 31-01-2023 14:30:45 (fallback for European)
"%d-%m-%Y %H:%M:%S%z", # 31-01-2023 14:30:45+02:00
"%d-%m-%Y %H:%M:%S %z", # 31-01-2023 14:30:45 +02:00
"%Y/%m/%d %H:%M:%S", # 2023/01/31 14:30:45
"%Y/%m/%d %H:%M:%S%z", # 2023/01/31 14:30:45+02:00
"%Y/%m/%d %H:%M:%S %z", # 2023/01/31 14:30:45 +02:00
]

# Exception-driven parsing is expected here while trying multiple datetime layouts.
Expand Down
87 changes: 87 additions & 0 deletions tests/test_dateutils.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import datetime
import locale
import re
from importlib.metadata import PackageNotFoundError
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

import pytest
from freezegun import freeze_time

import dateutils
from dateutils.dateutils import (
add_business_days,
age_in_years,
Expand Down Expand Up @@ -58,6 +62,58 @@
)


def test_package_version_matches_pyproject() -> None:
"""Package __version__ should be derived from the pyproject source-of-truth."""
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
pyproject_contents = pyproject_path.read_text(encoding="utf-8")
version_match = re.search(r'^version = "([^"]+)"$', pyproject_contents, re.MULTILINE)
assert version_match is not None
assert dateutils.__version__ == version_match.group(1)


def test_version_from_pyproject_helper() -> None:
"""The helper should parse the version from pyproject.toml."""
assert dateutils._version_from_pyproject() == dateutils.__version__


def test_version_from_pyproject_helper_handles_io_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""The helper should return None when pyproject cannot be read."""

def _raise_oserror(*_args: object, **_kwargs: object) -> str:
raise OSError("read failure")

monkeypatch.setattr(Path, "read_text", _raise_oserror)
assert dateutils._version_from_pyproject() is None


def test_version_from_pyproject_helper_handles_missing_version_line(monkeypatch: pytest.MonkeyPatch) -> None:
"""The helper should return None if no version line exists in pyproject content."""
monkeypatch.setattr(Path, "read_text", lambda *_args, **_kwargs: '[project]\nname = "dateutils-python"\n')
assert dateutils._version_from_pyproject() is None


def test_resolve_version_falls_back_to_pyproject(monkeypatch: pytest.MonkeyPatch) -> None:
"""If package metadata is unavailable, resolve version from pyproject helper."""

def _raise_not_found(_distribution_name: str) -> str:
raise PackageNotFoundError

monkeypatch.setattr(dateutils, "_distribution_version", _raise_not_found)
monkeypatch.setattr(dateutils, "_version_from_pyproject", lambda: "9.9.9")
assert dateutils._resolve_version() == "9.9.9"


def test_resolve_version_returns_unknown_when_no_sources(monkeypatch: pytest.MonkeyPatch) -> None:
"""If no version source is available, return the sentinel unknown version."""

def _raise_not_found(_distribution_name: str) -> str:
raise PackageNotFoundError

monkeypatch.setattr(dateutils, "_distribution_version", _raise_not_found)
monkeypatch.setattr(dateutils, "_version_from_pyproject", lambda: None)
assert dateutils._resolve_version() == "0+unknown"


@freeze_time("2024-03-27")
def test_utc_today() -> None:
"""Test that utc_today returns the current UTC date."""
Expand Down Expand Up @@ -1243,11 +1299,41 @@ def test_parse_datetime() -> None:
with_ms_z = parse_datetime("2024-03-27T14:30:45.123456Z")
assert with_ms_z == datetime.datetime(2024, 3, 27, 14, 30, 45, 123456, tzinfo=datetime.timezone.utc)

# Test with timezone offset with colon (ISO)
with_tz_colon = parse_datetime("2024-03-27T14:30:45+02:00")
assert with_tz_colon is not None
assert with_tz_colon.tzinfo is not None
assert with_tz_colon.tzinfo.utcoffset(None) == datetime.timedelta(hours=2)

# Test with timezone offset without colon (ISO)
with_tz_no_colon = parse_datetime("2024-03-27T14:30:45-0500")
assert with_tz_no_colon is not None
assert with_tz_no_colon.tzinfo is not None
assert with_tz_no_colon.tzinfo.utcoffset(None) == datetime.timedelta(hours=-5)

# Test other date formats with time
assert parse_datetime("27/03/2024 14:30:45") == datetime.datetime(2024, 3, 27, 14, 30, 45) # DD/MM/YYYY
assert parse_datetime("03/27/2024 14:30:45") == datetime.datetime(2024, 3, 27, 14, 30, 45) # MM/DD/YYYY
assert parse_datetime("27-03-2024 14:30:45") == datetime.datetime(2024, 3, 27, 14, 30, 45) # DD-MM-YYYY
assert parse_datetime("2024/03/27 14:30:45") == datetime.datetime(2024, 3, 27, 14, 30, 45) # YYYY/MM/DD
assert parse_datetime("03/27/2024 14:30:45-0500") == datetime.datetime(
2024,
3,
27,
14,
30,
45,
tzinfo=datetime.timezone(datetime.timedelta(hours=-5)),
)
assert parse_datetime("03/27/2024 14:30:45 +02:00") == datetime.datetime(
2024,
3,
27,
14,
30,
45,
tzinfo=datetime.timezone(datetime.timedelta(hours=2)),
)

# Test with custom format
assert parse_datetime("2024|03|27|14|30|45", formats=["%Y|%m|%d|%H|%M|%S"]) == datetime.datetime(
Expand All @@ -1267,6 +1353,7 @@ def test_parse_datetime() -> None:
# Test invalid datetime
assert parse_datetime("not a datetime") is None
assert parse_datetime("2024-03-27T25:70:80") is None # Invalid hours, minutes, seconds
assert parse_datetime("2024-03-27T14:30:45+25:00") is None # Invalid timezone offset


def test_parse_iso8601() -> None:
Expand Down