Skip to content

Commit 2a48690

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

File tree

6 files changed

+151
-9
lines changed

6 files changed

+151
-9
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

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ python setup.py install
4444
More usage examples are available under [docs/]. If there's a specific thing you're interested in,
4545
but are not able to get working, please [contact UpCloud support](https://upcloud.com/contact/).
4646

47+
### Load credentials from environment
48+
49+
```python
50+
from upcloud_api import CloudManager, Credentials
51+
52+
credentials = Credentials.parse()
53+
c = CloudManager(**credentials.dict)
54+
c.get_account()
55+
```
56+
4757
### Defining and creating servers
4858

4959
```python

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/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
__copyright__ = 'Copyright (c) 2015 UpCloud Oy'
1212

1313
from upcloud_api.cloud_manager import CloudManager
14+
from upcloud_api.credentials import Credentials
1415
from upcloud_api.errors import UpCloudAPIError, UpCloudClientError
1516
from upcloud_api.firewall import FirewallRule
1617
from upcloud_api.host import Host

upcloud_api/cloud_manager/__init__.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import base64
2-
31
from upcloud_api.api import API
42
from upcloud_api.cloud_manager.firewall_mixin import FirewallManager
53
from upcloud_api.cloud_manager.host_mixin import HostManager
@@ -10,6 +8,8 @@
108
from upcloud_api.cloud_manager.server_mixin import ServerManager
119
from upcloud_api.cloud_manager.storage_mixin import StorageManager
1210
from upcloud_api.cloud_manager.tag_mixin import TagManager
11+
from upcloud_api.credentials import Credentials
12+
from upcloud_api.errors import UpCloudClientError
1313

1414

1515
class CloudManager(
@@ -31,21 +31,23 @@ class CloudManager(
3131

3232
api: API
3333

34-
def __init__(self, username: str, password: str, timeout: int = 60) -> None:
34+
def __init__(
35+
self, username: str = None, password: str = None, timeout: int = 60, token: str = None
36+
) -> None:
3537
"""
3638
Initiates CloudManager that handles all HTTP connections with UpCloud's API.
3739
3840
Optionally determine a timeout for API connections (in seconds). A timeout with the value
3941
`None` means that there is no timeout.
4042
"""
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()
43+
credentials = Credentials(username, password, token)
44+
if not credentials.is_defined:
45+
raise UpCloudClientError(
46+
"Credentials are not defined. Please provide username and password or an API token."
47+
)
4648

4749
self.api = API(
48-
token=f'Basic {encoded_credentials}',
50+
token=credentials.authorization,
4951
timeout=timeout,
5052
)
5153

upcloud_api/credentials.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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+
return base64.b64decode(value).decode()
24+
25+
return value
26+
27+
28+
def _read_keyring_value(username: str) -> str:
29+
if keyring is None:
30+
return None
31+
32+
value = keyring.get_password(KEYRING_SERVICE_NAME, username)
33+
try:
34+
return _parse_keyring_value(value) if value else None
35+
except Exception:
36+
raise UpCloudClientError(
37+
f"Failed to read keyring value for {username}. Ensure that the value saved to the system keyring is correct."
38+
) from None
39+
40+
41+
class Credentials:
42+
"""
43+
Class for handling UpCloud API credentials.
44+
"""
45+
46+
def __init__(self, username: str = None, password: str = None, token: str = None):
47+
"""
48+
Initializes the Credentials object with username, password and/or token. Use `parse` method to read credentials from environment variables or keyring.
49+
"""
50+
self._username = username
51+
self._password = password
52+
self._token = token
53+
54+
@property
55+
def authorization(self) -> str:
56+
"""
57+
Returns the authorization header value based on the provided credentials.
58+
"""
59+
if self._token:
60+
return f"Bearer {self._token}"
61+
62+
credentials = f"{self._username}:{self._password}".encode()
63+
encoded_credentials = base64.b64encode(credentials).decode()
64+
return f"Basic {encoded_credentials}"
65+
66+
@property
67+
def dict(self) -> dict:
68+
"""
69+
Returns the credentials as a dictionary.
70+
"""
71+
return {
72+
"username": self._username,
73+
"password": self._password,
74+
"token": self._token,
75+
}
76+
77+
@property
78+
def is_defined(self) -> bool:
79+
"""
80+
Checks if the credentials are defined.
81+
"""
82+
return bool(self._username and self._password or self._token)
83+
84+
def _read_from_env(self):
85+
if not self._username:
86+
self._username = os.getenv(ENV_KEY_USERNAME)
87+
if not self._password:
88+
self._password = os.getenv(ENV_KEY_PASSWORD)
89+
if not self._token:
90+
self._token = os.getenv(ENV_KEY_TOKEN)
91+
92+
def _read_from_keyring(self):
93+
if self._username and not self._password:
94+
self._password = _read_keyring_value(self._username)
95+
96+
if self.is_defined:
97+
return
98+
99+
self._token = _read_keyring_value(KEYRING_TOKEN_USER)
100+
101+
@classmethod
102+
def parse(cls, username: str = None, password: str = None, token: str = None):
103+
"""
104+
Parses credentials from the provided parameters, environment variables or the system keyring.
105+
"""
106+
credentials = cls(username, password, token)
107+
if credentials.is_defined:
108+
return credentials
109+
110+
credentials._read_from_env()
111+
if credentials.is_defined:
112+
return credentials
113+
114+
credentials._read_from_keyring()
115+
if credentials.is_defined:
116+
return credentials
117+
118+
raise UpCloudClientError(
119+
f"Credentials not found. These must be set in configuration, via environment variables or in the system keyring ({KEYRING_SERVICE_NAME})"
120+
)

0 commit comments

Comments
 (0)