Skip to content

harbor_cli.utils

Attributes

BaseModelType = TypeVar('BaseModelType', bound=BaseModel) module-attribute

PREFIX_ID = 'id:' module-attribute

OPTION_QUERY = typer.Option(None, '--query', help='Query parameters to filter the results.') module-attribute

OPTION_SORT = typer.Option(None, '--sort', help="Sorting order of the results. Example: [green]'name,-id'[/] to sort by name ascending and id descending.") module-attribute

OPTION_PAGE_SIZE = typer.Option(10, '--page-size', help='(Advanced) Results to fetch per API call.') module-attribute

OPTION_PAGE = typer.Option(1, '--page', help='(Advanced) Page to begin fetching from.') module-attribute

OPTION_LIMIT = typer.Option(None, '--limit', help='Maximum number of results to fetch.') module-attribute

OPTION_PROJECT_NAME_OR_ID = typer.Option(None, '--project', help=f'Project name or ID. {_USE_ID_HELP}') module-attribute

OPTION_FORCE = typer.Option(False, '--force', help='Force deletion without confirmation.') module-attribute

ARG_PROJECT_NAME = typer.Argument(None, help='Name of the project to use.') module-attribute

ARG_PROJECT_NAME_OR_ID = _arg_project_name_or_id() module-attribute

ARG_PROJECT_NAME_OR_ID_OPTIONAL = _arg_project_name_or_id(None) module-attribute

ARG_REPO_NAME = typer.Argument(help='Name of the repository to use.') module-attribute

ARG_USERNAME_OR_ID = typer.Argument(help=f'Username or ID of the user to use. {_USE_ID_HELP}') module-attribute

ARG_LDAP_GROUP_DN_OR_ID = typer.Argument(help=f'LDAP Group DN or ID of the group to use. {_USE_ID_HELP}') module-attribute

MutableMappingType = TypeVar('MutableMappingType', bound=MutableMapping[Any, Any]) module-attribute

Classes

CommandSummary

Bases: BaseModel

Convenience class for accessing information about a command.

Source code in harbor_cli/models.py
class CommandSummary(BaseModel):
    """Convenience class for accessing information about a command."""

    category: Optional[str] = None  # not part of TyperCommand
    deprecated: bool
    epilog: Optional[str]
    help: str
    hidden: bool
    name: str
    options_metavar: str
    params: List[ParamSummary] = []
    score: int = 0  # match score (not part of TyperCommand)
    short_help: Optional[str]

    @classmethod
    def from_command(
        cls, command: TyperCommand, name: str | None = None, category: str | None = None
    ) -> CommandSummary:
        """Construct a new CommandSummary from a TyperCommand."""
        return cls(
            category=category,
            deprecated=command.deprecated,
            epilog=command.epilog or "",
            help=command.help or "",
            hidden=command.hidden,
            name=name or command.name or "",
            options_metavar=command.options_metavar or "",
            params=[ParamSummary.from_param(p) for p in command.params],
            short_help=command.short_help or "",
        )

    @property
    def help_plain(self) -> str:
        return markup_as_plain_text(self.help)

    @property
    def help_md(self) -> str:
        return markup_to_markdown(self.help)

    @property
    def usage(self) -> str:
        parts = [self.name]

        # Assume arg list is sorted by required/optional
        # `<POSITIONAL_ARG1> <POSITIONAL_ARG2> [OPTIONAL_ARG1] [OPTIONAL_ARG2]`
        for arg in self.arguments:
            metavar = arg.metavar or arg.human_readable_name
            parts.append(metavar)

        # Command with both required and optional options:
        # `--option1 <opt1> --option2 <opt2> [OPTIONS]`
        has_optional = False
        for option in self.options:
            if option.required:
                metavar = option.metavar or option.human_readable_name
                if option.opts:
                    s = f"{max(option.opts)} {metavar}"
                else:
                    # this shouldn't happen, but just in case. A required
                    # option without any opts is not very useful.
                    # NOTE: could raise exception here instead
                    s = metavar
                parts.append(s)
            else:
                has_optional = True
        if has_optional:
            parts.append("[OPTIONS]")

        return " ".join(parts)

    @property
    def options(self) -> List[ParamSummary]:
        return [p for p in self.params if not p.is_argument]

    @property
    def arguments(self) -> List[ParamSummary]:
        return [p for p in self.params if p.is_argument]

Attributes

category: Optional[str] = None class-attribute instance-attribute
deprecated: bool instance-attribute
epilog: Optional[str] instance-attribute
help: str instance-attribute
hidden: bool instance-attribute
name: str instance-attribute
options_metavar: str instance-attribute
params: List[ParamSummary] = [] class-attribute instance-attribute
score: int = 0 class-attribute instance-attribute
short_help: Optional[str] instance-attribute
help_plain: str property
help_md: str property
usage: str property
options: List[ParamSummary] property
arguments: List[ParamSummary] property

Functions

from_command(command: TyperCommand, name: str | None = None, category: str | None = None) -> CommandSummary classmethod

Construct a new CommandSummary from a TyperCommand.

Source code in harbor_cli/models.py
@classmethod
def from_command(
    cls, command: TyperCommand, name: str | None = None, category: str | None = None
) -> CommandSummary:
    """Construct a new CommandSummary from a TyperCommand."""
    return cls(
        category=category,
        deprecated=command.deprecated,
        epilog=command.epilog or "",
        help=command.help or "",
        hidden=command.hidden,
        name=name or command.name or "",
        options_metavar=command.options_metavar or "",
        params=[ParamSummary.from_param(p) for p in command.params],
        short_help=command.short_help or "",
    )

PackageVersion

Bases: NamedTuple

Source code in harbor_cli/utils/utils.py
class PackageVersion(NamedTuple):
    package: str
    min_version: Optional[str] = None
    max_version: Optional[str] = None
    not_version: Optional[str] = None  # NYI

Attributes

package: str instance-attribute
min_version: Optional[str] = None class-attribute instance-attribute
max_version: Optional[str] = None class-attribute instance-attribute
not_version: Optional[str] = None class-attribute instance-attribute

Functions

is_builtin_obj(obj: object) -> bool

Source code in harbor_cli/utils/_types.py
def is_builtin_obj(obj: object) -> bool:
    return obj.__class__.__module__ == "builtins"

exit_err(message: str, code: int = 1, **kwargs: Any) -> NoReturn

Logs a message with ERROR level and exits with the given code (default: 1).

Parameters:

Name Type Description Default
message str

Message to print.

required
code int

Exit code, by default 1

1
**kwargs Any

Additional keyword arguments to pass to the extra dict.

{}
Source code in harbor_cli/output/console.py
def exit_err(message: str, code: int = 1, **kwargs: Any) -> NoReturn:
    """Logs a message with ERROR level and exits with the given
    code (default: 1).

    Parameters
    ----------
    message : str
        Message to print.
    code : int, optional
        Exit code, by default 1
    **kwargs
        Additional keyword arguments to pass to the extra dict.
    """
    error(message, **kwargs)
    raise SystemExit(code)

model_params_from_ctx(ctx: typer.Context, model: Type[BaseModel], filter_none: bool = True) -> dict[str, Any]

Get CLI options from a Typer context that correspond with Pydantic model field names.

Given a command where the function parameter names match the model field names, the function returns a dict of the parameters that are valid for the model.

If filter_none is True, then parameters that are None will be filtered out. This is enabled by default, since most Harbor API model fields are optional, and we want to signal to Pydantic that these fields should be treated as unset rather than set to None.

Examples:

>>> from pydantic import BaseModel
>>> class Foo(BaseModel):
...     foo: str
...     bar: str
>>> foo = Foo(foo="foo", bar="bar")
>>> ctx = typer.Context(...) # some-cmd --bar grok --baz quux
>>> model_params_from_ctx(ctx, Foo)
{"bar": "grok"} # baz is not a valid field for Foo

Parameters:

Name Type Description Default
ctx Context

The Typer context.

required
model Type[BaseModel]

The model to get the parameters for.

required
filter_none bool

Whether to filter out None values, by default True

True

Returns:

Type Description
dict[str, Any]

The model parameters.

Source code in harbor_cli/utils/args.py
def model_params_from_ctx(
    ctx: typer.Context, model: Type[BaseModel], filter_none: bool = True
) -> dict[str, Any]:
    """Get CLI options from a Typer context that correspond with Pydantic
    model field names.

    Given a command where the function parameter names match the
    model field names, the function returns a dict of the parameters
    that are valid for the model.

    If `filter_none` is True, then parameters that are None will be filtered out.
    This is enabled by default, since most Harbor API model fields are optional,
    and we want to signal to Pydantic that these fields should be treated
    as *unset* rather than *set to None*.

    Examples
    --------
    >>> from pydantic import BaseModel
    >>> class Foo(BaseModel):
    ...     foo: str
    ...     bar: str
    >>> foo = Foo(foo="foo", bar="bar")
    >>> ctx = typer.Context(...) # some-cmd --bar grok --baz quux
    >>> model_params_from_ctx(ctx, Foo)
    {"bar": "grok"} # baz is not a valid field for Foo


    Parameters
    ----------
    ctx : typer.Context
        The Typer context.
    model : Type[BaseModel]
        The model to get the parameters for.
    filter_none : bool
        Whether to filter out None values, by default True

    Returns
    -------
    dict[str, Any]
        The model parameters.
    """
    return {
        key: value
        for key, value in ctx.params.items()
        if key in model.model_fields and (not filter_none or value is not None)
    }

create_updated_model(existing: BaseModel, new: Type[BaseModelType], ctx: typer.Context, extra: bool = False, empty_ok: bool = False) -> BaseModelType

Given an existing model instance and another model type, instantiate other model based on the fields of the existing model combined with CLI args passed in by the user.

When we call a PUT enpdoint, the API expects the full model definition, but we want to allow the user to only specify the fields they want to update. This function allows us to do that, by taking an existing model fetched via a GET call and updating it with new values from the Typer context.

To further complicate things, Harbor API generally uses a different model definition for updating resources (PUT) than the one fetched from a GET call. For example, fetching information about a project returns a Project object, while updating a project requires a ProjectUpdateReq object.

These models largely contain the same fields, but might have certain deviations. For example, the Project model has a creation_time field, while the ProjectUpdateReq model does not.

This function allows us to create, for example, a ProjectUpdateReq object from a combination of a Project object and CLI args that correspond with the fields of the ProjectUpdateReq model.

See model_params_from_ctx for more information on how the CLI context is used to provide the updated fields for the new model.

Examples:

>>> from pydantic import BaseModel
>>> class Foo(BaseModel):
...     a: Optional[int]
...     b: Optional[str]
...     c: Optional[bool]
>>> class FooUpdateReq(BaseModel):
...     a: Optional[int]
...     b: Optional[str]
...     c: Optional[bool]
...     d: bool = False
>>> foo = Foo(a=1, b="foo", c=True)
>>> # we get a ctx object from a Typer command
>>> ctx = typer.Context(...) # update-foo --a 2 --b bar
>>> foo_update = create_updated_model(foo, FooUpdateReq, ctx)
>>> foo_update
FooUpdateReq(a=2, b='bar', c=True, d=False)
>>> #        ^^^  ^^^^^^^
>>> # We created a FooUpdateReq with the new values from the context

Parameters:

Name Type Description Default
existing BaseModel

The existing model to use as a base.

required
new Type[BaseModelType]

The new model type to construct.

required
ctx Context

The Typer context to get the updated model parameters from.

required
extra bool

Whether to include extra fields set on the existing model.

False
empty_ok bool

Whether to allow the update to be empty. If False, an error will be raised if no parameters are provided to update.

False

Returns:

Type Description
BaseModelType

The updated model.

Source code in harbor_cli/utils/args.py
def create_updated_model(
    existing: BaseModel,
    new: Type[BaseModelType],
    ctx: typer.Context,
    extra: bool = False,
    empty_ok: bool = False,
) -> BaseModelType:
    """Given an existing model instance and another model type, instantiate
    other model based on the fields of the existing model combined with CLI args
    passed in by the user.

    When we call a PUT enpdoint, the API expects the full model definition,
    but we want to allow the user to only specify the fields they want to update.
    This function allows us to do that, by taking an existing model fetched via
    a GET call and updating it with new values from the Typer context.

    To further complicate things, Harbor API generally uses a different model
    definition for updating resources (PUT) than the one fetched from a GET call.
    For example, fetching information about a project returns a Project object,
    while updating a project requires a ProjectUpdateReq object.

    These models largely contain the same fields, but might have certain deviations.
    For example, the Project model has a `creation_time` field, while the
    ProjectUpdateReq model does not.

    This function allows us to create, for example, a ProjectUpdateReq object
    from a combination of a Project object and CLI args that correspond with
    the fields of the ProjectUpdateReq model.

    See [model_params_from_ctx][harbor_cli.utils.args.model_params_from_ctx]
    for more information on how the CLI context is used to provide the updated
    fields for the new model.

    Examples
    --------
    >>> from pydantic import BaseModel
    >>> class Foo(BaseModel):
    ...     a: Optional[int]
    ...     b: Optional[str]
    ...     c: Optional[bool]
    >>> class FooUpdateReq(BaseModel):
    ...     a: Optional[int]
    ...     b: Optional[str]
    ...     c: Optional[bool]
    ...     d: bool = False
    >>> foo = Foo(a=1, b="foo", c=True)
    >>> # we get a ctx object from a Typer command
    >>> ctx = typer.Context(...) # update-foo --a 2 --b bar
    >>> foo_update = create_updated_model(foo, FooUpdateReq, ctx)
    >>> foo_update
    FooUpdateReq(a=2, b='bar', c=True, d=False)
    >>> #        ^^^  ^^^^^^^
    >>> # We created a FooUpdateReq with the new values from the context

    Parameters
    ----------
    existing : BaseModel
        The existing model to use as a base.
    new : Type[BaseModelType]
        The new model type to construct.
    ctx : typer.Context
        The Typer context to get the updated model parameters from.
    extra : bool
        Whether to include extra fields set on the existing model.
    empty_ok: bool
        Whether to allow the update to be empty. If False, an error will be raised
        if no parameters are provided to update.

    Returns
    -------
    BaseModelType
        The updated model.
    """
    # Make sure ctx contains values we can update model with
    params = model_params_from_ctx(ctx, new)
    if not params and not empty_ok:
        exit_err("No parameters provided to update")

    # Cast existing model to dict, update it with the new values
    d = existing.model_dump(include=None if extra else set(new.model_fields))
    d.update(params)
    return new.model_validate(d)

parse_commalist(arg: Optional[List[str]]) -> List[str]

Parses an optional argument that can be specified multiple times, or as a comma-separated string, into a list of strings.

harbor subcmd --arg foo --arg bar,baz will be parsed as: ["foo", "bar", "baz"]

Examples:

>>> parse_commalist(["foo", "bar,baz"])
["foo", "bar", "baz"]
>>> parse_commalist([])
[]
>>> parse_commalist(None)
[]
Source code in harbor_cli/utils/args.py
def parse_commalist(arg: Optional[List[str]]) -> List[str]:
    """Parses an optional argument that can be specified multiple times,
    or as a comma-separated string, into a list of strings.

    `harbor subcmd --arg foo --arg bar,baz`
    will be parsed as: `["foo", "bar", "baz"]`

    Examples
    --------
    ```py
    >>> parse_commalist(["foo", "bar,baz"])
    ["foo", "bar", "baz"]
    >>> parse_commalist([])
    []
    >>> parse_commalist(None)
    []
    ```
    """
    if arg is None:
        return []
    return [item for arg_list in arg for item in arg_list.split(",")]

parse_commalist_int(arg: Optional[List[str]]) -> List[int]

Parses a comma-separated list and converts the values to integers.

Source code in harbor_cli/utils/args.py
def parse_commalist_int(arg: Optional[List[str]]) -> List[int]:
    """Parses a comma-separated list and converts the values to integers."""
    int_list: List[int] = []
    for item in parse_commalist(arg):
        try:
            int_list.append(int(item))
        except ValueError:
            raise ValueError(f"Invalid integer value: {item!r}")
    return int_list

parse_key_value_args(arg: list[str]) -> dict[str, str]

Parses a list of key=value arguments.

Examples:

>>> parse_key_value_args(["foo=bar", "baz=qux"])
{'foo': 'bar', 'baz': 'qux'}

Parameters:

Name Type Description Default
arg list[str]

A list of key=value arguments.

required

Returns:

Type Description
dict[str, str]

A dictionary mapping keys to values.

Source code in harbor_cli/utils/args.py
def parse_key_value_args(arg: list[str]) -> dict[str, str]:
    """Parses a list of key=value arguments.

    Examples
    --------
    >>> parse_key_value_args(["foo=bar", "baz=qux"])
    {'foo': 'bar', 'baz': 'qux'}

    Parameters
    ----------
    arg
        A list of key=value arguments.

    Returns
    -------
    dict[str, str]
        A dictionary mapping keys to values.
    """
    metadata: Dict[str, str] = {}
    for item in arg:
        try:
            key, value = item.split("=", maxsplit=1)
        except ValueError:
            raise typer.BadParameter(
                f"Invalid metadata item {item!r}. Expected format: key=value"
            )
        metadata[key] = value
    return metadata

as_query(**kwargs: Any) -> str

Converts keyword arguments into a query string.

Examples:

>>> as_query(foo="bar", baz="qux")
'foo=bar,baz=qux'
Source code in harbor_cli/utils/args.py
def as_query(**kwargs: Any) -> str:
    """Converts keyword arguments into a query string.

    Examples
    --------
    >>> as_query(foo="bar", baz="qux")
    'foo=bar,baz=qux'
    """
    return ",".join(f"{k}={v}" for k, v in kwargs.items())

construct_query_list(*values: Any, union: bool = True, allow_empty: bool = False, comma: bool = False) -> str

Given a key and a list of values, returns a harbor API query string with values as a list with union or intersection relationship (default: union).

Falsey values are ignored if allow_empty is False (default).

Examples:

>>> construct_query_list("foo", "bar", "baz", union=True)
'{foo bar baz}'
>>> construct_query_list("foo", "bar", "baz", union=False)
'(foo bar baz)'
>>> construct_query_list("", "bar", "baz")
'{bar baz}'
>>> construct_query_list("", "bar", "baz", allow_empty=True)
'{ bar baz}'
>>> construct_query_list("", "bar", "baz", comma=True)
'{bar,baz}'
Source code in harbor_cli/utils/args.py
def construct_query_list(
    *values: Any,
    union: bool = True,
    allow_empty: bool = False,
    comma: bool = False,
) -> str:
    """Given a key and a list of values, returns a harbor API
    query string with values as a list with union or intersection
    relationship (default: union).

    Falsey values are ignored if allow_empty is False (default).

    Examples
    --------
    >>> construct_query_list("foo", "bar", "baz", union=True)
    '{foo bar baz}'
    >>> construct_query_list("foo", "bar", "baz", union=False)
    '(foo bar baz)'
    >>> construct_query_list("", "bar", "baz")
    '{bar baz}'
    >>> construct_query_list("", "bar", "baz", allow_empty=True)
    '{ bar baz}'
    >>> construct_query_list("", "bar", "baz", comma=True)
    '{bar,baz}'
    """
    if len(values) < 2:
        return str(values[0] if values else "")
    start = "{" if union else "("
    end = "}" if union else ")"
    sep = "," if comma else " "
    return f"{start}{sep.join(str(v) for v in values if v or allow_empty)}{end}"

deconstruct_query_list(qlist: str) -> list[str]

Given a harbor API query string with values as a list (either union and intersection), returns a list of values. Will break if values contain spaces.

Examples:

>>> deconstruct_query_list("{foo bar baz}")
['foo', 'bar', 'baz']
>>> deconstruct_query_list("(foo bar baz)")
['foo', 'bar', 'baz']
>>> deconstruct_query_list("{}")
[]
Source code in harbor_cli/utils/args.py
def deconstruct_query_list(qlist: str) -> list[str]:
    """Given a harbor API query string with values as a list (either union
    and intersection), returns a list of values. Will break if values
    contain spaces.

    Examples
    --------
    >>> deconstruct_query_list("{foo bar baz}")
    ['foo', 'bar', 'baz']
    >>> deconstruct_query_list("(foo bar baz)")
    ['foo', 'bar', 'baz']
    >>> deconstruct_query_list("{}")
    []
    """
    # TODO: add comma support
    values = qlist.strip("{}()").split(" ")
    return [v for v in values if v]

add_to_query(query: str | None, **kwargs: str | list[str] | None) -> str

Given a query string and a set of keyword arguments, returns a new query string with the keyword arguments added to it. Keyword arguments that are already present in the query string will be overwritten.

Always returns a string, even if the resulting query string is empty.

TODO: allow fuzzy matching, e.g. foo=~bar

Examples:

>>> add_to_query("foo=bar", baz="qux")
'foo=bar,baz=qux'
>>> add_to_query("foo=bar", foo="baz")
'foo=baz'
>>> add_to_query(None, foo="baz")
'foo=baz'
>>> add_to_query("foo=foo", foo="bar")
'foo={foo bar}'
Source code in harbor_cli/utils/args.py
def add_to_query(query: str | None, **kwargs: str | list[str] | None) -> str:
    """Given a query string and a set of keyword arguments, returns a
    new query string with the keyword arguments added to it. Keyword
    arguments that are already present in the query string will be
    overwritten.

    Always returns a string, even if the resulting query string is empty.

    TODO: allow fuzzy matching, e.g. foo=~bar

    Examples
    --------
    >>> add_to_query("foo=bar", baz="qux")
    'foo=bar,baz=qux'
    >>> add_to_query("foo=bar", foo="baz")
    'foo=baz'
    >>> add_to_query(None, foo="baz")
    'foo=baz'
    >>> add_to_query("foo=foo", foo="bar")
    'foo={foo bar}'
    """
    query_items = parse_commalist([query] if query else [])
    query_dict = parse_key_value_args(query_items)
    for k, v in kwargs.items():
        # Empty string, empty list, None, etc. are all ignored
        if not v:
            continue

        # Remove empty list or otherwise absent value for key if exists
        query_val = query_dict.get(k, None)
        if query_val is not None and any(query_val.startswith(c) for c in ["{", "("]):
            # Query dict contains empty list (invalid), so we just remove it
            # TODO: respect union/intersection list type
            del query_dict[k]

        # When the query already has a value for the given key, we need to
        # convert the value to a list if isn't already one.
        if k in query_dict:
            if isinstance(v, list):
                query_dict[k] = construct_query_list(query_dict[k], *v)
            else:
                query_dict[k] = construct_query_list(
                    *deconstruct_query_list(query_dict[k]),
                    v,
                )
        else:  # doesn't exist in query
            if isinstance(v, str):
                query_dict[k] = v
            elif len(v) > 1:
                query_dict[k] = construct_query_list(*v)
            else:
                query_dict[k] = v[0]
    return as_query(**query_dict)

get_project_arg(project_name_or_id: str) -> str | int

Given a project name or ID argument (prefixed with 'id:'), return a project name (str) or project ID (int).

Source code in harbor_cli/utils/args.py
def get_project_arg(project_name_or_id: str) -> str | int:
    """Given a project name or ID argument (prefixed with 'id:'),
    return a project name (str) or project ID (int).
    """
    return _get_id_name_arg("project", project_name_or_id)

get_user_arg(username_or_id: str) -> str | int

Given a username or ID argument (prefixed with 'id:'), return a username (str) or user ID (int).

Source code in harbor_cli/utils/args.py
def get_user_arg(username_or_id: str) -> str | int:
    """Given a username or ID argument (prefixed with 'id:'),
    return a username (str) or user ID (int).
    """
    return _get_id_name_arg("user", username_or_id)

get_ldap_group_arg(group_dn_or_id: str) -> str | int

Source code in harbor_cli/utils/args.py
def get_ldap_group_arg(group_dn_or_id: str) -> str | int:
    return _get_id_name_arg("LDAP Group", group_dn_or_id)

render_cli_value(value: Any) -> str

Render a CLI value/argument.

Source code in harbor_cli/style/style.py
def render_cli_value(value: Any) -> str:
    """Render a CLI value/argument."""
    return f"[{STYLE_CLI_VALUE}]{value!r}[/]"

get_parent_ctx(ctx: typer.Context | click.core.Context) -> typer.Context | click.core.Context

Get the top-level parent context of a context.

Source code in harbor_cli/utils/commands.py
def get_parent_ctx(
    ctx: typer.Context | click.core.Context,
) -> typer.Context | click.core.Context:
    """Get the top-level parent context of a context."""
    if ctx.parent is None:
        return ctx
    return get_parent_ctx(ctx.parent)

get_command_help(command: typer.models.CommandInfo) -> str

Get the help text of a command.

Source code in harbor_cli/utils/commands.py
def get_command_help(command: typer.models.CommandInfo) -> str:
    """Get the help text of a command."""
    if command.help:
        return command.help
    if command.callback and command.callback.__doc__:
        lines = command.callback.__doc__.strip().splitlines()
        if lines:
            return lines[0]
    if command.short_help:
        return command.short_help
    return ""

get_app_commands(app: typer.Typer) -> list[CommandSummary] cached

Get a list of commands from a typer app.

Source code in harbor_cli/utils/commands.py
@lru_cache(maxsize=None)
def get_app_commands(app: typer.Typer) -> list[CommandSummary]:
    """Get a list of commands from a typer app."""
    return _get_app_commands(app)

get_app_callback_options(app: typer.Typer) -> list[typer.models.OptionInfo]

Get the options of the main callback of a Typer app.

Source code in harbor_cli/utils/commands.py
def get_app_callback_options(app: typer.Typer) -> list[typer.models.OptionInfo]:
    """Get the options of the main callback of a Typer app."""
    options: List[typer.models.OptionInfo] = []

    if not app.registered_callback:
        return options

    callback = app.registered_callback.callback

    if not callback:
        return options
    if not hasattr(callback, "__defaults__") or not callback.__defaults__:
        return options

    for option in callback.__defaults__:
        options.append(option)
    return options

inject_help(model: Type[BaseModel], strict: bool = False, remove: Optional[List[str]] = None, **field_additions: str) -> Any

Injects a Pydantic model's field descriptions into the help attributes of Typer.Option() function parameters whose names match the field names.

Examples:

class MyModel(BaseModel):
    my_field: str = Field(..., description="Description of my_field")

@app.command(name="my-command")
@inject_help(MyModel)
def my_command(my_field: str = typer.Option(...)):
    ...

# `my-app my-command --help`
# my_field's help text will be "Description of my_field"
NOTE

Does not modify the help text of options with existing help text! Use the **field_additions parameter to add additional help text to a field in addition to the field's description. This text is appended to the help text, separated by a space.

e.g. @inject_help(MyModel, my_field="Additional help text that is appended to the field's description.")

Parameters:

Name Type Description Default
model Type[BaseModel]

The pydantic model to use for help injection.

required
strict bool

If True, fail if a field in the model does not correspond to a function parameter of the same name with a typer.OptionInfo as a default value.

False
remove Optional[List[str]]

List of strings to remove from descriptions before injecting them.

None
**field_additions str

Additional help text to add to the help attribute of a field. The parameter name should be the name of the field, and the value should be the additional help text to add. This is useful when the field's description is not sufficient, and you want to add additional help text to supplement the existing description.

{}
Source code in harbor_cli/utils/commands.py
def inject_help(
    model: Type[BaseModel],
    strict: bool = False,
    remove: Optional[List[str]] = None,
    **field_additions: str,
) -> Any:
    """
    Injects a Pydantic model's field descriptions into the help attributes
    of Typer.Option() function parameters whose names match the field names.

    Examples
    --------
    ```python
    class MyModel(BaseModel):
        my_field: str = Field(..., description="Description of my_field")

    @app.command(name="my-command")
    @inject_help(MyModel)
    def my_command(my_field: str = typer.Option(...)):
        ...

    # `my-app my-command --help`
    # my_field's help text will be "Description of my_field"
    ```

    NOTE
    ----
    Does not modify the help text of options with existing help text!
    Use the `**field_additions` parameter to add additional help text to a field
    in addition to the field's description. This text is appended to the
    help text, separated by a space.

    e.g. `@inject_help(MyModel, my_field="Additional help text that is appended to the field's description.")`

    Parameters
    ----------
    model : Type[BaseModel]
        The pydantic model to use for help injection.
    strict : bool
        If True, fail if a field in the model does not correspond to a function
        parameter of the same name with a typer.OptionInfo as a default value.
    remove: Optional[List[str]]
        List of strings to remove from descriptions before injecting them.
    **field_additions
        Additional help text to add to the help attribute of a field.
        The parameter name should be the name of the field, and the value
        should be the additional help text to add. This is useful when
        the field's description is not sufficient, and you want to add
        additional help text to supplement the existing description.
    """

    def decorator(func: Any) -> Any:
        sig = inspect.signature(func)
        for field_name, field in model.model_fields.items():
            # only overwrite help if not already set
            param = sig.parameters.get(field_name, None)
            if not param:
                if strict:
                    raise ValueError(
                        f"Field {field_name!r} not found in function signature of {func.__qualname__!r}."
                    )
                continue
            if not hasattr(param, "default") or not hasattr(param.default, "help"):
                continue
            if not param.default.help:
                addition = field_additions.get(field_name, "")
                if addition:
                    addition = f" {addition}"  # add leading space
                description = field.description or ""
                if remove:
                    for to_remove in remove:
                        # Could this be faster with a regex?
                        description = description.replace(to_remove, "")
                description = description.strip()
                param.default.help = f"{description}{addition}"
        return func

    return decorator

inject_resource_options(f: Any = None, *, strict: bool = False, use_defaults: bool = True) -> Any

Decorator that calls inject_query, inject_sort, inject_page_size, inject_page and inject_limit to inject typer.Option() defaults for common options used when querying multiple resources.

NOTE: needs to be specified BEFORE @app.command() in order to work!

Not strict by default, so that it can be used on functions that only have a subset of the parameters (e.g. only query and sort).

The decorated function should always declare the parameters in the following order if the parameters don't have defaults: query, sort, page, page_size, limit

Examples:

@app.command()
@inject_resource_options()
def my_command(query: str, sort: str, page: int, page_size: int, limit: Optional[int]):
    ...

# OK
@app.command()
@inject_resource_options()
def my_command(query: str, sort: str):
    ...

# NOT OK (missing all required parameters)
@app.command()
@inject_resource_options(strict=True)
def my_command(query: str, sort: str):
    ...

# OK (inherits defaults)
@app.command()
@inject_resource_options()
def my_command(query: str, sort: str, page: int = typer.Option(1)):
    ...

# NOT OK (syntax error [non-default param after param with default])
# Use ellipsis to specify unset defaults
@app.command()
@inject_resource_options()
def my_command(query: str = typer.Option("tag=latest"), sort: str, page: int):

# OK (inherit default query, but override others)
# Use ellipsis to specify unset defaults
@app.command()
@inject_resource_options()
def my_command(query: str = typer.Option("my-query"), sort: str = ..., page: int = ...):

Parameters:

Name Type Description Default
f Any

The function to decorate, by default None

None
strict bool

If True, fail if function is missing any of the injected parameters, by default False E.g. all of query, sort, page, page_size, limit must be present

False
use_defaults bool

If True, use the default value specified by a parameter's typer.Option() field as the default value for the parameter, by default True.

True

Returns:

Type Description
Any

The decorated function

Examples:

@inject_resource_options(use_defaults=True)
my_func(page_size: int = typer.Option(20)) -> None: ...
If use_defaults is True, the default value of page_size will be 20, instead of 10, which is the value inject_page_size() would use by default.

Warning

inject_resource_options() only accepts parameter defaults specified with typer.Option() and typer.Argument()!

@inject_resource_options(use_default=True)
my_func(page_size: int = 20) -> None: ... # will fail (for now)
Source code in harbor_cli/utils/commands.py
def inject_resource_options(
    f: Any = None, *, strict: bool = False, use_defaults: bool = True
) -> Any:
    """Decorator that calls inject_query, inject_sort, inject_page_size,
    inject_page and inject_limit to inject typer.Option() defaults
    for common options used when querying multiple resources.

    NOTE: needs to be specified BEFORE @app.command() in order to work!

    Not strict by default, so that it can be used on functions that only
    have a subset of the parameters (e.g. only query and sort).

    The decorated function should always declare the parameters in the following order
    if the parameters don't have defaults:
    `query`, `sort`, `page`, `page_size`, `limit`

    Examples
    --------
    ```python
    @app.command()
    @inject_resource_options()
    def my_command(query: str, sort: str, page: int, page_size: int, limit: Optional[int]):
        ...

    # OK
    @app.command()
    @inject_resource_options()
    def my_command(query: str, sort: str):
        ...

    # NOT OK (missing all required parameters)
    @app.command()
    @inject_resource_options(strict=True)
    def my_command(query: str, sort: str):
        ...

    # OK (inherits defaults)
    @app.command()
    @inject_resource_options()
    def my_command(query: str, sort: str, page: int = typer.Option(1)):
        ...

    # NOT OK (syntax error [non-default param after param with default])
    # Use ellipsis to specify unset defaults
    @app.command()
    @inject_resource_options()
    def my_command(query: str = typer.Option("tag=latest"), sort: str, page: int):

    # OK (inherit default query, but override others)
    # Use ellipsis to specify unset defaults
    @app.command()
    @inject_resource_options()
    def my_command(query: str = typer.Option("my-query"), sort: str = ..., page: int = ...):
    ```

    Parameters
    ----------
    f : Any, optional
        The function to decorate, by default None
    strict : bool, optional
        If True, fail if function is missing any of the injected parameters, by default False
        E.g. all of `query`, `sort`, `page`, `page_size`, `limit` must be present
    use_defaults : bool, optional
        If True, use the default value specified by a parameter's typer.Option() field
        as the default value for the parameter, by default True.

    Returns
    -------
    Any
        The decorated function

    Examples
    --------
    ```python
    @inject_resource_options(use_defaults=True)
    my_func(page_size: int = typer.Option(20)) -> None: ...
    ```
    If use_defaults is True, the default value of page_size will be 20,
    instead of 10, which is the value inject_page_size() would use by default.
    !!! warning
        `inject_resource_options()` only accepts parameter defaults specified with typer.Option() and typer.Argument()!

    ```python
    @inject_resource_options(use_default=True)
    my_func(page_size: int = 20) -> None: ... # will fail (for now)
    ```
    """
    # TODO: add check that the function signature is in the correct order
    # so we don't raise a cryptic error message later on!

    def decorator(func: Any) -> Any:
        # Inject in reverse order, because parameters with defaults
        # can't be followed by parameters without defaults
        for inject in [
            inject_limit,
            inject_page_size,
            inject_page,
            inject_sort,
            inject_query,
        ]:
            func = inject(func, strict=strict, use_default=use_defaults)
        return func

    # Support using plain @inject_resource_options or @inject_resource_options()
    if callable(f):
        return decorator(f)
    else:
        return decorator

inject_query(f: Any = None, *, strict: bool = False, use_default: bool = True) -> Any

Source code in harbor_cli/utils/commands.py
def inject_query(
    f: Any = None, *, strict: bool = False, use_default: bool = True
) -> Any:
    def decorator(func: Any) -> Any:
        return _patch_param(func, "query", OPTION_QUERY, strict, use_default)

    # Support using plain @inject_query or @inject_query()
    if callable(f):
        return decorator(f)
    else:
        return decorator

inject_sort(f: Any = None, *, strict: bool = False, use_default: bool = True) -> Any

Source code in harbor_cli/utils/commands.py
def inject_sort(
    f: Any = None, *, strict: bool = False, use_default: bool = True
) -> Any:
    def decorator(func: Any) -> Any:
        return _patch_param(func, "sort", OPTION_SORT, strict, use_default)

    # Support using plain @inject_sort or @inject_sort()
    if callable(f):
        return decorator(f)
    else:
        return decorator

inject_page_size(f: Any = None, *, strict: bool = False, use_default: bool = True) -> Any

Source code in harbor_cli/utils/commands.py
def inject_page_size(
    f: Any = None, *, strict: bool = False, use_default: bool = True
) -> Any:
    def decorator(func: Any) -> Any:
        return _patch_param(func, "page_size", OPTION_PAGE_SIZE, strict, use_default)

    # Support using plain @inject_page_size or @inject_page_size()
    if callable(f):
        return decorator(f)
    else:
        return decorator

inject_page(f: Any = None, *, strict: bool = False, use_default: bool = True) -> Any

Source code in harbor_cli/utils/commands.py
def inject_page(
    f: Any = None, *, strict: bool = False, use_default: bool = True
) -> Any:
    def decorator(func: Any) -> Any:
        return _patch_param(func, "page", OPTION_PAGE, strict, use_default)

    # Support using plain @inject_page or @inject_page()
    if callable(f):
        return decorator(f)
    else:
        return decorator

inject_limit(f: Any = None, *, strict: bool = False, use_default: bool = False) -> Any

Source code in harbor_cli/utils/commands.py
def inject_limit(
    f: Any = None, *, strict: bool = False, use_default: bool = False
) -> Any:
    def decorator(func: Any) -> Any:
        return _patch_param(func, "limit", OPTION_LIMIT, strict, use_default)

    # Support using plain @inject_page or @inject_page()
    if callable(f):
        return decorator(f)
    else:
        return decorator

inject_project_name(f: Any = None, *, strict: bool = False, use_default: bool = True) -> Any

Source code in harbor_cli/utils/commands.py
def inject_project_name(
    f: Any = None, *, strict: bool = False, use_default: bool = True
) -> Any:
    def decorator(func: Any) -> Any:
        return _patch_param(func, "project_name", ARG_PROJECT_NAME, strict, use_default)

    # Support using plain @inject_query or @inject_query()
    if callable(f):
        return decorator(f)
    else:
        return decorator

replace_none(d: Optional[MutableMappingType], replacement: Any = '') -> MutableMappingType

Replaces None values in a dict with a given replacement value. Iterates recursively through nested dicts and iterables.

Untested with iterables other than list, tuple, and set.

Source code in harbor_cli/utils/utils.py
def replace_none(
    d: Optional[MutableMappingType], replacement: Any = ""
) -> MutableMappingType:
    """Replaces None values in a dict with a given replacement value.
    Iterates recursively through nested dicts and iterables.

    Untested with iterables other than list, tuple, and set.
    """

    if d is None:
        return replacement

    def _try_convert_to_original_type(
        value: Iterable[Any], original_type: type
    ) -> Iterable[Any]:
        """Try to convert an iterable to the original type.
        If the original type cannot be constructed with an iterable as
        the only argument, return a list instead.
        """
        try:
            return original_type(value)
        except TypeError:
            return list(value)

    def _iter_iterable(value: Iterable[Any]) -> Iterable[Any]:
        """Iterates through an iterable recursively, replacing None values."""
        t = type(value)
        v_generator = (item if item is not None else replacement for item in value)
        values: List[Any] = []

        for item in v_generator:
            v = None
            if isinstance(item, MutableMapping):
                v = replace_none(  # pyright: ignore[reportUnknownVariableType]
                    item  # pyright: ignore[reportUnknownArgumentType]
                )
            elif isinstance(item, str):  # don't need to recurse into each char
                v = item
            elif isinstance(item, Iterable):
                v = _iter_iterable(item)  # pyright: ignore[reportUnknownArgumentType]
            else:
                v = item
            values.append(v)
        if values:
            return _try_convert_to_original_type(values, t)
        else:
            return value

    for key, value in d.items():
        key = str(key)
        if isinstance(value, MutableMapping):
            d[key] = replace_none(value)  # pyright: ignore[reportUnknownArgumentType]
        elif isinstance(value, str):
            d[key] = value
        elif isinstance(value, Iterable):
            d[key] = _iter_iterable(value)  # pyright: ignore[reportUnknownArgumentType]
        elif value is None:
            d[key] = replacement
    return d

parse_version_string(package: str) -> PackageVersion

Parse a PEP 440 package version string into a PackageVersion tuple.

Must be in the form of <package_name>[{~=,==,!=,<=,>=,<,>}{x.y.z}][,][{~=,==,!=,<=,>=,<,>}{x.y.z}]

Examples:

- "foo"
- "foo==1.2.3"
- "foo>=1.2.3"
- "foo>=1.2.3,<=2.3.4"
Source code in harbor_cli/utils/utils.py
def parse_version_string(package: str) -> PackageVersion:
    """Parse a PEP 440 package version string into a PackageVersion tuple.

    Must be in the form of `<package_name>[{~=,==,!=,<=,>=,<,>}{x.y.z}][,][{~=,==,!=,<=,>=,<,>}{x.y.z}]`

    Examples
    --------
        - "foo"
        - "foo==1.2.3"
        - "foo>=1.2.3"
        - "foo>=1.2.3,<=2.3.4"
    """
    # super dumb parsing, no regex for now
    parts = package.replace(" ", "").split(",")
    if len(parts) > 2:
        raise ValueError("Invalid package version string")
    package_name = parts[0]
    min_version = None
    max_version = None
    not_version = None  # pyright: ignore[reportUnusedVariable] # noqa # NYI

    operators = ["~=", "==", "<=", ">=", "<", ">"]  # no != for now

    p0 = parts[0]
    for op in operators:
        if op not in p0:
            continue
        package_name, version = p0.split(op)
        package_name = package_name.strip(op)
        if op in ["~=", "=="] and op in p0:
            return PackageVersion(
                package_name, min_version=version, max_version=version
            )
        elif op in ["<=", "<"] and op in p0:
            max_version = version
            break
        elif op in [">=", ">"] and op in p0:
            min_version = version
            break
    if len(parts) == 1:
        return PackageVersion(
            package_name, min_version=min_version, max_version=max_version
        )

    # max version
    p1 = parts[1]
    if p1 and p1[0] in operators:
        if not any(p1.startswith(op) for op in ["<=", "<"]):
            raise ValueError("Invalid package version string")
        max_version = p1.strip(string.punctuation)
    return PackageVersion(
        package_name, min_version=min_version, max_version=max_version
    )