anyplotlib 0.1.0b1__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 (42) hide show
  1. anyplotlib/__init__.py +53 -0
  2. anyplotlib/_base_plot.py +229 -0
  3. anyplotlib/_electron.py +74 -0
  4. anyplotlib/_repr_utils.py +345 -0
  5. anyplotlib/_utils.py +194 -0
  6. anyplotlib/axes/__init__.py +6 -0
  7. anyplotlib/axes/_axes.py +510 -0
  8. anyplotlib/axes/_inset_axes.py +126 -0
  9. anyplotlib/callbacks.py +328 -0
  10. anyplotlib/embed.py +194 -0
  11. anyplotlib/figure/__init__.py +7 -0
  12. anyplotlib/figure/_figure.py +633 -0
  13. anyplotlib/figure/_gridspec.py +99 -0
  14. anyplotlib/figure/_subplots.py +100 -0
  15. anyplotlib/figure_esm.js +6011 -0
  16. anyplotlib/markers.py +704 -0
  17. anyplotlib/plot1d/__init__.py +6 -0
  18. anyplotlib/plot1d/_plot1d.py +1376 -0
  19. anyplotlib/plot1d/_plotbar.py +436 -0
  20. anyplotlib/plot2d/__init__.py +5 -0
  21. anyplotlib/plot2d/_plot2d.py +726 -0
  22. anyplotlib/plot2d/_plotmesh.py +116 -0
  23. anyplotlib/plot3d/__init__.py +4 -0
  24. anyplotlib/plot3d/_plot3d.py +524 -0
  25. anyplotlib/plotxy/__init__.py +4 -0
  26. anyplotlib/plotxy/_plotxy.py +273 -0
  27. anyplotlib/sphinx_anywidget/__init__.py +177 -0
  28. anyplotlib/sphinx_anywidget/_directive.py +245 -0
  29. anyplotlib/sphinx_anywidget/_repr_utils.py +298 -0
  30. anyplotlib/sphinx_anywidget/_scraper.py +390 -0
  31. anyplotlib/sphinx_anywidget/_wheel_builder.py +84 -0
  32. anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +1099 -0
  33. anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +100 -0
  34. anyplotlib/widgets/__init__.py +18 -0
  35. anyplotlib/widgets/_base.py +218 -0
  36. anyplotlib/widgets/_widgets1d.py +109 -0
  37. anyplotlib/widgets/_widgets2d.py +141 -0
  38. anyplotlib/widgets/_widgets3d.py +50 -0
  39. anyplotlib-0.1.0b1.dist-info/METADATA +160 -0
  40. anyplotlib-0.1.0b1.dist-info/RECORD +42 -0
  41. anyplotlib-0.1.0b1.dist-info/WHEEL +4 -0
  42. anyplotlib-0.1.0b1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,273 @@
1
+ """
2
+ _plotxy.py — ``PlotXY``: a blank **data-coordinate 2-D axis**.
3
+
4
+ Where :class:`~anyplotlib.plot1d.Plot1D` is a curve over a (monotonic) x-axis,
5
+ ``PlotXY`` is a coordinate canvas in the matplotlib sense: you set ``xlim`` /
6
+ ``ylim`` (+ optional ``aspect="equal"``) and draw ``scatter`` / ``plot`` /
7
+ ``fill`` / ``text`` as **collection-style artists** in data coordinates — the
8
+ surface orix's ``StereographicPlot`` (and an IPF triangle) needs.
9
+
10
+ It is built on ``Plot1D`` because the 1-D marker collections
11
+ (``add_points`` / ``add_lines`` / ``add_polygons`` / ``add_texts``) already map
12
+ through the 1-D data→canvas transform, which is exactly matplotlib's
13
+ ``transLimits → transAxes`` (``_axisValToFrac`` for x + ``_valToPy1d`` for y →
14
+ unit box → panel rect). The primary curve is hidden (``alpha=0``); the markers
15
+ ARE the content. ``aspect="equal"`` is honoured by the renderer's xy-aspect step
16
+ (``state["aspect"]``); without it the data simply fills the panel (auto aspect).
17
+
18
+ Mirrors matplotlib: ``scatter`` → a ``PathCollection`` (offsets + per-point
19
+ colours/sizes), ``plot`` → ``Line2D``, ``fill`` → ``Polygon``, ``text`` →
20
+ ``Text``.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import numpy as np
25
+
26
+ from anyplotlib._utils import _build_colormap_lut
27
+ from anyplotlib.plot1d._plot1d import Plot1D
28
+
29
+
30
+ def _regular_grid_edges(x, y, rtol=1e-6):
31
+ """If ``x``/``y`` corner grids are separable + axis-aligned, return the 1-D
32
+ edge vectors ``(xe, ye)``; otherwise ``None``.
33
+
34
+ "Separable + axis-aligned" means every row of ``x`` is identical and every
35
+ column of ``y`` is identical (the usual ``np.meshgrid`` of 1-D edges) — the
36
+ case a single stretched raster reproduces exactly. Spacing may be
37
+ non-uniform; ``drawImage`` only needs a common bounding box, and each cell
38
+ is one pixel, so non-uniform *edges* would distort — hence we additionally
39
+ require uniform spacing for the raster fast path (checked by the caller).
40
+ """
41
+ if x.shape != y.shape or x.ndim != 2:
42
+ return None
43
+ xe = x[0, :]
44
+ ye = y[:, 0]
45
+ if not np.allclose(x, xe[None, :], rtol=rtol, atol=0.0):
46
+ return None
47
+ if not np.allclose(y, ye[:, None], rtol=rtol, atol=0.0):
48
+ return None
49
+ return xe, ye
50
+
51
+
52
+ def _is_uniform(v, rtol=1e-4):
53
+ """True if 1-D ``v`` is monotonic with (near-)constant spacing."""
54
+ d = np.diff(np.asarray(v, float))
55
+ if d.size == 0 or not np.all(np.isfinite(d)):
56
+ return False
57
+ if np.any(d > 0) and np.any(d < 0):
58
+ return False
59
+ step = d.mean()
60
+ if step == 0:
61
+ return False
62
+ return bool(np.allclose(d, step, rtol=rtol, atol=abs(step) * rtol))
63
+
64
+
65
+ def _colseq(c, n):
66
+ """A matplotlib-style colour arg → ``list[str]`` of length ``n`` (or None)."""
67
+ if c is None:
68
+ return None
69
+ if isinstance(c, str):
70
+ return [c] * n
71
+ return [str(x) for x in c]
72
+
73
+
74
+ class PlotXY(Plot1D):
75
+ """Coordinate-only 2-D axis: ``scatter`` / ``plot`` / ``fill`` / ``text`` in
76
+ data coordinates, with ``set_xlim`` / ``set_ylim`` / ``set_aspect``."""
77
+
78
+ #: Heavy state keys routed to the geometry channel (see Figure._push).
79
+ #: ``raster_geom`` holds the base64 RGBA bytes of any ``add_raster`` /
80
+ #: ``pcolormesh`` raster (keyed by marker-set id), so re-aiming the view
81
+ #: never re-transmits the image. Declared here rather than on Plot1D so
82
+ #: plain line/bar panels keep the single-trait path (no empty geom trait).
83
+ _GEOM_KEYS = frozenset({"raster_geom"})
84
+
85
+ def __init__(self, *, xlim=(0.0, 1.0), ylim=(0.0, 1.0), aspect=None,
86
+ units: str = "", y_units: str = ""):
87
+ super().__init__(
88
+ np.zeros(2, dtype=float),
89
+ x_axis=np.asarray([xlim[0], xlim[1]], dtype=float),
90
+ units=units, y_units=y_units, alpha=0.0, # hidden primary curve
91
+ )
92
+ s = self._state
93
+ s["line_alpha"] = 0.0
94
+ s["data_min"] = float(ylim[0])
95
+ s["data_max"] = float(ylim[1])
96
+ s["y_range"] = [float(ylim[0]), float(ylim[1])]
97
+ s["aspect"] = "equal" if aspect == "equal" else None
98
+
99
+ # ── view (data limits = the transData domain) ────────────────────────────
100
+ def set_xlim(self, xmin: float, xmax: float) -> None:
101
+ self._state["x_axis"] = np.asarray([float(xmin), float(xmax)], dtype=float)
102
+ self._push()
103
+
104
+ def set_ylim(self, ymin: float, ymax: float) -> None:
105
+ self._state["y_range"] = [float(ymin), float(ymax)]
106
+ self._state["data_min"] = float(ymin)
107
+ self._state["data_max"] = float(ymax)
108
+ self._push()
109
+
110
+ def get_xlim(self) -> tuple:
111
+ x = self._state["x_axis"]
112
+ return (float(x[0]), float(x[-1]))
113
+
114
+ def get_ylim(self) -> tuple:
115
+ yr = self._state["y_range"]
116
+ return (float(yr[0]), float(yr[1]))
117
+
118
+ def set_aspect(self, aspect) -> None:
119
+ """``"equal"`` → one data unit is the same pixel length on x and y
120
+ (matplotlib ``apply_aspect``); ``"auto"`` / ``None`` → fill the panel."""
121
+ self._state["aspect"] = "equal" if aspect == "equal" else None
122
+ self._push()
123
+
124
+ def get_aspect(self):
125
+ return self._state.get("aspect")
126
+
127
+ # ── matplotlib-parity artists (each returns its collection MarkerGroup) ──
128
+ def scatter(self, x, y, *, s=8, c=None, facecolors=None,
129
+ edgecolors="#1f77b4", alpha=1.0, name=None):
130
+ """Data-coord scatter → a ``PathCollection``-style points group."""
131
+ x = np.asarray(x, float).ravel()
132
+ y = np.asarray(y, float).ravel()
133
+ offs = np.column_stack([x, y])
134
+ face = _colseq(c if c is not None else facecolors, len(x))
135
+ return self.add_points(offs, name=name, sizes=s, color=edgecolors,
136
+ facecolors=face, alpha=alpha)
137
+
138
+ def plot(self, x, y, *, color="#1f77b4", linewidth=1.5, name=None):
139
+ """Data-coord polyline → a ``Line2D``-style lines group."""
140
+ pts = np.column_stack([np.asarray(x, float).ravel(),
141
+ np.asarray(y, float).ravel()])
142
+ segs = [[pts[i].tolist(), pts[i + 1].tolist()] for i in range(len(pts) - 1)]
143
+ return self.add_lines(segs, name=name, edgecolors=color, linewidths=linewidth)
144
+
145
+ def fill(self, x, y, *, facecolor=None, edgecolor="#1f77b4", alpha=0.3,
146
+ linewidth=1.5, name=None):
147
+ """Data-coord filled polygon → a ``Polygon``-style group."""
148
+ verts = np.column_stack([np.asarray(x, float).ravel(),
149
+ np.asarray(y, float).ravel()]).tolist()
150
+ return self.add_polygons([verts], name=name, facecolors=facecolor,
151
+ edgecolors=edgecolor, alpha=alpha,
152
+ linewidths=linewidth)
153
+
154
+ def text(self, x, y, s, *, color="#000000", fontsize=12, name=None):
155
+ """Data-coord text → a ``Text``-style group (one label)."""
156
+ return self.add_texts([[float(x), float(y)]], [str(s)], name=name,
157
+ color=color, fontsize=fontsize)
158
+
159
+ def pcolormesh(self, x, y, c, *, cmap="viridis", vmin=None, vmax=None,
160
+ edgecolor=None, alpha=1.0, clip_path=None, smooth=False,
161
+ name=None):
162
+ """Data-coord quad mesh — matplotlib ``pcolormesh``.
163
+
164
+ ``x``/``y`` are the ``(N+1, M+1)`` cell-corner grids and ``c`` the
165
+ ``(N, M)`` field: either a scalar array (mapped through *cmap* between
166
+ *vmin*/*vmax*) or an array of CSS colour strings. Masked / non-finite
167
+ cells are skipped — so an ``orix`` pole-density histogram (masked
168
+ outside the fundamental sector) clips itself to the sector. Drawn as
169
+ one polygon ``MarkerGroup`` with per-cell face colours (a
170
+ ``PathCollection``); the edges default to the face colour for a
171
+ seamless heatmap.
172
+
173
+ ``clip_path`` is an optional ``(K, 2)`` data-coord polygon the mesh is
174
+ clipped to (matplotlib ``set_clip_path``) — pass the curved sector
175
+ boundary so the edge cells don't overflow it.
176
+
177
+ **Fast path.** When the grid is a regular, axis-aligned, uniformly
178
+ spaced mesh of a scalar field (the common heatmap / density-histogram
179
+ case), the whole mesh is rendered as one stretched RGBA raster
180
+ (:meth:`add_raster`) instead of one polygon per cell — orders of
181
+ magnitude less data to serialise and one ``drawImage`` to draw.
182
+ Irregular meshes, colour-string ``c``, or an explicit ``edgecolor``
183
+ fall back to the per-cell polygon path. ``smooth=True`` bilinearly
184
+ interpolates the raster for a smooth heat field (default is crisp
185
+ nearest-neighbour cells); it applies only on the raster fast path.
186
+ """
187
+ x = np.asarray(x, float)
188
+ y = np.asarray(y, float)
189
+ cm = np.ma.asarray(c)
190
+ nr, nc = cm.shape
191
+ mask = np.ma.getmaskarray(cm)
192
+
193
+ if cm.dtype.kind in "fiub": # scalar field → LUT
194
+ vals = np.ma.filled(cm.astype(float), np.nan)
195
+ finite = vals[np.isfinite(vals)]
196
+ lo = float(vmin) if vmin is not None else (
197
+ float(finite.min()) if finite.size else 0.0)
198
+ hi = float(vmax) if vmax is not None else (
199
+ float(finite.max()) if finite.size else 1.0)
200
+ lut = _build_colormap_lut(cmap)
201
+ span = (hi - lo) or 1.0
202
+
203
+ # ── Raster fast path ────────────────────────────────────────────
204
+ edges = _regular_grid_edges(x, y) if edgecolor is None else None
205
+ if edges is not None:
206
+ xe, ye = edges
207
+ if _is_uniform(xe) and _is_uniform(ye):
208
+ return self._pcolormesh_raster(
209
+ xe, ye, vals, mask, lut, lo, span,
210
+ alpha=alpha, clip_path=clip_path, smooth=smooth,
211
+ name=name)
212
+
213
+ def _color(i, j):
214
+ v = vals[i, j]
215
+ if not np.isfinite(v):
216
+ return None
217
+ t = min(1.0, max(0.0, (v - lo) / span))
218
+ r, g, b = lut[int(round(t * 255))]
219
+ return f"#{r:02x}{g:02x}{b:02x}"
220
+ else: # already colour strings
221
+ def _color(i, j):
222
+ return None if mask[i, j] else str(cm[i, j])
223
+
224
+ verts, faces = [], []
225
+ for i in range(nr):
226
+ for j in range(nc):
227
+ if mask[i, j]:
228
+ continue
229
+ col = _color(i, j)
230
+ if col is None:
231
+ continue
232
+ verts.append([[x[i, j], y[i, j]],
233
+ [x[i + 1, j], y[i + 1, j]],
234
+ [x[i + 1, j + 1], y[i + 1, j + 1]],
235
+ [x[i, j + 1], y[i, j + 1]]])
236
+ faces.append(col)
237
+
238
+ edges = faces if edgecolor is None else edgecolor
239
+ return self.add_polygons(verts, name=name, facecolors=faces,
240
+ edgecolors=edges, alpha=alpha, linewidths=0.5,
241
+ clip_path=clip_path)
242
+
243
+ def _pcolormesh_raster(self, xe, ye, vals, mask, lut, lo, span, *,
244
+ alpha, clip_path, smooth, name):
245
+ """Render a regular scalar mesh as one stretched RGBA raster.
246
+
247
+ ``vals``/``mask`` are ``(nr, nc)``; ``xe``/``ye`` the ``nc+1``/``nr+1``
248
+ uniform edge vectors. Each cell maps to one pixel; colours come from
249
+ the same LUT / lo / span math as the polygon path so the two are
250
+ pixel-equivalent (up to per-cell quantisation).
251
+ """
252
+ lut_arr = np.asarray(lut, dtype=np.uint8) # (256, 3)
253
+ opaque = (~mask) & np.isfinite(vals)
254
+ # NaN/masked cells become alpha 0; give them a safe index (0) so the
255
+ # cast never overflows — their RGB is invisible regardless.
256
+ t = np.clip((np.where(opaque, vals, lo) - lo) / span, 0.0, 1.0)
257
+ idx = np.rint(t * 255).astype(np.intp) # matches int(round(...))
258
+ rgba = np.zeros((*vals.shape, 4), dtype=np.uint8)
259
+ rgba[..., :3] = lut_arr[idx]
260
+ rgba[..., 3] = np.where(opaque, 255, 0).astype(np.uint8)
261
+
262
+ # Orient so image row 0 = highest data-y, col 0 = lowest data-x —
263
+ # add_raster stretches the image across ``extent`` with row 0 at the
264
+ # top (max y) and col 0 at the left (min x).
265
+ if ye[0] <= ye[-1]: # ascending y → flip rows so top = max y
266
+ rgba = rgba[::-1, :, :]
267
+ if xe[0] > xe[-1]: # descending x → flip cols so left = min x
268
+ rgba = rgba[:, ::-1, :]
269
+
270
+ extent = (float(min(xe[0], xe[-1])), float(max(xe[0], xe[-1])),
271
+ float(min(ye[0], ye[-1])), float(max(ye[0], ye[-1])))
272
+ return self.add_raster(np.ascontiguousarray(rgba), extent=extent,
273
+ clip_path=clip_path, smooth=smooth, name=name)
@@ -0,0 +1,177 @@
1
+ """
2
+ sphinx_anywidget
3
+ ================
4
+
5
+ A generic Sphinx extension that makes any ``anywidget.AnyWidget``-based
6
+ figure interactive in documentation pages — powered by Pyodide, with no
7
+ server or Jupyter kernel required.
8
+
9
+ Quick start (any project)
10
+ -------------------------
11
+
12
+ In your ``conf.py``::
13
+
14
+ extensions = [
15
+ "anyplotlib.sphinx_anywidget",
16
+ ]
17
+
18
+ # Package whose wheel is built and served to Pyodide at runtime.
19
+ anywidget_pyodide_package = "mypackage"
20
+
21
+
22
+ The extension:
23
+ * builds a pure-Python wheel at docs-build time;
24
+ * injects ``anywidget_bridge.js`` (per-figure ⚡ badges + Pyodide boot);
25
+ * provides ``AnywidgetScraper`` for Sphinx Gallery (``# Interactive`` tag);
26
+ * registers ``.. anywidget-figure::`` RST directive.
27
+
28
+ Monkey-patch approach
29
+ ---------------------
30
+ ``anywidget_bridge.js`` patches ``AnyWidget.__init__`` in Pyodide to add a
31
+ ``traitlets.observe(names=All)`` observer. When any ``sync=True`` trait
32
+ changes and the widget has ``_anywidget_fig_id`` set, the observer calls
33
+ ``window._anywidgetPush(fig_id, name, value_str)`` which postMessages the
34
+ new state into the matching iframe — no library-side Pyodide code needed.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from pathlib import Path
40
+
41
+ from anyplotlib.sphinx_anywidget._scraper import AnywidgetScraper, ViewerScraper # noqa: F401
42
+
43
+ _HERE = Path(__file__).parent
44
+ _STATIC_SRC = _HERE / "static"
45
+
46
+
47
+ def setup(app):
48
+ """Register sphinx_anywidget with Sphinx."""
49
+ app.add_config_value("anywidget_pyodide_package", default=None, rebuild="html")
50
+
51
+ from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective
52
+ app.add_directive("anywidget-figure", AnywidgetFigureDirective)
53
+
54
+ app.connect("builder-inited", _copy_static_assets)
55
+ app.connect("builder-inited", _build_pyodide_wheel)
56
+
57
+ # anywidget_config.js is written dynamically by _build_pyodide_wheel;
58
+ # it must load BEFORE anywidget_bridge.js so _inferPackageName sees the name.
59
+ app.add_js_file("anywidget_config.js", loading_method="defer", priority=490)
60
+ app.add_js_file("anywidget_bridge.js", loading_method="defer", priority=500)
61
+ app.add_css_file("anywidget_overlay.css")
62
+
63
+ return {
64
+ "version": "0.1.0",
65
+ "parallel_read_safe": True,
66
+ "parallel_write_safe": True,
67
+ }
68
+
69
+
70
+ def _copy_static_assets(app):
71
+ """Add the extension's static/ dir to html_static_path."""
72
+ src_str = str(_STATIC_SRC)
73
+ if hasattr(app.config, "html_static_path"):
74
+ if src_str not in app.config.html_static_path:
75
+ app.config.html_static_path.append(src_str)
76
+
77
+
78
+ def _build_pyodide_wheel(app):
79
+ """Build the configured package wheel for the Pyodide bridge."""
80
+ pkg = getattr(app.config, "anywidget_pyodide_package", None)
81
+ if not pkg:
82
+ pkg = _infer_package_name(app)
83
+
84
+ conf_dir = Path(app.confdir)
85
+ static_dir = conf_dir / "_static"
86
+ static_dir.mkdir(parents=True, exist_ok=True)
87
+
88
+ import json as _json
89
+ import re as _re
90
+
91
+ if not pkg:
92
+ print(
93
+ "[sphinx_anywidget] WARNING: anywidget_pyodide_package not set; "
94
+ "Pyodide interactive mode disabled."
95
+ )
96
+ # Write a config that explicitly disables interactive mode so the
97
+ # bridge's heuristic package detection never runs.
98
+ config_js = (
99
+ "window._anywidgetPackage = null;\n"
100
+ "window._anywidgetInteractiveDisabled = true;\n"
101
+ )
102
+ (static_dir / "anywidget_config.js").write_text(config_js, encoding="utf-8")
103
+ return
104
+
105
+ # Write a tiny config script so anywidget_bridge.js can find the package
106
+ # name without fragile heuristics. Loaded before anywidget_bridge.js.
107
+ config_js = f"window._anywidgetPackage = {_json.dumps(pkg)};\n"
108
+ (static_dir / "anywidget_config.js").write_text(config_js, encoding="utf-8")
109
+
110
+ # If a stable wheel was already placed here (e.g. by the CI ``uv build``
111
+ # step that runs before sphinx-build), reuse it — but only when it is
112
+ # newer than every source file, otherwise the ⚡ interactive mode would
113
+ # run stale code.
114
+ normalised = _re.sub(r"[-.]", "_", pkg)
115
+ wheels_dir = static_dir / "wheels"
116
+ stable = wheels_dir / f"{normalised}-0.0.0-py3-none-any.whl"
117
+ if stable.exists():
118
+ project_root = _find_project_root(conf_dir)
119
+ pkg_dir = project_root / pkg
120
+ newest_src = 0.0
121
+ if pkg_dir.is_dir():
122
+ newest_src = max(
123
+ (f.stat().st_mtime for f in pkg_dir.rglob("*")
124
+ if f.suffix in (".py", ".js", ".css") and "tests" not in f.parts),
125
+ default=0.0,
126
+ )
127
+ if stable.stat().st_mtime >= newest_src:
128
+ # ASCII only: Windows consoles (cp1252) can't print '→'
129
+ print(f"[sphinx_anywidget] wheel up to date -> {stable}")
130
+ return
131
+ print("[sphinx_anywidget] wheel older than source -> rebuilding")
132
+ stable.unlink()
133
+
134
+ from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel
135
+ project_root = _find_project_root(conf_dir)
136
+ build_wheel(static_dir, pkg, project_root)
137
+
138
+
139
+ def _find_project_root(start: Path) -> Path:
140
+ """Walk up from *start* to find the directory containing pyproject.toml or setup.py.
141
+
142
+ Falls back to ``start.parent`` for backwards compatibility so behaviour is
143
+ unchanged for the common ``docs/conf.py`` layout where the project root is
144
+ directly above the docs directory.
145
+ """
146
+ markers = ("pyproject.toml", "setup.py", "setup.cfg")
147
+ current = start.resolve()
148
+ # Check start itself and each parent up to the filesystem root.
149
+ for directory in [current, *current.parents]:
150
+ if any((directory / m).exists() for m in markers):
151
+ return directory
152
+ # No marker found — fall back to conf_dir's parent (original behaviour).
153
+ return start.parent
154
+
155
+
156
+ def _infer_package_name(app) -> str | None:
157
+ """Infer package name from pyproject.toml near conf.py."""
158
+ try:
159
+ import tomllib
160
+ except ImportError:
161
+ try:
162
+ import tomli as tomllib # type: ignore[no-redef]
163
+ except ImportError:
164
+ return None
165
+ conf_dir = Path(app.confdir)
166
+ for candidate in [conf_dir / "pyproject.toml", conf_dir.parent / "pyproject.toml"]:
167
+ if candidate.exists():
168
+ with open(candidate, "rb") as fh:
169
+ data = tomllib.load(fh)
170
+ name = (
171
+ data.get("project", {}).get("name")
172
+ or data.get("tool", {}).get("poetry", {}).get("name")
173
+ )
174
+ if name:
175
+ return name
176
+ return None
177
+