diff --git a/did/base.py b/did/base.py index 3971e78c..002cdd24 100644 --- a/did/base.py +++ b/did/base.py @@ -7,6 +7,8 @@ import locale import os import re +import shlex +import subprocess import sys from configparser import NoOptionError, NoSectionError from datetime import timedelta @@ -532,19 +534,27 @@ def alias(self, aliases: Optional[str], stats: Optional[str]) -> None: def get_token( config: dict[str, str], token_key: str = "token", - token_file_key: str = "token_file") -> Optional[str]: + token_file_key: str = "token_file", + token_command_key: str = "token_command") -> Optional[str]: """ - Extract the authentication token from config or token file + Extract the authentication token from config, token file, or command - Returns the contents of `config[token_key]`, or the file contents of - `config[token_file_key]` if no `config[token]` exists. If neither - keys exist, `None` is returned. + Returns the contents of ``config[token_key]``, the file contents of + ``config[token_file_key]``, or the stdout of + ``config[token_command_key]``. If none of these keys exist, + ``None`` is returned. Sometimes you want to be able to store a token in a file rather than in the your plain config file. Use this function to support a system wide mechanism to retrieve tokens or secrets either directly from the config file as plain text or from an outsourced file. + Use ``token_command`` to run an external command that prints the + token to stdout. This is useful for retrieving tokens from CLI + tools such as ``glab``:: + + token_command = glab config get --host gitlab.com token + :param config: A configuration dictionary. :param token_key: @@ -553,6 +563,9 @@ def get_token( :param token_file_key: The dict entry to look for when the token is supposed to be read from file. + :param token_command_key: + The dict entry to look for when the token is retrieved by + running an external command. :returns: The stripped token or `None` if no or only empty entries were found in the `config` dict. @@ -566,6 +579,16 @@ def get_token( file_path = os.path.expanduser(config[token_file_key]) with open(file_path, encoding="utf-8") as token_file: token = token_file.read().strip() + elif token_command_key in config: + command = config[token_command_key] + try: + result = subprocess.run( + shlex.split(command), + capture_output=True, text=True, check=True) + token = result.stdout.strip() + except (subprocess.CalledProcessError, OSError) as err: + raise ReportError( + f"Token command '{command}' failed: {err}") from err if token == "": token = None diff --git a/did/plugins/gitlab.py b/did/plugins/gitlab.py index 8ac6165b..2e696580 100644 --- a/did/plugins/gitlab.py +++ b/did/plugins/gitlab.py @@ -8,6 +8,7 @@ url = https://gitlab.com/ token = token_file = + token_command = login = ssl_verify = true @@ -15,6 +16,12 @@ interface (select ``api`` as the desired scope). See the `GitLab API`__ documentation for details. +Use ``token_command`` to run an external command that prints the +token to stdout. For example, to read tokens from the ``glab`` CLI +configuration:: + + token_command = glab config get --host gitlab.com token + Use ``login`` to override user name detected from the email address. See the :doc:`config` documentation for details on using aliases. Use ``ssl_verify`` to enable/disable SSL verification (default: true) diff --git a/tests/test_base.py b/tests/test_base.py index c07930ea..8c94634c 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -374,3 +374,26 @@ def test_get_token_file_different_name(self) -> None: config = {"mytoken_file": filename} assert did.base.get_token( config, token_file_key="mytoken_file") == token_in_file + + def test_get_token_command(self) -> None: + """ Test getting a token from a command """ + token = str(uuid4()) + config = {"token_command": f"echo {token}"} + assert did.base.get_token(config) == token + + def test_get_token_command_empty(self) -> None: + """ Test getting a token from a command that outputs whitespace """ + config = {"token_command": "echo"} + assert did.base.get_token(config) is None + + def test_get_token_command_failure(self) -> None: + """ Test getting a token from a command that fails """ + config = {"token_command": "false"} + with pytest.raises(did.base.ReportError): + did.base.get_token(config) + + def test_get_token_command_precedence(self) -> None: + """ Test that plain token takes precedence over command """ + token_plain = str(uuid4()) + config = {"token": token_plain, "token_command": "echo other"} + assert did.base.get_token(config) == token_plain