Metadata-Version: 2.4
Name: dateexpr
Version: 1.0.0
Summary: A Python library for date calculations using ISO8601 period/duration elements
Author-email: Eric <eric@thinklearning.ca>
License: MIT
Project-URL: Homepage, https://github.com/etietz/dateexpr
Project-URL: Repository, https://github.com/etietz/dateexpr
Project-URL: Issues, https://github.com/etietz/dateexpr/issues
Keywords: date,datetime,iso8601,period,duration,calendar
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pytz>=2024.1
Requires-Dist: python-dateutil>=2.9.0
Provides-Extra: dev
Requires-Dist: pytest>=9.0.0; extra == "dev"
Dynamic: license-file

# DateExpr

A Python library for date calculations using ISO8601 period/duration elements. This library was created to support complex date/time calculations declaratively, rather than programmatically, allowing periods/intervals to be stored in files/databases for configuration purposes.

## Installation

```bash
pip install dateexpr
```

## Features

- **ISO8601 Duration Support**: Use standard period expressions like `P1Y`, `P3M`, `P1W`, `P1D`, `PT1H`, `PT30M`, `PT1S`
- **Variable-Duration Period Handling**: Correctly handles months and years that have different lengths
- **Month-End Preservation**: `Jan 31 + P1M = Feb 28/29` (stays at month end)
- **Timezone Support**: Full timezone support via `pytz`
- **Fluent Interface**: Chain operations for readable date calculations
- **Predefined Aliases**: Convenient shortcuts like `@today`, `@monthly`, `@quarter`

## Quick Start

```python
from dateexpr import DateExpr
import pytz

# Create from ISO8601 date string
expr = DateExpr("2024-01-15")

# Add periods
next_month = expr.advanced("+P1M")      # 2024-02-15
next_week = expr.advanced("+P1W")       # 2024-01-22
tomorrow = expr.advanced("+P1D")        # 2024-01-16

# Use predefined aliases
today = DateExpr("@today")
month_end = expr.advanced("@month-end")  # 2024-01-31

# Month-end preservation
jan_31 = DateExpr("2024-01-31")
feb_end = jan_31.advanced("+P1M")        # 2024-02-29 (leap year)

# Timezone handling
tz = pytz.timezone("America/Toronto")
expr = DateExpr("2024-01-15T10:30:00", tz=tz)
print(expr.iso8601())                    # 2024-01-15T10:30:00-0500
print(expr.to_utc())                     # UTC datetime
```

## Period Syntax

### ISO8601 Durations

| Pattern         | Meaning                                               |
| --------------- | ----------------------------------------------------- |
| `P1Y`           | 1 year                                                |
| `P3M`           | 3 months                                              |
| `P1W`           | 1 week                                                |
| `P1D`           | 1 day                                                 |
| `PT1H`          | 1 hour                                                |
| `PT30M`         | 30 minutes                                            |
| `PT1S`          | 1 second                                              |
| `P1Y3M1DT1H30M` | Combined: 1 year, 3 months, 1 day, 1 hour, 30 minutes |

### Prefixes

| Prefix | Meaning                    |
| ------ | -------------------------- |
| `+P1M` | Add 1 month                |
| `-P1M` | Subtract 1 month           |
| `P0M`  | Truncate to start of month |
| `P0D`  | Truncate to start of day   |

### Predefined Aliases

#### Date/Time Navigation

| Alias           | Equivalent       | Description                 |
| --------------- | ---------------- | --------------------------- |
| `@today`        | `P0D`            | Start of current day        |
| `@tomorrow`     | `P0D,+P1D`       | Start of next day           |
| `@yesterday`    | `P1D`            | Start of previous day       |
| `@this_date`    | `P0D`            | Start of current day        |
| `@next_date`    | `P0D,+P1D`       | Start of next day           |
| `@this_week`    | `P0W`            | Start of current week       |
| `@next_week`    | `P0W,+P1W`       | Start of next week          |
| `@last_week`    | `P0W,-P1W`       | Start of previous week      |
| `@this_month`   | `P0M`            | Start of current month      |
| `@next_month`   | `P0M,+P1M`       | Start of next month         |
| `@last_month`   | `P1M`            | Start of previous month     |
| `@month-end`    | `P0M,+P1M,-P1D`  | Last day of current month   |
| `@month-mid`    | `P0M,+P1M,+P14D` | Middle of next month (15th) |
| `@this_quarter` | `P0Q`            | Start of current quarter    |
| `@next_quarter` | `P0Q,+P1Q`       | Start of next quarter       |
| `@last_quarter` | `P1Q`            | Start of previous quarter   |
| `@this_year`    | `P0Y`            | Start of current year       |
| `@next_year`    | `P0Y,+P1Y`       | Start of next year          |
| `@last_year`    | `P1Y`            | Start of previous year      |

#### Frequency Aliases (for `advance()` and `split()`)

| Alias            | Equivalent | Description    |
| ---------------- | ---------- | -------------- |
| `@daily`         | `+P1D`     | Every day      |
| `@weekly`        | `+P1W`     | Every week     |
| `@bi-weekly`     | `+P2W`     | Every 2 weeks  |
| `@monthly`       | `+P1M`     | Every month    |
| `@quarterly`     | `+P3M`     | Every 3 months |
| `@semi-annually` | `+P6M`     | Every 6 months |
| `@annually`      | `+P1Y`     | Every year     |

## Week Start Configuration

By default, weeks start on Monday (ISO 8601). You can configure a different week start day using the `week_start` parameter:

```python
# Default: Monday (week_start=0)
expr = DateExpr("2024-01-17")  # Wednesday
expr.advanced("P0W")           # → Monday 2024-01-15

# US convention: Sunday (week_start=6)
expr = DateExpr("2024-01-17", week_start=6)  # Wednesday
expr.advanced("P0W")           # → Sunday 2024-01-14

# Week start values: 0=Monday, 1=Tuesday, ..., 6=Sunday
```

The `week_start` setting affects:

- **`P0W` truncation**: Truncates to the configured first day of the week
- **Week aliases**: `@this_week`, `@next_week`, `@last_week` respect the setting
- **`copy()` method**: Preserves the `week_start` value

```python
# Week aliases with Sunday start
expr = DateExpr("2024-01-17", week_start=6)  # Wednesday
expr.advanced("@this_week")    # → Sunday 2024-01-14
expr.advanced("@next_week")    # → Sunday 2024-01-21
expr.advanced("@last_week")    # → Sunday 2024-01-07

# Setting is preserved through copy()
copy = expr.copy()
copy.advanced("P0W")           # → Sunday (setting preserved)
```

## Chained Operations

Comma-separated expressions are applied sequentially:

```python
# End of current month
expr.advanced("P0M,+P1M,-P1D")

# First Monday of month
expr.advanced("P0M,+mo")

# Last Friday of month
expr.advanced("@month-end,-fr")
```

## Splitting Time Ranges

```python
expr = DateExpr("2024-01-01")

# Split into monthly ranges
ranges = expr.split(frequency="P1M", end="+P3M")
# Returns 3 DateRange objects: Jan, Feb, Mar

# Split with rate format
ranges = expr.split(frequency="2/month", end="+P1M")
# Returns 2 DateRange objects per month
```

### Ranges with Gaps (duration parameter)

Use the `duration` parameter to create ranges shorter than the interval between them:

```python
expr = DateExpr("2024-01-01")

# 1-month ranges every quarter (with 2-month gaps)
ranges = expr.split(frequency="+P3M", duration="P1M", end="+P1Y")
# Returns 4 ranges: Jan 1-Feb 1, Apr 1-May 1, Jul 1-Aug 1, Oct 1-Nov 1

# 1-day ranges every week
ranges = expr.split(frequency="+P1W", duration="P1D", end="+P4W")
# Returns 4 ranges: Jan 1-2, Jan 8-9, Jan 15-16, Jan 22-23

# 8-hour business day blocks every day
expr = DateExpr("2024-01-01T09:00:00")
ranges = expr.split(frequency="+P1D", duration="PT8H", end="+P5D")
# Returns 5 ranges: 9am-5pm each day
```

### Alternating Frequencies

Use semicolon-separated expressions to cycle through different intervals:

```python
# Alternating weeks: 1 week, then 2 weeks, repeating
expr = DateExpr("2024-01-01", frequency="+P1W;+P2W")
ranges = expr.split(end="+P9W")
# Returns ranges: Jan 1-8, Jan 8-22, Jan 22-29, Jan 29-Feb 12, ...

# Business day pattern: 4x 2-hour blocks, then 16-hour overnight gap
expr = DateExpr("2024-01-01T09:00:00", frequency="+PT2H;+PT2H;+PT2H;+PT2H;+PT16H")
ranges = expr.split(end="+P5D")
# Creates time blocks: 9-11am, 11am-1pm, 1-3pm, 3-5pm, then jumps to 9am next day

# Using advance() with alternating frequency
expr = DateExpr("2024-01-01", frequency="+P1W;+P2W")
expr.advance()  # +1 week → Jan 8
expr.advance()  # +2 weeks → Jan 22
expr.advance()  # +1 week → Jan 29 (cycles back)
```

## Custom Quarters

By default, quarters start in January, April, July, and October. You can configure custom quarter boundaries for fiscal years:

```python
# US Federal fiscal year (starts October 1)
expr = DateExpr("2024-11-15", quarters=[10, 1, 4, 7])
expr.advanced("@this_quarter")   # → 2024-10-01
expr.advanced("@next_quarter")   # → 2025-01-01

# Add/subtract quarters with P#Q syntax
expr = DateExpr("2024-01-15")
expr.advanced("+P1Q")            # → 2024-04-15
expr.advanced("+P2Q")            # → 2024-07-15
```

## Output Methods

### Getting Values

```python
expr = DateExpr("2024-01-15T10:30:00", tz=pytz.timezone("America/Toronto"))

# Get date only
expr.date()                      # date(2024, 1, 15)

# Get datetime in original timezone
expr.to_datetime()               # datetime with original tz

# Get datetime in specific timezone
pst = pytz.timezone("America/Los_Angeles")
expr.datetime(tz=pst)            # datetime converted to PST

# Get datetime in UTC
expr.to_utc()                    # datetime in UTC
```

### Formatting Output

```python
expr = DateExpr("2024-01-15T10:30:00", tz=pytz.timezone("America/Toronto"))

# ISO8601 format
expr.iso8601()                   # "2024-01-15T10:30:00-0500"

# ISO8601 with timezone name
expr.iso8601(with_tzname=True)   # "2024-01-15T10:30:00-0500[EST]"

# ISO8601 in different timezone
expr.iso8601(tz=pytz.UTC)        # "2024-01-15T15:30:00+0000"

# Simple date string
expr.to_date_str()               # "2024-01-15"
```

## Duration Calculations

Calculate the duration between two dates:

```python
expr = DateExpr("2024-01-15")

# Duration as ISO8601 period string
expr.duration_to("2024-03-20")           # "+P2M5D"
expr.duration_to("+P1M")                 # "+P1M"

# Duration in specific units
expr.duration_seconds("+PT1H")           # 3600.0
expr.duration_minutes("+PT90M")          # 90.0
expr.duration_hours("+P1D")              # 24.0

# Compare with datetime objects
from datetime import datetime
other = datetime(2024, 2, 15, 12, 0, 0)
expr.duration_hours(other)               # ~744.5
```

## Utility Methods

```python
expr = DateExpr("2024-02-15")

# Get days in current month
expr.days_in_month()             # 29 (leap year)

# Get a single period as DateRange
expr = DateExpr("2024-01-01", frequency="@monthly")
rng = expr.period()              # DateRange: Jan 1 - Feb 1
```

## DateRange Class

A `DateRange` represents a time span with inclusive start and exclusive end.

### Creating DateRanges

```python
from dateexpr import DateRange
from datetime import date, datetime

# From dates
range = DateRange(date(2024, 1, 15), date(2024, 1, 20))

# From datetimes (preserves time and timezone)
start = datetime(2024, 1, 15, 9, 0, 0)
end = datetime(2024, 1, 15, 17, 0, 0)
range = DateRange(start, end)
```

### Accessing Start/End

```python
range = DateRange(date(2024, 1, 15), date(2024, 1, 20))

# As datetime
range.start()                    # datetime at midnight
range.end()                      # datetime at midnight

# As date
range.start_date()               # date(2024, 1, 15)
range.end_date()                 # date(2024, 1, 20)

# Inclusive end (last full day in range)
range.end_date_inclusive()       # date(2024, 1, 19)

# With timezone conversion
est = pytz.timezone("America/New_York")
range.start(tz=est)              # datetime in EST
range.start_date(tz=est)         # date in EST

# As UTC
range.start_utc()                # datetime in UTC
range.end_utc()                  # datetime in UTC

# As ISO8601 strings
range.start_iso8601()            # "2024-01-15T00:00:00+0000"
range.end_iso8601()              # "2024-01-20T00:00:00+0000"
```

### Duration Methods

```python
range = DateRange(date(2024, 1, 15), date(2024, 1, 20))

# As ISO8601 period
range.duration()                 # "+P5D"

# In specific units
range.duration_seconds()         # 432000.0
range.duration_minutes()         # 7200.0
range.duration_hours()           # 120.0
```

## Mutating vs Non-Mutating Methods

| Non-Mutating (returns new) | Mutating (modifies in-place) |
| -------------------------- | ---------------------------- |
| `advanced(spec)`           | `advance(spec)`              |
| `truncated_microseconds()` | `truncate_microseconds()`    |
| `copy()`                   | -                            |

## Month and Weekday Navigation

Navigate to specific months or weekdays:

```python
expr = DateExpr("2024-06-15")

# Navigate to month (nearest)
expr.advanced("jan")             # → 2024-01-15 (nearest January)

# Navigate forward to month
expr.advanced("+jan")            # → 2025-01-15 (next January)

# Navigate backward to month
expr.advanced("-jan")            # → 2024-01-15 (previous January)

# Navigate to weekday
expr.advanced("mo")              # → nearest Monday
expr.advanced("+fr")             # → next Friday
expr.advanced("-we")             # → previous Wednesday
```

## License

MIT License
