meowplotlib 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.
@@ -0,0 +1,9 @@
1
+ """meowplotlib: whimsical cat decorations for matplotlib figures."""
2
+
3
+ from meowplotlib.api import config, disable, enable, set_density, set_seed, set_style
4
+ from meowplotlib.render.hook import install as _install
5
+
6
+ __all__ = ["config", "disable", "enable", "set_density", "set_seed", "set_style"]
7
+ __version__ = "0.1.0"
8
+
9
+ _install()
meowplotlib/api.py ADDED
@@ -0,0 +1,74 @@
1
+ """User-facing public API: enable/disable/set_style/set_density/set_seed/config."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+
8
+ from meowplotlib.assets.registry import resolve_style_names
9
+ from meowplotlib.core.config import get_config
10
+
11
+
12
+ def enable() -> None:
13
+ """Re-enable cat decoration for the current session."""
14
+ get_config().enabled = True
15
+
16
+
17
+ def disable() -> None:
18
+ """Disable cat decoration for the current session."""
19
+ get_config().enabled = False
20
+
21
+
22
+ _VALID_DENSITIES = ("sparse", "normal", "chaotic")
23
+
24
+
25
+ def _validate_style(style: object) -> None:
26
+ if not isinstance(style, str | list):
27
+ raise TypeError(f"style must be a str or list[str], got {type(style).__name__}")
28
+ resolve_style_names(style) # fail-fast; "mix" always validates, resolved fresh at render time
29
+
30
+
31
+ def _validate_density(density: object) -> None:
32
+ if density not in _VALID_DENSITIES:
33
+ raise ValueError(f"Unknown density: {density!r}. Valid options: {_VALID_DENSITIES}")
34
+
35
+
36
+ def set_style(style: str | list[str]) -> None:
37
+ """Select a single style, a mix of styles, or "mix" for all styles.
38
+
39
+ Validates eagerly: an unregistered style name raises immediately, rather than
40
+ surfacing as a silent absence of cats at the next render.
41
+ """
42
+ _validate_style(style)
43
+ get_config().style = style
44
+
45
+
46
+ def set_density(density: str) -> None:
47
+ """Set cat density: "sparse", "normal", or "chaotic"."""
48
+ _validate_density(density)
49
+ get_config().density = density
50
+
51
+
52
+ def set_seed(seed: int | None) -> None:
53
+ """Set the RNG seed for reproducible placements, or None for randomized."""
54
+ get_config().seed = seed
55
+
56
+
57
+ @contextmanager
58
+ def config(**overrides: object) -> Iterator[None]:
59
+ """Temporarily override config values within a `with` block.
60
+
61
+ Applies the same validation as `set_style`/`set_density` to any overridden values.
62
+ """
63
+ if "style" in overrides:
64
+ _validate_style(overrides["style"])
65
+ if "density" in overrides:
66
+ _validate_density(overrides["density"])
67
+
68
+ cfg = get_config()
69
+ saved = cfg.snapshot()
70
+ try:
71
+ cfg.update(**overrides)
72
+ yield
73
+ finally:
74
+ cfg.restore(saved)
File without changes
@@ -0,0 +1,46 @@
1
+ """Minimal parser for styles.toml on Python < 3.11 (no stdlib tomllib).
2
+
3
+ NOT a general-purpose TOML parser. Covers exactly the subset styles.toml uses: flat
4
+ `[styles.<name>]` sections, each with a quoted `display_name` string and an optional numeric
5
+ `scale`. See specs/003-style-system/research.md for why this exists instead of a dependency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ _SECTION_RE = re.compile(r"^\[styles\.([A-Za-z0-9_-]+)\]\s*$")
13
+ _STRING_KV_RE = re.compile(r'^display_name\s*=\s*"([^"]*)"\s*$')
14
+ _FLOAT_KV_RE = re.compile(r"^scale\s*=\s*([0-9.]+)\s*$")
15
+
16
+
17
+ def loads(text: str) -> dict[str, object]:
18
+ """Parse styles.toml's specific shape into {"styles": {name: {...}}}."""
19
+ styles: dict[str, dict[str, object]] = {}
20
+ current: str | None = None
21
+
22
+ for raw_line in text.splitlines():
23
+ line = raw_line.split("#", 1)[0].strip()
24
+ if not line:
25
+ continue
26
+
27
+ section_match = _SECTION_RE.match(line)
28
+ if section_match:
29
+ current = section_match.group(1)
30
+ styles[current] = {}
31
+ continue
32
+
33
+ if current is None:
34
+ continue
35
+
36
+ string_match = _STRING_KV_RE.match(line)
37
+ if string_match:
38
+ styles[current]["display_name"] = string_match.group(1)
39
+ continue
40
+
41
+ float_match = _FLOAT_KV_RE.match(line)
42
+ if float_match:
43
+ styles[current]["scale"] = float(float_match.group(1))
44
+ continue
45
+
46
+ return {"styles": styles}
@@ -0,0 +1,103 @@
1
+ """Discovers styles from the assets dir + manifest. Zero-code-change style extension.
2
+
3
+ Re-scans the filesystem on every query rather than caching (see research.md) — style counts are
4
+ tiny, and this keeps "drop a file, it just works" true even mid-process.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.resources
10
+ import sys
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ if sys.version_info >= (3, 11):
15
+ import tomllib
16
+ else:
17
+ from meowplotlib.assets import _toml_fallback as tomllib # type: ignore[no-redef]
18
+
19
+ IMAGE_SUFFIXES = (".png",)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class StyleInfo:
24
+ """A discovered, valid style: has both an image directory and a manifest entry."""
25
+
26
+ name: str
27
+ display_name: str
28
+ scale: float = 1.0
29
+ image_paths: list[Path] = field(default_factory=list)
30
+
31
+
32
+ def _parse_manifest(manifest_path: Path) -> dict[str, dict[str, object]]:
33
+ if not manifest_path.is_file():
34
+ return {}
35
+ if sys.version_info >= (3, 11):
36
+ with manifest_path.open("rb") as f:
37
+ data = tomllib.load(f)
38
+ else:
39
+ data = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
40
+ styles = data.get("styles", {})
41
+ return styles if isinstance(styles, dict) else {}
42
+
43
+
44
+ def discover_styles(images_dir: Path, manifest_path: Path) -> dict[str, StyleInfo]:
45
+ """Scan `images_dir` + parse `manifest_path`; return styles with both present."""
46
+ manifest_styles = _parse_manifest(manifest_path)
47
+ result: dict[str, StyleInfo] = {}
48
+
49
+ if not images_dir.is_dir():
50
+ return result
51
+
52
+ for style_dir in sorted(images_dir.iterdir()):
53
+ if not style_dir.is_dir():
54
+ continue
55
+ name = style_dir.name
56
+ manifest_entry = manifest_styles.get(name)
57
+ if not isinstance(manifest_entry, dict):
58
+ continue
59
+
60
+ image_paths = sorted(p for p in style_dir.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES)
61
+ if not image_paths:
62
+ continue
63
+
64
+ display_name = manifest_entry.get("display_name")
65
+ if not isinstance(display_name, str):
66
+ continue
67
+ scale = manifest_entry.get("scale", 1.0)
68
+ if not isinstance(scale, int | float):
69
+ scale = 1.0
70
+
71
+ result[name] = StyleInfo(
72
+ name=name,
73
+ display_name=display_name,
74
+ scale=float(scale),
75
+ image_paths=image_paths,
76
+ )
77
+
78
+ return result
79
+
80
+
81
+ def available_styles() -> dict[str, StyleInfo]:
82
+ """Discover styles from the package's own bundled assets tree."""
83
+ images_root = importlib.resources.files("meowplotlib.assets") / "images"
84
+ manifest = importlib.resources.files("meowplotlib.assets") / "styles.toml"
85
+ with (
86
+ importlib.resources.as_file(images_root) as images_dir,
87
+ importlib.resources.as_file(manifest) as manifest_path,
88
+ ):
89
+ return discover_styles(images_dir, manifest_path)
90
+
91
+
92
+ def resolve_style_names(selection: str | list[str]) -> list[str]:
93
+ """Resolve set_style()'s raw value to a flat, validated list of style names."""
94
+ styles = available_styles()
95
+
96
+ if selection == "mix":
97
+ return list(styles)
98
+
99
+ names = [selection] if isinstance(selection, str) else selection
100
+ for name in names:
101
+ if name not in styles:
102
+ raise ValueError(f"Unknown style: {name!r}. Available styles: {sorted(styles)}")
103
+ return list(names)
@@ -0,0 +1,14 @@
1
+ # Style manifest. Adding a style = a new [styles.<name>] section + a directory
2
+ # of PNGs under assets/images/<name>/. No code changes required.
3
+
4
+ [styles.classic]
5
+ display_name = "Classic"
6
+ scale = 1.0
7
+
8
+ [styles.derp]
9
+ display_name = "Derp"
10
+ scale = 1.0
11
+
12
+ [styles.chonk]
13
+ display_name = "Chonk"
14
+ scale = 1.15
File without changes
@@ -0,0 +1,36 @@
1
+ """Module-level config dataclass. No matplotlib imports, no I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, fields
6
+
7
+
8
+ @dataclass
9
+ class Config:
10
+ """Global meowplotlib configuration, mutated by api.py and read by render/hook.py."""
11
+
12
+ enabled: bool = True
13
+ style: str | list[str] = "mix"
14
+ density: str = "normal"
15
+ seed: int | None = None
16
+
17
+ def snapshot(self) -> dict[str, object]:
18
+ return {f.name: getattr(self, f.name) for f in fields(self)}
19
+
20
+ def restore(self, snapshot: dict[str, object]) -> None:
21
+ for name, value in snapshot.items():
22
+ setattr(self, name, value)
23
+
24
+ def update(self, **overrides: object) -> None:
25
+ valid = {f.name for f in fields(self)}
26
+ for name, value in overrides.items():
27
+ if name not in valid:
28
+ raise TypeError(f"Unknown config option: {name}")
29
+ setattr(self, name, value)
30
+
31
+
32
+ _CONFIG = Config()
33
+
34
+
35
+ def get_config() -> Config:
36
+ return _CONFIG
@@ -0,0 +1,183 @@
1
+ """Placement geometry: figure dims + exclusion bboxes -> cat placements.
2
+
3
+ Pure logic — no matplotlib imports, no I/O (constitution #1).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ from dataclasses import dataclass
10
+ from typing import Literal
11
+
12
+ from meowplotlib.core.rng import new_rng
13
+
14
+ Density = Literal["sparse", "normal", "chaotic"]
15
+
16
+ # Per-tier area divisor: target count = available_border_area / divisor. Chosen so a reference
17
+ # canvas (unit square, ~70% centered exclusion, default size_range) yields roughly 6/12/24 cats
18
+ # for sparse/normal/chaotic respectively — strictly increasing on any fixed canvas. (Halved from
19
+ # the original 3/6/12 tuning per user request for a denser default look, 2026-07-02.)
20
+ _DENSITY_DIVISORS: dict[Density, float] = {
21
+ "sparse": 0.0655,
22
+ "normal": 0.0328,
23
+ "chaotic": 0.0164,
24
+ }
25
+
26
+ # Fixed edge margin as a fraction of the smaller canvas dimension.
27
+ _EDGE_MARGIN_FRACTION = 0.03
28
+
29
+ _MAX_ATTEMPTS_PER_CANDIDATE = 30
30
+ _SHRINK_FACTOR = 0.85
31
+
32
+ # Defensive upper bound on target count regardless of canvas size, so a pathologically large
33
+ # canvas can't make placement (O(n^2) collision checks against prior placements) unbounded.
34
+ _MAX_TARGET_COUNT = 200
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Rect:
39
+ """Axis-aligned bounding rectangle in figure-fraction coordinates."""
40
+
41
+ x: float
42
+ y: float
43
+ width: float
44
+ height: float
45
+
46
+ def intersects(self, other: Rect) -> bool:
47
+ return (
48
+ self.x < other.x + other.width
49
+ and other.x < self.x + self.width
50
+ and self.y < other.y + other.height
51
+ and other.y < self.y + self.height
52
+ )
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class PlacementConfig:
57
+ """Input configuration for one `place_cats(...)` call."""
58
+
59
+ density: Density
60
+ size_range: tuple[float, float]
61
+ seed: int | None
62
+ styles: list[str]
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class Placement:
67
+ """One engine output entry."""
68
+
69
+ x: float
70
+ y: float
71
+ size: float
72
+ rotation: float
73
+ style: str
74
+
75
+ def bbox(self) -> Rect:
76
+ """Axis-aligned bounding box of this cat's square footprint AFTER rotation.
77
+
78
+ Reserves the true rotated extent (side * (|cos theta| + |sin theta|), up to ~1.41x at
79
+ 45 degrees) rather than the unrotated square, so a rendered, actually-rotated image
80
+ never exceeds the space collision-checked against exclusions and other placements. See
81
+ specs/001-core-placement-engine/research.md's 2026-07-02 addendum.
82
+ """
83
+ theta = math.radians(self.rotation)
84
+ side = self.size * (abs(math.cos(theta)) + abs(math.sin(theta)))
85
+ half = side / 2
86
+ return Rect(x=self.x - half, y=self.y - half, width=side, height=side)
87
+
88
+
89
+ def _margin_rects(canvas_width: float, canvas_height: float, margin: float) -> list[Rect]:
90
+ """Four thin rectangles covering the edge-margin band, so it composes with exclusions."""
91
+ return [
92
+ Rect(x=0, y=0, width=canvas_width, height=margin), # bottom
93
+ Rect(x=0, y=canvas_height - margin, width=canvas_width, height=margin), # top
94
+ Rect(x=0, y=0, width=margin, height=canvas_height), # left
95
+ Rect(x=canvas_width - margin, y=0, width=margin, height=canvas_height), # right
96
+ ]
97
+
98
+
99
+ def _available_border_area(
100
+ canvas_width: float, canvas_height: float, exclusions: list[Rect], margin: float
101
+ ) -> float:
102
+ """Coarse Monte Carlo estimate of canvas area outside exclusions and the edge margin."""
103
+ inner = Rect(
104
+ x=margin,
105
+ y=margin,
106
+ width=max(0.0, canvas_width - 2 * margin),
107
+ height=max(0.0, canvas_height - 2 * margin),
108
+ )
109
+ if inner.width <= 0 or inner.height <= 0:
110
+ return 0.0
111
+ total = inner.width * inner.height
112
+ excluded = 0.0
113
+ for rect in exclusions:
114
+ overlap_w = min(inner.x + inner.width, rect.x + rect.width) - max(inner.x, rect.x)
115
+ overlap_h = min(inner.y + inner.height, rect.y + rect.height) - max(inner.y, rect.y)
116
+ if overlap_w > 0 and overlap_h > 0:
117
+ excluded += overlap_w * overlap_h
118
+ return max(0.0, total - excluded)
119
+
120
+
121
+ def _target_count(available_area: float, density: Density) -> int:
122
+ if available_area <= 0:
123
+ return 0
124
+ return min(_MAX_TARGET_COUNT, int(available_area / _DENSITY_DIVISORS[density]))
125
+
126
+
127
+ def place_cats(
128
+ canvas_width: float,
129
+ canvas_height: float,
130
+ exclusions: list[Rect],
131
+ config: PlacementConfig,
132
+ ) -> list[Placement]:
133
+ """Return non-overlapping cat placements confined to the canvas border region.
134
+
135
+ See specs/001-core-placement-engine/contracts/placement-api.md for the full contract.
136
+ """
137
+ if canvas_width <= 0 or canvas_height <= 0 or not config.styles:
138
+ return []
139
+
140
+ margin = _EDGE_MARGIN_FRACTION * min(canvas_width, canvas_height)
141
+ blocked = list(exclusions) + _margin_rects(canvas_width, canvas_height, margin)
142
+ available_area = _available_border_area(canvas_width, canvas_height, exclusions, margin)
143
+ target = _target_count(available_area, config.density)
144
+ if target <= 0:
145
+ return []
146
+
147
+ rng = new_rng(config.seed)
148
+ min_size, max_size = config.size_range
149
+ placements: list[Placement] = []
150
+
151
+ for _ in range(target):
152
+ size = max_size
153
+ placed = False
154
+ while not placed:
155
+ for _ in range(_MAX_ATTEMPTS_PER_CANDIDATE):
156
+ rotation = rng.uniform(0, 360)
157
+ theta = math.radians(rotation)
158
+ rotated_side = size * (abs(math.cos(theta)) + abs(math.sin(theta)))
159
+ half = rotated_side / 2
160
+ x = rng.uniform(half, canvas_width - half)
161
+ y = rng.uniform(half, canvas_height - half)
162
+ candidate = Placement(
163
+ x=x,
164
+ y=y,
165
+ size=size,
166
+ rotation=rotation,
167
+ style=rng.choice(config.styles),
168
+ )
169
+ bbox = candidate.bbox()
170
+ if any(bbox.intersects(r) for r in blocked):
171
+ continue
172
+ if any(bbox.intersects(p.bbox()) for p in placements):
173
+ continue
174
+ placements.append(candidate)
175
+ placed = True
176
+ break
177
+ if placed:
178
+ break
179
+ size *= _SHRINK_FACTOR
180
+ if size < min_size:
181
+ break
182
+
183
+ return placements
@@ -0,0 +1,10 @@
1
+ """Seeded RNG wrapper for reproducible placements. No matplotlib imports, no I/O."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+
7
+
8
+ def new_rng(seed: int | None) -> random.Random:
9
+ """Return a fresh `random.Random` instance seeded from `seed`, or unseeded if `None`."""
10
+ return random.Random(seed)
File without changes
@@ -0,0 +1,88 @@
1
+ """Draws placed cats onto the figure.
2
+
3
+ Each cat is drawn in its own tiny inset Axes positioned at the placement's exact figure-fraction
4
+ bounding box (`Placement.bbox()`), rather than via OffsetImage/AnnotationBbox's point-space
5
+ `zoom`. This makes the actual rendered footprint match the bbox M1's collision math assumed
6
+ exactly, instead of approximating it through a DPI-dependent zoom factor. Cat axes are tagged
7
+ with `_meowplotlib_cat = True` so `render/bboxes.py` can exclude them from exclusion extraction.
8
+
9
+ Since M1's `Placement.bbox()` reserves the true rotated footprint (see
10
+ specs/001-core-placement-engine/research.md's 2026-07-02 addendum), the cat axes here is always
11
+ exactly that (larger, square) bbox; the image itself is drawn at its true unrotated size,
12
+ centered, and rotated in data-space via an affine transform. Because the reserved bbox is by
13
+ construction the axis-aligned bounding box of a `placement.size`-side square rotated by
14
+ `placement.rotation`, the rotated image's true footprint always stays within the axes — no
15
+ overflow into neighboring exclusions or other cats regardless of the sampled rotation angle.
16
+
17
+ Style resolution goes through `assets.registry` (M3): `placement.style` selects a style, and a
18
+ deterministic (not RNG-based) hash of the placement's own fields picks one image from that
19
+ style's pool, so seeded reproducibility survives multi-image pools — see
20
+ specs/003-style-system/research.md.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import importlib.resources
26
+ from typing import TYPE_CHECKING
27
+
28
+ import matplotlib.image as mpimg
29
+ import matplotlib.transforms as mtransforms
30
+
31
+ from meowplotlib.assets.registry import available_styles
32
+
33
+ if TYPE_CHECKING:
34
+ from pathlib import Path
35
+
36
+ import numpy as np
37
+ from matplotlib.figure import Figure
38
+
39
+ from meowplotlib.core.placement import Placement
40
+
41
+ _IMAGE_CACHE: dict[Path, np.ndarray] = {}
42
+
43
+
44
+ def _load_image(path: Path) -> np.ndarray:
45
+ if path not in _IMAGE_CACHE:
46
+ with importlib.resources.as_file(path) as local_path:
47
+ _IMAGE_CACHE[path] = mpimg.imread(local_path)
48
+ return _IMAGE_CACHE[path]
49
+
50
+
51
+ def _resolve_image(placement: Placement) -> tuple[np.ndarray, float]:
52
+ """Return (image array, clamped scale) for `placement`, deterministic given its fields.
53
+
54
+ Scale is clamped to <= 1.0: a style's manifest `scale` may only ever shrink a cat's
55
+ rendered footprint within its reserved bbox, never grow it beyond the box M1's collision
56
+ math reserved (constitution #2 takes priority over exact visual scale fidelity — see
57
+ PROGRESS.md for the follow-up needed to let scale genuinely grow a cat's footprint).
58
+ """
59
+ styles = available_styles()
60
+ style_info = styles[placement.style]
61
+ pool = style_info.image_paths # already filename-sorted by registry.discover_styles
62
+ index = hash((placement.x, placement.y, placement.size, placement.rotation)) % len(pool)
63
+ image = _load_image(pool[index])
64
+ return image, min(style_info.scale, 1.0)
65
+
66
+
67
+ def draw_placements(figure: Figure, placements: list[Placement]) -> None:
68
+ """Add one rotated cat image per placement, each in its own figure-fraction inset axes."""
69
+ for placement in placements:
70
+ image, scale = _resolve_image(placement)
71
+ bbox = placement.bbox() # the rotation-inclusive reserved square
72
+ cat_axes = figure.add_axes((bbox.x, bbox.y, bbox.width, bbox.height))
73
+ cat_axes._meowplotlib_cat = True # type: ignore[attr-defined]
74
+ cat_axes.set_axis_off()
75
+ cat_axes.patch.set_alpha(0.0)
76
+
77
+ half_span = bbox.width / 2
78
+ cat_axes.set_xlim(-half_span, half_span)
79
+ cat_axes.set_ylim(-half_span, half_span)
80
+
81
+ visual_half = (placement.size * scale) / 2
82
+ cat_image = cat_axes.imshow(
83
+ image,
84
+ extent=(-visual_half, visual_half, -visual_half, visual_half),
85
+ )
86
+ cat_image.set_transform(
87
+ mtransforms.Affine2D().rotate_deg(placement.rotation) + cat_axes.transData
88
+ )
@@ -0,0 +1,76 @@
1
+ """Extracts exclusion zones (axes, labels, ticks, legend) from a live figure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from meowplotlib.core.placement import Rect
8
+
9
+ if TYPE_CHECKING:
10
+ from matplotlib.figure import Figure
11
+ from matplotlib.transforms import Bbox, Transform
12
+
13
+
14
+ def extract_exclusions(figure: Figure) -> list[Rect]:
15
+ """Return one exclusion Rect per axes (tight bbox incl. ticks/labels), plus legends.
16
+
17
+ Coordinates are converted to figure-fraction space via `figure.transFigure`.
18
+ """
19
+ renderer = figure._get_renderer() # type: ignore[attr-defined] # noqa: SLF001
20
+ exclusions: list[Rect] = []
21
+ inv = figure.transFigure.inverted()
22
+
23
+ for axes in figure.axes:
24
+ if getattr(axes, "_meowplotlib_cat", False):
25
+ continue # a previously-placed cat's own inset axes, not a protected element
26
+ bbox = axes.get_tightbbox(renderer)
27
+ if bbox is None:
28
+ continue
29
+ exclusions.append(_bbox_to_rect(bbox, inv))
30
+
31
+ legend = axes.get_legend()
32
+ if legend is not None:
33
+ legend_bbox = legend.get_window_extent(renderer)
34
+ exclusions.append(_bbox_to_rect(legend_bbox, inv))
35
+
36
+ return exclusions
37
+
38
+
39
+ def ensure_minimum_margin(figure: Figure, margin: float) -> None:
40
+ """Shrink each axes so at least `margin` (figure-fraction) of clear space remains between
41
+ its tight bbox and every figure edge, so cats have room to land on all four sides.
42
+
43
+ This actively resizes the user's plot area — a deliberate, opted-in departure from pure
44
+ "decorate around the existing layout" behavior. See PROGRESS.md for the tradeoff record.
45
+ Only shrinks when needed: axes with an already-generous margin on a given side are left
46
+ untouched on that side.
47
+ """
48
+ renderer = figure._get_renderer() # type: ignore[attr-defined] # noqa: SLF001
49
+ inv = figure.transFigure.inverted()
50
+
51
+ for axes in figure.axes:
52
+ if getattr(axes, "_meowplotlib_cat", False):
53
+ continue
54
+ tight = axes.get_tightbbox(renderer)
55
+ if tight is None:
56
+ continue
57
+ fig_tight = tight.transformed(inv)
58
+
59
+ left_deficit = max(0.0, margin - fig_tight.x0)
60
+ bottom_deficit = max(0.0, margin - fig_tight.y0)
61
+ right_deficit = max(0.0, margin - (1.0 - fig_tight.x1))
62
+ top_deficit = max(0.0, margin - (1.0 - fig_tight.y1))
63
+ if not (left_deficit or bottom_deficit or right_deficit or top_deficit):
64
+ continue
65
+
66
+ pos = axes.get_position()
67
+ new_width = pos.width - left_deficit - right_deficit
68
+ new_height = pos.height - bottom_deficit - top_deficit
69
+ if new_width <= 0 or new_height <= 0:
70
+ continue # figure too small to guarantee this margin; leave axes as-is
71
+ axes.set_position((pos.x0 + left_deficit, pos.y0 + bottom_deficit, new_width, new_height))
72
+
73
+
74
+ def _bbox_to_rect(bbox: Bbox, inv: Transform) -> Rect:
75
+ fig_bbox = bbox.transformed(inv)
76
+ return Rect(x=fig_bbox.x0, y=fig_bbox.y0, width=fig_bbox.width, height=fig_bbox.height)
@@ -0,0 +1,69 @@
1
+ """Figure draw interception: the single hook point for both display and savefig.
2
+
3
+ See specs/002-matplotlib-integration/research.md for why `Figure.draw` is the shared
4
+ interception point, and why the patch is permanent (enable/disable only flips a flag).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from matplotlib.figure import Figure
13
+
14
+ from meowplotlib.assets.registry import resolve_style_names
15
+ from meowplotlib.core.config import get_config
16
+ from meowplotlib.core.placement import PlacementConfig, place_cats
17
+ from meowplotlib.render import artist, bboxes
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+ _installed = False
23
+
24
+ # Guaranteed clear space (figure-fraction) between each axes' tight bbox and every figure edge,
25
+ # so cats can land on all four sides rather than only wherever the chart's default layout
26
+ # happens to leave room. Sized comfortably above the largest rotated cat footprint (max size
27
+ # 0.16, up to ~1.41x when rotated ~= 0.226) so a typical cat fits without shrinking.
28
+ _GUARANTEED_MARGIN = 0.14
29
+
30
+
31
+ def install() -> None:
32
+ """Wrap `Figure.draw` once, process-wide. Safe to call more than once (no-op after first)."""
33
+ global _installed
34
+ if _installed:
35
+ return
36
+ _installed = True
37
+
38
+ original_draw: Callable[..., Any] = Figure.draw
39
+
40
+ @functools.wraps(original_draw)
41
+ def wrapped_draw(self: Figure, renderer: Any, *args: Any, **kwargs: Any) -> Any:
42
+ config = get_config()
43
+ if config.enabled and not getattr(self, "_meowplotlib_decorated", False):
44
+ _decorate(self, config.density, config.seed, config.style)
45
+ self._meowplotlib_decorated = True # type: ignore[attr-defined]
46
+ return original_draw(self, renderer, *args, **kwargs)
47
+
48
+ Figure.draw = wrapped_draw # type: ignore[assignment]
49
+
50
+
51
+ def _decorate(figure: Figure, density: str, seed: int | None, style: str | list[str]) -> None:
52
+ # bboxes.extract_exclusions() returns rects in figure-fraction space (transFigure-normalized
53
+ # to 0..1), so the canvas itself is always the unit square in that same coordinate system —
54
+ # NOT figure.get_size_inches(). This also gives small-figure degradation "for free": at a
55
+ # fixed font size, a small figure's tick/axis labels occupy a larger fraction of the unit
56
+ # square, shrinking available border area exactly as intended.
57
+ bboxes.ensure_minimum_margin(figure, _GUARANTEED_MARGIN)
58
+ exclusions = bboxes.extract_exclusions(figure)
59
+ styles = resolve_style_names(style)
60
+ if not styles:
61
+ return
62
+ config = PlacementConfig(
63
+ density=density, # type: ignore[arg-type]
64
+ size_range=(0.06, 0.16),
65
+ seed=seed,
66
+ styles=styles,
67
+ )
68
+ placements = place_cats(1.0, 1.0, exclusions, config)
69
+ artist.draw_placements(figure, placements)
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: meowplotlib
3
+ Version: 0.1.0
4
+ Summary: Whimsical cat decorations for matplotlib figures.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: matplotlib>=3.7
8
+ Requires-Dist: numpy>=1.24
9
+ Provides-Extra: dev
10
+ Requires-Dist: build>=1.0; extra == 'dev'
11
+ Requires-Dist: hypothesis>=6.100; extra == 'dev'
12
+ Requires-Dist: mypy>=1.10; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Requires-Dist: ruff>=0.6; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # meowplotlib
18
+
19
+ ![Before and after adding meowplotlib to a matplotlib chart](docs/before_after.png)
20
+
21
+ Whimsical cat decorations for `matplotlib` figures. One import, zero required config.
22
+
23
+ ```python
24
+ import matplotlib.pyplot as plt
25
+ import meowplotlib # cats now appear on every figure you show or save
26
+
27
+ plt.plot([1, 2, 3], [1, 4, 9])
28
+ plt.savefig("chart.png")
29
+ ```
30
+
31
+ ## Quickstart
32
+
33
+ ```bash
34
+ pip install meowplotlib
35
+ ```
36
+
37
+ ```python
38
+ import meowplotlib
39
+
40
+ meowplotlib.set_style("chonk") # or a list, or "mix"
41
+ meowplotlib.set_density("chaotic") # "sparse" | "normal" | "chaotic"
42
+ meowplotlib.set_seed(42) # reproducible layouts
43
+ meowplotlib.disable() # turn it off for the rest of the session
44
+ meowplotlib.enable() # turn it back on
45
+
46
+ with meowplotlib.config(enabled=False):
47
+ ... # one chart rendered without cats
48
+ ```
49
+
50
+ ## Config
51
+
52
+ | Function | Values | Effect |
53
+ |---|---|---|
54
+ | `set_style` | style name, list of names, or `"mix"` | which cat art pool(s) to draw from |
55
+ | `set_density` | `"sparse"` \| `"normal"` \| `"chaotic"` | how many cats appear |
56
+ | `set_seed` | `int` \| `None` | lock or randomize placement |
57
+ | `enable()` / `disable()` | — | session on/off switch |
58
+ | `config(**overrides)` | context manager | temporary overrides for one block |
59
+
60
+ ## Art
61
+
62
+ v1 ships with three real cat art styles: `classic`, `derp`, and `chonk`. Adding or swapping a
63
+ style is a pure file-drop under `src/meowplotlib/assets/images/<style>/` plus one `styles.toml`
64
+ entry — no code changes required. See [ATTRIBUTION.md](ATTRIBUTION.md) for notes.
65
+
66
+ See `STANDUP_PLAN.md` and `constitution.md` for the full project contract.
@@ -0,0 +1,20 @@
1
+ meowplotlib/__init__.py,sha256=9-QZnxDNUC2z1xNIcai33KfmyzpM6a63SkaW9oG4Jco,329
2
+ meowplotlib/api.py,sha256=WFy_2bESoUuvfFAZP0dHuwzdb4EbL9fMh7AC0DWx6Q8,2227
3
+ meowplotlib/assets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ meowplotlib/assets/_toml_fallback.py,sha256=w6maFX6mbrYgHc8npn1lXeBFHBNGjyZGD8LXUEpFj-g,1489
5
+ meowplotlib/assets/registry.py,sha256=YJ3h1BzmlfkIAHMZOr4usO4CwgzJXcFetxbEudepnbE,3368
6
+ meowplotlib/assets/styles.toml,sha256=2Ji9AU1kDulnxBpYTTgXtAmEIhDOAeMoAYSa2Ktv21c,300
7
+ meowplotlib/assets/images/chonk/chonk_01.png,sha256=mplF5TjfjRYyfIeCBYwCNNLPb99krr39aemDzPeTWg0,326322
8
+ meowplotlib/assets/images/classic/classic_01.png,sha256=F70_HOuKUn30uEi6BEicVrUsvbw1hsKSx5YHJUTwpFM,538877
9
+ meowplotlib/assets/images/derp/derp_01.png,sha256=mwXEPS0jorHD4HthwUAMW5hRG90Rk9bFFla2kY1hywM,204446
10
+ meowplotlib/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ meowplotlib/core/config.py,sha256=m43IrPzKXynM6Zxs7IaCJey8lDkMXKEOHJ1ckL6gpzM,995
12
+ meowplotlib/core/placement.py,sha256=4Wkr17wS5MivFqda5Z3qr31NwuvV2AwH1vnxOKO_VtU,6348
13
+ meowplotlib/core/rng.py,sha256=WLUgHVCMjruZrZAUIFxmZMK4yEzZqtPT-fuf_gqGk2U,310
14
+ meowplotlib/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ meowplotlib/render/artist.py,sha256=DQRKs1qjc6SSZIL1v19xBTgf1W0BAmooArxHhpZRLrU,3983
16
+ meowplotlib/render/bboxes.py,sha256=KdNTsS7xBfaNCyxbKtS1pk-ANir6xJoqFp4csBFZhXI,3073
17
+ meowplotlib/render/hook.py,sha256=hb8TkkTqnbfLVKW-pEtI29pSx-fEAKK03hzZsZQT8-M,2838
18
+ meowplotlib-0.1.0.dist-info/METADATA,sha256=_jLV72fd_bPyr9MqYPSmDT-rHm0n1-xtWGO75qF3U1s,2132
19
+ meowplotlib-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
20
+ meowplotlib-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any