boti-dask 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
boti_dask/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """Dask runtime and resilience utilities for the Boti ecosystem."""
2
+
3
+ from boti_dask.diagnostics import (
4
+ UniqueValuesExtractor,
5
+ describe_frame,
6
+ diagnostics_logger,
7
+ inspect_graph,
8
+ )
9
+ from boti_dask.resilience import (
10
+ RECOVERABLE_DASK_ERRORS,
11
+ async_safe_compute,
12
+ async_safe_gather,
13
+ async_safe_head,
14
+ async_safe_persist,
15
+ async_safe_wait,
16
+ dask_is_empty,
17
+ dask_is_probably_empty,
18
+ safe_compute,
19
+ safe_gather,
20
+ safe_head,
21
+ safe_persist,
22
+ safe_wait,
23
+ )
24
+ from boti_dask.session import (
25
+ DaskSession,
26
+ DaskSessionSettings,
27
+ apply_recommended_dask_config,
28
+ current_client_summary,
29
+ dask_session,
30
+ dask_session_from_env_prefix,
31
+ describe_client,
32
+ recommended_dask_config,
33
+ )
34
+
35
+ __all__ = [
36
+ "DaskSession",
37
+ "DaskSessionSettings",
38
+ "RECOVERABLE_DASK_ERRORS",
39
+ "UniqueValuesExtractor",
40
+ "apply_recommended_dask_config",
41
+ "async_safe_compute",
42
+ "async_safe_gather",
43
+ "async_safe_head",
44
+ "async_safe_persist",
45
+ "async_safe_wait",
46
+ "current_client_summary",
47
+ "dask_is_empty",
48
+ "dask_is_probably_empty",
49
+ "dask_session",
50
+ "dask_session_from_env_prefix",
51
+ "describe_frame",
52
+ "describe_client",
53
+ "diagnostics_logger",
54
+ "inspect_graph",
55
+ "recommended_dask_config",
56
+ "safe_compute",
57
+ "safe_gather",
58
+ "safe_head",
59
+ "safe_persist",
60
+ "safe_wait",
61
+ ]
62
+
boti_dask/_internal.py ADDED
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import dask.dataframe as dd
6
+
7
+
8
+ def _log(logger: Any | None, level: str, message: str) -> None:
9
+ if logger is None:
10
+ return
11
+ log_fn = getattr(logger, level, None)
12
+ if callable(log_fn):
13
+ log_fn(message)
14
+
15
+
16
+ def _is_dask_dataframe_like(obj: Any) -> bool:
17
+ return isinstance(obj, (dd.DataFrame, dd.Series)) or hasattr(obj, "_meta")
18
+
19
+
20
+ def _is_running_client(client: Any | None) -> bool:
21
+ if client is None:
22
+ return False
23
+ return getattr(client, "status", "running") in {"running", "started"}
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any
5
+
6
+ import dask.dataframe as dd
7
+ import pandas as pd
8
+
9
+ try:
10
+ import pyarrow as pa
11
+ except ImportError:
12
+ pa = None
13
+
14
+ try:
15
+ import polars as pl
16
+ except ImportError:
17
+ pl = None
18
+
19
+ from boti.core.logger import Logger
20
+
21
+ from ._internal import _log
22
+
23
+
24
+ def _has_dask_graph(obj: Any) -> bool:
25
+ return hasattr(obj, "__dask_graph__")
26
+
27
+
28
+ def inspect_graph(obj: Any, *, logger: Any | None = None) -> dict[str, Any]:
29
+ """Return compact Dask graph metrics for diagnostics and dry-run usage."""
30
+ if not _has_dask_graph(obj):
31
+ metrics = {"type": type(obj).__name__, "is_dask": False}
32
+ _log(logger, "info", f"Dask graph inspection metrics={metrics}")
33
+ return metrics
34
+
35
+ graph = obj.__dask_graph__()
36
+ graph_layers: int | None = None
37
+ if hasattr(getattr(obj, "dask", None), "layers"):
38
+ graph_layers = len(obj.dask.layers)
39
+ elif hasattr(graph, "layers"):
40
+ graph_layers = len(graph.layers)
41
+ metrics = {
42
+ "type": type(obj).__name__,
43
+ "is_dask": True,
44
+ "task_count": len(graph) if hasattr(graph, "__len__") else None,
45
+ "npartitions": getattr(obj, "npartitions", None),
46
+ "graph_layers": graph_layers,
47
+ }
48
+ _log(logger, "info", f"Dask graph inspection metrics={metrics}")
49
+ return metrics
50
+
51
+
52
+ def describe_frame(frame: Any) -> dict[str, Any]:
53
+ """Return compact frame metrics suitable for runtime diagnostics logs."""
54
+ if isinstance(frame, dd.DataFrame):
55
+ graph = frame.__dask_graph__()
56
+ return {
57
+ "engine": "dask",
58
+ "columns": len(frame.columns),
59
+ "npartitions": frame.npartitions,
60
+ "graph_tasks": len(graph) if hasattr(graph, "__len__") else None,
61
+ "graph_layers": len(frame.dask.layers) if hasattr(frame.dask, "layers") else None,
62
+ "known_divisions": frame.known_divisions,
63
+ }
64
+ if isinstance(frame, pd.DataFrame):
65
+ return {
66
+ "engine": "pandas",
67
+ "rows": len(frame.index),
68
+ "columns": len(frame.columns),
69
+ }
70
+ if pa is not None and isinstance(frame, pa.Table):
71
+ return {
72
+ "engine": "arrow",
73
+ "rows": frame.num_rows,
74
+ "columns": len(frame.column_names),
75
+ }
76
+ if pl is not None and isinstance(frame, pl.DataFrame):
77
+ return {
78
+ "engine": "polars",
79
+ "rows": frame.height,
80
+ "columns": frame.width,
81
+ }
82
+ return {"engine": type(frame).__name__}
83
+
84
+
85
+ def diagnostics_logger(logger: Any | None, *, name: str) -> Any:
86
+ """Return provided logger or a :class:`boti.Logger` fallback scoped by *name*."""
87
+ if logger is not None:
88
+ return logger
89
+ return Logger.default_logger(logger_name=name)
90
+
91
+
92
+ class UniqueValuesExtractor:
93
+ """Best-effort unique value extraction for Dask-backed columns."""
94
+
95
+ def __init__(self, *, dask_client: Any | None = None, logger: Any | None = None) -> None:
96
+ self.dask_client = dask_client
97
+ self.logger = logger
98
+
99
+ def _extract_one(self, frame: dd.DataFrame, column: str, limit: int) -> tuple[str, list[Any]]:
100
+ from .resilience import safe_compute
101
+
102
+ if column not in frame.columns:
103
+ _log(self.logger, "warning", f"Column '{column}' not found for unique extraction.")
104
+ return column, []
105
+
106
+ expression = frame[column].dropna().drop_duplicates()
107
+ if self.dask_client is not None:
108
+ values = safe_compute(expression, dask_client=self.dask_client, logger=self.logger)
109
+ else:
110
+ values = expression.compute(scheduler="threads")
111
+ as_series = pd.Series(values)
112
+ unique_values = as_series.dropna().unique().tolist()
113
+ if len(unique_values) > limit:
114
+ _log(
115
+ self.logger,
116
+ "warning",
117
+ f"Unique value extraction for column '{column}' truncated at {limit} items.",
118
+ )
119
+ unique_values = unique_values[:limit]
120
+ return column, unique_values
121
+
122
+ async def extract_unique_values(
123
+ self,
124
+ frame: dd.DataFrame,
125
+ *columns: str,
126
+ limit: int = 100_000,
127
+ ) -> dict[str, list[Any]]:
128
+ from .resilience import safe_persist
129
+
130
+ if self.dask_client is not None:
131
+ frame = safe_persist(frame, dask_client=self.dask_client, logger=self.logger)
132
+ else:
133
+ frame = frame.persist()
134
+ _log(self.logger, "info", f"Persisted {frame.npartitions}-partition frame for unique extraction.")
135
+ pairs = await asyncio.gather(
136
+ *(asyncio.to_thread(self._extract_one, frame, column, limit) for column in columns)
137
+ )
138
+ return dict(pairs)
139
+
140
+
141
+ __all__ = [
142
+ "UniqueValuesExtractor",
143
+ "describe_frame",
144
+ "diagnostics_logger",
145
+ "inspect_graph",
146
+ ]
boti_dask/py.typed ADDED
File without changes