diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e7e16a7..2914b19e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Development] +### Breaking 🔥 +- **API v1 Removal**: Removed legacy VGraph/protobuf API v1 support in favor of API v3. + * Removed `_etl1()`, `_etl_url()`, `_check_url()` methods from `pygraphistry.py` + * Removed API v1 dispatch path from `PlotterBase.py` + * Changed `register(api=...)` parameter type from `Literal[1, 3]` to `Literal[3]` + * Updated `client_session.py` type from `Literal["arrow", "vgraph"]` to `Literal["arrow"]` + * **Migration**: Users calling `graphistry.register(api=1)` must switch to `graphistry.register(api=3)` or omit the parameter (defaults to v3) + ### Fixed - **GFQL:** `Chain` now validates on construction (matching docs) and rejects invalid hops immediately; pass `validate=False` to defer validation when assembling advanced flows (fixes #860). - **GFQL / eq:** `eq()` now accepts strings in addition to numeric/temporal values (use `isna()`/`notna()` for nulls); added coverage across validator, schema validation, JSON, and GFQL runtime (fixes #862). diff --git a/graphistry/PlotterBase.py b/graphistry/PlotterBase.py index 12b146928..eef67b6bb 100644 --- a/graphistry/PlotterBase.py +++ b/graphistry/PlotterBase.py @@ -10,7 +10,7 @@ from weakref import WeakValueDictionary from graphistry.privacy import Privacy, Mode, ModeAction -from graphistry.client_session import ClientSession, AuthManagerProtocol +from graphistry.client_session import ClientSession, AuthManagerProtocol, DatasetInfo from .constants import SRC, DST, NODE from .plugins.igraph import to_igraph, from_igraph, compute_igraph, layout_igraph @@ -2137,36 +2137,24 @@ def plot( self._check_mandatory_bindings(not isinstance(n, type(None))) logger.debug("2. @PloatterBase plot: self._pygraphistry.org_name: {}".format(self.session.org_name)) - dataset: Union[ArrowUploader, Dict[str, Any], None] = None - uploader = None # Initialize to avoid UnboundLocalError when api_version != 3 - if self.session.api_version == 1: - dataset = self._plot_dispatch(g, n, name, description, 'json', self._style, memoize) - if skip_upload: - return dataset - info = self._pygraphistry._etl1(dataset) - elif self.session.api_version == 3: - logger.debug("3. @PloatterBase plot: self._pygraphistry.org_name: {}".format(self.session.org_name)) - self._pygraphistry.refresh() - logger.debug("4. @PloatterBase plot: self._pygraphistry.org_name: {}".format(self.session.org_name)) - - uploader = dataset = self._plot_dispatch_arrow(g, n, name, description, self._style, memoize) - assert uploader is not None - if skip_upload: - return uploader - uploader.token = self.session.api_token # type: ignore[assignment] - uploader.post(as_files=as_files, memoize=memoize, validate=validate, erase_files_on_fail=erase_files_on_fail) - uploader.maybe_post_share_link(self) - info = { - 'name': uploader.dataset_id, - 'type': 'arrow', - 'viztoken': str(uuid.uuid4()) - } - else: - raise ValueError( - f"Unsupported API version: {self.session.api_version}. " - f"Supported versions are 1 and 3. " - f"Please check your graphistry configuration or contact support." - ) + uploader = None + + logger.debug("3. @PloatterBase plot: self._pygraphistry.org_name: {}".format(self.session.org_name)) + self._pygraphistry.refresh() + logger.debug("4. @PloatterBase plot: self._pygraphistry.org_name: {}".format(self.session.org_name)) + + uploader = self._plot_dispatch_arrow(g, n, name, description, self._style, memoize) + assert uploader is not None + if skip_upload: + return uploader + uploader.token = self.session.api_token # type: ignore[assignment] + uploader.post(as_files=as_files, memoize=memoize, validate=validate, erase_files_on_fail=erase_files_on_fail) + uploader.maybe_post_share_link(self) + info: DatasetInfo = { + 'name': uploader.dataset_id, + 'type': 'arrow', + 'viztoken': str(uuid.uuid4()) + } viz_url = self._pygraphistry._viz_url(info, self._url_params) cfg_client_protocol_hostname = self.session.client_protocol_hostname diff --git a/graphistry/client_session.py b/graphistry/client_session.py index 056d87a08..e1a3d9228 100644 --- a/graphistry/client_session.py +++ b/graphistry/client_session.py @@ -12,7 +12,7 @@ -ApiVersion = Literal[1, 3] +ApiVersion = Literal[3] ENV_GRAPHISTRY_API_KEY = "GRAPHISTRY_API_KEY" @@ -55,9 +55,9 @@ def __init__(self) -> None: env_api_version = get_from_env("GRAPHISTRY_API_VERSION", int) if env_api_version is None: - env_api_version = 1 - elif env_api_version not in [1, 3]: - raise ValueError("Expected API version to be 1, 3, instead got (likely from API_VERSION): %s" % env_api_version) + env_api_version = 3 + elif env_api_version != 3: + raise ValueError("Expected API version to be 3. Legacy API versions 1 and 2 are no longer supported. Got: %s" % env_api_version) self.api_version: ApiVersion = cast(ApiVersion, env_api_version) self.dataset_prefix: str = get_from_env("GRAPHISTRY_DATASET_PREFIX", str, "PyGraphistry/") @@ -125,16 +125,13 @@ def as_proxy(self) -> MutableMapping[str, Any]: class DatasetInfo(TypedDict): name: str viztoken: str - type: Literal["arrow", "vgraph"] + type: Literal["arrow"] class AuthManagerProtocol(Protocol): session: ClientSession - def _etl1(self, dataset: Any) -> DatasetInfo: - ... - def refresh(self, token: Optional[str] = None, fail_silent: bool = False) -> Optional[str]: ... diff --git a/graphistry/pygraphistry.py b/graphistry/pygraphistry.py index c6aee4a96..6a8ae4aaa 100644 --- a/graphistry/pygraphistry.py +++ b/graphistry/pygraphistry.py @@ -104,26 +104,16 @@ def _is_authenticated(self, value: bool) -> None: self.session._is_authenticated = value def authenticate(self) -> None: - """Authenticate via already provided configuration (api=1,2). + """Authenticate via already provided configuration. This is called once automatically per session when uploading and rendering a visualization. - In api=3, if token_refresh_ms > 0 (defaults to 10min), this starts an automatic refresh loop. - In that case, note that a manual .login() is still required every 24hr by default. + If token_refresh_ms > 0 (defaults to 10min), this starts an automatic refresh loop. + Note that a manual .login() is still required every 24hr by default. """ - if self.api_version() == 3: - if not (self.api_token() is None): - self.refresh() - else: - key = self.api_key() - # Mocks may set to True, so bypass in that case - if (key is None) and (self.session._is_authenticated is False): - util.error( - "In api=1 mode, API key not set explicitly in `register()` or available at " - + ENV_GRAPHISTRY_API_KEY - ) - if not self.session._is_authenticated: - self._check_key_and_version() - self.session._is_authenticated = True + if not (self.api_token() is None): + self.refresh() + elif not self.session._is_authenticated: + self.session._is_authenticated = True def __reset_token_creds_in_memory(self) -> None: """Reset the token and creds in memory, used when switching hosts, switching register method""" @@ -535,15 +525,15 @@ def protocol(self, value: Optional[str] = None) -> str: return value def api_version(self, value: Optional[ApiVersion] = None) -> ApiVersion: - """Set or get the API version: 1 for 1.0 (deprecated), 3 for 2.0. - Setting api=2 (protobuf) fully deprecated from the PyGraphistry client. + """Set or get the API version. Only api=3 is supported. + Legacy API versions 1 and 2 are no longer supported. Also set via environment variable GRAPHISTRY_API_VERSION.""" if value is None: value = self.session.api_version - if value not in [1, 3]: - raise ValueError("Expected API version to be 1, 3, instead got: %s" % value) + if value != 3: + raise ValueError("Expected API version to be 3. Legacy API versions 1 and 2 are no longer supported. Got: %s" % value) # setter self.session.api_version = value @@ -578,7 +568,7 @@ def register( personal_key_secret: Optional[str] = None, server: Optional[str] = None, protocol: Optional[str] = None, - api: Optional[Literal[1, 3]] = None, + api: Optional[Literal[3]] = None, certificate_validation: Optional[bool] = None, bolt: Optional[Union[Dict, Any]] = None, store_token_creds_in_memory: Optional[bool] = None, @@ -594,15 +584,15 @@ def register( Changing the key effects all derived Plotter instances. - Provide one of key (deprecated api=1), username/password (api=3) or temporary token (api=3). + Provide username/password or temporary token for authentication. - :param key: API key (deprecated 1.0 API) + :param key: API key (deprecated, ignored) :type key: Optional[str] - :param username: Account username (2.0 API). + :param username: Account username. :type username: Optional[str] - :param password: Account password (2.0 API). + :param password: Account password. :type password: Optional[str] - :param token: Valid Account JWT token (2.0). Provide token, or username/password, but not both. + :param token: Valid Account JWT token. Provide token, or username/password, but not both. :type token: Optional[str] :param personal_key_id: Personal Key id for service account. :type personal_key_id: Optional[str] @@ -612,8 +602,8 @@ def register( :type server: Optional[str] :param protocol: Protocol to use for server uploaders, defaults to "https". :type protocol: Optional[str] - :param api: API version to use, defaults to 1 (deprecated slow json 1.0 API), prefer 3 (2.0 API with Arrow+JWT) - :type api: Optional[Literal[1, 3]] + :param api: API version (only 3 is supported, uses Arrow+JWT) + :type api: Optional[Literal[3]] :param certificate_validation: Override default-on check for valid TLS certificate by setting to True. :type certificate_validation: Optional[bool] :param bolt: Neo4j bolt information. Optional driver or named constructor arguments for instantiating a new one. @@ -689,12 +679,6 @@ def register( import graphistry graphistry.register(api=3, protocol='http', server='nginx', client_protocol_hostname='https://my.site.com', token='abc') - **Example: Standard (1.0)** - :: - - import graphistry - graphistry.register(api=1, key="my api key") - """ global _is_client_mode_warned if self is PyGraphistry and _client_mode_enabled and not _is_client_mode_warned: @@ -2290,16 +2274,6 @@ def settings(self, height=None, url_params={}, render=None): return self._plotter().settings(height, url_params, render) - def _etl_url(self): - hostname = self.session.hostname - protocol = self.session.protocol - return "%s://%s/etl" % (protocol, hostname) - - def _check_url(self): - hostname = self.session.hostname - protocol = self.session.protocol - return "%s://%s/api/check" % (protocol, hostname) - 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())]) @@ -2321,125 +2295,6 @@ def _switch_org_url(self, org_name): return "{}://{}/api/v2/o/{}/switch/".format(protocol, hostname, org_name) - def _coerce_str(self, v): - try: - return str(v) - except UnicodeDecodeError: - print("UnicodeDecodeError") - print("=", v, "=") - x = v.decode("utf-8") - print("x", x) - return x - - def _get_data_file(self, dataset, mode): - out_file = io.BytesIO() - if mode == "json": - json_dataset = None - try: - json_dataset = json.dumps( - dataset, ensure_ascii=False, cls=NumpyJSONEncoder - ) - except TypeError: - warnings.warn("JSON: Switching from NumpyJSONEncoder to str()") - json_dataset = json.dumps(dataset, default=self._coerce_str) - - with gzip.GzipFile(fileobj=out_file, mode="w", compresslevel=9) as f: - if sys.version_info < (3, 0) and isinstance(json_dataset, bytes): - f.write(json_dataset) - else: - f.write(json_dataset.encode("utf8")) - else: - raise ValueError("Unknown mode:", mode) - - kb_size = len(out_file.getvalue()) // 1024 - if kb_size >= 5 * 1024: - print("Uploading %d kB. This may take a while..." % kb_size) - sys.stdout.flush() - - return out_file - - def _etl1(self, dataset: Any) -> DatasetInfo: - self.authenticate() - - headers = {"Content-Encoding": "gzip", "Content-Type": "application/json"} - params = { - "usertag": self.session._tag, - "agent": "pygraphistry", - "apiversion": "1", - "agentversion": sys.modules["graphistry"].__version__, - "key": self.session.api_key, - } - - out_file = self._get_data_file(dataset, "json") - response = requests.post( - self._etl_url(), - out_file.getvalue(), - headers=headers, - params=params, - verify=self.session.certificate_validation, - ) - log_requests_error(response) - response.raise_for_status() - - try: - jres = response.json() - except Exception: - raise ValueError("Unexpected server response", response) - - if jres["success"] is not True: - raise ValueError("Server reported error:", jres["msg"]) - else: - return { - "name": jres["dataset"], - "viztoken": jres["viztoken"], - "type": "vgraph", - } - - - def _check_key_and_version(self): - params = {"text": self.session.api_key} - try: - response = requests.get( - self._check_url(), - params=params, - timeout=(3, 3), - verify=self.session.certificate_validation, - ) - log_requests_error(response) - response.raise_for_status() - jres = response.json() - - cver = sys.modules["graphistry"].__version__ - if ( - "pygraphistry" in jres - and "minVersion" in jres["pygraphistry"] # noqa: W503 - and "latestVersion" in jres["pygraphistry"] # noqa: W503 - ): - mver = jres["pygraphistry"]["minVersion"] - lver = jres["pygraphistry"]["latestVersion"] - - from packaging.version import parse - try: - if parse(mver) > parse(cver): - util.warn( - "Your version of PyGraphistry is no longer supported (installed=%s latest=%s). Please upgrade!" - % (cver, lver) - ) - elif parse(lver) > parse(cver): - print( - "A new version of PyGraphistry is available (installed=%s latest=%s)." - % (cver, lver) - ) - except: - raise ValueError(f'Unexpected version value format when comparing {mver}, {cver}, and {lver}') - if jres["success"] is not True: - util.warn(jres["error"]) - except Exception: - util.warn( - "Could not contact %s. Are you connected to the Internet?" - % self.session.hostname - ) - def layout_settings(self, play: Optional[int] = None, locked_x: Optional[bool] = None, diff --git a/graphistry/tests/common.py b/graphistry/tests/common.py index c2fab2a32..d4802b76e 100644 --- a/graphistry/tests/common.py +++ b/graphistry/tests/common.py @@ -28,7 +28,7 @@ class NoAuthTestCase(unittest.TestCase): @classmethod def setUpClass(cls): # Register once per test class to set up the session - graphistry.register(api=1) + graphistry.register(api=3, verify_token=False) # HACK: Set _is_authenticated after register() to bypass auth # This is reset by register(), so we set it after graphistry.pygraphistry.PyGraphistry._is_authenticated = True diff --git a/graphistry/tests/test_client_session.py b/graphistry/tests/test_client_session.py index 968b8d59d..7b7284c1d 100644 --- a/graphistry/tests/test_client_session.py +++ b/graphistry/tests/test_client_session.py @@ -23,7 +23,7 @@ def setup_method(self): Reset global state at the start of every test. Calling register() without credentials clears the in-memory session cleanly. """ - graphistry.register(api=1) + graphistry.register(api=3, verify_token=False) # --------------------------------------------------------------------- # # Basic config proxy # diff --git a/graphistry/tests/test_plotter.py b/graphistry/tests/test_plotter.py index b464bdad2..7115ec8a7 100644 --- a/graphistry/tests/test_plotter.py +++ b/graphistry/tests/test_plotter.py @@ -116,147 +116,6 @@ def assertFrameEqual(df1, df2, **kwds): ) -@patch("webbrowser.open") -@patch.object(graphistry.pygraphistry.PyGraphistry, "_etl1") -class TestPlotterBindings_API_1(NoAuthTestCase): - @classmethod - def setUpClass(cls): - graphistry.pygraphistry.PyGraphistry._is_authenticated = True - graphistry.register(api=1) - - def test_no_src_dst(self, mock_etl, mock_open): - with self.assertRaises(ValueError): - graphistry.bind().plot(triangleEdges) - with self.assertRaises(ValueError): - graphistry.bind(source="src").plot(triangleEdges) - with self.assertRaises(ValueError): - graphistry.bind(destination="dst").plot(triangleEdges) - with self.assertRaises(ValueError): - graphistry.bind(source="doesnotexist", destination="dst").plot( - triangleEdges - ) - - def test_no_nodeid(self, mock_etl, mock_open): - plotter = graphistry.bind(source="src", destination="dst") - with self.assertRaises(ValueError): - plotter.plot(triangleEdges, triangleNodes) - - def test_triangle_edges(self, mock_etl, mock_open): - plotter = graphistry.bind(source="src", destination="dst") - plotter.plot(triangleEdges) - self.assertTrue(mock_etl.called) - - def test_bind_edges(self, mock_etl, mock_open): - plotter = graphistry.bind(source="src", destination="dst", edge_title="src") - plotter.plot(triangleEdges) - self.assertTrue(mock_etl.called) - - def test_bind_graph_short(self, mock_etl, mock_open): - g = graphistry.nodes(pd.DataFrame({"n": []}), "n").edges( - pd.DataFrame({"a": [], "b": []}), "a", "b" - ) - assert g._node == "n" - assert g._source == "a" - assert g._destination == "b" - g2 = ( - graphistry.bind(source="a", destination="b", node="n") - .nodes(pd.DataFrame({"n": []})) - .edges(pd.DataFrame({"a": [], "b": []})) - ) - assert g2._node == "n" - assert g2._source == "a" - assert g2._destination == "b" - - def test_bind_nodes(self, mock_etl, mock_open): - plotter = graphistry.bind( - source="src", destination="dst", node="id", point_title="a2" - ) - plotter.plot(triangleEdges, triangleNodes) - self.assertTrue(mock_etl.called) - - def test_bind_nodes_rich(self, mock_etl, mock_open): - plotter = graphistry.bind( - source="src", destination="dst", node="id", point_title="a2" - ) - plotter.plot(triangleEdges, triangleNodesRich) - self.assertTrue(mock_etl.called) - - def test_bind_edges_rich_2(self, mock_etl, mock_open): - plotter = graphistry.bind(source="src", destination="dst") - plotter.plot(squareEvil) - self.assertTrue(mock_etl.called) - - def test_unknown_col_edges(self, mock_etl, mock_open): - plotter = graphistry.bind( - source="src", destination="dst", edge_title="doesnotexist" - ) - with pytest.warns(RuntimeWarning): - plotter.plot(triangleEdges) - self.assertTrue(mock_etl.called) - - def test_unknown_col_nodes(self, mock_etl, mock_open): - plotter = graphistry.bind( - source="src", destination="dst", node="id", point_title="doesnotexist" - ) - with pytest.warns(RuntimeWarning): - plotter.plot(triangleEdges, triangleNodes) - self.assertTrue(mock_etl.called) - - @patch("requests.post", return_value=Fake_Response()) - def test_empty_graph(self, mock_post, mock_etl, mock_open): - plotter = graphistry.bind(source="src", destination="dst") - with pytest.warns(RuntimeWarning): - plotter.plot(pd.DataFrame({"src": [], "dst": []})) - self.assertTrue(mock_etl.called) - - def test_edges(self, mock_etl, mock_open): - df = pd.DataFrame({"s": [0, 1, 2], "d": [1, 2, 0]}) - g = graphistry.edges(df) - assert g._edges is df - g = graphistry.edges(df, "s") - assert g._source == "s" - g = graphistry.edges(df, "s", "d") - assert g._destination == "d" - df2 = pd.DataFrame({"s": [2, 4, 6], "d": [1, 2, 0]}) - g2 = graphistry.edges(lambda g: g.edges(df2)._edges) - assert g2._edges is df2 - g3 = graphistry.edges(lambda g: g.edges(df2)._edges, source="s") - assert g3._source == "s" - g4 = graphistry.edges( - (lambda g, s: g.edges(df2)._edges.assign(**{s: 1})), - None, None, None, - 's2') - assert (g4._edges.columns == ['s', 'd', 's2']).all() - - def test_nodes(self, mock_etl, mock_open): - df = pd.DataFrame({"s": [0, 1, 2], "d": [1, 2, 0]}) - g = graphistry.nodes(df) - assert g._nodes is df - g = graphistry.nodes(df, "s") - assert g._node == "s" - df2 = pd.DataFrame({"s": [2, 4, 6], "d": [1, 2, 0]}) - g2 = g.nodes(lambda g: g.nodes(df2)._nodes) - assert g2._nodes is df2 - assert g2._node == "s" - g3 = graphistry.nodes( - (lambda g, n: g.nodes(df2, n)._nodes.assign(**{n: 1})), None, "n2" - ) - assert (g3._nodes.columns == ["s", "d", "n2"]).all() - - def test_pipe(self, mock_etl, mock_open): - df = pd.DataFrame({"s": [0, 1, 2], "d": [1, 2, 0]}) - g = graphistry.nodes(df, "s") - df2 = pd.DataFrame({"s": [2, 4, 6], "d": [1, 2, 0]}) - g2 = g.pipe((lambda g, s, d: g.edges(df2, s, d)), "s", "d") - assert g2._nodes is df - assert g2._edges is df2 - assert g2._source == "s" - assert g2._destination == "d" - df3 = pd.DataFrame({"s": [3, 6, 9], "d": [1, 2, 0]}) - g3 = g2.pipe((lambda g: g.edges(df3))) - assert g3._edges is df3 - - class TestPlotterConversions(NoAuthTestCase): @pytest.mark.xfail(raises=ModuleNotFoundError) def test_igraph2pandas(self): @@ -768,33 +627,6 @@ def test_addStyle_good(self): assert ds.metadata["page"] == page -class TestPlotterStylesJSON(NoAuthTestCase): - @classmethod - def setUpClass(cls): - graphistry.pygraphistry.PyGraphistry._is_authenticated = True - graphistry.pygraphistry.PyGraphistry.store_token_creds_in_memory(True) - graphistry.pygraphistry.PyGraphistry.relogin = lambda: True - graphistry.register(api=1) - - def test_styleApi_reject(self): - bg = {"color": "red"} - fg = {"blendMode": 1} - logo = {"url": "zzz"} - page = {"title": "zzz"} - g2 = graphistry.edges(pd.DataFrame({"s": [0], "d": [0]})).bind( - source="s", destination="d" - ) - g3 = g2.addStyle( - bg=copy.deepcopy(bg), - fg=copy.deepcopy(fg), - page=copy.deepcopy(page), - logo=copy.deepcopy(logo), - ) - - with pytest.raises(ValueError): - g3.plot(skip_upload=True) - - class TestPlotterEncodings(NoAuthTestCase): COMPLEX_EMPTY = {