mosaickit 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.
Files changed (64) hide show
  1. mosaickit/__init__.py +103 -0
  2. mosaickit/canvas/__init__.py +7 -0
  3. mosaickit/canvas/animation.py +51 -0
  4. mosaickit/canvas/canvas.py +110 -0
  5. mosaickit/canvas/grid.py +186 -0
  6. mosaickit/canvas/layout.py +11 -0
  7. mosaickit/canvas/span.py +19 -0
  8. mosaickit/canvas_spec.py +56 -0
  9. mosaickit/colors.py +51 -0
  10. mosaickit/config.py +101 -0
  11. mosaickit/errors.py +17 -0
  12. mosaickit/interval.py +20 -0
  13. mosaickit/parameter/__init__.py +5 -0
  14. mosaickit/parameter/binding.py +55 -0
  15. mosaickit/parameter/expression.py +125 -0
  16. mosaickit/parameter/parameter.py +49 -0
  17. mosaickit/py.typed +0 -0
  18. mosaickit/rendering/__init__.py +12 -0
  19. mosaickit/rendering/cache.py +70 -0
  20. mosaickit/rendering/context.py +17 -0
  21. mosaickit/rendering/matplotlib/__init__.py +3 -0
  22. mosaickit/rendering/matplotlib/animation.py +51 -0
  23. mosaickit/rendering/matplotlib/arrows.py +55 -0
  24. mosaickit/rendering/matplotlib/artists/__init__.py +14 -0
  25. mosaickit/rendering/matplotlib/artists/fill_artist.py +21 -0
  26. mosaickit/rendering/matplotlib/artists/marker_artist.py +18 -0
  27. mosaickit/rendering/matplotlib/artists/path_artist.py +42 -0
  28. mosaickit/rendering/matplotlib/artists/text_artist.py +25 -0
  29. mosaickit/rendering/matplotlib/fonts.py +5 -0
  30. mosaickit/rendering/matplotlib/legend.py +21 -0
  31. mosaickit/rendering/matplotlib/renderer.py +145 -0
  32. mosaickit/rendering/plan.py +74 -0
  33. mosaickit/rendering/protocol.py +22 -0
  34. mosaickit/rendering/registry.py +23 -0
  35. mosaickit/scene/__init__.py +33 -0
  36. mosaickit/scene/arrow.py +22 -0
  37. mosaickit/scene/axis_spec.py +70 -0
  38. mosaickit/scene/fill.py +18 -0
  39. mosaickit/scene/group.py +15 -0
  40. mosaickit/scene/layer.py +61 -0
  41. mosaickit/scene/legend.py +17 -0
  42. mosaickit/scene/marker.py +17 -0
  43. mosaickit/scene/path.py +17 -0
  44. mosaickit/scene/scene.py +67 -0
  45. mosaickit/scene/text.py +28 -0
  46. mosaickit/styles/__init__.py +19 -0
  47. mosaickit/styles/arrow.py +14 -0
  48. mosaickit/styles/base.py +38 -0
  49. mosaickit/styles/fill.py +17 -0
  50. mosaickit/styles/legend.py +16 -0
  51. mosaickit/styles/marker.py +23 -0
  52. mosaickit/styles/stroke.py +33 -0
  53. mosaickit/styles/text.py +25 -0
  54. mosaickit/themes/__init__.py +15 -0
  55. mosaickit/themes/_walk.py +5 -0
  56. mosaickit/themes/builtins.py +26 -0
  57. mosaickit/themes/registry.py +29 -0
  58. mosaickit/themes/resolution.py +10 -0
  59. mosaickit/themes/role_pack.py +21 -0
  60. mosaickit/themes/theme.py +68 -0
  61. mosaickit-0.1.0.dist-info/METADATA +204 -0
  62. mosaickit-0.1.0.dist-info/RECORD +64 -0
  63. mosaickit-0.1.0.dist-info/WHEEL +4 -0
  64. mosaickit-0.1.0.dist-info/licenses/LICENSE +21 -0
mosaickit/__init__.py ADDED
@@ -0,0 +1,103 @@
1
+ """Domain-neutral diagram construction and rendering."""
2
+
3
+ from mosaickit.canvas import Animation, Canvas, CanvasGrid, Layout, Span
4
+ from mosaickit.canvas_spec import CanvasSpec
5
+ from mosaickit.colors import TRANSPARENT, Color
6
+ from mosaickit.config import Config, use_config
7
+ from mosaickit.errors import BindingError, ConfigurationError, MosaicKitError, RenderError
8
+ from mosaickit.interval import Interval
9
+ from mosaickit.parameter import Constant, Expression, Parameter, ParameterValues
10
+ from mosaickit.rendering import (
11
+ CacheBypassWarning,
12
+ CacheKey,
13
+ RenderCache,
14
+ Renderer,
15
+ RendererRegistry,
16
+ SaveOptions,
17
+ )
18
+ from mosaickit.scene import (
19
+ ArrowLayer,
20
+ AxisSpec,
21
+ FillLayer,
22
+ GroupLayer,
23
+ Layer,
24
+ LegendLayer,
25
+ MarkerLayer,
26
+ PathLayer,
27
+ Scene,
28
+ TextLayer,
29
+ box_frame,
30
+ build_axes,
31
+ crosshair_axes,
32
+ quadrant_axes,
33
+ )
34
+ from mosaickit.styles import (
35
+ ArrowPlacement,
36
+ ArrowStyle,
37
+ DashStyle,
38
+ Fill,
39
+ LegendStyle,
40
+ Marker,
41
+ SparseStyle,
42
+ Stroke,
43
+ TextStyle,
44
+ )
45
+ from mosaickit.themes import RolePack, StyleBundle, Theme, ThemeRegistry, expand_roles
46
+
47
+ __version__ = "0.1.0"
48
+ __all__ = [
49
+ "Animation",
50
+ "ArrowLayer",
51
+ "ArrowPlacement",
52
+ "ArrowStyle",
53
+ "AxisSpec",
54
+ "BindingError",
55
+ "CacheBypassWarning",
56
+ "CacheKey",
57
+ "Canvas",
58
+ "CanvasGrid",
59
+ "CanvasSpec",
60
+ "Color",
61
+ "Config",
62
+ "ConfigurationError",
63
+ "Constant",
64
+ "DashStyle",
65
+ "MosaicKitError",
66
+ "Expression",
67
+ "Fill",
68
+ "FillLayer",
69
+ "GroupLayer",
70
+ "Interval",
71
+ "Layer",
72
+ "Layout",
73
+ "LegendLayer",
74
+ "LegendStyle",
75
+ "Marker",
76
+ "MarkerLayer",
77
+ "Parameter",
78
+ "ParameterValues",
79
+ "PathLayer",
80
+ "RenderCache",
81
+ "RenderError",
82
+ "Renderer",
83
+ "RendererRegistry",
84
+ "RolePack",
85
+ "SaveOptions",
86
+ "Scene",
87
+ "Span",
88
+ "SparseStyle",
89
+ "Stroke",
90
+ "StyleBundle",
91
+ "TRANSPARENT",
92
+ "TextLayer",
93
+ "TextStyle",
94
+ "Theme",
95
+ "ThemeRegistry",
96
+ "__version__",
97
+ "box_frame",
98
+ "build_axes",
99
+ "crosshair_axes",
100
+ "expand_roles",
101
+ "quadrant_axes",
102
+ "use_config",
103
+ ]
@@ -0,0 +1,7 @@
1
+ from mosaickit.canvas.animation import Animation
2
+ from mosaickit.canvas.canvas import Canvas
3
+ from mosaickit.canvas.grid import CanvasGrid
4
+ from mosaickit.canvas.layout import Layout
5
+ from mosaickit.canvas.span import Span
6
+
7
+ __all__ = ["Animation", "Canvas", "CanvasGrid", "Layout", "Span"]
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from mosaickit.canvas.canvas import Canvas
9
+ from mosaickit.errors import ConfigurationError, RenderError
10
+ from mosaickit.parameter import Parameter, ParameterValues
11
+ from mosaickit.rendering import RenderCache, Renderer
12
+ from mosaickit.scene import Scene
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class Animation:
17
+ template: Canvas
18
+ parameter: Parameter
19
+ values: tuple[Any, ...]
20
+ fps: int = 30
21
+
22
+ def __post_init__(self) -> None:
23
+ if isinstance(self.fps, bool) or not isinstance(self.fps, int) or self.fps < 1:
24
+ raise ConfigurationError("Animation.fps must be a positive integer")
25
+ values = ParameterValues(self.parameter, self.values).values
26
+ if not values:
27
+ raise ConfigurationError("Animation requires at least one value")
28
+ object.__setattr__(self, "values", values)
29
+ object.__setattr__(self, "template", self.template.copy())
30
+
31
+ @staticmethod
32
+ def sweep(canvas: Canvas, values: ParameterValues, *, fps: int = 30) -> Animation:
33
+ return Animation(canvas, values.parameter, values.values, fps)
34
+
35
+ def frames(self) -> Iterator[Scene]:
36
+ for value in self.values:
37
+ yield self.template.bind(self.parameter, value).snapshot()
38
+
39
+ def save(
40
+ self,
41
+ path: str | Path,
42
+ *,
43
+ renderer: Renderer | str | None = None,
44
+ cache: RenderCache | None = None,
45
+ ) -> list[Path]:
46
+ selected = self.template._renderer(renderer)
47
+ if not hasattr(selected, "save_animation"):
48
+ raise RenderError(f"Renderer {selected.name!r} does not support animation output")
49
+ return selected.save_animation(
50
+ self, Path(path), cache if cache is not None else RenderCache()
51
+ )
@@ -0,0 +1,110 @@
1
+ """Fluent scene construction with isolated render snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from pathlib import Path
7
+ from types import MappingProxyType
8
+ from typing import Any
9
+
10
+ from mosaickit.canvas_spec import CanvasSpec
11
+ from mosaickit.config import Config, current_config
12
+ from mosaickit.errors import RenderError
13
+ from mosaickit.parameter import Parameter, bind
14
+ from mosaickit.rendering import RenderCache, Renderer, RendererRegistry, SaveOptions
15
+ from mosaickit.rendering.context import _RenderContext
16
+ from mosaickit.scene import Layer, Scene
17
+ from mosaickit.themes import StyleBundle, Theme
18
+
19
+
20
+ class Canvas:
21
+ def __init__(
22
+ self,
23
+ spec: CanvasSpec | None = None,
24
+ theme: Theme | None = None,
25
+ config: Config | None = None,
26
+ renderer: Renderer | str | None = None,
27
+ *,
28
+ role_overrides: Mapping[str, StyleBundle] | None = None,
29
+ ) -> None:
30
+ self.config = config if config is not None else current_config()
31
+ self.spec = spec if spec is not None else self.config.canvas_spec
32
+ self.theme = theme if theme is not None else self.config.theme
33
+ self.renderer = renderer if renderer is not None else self.config.renderer
34
+ self.role_overrides = MappingProxyType(dict(role_overrides or {}))
35
+ self._scene = Scene.empty()
36
+ self._bindings: tuple = ()
37
+
38
+ def add(self, layer: Layer) -> Canvas:
39
+ self._scene = self._scene.add(layer)
40
+ return self
41
+
42
+ def extend(self, layers: Iterable[Layer]) -> Canvas:
43
+ self._scene = self._scene.extend(layers)
44
+ return self
45
+
46
+ def remove(self, layer_id: str) -> Canvas:
47
+ self._scene = self._scene.remove(layer_id)
48
+ return self
49
+
50
+ def clear(self) -> Canvas:
51
+ self._scene = self._scene.clear()
52
+ return self
53
+
54
+ def snapshot(self) -> Scene:
55
+ return self._scene
56
+
57
+ def copy(self) -> Canvas:
58
+ canvas = Canvas(
59
+ self.spec, self.theme, self.config, self.renderer, role_overrides=self.role_overrides
60
+ )
61
+ canvas._scene = self._scene
62
+ canvas._bindings = self._bindings
63
+ return canvas
64
+
65
+ def bind(self, parameter: Parameter | Mapping[Parameter, Any], value: Any = None) -> Canvas:
66
+ bindings = dict(parameter) if isinstance(parameter, Mapping) else {parameter: value}
67
+ for item, bound in bindings.items():
68
+ item.evaluate({item: bound})
69
+ canvas = self.copy()
70
+ canvas._scene = bind(self._scene, bindings)
71
+ canvas._bindings = (*self._bindings, *bindings.items())
72
+ return canvas
73
+
74
+ def _context(self, cache: RenderCache | None = None) -> _RenderContext:
75
+ return _RenderContext(
76
+ self.spec,
77
+ self.theme,
78
+ self.config.role_overrides,
79
+ self.role_overrides,
80
+ cache if cache is not None else RenderCache(),
81
+ self._bindings,
82
+ )
83
+
84
+ def _renderer(self, renderer: Renderer | str | None = None) -> Renderer:
85
+ selected = self.renderer if renderer is None else renderer
86
+ return RendererRegistry().get(selected) if isinstance(selected, str) else selected
87
+
88
+ def render(
89
+ self, *, renderer: Renderer | str | None = None, cache: RenderCache | None = None
90
+ ) -> Any:
91
+ return self._renderer(renderer).render(self.snapshot(), self._context(cache))
92
+
93
+ def save(
94
+ self,
95
+ target: str | Path,
96
+ *,
97
+ renderer: Renderer | str | None = None,
98
+ cache: RenderCache | None = None,
99
+ **options: Any,
100
+ ) -> list[Path]:
101
+ target = Path(target)
102
+ if target.suffix.lower() not in {".png", ".pdf", ".svg"}:
103
+ raise RenderError(f"Unsupported output format: {target.suffix!r}")
104
+ selected = self._renderer(renderer)
105
+ result = selected.render(self.snapshot(), self._context(cache))
106
+ try:
107
+ return selected.save(result, target, SaveOptions(**options))
108
+ finally:
109
+ if hasattr(result, "close"):
110
+ result.close()
@@ -0,0 +1,186 @@
1
+ """Validated grid placement with real row and column spans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from mosaickit.canvas.canvas import Canvas
11
+ from mosaickit.canvas.layout import Layout
12
+ from mosaickit.canvas.span import Span
13
+ from mosaickit.errors import ConfigurationError, RenderError
14
+ from mosaickit.parameter import ParameterValues
15
+ from mosaickit.rendering import RenderCache, Renderer, RendererRegistry, SaveOptions
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class _Placement:
20
+ canvas: Canvas
21
+ row: int
22
+ col: int
23
+ rows: int
24
+ cols: int
25
+
26
+
27
+ class CanvasGrid:
28
+ def __init__(
29
+ self,
30
+ cells: Any,
31
+ rows: int | None = None,
32
+ cols: int | None = None,
33
+ shape: tuple[int, int] | None = None,
34
+ ) -> None:
35
+ if shape is not None:
36
+ if rows is not None or cols is not None or len(shape) != 2:
37
+ raise ConfigurationError("Use shape=(rows, cols) or rows/cols, not both")
38
+ rows, cols = shape
39
+ for name, value in (("rows", rows), ("cols", cols)):
40
+ if value is not None and (
41
+ isinstance(value, bool) or not isinstance(value, int) or value < 1
42
+ ):
43
+ raise ConfigurationError(f"CanvasGrid.{name} must be a positive integer")
44
+ cells = list(cells)
45
+ if not cells:
46
+ raise ConfigurationError("CanvasGrid requires at least one cell")
47
+ nested = isinstance(cells[0], (list, tuple))
48
+ if nested:
49
+ if not all(isinstance(row, (list, tuple)) for row in cells):
50
+ raise ConfigurationError("CanvasGrid cannot mix rows and flat cells")
51
+ matrix = [list(row) for row in cells]
52
+ rows = rows if rows is not None else len(matrix)
53
+ if len(matrix) > rows:
54
+ raise ConfigurationError(f"Grid has {len(matrix)} rows; expected at most {rows}")
55
+ cols = (
56
+ cols
57
+ if cols is not None
58
+ else max(
59
+ sum(cell.cols if isinstance(cell, Span) else 1 for cell in row)
60
+ for row in matrix
61
+ )
62
+ )
63
+ else:
64
+ if any(isinstance(cell, Span) and cell.rows != 1 for cell in cells):
65
+ raise ConfigurationError("Row spans require nested grid rows")
66
+ count = sum(cell.cols if isinstance(cell, Span) else 1 for cell in cells)
67
+ cols = (
68
+ cols
69
+ if cols is not None
70
+ else (math.ceil(count / rows) if rows else math.ceil(math.sqrt(count)))
71
+ )
72
+ rows = rows if rows is not None else math.ceil(count / cols)
73
+ if count > rows * cols:
74
+ raise ConfigurationError(
75
+ f"Grid has {count} cells; shape ({rows}, {cols}) is too small"
76
+ )
77
+ matrix = [[]]
78
+ width = 0
79
+ for cell in cells:
80
+ span_width = cell.cols if isinstance(cell, Span) else 1
81
+ if width + span_width > cols:
82
+ matrix.append([])
83
+ width = 0
84
+ matrix[-1].append(cell)
85
+ width += span_width
86
+ if not cols:
87
+ raise ConfigurationError("CanvasGrid rows cannot all be empty")
88
+ occupied: set[tuple[int, int]] = set()
89
+ placements = []
90
+ for row_index, row in enumerate(matrix):
91
+ cursor = 0
92
+ for cell_index, cell in enumerate(row):
93
+ while (row_index, cursor) in occupied:
94
+ cursor += 1
95
+ span = cell if isinstance(cell, Span) else Span(cell) if cell is not None else None
96
+ height, width = (span.rows, span.cols) if span else (1, 1)
97
+ if cursor + width > cols or row_index + height > rows:
98
+ raise ConfigurationError(
99
+ f"Grid row {row_index}, cell {cell_index}: span "
100
+ f"({height}, {width}) exceeds shape ({rows}, {cols})"
101
+ )
102
+ region = {
103
+ (r, c)
104
+ for r in range(row_index, row_index + height)
105
+ for c in range(cursor, cursor + width)
106
+ }
107
+ if region & occupied:
108
+ raise ConfigurationError(
109
+ f"Grid row {row_index}, cell {cell_index}: overlapping span"
110
+ )
111
+ occupied.update(region)
112
+ if span:
113
+ placements.append(
114
+ _Placement(span.canvas.copy(), row_index, cursor, height, width)
115
+ )
116
+ cursor += width
117
+ if nested:
118
+ for row_index in range(rows):
119
+ missing = [col for col in range(cols) if (row_index, col) not in occupied]
120
+ if missing:
121
+ raise ConfigurationError(
122
+ f"Grid row {row_index}: missing columns {missing}; use None for empty cells"
123
+ )
124
+ self.rows, self.cols = rows, cols
125
+ self.placements = tuple(placements)
126
+
127
+ @classmethod
128
+ def sweep(
129
+ cls, template: Canvas, values: ParameterValues, *, cols: int | None = None
130
+ ) -> CanvasGrid:
131
+ return cls([template.bind(values.parameter, value) for value in values.values], cols=cols)
132
+
133
+ @classmethod
134
+ def from_layout(cls, canvases: list[Canvas], layout: Layout) -> CanvasGrid:
135
+ if layout == Layout.TOP_TWO_BOTTOM_ONE:
136
+ if len(canvases) != 3:
137
+ raise ConfigurationError("TOP_TWO_BOTTOM_ONE requires 3 canvases")
138
+ return cls([[canvases[0], canvases[1]], [Span(canvases[2], cols=2)]])
139
+ if layout == Layout.TOP_ONE_BOTTOM_TWO:
140
+ if len(canvases) != 3:
141
+ raise ConfigurationError("TOP_ONE_BOTTOM_TWO requires 3 canvases")
142
+ return cls([[Span(canvases[0], cols=2)], [canvases[1], canvases[2]]])
143
+ shapes = {
144
+ Layout.SINGLE: (1, 1),
145
+ Layout.STACKED: (2, 1),
146
+ Layout.SIDE_BY_SIDE: (1, 2),
147
+ Layout.GRID_2X2: (2, 2),
148
+ Layout.GRID_3X3: (3, 3),
149
+ }
150
+ return cls(canvases, shape=shapes[layout])
151
+
152
+ def _renderer(self, renderer: Renderer | str | None) -> Renderer:
153
+ if renderer is None:
154
+ return (
155
+ self.placements[0].canvas._renderer()
156
+ if self.placements
157
+ else RendererRegistry().get("matplotlib")
158
+ )
159
+ return RendererRegistry().get(renderer) if isinstance(renderer, str) else renderer
160
+
161
+ def render(
162
+ self, *, renderer: Renderer | str | None = None, cache: RenderCache | None = None
163
+ ) -> Any:
164
+ selected = self._renderer(renderer)
165
+ if not hasattr(selected, "render_grid"):
166
+ raise RenderError(f"Renderer {selected.name!r} does not support grids")
167
+ return selected.render_grid(self, cache if cache is not None else RenderCache())
168
+
169
+ def save(
170
+ self,
171
+ target: str | Path,
172
+ *,
173
+ renderer: Renderer | str | None = None,
174
+ cache: RenderCache | None = None,
175
+ **options: Any,
176
+ ) -> list[Path]:
177
+ target = Path(target)
178
+ if target.suffix.lower() not in {".png", ".pdf", ".svg"}:
179
+ raise RenderError(f"Unsupported output format: {target.suffix!r}")
180
+ selected = self._renderer(renderer)
181
+ result = self.render(renderer=selected, cache=cache)
182
+ try:
183
+ return selected.save(result, target, SaveOptions(**options))
184
+ finally:
185
+ if hasattr(result, "close"):
186
+ result.close()
@@ -0,0 +1,11 @@
1
+ from enum import Enum
2
+
3
+
4
+ class Layout(str, Enum):
5
+ SINGLE = "single"
6
+ STACKED = "stacked"
7
+ SIDE_BY_SIDE = "side_by_side"
8
+ GRID_2X2 = "grid_2x2"
9
+ GRID_3X3 = "grid_3x3"
10
+ TOP_TWO_BOTTOM_ONE = "top_two_bottom_one"
11
+ TOP_ONE_BOTTOM_TWO = "top_one_bottom_two"
@@ -0,0 +1,19 @@
1
+ from dataclasses import dataclass
2
+
3
+ from mosaickit.canvas.canvas import Canvas
4
+ from mosaickit.errors import ConfigurationError
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class Span:
9
+ canvas: Canvas
10
+ rows: int = 1
11
+ cols: int = 1
12
+
13
+ def __post_init__(self) -> None:
14
+ if not isinstance(self.canvas, Canvas):
15
+ raise ConfigurationError("Span.canvas must be a Canvas")
16
+ for name in ("rows", "cols"):
17
+ value = getattr(self, name)
18
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
19
+ raise ConfigurationError(f"Span.{name} must be a positive integer")
@@ -0,0 +1,56 @@
1
+ """Physical and coordinate dimensions of a diagram."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, replace
7
+ from typing import Any
8
+
9
+ from mosaickit.errors import ConfigurationError
10
+ from mosaickit.interval import Interval
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class CanvasSpec:
15
+ x_range: tuple[float, float] = (0.0, 10.0)
16
+ y_range: tuple[float, float] = (0.0, 10.0)
17
+ width: float = 6.0
18
+ height: float = 6.0
19
+ dpi: int = 300
20
+ x_label: str = "X"
21
+ y_label: str = "Y"
22
+ title: str | None = None
23
+
24
+ def __post_init__(self) -> None:
25
+ for name in ("x_range", "y_range"):
26
+ value = getattr(self, name)
27
+ try:
28
+ interval = Interval(*value)
29
+ except (TypeError, ValueError) as exc:
30
+ raise ConfigurationError(f"CanvasSpec.{name}: {exc}") from exc
31
+ object.__setattr__(self, name, (interval.lo, interval.hi))
32
+ for name in ("width", "height"):
33
+ value = getattr(self, name)
34
+ if not math.isfinite(value) or value <= 0:
35
+ raise ConfigurationError(f"CanvasSpec.{name} must be positive and finite")
36
+ if isinstance(self.dpi, bool) or not isinstance(self.dpi, int) or not 1 <= self.dpi <= 1200:
37
+ raise ConfigurationError("CanvasSpec.dpi must be an integer in [1, 1200]")
38
+
39
+ @property
40
+ def x_min(self) -> float:
41
+ return self.x_range[0]
42
+
43
+ @property
44
+ def x_max(self) -> float:
45
+ return self.x_range[1]
46
+
47
+ @property
48
+ def y_min(self) -> float:
49
+ return self.y_range[0]
50
+
51
+ @property
52
+ def y_max(self) -> float:
53
+ return self.y_range[1]
54
+
55
+ def replace(self, **changes: Any) -> CanvasSpec:
56
+ return replace(self, **changes)
mosaickit/colors.py ADDED
@@ -0,0 +1,51 @@
1
+ """Immutable, backend-independent RGBA colors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import string
7
+ from dataclasses import dataclass
8
+
9
+ from mosaickit.errors import ConfigurationError
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Color:
14
+ red: float
15
+ green: float
16
+ blue: float
17
+ alpha: float = 1.0
18
+
19
+ def __post_init__(self) -> None:
20
+ if not all(math.isfinite(v) and 0 <= v <= 1 for v in self.channels):
21
+ raise ConfigurationError("Color channels must be finite and in [0, 1]")
22
+
23
+ @property
24
+ def channels(self) -> tuple[float, float, float, float]:
25
+ return self.red, self.green, self.blue, self.alpha
26
+
27
+ @classmethod
28
+ def from_channels(cls, red: float, green: float, blue: float, alpha: float = 1) -> Color:
29
+ return cls(red, green, blue, alpha)
30
+
31
+ @classmethod
32
+ def from_hex(cls, value: str) -> Color:
33
+ if not isinstance(value, str) or not value.startswith("#"):
34
+ raise ConfigurationError("Color must be #RGB, #RRGGBB, or #RRGGBBAA")
35
+ digits = value[1:]
36
+ if len(digits) == 3:
37
+ digits = "".join(c * 2 for c in digits)
38
+ if len(digits) not in (6, 8) or any(char not in string.hexdigits for char in digits):
39
+ raise ConfigurationError(f"Invalid hex color: {value!r}")
40
+ try:
41
+ channels = [int(digits[i : i + 2], 16) / 255 for i in range(0, len(digits), 2)]
42
+ except ValueError as exc:
43
+ raise ConfigurationError(f"Invalid hex color: {value!r}") from exc
44
+ return cls(*channels)
45
+
46
+ def to_hex(self, *, include_alpha: bool | None = None) -> str:
47
+ alpha = self.alpha != 1 if include_alpha is None else include_alpha
48
+ return "#" + "".join(f"{round(v * 255):02X}" for v in self.channels[: 4 if alpha else 3])
49
+
50
+
51
+ TRANSPARENT = Color(0, 0, 0, 0)