diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 26d8c6dd8..76c7a7608 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|extensions/gubbins/gubbins-client/src/gubbins/client)/" 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..ca8fc4b59 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"] @@ -7,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 @@ -15,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 @@ -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: @@ -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: @@ -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 @@ -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(): @@ -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 diff --git a/diracx-cli/src/diracx/cli/config.py b/diracx-cli/src/diracx/cli/config.py index fbe3c6507..6b6177617 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: 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..c2cb12e18 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: 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") @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/diracx-cli/src/diracx/cli/internal/legacy.py b/diracx-cli/src/diracx/cli/internal/legacy.py index 60dd0fcd8..e66e4659f 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: Identity provider URL. + client_id: 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: Default group name for the VO. + idp: Identity provider settings for the VO. + user_subjects: Mapping from legacy usernames to + subject identifiers. + support: 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,27 @@ class VOConfig(BaseModel): class ConversionConfig(BaseModel): + """Top-level conversion settings for all virtual organizations. + + Attributes: + vos: 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 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 + 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 +122,11 @@ 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. + """ conv_config = ConversionConfig.model_validate(raw["DiracX"]["CsSync"]) raw.pop("DiracX", None) @@ -186,9 +224,18 @@ 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 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. - 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..5a0c81792 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: Condition string, e.g. ``"JobID eq 1000"`` or + ``"Embedding cos_sim [0.1, 0.2, 0.3]"``. + + Returns: + 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,22 @@ 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 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: jobs, content_range = await api.jobs.search( @@ -86,6 +121,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: 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 start: int | None = None end: int | None = None @@ -101,7 +149,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: + 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 +166,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: Parsed content-range metadata. + """ output_format = get_diracx_preferences().output_format match output_format: case OutputFormats.JSON: @@ -125,6 +188,16 @@ 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: Parsed content-range metadata used for the table caption. + """ if not data: print(f"No {content_range.unit} found") return @@ -155,6 +228,15 @@ 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 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]) 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/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..bd8551fdb 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" = [] +"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"]