Skip to content

Commit

Permalink
util.date: some refactors
Browse files Browse the repository at this point in the history
  • Loading branch information
yagebu committed Feb 14, 2024
1 parent 5de937a commit 699f767
Show file tree
Hide file tree
Showing 3 changed files with 74 additions and 87 deletions.
8 changes: 5 additions & 3 deletions src/fava/help/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,15 @@ Default: `12-31`
The last day of the fiscal (financial or tax) period for accounting purposes in
`%m-%d` format. Allows for the use of `FY2018`, `FY2018-Q3`, `fiscal_year` and
`fiscal_quarter` in the time filter, and `FY2018` as the start date, end date,
or both dates in a date range in the time filter.
or both dates in a date range in the time filter. Month can be a value larger
than `12` to have `FY2018` end in 2019 for example.

Examples are:

- `09-30` - US federal government
- `06-30` - Australia / NZ
- `04-05` - UK
- `06-30` - Australia / NZ
- `09-30` - US federal government
- `15-31` - Japan

See [Fiscal Year on WikiPedia](https://en.wikipedia.org/wiki/Fiscal_year) for
more examples.
Expand Down
150 changes: 67 additions & 83 deletions src/fava/util/date.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@
WEEK_RE = re.compile(r"^(\d{4})-w(\d{2})$")

# this matches a quarter like 2016-Q1 for the first quarter of 2016
QUARTER_RE = re.compile(r"^(\d{4})-q(\d)$")
QUARTER_RE = re.compile(r"^(\d{4})-q([1234])$")

# this matches a financial year like FY2018 for the financial year ending 2018
FY_RE = re.compile(r"^fy(\d{4})$")

# this matches a quarter in a financial year like FY2018-Q2
FY_QUARTER_RE = re.compile(r"^fy(\d{4})-q(\d)$")
FY_QUARTER_RE = re.compile(r"^fy(\d{4})-q([1234])$")

VARIABLE_RE = re.compile(
r"\(?(fiscal_year|year|fiscal_quarter|quarter"
Expand All @@ -51,6 +51,32 @@ class FiscalYearEnd:
month: int
day: int

@property
def month_of_year(self) -> int:
"""Actual month of the year."""
return (self.month - 1) % 12 + 1

@property
def year_offset(self) -> int:
"""Number of years that this is offset into the future."""
return (self.month - 1) // 12

def has_quarters(self) -> bool:
"""Whether this fiscal year end supports fiscal quarters."""
return (
datetime.date(2001, self.month_of_year, self.day) + ONE_DAY
).day == 1


class FyeHasNoQuartersError(ValueError):
"""Only fiscal year that start on the first of a month have quarters."""

def __init__(self) -> None:
super().__init__(
"Cannot use fiscal quarter if fiscal year "
"does not start on first of the month"
)


END_OF_YEAR = FiscalYearEnd(12, 31)

Expand Down Expand Up @@ -229,7 +255,7 @@ def local_today() -> datetime.date:
return datetime.date.today() # noqa: DTZ011


def substitute( # noqa: PLR0912 PLR0914
def substitute(
string: str,
fye: FiscalYearEnd | None = None,
) -> str:
Expand All @@ -245,66 +271,45 @@ def substitute( # noqa: PLR0912 PLR0914
:func:`parse_date`. Can compute addition and subtraction.
"""
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
today = local_today()
fye = fye or END_OF_YEAR

for match in VARIABLE_RE.finditer(string):
complete_match, interval, plusminus_, mod_ = match.group(0, 1, 2, 3)
mod = int(mod_) if mod_ else 0
plusminus = 1 if plusminus_ == "+" else -1
offset = mod if plusminus_ == "+" else -mod
if interval == "fiscal_year":
year = today.year
start, end = get_fiscal_period(year, fye)
if end and today >= end:
year += 1
elif start and today < start:
year -= 1
year += plusminus * mod
string = string.replace(complete_match, f"FY{year}")
after_fye = (today.month, today.day) > (fye.month_of_year, fye.day)
year = today.year + (1 if after_fye else 0) - fye.year_offset
string = string.replace(complete_match, f"FY{year + offset}")
if interval == "year":
year = today.year + plusminus * mod
string = string.replace(complete_match, str(year))
string = string.replace(complete_match, str(today.year + offset))
if interval == "fiscal_quarter":
target = month_offset(today.replace(day=1), plusminus * mod * 3)
start, end = get_fiscal_period(target.year, fye)
if start and start.day != 1:
raise ValueError(
"Cannot use fiscal_quarter if fiscal year "
"does not start on first of the month",
)
if end and target >= end:
start = end
if start:
if target < start:
start = start.replace(year=start.year - 1)
fiscal_year = (
start.year if fye and fye.month > 12 else start.year + 1
)
quarter = int(((target.month - start.month) % 12) / 3)
string = string.replace(
complete_match,
f"FY{fiscal_year}-Q{(quarter % 4) + 1}",
)
if not fye.has_quarters():
raise FyeHasNoQuartersError
target = month_offset(today.replace(day=1), offset * 3)
after_fye = (target.month) > (fye.month_of_year)
year = target.year + (1 if after_fye else 0) - fye.year_offset
quarter = ((target.month - fye.month_of_year - 1) // 3) % 4 + 1
string = string.replace(complete_match, f"FY{year}-Q{quarter}")
if interval == "quarter":
quarter_today = (today.month - 1) // 3 + 1
year = today.year + (quarter_today + plusminus * mod - 1) // 4
quarter = (quarter_today + plusminus * mod - 1) % 4 + 1
year = today.year + (quarter_today + offset - 1) // 4
quarter = (quarter_today + offset - 1) % 4 + 1
string = string.replace(complete_match, f"{year}-Q{quarter}")
if interval == "month":
year = today.year + (today.month + plusminus * mod - 1) // 12
month = (today.month + plusminus * mod - 1) % 12 + 1
year = today.year + (today.month + offset - 1) // 12
month = (today.month + offset - 1) % 12 + 1
string = string.replace(complete_match, f"{year}-{month:02}")
if interval == "week":
delta = timedelta(plusminus * mod * 7)
string = string.replace(
complete_match,
(today + delta).strftime("%Y-W%W"),
(today + timedelta(offset * 7)).strftime("%Y-W%W"),
)
if interval == "day":
delta = timedelta(plusminus * mod)
string = string.replace(
complete_match,
(today + delta).isoformat(),
(today + timedelta(offset)).isoformat(),
)
return string

Expand Down Expand Up @@ -412,24 +417,16 @@ def parse_fye_string(fye: str) -> FiscalYearEnd | None:
Args:
fye: The end of the fiscal year to parse.
"""
match = re.match(r"^(?P<month>\d{2})-(?P<day>\d{2})$", fye)
if not match:
return None
month = int(match.group("month"))
day = int(match.group("day"))
try:
date = datetime.date.fromisoformat(f"2001-{fye}")
except ValueError:
pass
else:
return FiscalYearEnd(date.month, date.day)

try:
match = re.match(r"^(\d{2})-(\d{2})$", fye)
if not match:
return None
month, day = match.groups()
month_int = int(month)
real_month = str(month_int - 12).zfill(2) if month_int > 12 else month
date = datetime.date.fromisoformat(f"2001-{real_month}-{day}")
_ = datetime.date(2001, (month - 1) % 12 + 1, day)
return FiscalYearEnd(month, day)
except ValueError:
return None
return FiscalYearEnd(month_int, date.day)


def get_fiscal_period(
Expand All @@ -451,40 +448,27 @@ def get_fiscal_period(
A tuple (start, end) of dates.
"""
if fye is None:
start_date = datetime.date(year=year, month=1, day=1)
else:
if fye.month > 12:
real_month = fye.month - 12
start_year = year
else:
real_month = fye.month
start_year = year - 1
start_date = datetime.date(
year=start_year,
month=real_month,
day=fye.day,
) + timedelta(days=1)
# Special case 02-28 because of leap years
if fye.month == 2 and fye.day == 28:
start_date = start_date.replace(month=3, day=1)
fye = fye or END_OF_YEAR
start = (
datetime.date(year - 1 + fye.year_offset, fye.month_of_year, fye.day)
+ ONE_DAY
)
# Special case 02-28 because of leap years
if fye.month_of_year == 2 and fye.day == 28:
start = start.replace(month=3, day=1)

if quarter is None:
return start_date, start_date.replace(year=start_date.year + 1)
return start, start.replace(year=start.year + 1)

if start_date.day != 1:
# quarters make no sense in jurisdictions where period starts
# on a date (UK etc)
if not fye.has_quarters():
return None, None

if quarter < 1 or quarter > 4:
return None, None

if quarter > 1:
start_date = month_offset(start_date, (quarter - 1) * 3)
start = month_offset(start, (quarter - 1) * 3)

end_date = month_offset(start_date, 3)
return start_date, end_date
return start, month_offset(start, 3)


def days_in_daterange(
Expand Down
3 changes: 2 additions & 1 deletion tests/test_util_date.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ def test_fiscal_substitute(
if output is None:
with pytest.raises(
ValueError,
match="Cannot use fiscal_quarter if fiscal year",
match="Cannot use fiscal quarter if fiscal year",
):
substitute(string, fye)
else:
Expand Down Expand Up @@ -370,6 +370,7 @@ def test_get_fiscal_period(
("12-31", 12, 31),
("06-30", 6, 30),
("02-28", 2, 28),
("15-31", 15, 31),
],
)
def test_parse_fye_string(fye_str: str, month: int, day: int) -> None:
Expand Down

0 comments on commit 699f767

Please sign in to comment.