tpcp 2.2.1__tar.gz → 2.3.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 (40) hide show
  1. {tpcp-2.2.1 → tpcp-2.3.0}/PKG-INFO +1 -1
  2. {tpcp-2.2.1 → tpcp-2.3.0}/pyproject.toml +23 -7
  3. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/__init__.py +3 -2
  4. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/_dataset.py +92 -6
  5. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/_utils/_score.py +36 -38
  6. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/caching.py +6 -4
  7. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/misc/__init__.py +12 -0
  8. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/misc/_typed_iterator.py +54 -5
  9. tpcp-2.3.0/src/tpcp/misc/_warning_error_context.py +551 -0
  10. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/optimize/_optimize.py +106 -51
  11. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/optimize/optuna.py +10 -3
  12. tpcp-2.3.0/src/tpcp/parallel.py +404 -0
  13. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/validate/_scorer.py +46 -37
  14. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/validate/_validate.py +24 -25
  15. tpcp-2.2.1/src/tpcp/parallel.py +0 -156
  16. {tpcp-2.2.1 → tpcp-2.3.0}/README.md +0 -0
  17. {tpcp-2.2.1 → tpcp-2.3.0}/skills/tpcp/tpcp-basics/SKILL.md +0 -0
  18. {tpcp-2.2.1 → tpcp-2.3.0}/skills/tpcp/tpcp-builder/SKILL.md +0 -0
  19. {tpcp-2.2.1 → tpcp-2.3.0}/skills/tpcp/tpcp-datasets/SKILL.md +0 -0
  20. {tpcp-2.2.1 → tpcp-2.3.0}/skills/tpcp/tpcp-multiprocessing/SKILL.md +0 -0
  21. {tpcp-2.2.1 → tpcp-2.3.0}/skills/tpcp/tpcp-optimization/SKILL.md +0 -0
  22. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/_algorithm.py +0 -0
  23. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/_algorithm_utils.py +0 -0
  24. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/_base.py +0 -0
  25. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/_cli.py +0 -0
  26. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/_hash.py +0 -0
  27. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/_optimize.py +0 -0
  28. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/_parameters.py +0 -0
  29. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/_pipeline.py +0 -0
  30. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/_utils/__init__.py +0 -0
  31. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/_utils/_general.py +0 -0
  32. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/exceptions.py +0 -0
  33. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/misc/_class_utils.py +0 -0
  34. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/optimize/__init__.py +0 -0
  35. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/testing/__init__.py +0 -0
  36. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/testing/_algorithm_test_mixin.py +0 -0
  37. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/testing/_regression_utils.py +0 -0
  38. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/types.py +0 -0
  39. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/validate/__init__.py +0 -0
  40. {tpcp-2.2.1 → tpcp-2.3.0}/src/tpcp/validate/_cross_val_helper.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: tpcp
3
- Version: 2.2.1
3
+ Version: 2.3.0
4
4
  Summary: Pipeline and Dataset helpers for complex algorithm evaluation.
5
5
  Author: Arne Küderle, Robert Richer, Raul C. Sîmpetru, Björn Eskofier
6
6
  Author-email: Arne Küderle <arne.kuederle@fau.de>, Robert Richer <robert.richer@fau.de>, Raul C. Sîmpetru <raul.simpetru@fau.de>, Björn Eskofier <bjoern.eskofier@fau.de>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tpcp"
3
- version = "2.2.1"
3
+ version = "2.3.0"
4
4
  description = "Pipeline and Dataset helpers for complex algorithm evaluation."
5
5
  authors = [
6
6
  { name = "Arne Küderle", email = "arne.kuederle@fau.de" },
@@ -36,11 +36,22 @@ tpcp = "tpcp._cli:main"
36
36
  tpcp_snapshots = "tpcp.testing._regression_utils"
37
37
 
38
38
  [dependency-groups]
39
- dev = [
40
- "pydata-sphinx-theme>=0.14.1,<0.15",
39
+ test = [
40
+ "attrs>=22.1.0",
41
+ "matplotlib>=3.4.3,<4",
42
+ "numpydoc>=1.4.0,<2",
43
+ "optuna>=2.10",
41
44
  "pytest>=8.4.2,<9",
45
+ "pytest-xdist>=3.8.0,<4",
46
+ ]
47
+ coverage = [
42
48
  "pytest-cov>=7.0.0,<8",
43
- "poethepoet>=0.10.0,<0.11",
49
+ ]
50
+ lint = [
51
+ "ruff>=0.14.0,<0.15",
52
+ ]
53
+ docs = [
54
+ "pydata-sphinx-theme>=0.14.1,<0.15",
44
55
  "pyright>=1.1.230,<2",
45
56
  "numpydoc>=1.4.0,<2",
46
57
  "sphinx-gallery>=0.14.0,<0.15",
@@ -49,8 +60,13 @@ dev = [
49
60
  "matplotlib>=3.4.3,<4",
50
61
  "toml>=0.10.2,<0.11",
51
62
  "Sphinx>=7.2.6,<8",
52
- "ruff>=0.14.0,<0.15",
53
- "tensorflow-cpu>=2.18.0rc2,<3; python_full_version < '3.14'",
63
+ ]
64
+ dev = [
65
+ { include-group = "test" },
66
+ { include-group = "coverage" },
67
+ { include-group = "lint" },
68
+ { include-group = "docs" },
69
+ "poethepoet>=0.10.0,<0.11",
54
70
  ]
55
71
 
56
72
  [tool.uv]
@@ -82,7 +98,7 @@ lint = { cmd = "ruff check src/tpcp --fix", help = "Lint all files with ruff." }
82
98
  _lint_ci = "ruff check src/tpcp --output-format=github"
83
99
  _check_format = "ruff format . --check"
84
100
  ci_check = { sequence = ["_check_format", "_lint_ci"], help = "Check all potential format and linting issues." }
85
- test = { cmd = "pytest ./tests --cov=tpcp --cov-report=term-missing --cov-report=xml", help = "Run Pytest with coverage." }
101
+ test = { cmd = "pytest ./tests -n 2 --dist=worksteal --cov=tpcp --cov-report=term-missing --cov-report=xml", help = "Run Pytest with coverage." }
86
102
  docs = { "script" = "_tasks:task_docs()", help = "Build the html docs using Sphinx." }
87
103
  docs_clean = { "script" = "_tasks:task_docs(clean=True)", help = "Remove all old build files and build a clean version of the docs." }
88
104
  docs_preview = { cmd = "python -m http.server --directory docs/_build/html", help = "Preview the built html docs." }
@@ -11,7 +11,7 @@ from tpcp._algorithm_utils import (
11
11
  make_optimize_safe,
12
12
  )
13
13
  from tpcp._base import NOTHING, BaseFactory, BaseTpcpObject, CloneFactory, cf, clone, get_param_names
14
- from tpcp._dataset import Dataset
14
+ from tpcp._dataset import Dataset, DatasetWrapperMixin
15
15
  from tpcp._parameters import (
16
16
  HyperPara,
17
17
  HyperParameter,
@@ -24,7 +24,7 @@ from tpcp._parameters import (
24
24
  )
25
25
  from tpcp._pipeline import OptimizablePipeline, Pipeline
26
26
 
27
- __version__ = "2.2.1"
27
+ __version__ = "2.3.0"
28
28
 
29
29
 
30
30
  __all__ = [
@@ -34,6 +34,7 @@ __all__ = [
34
34
  "BaseTpcpObject",
35
35
  "CloneFactory",
36
36
  "Dataset",
37
+ "DatasetWrapperMixin",
37
38
  "HyperPara",
38
39
  "HyperParameter",
39
40
  "OptiPara",
@@ -3,7 +3,7 @@
3
3
  import warnings
4
4
  from collections.abc import Iterator, Sequence
5
5
  from keyword import iskeyword
6
- from typing import Generic, Optional, TypeVar, Union, cast, get_args, overload
6
+ from typing import ClassVar, Generic, Optional, TypeVar, Union, cast, get_args, get_origin, overload
7
7
 
8
8
  import numpy as np
9
9
  import pandas as pd
@@ -125,7 +125,7 @@ class _Dataset(BaseTpcpObject, Generic[GroupLabelT]):
125
125
  )
126
126
 
127
127
  # Get the generic type of the dataset
128
- group_label_type = get_args(type(self).__orig_bases__[0])[0]
128
+ group_label_type = self._get_group_label_type()
129
129
  # If group label type is a named tuple, we check that the keys are the same as the index columns
130
130
  if (label_fields := getattr(group_label_type, "_fields", None)) and label_fields != (
131
131
  index_cols := tuple(index_1.columns)
@@ -155,7 +155,7 @@ class _Dataset(BaseTpcpObject, Generic[GroupLabelT]):
155
155
 
156
156
  For some examples and additional explanation see this :ref:`example <custom_dataset_basics>`.
157
157
  """
158
- if getattr(group_label_type := get_args(type(self).__orig_bases__[0])[0], "_fields", None):
158
+ if getattr(group_label_type := self._get_group_label_type(), "_fields", None):
159
159
  nd_tuple_name = group_label_type.__name__
160
160
  else:
161
161
  nd_tuple_name = type(self).__name__ + "GroupLabel"
@@ -198,7 +198,7 @@ class _Dataset(BaseTpcpObject, Generic[GroupLabelT]):
198
198
 
199
199
  def index_as_tuples(self) -> list[GroupLabelT]:
200
200
  """Get all datapoint labels of the dataset (i.e. a list of the rows of the index as named tuples)."""
201
- if getattr(group_label_type := get_args(type(self).__orig_bases__[0])[0], "_fields", None):
201
+ if getattr(group_label_type := self._get_group_label_type(), "_fields", None):
202
202
  # If a generic is provided, we actually convert the named tuples.
203
203
  return [group_label_type(*row) for row in self.index.itertuples(index=False)]
204
204
  return list(self.index.itertuples(index=False, name=type(self).__name__ + "GroupLabel"))
@@ -234,11 +234,22 @@ class _Dataset(BaseTpcpObject, Generic[GroupLabelT]):
234
234
  ) from e
235
235
 
236
236
  def _get_groupby_columns(self) -> list[str]:
237
- """Get the groupby columns."""
237
+ """Get the groupby columns after materializing the dataset index."""
238
+ # `create_index` may establish the grouping based on the materialized index.
239
+ index = self.index
238
240
  if self.groupby_cols is None:
239
- return self.index.columns.to_list()
241
+ return index.columns.to_list()
240
242
  return _ensure_is_list(self.groupby_cols)
241
243
 
244
+ def _get_group_label_type(self):
245
+ """Find the concrete group-label type in the dataset inheritance hierarchy."""
246
+ for cls in type(self).__mro__:
247
+ for generic_base in getattr(cls, "__orig_bases__", ()):
248
+ origin = get_origin(generic_base)
249
+ if isinstance(origin, type) and issubclass(origin, _Dataset) and (args := get_args(generic_base)):
250
+ return args[0]
251
+ return None
252
+
242
253
  def _get_unique_groups(self) -> Union[pd.MultiIndex, pd.Index]:
243
254
  return self.grouped_index.index.unique()
244
255
 
@@ -750,6 +761,81 @@ class Dataset(_Dataset[GroupLabelT], Generic[GroupLabelT]):
750
761
  return DatasetAt
751
762
 
752
763
 
764
+ WrappedDatasetT = TypeVar("WrappedDatasetT", bound="_Dataset")
765
+
766
+
767
+ class DatasetWrapperMixin(Generic[WrappedDatasetT]):
768
+ """Provide common behavior for datasets that wrap another dataset.
769
+
770
+ This mixin expands the index of ``wrapped_dataset`` through
771
+ :meth:`_create_wrapped_index`, links the wrapper's initial grouping to the
772
+ wrapped dataset, and resolves the source datapoint represented by a wrapper
773
+ datapoint through :attr:`wrapped_datapoint`.
774
+
775
+ The mixin deliberately does not inherit from :class:`Dataset`. A concrete
776
+ wrapper must inherit from both this mixin and the domain-specific dataset
777
+ interface implemented by the wrapped dataset.
778
+
779
+ .. important::
780
+ ``DatasetWrapperMixin`` must be listed **before** the dataset base class.
781
+ The order controls Python's method resolution order and ensures that this
782
+ mixin's :meth:`create_index` implementation is used. Reversing the bases
783
+ raises a :class:`TypeError` when the wrapper class is defined.
784
+
785
+ Examples
786
+ --------
787
+ The mixin comes first and the shared dataset interface comes second::
788
+
789
+ class AugmentedDataset(
790
+ DatasetWrapperMixin[SourceDataset],
791
+ SourceDataset,
792
+ ):
793
+ _wrapper_groupby_cols = ("augmentation",)
794
+
795
+ def _create_wrapped_index(self, source_index): ...
796
+
797
+ """
798
+
799
+ wrapped_dataset: WrappedDatasetT
800
+ _wrapper_groupby_cols: ClassVar[tuple[str, ...]]
801
+
802
+ def __init_subclass__(cls, **kwargs) -> None:
803
+ """Validate that the mixin precedes the dataset base in the MRO."""
804
+ super().__init_subclass__(**kwargs)
805
+ mro = cls.__mro__
806
+ if _Dataset in mro and mro.index(DatasetWrapperMixin) > mro.index(_Dataset):
807
+ raise TypeError(
808
+ "DatasetWrapperMixin must be listed before the Dataset base class so its create_index method takes "
809
+ "precedence in the method resolution order."
810
+ )
811
+
812
+ def create_index(self) -> pd.DataFrame:
813
+ """Create the wrapper index and link its initial grouping to the wrapped dataset."""
814
+ source_index = self.wrapped_dataset.index.copy()
815
+ dataset = cast("_Dataset", self)
816
+
817
+ if dataset.groupby_cols is None:
818
+ dataset.groupby_cols = [*self.wrapped_dataset._get_groupby_columns(), *self._wrapper_groupby_cols]
819
+
820
+ return self._create_wrapped_index(source_index)
821
+
822
+ def _create_wrapped_index(self, source_index: pd.DataFrame) -> pd.DataFrame:
823
+ """Create the wrapper index from a copy of the wrapped dataset index."""
824
+ raise NotImplementedError
825
+
826
+ @property
827
+ def wrapped_datapoint(self) -> WrappedDatasetT:
828
+ """Return the wrapped datapoint represented by the current wrapper group."""
829
+ dataset = cast("_Dataset", self)
830
+ dataset.assert_is_single_group("wrapped_datapoint")
831
+
832
+ source_columns = self.wrapped_dataset.index.columns.to_list()
833
+ source_index = pd.DataFrame(dataset.index.loc[:, source_columns]).drop_duplicates().reset_index(drop=True)
834
+ source_groupby = [column for column in dataset._get_groupby_columns() if column in source_columns]
835
+
836
+ return self.wrapped_dataset.get_subset(index=source_index).groupby(source_groupby or None)
837
+
838
+
753
839
  T = TypeVar("T")
754
840
 
755
841
 
@@ -17,6 +17,7 @@ from tpcp._base import clone
17
17
  from tpcp._hash import custom_hash
18
18
  from tpcp._utils._general import _get_nested_paras
19
19
  from tpcp.exceptions import OptimizationError, TestError
20
+ from tpcp.misc import warning_error_context
20
21
 
21
22
  if TYPE_CHECKING:
22
23
  from tpcp._dataset import Dataset
@@ -62,7 +63,6 @@ def _score(
62
63
  return_parameters=False,
63
64
  return_data_labels=False,
64
65
  return_times=False,
65
- error_info: Optional[str] = None,
66
66
  ) -> _ScoreResults:
67
67
  """Set parameters and return score.
68
68
 
@@ -104,15 +104,15 @@ def _score(
104
104
 
105
105
  pipeline = pipeline.set_params(**parameters)
106
106
 
107
- try:
108
- start_time = time.time()
109
- agg_scores, single_scores = scorer(pipeline, dataset)
110
- score_time = time.time() - start_time
111
- except Exception as e:
112
- raise TestError(
113
- f"Testing the algorithm on the dataset failed with the error above.\n{error_info or ''}\n\n"
114
- f"The test-set is:\n{[d.group_labels for d in dataset]}"
115
- ) from e
107
+ score_context: dict[str, Any] = {}
108
+ with warning_error_context("score", context_provider=lambda: score_context):
109
+ try:
110
+ start_time = time.time()
111
+ score_context = {"data_labels": dataset.group_labels}
112
+ agg_scores, single_scores = scorer(pipeline, dataset)
113
+ score_time = time.time() - start_time
114
+ except Exception as e:
115
+ raise TestError("Testing failed.") from e
116
116
 
117
117
  result: _ScoreResults = {
118
118
  "scores": agg_scores,
@@ -142,7 +142,6 @@ def _optimize_and_score(
142
142
  return_data_labels=False,
143
143
  return_times=False,
144
144
  memory: Optional[Memory] = None,
145
- error_info: Optional[str] = None,
146
145
  ) -> _OptimizeScoreResults:
147
146
  """Optimize and score the optimized pipeline on the train and test data, respectively.
148
147
 
@@ -177,17 +176,17 @@ def _optimize_and_score(
177
176
 
178
177
  optimize_params_clean: dict = optimize_params or {}
179
178
 
180
- try:
181
- start_time = time.time()
182
- optimizer = _cached_optimize(
183
- optimizer, train_set, hyperparameters, pure_parameters, memory, optimize_params_clean
184
- )
185
- optimize_time = time.time() - start_time
186
- except Exception as e:
187
- raise OptimizationError(
188
- f"The optimization on the trainset failed with the error above.\n{error_info or ''}\n\n"
189
- f"This optimization used the following trainset:\n{train_set}"
190
- ) from e
179
+ optimize_context: dict[str, Any] = {}
180
+ with warning_error_context("optimize", context_provider=lambda: optimize_context):
181
+ try:
182
+ start_time = time.time()
183
+ optimize_context = {"data_labels": train_set.group_labels}
184
+ optimizer = _cached_optimize(
185
+ optimizer, train_set, hyperparameters, pure_parameters, memory, optimize_params_clean
186
+ )
187
+ optimize_time = time.time() - start_time
188
+ except Exception as e:
189
+ raise OptimizationError("Optimization failed.") from e
191
190
 
192
191
  # Now we set the remaining paras.
193
192
  # Because, we need to set the parameters on the optimized pipeline and not the input pipeline we strip the
@@ -198,28 +197,27 @@ def _optimize_and_score(
198
197
  # beginning.
199
198
  optimizer = optimizer.set_params(**pure_parameters)
200
199
 
201
- try:
202
- agg_scores, single_scores = scorer(optimizer.optimized_pipeline_, test_set)
203
- score_time = time.time() - optimize_time - start_time
204
- except Exception as e:
205
- raise TestError(
206
- f"Testing the optimized algorithm on the test-set failed with the error above.\n{error_info or ''}\n\n"
207
- f"The test-set is:\n{test_set}"
208
- ) from e
200
+ test_score_context: dict[str, Any] = {}
201
+ with warning_error_context("test_score", context_provider=lambda: test_score_context):
202
+ try:
203
+ test_score_context = {"data_labels": test_set.group_labels}
204
+ agg_scores, single_scores = scorer(optimizer.optimized_pipeline_, test_set)
205
+ score_time = time.time() - optimize_time - start_time
206
+ except Exception as e:
207
+ raise TestError("Testing failed.") from e
209
208
 
210
209
  result: _OptimizeScoreResults = {
211
210
  "test__scores": agg_scores,
212
211
  "test__single__scores": single_scores,
213
212
  }
214
213
  if return_train_score:
215
- try:
216
- train_agg_scores, train_single_scores = scorer(optimizer.optimized_pipeline_, train_set)
217
- except Exception as e:
218
- raise TestError(
219
- "Running the optimized algorithm on the train-set to calculate the train error failed with the error "
220
- f"above.\n{error_info or ''}\n\n"
221
- f"The train-set is:\n{[d.group_labels for d in test_set]}"
222
- ) from e
214
+ train_score_context: dict[str, Any] = {}
215
+ with warning_error_context("train_score", context_provider=lambda: train_score_context):
216
+ try:
217
+ train_score_context = {"data_labels": train_set.group_labels}
218
+ train_agg_scores, train_single_scores = scorer(optimizer.optimized_pipeline_, train_set)
219
+ except Exception as e:
220
+ raise TestError("Testing failed.") from e
223
221
  result["train__scores"] = train_agg_scores
224
222
  result["train__single__scores"] = train_single_scores
225
223
  if return_times:
@@ -14,7 +14,7 @@ from joblib import Memory
14
14
 
15
15
  from tpcp import Algorithm, get_action_methods_names, get_results, make_action_safe
16
16
  from tpcp._hash import custom_hash
17
- from tpcp.parallel import register_global_parallel_callback, remove_global_parallel_callback
17
+ from tpcp.parallel import _register_tpcp_global_parallel_callback, _remove_tpcp_global_parallel_callback
18
18
 
19
19
  _ALREADY_WARNED = False
20
20
 
@@ -130,7 +130,7 @@ def _register_global_parallel_callback(func, name):
130
130
  def _callback():
131
131
  return None, wrapper
132
132
 
133
- register_global_parallel_callback(_callback, name=name)
133
+ _register_tpcp_global_parallel_callback(name, _callback)
134
134
 
135
135
 
136
136
  def global_disk_cache( # noqa: C901
@@ -313,7 +313,9 @@ def remove_disk_cache(algorithm_object: type[Algorithm]):
313
313
  if getattr(action_method, "__wrapped__", None) is not None:
314
314
  setattr(algorithm_object, action_name, action_method.__wrapped__)
315
315
  with contextlib.suppress(KeyError):
316
- remove_global_parallel_callback(f"global_disk_cache__{algorithm_object.__qualname__}__{action_name}")
316
+ _remove_tpcp_global_parallel_callback(
317
+ f"global_disk_cache__{algorithm_object.__qualname__}__{action_name}"
318
+ )
317
319
  return algorithm_object
318
320
 
319
321
 
@@ -453,7 +455,7 @@ def remove_ram_cache(algorithm_object: type[Algorithm]):
453
455
  if getattr(action_method, "__wrapped__", None) is not None:
454
456
  setattr(algorithm_object, action_method_name, action_method.__wrapped__)
455
457
  with contextlib.suppress(KeyError):
456
- remove_global_parallel_callback(
458
+ _remove_tpcp_global_parallel_callback(
457
459
  f"global_ram_cache__{algorithm_object.__qualname__}__{action_method_name}"
458
460
  )
459
461
 
@@ -3,12 +3,24 @@
3
3
  from tpcp._hash import custom_hash
4
4
  from tpcp.misc._class_utils import classproperty, set_defaults
5
5
  from tpcp.misc._typed_iterator import BaseTypedIterator, TypedIterator, TypedIteratorResultTuple
6
+ from tpcp.misc._warning_error_context import (
7
+ WarningErrorContext,
8
+ WarningErrorContextRecord,
9
+ iter_with_warning_error_context,
10
+ print_with_context,
11
+ warning_error_context,
12
+ )
6
13
 
7
14
  __all__ = [
8
15
  "BaseTypedIterator",
9
16
  "TypedIterator",
10
17
  "TypedIteratorResultTuple",
18
+ "WarningErrorContext",
19
+ "WarningErrorContextRecord",
11
20
  "classproperty",
12
21
  "custom_hash",
22
+ "iter_with_warning_error_context",
23
+ "print_with_context",
13
24
  "set_defaults",
25
+ "warning_error_context",
14
26
  ]
@@ -1,11 +1,13 @@
1
1
  import warnings
2
- from collections.abc import Iterable, Iterator, Sequence
2
+ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
3
3
  from dataclasses import fields, is_dataclass
4
- from typing import Any, Callable, Generic, Optional, TypeVar
4
+ from typing import Any, Generic, Optional, TypeVar
5
5
 
6
6
  from typing_extensions import NamedTuple, TypeAlias
7
7
 
8
8
  from tpcp import Algorithm, cf
9
+ from tpcp.misc._warning_error_context import WarningErrorContext
10
+ from tpcp.misc._warning_error_context import warning_error_context as _warning_error_context
9
11
 
10
12
  DataclassT = TypeVar("DataclassT")
11
13
  InputTypeT = TypeVar("InputTypeT")
@@ -26,6 +28,7 @@ class _NotSet:
26
28
 
27
29
 
28
30
  _NULL_VALUE = _NotSet()
31
+ _NO_PREVIOUS_ITERATION_INDEX = object()
29
32
 
30
33
 
31
34
  class BaseTypedIterator(Algorithm, Generic[InputTypeT, DataclassT]):
@@ -159,6 +162,10 @@ class BaseTypedIterator(Algorithm, Generic[InputTypeT, DataclassT]):
159
162
  The result object is a dataclass instance of the type defined in ``self.data_type``.
160
163
  All values of the result object are set to ``TypedIterator.NULL_VALUE`` by default.
161
164
 
165
+ To add context to warnings and exceptions from the loop body, explicitly enter
166
+ :meth:`warning_error_context` inside the loop. Context must not remain active across this generator's
167
+ ``yield`` boundary.
168
+
162
169
  """
163
170
  if not is_dataclass(self.data_type):
164
171
  raise TypeError(f"Expected a dataclass as data_type, got {self.data_type}")
@@ -173,13 +180,55 @@ class BaseTypedIterator(Algorithm, Generic[InputTypeT, DataclassT]):
173
180
  self._raw_results = []
174
181
 
175
182
  self.done_[iteration_name] = False
176
- for d in iterable:
183
+ for i, d in enumerate(iterable):
177
184
  result_object = self._get_new_empty_object()
178
- result_tuple = TypedIteratorResultTuple(iteration_name, d, result_object, iteration_context or {})
185
+ iteration_context = iteration_context or {}
186
+ result_tuple = TypedIteratorResultTuple(iteration_name, d, result_object, iteration_context)
179
187
  self._report_new_result(result_tuple)
180
- yield d, result_object
188
+ previous_i = getattr(self, "_warning_error_context_i", _NO_PREVIOUS_ITERATION_INDEX)
189
+ self._warning_error_context_i = i
190
+ try:
191
+ yield d, result_object
192
+ finally:
193
+ if previous_i is _NO_PREVIOUS_ITERATION_INDEX:
194
+ del self._warning_error_context_i
195
+ else:
196
+ self._warning_error_context_i = previous_i
181
197
  self.done_[iteration_name] = True
182
198
 
199
+ def warning_error_context(
200
+ self,
201
+ name: str,
202
+ context: Optional[dict[str, Any]] = None,
203
+ /,
204
+ *,
205
+ context_provider: Optional[Callable[[], Mapping[str, Any]]] = None,
206
+ record_only: bool = False,
207
+ ) -> WarningErrorContext:
208
+ """Create explicit warning/error context for an iteration body.
209
+
210
+ This convenience method has the same interface as
211
+ :func:`~tpcp.misc.warning_error_context` and adds the current zero-based
212
+ iteration index as ``i``. It deliberately does not infer any other context
213
+ from the iterator or its current item. Enter the returned context manager
214
+ inside the iteration body and pass all other relevant context explicitly.
215
+ """
216
+ context = {} if context is None else context
217
+ if "i" in context:
218
+ raise ValueError("The context key 'i' is reserved by TypedIterator.")
219
+ try:
220
+ i = self._warning_error_context_i
221
+ except AttributeError as exc:
222
+ raise RuntimeError(
223
+ "warning_error_context must be called inside an active TypedIterator loop body."
224
+ ) from exc
225
+ return _warning_error_context(
226
+ name,
227
+ {"i": i, **context},
228
+ context_provider=context_provider,
229
+ record_only=record_only,
230
+ )
231
+
183
232
  def _get_new_empty_object(self) -> DataclassT:
184
233
  init_dict = {k.name: self.NULL_VALUE for k in fields(self.data_type)}
185
234
  return self.data_type(**init_dict)