Skip to content

Commit 69af81c

Browse files
authored
feat: rename wait_status to wait and adjust the API (#2)
1 parent f86a62a commit 69af81c

File tree

10 files changed

+352
-68
lines changed

10 files changed

+352
-68
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
__pycache__
22
jubilant.egg-info
33
/dist
4+
/SCRATCH.md
45
/docs/_build

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,11 @@
33
Jubilant is a Python library that wraps the [Juju](https://juju.is/) CLI for use in charm integration tests. It provides methods that map 1:1 to Juju CLI commands, but with a type-annotated, Pythonic interface.
44

55
**NOTE:** Jubilant is in very early stages of development. This is pre-alpha code. Our intention is to release a 1.0.0 version early to mid 2025.
6+
7+
8+
## Design goals
9+
10+
- Familiar to users of the Juju CLI. We try to ensure methods, argument names, and response field names match the Juju CLI and its responses, with minor exceptions (such as "application" being shortened to "app" in `Status` fields).
11+
- Simple API. Any cleverness in helpers and fixtures.
12+
- TODO: versioning (Juju 3 and 4, maybe jubilant.compat for Juju 5 changes)
13+
- TODO: no async/await

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
project = 'jubilant'
77
extensions = [
88
'sphinx.ext.autodoc',
9+
'sphinx.ext.napoleon',
910
]
1011
templates_path = ['_templates']
1112
html_theme = 'sphinx_rtd_theme'

jubilant/__init__.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,36 @@
11
"""Jubilant is a Pythonic wrapper around the Juju CLI for integration testing."""
22

3-
from ._errors import RunError
3+
from ._errors import CLIError, WaitError
4+
from ._helpers import (
5+
all_active,
6+
all_blocked,
7+
all_error,
8+
all_maintenance,
9+
all_waiting,
10+
any_active,
11+
any_blocked,
12+
any_error,
13+
any_maintenance,
14+
any_waiting,
15+
)
416
from ._juju import Juju
517
from ._types import Status
618

719
__all__ = [
20+
'CLIError',
821
'Juju',
9-
'RunError',
1022
'Status',
23+
'WaitError',
24+
'all_active',
25+
'all_blocked',
26+
'all_error',
27+
'all_maintenance',
28+
'all_waiting',
29+
'any_active',
30+
'any_blocked',
31+
'any_error',
32+
'any_maintenance',
33+
'any_waiting',
1134
]
1235

1336
__version__ = '0.0.0a1'

jubilant/_errors.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import subprocess
22

33

4-
class RunError(subprocess.CalledProcessError):
5-
"""Subclass of CalledProcessError that includes stdout/stderr in the string."""
4+
class CLIError(subprocess.CalledProcessError):
5+
"""Subclass of CalledProcessError that includes stdout and stderr in the __str__."""
66

77
def __str__(self):
88
s = super().__str__()
@@ -11,3 +11,9 @@ def __str__(self):
1111
if self.stderr:
1212
s += '\nStderr:\n' + self.stderr
1313
return s
14+
15+
16+
class WaitError(Exception):
17+
"""Raised when :meth:`Juju.wait` returns false."""
18+
19+
# TODO: want a "status" or other attributes?

jubilant/_helpers.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
from collections.abc import Iterable
2+
3+
from ._types import Status
4+
5+
# TODO: think further about how these should handle subordinate applications;
6+
# they should probably automatically include subordinate status, but per
7+
# Dima that might come from a different place in the status object, so not
8+
# be handled automatically with the code below. See also:
9+
# https://github.com/juju/python-libjuju/blob/138d8f9058e1023e01a01d587b81949433da61ce/tests/unit/data/subordinate-fullstatus.json
10+
11+
12+
def all_active(status: Status, apps: Iterable[str] | None = None) -> bool:
13+
"""Report whether all applications or units in *status* are in "active" status.
14+
15+
Args:
16+
status: The :class:`Status` object being tested.
17+
apps: An optional list of application names. If provided, only these applications
18+
(and their units) are tested.
19+
"""
20+
return _all_statuses_are('active', status, apps)
21+
22+
23+
def all_error(status: Status, apps: Iterable[str] | None = None) -> bool:
24+
"""Report whether all applications or units in *status* are in "error" status.
25+
26+
TODO: Args
27+
"""
28+
return _all_statuses_are('error', status, apps)
29+
30+
31+
def all_blocked(status: Status, apps: Iterable[str] | None = None) -> bool:
32+
"""Report whether all applications or units in *status* are in "blocked" status.
33+
34+
TODO: Args
35+
"""
36+
return _all_statuses_are('blocked', status, apps)
37+
38+
39+
def all_maintenance(status: Status, apps: Iterable[str] | None = None) -> bool:
40+
"""Report whether all applications or units in *status* are in "maintenance" status.
41+
42+
TODO: Args
43+
"""
44+
return _all_statuses_are('maintenance', status, apps)
45+
46+
47+
def all_waiting(status: Status, apps: Iterable[str] | None = None) -> bool:
48+
"""Report whether all applications or units in *status* are in "waiting" status.
49+
50+
TODO: Args
51+
"""
52+
return _all_statuses_are('waiting', status, apps)
53+
54+
55+
def any_active(status: Status, apps: Iterable[str] | None = None) -> bool:
56+
"""Report whether any application or unit in *status* is in "active" status.
57+
58+
Args:
59+
status: The :class:`Status` object being tested.
60+
apps: An optional list of application names. If provided, only these applications
61+
(and their units) are tested.
62+
"""
63+
return _any_status_is('active', status, apps)
64+
65+
66+
def any_error(status: Status, apps: Iterable[str] | None = None) -> bool:
67+
"""Report whether any application or unit in *status* is in "error" status.
68+
69+
TODO: Args
70+
"""
71+
return _any_status_is('error', status, apps)
72+
73+
74+
def any_blocked(status: Status, apps: Iterable[str] | None = None) -> bool:
75+
"""Report whether any application or unit in *status* is in "blocked" status.
76+
77+
TODO: Args
78+
"""
79+
return _any_status_is('blocked', status, apps)
80+
81+
82+
def any_maintenance(status: Status, apps: Iterable[str] | None = None) -> bool:
83+
"""Report whether any application or unit in *status* is in "maintenance" status.
84+
85+
TODO: Args
86+
"""
87+
return _any_status_is('maintenance', status, apps)
88+
89+
90+
def any_waiting(status: Status, apps: Iterable[str] | None = None) -> bool:
91+
"""Report whether any application or unit in *status* is in "waiting" status.
92+
93+
TODO: Args
94+
"""
95+
return _any_status_is('waiting', status, apps)
96+
97+
98+
def _all_statuses_are(expected: str, status: Status, apps: Iterable[str] | None) -> bool:
99+
if isinstance(apps, str | bytes):
100+
raise TypeError('"apps" must be an iterable of names (like a list), not a string')
101+
102+
if apps is None:
103+
apps = status.apps.keys()
104+
105+
for app in apps:
106+
app_info = status.apps.get(app)
107+
if app_info is None:
108+
return False
109+
if app_info.app_status.current != expected:
110+
return False
111+
for unit_info in app_info.units.values():
112+
if unit_info.workload_status.current != expected:
113+
return False
114+
return True
115+
116+
117+
def _any_status_is(expected: str, status: Status, apps: Iterable[str] | None) -> bool:
118+
if isinstance(apps, str | bytes):
119+
raise TypeError('"apps" must be an iterable of names (like a list), not a string')
120+
121+
if apps is None:
122+
apps = status.apps.keys()
123+
124+
for app in apps:
125+
app_info = status.apps.get(app)
126+
if app_info is None:
127+
continue
128+
if app_info.app_status.current == expected:
129+
return True
130+
for unit_info in app_info.units.values():
131+
if unit_info.workload_status.current == expected:
132+
return True
133+
return False

0 commit comments

Comments
 (0)