figurestead 0.9.0a1__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.
- figurestead-0.9.0a1/LICENSE +21 -0
- figurestead-0.9.0a1/PKG-INFO +8 -0
- figurestead-0.9.0a1/README.md +10 -0
- figurestead-0.9.0a1/THIRD_PARTY_NOTICES.md +39 -0
- figurestead-0.9.0a1/TRADEMARKS.md +13 -0
- figurestead-0.9.0a1/pyproject.toml +32 -0
- figurestead-0.9.0a1/src/figurestead/__init__.py +78 -0
- figurestead-0.9.0a1/src/figurestead/adapters/__init__.py +1 -0
- figurestead-0.9.0a1/src/figurestead/adapters/tabular.py +399 -0
- figurestead-0.9.0a1/src/figurestead/application.py +63 -0
- figurestead-0.9.0a1/src/figurestead/core.py +167 -0
- figurestead-0.9.0a1/src/figurestead/extensions/__init__.py +1 -0
- figurestead-0.9.0a1/src/figurestead/extensions/categorical/__init__.py +13 -0
- figurestead-0.9.0a1/src/figurestead/extensions/categorical/contracts.py +88 -0
- figurestead-0.9.0a1/src/figurestead/extensions/categorical/renderers.py +125 -0
- figurestead-0.9.0a1/src/figurestead/extensions/categorical/validation.py +110 -0
- figurestead-0.9.0a1/src/figurestead/extensions/interval/__init__.py +23 -0
- figurestead-0.9.0a1/src/figurestead/extensions/interval/renderer.py +276 -0
- figurestead-0.9.0a1/src/figurestead/extensions/matrix/__init__.py +5 -0
- figurestead-0.9.0a1/src/figurestead/extensions/matrix/renderer.py +188 -0
- figurestead-0.9.0a1/src/figurestead/extensions/paired_reference/__init__.py +300 -0
- figurestead-0.9.0a1/src/figurestead/extensions/temporal/__init__.py +14 -0
- figurestead-0.9.0a1/src/figurestead/extensions/temporal/contracts.py +56 -0
- figurestead-0.9.0a1/src/figurestead/extensions/temporal/plots.py +95 -0
- figurestead-0.9.0a1/src/figurestead/extensions/temporal/validation.py +115 -0
- figurestead-0.9.0a1/src/figurestead/gallery.py +116 -0
- figurestead-0.9.0a1/src/figurestead/motion.py +426 -0
- figurestead-0.9.0a1/src/figurestead/motion_gallery.py +77 -0
- figurestead-0.9.0a1/src/figurestead/plots.py +175 -0
- figurestead-0.9.0a1/src/figurestead/portable.py +398 -0
- figurestead-0.9.0a1/src/figurestead/presentation.py +139 -0
- figurestead-0.9.0a1/src/figurestead/profiles.py +91 -0
- figurestead-0.9.0a1/src/figurestead/registry.py +39 -0
- figurestead-0.9.0a1/src/figurestead/scene.py +63 -0
- figurestead-0.9.0a1/src/figurestead/theme_io.py +490 -0
- figurestead-0.9.0a1/src/figurestead/themes/deep_observatory_sage_core.json +28 -0
- figurestead-0.9.0a1/src/figurestead/themes/lavender_fog_notebook.json +29 -0
- figurestead-0.9.0a1/src/figurestead/themes/midnight_transit_signal_slate.json +28 -0
- figurestead-0.9.0a1/src/figurestead/themes/registration_ink.json +28 -0
- figurestead-0.9.0a1/src/figurestead/themes/slipware.json +28 -0
- figurestead-0.9.0a1/src/figurestead/themes/ultraviolet_laboratory.json +29 -0
- figurestead-0.9.0a1/src/figurestead/themes.py +129 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Figurestead contributors
|
|
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.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: figurestead
|
|
3
|
+
Version: 0.9.0a1
|
|
4
|
+
License: MIT
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Project-URL: Homepage, https://github.com/CharlesMish/figurestead
|
|
7
|
+
Project-URL: Repository, https://github.com/CharlesMish/figurestead
|
|
8
|
+
Project-URL: Issues, https://github.com/CharlesMish/figurestead/issues
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Figurestead experimental public alpha
|
|
2
|
+
|
|
3
|
+
Figurestead is a framework-free scientific figure system for deterministic,
|
|
4
|
+
inspectable Python and browser output. This experimental alpha contains six
|
|
5
|
+
curated themes and uses Slipware as the default. Presentation profiles are
|
|
6
|
+
independent from themes. The `monograph` profile is available through
|
|
7
|
+
`get_profile("monograph")` and remains unavailable through `get_theme`.
|
|
8
|
+
|
|
9
|
+
Python package: `figurestead` 0.9.0a1. Browser package: `@figurestead/web`
|
|
10
|
+
0.9.0-alpha.1. Source: <https://github.com/CharlesMish/figurestead>.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Third-party factual notice inventory
|
|
2
|
+
|
|
3
|
+
Status: `EXPERIMENTAL PUBLIC-ALPHA FACTUAL NOTICE`
|
|
4
|
+
|
|
5
|
+
This inventory records the dependency evidence used for the Figurestead
|
|
6
|
+
experimental alpha; it makes no legal conclusion. Runtime dependencies are not
|
|
7
|
+
vendored into the wheel, sdist, or npm tarball. The canonical Figurestead MIT
|
|
8
|
+
text is supplied separately as `LICENSE`.
|
|
9
|
+
|
|
10
|
+
## Python dependency observations
|
|
11
|
+
|
|
12
|
+
An external Python 3.14.6 evidence environment resolved the current declared
|
|
13
|
+
constraints on 2026-08-05 to Matplotlib 3.11.1, NumPy 2.5.1, Pillow 12.3.0,
|
|
14
|
+
PyYAML 6.0.3, and setuptools 82.0.1, plus contourpy 1.3.3, cycler 0.12.1,
|
|
15
|
+
fonttools 4.63.0, kiwisolver 1.5.0, packaging 26.3, pyparsing 3.3.2,
|
|
16
|
+
python-dateutil 2.9.0.post0, and six 1.17.0. These versions are an evidence
|
|
17
|
+
snapshot, not an approved runtime lock.
|
|
18
|
+
|
|
19
|
+
The static-site PNGs are first-party deterministic synthetic Figurestead render
|
|
20
|
+
outputs produced through Matplotlib. No font file is embedded as a separate
|
|
21
|
+
site member. Matplotlib's evidence distribution includes its own license plus
|
|
22
|
+
DejaVu and STIX font notices; rendered glyph provenance remains a review
|
|
23
|
+
consideration. Site CSS names
|
|
24
|
+
only system font families and carries no downloaded font bytes or remote URL.
|
|
25
|
+
|
|
26
|
+
## Browser tooling observations
|
|
27
|
+
|
|
28
|
+
The alpha verification environment used Playwright 1.61.1 and
|
|
29
|
+
playwright-core 1.61.1 as
|
|
30
|
+
Apache-2.0 development/test tooling and optional fsevents 2.3.2 as MIT. These
|
|
31
|
+
packages and downloaded browsers are not intended npm-tarball members or
|
|
32
|
+
top-level archive members. They remain test-tool provenance only.
|
|
33
|
+
|
|
34
|
+
## Placement and maintenance
|
|
35
|
+
|
|
36
|
+
Each Figurestead wheel, sdist, npm package, static-site bundle, and composition
|
|
37
|
+
archive carries this file and `TRADEMARKS.md` beside its canonical `LICENSE`.
|
|
38
|
+
Any newly resolved dependency version, copied source, font, image, or other
|
|
39
|
+
byte requires a corresponding inventory update and review.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Trademarks and names
|
|
2
|
+
|
|
3
|
+
Figurestead is an independent project.
|
|
4
|
+
|
|
5
|
+
Names of third-party products, services, organizations, and trademarks remain
|
|
6
|
+
the property of their respective owners. Their appearance in factual dependency
|
|
7
|
+
or compatibility notices does not imply affiliation, sponsorship, or
|
|
8
|
+
endorsement.
|
|
9
|
+
|
|
10
|
+
The experimental public-alpha distribution is intentionally limited to six
|
|
11
|
+
curated Figurestead themes.
|
|
12
|
+
Legacy or franchise-derived internal themes are not part of the public
|
|
13
|
+
distribution.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "figurestead"
|
|
7
|
+
version = "0.9.0a1"
|
|
8
|
+
description = "A framework-free scientific figure system for deterministic, inspectable Python and browser output."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["matplotlib>=3.7", "numpy>=1.24", "Pillow>=10"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/CharlesMish/figurestead"
|
|
16
|
+
Repository = "https://github.com/CharlesMish/figurestead"
|
|
17
|
+
Issues = "https://github.com/CharlesMish/figurestead/issues"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
themes = ["PyYAML>=6"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
figurestead = ["themes/*.json"]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
figurestead-gallery = "figurestead.gallery:main"
|
|
30
|
+
figurestead-motion-gallery = "figurestead.motion_gallery:main"
|
|
31
|
+
figurestead-portable = "figurestead.portable:main"
|
|
32
|
+
figurestead-theme = "figurestead.theme_io:main"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""figurestead public API."""
|
|
2
|
+
|
|
3
|
+
from .core import PlotSpec
|
|
4
|
+
from .plots import heatmap, histogram, line, scatter, strip_summary
|
|
5
|
+
from .motion import MotionStyle, MotionTimeline, animate_line, animate_scatter, animate_strip_summary
|
|
6
|
+
from .portable import PORTABLE_SCHEMA_VERSION, RENDERER_API_VERSION, export_contract, export_figure, portable_schema
|
|
7
|
+
from .profiles import PROFILES, Profile, get_profile
|
|
8
|
+
from .registry import PLOTS, available_plots, render
|
|
9
|
+
from .themes import THEMES, Theme, get_theme
|
|
10
|
+
from .theme_io import (
|
|
11
|
+
THEME_PACK_VERSION, PALETTE_PACK_VERSION, ThemePack, PalettePack, PaletteResolution, ThemePackError, apply_theme, compile_theme_pack,
|
|
12
|
+
contrast_audit, load_authored_theme_pack, load_compiled_theme_pack, load_theme, load_theme_pack,
|
|
13
|
+
normalize_theme_pack_lenient, preview_theme_pack, theme_to_contract,
|
|
14
|
+
load_palette_pack, resolve_palette,
|
|
15
|
+
)
|
|
16
|
+
from .presentation import EvidenceFocusAnnotation, FocusAnnotation, SCIENTIFIC_POSE, ScientificPose, apply_evidence_pose, apply_scientific_pose, monotone_curve
|
|
17
|
+
from .application import APPLICATION_PROFILE_VERSION, APPLICATION_PROFILES, ApplicationProfile, apply_application_profile, get_application_profile
|
|
18
|
+
from .scene import TERMINAL_SCENE_VERSION, compile_terminal_scene
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"PLOTS",
|
|
22
|
+
"PROFILES",
|
|
23
|
+
"THEMES",
|
|
24
|
+
"PlotSpec",
|
|
25
|
+
"MotionStyle",
|
|
26
|
+
"MotionTimeline",
|
|
27
|
+
"PORTABLE_SCHEMA_VERSION",
|
|
28
|
+
"RENDERER_API_VERSION",
|
|
29
|
+
"Profile",
|
|
30
|
+
"Theme",
|
|
31
|
+
"ThemePack",
|
|
32
|
+
"ThemePackError",
|
|
33
|
+
"FocusAnnotation",
|
|
34
|
+
"EvidenceFocusAnnotation",
|
|
35
|
+
"ScientificPose",
|
|
36
|
+
"SCIENTIFIC_POSE",
|
|
37
|
+
"APPLICATION_PROFILE_VERSION",
|
|
38
|
+
"APPLICATION_PROFILES",
|
|
39
|
+
"ApplicationProfile",
|
|
40
|
+
"TERMINAL_SCENE_VERSION",
|
|
41
|
+
"apply_application_profile",
|
|
42
|
+
"get_application_profile",
|
|
43
|
+
"compile_terminal_scene",
|
|
44
|
+
"apply_scientific_pose",
|
|
45
|
+
"apply_evidence_pose",
|
|
46
|
+
"THEME_PACK_VERSION",
|
|
47
|
+
"PALETTE_PACK_VERSION",
|
|
48
|
+
"PalettePack",
|
|
49
|
+
"PaletteResolution",
|
|
50
|
+
"available_plots",
|
|
51
|
+
"animate_line",
|
|
52
|
+
"animate_scatter",
|
|
53
|
+
"animate_strip_summary",
|
|
54
|
+
"export_contract",
|
|
55
|
+
"export_figure",
|
|
56
|
+
"get_profile",
|
|
57
|
+
"get_theme",
|
|
58
|
+
"load_theme",
|
|
59
|
+
"load_theme_pack",
|
|
60
|
+
"load_authored_theme_pack",
|
|
61
|
+
"load_compiled_theme_pack",
|
|
62
|
+
"normalize_theme_pack_lenient",
|
|
63
|
+
"load_palette_pack",
|
|
64
|
+
"resolve_palette",
|
|
65
|
+
"compile_theme_pack",
|
|
66
|
+
"apply_theme",
|
|
67
|
+
"contrast_audit",
|
|
68
|
+
"preview_theme_pack",
|
|
69
|
+
"theme_to_contract",
|
|
70
|
+
"heatmap",
|
|
71
|
+
"histogram",
|
|
72
|
+
"line",
|
|
73
|
+
"monotone_curve",
|
|
74
|
+
"portable_schema",
|
|
75
|
+
"render",
|
|
76
|
+
"scatter",
|
|
77
|
+
"strip_summary",
|
|
78
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Strict, additive adapters for Figurestead portable contracts."""
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Strict records and CSV ingress for existing portable contracts.
|
|
2
|
+
|
|
3
|
+
This module deliberately performs no inference, coercion, aggregation, sorting,
|
|
4
|
+
interpolation, or imputation. Validation completes before ``export_contract``
|
|
5
|
+
is called so rejected input cannot produce partial Figurestead evidence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
import csv
|
|
12
|
+
import math
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from figurestead import portable
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_CSV_NUMBER = re.compile(r"^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TabularIngressError(ValueError):
|
|
25
|
+
"""Raised when tabular input cannot be converted without ambiguity."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _error(path: str, message: str) -> TabularIngressError:
|
|
29
|
+
return TabularIngressError(f"{path}: {message}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _column_name(value: Any, argument: str) -> str:
|
|
33
|
+
if not isinstance(value, str) or not value.strip():
|
|
34
|
+
raise _error(argument, "must be a nonblank column-name string")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validate_dataframe_columns(source: Any) -> None:
|
|
39
|
+
try:
|
|
40
|
+
columns = source.columns
|
|
41
|
+
except AttributeError:
|
|
42
|
+
return
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
raise _error("header", f"DataFrame-like column metadata could not be read: {exc}") from exc
|
|
45
|
+
if isinstance(columns, (str, bytes, bytearray)):
|
|
46
|
+
raise _error("header", "DataFrame-like columns must be an iterable of unique labels")
|
|
47
|
+
try:
|
|
48
|
+
labels = list(columns)
|
|
49
|
+
except TypeError as exc:
|
|
50
|
+
raise _error("header", "DataFrame-like columns must be an iterable of unique labels") from exc
|
|
51
|
+
seen: set[Any] = set()
|
|
52
|
+
for index, label in enumerate(labels):
|
|
53
|
+
try:
|
|
54
|
+
duplicate = label in seen
|
|
55
|
+
seen.add(label)
|
|
56
|
+
except TypeError as exc:
|
|
57
|
+
raise _error(f"header[{index}]", "DataFrame-like column labels must be hashable") from exc
|
|
58
|
+
if duplicate:
|
|
59
|
+
path = f"header.{label}" if isinstance(label, str) and label else f"header[{index}]"
|
|
60
|
+
raise _error(path, "duplicate column name")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _record_rows(source: Any, required: set[str]) -> list[dict[str, Any]]:
|
|
64
|
+
if isinstance(source, (str, bytes, bytearray, Mapping)):
|
|
65
|
+
raise _error("source", "must be a non-string sequence of mappings or DataFrame-like object")
|
|
66
|
+
if hasattr(source, "to_dict"):
|
|
67
|
+
_validate_dataframe_columns(source)
|
|
68
|
+
try:
|
|
69
|
+
source = source.to_dict(orient="records")
|
|
70
|
+
except Exception as exc: # the foreign object owns the concrete failure
|
|
71
|
+
raise _error("source", f"to_dict(orient='records') failed: {exc}") from exc
|
|
72
|
+
if not isinstance(source, Sequence) or isinstance(source, (str, bytes, bytearray)):
|
|
73
|
+
raise _error("source", "must be a non-string sequence of mappings or DataFrame-like object")
|
|
74
|
+
rows: list[dict[str, Any]] = []
|
|
75
|
+
for index, row in enumerate(source, 1):
|
|
76
|
+
if not isinstance(row, Mapping):
|
|
77
|
+
raise _error(f"row {index}", "must be a mapping")
|
|
78
|
+
copied = dict(row)
|
|
79
|
+
missing = sorted(required - copied.keys())
|
|
80
|
+
if missing:
|
|
81
|
+
raise _error(f"row {index}.{missing[0]}", "required column is missing")
|
|
82
|
+
rows.append(copied)
|
|
83
|
+
if not rows:
|
|
84
|
+
raise _error("source", "must contain at least one data row")
|
|
85
|
+
return rows
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _reject_url(value: str) -> None:
|
|
89
|
+
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*://", value):
|
|
90
|
+
raise _error("source", "network URLs are not accepted")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _csv_text(source: Any) -> str:
|
|
94
|
+
if isinstance(source, str):
|
|
95
|
+
_reject_url(source)
|
|
96
|
+
if "\n" in source or "\r" in source:
|
|
97
|
+
raise _error("source", "inline CSV strings are not accepted; use a path or text stream")
|
|
98
|
+
path = Path(source)
|
|
99
|
+
try:
|
|
100
|
+
return path.read_text(encoding="utf-8")
|
|
101
|
+
except (OSError, UnicodeError) as exc:
|
|
102
|
+
raise _error("source", f"CSV path could not be read: {exc}") from exc
|
|
103
|
+
if isinstance(source, os.PathLike):
|
|
104
|
+
path = Path(source)
|
|
105
|
+
try:
|
|
106
|
+
return path.read_text(encoding="utf-8")
|
|
107
|
+
except (OSError, UnicodeError) as exc:
|
|
108
|
+
raise _error("source", f"CSV path could not be read: {exc}") from exc
|
|
109
|
+
if isinstance(source, (bytes, bytearray)):
|
|
110
|
+
raise _error("source", "binary CSV input is not accepted")
|
|
111
|
+
reader = getattr(source, "read", None)
|
|
112
|
+
if not callable(reader):
|
|
113
|
+
raise _error("source", "must be a filesystem path or readable text stream")
|
|
114
|
+
try:
|
|
115
|
+
text = reader()
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
raise _error("source", f"text stream could not be read: {exc}") from exc
|
|
118
|
+
if not isinstance(text, str):
|
|
119
|
+
raise _error("source", "binary streams are not accepted")
|
|
120
|
+
return text
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _csv_rows(source: Any, required: set[str]) -> list[dict[str, str]]:
|
|
124
|
+
text = _csv_text(source)
|
|
125
|
+
try:
|
|
126
|
+
parsed = list(csv.reader(text.splitlines(), strict=True))
|
|
127
|
+
except csv.Error as exc:
|
|
128
|
+
raise _error("source", f"malformed CSV: {exc}") from exc
|
|
129
|
+
if not parsed:
|
|
130
|
+
raise _error("header", "CSV header is required")
|
|
131
|
+
header = parsed[0]
|
|
132
|
+
if not header:
|
|
133
|
+
raise _error("header", "CSV header is required")
|
|
134
|
+
seen: set[str] = set()
|
|
135
|
+
for name in header:
|
|
136
|
+
if not name:
|
|
137
|
+
raise _error("header", "column names must be nonblank")
|
|
138
|
+
if name in seen:
|
|
139
|
+
raise _error(f"header.{name}", "duplicate column name")
|
|
140
|
+
seen.add(name)
|
|
141
|
+
missing = sorted(required - seen)
|
|
142
|
+
if missing:
|
|
143
|
+
raise _error(f"header.{missing[0]}", "required column is missing")
|
|
144
|
+
rows: list[dict[str, str]] = []
|
|
145
|
+
for index, values in enumerate(parsed[1:], 1):
|
|
146
|
+
if len(values) != len(header):
|
|
147
|
+
raise _error(f"row {index}", f"expected {len(header)} columns, found {len(values)}")
|
|
148
|
+
rows.append(dict(zip(header, values, strict=True)))
|
|
149
|
+
if not rows:
|
|
150
|
+
raise _error("source", "must contain at least one data row")
|
|
151
|
+
return rows
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _record_number(row: Mapping[str, Any], row_index: int, column: str) -> float:
|
|
155
|
+
value = row[column]
|
|
156
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
157
|
+
raise _error(f"row {row_index}.{column}", "must be a finite int or float (bool and numeric strings are rejected)")
|
|
158
|
+
return float(value)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _csv_number(row: Mapping[str, str], row_index: int, column: str) -> float:
|
|
162
|
+
value = row[column]
|
|
163
|
+
if value != value.strip() or not _CSV_NUMBER.fullmatch(value):
|
|
164
|
+
raise _error(f"row {row_index}.{column}", "must exactly match the finite CSV numeric grammar")
|
|
165
|
+
result = float(value)
|
|
166
|
+
if not math.isfinite(result):
|
|
167
|
+
raise _error(f"row {row_index}.{column}", "must parse to a finite number")
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _category(row: Mapping[str, Any], row_index: int, column: str) -> str:
|
|
172
|
+
value = row[column]
|
|
173
|
+
if not isinstance(value, str) or not value.strip():
|
|
174
|
+
raise _error(f"row {row_index}.{column}", "must be a nonblank string")
|
|
175
|
+
return value
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _numeric_order(value: Any, argument: str) -> list[float]:
|
|
179
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)) or not value:
|
|
180
|
+
raise _error(argument, "must be a nonempty sequence of unique finite numbers")
|
|
181
|
+
result: list[float] = []
|
|
182
|
+
for index, item in enumerate(value):
|
|
183
|
+
if isinstance(item, bool) or not isinstance(item, (int, float)) or not math.isfinite(item):
|
|
184
|
+
raise _error(f"{argument}[{index}]", "must be a finite number")
|
|
185
|
+
number = float(item)
|
|
186
|
+
if number in result:
|
|
187
|
+
raise _error(f"{argument}[{index}]", "duplicate order member")
|
|
188
|
+
result.append(number)
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _string_order(value: Any, argument: str) -> list[str]:
|
|
193
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)) or not value:
|
|
194
|
+
raise _error(argument, "must be a nonempty sequence of unique nonblank strings")
|
|
195
|
+
result: list[str] = []
|
|
196
|
+
for index, item in enumerate(value):
|
|
197
|
+
if not isinstance(item, str) or not item.strip():
|
|
198
|
+
raise _error(f"{argument}[{index}]", "must be a nonblank string")
|
|
199
|
+
if item in result:
|
|
200
|
+
raise _error(f"{argument}[{index}]", "duplicate order member")
|
|
201
|
+
result.append(item)
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _domain(value: Any, argument: str) -> list[float] | None:
|
|
206
|
+
if value is None:
|
|
207
|
+
return None
|
|
208
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)) or len(value) != 2:
|
|
209
|
+
raise _error(argument, "must contain exactly two finite increasing numbers")
|
|
210
|
+
result = []
|
|
211
|
+
for index, item in enumerate(value):
|
|
212
|
+
if isinstance(item, bool) or not isinstance(item, (int, float)) or not math.isfinite(item):
|
|
213
|
+
raise _error(f"{argument}[{index}]", "must be a finite number")
|
|
214
|
+
result.append(float(item))
|
|
215
|
+
if result[0] >= result[1]:
|
|
216
|
+
raise _error(argument, "must be strictly increasing")
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _require_members(observed: list[Any], order: list[Any], argument: str, column: str) -> None:
|
|
221
|
+
unknown = [item for item in observed if item not in order]
|
|
222
|
+
if unknown:
|
|
223
|
+
raise _error(column, f"observed member {unknown[0]!r} is absent from {argument}")
|
|
224
|
+
extras = [item for item in order if item not in observed]
|
|
225
|
+
if extras:
|
|
226
|
+
raise _error(argument, f"member {extras[0]!r} is not observed")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _common_columns(*values: tuple[Any, str]) -> set[str]:
|
|
230
|
+
return {_column_name(value, argument) for value, argument in values}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _line(rows: list[Mapping[str, Any]], *, numeric, spec, x: str, series: str,
|
|
234
|
+
value: str, x_order, series_order, x_domain, y_domain, reveal_order,
|
|
235
|
+
theme, profile, timeline, motion, application_profile):
|
|
236
|
+
x_values = _numeric_order(x_order, "x_order")
|
|
237
|
+
series_values = _string_order(series_order, "series_order")
|
|
238
|
+
if reveal_order not in {"random", "x"}:
|
|
239
|
+
raise _error("reveal_order", "must be 'random' or 'x'")
|
|
240
|
+
cells: dict[tuple[float, str], float] = {}
|
|
241
|
+
observed_x: list[float] = []
|
|
242
|
+
observed_series: list[str] = []
|
|
243
|
+
for index, row in enumerate(rows, 1):
|
|
244
|
+
row_x = numeric(row, index, x)
|
|
245
|
+
row_series = _category(row, index, series)
|
|
246
|
+
row_value = numeric(row, index, value)
|
|
247
|
+
if row_x not in observed_x:
|
|
248
|
+
observed_x.append(row_x)
|
|
249
|
+
if row_series not in observed_series:
|
|
250
|
+
observed_series.append(row_series)
|
|
251
|
+
cell = (row_x, row_series)
|
|
252
|
+
if cell in cells:
|
|
253
|
+
raise _error(f"row {index}.{value}", f"duplicate line cell for x={row_x!r}, series={row_series!r}")
|
|
254
|
+
cells[cell] = row_value
|
|
255
|
+
_require_members(observed_x, x_values, "x_order", x)
|
|
256
|
+
_require_members(observed_series, series_values, "series_order", series)
|
|
257
|
+
missing = [(row_x, row_series) for row_x in x_values for row_series in series_values if (row_x, row_series) not in cells]
|
|
258
|
+
if missing:
|
|
259
|
+
raise _error("data", f"incomplete line grid; missing x={missing[0][0]!r}, series={missing[0][1]!r}")
|
|
260
|
+
data: dict[str, Any] = {
|
|
261
|
+
"x": x_values,
|
|
262
|
+
"series": [{"key": key, "label": key, "y": [cells[(row_x, key)] for row_x in x_values]} for key in series_values],
|
|
263
|
+
"revealOrder": reveal_order,
|
|
264
|
+
}
|
|
265
|
+
if (domain := _domain(x_domain, "x_domain")) is not None:
|
|
266
|
+
data["xDomain"] = domain
|
|
267
|
+
if (domain := _domain(y_domain, "y_domain")) is not None:
|
|
268
|
+
data["yDomain"] = domain
|
|
269
|
+
return portable.export_contract(renderer="line", spec=spec, data=data, theme=theme,
|
|
270
|
+
profile=profile, timeline=timeline, motion=motion,
|
|
271
|
+
application_profile=application_profile)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _scatter(rows: list[Mapping[str, Any]], *, numeric, spec, x: str, y: str,
|
|
275
|
+
series: str | None, x_domain, y_domain, summary, theme, profile,
|
|
276
|
+
timeline, motion, application_profile):
|
|
277
|
+
if series is not None:
|
|
278
|
+
_column_name(series, "series")
|
|
279
|
+
xs, ys, identities = [], [], []
|
|
280
|
+
for index, row in enumerate(rows, 1):
|
|
281
|
+
xs.append(numeric(row, index, x))
|
|
282
|
+
ys.append(numeric(row, index, y))
|
|
283
|
+
identities.append(_category(row, index, series) if series is not None else "series")
|
|
284
|
+
labels = {key: key for key in dict.fromkeys(identities)}
|
|
285
|
+
data: dict[str, Any] = {"x": xs, "y": ys, "series": identities, "seriesLabels": labels}
|
|
286
|
+
if summary is not None:
|
|
287
|
+
data["summary"] = summary
|
|
288
|
+
if (domain := _domain(x_domain, "x_domain")) is not None:
|
|
289
|
+
data["xDomain"] = domain
|
|
290
|
+
if (domain := _domain(y_domain, "y_domain")) is not None:
|
|
291
|
+
data["yDomain"] = domain
|
|
292
|
+
return portable.export_contract(renderer="scatter", spec=spec, data=data, theme=theme,
|
|
293
|
+
profile=profile, timeline=timeline, motion=motion,
|
|
294
|
+
application_profile=application_profile)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _strip(rows: list[Mapping[str, Any]], *, numeric, spec, value: str, group: str,
|
|
298
|
+
group_order, series: str | None, summary, y_domain, theme, profile,
|
|
299
|
+
timeline, motion, application_profile):
|
|
300
|
+
groups = _string_order(group_order, "group_order")
|
|
301
|
+
if series is not None:
|
|
302
|
+
_column_name(series, "series")
|
|
303
|
+
values, assignments, identities = [], [], []
|
|
304
|
+
for index, row in enumerate(rows, 1):
|
|
305
|
+
values.append(numeric(row, index, value))
|
|
306
|
+
assignments.append(_category(row, index, group))
|
|
307
|
+
identities.append(_category(row, index, series) if series is not None else "series")
|
|
308
|
+
_require_members(list(dict.fromkeys(assignments)), groups, "group_order", group)
|
|
309
|
+
labels = {key: key for key in dict.fromkeys(identities)}
|
|
310
|
+
data: dict[str, Any] = {
|
|
311
|
+
"values": values, "groups": groups, "group": assignments,
|
|
312
|
+
"series": identities, "seriesLabels": labels,
|
|
313
|
+
}
|
|
314
|
+
if summary is not None:
|
|
315
|
+
data["summary"] = summary
|
|
316
|
+
if (domain := _domain(y_domain, "y_domain")) is not None:
|
|
317
|
+
data["yDomain"] = domain
|
|
318
|
+
return portable.export_contract(renderer="strip_summary", spec=spec, data=data,
|
|
319
|
+
theme=theme, profile=profile, timeline=timeline,
|
|
320
|
+
motion=motion, application_profile=application_profile)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def line_from_records(records_or_dataframe_like, *, spec, x, series, value,
|
|
324
|
+
x_order, series_order, x_domain=None, y_domain=None,
|
|
325
|
+
reveal_order="random", theme="slipware",
|
|
326
|
+
profile="deep_scope", timeline=None, motion=None,
|
|
327
|
+
application_profile="atlas"):
|
|
328
|
+
required = _common_columns((x, "x"), (series, "series"), (value, "value"))
|
|
329
|
+
rows = _record_rows(records_or_dataframe_like, required)
|
|
330
|
+
return _line(rows, numeric=_record_number, spec=spec, x=x, series=series,
|
|
331
|
+
value=value, x_order=x_order, series_order=series_order,
|
|
332
|
+
x_domain=x_domain, y_domain=y_domain, reveal_order=reveal_order,
|
|
333
|
+
theme=theme, profile=profile, timeline=timeline, motion=motion,
|
|
334
|
+
application_profile=application_profile)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def line_from_csv(source, *, spec, x, series, value, x_order, series_order,
|
|
338
|
+
x_domain=None, y_domain=None, reveal_order="random",
|
|
339
|
+
theme="slipware", profile="deep_scope", timeline=None,
|
|
340
|
+
motion=None, application_profile="atlas"):
|
|
341
|
+
required = _common_columns((x, "x"), (series, "series"), (value, "value"))
|
|
342
|
+
rows = _csv_rows(source, required)
|
|
343
|
+
return _line(rows, numeric=_csv_number, spec=spec, x=x, series=series,
|
|
344
|
+
value=value, x_order=x_order, series_order=series_order,
|
|
345
|
+
x_domain=x_domain, y_domain=y_domain, reveal_order=reveal_order,
|
|
346
|
+
theme=theme, profile=profile, timeline=timeline, motion=motion,
|
|
347
|
+
application_profile=application_profile)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def scatter_from_records(records_or_dataframe_like, *, spec, x, y, series=None,
|
|
351
|
+
x_domain=None, y_domain=None, summary=None,
|
|
352
|
+
theme="slipware", profile="deep_scope", timeline=None,
|
|
353
|
+
motion=None, application_profile="atlas"):
|
|
354
|
+
required = _common_columns((x, "x"), (y, "y"), *((series, "series"),) if series is not None else ())
|
|
355
|
+
rows = _record_rows(records_or_dataframe_like, required)
|
|
356
|
+
return _scatter(rows, numeric=_record_number, spec=spec, x=x, y=y,
|
|
357
|
+
series=series, x_domain=x_domain, y_domain=y_domain,
|
|
358
|
+
summary=summary, theme=theme, profile=profile,
|
|
359
|
+
timeline=timeline, motion=motion,
|
|
360
|
+
application_profile=application_profile)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def scatter_from_csv(source, *, spec, x, y, series=None, x_domain=None,
|
|
364
|
+
y_domain=None, summary=None, theme="slipware",
|
|
365
|
+
profile="deep_scope", timeline=None, motion=None,
|
|
366
|
+
application_profile="atlas"):
|
|
367
|
+
required = _common_columns((x, "x"), (y, "y"), *((series, "series"),) if series is not None else ())
|
|
368
|
+
rows = _csv_rows(source, required)
|
|
369
|
+
return _scatter(rows, numeric=_csv_number, spec=spec, x=x, y=y,
|
|
370
|
+
series=series, x_domain=x_domain, y_domain=y_domain,
|
|
371
|
+
summary=summary, theme=theme, profile=profile,
|
|
372
|
+
timeline=timeline, motion=motion,
|
|
373
|
+
application_profile=application_profile)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def strip_from_records(records_or_dataframe_like, *, spec, value, group,
|
|
377
|
+
group_order, series=None, summary=None, y_domain=None,
|
|
378
|
+
theme="slipware", profile="deep_scope", timeline=None,
|
|
379
|
+
motion=None, application_profile="atlas"):
|
|
380
|
+
required = _common_columns((value, "value"), (group, "group"), *((series, "series"),) if series is not None else ())
|
|
381
|
+
rows = _record_rows(records_or_dataframe_like, required)
|
|
382
|
+
return _strip(rows, numeric=_record_number, spec=spec, value=value,
|
|
383
|
+
group=group, group_order=group_order, series=series,
|
|
384
|
+
summary=summary, y_domain=y_domain, theme=theme,
|
|
385
|
+
profile=profile, timeline=timeline, motion=motion,
|
|
386
|
+
application_profile=application_profile)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def strip_from_csv(source, *, spec, value, group, group_order, series=None,
|
|
390
|
+
summary=None, y_domain=None, theme="slipware",
|
|
391
|
+
profile="deep_scope", timeline=None, motion=None,
|
|
392
|
+
application_profile="atlas"):
|
|
393
|
+
required = _common_columns((value, "value"), (group, "group"), *((series, "series"),) if series is not None else ())
|
|
394
|
+
rows = _csv_rows(source, required)
|
|
395
|
+
return _strip(rows, numeric=_csv_number, spec=spec, value=value,
|
|
396
|
+
group=group, group_order=group_order, series=series,
|
|
397
|
+
summary=summary, y_domain=y_domain, theme=theme,
|
|
398
|
+
profile=profile, timeline=timeline, motion=motion,
|
|
399
|
+
application_profile=application_profile)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Application profiles for publication, atlas, and presentation output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
APPLICATION_PROFILE_VERSION = "figurestead.application-profile/1"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ApplicationProfile:
|
|
15
|
+
key: str
|
|
16
|
+
name: str
|
|
17
|
+
surface: str
|
|
18
|
+
density: str
|
|
19
|
+
typography: str
|
|
20
|
+
legend: str
|
|
21
|
+
ambient: str
|
|
22
|
+
motion: str
|
|
23
|
+
line_width: float
|
|
24
|
+
marker_scale: float
|
|
25
|
+
panel_surface: bool
|
|
26
|
+
frame: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
APPLICATION_PROFILES = {
|
|
30
|
+
"paper": ApplicationProfile("paper", "Paper", "neutral", "compact", "publication", "outside", "none", "none", 1.55, .92, False, False),
|
|
31
|
+
"atlas": ApplicationProfile("atlas", "Atlas", "themed", "balanced", "scientific", "auto", "none", "semantic", 2.15, 1.12, True, True),
|
|
32
|
+
"talk": ApplicationProfile("talk", "Talk", "themed", "open", "display", "auto", "none", "semantic", 2.75, 1.34, True, True),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_application_profile(profile: str | ApplicationProfile = "atlas") -> ApplicationProfile:
|
|
37
|
+
if isinstance(profile, ApplicationProfile):
|
|
38
|
+
return profile
|
|
39
|
+
try:
|
|
40
|
+
return APPLICATION_PROFILES[profile]
|
|
41
|
+
except KeyError as exc:
|
|
42
|
+
raise ValueError(f"Unknown application profile {profile!r}; choose: {', '.join(APPLICATION_PROFILES)}") from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def apply_application_profile(contract: Mapping[str, Any], profile: str | ApplicationProfile = "atlas", *, motion: str | None = None, ambient: str | None = None) -> dict[str, Any]:
|
|
46
|
+
result = deepcopy(dict(contract))
|
|
47
|
+
value = get_application_profile(profile)
|
|
48
|
+
previous = get_application_profile(result.get("view", {}).get("profile", "atlas"))
|
|
49
|
+
previous_presentation = {
|
|
50
|
+
"panelSurface": previous.panel_surface, "frame": previous.frame,
|
|
51
|
+
"legend": "outside-right" if previous.legend == "outside" else "auto" if previous.legend == "auto" else "bottom-right",
|
|
52
|
+
"lineWidth": previous.line_width, "markerScale": previous.marker_scale,
|
|
53
|
+
}
|
|
54
|
+
result["view"] = {"profile": value.key, "motion": motion or value.motion, "ambient": ambient or value.ambient, "strategy": "auto" if (motion or value.motion) == "semantic" else "none"}
|
|
55
|
+
for panel in result.get("panels", []):
|
|
56
|
+
overrides = {key: item for key, item in dict(panel.get("presentation", {})).items() if previous_presentation.get(key) != item}
|
|
57
|
+
panel["presentation"] = {
|
|
58
|
+
"panelSurface": value.panel_surface, "frame": value.frame,
|
|
59
|
+
"legend": "outside-right" if value.legend == "outside" else "auto" if value.legend == "auto" else "bottom-right",
|
|
60
|
+
"lineWidth": value.line_width, "markerScale": value.marker_scale,
|
|
61
|
+
**overrides,
|
|
62
|
+
}
|
|
63
|
+
return result
|