function.cli

Standard CLI options for Python composition functions.

Provides reusable options and a run helper so that every composition function shares a standard set of flags, environment variables, and defaults.

Standard flags include --address, --debug, --insecure, --tls-server-certs-dir, gRPC message size limits, and --ttl. Each option also supports a corresponding environment variable (for example ADDRESS, DEBUG, TTL).

Usage in a function's main.py::

import click
from crossplane.function import cli as sdkcli
from function import fn

@click.command()
@sdkcli.standard_options
def cli(**kwargs):
    sdkcli.run(fn.FunctionRunner(), **kwargs)

To add custom options, stack them with the decorator::

@click.command()
@sdkcli.standard_options
@click.option("--cache-size", default=100, envvar="CACHE_SIZE")
def cli(cache_size, **kwargs):
    runner = fn.FunctionRunner(cache_size=cache_size)
    sdkcli.run(runner, **kwargs)
  1# Copyright 2026 The Crossplane Authors.
  2#
  3# Licensed under the Apache License, Version 2.0 (the "License");
  4# you may not use this file except in compliance with the License.
  5# You may obtain a copy of the License at
  6#
  7#     http://www.apache.org/licenses/LICENSE-2.0
  8#
  9# Unless required by applicable law or agreed to in writing, software
 10# distributed under the License is distributed on an "AS IS" BASIS,
 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12# See the License for the specific language governing permissions and
 13# limitations under the License.
 14
 15"""Standard CLI options for Python composition functions.
 16
 17Provides reusable options and a run helper so that every composition
 18function shares a standard set of flags, environment variables, and defaults.
 19
 20Standard flags include ``--address``, ``--debug``, ``--insecure``,
 21``--tls-server-certs-dir``, gRPC message size limits, and ``--ttl``. Each
 22option also supports a corresponding environment variable (for example
 23``ADDRESS``, ``DEBUG``, ``TTL``).
 24
 25Usage in a function's main.py::
 26
 27    import click
 28    from crossplane.function import cli as sdkcli
 29    from function import fn
 30
 31    @click.command()
 32    @sdkcli.standard_options
 33    def cli(**kwargs):
 34        sdkcli.run(fn.FunctionRunner(), **kwargs)
 35
 36To add custom options, stack them with the decorator::
 37
 38    @click.command()
 39    @sdkcli.standard_options
 40    @click.option("--cache-size", default=100, envvar="CACHE_SIZE")
 41    def cli(cache_size, **kwargs):
 42        runner = fn.FunctionRunner(cache_size=cache_size)
 43        sdkcli.run(runner, **kwargs)
 44"""
 45
 46import datetime
 47import functools
 48import re
 49from collections.abc import Callable
 50from typing import TypeVar
 51
 52import click
 53
 54from crossplane.function import logging, response, runtime
 55from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1
 56
 57F = TypeVar("F", bound=Callable)
 58
 59DEFAULT_ADDRESS = "0.0.0.0:9443"
 60DEFAULT_MAX_RECV_MESSAGE_SIZE = 4  # MB
 61
 62_UNIT_TO_SECONDS = {
 63    "s": 1,
 64    "m": 60,
 65    "h": 3600,
 66    "d": 86400,
 67}
 68_DURATION_COMPONENT_RE = re.compile(r"(\d+(?:\.\d+)?)([smhd])")
 69
 70
 71def parse_duration(value: str) -> datetime.timedelta:
 72    """Parse a duration string into a :class:`datetime.timedelta`.
 73
 74    Accepts Go-style duration strings (e.g. ``60s``, ``1m``, ``1h30m``) and bare
 75    integers interpreted as seconds (e.g. ``60``).
 76
 77    Args:
 78        value: The duration string to parse.
 79
 80    Returns:
 81        The parsed duration.
 82
 83    Raises:
 84        ValueError: If the string is empty, invalid, or negative.
 85    """
 86    value = value.strip()
 87    if not value:
 88        msg = "duration must not be empty"
 89        raise ValueError(msg)
 90
 91    if value.isdigit():
 92        return datetime.timedelta(seconds=int(value))
 93
 94    total_seconds = 0.0
 95    pos = 0
 96    for match in _DURATION_COMPONENT_RE.finditer(value):
 97        if match.start() != pos:
 98            msg = f"invalid duration: {value}"
 99            raise ValueError(msg)
100        total_seconds += float(match.group(1)) * _UNIT_TO_SECONDS[match.group(2)]
101        pos = match.end()
102
103    if pos != len(value):
104        msg = f"invalid duration: {value}"
105        raise ValueError(msg)
106
107    if total_seconds < 0:
108        msg = "duration must not be negative"
109        raise ValueError(msg)
110
111    return datetime.timedelta(seconds=total_seconds)
112
113
114class DurationParamType(click.ParamType):
115    """A Click parameter type that parses duration strings."""
116
117    name = "duration"
118
119    def convert(
120        self,
121        value: object,
122        param: click.Parameter | None,
123        ctx: click.Context | None,
124    ) -> datetime.timedelta:
125        """Convert a CLI value to a :class:`datetime.timedelta`."""
126        if isinstance(value, datetime.timedelta):
127            return value
128        try:
129            return parse_duration(str(value))
130        except ValueError as e:
131            self.fail(str(e), param, ctx)
132
133
134DURATION = DurationParamType()
135
136
137def standard_options(func: F) -> F:
138    """Apply the standard Composition Function CLI options to a Click command."""
139
140    @click.option(
141        "--max-send-message-size",
142        type=int,
143        default=None,
144        envvar="MAX_SEND_MESSAGE_SIZE",
145        help="Maximum size of sent gRPC messages in MB. "
146        "Defaults to --max-recv-message-size.",
147    )
148    @click.option(
149        "--max-recv-message-size",
150        "--max-grpc-message-size",
151        type=int,
152        default=DEFAULT_MAX_RECV_MESSAGE_SIZE,
153        show_default=True,
154        envvar=["MAX_RECV_MESSAGE_SIZE", "MAX_GRPC_MESSAGE_SIZE"],
155        help="Maximum size of received gRPC messages in MB.",
156    )
157    @click.option(
158        "--insecure",
159        is_flag=True,
160        envvar="INSECURE",
161        help="Run without mTLS credentials. "
162        "If you supply this flag --tls-server-certs-dir will be ignored.",
163    )
164    @click.option(
165        "--tls-server-certs-dir",
166        "--tls-certs-dir",
167        "tls_certs_dir",
168        envvar="TLS_SERVER_CERTS_DIR",
169        help="Serve using mTLS certificates.",
170    )
171    @click.option(
172        "--address",
173        default=DEFAULT_ADDRESS,
174        show_default=True,
175        envvar="ADDRESS",
176        help="Address at which to listen for gRPC connections.",
177    )
178    @click.option(
179        "--debug",
180        "-d",
181        is_flag=True,
182        envvar="DEBUG",
183        help="Emit debug logs.",
184    )
185    @click.option(
186        "--ttl",
187        type=DURATION,
188        default=None,
189        show_default="1m",
190        envvar="TTL",
191        help="Default TTL for RunFunctionResponses. "
192        "Controls how long Crossplane may cache the response "
193        "before re-invoking the function.",
194    )
195    @functools.wraps(func)
196    def wrapper(*args, **kwargs):
197        return func(*args, **kwargs)
198
199    return wrapper
200
201
202def run(  # noqa: PLR0913
203    function_runner: grpcv1.FunctionRunnerServiceServicer,
204    *,
205    debug: bool,
206    address: str,
207    tls_certs_dir: str | None,
208    insecure: bool,
209    max_recv_message_size: int,
210    max_send_message_size: int | None,
211    ttl: datetime.timedelta | None,
212) -> None:
213    """Start a composition function gRPC server with standard options."""
214    level = logging.Level.DEBUG if debug else logging.Level.INFO
215    logging.configure(level=level)
216
217    if ttl is not None:
218        response.set_default_ttl(ttl)
219
220    if max_send_message_size is None:
221        max_send_message_size = max_recv_message_size
222
223    options = [
224        ("grpc.max_receive_message_length", max_recv_message_size * 1024 * 1024),
225        ("grpc.max_send_message_length", max_send_message_size * 1024 * 1024),
226    ]
227
228    runtime.serve(
229        function_runner,
230        address,
231        creds=runtime.load_credentials(tls_certs_dir),
232        insecure=insecure,
233        options=options,
234    )
DEFAULT_ADDRESS = '0.0.0.0:9443'
DEFAULT_MAX_RECV_MESSAGE_SIZE = 4
def parse_duration(value: str) -> datetime.timedelta:
 72def parse_duration(value: str) -> datetime.timedelta:
 73    """Parse a duration string into a :class:`datetime.timedelta`.
 74
 75    Accepts Go-style duration strings (e.g. ``60s``, ``1m``, ``1h30m``) and bare
 76    integers interpreted as seconds (e.g. ``60``).
 77
 78    Args:
 79        value: The duration string to parse.
 80
 81    Returns:
 82        The parsed duration.
 83
 84    Raises:
 85        ValueError: If the string is empty, invalid, or negative.
 86    """
 87    value = value.strip()
 88    if not value:
 89        msg = "duration must not be empty"
 90        raise ValueError(msg)
 91
 92    if value.isdigit():
 93        return datetime.timedelta(seconds=int(value))
 94
 95    total_seconds = 0.0
 96    pos = 0
 97    for match in _DURATION_COMPONENT_RE.finditer(value):
 98        if match.start() != pos:
 99            msg = f"invalid duration: {value}"
100            raise ValueError(msg)
101        total_seconds += float(match.group(1)) * _UNIT_TO_SECONDS[match.group(2)]
102        pos = match.end()
103
104    if pos != len(value):
105        msg = f"invalid duration: {value}"
106        raise ValueError(msg)
107
108    if total_seconds < 0:
109        msg = "duration must not be negative"
110        raise ValueError(msg)
111
112    return datetime.timedelta(seconds=total_seconds)

Parse a duration string into a datetime.timedelta.

Accepts Go-style duration strings (e.g. 60s, 1m, 1h30m) and bare integers interpreted as seconds (e.g. 60).

Arguments:
  • value: The duration string to parse.
Returns:

The parsed duration.

Raises:
  • ValueError: If the string is empty, invalid, or negative.
class DurationParamType(typing.Generic[+_ValueT_co, -_InputT_contra], abc.ABC):
115class DurationParamType(click.ParamType):
116    """A Click parameter type that parses duration strings."""
117
118    name = "duration"
119
120    def convert(
121        self,
122        value: object,
123        param: click.Parameter | None,
124        ctx: click.Context | None,
125    ) -> datetime.timedelta:
126        """Convert a CLI value to a :class:`datetime.timedelta`."""
127        if isinstance(value, datetime.timedelta):
128            return value
129        try:
130            return parse_duration(str(value))
131        except ValueError as e:
132            self.fail(str(e), param, ctx)

A Click parameter type that parses duration strings.

name = 'duration'
def convert( self, value: object, param: click.core.Parameter | None, ctx: click.core.Context | None) -> datetime.timedelta:
120    def convert(
121        self,
122        value: object,
123        param: click.Parameter | None,
124        ctx: click.Context | None,
125    ) -> datetime.timedelta:
126        """Convert a CLI value to a :class:`datetime.timedelta`."""
127        if isinstance(value, datetime.timedelta):
128            return value
129        try:
130            return parse_duration(str(value))
131        except ValueError as e:
132            self.fail(str(e), param, ctx)

Convert a CLI value to a datetime.timedelta.

DURATION = <DurationParamType object>
def standard_options(func: ~F) -> ~F:
138def standard_options(func: F) -> F:
139    """Apply the standard Composition Function CLI options to a Click command."""
140
141    @click.option(
142        "--max-send-message-size",
143        type=int,
144        default=None,
145        envvar="MAX_SEND_MESSAGE_SIZE",
146        help="Maximum size of sent gRPC messages in MB. "
147        "Defaults to --max-recv-message-size.",
148    )
149    @click.option(
150        "--max-recv-message-size",
151        "--max-grpc-message-size",
152        type=int,
153        default=DEFAULT_MAX_RECV_MESSAGE_SIZE,
154        show_default=True,
155        envvar=["MAX_RECV_MESSAGE_SIZE", "MAX_GRPC_MESSAGE_SIZE"],
156        help="Maximum size of received gRPC messages in MB.",
157    )
158    @click.option(
159        "--insecure",
160        is_flag=True,
161        envvar="INSECURE",
162        help="Run without mTLS credentials. "
163        "If you supply this flag --tls-server-certs-dir will be ignored.",
164    )
165    @click.option(
166        "--tls-server-certs-dir",
167        "--tls-certs-dir",
168        "tls_certs_dir",
169        envvar="TLS_SERVER_CERTS_DIR",
170        help="Serve using mTLS certificates.",
171    )
172    @click.option(
173        "--address",
174        default=DEFAULT_ADDRESS,
175        show_default=True,
176        envvar="ADDRESS",
177        help="Address at which to listen for gRPC connections.",
178    )
179    @click.option(
180        "--debug",
181        "-d",
182        is_flag=True,
183        envvar="DEBUG",
184        help="Emit debug logs.",
185    )
186    @click.option(
187        "--ttl",
188        type=DURATION,
189        default=None,
190        show_default="1m",
191        envvar="TTL",
192        help="Default TTL for RunFunctionResponses. "
193        "Controls how long Crossplane may cache the response "
194        "before re-invoking the function.",
195    )
196    @functools.wraps(func)
197    def wrapper(*args, **kwargs):
198        return func(*args, **kwargs)
199
200    return wrapper

Apply the standard Composition Function CLI options to a Click command.

def run( function_runner: crossplane.function.proto.v1.run_function_pb2_grpc.FunctionRunnerServiceServicer, *, debug: bool, address: str, tls_certs_dir: str | None, insecure: bool, max_recv_message_size: int, max_send_message_size: int | None, ttl: datetime.timedelta | None) -> None:
203def run(  # noqa: PLR0913
204    function_runner: grpcv1.FunctionRunnerServiceServicer,
205    *,
206    debug: bool,
207    address: str,
208    tls_certs_dir: str | None,
209    insecure: bool,
210    max_recv_message_size: int,
211    max_send_message_size: int | None,
212    ttl: datetime.timedelta | None,
213) -> None:
214    """Start a composition function gRPC server with standard options."""
215    level = logging.Level.DEBUG if debug else logging.Level.INFO
216    logging.configure(level=level)
217
218    if ttl is not None:
219        response.set_default_ttl(ttl)
220
221    if max_send_message_size is None:
222        max_send_message_size = max_recv_message_size
223
224    options = [
225        ("grpc.max_receive_message_length", max_recv_message_size * 1024 * 1024),
226        ("grpc.max_send_message_length", max_send_message_size * 1024 * 1024),
227    ]
228
229    runtime.serve(
230        function_runner,
231        address,
232        creds=runtime.load_credentials(tls_certs_dir),
233        insecure=insecure,
234        options=options,
235    )

Start a composition function gRPC server with standard options.