diff --git a/agent/uv.lock b/agent/uv.lock
index 46d382060..2d3a910b3 100644
--- a/agent/uv.lock
+++ b/agent/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.13'",
@@ -2237,7 +2237,7 @@ dev = [
[[package]]
name = "testflinger-common"
-version = "1.1.2"
+version = "1.1.3"
source = { directory = "../common" }
dependencies = [
{ name = "strenum" },
diff --git a/common/pyproject.toml b/common/pyproject.toml
index e74904928..dba617cd3 100644
--- a/common/pyproject.toml
+++ b/common/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "uv_build"
name = "testflinger-common"
description = "Testflinger common modules"
readme = "README.md"
-version = "1.1.2"
+version = "1.1.3"
requires-python = ">=3.10"
dependencies = ["strenum>=0.4.15"]
diff --git a/common/src/testflinger_common/duration.py b/common/src/testflinger_common/duration.py
new file mode 100644
index 000000000..b56287173
--- /dev/null
+++ b/common/src/testflinger_common/duration.py
@@ -0,0 +1,176 @@
+# Copyright (C) 2025 Canonical
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""Duration parsing utilities for Testflinger.
+
+This module provides utilities to parse duration strings in human-readable
+formats (like '30m', '5h', '4d') into seconds, similar to the sleep command.
+"""
+
+import re
+from typing import Union
+
+
+class DurationParseError(ValueError):
+ """Raised when a duration string cannot be parsed."""
+
+ pass
+
+
+def parse_duration(duration: Union[str, int]) -> int:
+ """Parse a duration string or integer into seconds.
+
+ Supports the following formats:
+
+ - Plain integers (interpreted as seconds): 3600
+ - Duration strings with suffixes:
+
+ - 's' or 'sec' for seconds: '30s', '30sec'
+ - 'm' or 'min' for minutes: '30m', '30min'
+ - 'h' or 'hour' for hours: '5h', '5hour'
+ - 'd' or 'day' for days: '4d', '4day'
+
+ Multiple units can be combined: '1h30m', '2d5h30m'
+
+ :param duration: Duration as string or integer
+ :type duration: Union[str, int]
+ :returns: Duration in seconds as integer
+ :rtype: int
+ :raises DurationParseError: If the duration string is invalid
+
+ Examples::
+
+ >>> parse_duration(3600)
+ 3600
+ >>> parse_duration('30m')
+ 1800
+ >>> parse_duration('5h')
+ 18000
+ >>> parse_duration('4d')
+ 345600
+ >>> parse_duration('1h30m')
+ 5400
+ >>> parse_duration('2d5h30m')
+ 192600
+ """
+ if isinstance(duration, int):
+ if duration < 0:
+ raise DurationParseError("Duration cannot be negative")
+ return duration
+
+ if not isinstance(duration, str):
+ raise DurationParseError(
+ f"Duration must be string or int, got {type(duration)}"
+ )
+
+ duration = duration.strip().lower()
+ if not duration:
+ raise DurationParseError("Duration cannot be empty")
+
+ # Check for negative numbers in duration strings
+ if duration.startswith("-"):
+ raise DurationParseError("Duration cannot be negative")
+
+ # Try parsing as plain integer first
+ try:
+ return int(duration)
+ except ValueError:
+ pass
+
+ # Parse duration string with units
+ # Pattern matches: number followed by optional unit
+ # Units: s/sec, m/min, h/hour, d/day (case insensitive)
+ # Order matters - longer forms first to avoid partial matches
+ pattern = r"(\d+)\s*(secs?|mins?|hours?|days?|[smhd])"
+ matches = re.findall(pattern, duration)
+
+ if not matches:
+ raise DurationParseError(f"Invalid duration format: '{duration}'")
+
+ # Check if the entire string was consumed by matches
+ # Reconstruct what should have been matched and compare
+ reconstructed = ""
+ for num, unit in matches:
+ reconstructed += f"{num}{unit}"
+
+ # Remove all whitespace for comparison
+ normalized_input = re.sub(r"\s+", "", duration)
+
+ if normalized_input != reconstructed:
+ raise DurationParseError(f"Invalid duration format: '{duration}'")
+
+ total_seconds = 0
+ unit_multipliers = {
+ "s": 1,
+ "sec": 1,
+ "secs": 1,
+ "m": 60,
+ "min": 60,
+ "mins": 60,
+ "h": 3600,
+ "hour": 3600,
+ "hours": 3600,
+ "d": 86400,
+ "day": 86400,
+ "days": 86400,
+ }
+
+ for num_str, unit in matches:
+ num = int(num_str)
+ multiplier = unit_multipliers[unit]
+ total_seconds += num * multiplier
+
+ return total_seconds
+
+
+def format_duration(seconds: int) -> str:
+ """Format seconds into a human-readable duration string.
+
+ :param seconds: Duration in seconds
+ :type seconds: int
+ :returns: Human-readable duration string
+ :rtype: str
+
+ Examples::
+
+ >>> format_duration(3600)
+ '1h'
+ >>> format_duration(1800)
+ '30m'
+ >>> format_duration(5400)
+ '1h30m'
+ >>> format_duration(345600)
+ '4d'
+ """
+ if seconds < 0:
+ raise ValueError("Duration cannot be negative")
+
+ if seconds == 0:
+ return "0s"
+
+ units = [
+ (86400, "d"),
+ (3600, "h"),
+ (60, "m"),
+ (1, "s"),
+ ]
+ parts = []
+
+ for divisor, suffix in units:
+ if seconds >= divisor:
+ num = seconds // divisor
+ parts.append(f"{num}{suffix}")
+ seconds %= divisor
+
+ return "".join(parts)
diff --git a/common/tests/__init__.py b/common/tests/__init__.py
new file mode 100644
index 000000000..4e17b2995
--- /dev/null
+++ b/common/tests/__init__.py
@@ -0,0 +1,15 @@
+# Copyright (C) 2025 Canonical
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""Tests for testflinger_common package."""
diff --git a/common/tests/test_duration.py b/common/tests/test_duration.py
new file mode 100644
index 000000000..8b2b62a7f
--- /dev/null
+++ b/common/tests/test_duration.py
@@ -0,0 +1,41 @@
+# Copyright (C) 2025 Canonical
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+"""Tests for duration parsing utilities."""
+
+import pytest
+from testflinger_common.duration import parse_duration, DurationParseError
+
+
+def test_parse_duration_integers():
+ """Test parsing integer seconds."""
+ assert parse_duration(3600) == 3600
+ assert parse_duration("3600") == 3600
+
+
+def test_parse_duration_formats():
+ """Test parsing duration string formats."""
+ assert parse_duration("30m") == 1800
+ assert parse_duration("5h") == 18000
+ assert parse_duration("4d") == 345600
+ assert parse_duration("2h30m") == 9000
+
+
+def test_parse_duration_invalid():
+ """Test that invalid formats raise DurationParseError."""
+ with pytest.raises(DurationParseError):
+ parse_duration("invalid")
+
+ with pytest.raises(DurationParseError):
+ parse_duration("-30")
diff --git a/common/uv.lock b/common/uv.lock
index 0e6934b27..053fbcb1f 100644
--- a/common/uv.lock
+++ b/common/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.10"
[[package]]
@@ -13,11 +13,14 @@ wheels = [
[[package]]
name = "exceptiongroup"
-version = "1.2.2"
+version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload-time = "2024-07-12T22:26:00.161Z" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" },
+ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
]
[[package]]
@@ -31,25 +34,34 @@ wheels = [
[[package]]
name = "packaging"
-version = "24.2"
+version = "25.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" },
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
-version = "1.5.0"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
-version = "8.3.5"
+version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -57,36 +69,38 @@ dependencies = [
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
+ { name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
+ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
]
[[package]]
name = "ruff"
-version = "0.11.6"
+version = "0.12.9"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d9/11/bcef6784c7e5d200b8a1f5c2ddf53e5da0efec37e6e5a44d163fb97e04ba/ruff-0.11.6.tar.gz", hash = "sha256:bec8bcc3ac228a45ccc811e45f7eb61b950dbf4cf31a67fa89352574b01c7d79", size = 4010053, upload-time = "2025-04-17T13:35:53.905Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4a/45/2e403fa7007816b5fbb324cb4f8ed3c7402a927a0a0cb2b6279879a8bfdc/ruff-0.12.9.tar.gz", hash = "sha256:fbd94b2e3c623f659962934e52c2bea6fc6da11f667a427a368adaf3af2c866a", size = 5254702, upload-time = "2025-08-14T16:08:55.2Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6e/1f/8848b625100ebcc8740c8bac5b5dd8ba97dd4ee210970e98832092c1635b/ruff-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:d84dcbe74cf9356d1bdb4a78cf74fd47c740bf7bdeb7529068f69b08272239a1", size = 10248105, upload-time = "2025-04-17T13:35:14.758Z" },
- { url = "https://files.pythonhosted.org/packages/e0/47/c44036e70c6cc11e6ee24399c2a1e1f1e99be5152bd7dff0190e4b325b76/ruff-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9bc583628e1096148011a5d51ff3c836f51899e61112e03e5f2b1573a9b726de", size = 11001494, upload-time = "2025-04-17T13:35:18.444Z" },
- { url = "https://files.pythonhosted.org/packages/ed/5b/170444061650202d84d316e8f112de02d092bff71fafe060d3542f5bc5df/ruff-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2959049faeb5ba5e3b378709e9d1bf0cab06528b306b9dd6ebd2a312127964a", size = 10352151, upload-time = "2025-04-17T13:35:20.563Z" },
- { url = "https://files.pythonhosted.org/packages/ff/91/f02839fb3787c678e112c8865f2c3e87cfe1744dcc96ff9fc56cfb97dda2/ruff-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63c5d4e30d9d0de7fedbfb3e9e20d134b73a30c1e74b596f40f0629d5c28a193", size = 10541951, upload-time = "2025-04-17T13:35:22.522Z" },
- { url = "https://files.pythonhosted.org/packages/9e/f3/c09933306096ff7a08abede3cc2534d6fcf5529ccd26504c16bf363989b5/ruff-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4b9a4e1439f7d0a091c6763a100cef8fbdc10d68593df6f3cfa5abdd9246e", size = 10079195, upload-time = "2025-04-17T13:35:24.485Z" },
- { url = "https://files.pythonhosted.org/packages/e0/0d/a87f8933fccbc0d8c653cfbf44bedda69c9582ba09210a309c066794e2ee/ruff-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5edf270223dd622218256569636dc3e708c2cb989242262fe378609eccf1308", size = 11698918, upload-time = "2025-04-17T13:35:26.504Z" },
- { url = "https://files.pythonhosted.org/packages/52/7d/8eac0bd083ea8a0b55b7e4628428203441ca68cd55e0b67c135a4bc6e309/ruff-0.11.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f55844e818206a9dd31ff27f91385afb538067e2dc0beb05f82c293ab84f7d55", size = 12319426, upload-time = "2025-04-17T13:35:28.452Z" },
- { url = "https://files.pythonhosted.org/packages/c2/dc/d0c17d875662d0c86fadcf4ca014ab2001f867621b793d5d7eef01b9dcce/ruff-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d8f782286c5ff562e4e00344f954b9320026d8e3fae2ba9e6948443fafd9ffc", size = 11791012, upload-time = "2025-04-17T13:35:30.455Z" },
- { url = "https://files.pythonhosted.org/packages/f9/f3/81a1aea17f1065449a72509fc7ccc3659cf93148b136ff2a8291c4bc3ef1/ruff-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01c63ba219514271cee955cd0adc26a4083df1956d57847978383b0e50ffd7d2", size = 13949947, upload-time = "2025-04-17T13:35:33.133Z" },
- { url = "https://files.pythonhosted.org/packages/61/9f/a3e34de425a668284e7024ee6fd41f452f6fa9d817f1f3495b46e5e3a407/ruff-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15adac20ef2ca296dd3d8e2bedc6202ea6de81c091a74661c3666e5c4c223ff6", size = 11471753, upload-time = "2025-04-17T13:35:35.416Z" },
- { url = "https://files.pythonhosted.org/packages/df/c5/4a57a86d12542c0f6e2744f262257b2aa5a3783098ec14e40f3e4b3a354a/ruff-0.11.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4dd6b09e98144ad7aec026f5588e493c65057d1b387dd937d7787baa531d9bc2", size = 10417121, upload-time = "2025-04-17T13:35:38.224Z" },
- { url = "https://files.pythonhosted.org/packages/58/3f/a3b4346dff07ef5b862e2ba06d98fcbf71f66f04cf01d375e871382b5e4b/ruff-0.11.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:45b2e1d6c0eed89c248d024ea95074d0e09988d8e7b1dad8d3ab9a67017a5b03", size = 10073829, upload-time = "2025-04-17T13:35:40.255Z" },
- { url = "https://files.pythonhosted.org/packages/93/cc/7ed02e0b86a649216b845b3ac66ed55d8aa86f5898c5f1691797f408fcb9/ruff-0.11.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd40de4115b2ec4850302f1a1d8067f42e70b4990b68838ccb9ccd9f110c5e8b", size = 11076108, upload-time = "2025-04-17T13:35:42.559Z" },
- { url = "https://files.pythonhosted.org/packages/39/5e/5b09840fef0eff1a6fa1dea6296c07d09c17cb6fb94ed5593aa591b50460/ruff-0.11.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:77cda2dfbac1ab73aef5e514c4cbfc4ec1fbef4b84a44c736cc26f61b3814cd9", size = 11512366, upload-time = "2025-04-17T13:35:45.702Z" },
- { url = "https://files.pythonhosted.org/packages/6f/4c/1cd5a84a412d3626335ae69f5f9de2bb554eea0faf46deb1f0cb48534042/ruff-0.11.6-py3-none-win32.whl", hash = "sha256:5151a871554be3036cd6e51d0ec6eef56334d74dfe1702de717a995ee3d5b287", size = 10485900, upload-time = "2025-04-17T13:35:47.695Z" },
- { url = "https://files.pythonhosted.org/packages/42/46/8997872bc44d43df986491c18d4418f1caff03bc47b7f381261d62c23442/ruff-0.11.6-py3-none-win_amd64.whl", hash = "sha256:cce85721d09c51f3b782c331b0abd07e9d7d5f775840379c640606d3159cae0e", size = 11558592, upload-time = "2025-04-17T13:35:49.837Z" },
- { url = "https://files.pythonhosted.org/packages/d7/6a/65fecd51a9ca19e1477c3879a7fda24f8904174d1275b419422ac00f6eee/ruff-0.11.6-py3-none-win_arm64.whl", hash = "sha256:3567ba0d07fb170b1b48d944715e3294b77f5b7679e8ba258199a250383ccb79", size = 10682766, upload-time = "2025-04-17T13:35:52.014Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/20/53bf098537adb7b6a97d98fcdebf6e916fcd11b2e21d15f8c171507909cc/ruff-0.12.9-py3-none-linux_armv6l.whl", hash = "sha256:fcebc6c79fcae3f220d05585229463621f5dbf24d79fdc4936d9302e177cfa3e", size = 11759705, upload-time = "2025-08-14T16:08:12.968Z" },
+ { url = "https://files.pythonhosted.org/packages/20/4d/c764ee423002aac1ec66b9d541285dd29d2c0640a8086c87de59ebbe80d5/ruff-0.12.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aed9d15f8c5755c0e74467731a007fcad41f19bcce41cd75f768bbd687f8535f", size = 12527042, upload-time = "2025-08-14T16:08:16.54Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/45/cfcdf6d3eb5fc78a5b419e7e616d6ccba0013dc5b180522920af2897e1be/ruff-0.12.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5b15ea354c6ff0d7423814ba6d44be2807644d0c05e9ed60caca87e963e93f70", size = 11724457, upload-time = "2025-08-14T16:08:18.686Z" },
+ { url = "https://files.pythonhosted.org/packages/72/e6/44615c754b55662200c48bebb02196dbb14111b6e266ab071b7e7297b4ec/ruff-0.12.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d596c2d0393c2502eaabfef723bd74ca35348a8dac4267d18a94910087807c53", size = 11949446, upload-time = "2025-08-14T16:08:21.059Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/d1/9b7d46625d617c7df520d40d5ac6cdcdf20cbccb88fad4b5ecd476a6bb8d/ruff-0.12.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b15599931a1a7a03c388b9c5df1bfa62be7ede6eb7ef753b272381f39c3d0ff", size = 11566350, upload-time = "2025-08-14T16:08:23.433Z" },
+ { url = "https://files.pythonhosted.org/packages/59/20/b73132f66f2856bc29d2d263c6ca457f8476b0bbbe064dac3ac3337a270f/ruff-0.12.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3d02faa2977fb6f3f32ddb7828e212b7dd499c59eb896ae6c03ea5c303575756", size = 13270430, upload-time = "2025-08-14T16:08:25.837Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/21/eaf3806f0a3d4c6be0a69d435646fba775b65f3f2097d54898b0fd4bb12e/ruff-0.12.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:17d5b6b0b3a25259b69ebcba87908496e6830e03acfb929ef9fd4c58675fa2ea", size = 14264717, upload-time = "2025-08-14T16:08:27.907Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/82/1d0c53bd37dcb582b2c521d352fbf4876b1e28bc0d8894344198f6c9950d/ruff-0.12.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72db7521860e246adbb43f6ef464dd2a532ef2ef1f5dd0d470455b8d9f1773e0", size = 13684331, upload-time = "2025-08-14T16:08:30.352Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/2f/1c5cf6d8f656306d42a686f1e207f71d7cebdcbe7b2aa18e4e8a0cb74da3/ruff-0.12.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a03242c1522b4e0885af63320ad754d53983c9599157ee33e77d748363c561ce", size = 12739151, upload-time = "2025-08-14T16:08:32.55Z" },
+ { url = "https://files.pythonhosted.org/packages/47/09/25033198bff89b24d734e6479e39b1968e4c992e82262d61cdccaf11afb9/ruff-0.12.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fc83e4e9751e6c13b5046d7162f205d0a7bac5840183c5beebf824b08a27340", size = 12954992, upload-time = "2025-08-14T16:08:34.816Z" },
+ { url = "https://files.pythonhosted.org/packages/52/8e/d0dbf2f9dca66c2d7131feefc386523404014968cd6d22f057763935ab32/ruff-0.12.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:881465ed56ba4dd26a691954650de6ad389a2d1fdb130fe51ff18a25639fe4bb", size = 12899569, upload-time = "2025-08-14T16:08:36.852Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/bd/b614d7c08515b1428ed4d3f1d4e3d687deffb2479703b90237682586fa66/ruff-0.12.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:43f07a3ccfc62cdb4d3a3348bf0588358a66da756aa113e071b8ca8c3b9826af", size = 11751983, upload-time = "2025-08-14T16:08:39.314Z" },
+ { url = "https://files.pythonhosted.org/packages/58/d6/383e9f818a2441b1a0ed898d7875f11273f10882f997388b2b51cb2ae8b5/ruff-0.12.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:07adb221c54b6bba24387911e5734357f042e5669fa5718920ee728aba3cbadc", size = 11538635, upload-time = "2025-08-14T16:08:41.297Z" },
+ { url = "https://files.pythonhosted.org/packages/20/9c/56f869d314edaa9fc1f491706d1d8a47747b9d714130368fbd69ce9024e9/ruff-0.12.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f5cd34fabfdea3933ab85d72359f118035882a01bff15bd1d2b15261d85d5f66", size = 12534346, upload-time = "2025-08-14T16:08:43.39Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/4b/d8b95c6795a6c93b439bc913ee7a94fda42bb30a79285d47b80074003ee7/ruff-0.12.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f6be1d2ca0686c54564da8e7ee9e25f93bdd6868263805f8c0b8fc6a449db6d7", size = 13017021, upload-time = "2025-08-14T16:08:45.889Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/c1/5f9a839a697ce1acd7af44836f7c2181cdae5accd17a5cb85fcbd694075e/ruff-0.12.9-py3-none-win32.whl", hash = "sha256:cc7a37bd2509974379d0115cc5608a1a4a6c4bff1b452ea69db83c8855d53f93", size = 11734785, upload-time = "2025-08-14T16:08:48.062Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/66/cdddc2d1d9a9f677520b7cfc490d234336f523d4b429c1298de359a3be08/ruff-0.12.9-py3-none-win_amd64.whl", hash = "sha256:6fb15b1977309741d7d098c8a3cb7a30bc112760a00fb6efb7abc85f00ba5908", size = 12840654, upload-time = "2025-08-14T16:08:50.158Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/fd/669816bc6b5b93b9586f3c1d87cd6bc05028470b3ecfebb5938252c47a35/ruff-0.12.9-py3-none-win_arm64.whl", hash = "sha256:63c8c819739d86b96d500cce885956a1a48ab056bbcbc61b747ad494b2485089", size = 11949623, upload-time = "2025-08-14T16:08:52.233Z" },
]
[[package]]
@@ -100,7 +114,7 @@ wheels = [
[[package]]
name = "testflinger-common"
-version = "1.1.2"
+version = "1.1.3"
source = { editable = "." }
dependencies = [
{ name = "strenum" },
@@ -159,3 +173,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
]
+
+[[package]]
+name = "typing-extensions"
+version = "4.14.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
+]
diff --git a/docs/reference/test-phases.rst b/docs/reference/test-phases.rst
index 8b46d0616..a0b6cd9ff 100644
--- a/docs/reference/test-phases.rst
+++ b/docs/reference/test-phases.rst
@@ -202,8 +202,19 @@ To reserve a device, provide the following section in the job definition file:
Variables in ``reserve_data``:
* ``ssh_keys``: The list of public SSH keys to use for reserving the device. Each line includes an identity provider name and your username on the provider's system. Testflinger uses the ``ssh-import-id`` command to import public SSH keys from trusted, online identity. Supported identities are Launchpad (``lp``) and GitHub (``gh``).
-* ``timeout``: Reservation time in seconds. The default is one hour (3600), and you can request a reservation for up to 6 hours (21600).
+* ``timeout``: Reservation time. The default is one hour, and you can request a reservation for up to 6 hours.
Authenticated clients can request longer :doc:`reservation times <../explanation/extended-reservation>` with prior authorisation.
+
+ The timeout can be specified in two formats:
+
+ - **Seconds** (integer): ``3600`` for 1 hour, ``21600`` for 6 hours
+ - **Duration format**: Human-readable format similar to the ``sleep`` command:
+
+ - ``30s`` or ``30sec`` for 30 seconds
+ - ``30m`` or ``30min`` for 30 minutes
+ - ``5h`` or ``5hour`` for 5 hours
+ - ``4d`` or ``4day`` for 4 days
+ - Combined formats: ``2h30m`` for 2 hours and 30 minutes, ``1d5h30m`` for 1 day, 5 hours, and 30 minutes
If either ``reserve_command`` is missing from the agent configuration, or the the ``reserve_data`` section is missing from the job, this phase will be skipped.
@@ -224,7 +235,7 @@ If either ``reserve_command`` is missing from the agent configuration, or the th
reserve_data:
ssh_keys:
- "lp:user1"
- timeout: 4800
+ timeout: 2h30m # 2 hours and 30 minutes (equivalent to 9000 seconds)
Note: ``lp:user1`` is a string in the job definition YAML, not a YAML key-value pair, so there should be no space between the colon and the username.
diff --git a/server/pyproject.toml b/server/pyproject.toml
index 170965032..14f6c11af 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -89,4 +89,4 @@ ignore = "H023,H021"
indent = 2
[tool.uv.sources]
-testflinger-common = { path = "../common", editable = true }
\ No newline at end of file
+testflinger-common = { path = "../common", editable = true }
diff --git a/server/src/testflinger/api/schemas.py b/server/src/testflinger/api/schemas.py
index 2e7192b79..4a71d43b8 100644
--- a/server/src/testflinger/api/schemas.py
+++ b/server/src/testflinger/api/schemas.py
@@ -19,6 +19,7 @@
from apiflask.validators import Length, OneOf, Regexp
from marshmallow import ValidationError, validates_schema
from marshmallow_oneofschema import OneOfSchema
+from testflinger_common.duration import DurationParseError, parse_duration
from testflinger.enums import ServerRoles
@@ -277,13 +278,38 @@ class TestData(Schema):
test_password = fields.String(required=False)
+class DurationField(fields.Field):
+ """Custom field that accepts both integer seconds and duration strings."""
+
+ def __init__(self, **kwargs):
+ """Initialize the field with allow_none=True by default."""
+ kwargs.setdefault("allow_none", True)
+ super().__init__(**kwargs)
+
+ def _serialize(self, value, attr, obj, **kwargs):
+ """Serialize duration to integer seconds."""
+ if value is None:
+ return None
+ return int(value)
+
+ def _deserialize(self, value, attr, data, **kwargs):
+ """Deserialize duration from integer or string to integer seconds."""
+ if value is None:
+ return None
+
+ try:
+ return parse_duration(value)
+ except DurationParseError as e:
+ raise ValidationError(f"Invalid duration format: {e}") from e
+
+
class ReserveData(Schema):
"""Schema for the `reserve_data` section of a Testflinger job."""
ssh_keys = fields.List(
fields.String(validate=Regexp(r"^(lp|gh):(\S+)$")), required=False
)
- timeout = fields.Integer(required=False)
+ timeout = DurationField(required=False)
class Job(Schema):
diff --git a/server/tests/test_v1.py b/server/tests/test_v1.py
index fa9a8f5f3..77a8317bc 100644
--- a/server/tests/test_v1.py
+++ b/server/tests/test_v1.py
@@ -21,6 +21,8 @@
from http import HTTPStatus
from io import BytesIO
+import pytest
+
from testflinger.api import v1
@@ -1237,3 +1239,29 @@ def test_get_jobs_on_queue(mongo_app):
# Confirm the status code returned if there are no jobs in queue
output = app.get("/v1/queues/test2/jobs")
assert output.status_code == HTTPStatus.NO_CONTENT
+
+
+@pytest.mark.parametrize("timeout", [1800, "1800", "30m", "1800s"])
+def test_reserve_data_with_human_readable_timeout(mongo_app, timeout):
+ """Test that a job with valid human readable timeout field works."""
+ job_data = {
+ "job_queue": "test",
+ "reserve_data": {"timeout": timeout},
+ }
+ app, _ = mongo_app
+ output = app.post("/v1/job", json=job_data)
+ assert output.status_code == HTTPStatus.OK
+ submitted_job = app.get("/v1/job?queue=test").json
+ assert submitted_job["reserve_data"]["timeout"] == 1800
+
+
+@pytest.mark.parametrize("timeout", ["invalid", "30x", "-30m", ""])
+def test_reserve_data_with_invalid_timeout(mongo_app, timeout):
+ """Test that a job with invalid timeout field is rejected."""
+ job_data = {
+ "job_queue": "test",
+ "reserve_data": {"timeout": timeout},
+ }
+ app, _ = mongo_app
+ output = app.post("/v1/job", json=job_data)
+ assert output.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
diff --git a/server/uv.lock b/server/uv.lock
index 66e09926f..53bd74c81 100644
--- a/server/uv.lock
+++ b/server/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.12'",
@@ -1343,7 +1343,7 @@ dev = [
[[package]]
name = "testflinger-common"
-version = "1.1.1"
+version = "1.1.3"
source = { editable = "../common" }
dependencies = [
{ name = "strenum" },