From 906504b48947109d54d29c1885cd588c8f8e35d0 Mon Sep 17 00:00:00 2001 From: Georg Grab Date: Fri, 11 Sep 2026 15:31:33 +0200 Subject: [PATCH 1/3] fix: stop printing from fit/predict and demote routine log records A library should leave stdout and logging configuration to the application. The estimators wrote a `\r` spinner to stdout on every fit/predict, which lands in scripts, CI logs and notebook cells and interleaves with log output. Routine events were logged at WARNING, which Python's last-resort handler prints to stderr even when the application configured no logging. UserDataClient logged through module-level logging.info()/error(), which call basicConfig() when the root logger has no handlers, so a later basicConfig() in the application silently did nothing. TABPFN_CLIENT_CI_MODE only disabled the spinner and is removed with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018bhEkFCJWSiwyStDihRFMa --- src/tabpfn_client/client.py | 10 +-- src/tabpfn_client/config.py | 22 +++-- src/tabpfn_client/estimator.py | 121 +++++++++------------------ src/tabpfn_client/options.py | 1 - src/tabpfn_client/prompt_agent.py | 4 - src/tabpfn_client/service_wrapper.py | 44 ++-------- src/tabpfn_client/ui.py | 20 ----- tests/unit/test_tabpfn_regressor.py | 9 +- 8 files changed, 62 insertions(+), 169 deletions(-) diff --git a/src/tabpfn_client/client.py b/src/tabpfn_client/client.py index cda25b8..ecdb350 100644 --- a/src/tabpfn_client/client.py +++ b/src/tabpfn_client/client.py @@ -15,7 +15,6 @@ import re import struct import time -import traceback import warnings from pydantic import BaseModel, ValidationError from typing import Any, cast, Mapping, NoReturn @@ -361,7 +360,7 @@ def fit( ) if isinstance(prepare_resp, DuplicateTrainSetErrorResponse): - logger.warning("Train set already exists, skipping upload.") + logger.debug("Train set already exists, skipping upload.") else: with ThreadPoolExecutor(max_workers=2) as pool: futures = [ @@ -708,7 +707,7 @@ def predict( raise FittedModelNotFoundError(message) if isinstance(prepare_resp, DuplicateTestSetErrorResponse): - logger.warning("Test set already exists, skipping upload.") + logger.debug("Test set already exists, skipping upload.") else: cls._upload_to_gcs( "x_test", @@ -989,9 +988,8 @@ def try_connection(cls) -> bool: if response.status_code == 200: found_valid_connection = True - except httpx.ConnectError as e: - logger.error(f"Failed to connect to the server with error: {e}") - traceback.print_exc() + except httpx.ConnectError: + logger.debug("Failed to connect to the server", exc_info=True) found_valid_connection = False return found_valid_connection diff --git a/src/tabpfn_client/config.py b/src/tabpfn_client/config.py index d57f73b..87299e3 100644 --- a/src/tabpfn_client/config.py +++ b/src/tabpfn_client/config.py @@ -68,18 +68,16 @@ def init(use_server=True): except ConnectError: raise CONNECTION_ERROR - if is_valid_token: - PromptAgent.prompt_reusing_existing_token() - elif unverified_token is not None: - # The token is well-formed but the account's email is unverified, - # which no token can work around. - raise RuntimeError( - "Your TabPFN account's email address is not verified.\n" - "Check your inbox for the verification email, or sign in at\n" - f" {ServiceClient.server_config.gui_url}\n" - "to request a new one, then run your script again." - ) - else: + if not is_valid_token: + if unverified_token is not None: + # The token is well-formed but the account's email is unverified, + # which no token can work around. + raise RuntimeError( + "Your TabPFN account's email address is not verified.\n" + "Check your inbox for the verification email, or sign in at\n" + f" {ServiceClient.server_config.gui_url}\n" + "to request a new one, then run your script again." + ) if not UserAuthenticationClient.is_accessible_connection(): raise CONNECTION_ERROR # Never prompt from the default path: this is a library, so a diff --git a/src/tabpfn_client/estimator.py b/src/tabpfn_client/estimator.py index 30ee59d..d847f14 100644 --- a/src/tabpfn_client/estimator.py +++ b/src/tabpfn_client/estimator.py @@ -4,13 +4,10 @@ from __future__ import annotations import logging -import sys -import time +import warnings from uuid import uuid4 -from concurrent.futures import ThreadPoolExecutor -from typing import Any, Callable, Literal, cast, overload +from typing import Any, Literal, cast, overload from typing_extensions import Self -from uuid import UUID import numpy as np import pandas as pd @@ -45,7 +42,6 @@ ) from tabpfn_client.models import ApiMode, TabPFNConfig, FitModeLiteral from tabpfn_client.persistence import ModelPersistenceMixin -from tabpfn_client.options import get_opts try: from torch import Tensor # type: ignore @@ -329,19 +325,16 @@ def fit( self._last_trace_id = self.client_options.headers["sentry-trace"] - def fit_task() -> UUID: - return InferenceClient.fit( - X_clean, - y, - task_config=task_config, - tabpfn_systems=tabpfn_systems, - thinking_config=thinking_config, - api_mode=self.api_mode, - client_options=self.client_options, - description=description, - ) - - self.model_id_ = cast(UUID, run_task(fit_task, "Fitting")) + self.model_id_ = InferenceClient.fit( + X_clean, + y, + task_config=task_config, + tabpfn_systems=tabpfn_systems, + thinking_config=thinking_config, + api_mode=self.api_mode, + client_options=self.client_options, + description=description, + ) # NOTE: Previously classes were assigned in-place before a fit succeeded, # consider this failure mode: # 1. first fit() -> succeeds, model_id_ and classes_ are assigned @@ -414,19 +407,16 @@ def _predict( ): self.client_options.headers["sentry-trace"] = self._last_trace_id - def predict_task() -> PredictionResult: - return InferenceClient.predict( - X_clean, - fitted_train_set_id=self.model_id_, - task_config=task_config, - client_options=self.client_options, - ) - - result = run_task(predict_task, "Predicting") + result = InferenceClient.predict( + X_clean, + fitted_train_set_id=self.model_id_, + task_config=task_config, + client_options=self.client_options, + ) # Unpack and store metadata self._last_meta = result.metadata - return result.y_pred + return cast("np.ndarray", result.y_pred) def _get_tabpfn_config(self) -> ClassifierTabPFNConfig: init_params = self.get_params() @@ -674,19 +664,16 @@ def fit( self._last_trace_id = self.client_options.headers["sentry-trace"] - def fit_task() -> UUID: - return InferenceClient.fit( - X_clean, - y, - task_config=task_config, - tabpfn_systems=tabpfn_systems, - thinking_config=thinking_config, - api_mode=self.api_mode, - client_options=self.client_options, - description=description, - ) - - self.model_id_ = cast(UUID, run_task(fit_task, "Fitting")) + self.model_id_ = InferenceClient.fit( + X_clean, + y, + task_config=task_config, + tabpfn_systems=tabpfn_systems, + thinking_config=thinking_config, + api_mode=self.api_mode, + client_options=self.client_options, + description=description, + ) self._n_train_rows = X.shape[0] self._fit_count += 1 else: @@ -775,17 +762,12 @@ def predict( self.client_options.headers["sentry-trace"] = self._last_trace_id def predict_rows(X_rows: Any) -> PredictionResult: - X_clean = _clean_text_features(X_rows) - - def predict_task() -> PredictionResult: - return InferenceClient.predict( - X_clean, - fitted_train_set_id=self.model_id_, - task_config=task_config, - client_options=self.client_options, - ) - - return run_task(predict_task, "Predicting") + return InferenceClient.predict( + _clean_text_features(X_rows), + fitted_train_set_id=self.model_id_, + task_config=task_config, + client_options=self.client_options, + ) if chunked: rows_per_call = cast(int, rows_per_call) @@ -821,9 +803,10 @@ def predict_task() -> PredictionResult: borders=torch.tensor(full["borders"]) ) except ImportError: - logger.warning( + warnings.warn( "Optional dependencies 'tabpfn' and 'torch' are required to " - "construct the criterion when output_type='full'. Skipping criterion." + "construct the criterion when output_type='full'. Skipping criterion.", + stacklevel=2, ) return full @@ -1050,34 +1033,6 @@ def _clean_text_features(X): return df -def run_task(task: Callable, message: str, with_spinner: bool = True) -> Any: - if not with_spinner or get_opts().TABPFN_CLIENT_CI_MODE: - result = task() - else: - start = time.time() - spinner = ["-", "\\", "|", "/"] - i = 0 - minutes = 0 - seconds = 0 - with ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(task) - while not future.done(): - elapsed = int(time.time() - start) - minutes = elapsed // 60 - seconds = elapsed % 60 - sys.stdout.write( - f"\r{minutes:02d}:{seconds:02d} {message}... {spinner[i % len(spinner)]}" - ) - sys.stdout.flush() - time.sleep(0.2) - i += 1 - result = future.result() - # Remove spinner, but keep elapsed time - sys.stdout.write(f"\r{minutes:02d}:{seconds:02d} {message}... Done!\n") - sys.stdout.flush() - return result - - def _build_fit_task_config(tabpfn_config: TabPFNConfig) -> FitTaskConfig: match tabpfn_config: case ClassifierTabPFNConfig(): diff --git a/src/tabpfn_client/options.py b/src/tabpfn_client/options.py index b1aebce..d635db5 100644 --- a/src/tabpfn_client/options.py +++ b/src/tabpfn_client/options.py @@ -10,7 +10,6 @@ class Options(BaseSettings): TABPFN_CLIENT_MAX_THREAD_PER_UPLOAD: int = 8 TABPFN_CLIENT_TIMEOUT: float = 900.0 TABPFN_CLIENT_UPLOAD_TIMEOUT: float = 7200.0 # 2 hours - TABPFN_CLIENT_CI_MODE: bool = False TABPFN_CLIENT_FORCE_REUPLOAD: bool = False TABPFN_CLIENT_DEDUP_DATASETS: bool = True diff --git a/src/tabpfn_client/prompt_agent.py b/src/tabpfn_client/prompt_agent.py index 1e9fcb3..2c9ac44 100644 --- a/src/tabpfn_client/prompt_agent.py +++ b/src/tabpfn_client/prompt_agent.py @@ -354,10 +354,6 @@ def _verify_user_email(cls, access_token: str | None) -> bool: " [cyan]Try again, type 'resend' for a new code, or 'quit' to exit.[/cyan]" ) - @classmethod - def prompt_reusing_existing_token(cls): - notify("Found existing access token, reusing it for authentication.") - @classmethod def prompt_retrieved_greeting_messages(cls, greeting_messages: list[str]): for message in greeting_messages: diff --git a/src/tabpfn_client/service_wrapper.py b/src/tabpfn_client/service_wrapper.py index 50b13b6..aaac245 100644 --- a/src/tabpfn_client/service_wrapper.py +++ b/src/tabpfn_client/service_wrapper.py @@ -211,50 +211,27 @@ class UserDataClient(ServiceClientWrapper, Singleton): @classmethod def get_data_summary(cls) -> dict: - try: - summary = ServiceClient.get_data_summary() - except RuntimeError as e: - logging.error(f"Failed to get data summary: {e}") - raise e - - return summary + return ServiceClient.get_data_summary() @classmethod def download_all_data(cls, save_dir: Path = Path(".")) -> Path: - try: - saved_path = ServiceClient.download_all_data(save_dir) - except RuntimeError as e: - logging.error(f"Failed to download data: {e}") - raise e - + saved_path = ServiceClient.download_all_data(save_dir) if saved_path is None: raise RuntimeError("Failed to download data.") - logging.info(f"Data saved to {saved_path}") + logger.info(f"Data saved to {saved_path}") return saved_path @classmethod def delete_dataset(cls, dataset_uid: str) -> list[str]: - try: - deleted_datasets = ServiceClient.delete_dataset(dataset_uid) - except RuntimeError as e: - logging.error(f"Failed to delete dataset: {e}") - raise e - - logging.info(f"Deleted datasets: {deleted_datasets}") - + deleted_datasets = ServiceClient.delete_dataset(dataset_uid) + logger.info(f"Deleted datasets: {deleted_datasets}") return deleted_datasets @classmethod def delete_all_datasets(cls) -> list[str]: - try: - deleted_datasets = ServiceClient.delete_all_datasets() - except RuntimeError as e: - logging.error(f"Failed to delete all datasets: {e}") - raise e - - logging.info(f"Deleted datasets: {deleted_datasets}") - + deleted_datasets = ServiceClient.delete_all_datasets() + logger.info(f"Deleted datasets: {deleted_datasets}") return deleted_datasets @classmethod @@ -266,12 +243,7 @@ def delete_user_account(cls): logger.info("Account deletion cancelled — confirmation phrase not entered.") return - try: - ServiceClient.delete_user_account() - except RuntimeError as e: - logging.error(f"Failed to delete user account: {e}") - raise e - + ServiceClient.delete_user_account() PromptAgent.prompt_account_deleted() diff --git a/src/tabpfn_client/ui.py b/src/tabpfn_client/ui.py index 7efe145..b2dc16c 100644 --- a/src/tabpfn_client/ui.py +++ b/src/tabpfn_client/ui.py @@ -9,7 +9,6 @@ from contextlib import contextmanager from rich.console import Console -from rich.logging import RichHandler from rich.panel import Panel from rich.progress import ( BarColumn, @@ -33,24 +32,6 @@ def _should_use_color() -> bool: console = Console(soft_wrap=False, highlight=True, force_terminal=_should_use_color()) -def setup_logging(verbosity: int = 0) -> None: - """Configure logging to emit through Rich with a consistent style.""" - - level = logging.WARNING - min(verbosity, 2) * 10 - logging.basicConfig( - level=level, - format="%(message)s", - handlers=[ - RichHandler( - console=console, - rich_tracebacks=True, - show_time=False, - show_path=False, - ) - ], - ) - - def header(title: str, subtitle: str | None = None) -> None: """Render a section header.""" @@ -148,7 +129,6 @@ def print_logo_small(subtitle=None) -> None: "print_logo", "print_logo_small", "progress_bar", - "setup_logging", "status", "success", "info", diff --git a/tests/unit/test_tabpfn_regressor.py b/tests/unit/test_tabpfn_regressor.py index 1d2515e..03bfd96 100644 --- a/tests/unit/test_tabpfn_regressor.py +++ b/tests/unit/test_tabpfn_regressor.py @@ -604,7 +604,7 @@ def __init__(self, borders): self.assertIsInstance(output["criterion"], DummyFullSupportBarDistribution) self.assertEqual(output["criterion"].borders, dummy_output["borders"]) - def test_predict_full_missing_optional_dependencies_logs_warning(self): + def test_predict_full_missing_optional_dependencies_warns(self): regressor = TabPFNRegressor() regressor.model_id_ = UUID("00000000-0000-0000-0000-000000000000") regressor._n_train_rows = 5 @@ -625,15 +625,10 @@ def import_side_effect(name, *args, **kwargs): return original_import(name, *args, **kwargs) with patch("builtins.__import__", side_effect=import_side_effect): - with self.assertLogs( - "tabpfn_client.estimator", level="WARNING" - ) as captured_logs: + with self.assertWarnsRegex(UserWarning, "Optional dependencies"): output = regressor.predict(test_X, output_type="full") self.assertNotIn("criterion", output) - self.assertTrue( - any("Optional dependencies" in message for message in captured_logs.output) - ) def test_predict_with_long_and_comma_text(self): """Test predictions with long text (>2500 chars) and text containing commas.""" From 1bbdf895ff744e97b0a7ed56d978bf22f1c6aae6 Mon Sep 17 00:00:00 2001 From: Georg Grab Date: Fri, 11 Sep 2026 15:33:26 +0200 Subject: [PATCH 2/3] docs: add changelog entry for quieter client output Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018bhEkFCJWSiwyStDihRFMa --- changelog/385.changed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/385.changed.md diff --git a/changelog/385.changed.md b/changelog/385.changed.md new file mode 100644 index 0000000..d8802be --- /dev/null +++ b/changelog/385.changed.md @@ -0,0 +1 @@ +`fit()` and `predict()` no longer print a progress spinner to stdout, `init()` no longer announces that it is reusing a cached token, and routine messages such as "Test set already exists, skipping upload." are logged at DEBUG instead of WARNING. `TABPFN_CLIENT_CI_MODE`, which only disabled the spinner, is removed. From e4d9d23c628e0f7d9d29e945c0f38dfe243c7192 Mon Sep 17 00:00:00 2001 From: Georg Grab Date: Fri, 11 Sep 2026 15:37:10 +0200 Subject: [PATCH 3/3] refactor(ui): remove unused helpers Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018bhEkFCJWSiwyStDihRFMa --- src/tabpfn_client/ui.py | 51 +---------------------------------------- 1 file changed, 1 insertion(+), 50 deletions(-) diff --git a/src/tabpfn_client/ui.py b/src/tabpfn_client/ui.py index b2dc16c..56912ba 100644 --- a/src/tabpfn_client/ui.py +++ b/src/tabpfn_client/ui.py @@ -9,14 +9,6 @@ from contextlib import contextmanager from rich.console import Console -from rich.panel import Panel -from rich.progress import ( - BarColumn, - Progress, - SpinnerColumn, - TextColumn, - TimeElapsedColumn, -) def _should_use_color() -> bool: @@ -31,17 +23,6 @@ def _should_use_color() -> bool: console = Console(soft_wrap=False, highlight=True, force_terminal=_should_use_color()) - -def header(title: str, subtitle: str | None = None) -> None: - """Render a section header.""" - - console.print( - Panel.fit( - title if not subtitle else f"[bold]{title}[/bold]\n[dim]{subtitle}[/dim]" - ) - ) - - logger = logging.getLogger(__name__) @@ -69,28 +50,12 @@ def fail(message: str) -> None: console.print(f"[bold red]{message}[/bold red]") -def info(message: str) -> None: - console.print(f"[blue]{message}[/blue]") - - @contextmanager def status(message: str) -> Generator[None]: with console.status(f"[bold]{message}[/bold]"): yield -def progress_bar(description: str = "Working...") -> Progress: - return Progress( - SpinnerColumn(), - TextColumn("[bold]{task.description}"), - BarColumn(), - TextColumn("{task.completed}/{task.total}"), - TimeElapsedColumn(), - console=console, - transient=True, - ) - - # ============================= # Branding: Prior Labs ASCII # ============================= @@ -103,10 +68,6 @@ def progress_bar(description: str = "Working...") -> Progress: ### ### ## ### ######### ### ### ######## ### ### ######## ######## """ -_PRIOR_LABS_ASCII_SMALL = r""" -[ PRIOR LABS ] -""" - def print_logo(subtitle=None) -> None: """Print the large Prior Labs ASCII logo with optional subtitle.""" @@ -115,22 +76,12 @@ def print_logo(subtitle=None) -> None: console.print(f"[dim]{subtitle}[/dim]", end="\n\n") -def print_logo_small(subtitle=None) -> None: - """Print a small Prior Labs ASCII banner with optional subtitle.""" - console.print(_PRIOR_LABS_ASCII_SMALL, style="bold blue") - if subtitle: - console.print(f"[dim]{subtitle}[/dim]") - - __all__ = [ "console", "fail", - "header", + "notify", "print_logo", - "print_logo_small", - "progress_bar", "status", "success", - "info", "warn", ]