Skip to content

Commit de8a23e

Browse files
authored
Merge pull request #837 from danielballan/human-friendly-expiration
Support expiration times given as a number with units.
2 parents e19d276 + 75f8fc7 commit de8a23e

File tree

6 files changed

+81
-12
lines changed

6 files changed

+81
-12
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Write the date in place of the "Unreleased" in the case a new version is release
88
### Added
99

1010
- `docker-compose.yml` now uses the healthcheck endpoint `/healthz`
11+
- In client, support specifying API key expiration time as string with
12+
units, like ``"7d"` or `"10m"`.
1113

1214
### Fixed
1315

docs/source/how-to/api-keys.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,14 +167,17 @@ as the user who is for. If an API key will be used for a specific task, it is
167167
good security hygiene to give it only the privileges it needs for that task. It
168168
is also recommended to set a limited lifetimes so that if the key is
169169
unknowingly leaked it will not continue to work forever. For example, this
170-
command creates an API key that will expire in 10 minutes (600 seconds) and can
170+
command creates an API key that will expire in 10 minutes and can
171171
search/list metadata but cannot download array data.
172172

173173
```
174-
$ tiled api_key create --expires-in 600 --scopes read:metadata
174+
$ tiled api_key create --expires-in 10m --scopes read:metadata
175175
ba9af604023a829ab22edb786168d6e1b97cef68c54c6d95d7fad5e3e6347fa131263581
176176
```
177177

178+
Expiration can be given in units of years `y`, days `d`, hours `h`, minutes
179+
`m`, or seconds `s`.
180+
178181
See {doc}`../reference/scopes` for the full list of scopes and their capabilities.
179182

180183
```

tiled/_tests/test_utils.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from pathlib import Path
22

3-
from ..utils import ensure_specified_sql_driver
3+
import pytest
4+
5+
from ..utils import ensure_specified_sql_driver, parse_time_string
46

57

68
def test_ensure_specified_sql_driver():
@@ -73,3 +75,31 @@ def test_ensure_specified_sql_driver():
7375
ensure_specified_sql_driver(Path("/tmp/test.db"))
7476
== f"sqlite+aiosqlite:///{Path('/tmp/test.db')}"
7577
)
78+
79+
80+
@pytest.mark.parametrize(
81+
"string_input,expected",
82+
[
83+
("3s", 3),
84+
("7m", 7 * 60),
85+
("5h", 5 * 60 * 60),
86+
("1d", 1 * 24 * 60 * 60),
87+
("2y", 2 * 365 * 24 * 60 * 60),
88+
],
89+
)
90+
def test_parse_time_string_valid(string_input, expected):
91+
assert parse_time_string(string_input) == expected
92+
93+
94+
@pytest.mark.parametrize(
95+
"string_input",
96+
[
97+
"3z", # unrecognized units
98+
"3M", # unrecognized units
99+
"-3m", # invalid character '-'
100+
"3 m", # invalid character '-'
101+
],
102+
)
103+
def test_parse_time_string_invalid(string_input):
104+
with pytest.raises(ValueError):
105+
parse_time_string(string_input)

tiled/client/context.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import platformdirs
1515

1616
from .._version import __version__ as tiled_version
17-
from ..utils import UNSET, DictView
17+
from ..utils import UNSET, DictView, parse_time_string
1818
from .auth import CannotRefreshAuthentication, TiledAuth, build_refresh_request
1919
from .decoders import SUPPORTED_DECODERS
2020
from .transport import Transport
@@ -419,13 +419,16 @@ def create_api_key(self, scopes=None, expires_in=None, note=None):
419419
scopes : Optional[List[str]]
420420
Restrict the access available to the API key by listing specific scopes.
421421
By default, this will have the same access as the user.
422-
expires_in : Optional[int]
423-
Number of seconds until API key expires. If None,
424-
it will never expire or it will have the maximum lifetime
425-
allowed by the server.
422+
expires_in : Optional[Union[int, str]]
423+
Number of seconds until API key expires, given as integer seconds
424+
or a string like: '3y' (years), '3d' (days), '5m' (minutes), '1h'
425+
(hours), '30s' (seconds). If None, it will never expire or it will
426+
have the maximum lifetime allowed by the server.
426427
note : Optional[str]
427428
Description (for humans).
428429
"""
430+
if isinstance(expires_in, str):
431+
expires_in = parse_time_string(expires_in)
429432
return handle_error(
430433
self.http_client.post(
431434
self.server_info["authentication"]["links"]["apikey"],

tiled/commandline/_api_key.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@ def create_api_key(
1212
profile: Optional[str] = typer.Option(
1313
None, help="If you use more than one Tiled server, use this to specify which."
1414
),
15-
expires_in: Optional[int] = typer.Option(
15+
expires_in: Optional[str] = typer.Option(
1616
None,
1717
help=(
18-
"Number of seconds until API key expires. If None, "
19-
"it will never expire or it will have the maximum lifetime "
20-
"allowed by the server."
18+
"Number of seconds until API key expires, given as integer seconds "
19+
"or a string like: '3y' (years), '3d' (days), '5m' (minutes), '1h' "
20+
"(hours), '30s' (seconds). If None, it will never expire or it will "
21+
"have the maximum lifetime allowed by the server. "
2122
),
2223
),
2324
scopes: Optional[List[str]] = typer.Option(
@@ -35,6 +36,8 @@ def create_api_key(
3536
# This is how typer interprets unspecified scopes.
3637
# Replace with None to get default scopes.
3738
scopes = None
39+
if expires_in.isdigit():
40+
expires_in = int(expires_in)
3841
info = context.create_api_key(scopes=scopes, expires_in=expires_in, note=note)
3942
# TODO Print other info to the stderr?
4043
typer.echo(info["secret"])

tiled/utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,34 @@ def bytesize_repr(num):
659659
num /= 1024.0
660660

661661

662+
TIME_STRING_PATTERN = re.compile(r"(\d+)(s|m|h|d|y)")
663+
TIME_STRING_UNITS = {
664+
"s": 1,
665+
"m": 60,
666+
"h": 60 * 60,
667+
"d": 60 * 60 * 24,
668+
"y": 60 * 60 * 24 * 365,
669+
}
670+
671+
672+
def parse_time_string(s):
673+
"""
674+
Accept strings like '1y', '1d', '24h'; return int seconds.
675+
676+
Accepted Units:
677+
'y' = year
678+
'd' = day
679+
'h' = hour
680+
'm' = minutes
681+
's' = seconds
682+
"""
683+
matched = TIME_STRING_PATTERN.match(s)
684+
if matched is None:
685+
raise ValueError(f"Could not parse {s} as a number and a unit like '5m'")
686+
number, unit = matched.groups()
687+
return int(number) * TIME_STRING_UNITS[unit]
688+
689+
662690
def is_coroutine_callable(call: Callable[..., Any]) -> bool:
663691
if inspect.isroutine(call):
664692
return inspect.iscoroutinefunction(call)

0 commit comments

Comments
 (0)