time-split 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
time_split/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Create temporal k-folds for cross-validation with heterogeneous data."""
2
+
3
+ import logging as _logging
4
+
5
+ from ._frontend import log_split_progress, plot, split
6
+
7
+ __all__ = [
8
+ "log_split_progress",
9
+ "plot",
10
+ "split",
11
+ "__version__", # Make MyPy happy
12
+ ]
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ _logging.getLogger(__name__).addHandler(_logging.NullHandler())
@@ -0,0 +1,4 @@
1
+ from ._limits import expand_limits
2
+ from ._splitter import DatetimeIndexSplitter
3
+
4
+ __all__ = ["DatetimeIndexSplitter", "expand_limits"]
@@ -0,0 +1,57 @@
1
+ from typing import NamedTuple
2
+
3
+ from pandas import DatetimeIndex, Timestamp, isna
4
+
5
+ from .._support import handle_dask
6
+ from ..types import DatetimeIterable, Flex
7
+ from ._datetime_index_like import DatetimeIndexLike
8
+ from ._limits import LimitsTuple, expand_limits
9
+
10
+
11
+ class AvailableMetadata(NamedTuple):
12
+ """Output of :func:`process_available`."""
13
+
14
+ available_as_index: DatetimeIndexLike | None
15
+ limits: LimitsTuple
16
+ expanded_limits: LimitsTuple
17
+
18
+
19
+ def process_available(available: DatetimeIterable, *, flex: Flex) -> AvailableMetadata:
20
+ """Process a user-given `available` argument.
21
+
22
+ Args:
23
+ available: Available data from user. May be ``None``
24
+ flex: Flex-argument. Determines how much (if at all) to expand limits.
25
+
26
+ Returns:
27
+ A tuple ``(available, limits)``. Note that `available` will be ``None``, it has not been iterated over. This
28
+ assures that iterables are not consumed unless needed.
29
+
30
+ Raises:
31
+ ValueError: For invalid `available` arguments.
32
+
33
+ """
34
+ if isinstance(available, DatetimeIndexLike):
35
+ # Avoids conversion, which may be expensive (or impossible on the current machine),
36
+ # and leverages additional non-Pandas types that do vectorized min/max.
37
+ limits = _compute_data_limits(available)
38
+ available_retval = available
39
+ else:
40
+ available_retval = DatetimeIndex(available)
41
+ limits = _compute_data_limits(available_retval)
42
+
43
+ min_dt, max_dt = limits
44
+ if min_dt == max_dt or isna(min_dt) or isna(max_dt):
45
+ raise ValueError("Not enough data in 'available'; at least two unique elements are required.")
46
+
47
+ if not (isinstance(min_dt, Timestamp) and isinstance(max_dt, Timestamp)):
48
+ # Enforce pandas types; we might have a numpy.datetime64
49
+ # (croniter 1.4.1 + numpy 1.25.2): croniter cannot handle numpy.datetime64
50
+ limits = Timestamp(min_dt), Timestamp(max_dt)
51
+
52
+ expanded_limits = expand_limits(limits, flex=flex)
53
+ return AvailableMetadata(available_retval, limits=limits, expanded_limits=expanded_limits)
54
+
55
+
56
+ def _compute_data_limits(available: DatetimeIndexLike) -> LimitsTuple:
57
+ return handle_dask(available.min()), handle_dask(available.max())
@@ -0,0 +1,19 @@
1
+ from collections.abc import Iterable, Iterator
2
+ from typing import Protocol, runtime_checkable
3
+
4
+ from pandas import Series
5
+
6
+ from ..types import DatetimeTypes
7
+
8
+
9
+ @runtime_checkable
10
+ class DatetimeIndexLike(Protocol, Iterable[DatetimeTypes]):
11
+ """A type that behaves (sort of) like a pandas DatetimeIndex."""
12
+
13
+ def __iter__(self) -> Iterator[DatetimeTypes]: ...
14
+
15
+ def value_counts(self) -> Series: ...
16
+
17
+ def min(self) -> DatetimeTypes: ...
18
+
19
+ def max(self) -> DatetimeTypes: ...
@@ -0,0 +1,151 @@
1
+ from collections.abc import Iterable
2
+ from itertools import starmap
3
+ from typing import NamedTuple
4
+
5
+ from pandas import Timedelta, Timestamp
6
+
7
+ from ..settings import auto_flex
8
+ from ..types import Flex, TimedeltaTypes
9
+
10
+ LimitsTuple = tuple[Timestamp, Timestamp]
11
+ LevelTuple = tuple[TimedeltaTypes, TimedeltaTypes, TimedeltaTypes]
12
+
13
+
14
+ class _TimedeltaTuple(NamedTuple):
15
+ start_at: Timedelta
16
+ round_to: Timedelta
17
+ tolerance: Timedelta
18
+
19
+
20
+ def expand_limits(limits: LimitsTuple, *, flex: Flex | LevelTuple | Iterable[LevelTuple] = "auto") -> LimitsTuple:
21
+ """Derive the `"real"` bounds of `limits`.
22
+
23
+ Args:
24
+ limits: A tuple ``(lo, hi)`` of timestamps.
25
+ flex: Flex arguments as described in the :ref:`User guide`. Also supports level-tuples
26
+ ``[(start_at, round_to, tolerance)...]``. Passing ``flex=[settings.auto_flex.day, settings.auto_flex.hour]``
27
+ is equivalent to ``flex='auto'``.
28
+
29
+ Returns:
30
+ Limits rounded according to the `flex`-argument.
31
+
32
+ Raises:
33
+ ValueError: For invalid limits.
34
+
35
+ Examples:
36
+ >>> from pandas import Timestamp
37
+ >>> limits = Timestamp("2019-05-11"), Timestamp("2019-05-11 22:05:30")
38
+
39
+ Basic usage.
40
+
41
+ >>> expand_limits(limits, flex="d")
42
+ (Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-12 00:00:00'))
43
+
44
+ You may specify a maximum "distance" that limits may be expanded.
45
+
46
+ >>> expand_limits(limits, flex="d<1h")
47
+ (Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-11 22:05:30'))
48
+
49
+ Limits will never be rounded in the "wrong" direction...
50
+
51
+ >>> limits = Timestamp("2019-05-11"), Timestamp("2019-05-11 11:05:30")
52
+ >>> expand_limits(limits, flex="d")
53
+ (Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-11 11:05:30'))
54
+
55
+ ...even if you make the tolerance large enough.
56
+
57
+ >>> expand_limits(limits, flex="d<14h")
58
+ (Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-11 11:05:30'))
59
+
60
+ """
61
+ if limits[0] >= limits[1]:
62
+ msg = f"Bad limits. Expected limits[1] > limits[0], but got {limits=}."
63
+ raise ValueError(msg)
64
+
65
+ if flex is False:
66
+ return limits
67
+
68
+ if flex is True or flex == "auto":
69
+ return _from_levels(limits)
70
+
71
+ if isinstance(flex, str):
72
+ round_to, _, tolerance = flex.partition("<")
73
+ level = _make_level(None, round_to=round_to.strip(), tolerance=tolerance.strip())
74
+ return _apply(limits, level=level)
75
+ if isinstance(flex, tuple):
76
+ return _from_levels(limits, levels=[_make_level(*flex)])
77
+
78
+ return _from_levels(limits, levels=starmap(_make_level, flex))
79
+
80
+
81
+ def _from_levels(limits: LimitsTuple, *, levels: Iterable[_TimedeltaTuple] | None = None) -> LimitsTuple:
82
+ if levels is None:
83
+ levels = _levels_from_settings()
84
+ else:
85
+ levels = sorted(levels, reverse=True, key=lambda level: level[0])
86
+
87
+ lo, hi = limits
88
+ diff = hi - lo
89
+
90
+ for level in levels:
91
+ start_at, _round_to, _tolerance = level
92
+ if diff >= start_at:
93
+ return _apply(limits, level=level)
94
+
95
+ return limits
96
+
97
+
98
+ def _apply(limits: LimitsTuple, *, level: _TimedeltaTuple) -> LimitsTuple:
99
+ lo, hi = limits
100
+
101
+ lo_floor = lo.floor(level.round_to)
102
+ if abs(lo_floor - lo) > level.tolerance:
103
+ return limits
104
+
105
+ hi_ceil = hi.ceil(level.round_to)
106
+ if abs(hi_ceil - hi) > level.tolerance:
107
+ return limits
108
+
109
+ if auto_flex.SANITY_CHECK:
110
+ if lo.round(level.round_to) != lo_floor:
111
+ return limits
112
+ if hi.round(level.round_to) != hi_ceil:
113
+ return limits
114
+
115
+ return lo_floor, hi_ceil
116
+
117
+
118
+ def _levels_from_settings() -> list[_TimedeltaTuple]:
119
+ day, hour = _make_level(*auto_flex.day), _make_level(*auto_flex.hour)
120
+ if day.round_to != Timedelta(days=1):
121
+ msg = f"Invalid settings: {auto_flex.day=} must have round_to=1 day."
122
+ raise ValueError(msg)
123
+ if hour.round_to != Timedelta(hours=1):
124
+ msg = f"Invalid settings: {auto_flex.hour=} must have round_to=1 hour."
125
+ raise ValueError(msg)
126
+ return [day, hour]
127
+
128
+
129
+ def _make_level(
130
+ start_at: TimedeltaTypes | None,
131
+ round_to: TimedeltaTypes,
132
+ tolerance: TimedeltaTypes,
133
+ ) -> _TimedeltaTuple:
134
+ start_at = Timedelta(start_at) # Will be NaT if None, making all ifs False.
135
+ if start_at <= Timedelta(0):
136
+ raise ValueError(f"Bad {start_at=}; must be non-negative.")
137
+
138
+ try:
139
+ round_to = Timedelta(1, unit=round_to)
140
+ except ValueError as e:
141
+ raise ValueError(f"Got {round_to=}, which is not a valid frequency.") from e
142
+
143
+ tolerance = Timedelta(tolerance)
144
+ if tolerance <= Timedelta(0):
145
+ raise ValueError(f"Bad {tolerance=}; must be non-negative.")
146
+
147
+ if start_at < round_to:
148
+ raise ValueError(f"{start_at=} < {round_to=}")
149
+ if round_to < tolerance:
150
+ raise ValueError(f"{round_to=} < {tolerance=}")
151
+ return _TimedeltaTuple(start_at, round_to, tolerance)
@@ -0,0 +1,66 @@
1
+ from typing import NamedTuple, cast, get_args
2
+
3
+ from pandas import DatetimeIndex, NaT, Timedelta, Timestamp, date_range
4
+
5
+ from ..types import DatetimeIterable, Flex, Schedule, TimedeltaTypes
6
+ from ._available import AvailableMetadata, process_available
7
+ from ._limits import LimitsTuple
8
+
9
+ NO_LIMITS = cast(LimitsTuple, (NaT, NaT))
10
+
11
+
12
+ class MaterializedSchedule(NamedTuple):
13
+ """An explicit schedule."""
14
+
15
+ schedule: DatetimeIndex
16
+ available_metadata: AvailableMetadata
17
+
18
+
19
+ def materialize_schedule(
20
+ schedule: Schedule, flex: Flex, *, available: DatetimeIterable | None = None
21
+ ) -> MaterializedSchedule:
22
+ """Materialize user schedule based on available data."""
23
+ if available is None:
24
+ try:
25
+ return MaterializedSchedule(
26
+ DatetimeIndex(schedule),
27
+ available_metadata=AvailableMetadata(None, NO_LIMITS, NO_LIMITS),
28
+ )
29
+ except TypeError as e:
30
+ raise ValueError("Schedule must be explicit when not bounded by an available range.") from e
31
+
32
+ available_metadata = process_available(available, flex=flex)
33
+ min_dt, max_dt = available_metadata.expanded_limits
34
+
35
+ if isinstance(schedule, str) and _cron_like(schedule):
36
+ schedule = _handle_cron(schedule, min_dt, max_dt)
37
+ elif isinstance(schedule, get_args(TimedeltaTypes)):
38
+ schedule = _from_timedelta(schedule, available_metadata.expanded_limits)
39
+ else:
40
+ if not isinstance(schedule, DatetimeIndex):
41
+ schedule = DatetimeIndex(schedule)
42
+
43
+ schedule = schedule.tz_localize(min_dt.tz) if schedule.tz is None else schedule.tz_convert(min_dt.tz)
44
+ schedule = schedule[(min_dt <= schedule) & (schedule <= max_dt)]
45
+ return MaterializedSchedule(schedule, available_metadata=available_metadata)
46
+
47
+
48
+ def _cron_like(schedule: str) -> bool:
49
+ # CroniterBadCronError: Exactly 5 or 6 columns has to be specified for iterator expression.
50
+ return len(schedule.split()) > 4 or schedule[0] == "@" # noqa: PLR2004
51
+
52
+
53
+ def _from_timedelta(schedule: TimedeltaTypes, limits: LimitsTuple) -> DatetimeIndex:
54
+ timedelta = Timedelta(schedule)
55
+ if timedelta <= Timedelta(0):
56
+ raise ValueError(f"unbounded {schedule=} must be greater than zero.")
57
+ return date_range(*limits, freq=timedelta, inclusive="both")
58
+
59
+
60
+ def _handle_cron(expr: str, min_dt: Timestamp, max_dt: Timestamp) -> DatetimeIndex:
61
+ try:
62
+ from croniter import croniter_range
63
+
64
+ return DatetimeIndex(croniter_range(min_dt, max_dt, expr))
65
+ except ModuleNotFoundError as e:
66
+ raise ModuleNotFoundError(f"Install 'croniter' to parse cron expressions such such as '{expr}'.") from e
@@ -0,0 +1,128 @@
1
+ from collections.abc import Callable
2
+ from datetime import timedelta
3
+ from typing import Literal, TypeAlias
4
+
5
+ from pandas import DatetimeIndex, Timedelta, Timestamp
6
+
7
+ from ..types import Span
8
+ from ._limits import LimitsTuple
9
+ from ._schedule import NO_LIMITS
10
+
11
+ StrictSpan: TypeAlias = int | Timedelta | Literal["all"]
12
+ SpanArgumentName = Literal["before", "after"]
13
+ FuncType = Callable[[int], Timestamp | None]
14
+
15
+
16
+ class InvalidSpanError(ValueError):
17
+ """Raised when offsets values for 'before' and 'after' arguments are invalid."""
18
+
19
+ def __init__(
20
+ self,
21
+ span: Span,
22
+ *,
23
+ name: SpanArgumentName,
24
+ reason: str = "must be greater than zero",
25
+ ) -> None:
26
+ super().__init__(f"Bad '{name}'-argument; offset {span!r} {reason}.")
27
+
28
+
29
+ def _to_timedelta(span: str | timedelta | Timedelta, *, name: SpanArgumentName) -> Timedelta:
30
+ retval = Timedelta(span)
31
+ if retval <= Timedelta(0):
32
+ raise InvalidSpanError(span, name=name)
33
+ return retval
34
+
35
+
36
+ def to_strict_span(span: Span, *, name: SpanArgumentName) -> StrictSpan:
37
+ """Convert `span` to a strict span.
38
+
39
+ Args:
40
+ span: User span.
41
+ name: Name of user input. User for error reporting.
42
+
43
+ Returns:
44
+ A valid span with reduced types.
45
+
46
+ Raises:
47
+ TypeError: For bad `span` types.
48
+ InvalidSpanError: For invalid `span` arguments.
49
+
50
+ """
51
+ if span == "all":
52
+ return "all"
53
+
54
+ if isinstance(span, (str, timedelta, Timedelta)):
55
+ return _to_timedelta(span, name=name)
56
+
57
+ if span is False or isinstance(span, (int, float)):
58
+ retval = int(span)
59
+ if retval <= 0:
60
+ raise InvalidSpanError(retval, name=name)
61
+ return retval
62
+
63
+ raise TypeError(f"cannot parse '{name}'-argument of type {type(span).__name__}: {span!r}.")
64
+
65
+
66
+ class OffsetCalculator:
67
+ """Utility class for computing before/after offsets from the `schedule`."""
68
+
69
+ def __init__(
70
+ self,
71
+ span: Span,
72
+ schedule: DatetimeIndex,
73
+ limits: LimitsTuple,
74
+ *,
75
+ name: SpanArgumentName,
76
+ ) -> None:
77
+ span = to_strict_span(span, name=name)
78
+ is_before = name == "before"
79
+
80
+ self.func: FuncType
81
+ if span == "all":
82
+ if limits == NO_LIMITS:
83
+ raise InvalidSpanError(
84
+ span,
85
+ name=name,
86
+ reason="requires available data to bound the schedule",
87
+ )
88
+ self.func = self._make_all(is_before, limits)
89
+ elif isinstance(span, int):
90
+ self.func = self._make_int(is_before, span, schedule)
91
+ else: # Timedelta
92
+ self.func = self._make_timedelta(is_before, span, schedule)
93
+
94
+ @staticmethod
95
+ def _make_all(is_before: bool, limits: LimitsTuple) -> FuncType:
96
+ retval = Timestamp(limits[0 if is_before else 1])
97
+
98
+ def func(_: int) -> Timestamp | None:
99
+ return retval
100
+
101
+ return func
102
+
103
+ @staticmethod
104
+ def _make_int(is_before: bool, offset: int, schedule: DatetimeIndex) -> FuncType:
105
+ def func(i: int) -> Timestamp | None:
106
+ i = i - offset if is_before else i + offset
107
+ if 0 <= i < len(schedule):
108
+ return schedule[i]
109
+ else:
110
+ return None
111
+
112
+ return func
113
+
114
+ @staticmethod
115
+ def _make_timedelta(is_before: bool, offset: Timedelta, schedule: DatetimeIndex) -> FuncType:
116
+ if is_before:
117
+ schedule = schedule - offset
118
+ else:
119
+ schedule = schedule + offset
120
+
121
+ def func(i: int) -> Timestamp | None:
122
+ return schedule[i]
123
+
124
+ return func
125
+
126
+ def __call__(self, i: int) -> Timestamp | None:
127
+ """Get start/end timestamp."""
128
+ return self.func(i)
@@ -0,0 +1,162 @@
1
+ import logging
2
+ from dataclasses import asdict, dataclass
3
+ from typing import cast, get_args
4
+
5
+ from pandas import Timedelta, Timestamp
6
+ from rics.misc import format_kwargs
7
+ from rics.performance import format_seconds
8
+
9
+ from ..settings import misc
10
+ from ..types import (
11
+ DatetimeIndexSplitterKwargs,
12
+ DatetimeIterable,
13
+ DatetimeSplitBounds,
14
+ DatetimeSplits,
15
+ Flex,
16
+ Schedule,
17
+ Span,
18
+ TimedeltaTypes,
19
+ )
20
+ from ._limits import LimitsTuple
21
+ from ._schedule import MaterializedSchedule, materialize_schedule
22
+ from ._span import OffsetCalculator, to_strict_span
23
+
24
+ LOGGER = logging.getLogger("time_split")
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class DatetimeIndexSplitter:
29
+ """Backend interface for splitting user data."""
30
+
31
+ schedule: Schedule
32
+ before: Span
33
+ after: Span
34
+ step: int
35
+ n_splits: int | None
36
+ flex: Flex
37
+
38
+ def get_splits(self, available: DatetimeIterable | None = None) -> DatetimeSplits:
39
+ """Compute a split of given user data."""
40
+ ms = self._materialize_schedule(available)
41
+ self._log_expansion(ms.available_metadata.limits, expanded=ms.available_metadata.expanded_limits)
42
+ return self._make_bounds_list(ms)
43
+
44
+ def get_plot_data(self, available: DatetimeIterable | None = None) -> tuple[DatetimeSplits, MaterializedSchedule]:
45
+ """Returns additional data needed to visualize folds."""
46
+ ms = self._materialize_schedule(available)
47
+ splits = self._make_bounds_list(ms)
48
+ return splits, ms
49
+
50
+ def _materialize_schedule(self, available: DatetimeIterable | None = None) -> MaterializedSchedule:
51
+ ms = materialize_schedule(self.schedule, self.flex, available=available)
52
+ if not ms.schedule.sort_values().equals(ms.schedule):
53
+ raise ValueError(f"schedule must be sorted in ascending order; schedule={self.schedule!r} is not valid.")
54
+
55
+ types = get_args(TimedeltaTypes)
56
+ if (
57
+ misc.snap_to_end
58
+ and self.after != "all"
59
+ and isinstance(self.schedule, types)
60
+ and isinstance(self.after, types)
61
+ ):
62
+ ms = self._snap_to_end(ms)
63
+
64
+ return ms
65
+
66
+ def _snap_to_end(self, ms: MaterializedSchedule) -> MaterializedSchedule:
67
+ schedule_frequency = ms.schedule.freq
68
+ if schedule_frequency is None:
69
+ return ms
70
+
71
+ from_end = ms.available_metadata.expanded_limits[1] - (ms.schedule[-1] + Timedelta(self.after))
72
+ from_end = from_end.floor(schedule_frequency.base)
73
+
74
+ return ms._replace(schedule=ms.schedule + from_end)
75
+
76
+ def _make_bounds_list(self, ms: MaterializedSchedule) -> DatetimeSplits:
77
+ get_start = OffsetCalculator(
78
+ self.before,
79
+ ms.schedule,
80
+ ms.available_metadata.expanded_limits,
81
+ name="before",
82
+ )
83
+ get_end = OffsetCalculator(self.after, ms.schedule, ms.available_metadata.expanded_limits, name="after")
84
+
85
+ min_start, max_end = ms.available_metadata.expanded_limits
86
+
87
+ retval = []
88
+ for i, mid in enumerate(ms.schedule):
89
+ start, end = get_start(i), get_end(i)
90
+ if start is None or start < min_start or start >= mid:
91
+ continue
92
+ if end is None or end > max_end or end <= mid:
93
+ continue
94
+ retval.append(DatetimeSplitBounds(start, mid, end))
95
+
96
+ if not retval:
97
+ limits_info = f"limits={tuple(map(str, ms.available_metadata.limits))} and "
98
+ msg = f"No valid splits with {limits_info}split params: ({format_kwargs(self.as_dict())})"
99
+ raise ValueError(msg)
100
+
101
+ return self._filter(retval)
102
+
103
+ def _filter(self, splits: DatetimeSplits) -> DatetimeSplits:
104
+ """Apply splitting arguments.
105
+
106
+ Args:
107
+ splits: Splits to filter.
108
+
109
+ Returns:
110
+ Filtered splits.
111
+
112
+ """
113
+ if self.step != 1:
114
+ step = abs(self.step)
115
+ splits = [s for i, s in enumerate(reversed(splits)) if i % step == 0]
116
+ splits.reverse()
117
+
118
+ if self.n_splits:
119
+ splits = splits[-self.n_splits :]
120
+
121
+ if self.step < 0:
122
+ splits.reverse()
123
+
124
+ return splits
125
+
126
+ def _log_expansion(self, original: LimitsTuple, *, expanded: LimitsTuple) -> None:
127
+ if original == expanded:
128
+ return
129
+
130
+ if not LOGGER.isEnabledFor(logging.INFO):
131
+ return
132
+
133
+ def stringify(old: Timestamp, *, new: Timestamp) -> str:
134
+ from .._frontend._to_string import _PrettyTimestamp
135
+
136
+ retval = f"{old} -> "
137
+ if old == new:
138
+ return retval + "<no change>"
139
+ diff = (new - old).total_seconds()
140
+ return retval + f"{_PrettyTimestamp(new).auto} ({'+' if diff > 0 else '-'}{format_seconds(abs(diff))})"
141
+
142
+ LOGGER.info(
143
+ f"Available data limits have been expanded (since flex={self.flex!r}):\n"
144
+ f" start: {stringify(original[0], new=expanded[0])}\n"
145
+ f" end: {stringify(original[1], new=expanded[1])}"
146
+ )
147
+
148
+ def __post_init__(self) -> None:
149
+ # Verify n_splits
150
+ if self.n_splits is not None and self.n_splits < 1:
151
+ raise ValueError(f"Expected n_splits >= 1, but got n_splits={self.n_splits!r}.")
152
+
153
+ # Verify before/after
154
+ to_strict_span(self.before, name="before")
155
+ to_strict_span(self.after, name="after")
156
+
157
+ if self.step == 0:
158
+ raise ValueError(f"Bad argument step={self.step}; must be a non-zero integer.")
159
+
160
+ def as_dict(self) -> DatetimeIndexSplitterKwargs:
161
+ """Returns the splitter as a ``dict``."""
162
+ return cast(DatetimeIndexSplitterKwargs, asdict(self))
@@ -0,0 +1,36 @@
1
+ from typing import TypeVar
2
+
3
+ FuncType = TypeVar("FuncType") # No proper way to type this?
4
+
5
+
6
+ def add_docstrings(**kwargs: str) -> None:
7
+ """Add a documented argument to the ``@docs`` decorator."""
8
+ _DOCSTRINGS.update(kwargs)
9
+
10
+
11
+ def docs(function: FuncType) -> FuncType:
12
+ """Add info the docstring of a function."""
13
+ assert function.__doc__ is not None # noqa: S101
14
+ function.__doc__ = function.__doc__.format(**_DOCSTRINGS)
15
+ return function
16
+
17
+
18
+ _OFFSET = "pandas :ref:`offset alias <pandas:timeseries.offset_aliases>`"
19
+ _SPAN = (
20
+ f"Range {{arg}} `schedule` timestamps. Either a {_OFFSET}, an integer (`schedule`-based offsets), "
21
+ f"or `'all'` (requires `available` data)."
22
+ )
23
+ _DOCSTRINGS = {
24
+ "schedule": f"A collection of timestamps, a {_OFFSET}, or a cron expression.",
25
+ "before": _SPAN.format(arg="before"),
26
+ "after": _SPAN.format(arg="after"),
27
+ "step": "Select a subset of folds, preferring folds later in the schedule.",
28
+ "n_splits": "Maximum number of folds, preferring folds later in the schedule.",
29
+ "available": "Binds `schedule` to a range.",
30
+ "flex": f'A {_OFFSET} used to expand `available` data to its likely `"true"` limits. Pass ``False`` to disable.',
31
+ "USER_GUIDE": (
32
+ "For more information about the `schedule`, `before/after` and `flex`-arguments" ", see the :ref:`User guide`."
33
+ ),
34
+ "OFFSET": _OFFSET,
35
+ "DatetimeIndexSplitterKwargs": "See :func:`~time_split.split`. The `available` keyword is managed by the integration.",
36
+ }
@@ -0,0 +1,13 @@
1
+ from ._plot import plot
2
+ from ._progress import log_split_progress
3
+ from ._split import split
4
+ from ._to_string import to_string
5
+ from ._weight import fold_weight
6
+
7
+ __all__ = [
8
+ "fold_weight",
9
+ "log_split_progress",
10
+ "plot",
11
+ "split",
12
+ "to_string",
13
+ ]