Skip to content

Commit 3077b36

Browse files
lmeyerovclaude
andauthored
Phase 1: settings validators + deep ring axis validation + persist axis defaults (#1280)
* phase1: validators, deep ring axis checks, and persist axis defaults * fix: satisfy mypy for dataset_id URL refresh helper * fix: make validate_settings typing compatible with python 3.8 * refactor: centralize axis field constants and typed dict contracts * docs(changelog): record settings/axis validation + persist defaults work * refactor(validate): centralize ring axis payload validators * types(validate): add named url params/react settings aliases * types(url_params): propagate named URLParamsDict across APIs * feat(cypher): admit simple free-form intermediate MATCH (#1263) (#1279) * feat(cypher/ir): add ReentryPlan.free_form marker for intermediate MATCH (#1263) Adds a `free_form: bool = False` field to `ReentryPlan` so the runtime can distinguish the LDBC SNB IC3 free-form intermediate MATCH shape from the existing whole-row and scalar-only prefix shapes. When the trailing MATCH after a prefix `WITH` introduces aliases none of which is in the carried set, no carried alias anchors the seed pattern. The runtime needs to broadcast carried columns onto the base node table (single-prefix-row) or fall back to per-row union (multi-prefix-row) so the trailing MATCH cross-joins implicitly via the row pipeline. Default value preserves existing whole-row and scalar-only behavior. Compile + runtime branches that consume this marker land in subsequent commits. Refs #1263 #999 #989 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cypher): admit free-form intermediate MATCH at compile (#1263) Lifts the failfast at the trailing-MATCH-first-alias check in ``_compile_bounded_reentry_query`` for the free-form case (LDBC SNB IC3 endpoint): when the trailing MATCH binds aliases none of which is in the prefix WITH's carried whole-row set, treat every carried alias as non-source and use the trailing MATCH's first alias as the carrier label for downstream rewrites. The existing ``_collect_non_source_alias_property_refs`` / ``_rewrite_reentry_expr_to_hidden_properties`` machinery (slice 4.3a/b from #1248) materializes carried-alias property references as hidden columns; with this change the same machinery covers free-form cases by passing all whole-row aliases as ``non_source_alias_names``. The ``ReentryPlan`` constructed for free-form sets ``free_form=True`` (no ``CarriedAlias`` entry has ``is_reentry_alias=True``) so the runtime can branch into a broadcast path. The runtime change lands in a follow-up commit; until then, free-form queries compile but return wrong rows because the existing ``_compiled_query_reentry_state`` still seeds from the wrong alias's node ids — the regression is gated by the failfast tests (which fail intentionally at this commit and are retargeted alongside the runtime commit). Also tightens the surviving guard from ``first_alias is None or first_alias != reentry_alias`` to ``first_alias is None`` since the admit branch now sets ``reentry_alias = first_alias`` for the previously-rejected case. Refs #1263 #999 #989 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cypher): runtime broadcast for simple free-form + scoped failfast (#1263) Lands the runtime half of the #1263 conservative admit: * New ``_compiled_query_freeform_reentry_state`` in ``gfql_unified.py`` branches on ``ReentryPlan.free_form``. For a single-row prefix WITH it broadcasts every ``__cypher_reentry_*`` hidden column from the prefix row onto every base node and returns ``start_nodes=None``, so the trailing MATCH runs as a regular MATCH and inherits the carried values via the row pipeline. Multi-row prefix raises a clear failfast pointing at the multi-row free-form follow-up slice. * Compile gate added in ``_compile_bounded_reentry_query``: when ``free_form`` is True AND the trailing scope references any carried alias property (e.g. ``WHERE country.id IN [x.id, y.id]`` in literal IC3), raise a scoped ``#1263`` failfast pointing at the rewrite-order refactor follow-up. The double-rewrite that emerges from composing ``_demote_secondary_whole_row_aliases`` (#1071) with ``_rewrite_reentry_expr_to_hidden_properties`` (#1248) under free-form is deferred to its own focused slice. End-to-end: * Simple free-form (``MATCH (a) WITH a MATCH (c)-[:T]->(d) RETURN ...``) compiles + executes vectorized on pandas. * IC3-shape with carried-property WHERE rejects with a scoped #1263 failfast (clear error pointing at the follow-up). Refs #1263 #999 #989 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(cypher): retarget free-form failfast tests for #1263 conservative admit Two #1263 regression-locks added in #1270 expected the carried-alias gate to fire for free-form intermediate MATCH. The conservative admit landed in the preceding commits flips the simple shape from rejection to positive execution and tightens the rejection scope to the carried-property variant: * ``test_string_cypher_failfast_rejects_intermediate_reentry_match_with_no_carried_source`` → renamed to ``..._with_carried_property_in_trailing_where`` and pinned to the new scoped #1263 failfast (carried-alias property in trailing scope). * ``test_string_cypher_failfast_rejects_simple_freeform_intermediate_reentry_match`` → renamed to ``test_string_cypher_executes_simple_freeform_intermediate_reentry_match`` and converted to positive: asserts the trailing MATCH binds correctly against the broadcast carried row table. * New ``..._on_cudf_when_available`` mirror with ``pytest.importorskip("cudf")`` for engine parity per coordinator def-of-done. Refs #1263 #999 #989 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(cypher): add multi-prefix-row free-form failfast regression (#1263) Wave 1 review (single CONFIRMED IMPORTANT): the runtime failfast at gfql_unified.py for multi-prefix-row free-form admit was untested. Adds a positive regression-lock that builds a 2-row prefix and asserts the scoped ``single-row prefix WITH only`` failfast wording. Refs #1263 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(changelog): #1263 simple free-form intermediate MATCH admit Records the conservative #1263 close: simple free-form admit + scoped failfast for the carried-property-in-trailing-WHERE variant + runtime broadcast helper + retargeted regression tests + three TCK admits. Refs #1263 #999 #989 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(cypher): Wave 2 amplification — multi-carried + empty prefix free-form (#1263) Wave 2 review per agents/skills/review/SKILL.md confirmed convergence (two consecutive non-significant-advance waves) and surfaced two amplification SUGGESTIONs that the reviewers verified produce correct behavior. Locking the contract: * test_string_cypher_executes_freeform_intermediate_reentry_match_with_multi_carried_aliases — covers `WITH a, b MATCH (c)-[:T]->(d)` shape (multi-carried-aliases admit). * test_string_cypher_executes_freeform_intermediate_reentry_match_with_empty_prefix — covers `_compiled_query_freeform_reentry_state`'s empty-prefix early-return path. Refs #1263 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c069e8f commit 3077b36

15 files changed

Lines changed: 707 additions & 35 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
2828
### Added
2929
- **CI / Polars**: Added `test-polars` CI job (Python 3.9–3.14) with a dedicated `test-polars` lockfile profile; `polars` is now a named `setup.py` extra so the test matrix installs and exercises `test_polars.py` on every PR (#1133).
3030
- **Polars support**: `polars.DataFrame` and `polars.LazyFrame` now work in `plot()`, `materialize_nodes()`, `get_degrees()`, `get_indegrees()`, `get_outdegrees()`, and `hypergraph()`. Polars is an optional dependency — no behavior change when not installed. Upload path uses efficient Arrow conversion (`to_arrow()` with schema-metadata stripping and memoization); compute/hypergraph paths coerce to pandas at entry. `LazyFrame` is materialized via `.collect()` at each boundary. Adds `test_polars.py` with 17 tests; skips gracefully when polars is absent (#1133).
31+
- **Validation / settings + axis contracts (#1240, #1239, #1251)**: Added reusable public validation/settings contracts under `graphistry.validate` including `URL_PARAM_NAMES`/`REACT_SETTING_NAMES` (and set forms), `normalize_url_params()` / `normalize_react_settings()`, axis URL default contracts (`RADIAL_AXIS_URL_DEFAULTS`, `LINEAR_AXIS_URL_DEFAULTS`, `axis_url_defaults()`), and typed axis payload contracts (`AxisRow`, `AxisBounds`, `RingContinuousAxis`, `RingCategoricalAxis`, plus centralized allowed-field constants). GFQL call validation now deep-validates `ring_continuous_layout.axis` and `ring_categorical_layout.axis` against these contracts, and `gfql_remote(persist=True)` metadata hydration now round-trips and reapplies URL params/defaults correctly after server persistence.
3132

3233
### Internal
3334
- **GFQL / Cypher lowering — #1273 acceptance slice admits scalar multi-alias WITH shape A**: First-stage bounded reentry now admits `MATCH ... WITH a.id AS a_id, b.id AS b_id ... MATCH ...` multi-alias scalar/property projections (shape A) while keeping whole-row multi-alias carries (for example `WITH a, b`) outside the admitted lane. The admission gate is explicitly narrowed to scalar/property projections only, so known TCK xfail contract shape `WITH n, x` (`with-where3-3`) remains fail-fast and unchanged pending broader #1273 closure. Tests were converted to positive execution assertions for the admitted shape, and remaining rejected shapes continue to point explicitly to #1273.

graphistry/Plottable.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from graphistry.client_session import ClientSession, AuthManagerProtocol
2020
from graphistry.models.collections import CollectionsInput
2121
from graphistry.models.types import ValidationParam
22+
from graphistry.validate import URLParamsDict
2223

2324
if TYPE_CHECKING:
2425
try:
@@ -83,7 +84,7 @@ class Plottable(Protocol):
8384
_point_latitude : Optional[str]
8485
_height : int
8586
_render : RenderModesConcrete
86-
_url_params : dict
87+
_url_params : URLParamsDict
8788
_privacy : Optional[Privacy]
8889
_name : Optional[str]
8990
_description : Optional[str]
@@ -780,8 +781,10 @@ def encode_axis(self, rows: List[Dict] = []) -> 'Plottable':
780781

781782
def settings(self,
782783
height: Optional[int] = None,
783-
url_params: Dict[str, Any] = {},
784-
render: Optional[Union[bool, RenderModes]] = None
784+
url_params: Optional[URLParamsDict] = None,
785+
render: Optional[Union[bool, RenderModes]] = None,
786+
validate: ValidationParam = 'autofix',
787+
warn: bool = True
785788
) -> 'Plottable':
786789
...
787790

graphistry/PlotterBase.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from graphistry.io.types import ComplexEncodingsDict
55
from graphistry.models.collections import CollectionsInput
66
from graphistry.models.types import ValidationMode, ValidationParam
7+
from graphistry.validate import URLParamsDict
78
from graphistry.plugins_types.hypergraph import HypergraphResult
89
from graphistry.render.resolve_render_mode import resolve_render_mode
910
from graphistry.Engine import EngineAbstractType
@@ -227,7 +228,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
227228
# Settings
228229
self._height : int = 500
229230
self._render : RenderModesConcrete = resolve_render_mode(self, True)
230-
self._url_params : dict = {'info': 'true'}
231+
self._url_params : URLParamsDict = {'info': 'true'}
231232
self._privacy : Optional[Privacy] = None
232233
# Metadata
233234
self._name : Optional[str] = None
@@ -1883,7 +1884,14 @@ def graph(self, ig: Any) -> Plottable:
18831884
return res
18841885

18851886

1886-
def settings(self, height=None, url_params={}, render=None):
1887+
def settings(
1888+
self,
1889+
height=None,
1890+
url_params: Optional[URLParamsDict] = None,
1891+
render=None,
1892+
validate: ValidationParam = 'autofix',
1893+
warn: bool = True
1894+
):
18871895
"""Specify iframe height and add URL parameter dictionary.
18881896
18891897
Collections URL params are normalized and URL-encoded at plot time; other
@@ -1898,11 +1906,19 @@ def settings(self, height=None, url_params={}, render=None):
18981906
:param render: Whether to render the visualization using the native notebook environment (default True), or return the visualization URL
18991907
:type render: bool
19001908
1909+
:param validate: Validation mode for url_params. 'autofix' (default) drops invalid keys/types with warnings; 'strict' raises.
1910+
:type validate: ValidationParam
1911+
1912+
:param warn: Whether to emit warnings in autofix mode.
1913+
:type warn: bool
1914+
19011915
"""
1916+
from graphistry.validate import normalize_url_params
19021917

19031918
res = copy.copy(self)
19041919
res._height = height or self._height
1905-
res._url_params = dict(self._url_params, **url_params)
1920+
normalized = normalize_url_params(url_params, validate=validate, warn=warn)
1921+
res._url_params = dict(self._url_params, **normalized)
19061922
res._render = self._render if render is None else resolve_render_mode(self, render)
19071923
return res
19081924

graphistry/client_session.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import warnings
77

88
from graphistry.privacy import Privacy
9+
from graphistry.validate.validate_settings import URLParamsDict
910
from . import util
1011
from .plugins_types.spanner_types import SpannerConfig
1112
from .plugins_types.kusto_types import KustoConfig
@@ -135,7 +136,7 @@ class AuthManagerProtocol(Protocol):
135136
def refresh(self, token: Optional[str] = None, fail_silent: bool = False) -> Optional[str]:
136137
...
137138

138-
def _viz_url(self, info: DatasetInfo, url_params: Dict[str, Any]) -> str:
139+
def _viz_url(self, info: DatasetInfo, url_params: URLParamsDict) -> str:
139140
...
140141

141142
def certificate_validation(self, value: Optional[bool] = None) -> bool:

graphistry/compute/chain_remote.py

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,29 @@ def _compiled_to_let_json(compiled: CompiledQueryLike) -> Dict[str, Any]:
9292
return {"type": "Let", "bindings": bindings}
9393

9494

95+
def _refresh_url_from_dataset_id(g: Plottable) -> None:
96+
dataset_id = getattr(g, "_dataset_id", None)
97+
if not isinstance(dataset_id, str) or dataset_id == "":
98+
return
99+
info: DatasetInfo = {
100+
"name": dataset_id,
101+
"type": "arrow",
102+
"viztoken": str(uuid.uuid4()),
103+
}
104+
g._url = g._pygraphistry._viz_url(info, g._url_params)
105+
106+
107+
def _apply_persist_axis_defaults(g: Plottable) -> None:
108+
from graphistry.validate import apply_axis_url_defaults
109+
110+
merged = apply_axis_url_defaults(
111+
getattr(g, "_url_params", None),
112+
getattr(g, "_complex_encodings", None),
113+
)
114+
if isinstance(merged, dict):
115+
g._url_params = merged
116+
117+
95118
def chain_remote_generic(
96119
self: Plottable,
97120
chain: Union[Chain, Dict[str, JSONVal], List[Any], 'ASTLet', str],
@@ -311,20 +334,17 @@ def chain_remote_generic(
311334

312335
# Generate URL using existing infrastructure
313336
if result._dataset_id: # Type guard
314-
info: DatasetInfo = {
315-
'name': result._dataset_id,
316-
'type': 'arrow',
317-
'viztoken': str(uuid.uuid4())
318-
}
319-
320-
result._url = result._pygraphistry._viz_url(info, result._url_params)
337+
_refresh_url_from_dataset_id(result)
321338

322339
# Optionally restore privacy settings
323340
if 'privacy' in metadata:
324341
result._privacy = metadata['privacy']
325342

326343
if 'gfql_metadata' in metadata:
327344
result = deserialize_plottable_metadata(metadata['gfql_metadata'], result)
345+
_apply_persist_axis_defaults(result)
346+
if persist:
347+
_refresh_url_from_dataset_id(result)
328348

329349
except Exception as e:
330350
if persist:
@@ -390,20 +410,17 @@ def chain_remote_generic(
390410

391411
# Generate URL using existing infrastructure
392412
if result._dataset_id: # Type guard
393-
dataset_info: DatasetInfo = {
394-
'name': result._dataset_id,
395-
'type': 'arrow',
396-
'viztoken': str(uuid.uuid4())
397-
}
398-
399-
result._url = result._pygraphistry._viz_url(dataset_info, result._url_params)
413+
_refresh_url_from_dataset_id(result)
400414
else:
401415
warnings.warn("persist=True requested but server did not return dataset_id in JSON response. "
402416
"URL generation will not be available. This indicates an older server version that doesn't support persistence.",
403417
UserWarning, stacklevel=2)
404418

405419
if 'metadata' in o:
406420
result = deserialize_plottable_metadata(o['metadata'], result)
421+
_apply_persist_axis_defaults(result)
422+
if persist:
423+
_refresh_url_from_dataset_id(result)
407424

408425
return result
409426
else:

graphistry/compute/gfql/call/validation.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@
4444
is_unwind_expr,
4545
validate_hypergraph_opts,
4646
)
47+
from graphistry.validate import (
48+
is_ring_categorical_axis_payload,
49+
is_ring_continuous_axis_payload,
50+
)
4751
from graphistry.compute.gfql.row.order_expr import (
4852
is_order_aggregate_alias_ast,
4953
order_expr_ast_static_supported,
@@ -608,7 +612,7 @@ def _semi_apply_mark_added_node_cols(params: Dict[str, object]) -> Set[str]:
608612
'v_start': lambda v: v is None or is_int_or_float(v),
609613
'v_end': lambda v: v is None or is_int_or_float(v),
610614
'v_step': lambda v: v is None or is_int_or_float(v),
611-
'axis': lambda v: v is None or is_list_or_dict(v),
615+
'axis': lambda v: v is None or is_ring_continuous_axis_payload(v),
612616
'normalize_ring_col': is_bool,
613617
'reverse': is_bool,
614618
'play_ms': lambda v: v is None or is_int(v),
@@ -633,7 +637,7 @@ def _semi_apply_mark_added_node_cols(params: Dict[str, object]) -> Set[str]:
633637
'append_unhandled': is_bool,
634638
'min_r': lambda v: v is None or is_int_or_float(v),
635639
'max_r': lambda v: v is None or is_int_or_float(v),
636-
'axis': lambda v: v is None or is_list_or_dict(v),
640+
'axis': lambda v: v is None or is_ring_categorical_axis_payload(v),
637641
'reverse': is_bool,
638642
'play_ms': lambda v: v is None or is_int(v),
639643
'engine': lambda v: v is None or v in ('auto', 'pandas', 'cudf', 'dask', 'dask_cudf')

graphistry/io/metadata.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- encodings: simple (point_color, etc.) and complex encodings
1010
- metadata: name, description
1111
- style: visualization styles
12+
- url_params: viewer URL parameter defaults
1213
"""
1314
from typing import Any, Dict, List, TYPE_CHECKING
1415
import copy
@@ -21,6 +22,7 @@
2122
NodeEdgeEncodingsDict,
2223
PlottableMetadata,
2324
)
25+
from graphistry.validate import URLParamsDict, normalize_url_params
2426

2527
if TYPE_CHECKING:
2628
from graphistry.Plottable import Plottable
@@ -241,6 +243,10 @@ def serialize_plottable_metadata(g: 'Plottable') -> PlottableMetadata:
241243
style: Dict[str, Any] = {}
242244
if hasattr(g, '_style') and g._style:
243245
style = g._style
246+
url_params: URLParamsDict = {}
247+
if hasattr(g, "_url_params") and isinstance(g._url_params, dict):
248+
# Keep serializer permissive and never raise from metadata export path.
249+
url_params = normalize_url_params(g._url_params, validate="autofix", warn=False)
244250

245251
result: PlottableMetadata = {}
246252
if bindings:
@@ -251,6 +257,8 @@ def serialize_plottable_metadata(g: 'Plottable') -> PlottableMetadata:
251257
result['metadata'] = metadata_obj
252258
if style:
253259
result['style'] = style
260+
if url_params:
261+
result['url_params'] = url_params
254262

255263
return result
256264

@@ -350,4 +358,13 @@ def deserialize_plottable_metadata(metadata: PlottableMetadata, g: 'Plottable')
350358
except Exception as e:
351359
warnings.warn(f"Failed to hydrate style from metadata: {e}", UserWarning, stacklevel=2)
352360

361+
if 'url_params' in metadata:
362+
try:
363+
url_params = metadata['url_params']
364+
if isinstance(url_params, dict):
365+
res = copy.copy(res)
366+
res._url_params = normalize_url_params(url_params, validate="autofix", warn=False)
367+
except Exception as e:
368+
warnings.warn(f"Failed to hydrate url_params from metadata: {e}", UserWarning, stacklevel=2)
369+
353370
return res

graphistry/io/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
"""
77
from typing import Any, Dict, TypedDict
88

9+
from graphistry.validate import URLParamsDict
10+
911

1012
# Complex Encodings Structure
1113
class ComplexEncodingModes(TypedDict):
@@ -116,6 +118,7 @@ class PlottableMetadata(TypedDict, total=False):
116118
:field encodings: Visual encoding mappings (colors, sizes, labels, complex)
117119
:field metadata: Graph metadata (name, description)
118120
:field style: Visualization styles (background, layout, etc.)
121+
:field url_params: Visualization URL parameter defaults
119122
120123
**Example**
121124
@@ -144,3 +147,4 @@ class PlottableMetadata(TypedDict, total=False):
144147
encodings: EncodingsDict
145148
metadata: MetadataDict
146149
style: Dict[str, Any]
150+
url_params: URLParamsDict

graphistry/pygraphistry.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from graphistry.Engine import EngineAbstractType
99
from graphistry.models.collections import CollectionsInput
1010
from graphistry.models.types import ValidationParam
11+
from graphistry.validate import URLParamsDict
1112
from graphistry.otel import inject_trace_headers, otel as otel_config
1213

1314
"""Top-level import of class PyGraphistry as "Graphistry". Used to connect to the Graphistry server and then create a base plotter."""
@@ -2374,9 +2375,16 @@ def from_cugraph(self,
23742375
):
23752376
return self._plotter().from_cugraph(G, node_attributes, edge_attributes, load_nodes, load_edges, merge_if_existing)
23762377

2377-
def settings(self, height=None, url_params={}, render=None):
2378+
def settings(
2379+
self,
2380+
height=None,
2381+
url_params: Optional[URLParamsDict] = None,
2382+
render=None,
2383+
validate: ValidationParam = 'autofix',
2384+
warn: bool = True,
2385+
):
23782386

2379-
return self._plotter().settings(height, url_params, render)
2387+
return self._plotter().settings(height, url_params, render, validate=validate, warn=warn)
23802388

23812389
def collections(
23822390
self,
@@ -2396,7 +2404,7 @@ def collections(
23962404
warn=warn
23972405
)
23982406

2399-
def _viz_url(self, info: DatasetInfo, url_params: Dict[str, Any]) -> str:
2407+
def _viz_url(self, info: DatasetInfo, url_params: URLParamsDict) -> str:
24002408
splash_time = int(calendar.timegm(time.gmtime())) + 15
24012409
extra = "&".join([k + "=" + str(v) for k, v in list(url_params.items())])
24022410
cph = self.client_protocol_hostname()

graphistry/tests/compute/test_gfql_call_validation.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,5 +386,53 @@ def test_call_helper_validates_params(self):
386386
call_obj.validate()
387387

388388

389+
class TestRingAxisValidation:
390+
"""Deep axis payload validation for ring layout calls."""
391+
392+
def test_ring_categorical_axis_label_map_valid(self):
393+
params = validate_call_params('ring_categorical_layout', {
394+
'ring_col': 'segment',
395+
'axis': {'a': 'A', 'b': 'B'},
396+
})
397+
assert params['axis'] == {'a': 'A', 'b': 'B'}
398+
399+
def test_ring_categorical_axis_rows_valid(self):
400+
params = validate_call_params('ring_categorical_layout', {
401+
'ring_col': 'segment',
402+
'axis': [{'r': 200, 'label': 'outer', 'external': True}],
403+
})
404+
assert isinstance(params['axis'], list)
405+
406+
def test_ring_categorical_axis_rejects_invalid_rows(self):
407+
with pytest.raises(GFQLTypeError) as exc_info:
408+
validate_call_params('ring_categorical_layout', {
409+
'ring_col': 'segment',
410+
'axis': [{'label': 'missing_pos'}],
411+
})
412+
assert 'axis' in exc_info.value.message
413+
414+
def test_ring_continuous_axis_accepts_string_labels(self):
415+
params = validate_call_params('ring_continuous_layout', {
416+
'ring_col': 'score',
417+
'axis': ['low', 'mid', 'high'],
418+
})
419+
assert params['axis'] == ['low', 'mid', 'high']
420+
421+
def test_ring_continuous_axis_accepts_numeric_map(self):
422+
params = validate_call_params('ring_continuous_layout', {
423+
'ring_col': 'score',
424+
'axis': {100.0: 'inner', 200.0: 'outer'},
425+
})
426+
assert isinstance(params['axis'], dict)
427+
428+
def test_ring_continuous_axis_rejects_bad_bounds(self):
429+
with pytest.raises(GFQLTypeError) as exc_info:
430+
validate_call_params('ring_continuous_layout', {
431+
'ring_col': 'score',
432+
'axis': [{'y': 40, 'bounds': {'min': '40', 'max': 100}}],
433+
})
434+
assert 'axis' in exc_info.value.message
435+
436+
389437
if __name__ == '__main__':
390438
pytest.main([__file__, '-v', '--tb=short'])

0 commit comments

Comments
 (0)