Repository: optuna/optuna

Issue:
`Studt.tell` with `skip_if_finished`
## Motivation

Keep the `Study.tell` API consistent with `optuna tell` CLI.

## Description

Add a `skip_if_finished: bool` option to `Studt.tell`, similar to `optuna tell` the CLI subcommand, that allows `Study.tell` to become a NOOP instead of raising an error in case e.g. a trial is reported twice.

See https://github.com/optuna/optuna/pull/3131.


Discussion:
I'm happy to implement it.
That'd be great, and I'd be happy to review.

Resolving patch:
diff --git a/optuna/cli.py b/optuna/cli.py
--- a/optuna/cli.py
+++ b/optuna/cli.py
@@ -865,22 +865,12 @@ def take_action(self, parsed_args: Namespace) -> int:
         trial_number = parsed_args.trial_number
         values = parsed_args.values
 
-        # TODO(hvy): Consider introducing `skip_if_finished` to `Study.tell` and let the method
-        # do all error handling, such as invalid trial numbers.
-        if parsed_args.skip_if_finished:
-            trials = study.get_trials(deepcopy=False)
-            trial = trials[trial_number]
-            assert trial.number == trial_number
-
-            if trial.state.is_finished():
-                self.logger.info(
-                    f"Skipped telling trial {trial_number} with values "
-                    f"{values} and state {state} since trial was already finished. "
-                    f"Finished trial has values {trial.values} and state {trial.state}."
-                )
-                return 0
-
-        study.tell(trial=trial_number, values=values, state=state)
+        study.tell(
+            trial=trial_number,
+            values=values,
+            state=state,
+            skip_if_finished=parsed_args.skip_if_finished,
+        )
 
         self.logger.info(f"Told trial {trial_number} with values {values} and state " f"{state}.")
 
diff --git a/optuna/study/study.py b/optuna/study/study.py
--- a/optuna/study/study.py
+++ b/optuna/study/study.py
@@ -490,6 +490,7 @@ def tell(
         trial: Union[trial_module.Trial, int],
         values: Optional[Union[float, Sequence[float]]] = None,
         state: TrialState = TrialState.COMPLETE,
+        skip_if_finished: bool = False,
     ) -> None:
         """Finish a trial created with :func:`~optuna.study.Study.ask`.
 
@@ -553,6 +554,10 @@ def df(x):
                 State to be reported. Must be :class:`~optuna.trial.TrialState.COMPLETE`,
                 :class:`~optuna.trial.TrialState.FAIL` or
                 :class:`~optuna.trial.TrialState.PRUNED`.
+            skip_if_finished:
+                Flag to control whether exception should be raised when values for already
+                finished trial are told. If :obj:`True`, tell is skipped without any error
+                when the trial is already finished.
 
         Raises:
             TypeError:
@@ -627,6 +632,14 @@ def df(x):
 
         frozen_trial = self._storage.get_trial(trial_id)
 
+        if frozen_trial.state.is_finished() and skip_if_finished:
+            _logger.info(
+                f"Skipped telling trial {trial_number} with values "
+                f"{values} and state {state} since trial was already finished. "
+                f"Finished trial has values {frozen_trial.values} and state {frozen_trial.state}."
+            )
+            return
+
         if state == TrialState.PRUNED:
             # Register the last intermediate value if present as the value of the trial.
             # TODO(hvy): Whether a pruned trials should have an actual value can be discussed.


Test patch:
diff --git a/tests/study_tests/test_study.py b/tests/study_tests/test_study.py
--- a/tests/study_tests/test_study.py
+++ b/tests/study_tests/test_study.py
@@ -1040,8 +1040,11 @@ def test_tell_duplicate_tell() -> None:
     trial = study.ask()
     study.tell(trial, 1.0)
 
+    # Should not panic when passthrough is enabled.
+    study.tell(trial, 1.0, skip_if_finished=True)
+
     with pytest.raises(RuntimeError):
-        study.tell(trial, 1.0)
+        study.tell(trial, 1.0, skip_if_finished=False)
 
 
 def test_tell_values() -> None: