gsplot 0.4.0__tar.gz → 0.4.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.
Files changed (123) hide show
  1. {gsplot-0.4.0 → gsplot-0.4.1}/PKG-INFO +1 -1
  2. {gsplot-0.4.0 → gsplot-0.4.1}/pyproject.toml +1 -1
  3. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/__init__.py +2 -0
  4. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/__init__.pyi +2 -0
  5. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy_api.py +16 -10
  6. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/root.py +1 -0
  7. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/root_api.py +65 -63
  8. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_core/plans.py +4 -3
  9. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_core/targets.py +4 -3
  10. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_core/types.py +79 -15
  11. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_figure/layout.py +5 -13
  12. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_figure/output.py +18 -9
  13. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_plot/basic.py +2 -2
  14. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_plot/colored.py +34 -4
  15. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_plot/series.py +4 -4
  16. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_style/axes.py +562 -21
  17. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_style/legends.py +77 -36
  18. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_style/panels.py +143 -12
  19. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_style/paper.py +3 -1
  20. {gsplot-0.4.0 → gsplot-0.4.1}/LICENSE +0 -0
  21. {gsplot-0.4.0 → gsplot-0.4.1}/README.md +0 -0
  22. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/__init__.py +0 -0
  23. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/config.py +0 -0
  24. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/__init__.py +0 -0
  25. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/base/__init__.py +0 -0
  26. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/base/base.py +0 -0
  27. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/base/base_alias_validator.py +0 -0
  28. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/color/__init__.py +0 -0
  29. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/color/colormap.py +0 -0
  30. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/config/__init__.py +0 -0
  31. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/config/config.py +0 -0
  32. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/data/__init__.py +0 -0
  33. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/data/load_file.py +0 -0
  34. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/figure/__init__.py +0 -0
  35. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/figure/axes.py +0 -0
  36. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/figure/axes_base.py +0 -0
  37. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/figure/axes_inset.py +0 -0
  38. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/figure/axes_range_base.py +0 -0
  39. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/figure/figure_tools.py +0 -0
  40. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/figure/show.py +0 -0
  41. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/figure/store.py +0 -0
  42. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/hello_world/__init__.py +0 -0
  43. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/hello_world/hello_world.py +0 -0
  44. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/logger.py +0 -0
  45. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/path/__init__.py +0 -0
  46. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/path/path.py +0 -0
  47. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/plot/__init__.py +0 -0
  48. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/plot/line.py +0 -0
  49. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/plot/line_base.py +0 -0
  50. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/plot/line_colormap_base.py +0 -0
  51. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/plot/line_colormap_dashed.py +0 -0
  52. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/plot/line_colormap_solid.py +0 -0
  53. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/plot/scatter.py +0 -0
  54. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/plot/scatter_colormap.py +0 -0
  55. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/style/__init__.py +0 -0
  56. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/style/graph.py +0 -0
  57. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/style/label.py +0 -0
  58. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/style/legend.py +0 -0
  59. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/style/legend_colormap.py +0 -0
  60. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/style/ticks.py +0 -0
  61. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/legacy/style/title.py +0 -0
  62. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/root_legacy.py +0 -0
  63. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_compat/shim.py +0 -0
  64. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_config/__init__.py +0 -0
  65. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_config/loader.py +0 -0
  66. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_config/model.py +0 -0
  67. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_config/schema.py +0 -0
  68. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_core/__init__.py +0 -0
  69. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_core/errors.py +0 -0
  70. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_core/numerics.py +0 -0
  71. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_core/options.py +0 -0
  72. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_core/validation.py +0 -0
  73. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_figure/__init__.py +0 -0
  74. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_figure/backend.py +0 -0
  75. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_figure/inset.py +0 -0
  76. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_io/__init__.py +0 -0
  77. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_io/arrays.py +0 -0
  78. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_io/build.py +0 -0
  79. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_io/metadata.py +0 -0
  80. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_io/paths.py +0 -0
  81. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_plot/__init__.py +0 -0
  82. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_plot/colormap.py +0 -0
  83. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_style/__init__.py +0 -0
  84. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/_style/themes.py +0 -0
  85. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/base/__init__.py +0 -0
  86. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/base/base.py +0 -0
  87. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/base/base_alias_validator.py +0 -0
  88. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/color/__init__.py +0 -0
  89. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/color/colormap.py +0 -0
  90. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/config/__init__.py +0 -0
  91. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/config/config.py +0 -0
  92. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/data/__init__.py +0 -0
  93. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/data/load_file.py +0 -0
  94. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/figure/__init__.py +0 -0
  95. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/figure/axes.py +0 -0
  96. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/figure/axes_base.py +0 -0
  97. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/figure/axes_inset.py +0 -0
  98. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/figure/axes_range_base.py +0 -0
  99. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/figure/figure_tools.py +0 -0
  100. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/figure/show.py +0 -0
  101. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/figure/store.py +0 -0
  102. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/hello_world/__init__.py +0 -0
  103. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/hello_world/hello_world.py +0 -0
  104. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/logger.py +0 -0
  105. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/path/__init__.py +0 -0
  106. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/path/path.py +0 -0
  107. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/plot/__init__.py +0 -0
  108. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/plot/line.py +0 -0
  109. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/plot/line_base.py +0 -0
  110. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/plot/line_colormap_base.py +0 -0
  111. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/plot/line_colormap_dashed.py +0 -0
  112. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/plot/line_colormap_solid.py +0 -0
  113. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/plot/scatter.py +0 -0
  114. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/plot/scatter_colormap.py +0 -0
  115. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/py.typed +0 -0
  116. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/style/__init__.py +0 -0
  117. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/style/graph.py +0 -0
  118. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/style/label.py +0 -0
  119. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/style/legend.py +0 -0
  120. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/style/legend_colormap.py +0 -0
  121. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/style/ticks.py +0 -0
  122. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/style/title.py +0 -0
  123. {gsplot-0.4.0 → gsplot-0.4.1}/src/gsplot/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gsplot
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Concise publication-quality scientific plotting built on Matplotlib
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gsplot"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "Concise publication-quality scientific plotting built on Matplotlib"
5
5
  authors = [
6
6
  { name = "Giordano Mattoni" },
@@ -75,6 +75,7 @@ if TYPE_CHECKING:
75
75
  square,
76
76
  style_axes,
77
77
  suptitle,
78
+ ticks,
78
79
  title,
79
80
  )
80
81
  from ._style.legends import cmap_legend, legend, legend_entries, legends
@@ -139,6 +140,7 @@ __all__ = [
139
140
  "title",
140
141
  "suptitle",
141
142
  "minor_ticks",
143
+ "ticks",
142
144
  "box_aspect",
143
145
  "panel_labels",
144
146
  "index",
@@ -60,6 +60,7 @@ from ._style.axes import minor_ticks as minor_ticks
60
60
  from ._style.axes import square as square
61
61
  from ._style.axes import style_axes as style_axes
62
62
  from ._style.axes import suptitle as suptitle
63
+ from ._style.axes import ticks as ticks
63
64
  from ._style.axes import title as title
64
65
  from ._style.legends import cmap_legend as cmap_legend
65
66
  from ._style.legends import legend as legend
@@ -129,6 +130,7 @@ __all__ = [
129
130
  "title",
130
131
  "suptitle",
131
132
  "minor_ticks",
133
+ "ticks",
132
134
  "box_aspect",
133
135
  "panel_labels",
134
136
  "index",
@@ -20,6 +20,7 @@ from typing import Any, Literal, cast
20
20
  import numpy as np
21
21
  from matplotlib import ticker
22
22
  from matplotlib.axes import Axes
23
+ from matplotlib.axes._base import _AxesBase
23
24
  from matplotlib.figure import Figure
24
25
  from mpl_toolkits.axes_grid1.inset_locator import inset_axes as _mpl_inset_axes
25
26
  from numpy.typing import ArrayLike, NDArray
@@ -310,16 +311,21 @@ def _legacy_limits(
310
311
  if value is None:
311
312
  return None, "linear", None, None
312
313
  if isinstance(value, (str, bytes)):
314
+ if value in ("", "*"):
315
+ return None, "linear", None, None
313
316
  raise LayoutError("legacy limits must be a finite sequence")
314
317
  values = tuple(value)
315
318
  if len(values) < 2:
316
319
  raise LayoutError("legacy limits must contain two values")
317
- try:
318
- limits = (float(values[0]), float(values[1]))
319
- except (TypeError, ValueError) as exc:
320
- raise LayoutError("legacy limits must contain finite values") from exc
321
- if not np.all(np.isfinite(limits)) or limits[0] == limits[1]:
322
- raise LayoutError("legacy limits must contain finite unequal values")
320
+ if values[0] in (None, "", "*") and values[1] in (None, "", "*"):
321
+ limits = None
322
+ else:
323
+ try:
324
+ limits = (float(cast(Any, values[0])), float(cast(Any, values[1])))
325
+ except (TypeError, ValueError) as exc:
326
+ raise LayoutError("legacy limits must contain finite values") from exc
327
+ if not np.all(np.isfinite(limits)) or limits[0] == limits[1]:
328
+ raise LayoutError("legacy limits must contain finite unequal values")
323
329
  scale = values[2] if len(values) > 2 and isinstance(values[2], str) else "linear"
324
330
  if scale not in {"linear", "log", "symlog", "logit"}:
325
331
  raise LayoutError(f"unsupported legacy scale: {scale!r}")
@@ -835,25 +841,25 @@ def legend_colormap(
835
841
  )
836
842
 
837
843
 
838
- def ticks_off(ax: Axes, mode: str = "xy") -> None:
844
+ def ticks_off(ax: Axes | _AxesBase, mode: str = "xy") -> None:
839
845
  """Adapt legacy minor-tick disabling to the canonical selector."""
840
846
 
841
847
  _warn("ticks_off", "minor_ticks")
842
848
  selected = {"xy": "both"}.get(mode, mode)
843
849
  _minor_ticks(
844
- ax,
850
+ cast(Any, ax),
845
851
  False,
846
852
  axis=cast(Literal["x", "y", "both"], selected),
847
853
  )
848
854
 
849
855
 
850
- def ticks_on(ax: Axes, mode: str = "xy") -> None:
856
+ def ticks_on(ax: Axes | _AxesBase, mode: str = "xy") -> None:
851
857
  """Adapt legacy minor-tick enabling to the canonical selector."""
852
858
 
853
859
  _warn("ticks_on", "minor_ticks")
854
860
  selected = {"xy": "both"}.get(mode, mode)
855
861
  _minor_ticks(
856
- ax,
862
+ cast(Any, ax),
857
863
  True,
858
864
  axis=cast(Literal["x", "y", "both"], selected),
859
865
  )
@@ -86,6 +86,7 @@ _CANONICAL_EXPORTS: Final[dict[str, tuple[str, str]]] = {
86
86
  "title": ("gsplot._compat.root_api", "title"),
87
87
  "suptitle": ("gsplot._style.axes", "suptitle"),
88
88
  "minor_ticks": ("gsplot._style.axes", "minor_ticks"),
89
+ "ticks": ("gsplot._style.axes", "ticks"),
89
90
  "box_aspect": ("gsplot._style.axes", "box_aspect"),
90
91
  "panel_labels": ("gsplot._style.panels", "panel_labels"),
91
92
  "index": ("gsplot._style.panels", "index"),
@@ -15,6 +15,7 @@ from typing import Any, get_type_hints
15
15
  from weakref import WeakKeyDictionary
16
16
 
17
17
  from matplotlib.axes import Axes
18
+ from matplotlib.axes._base import _AxesBase
18
19
  from matplotlib.figure import Figure
19
20
  from numpy.typing import ArrayLike
20
21
 
@@ -32,7 +33,7 @@ from .._style.legends import legend as _legend
32
33
  from .._style.paper import PAPER_CYCLE_RGBA as _LEGACY_COLORS
33
34
 
34
35
  _UNSET = object()
35
- _LEGACY_PLOT_COUNTS: WeakKeyDictionary[Axes, int] = WeakKeyDictionary()
36
+ _LEGACY_PLOT_COUNTS: WeakKeyDictionary[Axes | _AxesBase, int] = WeakKeyDictionary()
36
37
 
37
38
  _LEGACY_LEGEND_KEYS = {
38
39
  "handlers",
@@ -389,7 +390,7 @@ def scatter(
389
390
  def _legacy_label_records(value: Any) -> tuple[Any, ...] | None:
390
391
  """Recognize non-empty historical records without reading pyplot state."""
391
392
 
392
- if isinstance(value, (str, bytes, Mapping, Axes)):
393
+ if isinstance(value, (str, bytes, Mapping, Axes, _AxesBase)):
393
394
  return None
394
395
  try:
395
396
  records = tuple(value)
@@ -430,6 +431,14 @@ def label(
430
431
  pad: Any = _UNSET,
431
432
  xpad: Any = _UNSET,
432
433
  ypad: Any = _UNSET,
434
+ margin: Any = _UNSET,
435
+ xmargin: Any = _UNSET,
436
+ ymargin: Any = _UNSET,
437
+ top: Any = _UNSET,
438
+ bottom: Any = _UNSET,
439
+ left: Any = _UNSET,
440
+ right: Any = _UNSET,
441
+ direction: Any = _UNSET,
433
442
  square: Any = _UNSET,
434
443
  index: Any = _UNSET,
435
444
  lab_lims: Any = _UNSET,
@@ -536,6 +545,14 @@ def label(
536
545
  "pad": pad,
537
546
  "xpad": xpad,
538
547
  "ypad": ypad,
548
+ "margin": margin,
549
+ "xmargin": xmargin,
550
+ "ymargin": ymargin,
551
+ "top": top,
552
+ "bottom": bottom,
553
+ "left": left,
554
+ "right": right,
555
+ "direction": direction,
539
556
  "square": square,
540
557
  "index": index,
541
558
  }
@@ -559,6 +576,14 @@ def label(
559
576
  "pad": pad,
560
577
  "xpad": xpad,
561
578
  "ypad": ypad,
579
+ "margin": margin,
580
+ "xmargin": xmargin,
581
+ "ymargin": ymargin,
582
+ "top": top,
583
+ "bottom": bottom,
584
+ "left": left,
585
+ "right": right,
586
+ "direction": direction,
562
587
  "square": square,
563
588
  "index": index,
564
589
  }
@@ -677,11 +702,41 @@ def legend(
677
702
  ) -> Any:
678
703
  """Dispatch canonical ``legend`` or finite legacy options."""
679
704
 
680
- legacy = _provided(
705
+ is_legacy = (
706
+ legacy_handles is not _UNSET
707
+ or legacy_labels is not _UNSET
708
+ or handlers is not _UNSET
709
+ or ncol is not _UNSET
710
+ )
711
+ if is_legacy:
712
+ if (legacy_handles is not _UNSET or legacy_labels is not _UNSET) and (
713
+ handles is not None or labels is not None
714
+ ):
715
+ raise OptionError("legend cannot combine positional and canonical entries")
716
+ if handlers is not _UNSET:
717
+ if handler_map is not None:
718
+ raise OptionError("legend cannot combine handlers and handler_map")
719
+ handler_map = handlers
720
+ if ncol is not _UNSET:
721
+ if ncols is not _UNSET:
722
+ raise OptionError(
723
+ "legend: props cannot contain both 'ncol' and 'ncols'"
724
+ )
725
+ ncols = ncol
726
+ if legacy_handles is not _UNSET:
727
+ handles = legacy_handles
728
+ if legacy_labels is not _UNSET:
729
+ labels = legacy_labels
730
+ _warn("legend")
731
+
732
+ direct = _provided(
681
733
  {
682
- "handlers": handlers,
734
+ "loc": loc,
735
+ "frameon": frameon,
736
+ "fancybox": fancybox,
737
+ "labelspacing": labelspacing,
738
+ "handlelength": handlelength,
683
739
  "ncols": ncols,
684
- "ncol": ncol,
685
740
  "fontsize": fontsize,
686
741
  "title": title,
687
742
  "title_fontsize": title_fontsize,
@@ -702,59 +757,6 @@ def legend(
702
757
  "labelcolor": labelcolor,
703
758
  }
704
759
  )
705
- if legacy_handles is not _UNSET:
706
- legacy["_legacy_handles"] = legacy_handles
707
- if legacy_labels is not _UNSET:
708
- legacy["_legacy_labels"] = legacy_labels
709
- if (legacy_handles is not _UNSET or legacy_labels is not _UNSET) and (
710
- handles is not None or labels is not None
711
- ):
712
- raise OptionError("legend cannot combine positional and canonical entries")
713
- if legacy and props is not None:
714
- raise OptionError(
715
- "gsplot.legend cannot combine canonical props with legacy options"
716
- )
717
- direct = _provided(
718
- {
719
- "loc": loc,
720
- "frameon": frameon,
721
- "fancybox": fancybox,
722
- "labelspacing": labelspacing,
723
- "handlelength": handlelength,
724
- }
725
- )
726
- if not legacy:
727
- return _legend(
728
- target,
729
- handles=handles,
730
- labels=labels,
731
- handler_map=handler_map,
732
- reverse=reverse,
733
- replace=replace,
734
- props=props,
735
- **direct,
736
- )
737
- positional_handles = legacy.pop("_legacy_handles", _UNSET)
738
- positional_labels = legacy.pop("_legacy_labels", _UNSET)
739
- if handlers is not _UNSET:
740
- if handler_map is not None:
741
- raise OptionError("legend cannot combine handlers and handler_map")
742
- handler_map = handlers
743
- legacy.pop("handlers", None)
744
- translated = _translate_props(
745
- "legend",
746
- legacy,
747
- {"ncol": "ncols"},
748
- )
749
- if positional_handles is not _UNSET:
750
- if handles is not None:
751
- raise OptionError("legend received handles twice")
752
- handles = positional_handles
753
- if positional_labels is not _UNSET:
754
- if labels is not None:
755
- raise OptionError("legend received labels twice")
756
- labels = positional_labels
757
- _warn("legend")
758
760
  return _legend(
759
761
  target,
760
762
  handles=handles,
@@ -762,7 +764,7 @@ def legend(
762
764
  handler_map=handler_map,
763
765
  reverse=reverse,
764
766
  replace=replace,
765
- props=translated,
767
+ props=props,
766
768
  **direct,
767
769
  )
768
770
 
@@ -834,10 +836,10 @@ def title(
834
836
  "y": y,
835
837
  }
836
838
  )
837
- if isinstance(ax, Axes):
838
- if text is _UNSET or title is not _UNSET or legacy:
839
- raise OptionError("canonical title requires text and props")
840
- return _title(ax, text, props=props)
839
+ if isinstance(ax, (Axes, _AxesBase)):
840
+ if text is _UNSET or title is not _UNSET:
841
+ raise OptionError("canonical title requires an explicit text value")
842
+ return _title(ax, text, props=props, **legacy)
841
843
  if text is not _UNSET:
842
844
  raise TypeError("legacy Figure title accepts one text value")
843
845
  if title is not _UNSET:
@@ -7,6 +7,7 @@ from dataclasses import dataclass
7
7
  from typing import Any, Generic, Literal, TypeVar
8
8
 
9
9
  from matplotlib.axes import Axes
10
+ from matplotlib.axes._base import _AxesBase
10
11
  from matplotlib.figure import Figure
11
12
 
12
13
  from .errors import PlotError
@@ -24,7 +25,7 @@ def _operation_name(value: str) -> str:
24
25
  return value
25
26
 
26
27
 
27
- def _axis_root_figure(axis: Axes) -> Figure | None:
28
+ def _axis_root_figure(axis: Any) -> Figure | None:
28
29
  """Return an Axes root Figure without requiring ``root=True`` support."""
29
30
 
30
31
  owner: Any = axis.get_figure()
@@ -47,7 +48,7 @@ class TargetPlan:
47
48
 
48
49
  operation: str
49
50
  figure: Figure
50
- axes: tuple[Axes, ...]
51
+ axes: tuple[Axes | _AxesBase, ...]
51
52
  keys: tuple[object, ...]
52
53
  kind: TargetKind
53
54
 
@@ -61,7 +62,7 @@ class TargetPlan:
61
62
  raise PlotError(f"{self.operation}: target plan is empty or incomplete")
62
63
  if self.kind not in {"single", "sequence", "array", "mapping"}:
63
64
  raise PlotError(f"{self.operation}: target kind is invalid")
64
- if any(not isinstance(axis, Axes) for axis in self.axes):
65
+ if any(not isinstance(axis, (Axes, _AxesBase)) for axis in self.axes):
65
66
  raise PlotError(f"{self.operation}: target contains a non-Axes value")
66
67
  if len({id(axis) for axis in self.axes}) != len(self.axes):
67
68
  raise PlotError(f"{self.operation}: target contains a duplicate Axes")
@@ -7,6 +7,7 @@ from typing import Any, TypeVar
7
7
 
8
8
  import numpy as np
9
9
  from matplotlib.axes import Axes
10
+ from matplotlib.axes._base import _AxesBase
10
11
  from matplotlib.figure import Figure
11
12
 
12
13
  from .errors import PlotError
@@ -16,7 +17,7 @@ from .types import AxesTarget
16
17
  T = TypeVar("T")
17
18
 
18
19
 
19
- def _root_figure(axis: Axes, operation: str) -> Figure:
20
+ def _root_figure(axis: Axes | _AxesBase, operation: str) -> Figure:
20
21
  """Resolve an Axes root Figure without version-specific root arguments."""
21
22
 
22
23
  owner = _axis_root_figure(axis)
@@ -30,7 +31,7 @@ def _snapshot_target(
30
31
  ) -> tuple[TargetKind, tuple[object, ...], tuple[Any, ...]]:
31
32
  """Snapshot one supported target shape without retaining its container."""
32
33
 
33
- if isinstance(target, Axes):
34
+ if isinstance(target, (Axes, _AxesBase)):
34
35
  return "single", (target,), (target,)
35
36
  if isinstance(target, Mapping):
36
37
  items = tuple(target.items())
@@ -67,7 +68,7 @@ def normalize_axes(target: AxesTarget, *, operation: str) -> TargetPlan:
67
68
  kind, keys, values = _snapshot_target(target, operation)
68
69
  if not values:
69
70
  raise PlotError(f"{operation}: target must contain at least one Axes")
70
- if any(not isinstance(value, Axes) for value in values):
71
+ if any(not isinstance(value, (Axes, _AxesBase)) for value in values):
71
72
  raise PlotError(f"{operation}: target contains a non-Axes value")
72
73
  axes = tuple(values)
73
74
  if len({id(axis) for axis in axes}) != len(axes):
@@ -11,6 +11,7 @@ from typing import Any, Literal, Protocol, TypeAlias, cast, overload
11
11
 
12
12
  from matplotlib.artist import Artist
13
13
  from matplotlib.axes import Axes
14
+ from matplotlib.axes._base import _AxesBase
14
15
  from matplotlib.colors import is_color_like
15
16
  from matplotlib.legend_handler import HandlerBase
16
17
  from matplotlib.typing import LineStyleType, MarkerType
@@ -25,7 +26,13 @@ ColorSpec: TypeAlias = str | RGBColor
25
26
  # Public type alias: MosaicSpec; a Matplotlib mosaic string or label rows.
26
27
  MosaicSpec: TypeAlias = str | Sequence[Sequence[str | None]]
27
28
  # Public type alias: AxesTarget; one Axes or a deterministic finite collection.
28
- AxesTarget: TypeAlias = Axes | Sequence[Axes] | Mapping[Any, Axes] | NDArray[Any]
29
+ AxesTarget: TypeAlias = (
30
+ Axes
31
+ | _AxesBase
32
+ | Sequence[Axes | _AxesBase]
33
+ | Mapping[Any, Axes | _AxesBase]
34
+ | NDArray[Any]
35
+ )
29
36
  # Public type alias: PerTarget; ordered or exact-key per-target values.
30
37
  PerTarget: TypeAlias = Sequence[Any] | Mapping[Any, Any]
31
38
  # Public type alias: LineStyle; a named style or finite dash-tuple form.
@@ -45,7 +52,9 @@ StyleMode: TypeAlias = Literal["auto", "paper"] | None
45
52
  # Public type alias: ZoomCorners; two explicit parent/inset connector pairs.
46
53
  ZoomCorners: TypeAlias = tuple[tuple[int, int], tuple[int, int]]
47
54
  # Public type alias: Limit; finite two-value axis limits after validation.
48
- Limit: TypeAlias = tuple[float, float]
55
+ Limit: TypeAlias = (
56
+ tuple[float | str | None, float | str | None] | Sequence[float | str | None]
57
+ )
49
58
  # Public type alias: Scale; supported Cartesian scale names.
50
59
  Scale: TypeAlias = Literal["linear", "log", "symlog", "logit"]
51
60
  # Public type alias: TickSpec; finite numeric tick locations after validation.
@@ -204,6 +213,7 @@ NormalizeSpec: TypeAlias = tuple[float, float] | _NormalizeProtocol
204
213
 
205
214
 
206
215
  _SCALES = {"linear", "log", "symlog", "logit"}
216
+ _DIRECTIONS = {"in", "out", "inout"}
207
217
  _LOCATIONS = {
208
218
  "upper right",
209
219
  "upper left",
@@ -273,13 +283,13 @@ def _color(value: Any, name: str) -> ColorSpec:
273
283
  return cast(tuple[float, float, float, float], channels)
274
284
 
275
285
 
276
- def _limits(value: Any, name: str) -> tuple[float, float] | None:
277
- """Validate finite, non-equal limits while preserving their order."""
286
+ def _limits(value: Any, name: str) -> tuple[float | None, float | None] | None:
287
+ """Validate finite limits or partial limits while preserving their order."""
278
288
 
279
289
  if value is None:
280
290
  return None
281
291
  if isinstance(value, (str, bytes)):
282
- if value == "*":
292
+ if value in ("", "*"):
283
293
  return None
284
294
  raise LayoutError(f"{name} must contain exactly two finite values")
285
295
  try:
@@ -288,15 +298,19 @@ def _limits(value: Any, name: str) -> tuple[float, float] | None:
288
298
  raise LayoutError(f"{name} must contain exactly two finite values") from exc
289
299
  if len(values) != 2:
290
300
  raise LayoutError(f"{name} must contain exactly two finite values")
291
- if values[0] in (None, "*") and values[1] in (None, "*"):
292
- return None
293
- result = (
294
- _finite(values[0], f"{name}[0]", LayoutError),
295
- _finite(values[1], f"{name}[1]", LayoutError),
301
+ low = (
302
+ None
303
+ if values[0] in (None, "", "*")
304
+ else _finite(values[0], f"{name}[0]", LayoutError)
296
305
  )
297
- if result[0] == result[1]:
306
+ high = (
307
+ None
308
+ if values[1] in (None, "", "*")
309
+ else _finite(values[1], f"{name}[1]", LayoutError)
310
+ )
311
+ if low is not None and high is not None and low == high:
298
312
  raise LayoutError(f"{name} values must not be equal")
299
- return result
313
+ return (low, high)
300
314
 
301
315
 
302
316
  def _ticks(value: Any, name: str) -> tuple[float, ...] | None:
@@ -322,6 +336,22 @@ def _optional_finite(value: Any, name: str) -> float | None:
322
336
  return None if value is None else _finite(value, name, LayoutError)
323
337
 
324
338
 
339
+ def _nonnegative_finite(value: Any, name: str, default: float = 0.05) -> float:
340
+ """Validate a non-negative finite margin value."""
341
+
342
+ if value is None:
343
+ return default
344
+ if isinstance(value, bool):
345
+ raise LayoutError(f"{name} must be non-negative")
346
+ try:
347
+ num = float(value)
348
+ except (TypeError, ValueError) as exc:
349
+ raise LayoutError(f"{name} must be non-negative") from exc
350
+ if not math.isfinite(num) or num < 0:
351
+ raise LayoutError(f"{name} must be non-negative")
352
+ return num
353
+
354
+
325
355
  @dataclass(frozen=True, slots=True, kw_only=True)
326
356
  class AxisSpec:
327
357
  """Immutable Cartesian labels, limits, scales, ticks, and padding.
@@ -340,6 +370,12 @@ class AxisSpec:
340
370
  Optional minor-tick enable flags.
341
371
  xlabelpad, ylabelpad
342
372
  Optional finite label padding values.
373
+ xmargin, ymargin
374
+ Optional non-negative margin ratios for automatic endpoints.
375
+ top, bottom, left, right
376
+ Optional boolean flags for edge tick and label visibility.
377
+ direction
378
+ Optional tick direction: ``"in"``, ``"out"``, or ``"inout"``.
343
379
 
344
380
  Notes
345
381
  -----
@@ -349,15 +385,17 @@ class AxisSpec:
349
385
  Examples
350
386
  --------
351
387
  >>> import gsplot as gs
352
- >>> spec = gs.AxisSpec(xlabel="time", xscale="linear")
388
+ >>> spec = gs.AxisSpec(xlabel="time", xscale="linear", right=False)
353
389
  >>> spec.xlabel
354
390
  'time'
391
+ >>> spec.right
392
+ False
355
393
  """
356
394
 
357
395
  xlabel: str | None = None
358
396
  ylabel: str | None = None
359
- xlim: tuple[float, float] | None = None
360
- ylim: tuple[float, float] | None = None
397
+ xlim: Limit | None = None
398
+ ylim: Limit | None = None
361
399
  xscale: Literal["linear", "log", "symlog", "logit"] = "linear"
362
400
  yscale: Literal["linear", "log", "symlog", "logit"] = "linear"
363
401
  xticks: tuple[float, ...] | None = None
@@ -366,6 +404,13 @@ class AxisSpec:
366
404
  yminor: bool | None = None
367
405
  xlabelpad: float | None = None
368
406
  ylabelpad: float | None = None
407
+ xmargin: float = 0.05
408
+ ymargin: float = 0.05
409
+ top: bool | None = None
410
+ bottom: bool | None = None
411
+ left: bool | None = None
412
+ right: bool | None = None
413
+ direction: Literal["in", "out", "inout"] | None = None
369
414
 
370
415
  def __post_init__(self) -> None:
371
416
  """Validate every field and normalize sequence-like inputs."""
@@ -394,6 +439,25 @@ class AxisSpec:
394
439
  object.__setattr__(
395
440
  self, "ylabelpad", _optional_finite(self.ylabelpad, "ylabelpad")
396
441
  )
442
+ object.__setattr__(
443
+ self, "xmargin", _nonnegative_finite(self.xmargin, "xmargin")
444
+ )
445
+ object.__setattr__(
446
+ self, "ymargin", _nonnegative_finite(self.ymargin, "ymargin")
447
+ )
448
+ for edge_name, edge_val in (
449
+ ("top", self.top),
450
+ ("bottom", self.bottom),
451
+ ("left", self.left),
452
+ ("right", self.right),
453
+ ):
454
+ if edge_val is not None and not isinstance(edge_val, bool):
455
+ raise LayoutError(f"{edge_name} must be a boolean or None")
456
+ if self.direction is not None:
457
+ if not isinstance(self.direction, str) or self.direction not in _DIRECTIONS:
458
+ raise LayoutError(
459
+ f"direction must be one of: {', '.join(sorted(_DIRECTIONS))}"
460
+ )
397
461
 
398
462
 
399
463
  @dataclass(frozen=True, slots=True, kw_only=True)
@@ -531,21 +531,12 @@ def _layout_kind(figure: Figure) -> str:
531
531
  def _validate_reuse(
532
532
  figure: Figure,
533
533
  *,
534
- size: SizeSpec,
535
- size_inches: tuple[float, float] | None,
536
534
  layout: LayoutMode,
535
+ clear: bool = False,
537
536
  ) -> None:
538
537
  """Reject incompatible Figure reuse before clearing or adding Axes."""
539
538
 
540
- if (
541
- size not in {"auto", None}
542
- and size_inches is not None
543
- and not np.allclose(figure.get_size_inches(), size_inches, rtol=0, atol=1e-9)
544
- ):
545
- raise LayoutError(
546
- "requested size does not match the reused Figure; omit size to preserve it"
547
- )
548
- if layout == "auto":
539
+ if layout == "auto" or clear:
549
540
  return
550
541
  current = _layout_kind(figure)
551
542
  if current != layout and not (
@@ -854,9 +845,8 @@ def subplots(
854
845
  if resolved_fig is not None:
855
846
  _validate_reuse(
856
847
  resolved_fig,
857
- size=options["size"],
858
- size_inches=size_inches,
859
848
  layout=options["layout"],
849
+ clear=selected_clear,
860
850
  )
861
851
 
862
852
  new_figure = resolved_fig is None
@@ -872,6 +862,8 @@ def subplots(
872
862
  )
873
863
  else:
874
864
  target = cast(Figure, resolved_fig)
865
+ if size_inches is not None:
866
+ target.set_size_inches(size_inches, forward=True)
875
867
  if selected_clear:
876
868
  target.clear()
877
869
  target.set_layout_engine(
@@ -524,24 +524,28 @@ def _formats(
524
524
  return selected, destinations
525
525
 
526
526
 
527
- def _save_props(props: Mapping[str, Any] | None) -> dict[str, Any]:
527
+ def _save_props(
528
+ props: Mapping[str, Any] | None,
529
+ kwargs: Mapping[str, Any] | None = None,
530
+ ) -> dict[str, Any]:
528
531
  """Validate a closed Matplotlib savefig property mapping."""
529
532
 
530
- if props is None:
531
- return {}
532
- if not isinstance(props, Mapping):
533
+ if props is not None and not isinstance(props, Mapping):
533
534
  raise OutputError("props must be a mapping")
534
- if any(not isinstance(key, str) for key in props):
535
+ merged = dict(props or {})
536
+ if kwargs:
537
+ merged.update(kwargs)
538
+ if any(not isinstance(key, str) for key in merged):
535
539
  raise OutputError("props keys must be strings")
536
- duplicate = sorted(set(props) & _CONTROLLED_PROPS)
540
+ duplicate = sorted(set(merged) & _CONTROLLED_PROPS)
537
541
  if duplicate:
538
542
  joined = ", ".join(repr(key) for key in duplicate)
539
543
  raise TypeError(f"props cannot contain gsplot-controlled key(s): {joined}")
540
- unknown = sorted(set(props) - _SAVEFIG_PROPS)
544
+ unknown = sorted(set(merged) - _SAVEFIG_PROPS)
541
545
  if unknown:
542
546
  joined = ", ".join(repr(key) for key in unknown)
543
547
  raise OptionError(f"savefig props contains unknown key(s): {joined}")
544
- return dict(props)
548
+ return merged
545
549
 
546
550
 
547
551
  def _is_interactive_figure(figure: Figure) -> bool:
@@ -606,6 +610,7 @@ def savefig(
606
610
  overwrite: bool = False,
607
611
  show: bool = True,
608
612
  props: Mapping[str, Any] | None = None,
613
+ **kwargs: Any,
609
614
  ) -> tuple[Path, ...]:
610
615
  """Save an explicit Figure and optionally display it after all writes.
611
616
 
@@ -626,6 +631,10 @@ def savefig(
626
631
  props
627
632
  A finite mapping of Matplotlib save properties; gsplot controls may
628
633
  not be supplied through this mapping.
634
+ **kwargs
635
+ Optional direct Matplotlib save properties (e.g. ``transparent``,
636
+ ``facecolor``, ``edgecolor``). Direct keyword arguments are merged with
637
+ and take precedence over ``props``.
629
638
 
630
639
  Returns
631
640
  -------
@@ -665,7 +674,7 @@ def savefig(
665
674
  if not math.isfinite(dpi_value) or dpi_value <= 0:
666
675
  raise OutputError("dpi must be a positive finite number")
667
676
  dpi = dpi_value
668
- selected_props = _save_props(props)
677
+ selected_props = _save_props(props, kwargs)
669
678
  destination = _resolved_path(path, "path")
670
679
  selected_formats, destinations = _formats(destination, formats)
671
680
  parents = {item.parent for item in destinations}