hedgehogs 0.0.1__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.
hedgehogs/__init__.py ADDED
@@ -0,0 +1,66 @@
1
+ """Hedgehogs — publication-quality scientific visualisation and reporting utilities."""
2
+
3
+ from .core.version import __version__, __date__
4
+ from .core.constants import EPS
5
+ from .core.palette import (
6
+ Palette,
7
+ get_palette,
8
+ build_color_map,
9
+ build_style_map,
10
+ register_palette,
11
+ save_palette,
12
+ load_palette,
13
+ )
14
+ from .plots.style import (
15
+ set_style,
16
+ reset_style,
17
+ journal_preset,
18
+ set_journal_style,
19
+ figsize,
20
+ subplots,
21
+ )
22
+ from .plots.helpers import (
23
+ finalize,
24
+ style_colorbar,
25
+ annotate_panels,
26
+ enable_minor_ticks,
27
+ apply_grid,
28
+ )
29
+ from . import plots
30
+ from .tables import Table
31
+ from .terminal import Progress
32
+ from .terminal import echo, rule
33
+ from .core.info import info
34
+
35
+ __all__ = [
36
+ "EPS",
37
+ "Palette",
38
+ "get_palette",
39
+ "build_color_map",
40
+ "build_style_map",
41
+ "register_palette",
42
+ "save_palette",
43
+ "load_palette",
44
+ "set_style",
45
+ "reset_style",
46
+ "journal_preset",
47
+ "set_journal_style",
48
+ "figsize",
49
+ "subplots",
50
+ "finalize",
51
+ "style_colorbar",
52
+ "annotate_panels",
53
+ "enable_minor_ticks",
54
+ "apply_grid",
55
+ "plots",
56
+ "Table",
57
+ "Progress",
58
+ "echo",
59
+ "rule",
60
+ "info",
61
+ ]
62
+
63
+
64
+ def __dir__() -> list[str]:
65
+ """List the supported public interface and package metadata."""
66
+ return sorted([*__all__, "__version__", "__date__"])
@@ -0,0 +1 @@
1
+ """Shared metadata, palette and environment support."""
@@ -0,0 +1,6 @@
1
+ """Public numerical constants."""
2
+
3
+ import sys
4
+
5
+
6
+ EPS = sys.float_info.epsilon
@@ -0,0 +1,8 @@
1
+ """Runtime environment helpers."""
2
+
3
+ import sys
4
+
5
+
6
+ def _is_jupyter():
7
+ """Detect whether code is running inside a Jupyter / IPython kernel."""
8
+ return "ipykernel" in sys.modules
hedgehogs/core/info.py ADDED
@@ -0,0 +1,15 @@
1
+ """Package information utilities."""
2
+
3
+ import matplotlib.pyplot as plt
4
+
5
+ from .version import __version__, __date__
6
+
7
+
8
+ def info():
9
+ """Print version and dependency info."""
10
+ text = (
11
+ f"Hedgehogs v{__version__} ({__date__})\n"
12
+ f" matplotlib {plt.matplotlib.__version__}"
13
+ )
14
+ print(text)
15
+ return text
@@ -0,0 +1,263 @@
1
+ """Colour palettes and colour mapping utilities."""
2
+
3
+ import json
4
+ from collections.abc import Iterable, Mapping
5
+ from os import PathLike
6
+ from pathlib import Path
7
+ from numbers import Integral
8
+ from typing import Any
9
+ import warnings
10
+
11
+ from matplotlib.colors import is_color_like
12
+
13
+
14
+ _PALETTES = {
15
+ "tableau10": {
16
+ "colors": [
17
+ "#4e79a7", "#f28e2b", "#e15759", "#76b7b2", "#59a14f",
18
+ "#edc948", "#b07aa1", "#ff9da7", "#9c755f", "#bab0ac",
19
+ ],
20
+ "names": [
21
+ "blue", "orange", "red", "teal", "green",
22
+ "yellow", "purple", "pink", "brown", "grey",
23
+ ],
24
+ },
25
+ "okabe-ito": {
26
+ "colors": [
27
+ "#0072B2", "#E69F00", "#D55E00", "#009E73",
28
+ "#56B4E9", "#F0E442", "#CC79A7", "#000000",
29
+ ],
30
+ "names": [
31
+ "blue", "orange", "red", "green",
32
+ "cyan", "yellow", "purple", "black",
33
+ ],
34
+ },
35
+ "paul-tol-vibrant": {
36
+ "colors": [
37
+ "#0077BB", "#33BBEE", "#009988", "#EE7733",
38
+ "#CC3311", "#EE3377", "#BBBBBB",
39
+ ],
40
+ "names": [
41
+ "blue", "cyan", "teal", "orange",
42
+ "red", "magenta", "grey",
43
+ ],
44
+ },
45
+ "paul-tol-bright": {
46
+ "colors": [
47
+ "#4477AA", "#EE6677", "#228833", "#CCBB44",
48
+ "#66CCEE", "#AA3377", "#BBBBBB",
49
+ ],
50
+ "names": [
51
+ "blue", "red", "green", "yellow",
52
+ "cyan", "purple", "grey",
53
+ ],
54
+ },
55
+ "paul-tol-muted": {
56
+ "colors": [
57
+ "#332288", "#88CCEE", "#44AA99", "#117733",
58
+ "#999933", "#DDCC77", "#CC6677", "#882255", "#AA4499",
59
+ ],
60
+ "names": [
61
+ "indigo", "cyan", "teal", "green",
62
+ "olive", "sand", "rose", "wine", "purple",
63
+ ],
64
+ },
65
+ "ibm": {
66
+ "colors": ["#648FFF", "#785EF0", "#DC267F", "#FE6100", "#FFB000"],
67
+ "names": ["blue", "indigo", "magenta", "orange", "gold"],
68
+ },
69
+ }
70
+
71
+
72
+ class Palette:
73
+ """Colour palette with both name-based and index-based access."""
74
+
75
+ def __init__(self, names: Iterable[str], colors: Iterable[str]) -> None:
76
+ self._names = list(names)
77
+ self._colors = list(colors)
78
+ self._map = dict(zip(self._names, self._colors))
79
+
80
+ def __repr__(self) -> str:
81
+ pairs = ", ".join(f"{n}={c}" for n, c in zip(self._names, self._colors))
82
+ return f"Palette({pairs})"
83
+
84
+ def __getitem__(self, key: int | slice | str):
85
+ if isinstance(key, Integral) and not isinstance(key, bool):
86
+ index = int(key)
87
+ return self._colors[index % len(self._colors)]
88
+ if isinstance(key, slice):
89
+ return self._colors[key]
90
+ if isinstance(key, str):
91
+ return self._map[key.lower()]
92
+ raise KeyError(key)
93
+
94
+ def __contains__(self, key: object) -> bool:
95
+ if isinstance(key, str):
96
+ return key.lower() in self._map
97
+ if isinstance(key, Integral) and not isinstance(key, bool):
98
+ index = int(key)
99
+ return -len(self._colors) <= index < len(self._colors)
100
+ return False
101
+
102
+ def __iter__(self):
103
+ return iter(self._colors)
104
+
105
+ def __len__(self) -> int:
106
+ return len(self._colors)
107
+
108
+ def keys(self):
109
+ return self._map.keys()
110
+
111
+ def values(self):
112
+ return self._map.values()
113
+
114
+ def items(self):
115
+ return self._map.items()
116
+
117
+
118
+ def _resolve_palette(name: object) -> str:
119
+ p = str(name).lower()
120
+ if p in _PALETTES: return p
121
+ if "vibrant" in p: return "paul-tol-vibrant"
122
+ if "bright" in p: return "paul-tol-bright"
123
+ if "muted" in p: return "paul-tol-muted"
124
+ if "tol" in p or "paul" in p: return "paul-tol-vibrant"
125
+ if "tab" in p or "tableau" in p: return "tableau10"
126
+ if "ibm" in p: return "ibm"
127
+ if "okabe" in p or "ito" in p: return "okabe-ito"
128
+ warnings.warn(
129
+ f"Unrecognised palette name {name!r}; falling back to 'okabe-ito'. "
130
+ f"Known palettes: {sorted(_PALETTES)}",
131
+ UserWarning,
132
+ stacklevel=3,
133
+ )
134
+ return "okabe-ito"
135
+
136
+
137
+ def get_palette(palette: str | Palette = "okabe-ito", n: int | None = None) -> Palette:
138
+ """Return a Palette object. Supports fuzzy name matching."""
139
+ if isinstance(palette, Palette):
140
+ if n is None:
141
+ return palette
142
+ source = palette
143
+ if not isinstance(n, int) or isinstance(n, bool) or n < 1:
144
+ raise ValueError("n must be a positive integer or None.")
145
+ return Palette(list(source.keys())[:n], list(source.values())[:n])
146
+
147
+ if n is not None and (
148
+ not isinstance(n, int)
149
+ or isinstance(n, bool)
150
+ or n < 1
151
+ ):
152
+ raise ValueError("n must be a positive integer or None.")
153
+
154
+ key = _resolve_palette(palette)
155
+ entry = _PALETTES[key]
156
+ limit = len(entry["colors"]) if n is None else min(n, len(entry["colors"]))
157
+ return Palette(entry["names"][:limit], entry["colors"][:limit])
158
+
159
+
160
+ def build_color_map(
161
+ labels: Iterable[Any],
162
+ palette: str | Palette = "okabe-ito",
163
+ ) -> dict[Any, str]:
164
+ """Map unique labels to palette colours."""
165
+ pal = get_palette(palette=palette)
166
+ unique_labels = dict.fromkeys(labels)
167
+ return {
168
+ label: pal[index]
169
+ for index, label in enumerate(unique_labels)
170
+ }
171
+
172
+
173
+ def build_style_map(
174
+ labels: Iterable[Any],
175
+ palette: str | Palette = "okabe-ito",
176
+ ) -> dict[Any, dict[str, str]]:
177
+ """Map labels to distinct colour, line-style, and marker combinations."""
178
+ colours = get_palette(palette=palette)
179
+ line_styles = ("-", "--", "-.", ":")
180
+ markers = ("o", "s", "^", "D", "v", "P", "X")
181
+ return {
182
+ label: {
183
+ "color": colours[index],
184
+ "linestyle": line_styles[index % len(line_styles)],
185
+ "marker": markers[index % len(markers)],
186
+ }
187
+ for index, label in enumerate(dict.fromkeys(labels))
188
+ }
189
+
190
+
191
+ def register_palette(
192
+ name: str,
193
+ colors: Mapping[str, str] | Iterable[str],
194
+ names: Iterable[str] | None = None,
195
+ *,
196
+ overwrite: bool = False,
197
+ ) -> Palette:
198
+ """Register a custom palette for use by :func:`get_palette`."""
199
+ key = str(name).strip().lower()
200
+ if not key:
201
+ raise ValueError("Palette name must not be empty.")
202
+ if key in _PALETTES and not overwrite:
203
+ raise ValueError(f"Palette {key!r} is already registered.")
204
+
205
+ if isinstance(colors, Mapping):
206
+ if names is not None:
207
+ raise ValueError("names must be omitted when colors is a mapping.")
208
+ palette_names = [str(value) for value in colors]
209
+ palette_colors = list(colors.values())
210
+ else:
211
+ palette_colors = list(colors)
212
+ palette_names = (
213
+ [f"color-{index + 1}" for index in range(len(palette_colors))]
214
+ if names is None
215
+ else [str(value) for value in names]
216
+ )
217
+
218
+ if not palette_colors:
219
+ raise ValueError("A palette must contain at least one colour.")
220
+ if len(palette_names) != len(palette_colors):
221
+ raise ValueError("Palette names and colors must have the same length.")
222
+ if len(set(palette_names)) != len(palette_names):
223
+ raise ValueError("Palette colour names must be unique.")
224
+
225
+ invalid = [value for value in palette_colors if not is_color_like(value)]
226
+ if invalid:
227
+ raise ValueError(f"Invalid Matplotlib colour values: {invalid!r}.")
228
+
229
+ _PALETTES[key] = {
230
+ "names": palette_names,
231
+ "colors": [str(value) for value in palette_colors],
232
+ }
233
+ return get_palette(key)
234
+
235
+
236
+ def save_palette(name: str, path: str | PathLike[str]) -> Path:
237
+ """Save a registered palette as portable JSON."""
238
+ key = _resolve_palette(name)
239
+ destination = Path(path).expanduser()
240
+ destination.parent.mkdir(parents=True, exist_ok=True)
241
+ destination.write_text(
242
+ json.dumps({"name": key, **_PALETTES[key]}, indent=2) + "\n",
243
+ encoding="utf-8",
244
+ )
245
+ return destination
246
+
247
+
248
+ def load_palette(
249
+ path: str | PathLike[str],
250
+ *,
251
+ name: str | None = None,
252
+ overwrite: bool = False,
253
+ ) -> Palette:
254
+ """Load and register a palette saved by :func:`save_palette`."""
255
+ source = Path(path).expanduser()
256
+ payload = json.loads(source.read_text(encoding="utf-8"))
257
+ palette_name = name or payload.get("name") or source.stem
258
+ return register_palette(
259
+ palette_name,
260
+ payload["colors"],
261
+ names=payload.get("names"),
262
+ overwrite=overwrite,
263
+ )
@@ -0,0 +1,4 @@
1
+ """Hedgehogs version metadata."""
2
+
3
+ __version__ = "0.0.1"
4
+ __date__ = "08 Oct 2026"
@@ -0,0 +1,13 @@
1
+ """Specialised Matplotlib plots with Hedgehogs presentation defaults."""
2
+
3
+ from .tree import tree
4
+ from .pairplot import pairplot
5
+ from .presentation import show
6
+ from .output import save
7
+
8
+ __all__ = ["tree", "pairplot", "show", "save"]
9
+
10
+
11
+ def __dir__() -> list[str]:
12
+ """List the public plotting functions rather than implementation modules."""
13
+ return sorted(__all__)
@@ -0,0 +1,129 @@
1
+ """Matplotlib plotting helpers."""
2
+
3
+ import math
4
+ import matplotlib.pyplot as plt
5
+ import matplotlib.ticker as mticker
6
+
7
+
8
+ class _SkipZeroFormatter(mticker.ScalarFormatter):
9
+ def __call__(self, x, pos=None):
10
+ return "" if math.isclose(x, 0.0, rel_tol=1e-5, abs_tol=1e-8) else super().__call__(x, pos)
11
+
12
+
13
+ def _to_axes_list(ax):
14
+ if ax is None:
15
+ return [plt.gca()]
16
+ if hasattr(ax, "ravel") and callable(ax.ravel):
17
+ return ax.ravel().tolist()
18
+ if hasattr(ax, "__iter__") and not hasattr(ax, "plot"):
19
+ return list(ax)
20
+ return [ax]
21
+
22
+
23
+ def _fix_origin_overlap(ax=None, keep="x"):
24
+ if ax is None:
25
+ ax = plt.gca()
26
+ xlim, ylim = ax.get_xlim(), ax.get_ylim()
27
+ if not (xlim[0] <= 0 <= xlim[1] and ylim[0] <= 0 <= ylim[1]):
28
+ return
29
+ target = ax.yaxis if keep == "x" else ax.xaxis
30
+ current = target.get_major_formatter()
31
+ if isinstance(current, (mticker.ScalarFormatter, _SkipZeroFormatter)):
32
+ fmt = _SkipZeroFormatter()
33
+ try:
34
+ fmt.set_useOffset(current.get_useOffset())
35
+ fmt.set_useMathText(current.get_useMathText())
36
+ except AttributeError:
37
+ pass
38
+ target.set_major_formatter(fmt)
39
+
40
+
41
+ def _apply_grid(ax=None, axis="both"):
42
+ if ax is None:
43
+ ax = plt.gca()
44
+ ax.grid(True, which="major", axis=axis,
45
+ linestyle=":", color="black", alpha=0.7)
46
+ ax.set_axisbelow(True)
47
+
48
+
49
+ def _fix_legend_frame(ax=None):
50
+ if ax is None:
51
+ ax = plt.gca()
52
+ leg = ax.get_legend()
53
+ if leg is not None:
54
+ frame = leg.get_frame()
55
+ frame.set_linewidth(ax.spines["left"].get_linewidth())
56
+ frame.set_edgecolor(plt.rcParams.get("legend.edgecolor", "black"))
57
+
58
+
59
+ def _enable_minor_ticks(ax=None, x=True, y=True):
60
+ if ax is None:
61
+ ax = plt.gca()
62
+ if x:
63
+ ax.xaxis.set_minor_locator(mticker.AutoMinorLocator())
64
+ if y:
65
+ ax.yaxis.set_minor_locator(mticker.AutoMinorLocator())
66
+ major_tick = ax.xaxis.get_major_ticks()[0].tick1line
67
+ lw = major_tick.get_markeredgewidth() * 0.6
68
+ sz = major_tick.get_markersize() * 0.5
69
+ ax.tick_params(which="minor", direction="out",
70
+ length=sz, width=lw, top=True, right=True)
71
+
72
+
73
+ def _style_colorbar(cb, label=None):
74
+ lw = cb.ax.spines["left"].get_linewidth()
75
+ cb.outline.set_linewidth(lw)
76
+ cb.outline.set_edgecolor("black")
77
+ cb.ax.tick_params(
78
+ direction="out",
79
+ width=cb.ax.yaxis.get_major_ticks()[0].tick1line.get_markeredgewidth(),
80
+ length=cb.ax.yaxis.get_major_ticks()[0].tick1line.get_markersize() * 0.7,
81
+ )
82
+ if label is not None:
83
+ cb.set_label(label)
84
+
85
+
86
+ def _annotate_panels(axes, labels=None, loc="upper left",
87
+ offset=None, fontsize=None, fontweight="bold"):
88
+ axes = _to_axes_list(axes)
89
+ if labels is None:
90
+ labels = [f"({chr(97 + i)})" for i in range(len(axes))]
91
+ if fontsize is None:
92
+ fontsize = plt.rcParams["font.size"]
93
+ if offset is not None:
94
+ ox, oy = offset
95
+ elif "right" in loc:
96
+ ox, oy = 0.94, 0.93
97
+ else:
98
+ ox, oy = 0.06, 0.93
99
+ ha = "right" if "right" in loc else "left"
100
+ for a, lbl in zip(axes, labels):
101
+ a.text(ox, oy, lbl, transform=a.transAxes,
102
+ fontsize=fontsize, fontweight=fontweight, va="top", ha=ha)
103
+
104
+
105
+ def _finalize(ax=None, fix_origin=True, keep="x",
106
+ grid=False, minor_ticks=False):
107
+ for a in _to_axes_list(ax):
108
+ if fix_origin:
109
+ _fix_origin_overlap(a, keep=keep)
110
+ _fix_legend_frame(a)
111
+ if grid:
112
+ _apply_grid(a)
113
+ if minor_ticks:
114
+ _enable_minor_ticks(a)
115
+
116
+
117
+ finalize = _finalize
118
+
119
+
120
+ style_colorbar = _style_colorbar
121
+
122
+
123
+ annotate_panels = _annotate_panels
124
+
125
+
126
+ enable_minor_ticks = _enable_minor_ticks
127
+
128
+
129
+ apply_grid = _apply_grid
@@ -0,0 +1,75 @@
1
+ """Finish and save Matplotlib figures."""
2
+
3
+ from collections.abc import Iterable, Mapping
4
+ from os import PathLike
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import matplotlib.pyplot as plt
9
+ from matplotlib.figure import Figure
10
+
11
+
12
+ def save(
13
+ filename: str | PathLike[str],
14
+ *,
15
+ fig: Figure | None = None,
16
+ formats: Iterable[str] | None = None,
17
+ dpi: float = 300,
18
+ transparent: bool = False,
19
+ bbox_inches: str | None = "tight",
20
+ metadata: Mapping[str, Any] | None = None,
21
+ close: bool = False,
22
+ **savefig_kw: Any,
23
+ ) -> tuple[Path, ...]:
24
+ """Finish and save the current pyplot figure or an explicit ``fig``.
25
+
26
+ A filename extension selects one format. Without an extension, ``formats``
27
+ selects outputs and defaults to PDF and PNG. Return a tuple of written Paths.
28
+ An extension combined with formats raises ValueError; an absent current
29
+ figure also raises ValueError without creating an empty figure.
30
+
31
+ Examples: ``save("figure.pdf")`` or ``save("figure", fig=fig,
32
+ formats=("pdf", "png"))``. Explicit Matplotlib save options remain available.
33
+ """
34
+ base = Path(filename).expanduser()
35
+ requested: tuple[str, ...]
36
+ destinations: tuple[Path, ...]
37
+ if "format" in savefig_kw:
38
+ raise ValueError("Select the format with the filename extension or formats, not format.")
39
+ if base.suffix:
40
+ if formats is not None:
41
+ raise ValueError("A filename extension and formats cannot be supplied together.")
42
+ requested = (base.suffix[1:].lower(),)
43
+ destinations = (base,)
44
+ else:
45
+ selected = ("pdf", "png") if formats is None else formats
46
+ if isinstance(selected, str):
47
+ selected = (selected,)
48
+ requested = tuple(str(value).lower().lstrip(".") for value in selected)
49
+ if not requested or any(not value for value in requested):
50
+ raise ValueError("formats must contain at least one file format.")
51
+ destinations = tuple(base.with_suffix(f".{suffix}") for suffix in requested)
52
+ if fig is None:
53
+ if not plt.get_fignums():
54
+ raise ValueError("No current figure to save; create one or supply fig.")
55
+ fig = plt.gcf()
56
+ from .presentation import prepare
57
+ prepare(fig)
58
+ base.parent.mkdir(parents=True, exist_ok=True)
59
+
60
+ options = {
61
+ "dpi": dpi,
62
+ "transparent": transparent,
63
+ "bbox_inches": bbox_inches,
64
+ "metadata": metadata,
65
+ **savefig_kw,
66
+ }
67
+
68
+ with plt.rc_context({"pdf.fonttype": 42, "ps.fonttype": 42, "savefig.bbox": None}):
69
+ for destination, file_format in zip(destinations, requested):
70
+ fig.savefig(destination, format=file_format, **options)
71
+
72
+ if close:
73
+ plt.close(fig)
74
+
75
+ return destinations