Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/guides/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,27 @@ Typical side files:
For semantic per-series names, use the `data_name` keyword in generated-data
methods such as `plot()` and `fill_between()`.

#### Allowed characters

The prefix is used **verbatim**, so the sidecar names stay predictable —
`data_prefix='experimentA'` yields `experimentA_0.dat`, not `experimenta_0.dat`.
In exchange it is validated when the figure is created, and an unusable prefix
raises `ValueError` immediately instead of producing a `.gle` script that fails
at compile time:

glp.figure(data_prefix='mk+white')
# ValueError: data_prefix 'mk+white' contains characters that cannot appear
# in a GLE data filename: '+' at index 2. ...

Rejected: whitespace, control characters, and `!`, `"`, `+` (GLE cannot parse
these in the unquoted filename of a `data` statement), plus the path separators
`/` and `\`. An empty or whitespace-only prefix is rejected too; pass `None` for
the default `data_N.dat` naming.

Most other punctuation is allowed, including `.`, `-`, `_` and `#`. Note that
`data_name` behaves differently: it takes a free-form *label* and sanitizes it
into a filename stem, because no caller depends on its exact spelling.

## Resetting to Defaults

Reset all global configurations to defaults:
Expand Down
6 changes: 6 additions & 0 deletions docs/source/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ This produces files such as:
- ``experimentA_plot.gle``
- ``experimentA_0.dat``

The prefix is used verbatim (note the preserved capital ``A``), so it must be
usable as a GLE data filename. Whitespace, control characters, ``!``, ``"``,
``+`` and the path separators ``/`` and ``\`` raise ``ValueError`` when the
figure is created, rather than producing a script that fails to compile. See
``docs/guides/CONFIGURATION.md`` for the full list.

New: Shared Axes in Subplots
----------------------------

Expand Down
8 changes: 6 additions & 2 deletions src/gleplot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ def figure(
Marker configuration. If None, uses global default.
data_prefix : str, optional
Custom prefix for data file names (e.g., 'test9' creates 'test9_0.dat', 'test9_1.dat').
If None, uses global counter with ``data_`` prefix.
If None, uses global counter with ``data_`` prefix. Used verbatim, so
it must be usable as a GLE data filename: whitespace, control
characters and any of ``! " + / \\`` raise ``ValueError``.

Returns
-------
Expand Down Expand Up @@ -263,7 +265,9 @@ def subplots(
will show y-axis labels and ticks. Default: False
data_prefix : str, optional
Custom prefix for data file names (e.g., 'test9' creates 'test9_0.dat', 'test9_1.dat').
If None, uses global counter with ``data_`` prefix.
If None, uses global counter with ``data_`` prefix. Used verbatim, so
it must be usable as a GLE data filename: whitespace, control
characters and any of ``! " + / \\`` raise ``ValueError``.

Returns
-------
Expand Down
77 changes: 77 additions & 0 deletions src/gleplot/axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,83 @@ def _sanitize_data_stem(name: object) -> str:
return text or "data"


#: Characters rejected in a figure-level ``data_prefix``.
#:
#: Two disjoint groups, both verified against GLE 4.3.10 rather than assumed:
#:
#: 1. ``!``, ``"``, ``+`` and any whitespace -- GLE's tokenizer cannot read
#: these inside the *unquoted* filename of a ``data <file>`` statement.
#: ``data mk+white_0.dat`` aborts the compile with
#: ``>> Error: left hand side contains unquoted string``. A sweep of the
#: printable ASCII punctuation found exactly these; ``. - # $ % & ( ) [ ] {
#: } , ; : = @ ~ ^ * ? < > | \ ` '`` and non-ASCII all parse fine, so they
#: are deliberately *not* rejected.
#: 2. ``/`` and ``\\`` -- path separators. A ``data_prefix`` names a filename
#: stem, not a path; either one would redirect the sidecar into a
#: directory (or, on the platform where it is not a separator, produce a
#: filename that stops resolving when the script moves). Rejected on every
#: platform so a prefix means the same thing everywhere.
#:
#: C0 control characters (including NUL) are rejected as well -- see
#: :func:`_validate_data_prefix`.
_DATA_PREFIX_FORBIDDEN = '!"+/\\'


def _describe_char(ch: str) -> str:
"""Render a character for an error message, naming invisible ones."""
names = {" ": "space", "\t": "tab", "\n": "newline", "\r": "carriage return"}
if ch in names:
return names[ch]
if ch.isspace() or ord(ch) < 0x20 or ord(ch) == 0x7F:
return f"U+{ord(ch):04X}"
return repr(ch)


def _validate_data_prefix(prefix: object) -> str:
"""Validate a figure-level ``data_prefix``, or raise ``ValueError``.

Returns the prefix unchanged on success. Unlike :func:`_sanitize_data_stem`
-- which quietly rewrites the *label-like* ``data_name=`` argument into a
filename stem -- this rejects a bad prefix instead of repairing it. The
whole point of ``data_prefix`` is that the caller can predict the sidecar
filenames (batch pipelines glob for them), so silently renaming
``mk+white`` to ``mk_white`` would trade a GLE compile error for a missing
file much further downstream. Sanitizing here would also lowercase, which
would break documented usage such as ``data_prefix='experimentA'``.

See :data:`_DATA_PREFIX_FORBIDDEN` for what is rejected and why.
"""
if not isinstance(prefix, str):
raise TypeError(
f"data_prefix must be a str, got {type(prefix).__name__}: {prefix!r}"
)
if not prefix or not prefix.strip():
raise ValueError(
"data_prefix must be a non-empty filename stem; got "
f"{prefix!r}. Pass None to use the default 'data_N.dat' naming."
)

bad = [
(idx, ch)
for idx, ch in enumerate(prefix)
if ch in _DATA_PREFIX_FORBIDDEN
or ch.isspace()
or ord(ch) < 0x20
or ord(ch) == 0x7F
]
if bad:
detail = ", ".join(f"{_describe_char(c)} at index {i}" for i, c in bad)
raise ValueError(
f"data_prefix {prefix!r} contains characters that cannot appear in "
f"a GLE data filename: {detail}. Forbidden: whitespace, control "
'characters, and any of ! " + / \\ (GLE cannot parse the first '
"four in an unquoted 'data' statement; the last two are path "
"separators). Most other punctuation, including . - _ and #, is "
"allowed."
)
return prefix


def _looks_numeric(token: str) -> bool:
"""True if ``token`` would parse as a float (int/float/exponent form).

Expand Down
34 changes: 30 additions & 4 deletions src/gleplot/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import numpy as np
from pathlib import Path
from typing import Tuple, Optional, Literal
from .axes import Axes
from .axes import Axes, _validate_data_prefix
from .writer import GLEWriter
from .compiler import GLECompiler, SUFFIX_TO_COMPILE_FORMAT
from .colors import rgb_to_gle
Expand Down Expand Up @@ -64,6 +64,20 @@ def __init__(
data_prefix : str, optional
Custom prefix for data file names (e.g., 'test9' creates 'test9_0.dat', 'test9_1.dat').
If None, uses global counter with ``data_`` prefix.

Used verbatim -- it is never sanitized, so the sidecar names stay
predictable (``'experimentA'`` yields ``experimentA_0.dat``, not
``experimenta_0.dat``). In exchange it is validated: a prefix
containing whitespace, a control character, or any of
``! " + / \\`` raises ``ValueError`` here rather than producing a
script GLE fails to parse. See
:func:`gleplot.axes._validate_data_prefix`.

Raises
------
ValueError
If ``data_prefix`` is empty/whitespace-only or contains a
character that is not usable in a GLE data filename.
"""
self.figsize = figsize
self.dpi = dpi
Expand All @@ -77,8 +91,12 @@ def __init__(
self.sharex = sharex
self.sharey = sharey

# Custom data file naming
self.data_prefix = data_prefix
# Custom data file naming. Validated (not sanitized) so a prefix GLE
# cannot parse fails here, at the point the bad value was supplied,
# instead of at compile time inside a generated 'data <file>' line.
self.data_prefix = (
_validate_data_prefix(data_prefix) if data_prefix is not None else None
)
self._local_data_counter = 0 # Local counter when using custom prefix
self._used_data_files: set[str] = set()
self._subplot_adjust: dict[str, float] = {}
Expand Down Expand Up @@ -1624,9 +1642,17 @@ def from_dict(cls, d: dict) -> "Figure":
marker=marker,
sharex=fig_block.get("sharex", False),
sharey=fig_block.get("sharey", False),
data_prefix=fig_block.get("data_prefix"),
)

# Restore the prefix verbatim, bypassing __init__'s validation. This is
# recorded state, not a user-supplied value: the recognizer derives a
# prefix from filenames already present in a parsed .gle, and those can
# legitimately contain characters __init__ rejects (a quoted
# 'data "my file_0.dat"' yields the prefix 'my file'). Re-validating
# here would make such a figure impossible to round-trip -- and would
# break GUI undo/redo, which restores every snapshot through from_dict.
fig.data_prefix = fig_block.get("data_prefix")

fig._local_data_counter = fig_block.get("local_data_counter", 0)
fig._used_data_files = set(fig_block.get("used_data_files", []))
fig._subplot_adjust = {
Expand Down
94 changes: 94 additions & 0 deletions tests/unit/test_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,100 @@ def test_data_name_collision_is_disambiguated(self):
self.assertEqual(self.ax.lines[1]['data_file'], 'model-range_1.dat')


class TestDataPrefixValidation(unittest.TestCase):
"""Test that a figure-level data_prefix is validated, not sanitized.

Unlike ``data_name`` (a label, sanitized into a stem by the tests above),
``data_prefix`` is used verbatim to build sidecar filenames. A prefix GLE
cannot parse must therefore fail in Python at the point it is supplied,
rather than producing a script that aborts at compile time with
``>> Error: left hand side contains unquoted string``.
"""

def tearDown(self):
glp.close()

def test_plus_in_prefix_raises_instead_of_failing_at_compile_time(self):
"""The reported reproducer: 'mk+white' produced unparseable GLE."""
with self.assertRaises(ValueError) as ctx:
glp.figure(data_prefix='mk+white')

message = str(ctx.exception)
self.assertIn('mk+white', message) # names the offending value
self.assertIn("'+'", message) # ...and the offending character

def test_forbidden_characters_are_rejected(self):
"""Every character GLE cannot read in an unquoted data filename."""
# '!', '"' and '+' were confirmed against GLE 4.3.10 by compiling a
# data file containing each; '/' and '\\' are path separators.
for prefix in ['a!b', 'a"b', 'a+b', 'a/b', 'a\\b']:
with self.subTest(prefix=prefix):
with self.assertRaises(ValueError):
glp.figure(data_prefix=prefix)

def test_whitespace_and_control_characters_are_rejected(self):
for prefix in ['a b', 'a\tb', 'a\nb', 'a\x00b', 'a\x1fb']:
with self.subTest(prefix=prefix):
with self.assertRaises(ValueError):
glp.figure(data_prefix=prefix)

def test_empty_prefix_is_rejected(self):
for prefix in ['', ' ']:
with self.subTest(prefix=prefix):
with self.assertRaises(ValueError):
glp.figure(data_prefix=prefix)

def test_non_string_prefix_raises_type_error(self):
with self.assertRaises(TypeError):
glp.figure(data_prefix=42)

def test_none_prefix_keeps_default_naming(self):
"""None is not a bad value -- it selects the global data_N counter."""
fig = glp.figure(data_prefix=None)
self.assertIsNone(fig.data_prefix)

def test_valid_prefix_is_preserved_verbatim(self):
"""Case and legal punctuation must survive: no sanitizing here.

Sanitizing would lowercase, silently turning the documented
``data_prefix='experimentA'`` into ``experimenta_0.dat``.
"""
for prefix in ['experimentA', 'run42', 'exp-1.2', 'a#b', 'a,b', 'a=b']:
with self.subTest(prefix=prefix):
fig = glp.figure(data_prefix=prefix)
self.assertEqual(fig.data_prefix, prefix)

def test_valid_prefix_drives_sidecar_filenames(self):
fig = glp.figure(data_prefix='experimentA')
ax = fig.add_subplot(111)
ax.plot([1, 2, 3], [1, 2, 3])
ax.plot([1, 2, 3], [3, 2, 1])

self.assertEqual(ax.lines[0]['data_file'], 'experimentA_0.dat')
self.assertEqual(ax.lines[1]['data_file'], 'experimentA_1.dat')

def test_subplots_validates_prefix_too(self):
with self.assertRaises(ValueError):
glp.subplots(1, 2, data_prefix='mk+white')

def test_from_dict_restores_a_prefix_init_would_reject(self):
"""Recorded state round-trips even when it would fail validation.

The recognizer derives a prefix from filenames already present in a
parsed .gle, and a quoted ``data "my file_0.dat"`` yields the prefix
``my file``. from_dict restores such a figure verbatim -- validating
there would make the figure impossible to reload and would break GUI
undo/redo, which restores every snapshot through from_dict.
"""
fig = glp.figure()
fig.add_subplot(111).plot([1, 2], [1, 2])
fig.data_prefix = 'my file'

restored = glp.Figure.from_dict(fig.to_dict())

self.assertEqual(restored.data_prefix, 'my file')


class TestErrorBars(unittest.TestCase):
"""Test error bar functionality."""

Expand Down
Loading