Skip to content

Commit 55e79f6

Browse files
authored
Merge pull request #737 from cderici/charmhub-info-revisit
#737 #### Description This PR revisits the `info` method in `charmhub.py`, allowing users to get information for a charm (like `juju info juju-qa-test`) in pylibjuju using either old or new clients. For old clients it calls `Info` from the `Charmhub` facade, and for the new clients it directly calls the charmhub api and processes the result. The channel-map representations are different between the `2.9` and `3.0` clients, this change introduces extra effort to get them as close as possible. Fixes #734 #### QA Steps The existing charmhub tests are updated, and the ones that we used to skip (due to charmhub facade not existing anymore) are re-activated since we have the working code now (only the `info` ones). QA should be done for both `2.9` and `3.0` clients. So get yourself `juju 2.9` and `juju 3.0` and bootstrap two controllers and run the relevant tests: ``` tox -e integration -- tests/integration/test_charmhub.py::test_info ``` ``` tox -e integration -- tests/integration/test_charmhub.py::test_info_with_channel ``` ``` tox -e integration -- tests/integration/test_charmhub.py::test_info_not_found ``` You may also play with the `examples/charmhub_info.py` with different controllers if you wanna be extra pedantic.
2 parents 46a44f5 + 3bc1c9b commit 55e79f6

2 files changed

Lines changed: 124 additions & 27 deletions

File tree

juju/charmhub.py

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,31 +10,33 @@ class CharmHub:
1010
def __init__(self, model):
1111
self.model = model
1212

13-
def request_charmhub_with_retry(self, url, retries):
13+
async def _charmhub_url(self):
14+
model_conf = await self.model.get_config()
15+
return model_conf['charmhub-url']
16+
17+
async def request_charmhub_with_retry(self, url, retries):
1418
for attempt in range(retries):
1519
_response = requests.get(url)
1620
if _response.status_code == 200:
1721
return _response
18-
jasyncio.sleep(5)
22+
await jasyncio.sleep(5)
1923
raise JujuError("Got {} from {}".format(_response.status_code, url))
2024

2125
async def get_charm_id(self, charm_name):
2226
conn, headers, path_prefix = self.model.connection().https_connection()
2327

24-
model_conf = await self.model.get_config()
25-
charmhub_url = model_conf['charmhub-url']
28+
charmhub_url = await self._charmhub_url()
2629
url = "{}/v2/charms/info/{}".format(charmhub_url.value, charm_name)
27-
_response = self.request_charmhub_with_retry(url, 5)
30+
_response = await self.request_charmhub_with_retry(url, 5)
2831
response = json.loads(_response.text)
2932
return response['id'], response['name']
3033

3134
async def is_subordinate(self, charm_name):
3235
conn, headers, path_prefix = self.model.connection().https_connection()
3336

34-
model_conf = await self.model.get_config()
35-
charmhub_url = model_conf['charmhub-url']
37+
charmhub_url = await self._charmhub_url()
3638
url = "{}/v2/charms/info/{}?fields=default-release.revision.subordinate".format(charmhub_url.value, charm_name)
37-
_response = self.request_charmhub_with_retry(url, 5)
39+
_response = await self.request_charmhub_with_retry(url, 5)
3840
response = json.loads(_response.text)
3941
return 'subordinate' in response['default-release']['revision']
4042

@@ -44,10 +46,9 @@ async def is_subordinate(self, charm_name):
4446
async def list_resources(self, charm_name):
4547
conn, headers, path_prefix = self.model.connection().https_connection()
4648

47-
model_conf = await self.model.get_config()
48-
charmhub_url = model_conf['charmhub-url']
49+
charmhub_url = await self._charmhub_url()
4950
url = "{}/v2/charms/info/{}?fields=default-release.resources".format(charmhub_url.value, charm_name)
50-
_response = self.request_charmhub_with_retry(url, 5)
51+
_response = await self.request_charmhub_with_retry(url, 5)
5152
response = json.loads(_response.text)
5253
return response['default-release']['resources']
5354

@@ -63,11 +64,89 @@ async def info(self, name, channel=None):
6364
if not name:
6465
raise JujuError("name expected")
6566

66-
if channel is None:
67-
channel = ""
68-
69-
facade = self._facade()
70-
return await facade.Info(tag="application-{}".format(name), channel=channel)
67+
if self.model.connection().is_using_old_client:
68+
if channel is None:
69+
channel = ""
70+
facade = self._facade()
71+
res = await facade.Info(tag="application-{}".format(name),
72+
channel=channel)
73+
err_code = res.errors.error_list.code
74+
if err_code:
75+
raise JujuError(f'charmhub.info - {err_code} :'
76+
f' {res.errors.error_list.message}')
77+
result = res.result
78+
result.channel_map = CharmHub._channel_map_to_dict(
79+
result.channel_map,
80+
name,
81+
channel=channel)
82+
result = result.serialize()
83+
else:
84+
charmhub_url = await self._charmhub_url()
85+
url = "{}/v2/charms/info/{}?fields=channel-map".format(
86+
charmhub_url.value, name)
87+
try:
88+
_response = await self.request_charmhub_with_retry(url, 5)
89+
except JujuError as e:
90+
if '404' in e.message:
91+
raise JujuError(f'{name} not found') from e
92+
result = json.loads(_response.text)
93+
result['channel-map'] = CharmHub._channel_list_to_map(result['channel-map'],
94+
name,
95+
channel=channel)
96+
return result
97+
98+
@staticmethod
99+
def _channel_list_to_map(channel_list_map, name, channel=""):
100+
"""Charmhub API returns the channel map as a list of channel objects
101+
(with risk, track, revision, download etc). This turns that into a map
102+
that's keyed with the channel=track/risk for easy
103+
filtering/processing. This representation is also closer to the
104+
result of the 2.9 facade call.
105+
106+
So basically,
107+
[{'channel':{'risk':'stable', 'track':'latest'}, 'revision': 58}]
108+
becomes:
109+
{'latest/stable': {'channel':{'risk':'stable', 'track':'latest'},
110+
'revision': 58}}
111+
112+
:param channel_list_map: [map[str][any]]
113+
:return: map[str][map[str][any]]
114+
"""
115+
channel_map = {}
116+
for ch in channel_list_map:
117+
ch_name = f"{ch['channel']['track']}/{ch['channel']['risk']}"
118+
if channel and channel != ch_name:
119+
# If channel is given, then filter out the rest
120+
continue
121+
channel_map[ch_name] = ch
122+
if channel == ch_name:
123+
# If we found the desired channel, no need to continue
124+
break
125+
# After loop is done, check for non-existent channel
126+
if channel and channel not in channel_map:
127+
raise JujuError(f'Charmhub.info : channel {channel} not found for'
128+
f' {name}')
129+
return channel_map
130+
131+
@staticmethod
132+
def _channel_map_to_dict(channel_map, name, channel=""):
133+
"""Converts the client.definitions.Channel objects into python maps
134+
inside a channel map (for pylibjuju <3.0)
135+
136+
:param channel_map: map[str][Channel]
137+
:return: map[str][map[str][any]]
138+
"""
139+
channel_dict = {}
140+
for ch_name, ch_obj in channel_map.items():
141+
# No need to worry about filtering channel
142+
# Charmhub facade will take care of that
143+
_ch = ch_obj.serialize()
144+
_ch['platforms'] = [p.serialize() for p in _ch['platforms']]
145+
channel_dict[ch_name] = _ch
146+
if channel and channel not in channel_dict:
147+
raise JujuError(f'Charmhub.info : channel {channel} not found for'
148+
f' {name}')
149+
return channel_dict
71150

72151
async def find(self, query, category=None, channel=None,
73152
charm_type=None, platforms=None, publisher=None,

tests/integration/test_charmhub.py

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22

33
from .. import base
4-
from juju.errors import JujuAPIError, JujuError
4+
from juju.errors import JujuError
55
from juju import jasyncio
66

77

@@ -12,29 +12,47 @@ async def test_info(event_loop):
1212
_, name = await model.charmhub.get_charm_id("hello-juju")
1313
assert name == "hello-juju"
1414

15+
charm_name = 'juju-qa-test'
16+
charm_info = await model.charmhub.info(charm_name)
17+
assert charm_info['name'] == 'juju-qa-test'
18+
assert charm_info['type'] == 'charm'
19+
assert charm_info['id'] == 'Hw30RWzpUBnJLGtO71SX8VDWvd3WrjaJ'
20+
assert '2.0/stable' in charm_info['channel-map']
21+
cm_rev = charm_info['channel-map']['2.0/stable']['revision']
22+
if type(cm_rev) == dict:
23+
# New client (>= 3.0)
24+
assert cm_rev['revision'] == 22
25+
else:
26+
# Old client (<= 2.9)
27+
assert cm_rev == 22
28+
1529

1630
@base.bootstrapped
1731
@pytest.mark.asyncio
18-
@pytest.mark.skip('CharmHub facade no longer exists')
1932
async def test_info_with_channel(event_loop):
2033
async with base.CleanModel() as model:
21-
result = await model.charmhub.info("hello-juju", "latest/stable")
34+
charm_info = await model.charmhub.info("juju-qa-test", "2.0/stable")
35+
assert charm_info['name'] == 'juju-qa-test'
36+
assert '2.0/stable' in charm_info['channel-map']
37+
assert 'latest/stable' not in charm_info['channel-map']
2238

23-
assert result.result.name == "hello-juju"
24-
assert "latest/stable" in result.result.channel_map
39+
try:
40+
await model.charmhub.info("juju-qa-test", "non-existing-channel")
41+
except JujuError as err:
42+
assert err.message == 'Charmhub.info : channel ' \
43+
'non-existing-channel not found for ' \
44+
'juju-qa-test'
45+
else:
46+
assert False, "non-existing-channel didn't raise an error"
2547

2648

2749
@base.bootstrapped
2850
@pytest.mark.asyncio
29-
@pytest.mark.skip('CharmHub facade no longer exists')
3051
async def test_info_not_found(event_loop):
3152
async with base.CleanModel() as model:
32-
try:
53+
with pytest.raises(JujuError) as err:
3354
await model.charmhub.info("badnameforapp")
34-
except JujuAPIError as e:
35-
assert e.message == "badnameforapp not found"
36-
else:
37-
assert False
55+
assert "badnameforapp not found" in str(err)
3856

3957

4058
@base.bootstrapped

0 commit comments

Comments
 (0)