|
28 | 28 | import string |
29 | 29 | import sys |
30 | 30 | import types |
31 | | -from typing import Callable |
| 31 | +from typing import Callable, Literal |
32 | 32 |
|
33 | 33 |
|
34 | 34 | class Tracker(importlib.abc.MetaPathFinder): |
@@ -194,6 +194,74 @@ class ColorError(ValueError): |
194 | 194 | pass |
195 | 195 |
|
196 | 196 |
|
| 197 | +def parse_color(spec: Literal["none", "transparent"] | str | float | tuple[float]): |
| 198 | + """Parse a color specification to a Matplotlib color. |
| 199 | +
|
| 200 | + Recognised color formats are: |
| 201 | + * Named colors (red, yellow, etc.) |
| 202 | + * Cycle colors (C1, C2 etc.) |
| 203 | + * Tuples (r, g, b) or (r, g, b, a) with floating-point entries in [0, 1] |
| 204 | + * A floating-point value in [0, 1] for grayscale |
| 205 | + * 'none', 'transparent', or an empty value for transparent |
| 206 | +
|
| 207 | + Parameters |
| 208 | + ---------- |
| 209 | + spec |
| 210 | + The color specification to parse. |
| 211 | +
|
| 212 | + Returns |
| 213 | + ------- |
| 214 | + matplotlib-compatible colour. |
| 215 | +
|
| 216 | + Raises |
| 217 | + ------ |
| 218 | + ColorError |
| 219 | + The value could not be interpreted as a color. |
| 220 | +
|
| 221 | + """ |
| 222 | + # Transparent. |
| 223 | + if spec in {"none", "transparent", ""}: |
| 224 | + return "none" |
| 225 | + |
| 226 | + # Single floating point number: grayscale. |
| 227 | + try: |
| 228 | + gray = float(spec) |
| 229 | + except ValueError: |
| 230 | + pass |
| 231 | + else: |
| 232 | + if not (0 <= gray <= 1): |
| 233 | + raise ColorError("greyscale floats must be in [0, 1].") |
| 234 | + |
| 235 | + # For historical reasons Matlotlib requires this to be a string. |
| 236 | + return spec |
| 237 | + |
| 238 | + # Nth color in the cycle (i.e., C1, C2 etc), or a named color. Unfortunately, |
| 239 | + # this returns True for grayscale values outside [0, 1] so we have to do our own |
| 240 | + # check above. |
| 241 | + if matplotlib.colors.is_color_like(spec): |
| 242 | + return spec |
| 243 | + |
| 244 | + # Anything else we accept is valid Python syntax, so parse it. |
| 245 | + try: |
| 246 | + parsed = ast.literal_eval(spec) |
| 247 | + except (SyntaxError, TypeError, ValueError): |
| 248 | + raise ColorError(f"could not interpret '{spec}' as a color.") |
| 249 | + |
| 250 | + # Needs to be a list or tuple of channel values. |
| 251 | + if not isinstance(parsed, (list, tuple)): |
| 252 | + raise ColorError(f"could not interpret '{spec}' as a color.") |
| 253 | + |
| 254 | + # Filter out Booleans which Matplotlib would treat as 0 or 1. |
| 255 | + if any(isinstance(entry, bool) for entry in parsed): |
| 256 | + raise ColorError(f"could not interpret '{spec}' as a color.") |
| 257 | + |
| 258 | + # And get Matplotlib to convert to a color. |
| 259 | + try: |
| 260 | + return matplotlib.colors.to_rgba(parsed) |
| 261 | + except ValueError as e: |
| 262 | + raise ColorError(str(e)) |
| 263 | + |
| 264 | + |
197 | 265 | # Recognise pieces of a dimension string. |
198 | 266 | dimension_pieces = re.compile(r"^\s*(?P<size>\d+(?:\.\d*)?)\s*(?P<unit>.+?)?\s*$") |
199 | 267 |
|
@@ -385,58 +453,15 @@ def getcolor(self, section, option, **kwargs): |
385 | 453 | The value could not be interpreted as a color. |
386 | 454 |
|
387 | 455 | """ |
388 | | - # Retrieve the string value. Empty values are interpreted as none. |
389 | | - value = self.get(section, option, **kwargs).strip() or "none" |
390 | | - |
391 | | - # Transparent. |
392 | | - if value in {"none", "transparent"}: |
393 | | - return "none" |
394 | | - |
395 | | - # Single floating point number: grayscale. |
396 | | - try: |
397 | | - gray = float(value) |
398 | | - except ValueError: |
399 | | - pass |
400 | | - else: |
401 | | - if not (0 <= gray <= 1): |
402 | | - raise ColorError( |
403 | | - f"{section}.{option}: greyscale floats must be in [0, 1]." |
404 | | - ) |
405 | | - |
406 | | - # For historical reasons Matlotlib requires this to be a string. |
407 | | - return value |
408 | | - |
409 | | - # Nth color in the cycle (i.e., C1, C2 etc), or a named color. Unfortunately, |
410 | | - # this returns True for grayscale values outside [0, 1] so we have to do our own |
411 | | - # check above. |
412 | | - if matplotlib.colors.is_color_like(value): |
413 | | - return value |
414 | | - |
415 | | - # Anything else we accept is valid Python syntax, so parse it. |
416 | | - try: |
417 | | - parsed = ast.literal_eval(value) |
418 | | - except (SyntaxError, TypeError, ValueError): |
419 | | - raise ColorError( |
420 | | - f"{section}.{option}: could not interpret '{value}' as a color." |
421 | | - ) from None |
422 | | - |
423 | | - # Needs to be a list or tuple of channel values. |
424 | | - if not isinstance(parsed, (list, tuple)): |
425 | | - raise ColorError( |
426 | | - f"{section}.{option}: could not interpret '{value}' as a color." |
427 | | - ) |
428 | | - |
429 | | - # Filter out Booleans which Matplotlib would treat as 0 or 1. |
430 | | - if any(isinstance(entry, bool) for entry in parsed): |
431 | | - raise ColorError( |
432 | | - f"{section}.{option}: could not interpret '{value}' as a color." |
433 | | - ) |
| 456 | + # Get the string version of the dimension. |
| 457 | + spec = self.get(section, option, **kwargs) |
434 | 458 |
|
435 | | - # And get Matplotlib to convert to a color. |
| 459 | + # And parse it; modify any parsing exception to include |
| 460 | + # the section and option we were parsing. |
436 | 461 | try: |
437 | | - return matplotlib.colors.to_rgba(parsed) |
438 | | - except ValueError as e: |
439 | | - raise ColorError(f"{section}.{option}: {e}.") from None |
| 462 | + return parse_color(spec) |
| 463 | + except ColorError as e: |
| 464 | + raise ColorError(f"{section}.{option}: {e}") from None |
440 | 465 |
|
441 | 466 | def in_tracking_dir(self, type, fn): |
442 | 467 | """Check if a file is in a tracking directory. |
|
0 commit comments