Skip to content

Commit d85255d

Browse files
committed
Add a CLI input parameter for default ttl
Signed-off-by: Bob Haddleton <bob.haddleton@nokia.com>
1 parent af9f047 commit d85255d

4 files changed

Lines changed: 348 additions & 3 deletions

File tree

‎crossplane/function/cli.py‎

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
Provides reusable options and a run helper so that every composition
1818
function shares a standard set of flags, environment variables, and defaults.
1919
20+
Standard flags include ``--address``, ``--debug``, ``--insecure``,
21+
``--tls-server-certs-dir``, gRPC message size limits, and ``--ttl``. Each
22+
option also supports a corresponding environment variable (for example
23+
``ADDRESS``, ``DEBUG``, ``TTL``).
24+
2025
Usage in a function's main.py::
2126
2227
import click
@@ -38,20 +43,96 @@ def cli(cache_size, **kwargs):
3843
sdkcli.run(runner, **kwargs)
3944
"""
4045

46+
import datetime
4147
import functools
48+
import re
4249
from collections.abc import Callable
4350
from typing import TypeVar
4451

4552
import click
4653

47-
from crossplane.function import logging, runtime
54+
from crossplane.function import logging, response, runtime
4855
from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1
4956

5057
F = TypeVar("F", bound=Callable)
5158

5259
DEFAULT_ADDRESS = "0.0.0.0:9443"
5360
DEFAULT_MAX_RECV_MESSAGE_SIZE = 4 # MB
5461

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+
71+
def 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+
114+
class 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+
134+
DURATION = DurationParamType()
135+
55136

56137
def standard_options(func: F) -> F:
57138
"""Apply the standard Composition Function CLI options to a Click command."""
@@ -101,6 +182,16 @@ def standard_options(func: F) -> F:
101182
envvar="DEBUG",
102183
help="Emit debug logs.",
103184
)
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+
)
104195
@functools.wraps(func)
105196
def wrapper(*args, **kwargs):
106197
return func(*args, **kwargs)
@@ -117,11 +208,15 @@ def run( # noqa: PLR0913
117208
insecure: bool,
118209
max_recv_message_size: int,
119210
max_send_message_size: int | None,
211+
ttl: datetime.timedelta | None,
120212
) -> None:
121213
"""Start a composition function gRPC server with standard options."""
122214
level = logging.Level.DEBUG if debug else logging.Level.INFO
123215
logging.configure(level=level)
124216

217+
if ttl is not None:
218+
response.set_default_ttl(ttl)
219+
125220
if max_send_message_size is None:
126221
max_send_message_size = max_recv_message_size
127222

‎crossplane/function/response.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,23 +25,45 @@
2525
"""The default TTL for which a RunFunctionResponse may be cached."""
2626
DEFAULT_TTL = datetime.timedelta(minutes=1)
2727

28+
_default_ttl = DEFAULT_TTL
29+
30+
31+
def get_default_ttl() -> datetime.timedelta:
32+
"""Return the process-wide default TTL for RunFunctionResponses."""
33+
return _default_ttl
34+
35+
36+
def set_default_ttl(ttl: datetime.timedelta) -> None:
37+
"""Set the process-wide default TTL for RunFunctionResponses.
38+
39+
Args:
40+
ttl: How long Crossplane may optionally cache responses when no explicit
41+
TTL is passed to :func:`to`.
42+
"""
43+
global _default_ttl # noqa: PLW0603
44+
_default_ttl = ttl
45+
2846

2947
def to(
3048
req: fnv1.RunFunctionRequest,
31-
ttl: datetime.timedelta = DEFAULT_TTL,
49+
ttl: datetime.timedelta | None = None,
3250
) -> fnv1.RunFunctionResponse:
3351
"""Create a response to the supplied request.
3452
3553
Args:
3654
req: The request to respond to.
37-
ttl: How long Crossplane may optionally cache the response.
55+
ttl: How long Crossplane may optionally cache the response. Defaults to
56+
the process-wide default TTL set by :func:`set_default_ttl`.
3857
3958
Returns:
4059
A response to the supplied request.
4160
4261
The request's tag, desired resources, and context is automatically copied to
4362
the response. Using response.to is a good pattern to ensure
4463
"""
64+
if ttl is None:
65+
ttl = get_default_ttl()
66+
4567
dttl = durationpb.Duration()
4668
dttl.FromTimedelta(ttl)
4769
return fnv1.RunFunctionResponse(

‎tests/test_cli.py‎

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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+
import dataclasses
16+
import datetime
17+
import unittest
18+
from unittest import mock
19+
20+
import click
21+
from click.testing import CliRunner
22+
from google.protobuf import duration_pb2 as durationpb
23+
from google.protobuf import json_format
24+
25+
from crossplane.function import cli, response
26+
from crossplane.function.proto.v1 import run_function_pb2 as fnv1
27+
28+
29+
class TestParseDuration(unittest.TestCase):
30+
def test_parse_duration(self) -> None:
31+
@dataclasses.dataclass
32+
class TestCase:
33+
reason: str
34+
value: str
35+
want: datetime.timedelta
36+
37+
cases = [
38+
TestCase(
39+
reason="Bare integers are interpreted as seconds.",
40+
value="60",
41+
want=datetime.timedelta(seconds=60),
42+
),
43+
TestCase(
44+
reason="Seconds suffix should work.",
45+
value="60s",
46+
want=datetime.timedelta(seconds=60),
47+
),
48+
TestCase(
49+
reason="Minutes suffix should work.",
50+
value="1m",
51+
want=datetime.timedelta(minutes=1),
52+
),
53+
TestCase(
54+
reason="Hours suffix should work.",
55+
value="1h",
56+
want=datetime.timedelta(hours=1),
57+
),
58+
TestCase(
59+
reason="Days suffix should work.",
60+
value="1d",
61+
want=datetime.timedelta(days=1),
62+
),
63+
TestCase(
64+
reason="Combined durations should be summed.",
65+
value="1h30m",
66+
want=datetime.timedelta(hours=1, minutes=30),
67+
),
68+
TestCase(
69+
reason="Zero seconds should work.",
70+
value="0s",
71+
want=datetime.timedelta(seconds=0),
72+
),
73+
TestCase(
74+
reason="Zero as a bare integer should work.",
75+
value="0",
76+
want=datetime.timedelta(seconds=0),
77+
),
78+
TestCase(
79+
reason="Fractional seconds should work.",
80+
value="1.5s",
81+
want=datetime.timedelta(seconds=1.5),
82+
),
83+
]
84+
85+
for case in cases:
86+
got = cli.parse_duration(case.value)
87+
self.assertEqual(case.want, got, case.reason)
88+
89+
def test_parse_duration_invalid(self) -> None:
90+
for value in ("", "1x", "-5m", "1m2"):
91+
with self.assertRaises(ValueError, msg=value):
92+
cli.parse_duration(value)
93+
94+
95+
class TestStandardOptions(unittest.TestCase):
96+
def setUp(self) -> None:
97+
self._saved_default_ttl = response.get_default_ttl()
98+
99+
def tearDown(self) -> None:
100+
response.set_default_ttl(self._saved_default_ttl)
101+
102+
def test_run_sets_default_ttl_from_flag(self) -> None:
103+
@click.command()
104+
@cli.standard_options
105+
def main(**kwargs):
106+
cli.run(mock.Mock(), **kwargs)
107+
108+
runner = CliRunner()
109+
with mock.patch("crossplane.function.cli.runtime.serve"):
110+
result = runner.invoke(main, ["--ttl", "5m", "--insecure"])
111+
self.assertEqual(0, result.exit_code, result.output)
112+
self.assertEqual(datetime.timedelta(minutes=5), response.get_default_ttl())
113+
114+
def test_run_sets_default_ttl_from_env(self) -> None:
115+
@click.command()
116+
@cli.standard_options
117+
def main(**kwargs):
118+
cli.run(mock.Mock(), **kwargs)
119+
120+
runner = CliRunner()
121+
with mock.patch("crossplane.function.cli.runtime.serve"):
122+
result = runner.invoke(
123+
main,
124+
["--insecure"],
125+
env={"TTL": "10m"},
126+
)
127+
self.assertEqual(0, result.exit_code, result.output)
128+
self.assertEqual(datetime.timedelta(minutes=10), response.get_default_ttl())
129+
130+
def test_run_leaves_default_ttl_when_flag_omitted(self) -> None:
131+
response.set_default_ttl(response.DEFAULT_TTL)
132+
133+
@click.command()
134+
@cli.standard_options
135+
def main(**kwargs):
136+
cli.run(mock.Mock(), **kwargs)
137+
138+
runner = CliRunner()
139+
with mock.patch("crossplane.function.cli.runtime.serve"):
140+
result = runner.invoke(main, ["--insecure"])
141+
self.assertEqual(0, result.exit_code, result.output)
142+
self.assertEqual(response.DEFAULT_TTL, response.get_default_ttl())
143+
144+
def test_invalid_ttl_flag(self) -> None:
145+
@click.command()
146+
@cli.standard_options
147+
def main(**kwargs):
148+
cli.run(mock.Mock(), **kwargs)
149+
150+
runner = CliRunner()
151+
result = runner.invoke(main, ["--ttl", "1x", "--insecure"])
152+
self.assertNotEqual(0, result.exit_code)
153+
154+
def test_ttl_flag_affects_response_to(self) -> None:
155+
@click.command()
156+
@cli.standard_options
157+
def main(**kwargs):
158+
cli.run(mock.Mock(), **kwargs)
159+
160+
runner = CliRunner()
161+
with mock.patch("crossplane.function.cli.runtime.serve"):
162+
result = runner.invoke(main, ["--ttl", "5m", "--insecure"])
163+
self.assertEqual(0, result.exit_code, result.output)
164+
165+
req = fnv1.RunFunctionRequest(meta=fnv1.RequestMeta(tag="hi"))
166+
got = response.to(req)
167+
want = fnv1.RunFunctionResponse(
168+
meta=fnv1.ResponseMeta(tag="hi", ttl=durationpb.Duration(seconds=60 * 5)),
169+
desired=req.desired,
170+
context=req.context,
171+
)
172+
self.assertEqual(
173+
json_format.MessageToJson(want, sort_keys=True),
174+
json_format.MessageToJson(got, sort_keys=True),
175+
)
176+
177+
178+
class TestDurationParamType(unittest.TestCase):
179+
def test_convert(self) -> None:
180+
param_type = cli.DurationParamType()
181+
got = param_type.convert("5m", None, None)
182+
self.assertEqual(datetime.timedelta(minutes=5), got)
183+
184+
def test_convert_invalid(self) -> None:
185+
param_type = cli.DurationParamType()
186+
with self.assertRaises(click.BadParameter):
187+
param_type.convert("1x", None, None)
188+
189+
190+
if __name__ == "__main__":
191+
unittest.main()

0 commit comments

Comments
 (0)