Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,12 @@ repos:
entry: pixi run -e default python scripts/check_init_files_precommit_hook.py --mode=forbid
files: ^(diracx-|extensions/gubbins/gubbins-)[a-z]+/src/[a-z]+/[a-z]+/[a-z_]+/.+\.py$
exclude: (__init__\.py$|test|_generated|__main__|/patches/)

- repo: local
hooks:
- id: forbid-rest-docstrings
name: forbid reST docstring tags (use Google style, see #927)
language: pygrep
entry: '^\s*:(param|type|returns?|rtype|raises?|ivar|cvar|vartype)\b'
types: [python]
exclude: "^(diracx-client/src/diracx/client|extensions/gubbins/gubbins-client/src/gubbins/client)/"
2 changes: 2 additions & 0 deletions diracx-cli/src/diracx/cli/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Module entry point for running the diracx CLI as a Python module."""

from __future__ import annotations

from . import app
Expand Down
88 changes: 75 additions & 13 deletions diracx-cli/src/diracx/cli/auth.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Authentication-related CLI commands for DIRACX."""

from __future__ import annotations

__all__ = ["app"]
Expand All @@ -7,14 +9,18 @@
import os
from asyncio import sleep
from datetime import datetime, timedelta, timezone
from typing import Annotated, Optional
from typing import TYPE_CHECKING, Annotated, Optional

import typer

from diracx.client.aio import AsyncDiracClient

# See https://github.com/DIRACGrid/diracx/issues/578
from diracx.client.models import DeviceFlowErrorResponse # type: ignore [attr-defined]

if TYPE_CHECKING:
from diracx.client._generated.models import Metadata

from diracx.core.preferences import get_diracx_preferences
from diracx.core.utils import read_credentials, write_credentials

Expand All @@ -23,12 +29,37 @@
app = AsyncTyper()


async def installation_metadata():
async def installation_metadata() -> Metadata:
"""Fetch installation metadata from the server's well-known endpoint.

This helper uses an `AsyncDiracClient` to request the DIRAC installation
metadata. It is intended for use from synchronous callback contexts
(e.g. `vo_callback`) where `asyncio.run` may be used to synchronously
obtain the result.

Returns:
Installation metadata retrieved from the server.
"""
async with AsyncDiracClient() as api:
return await api.well_known.get_installation_metadata()


def vo_callback(vo: str | None) -> str:
"""Validate the provided VO against installation metadata.

This callback is used by `typer` to validate the `vo` argument passed by
the user. It synchronously fetches installation metadata and verifies
that the supplied VO exists. On failure it raises a `typer.BadParameter`.

Args:
vo: The VO name provided by the user.

Returns:
The validated VO string.

Raises:
typer.BadParameter: If no VO was provided or the VO is not known.
"""
metadata = asyncio.run(installation_metadata())
vos = list(metadata.virtual_organizations)
if not vo:
Expand Down Expand Up @@ -64,17 +95,28 @@ async def login(
),
] = None,
):
"""Login to the DIRAC system using the device flow.

- If only VO is provided: Uses the default group and its properties for the VO.

- If VO and group are provided: Uses the specified group and its properties for the VO.

- If VO and properties are provided: Uses the default group and combines its properties with the
provided properties.

- If VO, group, and properties are provided: Uses the specified group and combines its properties with the
provided properties.
"""Login to DIRAC using the OAuth2 device flow.

The command initiates a device authorization flow, instructs the user to
open a verification URL and enter a user code, polls the token endpoint
until the user completes authorization, and saves received credentials to
the local preferences file.

Scope resolution behavior:
- If only VO is provided: uses the VO's default group and its properties.
- If VO and group are provided: uses the specified group and its properties.
- If VO and properties are provided: uses the default group and merges its
properties with the provided properties.
- If VO, group, and properties are provided: uses the specified group and
merges its properties with the provided properties.

Args:
vo: Virtual Organization name (validated by `vo_callback`).
group: Group name within the VO.
property: Additional properties to request.

Raises:
RuntimeError: If the device flow fails or expires before completion.
"""
scopes = [f"vo:{vo}"]
if group:
Expand Down Expand Up @@ -117,6 +159,11 @@ async def login(

@app.async_command()
async def whoami():
"""Print authenticated user's identity information.

Queries the `userinfo` endpoint and prints a JSON representation of the
returned identity attributes. Intended for interactive inspection.
"""
async with AsyncDiracClient() as api:
user_info = await api.auth.userinfo()
# TODO: Add a RICH output format
Expand All @@ -125,6 +172,13 @@ async def whoami():

@app.async_command()
async def logout():
"""Logout by revoking refresh token and removing stored credentials.

If stored credentials are present, the command attempts to revoke the
refresh token at the server and then deletes the local credentials file.
Any errors during revocation are printed but do not prevent credential
file removal.
"""
async with AsyncDiracClient() as api:
credentials_path = get_diracx_preferences().credentials_path
if credentials_path.exists():
Expand All @@ -150,5 +204,13 @@ async def logout():

@app.callback()
def callback(output_format: Optional[str] = None):
"""Typer callback to set the output format for CLI commands.

When provided, this callback sets the `DIRACX_OUTPUT_FORMAT` environment
variable so subsequent commands can adapt their output formatting.

Args:
output_format: Output format identifier (e.g. "json").
"""
if output_format is not None:
os.environ["DIRACX_OUTPUT_FORMAT"] = output_format
24 changes: 23 additions & 1 deletion diracx-cli/src/diracx/cli/config.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
"""CLI commands for fetching and displaying DIRACX configuration."""

# Can't using PEP-604 with typer: https://github.com/tiangolo/typer/issues/348
# from __future__ import annotations
from __future__ import annotations

__all__ = ["dump"]

import json
from typing import Any

from rich import print_json

Expand All @@ -18,12 +21,31 @@

@app.async_command()
async def dump():
"""Fetch and display server configuration using the configured output format.

This CLI command queries the server's `serve_config` endpoint and prints
the returned configuration using the user's preferred output format (JSON
or rich). The command delegates presentation to the `display` helper.
"""
async with AsyncDiracClient() as api:
config = await api.config.serve_config()
display(config)


def display(data):
def display(data: Any) -> None:
"""Render `data` using the configured output format.

The helper reads the `output_format` preference and selects an
appropriate renderer. `data` is treated as arbitrary JSON-serializable
content. Supported formats are JSON (pretty-printed) and rich (uses
Rich's `print_json`). An unknown format raises `NotImplementedError`.

Args:
data: Arbitrary JSON-serializable data to display.

Raises:
NotImplementedError: If the configured output format is unsupported.
"""
output_format = get_diracx_preferences().output_format
match output_format:
case OutputFormats.JSON:
Expand Down
79 changes: 74 additions & 5 deletions diracx-cli/src/diracx/cli/internal/config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Internal CLI commands for creating and editing configuration repositories."""

from __future__ import annotations

from pathlib import Path
Expand Down Expand Up @@ -26,6 +28,18 @@


def get_repo_path(config_repo_str: str) -> Path:
"""Validate and extract a local repository path from a config source URL.

Args:
config_repo_str: Repository URL expected to use the
``git+file://`` scheme.

Returns:
Local filesystem path for the target repository.

Raises:
NotImplementedError: If the URL does not use ``git+file://``.
"""
config_repo = TypeAdapter(ConfigSourceUrl).validate_python(config_repo_str)
if config_repo.scheme != "git+file" or config_repo.path is None:
raise NotImplementedError("Only git+file:// URLs are supported")
Expand All @@ -36,12 +50,27 @@ def get_repo_path(config_repo_str: str) -> Path:


def get_config_from_repo_path(repo_path: Path) -> Config:
"""Load the DiracX configuration from a repository path.

Args:
repo_path: Local path to the configuration repository.

Returns:
Parsed configuration loaded from the repository backend.
"""
return ConfigSource.create_from_url(backend_url=repo_path).read()


@app.command()
def generate_cs(config_repo: str):
"""Generate a minimal DiracX configuration repository."""
"""Generate a minimal DiracX configuration repository.

Args:
config_repo: Repository URL for the new local config repository.

Raises:
typer.Exit: If the target directory already exists and is not empty.
"""
# TODO: The use of TypeAdapter should be moved in to typer itself

repo_path = get_repo_path(config_repo)
Expand Down Expand Up @@ -72,7 +101,18 @@ def add_vo(
idp_url: Annotated[str, typer.Option()],
idp_client_id: Annotated[str, typer.Option()],
):
"""Add a registry entry (vo) to an existing configuration repository."""
"""Add a virtual organization entry to an existing configuration repository.

Args:
config_repo: Repository URL for the local config repository.
vo: Virtual organization name to add.
default_group: Default group assigned to the VO.
idp_url: Identity provider URL for the VO.
idp_client_id: Client ID used with the identity provider.

Raises:
typer.Exit: If the VO already exists.
"""
# TODO: The use of TypeAdapter should be moved in to typer itself
repo_path = get_repo_path(config_repo)
config = get_config_from_repo_path(repo_path)
Expand Down Expand Up @@ -111,7 +151,17 @@ def add_group(
group: Annotated[str, typer.Option()],
properties: list[str] = ["NormalUser"],
):
"""Add a group to an existing vo in the configuration repository."""
"""Add a group to an existing virtual organization.

Args:
config_repo: Repository URL for the local config repository.
vo: Virtual organization that will receive the new group.
group: Group name to add.
properties: Initial properties assigned to the group.

Raises:
typer.Exit: If the VO does not exist or the group already exists.
"""
# TODO: The use of TypeAdapter should be moved in to typer itself
repo_path = get_repo_path(config_repo)
config = get_config_from_repo_path(repo_path)
Expand Down Expand Up @@ -143,7 +193,20 @@ def add_user(
sub: Annotated[str, typer.Option()],
preferred_username: Annotated[str, typer.Option()],
):
"""Add a user to an existing vo and group."""
"""Add a user to an existing virtual organization and one or more groups.

Args:
config_repo: Repository URL for the local config repository.
vo: Virtual organization that will receive the user.
groups: Groups to assign to the user. If not
provided, the VO default group is used.
sub: Subject identifier for the user.
preferred_username: Preferred username stored in the config.

Raises:
typer.Exit: If the VO or group does not exist, or if the user already
exists in the VO or one of the selected groups.
"""
# TODO: The use of TypeAdapter should be moved in to typer itself
repo_path = get_repo_path(config_repo)
config = get_config_from_repo_path(repo_path)
Expand Down Expand Up @@ -182,7 +245,13 @@ def add_user(


def update_config_and_commit(repo_path: Path, config: Config, message: str):
"""Update the yaml file in the repo and commit it."""
"""Write the current configuration to disk and create a git commit.

Args:
repo_path: Local path to the configuration repository.
config: Configuration object to serialize.
message: Commit message for the repository update.
"""
repo = git.Repo(repo_path)
yaml_path = repo_path / "default.yml"
typer.echo(f"Writing back configuration to {yaml_path}", err=True)
Expand Down
Loading
Loading