Metadata-Version: 2.4
Name: pyworkdates
Version: 0.1.0
Summary: Workday calculations with a built-in Norwegian calendar
Author: Jonas Tendal
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.10
Project-URL: Homepage, https://github.com/jtendal/pyworkdates
Project-URL: Repository, https://github.com/jtendal/pyworkdates
Project-URL: Issues, https://github.com/jtendal/pyworkdates/issues
Description-Content-Type: text/markdown

# pyworkdates

Dependency-free workday calculations for Python, with a built-in Norwegian calendar.

[![PyPI](https://img.shields.io/pypi/v/pyworkdates)](https://pypi.org/project/pyworkdates/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)

pyworkdates answers questions like *is this date a workday?*, *what date is
three workdays from now?*, and *which workdays fall in this range?* — using
only the Python standard library. It ships with a built-in Norwegian holiday
calendar, and every function accepts a `calendar` argument to override it with
any collection of dates.

## Installation

```sh
pip install pyworkdates
```

or with [uv](https://docs.astral.sh/uv/):

```sh
uv add pyworkdates
```

## Quickstart

Using the built-in Norwegian calendar (the default when no `calendar` is
given):

```python
from pyworkdates import add_workdays, is_workday, next_workday

print(is_workday("2026-07-06"))       # True   (a regular Monday)
print(is_workday("2026-05-01"))       # False  (a holiday in the built-in calendar)
print(is_workday("2026-07-11"))       # False  (a Saturday)

print(add_workdays("2026-07-06", 3))  # 2026-07-09
print(next_workday("2026-07-10"))     # 2026-07-13  (Friday -> Monday)
```

Overriding the calendar per call — pass any collection of dates:

```python
from pyworkdates import add_workdays, is_workday

us_holidays = ["2026-07-03", "2026-11-26", "2026-12-25"]  # a few US 2026 holidays

print(is_workday("2026-07-03", calendar=us_holidays))       # False
print(is_workday("2026-05-01", calendar=us_holidays))       # True  (not in this calendar)
print(add_workdays("2026-07-02", 1, calendar=us_holidays))  # 2026-07-06
```

Defining a calendar once and reusing it with `WorkdayCalendar`:

```python
from pyworkdates import WorkdayCalendar

us = WorkdayCalendar(["2026-07-03", "2026-11-26", "2026-12-25"])

print(us.is_workday("2026-07-03"))    # False
print(us.next_workday("2026-11-25"))  # 2026-11-27
print(us.workdays_between("2026-12-24", "2026-12-28"))
# [datetime.date(2026, 12, 24), datetime.date(2026, 12, 28)]
```

## Key concepts

- **Workday**: a day that is neither a weekend day (Saturday/Sunday) nor a
  date in the active calendar.
- **Built-in calendar**: Norwegian holidays, hardcoded for the years
  2016–2050. It is the active calendar whenever `calendar` is not given.
- **Coverage guard**: any computation that touches a date outside 2016–2050
  while using the built-in calendar raises `ValueError` — never a silent
  wrong answer. Pass an explicit `calendar` to work outside that range:

  ```python
  >>> from pyworkdates import is_workday
  >>> is_workday("2055-01-04")
  Traceback (most recent call last):
      ...
  ValueError: Date 2055-01-04 is outside the built-in calendar's coverage (2016-2050). Pass an explicit 'calendar' argument to work with dates outside this range.
  >>> is_workday("2055-01-04", calendar=["2055-01-01"])
  True

  ```

- **Custom calendars**: any iterable of `datetime.date`, `datetime.datetime`,
  or ISO strings `"YYYY-MM-DD"`, freely mixed. Custom calendars are trusted
  as-is and never range-checked.
- **Input and output types**: every function accepts a `datetime.date`, a
  `datetime.datetime` (its date part is used), or an ISO string
  `"YYYY-MM-DD"`. Date-returning functions always return `datetime.date`.
  Invalid input raises `ValueError`.

## API reference

Every function takes an optional `calendar` keyword argument: `None` (the
default) means the built-in Norwegian calendar; otherwise the supplied
calendar is used. All examples below are doctest-verified.

### `is_workday(date, calendar=None) -> bool`

True if the date is a workday: neither a weekend day nor a date in the active
calendar.

```python
>>> from pyworkdates import is_workday
>>> is_workday("2026-07-06")  # a regular Monday
True
>>> is_workday("2026-05-01")  # a holiday in the built-in calendar
False

```

### `is_holiday(date, calendar=None) -> bool`

True if the date is in the active calendar. Weekend days are not holidays
unless the calendar contains them.

```python
>>> from pyworkdates import is_holiday
>>> is_holiday("2026-05-01")
True
>>> is_holiday("2026-07-11")  # a Saturday, but not in the calendar
False

```

### `add_workdays(date, n, calendar=None) -> datetime.date`

The date moved `n` workdays forward, or backward for negative `n`. Starting
from a non-workday does not first roll to one: Saturday plus one workday is
Monday, and Saturday minus one workday is Friday (holidays permitting). For
`n=0` the date is returned unchanged if it is a workday, otherwise it rolls
forward to the nearest workday. `n` must be an `int`; `bool` and non-int
values raise `ValueError`.

```python
>>> from pyworkdates import add_workdays
>>> add_workdays("2026-07-06", 3)   # Monday + 3 workdays
datetime.date(2026, 7, 9)
>>> add_workdays("2026-07-06", -1)  # Monday - 1 workday
datetime.date(2026, 7, 3)
>>> add_workdays("2026-07-11", 0)   # Saturday rolls forward to Monday
datetime.date(2026, 7, 13)

```

### `next_workday(date, calendar=None) -> datetime.date`

The first workday strictly after the date. The result is always later than
`date`, even when `date` itself is a workday.

```python
>>> from pyworkdates import next_workday
>>> next_workday("2026-07-06")  # a Monday: strictly after
datetime.date(2026, 7, 7)
>>> next_workday("2026-04-01")  # Easter holidays start the next day
datetime.date(2026, 4, 7)

```

### `previous_workday(date, calendar=None) -> datetime.date`

The first workday strictly before the date. The result is always earlier than
`date`, even when `date` itself is a workday.

```python
>>> from pyworkdates import previous_workday
>>> previous_workday("2026-07-07")  # a Tuesday: strictly before
datetime.date(2026, 7, 6)
>>> previous_workday("2027-01-04")  # skips New Year's Day and the weekend
datetime.date(2026, 12, 31)

```

### `first_workday_of_month(date, calendar=None) -> datetime.date`

The first workday of the date's month. Only days within that month are
examined. If the month contains no workdays — possible only with a custom
calendar — a `ValueError` is raised.

```python
>>> from pyworkdates import first_workday_of_month
>>> first_workday_of_month("2026-05-20")  # May 1st is a Friday holiday
datetime.date(2026, 5, 4)
>>> every_day_off = [f"2026-02-{day:02d}" for day in range(1, 29)]
>>> first_workday_of_month("2026-02-10", calendar=every_day_off)
Traceback (most recent call last):
    ...
ValueError: Month 2026-02 contains no workdays with the active calendar.

```

### `last_workday_of_month(date, calendar=None) -> datetime.date`

The last workday of the date's month. Only days within that month are
examined, and the same "month contains no workdays" `ValueError` applies.

```python
>>> from pyworkdates import last_workday_of_month
>>> last_workday_of_month("2026-05-15")  # May 31st 2026 is a Sunday
datetime.date(2026, 5, 29)

```

### `last_workday_of_previous_month(date, calendar=None) -> datetime.date`

The last workday of the month before the date's month. With the built-in
calendar, a January 2016 input raises `ValueError` because the previous month
(December 2015) is outside coverage.

```python
>>> from pyworkdates import last_workday_of_previous_month
>>> last_workday_of_previous_month("2026-01-15")  # crosses the year boundary
datetime.date(2025, 12, 31)

```

### `is_first_workday_of_month(date, calendar=None) -> bool`

True if the date is its month's first workday. A date that is not itself a
workday is never its month's first workday.

```python
>>> from pyworkdates import is_first_workday_of_month
>>> is_first_workday_of_month("2026-05-04")  # the Monday after May 1st
True
>>> is_first_workday_of_month("2026-05-01")  # a holiday is not a workday
False

```

### `is_last_workday_of_month(date, calendar=None) -> bool`

True if the date is its month's last workday. A date that is not itself a
workday is never its month's last workday.

```python
>>> from pyworkdates import is_last_workday_of_month
>>> is_last_workday_of_month("2026-05-29")  # the last Friday of May 2026
True
>>> is_last_workday_of_month("2026-05-31")  # a Sunday is not a workday
False

```

### `workdays_between(start, end, calendar=None) -> list[datetime.date]`

All workdays `d` with `start <= d <= end`, in ascending order. Both endpoints
are included when they are workdays. If `start` is after `end`, the result is
an empty list.

```python
>>> from pyworkdates import workdays_between
>>> workdays_between("2026-07-09", "2026-07-13")  # Thursday through Monday
[datetime.date(2026, 7, 9), datetime.date(2026, 7, 10), datetime.date(2026, 7, 13)]
>>> workdays_between("2026-07-10", "2026-07-06")  # start after end
[]

```

### `WorkdayCalendar(calendar=None)`

A reusable calendar exposing every function above as a method. Define the
calendar once and call the operations without repeating the `calendar`
argument; each method behaves exactly like the module-level function of the
same name. Calendar entries are validated at construction, so invalid
entries are reported immediately; the methods then delegate to the
module-level functions with that calendar. `WorkdayCalendar()` uses the
built-in Norwegian calendar, including its coverage guard.

```python
>>> from pyworkdates import WorkdayCalendar
>>> us = WorkdayCalendar(["2026-07-03", "2026-11-26"])
>>> us.is_workday("2026-07-03")
False
>>> us.add_workdays("2026-07-02", 1)
datetime.date(2026, 7, 6)
>>> WorkdayCalendar()
WorkdayCalendar(built-in Norwegian calendar, 2016-2050)

```

## Development

The project uses [uv](https://docs.astral.sh/uv/) with a `src/` layout and no
runtime dependencies.

```sh
uv sync              # install dev dependencies
uv run pytest        # tests
uv run ruff check .  # lint
uv run ruff format . # format
uv run ty check      # type check
uv build             # build sdist + wheel
```

## License

MIT — see [LICENSE](LICENSE).
