Skip to content
Open
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
34 changes: 33 additions & 1 deletion docs/connect_servicex.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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: <YOUR 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.
73 changes: 73 additions & 0 deletions servicex/app/check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# 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.configuration import Configuration
from servicex.servicex_adapter import ServiceXAdapter

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 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}")
else:
console.print(f"[red]✗[/red] {endpoint.endpoint}")
return result

async def verify_all_endpoints():
tasks = [verify_endpoint(endpoint) for endpoint in config.api_endpoints]
return await asyncio.gather(*tasks)

asyncio.run(verify_all_endpoints())

console.print()
8 changes: 5 additions & 3 deletions servicex/app/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions servicex/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
26 changes: 26 additions & 0 deletions servicex/servicex_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,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]:
Expand Down
139 changes: 139 additions & 0 deletions tests/app/test_check.py
Original file line number Diff line number Diff line change
@@ -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=""
)
28 changes: 24 additions & 4 deletions tests/app/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,39 @@ 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
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
Expand Down
Loading