Skip to content

Commit 74bb9f3

Browse files
committed
feat: add support for tokens and system keyring
1 parent 841f805 commit 74bb9f3

File tree

6 files changed

+117
-7
lines changed

6 files changed

+117
-7
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12+
### Added
13+
14+
- Support authentication with API tokens
15+
- Added `Credentials` class for parsing credentials from environment similarly than in our Go based tooling.
16+
1217
## [2.7.0] - 2025-07-23
1318

1419
### Changed

setup.cfg

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,7 @@ install_requires =
2020
packages =
2121
upcloud_api
2222
upcloud_api.cloud_manager
23+
24+
[options.extras_require]
25+
keyring =
26+
keyring>=23.0

upcloud_api/cloud_manager/__init__.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from upcloud_api.cloud_manager.server_mixin import ServerManager
1111
from upcloud_api.cloud_manager.storage_mixin import StorageManager
1212
from upcloud_api.cloud_manager.tag_mixin import TagManager
13+
from upcloud_api.credentials import Credentials
1314

1415

1516
class CloudManager(
@@ -31,21 +32,17 @@ class CloudManager(
3132

3233
api: API
3334

34-
def __init__(self, username: str, password: str, timeout: int = 60) -> None:
35+
def __init__(self, username: str = None, password: str = None, timeout: int = 60, token: str = None) -> None:
3536
"""
3637
Initiates CloudManager that handles all HTTP connections with UpCloud's API.
3738
3839
Optionally determine a timeout for API connections (in seconds). A timeout with the value
3940
`None` means that there is no timeout.
4041
"""
41-
if not username or not password:
42-
raise Exception('Invalid credentials, please provide a username and password')
43-
44-
credentials = f'{username}:{password}'.encode()
45-
encoded_credentials = base64.b64encode(credentials).decode()
42+
credentials = Credentials.parse(username, password, token)
4643

4744
self.api = API(
48-
token=f'Basic {encoded_credentials}',
45+
token=credentials.authorization,
4946
timeout=timeout,
5047
)
5148

upcloud_api/credentials.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import base64
2+
import os
3+
4+
try:
5+
import keyring
6+
except ImportError:
7+
keyring = None
8+
9+
from upcloud_api.errors import UpCloudClientError
10+
11+
ENV_KEY_USERNAME = "UPCLOUD_USERNAME"
12+
ENV_KEY_PASSWORD = "UPCLOUD_PASSWORD" # noqa: S105
13+
ENV_KEY_TOKEN = "UPCLOUD_TOKEN" # noqa: S105
14+
15+
KEYRING_SERVICE_NAME = "UpCloud"
16+
KEYRING_TOKEN_USER = ""
17+
KEYRING_GO_PREFIX = "go-keyring-base64:"
18+
19+
20+
def _parse_keyring_value(value: str) -> str:
21+
if value.startswith(KEYRING_GO_PREFIX):
22+
value = value[len(KEYRING_GO_PREFIX):]
23+
24+
try:
25+
return base64.b64decode(value).decode()
26+
except:
27+
raise UpCloudClientError(f"Invalid keyring value: {value}")
28+
29+
return value
30+
31+
32+
def _read_keyring_value(username: str) -> str:
33+
if keyring is None:
34+
return None
35+
36+
value = keyring.get_password(KEYRING_SERVICE_NAME, username)
37+
return _parse_keyring_value(value) if value else None
38+
39+
class Credentials:
40+
"""
41+
Class for handling UpCloud API credentials.
42+
"""
43+
44+
def __init__(self, username: str = None, password: str = None, token: str = None):
45+
self._username = username
46+
self._password = password
47+
self._token = token
48+
49+
@property
50+
def authorization(self) -> str:
51+
"""
52+
Returns the authorization header value based on the provided credentials.
53+
"""
54+
if self._token:
55+
return f'Bearer {self._token}'
56+
57+
credentials = f'{self._username}:{self._password}'.encode()
58+
encoded_credentials = base64.b64encode(credentials).decode()
59+
return f'Basic {encoded_credentials}'
60+
61+
@property
62+
def is_defined(self) -> bool:
63+
"""
64+
Checks if the credentials are defined.
65+
"""
66+
return bool(self._username and self._password or self._token)
67+
68+
def _read_from_env(self):
69+
if not self._username:
70+
self._username = os.getenv(ENV_KEY_USERNAME)
71+
if not self._password:
72+
self._password = os.getenv(ENV_KEY_PASSWORD)
73+
if not self._token:
74+
self._token = os.getenv(ENV_KEY_TOKEN)
75+
76+
def _read_from_keyring(self):
77+
if self._username and not self._password:
78+
self._password = _read_keyring_value(self._username)
79+
80+
if self.is_defined:
81+
return
82+
83+
self._token = _read_keyring_value(KEYRING_TOKEN_USER)
84+
85+
@classmethod
86+
def parse(cls, username: str = None, password: str = None, token: str = None):
87+
"""
88+
Parses credentials from the provided parameters, environment variables or the system keyring.
89+
"""
90+
credentials = cls(username, password, token)
91+
if credentials.is_defined:
92+
return credentials
93+
94+
credentials._read_from_env()
95+
if credentials.is_defined:
96+
return credentials
97+
98+
credentials._read_from_keyring()
99+
if credentials.is_defined:
100+
return credentials
101+
102+
raise UpCloudClientError(f'Credentials not found. These must be set in configuration, via environment variables or in the system keyring ({KEYRING_SERVICE_NAME})')

upcloud_api/ip_address.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class IPAddress(UpCloudResource):
1818
1919
Note that all of the fields are not always available depending on the API call,
2020
consult the official API docs for details.
21+
2122
"""
2223

2324
ATTRIBUTES = {

upcloud_api/tag.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class Tag(UpCloudResource):
1212
description -- optional description
1313
servers -- list of Server objects (with only uuid populated)
1414
can be instantiated with UUID strings or Server objects
15+
1516
"""
1617

1718
ATTRIBUTES = {'name': None, 'description': None, 'servers': []}

0 commit comments

Comments
 (0)