stdplot 0.0.1__tar.gz

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.
stdplot-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Gabriel Lauzier
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
stdplot-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: stdplot
3
+ Version: 0.0.1
4
+ Summary: A standardize wrapper around `matplotlib` targetting a variety of canvases.
5
+ Author-email: G-Lauz <gabriel.lauzier@usherbrooke.ca>
6
+ License-Expression: MIT
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Typing :: Typed
10
+ Classifier: Framework :: Matplotlib
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: matplotlib<3.13,>=3.11.2
16
+ Requires-Dist: scienceplots>=2.2
17
+ Provides-Extra: qt
18
+ Requires-Dist: PySide6; extra == "qt"
19
+ Provides-Extra: notebook
20
+ Requires-Dist: ipython; extra == "notebook"
21
+ Provides-Extra: all
22
+ Requires-Dist: PySide6; extra == "all"
23
+ Requires-Dist: ipython; extra == "all"
24
+ Dynamic: license-file
25
+
26
+ # Standard Plot
27
+ A standardized wrapper around `matplotlib` targeting a variety of canvases.
28
+
29
+ ## Installation
30
+ Default installation is via `pip`:
31
+ ```bash
32
+ pip install stdplot
33
+ ```
34
+ For notebook and GUI support, install the optional dependencies:
35
+ ```bash
36
+ pip install stdplot[notebook,qt] # or [all]
37
+ ```
38
+
39
+
40
+ ## Disclaimer
41
+ This repository originated from a personal script I've developped on my own. Now it is rewriten heavily using `Claude` based on that previous work. It is mostly a project use by me as introduction and exploration of the capabilities of such tools in my workflow.
@@ -0,0 +1,16 @@
1
+ # Standard Plot
2
+ A standardized wrapper around `matplotlib` targeting a variety of canvases.
3
+
4
+ ## Installation
5
+ Default installation is via `pip`:
6
+ ```bash
7
+ pip install stdplot
8
+ ```
9
+ For notebook and GUI support, install the optional dependencies:
10
+ ```bash
11
+ pip install stdplot[notebook,qt] # or [all]
12
+ ```
13
+
14
+
15
+ ## Disclaimer
16
+ This repository originated from a personal script I've developped on my own. Now it is rewriten heavily using `Claude` based on that previous work. It is mostly a project use by me as introduction and exploration of the capabilities of such tools in my workflow.
stdplot-0.0.1/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.1
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "stdplot"
7
+ authors = [
8
+ { name="G-Lauz", email="gabriel.lauzier@usherbrooke.ca" },
9
+ ]
10
+ description = "A standardize wrapper around `matplotlib` targetting a variety of canvases."
11
+ readme = "README.md"
12
+ license = "MIT"
13
+ license-files = ["LICENSE"]
14
+ requires-python = ">=3.11"
15
+ classifiers = [
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Typing :: Typed",
19
+ "Framework :: Matplotlib",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+ dynamic = ["version"]
23
+ dependencies = ["matplotlib>=3.11.2,<3.13", "scienceplots>=2.2"]
24
+
25
+ [project.optional-dependencies]
26
+ qt = ["PySide6"]
27
+ notebook = ["ipython"]
28
+ all = ["PySide6", "ipython"]
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+ include = ["stdplot*"]
33
+
34
+ [tool.setuptools.package-data]
35
+ stdplot = ["py.typed"]
36
+
37
+ [tool.setuptools.dynamic]
38
+ version = {file = "VERSION"}
39
+
40
+ [tool.ruff]
41
+ target-version = "py311"
42
+
43
+ [tool.pyright]
44
+ typeCheckingMode = "standard"
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ # tests/stdplot/ has no __init__.py; importlib mode keeps it from shadowing the package.
49
+ addopts = "--import-mode=importlib"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,47 @@
1
+ """Publication-ready matplotlib figures, drawn once and exported to every target."""
2
+
3
+ # Importing scienceplots registers its styles, which the default Style names.
4
+ import scienceplots
5
+
6
+ from stdplot.application import (
7
+ IEEE_PGF,
8
+ SCREEN,
9
+ AxesGrid,
10
+ Config,
11
+ Display,
12
+ Format,
13
+ LatexNotFoundError,
14
+ LatexSettings,
15
+ Layout,
16
+ MissingDependencyError,
17
+ MissingOutputError,
18
+ Size,
19
+ StdplotError,
20
+ Style,
21
+ Target,
22
+ UnknownStyleError,
23
+ ieee_pgf,
24
+ )
25
+ from stdplot.presentation.api import figure, plot
26
+
27
+ __all__ = [
28
+ "IEEE_PGF",
29
+ "SCREEN",
30
+ "AxesGrid",
31
+ "Config",
32
+ "Display",
33
+ "Format",
34
+ "LatexNotFoundError",
35
+ "LatexSettings",
36
+ "Layout",
37
+ "MissingDependencyError",
38
+ "MissingOutputError",
39
+ "Size",
40
+ "StdplotError",
41
+ "Style",
42
+ "Target",
43
+ "UnknownStyleError",
44
+ "figure",
45
+ "ieee_pgf",
46
+ "plot",
47
+ ]
@@ -0,0 +1,44 @@
1
+ from stdplot.domain.config import Config, PlotRequest
2
+ from stdplot.domain.errors import (
3
+ LatexNotFoundError,
4
+ MissingDependencyError,
5
+ MissingOutputError,
6
+ StdplotError,
7
+ UnknownStyleError,
8
+ )
9
+ from stdplot.domain.format import Display, Format, Layout
10
+ from stdplot.domain.style import LatexSettings, Style
11
+ from stdplot.domain.target import IEEE_PGF, SCREEN, Size, Target, ieee_pgf
12
+
13
+ from .axes import AxesGrid
14
+ from .environment import Environment, detect
15
+ from .exporter import export
16
+ from .session import Displayer, check_styles, resolve_style, session
17
+
18
+ __all__ = [
19
+ "IEEE_PGF",
20
+ "SCREEN",
21
+ "AxesGrid",
22
+ "Config",
23
+ "Display",
24
+ "Displayer",
25
+ "Environment",
26
+ "Format",
27
+ "LatexNotFoundError",
28
+ "LatexSettings",
29
+ "Layout",
30
+ "MissingDependencyError",
31
+ "MissingOutputError",
32
+ "PlotRequest",
33
+ "Size",
34
+ "StdplotError",
35
+ "Style",
36
+ "Target",
37
+ "UnknownStyleError",
38
+ "check_styles",
39
+ "detect",
40
+ "export",
41
+ "ieee_pgf",
42
+ "resolve_style",
43
+ "session",
44
+ ]
@@ -0,0 +1,65 @@
1
+ """Typed access to the grid of axes of a figure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import typing
7
+
8
+ if typing.TYPE_CHECKING:
9
+ from matplotlib.axes import Axes
10
+ from matplotlib.figure import Figure
11
+
12
+
13
+ @dataclasses.dataclass(frozen=True)
14
+ class AxesGrid:
15
+ """The axes of a figure, by row then column.
16
+
17
+ Attributes:
18
+ rows: One tuple per row, holding the axes of each column.
19
+ """
20
+
21
+ rows: tuple[tuple[Axes, ...], ...]
22
+
23
+ @classmethod
24
+ def from_figure(cls, figure: Figure, nrows: int, ncols: int) -> AxesGrid:
25
+ """Add an `nrows` by `ncols` grid of axes to the figure.
26
+
27
+ Args:
28
+ figure: Figure the axes are added to.
29
+ nrows: Number of rows.
30
+ ncols: Number of columns.
31
+
32
+ Returns:
33
+ The grid of the added axes.
34
+ """
35
+ # squeeze=False keeps the array 2-D even when nrows or ncols is 1.
36
+ array = figure.subplots(nrows, ncols, squeeze=False)
37
+ return cls(tuple(tuple(row) for row in array))
38
+
39
+ @property
40
+ def shape(self) -> tuple[int, int]:
41
+ """Number of rows and columns."""
42
+ return (len(self.rows), len(self.rows[0]))
43
+
44
+ @property
45
+ def flat(self) -> tuple[Axes, ...]:
46
+ """Every axes, row by row."""
47
+ return tuple(axes for row in self.rows for axes in row)
48
+
49
+ @property
50
+ def single(self) -> Axes:
51
+ """The only axes of a 1x1 grid.
52
+
53
+ Raises:
54
+ ValueError: The grid holds more than one axes.
55
+ """
56
+ is_single = self.shape == (1, 1)
57
+ if not is_single:
58
+ nrows, ncols = self.shape
59
+ raise ValueError(f"The grid holds {nrows}x{ncols} axes, not a single one.")
60
+ return self.rows[0][0]
61
+
62
+ def __getitem__(self, position: tuple[int, int]) -> Axes:
63
+ """Return the axes at `(row, column)`."""
64
+ row, col = position
65
+ return self.rows[row][col]
@@ -0,0 +1,44 @@
1
+ """Detection of what the running process can do."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import shutil
7
+
8
+ try:
9
+ import IPython
10
+ except ImportError: # stdplot[notebook] is not installed, so no kernel can be running
11
+ IPython = None
12
+
13
+
14
+ @dataclasses.dataclass(frozen=True)
15
+ class Environment:
16
+ """Capabilities of the running process that change how figures are drawn and shown.
17
+
18
+ Attributes:
19
+ latex_available: A `latex` binary is on the PATH.
20
+ in_notebook: The process is a Jupyter kernel, so figures can be shown inline.
21
+ """
22
+
23
+ latex_available: bool
24
+ in_notebook: bool
25
+
26
+
27
+ def detect() -> Environment:
28
+ """Inspect the running process.
29
+
30
+ Returns:
31
+ The detected environment.
32
+ """
33
+ return Environment(
34
+ latex_available=shutil.which("latex") is not None,
35
+ in_notebook=_in_notebook(),
36
+ )
37
+
38
+
39
+ def _in_notebook() -> bool:
40
+ if IPython is None:
41
+ return False
42
+ shell = IPython.get_ipython()
43
+ # A terminal IPython also has a shell, but only a kernel renders inline images.
44
+ return shell is not None and "IPKernelApp" in shell.config
@@ -0,0 +1,45 @@
1
+ """Export of a figure to its targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing
6
+
7
+ if typing.TYPE_CHECKING:
8
+ import pathlib
9
+
10
+ from matplotlib.figure import Figure
11
+
12
+ from stdplot.domain.target import Target
13
+
14
+
15
+ def export(
16
+ figure: Figure, targets: tuple[Target, ...], output_dir: pathlib.Path, name: str
17
+ ) -> tuple[pathlib.Path, ...]:
18
+ """Write the figure once per target, resized to each target's size.
19
+
20
+ The figure gets its size back afterwards, so exporting does not change what is
21
+ shown next.
22
+
23
+ Args:
24
+ figure: Figure to export.
25
+ targets: Files to write.
26
+ output_dir: Directory the files are written to; created when missing.
27
+ name: Name of the figure, used as the file stem.
28
+
29
+ Returns:
30
+ The written paths, in the order of the targets.
31
+ """
32
+ output_dir.mkdir(parents=True, exist_ok=True)
33
+ width, height = figure.get_size_inches()
34
+ try:
35
+ return tuple(
36
+ _write(figure, target, output_dir / target.filename(name)) for target in targets
37
+ )
38
+ finally:
39
+ figure.set_size_inches(width, height)
40
+
41
+
42
+ def _write(figure: Figure, target: Target, path: pathlib.Path) -> pathlib.Path:
43
+ figure.set_size_inches(target.size.as_tuple())
44
+ figure.savefig(path, format=target.format.value, dpi=target.dpi)
45
+ return path
@@ -0,0 +1,114 @@
1
+ """The drawing session: style a figure, hand it to the caller, then export and show it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import typing
7
+ import warnings
8
+
9
+ import matplotlib.style
10
+ from matplotlib.figure import Figure
11
+
12
+ from stdplot.domain.errors import LatexNotFoundError, UnknownStyleError
13
+
14
+ from .axes import AxesGrid
15
+ from .exporter import export
16
+
17
+ if typing.TYPE_CHECKING:
18
+ import collections.abc
19
+
20
+ from stdplot.domain.config import PlotRequest
21
+ from stdplot.domain.style import Style
22
+
23
+ from .environment import Environment
24
+
25
+
26
+ class Displayer(typing.Protocol):
27
+ """Shows a finished figure to the user."""
28
+
29
+ def show(self, figure: Figure, title: str) -> None:
30
+ """Show the figure.
31
+
32
+ Args:
33
+ figure: Figure to show.
34
+ title: Title of the figure, such as its name.
35
+ """
36
+
37
+
38
+ def resolve_style(request: PlotRequest, environment: Environment) -> Style:
39
+ """Pick the style to draw with, falling back to mathtext when LaTeX is missing.
40
+
41
+ Args:
42
+ request: The figure to draw.
43
+ environment: What the running process can do.
44
+
45
+ Returns:
46
+ The configured style, or a copy without LaTeX when it is not installed.
47
+
48
+ Raises:
49
+ LatexNotFoundError: A target needs LaTeX and it is not installed.
50
+ """
51
+ style = request.config.style
52
+ if environment.latex_available:
53
+ return style
54
+ if request.needs_latex:
55
+ raise LatexNotFoundError
56
+ if not style.latex.usetex:
57
+ return style
58
+ warnings.warn(
59
+ "LaTeX is not installed; rendering text with matplotlib's mathtext instead.",
60
+ stacklevel=2,
61
+ )
62
+ return style.without_latex()
63
+
64
+
65
+ def check_styles(style: Style) -> None:
66
+ """Check that matplotlib knows every style name.
67
+
68
+ Args:
69
+ style: The style to check.
70
+
71
+ Raises:
72
+ UnknownStyleError: Some names are not registered with matplotlib.
73
+ """
74
+ unknown = tuple(name for name in style.names if name not in matplotlib.style.available)
75
+ has_unknown = bool(unknown)
76
+ if has_unknown:
77
+ raise UnknownStyleError(unknown)
78
+
79
+
80
+ @contextlib.contextmanager
81
+ def session(
82
+ request: PlotRequest, displayer: Displayer, environment: Environment
83
+ ) -> collections.abc.Iterator[tuple[Figure, AxesGrid]]:
84
+ """Draw one figure under the configured style, then export and show it.
85
+
86
+ The style wraps the caller's drawing, not only the export, because matplotlib reads
87
+ fonts and LaTeX settings when each artist is created. An error raised while drawing
88
+ skips both the export and the display.
89
+
90
+ Args:
91
+ request: The figure to draw.
92
+ displayer: Shows the figure once drawn, when the request asks for it.
93
+ environment: What the running process can do.
94
+
95
+ Yields:
96
+ The figure, sized to the preview target, and its grid of axes.
97
+
98
+ Raises:
99
+ LatexNotFoundError: A target needs LaTeX and it is not installed.
100
+ UnknownStyleError: Some style names are not registered with matplotlib.
101
+ """
102
+ style = resolve_style(request, environment)
103
+ check_styles(style)
104
+ config = request.config
105
+ with matplotlib.style.context(style.to_rc_list()):
106
+ # The ieee style raises figure.dpi to 600; the preview dpi keeps windows usable.
107
+ figure = Figure(figsize=config.preview.size.as_tuple(), dpi=config.preview.dpi)
108
+ figure.set_layout_engine(config.layout.value)
109
+ grid = AxesGrid.from_figure(figure, request.nrows, request.ncols)
110
+ yield figure, grid
111
+ if request.save:
112
+ export(figure, config.targets, request.output_dir, request.name)
113
+ if request.show:
114
+ displayer.show(figure, request.name)
File without changes
@@ -0,0 +1,84 @@
1
+ """Configuration of how figures are drawn, exported and shown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import typing
7
+
8
+ from .errors import MissingOutputError
9
+ from .format import Display, Layout
10
+ from .style import Style
11
+ from .target import IEEE_PGF, SCREEN, Target
12
+
13
+ if typing.TYPE_CHECKING:
14
+ import pathlib
15
+
16
+
17
+ @dataclasses.dataclass(frozen=True)
18
+ class Config:
19
+ """Settings shared by every figure drawn with it.
20
+
21
+ Attributes:
22
+ style: Style applied while the figure is drawn.
23
+ targets: Files written when saving.
24
+ preview: Size, resolution and format the figure is drawn and shown at.
25
+ output_dir: Directory the targets are written to; required when saving.
26
+ layout: Layout engine re-run before every export.
27
+ display: Where a shown figure appears.
28
+ """
29
+
30
+ style: Style = dataclasses.field(default_factory=Style)
31
+ targets: tuple[Target, ...] = (IEEE_PGF, SCREEN)
32
+ preview: Target = SCREEN
33
+ output_dir: pathlib.Path | None = None
34
+ layout: Layout = Layout.TIGHT
35
+ display: Display = Display.AUTO
36
+
37
+
38
+ @dataclasses.dataclass(frozen=True)
39
+ class PlotRequest:
40
+ """One figure to draw: its config, name, axes grid and what happens once it is drawn."""
41
+
42
+ config: Config
43
+ name: str
44
+ nrows: int
45
+ ncols: int
46
+ save: bool
47
+ show: bool
48
+
49
+ def __post_init__(self) -> None:
50
+ """Reject requests that cannot be drawn or saved.
51
+
52
+ Raises:
53
+ ValueError: The name is empty or the grid has fewer than one row or column.
54
+ MissingOutputError: Saving without an output directory or without targets.
55
+ """
56
+ has_name = bool(self.name)
57
+ if not has_name:
58
+ raise ValueError("A figure needs a non-empty name.")
59
+
60
+ has_axes = self.nrows >= 1 and self.ncols >= 1
61
+ if not has_axes:
62
+ raise ValueError(f"A figure needs at least 1x1 axes, got {self.nrows}x{self.ncols}.")
63
+
64
+ if self.save and self.config.output_dir is None:
65
+ raise MissingOutputError("an output_dir")
66
+ if self.save and not self.config.targets:
67
+ raise MissingOutputError("at least one target")
68
+
69
+ @property
70
+ def output_dir(self) -> pathlib.Path:
71
+ """Directory the targets are written to.
72
+
73
+ Raises:
74
+ MissingOutputError: The config has no output directory.
75
+ """
76
+ output_dir = self.config.output_dir
77
+ if output_dir is None:
78
+ raise MissingOutputError("an output_dir")
79
+ return output_dir
80
+
81
+ @property
82
+ def needs_latex(self) -> bool:
83
+ """Whether drawing this request needs a LaTeX installation."""
84
+ return self.save and any(target.requires_latex for target in self.config.targets)
@@ -0,0 +1,61 @@
1
+ """Exceptions raised by stdplot."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class StdplotError(Exception):
7
+ """Base class of every error raised by stdplot."""
8
+
9
+
10
+ class LatexNotFoundError(StdplotError):
11
+ """A target needs LaTeX, but no LaTeX installation is on the PATH."""
12
+
13
+ def __init__(self) -> None:
14
+ """Build the message explaining why LaTeX is needed."""
15
+ super().__init__(
16
+ "A PGF target needs a LaTeX installation, but `latex` is not on the PATH. "
17
+ "Install a TeX distribution or remove the PGF targets from the config."
18
+ )
19
+
20
+
21
+ class MissingOutputError(StdplotError, ValueError):
22
+ """Saving was requested, but the config does not say where or as what."""
23
+
24
+ def __init__(self, missing: str) -> None:
25
+ """Build the message naming the missing setting.
26
+
27
+ Args:
28
+ missing: What the config lacks, such as "an output_dir".
29
+ """
30
+ super().__init__(f"Saving a figure needs {missing} in the config.")
31
+
32
+
33
+ class MissingDependencyError(StdplotError, ImportError):
34
+ """A feature needs an optional dependency that is not installed."""
35
+
36
+ def __init__(self, extra: str) -> None:
37
+ """Build the message naming the extra to install.
38
+
39
+ Args:
40
+ extra: Name of the stdplot extra providing the dependency, such as "qt".
41
+ """
42
+ super().__init__(
43
+ f'This feature needs an optional dependency: pip install "stdplot[{extra}]"'
44
+ )
45
+ self.extra = extra
46
+
47
+
48
+ class UnknownStyleError(StdplotError):
49
+ """A style name is not registered with matplotlib."""
50
+
51
+ def __init__(self, names: tuple[str, ...]) -> None:
52
+ """Build the message listing the unknown styles.
53
+
54
+ Args:
55
+ names: The style names matplotlib does not know.
56
+ """
57
+ super().__init__(
58
+ f"Unknown matplotlib style(s): {', '.join(names)}. "
59
+ "See matplotlib.style.available for the registered names."
60
+ )
61
+ self.names = names
@@ -0,0 +1,30 @@
1
+ """Enumerations of the export formats, layout engines and display backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+
7
+
8
+ class Format(enum.Enum):
9
+ """File format of an export; the value is both the extension and matplotlib's name."""
10
+
11
+ PGF = "pgf"
12
+ SVG = "svg"
13
+ PNG = "png"
14
+ PDF = "pdf"
15
+
16
+
17
+ class Layout(enum.Enum):
18
+ """Layout engine re-run before every export; the value is matplotlib's name."""
19
+
20
+ TIGHT = "tight"
21
+ CONSTRAINED = "constrained"
22
+ NONE = "none"
23
+
24
+
25
+ class Display(enum.Enum):
26
+ """Where a shown figure appears."""
27
+
28
+ AUTO = "auto"
29
+ NOTEBOOK = "notebook"
30
+ QT = "qt"
@@ -0,0 +1,49 @@
1
+ """The visual style applied while a figure is drawn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+
7
+ NO_LATEX_STYLE = "no-latex"
8
+
9
+
10
+ @dataclasses.dataclass(frozen=True)
11
+ class LatexSettings:
12
+ """LaTeX rendering settings, applied on top of the named styles."""
13
+
14
+ texsystem: str = "pdflatex"
15
+ font_family: str = "serif"
16
+ usetex: bool = True
17
+ rcfonts: bool = False
18
+
19
+ def to_rc(self) -> dict[str, object]:
20
+ """Translate the settings to matplotlib rcParams."""
21
+ return {
22
+ "pgf.texsystem": self.texsystem,
23
+ "font.family": self.font_family,
24
+ "text.usetex": self.usetex,
25
+ "pgf.rcfonts": self.rcfonts,
26
+ # The science style crops exports to a tight bbox, which would make a PGF
27
+ # narrower than the width its target was sized to fit.
28
+ "savefig.bbox": "standard",
29
+ }
30
+
31
+
32
+ @dataclasses.dataclass(frozen=True)
33
+ class Style:
34
+ """Named matplotlib styles plus the LaTeX settings layered over them."""
35
+
36
+ names: tuple[str, ...] = ("science", "grid", "ieee")
37
+ latex: LatexSettings = LatexSettings()
38
+
39
+ def without_latex(self) -> Style:
40
+ """Return a copy that renders text with matplotlib's mathtext instead of LaTeX."""
41
+ return dataclasses.replace(
42
+ self,
43
+ names=(*self.names, NO_LATEX_STYLE),
44
+ latex=dataclasses.replace(self.latex, usetex=False),
45
+ )
46
+
47
+ def to_rc_list(self) -> list[str | dict[str, object]]:
48
+ """Return the style in the form `matplotlib.style.context` accepts."""
49
+ return [*self.names, self.latex.to_rc()]
@@ -0,0 +1,87 @@
1
+ """Export targets: what format a figure is written to, and at what size."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+
7
+ from .format import Format
8
+
9
+ IEEE_COLUMN_WIDTH_IN = 3.40067
10
+
11
+
12
+ @dataclasses.dataclass(frozen=True)
13
+ class Size:
14
+ """Physical size of a figure, in inches."""
15
+
16
+ width: float
17
+ height: float
18
+
19
+ def __post_init__(self) -> None:
20
+ """Reject sizes matplotlib cannot draw.
21
+
22
+ Raises:
23
+ ValueError: The width or the height is not positive.
24
+ """
25
+ is_positive = self.width > 0 and self.height > 0
26
+ if not is_positive:
27
+ raise ValueError(f"A size must be positive, got {self.width} x {self.height} in.")
28
+
29
+ @classmethod
30
+ def from_width(cls, width: float, aspect: float) -> Size:
31
+ """Build a size from its width and its height-to-width ratio.
32
+
33
+ Args:
34
+ width: Width, in inches.
35
+ aspect: Height divided by width.
36
+
37
+ Returns:
38
+ The size.
39
+ """
40
+ return cls(width, width * aspect)
41
+
42
+ def as_tuple(self) -> tuple[float, float]:
43
+ """Return the size as the `(width, height)` pair matplotlib expects."""
44
+ return (self.width, self.height)
45
+
46
+
47
+ @dataclasses.dataclass(frozen=True)
48
+ class Target:
49
+ """One file a figure is exported to."""
50
+
51
+ format: Format
52
+ size: Size
53
+ dpi: int = 300
54
+
55
+ @property
56
+ def requires_latex(self) -> bool:
57
+ """Whether writing this target needs a LaTeX installation."""
58
+ return self.format is Format.PGF
59
+
60
+ def filename(self, name: str) -> str:
61
+ """Name the file this target writes for a figure.
62
+
63
+ Args:
64
+ name: Name of the figure.
65
+
66
+ Returns:
67
+ The file name, with the format's extension.
68
+ """
69
+ return f"{name}.{self.format.value}"
70
+
71
+
72
+ def ieee_pgf(scale: float = 1.0, aspect: float = 6 / 8, dpi: int = 300) -> Target:
73
+ """Build a PGF target sized to a fraction of an IEEE column.
74
+
75
+ Args:
76
+ scale: Fraction of the column width the figure spans.
77
+ aspect: Height divided by width.
78
+ dpi: Resolution of the raster elements embedded in the PGF.
79
+
80
+ Returns:
81
+ The PGF target.
82
+ """
83
+ return Target(Format.PGF, Size.from_width(IEEE_COLUMN_WIDTH_IN * scale, aspect), dpi)
84
+
85
+
86
+ IEEE_PGF = ieee_pgf()
87
+ SCREEN = Target(Format.SVG, Size(8.0, 6.0), dpi=100)
File without changes
@@ -0,0 +1,204 @@
1
+ """The public drawing API: the `figure` context manager and the `plot` decorator.
2
+
3
+ Without `nrows` and `ncols`, the drawing code receives the figure's single `Axes`.
4
+ With either of them it receives an `AxesGrid`, even for a 1x1 grid, so the type of the
5
+ axes follows from the call alone.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import functools
12
+ import typing
13
+
14
+ from stdplot.application import Config, PlotRequest, detect, session
15
+
16
+ from .display import select_displayer
17
+
18
+ if typing.TYPE_CHECKING:
19
+ import collections.abc
20
+
21
+ from matplotlib.axes import Axes
22
+ from matplotlib.figure import Figure
23
+
24
+ from stdplot.application import AxesGrid
25
+
26
+ P = typing.ParamSpec("P")
27
+ R = typing.TypeVar("R")
28
+
29
+ DEFAULT_CONFIG = Config()
30
+
31
+
32
+ @typing.overload
33
+ def figure(
34
+ name: str,
35
+ *,
36
+ nrows: None = None,
37
+ ncols: None = None,
38
+ config: Config = DEFAULT_CONFIG,
39
+ save: bool = False,
40
+ show: bool = True,
41
+ ) -> contextlib.AbstractContextManager[tuple[Figure, Axes]]: ...
42
+ @typing.overload
43
+ def figure(
44
+ name: str,
45
+ *,
46
+ nrows: int,
47
+ ncols: int | None = None,
48
+ config: Config = DEFAULT_CONFIG,
49
+ save: bool = False,
50
+ show: bool = True,
51
+ ) -> contextlib.AbstractContextManager[tuple[Figure, AxesGrid]]: ...
52
+ @typing.overload
53
+ def figure(
54
+ name: str,
55
+ *,
56
+ nrows: None = None,
57
+ ncols: int,
58
+ config: Config = DEFAULT_CONFIG,
59
+ save: bool = False,
60
+ show: bool = True,
61
+ ) -> contextlib.AbstractContextManager[tuple[Figure, AxesGrid]]: ...
62
+ def figure(
63
+ name: str,
64
+ *,
65
+ nrows: int | None = None,
66
+ ncols: int | None = None,
67
+ config: Config = DEFAULT_CONFIG,
68
+ save: bool = False,
69
+ show: bool = True,
70
+ ) -> contextlib.AbstractContextManager[tuple[Figure, Axes | AxesGrid]]:
71
+ """Open a styled figure, then export and show it when the block exits.
72
+
73
+ Args:
74
+ name: Name of the figure, used as the file stem and the window title.
75
+ nrows: Number of rows of axes; set it or `ncols` to receive an `AxesGrid`.
76
+ ncols: Number of columns of axes; set it or `nrows` to receive an `AxesGrid`.
77
+ config: Style, targets, preview and display of the figure.
78
+ save: Write the figure to every target of the config on exit.
79
+ show: Display the figure on exit.
80
+
81
+ Returns:
82
+ A context manager yielding the figure and its axes.
83
+
84
+ Raises:
85
+ ValueError: The name is empty or the grid has fewer than one row or column.
86
+ MissingOutputError: Saving without an output directory or without targets.
87
+ """
88
+ request = _request(name, nrows, ncols, config, save=save, show=show)
89
+ return _draw(request, single=nrows is None and ncols is None)
90
+
91
+
92
+ @typing.overload
93
+ def plot(
94
+ name: str | None = None,
95
+ *,
96
+ nrows: None = None,
97
+ ncols: None = None,
98
+ config: Config = DEFAULT_CONFIG,
99
+ save: bool = False,
100
+ show: bool = True,
101
+ ) -> collections.abc.Callable[
102
+ [collections.abc.Callable[typing.Concatenate[Figure, Axes, P], R]],
103
+ collections.abc.Callable[P, R],
104
+ ]: ...
105
+ @typing.overload
106
+ def plot(
107
+ name: str | None = None,
108
+ *,
109
+ nrows: int,
110
+ ncols: int | None = None,
111
+ config: Config = DEFAULT_CONFIG,
112
+ save: bool = False,
113
+ show: bool = True,
114
+ ) -> collections.abc.Callable[
115
+ [collections.abc.Callable[typing.Concatenate[Figure, AxesGrid, P], R]],
116
+ collections.abc.Callable[P, R],
117
+ ]: ...
118
+ @typing.overload
119
+ def plot(
120
+ name: str | None = None,
121
+ *,
122
+ nrows: None = None,
123
+ ncols: int,
124
+ config: Config = DEFAULT_CONFIG,
125
+ save: bool = False,
126
+ show: bool = True,
127
+ ) -> collections.abc.Callable[
128
+ [collections.abc.Callable[typing.Concatenate[Figure, AxesGrid, P], R]],
129
+ collections.abc.Callable[P, R],
130
+ ]: ...
131
+ def plot(
132
+ name: str | None = None,
133
+ *,
134
+ nrows: int | None = None,
135
+ ncols: int | None = None,
136
+ config: Config = DEFAULT_CONFIG,
137
+ save: bool = False,
138
+ show: bool = True,
139
+ ) -> collections.abc.Callable[
140
+ [collections.abc.Callable[typing.Concatenate[Figure, Axes | AxesGrid, P], R]],
141
+ collections.abc.Callable[P, R],
142
+ ]:
143
+ """Make a drawing function open, export and show its own figure on every call.
144
+
145
+ The function receives the figure and its axes ahead of its own arguments. Calling
146
+ the decorated function draws the figure and returns what the function returns.
147
+
148
+ Args:
149
+ name: Name of the figure; defaults to the name of the function.
150
+ nrows: Number of rows of axes; set it or `ncols` to receive an `AxesGrid`.
151
+ ncols: Number of columns of axes; set it or `nrows` to receive an `AxesGrid`.
152
+ config: Style, targets, preview and display of the figure.
153
+ save: Write the figure to every target of the config after each call.
154
+ show: Display the figure after each call.
155
+
156
+ Returns:
157
+ The decorator. It raises `ValueError` or `MissingOutputError` when applied, for
158
+ the same reasons as `figure`.
159
+ """
160
+ single = nrows is None and ncols is None
161
+
162
+ def decorator(
163
+ func: collections.abc.Callable[typing.Concatenate[Figure, Axes | AxesGrid, P], R],
164
+ ) -> collections.abc.Callable[P, R]:
165
+ figure_name = func.__name__ if name is None else name
166
+ request = _request(figure_name, nrows, ncols, config, save=save, show=show)
167
+
168
+ @functools.wraps(func)
169
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
170
+ with _draw(request, single=single) as (fig, axes):
171
+ return func(fig, axes, *args, **kwargs)
172
+
173
+ return wrapper
174
+
175
+ return decorator
176
+
177
+
178
+ def _request(
179
+ name: str,
180
+ nrows: int | None,
181
+ ncols: int | None,
182
+ config: Config,
183
+ *,
184
+ save: bool,
185
+ show: bool,
186
+ ) -> PlotRequest:
187
+ return PlotRequest(
188
+ config=config,
189
+ name=name,
190
+ nrows=1 if nrows is None else nrows,
191
+ ncols=1 if ncols is None else ncols,
192
+ save=save,
193
+ show=show,
194
+ )
195
+
196
+
197
+ @contextlib.contextmanager
198
+ def _draw(
199
+ request: PlotRequest, *, single: bool
200
+ ) -> collections.abc.Iterator[tuple[Figure, Axes | AxesGrid]]:
201
+ environment = detect()
202
+ displayer = select_displayer(request.config.display, environment, request.config.preview)
203
+ with session(request, displayer, environment) as (fig, grid):
204
+ yield (fig, grid.single) if single else (fig, grid)
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import typing
4
+
5
+ from stdplot.application import Display
6
+
7
+ from .notebook import NotebookDisplayer
8
+ from .qt import QtDisplayer
9
+
10
+ if typing.TYPE_CHECKING:
11
+ from stdplot.application import Displayer, Environment, Target
12
+
13
+
14
+ def select_displayer(display: Display, environment: Environment, preview: Target) -> Displayer:
15
+ """Pick where a shown figure appears, resolving `Display.AUTO` from the environment.
16
+
17
+ Args:
18
+ display: The requested display.
19
+ environment: What the running process can do.
20
+ preview: Format and resolution an inline figure is rendered with.
21
+
22
+ Returns:
23
+ The notebook displayer when asked for or inside a Jupyter kernel, the Qt
24
+ displayer otherwise.
25
+ """
26
+ is_auto_notebook = display is Display.AUTO and environment.in_notebook
27
+ use_notebook = display is Display.NOTEBOOK or is_auto_notebook
28
+ if use_notebook:
29
+ return NotebookDisplayer(preview)
30
+ return QtDisplayer()
@@ -0,0 +1,53 @@
1
+ """Inline display of figures in a Jupyter notebook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import io
7
+ import typing
8
+
9
+ from stdplot.application import Format, MissingDependencyError
10
+
11
+ if typing.TYPE_CHECKING:
12
+ from matplotlib.figure import Figure
13
+
14
+ from stdplot.application import Target
15
+
16
+ try:
17
+ import IPython.display
18
+ except ImportError: # stdplot[notebook] is not installed
19
+ IPython = None
20
+
21
+
22
+ @dataclasses.dataclass(frozen=True)
23
+ class NotebookDisplayer:
24
+ """Shows figures inline, in the output of the running cell.
25
+
26
+ Attributes:
27
+ target: Format and resolution the figure is rendered with.
28
+ """
29
+
30
+ target: Target
31
+
32
+ def show(self, figure: Figure, title: str) -> None:
33
+ """Render the figure and display it below the cell.
34
+
35
+ Args:
36
+ figure: Figure to show.
37
+ title: Unused, since an inline figure has no window to title.
38
+
39
+ Raises:
40
+ MissingDependencyError: IPython is not installed.
41
+ """
42
+ if IPython is None:
43
+ raise MissingDependencyError("notebook")
44
+ buffer = io.BytesIO()
45
+ figure.savefig(buffer, format=self.target.format.value, dpi=self.target.dpi)
46
+ data = buffer.getvalue()
47
+ is_svg = self.target.format is Format.SVG
48
+ image = (
49
+ IPython.display.SVG(data=data)
50
+ if is_svg
51
+ else IPython.display.Image(data=data, format=self.target.format.value)
52
+ )
53
+ IPython.display.display(image)
@@ -0,0 +1,44 @@
1
+ """Display of figures in a Qt window with the matplotlib toolbar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing
6
+
7
+ from stdplot.application import MissingDependencyError
8
+
9
+ if typing.TYPE_CHECKING:
10
+ from matplotlib.figure import Figure
11
+
12
+ try:
13
+ from matplotlib.backends import backend_qt, backend_qtagg
14
+ except ImportError: # no Qt binding is installed: stdplot[qt] is missing
15
+ backend_qt = None
16
+ backend_qtagg = None
17
+
18
+
19
+ class QtDisplayer:
20
+ """Shows figures in a Qt window, blocking until the window is closed."""
21
+
22
+ def show(self, figure: Figure, title: str) -> None:
23
+ """Open a window on the figure and run the Qt event loop until it closes.
24
+
25
+ Args:
26
+ figure: Figure to show.
27
+ title: Title of the window.
28
+
29
+ Raises:
30
+ MissingDependencyError: No Qt binding is installed.
31
+ """
32
+ if backend_qt is None or backend_qtagg is None:
33
+ raise MissingDependencyError("qt")
34
+ previous_canvas = figure.canvas
35
+ canvas = backend_qtagg.FigureCanvasQTAgg(figure)
36
+ try:
37
+ manager = backend_qt.FigureManagerQT(canvas, 1)
38
+ manager.set_window_title(title)
39
+ manager.show()
40
+ backend_qt.FigureManagerQT.start_main_loop()
41
+ finally:
42
+ # The Qt canvas is destroyed with its window; putting the previous canvas
43
+ # back keeps the figure drawable and savable after the window is closed.
44
+ figure.set_canvas(previous_canvas)
File without changes
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: stdplot
3
+ Version: 0.0.1
4
+ Summary: A standardize wrapper around `matplotlib` targetting a variety of canvases.
5
+ Author-email: G-Lauz <gabriel.lauzier@usherbrooke.ca>
6
+ License-Expression: MIT
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Typing :: Typed
10
+ Classifier: Framework :: Matplotlib
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: matplotlib<3.13,>=3.11.2
16
+ Requires-Dist: scienceplots>=2.2
17
+ Provides-Extra: qt
18
+ Requires-Dist: PySide6; extra == "qt"
19
+ Provides-Extra: notebook
20
+ Requires-Dist: ipython; extra == "notebook"
21
+ Provides-Extra: all
22
+ Requires-Dist: PySide6; extra == "all"
23
+ Requires-Dist: ipython; extra == "all"
24
+ Dynamic: license-file
25
+
26
+ # Standard Plot
27
+ A standardized wrapper around `matplotlib` targeting a variety of canvases.
28
+
29
+ ## Installation
30
+ Default installation is via `pip`:
31
+ ```bash
32
+ pip install stdplot
33
+ ```
34
+ For notebook and GUI support, install the optional dependencies:
35
+ ```bash
36
+ pip install stdplot[notebook,qt] # or [all]
37
+ ```
38
+
39
+
40
+ ## Disclaimer
41
+ This repository originated from a personal script I've developped on my own. Now it is rewriten heavily using `Claude` based on that previous work. It is mostly a project use by me as introduction and exploration of the capabilities of such tools in my workflow.
@@ -0,0 +1,27 @@
1
+ LICENSE
2
+ README.md
3
+ VERSION
4
+ pyproject.toml
5
+ src/stdplot/__init__.py
6
+ src/stdplot/py.typed
7
+ src/stdplot.egg-info/PKG-INFO
8
+ src/stdplot.egg-info/SOURCES.txt
9
+ src/stdplot.egg-info/dependency_links.txt
10
+ src/stdplot.egg-info/requires.txt
11
+ src/stdplot.egg-info/top_level.txt
12
+ src/stdplot/application/__init__.py
13
+ src/stdplot/application/axes.py
14
+ src/stdplot/application/environment.py
15
+ src/stdplot/application/exporter.py
16
+ src/stdplot/application/session.py
17
+ src/stdplot/domain/__init__.py
18
+ src/stdplot/domain/config.py
19
+ src/stdplot/domain/errors.py
20
+ src/stdplot/domain/format.py
21
+ src/stdplot/domain/style.py
22
+ src/stdplot/domain/target.py
23
+ src/stdplot/presentation/__init__.py
24
+ src/stdplot/presentation/api.py
25
+ src/stdplot/presentation/display/__init__.py
26
+ src/stdplot/presentation/display/notebook.py
27
+ src/stdplot/presentation/display/qt.py
@@ -0,0 +1,12 @@
1
+ matplotlib<3.13,>=3.11.2
2
+ scienceplots>=2.2
3
+
4
+ [all]
5
+ PySide6
6
+ ipython
7
+
8
+ [notebook]
9
+ ipython
10
+
11
+ [qt]
12
+ PySide6
@@ -0,0 +1 @@
1
+ stdplot