Skip to content

Commit 07a4b71

Browse files
authored
Add tox with black formatter (#171)
Add initial operator unit tests. Tox config turns on black formatter. Tweak to operator startup to stop loading config on module load, so we can test it.
1 parent dc9de46 commit 07a4b71

File tree

11 files changed

+230
-79
lines changed

11 files changed

+230
-79
lines changed

.github/workflows/test-pr.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ concurrency:
2525
# TODO(mkjpryor): Change this in the future to use the CAPI management only variation
2626
#####
2727
jobs:
28+
# Run the unit tests on every PR, even from external repos
29+
unit_tests:
30+
uses: ./.github/workflows/tox.yaml
31+
with:
32+
ref: ${{ github.event.pull_request.head.sha }}
33+
2834
# This job exists so that PRs from outside the main repo are rejected
2935
fail_on_remote:
3036
runs-on: ubuntu-latest
@@ -33,7 +39,7 @@ jobs:
3339
run: exit ${{ github.event.pull_request.head.repo.full_name == 'azimuth-cloud/cluster-api-janitor-openstack' && '0' || '1' }}
3440

3541
publish_artifacts:
36-
needs: [fail_on_remote]
42+
needs: [unit_tests,fail_on_remote]
3743
uses: ./.github/workflows/build-push-artifacts.yaml
3844
with:
3945
ref: ${{ github.event.pull_request.head.sha }}

.github/workflows/tox.yaml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Tox unit tests
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
ref:
7+
type: string
8+
description: The ref to build.
9+
required: true
10+
11+
jobs:
12+
build:
13+
name: Tox unit tests and linting
14+
runs-on: ubuntu-latest
15+
strategy:
16+
matrix:
17+
python-version: ['3.10']
18+
19+
steps:
20+
- name: Check out the repository
21+
uses: actions/checkout@v4
22+
with:
23+
ref: ${{ inputs.ref || github.ref }}
24+
25+
- name: Set up Python ${{ matrix.python-version }}
26+
uses: actions/setup-python@v5
27+
with:
28+
python-version: ${{ matrix.python-version }}
29+
30+
- name: Install dependencies
31+
run: |
32+
python -m pip install --upgrade pip
33+
python -m pip install tox
34+
35+
- name: Test with tox
36+
run: tox
37+
38+
- name: Generate coverage reports
39+
run: tox -e cover
40+
41+
- name: Archive code coverage results
42+
uses: actions/upload-artifact@v4
43+
with:
44+
name: code-coverage-report
45+
path: cover/

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ __pycache__
33
.python-version
44
chart/charts/*
55
chart/Chart.lock
6+
# ignore unit test stuff
7+
.stestr/*
8+
.tox/*
9+
.coverage

.stestr.conf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[DEFAULT]
2+
test_path=./capi_janitor/tests
3+
top_dir=./

capi_janitor/openstack/openstack.py

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,25 @@ class UnsupportedAuthenticationError(Exception):
1212
"""
1313
Raised when an unsupported authentication method is used.
1414
"""
15+
1516
def __init__(self, auth_type):
1617
super().__init__(f"unsupported authentication type: {auth_type}")
1718

19+
1820
class AuthenticationError(Exception):
1921
"""
2022
Raised when an unknown authentication error is encountered.
2123
"""
24+
2225
def __init__(self, user):
2326
super().__init__(f"failed to authenticate as user: {user}")
2427

28+
2529
class CatalogError(Exception):
2630
"""
2731
Raised when an unknown catalog service type is requested.
2832
"""
33+
2934
def __init__(self, name):
3035
super().__init__(f"service type {name} not found in OpenStack service catalog")
3136

@@ -34,7 +39,10 @@ class Auth(httpx.Auth):
3439
"""
3540
Authenticator class for OpenStack connections.
3641
"""
37-
def __init__(self, auth_url, application_credential_id, application_credential_secret):
42+
43+
def __init__(
44+
self, auth_url, application_credential_id, application_credential_secret
45+
):
3846
self.url = auth_url
3947
self._application_credential_id = application_credential_id
4048
self._application_credential_secret = application_credential_secret
@@ -58,7 +66,7 @@ def _build_token_request(self):
5866
return httpx.Request(
5967
"POST",
6068
f"{self.url}/v3/auth/tokens",
61-
json = {
69+
json={
6270
"auth": {
6371
"identity": {
6472
"methods": ["application_credential"],
@@ -68,7 +76,7 @@ def _build_token_request(self):
6876
},
6977
},
7078
},
71-
}
79+
},
7280
)
7381

7482
def _handle_token_response(self, response):
@@ -82,15 +90,16 @@ async def async_auth_flow(self, request):
8290
response = yield self._build_token_request()
8391
await response.aread()
8492
self._handle_token_response(response)
85-
request.headers['X-Auth-Token'] = self._token
93+
request.headers["X-Auth-Token"] = self._token
8694
response = yield request
8795

8896

8997
class Resource(rest.Resource):
9098
"""
9199
Base resource for OpenStack APIs.
92100
"""
93-
def __init__(self, client, name, prefix = None, plural_name = None, singular_name = None):
101+
102+
def __init__(self, client, name, prefix=None, plural_name=None, singular_name=None):
94103
super().__init__(client, name, prefix)
95104
# Some resources support a /detail endpoint
96105
# In this case, we just want to use the name up to the slash as the plural name
@@ -114,7 +123,7 @@ def _extract_next_page(self, response):
114123
for link in response.json().get(f"{self._plural_name}_links", [])
115124
if link["rel"] == "next"
116125
),
117-
None
126+
None,
118127
)
119128
# Sometimes, the returned URLs have http where they should have https
120129
# To mitigate this, we split the URL and return the path and params separately
@@ -134,24 +143,27 @@ class Client(rest.AsyncClient):
134143
"""
135144
Client for OpenStack APIs.
136145
"""
137-
def __init__(self, /, base_url, prefix = None, **kwargs):
146+
147+
def __init__(self, /, base_url, prefix=None, **kwargs):
138148
# Extract the path part of the base_url
139149
url = urllib.parse.urlsplit(base_url)
140150
# Initialise the client with the scheme/host
141-
super().__init__(base_url = f"{url.scheme}://{url.netloc}", **kwargs)
151+
super().__init__(base_url=f"{url.scheme}://{url.netloc}", **kwargs)
142152
# If another prefix is not given, use the path from the base URL as the prefix,
143153
# otherwise combine the prefixes and remove duplicated path sections.
144154
# This ensures things like pagination work nicely without duplicating the prefix
145155
if prefix:
146-
self._prefix = "/".join([url.path.rstrip("/"), prefix.lstrip("/").lstrip(url.path)])
156+
self._prefix = "/".join(
157+
[url.path.rstrip("/"), prefix.lstrip("/").lstrip(url.path)]
158+
)
147159
else:
148160
self._prefix = url.path
149161

150162
def __aenter__(self):
151163
# Prevent individual clients from being used in a context manager
152164
raise RuntimeError("clients must be used via a cloud object")
153165

154-
def resource(self, name, prefix = None, plural_name = None, singular_name = None):
166+
def resource(self, name, prefix=None, plural_name=None, singular_name=None):
155167
# If an additional prefix is given, combine it with the existing prefix
156168
if prefix:
157169
prefix = "/".join([self._prefix.rstrip("/"), prefix.lstrip("/")])
@@ -164,7 +176,8 @@ class Cloud:
164176
"""
165177
Object for interacting with OpenStack clouds.
166178
"""
167-
def __init__(self, auth, transport, interface, region = None):
179+
180+
def __init__(self, auth, transport, interface, region=None):
168181
self._auth = auth
169182
self._transport = transport
170183
self._interface = interface
@@ -176,7 +189,9 @@ def __init__(self, auth, transport, interface, region = None):
176189
async def __aenter__(self):
177190
await self._transport.__aenter__()
178191
# Once the transport has been initialised, we can initialise the endpoints
179-
client = Client(base_url = self._auth.url, auth = self._auth, transport = self._transport)
192+
client = Client(
193+
base_url=self._auth.url, auth=self._auth, transport=self._transport
194+
)
180195
try:
181196
response = await client.get("/v3/auth/catalog")
182197
except httpx.HTTPStatusError as exc:
@@ -190,8 +205,8 @@ async def __aenter__(self):
190205
ep["url"]
191206
for ep in entry["endpoints"]
192207
if (
193-
ep["interface"] == self._interface and
194-
(not self._region or ep["region"] == self._region)
208+
ep["interface"] == self._interface
209+
and (not self._region or ep["region"] == self._region)
195210
)
196211
)
197212
for entry in response.json()["catalog"]
@@ -223,16 +238,16 @@ def apis(self):
223238
"""
224239
return list(self._endpoints.keys())
225240

226-
def api_client(self, name, prefix = None):
241+
def api_client(self, name, prefix=None):
227242
"""
228243
Returns a client for the named API.
229244
"""
230245
if name not in self._clients:
231246
self._clients[name] = Client(
232-
base_url = self._endpoints[name],
233-
prefix = prefix,
234-
auth = self._auth,
235-
transport = self._transport
247+
base_url=self._endpoints[name],
248+
prefix=prefix,
249+
auth=self._auth,
250+
transport=self._transport,
236251
)
237252
return self._clients[name]
238253

@@ -245,13 +260,13 @@ def from_clouds(cls, clouds, cloud, cacert):
245260
auth = Auth(
246261
auth_url,
247262
config["auth"]["application_credential_id"],
248-
config["auth"]["application_credential_secret"]
263+
config["auth"]["application_credential_secret"],
249264
)
250265
region = config.get("region_name")
251266
# Create a default context using the verification from the config
252-
context = httpx.create_ssl_context(verify = config.get("verify", True))
267+
context = httpx.create_ssl_context(verify=config.get("verify", True))
253268
# If a cacert was given, load it into the context
254269
if cacert is not None:
255-
context.load_verify_locations(cadata = cacert)
256-
transport = httpx.AsyncHTTPTransport(verify = context)
270+
context.load_verify_locations(cadata=cacert)
271+
transport = httpx.AsyncHTTPTransport(verify=context)
257272
return cls(auth, transport, config.get("interface", "public"), region)

0 commit comments

Comments
 (0)