Functional Weave
Code in Python

education.academic-year

The academic year and term a date falls in: 1 September to 31 August by default, terms from the caller.

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

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

What it does

Which academic year a date falls in (label, first and last day), and which of the caller's terms, if any, contains it.

## Decisions

For example

  • academic_year(2025-10-15, —, terms ×3) → label 2025/26, start 2025-09-01, end 2026-08-31, term autumn, term start 2025-09-03, term end 2025-12-19 mid-October is autumn term of 2025/26
  • academic_year(2025-09-01, —, terms ×3) → label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end — 1 September starts the year, but term has not begun
  • academic_year(2025-08-31, —, terms ×3) → label 2024/25, start 2024-09-01, end 2025-08-31, term —, term start —, term end — 31 August is the last day of the previous 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 academic_year(on_date: str, year_start: Optional[str], terms: Sequence[Term]) -> AcademicYear
on_datedatethe date to place
year_startstring?MM-DD the year begins on; null for 09-01, the academic year in England
termsTerm[]the institution's term dates, in any order, not overlapping; may be empty
returnsAcademicYear

The types it declares, generated into your project

@dataclass(frozen=True)
class Term:
    """One term or semester, first and last day inclusive."""

    name: str
    start: str
    end: str

@dataclass(frozen=True)
class AcademicYear:
    """The academic year containing a date, and the term if the date is in one."""

    #: 2025/26; a year starting 1 January is labelled 2026
    label: str
    #: first day of the academic year
    start: str
    #: last day, inclusive: the day before the next year starts
    end: str
    #: the term containing onDate; null in a holiday
    term: Optional[str]
    term_start: Optional[str]
    term_end: Optional[str]

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

from fune.education.academic_year import academic_year  # education.academic-year@^1
impl/python.py · 50 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.

import re
from typing import Optional, Sequence

from .dates_add_days import add_days, days_in_month, format_iso_date, parse_iso_date  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_add_days_types import CivilDate
from .education_academic_year_types import AcademicYear, Term

MONTH_DAY = re.compile(r"([0-9]{2})-([0-9]{2})")


def academic_year(on_date: str, year_start: Optional[str], terms: Sequence[Term]) -> AcademicYear:
    """The academic year and term for a date.

    The end is the day before the next year's start, worked out by date
    arithmetic, so a 1 March start ends on 29 February in a leap year.
    """
    on = parse_iso_date(on_date)
    start_text = "09-01" if year_start is None else year_start
    match = MONTH_DAY.fullmatch(start_text) if isinstance(start_text, str) else None
    month = int(match.group(1)) if match else 0
    day = int(match.group(2)) if match else 0
    if match is None or month < 1 or month > 12 or day < 1 or day > days_in_month(2001, month):
        raise ValueError('yearStart must be MM-DD on a day every year has, received "%s"' % (start_text,))

    ordered = sorted(terms, key=lambda t: t.start)
    for t in ordered:
        parse_iso_date(t.start)
        parse_iso_date(t.end)
        if t.end < t.start:
            raise ValueError("term %s ends (%s) before it starts (%s)" % (t.name, t.end, t.start))
    for i in range(1, len(ordered)):
        if ordered[i].start <= ordered[i - 1].end:
            raise ValueError("terms %s and %s overlap" % (ordered[i - 1].name, ordered[i].name))

    before_start = (on.month, on.day) < (month, day)
    start_year = on.year - 1 if before_start else on.year
    start = format_iso_date(CivilDate(year=start_year, month=month, day=day))
    end = add_days(format_iso_date(CivilDate(year=start_year + 1, month=month, day=day)), -1)
    end_year = int(end[0:4])
    label = str(start_year) if end_year == start_year else "%d/%02d" % (start_year, end_year % 100)

    term = next((t for t in ordered if t.start <= on_date <= t.end), None)
    return AcademicYear(
        label=label,
        start=start,
        end=end,
        term=None if term is None else term.name,
        term_start=None if term is None else term.start,
        term_end=None if term is None else term.end,
    )

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 education.academic-year
Download for Python education.academic-year-1.0.1-python.fune · 15,641 bytes sha256 df7d0af818fbac7ed0e04874d9801210f3904449b8f1198e443fa231359a38e6

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./education.academic-year-1.0.1-python.fune, or fetch it from a terminal with fune pull education.academic-year@1.0.1:python.

The whole function, every language, is one file too: education.academic-year-1.0.1.fune, 21,655 bytes, sha256 1fb1026f5bc09794198c4d38378e7004589a623292db9380b50098ecd9c976e8. 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 education.academic-year

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

# fune: after education.academic-year

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 education.academic-year

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 education.academic-year --steps.

# fune: step education.academic-year 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
mid-October is autumn term of 2025/26 2025-10-15, —, terms ×3 → label 2025/26, start 2025-09-01, end 2026-08-31, term autumn, term start 2025-09-03, term end 2025-12-19
1 September starts the year, but term has not begun 2025-09-01, —, terms ×3 → label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end —
31 August is the last day of the previous year 2025-08-31, —, terms ×3 → label 2024/25, start 2024-09-01, end 2025-08-31, term —, term start —, term end —
Christmas is in the year but in no term 2025-12-25, —, terms ×3 → label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end —
a term's last day is inside it 2025-12-19, —, terms ×3 → label 2025/26, start 2025-09-01, end 2026-08-31, term autumn, term start 2025-09-03, term end 2025-12-19
a term's first day is inside it 2026-04-13, —, terms ×3 → label 2025/26, start 2025-09-01, end 2026-08-31, term summer, term start 2026-04-13, term end 2026-07-22
the summer holiday after the last term 2026-08-15, —, terms ×3 → label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end —
no terms given 2026-02-01, —, → label 2025/26, start 2025-09-01, end 2026-08-31, term —, term start —, term end —
an August start 2025-08-01, 08-01, → label 2025/26, start 2025-08-01, end 2026-07-31, term —, term start —, term end —
the day before an August start 2025-07-31, 08-01, → label 2024/25, start 2024-08-01, end 2025-07-31, term —, term start —, term end —
Show the other 13 tests
CaseArgumentsExpected
a 1 January start is one calendar year 2026-06-30, 01-01, → label 2026, start 2026-01-01, end 2026-12-31, term —, term start —, term end —
the century turns in the label 1999-12-01, —, → label 1999/00, start 1999-09-01, end 2000-08-31, term —, term start —, term end —
a 1 March start ends on 29 February in a leap year 2024-02-29, 03-01, → label 2023/24, start 2023-03-01, end 2024-02-29, term —, term start —, term end —
and on 28 February otherwise 2025-02-28, 03-01, → label 2024/25, start 2024-03-01, end 2025-02-28, term —, term start —, term end —
a term outside the year is still found by date 2025-09-25, 10-01, terms ×1 → label 2024/25, start 2024-10-01, end 2025-09-30, term welcome week, term start 2025-09-22, term end 2025-09-26
29 February is not a day every year has 2025-10-15, 02-29, → error: yearStart must be MM-DD on a day every year has, received "02-29"
a thirteenth month is an error 2025-10-15, 13-01, → error: yearStart must be MM-DD on a day every year has, received "13-01"
an unpadded yearStart is an error 2025-10-15, 9-1, → error: yearStart must be MM-DD on a day every year has, received "9-1"
a term ending before it starts is an error 2025-10-15, —, terms ×1 → error: term autumn ends (2025-09-03) before it starts (2025-12-19)
overlapping terms are an error 2025-10-15, —, terms ×2 → error: terms autumn and winter overlap
an impossible date is an error 2025-02-30, —, → error: "2025-02-30" is not a real calendar date
a yearStart with a trailing newline is an error 2025-10-15, 09-01 , → error: yearStart must be MM-DD on a day every year has, received "09-01 "
a yearStart in Arabic-Indic digits is an error 2025-10-15, ٠٩-٠١, → error: yearStart must be MM-DD on a day every year has, received "٠٩-٠١"

More from the author

- **The year runs from `yearStart` to the day before the next one.** With `null` it is 1 September to 31 August, the academic year the Department for Education uses in England for school years, admissions and statistics. Pass `08-01` for a year that starts in August (Scottish schools, many universities), or whatever your institution uses. - **Term dates are arguments, not data.** Every local authority, academy trust and university sets its own, so there is nothing the registry could carry that would be right. A date between terms is in the academic year but in no term: `term` is null. - A term is found by date alone, so it need not lie inside the academic year (a university's September welcome week before a `10-01` year start still resolves). Overlapping terms are refused, since a date would be in two. - The label is `2025/26`, with the second year as two digits (`1999/00`); a year starting on 1 January is one calendar year and is labelled `2026`. - `yearStart` must be a day every year has, so `02-29` is refused. A `03-01` start ends on 29 February in a leap year: the end is the day before the next start, not a fixed date.

1.0.1 fixes Python accepting a trailing newline or non-ASCII digits in yearStart; adds tests.

Files

PathBytes
README.md1,421
impl/python.py2,254
impl/rust.rs3,608
impl/typescript.ts2,180
vectors.json7,638