time-split 0.2.1__tar.gz → 0.4.0__tar.gz

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.
Files changed (36) hide show
  1. {time_split-0.2.1 → time_split-0.4.0}/PKG-INFO +2 -2
  2. {time_split-0.2.1 → time_split-0.4.0}/README.md +1 -1
  3. {time_split-0.2.1 → time_split-0.4.0}/pyproject.toml +7 -6
  4. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/__init__.py +1 -1
  5. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/_limits.py +26 -22
  6. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/_process_available.py +7 -5
  7. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/_schedule.py +3 -3
  8. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/_splitter.py +7 -6
  9. time_split-0.4.0/src/time_split/_compat.py +49 -0
  10. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_docstrings.py +18 -8
  11. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_frontend/__init__.py +2 -1
  12. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_frontend/_plot.py +8 -7
  13. time_split-0.4.0/src/time_split/_frontend/_progress.py +245 -0
  14. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_frontend/_split.py +4 -4
  15. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_frontend/_to_string.py +1 -1
  16. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/_log_progress.py +4 -4
  17. time_split-0.2.1/src/time_split/integration/split_data.py → time_split-0.4.0/src/time_split/integration/base.py +1 -1
  18. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/pandas/_impl.py +3 -3
  19. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/polars/_impl.py +3 -3
  20. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/sklearn/_impl.py +6 -5
  21. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/settings.py +12 -10
  22. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/support/__init__.py +2 -1
  23. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/types.py +42 -3
  24. time_split-0.2.1/src/time_split/_frontend/_progress.py +0 -125
  25. {time_split-0.2.1 → time_split-0.4.0}/LICENSE.md +0 -0
  26. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/__init__.py +0 -0
  27. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/_datetime_index_like.py +0 -0
  28. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/_span.py +0 -0
  29. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_frontend/_weight.py +0 -0
  30. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_support.py +0 -0
  31. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/__init__.py +0 -0
  32. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/pandas/__init__.py +0 -0
  33. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/polars/__init__.py +0 -0
  34. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/sklearn/__init__.py +0 -0
  35. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/py.typed +0 -0
  36. {time_split-0.2.1 → time_split-0.4.0}/src/time_split/support/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: time-split
3
- Version: 0.2.1
3
+ Version: 0.4.0
4
4
  Summary: Time-based k-fold validation splits for heterogeneous data.
5
5
  Home-page: https://github.com/rsundqvist/time-split
6
6
  Author: Richard Sundqvist
@@ -58,7 +58,7 @@ strong non-stationary characteristics.
58
58
  a CRON-expression, or a pre-defined list.
59
59
  - [Data selection](https://time-split.readthedocs.io/en/stable/guide/spans.html) based on a timedelta, or the splitting
60
60
  schedule itself.
61
- - Automatically extract and [normalize](https://time-split.readthedocs.io/en/stable/guide/flex.html) data limits for
61
+ - Automatically extract and [normalize](https://time-split.readthedocs.io/en/stable/guide/expand_limits.html) data limits for
62
62
  supported data types.
63
63
  - [Plotting function](https://time-split.readthedocs.io/en/stable/generated/time_split.html#time_split.plot) for
64
64
  visualization of folds. Display fold sizes (hour/row count), or
@@ -27,7 +27,7 @@ strong non-stationary characteristics.
27
27
  a CRON-expression, or a pre-defined list.
28
28
  - [Data selection](https://time-split.readthedocs.io/en/stable/guide/spans.html) based on a timedelta, or the splitting
29
29
  schedule itself.
30
- - Automatically extract and [normalize](https://time-split.readthedocs.io/en/stable/guide/flex.html) data limits for
30
+ - Automatically extract and [normalize](https://time-split.readthedocs.io/en/stable/guide/expand_limits.html) data limits for
31
31
  supported data types.
32
32
  - [Plotting function](https://time-split.readthedocs.io/en/stable/generated/time_split.html#time_split.plot) for
33
33
  visualization of folds. Display fold sizes (hour/row count), or
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "time-split"
3
- version = "0.2.1"
3
+ version = "0.4.0"
4
4
  description = "Time-based k-fold validation splits for heterogeneous data."
5
5
  authors = ["Richard Sundqvist <richard.sundqvist@live.se>"]
6
6
 
@@ -46,8 +46,9 @@ plotting = ["matplotlib", "seaborn"]
46
46
  [tool.poetry.group.manual-extras.dependencies]
47
47
  # Extras that must be available for docs + tests go here. Doc/test-only deps go in their respective groups.
48
48
  croniter = "^2.0.3"
49
- pyarrow = "^15.0.2"
49
+ pyarrow = ">=15.0.2,<17.0.0"
50
50
  polars = { version = "^0.20.19", extras = ["pandas"] }
51
+ numpy = "<2" # TODO: numpy 2.0
51
52
 
52
53
  [tool.poetry.group.docs]
53
54
  [tool.poetry.group.docs.dependencies]
@@ -55,9 +56,9 @@ polars = { version = "^0.20.19", extras = ["pandas"] }
55
56
  # description: https://github.com/astropy/astropy/issues/11723
56
57
  # https://github.com/astropy/astropy/pull/12270
57
58
  sphinx = "^7.2.6"
58
- sphinx-gallery = "^0.15.0"
59
+ sphinx-gallery = "^0.16.0"
59
60
  pydata-sphinx-theme = "^0.15.2"
60
- myst-parser = "^2.0.0"
61
+ myst-parser = ">=2,<4"
61
62
  nbsphinx = "^0.9.3"
62
63
  ipython = "^8.23.0"
63
64
 
@@ -80,12 +81,12 @@ scikit-learn = "^1.4.2"
80
81
  # dask.datasets.timeseries() warns:
81
82
  # UserWarning: dask_expr does not support the DataFrameIOFunction protocol for column projection
82
83
  # on dask == 2024.3.1
83
- dask = { version = "==2024.4.1", extras = ["dataframe"] }
84
+ dask = { version = "==2024.5.0", extras = ["dataframe"] }
84
85
 
85
86
  [tool.poetry.group.devops.dependencies]
86
87
  invoke = "^2.2.0"
87
88
  mypy = "^1.9.0"
88
- ruff = "^0.3.7"
89
+ ruff = ">=0.3.7,<0.5.0"
89
90
 
90
91
  safety = "^3.1.0"
91
92
  codespell = "^2.2.6"
@@ -11,6 +11,6 @@ __all__ = [
11
11
  "__version__", # Make MyPy happy
12
12
  ]
13
13
 
14
- __version__ = "0.2.1"
14
+ __version__ = "0.4.0"
15
15
 
16
16
  _logging.getLogger(__name__).addHandler(_logging.NullHandler())
@@ -4,8 +4,8 @@ from typing import NamedTuple
4
4
 
5
5
  from pandas import Timedelta, Timestamp
6
6
 
7
- from ..settings import auto_flex
8
- from ..types import Flex, TimedeltaTypes
7
+ from ..settings import auto_expand_limits
8
+ from ..types import ExpandLimits, TimedeltaTypes
9
9
 
10
10
  LimitsTuple = tuple[Timestamp, Timestamp]
11
11
  LevelTuple = tuple[TimedeltaTypes, TimedeltaTypes, TimedeltaTypes]
@@ -17,17 +17,21 @@ class _TimedeltaTuple(NamedTuple):
17
17
  tolerance: Timedelta
18
18
 
19
19
 
20
- def expand_limits(limits: LimitsTuple, *, flex: Flex | LevelTuple | Iterable[LevelTuple] = "auto") -> LimitsTuple:
20
+ def expand_limits(
21
+ limits: LimitsTuple,
22
+ spec: ExpandLimits | LevelTuple | Iterable[LevelTuple] = "auto",
23
+ ) -> LimitsTuple:
21
24
  """Derive the `"real"` bounds of `limits`.
22
25
 
23
26
  Args:
24
27
  limits: A tuple ``(min, max)`` 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
+ spec: Expansion spec as described in the :ref:`User guide`. Also supports level-tuples
29
+ ``[(start_at, round_to, tolerance)...]``. Passing
30
+ ``expand_limits=[settings.auto_expand_limits.day, settings.auto_expand_limits.hour]``
31
+ is equivalent to ``expand_limits='auto'``.
28
32
 
29
33
  Returns:
30
- Limits rounded according to the `flex`-argument.
34
+ Limits rounded according to the given specification.
31
35
 
32
36
  Raises:
33
37
  ValueError: For invalid limits.
@@ -38,23 +42,23 @@ def expand_limits(limits: LimitsTuple, *, flex: Flex | LevelTuple | Iterable[Lev
38
42
 
39
43
  Basic usage.
40
44
 
41
- >>> expand_limits(limits, flex="d")
45
+ >>> expand_limits(limits, "d")
42
46
  (Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-12 00:00:00'))
43
47
 
44
48
  You may specify a maximum "distance" that limits may be expanded.
45
49
 
46
- >>> expand_limits(limits, flex="d<1h")
50
+ >>> expand_limits(limits, "d<1h")
47
51
  (Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-11 22:05:30'))
48
52
 
49
53
  Limits will never be rounded in the "wrong" direction...
50
54
 
51
55
  >>> limits = Timestamp("2019-05-11"), Timestamp("2019-05-11 11:05:30")
52
- >>> expand_limits(limits, flex="d")
56
+ >>> expand_limits(limits, "d")
53
57
  (Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-11 11:05:30'))
54
58
 
55
59
  ...even if you make the tolerance large enough.
56
60
 
57
- >>> expand_limits(limits, flex="d<14h")
61
+ >>> expand_limits(limits, "d<14h")
58
62
  (Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-11 11:05:30'))
59
63
 
60
64
  """
@@ -62,20 +66,20 @@ def expand_limits(limits: LimitsTuple, *, flex: Flex | LevelTuple | Iterable[Lev
62
66
  msg = f"Bad limits. Expected limits[1] > limits[0], but got {limits=}."
63
67
  raise ValueError(msg)
64
68
 
65
- if flex is False:
69
+ if spec is False:
66
70
  return limits
67
71
 
68
- if flex is True or flex == "auto":
72
+ if spec is True or spec == "auto":
69
73
  return _from_levels(limits)
70
74
 
71
- if isinstance(flex, str):
72
- round_to, _, tolerance = flex.partition("<")
75
+ if isinstance(spec, str):
76
+ round_to, _, tolerance = spec.partition("<")
73
77
  level = _make_level(None, round_to=round_to.strip(), tolerance=tolerance.strip())
74
78
  return _apply(limits, level=level)
75
- if isinstance(flex, tuple):
76
- return _from_levels(limits, levels=[_make_level(*flex)])
79
+ if isinstance(spec, tuple):
80
+ return _from_levels(limits, levels=[_make_level(*spec)])
77
81
 
78
- return _from_levels(limits, levels=starmap(_make_level, flex))
82
+ return _from_levels(limits, levels=starmap(_make_level, spec))
79
83
 
80
84
 
81
85
  def _from_levels(limits: LimitsTuple, *, levels: Iterable[_TimedeltaTuple] | None = None) -> LimitsTuple:
@@ -106,7 +110,7 @@ def _apply(limits: LimitsTuple, *, level: _TimedeltaTuple) -> LimitsTuple:
106
110
  if abs(hi_ceil - hi) > level.tolerance:
107
111
  return limits
108
112
 
109
- if auto_flex.SANITY_CHECK:
113
+ if auto_expand_limits.SANITY_CHECK:
110
114
  if lo.round(level.round_to) != lo_floor:
111
115
  return limits
112
116
  if hi.round(level.round_to) != hi_ceil:
@@ -116,12 +120,12 @@ def _apply(limits: LimitsTuple, *, level: _TimedeltaTuple) -> LimitsTuple:
116
120
 
117
121
 
118
122
  def _levels_from_settings() -> list[_TimedeltaTuple]:
119
- day, hour = _make_level(*auto_flex.day), _make_level(*auto_flex.hour)
123
+ day, hour = _make_level(*auto_expand_limits.day), _make_level(*auto_expand_limits.hour)
120
124
  if day.round_to != Timedelta(days=1):
121
- msg = f"Invalid settings: {auto_flex.day=} must have round_to=1 day."
125
+ msg = f"Invalid settings: {auto_expand_limits.day=} must have round_to=1 day."
122
126
  raise ValueError(msg)
123
127
  if hour.round_to != Timedelta(hours=1):
124
- msg = f"Invalid settings: {auto_flex.hour=} must have round_to=1 hour."
128
+ msg = f"Invalid settings: {auto_expand_limits.hour=} must have round_to=1 hour."
125
129
  raise ValueError(msg)
126
130
  return [day, hour]
127
131
 
@@ -3,9 +3,10 @@ from typing import NamedTuple
3
3
  from pandas import DatetimeIndex, Timestamp, isna
4
4
 
5
5
  from .._support import handle_dask
6
- from ..types import DatetimeIterable, Flex
6
+ from ..types import DatetimeIterable, ExpandLimits
7
7
  from ._datetime_index_like import DatetimeIndexLike
8
- from ._limits import LimitsTuple, expand_limits
8
+ from ._limits import LimitsTuple
9
+ from ._limits import expand_limits as expand
9
10
 
10
11
 
11
12
  class ProcessAvailableResult(NamedTuple):
@@ -16,12 +17,13 @@ class ProcessAvailableResult(NamedTuple):
16
17
  expanded_limits: LimitsTuple
17
18
 
18
19
 
19
- def process_available(available: DatetimeIterable, *, flex: Flex) -> ProcessAvailableResult:
20
+ def process_available(available: DatetimeIterable, *, expand_limits: ExpandLimits) -> ProcessAvailableResult:
20
21
  """Process a user-given `available` argument.
21
22
 
22
23
  Args:
23
24
  available: Available data from user. May be ``None``
24
- flex: Flex-argument. Determines how much (if at all) to expand limits.
25
+ expand_limits: Expansion spec as described in the :ref:`User guide`. Determines how much (if at all) to expand
26
+ limits.
25
27
 
26
28
  Returns:
27
29
  A tuple ``(available, limits)``. Note that `available` will be ``None``, it has not been iterated over. This
@@ -49,7 +51,7 @@ def process_available(available: DatetimeIterable, *, flex: Flex) -> ProcessAvai
49
51
  # (croniter 1.4.1 + numpy 1.25.2): croniter cannot handle numpy.datetime64
50
52
  limits = Timestamp(min_dt), Timestamp(max_dt)
51
53
 
52
- expanded_limits = expand_limits(limits, flex=flex)
54
+ expanded_limits = expand(limits, spec=expand_limits)
53
55
  return ProcessAvailableResult(available_retval, limits=limits, expanded_limits=expanded_limits)
54
56
 
55
57
 
@@ -2,7 +2,7 @@ from typing import NamedTuple, cast, get_args
2
2
 
3
3
  from pandas import DatetimeIndex, NaT, Timedelta, Timestamp, date_range
4
4
 
5
- from ..types import DatetimeIterable, Flex, Schedule, TimedeltaTypes
5
+ from ..types import DatetimeIterable, ExpandLimits, Schedule, TimedeltaTypes
6
6
  from ._limits import LimitsTuple
7
7
  from ._process_available import ProcessAvailableResult, process_available
8
8
 
@@ -17,7 +17,7 @@ class MaterializedSchedule(NamedTuple):
17
17
 
18
18
 
19
19
  def materialize_schedule(
20
- schedule: Schedule, flex: Flex, *, available: DatetimeIterable | None = None
20
+ schedule: Schedule, expand_limits: ExpandLimits, *, available: DatetimeIterable | None = None
21
21
  ) -> MaterializedSchedule:
22
22
  """Materialize user schedule based on available data."""
23
23
  if available is None:
@@ -29,7 +29,7 @@ def materialize_schedule(
29
29
  except TypeError as e:
30
30
  raise ValueError("Schedule must be explicit when not bounded by an available range.") from e
31
31
 
32
- available_metadata = process_available(available, flex=flex)
32
+ available_metadata = process_available(available, expand_limits=expand_limits)
33
33
  min_dt, max_dt = available_metadata.expanded_limits
34
34
 
35
35
  if isinstance(schedule, str) and _cron_like(schedule):
@@ -4,7 +4,8 @@ from typing import cast, get_args
4
4
 
5
5
  from pandas import Timedelta, Timestamp
6
6
  from rics.misc import format_kwargs
7
- from rics.performance import format_seconds
7
+
8
+ from time_split._compat import fmt_sec
8
9
 
9
10
  from ..settings import misc
10
11
  from ..types import (
@@ -12,7 +13,7 @@ from ..types import (
12
13
  DatetimeIterable,
13
14
  DatetimeSplitBounds,
14
15
  DatetimeSplits,
15
- Flex,
16
+ ExpandLimits,
16
17
  Schedule,
17
18
  Span,
18
19
  TimedeltaTypes,
@@ -33,7 +34,7 @@ class DatetimeIndexSplitter:
33
34
  after: Span
34
35
  step: int
35
36
  n_splits: int
36
- flex: Flex
37
+ expand_limits: ExpandLimits
37
38
 
38
39
  def get_splits(self, available: DatetimeIterable | None = None) -> DatetimeSplits:
39
40
  """Compute a split of given user data."""
@@ -48,7 +49,7 @@ class DatetimeIndexSplitter:
48
49
  return splits, ms
49
50
 
50
51
  def _materialize_schedule(self, available: DatetimeIterable | None = None) -> MaterializedSchedule:
51
- ms = materialize_schedule(self.schedule, self.flex, available=available)
52
+ ms = materialize_schedule(self.schedule, self.expand_limits, available=available)
52
53
  if not ms.schedule.sort_values().equals(ms.schedule):
53
54
  raise ValueError(f"schedule must be sorted in ascending order; schedule={self.schedule!r} is not valid.")
54
55
 
@@ -137,10 +138,10 @@ class DatetimeIndexSplitter:
137
138
  if old == new:
138
139
  return retval + "<no change>"
139
140
  diff = (new - old).total_seconds()
140
- return retval + f"{_PrettyTimestamp(new).auto} ({'+' if diff > 0 else '-'}{format_seconds(abs(diff))})"
141
+ return retval + f"{_PrettyTimestamp(new).auto} ({'+' if diff > 0 else '-'}{fmt_sec(abs(diff))})"
141
142
 
142
143
  LOGGER.info(
143
- f"Available data limits have been expanded (since flex={self.flex!r}):\n"
144
+ f"Available data limits have been expanded (since expand_limits={self.expand_limits!r}):\n"
144
145
  f" start: {stringify(original[0], new=expanded[0])}\n"
145
146
  f" end: {stringify(original[1], new=expanded[1])}"
146
147
  )
@@ -0,0 +1,49 @@
1
+ """Vendored functions."""
2
+
3
+ from functools import wraps
4
+ from typing import Callable, ParamSpec, TypeVar
5
+ from warnings import warn
6
+
7
+ from rics.misc import tname
8
+
9
+ try:
10
+ from rics import strings
11
+
12
+ fmt_perf = strings.format_perf_counter
13
+ fmt_sec = strings.format_seconds
14
+ except ImportError:
15
+ from rics import performance
16
+
17
+ # TODO(0.5.0): Drop this.
18
+ fmt_perf = performance.format_perf_counter
19
+ fmt_sec = performance.format_seconds
20
+
21
+ P = ParamSpec("P")
22
+ T = TypeVar("T")
23
+
24
+ PARAMS = (("expand_limits", "flex"),)
25
+ WARNED: set[str] = set() # Minimize the amount of warnings - once per param is enough.
26
+
27
+
28
+ def deprecated_params(__func: Callable[P, T], /) -> Callable[P, T]:
29
+ @wraps(__func)
30
+ def wrap(*args: P.args, **kwargs: P.kwargs) -> T:
31
+ for new, old in PARAMS:
32
+ if old in kwargs:
33
+ if new in kwargs:
34
+ name = tname(__func, prefix_classname=True)
35
+ msg = f"Ambiguous call: {name}() does not accept both `{old}` and `{new}`."
36
+ raise TypeError(msg)
37
+
38
+ if old not in WARNED:
39
+ WARNED.add(old)
40
+
41
+ name = tname(__func, prefix_classname=True)
42
+ msg = f"{name}(): The `{old}` parameter is deprecated; use `{new}` instead."
43
+ warn(message=msg, category=DeprecationWarning, stacklevel=2)
44
+
45
+ kwargs[new] = kwargs.pop(old)
46
+
47
+ return __func(*args, **kwargs)
48
+
49
+ return wrap
@@ -1,6 +1,9 @@
1
- from typing import TypeVar
1
+ from typing import Callable, ParamSpec, TypeVar
2
2
 
3
- FuncType = TypeVar("FuncType") # No proper way to type this?
3
+ from time_split._compat import deprecated_params
4
+
5
+ P = ParamSpec("P")
6
+ T = TypeVar("T")
4
7
 
5
8
 
6
9
  def add_docstrings(**kwargs: str) -> None:
@@ -8,11 +11,17 @@ def add_docstrings(**kwargs: str) -> None:
8
11
  _DOCSTRINGS.update(kwargs)
9
12
 
10
13
 
11
- def docs(function: FuncType) -> FuncType:
14
+ def docs(__func: Callable[P, T], /) -> Callable[P, T]:
12
15
  """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
+ assert __func.__doc__ is not None # noqa: S101
17
+ __func.__doc__ = __func.__doc__.format(**_DOCSTRINGS)
18
+
19
+ if isinstance(__func, type):
20
+ __func.__init__ = deprecated_params(__func.__init__)
21
+ else:
22
+ __func = deprecated_params(__func)
23
+
24
+ return __func
16
25
 
17
26
 
18
27
  _OFFSET = "pandas :ref:`offset alias <pandas:timeseries.offset_aliases>`"
@@ -27,9 +36,10 @@ _DOCSTRINGS = {
27
36
  "step": "Select a subset of folds, preferring folds later in the schedule.",
28
37
  "n_splits": "Maximum number of folds, preferring folds later in the schedule.",
29
38
  "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.',
39
+ "expand_limits": f'A {_OFFSET} used to expand `available` data to its likely `"true"` limits. Pass ``False`` to disable.',
31
40
  "USER_GUIDE": (
32
- "For more information about the `schedule`, `before/after` and `flex`-arguments" ", see the :ref:`User guide`."
41
+ "For more information about the `schedule`, `before/after` and `expand_limits`-arguments"
42
+ ", see the :ref:`User guide`."
33
43
  ),
34
44
  "OFFSET": _OFFSET,
35
45
  "DatetimeIndexSplitterKwargs": "See :func:`~time_split.split`. The `available` keyword is managed by the integration.",
@@ -1,5 +1,5 @@
1
1
  from ._plot import plot
2
- from ._progress import log_split_progress
2
+ from ._progress import default_metrics_formatter, log_split_progress
3
3
  from ._split import split
4
4
  from ._to_string import to_string
5
5
  from ._weight import fold_weight
@@ -7,6 +7,7 @@ from ._weight import fold_weight
7
7
  __all__ = [
8
8
  "fold_weight",
9
9
  "log_split_progress",
10
+ "default_metrics_formatter",
10
11
  "plot",
11
12
  "split",
12
13
  "to_string",
@@ -4,7 +4,8 @@ from typing import TYPE_CHECKING, Any, Literal
4
4
  import pandas as pd
5
5
  from pandas import Timestamp
6
6
  from rics.misc import format_kwargs, get_public_module
7
- from rics.performance import format_seconds
7
+
8
+ from time_split._compat import fmt_sec
8
9
 
9
10
  from .._backend import DatetimeIndexSplitter
10
11
  from .._backend._datetime_index_like import DatetimeIndexLike
@@ -16,7 +17,7 @@ from ..types import (
16
17
  DatetimeIterable,
17
18
  DatetimeSplitBounds,
18
19
  DatetimeSplits,
19
- Flex,
20
+ ExpandLimits,
20
21
  Schedule,
21
22
  Span,
22
23
  )
@@ -64,7 +65,7 @@ def plot(
64
65
  step: int = 1,
65
66
  n_splits: int = 0,
66
67
  available: DatetimeIterable | None = None,
67
- flex: Flex = "auto",
68
+ expand_limits: ExpandLimits = "auto",
68
69
  # Split plot args
69
70
  bar_labels: str | Rows | list[tuple[str, str]] | bool = True,
70
71
  show_removed: bool = False,
@@ -84,7 +85,7 @@ def plot(
84
85
  n_splits: {n_splits}
85
86
  available: {available} If `bar_labels` is given but is not a ``list``,
86
87
  this data will be used to compute fold sizes.
87
- flex: {flex}
88
+ expand_limits: {expand_limits}
88
89
  bar_labels: Labels to draw on the bars. If you pass a string, it will be interpreted as a time unit (see
89
90
  :ref:`pandas:timeseries.offset_aliases` for valid frequency strings). Bars will show the number of units
90
91
  contained. Pass `'rows'` to simply count the numbers of elements in `data` (if given). To write custom
@@ -114,7 +115,7 @@ def plot(
114
115
  after=after,
115
116
  step=step,
116
117
  n_splits=n_splits,
117
- flex=flex,
118
+ expand_limits=expand_limits,
118
119
  )
119
120
  plot_data = _get_plot_data(available, splitter, row_count_bin=row_count_bin, show_removed=show_removed)
120
121
 
@@ -207,10 +208,10 @@ def _plot_row_counts(ax: "Axes", row_count_bin: str | pd.Series, row_counts: pd.
207
208
  from numpy import diff, timedelta64
208
209
 
209
210
  row_counts = row_count_bin.sort_index()
210
- pretty = format_seconds(diff(row_counts.index).min() / timedelta64(1, "s"))
211
+ pretty = fmt_sec(diff(row_counts.index).min() / timedelta64(1, "s"))
211
212
  else:
212
213
  row_counts = row_counts.sort_index()
213
- pretty = format_seconds(pd.Timedelta(row_count_bin).total_seconds())
214
+ pretty = fmt_sec(pd.Timedelta(row_count_bin).total_seconds())
214
215
 
215
216
  row_counts = row_counts * (max(ax.get_yticks()) / row_counts.max()) # Normalize to fold number yaxis
216
217
  ax.fill_between(
@@ -0,0 +1,245 @@
1
+ import logging
2
+ from collections.abc import Callable, Iterable, MutableMapping, Sequence
3
+ from dataclasses import dataclass
4
+ from time import perf_counter
5
+ from typing import Any, Generic
6
+
7
+ import pandas as pd
8
+ from rics.collections.dicts import flatten_dict
9
+
10
+ from time_split._compat import fmt_sec
11
+
12
+ from ..settings import log_split_progress as settings
13
+ from ..types import DatetimeSplitBounds, FormatMetrics, GetMetrics, LoggerArg, MetricsType, SplitProgressExtras
14
+ from ._to_string import _PrettyTimestamp
15
+
16
+
17
+ def log_split_progress(
18
+ splits: Sequence[DatetimeSplitBounds],
19
+ *,
20
+ logger: LoggerArg = "time_split",
21
+ start_level: int = logging.INFO,
22
+ end_level: int = logging.INFO,
23
+ extra: dict[str, Any] | None = None,
24
+ get_metrics: GetMetrics[MetricsType] | None = None,
25
+ ) -> Iterable[DatetimeSplitBounds]:
26
+ """Log iteration progress.
27
+
28
+ Args:
29
+ splits: Splits to iterate over.
30
+ logger: Logger or logger name to use.
31
+ start_level: Log level to use for the :attr:`fold-begin message <.settings.log_split_progress.START_MESSAGE>`.
32
+ end_level: Log level to use for the :attr:`fold-end message <.settings.log_split_progress.END_MESSAGE>`.
33
+ extra: Immutable, user-defined `extra`-arguments to use when logging, merged with progress-related extras (see
34
+ :class:`~time_split.types.SplitProgressExtras`).
35
+ get_metrics: A callable ``(training_date) -> fold_metrics | str`` (see :attr:`~.DatetimeSplit.training_date`).
36
+ If given, metrics are added to the :attr:`fold-end message <.settings.log_split_progress.END_MESSAGE>`. The
37
+ message is formatted using the :func:`default formatter <.support.default_metrics_formatter>` unless
38
+ :attr:`~.settings.log_split_progress.FORMAT_METRICS` is set. If this callback returns a ``str`` argument,
39
+ the :func:`default formatter <.support.default_metrics_formatter>` will assume that the metrics are
40
+ pre-formatted, simply appending the formatted metrics to the
41
+ :attr:`fold-end message <.settings.log_split_progress.END_MESSAGE>` as-is.
42
+
43
+ Returns:
44
+ An iterable over `splits`.
45
+
46
+ Examples:
47
+ Configuring the `logger` name and
48
+ :attr:`fold-begin message <.settings.log_split_progress.START_MESSAGE>` log level.
49
+
50
+ >>> from time_split import split, log_split_progress
51
+ >>> schedule = ["2023-08-16", "2023-08-17 12", "2023-08-19"]
52
+ >>> tracked_splits = log_split_progress(
53
+ ... split(schedule),
54
+ ... logger="progress",
55
+ ... start_level=logging.DEBUG,
56
+ ... )
57
+ >>> list(splits) # doctest: +SKIP
58
+ [progress:DEBUG] Begin fold 1/2: '2023-08-09' <= [schedule: '2023-08-16' (Wednesday)] < '2023-08-17 12:00:00'.
59
+ [progress:INFO] Finished fold 1/2 [schedule: '2023-08-16' (Wednesday)] after 5m 18s.
60
+ [progress:DEBUG] Begin fold 2/2: '2023-08-10 12:00:00' <= [schedule: '2023-08-17 12:00:00' (Thursday)] < '2023-08-19'.
61
+ [progress:INFO] Finished fold 2/2 [schedule: '2023-08-17 12:00:00' (Thursday)] after 4m 3s.
62
+
63
+ Using the `get_metrics` callback argument.
64
+
65
+ >>> metrics = {
66
+ ... "2023-08-16 00:00:00": {"rmse": {"train": 0.11, "test": 0.5}},
67
+ ... "2023-08-17 12:00:00": {"rmse": {"test": 0.5, "future": 20.19}},
68
+ ... }
69
+ >>> tracked_splits = log_split_progress(
70
+ ... split(schedule),
71
+ ... get_metrics=lambda key: metrics[str(key)],
72
+ ... )
73
+ >>> list(tracked_splits) # doctest: +SKIP
74
+ [time_split:INFO] Begin fold 1/2: '2023-08-09' <= [schedule: '2023-08-16' (Wednesday)] < '2023-08-17 12:00:00'.
75
+ [time_split:INFO] Finished fold 1/2 [schedule: '2023-08-16' (Wednesday)] after 5m 18s. Fold metrics:
76
+ rmse.train 0.11
77
+ rmse.test 0.5
78
+ [time_split:INFO] Begin fold 2/2: '2023-08-10 12:00:00' <= [schedule: '2023-08-17 12:00:00' (Thursday)] < '2023-08-19'.
79
+ [time_split:INFO] Finished fold 2/2 [schedule: '2023-08-17 12:00:00' (Thursday)] after 4m 3s. Fold metrics:
80
+ rmse.test 0.5
81
+ rmse.future 20.19
82
+
83
+ Formatting was done using the :func:`default formatter <.support.default_metrics_formatter>`, since the
84
+ :attr:`~.settings.log_split_progress.FORMAT_METRICS` setting is ``None``.
85
+
86
+ """
87
+ logger = logging.getLogger(logger) if isinstance(logger, str) else logger
88
+
89
+ if isinstance(logger, logging.LoggerAdapter) and not hasattr(logger, "merge_extra"):
90
+ # Backport of https://github.com/python/cpython/pull/107292
91
+ logger = _MergingLoggerAdapter(logger.logger, logger.extra)
92
+
93
+ track = _ProgressTracker(
94
+ logger=logger,
95
+ fold_format=settings.FOLD_FORMAT,
96
+ start_level=start_level,
97
+ start_message=settings.START_MESSAGE,
98
+ end_level=end_level,
99
+ end_message=settings.END_MESSAGE,
100
+ seconds_formatter=settings.SECONDS_FORMATTER or fmt_sec,
101
+ user_extra={} if extra is None else extra.copy(), # Not actually immutable; deepcopy can be very expensive.
102
+ get_metrics=get_metrics,
103
+ format_metrics=settings.FORMAT_METRICS or default_metrics_formatter,
104
+ )
105
+ return track(splits)
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class _ProgressTracker(Generic[MetricsType]):
110
+ logger: logging.Logger | logging.LoggerAdapter # type: ignore[type-arg]
111
+ fold_format: str
112
+ start_level: int
113
+ start_message: str
114
+ end_level: int
115
+ end_message: str
116
+ seconds_formatter: Callable[[float], str]
117
+ user_extra: dict[str, Any]
118
+ get_metrics: GetMetrics[MetricsType] | None
119
+ format_metrics: FormatMetrics[MetricsType]
120
+
121
+ def __call__(self, splits: Sequence[DatetimeSplitBounds]) -> Iterable[DatetimeSplitBounds]:
122
+ n_splits = len(splits)
123
+
124
+ for n, split in enumerate(splits, start=1):
125
+ default_extras = SplitProgressExtras(
126
+ n=n,
127
+ n_splits=n_splits,
128
+ start=split.start.isoformat(),
129
+ mid=split.mid.isoformat(),
130
+ end=split.end.isoformat(),
131
+ )
132
+ extra = {**self.user_extra, **default_extras}
133
+ kwargs: dict[str, Any] = dict(
134
+ n=n,
135
+ n_splits=n_splits,
136
+ start=_PrettyTimestamp(split.start),
137
+ mid=_PrettyTimestamp(split.mid),
138
+ end=_PrettyTimestamp(split.end),
139
+ **self.user_extra,
140
+ )
141
+ kwargs.update(fold=self.fold_format.format(**kwargs))
142
+
143
+ self.logger.log(self.start_level, self.start_message.format(**kwargs), extra=extra)
144
+
145
+ # Yield split and count user time.
146
+ start = perf_counter()
147
+ yield split
148
+ seconds = round(perf_counter() - start, 6)
149
+
150
+ kwargs.update(
151
+ seconds=seconds,
152
+ formatted_seconds=self.seconds_formatter(seconds),
153
+ )
154
+ msg = self.end_message.format(**kwargs)
155
+
156
+ if self.get_metrics is not None:
157
+ extra["metrics"] = self.get_metrics(split.mid)
158
+ msg = self.format_metrics(msg, extra["metrics"])
159
+
160
+ extra.update(seconds=seconds)
161
+ self.logger.log(self.end_level, msg, extra=extra)
162
+
163
+
164
+ def default_metrics_formatter(end_message: str, metrics: dict[Any, Any] | pd.Series | pd.DataFrame | str | Any) -> str:
165
+ """Default formatting implementation.
166
+
167
+ Format using an appropriate pandas ``to_string()``-method if `metrics` is a ``dict`` or a pandas type. Nested
168
+ dictionaries are flattened using :func:`~rics.collections.dicts.flatten_dict` if `metrics` is a dict-of-dicts.
169
+
170
+ Metrics of type ``str`` are assumed to be preformatted, and are appended to `end_message` as-is.
171
+
172
+ If any other types are given, fall back to
173
+ ``f"{end_message} Metrics: {metrics}"``.
174
+
175
+ Examples:
176
+ Formatting a nested dict.
177
+
178
+ >>> metrics = {"rmse": {"train": 0.11, "test": 0.5, "future": 20.19}}
179
+
180
+ >>> print(default_metrics_formatter("End message.", metrics))
181
+ End message. Fold metrics:
182
+ train test future
183
+ rmse 0.11 0.5 20.19
184
+
185
+ Formatting a :class:`pandas.DataFrame`.
186
+
187
+ >>> metrics = {"me": [0.1, 0.2, 0.3], "rmse": [0.11, 0.5, 20.19]}
188
+ >>> df = pd.DataFrame(metrics, index=["train", "test", "future"])
189
+ >>> print(default_metrics_formatter("End message.", df))
190
+ End message. Fold metrics:
191
+ me rmse
192
+ train 0.1 0.11
193
+ test 0.2 0.50
194
+ future 0.3 20.19
195
+
196
+ The index printed unless` it is a :class:`pandas.RangeIndex`.
197
+ """
198
+ if isinstance(metrics, dict):
199
+ metrics = _convert_dict(metrics)
200
+
201
+ if isinstance(metrics, (pd.Series, pd.DataFrame)):
202
+ if isinstance(metrics, pd.DataFrame):
203
+ with_index = not isinstance(metrics, pd.RangeIndex)
204
+ else:
205
+ with_index = True
206
+ return f"{end_message} Fold metrics:\n{metrics.round(3).to_string(index=with_index)}"
207
+ elif isinstance(metrics, str):
208
+ return f"{end_message} {metrics}"
209
+ else:
210
+ return f"{end_message} Metrics: {metrics}"
211
+
212
+
213
+ class _MergingLoggerAdapter(logging.LoggerAdapter[Any]):
214
+ # TODO(3.13): Use merge_extra=True init arg
215
+ def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> tuple[Any, MutableMapping[str, Any]]:
216
+ """See https://github.com/python/cpython/pull/107292."""
217
+ kwargs["extra"] = {**self.extra, **kwargs["extra"]} if "extra" in kwargs and self.extra else self.extra
218
+ return msg, kwargs
219
+
220
+
221
+ def _convert_dict(metrics: dict[Any, Any]) -> dict[Any, Any] | pd.DataFrame | pd.Series:
222
+ original = metrics
223
+
224
+ scalar_leaves: bool = False
225
+
226
+ if all(isinstance(v, dict) for v in metrics.values()):
227
+ flat = flatten_dict(metrics)
228
+
229
+ # A single dot usually indicates nesting one level deep; can be formatted as a DataFrame.
230
+ if any(k.count(".") != 1 for k in flat) and all(pd.api.types.is_scalar(v) for v in flat.values()):
231
+ metrics = flat
232
+ elif all(pd.api.types.is_scalar(v) for v in original.values()):
233
+ scalar_leaves = True
234
+
235
+ try:
236
+ if scalar_leaves:
237
+ return pd.Series(metrics)
238
+
239
+ df = pd.DataFrame(metrics)
240
+ except Exception:
241
+ return original
242
+
243
+ if len(df) > len(df.columns):
244
+ df = df.T # Prefer wider horizontal output
245
+ return df
@@ -1,6 +1,6 @@
1
1
  from .._backend import DatetimeIndexSplitter
2
2
  from .._docstrings import docs
3
- from ..types import DatetimeIterable, DatetimeSplits, Flex, Schedule, Span
3
+ from ..types import DatetimeIterable, DatetimeSplits, ExpandLimits, Schedule, Span
4
4
 
5
5
 
6
6
  @docs
@@ -12,7 +12,7 @@ def split(
12
12
  step: int = 1,
13
13
  n_splits: int = 0,
14
14
  available: DatetimeIterable | None = None,
15
- flex: Flex = "auto",
15
+ expand_limits: ExpandLimits = "auto",
16
16
  ) -> DatetimeSplits:
17
17
  """Create time-based cross-validation splits.
18
18
 
@@ -25,7 +25,7 @@ def split(
25
25
  step: {step}
26
26
  n_splits: {n_splits}
27
27
  available: {available} Passing a tuple ``(min, max)`` is enough.
28
- flex: {flex}
28
+ expand_limits: {expand_limits}
29
29
 
30
30
  {USER_GUIDE}
31
31
 
@@ -41,5 +41,5 @@ def split(
41
41
  after=after,
42
42
  step=step,
43
43
  n_splits=n_splits,
44
- flex=flex,
44
+ expand_limits=expand_limits,
45
45
  ).get_splits(available)
@@ -29,7 +29,7 @@ def to_string(
29
29
 
30
30
 
31
31
  def to_string(
32
- bounds: (DatetimeTypes | DatetimeSplitBounds | tuple[DatetimeTypes, DatetimeTypes, DatetimeTypes]),
32
+ bounds: DatetimeTypes | DatetimeSplitBounds | tuple[DatetimeTypes, DatetimeTypes, DatetimeTypes],
33
33
  mid: DatetimeTypes | None = None,
34
34
  end: DatetimeTypes | None = None,
35
35
  /,
@@ -1,18 +1,18 @@
1
1
  import logging
2
2
  from collections.abc import Iterable
3
- from typing import Any, Union
3
+ from typing import Any
4
4
 
5
5
  from .. import log_split_progress
6
6
  from .._docstrings import add_docstrings
7
- from ..types import DatetimeSplitBounds, DatetimeSplits
7
+ from ..types import DatetimeSplitBounds, DatetimeSplits, LogSplitProgressKwargs, MetricsType
8
8
 
9
- LogProgressArg = Union[str, bool, dict[str, Any], logging.Logger, logging.LoggerAdapter] # type: ignore[type-arg]
9
+ LogProgressArg = str | bool | logging.Logger | logging.LoggerAdapter[Any] | LogSplitProgressKwargs[MetricsType]
10
10
 
11
11
  add_docstrings(log_progress="Controls logging of fold progress. See :func:`~.log_split_progress` for details.")
12
12
 
13
13
 
14
14
  def handle_log_progress_arg(
15
- log_progress: LogProgressArg, *, splits: DatetimeSplits
15
+ log_progress: LogProgressArg[MetricsType], *, splits: DatetimeSplits
16
16
  ) -> Iterable[DatetimeSplitBounds] | None:
17
17
  """Wrapper function for integrations."""
18
18
  if log_progress is True:
@@ -49,7 +49,7 @@ class DatetimeSplit(_t.NamedTuple, _t.Generic[DataT]):
49
49
  def split_data(
50
50
  data: DataT,
51
51
  *,
52
- log_progress: _log_progress.LogProgressArg = False,
52
+ log_progress: _log_progress.LogProgressArg[_tst.MetricsType] = False,
53
53
  as_available: DataAsAvailableFn[DataT],
54
54
  select: DataSelectFn[DataT],
55
55
  **kwargs: _t.Unpack[_tst.DatetimeIndexSplitterKwargs],
@@ -6,9 +6,9 @@ from pandas import DataFrame, DatetimeIndex, Series, Timestamp
6
6
  from rics.misc import tname
7
7
 
8
8
  from ..._docstrings import docs
9
- from ...types import DatetimeIndexSplitterKwargs
9
+ from ...types import DatetimeIndexSplitterKwargs, MetricsType
10
10
  from .._log_progress import LogProgressArg
11
- from ..split_data import DatetimeSplit, split_data
11
+ from ..base import DatetimeSplit, split_data
12
12
 
13
13
  PandasT = TypeVar("PandasT", Series, DataFrame)
14
14
  """A splittable pandas type."""
@@ -19,7 +19,7 @@ def split_pandas(
19
19
  data: PandasT,
20
20
  time_column: Hashable = None,
21
21
  *,
22
- log_progress: LogProgressArg = False,
22
+ log_progress: LogProgressArg[MetricsType] = False,
23
23
  **kwargs: Unpack[DatetimeIndexSplitterKwargs],
24
24
  ) -> Iterable[DatetimeSplit[PandasT]]:
25
25
  """Split a pandas type.
@@ -6,9 +6,9 @@ from polars import DataFrame, Series
6
6
  from rics.misc import tname
7
7
 
8
8
  from ..._docstrings import docs
9
- from ...types import DatetimeIndexSplitterKwargs
9
+ from ...types import DatetimeIndexSplitterKwargs, MetricsType
10
10
  from .._log_progress import LogProgressArg
11
- from ..split_data import DatetimeSplit, split_data
11
+ from ..base import DatetimeSplit, split_data
12
12
 
13
13
 
14
14
  @docs
@@ -16,7 +16,7 @@ def split_polars(
16
16
  data: DataFrame,
17
17
  time_column: str,
18
18
  *,
19
- log_progress: LogProgressArg = False,
19
+ log_progress: LogProgressArg[MetricsType] = False,
20
20
  **kwargs: Unpack[DatetimeIndexSplitterKwargs],
21
21
  ) -> Iterable[DatetimeSplit[DataFrame]]:
22
22
  """Split a polars frame.
@@ -10,7 +10,8 @@ from ...types import (
10
10
  DatetimeIterable,
11
11
  DatetimeSplits,
12
12
  DatetimeTypes,
13
- Flex,
13
+ ExpandLimits,
14
+ MetricsType,
14
15
  Schedule,
15
16
  Span,
16
17
  )
@@ -43,7 +44,7 @@ class ScikitLearnSplitter(BaseCrossValidator): # type: ignore[misc]
43
44
  after: {after}
44
45
  step: {step}
45
46
  n_splits: {n_splits}
46
- flex: {flex}
47
+ expand_limits: {expand_limits}
47
48
  log_progress: {log_progress}
48
49
  verify_xy: If ``True``, split X and y independently and verify that they are equal.
49
50
 
@@ -58,9 +59,9 @@ class ScikitLearnSplitter(BaseCrossValidator): # type: ignore[misc]
58
59
  before: Span = "7d",
59
60
  after: Span = 1,
60
61
  n_splits: int = 0,
61
- flex: Flex = "auto",
62
+ expand_limits: ExpandLimits = "auto",
62
63
  step: int = 1,
63
- log_progress: LogProgressArg = False,
64
+ log_progress: LogProgressArg[MetricsType] = False,
64
65
  verify_xy: bool = True,
65
66
  ) -> None:
66
67
  super().__init__()
@@ -70,7 +71,7 @@ class ScikitLearnSplitter(BaseCrossValidator): # type: ignore[misc]
70
71
  after=after,
71
72
  step=step,
72
73
  n_splits=n_splits,
73
- flex=flex,
74
+ expand_limits=expand_limits,
74
75
  )
75
76
  self.log_progress = log_progress
76
77
  self.verify_xy = verify_xy
@@ -5,14 +5,14 @@ import typing as _t
5
5
  from . import types as _tst
6
6
 
7
7
 
8
- class auto_flex: # noqa: N801
9
- """Configuration for the `'auto'` :attr:`~time_split.types.Flex` logic.
8
+ class auto_expand_limits: # noqa: N801
9
+ """Configuration for the `'auto'` :attr:`~time_split.types.ExpandLimits` logic.
10
10
 
11
- This class determines how ``(min, max)``-tuples are expanded when flexible bounds are enabled.
11
+ This class determines how ``(min, max)``-tuples are expanded when expand_limitsible bounds are enabled.
12
12
  """
13
13
 
14
14
  SANITY_CHECK: bool = True
15
- """If ``True``, use original limits if flexed limits do not pass sanity checks."""
15
+ """If ``True``, use original limits if expand_limitsed limits do not pass sanity checks."""
16
16
 
17
17
  @classmethod
18
18
  def set_level(
@@ -23,7 +23,7 @@ class auto_flex: # noqa: N801
23
23
  round_to: _tst.TimedeltaTypes,
24
24
  tolerance: _tst.TimedeltaTypes,
25
25
  ) -> None:
26
- """Set a :class:`Level` used by the auto-flex logic.
26
+ """Set a :class:`Level` used by the auto-expand_limits logic.
27
27
 
28
28
  Inputs are not verified until an actual split is made.
29
29
 
@@ -43,7 +43,7 @@ class auto_flex: # noqa: N801
43
43
  setattr(cls, level, level_tuple)
44
44
 
45
45
  class Level(_t.NamedTuple):
46
- """Level type used by :attr:`auto_flex`."""
46
+ """Level type used by :attr:`auto_expand_limits`."""
47
47
 
48
48
  start_at: _tst.TimedeltaTypes
49
49
  """Span size at which this level starts."""
@@ -113,11 +113,10 @@ class log_split_progress: # noqa: N801
113
113
  '2021-12-30' <= [schedule: '2022-01-04' (Tuesday)] < '2022-01-04 18:00:00'
114
114
  """
115
115
 
116
- SECONDS_FORMATTER: str | _t.Callable[[float], str] = "rics.performance.format_seconds"
117
- """A callable ``(seconds) -> formatted_seconds``.
116
+ SECONDS_FORMATTER: _t.Callable[[float], str] | None = None
117
+ """A callable ``(seconds) -> formatted_seconds``. Use :func:`rics.strings.format_seconds` if ``None``.
118
118
 
119
- Both ``seconds`` and ``formatted_seconds`` will be available to the :attr:`END_MESSAGE` message. If a string is
120
- given, the actual callable will be resolved using :func:`rics.misc.get_by_full_name`.
119
+ Both ``seconds`` and ``formatted_seconds`` will be available to the :attr:`END_MESSAGE` message.
121
120
  """
122
121
 
123
122
  START_MESSAGE: str = "Begin fold {n}/{n_splits}: {fold}."
@@ -156,6 +155,9 @@ class log_split_progress: # noqa: N801
156
155
  AUTO_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
157
156
  """Long-form timestamp format_spec used by ``<key>.auto``."""
158
157
 
158
+ FORMAT_METRICS: _tst.FormatMetrics[_t.Any] | None = None
159
+ """A callable ``(end_message, metrics) -> str``. Use the :func:`default formatter <.support.default_metrics_formatter>` if ``None``."""
160
+
159
161
 
160
162
  class misc: # noqa: N801
161
163
  """Miscellaneous settings that don't fit in any other namespace."""
@@ -12,12 +12,13 @@ dependencies if you need to use the ``support`` module.
12
12
  """
13
13
 
14
14
  from .._backend import DatetimeIndexSplitter, expand_limits, process_available
15
- from .._frontend import fold_weight, to_string
15
+ from .._frontend import default_metrics_formatter, fold_weight, to_string
16
16
 
17
17
  __all__ = [
18
18
  "DatetimeIndexSplitter",
19
19
  "expand_limits",
20
20
  "process_available",
21
+ "default_metrics_formatter",
21
22
  "fold_weight",
22
23
  "to_string",
23
24
  ]
@@ -1,6 +1,7 @@
1
1
  """Types related to splitting data."""
2
2
 
3
3
  import datetime as _dt
4
+ import logging as _logging
4
5
  import typing as _t
5
6
 
6
7
  import numpy as _np
@@ -17,8 +18,8 @@ Schedule: _t.TypeAlias = _pd.DatetimeIndex | DatetimeIterable | TimedeltaTypes
17
18
  """User schedule type."""
18
19
  Span = int | _t.Literal["all"] | TimedeltaTypes
19
20
  """User span type. Used to determine limits from the timestamps given by a :attr:`Schedule`."""
20
- Flex = bool | _t.Literal["auto"] | str
21
- """Flexibility frequency string for ``floor/ceil``. Pass ``False`` to disable."""
21
+ ExpandLimits = bool | _t.Literal["auto"] | str
22
+ """Limits flexibility spec for ``floor/ceil``. Pass ``False`` to disable."""
22
23
 
23
24
 
24
25
  class DatetimeSplitBounds(_t.NamedTuple):
@@ -61,4 +62,42 @@ class DatetimeIndexSplitterKwargs(_t.TypedDict, total=False):
61
62
  after: Span
62
63
  step: int
63
64
  n_splits: int
64
- flex: Flex
65
+ expand_limits: ExpandLimits
66
+
67
+
68
+ LoggerArg = _logging.Logger | _logging.LoggerAdapter[_t.Any] | str
69
+
70
+
71
+ MetricsType = _t.TypeVar("MetricsType")
72
+ """Metrics argument type."""
73
+ GetMetrics = _t.Callable[[_pd.Timestamp], MetricsType]
74
+ """A callable ``(Timestamp) -> MetricsType``."""
75
+ FormatMetrics = _t.Callable[[str, MetricsType], str]
76
+ """A callable ``(end_message, metrics) -> str``."""
77
+
78
+
79
+ class LogSplitProgressKwargs(_t.TypedDict, _t.Generic[MetricsType], total=False):
80
+ """Keyword arguments for by :func:`.log_split_progress`."""
81
+
82
+ logger: LoggerArg
83
+ start_level: int
84
+ end_level: int
85
+ extra: dict[str, _t.Any] | None
86
+ get_metrics: GetMetrics[MetricsType]
87
+
88
+
89
+ class SplitProgressExtras(_t.TypedDict):
90
+ """Named `extras` used for messages logged by :func:`.log_split_progress`."""
91
+
92
+ n: int
93
+ """Current split number (starting at 1)."""
94
+ n_splits: int
95
+ """Total split count."""
96
+ start: str
97
+ """An :meth:`ISO-formatted <pandas.Timestamp.isoformat>` timestamp (see :attr:`.DatetimeSplitBounds.start`)."""
98
+ mid: str
99
+ """An :meth:`ISO-formatted <pandas.Timestamp.isoformat>` timestamp (see :attr:`.DatetimeSplitBounds.mid`)."""
100
+ end: str
101
+ """An :meth:`ISO-formatted <pandas.Timestamp.isoformat>` timestamp (see :attr:`.DatetimeSplitBounds.end`)."""
102
+ seconds: _t.NotRequired[float]
103
+ """User time for the fold. Available only for the :attr:`fold-end message <.settings.log_split_progress.END_MESSAGE>`."""
@@ -1,125 +0,0 @@
1
- import logging
2
- from collections.abc import Callable, Iterable, MutableMapping, Sequence
3
- from dataclasses import dataclass
4
- from time import perf_counter
5
- from typing import Any, Union
6
-
7
- from rics.misc import get_by_full_name
8
-
9
- from ..settings import log_split_progress as settings
10
- from ..types import DatetimeSplitBounds
11
- from ._to_string import _PrettyTimestamp
12
-
13
- LoggerArg = Union[logging.Logger, logging.LoggerAdapter, str] # type: ignore[type-arg]
14
-
15
-
16
- def log_split_progress(
17
- splits: Sequence[DatetimeSplitBounds],
18
- *,
19
- logger: LoggerArg = "time_split",
20
- start_level: int = logging.INFO,
21
- end_level: int = logging.INFO,
22
- extra: dict[str, Any] | None = None,
23
- ) -> Iterable[DatetimeSplitBounds]:
24
- """Log iteration progress over `splits` using `logger`.
25
-
26
- Args:
27
- splits: Splits to iterate over.
28
- logger: Logger or logger name to use.
29
- start_level: Log level to use for the :attr:`fold-begin message <.settings.log_split_progress.START_MESSAGE>`.
30
- end_level: Log level to use for the :attr:`fold-end message <.settings.log_split_progress.END_MESSAGE>`.
31
- extra: User-defined `extra`-arguments to use when logging, merged with progress-related extras. Will be
32
- available to all messages as well as the ``fold`` key. This argument is mutable; changes made to `extra`
33
- will be reflected in logged records.
34
-
35
- Returns:
36
- An iterable over `splits`.
37
-
38
- Examples:
39
- Basic usage.
40
-
41
- >>> from time_split import split, log_split_progress
42
- >>> splits = split("36h", available=("2023-08-10", "2023-08-19"))
43
- >>> tracked_splits = log_split_progress(
44
- ... splits, logger="progress", start_level=logging.DEBUG
45
- ... )
46
- >>> list(tracked_splits) # doctest: +SKIP
47
- [progress:DEBUG] Begin fold 1/2: ('2023-08-11' <= [schedule: '2023-08-16' (Wednesday)] < '2023-08-17 12:00:00').
48
- [progress:INFO] Finished fold 1/2 [schedule: '2023-08-16' (Wednesday)] after 5m 18s.
49
- [progress:DEBUG] Begin fold 2/2: ('2023-08-12 12:00:00' <= [schedule: '2023-08-17 12:00:00' (Thursday)] < '2023-08-19').
50
- [progress:INFO] Finished fold 2/2 [schedule: '2023-08-17 12:00:00' (Thursday)] after 4m 3s.
51
- """
52
- logger = logging.getLogger(logger) if isinstance(logger, str) else logger
53
-
54
- if isinstance(logger, logging.LoggerAdapter) and not hasattr(logger, "merge_extra"):
55
- # Backport of https://github.com/python/cpython/pull/107292
56
- logger = _MergingLoggerAdapter(logger.logger, logger.extra)
57
-
58
- track = _ProgressTracker(
59
- logger=logger,
60
- fold_format=settings.FOLD_FORMAT,
61
- start_level=start_level,
62
- start_message=settings.START_MESSAGE,
63
- end_level=end_level,
64
- end_message=settings.END_MESSAGE,
65
- seconds_formatter=settings.SECONDS_FORMATTER
66
- if callable(settings.SECONDS_FORMATTER)
67
- else get_by_full_name(settings.SECONDS_FORMATTER),
68
- user_extra=extra or {},
69
- )
70
- return track(splits)
71
-
72
-
73
- @dataclass(frozen=True)
74
- class _ProgressTracker:
75
- logger: logging.Logger | logging.LoggerAdapter # type: ignore[type-arg]
76
- fold_format: str
77
- start_level: int
78
- start_message: str
79
- end_level: int
80
- end_message: str
81
- seconds_formatter: Callable[[float], str]
82
- user_extra: dict[str, Any]
83
-
84
- def __call__(self, splits: Sequence[DatetimeSplitBounds]) -> Iterable[DatetimeSplitBounds]:
85
- n_splits = len(splits)
86
-
87
- for n, split in enumerate(splits, start=1):
88
- extra: dict[str, str | float | int | bool] = dict(
89
- n=n,
90
- n_splits=n_splits,
91
- start=split.start.isoformat(),
92
- mid=split.mid.isoformat(),
93
- end=split.end.isoformat(),
94
- **self.user_extra,
95
- )
96
- kwargs: dict[str, Any] = dict(
97
- n=n,
98
- n_splits=n_splits,
99
- start=_PrettyTimestamp(split.start),
100
- mid=_PrettyTimestamp(split.mid),
101
- end=_PrettyTimestamp(split.end),
102
- **self.user_extra,
103
- )
104
- kwargs.update(fold=self.fold_format.format(**kwargs))
105
-
106
- self.logger.log(self.start_level, self.start_message.format(**kwargs), extra=extra)
107
-
108
- # Yield split and count user time.
109
- start = perf_counter()
110
- yield split
111
- seconds = round(perf_counter() - start, 6)
112
-
113
- kwargs.update(
114
- seconds=seconds,
115
- formatted_seconds=self.seconds_formatter(seconds),
116
- )
117
- extra.update(seconds=seconds)
118
- self.logger.log(self.end_level, self.end_message.format(**kwargs), extra=extra)
119
-
120
-
121
- class _MergingLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg]
122
- def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> tuple[Any, MutableMapping[str, Any]]:
123
- """See https://github.com/python/cpython/pull/107292."""
124
- kwargs["extra"] = {**self.extra, **kwargs["extra"]} if "extra" in kwargs and self.extra else self.extra
125
- return msg, kwargs
File without changes