Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .agents/skills/review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,17 @@ Each wave writes `wave-report.md` with:

## Phase 3: Convergence Report

### Requirement-to-evidence closure

Before writing `final-report.md`, build an acceptance map so a green aggregate count cannot hide an unmet requirement:

| Requirement | Status | Evidence / limitation |
| --- | --- | --- |

Map every review requirement to `SATISFIED`, `LIMITED`, or `UNSATISFIED`; cite tests, engine/platform capability, dependencies, performance evidence, and documentation (including `CHANGELOG.md`) separately. Preserve still-valid evidence, distinguish same-seam from aggregate evidence, and state unsupported-scope limits without inflating coverage. Recheck the final full diff after repairs; a late repair can invalidate earlier evidence.

When acting on a requested planner decision, drain the latest planner answer first. At natural step boundaries, read material mailbox updates without waiting for routine ACKs.

Write `plans/<task>/final-report.md` with:
- summary (waves run, convergence status)
- per-wave table (new findings + advance signal)
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ 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 -->

### Fixed

- **GFQL temporal scalar filters**: an empty timestamp or duration string no longer silently becomes a `NaT` bound; it raises the same typed `GFQLSchemaError` E302 as an unparseable bound. Naive bounds are handled as `Timestamp` values before timezone localization, and optional `pytz` exception discovery is confined to aware-column localization.

## [0.61.2] - 2026-10-23

### Fixed
Expand Down
40 changes: 26 additions & 14 deletions graphistry/compute/filter_by_dict.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Any, Dict, Mapping, Optional, Tuple, Type, Union, cast
from importlib import import_module
from functools import lru_cache

import pandas as pd

Expand All @@ -13,6 +14,19 @@


logger = setup_logger(__name__)


@lru_cache(maxsize=None)
def _timezone_resolution_errors() -> Tuple[Type[BaseException], ...]:
try:
pytz_exceptions = import_module("pytz.exceptions")
except ModuleNotFoundError:
return (ValueError,)
return (
ValueError,
pytz_exceptions.AmbiguousTimeError,
pytz_exceptions.NonExistentTimeError,
)
def _dtype_text(dtype: Any) -> str:
from graphistry.compute.gfql.lazy.engine.polars.dtypes import dtype_text
return dtype_text(dtype)
Expand Down Expand Up @@ -42,24 +56,19 @@ def _coerce_temporal_filter_scalar(value: FilterValue, dtype: DType, *, field: s

from graphistry.compute.exceptions import ErrorCode, GFQLSchemaError

timezone_resolution_errors: Tuple[Type[BaseException], ...] = (ValueError,)
try:
pytz_exceptions = import_module("pytz.exceptions")

timezone_resolution_errors += (
pytz_exceptions.AmbiguousTimeError,
pytz_exceptions.NonExistentTimeError,
)
except ModuleNotFoundError:
pass

try:
if is_datetime:
parsed = pd.to_datetime(value)
if getattr(parsed, "tzinfo", None) is None and isinstance(dtype, pd.DatetimeTZDtype):
if parsed is pd.NaT:
raise ValueError(f"Empty temporal value: {value!r}")
if (
isinstance(parsed, pd.Timestamp)
and parsed.tzinfo is None
and isinstance(dtype, pd.DatetimeTZDtype)
):
try:
return parsed.tz_localize(dtype.tz)
except timezone_resolution_errors as exc:
except _timezone_resolution_errors() as exc:
raise GFQLSchemaError(
ErrorCode.E302,
f'Type mismatch: unresolvable temporal filter value for column "{field}"',
Expand All @@ -69,7 +78,10 @@ def _coerce_temporal_filter_scalar(value: FilterValue, dtype: DType, *, field: s
suggestion="Use an explicit UTC offset or a resolvable column-local timestamp",
) from exc
return parsed
return pd.to_timedelta(value)
parsed = pd.to_timedelta(value)
if parsed is pd.NaT:
raise ValueError(f"Empty duration value: {value!r}")
return parsed
except (TypeError, ValueError, OverflowError) as exc:
raise GFQLSchemaError(
ErrorCode.E302,
Expand Down
94 changes: 92 additions & 2 deletions graphistry/tests/compute/gfql/test_temporal_leak_family_1915.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ def test_aware_utc_date_only_answers_and_propagates_runtime_failure(self, monkey
})
g = graphistry.nodes(nodes, "id").edges(_edges_pd(), "s", "d")
expected = ["utc_midnight"]
runtime_failure = RuntimeError("boom")

assert _ids(g, "MATCH (n) WHERE n.ts_aw = '2024-01-01' RETURN n.id", "pandas") == expected
out = g.gfql(
Expand All @@ -390,12 +391,14 @@ def test_aware_utc_date_only_answers_and_propagates_runtime_failure(self, monkey
assert _ids(g, [chain], "pandas") == expected

def fail_localize(self, tz):
raise RuntimeError("boom")
raise runtime_failure

dtype = pd.DatetimeTZDtype("ns", "UTC")
runtime_failure = RuntimeError("boom")
monkeypatch.setattr(pd.Timestamp, "tz_localize", fail_localize)
with pytest.raises(RuntimeError, match="boom"):
with pytest.raises(RuntimeError) as excinfo:
_coerce_temporal_filter_scalar("2024-01-01", dtype, field="ts_aw")
assert excinfo.value is runtime_failure

def test_aware_null_empty_and_malformed_boundaries(self):
"""Empty/null-only supported frames return values; malformed bounds stay E302."""
Expand All @@ -410,6 +413,93 @@ def test_aware_null_empty_and_malformed_boundaries(self):
null_only.gfql([n({"ts_aw": "not-a-timestamp"})], engine="pandas")
assert excinfo.value.code == ErrorCode.E302

@pytest.mark.parametrize(("column", "bound"), [
("ts_aw", ""),
("dur", ""),
])
def test_empty_temporal_bound_is_typed_schema_error(self, column: str, bound: str):
from graphistry.compute.ast import n
g = _graph("pandas")
with pytest.raises(GFQLSchemaError) as excinfo:
g.gfql([n({column: bound})], engine="pandas")
assert excinfo.value.code == ErrorCode.E302

@pytest.mark.parametrize("public_form", ["string", "json", "param"])
def test_aware_dst_error_contract_without_pytz(self, monkeypatch, public_form):
"""Optional exception discovery falls back to ValueError without pytz."""
import json
from graphistry.compute.ast import n
from graphistry.compute.filter_by_dict import _coerce_temporal_filter_scalar
from graphistry.compute.filter_by_dict import _timezone_resolution_errors
from graphistry.compute import filter_by_dict
from importlib import import_module as real_import_module

_timezone_resolution_errors.cache_clear()
try:
import_calls = []
def no_pytz(name: str, *args: object, **kwargs: object):
import_calls.append(name)
if name == "pytz.exceptions":
raise ModuleNotFoundError("No module named 'pytz.exceptions'")
return real_import_module(name, *args, **kwargs)

monkeypatch.setattr(filter_by_dict, "import_module", no_pytz)
assert _timezone_resolution_errors() == (ValueError,)
assert "pytz.exceptions" in import_calls
_timezone_resolution_errors.cache_clear()

zone_nodes = pd.DataFrame({
"id": ["fall_early", "fall_late"],
"ts_zone": pd.to_datetime([
"2024-11-03T01:30:00", "2024-11-03T01:30:00",
]).tz_localize("America/New_York", ambiguous=[True, False]),
})
graph = graphistry.nodes(zone_nodes, "id").edges(_edges_pd(), "s", "d")
bound = "2024-11-03T01:30:00"
def fail_localize(value, tz):
raise ValueError("no pytz fallback")

monkeypatch.setattr(pd.Timestamp, "tz_localize", fail_localize)
if public_form == "param":
query = "MATCH (n) WHERE n.ts_zone = $bound RETURN n.id"
with pytest.raises(GFQLSchemaError) as excinfo:
graph.gfql(query, engine="pandas", params={"bound": bound})
elif public_form == "json":
query = [json.loads(json.dumps(n({"ts_zone": bound}).to_json()))]
with pytest.raises(GFQLSchemaError) as excinfo:
graph.gfql(query, engine="pandas")
else:
query = f"MATCH (n) WHERE n.ts_zone = '{bound}' RETURN n.id"
with pytest.raises(GFQLSchemaError) as excinfo:
graph.gfql(query, engine="pandas")
assert excinfo.value.code == ErrorCode.E302
assert excinfo.value.context["field"] == "ts_zone"
assert excinfo.value.context["value"] == bound
finally:
_timezone_resolution_errors.cache_clear()

def test_duration_does_not_discover_optional_timezone_exceptions(self, monkeypatch):
from graphistry.compute.filter_by_dict import _timezone_resolution_errors
_timezone_resolution_errors.cache_clear()
real_import = __import__

import_calls = []

def tracked_import(name: str, *args: object, **kwargs: object):
import_calls.append(name)
if name == "pytz.exceptions":
raise ModuleNotFoundError("No module named 'pytz.exceptions'")
return real_import(name, *args, **kwargs)

monkeypatch.setattr(
"graphistry.compute.filter_by_dict.import_module",
tracked_import,
)
g = _graph("pandas")
assert _ids(g, "MATCH (n) WHERE n.dur = '1 days' RETURN n.id", "pandas") == ["p"]
assert "pytz.exceptions" not in import_calls
_timezone_resolution_errors.cache_clear()

@pytest.mark.parametrize("engine", [
"pandas",
pytest.param("polars", marks=pytest.mark.skipif(pl is None, reason="polars not installed")),
Expand Down
Loading