Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
## [Development]
<!-- Do Not Erase This Section - Used for tracking unreleased changes -->

### Added
- **Collections**: New `g.collections(...)` API for defining subsets via GFQL expressions with priority-based visual encodings. Includes helper constructors `graphistry.collection_set(...)` and `graphistry.collection_intersection(...)`, support for `showCollections`, `collectionsGlobalNodeColor`, and `collectionsGlobalEdgeColor` URL params, and automatic JSON encoding. Accepts GFQL AST, Chain objects, or wire-protocol dicts (#874).
- **Docs / Collections**: Added collections usage guide in visualization/layout/settings, tutorial notebook (`demos/more_examples/graphistry_features/collections.ipynb`), and cross-references in 10-minute guides, cheatsheet, and GFQL docs (#875).

### Changed
- **Collections**: Autofix validation now drops invalid collections (e.g., invalid GFQL ops) and non-string collection color fields instead of string-coercing them; warnings still emit when `warn=True`.
- **Collections**: `collections(...)` now always canonicalizes to URL-encoded JSON (string inputs are parsed + re-encoded); the `encode` parameter was removed to avoid ambiguous behavior.

### Tests
- **Collections**: Added `test_collections.py` covering encoding, GFQL Chain/AST normalization, wire-protocol acceptance, validation modes, and helper constructors.

## [0.50.4 - 2026-01-15]

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion ai/prompts/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ git log --oneline -n 10
- Source: `graphistry/`
- Tests: `graphistry/tests/` (mirrors source structure: `graphistry/foo/bar.py` → `graphistry/tests/foo/test_bar.py`)
- Docs: `docs/`
- Plans: `plans/` (gitignored - safe for auxiliary files, temp secrets, working data)
- Plans: `plans/` (gitignored - safe for auxiliary files, temp secrets, working data; Codex: avoid `~/.codex/plans`; if used, copy here then delete)
- AI prompts: `ai/prompts/`
- AI docs: `ai/docs/`

Expand Down
12 changes: 12 additions & 0 deletions graphistry/Plottable.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from graphistry.Engine import EngineAbstractType
from graphistry.utils.json import JSONVal
from graphistry.client_session import ClientSession, AuthManagerProtocol
from graphistry.models.collections import CollectionsInput
from graphistry.models.types import ValidationParam

if TYPE_CHECKING:
Expand Down Expand Up @@ -783,6 +784,17 @@ def settings(self,
) -> 'Plottable':
...

def collections(
self,
collections: Optional[CollectionsInput] = None,
show_collections: Optional[bool] = None,
collections_global_node_color: Optional[str] = None,
collections_global_edge_color: Optional[str] = None,
validate: ValidationParam = 'autofix',
warn: bool = True
) -> 'Plottable':
...

def privacy(self, mode: Optional[PrivacyMode] = None, notify: Optional[bool] = None, invited_users: Optional[List[str]] = None, message: Optional[str] = None, mode_action: Optional[ModeAction] = None) -> 'Plottable':
...

Expand Down
54 changes: 52 additions & 2 deletions graphistry/PlotterBase.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import Any, Callable, Dict, List, Optional, Union, Tuple, cast, overload, TYPE_CHECKING
from typing_extensions import Literal
from graphistry.io.types import ComplexEncodingsDict
from graphistry.models.collections import CollectionsInput
from graphistry.models.types import ValidationMode, ValidationParam
from graphistry.plugins_types.hypergraph import HypergraphResult
from graphistry.render.resolve_render_mode import resolve_render_mode
Expand Down Expand Up @@ -1826,7 +1827,8 @@ def graph(self, ig: Any) -> Plottable:
def settings(self, height=None, url_params={}, render=None):
"""Specify iframe height and add URL parameter dictionary.

The library takes care of URI component encoding for the dictionary.
Collections URL params are normalized and URL-encoded at plot time; other
params should already be URL-safe.

:param height: Height in pixels.
:type height: int
Expand All @@ -1846,6 +1848,51 @@ def settings(self, height=None, url_params={}, render=None):
return res


def collections(
self,
collections: Optional[CollectionsInput] = None,
show_collections: Optional[bool] = None,
collections_global_node_color: Optional[str] = None,
collections_global_edge_color: Optional[str] = None,
validate: ValidationParam = 'autofix',
warn: bool = True
) -> 'Plottable':
"""Set collections URL parameters. Additive over previous settings.

:param collections: List/dict of collections or JSON/URL-encoded JSON string (stored as URL-encoded JSON).
:param show_collections: Toggle collections panel display.
:param collections_global_node_color: Hex color for non-collection nodes (leading # stripped).
:param collections_global_edge_color: Hex color for non-collection edges (leading # stripped).
:param validate: Validation mode. 'autofix' (default) drops invalid collections and color fields with warnings, 'strict' raises on issues.
:param warn: Whether to emit warnings when validate='autofix'. validate=False forces warn=False.
"""
from graphistry.validate.validate_collections import (
encode_collections,
normalize_collections,
normalize_collections_url_params,
)

settings: Dict[str, Any] = {}
if collections is not None:
normalized = normalize_collections(collections, validate=validate, warn=warn)
settings['collections'] = encode_collections(normalized)
extras: Dict[str, Any] = {}
if show_collections is not None:
extras['showCollections'] = show_collections
if collections_global_node_color is not None:
extras['collectionsGlobalNodeColor'] = collections_global_node_color
if collections_global_edge_color is not None:
extras['collectionsGlobalEdgeColor'] = collections_global_edge_color
if extras:
extras = normalize_collections_url_params(extras, validate=validate, warn=warn)
settings.update(extras)

if len(settings.keys()) > 0:
return self.settings(url_params={**self._url_params, **settings})
else:
return self


def privacy(
self,
mode: Optional[Mode] = None,
Expand Down Expand Up @@ -2191,7 +2238,10 @@ def plot(
'viztoken': str(uuid.uuid4())
}

viz_url = self._pygraphistry._viz_url(info, self._url_params)
from graphistry.validate.validate_collections import normalize_collections_url_params

url_params = normalize_collections_url_params(self._url_params, validate=validate_mode, warn=warn)
viz_url = self._pygraphistry._viz_url(info, url_params)
cfg_client_protocol_hostname = self.session.client_protocol_hostname
full_url = ('%s:%s' % (self.session.protocol, viz_url)) if cfg_client_protocol_hostname is None else viz_url

Expand Down
8 changes: 8 additions & 0 deletions graphistry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
nodes,
graph,
settings,
collections,
encode_point_color,
encode_point_size,
encode_point_icon,
Expand Down Expand Up @@ -61,6 +62,13 @@
from_cugraph
)

from graphistry.collections import (
collection_set,
collection_intersection,
CollectionSet,
CollectionIntersection,
)

from graphistry.compute import (
n, e, e_forward, e_reverse, e_undirected,
let, ref,
Expand Down
113 changes: 113 additions & 0 deletions graphistry/collections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from typing import Dict, List, Optional, Sequence, TypeVar

from graphistry.models.collections import (
CollectionIntersection,
CollectionExprInput,
CollectionSet,
)
from graphistry.utils.json import JSONVal

CollectionDict = TypeVar("CollectionDict", CollectionSet, CollectionIntersection)


def _apply_collection_metadata(collection: CollectionDict, **metadata: Optional[str]) -> CollectionDict:
value = metadata.get("id")
if value is not None:
collection["id"] = value
value = metadata.get("name")
if value is not None:
collection["name"] = value
value = metadata.get("description")
if value is not None:
collection["description"] = value
value = metadata.get("node_color")
if value is not None:
collection["node_color"] = value
value = metadata.get("edge_color")
if value is not None:
collection["edge_color"] = value
return collection


def _wrap_gfql_expr(expr: CollectionExprInput) -> Dict[str, JSONVal]:

from graphistry.compute.ast import ASTObject, from_json as ast_from_json
from graphistry.compute.chain import Chain

def _normalize_op(op: object) -> Dict[str, JSONVal]:
if isinstance(op, ASTObject):
return op.to_json()
if isinstance(op, dict):
return ast_from_json(op, validate=True).to_json()
raise TypeError("Collection GFQL operations must be AST objects or dictionaries")

def _normalize_ops(raw: object) -> List[Dict[str, JSONVal]]:
if isinstance(raw, Chain):
return _normalize_ops(raw.to_json().get("chain", []))
if isinstance(raw, ASTObject):
return [raw.to_json()]
if isinstance(raw, list):
if len(raw) == 0:
raise ValueError("Collection GFQL operations list cannot be empty")
return [_normalize_op(op) for op in raw]
if isinstance(raw, dict):
if raw.get("type") == "Chain" and "chain" in raw:
return _normalize_ops(raw.get("chain"))
if raw.get("type") == "gfql_chain" and "gfql" in raw:
return _normalize_ops(raw.get("gfql"))
if "chain" in raw:
return _normalize_ops(raw.get("chain"))
if "gfql" in raw:
return _normalize_ops(raw.get("gfql"))
return [_normalize_op(raw)]
raise TypeError("Collection expr must be an AST object, chain, list, or dict")

return {"type": "gfql_chain", "gfql": _normalize_ops(expr)}


def collection_set(
*,
expr: CollectionExprInput,
id: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
node_color: Optional[str] = None,
edge_color: Optional[str] = None,
) -> CollectionSet:
"""Build a collection dict for a GFQL-defined set."""
collection: CollectionSet = {"type": "set", "expr": _wrap_gfql_expr(expr)}
return _apply_collection_metadata(
collection,
id=id,
name=name,
description=description,
node_color=node_color,
edge_color=edge_color,
)


def collection_intersection(
*,
sets: Sequence[str],
id: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
node_color: Optional[str] = None,
edge_color: Optional[str] = None,
) -> CollectionIntersection:
"""Build a collection dict for an intersection of set IDs."""
collection: CollectionIntersection = {
"type": "intersection",
"expr": {
"type": "intersection",
"sets": list(sets),
},
}
return _apply_collection_metadata(
collection,
id=id,
name=name,
description=description,
node_color=node_color,
edge_color=edge_color,
)
46 changes: 46 additions & 0 deletions graphistry/models/collections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from __future__ import annotations

from typing import Dict, List, TYPE_CHECKING, Union
from typing_extensions import Literal, NotRequired, Required, TypedDict

from graphistry.utils.json import JSONVal

if TYPE_CHECKING:
from graphistry.compute.ast import ASTObject
from graphistry.compute.chain import Chain


CollectionExprInput = Union[
"Chain",
"ASTObject",
List["ASTObject"],
Dict[str, JSONVal],
List[Dict[str, JSONVal]],
]


class IntersectionExpr(TypedDict):
type: Literal["intersection"]
sets: List[str]


class CollectionBase(TypedDict, total=False):
id: str
name: str
description: str
node_color: str
edge_color: str


class CollectionSet(CollectionBase):
type: NotRequired[Literal["set"]]
expr: Required[CollectionExprInput]


class CollectionIntersection(CollectionBase):
type: NotRequired[Literal["intersection"]]
expr: Required[IntersectionExpr]


Collection = Union[CollectionSet, CollectionIntersection]
CollectionsInput = Union[str, Collection, List[Collection]]
21 changes: 21 additions & 0 deletions graphistry/pygraphistry.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from graphistry.plugins_types.hypergraph import HypergraphResult
from graphistry.client_session import ClientSession, ApiVersion, ENV_GRAPHISTRY_API_KEY, DatasetInfo, AuthManagerProtocol, strtobool
from graphistry.Engine import EngineAbstractType
from graphistry.models.collections import CollectionsInput
from graphistry.models.types import ValidationParam

"""Top-level import of class PyGraphistry as "Graphistry". Used to connect to the Graphistry server and then create a base plotter."""
import calendar, copy, gzip, io, json, numpy as np, pandas as pd, requests, sys, time, warnings
Expand Down Expand Up @@ -2274,6 +2276,24 @@ def settings(self, height=None, url_params={}, render=None):

return self._plotter().settings(height, url_params, render)

def collections(
self,
collections: Optional[CollectionsInput] = None,
show_collections: Optional[bool] = None,
collections_global_node_color: Optional[str] = None,
collections_global_edge_color: Optional[str] = None,
validate: ValidationParam = 'autofix',
warn: bool = True
):
return self._plotter().collections(
collections=collections,
show_collections=show_collections,
collections_global_node_color=collections_global_node_color,
collections_global_edge_color=collections_global_edge_color,
validate=validate,
warn=warn
)

def _viz_url(self, info: DatasetInfo, url_params: Dict[str, Any]) -> str:
splash_time = int(calendar.timegm(time.gmtime())) + 15
extra = "&".join([k + "=" + str(v) for k, v in list(url_params.items())])
Expand Down Expand Up @@ -2501,6 +2521,7 @@ def _handle_api_response(self, response):
pipe = PyGraphistry.pipe
graph = PyGraphistry.graph
settings = PyGraphistry.settings
collections = PyGraphistry.collections
hypergraph = PyGraphistry.hypergraph
bolt = PyGraphistry.bolt
cypher = PyGraphistry.cypher
Expand Down
Loading
Loading