Skip to content
Open
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- **GFQL Cypher relationship uniqueness across `MATCH` clauses**: sequential connected `MATCH` clauses can reuse the same relationship with a new relationship variable, as in `MATCH (x)-[e]->(y) MATCH (y)<-[f]-(z)`, while uniqueness remains path-global within each clause (#2235).
## [0.61.2] - 2026-10-23

### Added

- GFQL `searchAny` / `search_nodes` / `search_edges` now search **datetime** columns when the term is numeric, matching what the viz inspector displays rather than `repr`: a timestamp renders as the inspector's moment format (`Jan 5 2024, 3:04:05 am UTC`), so `"2024"` finds rows in that year. Datetimes previously declined. The zone is a `temporal_tz` parameter (default UTC) because the inspector renders in the viewer's zone, which a server cannot know — and the zone is not cosmetic, since it decides which day a timestamp lands on and therefore which rows match. Verified byte-identical to real `moment-timezone` on 3000 timestamps across 12 zones, including both DST directions, half- and quarter-hour offsets, and zones whose abbreviation changes seasonally.
- Searching a datetime column means rendering it, which a numeric-term search now pays for on every datetime column present: on a 300k-row frame one datetime column takes a `searchAny` from 77ms to 629ms, about three times what a float column added when #2089 gated floats in the same way. A term that is not numeric does not gate datetime columns in and pays nothing, which is pinned by spying on the renderer rather than by timing.
- pandas and polars render every zone and answer identical rows; polars matches pandas on 2968/3000 fuzz cases, the residue being `Africa/Casablanca`, where polars' bundled timezone database and Python's disagree about its future DST. cuDF matches at UTC (0/5000 mismatches on a GB10) and declines any other zone rather than mislabel it: cudf 26.02.01's `strftime` ignores `tz_convert`, answering `UTC` for `%Z` whatever the zone and reading the underlying UTC instant for `%p`, so an `Asia/Kolkata` column would render the right hour beside the wrong meridiem.

- GFQL `searchAny` / `search_nodes` / `search_edges` now search **float** columns when the term is numeric, matching what the viz inspector displays rather than `repr`: fractional values render to a fixed number of decimals (so `0.1 + 0.2` is found by `"0.3"` and never by `"0.30000000000000004"`), whole values render without a trailing `.0`, and NaN plus the Int32 sentinel `2147483647` display nothing and never match. Precision is a `float_precision` parameter (default 4). This removes the previous cuDF float decline and the polars float exclusion (#1695).
- Only pandas can reproduce the inspector's half-away-from-zero rounding; polars and cuDF round half-to-even, so a value whose `float_precision+1`-th decimal is exactly 5 renders one unit lower there. Measured on 26,008 realistic column values: pandas 100%, cuDF 99.996%, polars 99.931% agreement with the UI. Pinned in `test_known_cross_engine_divergences.py`.

### Fixed

- **igraph calls on nodes with a `name` column**: a node attribute named `name`, other than the node ID column, no longer makes `to_igraph`, `compute_igraph`, and `CALL graphistry.igraph.*` fail with `Vertex attribute conflict`. It moves to a generated name for igraph, which reserves `name` for node IDs, and is restored by `from_igraph` (#2217).
Expand Down
50 changes: 46 additions & 4 deletions graphistry/client_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,40 @@
]


def _jwt_exp(token: str) -> Optional[float]:
"""Best-effort read of a JWT's "exp" claim, without verifying its signature --
def _jwt_payload(token: str) -> Optional[Dict[str, Any]]: # hygiene-ok: explicit-any -- decoded JWT claims are arbitrary server-provided JSON
"""Best-effort decode of a JWT's payload, without verifying its signature --
the token is already trusted (this client received it from the server over
the auth flow it just completed); this only reads a claim out of a token
the auth flow it just completed); this only reads claims out of a token
it already holds, never validates one from an untrusted source."""
try:
payload_b64 = token.split(".")[1]
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded))
return payload if isinstance(payload, dict) else None
except Exception:
return None


def _jwt_exp(token: str) -> Optional[float]:
payload = _jwt_payload(token)
if payload is None:
return None
try:
exp = payload.get("exp")
return float(exp) if exp is not None else None
except Exception:
return None


def _jwt_user_id(token: str) -> Optional[str]:
"""The Hub's "user_id" claim as a string, or None when the token is opaque / has no such claim."""
payload = _jwt_payload(token)
if payload is None:
return None
user_id = payload.get("user_id")
return str(user_id) if user_id is not None and user_id != "" else None


def _jwt_expired(token: str) -> bool:
exp = _jwt_exp(token)
# No readable exp claim -> not expired by this check; let the server reject it if it's actually stale.
Expand Down Expand Up @@ -137,12 +156,31 @@ def mark_org_verified(self, token: Optional[str], org_name: str) -> None:
return
self._verified_org_tokens[org_name] = (token, _jwt_exp(token))

def get_verified_token(self, org_name: str) -> Optional[str]:
def forget_verified_org(self, org_name: str) -> None:
"""Drop any cached verification for org_name.

Callers that just rotated the active token (e.g. refresh()) must call
this for the current org before re-checking verification: the server
resets the active org on every token reissue, so a cache entry from
before the rotation is stale, and get_verified_token()'s same_user_as
guard would otherwise happily swap the old (superseded) token back in
as active since it still belongs to the same user.
"""
self._verified_org_tokens.pop(org_name, None)

def get_verified_token(self, org_name: str, same_user_as: Optional[str] = None) -> Optional[str]:
"""Return a still-unexpired token previously verified for org_name, if any.

May differ from the currently-active token (e.g. a later SSO login
for a different org minted a new one) -- callers that use this to
skip a switch should swap it in as the active token.

When ``same_user_as`` is given, the cached token is only returned if
both it and ``same_user_as`` carry a readable Hub ``user_id`` claim and
the two match. The cache is keyed by org only, and login()/api_token()
can change the active principal without resetting the session, so
without this guard a switch could silently reinstate another user's
token. Opaque tokens (no claim) never qualify.
"""
entry = self._verified_org_tokens.get(org_name)
if entry is None:
Expand All @@ -151,6 +189,10 @@ def get_verified_token(self, org_name: str) -> Optional[str]:
if _jwt_expired(token):
del self._verified_org_tokens[org_name]
return None
if same_user_as is not None:
cached_user = _jwt_user_id(token)
if cached_user is None or cached_user != _jwt_user_id(same_user_as):
return None
return token

def copy(self) -> "ClientSession":
Expand Down
6 changes: 0 additions & 6 deletions graphistry/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,6 @@ class SsoException(Exception):
pass


class SsoRetrieveTokenTimeoutException(SsoException):
"""
Koa, 15 Sep 2022 Custom Exception to Sso retrieve token time out exception scenario
"""
pass

class SsoStateInvalidException(SsoException):
"""
30 Jun 2025 Raised when the SSO state is missing, invalid, or not initialized during token retrieval.
Expand Down
45 changes: 22 additions & 23 deletions graphistry/pygraphistry.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from . import bolt_util
from .plotter import Plotter
from .util import in_databricks, setup_logger, in_ipython
from .exceptions import SsoRetrieveTokenTimeoutException, TokenExpireException, SsoStateInvalidException, SsoPendingException
from .exceptions import TokenExpireException, SsoStateInvalidException, SsoPendingException

from .messages import (
MSG_REGISTER_MISSING_PASSWORD,
Expand Down Expand Up @@ -340,25 +340,24 @@ def _handle_auth_url(self, auth_url: str, sso_timeout: Optional[int], sso_opt_in
logger.debug("Transient error polling SSO token, will retry: %s", exc)
token = None
org_name = None
try:
if not token:
if elapsed_time % 10 == 1:
count_down = "Waiting for token : {} seconds ...".format(sso_timeout - elapsed_time + 1)
print(count_down)
if token:
break
if elapsed_time % 10 == 1:
count_down = "Waiting for token : {} seconds ...".format(sso_timeout - elapsed_time + 1)
print(count_down)
# Display-only nicety: must never affect the sleep/timeout below (IPython is an optional dependency).
if in_ipython():
try:
from IPython.core.display import HTML
from IPython.display import display
display(HTML(f'<strong>{count_down}</strong>'))
time.sleep(1)
elapsed_time = elapsed_time + 1
if elapsed_time > sso_timeout:
raise SsoRetrieveTokenTimeoutException("[SSO] Get token timeout")
else:
break
except SsoRetrieveTokenTimeoutException as toe:
logger.debug(toe, exc_info=1)
except Exception as exc:
logger.debug("Could not render SSO countdown in notebook: %s", exc)
time.sleep(1)
elapsed_time = elapsed_time + 1
if elapsed_time > sso_timeout:
logger.debug("[SSO] Get token timeout after %s seconds", sso_timeout)
break
except Exception:
token = None
if token:
# set org_name to sso org
self.session.org_name = org_name
Expand Down Expand Up @@ -432,23 +431,23 @@ def refresh(self, token: Optional[str] = None, fail_silent: bool = False) -> Opt
logger.debug("JWT refresh via token")
if using_self_token:
self.session._is_authenticated = False
prior_token = self.api_token() if using_self_token else token
token = (
ArrowUploader(
client_session=self.session,
server_base_path=self.protocol()
+ "://"
+ "://"
+ self.server(),
certificate_validation=self.certificate_validation(),
)
.refresh(self.api_token() if using_self_token else token)
.refresh(prior_token)
.token
)
self.api_token(token)
self.session._is_authenticated = True
refreshed_org = self.session.org_name
if token and refreshed_org and self.session.get_verified_token(refreshed_org):
# Refresh reissues under the same org context; carry the grant to the new token.
self.session.mark_org_verified(token, refreshed_org)
if refreshed_org and token != prior_token:
self.session.forget_verified_org(refreshed_org)
self._maybe_switch_org(refreshed_org)
return self.api_token()
except Exception as e:
Expand Down Expand Up @@ -2725,8 +2724,8 @@ def switch_org(self, value: str):
logger.debug("Already switched to organization %s with current token; skipping request.", org_name)
return

# Fast path 2: an earlier, still-unexpired token already verified org_name (now superseded as active by a later SSO login elsewhere) -- swap it back in.
cached_token = self.session.get_verified_token(org_name)
# Fast path 2: swap back in an earlier, still-unexpired token verified for org_name -- same Hub user only, since the cache is per-org and login()/api_token() don't reset it.
cached_token = self.session.get_verified_token(org_name, same_user_as=token) if token else None
if cached_token:
# Direct assign: api_token()'s setter would clear _is_authenticated and force a needless refresh().
self.session.api_token = cached_token
Expand Down
34 changes: 32 additions & 2 deletions graphistry/tests/test_client_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@
from unittest import mock


def _fake_jwt(exp=None) -> str:
def _fake_jwt(exp=None, user_id=None) -> str:
header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode()
payload = {} if exp is None else {"exp": exp}
payload = {}
if exp is not None:
payload["exp"] = exp
if user_id is not None:
payload["user_id"] = user_id
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode()
return f"{header}.{body}.sig"

Expand Down Expand Up @@ -56,6 +60,32 @@ def test_unknown_org_returns_none(self):
assert session.get_verified_token("never-switched") is None
assert session.is_org_verified("tok", "never-switched") is False

def test_same_user_guard_matches_on_user_id_claim(self):
session = ClientSession()
cached = _fake_jwt(exp=time.time() + 3600, user_id=42)
session.mark_org_verified(cached, "org-a")

same_user = _fake_jwt(exp=time.time() + 1800, user_id=42)
other_user = _fake_jwt(exp=time.time() + 1800, user_id=43)

assert session.get_verified_token("org-a", same_user_as=same_user) == cached
assert session.get_verified_token("org-a", same_user_as=other_user) is None
# user_id is compared as a string so int/str encodings of the same id agree.
assert session.get_verified_token("org-a", same_user_as=_fake_jwt(exp=time.time() + 1800, user_id="42")) == cached

def test_same_user_guard_rejects_tokens_without_user_claim(self):
session = ClientSession()
claimless_cached = _fake_jwt(exp=time.time() + 3600)
session.mark_org_verified(claimless_cached, "org-a")
assert session.get_verified_token("org-a", same_user_as=_fake_jwt(user_id=42)) is None

session.mark_org_verified("opaque-pkey-token", "org-b")
assert session.get_verified_token("org-b", same_user_as="another-opaque-token") is None
assert session.get_verified_token("org-b", same_user_as=_fake_jwt(user_id=42)) is None

# The guard is opt-in: unguarded lookups keep their previous behavior.
assert session.get_verified_token("org-a") == claimless_cached


class TestClientSession:
"""
Expand Down
Loading
Loading