boti-dask 1.0.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.
- boti_dask-1.0.0/PKG-INFO +174 -0
- boti_dask-1.0.0/README.md +144 -0
- boti_dask-1.0.0/pyproject.toml +75 -0
- boti_dask-1.0.0/setup.cfg +4 -0
- boti_dask-1.0.0/src/boti_dask/__init__.py +62 -0
- boti_dask-1.0.0/src/boti_dask/_internal.py +23 -0
- boti_dask-1.0.0/src/boti_dask/diagnostics.py +146 -0
- boti_dask-1.0.0/src/boti_dask/py.typed +0 -0
- boti_dask-1.0.0/src/boti_dask/resilience.py +557 -0
- boti_dask-1.0.0/src/boti_dask/session.py +671 -0
- boti_dask-1.0.0/src/boti_dask.egg-info/PKG-INFO +174 -0
- boti_dask-1.0.0/src/boti_dask.egg-info/SOURCES.txt +13 -0
- boti_dask-1.0.0/src/boti_dask.egg-info/dependency_links.txt +1 -0
- boti_dask-1.0.0/src/boti_dask.egg-info/requires.txt +8 -0
- boti_dask-1.0.0/src/boti_dask.egg-info/top_level.txt +1 -0
boti_dask-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: boti-dask
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Dask runtime and resilience utilities for the Boti ecosystem
|
|
5
|
+
Author-email: Luis Valverde <lvalverdeb@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/lvalverdeb/boti-dask
|
|
8
|
+
Project-URL: Repository, https://github.com/lvalverdeb/boti-dask
|
|
9
|
+
Project-URL: Documentation, https://github.com/lvalverdeb/boti-dask#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/lvalverdeb/boti-dask/issues
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: boti>=0.1.0
|
|
24
|
+
Requires-Dist: dask[dataframe,distributed]>=2026.3.0
|
|
25
|
+
Requires-Dist: pandas>=3.0.2
|
|
26
|
+
Provides-Extra: extra
|
|
27
|
+
Requires-Dist: numpy>=2.3.0; extra == "extra"
|
|
28
|
+
Requires-Dist: polars>=1.29.0; extra == "extra"
|
|
29
|
+
Requires-Dist: pyarrow>=23.0.1; extra == "extra"
|
|
30
|
+
|
|
31
|
+
# boti-dask
|
|
32
|
+
|
|
33
|
+
Dask runtime/session/resilience utilities for the Boti ecosystem.
|
|
34
|
+
|
|
35
|
+
## API sections
|
|
36
|
+
|
|
37
|
+
- Session: `docs/api/session.md`
|
|
38
|
+
- Resilience: `docs/api/resilience.md`
|
|
39
|
+
- Diagnostics: `docs/api/diagnostics.md`
|
|
40
|
+
|
|
41
|
+
## Phase-1 scope
|
|
42
|
+
|
|
43
|
+
This initial bootstrap provides:
|
|
44
|
+
|
|
45
|
+
- `DaskSession` and `dask_session(...)` helpers
|
|
46
|
+
- `DaskSessionSettings` and `dask_session_from_env_prefix(...)` helpers
|
|
47
|
+
- shared-session lifecycle support
|
|
48
|
+
- recommended Dask config profile helpers
|
|
49
|
+
- resilient execution wrappers:
|
|
50
|
+
- `safe_compute`, `safe_persist`, `safe_wait`, `safe_head`, `safe_gather`
|
|
51
|
+
- async counterparts
|
|
52
|
+
- diagnostics helpers:
|
|
53
|
+
- `inspect_graph`, `describe_frame`, `diagnostics_logger`
|
|
54
|
+
- Dask emptiness and unique-value helpers:
|
|
55
|
+
- `dask_is_probably_empty`, `dask_is_empty`, `UniqueValuesExtractor`
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from boti_dask import dask_session, inspect_graph, safe_compute
|
|
61
|
+
import dask
|
|
62
|
+
|
|
63
|
+
with dask_session(cluster_kwargs={"n_workers": 1, "threads_per_worker": 1, "processes": False, "dashboard_address": ":0"}) as client:
|
|
64
|
+
value = dask.delayed(lambda: 6 * 7)()
|
|
65
|
+
print(safe_compute(value, dask_client=client))
|
|
66
|
+
print(inspect_graph(value))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If `dashboard_address` is omitted for local managed sessions, `boti-dask` defaults it to `":0"`.
|
|
70
|
+
|
|
71
|
+
Load session defaults from prefixed environment variables:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from boti_dask import DaskSessionSettings, dask_session_from_env_prefix
|
|
75
|
+
|
|
76
|
+
settings = DaskSessionSettings.from_env_prefix("BOTI_DASK_", env_file=".env")
|
|
77
|
+
session = dask_session_from_env_prefix("BOTI_DASK_", env_file=".env", verify_connectivity=True)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Distributed computing scenarios
|
|
81
|
+
|
|
82
|
+
### Multi-worker cluster with grouped aggregation
|
|
83
|
+
|
|
84
|
+
Launch a 4-worker cluster, distribute a DataFrame across 8 partitions, and
|
|
85
|
+
run a grouped sum that executes in parallel across all workers:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from boti_dask import dask_session, describe_client, safe_persist, safe_compute
|
|
89
|
+
import dask.dataframe as dd, pandas as pd
|
|
90
|
+
|
|
91
|
+
with dask_session(
|
|
92
|
+
cluster_kwargs={"n_workers": 4, "threads_per_worker": 2, "processes": False, "dashboard_address": ":0"},
|
|
93
|
+
) as client:
|
|
94
|
+
print(describe_client(client)) # "workers": 4, "threads": 8
|
|
95
|
+
|
|
96
|
+
ddf = dd.from_pandas(pd.DataFrame({"v": range(10_000), "g": ["X","Y","Z"] * 3334}), npartitions=8)
|
|
97
|
+
persisted = safe_persist(ddf, dask_client=client)
|
|
98
|
+
grouped = safe_compute(persisted.groupby("g")["v"].sum(), dask_client=client)
|
|
99
|
+
print(grouped) # {"X": 16665000, "Y": 16665000, "Z": 16665000}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Shared session with nested contexts
|
|
103
|
+
|
|
104
|
+
Multiple ``with`` blocks share the same Dask client via reference counting.
|
|
105
|
+
The client stays alive until the last holder exits:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from boti_dask import dask_session
|
|
109
|
+
|
|
110
|
+
kwargs = {"n_workers": 2, "threads_per_worker": 1, "processes": False, "dashboard_address": ":0"}
|
|
111
|
+
with dask_session(cluster_kwargs=kwargs, shared=True, shared_key="my-cluster") as outer:
|
|
112
|
+
with dask_session(shared=True, shared_key="my-cluster") as inner:
|
|
113
|
+
print(outer is inner) # True
|
|
114
|
+
# outer still alive here
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Async distributed pipeline
|
|
118
|
+
|
|
119
|
+
Persist, wait, head, compute, and gather — all through async-safe wrappers:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
import asyncio
|
|
123
|
+
from boti_dask import dask_session, async_safe_persist, async_safe_head, async_safe_gather
|
|
124
|
+
import dask.dataframe as dd, pandas as pd, dask
|
|
125
|
+
|
|
126
|
+
async def pipeline():
|
|
127
|
+
ddf = dd.from_pandas(pd.DataFrame({"id": range(100)}), npartitions=4)
|
|
128
|
+
with dask_session(cluster_kwargs={"n_workers": 2, "processes": False, "dashboard_address": ":0"}) as client:
|
|
129
|
+
p = await async_safe_persist(ddf, dask_client=client)
|
|
130
|
+
preview = await async_safe_head(p, n=3, dask_client=client)
|
|
131
|
+
gathered = await async_safe_gather([dask.delayed(lambda: 42)()], dask_client=client)
|
|
132
|
+
return preview["id"].tolist(), gathered
|
|
133
|
+
|
|
134
|
+
asyncio.run(pipeline())
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Orphaned graph detection
|
|
138
|
+
|
|
139
|
+
When a Dask collection outlives the session that created it, operations
|
|
140
|
+
fail with a clear error instead of a cryptic Dask traceback:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
with dask_session(cluster_kwargs={...}) as client:
|
|
144
|
+
persisted = safe_persist(ddf, dask_client=client)
|
|
145
|
+
# session closed — persisted is now orphaned
|
|
146
|
+
safe_head(persisted) # RuntimeError: orphaned from its original client/worker state
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Pass an external Dask `Client` to keep the client alive across session boundaries:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from dask.distributed import LocalCluster, Client
|
|
153
|
+
cluster = LocalCluster(...)
|
|
154
|
+
external = Client(cluster)
|
|
155
|
+
with dask_session(client=external) as session_client:
|
|
156
|
+
result = safe_compute(dask.delayed(lambda: 42)(), dask_client=session_client)
|
|
157
|
+
# external.close() # manual lifecycle
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Development
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
uv sync --dev
|
|
164
|
+
uv run pytest -q
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Examples
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
uv run python examples/data_facade_dask_resilience.py
|
|
171
|
+
uv run python examples/smoke_all_examples.py
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
See `examples/README.md` for details.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# boti-dask
|
|
2
|
+
|
|
3
|
+
Dask runtime/session/resilience utilities for the Boti ecosystem.
|
|
4
|
+
|
|
5
|
+
## API sections
|
|
6
|
+
|
|
7
|
+
- Session: `docs/api/session.md`
|
|
8
|
+
- Resilience: `docs/api/resilience.md`
|
|
9
|
+
- Diagnostics: `docs/api/diagnostics.md`
|
|
10
|
+
|
|
11
|
+
## Phase-1 scope
|
|
12
|
+
|
|
13
|
+
This initial bootstrap provides:
|
|
14
|
+
|
|
15
|
+
- `DaskSession` and `dask_session(...)` helpers
|
|
16
|
+
- `DaskSessionSettings` and `dask_session_from_env_prefix(...)` helpers
|
|
17
|
+
- shared-session lifecycle support
|
|
18
|
+
- recommended Dask config profile helpers
|
|
19
|
+
- resilient execution wrappers:
|
|
20
|
+
- `safe_compute`, `safe_persist`, `safe_wait`, `safe_head`, `safe_gather`
|
|
21
|
+
- async counterparts
|
|
22
|
+
- diagnostics helpers:
|
|
23
|
+
- `inspect_graph`, `describe_frame`, `diagnostics_logger`
|
|
24
|
+
- Dask emptiness and unique-value helpers:
|
|
25
|
+
- `dask_is_probably_empty`, `dask_is_empty`, `UniqueValuesExtractor`
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from boti_dask import dask_session, inspect_graph, safe_compute
|
|
31
|
+
import dask
|
|
32
|
+
|
|
33
|
+
with dask_session(cluster_kwargs={"n_workers": 1, "threads_per_worker": 1, "processes": False, "dashboard_address": ":0"}) as client:
|
|
34
|
+
value = dask.delayed(lambda: 6 * 7)()
|
|
35
|
+
print(safe_compute(value, dask_client=client))
|
|
36
|
+
print(inspect_graph(value))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If `dashboard_address` is omitted for local managed sessions, `boti-dask` defaults it to `":0"`.
|
|
40
|
+
|
|
41
|
+
Load session defaults from prefixed environment variables:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from boti_dask import DaskSessionSettings, dask_session_from_env_prefix
|
|
45
|
+
|
|
46
|
+
settings = DaskSessionSettings.from_env_prefix("BOTI_DASK_", env_file=".env")
|
|
47
|
+
session = dask_session_from_env_prefix("BOTI_DASK_", env_file=".env", verify_connectivity=True)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Distributed computing scenarios
|
|
51
|
+
|
|
52
|
+
### Multi-worker cluster with grouped aggregation
|
|
53
|
+
|
|
54
|
+
Launch a 4-worker cluster, distribute a DataFrame across 8 partitions, and
|
|
55
|
+
run a grouped sum that executes in parallel across all workers:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from boti_dask import dask_session, describe_client, safe_persist, safe_compute
|
|
59
|
+
import dask.dataframe as dd, pandas as pd
|
|
60
|
+
|
|
61
|
+
with dask_session(
|
|
62
|
+
cluster_kwargs={"n_workers": 4, "threads_per_worker": 2, "processes": False, "dashboard_address": ":0"},
|
|
63
|
+
) as client:
|
|
64
|
+
print(describe_client(client)) # "workers": 4, "threads": 8
|
|
65
|
+
|
|
66
|
+
ddf = dd.from_pandas(pd.DataFrame({"v": range(10_000), "g": ["X","Y","Z"] * 3334}), npartitions=8)
|
|
67
|
+
persisted = safe_persist(ddf, dask_client=client)
|
|
68
|
+
grouped = safe_compute(persisted.groupby("g")["v"].sum(), dask_client=client)
|
|
69
|
+
print(grouped) # {"X": 16665000, "Y": 16665000, "Z": 16665000}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Shared session with nested contexts
|
|
73
|
+
|
|
74
|
+
Multiple ``with`` blocks share the same Dask client via reference counting.
|
|
75
|
+
The client stays alive until the last holder exits:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from boti_dask import dask_session
|
|
79
|
+
|
|
80
|
+
kwargs = {"n_workers": 2, "threads_per_worker": 1, "processes": False, "dashboard_address": ":0"}
|
|
81
|
+
with dask_session(cluster_kwargs=kwargs, shared=True, shared_key="my-cluster") as outer:
|
|
82
|
+
with dask_session(shared=True, shared_key="my-cluster") as inner:
|
|
83
|
+
print(outer is inner) # True
|
|
84
|
+
# outer still alive here
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Async distributed pipeline
|
|
88
|
+
|
|
89
|
+
Persist, wait, head, compute, and gather — all through async-safe wrappers:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import asyncio
|
|
93
|
+
from boti_dask import dask_session, async_safe_persist, async_safe_head, async_safe_gather
|
|
94
|
+
import dask.dataframe as dd, pandas as pd, dask
|
|
95
|
+
|
|
96
|
+
async def pipeline():
|
|
97
|
+
ddf = dd.from_pandas(pd.DataFrame({"id": range(100)}), npartitions=4)
|
|
98
|
+
with dask_session(cluster_kwargs={"n_workers": 2, "processes": False, "dashboard_address": ":0"}) as client:
|
|
99
|
+
p = await async_safe_persist(ddf, dask_client=client)
|
|
100
|
+
preview = await async_safe_head(p, n=3, dask_client=client)
|
|
101
|
+
gathered = await async_safe_gather([dask.delayed(lambda: 42)()], dask_client=client)
|
|
102
|
+
return preview["id"].tolist(), gathered
|
|
103
|
+
|
|
104
|
+
asyncio.run(pipeline())
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Orphaned graph detection
|
|
108
|
+
|
|
109
|
+
When a Dask collection outlives the session that created it, operations
|
|
110
|
+
fail with a clear error instead of a cryptic Dask traceback:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
with dask_session(cluster_kwargs={...}) as client:
|
|
114
|
+
persisted = safe_persist(ddf, dask_client=client)
|
|
115
|
+
# session closed — persisted is now orphaned
|
|
116
|
+
safe_head(persisted) # RuntimeError: orphaned from its original client/worker state
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Pass an external Dask `Client` to keep the client alive across session boundaries:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
from dask.distributed import LocalCluster, Client
|
|
123
|
+
cluster = LocalCluster(...)
|
|
124
|
+
external = Client(cluster)
|
|
125
|
+
with dask_session(client=external) as session_client:
|
|
126
|
+
result = safe_compute(dask.delayed(lambda: 42)(), dask_client=session_client)
|
|
127
|
+
# external.close() # manual lifecycle
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv sync --dev
|
|
134
|
+
uv run pytest -q
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Examples
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
uv run python examples/data_facade_dask_resilience.py
|
|
141
|
+
uv run python examples/smoke_all_examples.py
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
See `examples/README.md` for details.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "boti-dask"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Dask runtime and resilience utilities for the Boti ecosystem"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "Luis Valverde", email = "lvalverdeb@gmail.com"}
|
|
10
|
+
]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"boti>=0.1.0",
|
|
25
|
+
"dask[dataframe,distributed]>=2026.3.0",
|
|
26
|
+
"pandas>=3.0.2",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
extra = [
|
|
31
|
+
"numpy>=2.3.0",
|
|
32
|
+
"polars>=1.29.0",
|
|
33
|
+
"pyarrow>=23.0.1",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/lvalverdeb/boti-dask"
|
|
38
|
+
Repository = "https://github.com/lvalverdeb/boti-dask"
|
|
39
|
+
Documentation = "https://github.com/lvalverdeb/boti-dask#readme"
|
|
40
|
+
Issues = "https://github.com/lvalverdeb/boti-dask/issues"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["setuptools>=80", "wheel"]
|
|
44
|
+
build-backend = "setuptools.build_meta"
|
|
45
|
+
|
|
46
|
+
[dependency-groups]
|
|
47
|
+
dev = [
|
|
48
|
+
"pytest>=9.0.3",
|
|
49
|
+
"pytest-asyncio>=1.3.0",
|
|
50
|
+
"ruff>=0.11.0",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
line-length = 100
|
|
55
|
+
target-version = "py310"
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
select = ["E", "F", "W", "I", "UP"]
|
|
59
|
+
ignore = ["E501"]
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
addopts = "-q"
|
|
63
|
+
|
|
64
|
+
[tool.setuptools]
|
|
65
|
+
include-package-data = true
|
|
66
|
+
|
|
67
|
+
[tool.setuptools.package-data]
|
|
68
|
+
boti_dask = ["py.typed"]
|
|
69
|
+
|
|
70
|
+
[tool.setuptools.package-dir]
|
|
71
|
+
"" = "src"
|
|
72
|
+
|
|
73
|
+
[tool.setuptools.packages.find]
|
|
74
|
+
where = ["src"]
|
|
75
|
+
include = ["boti_dask*"]
|
|
@@ -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
|
+
|
|
@@ -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
|
+
]
|
|
File without changes
|