From b42f4872a8e411c7d2b9a6de7230310513ae1046 Mon Sep 17 00:00:00 2001 From: Matt Shirley Date: Wed, 21 Jan 2026 15:41:22 -0600 Subject: [PATCH 1/5] add basic check CLI command --- servicex/app/check.py | 72 +++++++++++++++++++++++++++++++++++++++++++ servicex/app/main.py | 2 ++ 2 files changed, 74 insertions(+) create mode 100644 servicex/app/check.py diff --git a/servicex/app/check.py b/servicex/app/check.py new file mode 100644 index 00000000..74af9f1a --- /dev/null +++ b/servicex/app/check.py @@ -0,0 +1,72 @@ +# Copyright (c) 2022, IRIS-HEP +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import asyncio + +import typer +from rich import get_console + +from servicex.app.init import verify_token +from servicex.configuration import Configuration + +check_app = typer.Typer( + name="check", invoke_without_command=True, no_args_is_help=False +) + + +@check_app.callback() +def check( + _: typer.Context, +): + console = get_console() + config = Configuration.read() + + if not config.api_endpoints: + console.print("[yellow]No ServiceX endpoints configured.[/yellow]") + return + + console.print("\n[bold]Checking ServiceX endpoints...[/bold]\n") + + results = {} + + async def wrapped_verify_token(endpoint): + result = await verify_token(endpoint.endpoint, endpoint.token) + results[endpoint.endpoint] = result + if result: + console.print(f"[green]✓[/green] {endpoint.endpoint}") + else: + console.print(f"[red]✗[/red] {endpoint.endpoint}") + return result + + async def verify_all_endpoints(): + tasks = [wrapped_verify_token(endpoint) for endpoint in config.api_endpoints] + return await asyncio.gather(*tasks) + + asyncio.run(verify_all_endpoints()) + + console.print() diff --git a/servicex/app/main.py b/servicex/app/main.py index fc71ab13..a0766d88 100644 --- a/servicex/app/main.py +++ b/servicex/app/main.py @@ -32,6 +32,7 @@ from servicex import servicex_client from servicex._version import __version__ +from servicex.app.check import check_app from servicex.app.cli_options import ( backend_cli_option, config_file_option, @@ -50,6 +51,7 @@ app.add_typer(codegen_app) app.add_typer(datasets_app) app.add_typer(init_app) +app.add_typer(check_app) spec_file_arg = typer.Argument(..., help="Spec file to submit to serviceX") ignore_cache_opt = typer.Option( From d4783c654953a66fbe770f143e666e2eea2dfa02 Mon Sep 17 00:00:00 2001 From: Matt Shirley Date: Wed, 4 Feb 2026 13:41:25 -0600 Subject: [PATCH 2/5] apply PR feedback --- docs/connect_servicex.rst | 34 +++++++++++++++++++++++++++++++++- servicex/app/check.py | 9 +++++---- servicex/app/init.py | 8 +++++--- servicex/servicex_adapter.py | 26 ++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/docs/connect_servicex.rst b/docs/connect_servicex.rst index d04eda67..01ad1acc 100644 --- a/docs/connect_servicex.rst +++ b/docs/connect_servicex.rst @@ -48,6 +48,8 @@ downloaded to your computer. .. image:: img/download-servicex-yaml.jpg :alt: Download button +.. _client-installation: + ServiceX Client Installation ---------------------------- ServiceX client Python package is a python library for users to communicate @@ -76,7 +78,7 @@ or with ``conda`` conda install --channel conda-forge servicex -Testing +Connecting to an analysis facility ~~~~~~~ Navigate to a directory in which you want to begin a ServiceX project and execute: @@ -128,3 +130,33 @@ After completing the instructions, you can execute one of the ServiceX :doc:`exa └─────────────────────┴────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ Total files delivered: 3 + +Writing your own ServiceX Access File +~~~~~~~~~~~~~~~~~~~~ + +The client relies on a ``servicex.yaml`` file to obtain the URLs of different +servicex deployments, as well as tokens to authenticate with the +service. + +The client library will search for this file in the current working directory +and then start looking in parent directories and your home directory until a file +is found. + +The format of this file is as follows: + +.. code:: yaml + api_endpoints: + - endpoint: https://servicex.af.uchicago.edu + name: servicex-uc-af + token: + cache_path: /tmp/ServiceX_Client/cache-dir + shortened_downloaded_filename: true +``cache_path`` and ``shortened_downloaded_filename`` are optional fields and default to +reasonable values. + +The cache database and downloaded files will be stored in the directory +specified by ``cache_path``. + +The ``shortened_downloaded_filename`` property controls whether +downloaded files will have their names shortened for convenience. +Setting to false preserves the full filename from the dataset. diff --git a/servicex/app/check.py b/servicex/app/check.py index 74af9f1a..b1a92b62 100644 --- a/servicex/app/check.py +++ b/servicex/app/check.py @@ -31,8 +31,8 @@ import typer from rich import get_console -from servicex.app.init import verify_token from servicex.configuration import Configuration +from servicex.servicex_adapter import ServiceXAdapter check_app = typer.Typer( name="check", invoke_without_command=True, no_args_is_help=False @@ -54,8 +54,9 @@ def check( results = {} - async def wrapped_verify_token(endpoint): - result = await verify_token(endpoint.endpoint, endpoint.token) + async def verify_endpoint(endpoint): + adapter = ServiceXAdapter(url=endpoint.endpoint, refresh_token=endpoint.token) + result = await adapter.verify_authentication() results[endpoint.endpoint] = result if result: console.print(f"[green]✓[/green] {endpoint.endpoint}") @@ -64,7 +65,7 @@ async def wrapped_verify_token(endpoint): return result async def verify_all_endpoints(): - tasks = [wrapped_verify_token(endpoint) for endpoint in config.api_endpoints] + tasks = [verify_endpoint(endpoint) for endpoint in config.api_endpoints] return await asyncio.gather(*tasks) asyncio.run(verify_all_endpoints()) diff --git a/servicex/app/init.py b/servicex/app/init.py index 4b418af5..f2278d97 100644 --- a/servicex/app/init.py +++ b/servicex/app/init.py @@ -61,12 +61,14 @@ class InitConfig(TypedDict): async def verify_token(url: str, token: str) -> bool: - """Verify the token by pinging the ServiceX server.""" + """Verify the token by authenticating with the ServiceX server.""" console = get_console() try: adapter = ServiceXAdapter(url=url, refresh_token=token) - await adapter.get_servicex_info() - return True + result = await adapter.verify_authentication() + if not result: + console.print("[red]✗ Failed to authenticate with ServiceX server[/red]") + return result except Exception as e: console.print(f"[red]✗ Failed to authenticate with ServiceX server:[/red] {e}") return False diff --git a/servicex/servicex_adapter.py b/servicex/servicex_adapter.py index bb6ee4a1..ad0390be 100644 --- a/servicex/servicex_adapter.py +++ b/servicex/servicex_adapter.py @@ -208,6 +208,32 @@ async def get_code_generators_async(self) -> dict[str, str]: get_code_generators = make_sync(get_code_generators_async) + async def verify_authentication(self) -> bool: + """Verify connectivity and authentication with the ServiceX server. + + If a refresh_token is configured, verifies authentication using the + /servicex/datasets endpoint which requires authentication. + + If no token is configured (auth disabled), verifies connectivity using + the /servicex info endpoint directly without going through authorization. + + Returns True if successful, False otherwise. + Any exception (connection error, auth error, etc.) results in False. + """ + try: + if self.refresh_token: + await self.get_datasets() + else: + # Call info endpoint directly without authorization headers + # to avoid issues with BEARER_TOKEN_FILE containing expired tokens + async with AsyncClient() as client: + r = await client.get(url=f"{self.url}/servicex") + if r.status_code != 200: + return False + return True + except Exception: + return False + async def get_datasets( self, did_finder=None, show_deleted=False ) -> List[CachedDataset]: From 22e69aa9bca410e2e7261f9cc7045c329cc83908 Mon Sep 17 00:00:00 2001 From: Matt Shirley Date: Thu, 12 Feb 2026 18:55:03 -0600 Subject: [PATCH 3/5] add tests --- tests/app/test_check.py | 139 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/app/test_check.py diff --git a/tests/app/test_check.py b/tests/app/test_check.py new file mode 100644 index 00000000..e66462fe --- /dev/null +++ b/tests/app/test_check.py @@ -0,0 +1,139 @@ +# Copyright (c) 2022, IRIS-HEP +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from unittest.mock import AsyncMock, Mock, patch + +from servicex.configuration import Configuration, Endpoint + + +def _make_config(endpoints): + """Helper to create a Configuration with given endpoints.""" + return Configuration(api_endpoints=endpoints) + + +@patch("servicex.app.check.Configuration.read") +def test_check_no_endpoints(mock_read, script_runner): + """Test check command when no endpoints are configured.""" + mock_read.return_value = _make_config([]) + + result = script_runner.run(["servicex", "check"]) + + assert result.returncode == 0 + assert "No ServiceX endpoints configured" in result.stdout + + +@patch("servicex.app.check.ServiceXAdapter") +@patch("servicex.app.check.Configuration.read") +def test_check_single_endpoint_success(mock_read, mock_adapter_class, script_runner): + """Test check command with a single endpoint that authenticates successfully.""" + mock_read.return_value = _make_config( + [Endpoint(endpoint="https://servicex.example.com", name="test", token="tok")] + ) + + mock_adapter = Mock() + mock_adapter.verify_authentication = AsyncMock(return_value=True) + mock_adapter_class.return_value = mock_adapter + + result = script_runner.run(["servicex", "check"]) + + assert result.returncode == 0 + assert "https://servicex.example.com" in result.stdout + mock_adapter_class.assert_called_once_with( + url="https://servicex.example.com", refresh_token="tok" + ) + assert "Checking ServiceX endpoints" in result.stdout + mock_adapter.verify_authentication.assert_called_once() + + +@patch("servicex.app.check.ServiceXAdapter") +@patch("servicex.app.check.Configuration.read") +def test_check_single_endpoint_failure(mock_read, mock_adapter_class, script_runner): + """Test check command with a single endpoint that fails authentication.""" + mock_read.return_value = _make_config( + [Endpoint(endpoint="https://servicex.example.com", name="test", token="bad")] + ) + + mock_adapter = Mock() + mock_adapter.verify_authentication = AsyncMock(return_value=False) + mock_adapter_class.return_value = mock_adapter + + result = script_runner.run(["servicex", "check"]) + + assert result.returncode == 0 + assert "Checking ServiceX endpoints" in result.stdout + assert "https://servicex.example.com" in result.stdout + + +@patch("servicex.app.check.ServiceXAdapter") +@patch("servicex.app.check.Configuration.read") +def test_check_multiple_endpoints(mock_read, mock_adapter_class, script_runner): + """Test check command with multiple endpoints, some succeeding and some failing.""" + mock_read.return_value = _make_config( + [ + Endpoint( + endpoint="https://servicex1.example.com", name="ep1", token="tok1" + ), + Endpoint( + endpoint="https://servicex2.example.com", name="ep2", token="tok2" + ), + ] + ) + + adapter1 = Mock() + adapter1.verify_authentication = AsyncMock(return_value=True) + adapter2 = Mock() + adapter2.verify_authentication = AsyncMock(return_value=False) + mock_adapter_class.side_effect = [adapter1, adapter2] + + result = script_runner.run(["servicex", "check"]) + + assert result.returncode == 0 + assert "https://servicex1.example.com" in result.stdout + assert "https://servicex2.example.com" in result.stdout + assert mock_adapter_class.call_count == 2 + + +@patch("servicex.app.check.ServiceXAdapter") +@patch("servicex.app.check.Configuration.read") +def test_check_endpoint_without_token(mock_read, mock_adapter_class, script_runner): + """Test check command with an endpoint that has no token (auth disabled).""" + mock_read.return_value = _make_config( + [Endpoint(endpoint="http://localhost:8000", name="local", token="")] + ) + + mock_adapter = Mock() + mock_adapter.verify_authentication = AsyncMock(return_value=True) + mock_adapter_class.return_value = mock_adapter + + result = script_runner.run(["servicex", "check"]) + + assert result.returncode == 0 + assert "http://localhost:8000" in result.stdout + mock_adapter_class.assert_called_once_with( + url="http://localhost:8000", refresh_token="" + ) From c889bffed765015c0bb05573c9b49d9c0c505427 Mon Sep 17 00:00:00 2001 From: Matt Shirley Date: Mon, 16 Feb 2026 15:34:57 -0600 Subject: [PATCH 4/5] fix breaking test_init --- tests/app/test_init.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/app/test_init.py b/tests/app/test_init.py index 3416ac72..ce5ba7b0 100644 --- a/tests/app/test_init.py +++ b/tests/app/test_init.py @@ -43,19 +43,19 @@ def temp_dir(): @pytest.mark.asyncio async def test_verify_token_success(): - """Test verify_token with successful authentication.""" with patch("servicex.app.init.ServiceXAdapter") as mock_adapter_class: mock_adapter = Mock() - mock_adapter.get_servicex_info = AsyncMock(return_value={}) + mock_adapter.verify_authentication = AsyncMock(return_value=True) mock_adapter_class.return_value = mock_adapter result = await verify_token("https://servicex.af.uchicago.edu", "test-token") assert result is True mock_adapter_class.assert_called_once_with( - url="https://servicex.af.uchicago.edu", refresh_token="test-token" + url="https://servicex.af.uchicago.edu", + refresh_token="test-token", ) - mock_adapter.get_servicex_info.assert_called_once() + mock_adapter.verify_authentication.assert_awaited_once() @pytest.mark.asyncio From 8aed2611d4f5749274f0f54b9e525ea440029d21 Mon Sep 17 00:00:00 2001 From: Matt Shirley Date: Wed, 18 Feb 2026 14:48:02 -0600 Subject: [PATCH 5/5] improve code coverage --- tests/app/test_init.py | 20 ++++++++++++++ tests/test_servicex_adapter.py | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/tests/app/test_init.py b/tests/app/test_init.py index ce5ba7b0..75553359 100644 --- a/tests/app/test_init.py +++ b/tests/app/test_init.py @@ -58,6 +58,26 @@ async def test_verify_token_success(): mock_adapter.verify_authentication.assert_awaited_once() +@pytest.mark.asyncio +async def test_verify_token_auth_returns_false(): + """Test verify_token when verify_authentication returns False (no exception).""" + with patch("servicex.app.init.ServiceXAdapter") as mock_adapter_class: + mock_adapter = Mock() + mock_adapter.verify_authentication = AsyncMock(return_value=False) + mock_adapter_class.return_value = mock_adapter + + with patch("servicex.app.init.get_console") as mock_console: + mock_console_obj = Mock() + mock_console.return_value = mock_console_obj + + result = await verify_token("https://servicex.af.uchicago.edu", "bad-token") + + assert result is False + mock_console_obj.print.assert_called_once() + call_args = mock_console_obj.print.call_args[0][0] + assert "Failed to authenticate" in call_args + + @pytest.mark.asyncio async def test_verify_token_failure(): """Test verify_token with failed authentication.""" diff --git a/tests/test_servicex_adapter.py b/tests/test_servicex_adapter.py index 036ae018..0693371d 100644 --- a/tests/test_servicex_adapter.py +++ b/tests/test_servicex_adapter.py @@ -877,3 +877,52 @@ async def test_sample_title_limit(servicex): ) with pytest.raises(RuntimeError): await servicex.get_servicex_sample_title_limit() + + +@pytest.mark.asyncio +async def test_verify_authentication_with_token_success(servicex): + """verify_authentication with refresh_token: get_datasets succeeds → True.""" + servicex.refresh_token = "mytoken" + servicex.get_datasets = AsyncMock(return_value=[]) + result = await servicex.verify_authentication() + assert result is True + servicex.get_datasets.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_verify_authentication_with_token_exception(servicex): + """verify_authentication with refresh_token: get_datasets raises → False.""" + servicex.refresh_token = "mytoken" + servicex.get_datasets = AsyncMock(side_effect=Exception("Network error")) + result = await servicex.verify_authentication() + assert result is False + + +@pytest.mark.asyncio +@patch("servicex.servicex_adapter.AsyncClient.get") +async def test_verify_authentication_no_token_success(mock_get, servicex): + """verify_authentication without refresh_token: HTTP 200 → True.""" + mock_get.return_value = MagicMock() + mock_get.return_value.status_code = 200 + result = await servicex.verify_authentication() + assert result is True + mock_get.assert_called_once_with(url="https://servicex.org/servicex") + + +@pytest.mark.asyncio +@patch("servicex.servicex_adapter.AsyncClient.get") +async def test_verify_authentication_no_token_non200(mock_get, servicex): + """verify_authentication without refresh_token: HTTP non-200 → False.""" + mock_get.return_value = MagicMock() + mock_get.return_value.status_code = 503 + result = await servicex.verify_authentication() + assert result is False + + +@pytest.mark.asyncio +@patch("servicex.servicex_adapter.AsyncClient.get") +async def test_verify_authentication_no_token_exception(mock_get, servicex): + """verify_authentication without refresh_token: exception → False.""" + mock_get.side_effect = Exception("Connection refused") + result = await servicex.verify_authentication() + assert result is False