From bbe0a339cd4183a68274fdd7b984afce2c38535e Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 17 Nov 2025 08:38:23 -0800 Subject: [PATCH 01/12] ci: add python 3.13 to test matrices --- .github/workflows/ci-gpu.yml | 2 +- .github/workflows/ci.yml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-gpu.yml b/.github/workflows/ci-gpu.yml index 9a23e6a923..6a8e75c4b8 100644 --- a/.github/workflows/ci-gpu.yml +++ b/.github/workflows/ci-gpu.yml @@ -50,7 +50,7 @@ jobs: strategy: matrix: - python-version: ['3.10', 3.11, 3.12] + python-version: ['3.10', 3.11, 3.12, '3.13'] steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07b53cc0d2..3e9cae5d02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,7 @@ jobs: strategy: matrix: - python-version: [3.8, 3.9, '3.10', 3.11, 3.12] # Run lint/types on all versions + python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13'] # Run lint/types on all versions steps: - name: Checkout repo @@ -108,7 +108,7 @@ jobs: strategy: matrix: - python-version: [3.8, 3.9, '3.10', 3.11, 3.12] + python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13'] steps: @@ -153,7 +153,7 @@ jobs: strategy: matrix: - python-version: [3.8, 3.9, '3.10', 3.11, 3.12] + python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13'] steps: @@ -191,7 +191,7 @@ jobs: strategy: matrix: - python-version: [3.8, 3.9, '3.10', 3.11, 3.12] + python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13'] steps: @@ -257,7 +257,7 @@ jobs: strategy: matrix: - python-version: [3.9, '3.10', 3.11, 3.12] + python-version: [3.9, '3.10', 3.11, 3.12, '3.13'] steps: @@ -300,7 +300,7 @@ jobs: strategy: matrix: - python-version: [3.9, '3.10', 3.11, 3.12] + python-version: [3.9, '3.10', 3.11, 3.12, '3.13'] #include: # - python-version: 3.12 # continue-on-error: true From 1b3d22c7b16bc7fee34f105a5491d260a6474655 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 17 Nov 2025 11:24:37 -0800 Subject: [PATCH 02/12] ci: add python 3.14 to test matrices --- .github/workflows/ci-gpu.yml | 2 +- .github/workflows/ci.yml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-gpu.yml b/.github/workflows/ci-gpu.yml index 6a8e75c4b8..91e6044206 100644 --- a/.github/workflows/ci-gpu.yml +++ b/.github/workflows/ci-gpu.yml @@ -50,7 +50,7 @@ jobs: strategy: matrix: - python-version: ['3.10', 3.11, 3.12, '3.13'] + python-version: ['3.10', 3.11, 3.12, '3.13', '3.14'] steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e9cae5d02..81ac36ccf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,7 @@ jobs: strategy: matrix: - python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13'] # Run lint/types on all versions + python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13', '3.14'] # Run lint/types on all versions steps: - name: Checkout repo @@ -108,7 +108,7 @@ jobs: strategy: matrix: - python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13'] + python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13', '3.14'] steps: @@ -153,7 +153,7 @@ jobs: strategy: matrix: - python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13'] + python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13', '3.14'] steps: @@ -191,7 +191,7 @@ jobs: strategy: matrix: - python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13'] + python-version: [3.8, 3.9, '3.10', 3.11, 3.12, '3.13', '3.14'] steps: @@ -257,7 +257,7 @@ jobs: strategy: matrix: - python-version: [3.9, '3.10', 3.11, 3.12, '3.13'] + python-version: [3.9, '3.10', 3.11, 3.12, '3.13', '3.14'] steps: @@ -300,7 +300,7 @@ jobs: strategy: matrix: - python-version: [3.9, '3.10', 3.11, 3.12, '3.13'] + python-version: [3.9, '3.10', 3.11, 3.12, '3.13', '3.14'] #include: # - python-version: 3.12 # continue-on-error: true From 9a7a5a818aba626a9ff76c819472429d842756ce Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 17 Nov 2025 11:33:55 -0800 Subject: [PATCH 03/12] fix: dedupe policy imports for newer python --- graphistry/compute/chain.py | 42 ++++++++----- graphistry/compute/chain_let.py | 77 ++++++++++++++---------- graphistry/compute/gfql/call_executor.py | 35 +++++++---- 3 files changed, 94 insertions(+), 60 deletions(-) diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index 87cb8101c6..2a6138832a 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, Union, cast, List, Tuple, Sequence, Optional, TYPE_CHECKING +from typing import Dict, Union, cast, List, Tuple, Sequence, Optional, TYPE_CHECKING, Callable, Any from graphistry.Engine import Engine, EngineAbstract, df_concat, df_to_engine, resolve_engine from graphistry.Plottable import Plottable @@ -14,10 +14,18 @@ if TYPE_CHECKING: from graphistry.compute.exceptions import GFQLSchemaError, GFQLValidationError + from .gfql.policy import PolicyContext logger = setup_logger(__name__) +def _load_policy_runtime_deps() -> Tuple[type, Callable[[Plottable], Dict[str, Any]]]: + from .gfql.policy import PolicyException + from .gfql.policy.stats import extract_graph_stats + + return PolicyException, extract_graph_stats + + ############################################################################### @@ -604,6 +612,11 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni error = None success = False + policy_exception_cls: Optional[type] = None + extract_graph_stats_fn: Optional[Callable[[Plottable], Dict[str, Any]]] = None + if policy and any(hook in policy for hook in ('prechain', 'postchain', 'postload')): + policy_exception_cls, extract_graph_stats_fn = _load_policy_runtime_deps() + try: g = self.materialize_nodes(engine=EngineAbstract(engine_concrete.value)) @@ -627,13 +640,12 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni # Prechain hook - fires BEFORE chain operations execute if policy and 'prechain' in policy: - from .gfql.policy import PolicyContext, PolicyException - from .gfql.policy.stats import extract_graph_stats + assert extract_graph_stats_fn is not None and policy_exception_cls is not None - stats = extract_graph_stats(g) + stats = extract_graph_stats_fn(g) current_path = context.operation_path - prechain_context: PolicyContext = { + prechain_context: 'PolicyContext' = { 'phase': 'prechain', 'hook': 'prechain', 'query': ops, @@ -649,7 +661,7 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni try: policy['prechain'](prechain_context) - except PolicyException: + except policy_exception_cls: raise logger.debug('======================== FORWARDS ========================') @@ -774,16 +786,15 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni # Postchain hook - fires AFTER chain operations complete (even on error) postchain_policy_error = None if policy and 'postchain' in policy: - from .gfql.policy import PolicyContext, PolicyException - from .gfql.policy.stats import extract_graph_stats + assert extract_graph_stats_fn is not None and policy_exception_cls is not None # Extract stats from result (if success) or input graph (if error) # Cast: if success=True, g_out is guaranteed to be a Plottable graph_for_stats = cast(Plottable, g_out) if success else self - stats = extract_graph_stats(graph_for_stats) + stats = extract_graph_stats_fn(graph_for_stats) current_path = context.operation_path - postchain_context: PolicyContext = { + postchain_context: 'PolicyContext' = { 'phase': 'postchain', 'hook': 'postchain', 'query': ops, @@ -805,22 +816,21 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni try: policy['postchain'](postchain_context) - except PolicyException as e: + except policy_exception_cls as e: # Capture policy error instead of raising immediately postchain_policy_error = e # Postload policy phase - ALWAYS fires (even on error) policy_error = None if policy and 'postload' in policy: - from .gfql.policy import PolicyContext, PolicyException - from .gfql.policy.stats import extract_graph_stats + assert extract_graph_stats_fn is not None and policy_exception_cls is not None # Extract stats from result (if success) or input graph (if error) # Cast: if success=True, g_out is guaranteed to be a Plottable graph_for_stats = cast(Plottable, g_out) if success else self - stats = extract_graph_stats(graph_for_stats) + stats = extract_graph_stats_fn(graph_for_stats) - policy_context: PolicyContext = { + policy_context: 'PolicyContext' = { 'phase': 'postload', 'hook': 'postload', 'query': ops, @@ -842,7 +852,7 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni # Policy can only accept (None) or deny (exception) policy['postload'](policy_context) - except PolicyException as e: + except policy_exception_cls as e: # Enrich exception with context if not already set if e.query_type is None: e.query_type = 'chain' diff --git a/graphistry/compute/chain_let.py b/graphistry/compute/chain_let.py index 68662c8f95..96e7f0babe 100644 --- a/graphistry/compute/chain_let.py +++ b/graphistry/compute/chain_let.py @@ -1,4 +1,4 @@ -from typing import Dict, Set, List, Optional, Tuple, Union, cast, TYPE_CHECKING +from typing import Dict, Set, List, Optional, Tuple, Union, cast, TYPE_CHECKING, Callable, Any from typing_extensions import Literal import pandas as pd from graphistry.Engine import Engine, EngineAbstract, resolve_engine @@ -10,10 +10,18 @@ if TYPE_CHECKING: from graphistry.compute.chain import Chain + from .gfql.policy import PolicyContext logger = setup_logger(__name__) +def _load_policy_runtime_deps() -> Tuple[type, Callable[[Plottable], Dict[str, Any]]]: + from .gfql.policy import PolicyException + from .gfql.policy.stats import extract_graph_stats + + return PolicyException, extract_graph_stats + + def extract_dependencies(ast_obj: Union[ASTObject, 'Chain', 'Plottable']) -> Set[str]: """Recursively find all ASTRef references in an AST object or GraphOperation @@ -233,6 +241,11 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g: """ logger.debug("Executing node '%s' of type %s", name, type(ast_obj).__name__) + policy_exception_cls: Optional[type] = None + extract_graph_stats_fn: Optional[Callable[[Plottable], Dict[str, Any]]] = None + if policy and any(hook in policy for hook in ('preload', 'postload')): + policy_exception_cls, extract_graph_stats_fn = _load_policy_runtime_deps() + # Handle different AST object types if isinstance(ast_obj, ASTLet): # Nested let execution @@ -277,9 +290,9 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g: # Preload policy phase for remote data loading if policy and 'preload' in policy: - from .gfql.policy import PolicyContext, PolicyException + assert policy_exception_cls is not None - preload_context: PolicyContext = { + preload_context: 'PolicyContext' = { 'phase': 'preload', 'hook': 'preload', 'query': global_query if global_query else ast_obj, # Global query if available @@ -294,7 +307,7 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g: try: # Policy can only accept (None) or deny (exception) policy['preload'](preload_context) - except PolicyException: + except policy_exception_cls: # Re-raise without modification raise @@ -314,12 +327,11 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g: # Postload policy phase for remote data if policy and 'postload' in policy: - from .gfql.policy import PolicyContext, PolicyException - from .gfql.policy.stats import extract_graph_stats + assert policy_exception_cls is not None and extract_graph_stats_fn is not None - stats = extract_graph_stats(result) + stats = extract_graph_stats_fn(result) - postload_context: PolicyContext = { + postload_context: 'PolicyContext' = { 'phase': 'postload', 'hook': 'postload', 'query': global_query if global_query else ast_obj, @@ -335,7 +347,7 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g: try: # Policy can only accept (None) or deny (exception) policy['postload'](postload_context) - except PolicyException: + except policy_exception_cls: # Re-raise without modification raise elif isinstance(ast_obj, ASTCall): @@ -428,16 +440,20 @@ def chain_let_impl(g: Plottable, dag: ASTLet, success = False last_result = None + policy_exception_cls: Optional[type] = None + extract_graph_stats_fn: Optional[Callable[[Plottable], Dict[str, Any]]] = None + if policy and any(hook in policy for hook in ('prelet', 'preletbinding', 'postletbinding', 'postlet', 'postload')): + policy_exception_cls, extract_graph_stats_fn = _load_policy_runtime_deps() + try: # Prelet hook - fires BEFORE any bindings execute if policy and 'prelet' in policy: - from .gfql.policy import PolicyContext, PolicyException - from .gfql.policy.stats import extract_graph_stats + assert extract_graph_stats_fn is not None and policy_exception_cls is not None - stats = extract_graph_stats(g) + stats = extract_graph_stats_fn(g) current_path = context.operation_path - prelet_context: PolicyContext = { + prelet_context: 'PolicyContext' = { 'phase': 'prelet', 'hook': 'prelet', 'query': dag, @@ -453,7 +469,7 @@ def chain_let_impl(g: Plottable, dag: ASTLet, try: policy['prelet'](prelet_context) - except PolicyException: + except policy_exception_cls: raise # Execute nodes in topological order @@ -466,13 +482,13 @@ def chain_let_impl(g: Plottable, dag: ASTLet, # Preletbinding hook - fires BEFORE binding execution if policy and 'preletbinding' in policy: - from .gfql.policy import PolicyContext, PolicyException + assert policy_exception_cls is not None current_path = context.operation_path # Build path that includes this binding (even though we haven't pushed yet) binding_path = f"{current_path}.binding:{node_name}" - preletbinding_context: PolicyContext = { + preletbinding_context: 'PolicyContext' = { 'phase': 'preletbinding', 'hook': 'preletbinding', 'query': dag, @@ -491,7 +507,7 @@ def chain_let_impl(g: Plottable, dag: ASTLet, try: policy['preletbinding'](preletbinding_context) - except PolicyException: + except policy_exception_cls: raise # Execute the node with postletbinding in finally block @@ -526,16 +542,15 @@ def chain_let_impl(g: Plottable, dag: ASTLet, # Postletbinding hook - fires AFTER binding execution (even on error) policy_error = None if policy and 'postletbinding' in policy: - from .gfql.policy import PolicyContext, PolicyException - from .gfql.policy.stats import extract_graph_stats + assert policy_exception_cls is not None and extract_graph_stats_fn is not None # Extract stats from binding result (if success) or current graph (if error) # Cast: if binding_success=True, binding_result is guaranteed to be a Plottable graph_for_stats = cast(Plottable, binding_result) if binding_success else accumulated_result - stats = extract_graph_stats(graph_for_stats) + stats = extract_graph_stats_fn(graph_for_stats) current_path = context.operation_path - postletbinding_context: PolicyContext = { + postletbinding_context: 'PolicyContext' = { 'phase': 'postletbinding', 'hook': 'postletbinding', 'query': dag, @@ -562,7 +577,7 @@ def chain_let_impl(g: Plottable, dag: ASTLet, try: policy['postletbinding'](postletbinding_context) - except PolicyException as e: + except policy_exception_cls as e: # Capture policy error policy_error = e @@ -610,16 +625,15 @@ def chain_let_impl(g: Plottable, dag: ASTLet, # Postlet hook - fires AFTER all bindings complete (even on error) postlet_policy_error = None if policy and 'postlet' in policy: - from .gfql.policy import PolicyContext, PolicyException - from .gfql.policy.stats import extract_graph_stats + assert extract_graph_stats_fn is not None and policy_exception_cls is not None # Extract stats from result (if success) or input graph (if error) # Cast: if success=True, result is guaranteed to be a Plottable graph_for_stats = cast(Plottable, result) if success else g - stats = extract_graph_stats(graph_for_stats) + stats = extract_graph_stats_fn(graph_for_stats) current_path = context.operation_path - postlet_context: PolicyContext = { + postlet_context: 'PolicyContext' = { 'phase': 'postlet', 'hook': 'postlet', 'query': dag, @@ -641,22 +655,21 @@ def chain_let_impl(g: Plottable, dag: ASTLet, try: policy['postlet'](postlet_context) - except PolicyException as e: + except policy_exception_cls as e: # Capture policy error instead of raising immediately postlet_policy_error = e # Postload policy phase - ALWAYS fires (even on error) policy_error = None if policy and 'postload' in policy: - from .gfql.policy import PolicyContext, PolicyException - from .gfql.policy.stats import extract_graph_stats + assert extract_graph_stats_fn is not None and policy_exception_cls is not None # Extract stats from result (if success) or input graph (if error) # Cast: if success=True, result is guaranteed to be a Plottable graph_for_stats = cast(Plottable, result) if success else g - stats = extract_graph_stats(graph_for_stats) + stats = extract_graph_stats_fn(graph_for_stats) - context_dict: PolicyContext = { + context_dict: 'PolicyContext' = { 'phase': 'postload', 'hook': 'postload', 'query': dag, @@ -678,7 +691,7 @@ def chain_let_impl(g: Plottable, dag: ASTLet, # Policy can only accept (None) or deny (exception) policy['postload'](context_dict) - except PolicyException as e: + except policy_exception_cls as e: # Enrich exception with context if not already set if e.query_type is None: e.query_type = 'dag' diff --git a/graphistry/compute/gfql/call_executor.py b/graphistry/compute/gfql/call_executor.py index ace7f1e2c7..ba43d4ab98 100644 --- a/graphistry/compute/gfql/call_executor.py +++ b/graphistry/compute/gfql/call_executor.py @@ -5,7 +5,7 @@ """ import threading -from typing import Dict, Any, cast, Optional, TYPE_CHECKING +from typing import Dict, Any, cast, Optional, TYPE_CHECKING, Callable, Tuple from graphistry.Plottable import Plottable from graphistry.Engine import Engine from graphistry.compute.gfql.call_safelist import validate_call_params @@ -14,11 +14,19 @@ if TYPE_CHECKING: from graphistry.compute.execution_context import ExecutionContext + from graphistry.compute.gfql.policy import PolicyContext # Thread-local storage for policy context _thread_local = threading.local() +def _load_policy_runtime_deps() -> Tuple[type, Callable[[Plottable], Dict[str, Any]]]: + from graphistry.compute.gfql.policy import PolicyException + from graphistry.compute.gfql.policy.stats import extract_graph_stats + + return PolicyException, extract_graph_stats + + def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: Engine, policy=None, context: Optional['ExecutionContext'] = None) -> Plottable: """Execute a validated method call on a Plottable. @@ -49,18 +57,22 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En # Precall policy phase - before executing call operation final_params = params + policy_exception_cls: Optional[type] = None + extract_graph_stats_fn: Optional[Callable[[Plottable], Dict[str, Any]]] = None + if policy and any(hook in policy for hook in ('precall', 'postcall')): + policy_exception_cls, extract_graph_stats_fn = _load_policy_runtime_deps() + import time if policy and 'precall' in policy: - from graphistry.compute.gfql.policy import PolicyContext, PolicyException - from graphistry.compute.gfql.policy.stats import extract_graph_stats + assert extract_graph_stats_fn is not None and policy_exception_cls is not None - stats = extract_graph_stats(g) + stats = extract_graph_stats_fn(g) current_path = context.operation_path # Build path that includes this call (even though we haven't pushed yet) call_path = f"{current_path}.call:{function}" - policy_context: PolicyContext = { + policy_context: 'PolicyContext' = { 'phase': 'precall', 'hook': 'precall', 'query': None, # Not available in call context @@ -79,7 +91,7 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En # Policy can only accept (None) or deny (exception) policy['precall'](policy_context) - except PolicyException as e: + except policy_exception_cls as e: # Enrich exception with context if not already set if e.query_type is None: e.query_type = 'call' @@ -155,15 +167,14 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En # Postcall policy phase - ALWAYS fires (even on error) policy_error = None if policy and 'postcall' in policy: - from graphistry.compute.gfql.policy import PolicyContext, PolicyException - from graphistry.compute.gfql.policy.stats import extract_graph_stats + assert extract_graph_stats_fn is not None and policy_exception_cls is not None # Extract stats from result (if success) or input graph (if error) # IMPORTANT: hypergraph can return DataFrame when return_as != 'graph' # We must check isinstance BEFORE using the result to avoid triggering DataFrame.style (requires Jinja2) if success and isinstance(result, Plottable): graph_for_stats = result - result_stats = extract_graph_stats(graph_for_stats) + result_stats = extract_graph_stats_fn(graph_for_stats) elif success: # Result is not a Plottable (e.g., DataFrame from hypergraph) - use input graph for stats graph_for_stats = g @@ -171,10 +182,10 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En else: # Error case - use input graph graph_for_stats = g - result_stats = extract_graph_stats(graph_for_stats) + result_stats = extract_graph_stats_fn(graph_for_stats) current_path = context.operation_path - postcall_context: PolicyContext = { + postcall_context: 'PolicyContext' = { 'phase': 'postcall', 'hook': 'postcall', 'query': None, # Not available in call context @@ -200,7 +211,7 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En # Policy can only accept (None) or deny (exception) policy['postcall'](postcall_context) - except PolicyException as e: + except policy_exception_cls as e: # Enrich exception with context if not already set if e.query_type is None: e.query_type = 'call' From c1092803e0ca488535c22a007064aa54de1089d2 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 17 Nov 2025 11:49:16 -0800 Subject: [PATCH 04/12] fix: satisfy policy typing for new python --- graphistry/compute/chain.py | 11 ++++++----- graphistry/compute/chain_let.py | 15 ++++++++------- graphistry/compute/gfql/call_executor.py | 12 +++++++----- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index 2a6138832a..96f9a4cf4e 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, Union, cast, List, Tuple, Sequence, Optional, TYPE_CHECKING, Callable, Any +from typing import Dict, Union, cast, List, Tuple, Optional, TYPE_CHECKING, Callable, Any, Type from graphistry.Engine import Engine, EngineAbstract, df_concat, df_to_engine, resolve_engine from graphistry.Plottable import Plottable @@ -14,12 +14,13 @@ if TYPE_CHECKING: from graphistry.compute.exceptions import GFQLSchemaError, GFQLValidationError - from .gfql.policy import PolicyContext + from .gfql.policy import PolicyContext, PolicyException + from .gfql.policy.stats import GraphStats logger = setup_logger(__name__) -def _load_policy_runtime_deps() -> Tuple[type, Callable[[Plottable], Dict[str, Any]]]: +def _load_policy_runtime_deps() -> Tuple[Type['PolicyException'], Callable[[Plottable], 'GraphStats']]: from .gfql.policy import PolicyException from .gfql.policy.stats import extract_graph_stats @@ -612,8 +613,8 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni error = None success = False - policy_exception_cls: Optional[type] = None - extract_graph_stats_fn: Optional[Callable[[Plottable], Dict[str, Any]]] = None + policy_exception_cls: Optional[Type['PolicyException']] = None + extract_graph_stats_fn: Optional[Callable[[Plottable], 'GraphStats']] = None if policy and any(hook in policy for hook in ('prechain', 'postchain', 'postload')): policy_exception_cls, extract_graph_stats_fn = _load_policy_runtime_deps() diff --git a/graphistry/compute/chain_let.py b/graphistry/compute/chain_let.py index 96e7f0babe..c4dc7c933f 100644 --- a/graphistry/compute/chain_let.py +++ b/graphistry/compute/chain_let.py @@ -1,4 +1,4 @@ -from typing import Dict, Set, List, Optional, Tuple, Union, cast, TYPE_CHECKING, Callable, Any +from typing import Dict, Set, List, Optional, Tuple, Union, cast, TYPE_CHECKING, Callable, Any, Type from typing_extensions import Literal import pandas as pd from graphistry.Engine import Engine, EngineAbstract, resolve_engine @@ -10,12 +10,13 @@ if TYPE_CHECKING: from graphistry.compute.chain import Chain - from .gfql.policy import PolicyContext + from .gfql.policy import PolicyContext, PolicyException + from .gfql.policy.stats import GraphStats logger = setup_logger(__name__) -def _load_policy_runtime_deps() -> Tuple[type, Callable[[Plottable], Dict[str, Any]]]: +def _load_policy_runtime_deps() -> Tuple[Type['PolicyException'], Callable[[Plottable], 'GraphStats']]: from .gfql.policy import PolicyException from .gfql.policy.stats import extract_graph_stats @@ -241,8 +242,8 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g: """ logger.debug("Executing node '%s' of type %s", name, type(ast_obj).__name__) - policy_exception_cls: Optional[type] = None - extract_graph_stats_fn: Optional[Callable[[Plottable], Dict[str, Any]]] = None + policy_exception_cls: Optional[Type['PolicyException']] = None + extract_graph_stats_fn: Optional[Callable[[Plottable], 'GraphStats']] = None if policy and any(hook in policy for hook in ('preload', 'postload')): policy_exception_cls, extract_graph_stats_fn = _load_policy_runtime_deps() @@ -440,8 +441,8 @@ def chain_let_impl(g: Plottable, dag: ASTLet, success = False last_result = None - policy_exception_cls: Optional[type] = None - extract_graph_stats_fn: Optional[Callable[[Plottable], Dict[str, Any]]] = None + policy_exception_cls: Optional[Type['PolicyException']] = None + extract_graph_stats_fn: Optional[Callable[[Plottable], 'GraphStats']] = None if policy and any(hook in policy for hook in ('prelet', 'preletbinding', 'postletbinding', 'postlet', 'postload')): policy_exception_cls, extract_graph_stats_fn = _load_policy_runtime_deps() diff --git a/graphistry/compute/gfql/call_executor.py b/graphistry/compute/gfql/call_executor.py index ba43d4ab98..4e52685629 100644 --- a/graphistry/compute/gfql/call_executor.py +++ b/graphistry/compute/gfql/call_executor.py @@ -5,7 +5,7 @@ """ import threading -from typing import Dict, Any, cast, Optional, TYPE_CHECKING, Callable, Tuple +from typing import Dict, Any, cast, Optional, TYPE_CHECKING, Callable, Tuple, Type from graphistry.Plottable import Plottable from graphistry.Engine import Engine from graphistry.compute.gfql.call_safelist import validate_call_params @@ -14,13 +14,15 @@ if TYPE_CHECKING: from graphistry.compute.execution_context import ExecutionContext + from graphistry.compute.gfql.policy import PolicyContext, PolicyException + from graphistry.compute.gfql.policy.stats import GraphStats from graphistry.compute.gfql.policy import PolicyContext # Thread-local storage for policy context _thread_local = threading.local() -def _load_policy_runtime_deps() -> Tuple[type, Callable[[Plottable], Dict[str, Any]]]: +def _load_policy_runtime_deps() -> Tuple[Type['PolicyException'], Callable[[Plottable], 'GraphStats']]: from graphistry.compute.gfql.policy import PolicyException from graphistry.compute.gfql.policy.stats import extract_graph_stats @@ -57,8 +59,8 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En # Precall policy phase - before executing call operation final_params = params - policy_exception_cls: Optional[type] = None - extract_graph_stats_fn: Optional[Callable[[Plottable], Dict[str, Any]]] = None + policy_exception_cls: Optional[Type['PolicyException']] = None + extract_graph_stats_fn: Optional[Callable[[Plottable], 'GraphStats']] = None if policy and any(hook in policy for hook in ('precall', 'postcall')): policy_exception_cls, extract_graph_stats_fn = _load_policy_runtime_deps() @@ -178,7 +180,7 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En elif success: # Result is not a Plottable (e.g., DataFrame from hypergraph) - use input graph for stats graph_for_stats = g - result_stats = {} # Can't extract stats from DataFrame + result_stats = cast('GraphStats', {}) # Can't extract stats from DataFrame else: # Error case - use input graph graph_for_stats = g From 34a44c2d15e2272708a7b5c8a3c7e08987181910 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 17 Nov 2025 12:32:33 -0800 Subject: [PATCH 05/12] fix: flake8 type-only import --- graphistry/compute/gfql/call_executor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/graphistry/compute/gfql/call_executor.py b/graphistry/compute/gfql/call_executor.py index 4e52685629..45a9d88a95 100644 --- a/graphistry/compute/gfql/call_executor.py +++ b/graphistry/compute/gfql/call_executor.py @@ -16,7 +16,6 @@ from graphistry.compute.execution_context import ExecutionContext from graphistry.compute.gfql.policy import PolicyContext, PolicyException from graphistry.compute.gfql.policy.stats import GraphStats - from graphistry.compute.gfql.policy import PolicyContext # Thread-local storage for policy context _thread_local = threading.local() From dbfeab41c51173f500efcc61b6c78e2e4303dbe8 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 17 Nov 2025 15:12:44 -0800 Subject: [PATCH 06/12] ci: cap rapids jobs at py3.13 --- .github/workflows/ci-gpu.yml | 3 ++- .github/workflows/ci.yml | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-gpu.yml b/.github/workflows/ci-gpu.yml index 91e6044206..be288b50dd 100644 --- a/.github/workflows/ci-gpu.yml +++ b/.github/workflows/ci-gpu.yml @@ -50,7 +50,8 @@ jobs: strategy: matrix: - python-version: ['3.10', 3.11, 3.12, '3.13', '3.14'] + # RAPIDS GPU images currently support <= 3.13 + python-version: ['3.10', 3.11, 3.12, '3.13'] steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81ac36ccf9..26bf5ccbfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -257,7 +257,8 @@ jobs: strategy: matrix: - python-version: [3.9, '3.10', 3.11, 3.12, '3.13', '3.14'] + # RAPIDS (umap-learn/numba) lacks Python 3.14 wheels as of 2025-11, so keep <=3.13 here + python-version: [3.9, '3.10', 3.11, 3.12, '3.13'] steps: @@ -300,7 +301,8 @@ jobs: strategy: matrix: - python-version: [3.9, '3.10', 3.11, 3.12, '3.13', '3.14'] + # RAPIDS stack not available on Python 3.14 yet + python-version: [3.9, '3.10', 3.11, 3.12, '3.13'] #include: # - python-version: 3.12 # continue-on-error: true From 3cf4d17a32a255bd189da5b0e5c6efd7e1ce8d76 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 17 Nov 2025 15:39:09 -0800 Subject: [PATCH 07/12] refactor: import policy helpers directly --- graphistry/compute/chain.py | 34 +++++----------- graphistry/compute/chain_let.py | 50 +++++++----------------- graphistry/compute/gfql/call_executor.py | 31 ++++----------- 3 files changed, 31 insertions(+), 84 deletions(-) diff --git a/graphistry/compute/chain.py b/graphistry/compute/chain.py index 96f9a4cf4e..b9b066c429 100644 --- a/graphistry/compute/chain.py +++ b/graphistry/compute/chain.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, Union, cast, List, Tuple, Optional, TYPE_CHECKING, Callable, Any, Type +from typing import Dict, Union, cast, List, Tuple, Optional, TYPE_CHECKING, Callable, Any from graphistry.Engine import Engine, EngineAbstract, df_concat, df_to_engine, resolve_engine from graphistry.Plottable import Plottable @@ -11,22 +11,15 @@ from .typing import DataFrameT from .util import generate_safe_column_name from graphistry.compute.validate.validate_schema import validate_chain_schema +from .gfql.policy import PolicyContext, PolicyException +from .gfql.policy.stats import extract_graph_stats if TYPE_CHECKING: from graphistry.compute.exceptions import GFQLSchemaError, GFQLValidationError - from .gfql.policy import PolicyContext, PolicyException - from .gfql.policy.stats import GraphStats logger = setup_logger(__name__) -def _load_policy_runtime_deps() -> Tuple[Type['PolicyException'], Callable[[Plottable], 'GraphStats']]: - from .gfql.policy import PolicyException - from .gfql.policy.stats import extract_graph_stats - - return PolicyException, extract_graph_stats - - ############################################################################### @@ -613,11 +606,6 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni error = None success = False - policy_exception_cls: Optional[Type['PolicyException']] = None - extract_graph_stats_fn: Optional[Callable[[Plottable], 'GraphStats']] = None - if policy and any(hook in policy for hook in ('prechain', 'postchain', 'postload')): - policy_exception_cls, extract_graph_stats_fn = _load_policy_runtime_deps() - try: g = self.materialize_nodes(engine=EngineAbstract(engine_concrete.value)) @@ -641,9 +629,7 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni # Prechain hook - fires BEFORE chain operations execute if policy and 'prechain' in policy: - assert extract_graph_stats_fn is not None and policy_exception_cls is not None - - stats = extract_graph_stats_fn(g) + stats = extract_graph_stats(g) current_path = context.operation_path prechain_context: 'PolicyContext' = { @@ -662,7 +648,7 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni try: policy['prechain'](prechain_context) - except policy_exception_cls: + except PolicyException: raise logger.debug('======================== FORWARDS ========================') @@ -787,12 +773,11 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni # Postchain hook - fires AFTER chain operations complete (even on error) postchain_policy_error = None if policy and 'postchain' in policy: - assert extract_graph_stats_fn is not None and policy_exception_cls is not None # Extract stats from result (if success) or input graph (if error) # Cast: if success=True, g_out is guaranteed to be a Plottable graph_for_stats = cast(Plottable, g_out) if success else self - stats = extract_graph_stats_fn(graph_for_stats) + stats = extract_graph_stats(graph_for_stats) current_path = context.operation_path postchain_context: 'PolicyContext' = { @@ -817,19 +802,18 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni try: policy['postchain'](postchain_context) - except policy_exception_cls as e: + except PolicyException as e: # Capture policy error instead of raising immediately postchain_policy_error = e # Postload policy phase - ALWAYS fires (even on error) policy_error = None if policy and 'postload' in policy: - assert extract_graph_stats_fn is not None and policy_exception_cls is not None # Extract stats from result (if success) or input graph (if error) # Cast: if success=True, g_out is guaranteed to be a Plottable graph_for_stats = cast(Plottable, g_out) if success else self - stats = extract_graph_stats_fn(graph_for_stats) + stats = extract_graph_stats(graph_for_stats) policy_context: 'PolicyContext' = { 'phase': 'postload', @@ -853,7 +837,7 @@ def _chain_impl(self: Plottable, ops: Union[List[ASTObject], Chain], engine: Uni # Policy can only accept (None) or deny (exception) policy['postload'](policy_context) - except policy_exception_cls as e: + except PolicyException as e: # Enrich exception with context if not already set if e.query_type is None: e.query_type = 'chain' diff --git a/graphistry/compute/chain_let.py b/graphistry/compute/chain_let.py index c4dc7c933f..c3fe0eaafc 100644 --- a/graphistry/compute/chain_let.py +++ b/graphistry/compute/chain_let.py @@ -1,4 +1,4 @@ -from typing import Dict, Set, List, Optional, Tuple, Union, cast, TYPE_CHECKING, Callable, Any, Type +from typing import Dict, Set, List, Optional, Tuple, Union, cast, TYPE_CHECKING, Callable, Any from typing_extensions import Literal import pandas as pd from graphistry.Engine import Engine, EngineAbstract, resolve_engine @@ -7,22 +7,15 @@ from .ast import ASTObject, ASTLet, ASTRef, ASTRemoteGraph, ASTNode, ASTEdge, ASTCall from .execution_context import ExecutionContext from .engine_coercion import ensure_engine_match +from .gfql.policy import PolicyContext, PolicyException +from .gfql.policy.stats import extract_graph_stats if TYPE_CHECKING: from graphistry.compute.chain import Chain - from .gfql.policy import PolicyContext, PolicyException - from .gfql.policy.stats import GraphStats logger = setup_logger(__name__) -def _load_policy_runtime_deps() -> Tuple[Type['PolicyException'], Callable[[Plottable], 'GraphStats']]: - from .gfql.policy import PolicyException - from .gfql.policy.stats import extract_graph_stats - - return PolicyException, extract_graph_stats - - def extract_dependencies(ast_obj: Union[ASTObject, 'Chain', 'Plottable']) -> Set[str]: """Recursively find all ASTRef references in an AST object or GraphOperation @@ -242,10 +235,7 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g: """ logger.debug("Executing node '%s' of type %s", name, type(ast_obj).__name__) - policy_exception_cls: Optional[Type['PolicyException']] = None - extract_graph_stats_fn: Optional[Callable[[Plottable], 'GraphStats']] = None if policy and any(hook in policy for hook in ('preload', 'postload')): - policy_exception_cls, extract_graph_stats_fn = _load_policy_runtime_deps() # Handle different AST object types if isinstance(ast_obj, ASTLet): @@ -291,7 +281,6 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g: # Preload policy phase for remote data loading if policy and 'preload' in policy: - assert policy_exception_cls is not None preload_context: 'PolicyContext' = { 'phase': 'preload', @@ -308,7 +297,7 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g: try: # Policy can only accept (None) or deny (exception) policy['preload'](preload_context) - except policy_exception_cls: + except PolicyException: # Re-raise without modification raise @@ -328,9 +317,8 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g: # Postload policy phase for remote data if policy and 'postload' in policy: - assert policy_exception_cls is not None and extract_graph_stats_fn is not None - stats = extract_graph_stats_fn(result) + stats = extract_graph_stats(result) postload_context: 'PolicyContext' = { 'phase': 'postload', @@ -348,7 +336,7 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g: try: # Policy can only accept (None) or deny (exception) policy['postload'](postload_context) - except policy_exception_cls: + except PolicyException: # Re-raise without modification raise elif isinstance(ast_obj, ASTCall): @@ -441,17 +429,13 @@ def chain_let_impl(g: Plottable, dag: ASTLet, success = False last_result = None - policy_exception_cls: Optional[Type['PolicyException']] = None - extract_graph_stats_fn: Optional[Callable[[Plottable], 'GraphStats']] = None if policy and any(hook in policy for hook in ('prelet', 'preletbinding', 'postletbinding', 'postlet', 'postload')): - policy_exception_cls, extract_graph_stats_fn = _load_policy_runtime_deps() try: # Prelet hook - fires BEFORE any bindings execute if policy and 'prelet' in policy: - assert extract_graph_stats_fn is not None and policy_exception_cls is not None - stats = extract_graph_stats_fn(g) + stats = extract_graph_stats(g) current_path = context.operation_path prelet_context: 'PolicyContext' = { @@ -470,7 +454,7 @@ def chain_let_impl(g: Plottable, dag: ASTLet, try: policy['prelet'](prelet_context) - except policy_exception_cls: + except PolicyException: raise # Execute nodes in topological order @@ -483,7 +467,6 @@ def chain_let_impl(g: Plottable, dag: ASTLet, # Preletbinding hook - fires BEFORE binding execution if policy and 'preletbinding' in policy: - assert policy_exception_cls is not None current_path = context.operation_path # Build path that includes this binding (even though we haven't pushed yet) @@ -508,7 +491,7 @@ def chain_let_impl(g: Plottable, dag: ASTLet, try: policy['preletbinding'](preletbinding_context) - except policy_exception_cls: + except PolicyException: raise # Execute the node with postletbinding in finally block @@ -543,12 +526,11 @@ def chain_let_impl(g: Plottable, dag: ASTLet, # Postletbinding hook - fires AFTER binding execution (even on error) policy_error = None if policy and 'postletbinding' in policy: - assert policy_exception_cls is not None and extract_graph_stats_fn is not None # Extract stats from binding result (if success) or current graph (if error) # Cast: if binding_success=True, binding_result is guaranteed to be a Plottable graph_for_stats = cast(Plottable, binding_result) if binding_success else accumulated_result - stats = extract_graph_stats_fn(graph_for_stats) + stats = extract_graph_stats(graph_for_stats) current_path = context.operation_path postletbinding_context: 'PolicyContext' = { @@ -578,7 +560,7 @@ def chain_let_impl(g: Plottable, dag: ASTLet, try: policy['postletbinding'](postletbinding_context) - except policy_exception_cls as e: + except PolicyException as e: # Capture policy error policy_error = e @@ -626,12 +608,11 @@ def chain_let_impl(g: Plottable, dag: ASTLet, # Postlet hook - fires AFTER all bindings complete (even on error) postlet_policy_error = None if policy and 'postlet' in policy: - assert extract_graph_stats_fn is not None and policy_exception_cls is not None # Extract stats from result (if success) or input graph (if error) # Cast: if success=True, result is guaranteed to be a Plottable graph_for_stats = cast(Plottable, result) if success else g - stats = extract_graph_stats_fn(graph_for_stats) + stats = extract_graph_stats(graph_for_stats) current_path = context.operation_path postlet_context: 'PolicyContext' = { @@ -656,19 +637,18 @@ def chain_let_impl(g: Plottable, dag: ASTLet, try: policy['postlet'](postlet_context) - except policy_exception_cls as e: + except PolicyException as e: # Capture policy error instead of raising immediately postlet_policy_error = e # Postload policy phase - ALWAYS fires (even on error) policy_error = None if policy and 'postload' in policy: - assert extract_graph_stats_fn is not None and policy_exception_cls is not None # Extract stats from result (if success) or input graph (if error) # Cast: if success=True, result is guaranteed to be a Plottable graph_for_stats = cast(Plottable, result) if success else g - stats = extract_graph_stats_fn(graph_for_stats) + stats = extract_graph_stats(graph_for_stats) context_dict: 'PolicyContext' = { 'phase': 'postload', @@ -692,7 +672,7 @@ def chain_let_impl(g: Plottable, dag: ASTLet, # Policy can only accept (None) or deny (exception) policy['postload'](context_dict) - except policy_exception_cls as e: + except PolicyException as e: # Enrich exception with context if not already set if e.query_type is None: e.query_type = 'dag' diff --git a/graphistry/compute/gfql/call_executor.py b/graphistry/compute/gfql/call_executor.py index 45a9d88a95..8ed6d18c74 100644 --- a/graphistry/compute/gfql/call_executor.py +++ b/graphistry/compute/gfql/call_executor.py @@ -5,29 +5,23 @@ """ import threading -from typing import Dict, Any, cast, Optional, TYPE_CHECKING, Callable, Tuple, Type +import time +from typing import Dict, Any, cast, Optional, TYPE_CHECKING, Callable, Tuple from graphistry.Plottable import Plottable from graphistry.Engine import Engine from graphistry.compute.gfql.call_safelist import validate_call_params from graphistry.compute.exceptions import ErrorCode, GFQLTypeError from graphistry.compute.engine_coercion import ensure_engine_match +from graphistry.compute.gfql.policy import PolicyContext, PolicyException +from graphistry.compute.gfql.policy.stats import extract_graph_stats if TYPE_CHECKING: from graphistry.compute.execution_context import ExecutionContext - from graphistry.compute.gfql.policy import PolicyContext, PolicyException - from graphistry.compute.gfql.policy.stats import GraphStats # Thread-local storage for policy context _thread_local = threading.local() -def _load_policy_runtime_deps() -> Tuple[Type['PolicyException'], Callable[[Plottable], 'GraphStats']]: - from graphistry.compute.gfql.policy import PolicyException - from graphistry.compute.gfql.policy.stats import extract_graph_stats - - return PolicyException, extract_graph_stats - - def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: Engine, policy=None, context: Optional['ExecutionContext'] = None) -> Plottable: """Execute a validated method call on a Plottable. @@ -58,17 +52,9 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En # Precall policy phase - before executing call operation final_params = params - policy_exception_cls: Optional[Type['PolicyException']] = None - extract_graph_stats_fn: Optional[Callable[[Plottable], 'GraphStats']] = None - if policy and any(hook in policy for hook in ('precall', 'postcall')): - policy_exception_cls, extract_graph_stats_fn = _load_policy_runtime_deps() - - import time - if policy and 'precall' in policy: - assert extract_graph_stats_fn is not None and policy_exception_cls is not None + stats = extract_graph_stats(g) - stats = extract_graph_stats_fn(g) current_path = context.operation_path # Build path that includes this call (even though we haven't pushed yet) call_path = f"{current_path}.call:{function}" @@ -92,7 +78,7 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En # Policy can only accept (None) or deny (exception) policy['precall'](policy_context) - except policy_exception_cls as e: + except PolicyException as e: # Enrich exception with context if not already set if e.query_type is None: e.query_type = 'call' @@ -168,14 +154,12 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En # Postcall policy phase - ALWAYS fires (even on error) policy_error = None if policy and 'postcall' in policy: - assert extract_graph_stats_fn is not None and policy_exception_cls is not None # Extract stats from result (if success) or input graph (if error) # IMPORTANT: hypergraph can return DataFrame when return_as != 'graph' # We must check isinstance BEFORE using the result to avoid triggering DataFrame.style (requires Jinja2) if success and isinstance(result, Plottable): graph_for_stats = result - result_stats = extract_graph_stats_fn(graph_for_stats) elif success: # Result is not a Plottable (e.g., DataFrame from hypergraph) - use input graph for stats graph_for_stats = g @@ -183,7 +167,6 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En else: # Error case - use input graph graph_for_stats = g - result_stats = extract_graph_stats_fn(graph_for_stats) current_path = context.operation_path postcall_context: 'PolicyContext' = { @@ -212,7 +195,7 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En # Policy can only accept (None) or deny (exception) policy['postcall'](postcall_context) - except policy_exception_cls as e: + except PolicyException as e: # Enrich exception with context if not already set if e.query_type is None: e.query_type = 'call' From fd81fb521e2d873298e9e8fcb47bcaf89e5fed11 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 17 Nov 2025 15:43:35 -0800 Subject: [PATCH 08/12] fix: clean imports --- graphistry/compute/gfql/call_executor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphistry/compute/gfql/call_executor.py b/graphistry/compute/gfql/call_executor.py index 8ed6d18c74..0de4a40818 100644 --- a/graphistry/compute/gfql/call_executor.py +++ b/graphistry/compute/gfql/call_executor.py @@ -163,7 +163,7 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En elif success: # Result is not a Plottable (e.g., DataFrame from hypergraph) - use input graph for stats graph_for_stats = g - result_stats = cast('GraphStats', {}) # Can't extract stats from DataFrame + result_stats = {} # Can't extract stats from DataFrame else: # Error case - use input graph graph_for_stats = g From 36d03d205c429a6a477574898057b352fb8d4136 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 17 Nov 2025 17:24:25 -0800 Subject: [PATCH 09/12] fix: restore valid chain_let body --- graphistry/compute/chain_let.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/graphistry/compute/chain_let.py b/graphistry/compute/chain_let.py index c3fe0eaafc..8f19de2862 100644 --- a/graphistry/compute/chain_let.py +++ b/graphistry/compute/chain_let.py @@ -235,8 +235,6 @@ def execute_node(name: str, ast_obj: Union[ASTObject, 'Chain', 'Plottable'], g: """ logger.debug("Executing node '%s' of type %s", name, type(ast_obj).__name__) - if policy and any(hook in policy for hook in ('preload', 'postload')): - # Handle different AST object types if isinstance(ast_obj, ASTLet): # Nested let execution From 612226f89c0ee13fc6d8224c85f23b77a129330d Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 17 Nov 2025 17:51:03 -0800 Subject: [PATCH 10/12] fix: remove dangling policy guard --- graphistry/compute/chain_let.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/graphistry/compute/chain_let.py b/graphistry/compute/chain_let.py index 8f19de2862..6b6c1d59f5 100644 --- a/graphistry/compute/chain_let.py +++ b/graphistry/compute/chain_let.py @@ -427,8 +427,6 @@ def chain_let_impl(g: Plottable, dag: ASTLet, success = False last_result = None - if policy and any(hook in policy for hook in ('prelet', 'preletbinding', 'postletbinding', 'postlet', 'postload')): - try: # Prelet hook - fires BEFORE any bindings execute if policy and 'prelet' in policy: From 3ea5c3698002fe69f104bd877289815504060839 Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 17 Nov 2025 20:26:11 -0800 Subject: [PATCH 11/12] fix: mypy expectations for postcall stats --- graphistry/compute/gfql/call_executor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/graphistry/compute/gfql/call_executor.py b/graphistry/compute/gfql/call_executor.py index 0de4a40818..b6c0fdb3c8 100644 --- a/graphistry/compute/gfql/call_executor.py +++ b/graphistry/compute/gfql/call_executor.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: from graphistry.compute.execution_context import ExecutionContext + from graphistry.compute.gfql.policy.stats import GraphStats # Thread-local storage for policy context _thread_local = threading.local() @@ -154,19 +155,22 @@ def execute_call(g: Plottable, function: str, params: Dict[str, Any], engine: En # Postcall policy phase - ALWAYS fires (even on error) policy_error = None if policy and 'postcall' in policy: + result_stats: Optional['GraphStats'] = None # Extract stats from result (if success) or input graph (if error) # IMPORTANT: hypergraph can return DataFrame when return_as != 'graph' # We must check isinstance BEFORE using the result to avoid triggering DataFrame.style (requires Jinja2) if success and isinstance(result, Plottable): graph_for_stats = result + result_stats = extract_graph_stats(graph_for_stats) elif success: # Result is not a Plottable (e.g., DataFrame from hypergraph) - use input graph for stats graph_for_stats = g - result_stats = {} # Can't extract stats from DataFrame + result_stats = None # Can't extract stats from DataFrame else: # Error case - use input graph graph_for_stats = g + result_stats = extract_graph_stats(graph_for_stats) current_path = context.operation_path postcall_context: 'PolicyContext' = { From 3b4145f58423be4fa34ddcb64db01584a9ae12cf Mon Sep 17 00:00:00 2001 From: Leo Meyerovich Date: Mon, 17 Nov 2025 21:55:48 -0800 Subject: [PATCH 12/12] docs: update changelog for python ci --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7207297d9f..6325aa9114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - **GFQL / Oracle**: Introduced `graphistry.gfql.ref.enumerator`, a pandas-only reference implementation that enumerates fixed-length chains, enforces local + same-path predicates, applies strict null semantics, enforces safety caps, and emits alias tags/optional path bindings for use as a correctness oracle. ### Tests +- **CI / Python**: Expand GitHub Actions coverage to Python 3.13 + 3.13/3.14 for CPU lint/type/test jobs, while pinning RAPIDS-dependent CPU/GPU suites to <=3.13 until NVIDIA publishes 3.14 wheels (ensures lint/mypy/pytest signal on the latest interpreter without breaking RAPIDS installs). - **GFQL**: Added deterministic + property-based oracle tests (triangles, alias reuse, cuDF conversions, Hypothesis) plus parity checks ensuring pandas GFQL chains match the oracle outputs. - **Layouts**: Added comprehensive test coverage for `circle_layout()` and `group_in_a_box_layout()` with partition support (CPU/GPU)