jubilant 0.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.1
2
+ Name: jubilant
3
+ Version: 0.0.0
4
+ Summary: Juju CLI wrapper for charm integration testing
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+
8
+ # Jubilant, the joyful library for integration-testing Juju charms
9
+
10
+ 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.
11
+
12
+ **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.
@@ -0,0 +1,5 @@
1
+ # Jubilant, the joyful library for integration-testing Juju charms
2
+
3
+ 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.
4
+
5
+ **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.
@@ -0,0 +1,143 @@
1
+ """Jubilant is a Pythonic wrapper around the Juju CLI for integration testing."""
2
+
3
+ import json
4
+ import subprocess
5
+ import time
6
+ import typing
7
+
8
+ from ._types import Status
9
+
10
+ __all__ = [
11
+ 'Juju',
12
+ 'Status',
13
+ ]
14
+
15
+ __version__ = '0.0.1'
16
+
17
+
18
+ class Juju:
19
+ """TODO."""
20
+
21
+ def __init__(self, *, model: str | None = None):
22
+ self.model = model
23
+
24
+ def __repr__(self):
25
+ args = []
26
+ if self.model is not None:
27
+ args.append(f'model={self.model!r}')
28
+ return f'Juju({", ".join(args)})'
29
+
30
+ def cli(self, *args: str) -> str:
31
+ """TODO."""
32
+ # TODO: good error handling and include stderr in exception
33
+ process = subprocess.run(
34
+ ['juju', *args], check=True, capture_output=True, encoding='UTF-8'
35
+ )
36
+ return process.stdout
37
+
38
+ def add_model(
39
+ self,
40
+ model_name: str, # TODO: should this use self.model if set, and set it if not?
41
+ *,
42
+ controller: str | None = None,
43
+ config: dict[str, typing.Any] | None = None, # TODO: is Any correct here?
44
+ ) -> None:
45
+ """TODO."""
46
+ args = ['add-model', model_name]
47
+
48
+ if controller is not None:
49
+ args.extend(['--controller', controller])
50
+ if config is not None:
51
+ for k, v in config.items():
52
+ args.extend(['--config', f'{k}={v}'])
53
+
54
+ self.cli(*args)
55
+
56
+ def destroy_model(
57
+ self,
58
+ model_name: str,
59
+ *,
60
+ force=False,
61
+ ):
62
+ """TODO."""
63
+ args = ['destroy-model', model_name, '--no-prompt']
64
+ if force:
65
+ args.append('--force')
66
+ self.cli(*args)
67
+
68
+ def deploy(
69
+ self,
70
+ charm_name: str,
71
+ application_name: str | None = None,
72
+ *,
73
+ model: str | None = None,
74
+ config: dict[str, typing.Any] | None = None, # TODO: is Any correct here?
75
+ num_units: int = 1,
76
+ resources: dict[str, str] | None = None,
77
+ trust: bool = False,
78
+ # TODO: include all the arguments we think people we use
79
+ ) -> None:
80
+ """TODO."""
81
+ args = ['deploy', charm_name]
82
+ if application_name is not None:
83
+ args.append(application_name)
84
+
85
+ if model is None:
86
+ model = self.model
87
+ if model is not None:
88
+ args.extend(['--model', model])
89
+ if config is not None:
90
+ for k, v in config.items():
91
+ args.extend(['--config', f'{k}={v}'])
92
+ if num_units != 1:
93
+ args.extend(['--num-units', str(num_units)])
94
+ if resources is not None:
95
+ for k, v in resources.items():
96
+ args.extend(['--resource', f'{k}={v}'])
97
+ if trust:
98
+ args.append('--trust')
99
+
100
+ self.cli(*args)
101
+
102
+ def status(
103
+ self,
104
+ *,
105
+ model: str | None = None,
106
+ ) -> Status:
107
+ """TODO."""
108
+ args = ['status', '--format', 'json']
109
+
110
+ if model is None:
111
+ model = self.model
112
+ if model is not None:
113
+ args.extend(['--model', model])
114
+
115
+ stdout = self.cli(*args)
116
+ result = json.loads(stdout)
117
+ return Status.from_dict(result)
118
+
119
+ def wait_status(
120
+ self,
121
+ ready_func: typing.Callable[[Status], bool],
122
+ *,
123
+ model: str | None = None,
124
+ timeout: float = 10 * 60,
125
+ delay: float = 1,
126
+ successes: int = 3,
127
+ ) -> Status:
128
+ """TODO."""
129
+ start = time.time()
130
+ success_count = 0
131
+ this_status = None
132
+ while time.time() - start < timeout:
133
+ this_status = self.status(model=model)
134
+ # logger.info('wait_status: %s', this_status) # TODO: ensure better debugging
135
+ print('TODO wait_status', this_status)
136
+ if ready_func(this_status):
137
+ success_count += 1
138
+ if success_count >= successes:
139
+ return this_status
140
+ else:
141
+ success_count = 0
142
+ time.sleep(delay)
143
+ raise Exception(f'timed out after {timeout}, last status:\n{this_status}')
@@ -0,0 +1,43 @@
1
+ import dataclasses
2
+
3
+
4
+ @dataclasses.dataclass
5
+ class StatusInfoContents:
6
+ current: str | None = None
7
+ message: str | None = None
8
+
9
+ @classmethod
10
+ def from_dict(cls, d):
11
+ self = cls()
12
+ self.current = d.get('current')
13
+ self.message = d.get('message')
14
+ return self
15
+
16
+
17
+ @dataclasses.dataclass
18
+ class ApplicationStatus:
19
+ application_status: StatusInfoContents = dataclasses.field(default_factory=StatusInfoContents)
20
+
21
+ @classmethod
22
+ def from_dict(cls, d):
23
+ self = cls()
24
+ self.application_status = StatusInfoContents.from_dict(d.get('application-status') or {})
25
+ return self
26
+
27
+
28
+ @dataclasses.dataclass
29
+ class Status:
30
+ # TODO: Ideally we can generate the list of fields from the Go source in Juju:
31
+ # cmd/juju/status/formatted.go
32
+ applications: dict[str, ApplicationStatus] = dataclasses.field(default_factory=dict)
33
+
34
+ @classmethod
35
+ def from_dict(cls, d):
36
+ self = cls()
37
+ applications = d.get('applications') or {}
38
+ self.applications = {
39
+ name: ApplicationStatus.from_dict(status) for name, status in applications.items()
40
+ }
41
+ return self
42
+
43
+ # TODO: helper methods
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.1
2
+ Name: jubilant
3
+ Version: 0.0.0
4
+ Summary: Juju CLI wrapper for charm integration testing
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+
8
+ # Jubilant, the joyful library for integration-testing Juju charms
9
+
10
+ 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.
11
+
12
+ **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.
@@ -0,0 +1,8 @@
1
+ README.md
2
+ pyproject.toml
3
+ jubilant/__init__.py
4
+ jubilant/_types.py
5
+ jubilant.egg-info/PKG-INFO
6
+ jubilant.egg-info/SOURCES.txt
7
+ jubilant.egg-info/dependency_links.txt
8
+ jubilant.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ jubilant
@@ -0,0 +1,116 @@
1
+ [project]
2
+ name = "jubilant"
3
+ description = "Juju CLI wrapper for charm integration testing"
4
+ readme = "README.md"
5
+ requires-python = ">=3.12"
6
+ dynamic = ["version"]
7
+
8
+ [dependency-groups]
9
+ dev = [
10
+ "pyright==1.1.390",
11
+ "pytest==8.3.4",
12
+ "ruff==0.8.1",
13
+ ]
14
+
15
+ # Linting tools configuration
16
+ [tool.ruff]
17
+ line-length = 99
18
+ target-version = "py312"
19
+
20
+ # Ruff formatter configuration
21
+ [tool.ruff.format]
22
+ quote-style = "single"
23
+
24
+ [tool.ruff.lint]
25
+ select = [
26
+ # Pyflakes
27
+ "F",
28
+ # Pycodestyle
29
+ "E",
30
+ "W",
31
+ # isort
32
+ "I001",
33
+ # pep8-naming
34
+ "N",
35
+ # flake8-builtins
36
+ "A",
37
+ # pyupgrade
38
+ "UP",
39
+ # flake8-2020
40
+ "YTT",
41
+ # flake8-bandit
42
+ "S",
43
+ # flake8-bugbear
44
+ "B",
45
+ # flake8-simplify
46
+ "SIM",
47
+ # Ruff specific
48
+ "RUF",
49
+ # Perflint
50
+ "PERF",
51
+ # pyflakes-docstrings
52
+ "D",
53
+ ]
54
+ ignore = [
55
+ # Use of `assert` detected
56
+ "S101",
57
+ # Do not `assert False`
58
+ "B011",
59
+ # `pickle`, `cPickle`, `dill`, and `shelve` modules are possibly insecure
60
+ "S403",
61
+ # `subprocess` module is possibly insecure
62
+ "S404",
63
+
64
+ # No explicit `stacklevel` keyword argument found
65
+ "B028",
66
+
67
+ # Return condition directly, prefer readability.
68
+ "SIM103",
69
+ # Use contextlib.suppress() instead of try/except: pass
70
+ "SIM105",
71
+ # Use a single `with` statement with multiple contexts instead of nested `with` statements
72
+ "SIM117",
73
+
74
+ # Missing docstring in magic method
75
+ "D105",
76
+ # Missing docstring in `__init__`
77
+ "D107",
78
+
79
+ # Manual dict comprehension.
80
+ "PERF403",
81
+
82
+ # Convert {} from `TypedDict` functional to class syntax
83
+ # Note that since we have some `TypedDict`s that cannot use the class
84
+ # syntax, we're currently choosing to be consistent in syntax even though
85
+ # some can be moved to the class syntax.
86
+ "UP013",
87
+
88
+ ## Likely worth doing, but later.
89
+
90
+ # `subprocess` call: check for execution of untrusted input
91
+ "S603",
92
+ # Starting a process with a partial executable path
93
+ "S607",
94
+ ]
95
+
96
+ [tool.ruff.lint.pydocstyle]
97
+ convention = "google"
98
+
99
+ [tool.ruff.lint.flake8-builtins]
100
+ builtins-ignorelist = ["id", "min", "map", "range", "type", "input", "format"]
101
+
102
+ [tool.ruff.lint.per-file-ignores]
103
+ "test/*" = [
104
+ # All documentation linting.
105
+ "D",
106
+ ]
107
+
108
+ [tool.pyright]
109
+ include = ["jubilant/*.py"]
110
+ pythonVersion = "3.12"
111
+ pythonPlatform = "All"
112
+
113
+ [tool.pytest.ini_options]
114
+ pythonpath = [
115
+ "."
116
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+