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/26academic_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 begunacademic_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_date | date | the date to place |
| year_start | string? | MM-DD the year begins on; null for 09-01, the academic year in England |
| terms | Term[] | the institution's term dates, in any order, not overlapping; may be empty |
| returns | AcademicYear |
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
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,421 |
| impl/python.py | 2,254 |
| impl/rust.rs | 3,608 |
| impl/typescript.ts | 2,180 |
| vectors.json | 7,638 |