arvados.commands.arvcli

Main executable for Arvados CLI SDK, the arv command.

This script implements the arv command’s argument parser. The arv command is meant to be invoked in the following manner:

$ arv [–flags] subcommand|resource […options]

where --flags are common CLI options for the various subcommands.

The ArvCLIArgumentParser class, specializing the standard Python argparse.ArgumentParser, provides the support for this CLI usage.

   1# Copyright (C) The Arvados Authors. All rights reserved.
   2#
   3# SPDX-License-Identifier: Apache-2.0
   4
   5"""Main executable for Arvados CLI SDK, the `arv` command.
   6
   7This script implements the `arv` command's argument parser. The `arv` command
   8is meant to be invoked in the following manner:
   9
  10$ arv [--flags] subcommand|resource [...options]
  11
  12where `--flags` are common CLI options for the various subcommands.
  13
  14The `ArvCLIArgumentParser` class, specializing the standard Python
  15`argparse.ArgumentParser`, provides the support for this CLI usage.
  16"""
  17
  18
  19import abc
  20import argparse
  21from collections.abc import Container, Mapping
  22from contextlib import AbstractContextManager
  23from dataclasses import dataclass
  24import functools
  25import importlib
  26import json
  27import os
  28import re
  29import shlex
  30import shutil
  31import subprocess
  32import sys
  33from tempfile import NamedTemporaryFile
  34from typing import Any, NoReturn, TextIO
  35
  36from googleapiclient import discovery
  37from ruamel.yaml import YAML, YAMLError
  38
  39import arvados
  40import arvados.commands._util as cmd_util
  41
  42
  43yaml = YAML(typ="safe", pure=True)
  44yaml.default_flow_style = False
  45
  46
  47class _ArgTypes:
  48    """Private namespace class for JSON-related CLI argument types."""
  49
  50    @staticmethod
  51    def group_uuid(text: str) -> str:
  52        """Validate an Arvados group UUID as the value of a CLI argument (an
  53        Arvados project being a type of group).
  54        """
  55        # In theory this is a special case of "UUIDInfo" but we mostly need it
  56        # for the nicer error message.
  57        if arvados.util.group_uuid_pattern.fullmatch(text):
  58            return text
  59        raise argparse.ArgumentTypeError(
  60            f"Invalid UUID for Arvados project or group: {text}"
  61        )
  62
  63    @dataclass(frozen=True)
  64    class UUIDInfo:
  65        """'Interpreted' Arvados UUID object with resource type info."""
  66        uuid: str
  67        resource_type: str  # value in CamelCase
  68        rtype_lower: str  # value in snake_case
  69
  70        @classmethod
  71        def parse(
  72            cls, type_map: Mapping[str, str], text: str
  73        ) -> "UUIDInfo":  # self-typing support comes in Python 3.11.
  74            """Parse the UUID argument `text`. If accepted, returns an
  75            `UUIDInfo` instance whose `uuid` attribute is the input UUID
  76            unchanged and the `resource_type` attribute is the type of Arvados
  77            object (in CamelCase), as determined by the input parameter
  78            `type_map`, and `rtype_lower` is the alternative form of
  79            resource type in snake_case.
  80            """
  81            if not arvados.util.uuid_pattern.fullmatch(text):
  82                raise argparse.ArgumentTypeError(
  83                    f"Invalid Arvados object UUID: {text}"
  84                )
  85            type_code = text.split("-")[1]
  86            if type_code not in type_map:
  87                available_types = ", ".join(sorted(
  88                    f"{k} ({v})" for k, v in type_map.items()
  89                ))
  90                raise argparse.ArgumentTypeError(
  91                    f"Invalid object type code {type_code!r} in Arvados"
  92                    f" object UUID {text}: valid type codes are"
  93                    f" {available_types}"
  94                )
  95            type_key = type_map[type_code]
  96            return cls(text, type_key, _ArgUtil.camel_case_to_snake(type_key))
  97
  98    @staticmethod
  99    def _validate_type(obj_type, obj):
 100        if isinstance(obj, obj_type):
 101            return obj
 102        # No details to raise; caller handles error messaging with pretty_name.
 103        raise ValueError()
 104
 105    json_array = cmd_util.JSONStringArgument(
 106        validator=functools.partial(_validate_type, list),
 107        pretty_name="JSON array"
 108    )
 109
 110    json_object = cmd_util.JSONStringArgument(
 111        validator=functools.partial(_validate_type, dict),
 112        pretty_name="JSON object"
 113    )
 114
 115    json_filter = cmd_util.JSONArgument(
 116        validator=cmd_util.validate_filters,
 117        pretty_name="Arvados API filter"
 118    )
 119
 120    json_body = cmd_util.JSONArgument(
 121        validator=json_object.post_validator,
 122        pretty_name="JSON request body object"
 123    )
 124
 125
 126class _ArgUtil:
 127    """Private namespace class for helpful functions (static methods) that
 128    processes the discovery document for the purpose of CLI parser generation.
 129    """
 130    @staticmethod
 131    def singularize_resource(plural: str) -> str:
 132        """Returns the singular form of a resource term in the original
 133        plural.
 134        """
 135        match plural:
 136            case "vocabularies":
 137                return "vocabulary"
 138            case "sys":
 139                return "sys"
 140            case _:
 141                return plural.removesuffix("s")
 142
 143    @staticmethod
 144    def parameter_key_to_argument_name(parameter_key: str) -> str:
 145        """Convert a parameter key in the discovery document to CLI parameter
 146        form, for example, `--foo-bar`.
 147
 148        Arguments:
 149
 150        * parameter_key: str --- Parameter key in the form as they appear in
 151          the discovery document, typically like `foo_bar`.
 152        """
 153        return "--" + parameter_key.replace("_", "-")
 154
 155    @staticmethod
 156    def camel_case_to_snake(text: str) -> str:
 157        """Simple converter of CamelCase text to so-called 'snake_case' (lower
 158        case with underscore). Works if there's no consecutive upper-case
 159        letters such as "API".
 160        """
 161        return text[:1].lower() + "".join(
 162            f"_{c.lower()}" if c.isupper() else c for c in text[1:]
 163        )
 164
 165    @staticmethod
 166    def get_method_options(
 167        method_schema: Mapping[str, Any],
 168        ignored_parameters: Container[str] = ()
 169    ):
 170        """Generate command-line options, in the form of "-f/--foo", from the
 171        parameters as defined by the API method schema in the discovery
 172        document.
 173
 174        For each key "foo_bar" in the "parameters" field of the method schema,
 175        command-line options are created according to its definition as
 176        follows.
 177
 178        If the parameter type is "boolean", a pair of options "--no-foo-bar"
 179        and "--foo-bar" are created, with opposite meaning.
 180
 181        If the parameter type is "integer", the CLI input will be interpreted
 182        as a Python int.
 183
 184        All other parameter types are parsed as Python str.
 185
 186        The short form of each option will also be created, by taking the first
 187        letter of the long form, except when that letter is already used, in
 188        which case the second letter will be used, and so on. For example,
 189        "--foo-bar" will have short form "-f", unless "-f" is already used for
 190        another option, in which case "-o" will be used, etc.
 191
 192        The "negative" form of boolean options ("--no-foo-bar") will not have
 193        separate short forms of their own.
 194
 195        This  generator yields tuples in the form of `(names, kwargs)`, where
 196        `names` is a one- or two-element tuple and `kwargs` is a dict, suitable
 197        to be passed as
 198        `argparse.ArgumentParser.add_argument(*names, **kwargs)`.
 199
 200        Arguments:
 201
 202        * method_schema: Mapping[str, Any] --- Dict object from the parsed
 203          discover document that defines a method.
 204        * ignored_parameters: Container[str] --- If provided, the parameters
 205          that are in `ignored_parameters` will not be processed.
 206        """
 207        parameters_schema = method_schema.get("parameters", {}).copy()
 208        # If the method comes with the "request" field, add another parameter
 209        # based on the sole key in the "properties" dict of that field
 210        request_schema = method_schema.get("request")
 211        if request_schema is not None and request_schema.get("properties"):
 212            for parameter_key in request_schema["properties"].keys():
 213                parameters_schema[parameter_key] = {
 214                    "type": "request",  # special value for request parameter
 215                    "required": request_schema.get("required"),
 216                    "description": (
 217                        f"Either a string representing {parameter_key} as JSON"
 218                        f" or a filename from which to read {parameter_key}"
 219                        " JSON (use '-' to read from stdin)."
 220                    )
 221                }
 222        argument_key_abbrevs = set("h")  # prevent conflict with "help"
 223        for parameter_key, parameter_dict in parameters_schema.items():
 224            if parameter_key in ignored_parameters:
 225                continue
 226            parameter_kwargs = {
 227                "required": parameter_dict.get("required", False)
 228            }
 229            parameter_kwargs["help"] = parameter_dict.get("description", "")
 230            if parameter_kwargs["required"]:
 231                parameter_kwargs["help"] += " This option must be specified."
 232            # The "type" member refers to one of the JSON values types, out of
 233            # string/integer/array/object/boolean.
 234            # NOTE: Currently, enum-like value choices are not implemented, as
 235            # the enum values cannot be directly inferred from the discover
 236            # doc.
 237            argument_key = _ArgUtil.parameter_key_to_argument_name(
 238                parameter_key
 239            )
 240            for argument_short_key in argument_key:
 241                if (
 242                    argument_short_key.isalpha()
 243                    and argument_short_key not in argument_key_abbrevs
 244                ):
 245                    argument_key_abbrevs.add(argument_short_key)
 246                    break
 247            else:
 248                # If the letters of the full argument name are exhausted, fall
 249                # back to not using a short argument, indicated by the special
 250                # value None:
 251                argument_short_key = None
 252            default = parameter_dict.get("default")
 253            if default is not None and parameter_dict.get("type") != "boolean":
 254                parameter_kwargs["help"] += f" Default: {default}."
 255            match parameter_dict.get("type"):
 256                case "boolean":
 257                    # Using the 'action="store_true" (or "store_false")'
 258                    # mechanism results in flag-like action rather than an
 259                    # option that takes a true or false value. For each bool
 260                    # flag "--foo", also generate an additional "negative"
 261                    # version "--no-foo".
 262                    neg_argument_key = _ArgUtil.parameter_key_to_argument_name(
 263                        f"no_{parameter_key}"
 264                    )
 265                    neg_parameter_kwargs = {}
 266                    neg_parameter_kwargs["action"] = "store_false"
 267                    neg_parameter_kwargs["required"] = False
 268                    neg_parameter_kwargs["dest"] = parameter_key
 269                    neg_parameter_kwargs["default"] = json.loads(
 270                        default if default is not None else "null"
 271                    )
 272                    yield (neg_argument_key,), neg_parameter_kwargs
 273
 274                    parameter_kwargs["action"] = "store_true"
 275                    parameter_kwargs["dest"] = parameter_key
 276                    parameter_kwargs["default"] = (
 277                        neg_parameter_kwargs["default"]
 278                    )
 279                case "integer":
 280                    parameter_kwargs["type"] = int
 281                    parameter_kwargs["metavar"] = "N"
 282                case "array":
 283                    # The filters parameter is only used with "getter" methods
 284                    # that doesn't send a request body (which is exclusive to
 285                    # "creator"/"updater" methods). This means it's generally
 286                    # safe to use the "json_filter" type converter which can
 287                    # read from the stdin; it wouldn't conflict with the
 288                    # request body parameter which can also read the stdin.
 289                    if parameter_key == "filters":
 290                        parameter_kwargs["type"] = _ArgTypes.json_filter
 291                        parameter_kwargs["metavar"] = "{JSON,FILE,-}"
 292                        parameter_kwargs["help"] += (
 293                            " This can be a filename from which to read"
 294                            " JSON (use '-' to read from stdin)."
 295                        )
 296                    else:
 297                        parameter_kwargs["type"] = _ArgTypes.json_array
 298                        parameter_kwargs["metavar"] = "JSON_ARRAY"
 299                case "object":
 300                    parameter_kwargs["type"] = _ArgTypes.json_object
 301                    parameter_kwargs["metavar"] = "JSON_OBJECT"
 302                case "request":
 303                    parameter_kwargs["dest"] = "body"
 304                    parameter_kwargs["type"] = _ArgTypes.json_body
 305                    parameter_kwargs["metavar"] = "{JSON,FILE,-}"
 306                case _:
 307                    parameter_kwargs["type"] = str
 308                    parameter_kwargs["metavar"] = "STR"
 309            if argument_short_key is None:
 310                yield (argument_key,), parameter_kwargs
 311            else:
 312                yield (
 313                    (f"-{argument_short_key}", argument_key), parameter_kwargs
 314                )
 315
 316    @staticmethod
 317    def make_uuid_to_resource_map(schemas: dict[str, dict]) -> dict[str, str]:
 318        """Returns a mapping of Arvados object UUID prefixes to resource names
 319        (in the schema-key, CamelCase form, e.g. "ContainerRequest") based on
 320        the input "schemas" portion of the discovery document.
 321        """
 322        result = {}
 323        for schema in schemas.values():
 324            if (
 325                    (prefix := schema.get("uuidPrefix"))
 326                    and (key := schema.get("id"))
 327            ):
 328                result[prefix] = key
 329        return result
 330
 331
 332class ObjectEditingProcessBase(AbstractContextManager, abc.ABC):
 333    """Base class represending a process (in the generic sense, rather than
 334    "a Unix/Linux process") of editing an Arvados object with an external
 335    editor on a temporary file.
 336
 337    The methods `serialize(self, obj, file)` and `deserialize(self, file)` are
 338    abstract methods meant to be overridden. `serialize()` should write to the
 339    open `file` (any file-like object), and `deserialize()` should return an
 340    object loaded from the file.
 341
 342    When initialized, no external file has been created. To do so, enter it as
 343    a context manager.
 344
 345    Upon entering the context, the temporary file will be opened and written to
 346    with proper initial content if necessary. Upon leaving, the temporary file
 347    will be closed and cleaned-up (this normally means the file will be gone
 348    permanently).
 349
 350    Attributes:
 351
 352    * tmp_file: Optional[tempfile.NamedTemporaryFile] --- Temporary file to be
 353      edited.
 354    * prefix: Optional[str] --- Prefix of temporary filename if provided.
 355    * suffix: Optional[str] --- Suffix of temporary filename if provided. This
 356      can be a filename extension with the leading dot/period character `.`,
 357      useful for hinting the external editor with syntax highlighting.
 358    * base_command: list[str] --- Command-line argument list for invoking the
 359      external editor program. See `get_editor_cmdline()` for more.
 360    """
 361    _tmpfile_extension = None
 362
 363    def __init__(self, initial_object=None, prefix=None, file_extension=None):
 364        """Arguments:
 365
 366        * initial_object: Optional[Any] --- Initial object to be serialized and
 367          written to the temporary file before the editor process is run. If
 368          not provided, the file will be opened empty in the editor.
 369        * prefix: Optional[str] --- String to be used as the prefix
 370          of the temporary file's basename, followed by a hyphen (`-`)
 371          character that will be added automatically. If not provided, the
 372          initial object's `uuid` field will be used if available; otherwise, a
 373          platform-dependent prefix will be chosen automatically. A UUID as
 374          part of the filename is for information only, and it may be displayed
 375          in the editor's UI.
 376        * file_extension: Optional[str] --- Filename extension (without leading
 377          dot) of the temporary file, e.g. "json" or "yml". This information
 378          may be used by the editor to provide syntax highlighting, automatic
 379          indentation, completion, etc.
 380        """
 381        self.initial_object = initial_object
 382
 383        if prefix:
 384            self.prefix = f"{prefix}-"
 385        elif (
 386            isinstance(initial_object, Mapping)
 387            and (obj_uuid := initial_object.get("uuid"))
 388        ):
 389            self.prefix = f"{obj_uuid}-"
 390        else:
 391            self.prefix = None
 392
 393        ext = self._tmpfile_extension or file_extension
 394        self.suffix = f".{ext}" if ext else None
 395
 396        self.tmp_file = None
 397        self.base_command = self.get_editor_cmdline()
 398
 399    @staticmethod
 400    def get_editor_cmdline() -> list[str]:
 401        """Returns a partial command-line argument list that begins with the
 402        external editor program. The precedence is the $VISUAL environment
 403        variable, followed by $EDITOR; and if both are missing, then `nano` if
 404        it exists in the $PATH; and finally the hard-coded value `vi` no matter
 405        the command exists or not.
 406        """
 407        if cmd_str := (os.environ.get("VISUAL") or os.environ.get("EDITOR")):
 408            cmd = shlex.split(cmd_str)
 409        elif cmd_str := shutil.which("nano"):
 410            cmd = [cmd_str]
 411        else:
 412            cmd = ["vi"]
 413        return cmd
 414
 415    @abc.abstractmethod
 416    def serialize(self, obj: Any, file: TextIO) -> None:
 417        """Abstract method for serializing any object `obj` to the file-like
 418        object `file` as text.
 419        """
 420
 421    @abc.abstractmethod
 422    def deserialize(self, file: TextIO) -> Any:
 423        """Abstract method for loading from the file-like object `file` as
 424        text. Returns the object deserialized from the text content.
 425        """
 426
 427    def check_tmp_file(self):
 428        """Perform a basic sanity check for the temp file being usable."""
 429        if self.tmp_file is None or self.tmp_file.closed:
 430            raise RuntimeError("Temporary file is not available")
 431
 432    def dump(self, obj: Any) -> None:
 433        """Overwrite the temporary file with the serialized object `obj`."""
 434        self.check_tmp_file()
 435        # The following should not be done while the child process is pending.
 436        self.tmp_file.truncate(0)
 437        self.serialize(obj, self.tmp_file)
 438        self.tmp_file.flush()
 439
 440    def load(self) -> Any:
 441        """Read the temporary file from the beginning. Returns the deserialized
 442        object, or None if the file is empty or only whitespace.
 443        """
 444        self.check_tmp_file()
 445        # Snoop the file to see if it consists of only whitespace characters
 446        # (including empty lines); if so, return the special value None.
 447        with open(self.tmp_file.name, "r") as fdup:
 448            if not fdup.read().strip():
 449                return None
 450
 451        self.tmp_file.seek(0)
 452        return self.deserialize(self.tmp_file)
 453
 454    def edit(self) -> subprocess.CompletedProcess:
 455        """Run external editor and wait for it to finish."""
 456        self.check_tmp_file()
 457        return subprocess.run(
 458            self.base_command + [self.tmp_file.name],
 459            check=False
 460        )  # Wait for child.
 461
 462    def __enter__(self):
 463        self.tmp_file = NamedTemporaryFile(
 464            mode="w+", prefix=self.prefix, suffix=self.suffix
 465        )
 466        if self.initial_object is not None:
 467            self.dump(self.initial_object)
 468        return self
 469
 470    def __exit__(self, exc_type, exc_value, traceback):
 471        self.tmp_file.close()
 472
 473
 474class EditingContentError(ValueError):
 475    """Exception that indicates the content provided by the user via the editor
 476    is invalid for the specific format.
 477    """
 478    def __init__(
 479        self,
 480        path=None, line=0, column=0,
 481        file_type=None,
 482        original_exception=None,
 483    ):
 484        self.path = path
 485        self.line = line
 486        self.column = column
 487        self.file_type = file_type
 488        self.original_exception = original_exception
 489
 490    def __str__(self):
 491        msg = (
 492            f"Error: invalid input file [type {self.file_type or 'unknown'}]:"
 493            f" {self.path}:{self.line}:{self.column}"
 494        )
 495        if (
 496            self.original_exception
 497            and (orig_msg := str(self.original_exception))
 498        ):
 499            msg += f":\n{orig_msg}"
 500        return msg
 501
 502
 503class JSONEditingProcess(ObjectEditingProcessBase):
 504    """Subclass of editing process tuned for JSON files."""
 505    _tmpfile_extension = "json"
 506    input_error_type = functools.partial(
 507        EditingContentError, file_type="JSON"
 508    )
 509
 510    def __init__(self, *args, indent: int = 1, **kwargs):
 511        """Arguments:
 512
 513        * indent: int --- Number of spaces for each indentation level in the
 514          JSON file. Default: 1.
 515        """
 516        super().__init__(*args, **kwargs)
 517        self.indent = indent
 518
 519    def serialize(self, obj: Mapping[str, Any], file: TextIO) -> None:
 520        return json.dump(obj, file, indent=self.indent)
 521
 522    def deserialize(self, file: TextIO) -> Mapping[str, Any]:
 523        path = getattr(file, "name", "<unknown path>")
 524        try:
 525            obj = json.load(file)
 526        except json.JSONDecodeError as err:
 527            line = getattr(err, "lineno", 0)
 528            column = getattr(err, "colno", 0)
 529            raise self.input_error_type(
 530                path=path, line=line, column=column,
 531                original_exception=err
 532            )
 533        if not isinstance(obj, Mapping):
 534            raise self.input_error_type(
 535                path=path,
 536                original_exception=ValueError(
 537                    f"JSON input has type '{type(obj).__name__}',"
 538                    " not a valid Arvados object"
 539                )
 540            )
 541        return obj
 542
 543
 544class YAMLEditingProcess(ObjectEditingProcessBase):
 545    """Subclass of editing process tuned for YAML files."""
 546    _tmpfile_extension = "yml"
 547    input_error_type = functools.partial(
 548        EditingContentError, file_type="YAML"
 549    )
 550
 551    def serialize(self, obj: Mapping[str, Any], file: TextIO) -> None:
 552        return yaml.dump(obj, file)
 553
 554    def deserialize(self, file: TextIO) -> Mapping[str, Any]:
 555        path = getattr(file, "name", "<unknown path>")
 556        try:
 557            obj = yaml.load(file)
 558        except YAMLError as err:
 559            if problem_mark := getattr(err, "problem_mark", None):
 560                line = getattr(problem_mark, "line", 0)
 561                column = getattr(problem_mark, "column", 0)
 562            else:
 563                line = 0
 564                column = 0
 565            raise self.input_error_type(
 566                path=path, line=line, column=column,
 567                original_exception=err
 568            )
 569        if not isinstance(obj, Mapping):
 570            raise self.input_error_type(
 571                path=path,
 572                original_exception=ValueError(
 573                    f"YAML input has type '{type(obj).__name__}',"
 574                    " not a valid Arvados object"
 575                )
 576            )
 577        return obj
 578
 579
 580class FullHelpOnErrorArgumentParser(argparse.ArgumentParser):
 581    """Argument parser subclass that customizes the `error()` method.
 582
 583    Intended to be used as a base to a parser with complex subparsers, to print
 584    more-useful information when a required subcommand is missing.
 585    """
 586    def error(self, message, with_help=True):
 587        if with_help:
 588            self.print_help(sys.stderr)
 589            print(file=sys.stderr)
 590        # NOTE: self.prog is to be overridden by child class
 591        print(f"{self.prog}: error: {message}", file=sys.stderr)
 592        sys.exit(2)
 593
 594
 595class ArvCLIArgumentParser(FullHelpOnErrorArgumentParser):
 596    """Argument parser for `arv` commands.
 597    """
 598    prog = "arv"
 599    global_args = frozenset((
 600        "dry_run",
 601        "verbose",
 602        "format",
 603        "subcommand",
 604        "method"
 605    ))
 606    external_command_modules = {
 607        "keep ls": "arvados.commands.ls",
 608        "keep get": "arvados.commands.get",
 609        "keep put": "arvados.commands.put",
 610        "keep docker": "arvados.commands.keepdocker",
 611        "ws": "arvados.commands.ws",
 612        "copy": "arvados.commands.arv_copy"
 613    }
 614
 615    def __init__(self, discovery_document: dict[str, str | dict], **kwargs):
 616        """Arguments:
 617
 618        * discovery_document: dict --- Dict containing the parsed API discovery
 619          document; can be obtained as the `_rootDesc` attribute of an
 620          Arvados API client object.
 621        """
 622        super().__init__(
 623            description="Arvados command line client",
 624            prog=self.prog,
 625            **kwargs
 626        )
 627        # Common flags to the main command.
 628        self.add_argument("-n", "--dry-run", action="store_true",
 629                          help="Don't actually do anything")
 630        self.add_argument("-v", "--verbose", action="store_true",
 631                          help="Print some things on stderr")
 632        # Default output format is JSON, while "-s" or "--short" can be
 633        # used as a shorthand for "--format=uuid". If both are specified, the
 634        # last one takes effect.
 635        self.add_argument(
 636            "-f", "--format",
 637            choices=["json", "yaml", "uuid"],
 638            type=str.lower,
 639            default="json",
 640            help="Set output format"
 641        )
 642        self.add_argument(
 643            "-s", "--short",
 644            dest="format",
 645            action="store_const", const="uuid",
 646            help="Return only UUIDs (equivalent to --format=uuid)"
 647        )
 648
 649        subparsers = self.add_subparsers(
 650            dest="subcommand",
 651            description="Available subcommands and resources",
 652            required=True,
 653            metavar="subcommand",  # Suppress huge list in help message.
 654            parser_class=FullHelpOnErrorArgumentParser
 655        )
 656
 657        keep_methods = ["ls", "get", "put", "docker"]
 658        keep_parser = subparsers.add_parser(
 659            "keep", help="Arvados Keep client", add_help=False,
 660            epilog=f"available methods: {', '.join(keep_methods)}"
 661        )
 662        keep_parser.add_argument(
 663            "method",
 664            metavar="METHOD",
 665            choices=keep_methods
 666        )
 667
 668        subparsers.add_parser(
 669            "ws", help="Arvados WebSocket client", add_help=False
 670        )
 671        subparsers.add_parser(
 672            "copy",
 673            help=(
 674                "Copy collection, workflow, or project between Arvados"
 675                " instances"
 676            ),
 677            add_help=False
 678        )
 679
 680        self.subparsers = subparsers
 681        self.discovery_document = discovery_document
 682        # Work around googleapiclient's mutation of _rootDesc/_resourceDesc
 683        # dicts when a resource is created. For instance, currently (as of
 684        # 2026-06-03) "configs.get" resource-method's parameters get mutated at
 685        # init time of the API client object (as a side-effect of getting the
 686        # default storage classes for its KeepClient object).
 687        self._ignored_parameters = frozenset(
 688            discovery_document.get("parameters", {}).keys()
 689            | discovery.STACK_QUERY_PARAMETERS
 690        )
 691        self.resource_schemas = discovery_document.get("resources", {})
 692        self._subparser_index = {}
 693        self._subcommand_to_resource = {}
 694
 695        self.add_resource_subcommands()
 696
 697        if "sys" in self._subcommand_to_resource:
 698            self._subcommand_to_resource["sy"] = (
 699                self._subcommand_to_resource["sys"]
 700            )
 701
 702        self.uuid_parser = functools.partial(
 703            _ArgTypes.UUIDInfo.parse,
 704            _ArgUtil.make_uuid_to_resource_map(
 705                self.discovery_document.get("schemas", {})
 706            )
 707        )
 708
 709        self.add_editor_subcommands()
 710        self.add_get_subcommand()
 711
 712    def add_resource_subcommands(self):
 713        """Add resources as subcommands, their associated methods as
 714        sub-subcommands, and the parameters associated with each method.
 715        """
 716        for resource, resource_schema in self.resource_schemas.items():
 717            subcommand = _ArgUtil.singularize_resource(resource)
 718            self._subcommand_to_resource[subcommand] = resource
 719            # XXX: Below, "{resource}" can be a "word" like
 720            # "api_client_authorizations" that doesn't read well; consider
 721            # retrieving more natural-language-flavored description from the
 722            # "schema" portion of the discovery doc?
 723            subcommand_summary = f"Resource subcommand for {resource}"
 724            resource_subparser = self.subparsers.add_parser(
 725                subcommand,
 726                help=subcommand_summary,
 727                description=subcommand_summary,
 728                # For backward compatibility with legacy Ruby CLI client.
 729                aliases=["sy"] if subcommand == "sys" else []
 730            )
 731            methods_dict = resource_schema.get("methods")
 732            if methods_dict:
 733                # Create a collection of "sub-subparsers" under the resource
 734                # subparser for the methods.
 735                method_subparsers = resource_subparser.add_subparsers(
 736                    title="methods",
 737                    dest="method",
 738                    parser_class=FullHelpOnErrorArgumentParser,
 739                    required=True,
 740                    help=f"Methods for subcommand '{subcommand}'"
 741                )
 742                for method, method_schema in methods_dict.items():
 743                    # Add each specific method as a (sub-)subparser with its
 744                    # associated parameters.
 745                    method_summary = method_schema.get("description")
 746                    method_parser = method_subparsers.add_parser(
 747                        method,
 748                        description=method_summary,
 749                        help=method_summary
 750                    )
 751                    for parameter_names, kwargs in _ArgUtil.get_method_options(
 752                        method_schema,
 753                        ignored_parameters=self._ignored_parameters
 754                    ):
 755                        method_parser.add_argument(*parameter_names, **kwargs)
 756
 757    def add_editor_subcommands(self):
 758        """Add the "create" and "edit" subcommands."""
 759        # Only those resources that support a "create" method can be valid
 760        # for the "create" subcommand.
 761        creatable_targets = set()
 762        for cli_name, resource in self._subcommand_to_resource.items():
 763            if "create" in self.resource_schemas[resource].get("methods", {}):
 764                creatable_targets.add(cli_name)
 765        create_parser = self.subparsers.add_parser(
 766            "create", help="Create Arvados object using external editor"
 767        )
 768        create_parser.add_argument(
 769            "target_resource",
 770            choices=sorted(creatable_targets),
 771            metavar="RESOURCE",
 772            help="Type of the resource to be created"
 773        )
 774        create_parser.add_argument(
 775            "--project-uuid", "-p",
 776            type=_ArgTypes.group_uuid,
 777            metavar="UUID",
 778            help="UUID of the project in which to create the resource"
 779        )
 780
 781        edit_parser = self.subparsers.add_parser(
 782            "edit", help="Edit Arvados object using external editor"
 783        )
 784        edit_parser.add_argument(
 785            "uuid_info",
 786            help="UUID of the object to be edited", metavar="UUID",
 787            type=self.uuid_parser
 788        )
 789        edit_parser.add_argument(
 790            "fields", nargs="*",
 791            type=str.lower,  # "type" applies to individual items.
 792            help="Fields to be edited (case-insensitive)"
 793        )
 794
 795    def add_get_subcommand(self):
 796        get_parser = self.subparsers.add_parser(
 797            "get", help=(
 798                "Fetch the specified Arvados object, select the specified"
 799                " fields, and print a text representation"
 800            )
 801        )
 802        get_parser.add_argument(
 803            "uuid_info",
 804            help="UUID of the object to be fetched", metavar="UUID",
 805            type=self.uuid_parser
 806        )
 807        get_parser.add_argument(
 808            "fields", nargs="*",
 809            type=str.lower,
 810            help="Fields to be fetched (case-insensitive)"
 811        )
 812
 813
 814def _handle_external_command(module_name: str, args: list[str]) -> NoReturn:
 815    """Import the external module for the subcommand, call the module's
 816    `main()` function with given arguments, and exit with the main function's
 817    return value as the exit status code.
 818    """
 819    external_mod = importlib.import_module(module_name)
 820    sys.exit(external_mod.main(args))
 821
 822
 823def _format_api_error_msg(err: arvados.errors.ApiError, method_call) -> str:
 824    """Format API error, with the request-id from the HttpRequest object
 825    `method_call` if 1) it is available and 2) the original message itself
 826    doesn't already contain the request-id.
 827    """
 828    # NOTE: This is not exactly the same output as that generated by the Ruby
 829    # 'arv' command upon error.
 830    msg = str(err)
 831    request_id = method_call.headers.get("X-Request-Id")
 832    if request_id and not re.search(rf"\b{re.escape(request_id)}\b", msg):
 833        msg += f" ({request_id})"
 834    return msg
 835
 836
 837def _call_resource_method(method_obj, method_args: Mapping, fmt: str) -> int:
 838    """Given the API resource method object and parameters, create an API
 839    request, execute it (do the call), and print the response of the API server
 840    in the given format.
 841
 842    Returns 0 if successful, or 1 if any errors are encountered.
 843    """
 844    method_call = method_obj(**method_args)
 845    try:
 846        result = method_call.execute()
 847    except arvados.errors.ApiError as err:
 848        msg = _format_api_error_msg(err, method_call)
 849        print(f"Error: {msg}", file=sys.stderr)
 850        return 1
 851
 852    match fmt:
 853        case "json":
 854            json.dump(result, sys.stdout, indent=1)
 855            print()
 856        case "yaml":
 857            yaml.dump(result, sys.stdout)
 858        case "uuid":
 859            if (
 860                    result.get("kind", "").endswith("List")
 861                    and result.get("items")
 862            ):
 863                for item in result["items"]:
 864                    # The received items may have the "uuid" field filtered out
 865                    # by the "--select" parameter. The ruby "arv" command
 866                    # simply outputs blank lines, which is not desirable.
 867                    obj_uuid = item.get("uuid")
 868                    if obj_uuid is None:
 869                        print(
 870                            (
 871                                "Error: at least one item in response did not"
 872                                " include a uuid. The full response was:"
 873                            ),
 874                            json.dumps(result, indent=1),
 875                            sep="\n",
 876                            file=sys.stderr
 877                        )
 878                        return 1
 879                    print(item["uuid"])
 880            else:
 881                obj_uuid = result.get("uuid")
 882                if obj_uuid is None:
 883                    print(
 884                        "Error: response did not include a uuid:",
 885                        json.dumps(result, indent=1),
 886                        sep="\n",
 887                        file=sys.stderr
 888                    )
 889                    return 1
 890                print(obj_uuid)
 891    return 0
 892
 893
 894def _handle_resource_method(api_client, resource, args) -> NoReturn:
 895    """Prepare API request by resource name and the already-parsed arguments,
 896    send the request, and analyze & print out the result.
 897    """
 898    arv_resource = getattr(api_client, resource)()
 899    arv_method = getattr(arv_resource, args.method)
 900    method_args = {
 901        k: v
 902        for k, v in vars(args).items()
 903        if k not in ArvCLIArgumentParser.global_args
 904    }
 905
 906    sys.exit(_call_resource_method(arv_method, method_args, args.format))
 907
 908
 909def _select_fields(
 910    src: Mapping[str, Any], fields: Container[str]
 911) -> Mapping[str, Any]:
 912    """Select the items of input dict `src` whose keys are in `fields` (a
 913    subset of the keys of `src`, or, if `fields` is empty, return the input
 914    `src` unmodified.
 915    """
 916    return {k: src[k] for k in fields} or src
 917
 918
 919def _get_obj_by_uuid_info(api_client, parser, args) -> tuple[int, dict | str]:
 920    """Obtain the object for the "get" subcommand and the initial object for
 921    the "arv edit" subcommand, based on the commandline args and the current
 922    API client & CLI parser instances. If the "fields" argument (`args.fields`)
 923    is provided, only those fields that are specified are returned.
 924
 925    Return value:
 926
 927    * status: int --- Status code indicating the result of operation:
 928      * 0: Success; the second return value is the initial object as a dict.
 929      * 1: API error; the second return value is the error message.
 930      * 2: Invalid input; the second return value is the error message.
 931    * value: dict | str --- Returned, filtered initial object `dict` in case of
 932      success, or an error-message string in case of failure.
 933    """
 934    resource_name = args.uuid_info.rtype_lower
 935
 936    # Filter the fields for any invalid keys of the particular resource.
 937    valid_fields = parser.discovery_document.get("schemas", {})[
 938        args.uuid_info.resource_type
 939    ]["properties"]
 940    # Sets doesn't remember insertion order, but we want to put invalid keys in
 941    # the order given by the user for consistency, so we do a dedup with dict.
 942    invalid_fields = {f: None for f in args.fields if f not in valid_fields}
 943    if invalid_fields:
 944        return 2, (
 945            f"invalid fields for resource {resource_name!r}:"
 946            f" {', '.join(map(repr, invalid_fields))}"
 947        )
 948
 949    method_call = getattr(
 950        api_client, parser._subcommand_to_resource[resource_name]
 951    )().get(uuid=args.uuid_info.uuid)
 952    try:
 953        arv_obj = method_call.execute()
 954    except arvados.errors.ApiError as err:
 955        return 1, _format_api_error_msg(err, method_call)
 956
 957    return 0, _select_fields(arv_obj, args.fields)
 958
 959
 960def _handle_external_editor_command(api_client, parser, args) -> NoReturn:
 961    """Handle the subcommands "create" or "edit"."""
 962    if args.subcommand == "create":
 963        init_obj = {
 964            "owner_uuid": args.project_uuid
 965        } if args.project_uuid else {}
 966        # Tempfile name resembling "new-collection-{random}.{json|yml}".
 967        prefix = f"new-{args.target_resource}"
 968    else:
 969        status, obj_or_msg = _get_obj_by_uuid_info(
 970            api_client, parser, args
 971        )
 972        if status != 0:
 973            print(f"Error: {obj_or_msg}", file=sys.stderr)
 974            sys.exit(status)
 975        # Tempfile name resembling
 976        # "collection-clstr-4zz18-{15chars}-{random}.{json|yml}".
 977        init_obj = obj_or_msg
 978        prefix = f"{args.uuid_info.rtype_lower}-{args.uuid_info.uuid}"
 979
 980    match args.format:
 981        case "json":
 982            editing_class = JSONEditingProcess
 983        case "yaml":
 984            editing_class = YAMLEditingProcess
 985        case _:
 986            raise RuntimeError(
 987                f"Error: unexpected value for format option: {args.format}"
 988            )
 989
 990    with editing_class(initial_object=init_obj, prefix=prefix) as editing:
 991        api_call_status = None
 992        while api_call_status is None:
 993            try:
 994                editing.edit()
 995            except OSError as err:
 996                cmd_str = shlex.join(
 997                    editing.base_command + [editing.tmp_file.name]
 998                )
 999                print(
1000                    f"Error: failed to execute editor `{cmd_str}`: {err}",
1001                    file=sys.stderr
1002                )
1003                sys.exit(1)
1004
1005            try:
1006                edited_obj = editing.load()
1007            except EditingContentError as err:
1008                # Invalid input from editor; emit error message and let the
1009                # user try again.
1010                print(str(err), file=sys.stderr)
1011                while (wants_retry := _ask_reedit()) is None:
1012                    pass
1013                if wants_retry:
1014                    # NOTE: Back to the start of the editing loop!
1015                    continue
1016                sys.exit(1)  # User won't retry; exit with failure.
1017            if not edited_obj:
1018                print(
1019                    "notice: input is empty; exiting without changes",
1020                    file=sys.stderr
1021                )
1022                sys.exit(0)
1023
1024            if args.subcommand == "create":
1025                resource = parser._subcommand_to_resource[args.target_resource]
1026            else:
1027                resource = parser._subcommand_to_resource[
1028                    args.uuid_info.rtype_lower
1029                ]
1030
1031            arv_resource = getattr(api_client, resource)()
1032
1033            if args.subcommand == "create":
1034                api_call_status = _call_resource_method(
1035                    arv_resource.create, {"body": edited_obj}, args.format
1036                )
1037            else:
1038                obj_delta = {
1039                    k: v
1040                    for k, v in edited_obj.items()
1041                    if k not in init_obj or v != init_obj[k]
1042                }
1043                if not obj_delta:
1044                    print(
1045                        "notice: object is unchanged; did not update",
1046                        file=sys.stderr
1047                    )
1048                    sys.exit(0)
1049                api_call_status = _call_resource_method(
1050                    arv_resource.update,
1051                    {"uuid": args.uuid_info.uuid, "body": obj_delta},
1052                    args.format
1053                )
1054
1055            if api_call_status != 0:
1056                # If the API request failed, try editing again if the user so
1057                # desires.
1058                wants_retry = None
1059                while wants_retry is None:
1060                    wants_retry = _ask_reedit()
1061                    if wants_retry:
1062                        # Editing loop to be restarted; clear last API call
1063                        # status.
1064                        api_call_status = None
1065                        continue
1066            # End of the editing loop.
1067        sys.exit(api_call_status)
1068    # End of the NoReturn function.
1069
1070
1071def _ask_reedit() -> bool | None:
1072    """Ask the user if they'd like to continue editing. Returns True for "yes"
1073    (default, applies also when the user types in a blank newline), False for
1074    "no", and None for any other answer.
1075    """
1076    # Put the prompt to the stderr rather than the stdout because we would like
1077    # to keep the stdout clean for API server output, which makes testing
1078    # simpler, too. Note that if we are ever to import `readline`, which we're
1079    # not doing now, this customized prompting behavior might break cursor
1080    # positioning and would have to be revisited.
1081    print(
1082        "Edit and try again? ([Y]es/no) ", end="", file=sys.stderr, flush=True
1083    )
1084    match input().strip().lower():
1085        case "" | "y" | "ye" | "yes":
1086            return True
1087        case "n" | "no":
1088            return False
1089        case _:
1090            return None
1091
1092
1093def _handle_get_subcommand(api_client, parser, args) -> NoReturn:
1094    status, obj_or_msg = _get_obj_by_uuid_info(api_client, parser, args)
1095    if status != 0:
1096        # "obj_or_msg" is a message.
1097        print(f"Error: {obj_or_msg}", file=sys.stderr)
1098    else:
1099        # "obj_or_msg" is a real Arvados object.
1100        match args.format:
1101            case "json":
1102                json.dump(obj_or_msg, sys.stdout, indent=1)
1103                print()
1104            case "yaml":
1105                yaml.dump(obj_or_msg, sys.stdout)
1106            case _:
1107                # This must not happen, as "--format=uuid" is an invalid global
1108                # option value for "get" subcommand.
1109                raise RuntimeError(
1110                    f"Error: unexpected value for format option: {args.format}"
1111                )
1112    sys.exit(status)
1113
1114
1115def dispatch(arguments=None):
1116    api_client = arvados.api("v1")
1117    cmd_parser = ArvCLIArgumentParser(api_client._rootDesc)
1118    args, remaining_args = cmd_parser.parse_known_args(arguments)
1119
1120    # There's always args.subcommand if we reach here, because "subcommand" is
1121    # required by the parser. But "method" may be absent, as is in the case of
1122    # external commands like "ws" or "copy".
1123    method = getattr(args, "method", "")
1124    command_key = f"{args.subcommand} {method}" if method else args.subcommand
1125
1126    # Are we calling an external command?
1127    ext_module = cmd_parser.external_command_modules.get(command_key)
1128    if ext_module is not None:
1129        sys.argv[0] = f"arv {command_key}"
1130        _handle_external_command(ext_module, remaining_args)  # Exits.
1131
1132    # Are we doing an API resource call?
1133    resource = cmd_parser._subcommand_to_resource.get(args.subcommand)
1134    if resource is not None:
1135        # Any further remaining args indicate either malformed or unrecognized
1136        # global args (e.g. "arvcli.py --bad-arg resource method") or undefined
1137        # parameters to a valid resouce-method combination.
1138        if remaining_args:
1139            cmd_parser.error(
1140                f"unrecognized arguments: {', '.join(remaining_args)}\n"
1141                f"Try: {cmd_parser.prog} --help\n"
1142                f"     {cmd_parser.prog} {command_key} --help",
1143                with_help=False
1144            )  # Exits with status 2.
1145        _handle_resource_method(api_client, resource, args)  # Exits.
1146
1147    # Are we starting an external editor program?
1148    if args.subcommand in ("create", "edit"):
1149        if args.format == "uuid":
1150            cmd_parser.error(
1151                "--format=uuid or -s option is not supported when creating or"
1152                " editing Arvados objects with external editor. Please"
1153                " choose --format=json (default) or --format=yaml.",
1154                with_help=False
1155            )  # Exits with status 2.
1156        _handle_external_editor_command(api_client, cmd_parser, args)  # Exits.
1157
1158    # Are we running "arv get"?
1159    if args.subcommand == "get":
1160        if args.format == "uuid":
1161            cmd_parser.error(
1162                "--format=uuid or -s option is not supported for the 'arv get'"
1163                " command. Please choose --format=json (default) or"
1164                " --format=yaml.",
1165                with_help=False
1166            )  # Exits with status 2.
1167        _handle_get_subcommand(api_client, cmd_parser, args)  # Exits.
1168
1169    # NOTE: The code immediately below is not reachable.
1170    raise RuntimeError("Unexpected arguments: {arguments!r}")
1171
1172
1173if __name__ == "__main__":
1174    dispatch()
yaml = <ruamel.yaml.main.YAML object>
@dataclass(frozen=True)
class _ArgTypes.UUIDInfo:
64    @dataclass(frozen=True)
65    class UUIDInfo:
66        """'Interpreted' Arvados UUID object with resource type info."""
67        uuid: str
68        resource_type: str  # value in CamelCase
69        rtype_lower: str  # value in snake_case
70
71        @classmethod
72        def parse(
73            cls, type_map: Mapping[str, str], text: str
74        ) -> "UUIDInfo":  # self-typing support comes in Python 3.11.
75            """Parse the UUID argument `text`. If accepted, returns an
76            `UUIDInfo` instance whose `uuid` attribute is the input UUID
77            unchanged and the `resource_type` attribute is the type of Arvados
78            object (in CamelCase), as determined by the input parameter
79            `type_map`, and `rtype_lower` is the alternative form of
80            resource type in snake_case.
81            """
82            if not arvados.util.uuid_pattern.fullmatch(text):
83                raise argparse.ArgumentTypeError(
84                    f"Invalid Arvados object UUID: {text}"
85                )
86            type_code = text.split("-")[1]
87            if type_code not in type_map:
88                available_types = ", ".join(sorted(
89                    f"{k} ({v})" for k, v in type_map.items()
90                ))
91                raise argparse.ArgumentTypeError(
92                    f"Invalid object type code {type_code!r} in Arvados"
93                    f" object UUID {text}: valid type codes are"
94                    f" {available_types}"
95                )
96            type_key = type_map[type_code]
97            return cls(text, type_key, _ArgUtil.camel_case_to_snake(type_key))

‘Interpreted’ Arvados UUID object with resource type info.

_ArgTypes.UUIDInfo(uuid: str, resource_type: str, rtype_lower: str)
uuid: str
resource_type: str
rtype_lower: str
@classmethod
def parse(cls, type_map: Mapping[str, str], text: str) -> 'UUIDInfo':
71        @classmethod
72        def parse(
73            cls, type_map: Mapping[str, str], text: str
74        ) -> "UUIDInfo":  # self-typing support comes in Python 3.11.
75            """Parse the UUID argument `text`. If accepted, returns an
76            `UUIDInfo` instance whose `uuid` attribute is the input UUID
77            unchanged and the `resource_type` attribute is the type of Arvados
78            object (in CamelCase), as determined by the input parameter
79            `type_map`, and `rtype_lower` is the alternative form of
80            resource type in snake_case.
81            """
82            if not arvados.util.uuid_pattern.fullmatch(text):
83                raise argparse.ArgumentTypeError(
84                    f"Invalid Arvados object UUID: {text}"
85                )
86            type_code = text.split("-")[1]
87            if type_code not in type_map:
88                available_types = ", ".join(sorted(
89                    f"{k} ({v})" for k, v in type_map.items()
90                ))
91                raise argparse.ArgumentTypeError(
92                    f"Invalid object type code {type_code!r} in Arvados"
93                    f" object UUID {text}: valid type codes are"
94                    f" {available_types}"
95                )
96            type_key = type_map[type_code]
97            return cls(text, type_key, _ArgUtil.camel_case_to_snake(type_key))

Parse the UUID argument text. If accepted, returns an UUIDInfo instance whose uuid attribute is the input UUID unchanged and the resource_type attribute is the type of Arvados object (in CamelCase), as determined by the input parameter type_map, and rtype_lower is the alternative form of resource type in snake_case.

class ObjectEditingProcessBase(contextlib.AbstractContextManager, abc.ABC):
333class ObjectEditingProcessBase(AbstractContextManager, abc.ABC):
334    """Base class represending a process (in the generic sense, rather than
335    "a Unix/Linux process") of editing an Arvados object with an external
336    editor on a temporary file.
337
338    The methods `serialize(self, obj, file)` and `deserialize(self, file)` are
339    abstract methods meant to be overridden. `serialize()` should write to the
340    open `file` (any file-like object), and `deserialize()` should return an
341    object loaded from the file.
342
343    When initialized, no external file has been created. To do so, enter it as
344    a context manager.
345
346    Upon entering the context, the temporary file will be opened and written to
347    with proper initial content if necessary. Upon leaving, the temporary file
348    will be closed and cleaned-up (this normally means the file will be gone
349    permanently).
350
351    Attributes:
352
353    * tmp_file: Optional[tempfile.NamedTemporaryFile] --- Temporary file to be
354      edited.
355    * prefix: Optional[str] --- Prefix of temporary filename if provided.
356    * suffix: Optional[str] --- Suffix of temporary filename if provided. This
357      can be a filename extension with the leading dot/period character `.`,
358      useful for hinting the external editor with syntax highlighting.
359    * base_command: list[str] --- Command-line argument list for invoking the
360      external editor program. See `get_editor_cmdline()` for more.
361    """
362    _tmpfile_extension = None
363
364    def __init__(self, initial_object=None, prefix=None, file_extension=None):
365        """Arguments:
366
367        * initial_object: Optional[Any] --- Initial object to be serialized and
368          written to the temporary file before the editor process is run. If
369          not provided, the file will be opened empty in the editor.
370        * prefix: Optional[str] --- String to be used as the prefix
371          of the temporary file's basename, followed by a hyphen (`-`)
372          character that will be added automatically. If not provided, the
373          initial object's `uuid` field will be used if available; otherwise, a
374          platform-dependent prefix will be chosen automatically. A UUID as
375          part of the filename is for information only, and it may be displayed
376          in the editor's UI.
377        * file_extension: Optional[str] --- Filename extension (without leading
378          dot) of the temporary file, e.g. "json" or "yml". This information
379          may be used by the editor to provide syntax highlighting, automatic
380          indentation, completion, etc.
381        """
382        self.initial_object = initial_object
383
384        if prefix:
385            self.prefix = f"{prefix}-"
386        elif (
387            isinstance(initial_object, Mapping)
388            and (obj_uuid := initial_object.get("uuid"))
389        ):
390            self.prefix = f"{obj_uuid}-"
391        else:
392            self.prefix = None
393
394        ext = self._tmpfile_extension or file_extension
395        self.suffix = f".{ext}" if ext else None
396
397        self.tmp_file = None
398        self.base_command = self.get_editor_cmdline()
399
400    @staticmethod
401    def get_editor_cmdline() -> list[str]:
402        """Returns a partial command-line argument list that begins with the
403        external editor program. The precedence is the $VISUAL environment
404        variable, followed by $EDITOR; and if both are missing, then `nano` if
405        it exists in the $PATH; and finally the hard-coded value `vi` no matter
406        the command exists or not.
407        """
408        if cmd_str := (os.environ.get("VISUAL") or os.environ.get("EDITOR")):
409            cmd = shlex.split(cmd_str)
410        elif cmd_str := shutil.which("nano"):
411            cmd = [cmd_str]
412        else:
413            cmd = ["vi"]
414        return cmd
415
416    @abc.abstractmethod
417    def serialize(self, obj: Any, file: TextIO) -> None:
418        """Abstract method for serializing any object `obj` to the file-like
419        object `file` as text.
420        """
421
422    @abc.abstractmethod
423    def deserialize(self, file: TextIO) -> Any:
424        """Abstract method for loading from the file-like object `file` as
425        text. Returns the object deserialized from the text content.
426        """
427
428    def check_tmp_file(self):
429        """Perform a basic sanity check for the temp file being usable."""
430        if self.tmp_file is None or self.tmp_file.closed:
431            raise RuntimeError("Temporary file is not available")
432
433    def dump(self, obj: Any) -> None:
434        """Overwrite the temporary file with the serialized object `obj`."""
435        self.check_tmp_file()
436        # The following should not be done while the child process is pending.
437        self.tmp_file.truncate(0)
438        self.serialize(obj, self.tmp_file)
439        self.tmp_file.flush()
440
441    def load(self) -> Any:
442        """Read the temporary file from the beginning. Returns the deserialized
443        object, or None if the file is empty or only whitespace.
444        """
445        self.check_tmp_file()
446        # Snoop the file to see if it consists of only whitespace characters
447        # (including empty lines); if so, return the special value None.
448        with open(self.tmp_file.name, "r") as fdup:
449            if not fdup.read().strip():
450                return None
451
452        self.tmp_file.seek(0)
453        return self.deserialize(self.tmp_file)
454
455    def edit(self) -> subprocess.CompletedProcess:
456        """Run external editor and wait for it to finish."""
457        self.check_tmp_file()
458        return subprocess.run(
459            self.base_command + [self.tmp_file.name],
460            check=False
461        )  # Wait for child.
462
463    def __enter__(self):
464        self.tmp_file = NamedTemporaryFile(
465            mode="w+", prefix=self.prefix, suffix=self.suffix
466        )
467        if self.initial_object is not None:
468            self.dump(self.initial_object)
469        return self
470
471    def __exit__(self, exc_type, exc_value, traceback):
472        self.tmp_file.close()

Base class represending a process (in the generic sense, rather than “a Unix/Linux process") of editing an Arvados object with an external editor on a temporary file.

The methods serialize(self, obj, file) and deserialize(self, file) are abstract methods meant to be overridden. serialize() should write to the open file (any file-like object), and deserialize() should return an object loaded from the file.

When initialized, no external file has been created. To do so, enter it as a context manager.

Upon entering the context, the temporary file will be opened and written to with proper initial content if necessary. Upon leaving, the temporary file will be closed and cleaned-up (this normally means the file will be gone permanently).

Attributes:

  • tmp_file: Optional[tempfile.NamedTemporaryFile] — Temporary file to be edited.
  • prefix: Optional[str] — Prefix of temporary filename if provided.
  • suffix: Optional[str] — Suffix of temporary filename if provided. This can be a filename extension with the leading dot/period character ., useful for hinting the external editor with syntax highlighting.
  • base_command: list[str] — Command-line argument list for invoking the external editor program. See get_editor_cmdline() for more.
ObjectEditingProcessBase(initial_object=None, prefix=None, file_extension=None)
364    def __init__(self, initial_object=None, prefix=None, file_extension=None):
365        """Arguments:
366
367        * initial_object: Optional[Any] --- Initial object to be serialized and
368          written to the temporary file before the editor process is run. If
369          not provided, the file will be opened empty in the editor.
370        * prefix: Optional[str] --- String to be used as the prefix
371          of the temporary file's basename, followed by a hyphen (`-`)
372          character that will be added automatically. If not provided, the
373          initial object's `uuid` field will be used if available; otherwise, a
374          platform-dependent prefix will be chosen automatically. A UUID as
375          part of the filename is for information only, and it may be displayed
376          in the editor's UI.
377        * file_extension: Optional[str] --- Filename extension (without leading
378          dot) of the temporary file, e.g. "json" or "yml". This information
379          may be used by the editor to provide syntax highlighting, automatic
380          indentation, completion, etc.
381        """
382        self.initial_object = initial_object
383
384        if prefix:
385            self.prefix = f"{prefix}-"
386        elif (
387            isinstance(initial_object, Mapping)
388            and (obj_uuid := initial_object.get("uuid"))
389        ):
390            self.prefix = f"{obj_uuid}-"
391        else:
392            self.prefix = None
393
394        ext = self._tmpfile_extension or file_extension
395        self.suffix = f".{ext}" if ext else None
396
397        self.tmp_file = None
398        self.base_command = self.get_editor_cmdline()

Arguments:

  • initial_object: Optional[Any] — Initial object to be serialized and written to the temporary file before the editor process is run. If not provided, the file will be opened empty in the editor.
  • prefix: Optional[str] — String to be used as the prefix of the temporary file’s basename, followed by a hyphen (-) character that will be added automatically. If not provided, the initial object’s uuid field will be used if available; otherwise, a platform-dependent prefix will be chosen automatically. A UUID as part of the filename is for information only, and it may be displayed in the editor’s UI.
  • file_extension: Optional[str] — Filename extension (without leading dot) of the temporary file, e.g. “json” or “yml”. This information may be used by the editor to provide syntax highlighting, automatic indentation, completion, etc.
initial_object
suffix
tmp_file
base_command
@staticmethod
def get_editor_cmdline() -> list[str]:
400    @staticmethod
401    def get_editor_cmdline() -> list[str]:
402        """Returns a partial command-line argument list that begins with the
403        external editor program. The precedence is the $VISUAL environment
404        variable, followed by $EDITOR; and if both are missing, then `nano` if
405        it exists in the $PATH; and finally the hard-coded value `vi` no matter
406        the command exists or not.
407        """
408        if cmd_str := (os.environ.get("VISUAL") or os.environ.get("EDITOR")):
409            cmd = shlex.split(cmd_str)
410        elif cmd_str := shutil.which("nano"):
411            cmd = [cmd_str]
412        else:
413            cmd = ["vi"]
414        return cmd

Returns a partial command-line argument list that begins with the external editor program. The precedence is the $VISUAL environment variable, followed by $EDITOR; and if both are missing, then nano if it exists in the $PATH; and finally the hard-coded value vi no matter the command exists or not.

@abc.abstractmethod
def serialize(self, obj: Any, file: <class 'TextIO'>) -> None:
416    @abc.abstractmethod
417    def serialize(self, obj: Any, file: TextIO) -> None:
418        """Abstract method for serializing any object `obj` to the file-like
419        object `file` as text.
420        """

Abstract method for serializing any object obj to the file-like object file as text.

@abc.abstractmethod
def deserialize(self, file: <class 'TextIO'>) -> Any:
422    @abc.abstractmethod
423    def deserialize(self, file: TextIO) -> Any:
424        """Abstract method for loading from the file-like object `file` as
425        text. Returns the object deserialized from the text content.
426        """

Abstract method for loading from the file-like object file as text. Returns the object deserialized from the text content.

def check_tmp_file(self):
428    def check_tmp_file(self):
429        """Perform a basic sanity check for the temp file being usable."""
430        if self.tmp_file is None or self.tmp_file.closed:
431            raise RuntimeError("Temporary file is not available")

Perform a basic sanity check for the temp file being usable.

def dump(self, obj: Any) -> None:
433    def dump(self, obj: Any) -> None:
434        """Overwrite the temporary file with the serialized object `obj`."""
435        self.check_tmp_file()
436        # The following should not be done while the child process is pending.
437        self.tmp_file.truncate(0)
438        self.serialize(obj, self.tmp_file)
439        self.tmp_file.flush()

Overwrite the temporary file with the serialized object obj.

def load(self) -> Any:
441    def load(self) -> Any:
442        """Read the temporary file from the beginning. Returns the deserialized
443        object, or None if the file is empty or only whitespace.
444        """
445        self.check_tmp_file()
446        # Snoop the file to see if it consists of only whitespace characters
447        # (including empty lines); if so, return the special value None.
448        with open(self.tmp_file.name, "r") as fdup:
449            if not fdup.read().strip():
450                return None
451
452        self.tmp_file.seek(0)
453        return self.deserialize(self.tmp_file)

Read the temporary file from the beginning. Returns the deserialized object, or None if the file is empty or only whitespace.

def edit(self) -> subprocess.CompletedProcess:
455    def edit(self) -> subprocess.CompletedProcess:
456        """Run external editor and wait for it to finish."""
457        self.check_tmp_file()
458        return subprocess.run(
459            self.base_command + [self.tmp_file.name],
460            check=False
461        )  # Wait for child.

Run external editor and wait for it to finish.

class EditingContentError(builtins.ValueError):
475class EditingContentError(ValueError):
476    """Exception that indicates the content provided by the user via the editor
477    is invalid for the specific format.
478    """
479    def __init__(
480        self,
481        path=None, line=0, column=0,
482        file_type=None,
483        original_exception=None,
484    ):
485        self.path = path
486        self.line = line
487        self.column = column
488        self.file_type = file_type
489        self.original_exception = original_exception
490
491    def __str__(self):
492        msg = (
493            f"Error: invalid input file [type {self.file_type or 'unknown'}]:"
494            f" {self.path}:{self.line}:{self.column}"
495        )
496        if (
497            self.original_exception
498            and (orig_msg := str(self.original_exception))
499        ):
500            msg += f":\n{orig_msg}"
501        return msg

Exception that indicates the content provided by the user via the editor is invalid for the specific format.

EditingContentError(path=None, line=0, column=0, file_type=None, original_exception=None)
479    def __init__(
480        self,
481        path=None, line=0, column=0,
482        file_type=None,
483        original_exception=None,
484    ):
485        self.path = path
486        self.line = line
487        self.column = column
488        self.file_type = file_type
489        self.original_exception = original_exception
path
line
column
file_type
original_exception
class JSONEditingProcess(ObjectEditingProcessBase):
504class JSONEditingProcess(ObjectEditingProcessBase):
505    """Subclass of editing process tuned for JSON files."""
506    _tmpfile_extension = "json"
507    input_error_type = functools.partial(
508        EditingContentError, file_type="JSON"
509    )
510
511    def __init__(self, *args, indent: int = 1, **kwargs):
512        """Arguments:
513
514        * indent: int --- Number of spaces for each indentation level in the
515          JSON file. Default: 1.
516        """
517        super().__init__(*args, **kwargs)
518        self.indent = indent
519
520    def serialize(self, obj: Mapping[str, Any], file: TextIO) -> None:
521        return json.dump(obj, file, indent=self.indent)
522
523    def deserialize(self, file: TextIO) -> Mapping[str, Any]:
524        path = getattr(file, "name", "<unknown path>")
525        try:
526            obj = json.load(file)
527        except json.JSONDecodeError as err:
528            line = getattr(err, "lineno", 0)
529            column = getattr(err, "colno", 0)
530            raise self.input_error_type(
531                path=path, line=line, column=column,
532                original_exception=err
533            )
534        if not isinstance(obj, Mapping):
535            raise self.input_error_type(
536                path=path,
537                original_exception=ValueError(
538                    f"JSON input has type '{type(obj).__name__}',"
539                    " not a valid Arvados object"
540                )
541            )
542        return obj

Subclass of editing process tuned for JSON files.

JSONEditingProcess(*args, indent: int = 1, **kwargs)
511    def __init__(self, *args, indent: int = 1, **kwargs):
512        """Arguments:
513
514        * indent: int --- Number of spaces for each indentation level in the
515          JSON file. Default: 1.
516        """
517        super().__init__(*args, **kwargs)
518        self.indent = indent

Arguments:

  • indent: int — Number of spaces for each indentation level in the JSON file. Default: 1.
input_error_type = functools.partial(<class 'EditingContentError'>, file_type='JSON')
indent
def serialize(self, obj: Mapping[str, typing.Any], file: <class 'TextIO'>) -> None:
520    def serialize(self, obj: Mapping[str, Any], file: TextIO) -> None:
521        return json.dump(obj, file, indent=self.indent)

Abstract method for serializing any object obj to the file-like object file as text.

def deserialize(self, file: <class 'TextIO'>) -> Mapping[str, typing.Any]:
523    def deserialize(self, file: TextIO) -> Mapping[str, Any]:
524        path = getattr(file, "name", "<unknown path>")
525        try:
526            obj = json.load(file)
527        except json.JSONDecodeError as err:
528            line = getattr(err, "lineno", 0)
529            column = getattr(err, "colno", 0)
530            raise self.input_error_type(
531                path=path, line=line, column=column,
532                original_exception=err
533            )
534        if not isinstance(obj, Mapping):
535            raise self.input_error_type(
536                path=path,
537                original_exception=ValueError(
538                    f"JSON input has type '{type(obj).__name__}',"
539                    " not a valid Arvados object"
540                )
541            )
542        return obj

Abstract method for loading from the file-like object file as text. Returns the object deserialized from the text content.

class YAMLEditingProcess(ObjectEditingProcessBase):
545class YAMLEditingProcess(ObjectEditingProcessBase):
546    """Subclass of editing process tuned for YAML files."""
547    _tmpfile_extension = "yml"
548    input_error_type = functools.partial(
549        EditingContentError, file_type="YAML"
550    )
551
552    def serialize(self, obj: Mapping[str, Any], file: TextIO) -> None:
553        return yaml.dump(obj, file)
554
555    def deserialize(self, file: TextIO) -> Mapping[str, Any]:
556        path = getattr(file, "name", "<unknown path>")
557        try:
558            obj = yaml.load(file)
559        except YAMLError as err:
560            if problem_mark := getattr(err, "problem_mark", None):
561                line = getattr(problem_mark, "line", 0)
562                column = getattr(problem_mark, "column", 0)
563            else:
564                line = 0
565                column = 0
566            raise self.input_error_type(
567                path=path, line=line, column=column,
568                original_exception=err
569            )
570        if not isinstance(obj, Mapping):
571            raise self.input_error_type(
572                path=path,
573                original_exception=ValueError(
574                    f"YAML input has type '{type(obj).__name__}',"
575                    " not a valid Arvados object"
576                )
577            )
578        return obj

Subclass of editing process tuned for YAML files.

input_error_type = functools.partial(<class 'EditingContentError'>, file_type='YAML')
def serialize(self, obj: Mapping[str, typing.Any], file: <class 'TextIO'>) -> None:
552    def serialize(self, obj: Mapping[str, Any], file: TextIO) -> None:
553        return yaml.dump(obj, file)

Abstract method for serializing any object obj to the file-like object file as text.

def deserialize(self, file: <class 'TextIO'>) -> Mapping[str, typing.Any]:
555    def deserialize(self, file: TextIO) -> Mapping[str, Any]:
556        path = getattr(file, "name", "<unknown path>")
557        try:
558            obj = yaml.load(file)
559        except YAMLError as err:
560            if problem_mark := getattr(err, "problem_mark", None):
561                line = getattr(problem_mark, "line", 0)
562                column = getattr(problem_mark, "column", 0)
563            else:
564                line = 0
565                column = 0
566            raise self.input_error_type(
567                path=path, line=line, column=column,
568                original_exception=err
569            )
570        if not isinstance(obj, Mapping):
571            raise self.input_error_type(
572                path=path,
573                original_exception=ValueError(
574                    f"YAML input has type '{type(obj).__name__}',"
575                    " not a valid Arvados object"
576                )
577            )
578        return obj

Abstract method for loading from the file-like object file as text. Returns the object deserialized from the text content.

class FullHelpOnErrorArgumentParser(argparse.ArgumentParser):
581class FullHelpOnErrorArgumentParser(argparse.ArgumentParser):
582    """Argument parser subclass that customizes the `error()` method.
583
584    Intended to be used as a base to a parser with complex subparsers, to print
585    more-useful information when a required subcommand is missing.
586    """
587    def error(self, message, with_help=True):
588        if with_help:
589            self.print_help(sys.stderr)
590            print(file=sys.stderr)
591        # NOTE: self.prog is to be overridden by child class
592        print(f"{self.prog}: error: {message}", file=sys.stderr)
593        sys.exit(2)

Argument parser subclass that customizes the error() method.

Intended to be used as a base to a parser with complex subparsers, to print more-useful information when a required subcommand is missing.

def error(self, message, with_help=True):
587    def error(self, message, with_help=True):
588        if with_help:
589            self.print_help(sys.stderr)
590            print(file=sys.stderr)
591        # NOTE: self.prog is to be overridden by child class
592        print(f"{self.prog}: error: {message}", file=sys.stderr)
593        sys.exit(2)

error(message: string)

Prints a usage message incorporating the message to stderr and exits.

If you override this in a subclass, it should not return – it should either exit or raise an exception.

class ArvCLIArgumentParser(FullHelpOnErrorArgumentParser):
596class ArvCLIArgumentParser(FullHelpOnErrorArgumentParser):
597    """Argument parser for `arv` commands.
598    """
599    prog = "arv"
600    global_args = frozenset((
601        "dry_run",
602        "verbose",
603        "format",
604        "subcommand",
605        "method"
606    ))
607    external_command_modules = {
608        "keep ls": "arvados.commands.ls",
609        "keep get": "arvados.commands.get",
610        "keep put": "arvados.commands.put",
611        "keep docker": "arvados.commands.keepdocker",
612        "ws": "arvados.commands.ws",
613        "copy": "arvados.commands.arv_copy"
614    }
615
616    def __init__(self, discovery_document: dict[str, str | dict], **kwargs):
617        """Arguments:
618
619        * discovery_document: dict --- Dict containing the parsed API discovery
620          document; can be obtained as the `_rootDesc` attribute of an
621          Arvados API client object.
622        """
623        super().__init__(
624            description="Arvados command line client",
625            prog=self.prog,
626            **kwargs
627        )
628        # Common flags to the main command.
629        self.add_argument("-n", "--dry-run", action="store_true",
630                          help="Don't actually do anything")
631        self.add_argument("-v", "--verbose", action="store_true",
632                          help="Print some things on stderr")
633        # Default output format is JSON, while "-s" or "--short" can be
634        # used as a shorthand for "--format=uuid". If both are specified, the
635        # last one takes effect.
636        self.add_argument(
637            "-f", "--format",
638            choices=["json", "yaml", "uuid"],
639            type=str.lower,
640            default="json",
641            help="Set output format"
642        )
643        self.add_argument(
644            "-s", "--short",
645            dest="format",
646            action="store_const", const="uuid",
647            help="Return only UUIDs (equivalent to --format=uuid)"
648        )
649
650        subparsers = self.add_subparsers(
651            dest="subcommand",
652            description="Available subcommands and resources",
653            required=True,
654            metavar="subcommand",  # Suppress huge list in help message.
655            parser_class=FullHelpOnErrorArgumentParser
656        )
657
658        keep_methods = ["ls", "get", "put", "docker"]
659        keep_parser = subparsers.add_parser(
660            "keep", help="Arvados Keep client", add_help=False,
661            epilog=f"available methods: {', '.join(keep_methods)}"
662        )
663        keep_parser.add_argument(
664            "method",
665            metavar="METHOD",
666            choices=keep_methods
667        )
668
669        subparsers.add_parser(
670            "ws", help="Arvados WebSocket client", add_help=False
671        )
672        subparsers.add_parser(
673            "copy",
674            help=(
675                "Copy collection, workflow, or project between Arvados"
676                " instances"
677            ),
678            add_help=False
679        )
680
681        self.subparsers = subparsers
682        self.discovery_document = discovery_document
683        # Work around googleapiclient's mutation of _rootDesc/_resourceDesc
684        # dicts when a resource is created. For instance, currently (as of
685        # 2026-06-03) "configs.get" resource-method's parameters get mutated at
686        # init time of the API client object (as a side-effect of getting the
687        # default storage classes for its KeepClient object).
688        self._ignored_parameters = frozenset(
689            discovery_document.get("parameters", {}).keys()
690            | discovery.STACK_QUERY_PARAMETERS
691        )
692        self.resource_schemas = discovery_document.get("resources", {})
693        self._subparser_index = {}
694        self._subcommand_to_resource = {}
695
696        self.add_resource_subcommands()
697
698        if "sys" in self._subcommand_to_resource:
699            self._subcommand_to_resource["sy"] = (
700                self._subcommand_to_resource["sys"]
701            )
702
703        self.uuid_parser = functools.partial(
704            _ArgTypes.UUIDInfo.parse,
705            _ArgUtil.make_uuid_to_resource_map(
706                self.discovery_document.get("schemas", {})
707            )
708        )
709
710        self.add_editor_subcommands()
711        self.add_get_subcommand()
712
713    def add_resource_subcommands(self):
714        """Add resources as subcommands, their associated methods as
715        sub-subcommands, and the parameters associated with each method.
716        """
717        for resource, resource_schema in self.resource_schemas.items():
718            subcommand = _ArgUtil.singularize_resource(resource)
719            self._subcommand_to_resource[subcommand] = resource
720            # XXX: Below, "{resource}" can be a "word" like
721            # "api_client_authorizations" that doesn't read well; consider
722            # retrieving more natural-language-flavored description from the
723            # "schema" portion of the discovery doc?
724            subcommand_summary = f"Resource subcommand for {resource}"
725            resource_subparser = self.subparsers.add_parser(
726                subcommand,
727                help=subcommand_summary,
728                description=subcommand_summary,
729                # For backward compatibility with legacy Ruby CLI client.
730                aliases=["sy"] if subcommand == "sys" else []
731            )
732            methods_dict = resource_schema.get("methods")
733            if methods_dict:
734                # Create a collection of "sub-subparsers" under the resource
735                # subparser for the methods.
736                method_subparsers = resource_subparser.add_subparsers(
737                    title="methods",
738                    dest="method",
739                    parser_class=FullHelpOnErrorArgumentParser,
740                    required=True,
741                    help=f"Methods for subcommand '{subcommand}'"
742                )
743                for method, method_schema in methods_dict.items():
744                    # Add each specific method as a (sub-)subparser with its
745                    # associated parameters.
746                    method_summary = method_schema.get("description")
747                    method_parser = method_subparsers.add_parser(
748                        method,
749                        description=method_summary,
750                        help=method_summary
751                    )
752                    for parameter_names, kwargs in _ArgUtil.get_method_options(
753                        method_schema,
754                        ignored_parameters=self._ignored_parameters
755                    ):
756                        method_parser.add_argument(*parameter_names, **kwargs)
757
758    def add_editor_subcommands(self):
759        """Add the "create" and "edit" subcommands."""
760        # Only those resources that support a "create" method can be valid
761        # for the "create" subcommand.
762        creatable_targets = set()
763        for cli_name, resource in self._subcommand_to_resource.items():
764            if "create" in self.resource_schemas[resource].get("methods", {}):
765                creatable_targets.add(cli_name)
766        create_parser = self.subparsers.add_parser(
767            "create", help="Create Arvados object using external editor"
768        )
769        create_parser.add_argument(
770            "target_resource",
771            choices=sorted(creatable_targets),
772            metavar="RESOURCE",
773            help="Type of the resource to be created"
774        )
775        create_parser.add_argument(
776            "--project-uuid", "-p",
777            type=_ArgTypes.group_uuid,
778            metavar="UUID",
779            help="UUID of the project in which to create the resource"
780        )
781
782        edit_parser = self.subparsers.add_parser(
783            "edit", help="Edit Arvados object using external editor"
784        )
785        edit_parser.add_argument(
786            "uuid_info",
787            help="UUID of the object to be edited", metavar="UUID",
788            type=self.uuid_parser
789        )
790        edit_parser.add_argument(
791            "fields", nargs="*",
792            type=str.lower,  # "type" applies to individual items.
793            help="Fields to be edited (case-insensitive)"
794        )
795
796    def add_get_subcommand(self):
797        get_parser = self.subparsers.add_parser(
798            "get", help=(
799                "Fetch the specified Arvados object, select the specified"
800                " fields, and print a text representation"
801            )
802        )
803        get_parser.add_argument(
804            "uuid_info",
805            help="UUID of the object to be fetched", metavar="UUID",
806            type=self.uuid_parser
807        )
808        get_parser.add_argument(
809            "fields", nargs="*",
810            type=str.lower,
811            help="Fields to be fetched (case-insensitive)"
812        )

Argument parser for arv commands.

ArvCLIArgumentParser(discovery_document: dict[str, str | dict], **kwargs)
616    def __init__(self, discovery_document: dict[str, str | dict], **kwargs):
617        """Arguments:
618
619        * discovery_document: dict --- Dict containing the parsed API discovery
620          document; can be obtained as the `_rootDesc` attribute of an
621          Arvados API client object.
622        """
623        super().__init__(
624            description="Arvados command line client",
625            prog=self.prog,
626            **kwargs
627        )
628        # Common flags to the main command.
629        self.add_argument("-n", "--dry-run", action="store_true",
630                          help="Don't actually do anything")
631        self.add_argument("-v", "--verbose", action="store_true",
632                          help="Print some things on stderr")
633        # Default output format is JSON, while "-s" or "--short" can be
634        # used as a shorthand for "--format=uuid". If both are specified, the
635        # last one takes effect.
636        self.add_argument(
637            "-f", "--format",
638            choices=["json", "yaml", "uuid"],
639            type=str.lower,
640            default="json",
641            help="Set output format"
642        )
643        self.add_argument(
644            "-s", "--short",
645            dest="format",
646            action="store_const", const="uuid",
647            help="Return only UUIDs (equivalent to --format=uuid)"
648        )
649
650        subparsers = self.add_subparsers(
651            dest="subcommand",
652            description="Available subcommands and resources",
653            required=True,
654            metavar="subcommand",  # Suppress huge list in help message.
655            parser_class=FullHelpOnErrorArgumentParser
656        )
657
658        keep_methods = ["ls", "get", "put", "docker"]
659        keep_parser = subparsers.add_parser(
660            "keep", help="Arvados Keep client", add_help=False,
661            epilog=f"available methods: {', '.join(keep_methods)}"
662        )
663        keep_parser.add_argument(
664            "method",
665            metavar="METHOD",
666            choices=keep_methods
667        )
668
669        subparsers.add_parser(
670            "ws", help="Arvados WebSocket client", add_help=False
671        )
672        subparsers.add_parser(
673            "copy",
674            help=(
675                "Copy collection, workflow, or project between Arvados"
676                " instances"
677            ),
678            add_help=False
679        )
680
681        self.subparsers = subparsers
682        self.discovery_document = discovery_document
683        # Work around googleapiclient's mutation of _rootDesc/_resourceDesc
684        # dicts when a resource is created. For instance, currently (as of
685        # 2026-06-03) "configs.get" resource-method's parameters get mutated at
686        # init time of the API client object (as a side-effect of getting the
687        # default storage classes for its KeepClient object).
688        self._ignored_parameters = frozenset(
689            discovery_document.get("parameters", {}).keys()
690            | discovery.STACK_QUERY_PARAMETERS
691        )
692        self.resource_schemas = discovery_document.get("resources", {})
693        self._subparser_index = {}
694        self._subcommand_to_resource = {}
695
696        self.add_resource_subcommands()
697
698        if "sys" in self._subcommand_to_resource:
699            self._subcommand_to_resource["sy"] = (
700                self._subcommand_to_resource["sys"]
701            )
702
703        self.uuid_parser = functools.partial(
704            _ArgTypes.UUIDInfo.parse,
705            _ArgUtil.make_uuid_to_resource_map(
706                self.discovery_document.get("schemas", {})
707            )
708        )
709
710        self.add_editor_subcommands()
711        self.add_get_subcommand()

Arguments:

  • discovery_document: dict — Dict containing the parsed API discovery document; can be obtained as the _rootDesc attribute of an Arvados API client object.
prog = 'arv'
global_args = frozenset({'verbose', 'dry_run', 'subcommand', 'format', 'method'})
external_command_modules = {'keep ls': 'arvados.commands.ls', 'keep get': 'arvados.commands.get', 'keep put': 'arvados.commands.put', 'keep docker': 'arvados.commands.keepdocker', 'ws': 'arvados.commands.ws', 'copy': 'arvados.commands.arv_copy'}
subparsers
discovery_document
resource_schemas
uuid_parser
def add_resource_subcommands(self):
713    def add_resource_subcommands(self):
714        """Add resources as subcommands, their associated methods as
715        sub-subcommands, and the parameters associated with each method.
716        """
717        for resource, resource_schema in self.resource_schemas.items():
718            subcommand = _ArgUtil.singularize_resource(resource)
719            self._subcommand_to_resource[subcommand] = resource
720            # XXX: Below, "{resource}" can be a "word" like
721            # "api_client_authorizations" that doesn't read well; consider
722            # retrieving more natural-language-flavored description from the
723            # "schema" portion of the discovery doc?
724            subcommand_summary = f"Resource subcommand for {resource}"
725            resource_subparser = self.subparsers.add_parser(
726                subcommand,
727                help=subcommand_summary,
728                description=subcommand_summary,
729                # For backward compatibility with legacy Ruby CLI client.
730                aliases=["sy"] if subcommand == "sys" else []
731            )
732            methods_dict = resource_schema.get("methods")
733            if methods_dict:
734                # Create a collection of "sub-subparsers" under the resource
735                # subparser for the methods.
736                method_subparsers = resource_subparser.add_subparsers(
737                    title="methods",
738                    dest="method",
739                    parser_class=FullHelpOnErrorArgumentParser,
740                    required=True,
741                    help=f"Methods for subcommand '{subcommand}'"
742                )
743                for method, method_schema in methods_dict.items():
744                    # Add each specific method as a (sub-)subparser with its
745                    # associated parameters.
746                    method_summary = method_schema.get("description")
747                    method_parser = method_subparsers.add_parser(
748                        method,
749                        description=method_summary,
750                        help=method_summary
751                    )
752                    for parameter_names, kwargs in _ArgUtil.get_method_options(
753                        method_schema,
754                        ignored_parameters=self._ignored_parameters
755                    ):
756                        method_parser.add_argument(*parameter_names, **kwargs)

Add resources as subcommands, their associated methods as sub-subcommands, and the parameters associated with each method.

def add_editor_subcommands(self):
758    def add_editor_subcommands(self):
759        """Add the "create" and "edit" subcommands."""
760        # Only those resources that support a "create" method can be valid
761        # for the "create" subcommand.
762        creatable_targets = set()
763        for cli_name, resource in self._subcommand_to_resource.items():
764            if "create" in self.resource_schemas[resource].get("methods", {}):
765                creatable_targets.add(cli_name)
766        create_parser = self.subparsers.add_parser(
767            "create", help="Create Arvados object using external editor"
768        )
769        create_parser.add_argument(
770            "target_resource",
771            choices=sorted(creatable_targets),
772            metavar="RESOURCE",
773            help="Type of the resource to be created"
774        )
775        create_parser.add_argument(
776            "--project-uuid", "-p",
777            type=_ArgTypes.group_uuid,
778            metavar="UUID",
779            help="UUID of the project in which to create the resource"
780        )
781
782        edit_parser = self.subparsers.add_parser(
783            "edit", help="Edit Arvados object using external editor"
784        )
785        edit_parser.add_argument(
786            "uuid_info",
787            help="UUID of the object to be edited", metavar="UUID",
788            type=self.uuid_parser
789        )
790        edit_parser.add_argument(
791            "fields", nargs="*",
792            type=str.lower,  # "type" applies to individual items.
793            help="Fields to be edited (case-insensitive)"
794        )

Add the “create” and “edit” subcommands.

def add_get_subcommand(self):
796    def add_get_subcommand(self):
797        get_parser = self.subparsers.add_parser(
798            "get", help=(
799                "Fetch the specified Arvados object, select the specified"
800                " fields, and print a text representation"
801            )
802        )
803        get_parser.add_argument(
804            "uuid_info",
805            help="UUID of the object to be fetched", metavar="UUID",
806            type=self.uuid_parser
807        )
808        get_parser.add_argument(
809            "fields", nargs="*",
810            type=str.lower,
811            help="Fields to be fetched (case-insensitive)"
812        )
def dispatch(arguments=None):
1116def dispatch(arguments=None):
1117    api_client = arvados.api("v1")
1118    cmd_parser = ArvCLIArgumentParser(api_client._rootDesc)
1119    args, remaining_args = cmd_parser.parse_known_args(arguments)
1120
1121    # There's always args.subcommand if we reach here, because "subcommand" is
1122    # required by the parser. But "method" may be absent, as is in the case of
1123    # external commands like "ws" or "copy".
1124    method = getattr(args, "method", "")
1125    command_key = f"{args.subcommand} {method}" if method else args.subcommand
1126
1127    # Are we calling an external command?
1128    ext_module = cmd_parser.external_command_modules.get(command_key)
1129    if ext_module is not None:
1130        sys.argv[0] = f"arv {command_key}"
1131        _handle_external_command(ext_module, remaining_args)  # Exits.
1132
1133    # Are we doing an API resource call?
1134    resource = cmd_parser._subcommand_to_resource.get(args.subcommand)
1135    if resource is not None:
1136        # Any further remaining args indicate either malformed or unrecognized
1137        # global args (e.g. "arvcli.py --bad-arg resource method") or undefined
1138        # parameters to a valid resouce-method combination.
1139        if remaining_args:
1140            cmd_parser.error(
1141                f"unrecognized arguments: {', '.join(remaining_args)}\n"
1142                f"Try: {cmd_parser.prog} --help\n"
1143                f"     {cmd_parser.prog} {command_key} --help",
1144                with_help=False
1145            )  # Exits with status 2.
1146        _handle_resource_method(api_client, resource, args)  # Exits.
1147
1148    # Are we starting an external editor program?
1149    if args.subcommand in ("create", "edit"):
1150        if args.format == "uuid":
1151            cmd_parser.error(
1152                "--format=uuid or -s option is not supported when creating or"
1153                " editing Arvados objects with external editor. Please"
1154                " choose --format=json (default) or --format=yaml.",
1155                with_help=False
1156            )  # Exits with status 2.
1157        _handle_external_editor_command(api_client, cmd_parser, args)  # Exits.
1158
1159    # Are we running "arv get"?
1160    if args.subcommand == "get":
1161        if args.format == "uuid":
1162            cmd_parser.error(
1163                "--format=uuid or -s option is not supported for the 'arv get'"
1164                " command. Please choose --format=json (default) or"
1165                " --format=yaml.",
1166                with_help=False
1167            )  # Exits with status 2.
1168        _handle_get_subcommand(api_client, cmd_parser, args)  # Exits.
1169
1170    # NOTE: The code immediately below is not reachable.
1171    raise RuntimeError("Unexpected arguments: {arguments!r}")