lazy-visual 0.1.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.
- lazy_visual/__init__.py +82 -0
- lazy_visual/_errors.py +161 -0
- lazy_visual/_options.py +33 -0
- lazy_visual/core/__init__.py +0 -0
- lazy_visual/core/result.py +51 -0
- lazy_visual/core/spec.py +40 -0
- lazy_visual/core/style.py +177 -0
- lazy_visual/defaults.py +14 -0
- lazy_visual/export.py +52 -0
- lazy_visual/io/__init__.py +0 -0
- lazy_visual/io/loader.py +80 -0
- lazy_visual/io/normalize.py +79 -0
- lazy_visual/plots/__init__.py +0 -0
- lazy_visual/plots/_common.py +63 -0
- lazy_visual/plots/box.py +161 -0
- lazy_visual/plots/distribution.py +62 -0
- lazy_visual/plots/heatmap.py +96 -0
- lazy_visual/plots/raincloud.py +153 -0
- lazy_visual/plots/roc.py +79 -0
- lazy_visual/plots/scatter.py +88 -0
- lazy_visual/plots/upset.py +138 -0
- lazy_visual/plots/violin.py +6 -0
- lazy_visual/py.typed +0 -0
- lazy_visual/render/__init__.py +0 -0
- lazy_visual/render/base.py +7 -0
- lazy_visual/render/mpl.py +13 -0
- lazy_visual/stats/__init__.py +0 -0
- lazy_visual/stats/compare.py +159 -0
- lazy_visual/stats/density.py +25 -0
- lazy_visual/stats/roc.py +113 -0
- lazy_visual/stats/sets.py +49 -0
- lazy_visual/themes/__init__.py +0 -0
- lazy_visual/themes/apa.json +5 -0
- lazy_visual/themes/dark.json +4 -0
- lazy_visual/themes/default.json +4 -0
- lazy_visual/themes/nature.json +5 -0
- lazy_visual-0.1.0.dist-info/METADATA +29 -0
- lazy_visual-0.1.0.dist-info/RECORD +40 -0
- lazy_visual-0.1.0.dist-info/WHEEL +4 -0
- lazy_visual-0.1.0.dist-info/licenses/LICENSE +9 -0
lazy_visual/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""lazy-visual: one-line statistical plots with built-in stats."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ._errors import (
|
|
5
|
+
LvArgumentError,
|
|
6
|
+
LvDataError,
|
|
7
|
+
LvDataWarning,
|
|
8
|
+
LvDependencyError,
|
|
9
|
+
LvError,
|
|
10
|
+
LvExportError,
|
|
11
|
+
LvPerformanceWarning,
|
|
12
|
+
)
|
|
13
|
+
from ._options import Options, get_options, set_options
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"LvArgumentError",
|
|
19
|
+
"LvDataError",
|
|
20
|
+
"LvDataWarning",
|
|
21
|
+
"LvDependencyError",
|
|
22
|
+
"LvError",
|
|
23
|
+
"LvExportError",
|
|
24
|
+
"LvPerformanceWarning",
|
|
25
|
+
"LvResult",
|
|
26
|
+
"Options",
|
|
27
|
+
"Theme",
|
|
28
|
+
"__version__",
|
|
29
|
+
"box",
|
|
30
|
+
"distribution",
|
|
31
|
+
"get_options",
|
|
32
|
+
"heatmap",
|
|
33
|
+
"load",
|
|
34
|
+
"raincloud",
|
|
35
|
+
"roc",
|
|
36
|
+
"scatterplot",
|
|
37
|
+
"set_options",
|
|
38
|
+
"theme",
|
|
39
|
+
"upset",
|
|
40
|
+
"violin",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def __getattr__(name: str):
|
|
45
|
+
"""Lazy imports so `import lazy_visual` stays fast (NFR-1)."""
|
|
46
|
+
if name == "load":
|
|
47
|
+
from .io.loader import load
|
|
48
|
+
return load
|
|
49
|
+
if name == "theme":
|
|
50
|
+
from .core.style import theme
|
|
51
|
+
return theme
|
|
52
|
+
if name == "Theme":
|
|
53
|
+
from .core.style import Theme
|
|
54
|
+
return Theme
|
|
55
|
+
if name == "LvResult":
|
|
56
|
+
from .core.result import LvResult
|
|
57
|
+
return LvResult
|
|
58
|
+
if name == "scatterplot":
|
|
59
|
+
from .plots.scatter import scatterplot
|
|
60
|
+
return scatterplot
|
|
61
|
+
if name == "distribution":
|
|
62
|
+
from .plots.distribution import distribution
|
|
63
|
+
return distribution
|
|
64
|
+
if name == "box":
|
|
65
|
+
from .plots.box import box
|
|
66
|
+
return box
|
|
67
|
+
if name == "violin":
|
|
68
|
+
from .plots.violin import violin
|
|
69
|
+
return violin
|
|
70
|
+
if name == "raincloud":
|
|
71
|
+
from .plots.raincloud import raincloud
|
|
72
|
+
return raincloud
|
|
73
|
+
if name == "heatmap":
|
|
74
|
+
from .plots.heatmap import heatmap
|
|
75
|
+
return heatmap
|
|
76
|
+
if name == "upset":
|
|
77
|
+
from .plots.upset import upset
|
|
78
|
+
return upset
|
|
79
|
+
if name == "roc":
|
|
80
|
+
from .plots.roc import roc
|
|
81
|
+
return roc
|
|
82
|
+
raise AttributeError(f"module 'lazy_visual' has no attribute {name!r}")
|
lazy_visual/_errors.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LvError(Exception):
|
|
8
|
+
code: str = "LV-E000"
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str, *, code: str | None = None,
|
|
11
|
+
hint: str | None = None) -> None:
|
|
12
|
+
self.code = code or self.code
|
|
13
|
+
self.message = message
|
|
14
|
+
self.hint = hint
|
|
15
|
+
super().__init__(self._format())
|
|
16
|
+
|
|
17
|
+
def _format(self) -> str:
|
|
18
|
+
base = f"[{self.code}] {self.message}"
|
|
19
|
+
if self.hint:
|
|
20
|
+
base += f" Hint: {self.hint}"
|
|
21
|
+
return base
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
return self._format()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LvDataError(LvError, ValueError):
|
|
28
|
+
code = "LV-E100"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LvArgumentError(LvError, ValueError):
|
|
32
|
+
code = "LV-E200"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LvExportError(LvError, OSError):
|
|
36
|
+
code = "LV-E300"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LvDependencyError(LvError, ImportError):
|
|
40
|
+
code = "LV-E401"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LvDataWarning(UserWarning):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LvPerformanceWarning(UserWarning):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _suggest(name: str, available: Iterable[str]) -> str | None:
|
|
52
|
+
m = difflib.get_close_matches(name, list(available), n=1, cutoff=0.6)
|
|
53
|
+
return m[0] if m else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def err_column_not_found(name: str, available: list) -> LvDataError:
|
|
57
|
+
hint = "column names are case-sensitive"
|
|
58
|
+
s = _suggest(name, available)
|
|
59
|
+
if s:
|
|
60
|
+
hint = f"did you mean '{s}'? {hint}"
|
|
61
|
+
return LvDataError(
|
|
62
|
+
f"Column '{name}' not found. Available: {', '.join(available)}.",
|
|
63
|
+
code="LV-E101", hint=hint)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def err_wrong_dtype(name: str, expected: str, found: str) -> LvDataError:
|
|
67
|
+
return LvDataError(
|
|
68
|
+
f"Column '{name}' has invalid dtype '{found}', expected {expected}.",
|
|
69
|
+
code="LV-E102")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def err_empty_after_nan(n_before: int, n_dropped: int) -> LvDataError:
|
|
73
|
+
return LvDataError(
|
|
74
|
+
f"No rows left after dropping NaNs "
|
|
75
|
+
f"(had {n_before}, dropped {n_dropped}).", code="LV-E103")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def err_duplicate_columns(dupes: list) -> LvDataError:
|
|
79
|
+
return LvDataError(
|
|
80
|
+
f"Duplicate column names: {', '.join(dupes)}.",
|
|
81
|
+
code="LV-E104", hint="rename columns to be unique")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def err_y_true_not_binary(classes: list) -> LvDataError:
|
|
85
|
+
return LvDataError(
|
|
86
|
+
f"y_true must have exactly 2 classes, found {len(classes)}: "
|
|
87
|
+
f"{classes}.", code="LV-E106")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def err_upset_sets_count(n: int) -> LvDataError:
|
|
91
|
+
return LvDataError(f"upset requires 2-20 sets, got {n}.",
|
|
92
|
+
code="LV-E107")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def err_file_not_found(path: str) -> LvDataError:
|
|
96
|
+
return LvDataError(f"File not found or unreadable: {path}.",
|
|
97
|
+
code="LV-E108")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def err_unsupported_format(fmt: str) -> LvDataError:
|
|
101
|
+
return LvDataError(f"Unsupported file format: '{fmt}'.",
|
|
102
|
+
code="LV-E109")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def err_url_unreachable(url: str, reason: str) -> LvDataError:
|
|
106
|
+
return LvDataError(
|
|
107
|
+
f"URL unreachable or timed out: {url} ({reason}).",
|
|
108
|
+
code="LV-E110")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def err_out_of_range(name: str, value: object,
|
|
112
|
+
allowed: str) -> LvArgumentError:
|
|
113
|
+
return LvArgumentError(
|
|
114
|
+
f"Argument '{name}' = {value!r} is out of range. "
|
|
115
|
+
f"Allowed: {allowed}.", code="LV-E201")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def err_wrong_arg_type(name: str, expected: str,
|
|
119
|
+
got: str) -> LvArgumentError:
|
|
120
|
+
return LvArgumentError(
|
|
121
|
+
f"Argument '{name}' must be {expected}, got {got}.",
|
|
122
|
+
code="LV-E202")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def err_unknown_theme(name: str, available: list) -> LvArgumentError:
|
|
126
|
+
return LvArgumentError(
|
|
127
|
+
f"Unknown theme/palette '{name}'. "
|
|
128
|
+
f"Available: {', '.join(available)}.", code="LV-E203")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def err_incompatible_args(message: str) -> LvArgumentError:
|
|
132
|
+
return LvArgumentError(message, code="LV-E204")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def err_too_many_groups(n: int, limit: int) -> LvDataError:
|
|
136
|
+
return LvDataError(
|
|
137
|
+
f"Too many groups on axis ({n} > {limit}).",
|
|
138
|
+
code="LV-E205", hint="reduce the number of categories")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def err_export_format(fmt: str) -> LvExportError:
|
|
142
|
+
return LvExportError(
|
|
143
|
+
f"Unsupported export format '{fmt}'. Use png/svg/pdf.",
|
|
144
|
+
code="LV-E301")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def err_no_write_permission(path: str) -> LvExportError:
|
|
148
|
+
return LvExportError(f"Cannot write to {path}.", code="LV-E302")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def err_backend_unavailable(backend: str) -> LvArgumentError:
|
|
152
|
+
return LvArgumentError(
|
|
153
|
+
f"Backend '{backend}' is not available in this version.",
|
|
154
|
+
code="LV-E310", hint="only 'matplotlib' is supported in 0.1.0")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def err_missing_dependency(pkg: str, extra: str) -> LvDependencyError:
|
|
158
|
+
return LvDependencyError(
|
|
159
|
+
f"Optional dependency '{pkg}' is missing. "
|
|
160
|
+
f"Install with: pip install lazy-visual[{extra}]",
|
|
161
|
+
code="LV-E401")
|
lazy_visual/_options.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from . import defaults
|
|
6
|
+
from ._errors import err_out_of_range
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Options:
|
|
11
|
+
active_theme: str = "default"
|
|
12
|
+
random_state: int | None = defaults.RANDOM_STATE
|
|
13
|
+
max_points: int = defaults.MAX_POINTS
|
|
14
|
+
n_boot: int = defaults.N_BOOT
|
|
15
|
+
|
|
16
|
+
_OPTIONS = Options()
|
|
17
|
+
|
|
18
|
+
def get_options():
|
|
19
|
+
return _OPTIONS
|
|
20
|
+
|
|
21
|
+
def set_options(*, random_state=defaults.RANDOM_STATE,
|
|
22
|
+
max_points=defaults.MAX_POINTS,
|
|
23
|
+
n_boot=defaults.N_BOOT, active_theme=None):
|
|
24
|
+
if max_points < 1:
|
|
25
|
+
raise err_out_of_range("max_points", max_points, ">= 1")
|
|
26
|
+
if not (defaults.N_BOOT_MIN <= n_boot <= defaults.N_BOOT_MAX):
|
|
27
|
+
raise err_out_of_range("n_boot", n_boot,
|
|
28
|
+
f"{defaults.N_BOOT_MIN}-{defaults.N_BOOT_MAX}")
|
|
29
|
+
_OPTIONS.random_state = random_state
|
|
30
|
+
_OPTIONS.max_points = int(max_points)
|
|
31
|
+
_OPTIONS.n_boot = int(n_boot)
|
|
32
|
+
if active_theme is not None:
|
|
33
|
+
_OPTIONS.active_theme = active_theme
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from .spec import PlotSpec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class LvResult:
|
|
14
|
+
fig: Any
|
|
15
|
+
ax: Any
|
|
16
|
+
spec: PlotSpec
|
|
17
|
+
stats: list[Any] = field(default_factory=list)
|
|
18
|
+
tables: dict[str, pd.DataFrame] = field(default_factory=dict)
|
|
19
|
+
files: list[Path] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
def save(self, path: Any, *, fmt: Any = None,
|
|
22
|
+
dpi: Any = None) -> list[Path]:
|
|
23
|
+
from ..export import save_result
|
|
24
|
+
self.files = save_result(self.fig, path, fmt=fmt, dpi=dpi)
|
|
25
|
+
return self.files
|
|
26
|
+
|
|
27
|
+
def show(self) -> None:
|
|
28
|
+
import matplotlib.pyplot as plt
|
|
29
|
+
plt.show()
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"kind": self.spec.kind,
|
|
34
|
+
"stats": [
|
|
35
|
+
s.to_dict() if hasattr(s, "to_dict") else s.__dict__
|
|
36
|
+
for s in self.stats
|
|
37
|
+
],
|
|
38
|
+
"files": [str(p) for p in self.files],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
def __repr__(self) -> str:
|
|
42
|
+
head = f"<LvResult {self.spec.kind}"
|
|
43
|
+
if self.stats:
|
|
44
|
+
s = self.stats[0]
|
|
45
|
+
p = getattr(s, "p_value", None)
|
|
46
|
+
if p is not None:
|
|
47
|
+
ps = "p < 0.001" if p < 0.001 else f"p = {p:.3f}"
|
|
48
|
+
head += f", {getattr(s, 'method', '')} {ps}"
|
|
49
|
+
if self.files:
|
|
50
|
+
head += f", saved: {', '.join(p.name for p in self.files)}"
|
|
51
|
+
return head + ">"
|
lazy_visual/core/spec.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Layer:
|
|
9
|
+
geom: str
|
|
10
|
+
columns: dict[str, Any] = field(default_factory=dict)
|
|
11
|
+
style_overrides: dict[str, Any] = field(default_factory=dict)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Annotation:
|
|
16
|
+
kind: str
|
|
17
|
+
text: str = ""
|
|
18
|
+
position: tuple[float, float] = (0.0, 0.0)
|
|
19
|
+
groups: tuple[str, ...] = ()
|
|
20
|
+
stat_ref: int | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class AxesSpec:
|
|
25
|
+
xlabel: str | None = None
|
|
26
|
+
ylabel: str | None = None
|
|
27
|
+
title: str | None = None
|
|
28
|
+
xlim: tuple[float, float] | None = None
|
|
29
|
+
ylim: tuple[float, float] | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class PlotSpec:
|
|
34
|
+
kind: str
|
|
35
|
+
panels: list[str] = field(default_factory=list)
|
|
36
|
+
layers: list[Layer] = field(default_factory=list)
|
|
37
|
+
annotations: list[Annotation] = field(default_factory=list)
|
|
38
|
+
axes: AxesSpec = field(default_factory=AxesSpec)
|
|
39
|
+
legend: bool = True
|
|
40
|
+
style: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import matplotlib as mpl
|
|
9
|
+
|
|
10
|
+
from .. import defaults
|
|
11
|
+
from .._errors import (
|
|
12
|
+
err_out_of_range,
|
|
13
|
+
err_unknown_theme,
|
|
14
|
+
err_wrong_arg_type,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
PALETTES: dict[str, list[Any]] = {
|
|
18
|
+
"okabe": ["#000000", "#E69F00", "#56B4E9", "#009E73",
|
|
19
|
+
"#F0E442", "#0072B2", "#D55E00", "#CC79A7"],
|
|
20
|
+
"tab10": [mpl.colormaps["tab10"](i / 10)
|
|
21
|
+
for i in range(10)],
|
|
22
|
+
"viridis": [mpl.colormaps["viridis"](x)
|
|
23
|
+
for x in (0.0, 0.15, 0.3, 0.45, 0.6, 0.75, 0.9)],
|
|
24
|
+
"gray": ["#111111", "#444444", "#777777", "#999999", "#bbbbbb"],
|
|
25
|
+
}
|
|
26
|
+
KNOWN_THEMES: tuple[str, ...] = ("default", "nature", "apa", "dark")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Theme:
|
|
31
|
+
name: str = "default"
|
|
32
|
+
font_family: tuple[str, ...] = ("DejaVu Sans",)
|
|
33
|
+
font_size: float = 10.0
|
|
34
|
+
palette: str = "tab10"
|
|
35
|
+
linewidth: float = 1.0
|
|
36
|
+
alpha: float = 0.85
|
|
37
|
+
grid: bool = True
|
|
38
|
+
spines: tuple[str, ...] = ("left", "bottom", "top", "right")
|
|
39
|
+
background: str = "white"
|
|
40
|
+
figsize: tuple[float, float] = (6.0, 4.0)
|
|
41
|
+
dpi: int = defaults.DPI_DEFAULT
|
|
42
|
+
|
|
43
|
+
def palette_colors(self) -> list[Any]:
|
|
44
|
+
p = self.palette
|
|
45
|
+
if isinstance(p, str) and p in PALETTES:
|
|
46
|
+
return list(PALETTES[p])
|
|
47
|
+
if isinstance(p, str):
|
|
48
|
+
try:
|
|
49
|
+
cmap = mpl.colormaps[p]
|
|
50
|
+
except KeyError:
|
|
51
|
+
return list(PALETTES["tab10"])
|
|
52
|
+
return [cmap(x) for x in (0.0, 0.2, 0.4, 0.6, 0.8)]
|
|
53
|
+
return list(PALETTES["tab10"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class StyleArgs:
|
|
58
|
+
color: str | None = None
|
|
59
|
+
palette: Any | None = None
|
|
60
|
+
alpha: float | None = None
|
|
61
|
+
linewidth: float | None = None
|
|
62
|
+
size: float | None = None
|
|
63
|
+
title: str | None = None
|
|
64
|
+
xlabel: str | None = None
|
|
65
|
+
ylabel: str | None = None
|
|
66
|
+
show_legend: bool = True
|
|
67
|
+
show_grid: bool | None = None
|
|
68
|
+
annotate: bool = True
|
|
69
|
+
figsize: tuple[float, float] | None = None
|
|
70
|
+
dpi: int | None = None
|
|
71
|
+
save: Any | None = None
|
|
72
|
+
fmt: Any | None = None
|
|
73
|
+
theme: Any | None = None
|
|
74
|
+
ax: Any | None = None
|
|
75
|
+
backend: str = "matplotlib"
|
|
76
|
+
|
|
77
|
+
def validate(self) -> None:
|
|
78
|
+
if self.alpha is not None and not (0.0 <= self.alpha <= 1.0):
|
|
79
|
+
raise err_out_of_range("alpha", self.alpha, "0-1")
|
|
80
|
+
if self.linewidth is not None and not (0.0 < self.linewidth <= 10.0):
|
|
81
|
+
raise err_out_of_range(
|
|
82
|
+
"linewidth", self.linewidth, "0 < lw <= 10")
|
|
83
|
+
if self.size is not None and not (self.size > 0):
|
|
84
|
+
raise err_out_of_range("size", self.size, "> 0")
|
|
85
|
+
if self.figsize is not None:
|
|
86
|
+
if len(self.figsize) != 2:
|
|
87
|
+
raise err_wrong_arg_type(
|
|
88
|
+
"figsize", "tuple of 2 floats", str(self.figsize))
|
|
89
|
+
for v in self.figsize:
|
|
90
|
+
if not (1 <= v <= 30):
|
|
91
|
+
raise err_out_of_range(
|
|
92
|
+
"figsize", self.figsize, "1-30 per side")
|
|
93
|
+
if self.dpi is not None and not (
|
|
94
|
+
defaults.DPI_MIN <= self.dpi <= defaults.DPI_MAX
|
|
95
|
+
):
|
|
96
|
+
raise err_out_of_range(
|
|
97
|
+
"dpi", self.dpi,
|
|
98
|
+
f"{defaults.DPI_MIN}-{defaults.DPI_MAX}")
|
|
99
|
+
if self.backend != "matplotlib":
|
|
100
|
+
from .._errors import err_backend_unavailable
|
|
101
|
+
raise err_backend_unavailable(self.backend)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _theme_from_dict(d: dict[str, Any]) -> Theme:
|
|
105
|
+
known = set(Theme.__dataclass_fields__)
|
|
106
|
+
kwargs: dict[str, Any] = {k: v for k, v in d.items() if k in known}
|
|
107
|
+
if "font_family" in kwargs:
|
|
108
|
+
kwargs["font_family"] = tuple(kwargs["font_family"])
|
|
109
|
+
if "spines" in kwargs:
|
|
110
|
+
kwargs["spines"] = tuple(kwargs["spines"])
|
|
111
|
+
if "figsize" in kwargs:
|
|
112
|
+
kwargs["figsize"] = tuple(kwargs["figsize"])
|
|
113
|
+
return Theme(**kwargs)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _load_builtin(name: str) -> Theme:
|
|
117
|
+
if name not in KNOWN_THEMES:
|
|
118
|
+
raise err_unknown_theme(name, list(KNOWN_THEMES))
|
|
119
|
+
try:
|
|
120
|
+
data = resources.files("lazy_visual.themes").joinpath(
|
|
121
|
+
f"{name}.json").read_text("utf-8")
|
|
122
|
+
return _theme_from_dict(json.loads(data))
|
|
123
|
+
except (FileNotFoundError, ModuleNotFoundError):
|
|
124
|
+
return Theme(name=name)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_BUILTIN_CACHE: dict[str, Theme] = {}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_theme(name: str) -> Theme:
|
|
131
|
+
if name not in _BUILTIN_CACHE:
|
|
132
|
+
_BUILTIN_CACHE[name] = _load_builtin(name)
|
|
133
|
+
return _BUILTIN_CACHE[name]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def theme(name: str = "default", **overrides: Any) -> Theme:
|
|
137
|
+
base = get_theme(name)
|
|
138
|
+
if overrides:
|
|
139
|
+
base = replace(base, **{
|
|
140
|
+
k: v for k, v in overrides.items()
|
|
141
|
+
if k in Theme.__dataclass_fields__
|
|
142
|
+
})
|
|
143
|
+
return base
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def resolve(style: StyleArgs) -> tuple[Theme, StyleArgs]:
|
|
147
|
+
from .._options import get_options
|
|
148
|
+
if style.theme is None:
|
|
149
|
+
t = get_theme(get_options().active_theme)
|
|
150
|
+
elif isinstance(style.theme, str):
|
|
151
|
+
t = get_theme(style.theme)
|
|
152
|
+
elif isinstance(style.theme, dict):
|
|
153
|
+
base_name = style.theme.get("name", get_options().active_theme)
|
|
154
|
+
base = get_theme(base_name)
|
|
155
|
+
merged = {**base.__dict__, **style.theme}
|
|
156
|
+
t = _theme_from_dict(merged)
|
|
157
|
+
elif isinstance(style.theme, Theme):
|
|
158
|
+
t = style.theme
|
|
159
|
+
else:
|
|
160
|
+
raise err_wrong_arg_type(
|
|
161
|
+
"theme", "str, dict or Theme",
|
|
162
|
+
type(style.theme).__name__)
|
|
163
|
+
return t, style
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def apply_mpl_theme(t: Theme) -> None:
|
|
167
|
+
available = {f.name for f in mpl.font_manager.fontManager.ttflist}
|
|
168
|
+
fams = [f for f in t.font_family if f in available] or ["DejaVu Sans"]
|
|
169
|
+
mpl.rcParams["font.family"] = fams
|
|
170
|
+
mpl.rcParams["font.size"] = t.font_size
|
|
171
|
+
mpl.rcParams["axes.grid"] = t.grid
|
|
172
|
+
mpl.rcParams["axes.facecolor"] = t.background
|
|
173
|
+
mpl.rcParams["figure.facecolor"] = t.background
|
|
174
|
+
for side in ("left", "bottom", "top", "right"):
|
|
175
|
+
mpl.rcParams[f"axes.spines.{side}"] = side in t.spines
|
|
176
|
+
mpl.rcParams["svg.fonttype"] = "none"
|
|
177
|
+
mpl.rcParams["pdf.fonttype"] = 42
|
lazy_visual/defaults.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
RANDOM_STATE = 0
|
|
2
|
+
MAX_POINTS = 5000
|
|
3
|
+
N_BOOT = 1000
|
|
4
|
+
MAX_GROUPS = 30
|
|
5
|
+
UPSET_TOP_N = 30
|
|
6
|
+
MAX_SETS = 20
|
|
7
|
+
MAX_MODELS = 8
|
|
8
|
+
HEATMAP_ANNOT_LIMIT = 30
|
|
9
|
+
URL_TIMEOUT_S = 30
|
|
10
|
+
PERF_WARN_ROWS = 1_000_000
|
|
11
|
+
DPI_DEFAULT = 150
|
|
12
|
+
DPI_SAVE = 300
|
|
13
|
+
DPI_MIN, DPI_MAX = 72, 1200
|
|
14
|
+
N_BOOT_MIN, N_BOOT_MAX = 100, 10_000
|
lazy_visual/export.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ._errors import err_export_format, err_no_write_permission
|
|
8
|
+
|
|
9
|
+
VALID_FMTS = {"png", "svg", "pdf"}
|
|
10
|
+
|
|
11
|
+
def _resolve_targets(path, fmt):
|
|
12
|
+
items = list(path) if isinstance(path, (list, tuple)) else [path]
|
|
13
|
+
out = []
|
|
14
|
+
for p in items:
|
|
15
|
+
p = Path(p)
|
|
16
|
+
f = (fmt or p.suffix.lstrip(".") or "png").lower()
|
|
17
|
+
if f not in VALID_FMTS:
|
|
18
|
+
raise err_export_format(f)
|
|
19
|
+
if p.suffix.lower() != f".{f}":
|
|
20
|
+
p = p.with_suffix(f".{f}")
|
|
21
|
+
out.append((p, f))
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
def _atomic_save(fig, target, fmt, dpi):
|
|
25
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
fd, tmp = tempfile.mkstemp(suffix=f".{fmt}", dir=str(target.parent))
|
|
27
|
+
os.close(fd)
|
|
28
|
+
try:
|
|
29
|
+
extra = [a.get_legend() for a in fig.axes if a.get_legend()]
|
|
30
|
+
if extra:
|
|
31
|
+
fig.savefig(tmp, format=fmt, dpi=dpi,
|
|
32
|
+
bbox_inches="tight",
|
|
33
|
+
bbox_extra_artists=extra)
|
|
34
|
+
else:
|
|
35
|
+
fig.savefig(tmp, format=fmt, dpi=dpi, bbox_inches="tight")
|
|
36
|
+
os.replace(tmp, target)
|
|
37
|
+
except PermissionError as e:
|
|
38
|
+
try: os.unlink(tmp)
|
|
39
|
+
except OSError: pass
|
|
40
|
+
raise err_no_write_permission(str(target)) from e
|
|
41
|
+
except Exception:
|
|
42
|
+
try: os.unlink(tmp)
|
|
43
|
+
except OSError: pass
|
|
44
|
+
raise
|
|
45
|
+
|
|
46
|
+
def save_result(fig, path, *, fmt=None, dpi=None):
|
|
47
|
+
targets = _resolve_targets(path, fmt)
|
|
48
|
+
files = []
|
|
49
|
+
for target, f in targets:
|
|
50
|
+
_atomic_save(fig, target, f, dpi)
|
|
51
|
+
files.append(target)
|
|
52
|
+
return files
|
|
File without changes
|
lazy_visual/io/loader.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io as _io
|
|
4
|
+
import urllib.request
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from .. import defaults
|
|
10
|
+
from .._errors import (
|
|
11
|
+
LvPerformanceWarning,
|
|
12
|
+
err_file_not_found,
|
|
13
|
+
err_missing_dependency,
|
|
14
|
+
err_unsupported_format,
|
|
15
|
+
err_url_unreachable,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ext(path):
|
|
20
|
+
return Path(path).suffix.lower().lstrip(".")
|
|
21
|
+
|
|
22
|
+
def _read_bytes(payload, fmt, read_kwargs):
|
|
23
|
+
buf = _io.BytesIO(payload)
|
|
24
|
+
if fmt == "csv":
|
|
25
|
+
return pd.read_csv(buf, **read_kwargs)
|
|
26
|
+
if fmt in ("tsv", "tab"):
|
|
27
|
+
return pd.read_csv(buf, sep="\t", **read_kwargs)
|
|
28
|
+
if fmt in ("parquet", "pq"):
|
|
29
|
+
try:
|
|
30
|
+
return pd.read_parquet(buf, **read_kwargs)
|
|
31
|
+
except ImportError as e:
|
|
32
|
+
raise err_missing_dependency("pyarrow", "parquet") from e
|
|
33
|
+
if fmt in ("xlsx", "xls"):
|
|
34
|
+
try:
|
|
35
|
+
return pd.read_excel(buf, **read_kwargs)
|
|
36
|
+
except ImportError as e:
|
|
37
|
+
raise err_missing_dependency("openpyxl", "excel") from e
|
|
38
|
+
if fmt == "json":
|
|
39
|
+
return pd.read_json(buf, **read_kwargs)
|
|
40
|
+
raise err_unsupported_format(fmt)
|
|
41
|
+
|
|
42
|
+
def load(source, *, fmt=None, **read_kwargs):
|
|
43
|
+
if isinstance(source, pd.DataFrame):
|
|
44
|
+
return source
|
|
45
|
+
s = str(source)
|
|
46
|
+
if s.startswith(("http://", "https://")):
|
|
47
|
+
if fmt is None:
|
|
48
|
+
fmt = _ext(s.split("?")[0]) or "csv"
|
|
49
|
+
try:
|
|
50
|
+
with urllib.request.urlopen(s, timeout=defaults.URL_TIMEOUT_S) as r:
|
|
51
|
+
payload = r.read()
|
|
52
|
+
except Exception as e:
|
|
53
|
+
raise err_url_unreachable(s, str(e)) from e
|
|
54
|
+
return _read_bytes(payload, fmt, read_kwargs)
|
|
55
|
+
path = Path(s)
|
|
56
|
+
if not path.exists() or not path.is_file():
|
|
57
|
+
raise err_file_not_found(s)
|
|
58
|
+
size_mb = path.stat().st_size / (1024 * 1024)
|
|
59
|
+
if size_mb > 500:
|
|
60
|
+
import warnings
|
|
61
|
+
warnings.warn(f"[LV-W503] File is {size_mb:.0f} MB.",
|
|
62
|
+
LvPerformanceWarning, stacklevel=2)
|
|
63
|
+
fmt = fmt or _ext(s)
|
|
64
|
+
if fmt == "csv":
|
|
65
|
+
return pd.read_csv(path, **read_kwargs)
|
|
66
|
+
if fmt in ("tsv", "tab"):
|
|
67
|
+
return pd.read_csv(path, sep="\t", **read_kwargs)
|
|
68
|
+
if fmt in ("parquet", "pq"):
|
|
69
|
+
try:
|
|
70
|
+
return pd.read_parquet(path, **read_kwargs)
|
|
71
|
+
except ImportError as e:
|
|
72
|
+
raise err_missing_dependency("pyarrow", "parquet") from e
|
|
73
|
+
if fmt in ("xlsx", "xls"):
|
|
74
|
+
try:
|
|
75
|
+
return pd.read_excel(path, **read_kwargs)
|
|
76
|
+
except ImportError as e:
|
|
77
|
+
raise err_missing_dependency("openpyxl", "excel") from e
|
|
78
|
+
if fmt == "json":
|
|
79
|
+
return pd.read_json(path, **read_kwargs)
|
|
80
|
+
raise err_unsupported_format(fmt)
|