From 02bdf9288ed62c0158d4e4c007e4ac83b479c1dd Mon Sep 17 00:00:00 2001 From: martynia Date: Fri, 7 Aug 2026 11:01:44 +0200 Subject: [PATCH 1/5] docs: move D100-D300 to per-file-ignores, forbid rest-docstrings in pre-commit --- .pre-commit-config.yaml | 9 +++++ diracx-routers/tests/health/test_probes.py | 8 +++-- pyproject.toml | 41 +++++++++++++++++++--- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 26d8c6dd8..b9f7a9469 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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/ diff --git a/diracx-routers/tests/health/test_probes.py b/diracx-routers/tests/health/test_probes.py index a25bfdfd4..8e5fe49d8 100644 --- a/diracx-routers/tests/health/test_probes.py +++ b/diracx-routers/tests/health/test_probes.py @@ -73,10 +73,12 @@ async def test_startup(client_factory): async def _test_after_clear(config_source, probe_fcn): """Ensure that the probe fails after clearing the config source caches. - :param config_source: The config source to clear. - :param probe_fcn: The function to call to make the probe request. + Args: + config_source: The config source to clear. + probe_fcn: The function to call to make the probe request. - :return: The response from the probe. + Returns: + The response from the probe. """ orig_r = probe_fcn() assert orig_r.status_code == 200, orig_r.text diff --git a/pyproject.toml b/pyproject.toml index 21ebff84a..ea5908357 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,10 +54,6 @@ ignore = [ "B006", "S101", # bandit: use of assert https://docs.astral.sh/ruff/rules/assert/ # TODO: Maybe enable these - "D100", - "D101", - "D102", - "D103", "D104", "D105", "D107", @@ -73,6 +69,43 @@ required-imports = ["from __future__ import annotations"] # import-mode=importlib, packages with common names (e.g. tests/jobs) collide # in sys.modules when several diracx-* packages are collected in one run "diracx-*/tests/*" = ["S", "INP"] +"diracx-api/src/diracx/api/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-api/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-db/src/diracx/db/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-db/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-cli/src/diracx/cli/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-cli/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-core/src/diracx/core/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-core/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-logic/src/diracx/logic/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-logic/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-tasks/src/diracx/tasks/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-tasks/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-testing/src/diracx/testing/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-client/src/diracx/client/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-client/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-routers/src/diracx/routers/**/*py" = ["D100", "D101", "D102", "D103"] +"diracx-routers/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-api/src/gubbins/api/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-api/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-cli/src/gubbins/cli/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-cli/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-client/src/gubbins/client/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-client/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-core/src/gubbins/core/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-core/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-db/src/gubbins/db/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-db/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-logic/src/gubbins/logic/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-logic/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-routers/src/gubbins/routers/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-routers/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-tasks/src/gubbins/tasks/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-tasks/tests/**/*.py" = ["D100", "D101", "D102", "D103"] +"extensions/gubbins/gubbins-testing/src/gubbins/testing/**/*.py" = ["D100", "D101", "D102", "D103"] +"tests/*.py" = ["D100", "D101", "D102", "D103"] +"scripts/*.py" = ["D100", "D101", "D102", "D103"] + [tool.ruff.lint.extend-per-file-ignores] "diracx-routers/src/diracx/routers/access_policies.py" = ["I002"] From 77a2e2e14871bda487382af427bbbfd47b253de0 Mon Sep 17 00:00:00 2001 From: martynia Date: Fri, 7 Aug 2026 11:29:14 +0200 Subject: [PATCH 2/5] docs: add gubbins client to forbid rest-docstrings in pre-commit --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b9f7a9469..76c7a7608 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -143,4 +143,4 @@ repos: language: pygrep entry: '^\s*:(param|type|returns?|rtype|raises?|ivar|cvar|vartype)\b' types: [python] - exclude: ^diracx-client/src/diracx/client/ + exclude: "^(diracx-client/src/diracx/client|extensions/gubbins/gubbins-client/src/gubbins/client)/" From 8b893305cb45a14507d37af6bfed787dd5ad9dc0 Mon Sep 17 00:00:00 2001 From: martynia Date: Mon, 7 Sep 2026 12:44:38 +0200 Subject: [PATCH 3/5] docs: Add google style docstring to diracx-cli --- diracx-cli/src/diracx/cli/__main__.py | 2 + diracx-cli/src/diracx/cli/auth.py | 80 +++++++++++++--- diracx-cli/src/diracx/cli/config.py | 24 ++++- diracx-cli/src/diracx/cli/internal/config.py | 94 ++++++++++++++++++- diracx-cli/src/diracx/cli/internal/legacy.py | 65 ++++++++++++- diracx-cli/src/diracx/cli/jobs.py | 98 +++++++++++++++++++- diracx-cli/src/diracx/cli/utils.py | 32 +++++++ pyproject.toml | 2 +- 8 files changed, 373 insertions(+), 24 deletions(-) diff --git a/diracx-cli/src/diracx/cli/__main__.py b/diracx-cli/src/diracx/cli/__main__.py index 6c808d7f1..45afc594b 100644 --- a/diracx-cli/src/diracx/cli/__main__.py +++ b/diracx-cli/src/diracx/cli/__main__.py @@ -1,3 +1,5 @@ +"""Module entry point for running the diracx CLI as a Python module.""" + from __future__ import annotations from . import app diff --git a/diracx-cli/src/diracx/cli/auth.py b/diracx-cli/src/diracx/cli/auth.py index 74323d38e..d5d0eccf9 100644 --- a/diracx-cli/src/diracx/cli/auth.py +++ b/diracx-cli/src/diracx/cli/auth.py @@ -1,3 +1,5 @@ +"""Authentication-related CLI commands for DIRACX.""" + from __future__ import annotations __all__ = ["app"] @@ -24,11 +26,36 @@ async def installation_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: + Metadata: 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 (Optional[str]): The VO name provided by the user. + + Returns: + str: 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: @@ -64,17 +91,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 (Optional[str]): Virtual Organization name (validated by `vo_callback`). + group (Optional[str]): Group name within the VO. + property (Optional[list[str]]): Additional properties to request. + + Raises: + RuntimeError: If the device flow fails or expires before completion. """ scopes = [f"vo:{vo}"] if group: @@ -117,6 +155,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 @@ -125,6 +168,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(): @@ -150,5 +200,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 (Optional[str]): Output format identifier (e.g. "json"). + """ if output_format is not None: os.environ["DIRACX_OUTPUT_FORMAT"] = output_format diff --git a/diracx-cli/src/diracx/cli/config.py b/diracx-cli/src/diracx/cli/config.py index fbe3c6507..54d77708e 100644 --- a/diracx-cli/src/diracx/cli/config.py +++ b/diracx-cli/src/diracx/cli/config.py @@ -1,3 +1,5 @@ +"""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 @@ -5,6 +7,7 @@ __all__ = ["dump"] import json +from typing import Any from rich import print_json @@ -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 (Any): 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: diff --git a/diracx-cli/src/diracx/cli/internal/config.py b/diracx-cli/src/diracx/cli/internal/config.py index 908d25290..f80e803f1 100644 --- a/diracx-cli/src/diracx/cli/internal/config.py +++ b/diracx-cli/src/diracx/cli/internal/config.py @@ -1,3 +1,5 @@ +"""Internal CLI commands for creating and editing configuration repositories.""" + from __future__ import annotations from pathlib import Path @@ -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 (str): Repository URL expected to use the + ``git+file://`` scheme. + + Returns: + Path: 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") @@ -36,12 +50,30 @@ 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 (Path): Local path to the configuration repository. + + Returns: + Config: 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 (str): Repository URL for the new local config repository. + + Returns: + None + + 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) @@ -72,7 +104,21 @@ 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 (str): Repository URL for the local config repository. + vo (str): Virtual organization name to add. + default_group (Optional[str]): Default group assigned to the VO. + idp_url (str): Identity provider URL for the VO. + idp_client_id (str): Client ID used with the identity provider. + + Returns: + None + + 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) @@ -111,7 +157,20 @@ 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 (str): Repository URL for the local config repository. + vo (str): Virtual organization that will receive the new group. + group (str): Group name to add. + properties (list[str]): Initial properties assigned to the group. + + Returns: + None + + 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) @@ -143,7 +202,23 @@ 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 (str): Repository URL for the local config repository. + vo (str): Virtual organization that will receive the user. + groups (Optional[list[str]]): Groups to assign to the user. If not + provided, the VO default group is used. + sub (str): Subject identifier for the user. + preferred_username (str): Preferred username stored in the config. + + Returns: + None + + 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) @@ -182,7 +257,16 @@ 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 (Path): Local path to the configuration repository. + config (Config): Configuration object to serialize. + message (str): Commit message for the repository update. + + Returns: + None + """ repo = git.Repo(repo_path) yaml_path = repo_path / "default.yml" typer.echo(f"Writing back configuration to {yaml_path}", err=True) diff --git a/diracx-cli/src/diracx/cli/internal/legacy.py b/diracx-cli/src/diracx/cli/internal/legacy.py index 60dd0fcd8..596adbc69 100644 --- a/diracx-cli/src/diracx/cli/internal/legacy.py +++ b/diracx-cli/src/diracx/cli/internal/legacy.py @@ -1,3 +1,5 @@ +"""Internal CLI commands for migrating legacy DIRAC configuration data.""" + from __future__ import annotations import base64 @@ -34,11 +36,28 @@ class IdPConfig(BaseModel): + """Identity provider configuration used during legacy conversion. + + Attributes: + url (str): Identity provider URL. + client_id (str): OAuth2 client identifier. + """ + url: str = Field(alias="URL") client_id: str = Field(alias="ClientID") class VOConfig(BaseModel): + """Per-VO conversion settings extracted from the legacy CS. + + Attributes: + default_group (str): Default group name for the VO. + idp (IdPConfig): Identity provider settings for the VO. + user_subjects (dict[str, str]): Mapping from legacy usernames to + subject identifiers. + support (SupportInfo): Contact and support metadata for the VO. + """ + default_group: str = Field(alias="DefaultGroup") idp: IdPConfig = Field(alias="IdP") user_subjects: dict[str, str] = Field(alias="UserSubjects") @@ -46,12 +65,30 @@ class VOConfig(BaseModel): class ConversionConfig(BaseModel): + """Top-level conversion settings for all virtual organizations. + + Attributes: + vos (dict[str, VOConfig]): Conversion settings keyed by VO name. + """ + vos: dict[str, VOConfig] = Field(alias="VOs") @app.command() def cs_sync(old_file: Path, new_file: Path): - """Load the old CS and convert it to the new YAML format.""" + """Convert a legacy CS file into the new DiracX YAML configuration. + + Args: + old_file (Path): Path to the legacy configuration source file. + new_file (Path): Path where the converted YAML should be written. + + Returns: + None + + Raises: + RuntimeError: If CS conversion is disabled or the legacy + configuration contains incompatible settings. + """ if not os.environ.get("DIRAC_COMPAT_ENABLE_CS_CONVERSION"): raise RuntimeError( "DIRAC_COMPAT_ENABLE_CS_CONVERSION must be set for the conversion to be possible" @@ -88,7 +125,14 @@ def cs_sync(old_file: Path, new_file: Path): def _apply_fixes(raw): - """Modify raw in place to make any layout changes between the old and new structure.""" + """Apply in-place transformations from the legacy CS layout to DiracX. + + Args: + raw: Mutable configuration dictionary loaded from the legacy CS. + + Returns: + None + """ conv_config = ConversionConfig.model_validate(raw["DiracX"]["CsSync"]) raw.pop("DiracX", None) @@ -186,9 +230,22 @@ def generate_helm_values( Path | None, Option(help="Path to the cfg containing the secret") ] = None, ): - """Generate an initial values.yaml to run a DiracX installation. + """Generate a starter Helm values file from legacy configuration inputs. + + The generated file is intentionally incomplete and requires manual + editing before use. + + Args: + public_cfg (Path): Path to the public CS configuration file. + output_file (Path): Destination path for the generated YAML. + secret_cfg (Path | None): Optional path to a second CS file + containing secrets. + + Returns: + None - The file generated is not complete, and needs manual editing. + Raises: + typer.Exit: If required legacy exchange configuration is missing. """ helm_values = { "developer": {"enabled": False}, diff --git a/diracx-cli/src/diracx/cli/jobs.py b/diracx-cli/src/diracx/cli/jobs.py index 862f7b908..7d601b607 100644 --- a/diracx-cli/src/diracx/cli/jobs.py +++ b/diracx-cli/src/diracx/cli/jobs.py @@ -1,3 +1,5 @@ +"""CLI commands for searching and displaying DIRACX jobs.""" + # Can't using PEP-604 with typer: https://github.com/tiangolo/typer/issues/348 # from __future__ import annotations from __future__ import annotations @@ -32,6 +34,23 @@ def parse_condition(value: str) -> SearchSpec: + """Parse a single search condition into a `SearchSpec`. + + The expected string format is ``" "``. For + scalar operators the ``value`` is returned as ``value``; for vector + operators the ``value`` is parsed as JSON and returned under ``values``. + + Args: + value (str): Condition string, e.g. ``"JobID eq 1000"`` or + ``"Embedding cos_sim [0.1, 0.2, 0.3]"``. + + Returns: + SearchSpec: Dictionary describing the parsed condition in the + shape expected by the API client. + + Raises: + ValueError: If the operator is unknown or the input cannot be parsed. + """ parameter, operator, rest = value.split(" ", 2) if operator in set(ScalarSearchOperator): return { @@ -69,6 +88,26 @@ async def search( page: int = 1, per_page: int = 10, ): + """Search for jobs and display results. + + The command constructs a list of ``SearchSpec`` objects from the + provided ``condition`` arguments and performs a paginated job search + through the API client. Results are displayed using the user's + configured output format. + + Args: + parameter (list[str]): List of fields to return for each job. Use + the special flag ``--all`` to return all available parameters. + condition (list[str]): Search condition strings (see + ``parse_condition``) that will be combined with AND semantics. + all (bool): If true, ignore ``parameter`` and request all fields. + page (int): Page number for pagination (1-based). + per_page (int): Number of items per page. + + Returns: + None: Results are printed to stdout using the configured display + format (JSON or rich table). + """ search_specs = [parse_condition(cond) for cond in condition] async with AsyncDiracClient() as api: jobs, content_range = await api.jobs.search( @@ -86,6 +125,19 @@ async def search( class ContentRange: + """Parse and represent a `Content-Range` response header. + + The class understands headers of the form ``"unit start-end/total"`` + (e.g. ``"jobs 0-9/100"``) and exposes parsed attributes suitable for + building human-readable captions for CLI output. + + Attributes: + unit (str | None): The unit of the range (e.g., "bytes", "items", "jobs"). + start (int | None): The starting index of the requested range. + end (int | None): The ending index of the requested range. + total (int | None): The total number of items available. + """ + unit: str | None = None start: int | None = None end: int | None = None @@ -101,7 +153,12 @@ def __init__(self, header: str): self.unit = match.group() @property - def caption(self): + def caption(self) -> str: + """Build a human-readable caption from the parsed content range. + + Returns: + str: A summary string describing which items are being shown. + """ if self.start is None and self.end is None: range_str = "all" else: @@ -113,7 +170,17 @@ def caption(self): return f"Showing {range_str} {self.unit}" -def display(data, content_range: ContentRange): +def display(data, content_range: ContentRange) -> None: + """Render a rich table representation of the job results. + + Chooses between a two-column parameter/value layout (for wide or + numerous columns) and a multi-column table. The table caption displays + the parsed content-range information. + + Args: + data: List of job records (each a mapping of parameter -> value). + content_range (ContentRange): Parsed content-range metadata. + """ output_format = get_diracx_preferences().output_format match output_format: case OutputFormats.JSON: @@ -125,6 +192,20 @@ def display(data, content_range: ContentRange): def display_rich(data, content_range: ContentRange) -> None: + """Render job results using Rich table output. + + Chooses a two-column parameter/value layout when the set of columns is + too wide for the terminal, otherwise renders a multi-column table with + one row per job. + + Args: + data: Job records to display. + content_range (ContentRange): Parsed content-range metadata used for + the table caption. + + Returns: + None + """ if not data: print(f"No {content_range.unit} found") return @@ -155,6 +236,19 @@ def display_rich(data, content_range: ContentRange) -> None: @app.async_command() async def submit(jdl: list[FileText]): + """Submit one or more JDL job descriptions and print the inserted IDs. + + The command accepts one or more files containing JDL job descriptions, + submits them to the server via the client API, and prints a summary of + the inserted job IDs. + + Args: + jdl (list[FileText]): List of file-like objects pointing to JDL + descriptions. + + Returns: + None + """ async with AsyncDiracClient() as api: jobs = await api.jobs.submit_jdl_jobs([x.read() for x in jdl]) print( diff --git a/diracx-cli/src/diracx/cli/utils.py b/diracx-cli/src/diracx/cli/utils.py index d8944b5af..1e52a0e2b 100644 --- a/diracx-cli/src/diracx/cli/utils.py +++ b/diracx-cli/src/diracx/cli/utils.py @@ -1,3 +1,11 @@ +"""Utility helpers for the CLI. + +This module provides a small helper class `AsyncTyper` that adapts +asynchronous command functions to Typer's synchronous command model by +running the coroutine with ``asyncio.run`` and handling common network and +authentication errors with user-friendly messages. +""" + from __future__ import annotations __all__ = ["AsyncTyper"] @@ -12,7 +20,31 @@ class AsyncTyper(typer.Typer): + """Typer subclass that supports async command registration. + + Register an async function as a Typer command using the ``async_command`` + decorator. The decorator wraps the coroutine so it can be run + synchronously by Typer (via ``asyncio.run``) and prints friendly error + messages for common exceptions like authentication or connection errors. + """ + def async_command(self, *args, **kwargs): + """Register an async function as a Typer command. + + The returned decorator wraps the coroutine with ``asyncio.run`` so the + command can be executed from Typer's synchronous runtime. Common + authentication and connection errors are caught and shown with + user-friendly messages. + + Args: + *args: Positional arguments forwarded to ``Typer.command``. + **kwargs: Keyword arguments forwarded to ``Typer.command``. + + Returns: + A decorator that registers the async function and returns the + original coroutine function. + """ + def decorator(async_func): @wraps(async_func) def sync_func(*_args, **_kwargs): diff --git a/pyproject.toml b/pyproject.toml index ea5908357..bd8551fdb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ required-imports = ["from __future__ import annotations"] "diracx-api/tests/**/*.py" = ["D100", "D101", "D102", "D103"] "diracx-db/src/diracx/db/**/*.py" = ["D100", "D101", "D102", "D103"] "diracx-db/tests/**/*.py" = ["D100", "D101", "D102", "D103"] -"diracx-cli/src/diracx/cli/**/*.py" = ["D100", "D101", "D102", "D103"] +"diracx-cli/src/diracx/cli/**/*.py" = [] "diracx-cli/tests/**/*.py" = ["D100", "D101", "D102", "D103"] "diracx-core/src/diracx/core/**/*.py" = ["D100", "D101", "D102", "D103"] "diracx-core/tests/**/*.py" = ["D100", "D101", "D102", "D103"] From 7d38415ea0e65f1e3fd2b97ad7705465cdb504b9 Mon Sep 17 00:00:00 2001 From: martynia Date: Tue, 8 Sep 2026 17:40:37 +0200 Subject: [PATCH 4/5] docs: Apply suggestions from code review Adress comments from a review Co-authored-by: DevLink42 <42943261+devlink42@users.noreply.github.com> --- diracx-cli/src/diracx/cli/auth.py | 14 ++--- diracx-cli/src/diracx/cli/config.py | 2 +- diracx-cli/src/diracx/cli/internal/config.py | 59 ++++++++------------ diracx-cli/src/diracx/cli/internal/legacy.py | 34 ++++------- diracx-cli/src/diracx/cli/jobs.py | 48 ++++++---------- 5 files changed, 60 insertions(+), 97 deletions(-) diff --git a/diracx-cli/src/diracx/cli/auth.py b/diracx-cli/src/diracx/cli/auth.py index d5d0eccf9..e322b669a 100644 --- a/diracx-cli/src/diracx/cli/auth.py +++ b/diracx-cli/src/diracx/cli/auth.py @@ -34,7 +34,7 @@ async def installation_metadata(): obtain the result. Returns: - Metadata: Installation metadata retrieved from the server. + Installation metadata retrieved from the server. """ async with AsyncDiracClient() as api: return await api.well_known.get_installation_metadata() @@ -48,10 +48,10 @@ def vo_callback(vo: str | None) -> str: that the supplied VO exists. On failure it raises a `typer.BadParameter`. Args: - vo (Optional[str]): The VO name provided by the user. + vo: The VO name provided by the user. Returns: - str: The validated VO string. + The validated VO string. Raises: typer.BadParameter: If no VO was provided or the VO is not known. @@ -107,9 +107,9 @@ async def login( merges its properties with the provided properties. Args: - vo (Optional[str]): Virtual Organization name (validated by `vo_callback`). - group (Optional[str]): Group name within the VO. - property (Optional[list[str]]): Additional properties to request. + 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. @@ -206,7 +206,7 @@ def callback(output_format: Optional[str] = None): variable so subsequent commands can adapt their output formatting. Args: - output_format (Optional[str]): Output format identifier (e.g. "json"). + output_format: Output format identifier (e.g. "json"). """ if output_format is not None: os.environ["DIRACX_OUTPUT_FORMAT"] = output_format diff --git a/diracx-cli/src/diracx/cli/config.py b/diracx-cli/src/diracx/cli/config.py index 54d77708e..6b6177617 100644 --- a/diracx-cli/src/diracx/cli/config.py +++ b/diracx-cli/src/diracx/cli/config.py @@ -41,7 +41,7 @@ def display(data: Any) -> None: Rich's `print_json`). An unknown format raises `NotImplementedError`. Args: - data (Any): Arbitrary JSON-serializable data to display. + data: Arbitrary JSON-serializable data to display. Raises: NotImplementedError: If the configured output format is unsupported. diff --git a/diracx-cli/src/diracx/cli/internal/config.py b/diracx-cli/src/diracx/cli/internal/config.py index f80e803f1..c2cb12e18 100644 --- a/diracx-cli/src/diracx/cli/internal/config.py +++ b/diracx-cli/src/diracx/cli/internal/config.py @@ -31,11 +31,11 @@ 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 (str): Repository URL expected to use the + config_repo_str: Repository URL expected to use the ``git+file://`` scheme. Returns: - Path: Local filesystem path for the target repository. + Local filesystem path for the target repository. Raises: NotImplementedError: If the URL does not use ``git+file://``. @@ -53,10 +53,10 @@ def get_config_from_repo_path(repo_path: Path) -> Config: """Load the DiracX configuration from a repository path. Args: - repo_path (Path): Local path to the configuration repository. + repo_path: Local path to the configuration repository. Returns: - Config: Parsed configuration loaded from the repository backend. + Parsed configuration loaded from the repository backend. """ return ConfigSource.create_from_url(backend_url=repo_path).read() @@ -66,10 +66,7 @@ def generate_cs(config_repo: str): """Generate a minimal DiracX configuration repository. Args: - config_repo (str): Repository URL for the new local config repository. - - Returns: - None + config_repo: Repository URL for the new local config repository. Raises: typer.Exit: If the target directory already exists and is not empty. @@ -107,14 +104,11 @@ def add_vo( """Add a virtual organization entry to an existing configuration repository. Args: - config_repo (str): Repository URL for the local config repository. - vo (str): Virtual organization name to add. - default_group (Optional[str]): Default group assigned to the VO. - idp_url (str): Identity provider URL for the VO. - idp_client_id (str): Client ID used with the identity provider. - - Returns: - None + 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. @@ -160,13 +154,10 @@ def add_group( """Add a group to an existing virtual organization. Args: - config_repo (str): Repository URL for the local config repository. - vo (str): Virtual organization that will receive the new group. - group (str): Group name to add. - properties (list[str]): Initial properties assigned to the group. - - Returns: - None + 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. @@ -205,15 +196,12 @@ def add_user( """Add a user to an existing virtual organization and one or more groups. Args: - config_repo (str): Repository URL for the local config repository. - vo (str): Virtual organization that will receive the user. - groups (Optional[list[str]]): Groups to assign to the user. If not + 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 (str): Subject identifier for the user. - preferred_username (str): Preferred username stored in the config. - - Returns: - None + 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 @@ -260,12 +248,9 @@ def update_config_and_commit(repo_path: Path, config: Config, message: str): """Write the current configuration to disk and create a git commit. Args: - repo_path (Path): Local path to the configuration repository. - config (Config): Configuration object to serialize. - message (str): Commit message for the repository update. - - Returns: - None + 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" diff --git a/diracx-cli/src/diracx/cli/internal/legacy.py b/diracx-cli/src/diracx/cli/internal/legacy.py index 596adbc69..e66e4659f 100644 --- a/diracx-cli/src/diracx/cli/internal/legacy.py +++ b/diracx-cli/src/diracx/cli/internal/legacy.py @@ -39,8 +39,8 @@ class IdPConfig(BaseModel): """Identity provider configuration used during legacy conversion. Attributes: - url (str): Identity provider URL. - client_id (str): OAuth2 client identifier. + url: Identity provider URL. + client_id: OAuth2 client identifier. """ url: str = Field(alias="URL") @@ -51,11 +51,11 @@ class VOConfig(BaseModel): """Per-VO conversion settings extracted from the legacy CS. Attributes: - default_group (str): Default group name for the VO. - idp (IdPConfig): Identity provider settings for the VO. - user_subjects (dict[str, str]): Mapping from legacy usernames to + default_group: Default group name for the VO. + idp: Identity provider settings for the VO. + user_subjects: Mapping from legacy usernames to subject identifiers. - support (SupportInfo): Contact and support metadata for the VO. + support: Contact and support metadata for the VO. """ default_group: str = Field(alias="DefaultGroup") @@ -68,7 +68,7 @@ class ConversionConfig(BaseModel): """Top-level conversion settings for all virtual organizations. Attributes: - vos (dict[str, VOConfig]): Conversion settings keyed by VO name. + vos: Conversion settings keyed by VO name. """ vos: dict[str, VOConfig] = Field(alias="VOs") @@ -79,11 +79,8 @@ def cs_sync(old_file: Path, new_file: Path): """Convert a legacy CS file into the new DiracX YAML configuration. Args: - old_file (Path): Path to the legacy configuration source file. - new_file (Path): Path where the converted YAML should be written. - - Returns: - None + old_file: Path to the legacy configuration source file. + new_file: Path where the converted YAML should be written. Raises: RuntimeError: If CS conversion is disabled or the legacy @@ -129,9 +126,6 @@ def _apply_fixes(raw): Args: raw: Mutable configuration dictionary loaded from the legacy CS. - - Returns: - None """ conv_config = ConversionConfig.model_validate(raw["DiracX"]["CsSync"]) @@ -236,13 +230,9 @@ def generate_helm_values( editing before use. Args: - public_cfg (Path): Path to the public CS configuration file. - output_file (Path): Destination path for the generated YAML. - secret_cfg (Path | None): Optional path to a second CS file - containing secrets. - - Returns: - None + public_cfg: Path to the public CS configuration file. + output_file: Destination path for the generated YAML. + secret_cfg: Optional path to a second CS file containing secrets. Raises: typer.Exit: If required legacy exchange configuration is missing. diff --git a/diracx-cli/src/diracx/cli/jobs.py b/diracx-cli/src/diracx/cli/jobs.py index 7d601b607..5a0c81792 100644 --- a/diracx-cli/src/diracx/cli/jobs.py +++ b/diracx-cli/src/diracx/cli/jobs.py @@ -41,12 +41,12 @@ def parse_condition(value: str) -> SearchSpec: operators the ``value`` is parsed as JSON and returned under ``values``. Args: - value (str): Condition string, e.g. ``"JobID eq 1000"`` or + value: Condition string, e.g. ``"JobID eq 1000"`` or ``"Embedding cos_sim [0.1, 0.2, 0.3]"``. Returns: - SearchSpec: Dictionary describing the parsed condition in the - shape expected by the API client. + Dictionary describing the parsed condition in the shape expected by + the API client. Raises: ValueError: If the operator is unknown or the input cannot be parsed. @@ -96,17 +96,13 @@ async def search( configured output format. Args: - parameter (list[str]): List of fields to return for each job. Use - the special flag ``--all`` to return all available parameters. - condition (list[str]): Search condition strings (see - ``parse_condition``) that will be combined with AND semantics. - all (bool): If true, ignore ``parameter`` and request all fields. - page (int): Page number for pagination (1-based). - per_page (int): Number of items per page. - - Returns: - None: Results are printed to stdout using the configured display - format (JSON or rich table). + parameter: List of fields to return for each job. Use the special flag + ``--all`` to return all available parameters. + condition: Search condition strings (see ``parse_condition``) that will + be combined with AND semantics. + all: If true, ignore ``parameter`` and request all fields. + page: Page number for pagination (1-based). + per_page: Number of items per page. """ search_specs = [parse_condition(cond) for cond in condition] async with AsyncDiracClient() as api: @@ -132,10 +128,10 @@ class ContentRange: building human-readable captions for CLI output. Attributes: - unit (str | None): The unit of the range (e.g., "bytes", "items", "jobs"). - start (int | None): The starting index of the requested range. - end (int | None): The ending index of the requested range. - total (int | None): The total number of items available. + unit: The unit of the range (e.g., "bytes", "items", "jobs"). + start: The starting index of the requested range. + end: The ending index of the requested range. + total: The total number of items available. """ unit: str | None = None @@ -157,7 +153,7 @@ def caption(self) -> str: """Build a human-readable caption from the parsed content range. Returns: - str: A summary string describing which items are being shown. + A summary string describing which items are being shown. """ if self.start is None and self.end is None: range_str = "all" @@ -179,7 +175,7 @@ def display(data, content_range: ContentRange) -> None: Args: data: List of job records (each a mapping of parameter -> value). - content_range (ContentRange): Parsed content-range metadata. + content_range: Parsed content-range metadata. """ output_format = get_diracx_preferences().output_format match output_format: @@ -200,11 +196,7 @@ def display_rich(data, content_range: ContentRange) -> None: Args: data: Job records to display. - content_range (ContentRange): Parsed content-range metadata used for - the table caption. - - Returns: - None + content_range: Parsed content-range metadata used for the table caption. """ if not data: print(f"No {content_range.unit} found") @@ -243,11 +235,7 @@ async def submit(jdl: list[FileText]): the inserted job IDs. Args: - jdl (list[FileText]): List of file-like objects pointing to JDL - descriptions. - - Returns: - None + jdl: List of file-like objects pointing to JDL descriptions. """ async with AsyncDiracClient() as api: jobs = await api.jobs.submit_jdl_jobs([x.read() for x in jdl]) From 6e5c051dc3b66d1a23ff7c6beaf03fa72b1a05b7 Mon Sep 17 00:00:00 2001 From: martynia Date: Wed, 9 Sep 2026 12:46:38 +0200 Subject: [PATCH 5/5] docs: Address comments - add -> Metadata --- diracx-cli/src/diracx/cli/auth.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/diracx-cli/src/diracx/cli/auth.py b/diracx-cli/src/diracx/cli/auth.py index e322b669a..ca8fc4b59 100644 --- a/diracx-cli/src/diracx/cli/auth.py +++ b/diracx-cli/src/diracx/cli/auth.py @@ -9,7 +9,7 @@ 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 @@ -17,6 +17,10 @@ # 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 @@ -25,7 +29,7 @@ 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