Skip to content

Commit c58ec9d

Browse files
authored
Merge pull request #1155 from dimaqq/chore-refactor-facade-versions
#1155 #### Description Streamlining facades code: - removing intermediate "versions" key from: - static facade version declaration - specified facades argument - deprecating the `specified_facades` argument as it's - not covered by unit or integration tests - not used by any library user
2 parents 60821da + dfeb7fc commit c58ec9d

5 files changed

Lines changed: 190 additions & 101 deletions

File tree

juju/client/connection.py

Lines changed: 33 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
import logging
77
import ssl
88
import urllib.request
9+
import warnings
910
import weakref
1011
from http.client import HTTPSConnection
1112
from dateutil.parser import parse
12-
from typing import Dict, List
13+
from typing import Dict, Literal, Optional, Sequence
1314

1415
import macaroonbakery.bakery as bakery
1516
import macaroonbakery.httpbakery as httpbakery
@@ -18,53 +19,10 @@
1819
from juju.client import client
1920
from juju.utils import IdQueue
2021
from juju.version import CLIENT_VERSION
22+
from .facade_versions import client_facade_versions, known_unsupported_facades
2123

2224
log = logging.getLogger('juju.client.connection')
2325

24-
# Manual list of facades present in schemas + codegen which python-libjuju does not yet support
25-
excluded_facades: Dict[str, List[int]] = {
26-
'Charms': [7],
27-
}
28-
# Please keep in alphabetical order
29-
# in future this will likely be generated automatically (perhaps at runtime)
30-
client_facades = {
31-
'Action': {'versions': [7]},
32-
'Admin': {'versions': [3]},
33-
'AllModelWatcher': {'versions': [4]},
34-
'AllWatcher': {'versions': [3]},
35-
'Annotations': {'versions': [2]},
36-
'Application': {'versions': [17, 19]},
37-
'ApplicationOffers': {'versions': [4]},
38-
'Backups': {'versions': [3]},
39-
'Block': {'versions': [2]},
40-
'Bundle': {'versions': [6]},
41-
'Charms': {'versions': [6]},
42-
'Client': {'versions': [6, 7]},
43-
'Cloud': {'versions': [7]},
44-
'Controller': {'versions': [11]},
45-
'CredentialManager': {'versions': [1]},
46-
'FirewallRules': {'versions': [1]},
47-
'HighAvailability': {'versions': [2]},
48-
'ImageMetadataManager': {'versions': [1]},
49-
'KeyManager': {'versions': [1]},
50-
'MachineManager': {'versions': [10]},
51-
'MetricsDebug': {'versions': [2]},
52-
'ModelConfig': {'versions': [3]},
53-
'ModelGeneration': {'versions': [4]},
54-
'ModelManager': {'versions': [9]},
55-
'ModelUpgrader': {'versions': [1]},
56-
'Payloads': {'versions': [1]},
57-
'Pinger': {'versions': [1]},
58-
'Resources': {'versions': [3]},
59-
'SSHClient': {'versions': [4]},
60-
'SecretBackends': {'versions': [1]},
61-
'Secrets': {'versions': [1, 2]},
62-
'Spaces': {'versions': [6]},
63-
'Storage': {'versions': [6]},
64-
'Subnets': {'versions': [5]},
65-
'UserManager': {'versions': [3]},
66-
}
67-
6826

6927
def facade_versions(name, versions):
7028
"""
@@ -156,6 +114,8 @@ class Connection:
156114

157115
MAX_FRAME_SIZE = 2**22
158116
"Maximum size for a single frame. Defaults to 4MB."
117+
facades: Dict[str, int]
118+
_specified_facades: Dict[str, Sequence[int]]
159119

160120
@classmethod
161121
async def connect(
@@ -169,7 +129,7 @@ async def connect(
169129
max_frame_size=None,
170130
retries=3,
171131
retry_backoff=10,
172-
specified_facades=None,
132+
specified_facades: Optional[Dict[str, Dict[Literal["versions"], Sequence[int]]]] = None,
173133
proxy=None,
174134
debug_log_conn=None,
175135
debug_log_params={}
@@ -197,7 +157,7 @@ async def connect(
197157
:param int retry_backoff: Number of seconds to increase the wait
198158
between connection retry attempts (a backoff of 10 with 3 retries
199159
would wait 10s, 20s, and 30s).
200-
:param specified_facades: Define a series of facade versions you wish to override
160+
:param specified_facades: (deprecated) define a series of facade versions you wish to override
201161
to prevent using the conservative client pinning with in the client.
202162
:param TextIOWrapper debug_log_conn: target if this is a debug log connection
203163
:param dict debug_log_params: filtering parameters for the debug-log output
@@ -247,7 +207,18 @@ async def connect(
247207
self._retry_backoff = retry_backoff
248208

249209
self.facades = {}
250-
self.specified_facades = specified_facades or {}
210+
211+
if specified_facades:
212+
warnings.warn(
213+
"The `specified_facades` argument is deprecated and will be removed soon",
214+
DeprecationWarning,
215+
stacklevel=3,
216+
)
217+
self._specified_facades = {
218+
name: d["versions"] for name, d in specified_facades.items()
219+
}
220+
else:
221+
self._specified_facades = {}
251222

252223
self.messages = IdQueue()
253224
self.monitor = Monitor(connection=self)
@@ -826,48 +797,31 @@ async def _connect_with_redirect(self, endpoints):
826797
self._pinger_task = jasyncio.create_task(self._pinger(), name="Task_Pinger")
827798

828799
# _build_facades takes the facade list that comes from the connection with the controller,
829-
# validates that the client knows about them (client_facades) and builds the facade list
830-
# (into the self.specified facades) with the max versions that both the client and the controller
800+
# validates that the client knows about them (client_facade_versions) and builds the facade list
801+
# (into the self._specified facades) with the max versions that both the client and the controller
831802
# can negotiate on
832803
def _build_facades(self, facades_from_connection):
833804
self.facades.clear()
834805
for facade in facades_from_connection:
835806
name = facade['name']
836-
# the following attempts to get the best facade version for the
837-
# client. The client knows about the best facade versions it speaks,
838-
# so in order to be compatible forwards and backwards we speak a
839-
# common facade versions.
840-
if (name not in client_facades) and (name not in self.specified_facades):
841-
# if a facade is required but the client doesn't know about
842-
# it, then log a warning.
807+
if name in self._specified_facades:
808+
client_versions = self._specified_facades[name]
809+
elif name in client_facade_versions:
810+
client_versions = client_facade_versions[name]
811+
elif name in known_unsupported_facades:
812+
continue
813+
else:
843814
log.warning(f'unexpected facade {name} received from the controller')
844815
continue
845816

846-
try:
847-
# allow the ability to specify a set of facade versions, so the
848-
# client can define the non-conservative facade client pinning.
849-
if name in self.specified_facades:
850-
client_versions = self.specified_facades[name]['versions']
851-
elif name in client_facades:
852-
client_versions = client_facades[name]['versions']
853-
854-
controller_versions = facade['versions']
855-
# select the max version that both the client and the controller know
856-
version = max(set(client_versions).intersection(set(controller_versions)))
857-
except ValueError:
858-
# this can occur if client_verisons is [1, 2] and controller_versions is [3, 4]
859-
# there is just no way to know how to communicate with the facades we're trying to call.
817+
controller_versions = facade['versions']
818+
candidates = set(client_versions) & set(controller_versions)
819+
if not candidates:
860820
log.warning(f'unknown common facade version for {name},\n'
861821
f'versions known to client : {client_versions}\n'
862822
f'versions known to controller : {controller_versions}')
863-
except errors.JujuConnectionError:
864-
# If the facade isn't with in the local facades then it's not
865-
# possible to reason about what version should be used. In this
866-
# case we should log the facade was found, but we couldn't
867-
# handle it.
868-
log.warning(f'unexpected facade {name} found, unable to determine which version to use')
869-
else:
870-
self.facades[name] = version
823+
continue
824+
self.facades[name] = max(candidates)
871825

872826
async def login(self):
873827
params = {}

juju/client/facade_versions.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Copyright 2024 Canonical Ltd.
2+
# Licensed under the Apache V2, see LICENCE file for details.
3+
"""Constants for facade version negotation."""
4+
from typing import Dict, Sequence
5+
6+
7+
# Please keep in alphabetical order
8+
# in future this will likely be generated automatically (perhaps at runtime)
9+
client_facade_versions = {
10+
'Action': (7, ),
11+
'Admin': (3, ),
12+
'AllModelWatcher': (4, ),
13+
'AllWatcher': (3, ),
14+
'Annotations': (2, ),
15+
'Application': (17, 19),
16+
'ApplicationOffers': (4, ),
17+
'Backups': (3, ),
18+
'Block': (2, ),
19+
'Bundle': (6, ),
20+
'Charms': (6, ),
21+
'Client': (6, 7),
22+
'Cloud': (7, ),
23+
'Controller': (11, ),
24+
'CredentialManager': (1, ),
25+
'FirewallRules': (1, ),
26+
'HighAvailability': (2, ),
27+
'ImageMetadataManager': (1, ),
28+
'KeyManager': (1, ),
29+
'MachineManager': (10, ),
30+
'MetricsDebug': (2, ),
31+
'ModelConfig': (3, ),
32+
'ModelGeneration': (4, ),
33+
'ModelManager': (9, ),
34+
'ModelUpgrader': (1, ),
35+
'Payloads': (1, ),
36+
'Pinger': (1, ),
37+
'Resources': (3, ),
38+
'SSHClient': (4, ),
39+
'SecretBackends': (1, ),
40+
'Secrets': (1, 2),
41+
'Spaces': (6, ),
42+
'Storage': (6, ),
43+
'Subnets': (5, ),
44+
'UserManager': (3, ),
45+
}
46+
47+
# Manual list of facades present in schemas + codegen which python-libjuju does not yet support
48+
excluded_facade_versions: Dict[str, Sequence[int]] = {
49+
'Charms': (7, )
50+
}
51+
52+
53+
# We don't generate code for these, as we can never use them.
54+
# The controller happily lists them though, don't warn about these.
55+
known_unsupported_facades = (
56+
'ActionPruner',
57+
'Agent',
58+
'AgentLifeFlag',
59+
'AgentTools',
60+
'ApplicationScaler',
61+
'CAASAdmission',
62+
'CAASAgent',
63+
'CAASApplication',
64+
'CAASApplicationProvisioner',
65+
'CAASFirewaller',
66+
'CAASFirewallerSidecar',
67+
'CAASModelConfigManager',
68+
'CAASModelOperator',
69+
'CAASOperator',
70+
'CAASOperatorProvisioner',
71+
'CAASOperatorUpgrader',
72+
'CAASUnitProvisioner',
73+
'CharmDownloader',
74+
'CharmRevisionUpdater',
75+
'Cleaner',
76+
'CredentialValidator',
77+
'CrossController',
78+
'CrossModelRelations',
79+
'CrossModelSecrets',
80+
'Deployer',
81+
'DiskManager',
82+
'EntityWatcher',
83+
'EnvironUpgrader',
84+
'ExternalControllerUpdater',
85+
'FanConfigurer',
86+
'FilesystemAttachmentsWatcher',
87+
'Firewaller',
88+
'HostKeyReporter',
89+
'ImageMetadata',
90+
'InstanceMutater',
91+
'InstancePoller',
92+
'KeyUpdater',
93+
'LeadershipService',
94+
'LifeFlag',
95+
'LogForwarding',
96+
'Logger',
97+
'MachineActions',
98+
'MachineUndertaker',
99+
'Machiner',
100+
'MeterStatus',
101+
'MetricsAdder',
102+
'MetricsManager',
103+
'MigrationFlag',
104+
'MigrationMaster',
105+
'MigrationMinion',
106+
'MigrationStatusWatcher',
107+
'MigrationTarget',
108+
'ModelSummaryWatcher',
109+
'NotifyWatcher',
110+
'OfferStatusWatcher',
111+
'PayloadsHookContext',
112+
'Provisioner',
113+
'ProxyUpdater',
114+
'Reboot',
115+
'RelationStatusWatcher',
116+
'RelationUnitsWatcher',
117+
'RemoteRelationWatcher',
118+
'RemoteRelations',
119+
'ResourcesHookContext',
120+
'RetryStrategy',
121+
'SecretBackendsManager',
122+
'SecretBackendsRotateWatcher',
123+
'SecretsDrain',
124+
'SecretsManager',
125+
'SecretsRevisionWatcher',
126+
'SecretsTriggerWatcher',
127+
'Singular',
128+
'StatusHistory',
129+
'StorageProvisioner',
130+
'StringsWatcher',
131+
'Undertaker',
132+
'UnitAssigner',
133+
'Uniter',
134+
'UpgradeSeries',
135+
'UpgradeSteps',
136+
'Upgrader',
137+
'UserSecretsDrain',
138+
'UserSecretsManager',
139+
'VolumeAttachmentPlansWatcher',
140+
'VolumeAttachmentsWatcher'
141+
)

juju/controller.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ async def connect(self, *args, **kwargs):
9393
:param list macaroons: List of macaroons to load into the
9494
``bakery_client``.
9595
:param int max_frame_size: The maximum websocket frame size to allow.
96-
:param specified_facades: Overwrite the facades with a series of
96+
:param specified_facades: (deprecated) overwrite the facades with a series of
9797
specified facades.
9898
"""
9999
await self.disconnect()

juju/model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,7 @@ async def connect(self, *args, **kwargs):
649649
:param list macaroons: List of macaroons to load into the
650650
``bakery_client``.
651651
:param int max_frame_size: The maximum websocket frame size to allow.
652-
:param specified_facades: Overwrite the facades with a series of
652+
:param specified_facades: (deprecated) overwrite the facades with a series of
653653
specified facades.
654654
"""
655655
is_debug_log_conn = 'debug_log_conn' in kwargs
Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,11 @@
44
import importlib
55
from collections import defaultdict
66
from pathlib import Path
7-
from typing import Dict, List, TypedDict
7+
from typing import Dict, List, Sequence
88

99
import pytest
1010

11-
from juju.client.connection import client_facades, excluded_facades
12-
13-
14-
class Versions(TypedDict, total=True):
15-
versions: List[int]
16-
17-
18-
ClientFacades = Dict[str, Versions]
11+
from juju.client.facade_versions import client_facade_versions, excluded_facade_versions, known_unsupported_facades
1912

2013

2114
@pytest.fixture
@@ -24,8 +17,8 @@ def project_root(pytestconfig: pytest.Config) -> Path:
2417

2518

2619
@pytest.fixture
27-
def generated_code_facades(project_root: Path) -> ClientFacades:
28-
"""Return a client_facades dictionary from generated code under project_root.
20+
def generated_code_facades(project_root: Path) -> Dict[str, Sequence[int]]:
21+
"""Return a {facade_name: (versions,)} dictionary from the generated code.
2922
3023
Iterates through all the generated files matching juju/client/_client*.py,
3124
extracting facade types (those that have .name and .version properties).
@@ -42,19 +35,20 @@ def generated_code_facades(project_root: Path) -> ClientFacades:
4235
cls.version
4336
except AttributeError:
4437
continue
45-
if cls.version in excluded_facades.get(cls.name, []):
38+
if cls.version in excluded_facade_versions.get(cls.name, []):
4639
continue
4740
facades[cls.name].append(cls.version)
48-
return {name: {'versions': sorted(facades[name])} for name in sorted(facades)}
41+
return {name: tuple(sorted(facades[name])) for name in sorted(facades)}
4942

5043

51-
def test_client_facades(project_root: Path, generated_code_facades: ClientFacades) -> None:
52-
"""Ensure that juju.client.connection.client_facades matches expected facades.
44+
def test_client_facades(generated_code_facades: Dict[str, Sequence[int]]) -> None:
45+
"""Ensure that juju.client.facade_versions.client_facade_versions matches expected facades.
5346
5447
See generated_code_facades for how expected facades are computed.
5548
"""
56-
assert {
57-
k: v['versions'] for k, v in client_facades.items()
58-
} == {
59-
k: v['versions'] for k, v in generated_code_facades.items()
60-
}
49+
assert client_facade_versions == generated_code_facades
50+
51+
52+
def test_unsupported_facades(generated_code_facades: Dict[str, Sequence[int]]) -> None:
53+
"""Ensure that we don't accidentally ignore our own generated code."""
54+
assert not set(generated_code_facades) & set(known_unsupported_facades)

0 commit comments

Comments
 (0)