Functional Weave
Code in Python

dates.month-boundaries

The first day, last day and length of the calendar month containing a date.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 19 tests, run in TypeScript, Python and Rust.

What it does

Returns start, end and days together because callers need them together: a billing period, a statement range, a pro-rata fraction whose denominator is the length of the month. Handing back only the start and letting the caller derive the end is how "31 January plus one month" becomes 3 March.

"end" is the last day of the month, inclusive - 2024-02-29, not 2024-03-01. Inclusive is what a statement prints and what a human checks the figures against. Callers doing half-open range comparisons should add a day with dates.add-days rather than expecting this to hand back the 1st of the next month.

For example

  • month_boundaries(2024-02-15) → start 2024-02-01, end 2024-02-29, days 29 February 2024 has 29 days because 2024 is a leap year
  • month_boundaries(2024-02-29) → start 2024-02-01, end 2024-02-29, days 29 the leap day itself resolves to the same month
  • month_boundaries(1900-02-15) → start 1900-02-01, end 1900-02-28, days 28 February 1900 had 28 days: a century year is not a leap year

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def month_boundaries(iso: str) -> MonthBoundaries
isodateany date inside the month you want
returnsMonthBoundaries

The type it declares, generated into your project

@dataclass(frozen=True)
class MonthBoundaries:
    #: the 1st of the month
    start: str
    #: the last day of the month, inclusive: 28, 29, 30 or 31
    end: str
    #: how many days the month has
    days: int

Your code names it in one line, in the file that uses it

from fune.dates.month_boundaries import month_boundaries  # dates.month-boundaries@^1
impl/python.py · 31 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from .dates_add_days import days_in_month, format_iso_date, parse_iso_date, CivilDate  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_month_boundaries_types import MonthBoundaries


def month_boundaries(iso: str) -> MonthBoundaries:
    """The first and last day of the month containing a date, and its length.

    All three are returned together because a caller needs them together - a
    billing period, a statement range, a pro-rata fraction - and because
    deriving ``end`` from ``start`` in caller code is where "31 January plus one
    month" bugs come from.

    ``end`` is the last day of the month, inclusive, not the 1st of the next
    month. Inclusive is what a statement prints and what a human checks against.
    """
    date = parse_iso_date(iso)
    # February is the only month whose length is not a constant, and it follows
    # the full Gregorian rule: 2024 had 29 days, 1900 had 28 (a century year),
    # 2000 had 29 (divisible by 400).
    days = days_in_month(date.year, date.month)
    return MonthBoundaries(
        start=format_iso_date(CivilDate(date.year, date.month, 1)),
        end=format_iso_date(CivilDate(date.year, date.month, days)),
        days=days,
    )


def is_month_end(iso: str) -> bool:
    """True when the date is the last day of its month - a common billing trigger."""
    date = parse_iso_date(iso)
    return date.day == days_in_month(date.year, date.month)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add dates.month-boundaries
Download for Python dates.month-boundaries-1.0.0-python.fune · 7,301 bytes sha256 b1c53bc451860cf3587dc300b15a73ee199f92401c17f7bb146619357e060b2f

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./dates.month-boundaries-1.0.0-python.fune, or fetch it from a terminal with fune pull dates.month-boundaries@1.0.0:python.

The whole function, every language, is one file too: dates.month-boundaries-1.0.0.fune, 10,722 bytes, sha256 9d24e00cd40e79db6edcafbb0f672a03ca0901aaa9c0c59efcc7c89f63270ee8. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before dates.month-boundaries

after — your function gets the result and the arguments, and returns the final result.

# fune: after dates.month-boundaries

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace dates.add-days in dates.month-boundaries

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show dates.month-boundaries --steps.

# fune: step dates.month-boundaries after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
February 2024 has 29 days because 2024 is a leap year 2024-02-15 → start 2024-02-01, end 2024-02-29, days 29
the leap day itself resolves to the same month 2024-02-29 → start 2024-02-01, end 2024-02-29, days 29
February 1900 had 28 days: a century year is not a leap year 1900-02-15 → start 1900-02-01, end 1900-02-28, days 28
February 2000 had 29 days: divisible by 400, so it was 2000-02-15 → start 2000-02-01, end 2000-02-29, days 29
February 2100 will have 28 days, the next century exception 2100-02-15 → start 2100-02-01, end 2100-02-28, days 28
February 2026 is an ordinary 28 days 2026-02-15 → start 2026-02-01, end 2026-02-28, days 28
January has 31 days 2026-01-15 → start 2026-01-01, end 2026-01-31, days 31
April has 30 days 2026-04-15 → start 2026-04-01, end 2026-04-30, days 30
the 1st of a month is its own start 2026-09-01 → start 2026-09-01, end 2026-09-30, days 30
the last day of a month is its own end 2026-09-30 → start 2026-09-01, end 2026-09-30, days 30
Show the other 9 tests
CaseArgumentsExpected
December ends the year on the 31st, not on 1 January 2026-12-25 → start 2026-12-01, end 2026-12-31, days 31
December 1900, well before the 1970 epoch 1900-12-31 → start 1900-12-01, end 1900-12-31, days 31
the first supported month 0001-01-01 → start 0001-01-01, end 0001-01-31, days 31
the last supported month 9999-12-31 → start 9999-12-01, end 9999-12-31, days 31
a slash-separated date is an error, not a guess 15/02/2024 → error: is not an ISO date
a year-month with no day is an error 2026-02 → error: is not an ISO date
29 February 1900 never existed 1900-02-29 → error: is not a real calendar date
31 September never exists 2026-09-31 → error: is not a real calendar date
month 13 is rejected 2026-13-01 → error: is not a real calendar date

More from the author

February is the only month whose length is not a constant, and it follows the full Gregorian rule rather than year % 4: 2024 had 29 days, 1900 had 28 because a century year is skipped, 2000 had 29 because every 400th is not. Those three cases are in the vectors precisely because the shortcut rule gets two of them wrong, and 1900 is still inside the range of real data - pension and land records reach back that far.

Files

PathBytes
README.md1,044
impl/python.py1,382
impl/rust.rs1,901
impl/typescript.ts1,391
vectors.json2,707