Skip to content

Commit c961baf

Browse files
committed
chore: copy implementation from juju#1104
1 parent 35ba68b commit c961baf

File tree

4 files changed

+234
-6
lines changed

4 files changed

+234
-6
lines changed

juju/_sync.py

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Copyright 2024 Canonical Ltd.
2+
# Licensed under the Apache V2, see LICENCE file for details.
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import dataclasses
7+
import functools
8+
import logging
9+
import threading
10+
from typing import (
11+
Any,
12+
Callable,
13+
Coroutine,
14+
Generic,
15+
TypeVar,
16+
)
17+
18+
from typing_extensions import Self
19+
20+
import juju.client.connection
21+
import juju.model
22+
23+
R = TypeVar("R")
24+
25+
26+
@dataclasses.dataclass
27+
class SyncCacheLine(Generic[R]):
28+
value: R | None
29+
exception: Exception | None
30+
31+
32+
def cache_until_await(f: Callable[..., R]) -> Callable[..., R]:
33+
@functools.wraps(f)
34+
def inner(self: juju.model.ModelEntity, *args, **kwargs) -> R:
35+
try:
36+
assert isinstance(self, juju.model.ModelEntity)
37+
cached: SyncCacheLine[R] = self._sync_cache.setdefault(
38+
f.__name__,
39+
SyncCacheLine(None, None),
40+
)
41+
42+
if cached.value is None and cached.exception is None:
43+
asyncio.get_running_loop().call_soon(self._sync_cache.clear)
44+
try:
45+
cached.value = f(self, *args, **kwargs)
46+
except Exception as e:
47+
cached.exception = e
48+
49+
if cached.exception:
50+
raise cached.exception
51+
52+
assert cached.value is not None
53+
return cached.value
54+
except AttributeError as e:
55+
# The decorated functions are commonly used in @property's
56+
# where the class or base class declares __getattr__ too.
57+
# Python data model has is that AttributeError is special
58+
# in this case, so wrap it into something else.
59+
raise Exception(repr(e)) from e
60+
61+
return inner
62+
63+
64+
class ThreadedAsyncRunner(threading.Thread):
65+
_conn: juju.client.connection.Connection | None
66+
_loop: asyncio.AbstractEventLoop
67+
68+
@classmethod
69+
def new_connected(cls, *, connection_kwargs: dict[str, Any]) -> Self:
70+
rv = cls()
71+
rv.start()
72+
try:
73+
rv._conn = asyncio.run_coroutine_threadsafe(
74+
juju.client.connection.Connection.connect(**connection_kwargs), # type: ignore[reportUnknownMemberType]
75+
rv._loop,
76+
).result()
77+
return rv
78+
except Exception:
79+
logging.exception("Helper thread failed to connect")
80+
# TODO: .stop vs .close
81+
rv._loop.stop()
82+
rv.join()
83+
raise
84+
85+
def call(self, coro: Coroutine[None, None, R]) -> R:
86+
return asyncio.run_coroutine_threadsafe(coro, self._loop).result()
87+
88+
def stop(self) -> None:
89+
if self._conn:
90+
self.call(self._conn.close())
91+
self._conn = None
92+
self._loop.call_soon_threadsafe(self._loop.stop)
93+
self.join()
94+
95+
@property
96+
def connection(self) -> juju.client.connection.Connection:
97+
assert self._conn
98+
return self._conn
99+
100+
def __init__(self) -> None:
101+
super().__init__()
102+
self._conn = None
103+
self._loop = asyncio.new_event_loop()
104+
105+
def run(self) -> None:
106+
asyncio.set_event_loop(self._loop)
107+
self._loop.run_forever()
108+
self._loop.close()

juju/application.py

+50-4
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,18 @@
99
from pathlib import Path
1010

1111
from typing_extensions import deprecated
12+
from typing_extensions import reveal_type as reveal_type # FIXME temp
1213

1314
from . import model, tag, utils
15+
from ._sync import cache_until_await
1416
from .annotationhelper import _get_annotations, _set_annotations
1517
from .bundle import get_charm_series, is_local_charm
1618
from .client import _definitions, client
19+
from .client._definitions import (
20+
ApplicationGetResults,
21+
ApplicationResult,
22+
Value,
23+
)
1724
from .errors import JujuApplicationConfigError, JujuError
1825
from .origin import Channel
1926
from .placement import parse as parse_placement
@@ -44,7 +51,9 @@ def name(self) -> str:
4451

4552
@property
4653
def exposed(self) -> bool:
47-
return self.safe_data["exposed"]
54+
rv = self._application_info().exposed
55+
assert rv is not None
56+
return rv
4857

4958
@property
5059
@deprecated("Application.owner_tag is deprecated and will be removed in v4")
@@ -60,9 +69,22 @@ def life(self) -> str:
6069
def min_units(self) -> int:
6170
return self.safe_data["min-units"]
6271

72+
# Well, this attribute is lovely:
73+
# - not used in integration tests, as far as I can see
74+
# - not used in zaza*tests
75+
# - not used in openstack upgrader
76+
# - no unit tests in this repo
77+
# - no integration tests in this repo
78+
# Why was it here in the first place?
79+
# @property
80+
# def constraints(self) -> dict[str, str | int | bool]:
81+
# return FIXME_to_dict(self.constraints_object)
82+
6383
@property
64-
def constraints(self) -> dict[str, str | int | bool]:
65-
return self.safe_data["constraints"]
84+
def constraints_object(self) -> Value:
85+
rv = self._application_get().constraints
86+
assert isinstance(rv, Value) # FIXME #1249
87+
return rv
6688

6789
@property
6890
@deprecated("Application.subordinate is deprecated and will be removed in v4")
@@ -76,6 +98,28 @@ def subordinate(self) -> bool:
7698
def workload_version(self) -> str:
7799
return self.safe_data["workload-version"]
78100

101+
@cache_until_await
102+
def _application_get(self) -> ApplicationGetResults:
103+
return self.model._sync_call(
104+
self.model._sync_application_facade.Get(
105+
application=self.name,
106+
)
107+
)
108+
109+
@cache_until_await
110+
def _application_info(self) -> ApplicationResult:
111+
first = self.model._sync_call(
112+
self.model._sync_application_facade.ApplicationsInfo(
113+
entities=[client.Entity(self.tag)],
114+
)
115+
).results[0]
116+
# This API can get a bunch of results for a bunch of entities, or "tags"
117+
# For each, either .result or .error is set by Juju, and an exception is
118+
# raised on any .error by juju.client.connection.Connection.rpc()
119+
assert first
120+
assert first.result
121+
return first.result
122+
79123
@property
80124
def _unit_match_pattern(self):
81125
return rf"^{self.entity_id}.*$"
@@ -643,7 +687,9 @@ def charm_name(self) -> str:
643687
644688
:return str: The name of the charm
645689
"""
646-
return URL.parse(self.safe_data["charm-url"]).name
690+
rv = self._application_get().charm
691+
assert isinstance(rv, str) # FIXME #1249
692+
return rv
647693

648694
@property
649695
@deprecated("Application.charm_url is deprecated and will be removed in v4")

juju/client/protocols.py

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Copyright 2025 Canonical Ltd.
2+
# Licensed under the Apache V2, see LICENCE file for details.
3+
from __future__ import annotations
4+
5+
from typing import Protocol
6+
7+
from juju.client._definitions import (
8+
ApplicationGetResults,
9+
ApplicationInfoResults,
10+
Entity,
11+
)
12+
13+
14+
class ApplicationFacadeProtocol(Protocol):
15+
async def Get(self, application=None, branch=None) -> ApplicationGetResults: ... # noqa: N802
16+
17+
# jRRC Params={"entities":[{"tag": "yada-yada"}]}
18+
# codegen unpacks top-level keys into keyword arguments
19+
async def ApplicationsInfo( # noqa: N802
20+
self, entities: list[Entity]
21+
) -> ApplicationInfoResults: ...
22+
23+
# etc...
24+
# etc...
25+
# etc...
26+
# etc...
27+
# etc...
28+
# etc...
29+
30+
31+
class CharmsFacadeProtocol(Protocol): ...
32+
33+
34+
class UniterFacadeProtocol(Protocol): ...

juju/model/__init__.py

+42-2
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,28 @@
2323
from datetime import datetime, timedelta
2424
from functools import partial
2525
from pathlib import Path
26-
from typing import TYPE_CHECKING, Any, Iterable, Literal, Mapping, overload
26+
from typing import (
27+
TYPE_CHECKING,
28+
Any,
29+
Coroutine,
30+
Iterable,
31+
Literal,
32+
Mapping,
33+
TypeVar,
34+
overload,
35+
)
2736

2837
import websockets
2938
import yaml
3039
from typing_extensions import deprecated
3140

3241
from .. import provisioner, tag, utils
42+
from .._sync import SyncCacheLine as SyncCacheLine
43+
from .._sync import ThreadedAsyncRunner
3344
from ..annotationhelper import _get_annotations, _set_annotations
3445
from ..bundle import BundleHandler, get_charm_series, is_local_charm
3546
from ..charmhub import CharmHub
36-
from ..client import client, connection, connector
47+
from ..client import client, connection, connector, protocols
3748
from ..client._definitions import ApplicationStatus as ApplicationStatus
3849
from ..client._definitions import MachineStatus as MachineStatus
3950
from ..client._definitions import UnitStatus as UnitStatus
@@ -76,6 +87,8 @@
7687
from ..remoteapplication import ApplicationOffer, RemoteApplication
7788
from ..unit import Unit
7889

90+
R = TypeVar("R")
91+
7992
log = logger = logging.getLogger(__name__)
8093

8194

@@ -645,6 +658,7 @@ class Model:
645658

646659
connector: connector.Connector
647660
state: ModelState
661+
_sync: ThreadedAsyncRunner | None = None
648662

649663
def __init__(
650664
self,
@@ -686,6 +700,28 @@ def __init__(
686700
Schema.CHARM_HUB: CharmhubDeployType(self._resolve_charm),
687701
}
688702

703+
def _sync_call(self, coro: Coroutine[None, None, R]) -> R:
704+
assert self._sync
705+
return self._sync.call(coro)
706+
707+
@property
708+
def _sync_application_facade(self) -> protocols.ApplicationFacadeProtocol:
709+
"""An ApplicationFacade suitable for ._sync.call(...)"""
710+
assert self._sync
711+
return client.ApplicationFacade.from_connection(self._sync.connection)
712+
713+
@property
714+
def _sync_charms_facade(self) -> protocols.CharmsFacadeProtocol:
715+
assert self._sync
716+
return client.CharmsFacade.from_connection(self._sync.connection)
717+
718+
# FIXME uniter facade is gone now... I hope it was not needed
719+
# @property
720+
# def _sync_uniter_facade(self) -> protocols.UniterFacadeProtocol:
721+
# """A UniterFacade suitable for ._sync.call(...)"""
722+
# assert self._sync
723+
# return client.UniterFacade.from_connection(self._sync.connection)
724+
689725
def is_connected(self):
690726
"""Reports whether the Model is currently connected."""
691727
return self._connector.is_connected()
@@ -809,6 +845,10 @@ async def connect(self, *args, **kwargs):
809845
if not is_debug_log_conn:
810846
await self._after_connect(model_name, model_uuid)
811847

848+
self._sync = ThreadedAsyncRunner.new_connected(
849+
connection_kwargs=self._connector._kwargs_cache
850+
)
851+
812852
async def connect_model(self, model_name, **kwargs):
813853
""".. deprecated:: 0.6.2
814854
Use ``connect(model_name=model_name)`` instead.

0 commit comments

Comments
 (0)