diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 000000000..5ace4600a --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/charmcraft-pack.yaml b/.github/workflows/charmcraft-pack.yaml index 800aa660b..37c715f89 100644 --- a/.github/workflows/charmcraft-pack.yaml +++ b/.github/workflows/charmcraft-pack.yaml @@ -8,7 +8,7 @@ jobs: steps: - name: Checkout test charm repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: jnsgruk/hello-kubecon diff --git a/.github/workflows/db-charm-tests.yaml b/.github/workflows/db-charm-tests.yaml index 8dfc165bf..a8cdbafce 100644 --- a/.github/workflows/db-charm-tests.yaml +++ b/.github/workflows/db-charm-tests.yaml @@ -18,12 +18,12 @@ jobs: steps: - name: Checkout the ${{ matrix.charm-repo }} repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: ${{ matrix.charm-repo }} - name: Checkout the operator repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: myops diff --git a/.github/workflows/framework-tests.yaml b/.github/workflows/framework-tests.yaml index 4832ba8c6..0979b03b6 100644 --- a/.github/workflows/framework-tests.yaml +++ b/.github/workflows/framework-tests.yaml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3 uses: actions/setup-python@v5 with: @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3 uses: actions/setup-python@v5 @@ -43,7 +43,7 @@ jobs: - {python-version: "3.8", os: "macos-latest"} # macos-14 is arm64, and there's no Python 3.8 build for arm64 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: @@ -64,14 +64,14 @@ jobs: python-version: ["3.8", "3.10"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Set up Go 1.20 - uses: actions/setup-go@v1 + uses: actions/setup-go@v5 with: go-version: "1.20" @@ -103,9 +103,10 @@ jobs: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] exclude: - {python-version: "3.8", os: "macos-latest"} # macos-14 is arm64, and there's no Python 3.8 build for arm64 + - {python-version: "3.9", os: "macos-latest"} # macos-14 is arm64, and there's no Python 3.9 build for arm64 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3 uses: actions/setup-python@v5 with: diff --git a/.github/workflows/hello-charm-tests.yaml b/.github/workflows/hello-charm-tests.yaml index ae8dc0678..b4970af03 100644 --- a/.github/workflows/hello-charm-tests.yaml +++ b/.github/workflows/hello-charm-tests.yaml @@ -19,7 +19,7 @@ jobs: python-version: "3.8" - name: Checkout the ${{ matrix.charm-repo }} repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: ${{ matrix.charm-repo }} diff --git a/.github/workflows/observability-charm-tests.yaml b/.github/workflows/observability-charm-tests.yaml index 7edd0c9c8..709386a2d 100644 --- a/.github/workflows/observability-charm-tests.yaml +++ b/.github/workflows/observability-charm-tests.yaml @@ -10,16 +10,13 @@ jobs: fail-fast: false matrix: charm-repo: -# Disabled until the type checking passes (ops#1183) -# https://github.com/canonical/alertmanager-k8s-operator/pull/241 -# https://github.com/canonical/prometheus-k8s-operator/pull/587 -# - "canonical/alertmanager-k8s-operator" -# - "canonical/prometheus-k8s-operator" + - "canonical/alertmanager-k8s-operator" + - "canonical/prometheus-k8s-operator" - "canonical/grafana-k8s-operator" steps: - name: Checkout the ${{ matrix.charm-repo }} repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: ${{ matrix.charm-repo }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b91bfc8b1..7b770daf0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ jobs: id-token: write needs: [framework-tests, observability-charm-tests, hello-charm-tests] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 - name: Install build dependencies diff --git a/.github/workflows/test-publish.yml b/.github/workflows/test-publish.yml index 1eea6950d..4677c67c7 100644 --- a/.github/workflows/test-publish.yml +++ b/.github/workflows/test-publish.yml @@ -15,7 +15,7 @@ jobs: id-token: write needs: [framework-tests, observability-charm-tests, hello-charm-tests] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: diff --git a/CHANGES.md b/CHANGES.md index 684d42b12..3dbd9359f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,25 @@ +# 2.13.0 - 30 Apr 2024 + +## Features + +* Added support for user secrets in Harness (#1176) + +## Fixes + +* Corrected the model config types (#1183) +* In Harness, only inspect the source file if it will be used - this fixed using Harness in a Python REPL (#1181) + +## Documentation + +* Updated publishing a release in HACKING.md (#1173) +* Added `tox -e docs-deps` to compile requirements.txt (#1172) +* Updated doc to note deprecated functionality in (#1178) + +## Tests + +* First stage of converting tests from unittest to pytest (#1191, #1192, #1196, #1193, #1195) +* Added `pebble.CheckInfo.change_id` field (#1197) + # 2.12.0 - 28 Mar 2024 ## Features diff --git a/docs/requirements.txt b/docs/requirements.txt index 7faba991a..f6b237f66 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -37,7 +37,7 @@ idna==3.7 # via requests imagesize==1.4.1 # via sphinx -jinja2==3.1.3 +jinja2==3.1.4 # via # myst-parser # sphinx diff --git a/ops/testing.py b/ops/testing.py index 882d8b07d..e534aaf2f 100644 --- a/ops/testing.py +++ b/ops/testing.py @@ -166,6 +166,11 @@ def __init__(self, message: str, output: ActionOutput): self.message = message self.output = output + def __str__(self): + if self.message: + return self.message + return "Event handler called `fail()` with no additional details." + @dataclasses.dataclass() class _RunningAction: diff --git a/ops/version.py b/ops/version.py index 3e5d2c59d..a130acdd8 100644 --- a/ops/version.py +++ b/ops/version.py @@ -17,4 +17,4 @@ This module is NOT to be used when developing charms using ops. """ -version: str = '2.13.0.dev0' +version: str = '2.13.0' diff --git a/test/test_helpers.py b/test/test_helpers.py index 38e52e92a..ee75268f9 100644 --- a/test/test_helpers.py +++ b/test/test_helpers.py @@ -201,38 +201,3 @@ def test_fake_script_clear(self): assert fake_script_calls(self, clear=True) == [['bar', 'd e', 'f']] assert fake_script_calls(self, clear=True) == [] - - -class BaseTestCase(unittest.TestCase): - - def create_framework(self, - *, - model: typing.Optional[ops.Model] = None, - tmpdir: typing.Optional[pathlib.Path] = None): - """Create a Framework object. - - By default operate in-memory; pass a temporary directory via the 'tmpdir' - parameter if you wish to instantiate several frameworks sharing the - same dir (e.g. for storing state). - """ - if tmpdir is None: - data_fpath = ":memory:" - charm_dir = 'non-existant' - else: - data_fpath = tmpdir / "framework.data" - charm_dir = tmpdir - - framework = ops.Framework( - SQLiteStorage(data_fpath), - charm_dir, - meta=model._cache._meta if model else ops.CharmMeta(), - model=model) # type: ignore - self.addCleanup(framework.close) - return framework - - def create_model(self): - """Create a Model object.""" - backend = _ModelBackend(unit_name='myapp/0') - meta = ops.CharmMeta() - model = ops.Model(meta, backend) - return model diff --git a/test/test_main.py b/test/test_main.py index e81f343ee..a13dd0bf1 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -23,7 +23,6 @@ import sys import tempfile import typing -import unittest import warnings from pathlib import Path from unittest.mock import patch @@ -35,7 +34,7 @@ from ops.storage import SQLiteStorage from .charms.test_main.src.charm import MyCharmEvents -from .test_helpers import fake_script, fake_script_calls +from .test_helpers import FakeScript # This relies on the expected repository structure to find a path to # source of the charm under test. @@ -54,24 +53,25 @@ class SymlinkTargetError(Exception): class EventSpec: - def __init__(self, - event_type: typing.Type[ops.EventBase], - event_name: str, - env_var: typing.Optional[str] = None, - relation_id: typing.Optional[int] = None, - remote_app: typing.Optional[str] = None, - remote_unit: typing.Optional[str] = None, - model_name: typing.Optional[str] = None, - set_in_env: typing.Optional[typing.Dict[str, - str]] = None, - workload_name: typing.Optional[str] = None, - notice_id: typing.Optional[str] = None, - notice_type: typing.Optional[str] = None, - notice_key: typing.Optional[str] = None, - departing_unit_name: typing.Optional[str] = None, - secret_id: typing.Optional[str] = None, - secret_label: typing.Optional[str] = None, - secret_revision: typing.Optional[str] = None): + def __init__( + self, + event_type: typing.Type[ops.EventBase], + event_name: str, + env_var: typing.Optional[str] = None, + relation_id: typing.Optional[int] = None, + remote_app: typing.Optional[str] = None, + remote_unit: typing.Optional[str] = None, + model_name: typing.Optional[str] = None, + set_in_env: typing.Optional[typing.Dict[str, str]] = None, + workload_name: typing.Optional[str] = None, + notice_id: typing.Optional[str] = None, + notice_type: typing.Optional[str] = None, + notice_key: typing.Optional[str] = None, + departing_unit_name: typing.Optional[str] = None, + secret_id: typing.Optional[str] = None, + secret_label: typing.Optional[str] = None, + secret_revision: typing.Optional[str] = None, + ): self.event_type = event_type self.event_name = event_name self.env_var = env_var @@ -93,7 +93,7 @@ def __init__(self, @patch('ops.main.setup_root_logging', new=lambda *a, **kw: None) # type: ignore @patch('ops.main._emit_charm_event', new=lambda *a, **kw: None) # type: ignore @patch('ops.charm._evaluate_status', new=lambda *a, **kw: None) # type: ignore -class CharmInitTestCase(unittest.TestCase): +class TestCharmInit: @patch('sys.stderr', new_callable=io.StringIO) def test_breakpoint(self, fake_stderr: io.StringIO): @@ -118,11 +118,11 @@ class MyCharm(ops.CharmBase): assert mock.call_count == 0 def _check( - self, - charm_class: typing.Type[ops.CharmBase], - *, - extra_environ: typing.Optional[typing.Dict[str, str]] = None, - **kwargs: typing.Any + self, + charm_class: typing.Type[ops.CharmBase], + *, + extra_environ: typing.Optional[typing.Dict[str, str]] = None, + **kwargs: typing.Any, ): """Helper for below tests.""" fake_environ = { @@ -146,8 +146,8 @@ def _check( def test_init_signature_passthrough(self): class MyCharm(ops.CharmBase): - def __init__(self, *args): # type: ignore - super().__init__(*args) # type: ignore + def __init__(self, framework: ops.Framework): + super().__init__(framework) with warnings.catch_warnings(record=True) as warn_cm: self._check(MyCharm) @@ -192,7 +192,7 @@ def test_storage_with_storage(self): def test_controller_storage_deprecated(self): with patch('ops.storage.juju_backend_available') as juju_backend_available: juju_backend_available.return_value = True - with self.assertWarnsRegex(DeprecationWarning, 'Controller storage'): + with pytest.warns(DeprecationWarning, match='Controller storage'): with pytest.raises(FileNotFoundError, match='state-get'): self._check(ops.CharmBase, use_juju_for_storage=True) @@ -200,7 +200,7 @@ def test_controller_storage_deprecated(self): @patch('sys.argv', new=("hooks/config-changed",)) @patch('ops.main._Manager._setup_root_logging', new=lambda *a, **kw: None) # type: ignore @patch('ops.charm._evaluate_status', new=lambda *a, **kw: None) # type: ignore -class TestDispatch(unittest.TestCase): +class TestDispatch: def _check(self, *, with_dispatch: bool = False, dispatch_path: str = ''): """Helper for below tests.""" class MyCharm(ops.CharmBase): @@ -257,6 +257,11 @@ def test_with_dispatch_path_but_no_dispatch(self): typing.Dict[str, typing.Union[str, int, None]]]] +@pytest.fixture +def fake_script(request: pytest.FixtureRequest): + return FakeScript(request) + + class _TestMain(abc.ABC): @abc.abstractmethod @@ -270,11 +275,17 @@ def _setup_entry_point(self, directory: Path, entry_point: str): return NotImplemented @abc.abstractmethod - def _call_event(self, rel_path: Path, env: typing.Dict[str, str]): + def _call_event( + self, + fake_script: FakeScript, + rel_path: Path, + env: typing.Dict[str, str], + ): """Set up the environment and call (i.e. run) the given event.""" return NotImplemented @abc.abstractmethod + @pytest.mark.usefixtures("setup_charm") def test_setup_event_links(self): """Test auto-creation of symlinks caused by initial events. @@ -283,19 +294,9 @@ def test_setup_event_links(self): """ return NotImplemented - addCleanup = unittest.TestCase.addCleanup # noqa - assertEqual = unittest.TestCase.assertEqual # noqa - assertFalse = unittest.TestCase.assertFalse # noqa - assertIn = unittest.TestCase.assertIn # noqa - assertIsNotNone = unittest.TestCase.assertIsNotNone # noqa - assertRaises = unittest.TestCase.assertRaises # noqa - assertRegex = unittest.TestCase.assertRegex # noqa - assertNotIn = unittest.TestCase.assertNotIn # noqa - fail = unittest.TestCase.fail - subTest = unittest.TestCase.subTest # noqa - - def setUp(self): - self._setup_charm_dir() + @pytest.fixture + def setup_charm(self, request: pytest.FixtureRequest, fake_script: FakeScript): + self._setup_charm_dir(request) # Relations events are defined dynamically and modify the class attributes. # We use a subclass temporarily to prevent these side effects from leaking. @@ -305,18 +306,23 @@ class TestCharmEvents(ops.CharmEvents): def cleanup(): ops.CharmBase.on = ops.CharmEvents() # type: ignore - self.addCleanup(cleanup) + request.addfinalizer(cleanup) - fake_script(typing.cast(unittest.TestCase, self), 'is-leader', 'echo true') - fake_script(typing.cast(unittest.TestCase, self), 'juju-log', 'exit 0') + fake_script.write('is-leader', 'echo true') + fake_script.write('juju-log', 'exit 0') # set to something other than None for tests that care self.stdout = None self.stderr = None - def _setup_charm_dir(self): + def _setup_charm_dir(self, request: pytest.FixtureRequest): self._tmpdir = Path(tempfile.mkdtemp(prefix='tmp-ops-test-')).resolve() - self.addCleanup(shutil.rmtree, str(self._tmpdir)) + + def cleanup(): + shutil.rmtree(self._tmpdir, ignore_errors=True) + + request.addfinalizer(cleanup) + self.JUJU_CHARM_DIR = self._tmpdir / 'test_main' self._charm_state_file = self.JUJU_CHARM_DIR / '.unit-state.db' self.hooks_dir = self.JUJU_CHARM_DIR / 'hooks' @@ -347,9 +353,10 @@ def _prepare_actions(self): for action_name in ('start', 'foo-bar', 'get-model-name', 'get-status', 'keyerror'): self._setup_entry_point(actions_dir, action_name) - def _read_and_clear_state(self, - event_name: str) -> typing.Union[ops.BoundStoredState, - ops.StoredStateData]: + def _read_and_clear_state( + self, + event_name: str, + ) -> typing.Union[ops.BoundStoredState, ops.StoredStateData]: if self._charm_state_file.stat().st_size: storage = SQLiteStorage(self._charm_state_file) with (self.JUJU_CHARM_DIR / 'metadata.yaml').open() as m: @@ -381,7 +388,7 @@ class Charm(self.charm_module.Charm, _HasStored): # type: ignore stored = ops.StoredStateData(None, None) # type: ignore return stored - def _simulate_event(self, event_spec: EventSpec): + def _simulate_event(self, fake_script: FakeScript, event_spec: EventSpec): ppath = Path(__file__).parent pypath = str(ppath.parent) if 'PYTHONPATH' in os.environ: @@ -460,47 +467,62 @@ def _simulate_event(self, event_spec: EventSpec): if event_spec.model_name is not None: env['JUJU_MODEL_NAME'] = event_spec.model_name - self._call_event(Path(event_dir, event_filename), env) + self._call_event(fake_script, Path(event_dir, event_filename), env) return self._read_and_clear_state(event_spec.event_name) - def test_event_reemitted(self): + @pytest.mark.usefixtures("setup_charm") + def test_event_reemitted(self, fake_script: FakeScript): # First run "install" to make sure all hooks are set up. - state = self._simulate_event(EventSpec(ops.InstallEvent, 'install')) + state = self._simulate_event(fake_script, EventSpec(ops.InstallEvent, 'install')) assert isinstance(state, ops.BoundStoredState) assert list(state.observed_event_types) == ['InstallEvent'] - state = self._simulate_event(EventSpec(ops.ConfigChangedEvent, 'config-changed')) + state = self._simulate_event( + fake_script, + EventSpec(ops.ConfigChangedEvent, 'config-changed') + ) assert isinstance(state, ops.BoundStoredState) assert list(state.observed_event_types) == ['ConfigChangedEvent'] # Re-emit should pick the deferred config-changed. - state = self._simulate_event(EventSpec(ops.UpdateStatusEvent, 'update-status')) + state = self._simulate_event( + fake_script, + EventSpec(ops.UpdateStatusEvent, 'update-status') + ) assert isinstance(state, ops.BoundStoredState) assert list(state.observed_event_types) == \ ['ConfigChangedEvent', 'UpdateStatusEvent'] - def test_no_reemission_on_collect_metrics(self): - fake_script(typing.cast(unittest.TestCase, self), 'add-metric', 'exit 0') + @pytest.mark.usefixtures("setup_charm") + def test_no_reemission_on_collect_metrics(self, fake_script: FakeScript): + fake_script.write('add-metric', 'exit 0') # First run "install" to make sure all hooks are set up. - state = self._simulate_event(EventSpec(ops.InstallEvent, 'install')) + state = self._simulate_event(fake_script, EventSpec(ops.InstallEvent, 'install')) assert isinstance(state, ops.BoundStoredState) assert list(state.observed_event_types) == ['InstallEvent'] - state = self._simulate_event(EventSpec(ops.ConfigChangedEvent, 'config-changed')) + state = self._simulate_event( + fake_script, + EventSpec(ops.ConfigChangedEvent, 'config-changed') + ) assert isinstance(state, ops.BoundStoredState) assert list(state.observed_event_types) == ['ConfigChangedEvent'] # Re-emit should not pick the deferred config-changed because # collect-metrics runs in a restricted context. - state = self._simulate_event(EventSpec(ops.CollectMetricsEvent, 'collect-metrics')) + state = self._simulate_event( + fake_script, + EventSpec(ops.CollectMetricsEvent, 'collect-metrics') + ) assert isinstance(state, ops.BoundStoredState) assert list(state.observed_event_types) == ['CollectMetricsEvent'] - def test_multiple_events_handled(self): + @pytest.mark.usefixtures("setup_charm") + def test_multiple_events_handled(self, fake_script: FakeScript): self._prepare_actions() - fake_script(typing.cast(unittest.TestCase, self), 'action-get', "echo '{}'") + fake_script.write('action-get', "echo '{}'") # Sample events with a different amount of dashes used # and with endpoints from different sections of metadata.yaml @@ -643,11 +665,11 @@ def test_multiple_events_handled(self): logger.debug('Expected events %s', events_under_test) # First run "install" to make sure all hooks are set up. - self._simulate_event(EventSpec(ops.InstallEvent, 'install')) + self._simulate_event(fake_script, EventSpec(ops.InstallEvent, 'install')) # Simulate hook executions for every event. for event_spec, expected_event_data in events_under_test: - state = self._simulate_event(event_spec) + state = self._simulate_event(fake_script, event_spec) assert isinstance(state, ops.BoundStoredState) state_key = f"on_{event_spec.event_name}" @@ -665,7 +687,8 @@ def test_multiple_events_handled(self): assert getattr(state, f"{event_spec.event_name}_data") == \ expected_event_data - def test_event_not_implemented(self): + @pytest.mark.usefixtures("setup_charm") + def test_event_not_implemented(self, fake_script: FakeScript): """Make sure events without implementation do not cause non-zero exit.""" # Simulate a scenario where there is a symlink for an event that # a charm does not know how to handle. @@ -674,25 +697,34 @@ def test_event_not_implemented(self): hook_path.symlink_to('install') try: - self._simulate_event(EventSpec(ops.HookEvent, 'not-implemented-event')) + self._simulate_event( + fake_script, + EventSpec(ops.HookEvent, 'not-implemented-event') + ) except subprocess.CalledProcessError: - self.fail('Event simulation for an unsupported event' - ' results in a non-zero exit code returned') + pytest.fail('Event simulation for an unsupported event' + ' results in a non-zero exit code returned') - def test_no_actions(self): + @pytest.mark.usefixtures("setup_charm") + def test_no_actions(self, fake_script: FakeScript): (self.JUJU_CHARM_DIR / 'actions.yaml').unlink() - self._simulate_event(EventSpec(ops.InstallEvent, 'install')) + self._simulate_event(fake_script, EventSpec(ops.InstallEvent, 'install')) - def test_empty_actions(self): + @pytest.mark.usefixtures("setup_charm") + def test_empty_actions(self, fake_script: FakeScript): (self.JUJU_CHARM_DIR / 'actions.yaml').write_text('') - self._simulate_event(EventSpec(ops.InstallEvent, 'install')) + self._simulate_event(fake_script, EventSpec(ops.InstallEvent, 'install')) - def test_collect_metrics(self): - fake_script(typing.cast(unittest.TestCase, self), 'add-metric', 'exit 0') - self._simulate_event(EventSpec(ops.InstallEvent, 'install')) + @pytest.mark.usefixtures("setup_charm") + def test_collect_metrics(self, fake_script: FakeScript): + fake_script.write('add-metric', 'exit 0') + self._simulate_event(fake_script, EventSpec(ops.InstallEvent, 'install')) # Clear the calls during 'install' - fake_script_calls(typing.cast(unittest.TestCase, self), clear=True) - self._simulate_event(EventSpec(ops.CollectMetricsEvent, 'collect_metrics')) + fake_script.calls(clear=True) + self._simulate_event( + fake_script, + EventSpec(ops.CollectMetricsEvent, 'collect_metrics') + ) expected = [ VERSION_LOGLINE, @@ -700,18 +732,25 @@ def test_collect_metrics(self): ['add-metric', '--labels', 'bar=4.2', 'foo=42'], ['is-leader', '--format=json'], ] - calls = fake_script_calls(typing.cast(unittest.TestCase, self)) + calls = fake_script.calls() assert calls == expected - def test_custom_event(self): - self._simulate_event(EventSpec(ops.InstallEvent, 'install')) + @pytest.mark.usefixtures("setup_charm") + def test_custom_event(self, fake_script: FakeScript): + self._simulate_event(fake_script, EventSpec(ops.InstallEvent, 'install')) # Clear the calls during 'install' - fake_script_calls(typing.cast(unittest.TestCase, self), clear=True) - self._simulate_event(EventSpec(ops.UpdateStatusEvent, 'update-status', - set_in_env={'EMIT_CUSTOM_EVENT': "1"})) + fake_script.calls(clear=True) + self._simulate_event( + fake_script, + EventSpec( + ops.UpdateStatusEvent, + 'update-status', + set_in_env={'EMIT_CUSTOM_EVENT': "1"} + ) + ) - calls = fake_script_calls(typing.cast(unittest.TestCase, self)) + calls = fake_script.calls() custom_event_prefix = 'Emitting custom event FakeScript: + return FakeScript(request) + + +class TestModel: + @pytest.fixture + def harness(self): + harness = ops.testing.Harness(ops.CharmBase, meta=''' name: myapp provides: db0: @@ -65,202 +71,204 @@ def setUp(self): secretfoo: type: secret ''') - self.addCleanup(self.harness.cleanup) - self.relation_id_db0 = self.harness.add_relation('db0', 'db') - self.harness._get_backend_calls(reset=True) - self.model = self.harness.model + yield harness + harness.cleanup() def ensure_relation( self, + harness: ops.testing.Harness[ops.CharmBase], name: str = 'db1', relation_id: typing.Optional[int] = None) -> ops.Relation: - """Wrapper around self.model.get_relation that enforces that None is not returned.""" - rel_db1 = self.model.get_relation(name, relation_id) + """Wrapper around harness.model.get_relation that enforces that None is not returned.""" + rel_db1 = harness.model.get_relation(name, relation_id) assert rel_db1 is not None assert rel_db1 is not None # Type checkers understand this, but not the previous line. return rel_db1 - def test_model_attributes(self): - assert self.model.app is self.model.unit.app - assert self.model.name is None + def test_model_attributes(self, harness: ops.testing.Harness[ops.CharmBase]): + assert harness.model.app is harness.model.unit.app + assert harness.model.name is None - def test_unit_immutable(self): + def test_unit_immutable(self, harness: ops.testing.Harness[ops.CharmBase]): with pytest.raises(AttributeError): - self.model.unit = object() # type: ignore + harness.model.unit = object() # type: ignore - def test_app_immutable(self): + def test_app_immutable(self, harness: ops.testing.Harness[ops.CharmBase]): with pytest.raises(AttributeError): - self.model.app = object() # type: ignore + harness.model.app = object() # type: ignore - def test_model_name_from_backend(self): - self.harness.set_model_name('default') - m = ops.Model(ops.CharmMeta(), self.harness._backend) + def test_model_name_from_backend(self, harness: ops.testing.Harness[ops.CharmBase]): + harness.set_model_name('default') + m = ops.Model(ops.CharmMeta(), harness._backend) assert m.name == 'default' with pytest.raises(AttributeError): m.name = "changes-disallowed" # type: ignore - def test_relations_keys(self): - rel_app1 = self.harness.add_relation('db1', 'remoteapp1') - self.harness.add_relation_unit(rel_app1, 'remoteapp1/0') - self.harness.add_relation_unit(rel_app1, 'remoteapp1/1') - rel_app2 = self.harness.add_relation('db1', 'remoteapp2') - self.harness.add_relation_unit(rel_app2, 'remoteapp2/0') + def test_relations_keys(self, harness: ops.testing.Harness[ops.CharmBase]): + rel_app1 = harness.add_relation('db1', 'remoteapp1') + harness.add_relation_unit(rel_app1, 'remoteapp1/0') + harness.add_relation_unit(rel_app1, 'remoteapp1/1') + rel_app2 = harness.add_relation('db1', 'remoteapp2') + harness.add_relation_unit(rel_app2, 'remoteapp2/0') # We invalidate db1 so that it causes us to reload it - self.model.relations._invalidate('db1') - self.resetBackendCalls() - for relation in self.model.relations['db1']: - assert self.model.unit in relation.data + harness.model.relations._invalidate('db1') + self.resetBackendCalls(harness) + for relation in harness.model.relations['db1']: + assert harness.model.unit in relation.data unit_from_rel = next(filter(lambda u: u.name == 'myapp/0', relation.data.keys())) - assert self.model.unit is unit_from_rel + assert harness.model.unit is unit_from_rel - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('relation_ids', 'db1'), ('relation_list', rel_app1), ('relation_list', rel_app2), ]) - def test_relations_immutable(self): + def test_relations_immutable(self, harness: ops.testing.Harness[ops.CharmBase]): with pytest.raises(AttributeError): - self.model.relations = {} # type: ignore + harness.model.relations = {} # type: ignore - def test_get_relation(self): + def test_get_relation(self, harness: ops.testing.Harness[ops.CharmBase]): # one relation on db1 # two relations on db0 # no relations on db2 - relation_id_db1 = self.harness.add_relation('db1', 'remoteapp1') - self.harness.add_relation_unit(relation_id_db1, 'remoteapp1/0') - relation_id_db0_b = self.harness.add_relation('db0', 'another') - self.resetBackendCalls() + relation_id_db0 = harness.add_relation('db0', 'db') + harness._get_backend_calls(reset=True) + + relation_id_db1 = harness.add_relation('db1', 'remoteapp1') + harness.add_relation_unit(relation_id_db1, 'remoteapp1/0') + relation_id_db0_b = harness.add_relation('db0', 'another') + self.resetBackendCalls(harness) with pytest.raises(ops.ModelError): # You have to specify it by just the integer ID - self.model.get_relation('db1', f'db1:{relation_id_db1}') # type: ignore - rel_db1 = self.model.get_relation('db1', relation_id_db1) + harness.model.get_relation('db1', f'db1:{relation_id_db1}') # type: ignore + rel_db1 = harness.model.get_relation('db1', relation_id_db1) assert isinstance(rel_db1, ops.Relation) - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('relation_ids', 'db1'), ('relation_list', relation_id_db1), ]) - dead_rel = self.ensure_relation('db1', 7) + dead_rel = self.ensure_relation(harness, 'db1', 7) assert isinstance(dead_rel, ops.Relation) - assert set(dead_rel.data.keys()) == {self.model.unit, self.model.unit.app} - assert dead_rel.data[self.model.unit] == {} - self.assertBackendCalls([ + assert set(dead_rel.data.keys()) == {harness.model.unit, harness.model.unit.app} + assert dead_rel.data[harness.model.unit] == {} + self.assertBackendCalls(harness, [ ('relation_list', 7), ('relation_remote_app_name', 7), ('relation_get', 7, 'myapp/0', False), ]) - assert self.model.get_relation('db2') is None - self.assertBackendCalls([ + assert harness.model.get_relation('db2') is None + self.assertBackendCalls(harness, [ ('relation_ids', 'db2'), ]) - assert self.model.get_relation('db1') is rel_db1 + assert harness.model.get_relation('db1') is rel_db1 with pytest.raises(ops.TooManyRelatedAppsError): - self.model.get_relation('db0') + harness.model.get_relation('db0') - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('relation_ids', 'db0'), - ('relation_list', self.relation_id_db0), + ('relation_list', relation_id_db0), ('relation_remote_app_name', 0), ('relation_list', relation_id_db0_b), ('relation_remote_app_name', 2), ]) - def test_peer_relation_app(self): - self.harness.add_relation('db2', 'myapp') - rel_dbpeer = self.ensure_relation('db2') - assert rel_dbpeer.app is self.model.app + def test_peer_relation_app(self, harness: ops.testing.Harness[ops.CharmBase]): + harness.add_relation('db2', 'myapp') + rel_dbpeer = self.ensure_relation(harness, 'db2') + assert rel_dbpeer.app is harness.model.app - def test_remote_units_is_our(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.harness.add_relation_unit(relation_id, 'remoteapp1/1') - self.resetBackendCalls() + def test_remote_units_is_our(self, harness: ops.testing.Harness[ops.CharmBase]): + relation_id = harness.add_relation('db1', 'remoteapp1') + harness.add_relation_unit(relation_id, 'remoteapp1/0') + harness.add_relation_unit(relation_id, 'remoteapp1/1') + self.resetBackendCalls(harness) - for u in self.ensure_relation('db1').units: + for u in self.ensure_relation(harness, 'db1').units: assert not u._is_our_unit assert not u.app._is_our_app - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('relation_ids', 'db1'), - ('relation_list', relation_id) + ('relation_list', relation_id), ]) - def test_our_unit_is_our(self): - assert self.model.unit._is_our_unit - assert self.model.unit.app._is_our_app + def test_our_unit_is_our(self, harness: ops.testing.Harness[ops.CharmBase]): + assert harness.model.unit._is_our_unit + assert harness.model.unit.app._is_our_app - def test_invalid_type_relation_data(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') + def test_invalid_type_relation_data(self, harness: ops.testing.Harness[ops.CharmBase]): + relation_id = harness.add_relation('db1', 'remoteapp1') + harness.add_relation_unit(relation_id, 'remoteapp1/0') with pytest.raises(ops.RelationDataError): - with self.harness._event_context('foo_event'): - self.harness.update_relation_data( + with harness._event_context('foo_event'): + harness.update_relation_data( relation_id, 'remoteapp1/0', {42: 'remoteapp1-0'}) # type: ignore with pytest.raises(ops.RelationDataError): - with self.harness._event_context('foo_event'): - self.harness.update_relation_data( + with harness._event_context('foo_event'): + harness.update_relation_data( relation_id, 'remoteapp1/0', {'foo': 42}) # type: ignore - def test_get_app_relation_data(self): - self.harness.begin() - relation_id = self.harness.add_relation('db1', 'remote') - self.harness.add_relation_unit(relation_id, 'remote/0') - local_app = self.harness.model.app.name - with self.harness._event_context('foo_event'): - self.harness.update_relation_data( + def test_get_app_relation_data(self, harness: ops.testing.Harness[ops.CharmBase]): + harness.begin() + relation_id = harness.add_relation('db1', 'remote') + harness.add_relation_unit(relation_id, 'remote/0') + local_app = harness.model.app.name + with harness._event_context('foo_event'): + harness.update_relation_data( relation_id, local_app, {'foo': 'bar'}) - assert self.harness.get_relation_data( - relation_id, self.harness.model.app) == self.harness.get_relation_data( + assert harness.get_relation_data( + relation_id, harness.model.app) == harness.get_relation_data( relation_id, local_app) == {'foo': 'bar'} - def test_unit_relation_data(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - with self.harness._event_context('foo_event'): - self.harness.update_relation_data( + def test_unit_relation_data(self, harness: ops.testing.Harness[ops.CharmBase]): + relation_id = harness.add_relation('db1', 'remoteapp1') + harness.add_relation_unit(relation_id, 'remoteapp1/0') + with harness._event_context('foo_event'): + harness.update_relation_data( relation_id, 'remoteapp1/0', {'host': 'remoteapp1-0'}) - self.model.relations._invalidate('db1') - self.resetBackendCalls() + harness.model.relations._invalidate('db1') + self.resetBackendCalls(harness) - random_unit = self.model.get_unit('randomunit/0') + random_unit = harness.model.get_unit('randomunit/0') with pytest.raises(KeyError): - self.ensure_relation('db1').data[random_unit] + self.ensure_relation(harness, 'db1').data[random_unit] remoteapp1_0 = next(filter(lambda u: u.name == 'remoteapp1/0', - self.ensure_relation('db1').units)) - assert self.ensure_relation('db1').data[remoteapp1_0] == \ + self.ensure_relation(harness, 'db1').units)) + assert self.ensure_relation(harness, 'db1').data[remoteapp1_0] == \ {'host': 'remoteapp1-0'} - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('relation_ids', 'db1'), ('relation_list', relation_id), ('relation_get', relation_id, 'remoteapp1/0', False), ]) - def test_remote_app_relation_data(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - with self.harness._event_context('foo_event'): - self.harness.update_relation_data(relation_id, 'remoteapp1', - {'secret': 'cafedeadbeef'}) - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.harness.add_relation_unit(relation_id, 'remoteapp1/1') - self.resetBackendCalls() + def test_remote_app_relation_data(self, harness: ops.testing.Harness[ops.CharmBase]): + relation_id = harness.add_relation('db1', 'remoteapp1') + with harness._event_context('foo_event'): + harness.update_relation_data(relation_id, 'remoteapp1', + {'secret': 'cafedeadbeef'}) + harness.add_relation_unit(relation_id, 'remoteapp1/0') + harness.add_relation_unit(relation_id, 'remoteapp1/1') + self.resetBackendCalls(harness) - rel_db1 = self.ensure_relation('db1') + rel_db1 = self.ensure_relation(harness, 'db1') # Try to get relation data for an invalid remote application. - random_app = self.model._cache.get(ops.Application, 'randomapp') + random_app = harness.model._cache.get(ops.Application, 'randomapp') with pytest.raises(KeyError): rel_db1.data[random_app] @@ -270,43 +278,43 @@ def test_remote_app_relation_data(self): assert rel_db1.data[remoteapp1] == \ {'secret': 'cafedeadbeef'} - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('relation_ids', 'db1'), ('relation_list', relation_id), ('relation_get', relation_id, 'remoteapp1', True), ]) - def test_relation_data_modify_remote(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - with self.harness._event_context('foo_event'): - self.harness.update_relation_data(relation_id, 'remoteapp1', - {'secret': 'cafedeadbeef'}) - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.harness.update_relation_data(relation_id, 'remoteapp1/0', - {'host': 'remoteapp1/0'}) - self.model.relations._invalidate('db1') - self.resetBackendCalls() - - rel_db1 = self.ensure_relation('db1') + def test_relation_data_modify_remote(self, harness: ops.testing.Harness[ops.CharmBase]): + relation_id = harness.add_relation('db1', 'remoteapp1') + with harness._event_context('foo_event'): + harness.update_relation_data(relation_id, 'remoteapp1', + {'secret': 'cafedeadbeef'}) + harness.add_relation_unit(relation_id, 'remoteapp1/0') + harness.update_relation_data(relation_id, 'remoteapp1/0', + {'host': 'remoteapp1/0'}) + harness.model.relations._invalidate('db1') + self.resetBackendCalls(harness) + + rel_db1 = self.ensure_relation(harness, 'db1') remoteapp1_0 = next(filter(lambda u: u.name == 'remoteapp1/0', - self.ensure_relation('db1').units)) + self.ensure_relation(harness, 'db1').units)) # Force memory cache to be loaded. assert 'host' in rel_db1.data[remoteapp1_0] assert repr(rel_db1.data[remoteapp1_0]) == "{'host': 'remoteapp1/0'}" - with self.harness._event_context('foo_event'): + with harness._event_context('foo_event'): with pytest.raises(ops.RelationDataError): rel_db1.data[remoteapp1_0]['foo'] = 'bar' assert 'foo' not in rel_db1.data[remoteapp1_0] - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('relation_ids', 'db1'), ('relation_list', relation_id), ('relation_get', relation_id, 'remoteapp1/0', False), ]) # this will fire more backend calls - with self.harness._event_context('foo_event'): + with harness._event_context('foo_event'): data_repr = repr(rel_db1.data) assert data_repr == \ ('{: {}, ' @@ -314,317 +322,335 @@ def test_relation_data_modify_remote(self): ": {'host': 'remoteapp1/0'}, " ": {'secret': 'cafedeadbeef'}}") - def test_relation_data_modify_our(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') + def test_relation_data_modify_our(self, harness: ops.testing.Harness[ops.CharmBase]): + relation_id = harness.add_relation('db1', 'remoteapp1') - self.harness.update_relation_data(relation_id, 'myapp/0', {'host': 'nothing'}) - self.resetBackendCalls() - with self.harness._event_context('foo_event'): - rel_db1 = self.ensure_relation('db1') + harness.update_relation_data(relation_id, 'myapp/0', {'host': 'nothing'}) + self.resetBackendCalls(harness) + with harness._event_context('foo_event'): + rel_db1 = self.ensure_relation(harness, 'db1') # update_relation_data will also trigger relation-get, so we # invalidate the cache to ensure it will be reloaded - rel_db1.data[self.model.unit]._invalidate() + rel_db1.data[harness.model.unit]._invalidate() # Force memory cache to be loaded. - assert 'host' in rel_db1.data[self.model.unit] - rel_db1.data[self.model.unit]['host'] = 'bar' - assert rel_db1.data[self.model.unit]['host'] == 'bar' + assert 'host' in rel_db1.data[harness.model.unit] + rel_db1.data[harness.model.unit]['host'] = 'bar' + assert rel_db1.data[harness.model.unit]['host'] == 'bar' - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('relation_get', relation_id, 'myapp/0', False), - ('update_relation_data', relation_id, self.model.unit, 'host', 'bar'), + ('update_relation_data', relation_id, harness.model.unit, 'host', 'bar'), ]) - def test_app_relation_data_modify_local_as_leader(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - self.harness.update_relation_data(relation_id, 'myapp', {'password': 'deadbeefcafe'}) - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.harness.set_leader(True) - self.resetBackendCalls() + def test_app_relation_data_modify_local_as_leader( + self, + harness: ops.testing.Harness[ops.CharmBase] + ): + relation_id = harness.add_relation('db1', 'remoteapp1') + harness.update_relation_data(relation_id, 'myapp', {'password': 'deadbeefcafe'}) + harness.add_relation_unit(relation_id, 'remoteapp1/0') + harness.set_leader(True) + self.resetBackendCalls(harness) - local_app = self.model.unit.app + local_app = harness.model.unit.app - rel_db1 = self.ensure_relation('db1') + rel_db1 = self.ensure_relation(harness, 'db1') assert rel_db1.data[local_app] == {'password': 'deadbeefcafe'} rel_db1.data[local_app]['password'] = 'foo' assert rel_db1.data[local_app]['password'] == 'foo' - self.assertBackendCalls( - [('relation_ids', 'db1'), - ('relation_list', 1), - ('relation_get', 1, 'myapp', True), - ('update_relation_data', 1, self.model.app, 'password', 'foo')]) + self.assertBackendCalls(harness, [ + ('relation_ids', 'db1'), + ('relation_list', 0), + ('relation_get', 0, 'myapp', True), + ('update_relation_data', 0, harness.model.app, 'password', 'foo'), + ]) - def test_app_relation_data_modify_local_as_minion(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - self.harness.update_relation_data(relation_id, 'myapp', {'password': 'deadbeefcafe'}) - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.harness.set_leader(False) - self.resetBackendCalls() + def test_app_relation_data_modify_local_as_minion( + self, + harness: ops.testing.Harness[ops.CharmBase], + ): + relation_id = harness.add_relation('db1', 'remoteapp1') + harness.update_relation_data(relation_id, 'myapp', {'password': 'deadbeefcafe'}) + harness.add_relation_unit(relation_id, 'remoteapp1/0') + harness.set_leader(False) + self.resetBackendCalls(harness) - local_app = self.model.unit.app + local_app = harness.model.unit.app - rel_db1 = self.ensure_relation('db1') + rel_db1 = self.ensure_relation(harness, 'db1') assert rel_db1.data[local_app] == {'password': 'deadbeefcafe'} - with self.harness._event_context('foo_event'): + with harness._event_context('foo_event'): # if we were inside an event context, we'd get: with pytest.raises(ops.RelationDataError): rel_db1.data[local_app]['password'] = 'foobar' - self.assertBackendCalls([('relation_ids', 'db1'), - ('relation_list', 1), - ('relation_get', 1, 'myapp', True), - ('is_leader',)]) + self.assertBackendCalls(harness, [ + ('relation_ids', 'db1'), + ('relation_list', 0), + ('relation_get', 0, 'myapp', True), + ('is_leader',), + ]) - def test_relation_data_access_peer_leader(self): - r_id = self.harness.add_relation('db2', 'myapp') - self.harness.add_relation_unit(r_id, 'myapp/1') # peer! - self.harness.update_relation_data(r_id, 'myapp', {'foo': 'bar'}) - with self.harness._event_context('foo_event'): + def test_relation_data_access_peer_leader(self, harness: ops.testing.Harness[ops.CharmBase]): + r_id = harness.add_relation('db2', 'myapp') + harness.add_relation_unit(r_id, 'myapp/1') # peer! + harness.update_relation_data(r_id, 'myapp', {'foo': 'bar'}) + with harness._event_context('foo_event'): # leaders can read - self.harness.set_leader(True) - relation = self.harness.model.get_relation('db2') + harness.set_leader(True) + relation = harness.model.get_relation('db2') assert relation is not None and relation.app is not None assert relation.data[relation.app]['foo'] == 'bar' - def test_relation_data_access_peer_minion(self): - r_id = self.harness.add_relation('db2', 'myapp') - self.harness.add_relation_unit(r_id, 'myapp/1') # peer! - self.harness.update_relation_data(r_id, 'myapp', {'foo': 'bar'}) - with self.harness._event_context('foo_event'): + def test_relation_data_access_peer_minion(self, harness: ops.testing.Harness[ops.CharmBase]): + r_id = harness.add_relation('db2', 'myapp') + harness.add_relation_unit(r_id, 'myapp/1') # peer! + harness.update_relation_data(r_id, 'myapp', {'foo': 'bar'}) + with harness._event_context('foo_event'): # nonleaders can read - self.harness.set_leader(False) - relation = self.harness.model.get_relation('db2') + harness.set_leader(False) + relation = harness.model.get_relation('db2') assert relation is not None and relation.app is not None assert relation.data[relation.app]['foo'] == 'bar' - def test_relation_data_del_key(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - with self.harness._event_context('foo_event'): - self.harness.update_relation_data(relation_id, 'myapp/0', {'host': 'bar'}) - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.resetBackendCalls() + def test_relation_data_del_key(self, harness: ops.testing.Harness[ops.CharmBase]): + relation_id = harness.add_relation('db1', 'remoteapp1') + with harness._event_context('foo_event'): + harness.update_relation_data(relation_id, 'myapp/0', {'host': 'bar'}) + harness.add_relation_unit(relation_id, 'remoteapp1/0') + self.resetBackendCalls(harness) - rel_db1 = self.ensure_relation('db1') + rel_db1 = self.ensure_relation(harness, 'db1') # Force memory cache to be loaded. - assert 'host' in rel_db1.data[self.model.unit] - del rel_db1.data[self.model.unit]['host'] - assert 'host' not in rel_db1.data[self.model.unit] - assert self.harness.get_relation_data(relation_id, 'myapp/0') == {} + assert 'host' in rel_db1.data[harness.model.unit] + del rel_db1.data[harness.model.unit]['host'] + assert 'host' not in rel_db1.data[harness.model.unit] + assert harness.get_relation_data(relation_id, 'myapp/0') == {} - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('relation_ids', 'db1'), ('relation_list', relation_id), ('relation_get', relation_id, 'myapp/0', False), - ('update_relation_data', relation_id, self.model.unit, 'host', ''), + ('update_relation_data', relation_id, harness.model.unit, 'host', ''), ]) - def test_relation_data_del_missing_key(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - with self.harness._event_context('foo_event'): - self.harness.update_relation_data(relation_id, 'myapp/0', {'host': 'bar'}) - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.resetBackendCalls() + def test_relation_data_del_missing_key(self, harness: ops.testing.Harness[ops.CharmBase]): + relation_id = harness.add_relation('db1', 'remoteapp1') + with harness._event_context('foo_event'): + harness.update_relation_data(relation_id, 'myapp/0', {'host': 'bar'}) + harness.add_relation_unit(relation_id, 'remoteapp1/0') + self.resetBackendCalls(harness) - rel_db1 = self.ensure_relation('db1') + rel_db1 = self.ensure_relation(harness, 'db1') # Force memory cache to be loaded. - assert 'host' in rel_db1.data[self.model.unit] - with self.harness._event_context('foo_event'): - rel_db1.data[self.model.unit]['port'] = '' # Same as a delete, should not fail. - assert 'port' not in rel_db1.data[self.model.unit] - with self.harness._event_context('foo_event'): - assert self.harness.get_relation_data(relation_id, 'myapp/0') == \ + assert 'host' in rel_db1.data[harness.model.unit] + with harness._event_context('foo_event'): + rel_db1.data[harness.model.unit]['port'] = '' # Same as a delete, should not fail. + assert 'port' not in rel_db1.data[harness.model.unit] + with harness._event_context('foo_event'): + assert harness.get_relation_data(relation_id, 'myapp/0') == \ {'host': 'bar'} - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('relation_ids', 'db1'), ('relation_list', relation_id), ('relation_get', relation_id, 'myapp/0', False), - ('update_relation_data', relation_id, self.model.unit, 'port', ''), + ('update_relation_data', relation_id, harness.model.unit, 'port', ''), ]) - def test_relation_set_fail(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - with self.harness._event_context('foo_event'): - self.harness.update_relation_data(relation_id, 'myapp/0', {'host': 'myapp-0'}) - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.resetBackendCalls() + def test_relation_set_fail(self, harness: ops.testing.Harness[ops.CharmBase]): + relation_id = harness.add_relation('db1', 'remoteapp1') + with harness._event_context('foo_event'): + harness.update_relation_data(relation_id, 'myapp/0', {'host': 'myapp-0'}) + harness.add_relation_unit(relation_id, 'remoteapp1/0') + self.resetBackendCalls(harness) - backend = self.harness._backend + backend = harness._backend # TODO: jam 2020-03-06 This is way too much information about relation_set # The original test forced 'relation-set' to return exit code 2, # but there was nothing illegal about the data that was being set, # for us to properly test the side effects of relation-set failing. def broken_update_relation_data( - relation_id: int, - entity: typing.Union[ops.Unit, ops.Application], - key: str, - value: str): + relation_id: int, + entity: typing.Union[ops.Unit, ops.Application], + key: str, + value: str, + ): backend._calls.append(('update_relation_data', relation_id, entity, key, value)) raise ops.ModelError() + backend.update_relation_data = broken_update_relation_data - rel_db1 = self.ensure_relation('db1') + rel_db1 = self.ensure_relation(harness, 'db1') # Force memory cache to be loaded. - assert 'host' in rel_db1.data[self.model.unit] + assert 'host' in rel_db1.data[harness.model.unit] - with self.harness._event_context('foo_event'): + with harness._event_context('foo_event'): with pytest.raises(ops.ModelError): - rel_db1.data[self.model.unit]['host'] = 'bar' - assert rel_db1.data[self.model.unit]['host'] == 'myapp-0' + rel_db1.data[harness.model.unit]['host'] = 'bar' + assert rel_db1.data[harness.model.unit]['host'] == 'myapp-0' with pytest.raises(ops.ModelError): - del rel_db1.data[self.model.unit]['host'] - assert 'host' in rel_db1.data[self.model.unit] + del rel_db1.data[harness.model.unit]['host'] + assert 'host' in rel_db1.data[harness.model.unit] - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('relation_ids', 'db1'), ('relation_list', relation_id), ('relation_get', relation_id, 'myapp/0', False), - ('update_relation_data', relation_id, self.model.unit, 'host', 'bar'), - ('update_relation_data', relation_id, self.model.unit, 'host', ''), + ('update_relation_data', relation_id, harness.model.unit, 'host', 'bar'), + ('update_relation_data', relation_id, harness.model.unit, 'host', ''), ]) - def test_relation_data_type_check(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - self.harness.update_relation_data(relation_id, 'myapp/0', {'host': 'myapp-0'}) - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.resetBackendCalls() + def test_relation_data_type_check(self, harness: ops.testing.Harness[ops.CharmBase]): + relation_id = harness.add_relation('db1', 'remoteapp1') + harness.update_relation_data(relation_id, 'myapp/0', {'host': 'myapp-0'}) + harness.add_relation_unit(relation_id, 'remoteapp1/0') + self.resetBackendCalls(harness) - rel_db1 = self.ensure_relation('db1') + rel_db1 = self.ensure_relation(harness, 'db1') for key, value in ( - ('foo', 1), - ('foo', None), - ('foo', {'foo': 'bar'}), - (1, 'foo'), - (None, 'foo'), - (('foo', 'bar'), 'foo'), - (1, 1), - (None, None) + ('foo', 1), + ('foo', None), + ('foo', {'foo': 'bar'}), + (1, 'foo'), + (None, 'foo'), + (('foo', 'bar'), 'foo'), + (1, 1), + (None, None) ): with pytest.raises(ops.RelationDataError): - with self.harness.framework._event_context('foo_event'): - rel_db1.data[self.model.unit][key] = value # type: ignore + with harness.framework._event_context('foo_event'): + rel_db1.data[harness.model.unit][key] = value # type: ignore # No data has actually been changed - assert dict(rel_db1.data[self.model.unit]) == {'host': 'myapp-0'} + assert dict(rel_db1.data[harness.model.unit]) == {'host': 'myapp-0'} - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('relation_ids', 'db1'), ('relation_list', relation_id), ('relation_get', relation_id, 'myapp/0', False), ]) - def test_relation_local_app_data_readability_leader(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - self.harness.update_relation_data(relation_id, 'remoteapp1', - {'secret': 'cafedeadbeef'}) - self.harness.update_relation_data(relation_id, 'myapp', - {'local': 'data'}) + def test_relation_local_app_data_readability_leader( + self, + harness: ops.testing.Harness[ops.CharmBase], + ): + relation_id = harness.add_relation('db1', 'remoteapp1') + harness.update_relation_data(relation_id, 'remoteapp1', {'secret': 'cafedeadbeef'}) + harness.update_relation_data(relation_id, 'myapp', {'local': 'data'}) - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.harness.update_relation_data(relation_id, 'remoteapp1/0', - {'host': 'remoteapp1/0'}) - self.model.relations._invalidate('db1') - self.resetBackendCalls() + harness.add_relation_unit(relation_id, 'remoteapp1/0') + harness.update_relation_data(relation_id, 'remoteapp1/0', {'host': 'remoteapp1/0'}) + harness.model.relations._invalidate('db1') + self.resetBackendCalls(harness) - rel_db1 = self.ensure_relation('db1') - self.harness.begin() - self.harness.set_leader(True) - self.resetBackendCalls() + rel_db1 = self.ensure_relation(harness, 'db1') + harness.begin() + harness.set_leader(True) + self.resetBackendCalls(harness) - local_app = self.harness.charm.app - self.resetBackendCalls() + local_app = harness.charm.app + self.resetBackendCalls(harness) # addressing the object is OK rel_db1.data[local_app] - self.assertBackendCalls([]) + self.assertBackendCalls(harness, []) - with self.harness._event_context('foo_event'): - self.resetBackendCalls() + with harness._event_context('foo_event'): + self.resetBackendCalls(harness) assert rel_db1.data[local_app]['local'] == 'data' - self.assertBackendCalls([('is_leader',), - ('relation_get', 1, 'myapp', True)]) + self.assertBackendCalls(harness, [ + ('is_leader',), + ('relation_get', 0, 'myapp', True), + ]) - self.resetBackendCalls() + self.resetBackendCalls(harness) assert repr(rel_db1.data[local_app]) == repr({'local': 'data'}) # we don't get the data, because we're lazy - self.assertBackendCalls([('is_leader',)]) + self.assertBackendCalls(harness, [('is_leader',)]) # as well as relation data repr() in general: assert isinstance(repr(rel_db1.data), str) - def test_relation_local_app_data_readability_follower(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - with self.harness._event_context('foo_event'): - self.harness.update_relation_data(relation_id, 'remoteapp1', - {'secret': 'cafedeadbeef'}) - self.harness.update_relation_data(relation_id, 'myapp', - {'local': 'data'}) - - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.harness.update_relation_data(relation_id, 'remoteapp1/0', - {'host': 'remoteapp1/0'}) - self.model.relations._invalidate('db1') - self.resetBackendCalls() - - rel_db1 = self.ensure_relation('db1') - self.harness.begin() - self.harness.set_leader(False) - - local_app = self.harness.charm.app + def test_relation_local_app_data_readability_follower( + self, + harness: ops.testing.Harness[ops.CharmBase], + ): + relation_id = harness.add_relation('db1', 'remoteapp1') + with harness._event_context('foo_event'): + harness.update_relation_data(relation_id, 'remoteapp1', {'secret': 'cafedeadbeef'}) + harness.update_relation_data(relation_id, 'myapp', {'local': 'data'}) + + harness.add_relation_unit(relation_id, 'remoteapp1/0') + harness.update_relation_data(relation_id, 'remoteapp1/0', {'host': 'remoteapp1/0'}) + harness.model.relations._invalidate('db1') + self.resetBackendCalls(harness) + + rel_db1 = self.ensure_relation(harness, 'db1') + harness.begin() + harness.set_leader(False) + + local_app = harness.charm.app # addressing the object is OK rel_db1.data[local_app] # nonleader units cannot read their local app databag # attempting to read it is not - with self.harness._event_context('foo_event'): - self.resetBackendCalls() + with harness._event_context('foo_event'): + self.resetBackendCalls(harness) with pytest.raises(ops.RelationDataError): # 'local' is there, but still: rel_db1.data[local_app]['local'] # we didn't even get to relation-get - self.assertBackendCalls([('is_leader', )]) + self.assertBackendCalls(harness, [('is_leader', )]) # we can't see it but repr() works assert repr(rel_db1.data[local_app]) == '' - self.assertBackendCalls([('is_leader', )]) + self.assertBackendCalls(harness, [('is_leader', )]) # as well as relation data repr() in general: assert isinstance(repr(rel_db1.data), str) expected_backend_calls = [ - ('relation_get', 1, 'myapp/0', False), + ('relation_get', 0, 'myapp/0', False), ('is_leader',), - ('relation_get', 1, 'remoteapp1/0', False), + ('relation_get', 0, 'remoteapp1/0', False), ('is_leader',), - ('relation_get', 1, 'remoteapp1', True)] - self.assertBackendCalls(expected_backend_calls) + ('relation_get', 0, 'remoteapp1', True)] + self.assertBackendCalls(harness, expected_backend_calls) - def test_relation_no_units(self): - self.harness.add_relation('db1', 'remoteapp1') - rel = self.ensure_relation('db1') + def test_relation_no_units(self, harness: ops.testing.Harness[ops.CharmBase]): + harness.add_relation('db1', 'remoteapp1') + rel = self.ensure_relation(harness, 'db1') assert rel.units == set() - assert rel.app is self.model.get_app('remoteapp1') - self.assertBackendCalls([ + assert rel.app is harness.model.get_app('remoteapp1') + self.assertBackendCalls(harness, [ ('relation_ids', 'db1'), - ('relation_list', 1), - ('relation_remote_app_name', 1), + ('relation_list', 0), + ('relation_remote_app_name', 0), ]) - def test_config(self): - self.harness._get_backend_calls(reset=True) - self.harness.update_config({'foo': 'foo', 'bar': 1, 'qux': True, - 'baz': 3.1, 'secretfoo': 'secret:1234'}) - assert self.model.config == { + def test_config(self, harness: ops.testing.Harness[ops.CharmBase]): + harness._get_backend_calls(reset=True) + harness.update_config({ + 'foo': 'foo', + 'bar': 1, + 'qux': True, + 'baz': 3.1, + 'secretfoo': 'secret:1234', + }) + assert harness.model.config == { 'foo': 'foo', 'bar': 1, 'qux': True, @@ -633,88 +659,88 @@ def test_config(self): } with pytest.raises(TypeError): # Confirm that we cannot modify config values. - self.model.config['foo'] = 'bar' # type: ignore + harness.model.config['foo'] = 'bar' # type: ignore - self.assertBackendCalls([('config_get',)]) + self.assertBackendCalls(harness, [('config_get',)]) - def test_config_immutable(self): + def test_config_immutable(self, harness: ops.testing.Harness[ops.CharmBase]): with pytest.raises(AttributeError): - self.model.config = {} # type: ignore + harness.model.config = {} # type: ignore - def test_is_leader(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.harness.set_leader(True) - self.resetBackendCalls() + def test_is_leader(self, harness: ops.testing.Harness[ops.CharmBase]): + relation_id = harness.add_relation('db1', 'remoteapp1') + harness.add_relation_unit(relation_id, 'remoteapp1/0') + harness.set_leader(True) + self.resetBackendCalls(harness) def check_remote_units(): # Cannot determine leadership for remote units. - for u in self.ensure_relation('db1').units: + for u in self.ensure_relation(harness, 'db1').units: with pytest.raises(RuntimeError): u.is_leader() - assert self.model.unit.is_leader() + assert harness.model.unit.is_leader() check_remote_units() # Create a new model and backend to drop a cached is-leader output. - self.harness.set_leader(False) - assert not self.model.unit.is_leader() + harness.set_leader(False) + assert not harness.model.unit.is_leader() check_remote_units() - self.assertBackendCalls([ + self.assertBackendCalls(harness, [ ('is_leader',), ('relation_ids', 'db1'), ('relation_list', relation_id), ('is_leader',), ]) - def test_workload_version(self): - self.model.unit.set_workload_version('1.2.3') - self.assertBackendCalls([ + def test_workload_version(self, harness: ops.testing.Harness[ops.CharmBase]): + harness.model.unit.set_workload_version('1.2.3') + self.assertBackendCalls(harness, [ ('application_version_set', '1.2.3'), ]) - def test_workload_version_invalid(self): + def test_workload_version_invalid(self, harness: ops.testing.Harness[ops.CharmBase]): with pytest.raises(TypeError) as excinfo: - self.model.unit.set_workload_version(5) # type: ignore + harness.model.unit.set_workload_version(5) # type: ignore assert str(excinfo.value) == "workload version must be a str, not int: 5" - self.assertBackendCalls([]) + self.assertBackendCalls(harness, []) - def test_resources(self): + def test_resources(self, harness: ops.testing.Harness[ops.CharmBase]): with pytest.raises(ops.ModelError): - self.harness.model.resources.fetch('foo') + harness.model.resources.fetch('foo') - self.harness.add_resource('foo', 'foo contents\n') - self.harness.add_resource('bar', '') + harness.add_resource('foo', 'foo contents\n') + harness.add_resource('bar', '') with pytest.raises(NameError): - self.harness.model.resources.fetch('qux') + harness.model.resources.fetch('qux') - assert self.harness.model.resources.fetch('foo').name == 'foo.txt' - assert self.harness.model.resources.fetch('bar').name == 'bar.txt' + assert harness.model.resources.fetch('foo').name == 'foo.txt' + assert harness.model.resources.fetch('bar').name == 'bar.txt' - def test_resources_immutable(self): + def test_resources_immutable(self, harness: ops.testing.Harness[ops.CharmBase]): with pytest.raises(AttributeError): - self.model.resources = object() # type: ignore + harness.model.resources = object() # type: ignore - def test_pod_spec(self): - self.harness.set_leader(True) - self.harness.model.pod.set_spec({'foo': 'bar'}) - assert self.harness.get_pod_spec() == ({'foo': 'bar'}, None) + def test_pod_spec(self, harness: ops.testing.Harness[ops.CharmBase]): + harness.set_leader(True) + harness.model.pod.set_spec({'foo': 'bar'}) + assert harness.get_pod_spec() == ({'foo': 'bar'}, None) - self.harness.model.pod.set_spec({'bar': 'foo'}, {'qux': 'baz'}) - assert self.harness.get_pod_spec() == ({'bar': 'foo'}, {'qux': 'baz'}) + harness.model.pod.set_spec({'bar': 'foo'}, {'qux': 'baz'}) + assert harness.get_pod_spec() == ({'bar': 'foo'}, {'qux': 'baz'}) # no leader -> no set pod spec - self.harness.set_leader(False) + harness.set_leader(False) with pytest.raises(ops.ModelError): - self.harness.model.pod.set_spec({'foo': 'bar'}) + harness.model.pod.set_spec({'foo': 'bar'}) - def test_pod_immutable(self): + def test_pod_immutable(self, harness: ops.testing.Harness[ops.CharmBase]): with pytest.raises(AttributeError): - self.model.pod = object() # type: ignore + harness.model.pod = object() # type: ignore def test_base_status_instance_raises(self): with pytest.raises(TypeError): @@ -758,135 +784,144 @@ def test_status_eq(self): def test_active_message_default(self): assert ops.ActiveStatus().message == '' - def test_local_set_valid_unit_status(self): - self.harness._get_backend_calls(reset=True) - test_cases = [( - 'active', - ops.ActiveStatus('Green'), - ('status_set', 'active', 'Green', {'is_app': False}), - ), ( - 'maintenance', - ops.MaintenanceStatus('Yellow'), - ('status_set', 'maintenance', 'Yellow', {'is_app': False}), - ), ( - 'blocked', - ops.BlockedStatus('Red'), - ('status_set', 'blocked', 'Red', {'is_app': False}), - ), ( - 'waiting', - ops.WaitingStatus('White'), - ('status_set', 'waiting', 'White', {'is_app': False}), - )] - - for test_case, target_status, backend_call in test_cases: - with self.subTest(test_case): - self.model.unit.status = target_status - assert self.model.unit.status == target_status - self.model.unit._invalidate() - assert self.model.unit.status == target_status - self.assertBackendCalls([backend_call, ('status_get', {'is_app': False})]) - - def test_local_set_valid_app_status(self): - self.harness.set_leader(True) - test_cases = [( - 'active', - ops.ActiveStatus('Green'), - ('status_set', 'active', 'Green', {'is_app': True}), - ), ( - 'maintenance', - ops.MaintenanceStatus('Yellow'), - ('status_set', 'maintenance', 'Yellow', {'is_app': True}), - ), ( - 'blocked', - ops.BlockedStatus('Red'), - ('status_set', 'blocked', 'Red', {'is_app': True}), - ), ( - 'waiting', - ops.WaitingStatus('White'), - ('status_set', 'waiting', 'White', {'is_app': True}), - )] + @pytest.mark.parametrize("target_status,backend_call", [( + ops.ActiveStatus('Green'), + ('status_set', 'active', 'Green', {'is_app': False}), + ), ( + ops.MaintenanceStatus('Yellow'), + ('status_set', 'maintenance', 'Yellow', {'is_app': False}), + ), ( + ops.BlockedStatus('Red'), + ('status_set', 'blocked', 'Red', {'is_app': False}), + ), ( + ops.WaitingStatus('White'), + ('status_set', 'waiting', 'White', {'is_app': False}), + )]) + def test_local_set_valid_unit_status( + self, + harness: ops.testing.Harness[ops.CharmBase], + target_status: ops.StatusBase, + backend_call: typing.Tuple[str, str, str, typing.Dict[str, bool]], + ): + harness._get_backend_calls(reset=True) + harness.model.unit.status = target_status + assert harness.model.unit.status == target_status + harness.model.unit._invalidate() + assert harness.model.unit.status == target_status + self.assertBackendCalls(harness, [backend_call, ('status_get', {'is_app': False})]) + + @pytest.mark.parametrize("target_status,backend_call", [( + ops.ActiveStatus('Green'), + ('status_set', 'active', 'Green', {'is_app': True}), + ), ( + ops.MaintenanceStatus('Yellow'), + ('status_set', 'maintenance', 'Yellow', {'is_app': True}), + ), ( + ops.BlockedStatus('Red'), + ('status_set', 'blocked', 'Red', {'is_app': True}), + ), ( + ops.WaitingStatus('White'), + ('status_set', 'waiting', 'White', {'is_app': True}), + )]) + def test_local_set_valid_app_status( + self, + harness: ops.testing.Harness[ops.CharmBase], + target_status: ops.StatusBase, + backend_call: typing.Tuple[str, str, str, typing.Dict[str, bool]], + ): + harness.set_leader(True) + + harness.model.app.status = target_status + assert harness.model.app.status == target_status + harness.model.app._invalidate() + assert harness.model.app.status == target_status + # There is a backend call to check if we can set the value, + # and then another check each time we assert the status above + expected_calls = [ + ('is_leader',), backend_call, + ('is_leader',), + ('is_leader',), ('status_get', {'is_app': True}), + ] + self.assertBackendCalls(harness, expected_calls) - for test_case, target_status, backend_call in test_cases: - with self.subTest(test_case): - self.model.app.status = target_status - assert self.model.app.status == target_status - self.model.app._invalidate() - assert self.model.app.status == target_status - # There is a backend call to check if we can set the value, - # and then another check each time we assert the status above - expected_calls = [ - ('is_leader',), backend_call, - ('is_leader',), - ('is_leader',), ('status_get', {'is_app': True}), - ] - self.assertBackendCalls(expected_calls) - - def test_set_app_status_non_leader_raises(self): - self.harness.set_leader(False) + def test_set_app_status_non_leader_raises( + self, + harness: ops.testing.Harness[ops.CharmBase], + ): + harness.set_leader(False) with pytest.raises(RuntimeError): - self.model.app.status + harness.model.app.status with pytest.raises(RuntimeError): - self.model.app.status = ops.ActiveStatus() + harness.model.app.status = ops.ActiveStatus() - def test_set_unit_status_invalid(self): + def test_set_unit_status_invalid(self, harness: ops.testing.Harness[ops.CharmBase]): with pytest.raises(ops.InvalidStatusError): - self.model.unit.status = 'blocked' # type: ignore + harness.model.unit.status = 'blocked' # type: ignore - def test_set_app_status_invalid(self): + def test_set_app_status_invalid(self, harness: ops.testing.Harness[ops.CharmBase]): with pytest.raises(ops.InvalidStatusError): - self.model.app.status = 'blocked' # type: ignore - - def test_remote_unit_status(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.harness.add_relation_unit(relation_id, 'remoteapp1/1') - remote_unit = next(filter(lambda u: u.name == 'remoteapp1/0', - self.ensure_relation('db1').units)) - self.resetBackendCalls() + harness.model.app.status = 'blocked' # type: ignore + + @pytest.mark.parametrize("target_status", [ + ops.UnknownStatus(), + ops.ActiveStatus('Green'), + ops.MaintenanceStatus('Yellow'), + ops.BlockedStatus('Red'), + ops.WaitingStatus('White'), + ]) + def test_remote_unit_status( + self, + harness: ops.testing.Harness[ops.CharmBase], + target_status: ops.StatusBase, + ): + relation_id = harness.add_relation('db1', 'remoteapp1') + harness.add_relation_unit(relation_id, 'remoteapp1/0') + harness.add_relation_unit(relation_id, 'remoteapp1/1') + remote_unit = next( + filter( + lambda u: u.name == 'remoteapp1/0', + self.ensure_relation(harness, 'db1').units + ) + ) + self.resetBackendCalls(harness) # Remote unit status is always unknown. assert remote_unit.status == ops.UnknownStatus() - test_statuses = ( - ops.UnknownStatus(), - ops.ActiveStatus('Green'), - ops.MaintenanceStatus('Yellow'), - ops.BlockedStatus('Red'), - ops.WaitingStatus('White'), - ) - - for target_status in test_statuses: - with self.subTest(target_status.name): - with pytest.raises(RuntimeError): - remote_unit.status = target_status - self.assertBackendCalls([]) - - def test_remote_app_status(self): - relation_id = self.harness.add_relation('db1', 'remoteapp1') - self.harness.add_relation_unit(relation_id, 'remoteapp1/0') - self.harness.add_relation_unit(relation_id, 'remoteapp1/1') - remoteapp1 = self.ensure_relation('db1').app - self.resetBackendCalls() + with pytest.raises(RuntimeError): + remote_unit.status = target_status + + self.assertBackendCalls(harness, []) + + @pytest.mark.parametrize("target_status", [ + ops.UnknownStatus(), + ops.ActiveStatus(), + ops.MaintenanceStatus('Upgrading software'), + ops.BlockedStatus('Awaiting manual resolution'), + ops.WaitingStatus('Awaiting related app updates'), + ]) + def test_remote_app_status( + self, + harness: ops.testing.Harness[ops.CharmBase], + target_status: ops.StatusBase, + ): + relation_id = harness.add_relation('db1', 'remoteapp1') + harness.add_relation_unit(relation_id, 'remoteapp1/0') + harness.add_relation_unit(relation_id, 'remoteapp1/1') + remoteapp1 = self.ensure_relation(harness, 'db1').app + self.resetBackendCalls(harness) # Remote application status is always unknown. assert remoteapp1 is not None assert isinstance(remoteapp1.status, ops.UnknownStatus) - test_statuses = ( - ops.UnknownStatus(), - ops.ActiveStatus(), - ops.MaintenanceStatus('Upgrading software'), - ops.BlockedStatus('Awaiting manual resolution'), - ops.WaitingStatus('Awaiting related app updates'), - ) - for target_status in test_statuses: - with self.subTest(target_status.name): - with pytest.raises(RuntimeError): - remoteapp1.status = target_status - self.assertBackendCalls([]) + with pytest.raises(RuntimeError): + remoteapp1.status = target_status + + self.assertBackendCalls(harness, []) - def test_storage(self): + def test_storage(self, fake_script: FakeScript): meta = ops.CharmMeta() raw: 'ops.charm._StorageMetaDict' = { 'type': 'test', @@ -897,14 +932,14 @@ def test_storage(self): } model = ops.Model(meta, _ModelBackend('myapp/0')) - fake_script(self, 'storage-list', ''' + fake_script.write('storage-list', ''' if [ "$1" = disks ]; then echo '["disks/0", "disks/1"]' else echo '[]' fi ''') - fake_script(self, 'storage-get', ''' + fake_script.write('storage-get', ''' if [ "$2" = disks/0 ]; then echo '"/var/srv/disks/0"' elif [ "$2" = disks/1 ]; then @@ -913,7 +948,7 @@ def test_storage(self): exit 2 fi ''') - fake_script(self, 'storage-add', '') + fake_script.write('storage-add', '') assert len(model.storages) == 2 assert model.storages.keys() == meta.storages.keys() @@ -932,15 +967,15 @@ def test_storage(self): assert storage.name == test_cases[storage.id]['name'] assert storage.location == test_cases[storage.id]['location'] - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['storage-list', 'disks', '--format=json'], ['storage-get', '-s', 'disks/0', 'location', '--format=json'], ['storage-get', '-s', 'disks/1', 'location', '--format=json'], ] - self.assertSequenceEqual(model.storages['data'], []) + assert model.storages['data'] == [] model.storages.request('data', count=3) - assert fake_script_calls(self) == [ + assert fake_script.calls() == [ ['storage-list', 'data', '--format=json'], ['storage-add', 'data=3'], ] @@ -954,23 +989,24 @@ def test_storage(self): with pytest.raises(TypeError): model.storages.request('data', count_v) # type: ignore - def test_storages_immutable(self): + def test_storages_immutable(self, harness: ops.testing.Harness[ops.CharmBase]): with pytest.raises(AttributeError): - self.model.storages = {} # type: ignore + harness.model.storages = {} # type: ignore - def resetBackendCalls(self): # noqa: N802 - self.harness._get_backend_calls(reset=True) + def resetBackendCalls(self, harness: ops.testing.Harness[ops.CharmBase]): # noqa: N802 + harness._get_backend_calls(reset=True) def assertBackendCalls( # noqa: N802 self, + harness: ops.testing.Harness[ops.CharmBase], expected: typing.List[typing.Tuple[typing.Any, ...]], *, reset: bool = True): - assert expected == self.harness._get_backend_calls(reset=reset) + assert expected == harness._get_backend_calls(reset=reset) - def test_run_error(self): + def test_run_error(self, fake_script: FakeScript): model = ops.Model(ops.CharmMeta(), _ModelBackend('myapp/0')) - fake_script(self, 'status-get', """echo 'ERROR cannot get status' >&2; exit 1""") + fake_script.write('status-get', """echo 'ERROR cannot get status' >&2; exit 1""") with pytest.raises(ops.ModelError) as excinfo: _ = model.unit.status.message assert str(excinfo.value) == 'ERROR cannot get status\n' @@ -1001,16 +1037,18 @@ def test_push_path_unnamed(self, getpwuid: MagicMock, getgrgid: MagicMock): class PushPullCase: """Test case for table-driven tests.""" - def __init__(self, - *, - name: str, - path: typing.Union[str, typing.List[str]], - files: typing.List[str], - want: typing.Optional[typing.Set[str]] = None, - dst: typing.Optional[str] = None, - errors: typing.Optional[typing.Set[str]] = None, - dirs: typing.Optional[typing.Set[str]] = None, - want_dirs: typing.Optional[typing.Set[str]] = None): + def __init__( + self, + *, + name: str, + path: typing.Union[str, typing.List[str]], + files: typing.List[str], + want: typing.Optional[typing.Set[str]] = None, + dst: typing.Optional[str] = None, + errors: typing.Optional[typing.Set[str]] = None, + dirs: typing.Optional[typing.Set[str]] = None, + want_dirs: typing.Optional[typing.Set[str]] = None, + ): self.pattern = None self.dst = dst self.errors = errors or set() @@ -1334,10 +1372,10 @@ def test_push_path_relative(case: PushPullCase): os.chdir(cwd) -class TestApplication(unittest.TestCase): - - def setUp(self): - self.harness = ops.testing.Harness(ops.CharmBase, meta=''' +class TestApplication: + @pytest.fixture + def harness(self): + harness = ops.testing.Harness(ops.CharmBase, meta=''' name: myapp provides: db0: @@ -1355,15 +1393,14 @@ def setUp(self): bar: k: v ''') - self.peer_rel_id = self.harness.add_relation('db2', 'db2') - self.app = self.harness.model.app - self.addCleanup(self.harness.cleanup) + yield harness + harness.cleanup() # Tests fix for https://github.com/canonical/operator/issues/694. - def test_mocked_get_services(self): - self.harness.begin() - self.harness.set_can_connect('bar', True) - c = self.harness.charm.unit.get_container('bar') + def test_mocked_get_services(self, harness: ops.testing.Harness[ops.CharmBase]): + harness.begin() + harness.set_can_connect('bar', True) + c = harness.charm.unit.get_container('bar') c.add_layer('layer1', { 'summary': 'layer', 'services': {"baz": {'override': 'replace', 'summary': 'echo', 'command': 'echo 1'}}, @@ -1373,53 +1410,55 @@ def test_mocked_get_services(self): assert s assert 'baz' in c.get_services() - def test_planned_units(self): - rel_id = self.peer_rel_id + def test_planned_units(self, harness: ops.testing.Harness[ops.CharmBase]): + app = harness.model.app + peer_rel_id = harness.add_relation('db2', 'db2') # Test that we always count ourself. - assert self.app.planned_units() == 1 + assert app.planned_units() == 1 # Add some units, and verify count. - self.harness.add_relation_unit(rel_id, 'myapp/1') - self.harness.add_relation_unit(rel_id, 'myapp/2') + harness.add_relation_unit(peer_rel_id, 'myapp/1') + harness.add_relation_unit(peer_rel_id, 'myapp/2') - assert self.app.planned_units() == 3 + assert app.planned_units() == 3 - self.harness.add_relation_unit(rel_id, 'myapp/3') - assert self.app.planned_units() == 4 + harness.add_relation_unit(peer_rel_id, 'myapp/3') + assert app.planned_units() == 4 # And remove a unit - self.harness.remove_relation_unit(rel_id, 'myapp/2') - - assert self.app.planned_units() == 3 + harness.remove_relation_unit(peer_rel_id, 'myapp/2') - def test_planned_units_user_set(self): + assert app.planned_units() == 3 - self.harness.set_planned_units(1) - assert self.app.planned_units() == 1 + def test_planned_units_user_set(self, harness: ops.testing.Harness[ops.CharmBase]): + harness.set_planned_units(1) + app = harness.model.app + assert app.planned_units() == 1 - self.harness.set_planned_units(2) - assert self.app.planned_units() == 2 + harness.set_planned_units(2) + assert app.planned_units() == 2 - self.harness.set_planned_units(100) - assert self.app.planned_units() == 100 + harness.set_planned_units(100) + assert app.planned_units() == 100 - def test_planned_units_garbage_values(self): + def test_planned_units_garbage_values(self, harness: ops.testing.Harness[ops.CharmBase]): # Planned units should be a positive integer, or zero. with pytest.raises(TypeError): - self.harness.set_planned_units(-1) + harness.set_planned_units(-1) # Verify that we didn't set our value before raising the error. - assert self.harness._backend._planned_units is None + assert harness._backend._planned_units is None # Verify that we still get the default value back from .planned_units. - assert self.app.planned_units() == 1 + app = harness.model.app + assert app.planned_units() == 1 with pytest.raises(TypeError): - self.harness.set_planned_units("foo") # type: ignore + harness.set_planned_units("foo") # type: ignore with pytest.raises(TypeError): - self.harness.set_planned_units(-3423000102312321090) + harness.set_planned_units(-3423000102312321090) - def test_planned_units_override(self): + def test_planned_units_override(self, harness: ops.testing.Harness[ops.CharmBase]): """Verify that we override the calculated value of planned_units when we set it manually. When a charm author writes a test that explicitly calls set_planned_units, we assume that @@ -1427,22 +1466,24 @@ def test_planned_units_override(self): charm author is composing a charm without peer relations, and the harness's count of planned units, which is based on the number of peer relations, will not be accurate. """ - peer_id = self.peer_rel_id + peer_rel_id = harness.add_relation('db2', 'db2') - self.harness.set_planned_units(10) - self.harness.add_relation_unit(peer_id, 'myapp/1') - self.harness.add_relation_unit(peer_id, 'myapp/2') - self.harness.add_relation_unit(peer_id, 'myapp/3') + harness.set_planned_units(10) + harness.add_relation_unit(peer_rel_id, 'myapp/1') + harness.add_relation_unit(peer_rel_id, 'myapp/2') + harness.add_relation_unit(peer_rel_id, 'myapp/3') - assert self.app.planned_units() == 10 + app = harness.model.app + assert app.planned_units() == 10 # Verify that we can clear the override. - self.harness.reset_planned_units() - assert self.app.planned_units() == 4 # self + 3 peers + harness.reset_planned_units() + assert app.planned_units() == 4 # self + 3 peers -class TestContainers(unittest.TestCase): - def setUp(self): +class TestContainers: + @pytest.fixture + def model(self): meta = ops.CharmMeta.from_yaml(""" name: k8s-charm containers: @@ -1452,10 +1493,10 @@ def setUp(self): k: v """) backend = _ModelBackend('myapp/0') - self.model = ops.Model(meta, backend) + return ops.Model(meta, backend) - def test_unit_containers(self): - containers = self.model.unit.containers + def test_unit_containers(self, model: ops.Model): + containers = model.unit.containers assert sorted(containers) == ['c1', 'c2'] assert len(containers) == 2 assert 'c1' in containers @@ -1470,11 +1511,11 @@ def test_unit_containers(self): containers['c3'] with pytest.raises(RuntimeError): - other_unit = self.model.get_unit('other') + other_unit = model.get_unit('other') other_unit.containers - def test_unit_get_container(self): - unit = self.model.unit + def test_unit_get_container(self, model: ops.Model): + unit = model.unit for name in ['c1', 'c2']: container = unit.get_container(name) assert isinstance(container, ops.Container) @@ -1484,12 +1525,13 @@ def test_unit_get_container(self): unit.get_container('c3') with pytest.raises(RuntimeError): - other_unit = self.model.get_unit('other') + other_unit = model.get_unit('other') other_unit.get_container('foo') -class TestContainerPebble(unittest.TestCase): - def setUp(self): +class TestContainerPebble: + @pytest.fixture + def container(self): meta = ops.CharmMeta.from_yaml(""" name: k8s-charm containers: @@ -1497,74 +1539,72 @@ def setUp(self): k: v """) backend = MockPebbleBackend('myapp/0') - self.model = ops.Model(meta, backend) - self.container = self.model.unit.containers['c1'] - self.pebble: MockPebbleClient = self.container.pebble # type: ignore - - def test_socket_path(self): - assert self.pebble.socket_path == '/charm/containers/c1/pebble.socket' - - def test_autostart(self): - self.container.autostart() - assert self.pebble.requests == [('autostart',)] - - def test_replan(self): - self.container.replan() - assert self.pebble.requests == [('replan',)] - - def test_can_connect(self): - self.pebble.responses.append(pebble.SystemInfo.from_dict({'version': '1.0.0'})) - assert self.container.can_connect() - assert self.pebble.requests == [('get_system_info',)] - - def test_start(self): - self.container.start('foo') - self.container.start('foo', 'bar') - assert self.pebble.requests == [ + return ops.Model(meta, backend).unit.containers['c1'] + + def test_socket_path(self, container: ops.Container): + assert container.pebble.socket_path == '/charm/containers/c1/pebble.socket' + + def test_autostart(self, container: ops.Container): + container.autostart() + assert container.pebble.requests == [('autostart',)] # type: ignore + + def test_replan(self, container: ops.Container): + container.replan() + assert container.pebble.requests == [('replan',)] # type: ignore + + def test_can_connect(self, container: ops.Container): + container.pebble.responses.append(pebble.SystemInfo.from_dict({'version': '1.0.0'})) # type: ignore + assert container.can_connect() + assert container.pebble.requests == [('get_system_info',)] # type: ignore + + def test_start(self, container: ops.Container): + container.start('foo') + container.start('foo', 'bar') + assert container.pebble.requests == [ # type: ignore ('start', ('foo',)), ('start', ('foo', 'bar')), ] - def test_start_no_arguments(self): + def test_start_no_arguments(self, container: ops.Container): with pytest.raises(TypeError): - self.container.start() + container.start() - def test_stop(self): - self.container.stop('foo') - self.container.stop('foo', 'bar') - assert self.pebble.requests == [ + def test_stop(self, container: ops.Container): + container.stop('foo') + container.stop('foo', 'bar') + assert container.pebble.requests == [ # type: ignore ('stop', ('foo',)), ('stop', ('foo', 'bar')), ] - def test_stop_no_arguments(self): + def test_stop_no_arguments(self, container: ops.Container): with pytest.raises(TypeError): - self.container.stop() + container.stop() - def test_restart(self): - self.container.restart('foo') - self.container.restart('foo', 'bar') - assert self.pebble.requests == [ + def test_restart(self, container: ops.Container): + container.restart('foo') + container.restart('foo', 'bar') + assert container.pebble.requests == [ # type: ignore ('restart', ('foo',)), ('restart', ('foo', 'bar')), ] - def test_restart_fallback(self): + def test_restart_fallback(self, container: ops.Container): def restart_services(service_names: str): - self.pebble.requests.append(('restart', service_names)) + container.pebble.requests.append(('restart', service_names)) # type: ignore raise pebble.APIError({}, 400, "", "") - self.pebble.restart_services = restart_services + container.pebble.restart_services = restart_services # type: ignore # Setup the Pebble client to respond to a call to get_services() - self.pebble.responses.append([ + container.pebble.responses.append([ # type: ignore pebble.ServiceInfo.from_dict( {'name': 'foo', 'startup': 'enabled', 'current': 'active'}), pebble.ServiceInfo.from_dict( {'name': 'bar', 'startup': 'enabled', 'current': 'inactive'}), ]) - self.container.restart('foo', 'bar') - assert self.pebble.requests == [ + container.restart('foo', 'bar') + assert container.pebble.requests == [ # type: ignore # This is the first request, which in real life fails with APIError on older versions ('restart', ('foo', 'bar')), # Next the code should loop over the started services, and stop them @@ -1574,20 +1614,20 @@ def restart_services(service_names: str): ('start', ('foo', 'bar')) ] - def test_restart_fallback_non_400_error(self): + def test_restart_fallback_non_400_error(self, container: ops.Container): def restart_services(service_names: str): raise pebble.APIError({}, 500, "", "") - self.pebble.restart_services = restart_services + container.pebble.restart_services = restart_services # type: ignore with pytest.raises(pebble.APIError) as excinfo: - self.container.restart('foo') + container.restart('foo') assert excinfo.value.code == 500 - def test_restart_no_arguments(self): + def test_restart_no_arguments(self, container: ops.Container): with pytest.raises(TypeError): - self.container.restart() + container.restart() - def test_type_errors(self): + def test_type_errors(self, container: ops.Container): meta = ops.CharmMeta.from_yaml(""" name: k8s-charm containers: @@ -1605,12 +1645,12 @@ def test_type_errors(self): with pytest.raises(TypeError): container.stop(['foo']) # type: ignore - def test_add_layer(self): - self.container.add_layer('a', 'summary: str\n') - self.container.add_layer('b', {'summary': 'dict'}) - self.container.add_layer('c', pebble.Layer('summary: Layer')) - self.container.add_layer('d', 'summary: str\n', combine=True) - assert self.pebble.requests == [ + def test_add_layer(self, container: ops.Container): + container.add_layer('a', 'summary: str\n') + container.add_layer('b', {'summary': 'dict'}) + container.add_layer('c', pebble.Layer('summary: Layer')) + container.add_layer('d', 'summary: str\n', combine=True) + assert container.pebble.requests == [ # type: ignore ('add_layer', 'a', 'summary: str\n', False), ('add_layer', 'b', 'summary: dict\n', False), ('add_layer', 'c', 'summary: Layer\n', False), @@ -1619,13 +1659,13 @@ def test_add_layer(self): # combine is a keyword-only arg (should be combine=True) with pytest.raises(TypeError): - self.container.add_layer('x', {}, True) # type: ignore + container.add_layer('x', {}, True) # type: ignore - def test_get_plan(self): + def test_get_plan(self, container: ops.Container): plan_yaml = 'services:\n foo:\n override: replace\n command: bar' - self.pebble.responses.append(pebble.Plan(plan_yaml)) - plan = self.container.get_plan() - assert self.pebble.requests == [('get_plan',)] + container.pebble.responses.append(pebble.Plan(plan_yaml)) # type: ignore + plan = container.get_plan() + assert container.pebble.requests == [('get_plan',)] # type: ignore assert isinstance(plan, pebble.Plan) assert plan.to_yaml() == yaml.safe_dump(yaml.safe_load(plan_yaml)) @@ -1634,13 +1674,13 @@ def _make_service(name: str, startup: str, current: str): return pebble.ServiceInfo.from_dict( {'name': name, 'startup': startup, 'current': current}) - def test_get_services(self): + def test_get_services(self, container: ops.Container): two_services = [ self._make_service('s1', 'enabled', 'active'), self._make_service('s2', 'disabled', 'inactive'), ] - self.pebble.responses.append(two_services) - services = self.container.get_services() + container.pebble.responses.append(two_services) # type: ignore + services = container.get_services() assert len(services) == 2 assert set(services) == {'s1', 's2'} assert services['s1'].name == 's1' @@ -1650,8 +1690,8 @@ def test_get_services(self): assert services['s2'].startup == pebble.ServiceStartup.DISABLED assert services['s2'].current == pebble.ServiceStatus.INACTIVE - self.pebble.responses.append(two_services) - services = self.container.get_services('s1', 's2') + container.pebble.responses.append(two_services) # type: ignore + services = container.get_services('s1', 's2') assert len(services) == 2 assert set(services) == {'s1', 's2'} assert services['s1'].name == 's1' @@ -1661,35 +1701,35 @@ def test_get_services(self): assert services['s2'].startup == pebble.ServiceStartup.DISABLED assert services['s2'].current == pebble.ServiceStatus.INACTIVE - assert self.pebble.requests == [ + assert container.pebble.requests == [ # type: ignore ('get_services', None), ('get_services', ('s1', 's2')), ] - def test_get_service(self): + def test_get_service(self, container: ops.Container): # Single service returned successfully - self.pebble.responses.append([self._make_service('s1', 'enabled', 'active')]) - s = self.container.get_service('s1') - assert self.pebble.requests == [('get_services', ('s1', ))] + container.pebble.responses.append([self._make_service('s1', 'enabled', 'active')]) # type: ignore + s = container.get_service('s1') + assert container.pebble.requests == [('get_services', ('s1', ))] # type: ignore assert s.name == 's1' assert s.startup == pebble.ServiceStartup.ENABLED assert s.current == pebble.ServiceStatus.ACTIVE # If Pebble returns no services, should be a ops.ModelError - self.pebble.responses.append([]) + container.pebble.responses.append([]) # type: ignore with pytest.raises(ops.ModelError) as excinfo: - self.container.get_service('s2') + container.get_service('s2') assert str(excinfo.value) == "service 's2' not found" # If Pebble returns more than one service, RuntimeError is raised - self.pebble.responses.append([ + container.pebble.responses.append([ # type: ignore self._make_service('s1', 'enabled', 'active'), self._make_service('s2', 'disabled', 'inactive'), ]) with pytest.raises(RuntimeError): - self.container.get_service('s1') + container.get_service('s1') - def test_get_checks(self): + def test_get_checks(self, container: ops.Container): response_checks = [ pebble.CheckInfo.from_dict({ 'name': 'c1', @@ -1706,8 +1746,8 @@ def test_get_checks(self): }), ] - self.pebble.responses.append(response_checks) - checks = self.container.get_checks() + container.pebble.responses.append(response_checks) # type: ignore + checks = container.get_checks() assert len(checks) == 2 assert checks['c1'].name == 'c1' assert checks['c1'].level == pebble.CheckLevel.UNSET @@ -1720,8 +1760,8 @@ def test_get_checks(self): assert checks['c2'].failures == 2 assert checks['c2'].threshold == 2 - self.pebble.responses.append(response_checks[1:2]) - checks = self.container.get_checks('c1', 'c2', level=pebble.CheckLevel.ALIVE) + container.pebble.responses.append(response_checks[1:2]) # type: ignore + checks = container.get_checks('c1', 'c2', level=pebble.CheckLevel.ALIVE) assert len(checks) == 1 assert checks['c2'].name == 'c2' assert checks['c2'].level == pebble.CheckLevel.ALIVE @@ -1729,14 +1769,14 @@ def test_get_checks(self): assert checks['c2'].failures == 2 assert checks['c2'].threshold == 2 - assert self.pebble.requests == [ + assert container.pebble.requests == [ # type: ignore ('get_checks', None, None), ('get_checks', pebble.CheckLevel.ALIVE, ('c1', 'c2')), ] - def test_get_check(self): + def test_get_check(self, container: ops.Container): # Single check returned successfully - self.pebble.responses.append([ + container.pebble.responses.append([ # type: ignore pebble.CheckInfo.from_dict({ 'name': 'c1', 'status': 'up', @@ -1744,8 +1784,8 @@ def test_get_check(self): 'threshold': 3, }) # type: ignore ]) - c = self.container.get_check('c1') - assert self.pebble.requests == [('get_checks', None, ('c1', ))] + c = container.get_check('c1') + assert container.pebble.requests == [('get_checks', None, ('c1', ))] # type: ignore assert c.name == 'c1' assert c.level == pebble.CheckLevel.UNSET assert c.status == pebble.CheckStatus.UP @@ -1753,13 +1793,13 @@ def test_get_check(self): assert c.threshold == 3 # If Pebble returns no checks, should be a ops.ModelError - self.pebble.responses.append([]) + container.pebble.responses.append([]) # type: ignore with pytest.raises(ops.ModelError) as excinfo: - self.container.get_check('c2') + container.get_check('c2') assert str(excinfo.value) == "check 'c2' not found" # If Pebble returns more than one check, RuntimeError is raised - self.pebble.responses.append([ + container.pebble.responses.append([ # type: ignore pebble.CheckInfo.from_dict({ 'name': 'c1', 'status': 'up', @@ -1775,116 +1815,128 @@ def test_get_check(self): }), ]) with pytest.raises(RuntimeError): - self.container.get_check('c1') + container.get_check('c1') - def test_pull(self): - self.pebble.responses.append('dummy1') - got = self.container.pull('/path/1') + def test_pull(self, container: ops.Container): + container.pebble.responses.append('dummy1') # type: ignore + got = container.pull('/path/1') assert got == 'dummy1' - assert self.pebble.requests == [ + assert container.pebble.requests == [ # type: ignore ('pull', '/path/1', 'utf-8'), ] - self.pebble.requests = [] + container.pebble.requests = [] # type: ignore - self.pebble.responses.append(b'dummy2') - got = self.container.pull('/path/2', encoding=None) + container.pebble.responses.append(b'dummy2') # type: ignore + got = container.pull('/path/2', encoding=None) assert got == b'dummy2' - assert self.pebble.requests == [ + assert container.pebble.requests == [ # type: ignore ('pull', '/path/2', None), ] - def test_push(self): - self.container.push('/path/1', 'content1') - assert self.pebble.requests == [ + def test_push(self, container: ops.Container): + container.push('/path/1', 'content1') + assert container.pebble.requests == [ # type: ignore ('push', '/path/1', 'content1', 'utf-8', False, None, None, None, None, None), ] - self.pebble.requests = [] + container.pebble.requests = [] # type: ignore - self.container.push('/path/2', b'content2', make_dirs=True, - permissions=0o600, user_id=12, user='bob', group_id=34, group='staff') - assert self.pebble.requests == [ + container.push('/path/2', b'content2', make_dirs=True, + permissions=0o600, user_id=12, user='bob', group_id=34, group='staff') + assert container.pebble.requests == [ # type: ignore ('push', '/path/2', b'content2', 'utf-8', True, 0o600, 12, 'bob', 34, 'staff'), ] - def test_list_files(self): - self.pebble.responses.append('dummy1') - ret = self.container.list_files('/path/1') + def test_list_files(self, container: ops.Container): + container.pebble.responses.append('dummy1') # type: ignore + ret = container.list_files('/path/1') assert ret == 'dummy1' - assert self.pebble.requests == [ + assert container.pebble.requests == [ # type: ignore ('list_files', '/path/1', None, False), ] - self.pebble.requests = [] + container.pebble.requests = [] # type: ignore - self.pebble.responses.append('dummy2') - ret = self.container.list_files('/path/2', pattern='*.txt', itself=True) + container.pebble.responses.append('dummy2') # type: ignore + ret = container.list_files('/path/2', pattern='*.txt', itself=True) assert ret == 'dummy2' - assert self.pebble.requests == [ + assert container.pebble.requests == [ # type: ignore ('list_files', '/path/2', '*.txt', True), ] - def test_make_dir(self): - self.container.make_dir('/path/1') - assert self.pebble.requests == [ + def test_make_dir(self, container: ops.Container): + container.make_dir('/path/1') + assert container.pebble.requests == [ # type: ignore ('make_dir', '/path/1', False, None, None, None, None, None), ] - self.pebble.requests = [] + container.pebble.requests = [] # type: ignore - self.container.make_dir('/path/2', make_parents=True, permissions=0o700, - user_id=12, user='bob', group_id=34, group='staff') - assert self.pebble.requests == [ + container.make_dir('/path/2', make_parents=True, permissions=0o700, + user_id=12, user='bob', group_id=34, group='staff') + assert container.pebble.requests == [ # type: ignore ('make_dir', '/path/2', True, 0o700, 12, 'bob', 34, 'staff'), ] - def test_remove_path(self): - self.container.remove_path('/path/1') - assert self.pebble.requests == [ + def test_remove_path(self, container: ops.Container): + container.remove_path('/path/1') + assert container.pebble.requests == [ # type: ignore ('remove_path', '/path/1', False), ] - self.pebble.requests = [] + container.pebble.requests = [] # type: ignore - self.container.remove_path('/path/2', recursive=True) - assert self.pebble.requests == [ + container.remove_path('/path/2', recursive=True) + assert container.pebble.requests == [ # type: ignore ('remove_path', '/path/2', True), ] - def test_can_connect_simple(self): - self.pebble.responses.append(pebble.SystemInfo.from_dict({'version': '1.0.0'})) - assert self.container.can_connect() + def test_can_connect_simple(self, container: ops.Container): + container.pebble.responses.append(pebble.SystemInfo.from_dict({'version': '1.0.0'})) # type: ignore + assert container.can_connect() - def test_can_connect_connection_error(self): + def test_can_connect_connection_error( + self, + caplog: pytest.LogCaptureFixture, + container: ops.Container, + ): def raise_error(): raise pebble.ConnectionError('connection error!') - self.pebble.get_system_info = raise_error - with self.assertLogs('ops', level='DEBUG') as cm: - assert not self.container.can_connect() - assert len(cm.output) == 1 - assert re.search(r'DEBUG:ops.model:.*: connection error!', cm.output[0]) - - def test_can_connect_file_not_found_error(self): + container.pebble.get_system_info = raise_error + with caplog.at_level(level='DEBUG', logger='ops'): + assert not container.can_connect() + assert len(caplog.records) == 1 + assert re.search(r'connection error!', caplog.text) + + def test_can_connect_file_not_found_error( + self, + caplog: pytest.LogCaptureFixture, + container: ops.Container, + ): def raise_error(): raise FileNotFoundError('file not found!') - self.pebble.get_system_info = raise_error - with self.assertLogs('ops', level='DEBUG') as cm: - assert not self.container.can_connect() - assert len(cm.output) == 1 - assert re.search(r'DEBUG:ops.model:.*: file not found!', cm.output[0]) - - def test_can_connect_api_error(self): + container.pebble.get_system_info = raise_error + with caplog.at_level(level='DEBUG', logger='ops'): + assert not container.can_connect() + assert len(caplog.records) == 1 + assert re.search(r'file not found!', caplog.text) + + def test_can_connect_api_error( + self, + caplog: pytest.LogCaptureFixture, + container: ops.Container, + ): def raise_error(): raise pebble.APIError({'body': ''}, 404, 'status', 'api error!') - self.pebble.get_system_info = raise_error - with self.assertLogs('ops') as cm: - assert not self.container.can_connect() - assert len(cm.output) == 1 - assert re.search(r'WARNING:ops.model:.*: api error!', cm.output[0]) + container.pebble.get_system_info = raise_error + with caplog.at_level(level='WARNING', logger='ops'): + assert not container.can_connect() + assert len(caplog.records) == 1 + assert re.search(r'api error!', caplog.text) @patch('model.JujuVersion.from_environ', new=lambda: ops.model.JujuVersion('3.1.6')) - def test_exec(self): - self.pebble.responses.append('fake_exec_process') + def test_exec(self, container: ops.Container): + container.pebble.responses.append('fake_exec_process') # type: ignore stdout = io.StringIO('STDOUT') stderr = io.StringIO('STDERR') - p = self.container.exec( + p = container.exec( ['echo', 'foo'], service_context='srv1', environment={'K1': 'V1', 'K2': 'V2'}, @@ -1900,7 +1952,7 @@ def test_exec(self): encoding="encoding", combine_stderr=True, ) - assert self.pebble.requests == [ + assert container.pebble.requests == [ # type: ignore ('exec', ['echo', 'foo'], dict( service_context='srv1', environment={'K1': 'V1', 'K2': 'V2'}, @@ -1920,28 +1972,28 @@ def test_exec(self): assert p == 'fake_exec_process' @patch('model.JujuVersion.from_environ', new=lambda: ops.model.JujuVersion('3.1.5')) - def test_exec_service_context_not_supported(self): + def test_exec_service_context_not_supported(self, container: ops.Container): with pytest.raises(RuntimeError): - self.container.exec(['foo'], service_context='srv1') + container.exec(['foo'], service_context='srv1') - def test_send_signal(self): + def test_send_signal(self, container: ops.Container): with pytest.raises(TypeError): - self.container.send_signal('SIGHUP') + container.send_signal('SIGHUP') - self.container.send_signal('SIGHUP', 's1') - assert self.pebble.requests == [ + container.send_signal('SIGHUP', 's1') + assert container.pebble.requests == [ # type: ignore ('send_signal', 'SIGHUP', ('s1',)), ] - self.pebble.requests = [] + container.pebble.requests = [] # type: ignore - self.container.send_signal('SIGHUP', 's1', 's2') - assert self.pebble.requests == [ + container.send_signal('SIGHUP', 's1', 's2') + assert container.pebble.requests == [ # type: ignore ('send_signal', 'SIGHUP', ('s1', 's2')), ] - self.pebble.requests = [] + container.pebble.requests = [] # type: ignore - def test_get_notice(self): - self.pebble.responses.append(pebble.Notice.from_dict({ + def test_get_notice(self, container: ops.Container): + container.pebble.responses.append(pebble.Notice.from_dict({ # type: ignore 'id': '123', 'user-id': 1000, 'type': 'custom', @@ -1952,24 +2004,24 @@ def test_get_notice(self): 'occurrences': 8, })) - notice = self.container.get_notice('123') + notice = container.get_notice('123') assert notice.id == '123' assert notice.type == pebble.NoticeType.CUSTOM assert notice.key == 'example.com/a' - assert self.pebble.requests == [ + assert container.pebble.requests == [ # type: ignore ('get_notice', '123'), ] - def test_get_notice_not_found(self): + def test_get_notice_not_found(self, container: ops.Container): def raise_error(id: str): raise pebble.APIError({'body': ''}, 404, 'status', 'api error!') - self.pebble.get_notice = raise_error + container.pebble.get_notice = raise_error with pytest.raises(ops.ModelError): - self.container.get_notice('123') + container.get_notice('123') - def test_get_notices(self): - self.pebble.responses.append([ + def test_get_notices(self, container: ops.Container): + container.pebble.responses.append([ # type: ignore pebble.Notice.from_dict({ 'id': '124', 'user-id': 1000, @@ -1982,7 +2034,7 @@ def test_get_notices(self): }), ]) - notices = self.container.get_notices( + notices = container.get_notices( user_id=1000, users=pebble.NoticesUsers.ALL, types=[pebble.NoticeType.CUSTOM], @@ -1993,7 +2045,7 @@ def test_get_notices(self): assert notices[0].type == pebble.NoticeType.CUSTOM assert notices[0].key == 'example.com/b' - assert self.pebble.requests == [('get_notices', dict( + assert container.pebble.requests == [('get_notices', dict( # type: ignore user_id=1000, users=pebble.NoticesUsers.ALL, types=[pebble.NoticeType.CUSTOM], @@ -2109,9 +2161,9 @@ def get_notices(self, **kwargs: typing.Any): return self.responses.pop(0) -class TestModelBindings(unittest.TestCase): - - def setUp(self): +class TestModelBindings: + @pytest.fixture + def model(self, fake_script: FakeScript): meta = ops.CharmMeta() meta.relations = { 'db0': ops.RelationMeta( @@ -2121,12 +2173,12 @@ def setUp(self): 'db2': ops.RelationMeta( ops.RelationRole.peer, 'db2', {'interface': 'db2', 'scope': 'global'}), } - self.backend = _ModelBackend('myapp/0') - self.model = ops.Model(meta, self.backend) + backend = _ModelBackend('myapp/0') + model = ops.Model(meta, backend) - fake_script(self, 'relation-ids', - """([ "$1" = db0 ] && echo '["db0:4"]') || echo '[]'""") - fake_script(self, 'relation-list', """[ "$2" = 4 ] && echo '["remoteapp1/0"]' || exit 2""") + fake_script.write('relation-ids', + """([ "$1" = db0 ] && echo '["db0:4"]') || echo '[]'""") + fake_script.write('relation-list', """[ "$2" = 4 ] && echo '["remoteapp1/0"]' || exit 2""") self.network_get_out = '''{ "bind-addresses": [ { @@ -2180,16 +2232,22 @@ def setUp(self): "2001:db8::3" ] }''' + return model - def ensure_relation(self, name: str = 'db1', relation_id: typing.Optional[int] = None): - """Wrapper around self.model.get_relation that enforces that None is not returned.""" - rel_db1 = self.model.get_relation(name, relation_id) + def ensure_relation( + self, + model: ops.Model, + name: str = 'db1', + relation_id: typing.Optional[int] = None + ): + """Wrapper around model.get_relation that enforces that None is not returned.""" + rel_db1 = model.get_relation(name, relation_id) assert rel_db1 is not None, rel_db1 # Type checkers don't understand `assertIsNotNone` return rel_db1 - def ensure_binding(self, binding_key: typing.Union[str, ops.Relation]): + def ensure_binding(self, model: ops.Model, binding_key: typing.Union[str, ops.Relation]): """Wrapper around self.model.get_binding that enforces that None is not returned.""" - binding = self.model.get_binding(binding_key) + binding = model.get_binding(binding_key) assert binding is not None assert binding is not None # Type checkers understand this, but not the previous line. return binding @@ -2199,40 +2257,43 @@ def _check_binding_data(self, binding_name: str, binding: ops.Binding): assert binding.network.bind_address == ipaddress.ip_address('192.0.2.2') assert binding.network.ingress_address == ipaddress.ip_address('192.0.2.2') # /32 and /128 CIDRs are valid one-address networks for IPv{4,6}Network types respectively. - assert binding.network.egress_subnets == [ipaddress.ip_network('192.0.2.2/32'), - ipaddress.ip_network('192.0.3.0/24'), - ipaddress.ip_network('dead:beef::/64'), - ipaddress.ip_network('2001:db8::3/128')] + assert binding.network.egress_subnets == [ + ipaddress.ip_network('192.0.2.2/32'), + ipaddress.ip_network('192.0.3.0/24'), + ipaddress.ip_network('dead:beef::/64'), + ipaddress.ip_network('2001:db8::3/128'), + ] for (i, (name, address, subnet)) in enumerate([ - ('lo', '192.0.2.2', '192.0.2.0/24'), - ('lo', 'dead:beef::1', 'dead:beef::/64'), - ('tun', '192.0.3.3', '192.0.3.3/32'), - ('tun', '2001:db8::3', '2001:db8::3/128'), - ('tun', 'fe80::1:1', 'fe80::/64')]): + ('lo', '192.0.2.2', '192.0.2.0/24'), + ('lo', 'dead:beef::1', 'dead:beef::/64'), + ('tun', '192.0.3.3', '192.0.3.3/32'), + ('tun', '2001:db8::3', '2001:db8::3/128'), + ('tun', 'fe80::1:1', 'fe80::/64'), + ]): assert binding.network.interfaces[i].name == name assert binding.network.interfaces[i].address == ipaddress.ip_address(address) assert binding.network.interfaces[i].subnet == ipaddress.ip_network(subnet) for (i, (name, address, subnet)) in enumerate([ - ('lo', '192.0.2.2', '192.0.2.0/24'), - ('lo', 'dead:beef::1', 'dead:beef::/64'), - ('tun', '192.0.3.3', '192.0.3.3/32'), - ('tun', '2001:db8::3', '2001:db8::3/128'), - ('tun', 'fe80::1:1', 'fe80::/64')]): + ('lo', '192.0.2.2', '192.0.2.0/24'), + ('lo', 'dead:beef::1', 'dead:beef::/64'), + ('tun', '192.0.3.3', '192.0.3.3/32'), + ('tun', '2001:db8::3', '2001:db8::3/128'), + ('tun', 'fe80::1:1', 'fe80::/64'), + ]): assert binding.network.interfaces[i].name == name assert binding.network.interfaces[i].address == ipaddress.ip_address(address) assert binding.network.interfaces[i].subnet == ipaddress.ip_network(subnet) - def test_invalid_keys(self): + def test_invalid_keys(self, model: ops.Model): # Basic validation for passing invalid keys. for name in (object, 0): with pytest.raises(ops.ModelError): - self.model.get_binding(name) # type: ignore + model.get_binding(name) # type: ignore - def test_dead_relations(self): - fake_script( - self, + def test_dead_relations(self, fake_script: FakeScript, model: ops.Model): + fake_script.write( 'network-get', f''' if [ "$1" = db0 ] && [ "$2" = --format=json ]; then @@ -2243,14 +2304,14 @@ def test_dead_relations(self): fi ''') # Validate the behavior for dead relations. - binding = ops.Binding('db0', 42, self.model._backend) + binding = ops.Binding('db0', 42, model._backend) assert binding.network.bind_address == ipaddress.ip_address('192.0.2.2') - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['network-get', 'db0', '-r', '42', '--format=json'], ['network-get', 'db0', '--format=json'], ] - def test_broken_relations(self): + def test_broken_relations(self, fake_script: FakeScript): meta = ops.CharmMeta() meta.relations = { 'db0': ops.RelationMeta( @@ -2262,8 +2323,8 @@ def test_broken_relations(self): } backend = _ModelBackend('myapp/0') model = ops.Model(meta, backend, broken_relation_id=8) - fake_script(self, 'relation-ids', - """if [ "$1" = "db0" ]; then + fake_script.write('relation-ids', + """if [ "$1" = "db0" ]; then echo '["db0:4"]' elif [ "$1" = "db1" ]; then echo '["db1:8"]' @@ -2273,24 +2334,24 @@ def test_broken_relations(self): echo '[]' fi """) - fake_script(self, 'relation-list', """echo '""'""") + fake_script.write('relation-list', """echo '""'""") assert model.relations['db0'] assert not model.relations['db1'] assert model.relations['db2'] - def test_binding_by_relation_name(self): - fake_script(self, 'network-get', - f'''[ "$1" = db0 ] && echo '{self.network_get_out}' || exit 1''') + def test_binding_by_relation_name(self, fake_script: FakeScript, model: ops.Model): + fake_script.write('network-get', + f'''[ "$1" = db0 ] && echo '{self.network_get_out}' || exit 1''') binding_name = 'db0' expected_calls = [['network-get', 'db0', '--format=json']] - binding = self.ensure_binding(binding_name) + binding = self.ensure_binding(model, binding_name) self._check_binding_data(binding_name, binding) - assert fake_script_calls(self, clear=True) == expected_calls + assert fake_script.calls(clear=True) == expected_calls - def test_binding_by_relation(self): - fake_script(self, 'network-get', - f'''[ "$1" = db0 ] && echo '{self.network_get_out}' || exit 1''') + def test_binding_by_relation(self, fake_script: FakeScript, model: ops.Model): + fake_script.write('network-get', + f'''[ "$1" = db0 ] && echo '{self.network_get_out}' || exit 1''') binding_name = 'db0' expected_calls = [ ['relation-ids', 'db0', '--format=json'], @@ -2298,11 +2359,11 @@ def test_binding_by_relation(self): ['relation-list', '-r', '4', '--format=json'], ['network-get', 'db0', '-r', '4', '--format=json'], ] - binding = self.ensure_binding(self.ensure_relation(binding_name)) + binding = self.ensure_binding(model, self.ensure_relation(model, binding_name)) self._check_binding_data(binding_name, binding) - assert fake_script_calls(self, clear=True) == expected_calls + assert fake_script.calls(clear=True) == expected_calls - def test_binding_no_iface_name(self): + def test_binding_no_iface_name(self, fake_script: FakeScript, model: ops.Model): network_get_out_obj = { 'bind-addresses': [ { @@ -2325,80 +2386,80 @@ def test_binding_no_iface_name(self): ] } network_get_out = json.dumps(network_get_out_obj) - fake_script(self, 'network-get', - f'''[ "$1" = db0 ] && echo '{network_get_out}' || exit 1''') + fake_script.write('network-get', + f'''[ "$1" = db0 ] && echo '{network_get_out}' || exit 1''') binding_name = 'db0' expected_calls = [['network-get', 'db0', '--format=json']] - binding = self.ensure_binding(binding_name) + binding = self.ensure_binding(model, binding_name) assert binding.name == 'db0' assert binding.network.bind_address == ipaddress.ip_address('10.1.89.35') assert binding.network.ingress_address == ipaddress.ip_address('10.152.183.158') - assert fake_script_calls(self, clear=True) == expected_calls + assert fake_script.calls(clear=True) == expected_calls - def test_missing_bind_addresses(self): + def test_missing_bind_addresses(self, fake_script: FakeScript, model: ops.Model): network_data = json.dumps({}) - fake_script(self, 'network-get', - f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') + fake_script.write('network-get', + f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') binding_name = 'db0' - binding = self.ensure_binding(self.ensure_relation(binding_name)) + binding = self.ensure_binding(model, self.ensure_relation(model, binding_name)) assert binding.network.interfaces == [] - def test_empty_bind_addresses(self): + def test_empty_bind_addresses(self, fake_script: FakeScript, model: ops.Model): network_data = json.dumps({'bind-addresses': [{}]}) - fake_script(self, 'network-get', - f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') + fake_script.write('network-get', + f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') binding_name = 'db0' - binding = self.ensure_binding(self.ensure_relation(binding_name)) + binding = self.ensure_binding(model, self.ensure_relation(model, binding_name)) assert binding.network.interfaces == [] - def test_no_bind_addresses(self): + def test_no_bind_addresses(self, fake_script: FakeScript, model: ops.Model): network_data = json.dumps({'bind-addresses': [{'addresses': None}]}) - fake_script(self, 'network-get', - f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') + fake_script.write('network-get', + f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') binding_name = 'db0' - binding = self.ensure_binding(self.ensure_relation(binding_name)) + binding = self.ensure_binding(model, self.ensure_relation(model, binding_name)) assert binding.network.interfaces == [] - def test_empty_interface_info(self): + def test_empty_interface_info(self, fake_script: FakeScript, model: ops.Model): network_data = json.dumps({ 'bind-addresses': [{ 'interface-name': 'eth0', 'addresses': [{}], }], }) - fake_script(self, 'network-get', - f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') + fake_script.write('network-get', + f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') binding_name = 'db0' - binding = self.ensure_binding(self.ensure_relation(binding_name)) + binding = self.ensure_binding(model, self.ensure_relation(model, binding_name)) assert len(binding.network.interfaces) == 1 interface = binding.network.interfaces[0] assert interface.address is None assert interface.subnet is None - def test_missing_ingress_addresses(self): + def test_missing_ingress_addresses(self, fake_script: FakeScript, model: ops.Model): network_data = json.dumps({ 'bind-addresses': [], }) - fake_script(self, 'network-get', - f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') + fake_script.write('network-get', + f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') binding_name = 'db0' - binding = self.ensure_binding(self.ensure_relation(binding_name)) + binding = self.ensure_binding(model, self.ensure_relation(model, binding_name)) assert binding.network.ingress_addresses == [] assert binding.network.ingress_address is None - def test_missing_egress_subnets(self): + def test_missing_egress_subnets(self, fake_script: FakeScript, model: ops.Model): network_data = json.dumps({ 'bind-addresses': [], 'ingress-addresses': [], }) - fake_script(self, 'network-get', - f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') + fake_script.write('network-get', + f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') binding_name = 'db0' - binding = self.ensure_binding(self.ensure_relation(binding_name)) + binding = self.ensure_binding(model, self.ensure_relation(model, binding_name)) assert binding.network.egress_subnets == [] - def test_unresolved_ingress_addresses(self): + def test_unresolved_ingress_addresses(self, fake_script: FakeScript, model: ops.Model): # sometimes juju fails to resolve an url to an IP, in which case # ingress-addresses will be the 'raw' url instead of an IP. network_data = json.dumps({ @@ -2406,10 +2467,10 @@ def test_unresolved_ingress_addresses(self): 'foo.bar.baz.com' ], }) - fake_script(self, 'network-get', - f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') + fake_script.write('network-get', + f'''[ "$1" = db0 ] && echo '{network_data}' || exit 1''') binding_name = 'db0' - binding = self.ensure_binding(self.ensure_relation(binding_name)) + binding = self.ensure_binding(model, self.ensure_relation(model, binding_name)) assert binding.network.ingress_addresses == ['foo.bar.baz.com'] @@ -2423,14 +2484,11 @@ def test_unresolved_ingress_addresses(self): ] -class TestModelBackend(unittest.TestCase): - - def setUp(self): - self._backend = None - +class TestModelBackend: @property def backend(self): - if self._backend is None: + backend_instance = getattr(self, '_backend', None) + if backend_instance is None: self._backend = _ModelBackend('myapp/0') return self._backend @@ -2450,16 +2508,16 @@ def test_relation_get_set_is_app_arg(self): with pytest.raises(TypeError): self.backend.relation_get(1, 'fooentity', is_app=is_app_v) # type: ignore - def test_is_leader_refresh(self): + def test_is_leader_refresh(self, fake_script: FakeScript): meta = ops.CharmMeta.from_yaml(''' name: myapp ''') model = ops.Model(meta, self.backend) - fake_script(self, 'is-leader', 'echo false') + fake_script.write('is-leader', 'echo false') assert not model.unit.is_leader() # Change the leadership status - fake_script(self, 'is-leader', 'echo true') + fake_script.write('is-leader', 'echo true') # If you don't force it, we don't check, so we won't see the change assert not model.unit.is_leader() # If we force a recheck, then we notice @@ -2467,32 +2525,31 @@ def test_is_leader_refresh(self): assert model.unit.is_leader() # Force a recheck without changing the leadership status. - fake_script(self, 'is-leader', 'echo true') + fake_script.write('is-leader', 'echo true') self.backend._leader_check_time = None assert model.unit.is_leader() - def test_relation_tool_errors(self): - self.addCleanup(os.environ.pop, 'JUJU_VERSION', None) - os.environ['JUJU_VERSION'] = '2.8.0' + def test_relation_tool_errors(self, fake_script: FakeScript, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv('JUJU_VERSION', '2.8.0') err_msg = 'ERROR invalid value "$2" for option -r: relation not found' test_cases = [( - lambda: fake_script(self, 'relation-list', 'echo fooerror >&2 ; exit 1'), + lambda: fake_script.write('relation-list', 'echo fooerror >&2 ; exit 1'), lambda: self.backend.relation_list(3), ops.ModelError, [['relation-list', '-r', '3', '--format=json']], ), ( - lambda: fake_script(self, 'relation-list', f'echo {err_msg} >&2 ; exit 2'), + lambda: fake_script.write('relation-list', f'echo {err_msg} >&2 ; exit 2'), lambda: self.backend.relation_list(3), ops.RelationNotFoundError, [['relation-list', '-r', '3', '--format=json']], ), ( - lambda: fake_script(self, 'relation-set', 'echo fooerror >&2 ; exit 1'), + lambda: fake_script.write('relation-set', 'echo fooerror >&2 ; exit 1'), lambda: self.backend.relation_set(3, 'foo', 'bar', is_app=False), ops.ModelError, [['relation-set', '-r', '3', '--file', '-']], ), ( - lambda: fake_script(self, 'relation-set', f'echo {err_msg} >&2 ; exit 2'), + lambda: fake_script.write('relation-set', f'echo {err_msg} >&2 ; exit 2'), lambda: self.backend.relation_set(3, 'foo', 'bar', is_app=False), ops.RelationNotFoundError, [['relation-set', '-r', '3', '--file', '-']], @@ -2502,12 +2559,12 @@ def test_relation_tool_errors(self): ops.RelationNotFoundError, [['relation-set', '-r', '3', '--app', '--file', '-']], ), ( - lambda: fake_script(self, 'relation-get', 'echo fooerror >&2 ; exit 1'), + lambda: fake_script.write('relation-get', 'echo fooerror >&2 ; exit 1'), lambda: self.backend.relation_get(3, 'remote/0', is_app=False), ops.ModelError, [['relation-get', '-r', '3', '-', 'remote/0', '--format=json']], ), ( - lambda: fake_script(self, 'relation-get', f'echo {err_msg} >&2 ; exit 2'), + lambda: fake_script.write('relation-get', f'echo {err_msg} >&2 ; exit 2'), lambda: self.backend.relation_get(3, 'remote/0', is_app=False), ops.RelationNotFoundError, [['relation-get', '-r', '3', '-', 'remote/0', '--format=json']], @@ -2518,65 +2575,68 @@ def test_relation_tool_errors(self): [['relation-get', '-r', '3', '-', 'remote/0', '--app', '--format=json']], )] - for i, (do_fake, run, exception, calls) in enumerate(test_cases): - with self.subTest(i): - do_fake() - with pytest.raises(exception): - run() - assert fake_script_calls(self, clear=True) == calls - - def test_relation_get_juju_version_quirks(self): - self.addCleanup(os.environ.pop, 'JUJU_VERSION', None) + for _, (do_fake, run, exception, calls) in enumerate(test_cases): + do_fake() + with pytest.raises(exception): + run() + assert fake_script.calls(clear=True) == calls - fake_script(self, 'relation-get', '''echo '{"foo": "bar"}' ''') + @pytest.mark.parametrize("version", ['2.8.0', '2.7.0']) + def test_relation_get_juju_version_quirks( + self, + fake_script: FakeScript, + monkeypatch: pytest.MonkeyPatch, + version: str, + ): + fake_script.write('relation-get', '''echo '{"foo": "bar"}' ''') # on 2.7.0+, things proceed as expected - for v in ['2.8.0', '2.7.0']: - with self.subTest(v): - os.environ['JUJU_VERSION'] = v - rel_data = self.backend.relation_get(1, 'foo/0', is_app=True) - assert rel_data == {"foo": "bar"} - calls = [' '.join(i) for i in fake_script_calls(self, clear=True)] - assert calls == ['relation-get -r 1 - foo/0 --app --format=json'] + monkeypatch.setenv('JUJU_VERSION', version) + rel_data = self.backend.relation_get(1, 'foo/0', is_app=True) + assert rel_data == {"foo": "bar"} + calls = [' '.join(i) for i in fake_script.calls(clear=True)] + assert calls == ['relation-get -r 1 - foo/0 --app --format=json'] # before 2.7.0, it just fails (no --app support) - os.environ['JUJU_VERSION'] = '2.6.9' + monkeypatch.setenv('JUJU_VERSION', '2.6.9') with pytest.raises(RuntimeError, match='not supported on Juju version 2.6.9'): self.backend.relation_get(1, 'foo/0', is_app=True) - assert fake_script_calls(self) == [] - - def test_relation_set_juju_version_quirks(self): - self.addCleanup(os.environ.pop, 'JUJU_VERSION', None) - + assert fake_script.calls() == [] + + @pytest.mark.parametrize("version", ['2.8.0', '2.7.0']) + def test_relation_set_juju_version_quirks( + self, + fake_script: FakeScript, + monkeypatch: pytest.MonkeyPatch, + version: str, + ): # on 2.7.0+, things proceed as expected - for v in ['2.8.0', '2.7.0']: - with self.subTest(v): - t = tempfile.NamedTemporaryFile() - try: - fake_script(self, 'relation-set', dedent(""" - cat >> {} - """).format(pathlib.Path(t.name).as_posix())) - os.environ['JUJU_VERSION'] = v - self.backend.relation_set(1, 'foo', 'bar', is_app=True) - calls = [' '.join(i) for i in fake_script_calls(self, clear=True)] - assert calls == ['relation-set -r 1 --app --file -'] - t.seek(0) - content = t.read() - finally: - t.close() - decoded = content.decode('utf-8').replace('\r\n', '\n') - assert decoded == 'foo: bar\n' + t = tempfile.NamedTemporaryFile() + try: + fake_script.write('relation-set', dedent(""" + cat >> {} + """).format(pathlib.Path(t.name).as_posix())) + monkeypatch.setenv('JUJU_VERSION', version) + self.backend.relation_set(1, 'foo', 'bar', is_app=True) + calls = [' '.join(i) for i in fake_script.calls(clear=True)] + assert calls == ['relation-set -r 1 --app --file -'] + t.seek(0) + content = t.read() + finally: + t.close() + decoded = content.decode('utf-8').replace('\r\n', '\n') + assert decoded == 'foo: bar\n' # before 2.7.0, it just fails always (no --app support) - os.environ['JUJU_VERSION'] = '2.6.9' + monkeypatch.setenv('JUJU_VERSION', '2.6.9') with pytest.raises(RuntimeError, match='not supported on Juju version 2.6.9'): self.backend.relation_set(1, 'foo', 'bar', is_app=True) - assert fake_script_calls(self) == [] + assert fake_script.calls() == [] - def test_status_get(self): + def test_status_get(self, fake_script: FakeScript): # taken from actual Juju output content = '{"message": "", "status": "unknown", "status-data": {}}' - fake_script(self, 'status-get', f"echo '{content}'") + fake_script.write('status-get', f"echo '{content}'") s = self.backend.status_get(is_app=False) assert s['status'] == "unknown" assert s['message'] == "" @@ -2597,18 +2657,18 @@ def test_status_get(self): } } """) - fake_script(self, 'status-get', f"echo '{content}'") + fake_script.write('status-get', f"echo '{content}'") s = self.backend.status_get(is_app=True) assert s['status'] == "maintenance" assert s['message'] == "installing" - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['status-get', '--include-data', '--application=False', '--format=json'], ['status-get', '--include-data', '--application=True', '--format=json'], ] - def test_status_is_app_forced_kwargs(self): - fake_script(self, 'status-get', 'exit 1') - fake_script(self, 'status-set', 'exit 1') + def test_status_is_app_forced_kwargs(self, fake_script: FakeScript): + fake_script.write('status-get', 'exit 1') + fake_script.write('status-set', 'exit 1') test_cases = ( lambda: self.backend.status_get(False), # type: ignore @@ -2621,21 +2681,21 @@ def test_status_is_app_forced_kwargs(self): with pytest.raises(TypeError): case() - def test_local_set_invalid_status(self): + def test_local_set_invalid_status(self, fake_script: FakeScript): # juju returns exit code 1 if you ask to set status to 'unknown' or 'error' meta = ops.CharmMeta.from_yaml(''' name: myapp ''') model = ops.Model(meta, self.backend) - fake_script(self, 'status-set', 'exit 1') - fake_script(self, 'is-leader', 'echo true') + fake_script.write('status-set', 'exit 1') + fake_script.write('is-leader', 'echo true') with pytest.raises(ops.ModelError): model.unit.status = ops.UnknownStatus() with pytest.raises(ops.ModelError): model.unit.status = ops.ErrorStatus() - assert fake_script_calls(self, True) == [ + assert fake_script.calls(True) == [ ['status-set', '--application=False', 'unknown', ''], ['status-set', '--application=False', 'error', ''], ] @@ -2646,82 +2706,83 @@ def test_local_set_invalid_status(self): model.app.status = ops.ErrorStatus() # A leadership check is needed for application status. - assert fake_script_calls(self, True) == [ + assert fake_script.calls(True) == [ ['is-leader', '--format=json'], ['status-set', '--application=True', 'unknown', ''], ['status-set', '--application=True', 'error', ''], ] - def test_local_get_status(self): - for name, expected_cls in ( - ("active", ops.ActiveStatus), - ("waiting", ops.WaitingStatus), - ("blocked", ops.BlockedStatus), - ("maintenance", ops.MaintenanceStatus), - ("error", ops.ErrorStatus), - ): - meta = ops.CharmMeta.from_yaml(''' - name: myapp - ''') - model = ops.Model(meta, self.backend) + @pytest.mark.parametrize("name", ["active", "waiting", "blocked", "maintenance", "error"]) + def test_local_get_status(self, fake_script: FakeScript, name: str): + expected_cls = { + "active": ops.ActiveStatus, + "waiting": ops.WaitingStatus, + "blocked": ops.BlockedStatus, + "maintenance": ops.MaintenanceStatus, + "error": ops.ErrorStatus, + } - with self.subTest(name): - content = json.dumps({ - "message": "foo", - "status": name, - "status-data": {}, - }) - fake_script(self, 'status-get', f"echo '{content}'") - - assert isinstance(model.unit.status, expected_cls) - assert model.unit.status.name == name - assert model.unit.status.message == "foo" - - content = json.dumps({ - "application-status": { - "message": "bar", - "status": name, - "status-data": {}, - } - }) - fake_script(self, 'status-get', f"echo '{content}'") - fake_script(self, 'is-leader', 'echo true') + meta = ops.CharmMeta.from_yaml(''' + name: myapp + ''') + model = ops.Model(meta, self.backend) + + content = json.dumps({ + "message": "foo", + "status": name, + "status-data": {}, + }) + fake_script.write('status-get', f"echo '{content}'") + + assert isinstance(model.unit.status, expected_cls[name]) + assert model.unit.status.name == name + assert model.unit.status.message == "foo" + + content = json.dumps({ + "application-status": { + "message": "bar", + "status": name, + "status-data": {}, + } + }) + fake_script.write('status-get', f"echo '{content}'") + fake_script.write('is-leader', 'echo true') - assert isinstance(model.app.status, expected_cls) - assert model.app.status.name == name - assert model.app.status.message == "bar" + assert isinstance(model.app.status, expected_cls[name]) + assert model.app.status.name == name + assert model.app.status.message == "bar" def test_status_set_is_app_not_bool_raises(self): for is_app_v in [None, 1, 2.0, 'a', b'beef', object]: with pytest.raises(TypeError): self.backend.status_set(ops.ActiveStatus, is_app=is_app_v) # type: ignore - def test_storage_tool_errors(self): - fake_script(self, 'storage-list', 'echo fooerror >&2 ; exit 1') + def test_storage_tool_errors(self, fake_script: FakeScript): + fake_script.write('storage-list', 'echo fooerror >&2 ; exit 1') with pytest.raises(ops.ModelError): self.backend.storage_list('foobar') - assert fake_script_calls(self, clear=True) == \ + assert fake_script.calls(clear=True) == \ [['storage-list', 'foobar', '--format=json']] - fake_script(self, 'storage-get', 'echo fooerror >&2 ; exit 1') + fake_script.write('storage-get', 'echo fooerror >&2 ; exit 1') with pytest.raises(ops.ModelError): self.backend.storage_get('foobar', 'someattr') - assert fake_script_calls(self, clear=True) == \ + assert fake_script.calls(clear=True) == \ [['storage-get', '-s', 'foobar', 'someattr', '--format=json']] - fake_script(self, 'storage-add', 'echo fooerror >&2 ; exit 1') + fake_script.write('storage-add', 'echo fooerror >&2 ; exit 1') with pytest.raises(ops.ModelError): self.backend.storage_add('foobar', count=2) - assert fake_script_calls(self, clear=True) == \ + assert fake_script.calls(clear=True) == \ [['storage-add', 'foobar=2']] - fake_script(self, 'storage-add', 'echo fooerror >&2 ; exit 1') + fake_script.write('storage-add', 'echo fooerror >&2 ; exit 1') with pytest.raises(TypeError): self.backend.storage_add('foobar', count=object), # type: ignore - assert fake_script_calls(self, clear=True) == [] - fake_script(self, 'storage-add', 'echo fooerror >&2 ; exit 1') + assert fake_script.calls(clear=True) == [] + fake_script.write('storage-add', 'echo fooerror >&2 ; exit 1') with pytest.raises(TypeError): self.backend.storage_add('foobar', count=True) - assert fake_script_calls(self, clear=True) == [] + assert fake_script.calls(clear=True) == [] - def test_network_get(self): + def test_network_get(self, fake_script: FakeScript): network_get_out = '''{ "bind-addresses": [ { @@ -2743,30 +2804,30 @@ def test_network_get(self): "192.0.2.2" ] }''' - fake_script(self, 'network-get', - f'''[ "$1" = deadbeef ] && echo '{network_get_out}' || exit 1''') + fake_script.write('network-get', + f'''[ "$1" = deadbeef ] && echo '{network_get_out}' || exit 1''') network_info = self.backend.network_get('deadbeef') assert network_info == json.loads(network_get_out) - assert fake_script_calls(self, clear=True) == \ + assert fake_script.calls(clear=True) == \ [['network-get', 'deadbeef', '--format=json']] network_info = self.backend.network_get('deadbeef', 1) assert network_info == json.loads(network_get_out) - assert fake_script_calls(self, clear=True) == \ + assert fake_script.calls(clear=True) == \ [['network-get', 'deadbeef', '-r', '1', '--format=json']] - def test_network_get_errors(self): + def test_network_get_errors(self, fake_script: FakeScript): err_no_endpoint = 'ERROR no network config found for binding "$2"' err_no_rel = 'ERROR invalid value "$3" for option -r: relation not found' test_cases = [( - lambda: fake_script(self, 'network-get', - f'echo {err_no_endpoint} >&2 ; exit 1'), + lambda: fake_script.write('network-get', + f'echo {err_no_endpoint} >&2 ; exit 1'), lambda: self.backend.network_get("deadbeef"), ops.ModelError, [['network-get', 'deadbeef', '--format=json']], ), ( - lambda: fake_script(self, 'network-get', f'echo {err_no_rel} >&2 ; exit 2'), + lambda: fake_script.write('network-get', f'echo {err_no_rel} >&2 ; exit 2'), lambda: self.backend.network_get("deadbeef", 3), ops.RelationNotFoundError, [['network-get', 'deadbeef', '-r', '3', '--format=json']], @@ -2775,46 +2836,46 @@ def test_network_get_errors(self): do_fake() with pytest.raises(exception): run() - assert fake_script_calls(self, clear=True) == calls + assert fake_script.calls(clear=True) == calls - def test_action_get_error(self): - fake_script(self, 'action-get', '') - fake_script(self, 'action-get', 'echo fooerror >&2 ; exit 1') + def test_action_get_error(self, fake_script: FakeScript): + fake_script.write('action-get', '') + fake_script.write('action-get', 'echo fooerror >&2 ; exit 1') with pytest.raises(ops.ModelError): self.backend.action_get() calls = [['action-get', '--format=json']] - assert fake_script_calls(self, clear=True) == calls + assert fake_script.calls(clear=True) == calls - def test_action_set_error(self): - fake_script(self, 'action-get', '') - fake_script(self, 'action-set', 'echo fooerror >&2 ; exit 1') + def test_action_set_error(self, fake_script: FakeScript): + fake_script.write('action-get', '') + fake_script.write('action-set', 'echo fooerror >&2 ; exit 1') with pytest.raises(ops.ModelError): self.backend.action_set(OrderedDict([('foo', 'bar'), ('dead', 'beef cafe')])) - self.assertCountEqual( - ["action-set", "dead=beef cafe", "foo=bar"], fake_script_calls(self, clear=True)[0]) + assert sorted(["action-set", "dead=beef cafe", "foo=bar"] + ) == sorted(fake_script.calls(clear=True)[0]) - def test_action_log_error(self): - fake_script(self, 'action-get', '') - fake_script(self, 'action-log', 'echo fooerror >&2 ; exit 1') + def test_action_log_error(self, fake_script: FakeScript): + fake_script.write('action-get', '') + fake_script.write('action-log', 'echo fooerror >&2 ; exit 1') with pytest.raises(ops.ModelError): self.backend.action_log('log-message') calls = [["action-log", "log-message"]] - assert fake_script_calls(self, clear=True) == calls + assert fake_script.calls(clear=True) == calls - def test_action_get(self): - fake_script(self, 'action-get', """echo '{"foo-name": "bar", "silent": false}'""") + def test_action_get(self, fake_script: FakeScript): + fake_script.write('action-get', """echo '{"foo-name": "bar", "silent": false}'""") params = self.backend.action_get() assert params['foo-name'] == 'bar' assert not params['silent'] - assert fake_script_calls(self) == [['action-get', '--format=json']] + assert fake_script.calls() == [['action-get', '--format=json']] - def test_action_set(self): - fake_script(self, 'action-get', 'exit 1') - fake_script(self, 'action-set', 'exit 0') + def test_action_set(self, fake_script: FakeScript): + fake_script.write('action-get', 'exit 1') + fake_script.write('action-set', 'exit 0') self.backend.action_set({'x': 'dead beef', 'y': 1}) - self.assertCountEqual(['action-set', 'x=dead beef', 'y=1'], fake_script_calls(self)[0]) + assert sorted(['action-set', 'x=dead beef', 'y=1']), sorted(fake_script.calls()[0]) - def test_action_set_key_validation(self): + def test_action_set_key_validation(self, fake_script: FakeScript): with pytest.raises(ValueError): self.backend.action_set({'X': 'dead beef', 'y': 1}) with pytest.raises(ValueError): @@ -2824,76 +2885,76 @@ def test_action_set_key_validation(self): with pytest.raises(ValueError): self.backend.action_set({'some_key': 'dead beef', 'y': 1}) - def test_action_set_nested(self): - fake_script(self, 'action-get', 'exit 1') - fake_script(self, 'action-set', 'exit 0') + def test_action_set_nested(self, fake_script: FakeScript): + fake_script.write('action-get', 'exit 1') + fake_script.write('action-set', 'exit 0') self.backend.action_set({'a': {'b': 1, 'c': 2}, 'd': 3}) - self.assertCountEqual(['action-set', 'a.b=1', 'a.c=2', 'd=3'], fake_script_calls(self)[0]) + assert sorted(['action-set', 'a.b=1', 'a.c=2', 'd=3']) == sorted(fake_script.calls()[0]) - def test_action_set_more_nested(self): - fake_script(self, 'action-get', 'exit 1') - fake_script(self, 'action-set', 'exit 0') + def test_action_set_more_nested(self, fake_script: FakeScript): + fake_script.write('action-get', 'exit 1') + fake_script.write('action-set', 'exit 0') self.backend.action_set({'a': {'b': 1, 'c': 2, 'd': {'e': 3}}, 'f': 4}) - self.assertCountEqual( - ['action-set', 'a.b=1', 'a.c=2', 'a.d.e=3', 'f=4'], fake_script_calls(self)[0]) + assert sorted(['action-set', 'a.b=1', 'a.c=2', 'a.d.e=3', 'f=4'] + ) == sorted(fake_script.calls()[0]) - def test_action_set_dotted_dict(self): - fake_script(self, 'action-get', 'exit 1') - fake_script(self, 'action-set', 'exit 0') + def test_action_set_dotted_dict(self, fake_script: FakeScript): + fake_script.write('action-get', 'exit 1') + fake_script.write('action-set', 'exit 0') self.backend.action_set({'a.b': 1, 'a': {'c': 2}, 'd': 3}) - self.assertCountEqual(['action-set', 'a.b=1', 'a.c=2', 'd=3'], fake_script_calls(self)[0]) + assert sorted(['action-set', 'a.b=1', 'a.c=2', 'd=3']) == sorted(fake_script.calls()[0]) - def test_action_set_duplicated_keys(self): - fake_script(self, 'action-get', 'exit 1') - fake_script(self, 'action-set', 'exit 0') + def test_action_set_duplicated_keys(self, fake_script: FakeScript): + fake_script.write('action-get', 'exit 1') + fake_script.write('action-set', 'exit 0') with pytest.raises(ValueError): self.backend.action_set({'a.b': 1, 'a': {'b': 2}, 'd': 3}) with pytest.raises(ValueError): self.backend.action_set({'a': {'b': 1, 'c': 2, 'd': {'e': 3}}, 'f': 4, 'a.d.e': 'foo'}) - def test_action_fail(self): - fake_script(self, 'action-get', 'exit 1') - fake_script(self, 'action-fail', 'exit 0') + def test_action_fail(self, fake_script: FakeScript): + fake_script.write('action-get', 'exit 1') + fake_script.write('action-fail', 'exit 0') self.backend.action_fail('error 42') - assert fake_script_calls(self) == [['action-fail', 'error 42']] + assert fake_script.calls() == [['action-fail', 'error 42']] - def test_action_log(self): - fake_script(self, 'action-get', 'exit 1') - fake_script(self, 'action-log', 'exit 0') + def test_action_log(self, fake_script: FakeScript): + fake_script.write('action-get', 'exit 1') + fake_script.write('action-log', 'exit 0') self.backend.action_log('progress: 42%') - assert fake_script_calls(self) == [['action-log', 'progress: 42%']] + assert fake_script.calls() == [['action-log', 'progress: 42%']] - def test_application_version_set(self): - fake_script(self, 'application-version-set', 'exit 0') + def test_application_version_set(self, fake_script: FakeScript): + fake_script.write('application-version-set', 'exit 0') self.backend.application_version_set('1.2b3') - assert fake_script_calls(self) == [['application-version-set', '--', '1.2b3']] + assert fake_script.calls() == [['application-version-set', '--', '1.2b3']] - def test_application_version_set_invalid(self): - fake_script(self, 'application-version-set', 'exit 0') + def test_application_version_set_invalid(self, fake_script: FakeScript): + fake_script.write('application-version-set', 'exit 0') with pytest.raises(TypeError): self.backend.application_version_set(2) # type: ignore with pytest.raises(TypeError): self.backend.application_version_set() # type: ignore - assert fake_script_calls(self) == [] + assert fake_script.calls() == [] - def test_juju_log(self): - fake_script(self, 'juju-log', 'exit 0') + def test_juju_log(self, fake_script: FakeScript): + fake_script.write('juju-log', 'exit 0') self.backend.juju_log('WARNING', 'foo') - assert fake_script_calls(self, clear=True) == \ + assert fake_script.calls(clear=True) == \ [['juju-log', '--log-level', 'WARNING', '--', 'foo']] with pytest.raises(TypeError): self.backend.juju_log('DEBUG') # type: ignore - assert fake_script_calls(self, clear=True) == [] + assert fake_script.calls(clear=True) == [] - fake_script(self, 'juju-log', 'exit 1') + fake_script.write('juju-log', 'exit 1') with pytest.raises(ops.ModelError): self.backend.juju_log('BAR', 'foo') - assert fake_script_calls(self, clear=True) == \ + assert fake_script.calls(clear=True) == \ [['juju-log', '--log-level', 'BAR', '--', 'foo']] - def test_valid_metrics(self): - fake_script(self, 'add-metric', 'exit 0') + def test_valid_metrics(self, fake_script: FakeScript): + fake_script.write('add-metric', 'exit 0') test_cases: typing.List[_ValidMetricsTestCase] = [( OrderedDict([('foo', 42), ('b-ar', 4.5), ('ba_-z', 4.5), ('a', 1)]), OrderedDict([('de', 'ad'), ('be', 'ef_ -')]), @@ -2906,9 +2967,9 @@ def test_valid_metrics(self): )] for metrics, labels, expected_calls in test_cases: self.backend.add_metrics(metrics, labels) - assert fake_script_calls(self, clear=True) == expected_calls + assert fake_script.calls(clear=True) == expected_calls - def test_invalid_metric_names(self): + def test_invalid_metric_names(self, fake_script: FakeScript): invalid_inputs: typing.List[_MetricAndLabelPair] = [ ({'': 4.2}, {}), ({'1': 4.2}, {}), @@ -2960,70 +3021,65 @@ def test_invalid_metric_label_values(self): with pytest.raises(ops.ModelError): self.backend.add_metrics(metrics, labels) - def test_relation_remote_app_name_env(self): - self.addCleanup(os.environ.pop, 'JUJU_RELATION_ID', None) - self.addCleanup(os.environ.pop, 'JUJU_REMOTE_APP', None) - - os.environ['JUJU_RELATION_ID'] = 'x:5' - os.environ['JUJU_REMOTE_APP'] = 'remoteapp1' + def test_relation_remote_app_name_env(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv('JUJU_RELATION_ID', 'x:5') + monkeypatch.setenv('JUJU_REMOTE_APP', 'remoteapp1') assert self.backend.relation_remote_app_name(5) == 'remoteapp1' os.environ['JUJU_RELATION_ID'] = '5' assert self.backend.relation_remote_app_name(5) == 'remoteapp1' - def test_relation_remote_app_name_script_success(self): - self.addCleanup(os.environ.pop, 'JUJU_RELATION_ID', None) - self.addCleanup(os.environ.pop, 'JUJU_REMOTE_APP', None) - + def test_relation_remote_app_name_script_success( + self, fake_script: FakeScript, monkeypatch: pytest.MonkeyPatch): # JUJU_RELATION_ID and JUJU_REMOTE_APP both unset - fake_script(self, 'relation-list', r""" + fake_script.write('relation-list', r""" echo '"remoteapp2"' """) assert self.backend.relation_remote_app_name(1) == 'remoteapp2' - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['relation-list', '-r', '1', '--app', '--format=json'], ] # JUJU_RELATION_ID set but JUJU_REMOTE_APP unset - os.environ['JUJU_RELATION_ID'] = 'x:5' + monkeypatch.setenv('JUJU_RELATION_ID', 'x:5') assert self.backend.relation_remote_app_name(5) == 'remoteapp2' # JUJU_RELATION_ID unset but JUJU_REMOTE_APP set - del os.environ['JUJU_RELATION_ID'] + monkeypatch.delenv('JUJU_RELATION_ID') os.environ['JUJU_REMOTE_APP'] = 'remoteapp1' assert self.backend.relation_remote_app_name(5) == 'remoteapp2' # Both set, but JUJU_RELATION_ID a different relation - os.environ['JUJU_RELATION_ID'] = 'x:6' + monkeypatch.setenv('JUJU_RELATION_ID', 'x:6') assert self.backend.relation_remote_app_name(5) == 'remoteapp2' - def test_relation_remote_app_name_script_errors(self): - fake_script(self, 'relation-list', r""" + def test_relation_remote_app_name_script_errors(self, fake_script: FakeScript): + fake_script.write('relation-list', r""" echo "ERROR invalid value \"6\" for option -r: relation not found" >&2 # NOQA exit 2 """) assert self.backend.relation_remote_app_name(6) is None - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['relation-list', '-r', '6', '--app', '--format=json'], ] - fake_script(self, 'relation-list', r""" + fake_script.write('relation-list', r""" echo "ERROR option provided but not defined: --app" >&2 exit 2 """) assert self.backend.relation_remote_app_name(6) is None - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['relation-list', '-r', '6', '--app', '--format=json'], ] - def test_planned_units(self): + def test_planned_units(self, fake_script: FakeScript): # no units - fake_script(self, 'goal-state', """ + fake_script.write('goal-state', """ echo '{"units":{}, "relations":{}}' """) assert self.backend.planned_units() == 0 # only active units - fake_script(self, 'goal-state', """ + fake_script.write('goal-state', """ echo '{ "units":{ "app/0": {"status":"active","since":"2023-05-23 17:05:05Z"}, @@ -3034,7 +3090,7 @@ def test_planned_units(self): assert self.backend.planned_units() == 2 # active and dying units - fake_script(self, 'goal-state', """ + fake_script.write('goal-state', """ echo '{ "units":{ "app/0": {"status":"active","since":"2023-05-23 17:05:05Z"}, @@ -3045,7 +3101,7 @@ def test_planned_units(self): assert self.backend.planned_units() == 1 -class TestLazyMapping(unittest.TestCase): +class TestLazyMapping: def test_invalidate(self): loaded: typing.List[int] = [] @@ -3065,65 +3121,71 @@ def _load(self): assert loaded == [1, 1] -class TestSecrets(unittest.TestCase): - def setUp(self): - self.model = ops.Model(ops.CharmMeta(), _ModelBackend('myapp/0')) - self.app = self.model.app - self.unit = self.model.unit +class TestSecrets: + @pytest.fixture + def model(self): + return ops.Model(ops.CharmMeta(), _ModelBackend('myapp/0')) - def test_app_add_secret_simple(self): - fake_script(self, 'secret-add', 'echo secret:123') + def test_app_add_secret_simple(self, fake_script: FakeScript, model: ops.Model): + fake_script.write('secret-add', 'echo secret:123') - secret = self.app.add_secret({'foo': 'x'}) + secret = model.app.add_secret({'foo': 'x'}) assert isinstance(secret, ops.Secret) assert secret.id == 'secret:123' assert secret.label is None - assert fake_script_calls(self, clear=True) == \ - [['secret-add', '--owner', 'application', 'foo=x']] + assert fake_script.calls(clear=True) == [ + ['secret-add', '--owner', 'application', 'foo=x'] + ] - def test_app_add_secret_args(self): - fake_script(self, 'secret-add', 'echo secret:234') + def test_app_add_secret_args(self, fake_script: FakeScript, model: ops.Model): + fake_script.write('secret-add', 'echo secret:234') expire = datetime.datetime(2022, 12, 9, 16, 17, 0) - secret = self.app.add_secret({'foo': 'x', 'bar': 'y'}, label='lbl', description='desc', - expire=expire, rotate=ops.SecretRotate.HOURLY) + secret = model.app.add_secret({'foo': 'x', 'bar': 'y'}, label='lbl', description='desc', + expire=expire, rotate=ops.SecretRotate.HOURLY) assert secret.id == 'secret:234' assert secret.label == 'lbl' assert secret.get_content() == {'foo': 'x', 'bar': 'y'} - assert fake_script_calls(self, clear=True) == \ + assert fake_script.calls(clear=True) == \ [['secret-add', '--label', 'lbl', '--description', 'desc', '--expire', '2022-12-09T16:17:00', '--rotate', 'hourly', '--owner', 'application', 'foo=x', 'bar=y']] - def test_unit_add_secret_simple(self): - fake_script(self, 'secret-add', 'echo secret:345') + def test_unit_add_secret_simple(self, fake_script: FakeScript, model: ops.Model): + fake_script.write('secret-add', 'echo secret:345') - secret = self.unit.add_secret({'foo': 'x'}) + secret = model.unit.add_secret({'foo': 'x'}) assert isinstance(secret, ops.Secret) assert secret.id == 'secret:345' assert secret.label is None - assert fake_script_calls(self, clear=True) == \ - [['secret-add', '--owner', 'unit', 'foo=x']] + assert fake_script.calls(clear=True) == [ + ['secret-add', '--owner', 'unit', 'foo=x'] + ] - def test_unit_add_secret_args(self): - fake_script(self, 'secret-add', 'echo secret:456') + def test_unit_add_secret_args(self, fake_script: FakeScript, model: ops.Model): + fake_script.write('secret-add', 'echo secret:456') expire = datetime.datetime(2022, 12, 9, 16, 22, 0) - secret = self.unit.add_secret({'foo': 'w', 'bar': 'z'}, label='l2', description='xyz', - expire=expire, rotate=ops.SecretRotate.YEARLY) + secret = model.unit.add_secret( + {'foo': 'w', 'bar': 'z'}, + label='l2', + description='xyz', + expire=expire, + rotate=ops.SecretRotate.YEARLY + ) assert secret.id == 'secret:456' assert secret.label == 'l2' assert secret.get_content() == {'foo': 'w', 'bar': 'z'} - assert fake_script_calls(self, clear=True) == \ + assert fake_script.calls(clear=True) == \ [['secret-add', '--label', 'l2', '--description', 'xyz', '--expire', '2022-12-09T16:22:00', '--rotate', 'yearly', '--owner', 'unit', 'foo=w', 'bar=z']] - def test_unit_add_secret_errors(self): + def test_unit_add_secret_errors(self, model: ops.Model): # Additional add_secret tests are done in TestApplication errors: typing.Any = [ ({'xy': 'bar'}, {}, ValueError), @@ -3131,9 +3193,9 @@ def test_unit_add_secret_errors(self): ] for content, kwargs, exc_type in errors: with pytest.raises(exc_type): - self.unit.add_secret(content, **kwargs) # type: ignore + model.unit.add_secret(content, **kwargs) # type: ignore - def test_add_secret_errors(self): + def test_add_secret_errors(self, model: ops.Model): errors: typing.Any = [ # Invalid content dict or types (None, {}, TypeError), @@ -3151,82 +3213,85 @@ def test_add_secret_errors(self): ] for content, kwargs, exc_type in errors: with pytest.raises(exc_type): - self.app.add_secret(content, **kwargs) # type: ignore + model.app.add_secret(content, **kwargs) # type: ignore with pytest.raises(exc_type): - self.unit.add_secret(content, **kwargs) # type: ignore + model.unit.add_secret(content, **kwargs) # type: ignore - def test_get_secret_id(self): - fake_script(self, 'secret-get', """echo '{"foo": "g"}'""") + def test_get_secret_id(self, fake_script: FakeScript, model: ops.Model): + fake_script.write('secret-get', """echo '{"foo": "g"}'""") - secret = self.model.get_secret(id='123') + secret = model.get_secret(id='123') assert secret.id == 'secret:123' assert secret.label is None assert secret.get_content() == {'foo': 'g'} - assert fake_script_calls(self, clear=True) == \ - [['secret-get', 'secret:123', '--format=json']] + assert fake_script.calls(clear=True) == [ + ['secret-get', 'secret:123', '--format=json'] + ] - def test_get_secret_label(self): - fake_script(self, 'secret-get', """echo '{"foo": "g"}'""") + def test_get_secret_label(self, fake_script: FakeScript, model: ops.Model): + fake_script.write('secret-get', """echo '{"foo": "g"}'""") - secret = self.model.get_secret(label='lbl') + secret = model.get_secret(label='lbl') assert secret.id is None assert secret.label == 'lbl' assert secret.get_content() == {'foo': 'g'} - assert fake_script_calls(self, clear=True) == \ - [['secret-get', '--label', 'lbl', '--format=json']] + assert fake_script.calls(clear=True) == [ + ['secret-get', '--label', 'lbl', '--format=json'] + ] - def test_get_secret_id_and_label(self): - fake_script(self, 'secret-get', """echo '{"foo": "h"}'""") + def test_get_secret_id_and_label(self, fake_script: FakeScript, model: ops.Model): + fake_script.write('secret-get', """echo '{"foo": "h"}'""") - secret = self.model.get_secret(id='123', label='l') + secret = model.get_secret(id='123', label='l') assert secret.id == 'secret:123' assert secret.label == 'l' assert secret.get_content() == {'foo': 'h'} - assert fake_script_calls(self, clear=True) == \ - [['secret-get', 'secret:123', '--label', 'l', '--format=json']] + assert fake_script.calls(clear=True) == [ + ['secret-get', 'secret:123', '--label', 'l', '--format=json'] + ] - def test_get_secret_no_args(self): + def test_get_secret_no_args(self, model: ops.Model): with pytest.raises(TypeError): - self.model.get_secret() + model.get_secret() - def test_get_secret_not_found(self): + def test_get_secret_not_found(self, fake_script: FakeScript, model: ops.Model): script = """echo 'ERROR secret "123" not found' >&2; exit 1""" - fake_script(self, 'secret-get', script) + fake_script.write('secret-get', script) with pytest.raises(ops.SecretNotFoundError): - self.model.get_secret(id='123') + model.get_secret(id='123') - def test_get_secret_other_error(self): + def test_get_secret_other_error(self, fake_script: FakeScript, model: ops.Model): script = """echo 'ERROR other error' >&2; exit 1""" - fake_script(self, 'secret-get', script) + fake_script.write('secret-get', script) with pytest.raises(ops.ModelError) as excinfo: - self.model.get_secret(id='123') - self.assertNotIsInstance(excinfo.value, ops.SecretNotFoundError) + model.get_secret(id='123') + assert not isinstance(excinfo.value, ops.SecretNotFoundError) - def test_secret_unique_identifier(self): - fake_script(self, 'secret-get', """echo '{"foo": "g"}'""") + def test_secret_unique_identifier(self, fake_script: FakeScript, model: ops.Model): + fake_script.write('secret-get', """echo '{"foo": "g"}'""") - secret = self.model.get_secret(label='lbl') + secret = model.get_secret(label='lbl') assert secret.id is None assert secret.unique_identifier is None - secret = self.model.get_secret(id='123') + secret = model.get_secret(id='123') assert secret.id == 'secret:123' assert secret.unique_identifier == '123' - secret = self.model.get_secret(id='secret:124') + secret = model.get_secret(id='secret:124') assert secret.id == 'secret:124' assert secret.unique_identifier == '124' - secret = self.model.get_secret(id='secret://modeluuid/125') + secret = model.get_secret(id='secret://modeluuid/125') assert secret.id == 'secret://modeluuid/125' assert secret.unique_identifier == '125' - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['secret-get', '--label', 'lbl', '--format=json'], ['secret-get', 'secret:123', '--format=json'], ['secret-get', 'secret:124', '--format=json'], @@ -3234,7 +3299,7 @@ def test_secret_unique_identifier(self): ] -class TestSecretInfo(unittest.TestCase): +class TestSecretInfo: def test_init(self): info = ops.SecretInfo( id='3', @@ -3287,140 +3352,147 @@ def test_from_dict(self): assert info.revision == 9 -class TestSecretClass(unittest.TestCase): +class TestSecretClass: maxDiff = 64 * 1024 - def setUp(self): - self.model = ops.Model(ops.CharmMeta(), _ModelBackend('myapp/0')) - - def make_secret(self, - id: typing.Optional[str] = None, - label: typing.Optional[str] = None, - content: typing.Optional[typing.Dict[str, str]] = None): - return ops.Secret(self.model._backend, id=id, label=label, content=content) - - def test_id_and_label(self): - secret = self.make_secret(id=' abc ', label='lbl') + @pytest.fixture + def model(self): + return ops.Model(ops.CharmMeta(), _ModelBackend('myapp/0')) + + def make_secret( + self, + model: ops.Model, + id: typing.Optional[str] = None, + label: typing.Optional[str] = None, + content: typing.Optional[typing.Dict[str, str]] = None, + ) -> ops.Secret: + return ops.Secret(model._backend, id=id, label=label, content=content) + + def test_id_and_label(self, model: ops.Model): + secret = self.make_secret(model, id=' abc ', label='lbl') assert secret.id == 'secret:abc' assert secret.label == 'lbl' - secret = self.make_secret(id='x') + secret = self.make_secret(model, id='x') assert secret.id == 'secret:x' assert secret.label is None - secret = self.make_secret(label='y') + secret = self.make_secret(model, label='y') assert secret.id is None assert secret.label == 'y' - def test_get_content_cached(self): - fake_script(self, 'secret-get', """exit 1""") + def test_get_content_cached(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('secret-get', """exit 1""") - secret = self.make_secret(id='x', label='y', content={'foo': 'bar'}) + secret = self.make_secret(model, id='x', label='y', content={'foo': 'bar'}) content = secret.get_content() # will use cached content, not run secret-get assert content == {'foo': 'bar'} - assert fake_script_calls(self, clear=True) == [] + assert fake_script.calls(clear=True) == [] - def test_get_content_refresh(self): - fake_script(self, 'secret-get', """echo '{"foo": "refreshed"}'""") + def test_get_content_refresh(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('secret-get', """echo '{"foo": "refreshed"}'""") - secret = self.make_secret(id='y', content={'foo': 'bar'}) + secret = self.make_secret(model, id='y', content={'foo': 'bar'}) content = secret.get_content(refresh=True) assert content == {'foo': 'refreshed'} - assert fake_script_calls(self, clear=True) == \ - [['secret-get', 'secret:y', '--refresh', '--format=json']] + assert fake_script.calls(clear=True) == [ + ['secret-get', 'secret:y', '--refresh', '--format=json'] + ] - def test_get_content_uncached(self): - fake_script(self, 'secret-get', """echo '{"foo": "notcached"}'""") + def test_get_content_uncached(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('secret-get', """echo '{"foo": "notcached"}'""") - secret = self.make_secret(id='z') + secret = self.make_secret(model, id='z') content = secret.get_content() assert content == {'foo': 'notcached'} - assert fake_script_calls(self, clear=True) == \ - [['secret-get', 'secret:z', '--format=json']] + assert fake_script.calls(clear=True) == [ + ['secret-get', 'secret:z', '--format=json'] + ] - def test_get_content_copies_dict(self): - fake_script(self, 'secret-get', """echo '{"foo": "bar"}'""") + def test_get_content_copies_dict(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('secret-get', """echo '{"foo": "bar"}'""") - secret = self.make_secret(id='z') + secret = self.make_secret(model, id='z') content = secret.get_content() assert content == {'foo': 'bar'} content['new'] = 'value' assert secret.get_content() == {'foo': 'bar'} - assert fake_script_calls(self, clear=True) == \ - [['secret-get', 'secret:z', '--format=json']] + assert fake_script.calls(clear=True) == [ + ['secret-get', 'secret:z', '--format=json'] + ] - def test_set_content_invalidates_cache(self): - fake_script(self, 'secret-get', """echo '{"foo": "bar"}'""") - fake_script(self, 'secret-set', """exit 0""") + def test_set_content_invalidates_cache(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('secret-get', """echo '{"foo": "bar"}'""") + fake_script.write('secret-set', """exit 0""") - secret = self.make_secret(id='z') + secret = self.make_secret(model, id='z') old_content = secret.get_content() assert old_content == {'foo': 'bar'} secret.set_content({'new': 'content'}) - fake_script(self, 'secret-get', """echo '{"new": "content"}'""") + fake_script.write('secret-get', """echo '{"new": "content"}'""") new_content = secret.get_content() assert new_content == {'new': 'content'} - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['secret-get', 'secret:z', '--format=json'], ['secret-set', 'secret:z', 'new=content'], ['secret-get', 'secret:z', '--format=json'], ] - def test_peek_content(self): - fake_script(self, 'secret-get', """echo '{"foo": "peeked"}'""") + def test_peek_content(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('secret-get', """echo '{"foo": "peeked"}'""") - secret = self.make_secret(id='a', label='b') + secret = self.make_secret(model, id='a', label='b') content = secret.peek_content() assert content == {'foo': 'peeked'} - assert fake_script_calls(self, clear=True) == \ - [['secret-get', 'secret:a', '--label', 'b', '--peek', '--format=json']] + assert fake_script.calls(clear=True) == [ + ['secret-get', 'secret:a', '--label', 'b', '--peek', '--format=json'] + ] - def test_get_info(self): - fake_script(self, 'secret-info-get', """echo '{"x": {"label": "y", "revision": 7}}'""") + def test_get_info(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('secret-info-get', """echo '{"x": {"label": "y", "revision": 7}}'""") # Secret with ID only - secret = self.make_secret(id='x') + secret = self.make_secret(model, id='x') info = secret.get_info() assert info.id == 'secret:x' assert info.label == 'y' assert info.revision == 7 # Secret with label only - secret = self.make_secret(label='y') + secret = self.make_secret(model, label='y') info = secret.get_info() assert info.id == 'secret:x' assert info.label == 'y' assert info.revision == 7 # Secret with ID and label - secret = self.make_secret(id='x', label='y') + secret = self.make_secret(model, id='x', label='y') info = secret.get_info() assert info.id == 'secret:x' assert info.label == 'y' assert info.revision == 7 - assert fake_script_calls(self, clear=True) == \ - [ - ['secret-info-get', 'secret:x', '--format=json'], - ['secret-info-get', '--label', 'y', '--format=json'], - ['secret-info-get', 'secret:x', '--format=json'], + assert fake_script.calls(clear=True) == [ + ['secret-info-get', 'secret:x', '--format=json'], + ['secret-info-get', '--label', 'y', '--format=json'], + ['secret-info-get', 'secret:x', '--format=json'], ] - def test_set_content(self): - fake_script(self, 'secret-set', """exit 0""") - fake_script(self, 'secret-info-get', """echo '{"z": {"label": "y", "revision": 7}}'""") + def test_set_content(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('secret-set', """exit 0""") + fake_script.write('secret-info-get', """echo '{"z": {"label": "y", "revision": 7}}'""") - secret = self.make_secret(id='x') + secret = self.make_secret(model, id='x') secret.set_content({'foo': 'bar'}) # If secret doesn't have an ID, we'll run secret-info-get to fetch it - secret = self.make_secret(label='y') + secret = self.make_secret(model, label='y') assert secret.id is None secret.set_content({'bar': 'foo'}) assert secret.id == 'secret:z' @@ -3428,17 +3500,17 @@ def test_set_content(self): with pytest.raises(ValueError): secret.set_content({'s': 't'}) # ensure it validates content (key too short) - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['secret-set', 'secret:x', 'foo=bar'], ['secret-info-get', '--label', 'y', '--format=json'], ['secret-set', 'secret:z', 'bar=foo'], ] - def test_set_info(self): - fake_script(self, 'secret-set', """exit 0""") - fake_script(self, 'secret-info-get', """echo '{"z": {"label": "y", "revision": 7}}'""") + def test_set_info(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('secret-set', """exit 0""") + fake_script.write('secret-info-get', """echo '{"z": {"label": "y", "revision": 7}}'""") - secret = self.make_secret(id='x') + secret = self.make_secret(model, id='x') expire = datetime.datetime(2022, 12, 9, 16, 59, 0) secret.set_info( label='lab', @@ -3448,12 +3520,12 @@ def test_set_info(self): ) # If secret doesn't have an ID, we'll run secret-info-get to fetch it - secret = self.make_secret(label='y') + secret = self.make_secret(model, label='y') assert secret.id is None secret.set_info(label='lbl') assert secret.id == 'secret:z' - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['secret-set', 'secret:x', '--label', 'lab', '--description', 'desc', '--expire', '2022-12-09T16:59:00', '--rotate', 'monthly'], ['secret-info-get', '--label', 'y', '--format=json'], @@ -3463,12 +3535,12 @@ def test_set_info(self): with pytest.raises(TypeError): secret.set_info() # no args provided - def test_grant(self): - fake_script(self, 'relation-list', """echo '[]'""") - fake_script(self, 'secret-grant', """exit 0""") - fake_script(self, 'secret-info-get', """echo '{"z": {"label": "y", "revision": 7}}'""") + def test_grant(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('relation-list', """echo '[]'""") + fake_script.write('secret-grant', """exit 0""") + fake_script.write('secret-info-get', """echo '{"z": {"label": "y", "revision": 7}}'""") - secret = self.make_secret(id='x') + secret = self.make_secret(model, id='x') backend = ops.model._ModelBackend('test', 'test', 'test') meta = ops.CharmMeta() cache = ops.model._ModelCache(meta, backend) @@ -3480,13 +3552,13 @@ def test_grant(self): secret.grant(rel234, unit=unit) # If secret doesn't have an ID, we'll run secret-info-get to fetch it - secret = self.make_secret(label='y') + secret = self.make_secret(model, label='y') assert secret.id is None rel345 = ops.Relation('test', 345, True, unit, backend, cache) secret.grant(rel345) assert secret.id == 'secret:z' - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['relation-list', '-r', '123', '--format=json'], ['relation-list', '-r', '234', '--format=json'], ['secret-grant', 'secret:x', '--relation', '123'], @@ -3496,27 +3568,27 @@ def test_grant(self): ['secret-grant', 'secret:z', '--relation', '345'], ] - def test_revoke(self): - fake_script(self, 'relation-list', """echo '[]'""") - fake_script(self, 'secret-revoke', """exit 0""") - fake_script(self, 'secret-info-get', """echo '{"z": {"label": "y", "revision": 7}}'""") + def test_revoke(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('relation-list', """echo '[]'""") + fake_script.write('secret-revoke', """exit 0""") + fake_script.write('secret-info-get', """echo '{"z": {"label": "y", "revision": 7}}'""") - secret = self.make_secret(id='x') - unit = ops.Unit('test', ops.CharmMeta(), self.model._backend, self.model._cache) - rel123 = ops.Relation('test', 123, True, unit, self.model._backend, self.model._cache) - rel234 = ops.Relation('test', 234, True, unit, self.model._backend, self.model._cache) + secret = self.make_secret(model, id='x') + unit = ops.Unit('test', ops.CharmMeta(), model._backend, model._cache) + rel123 = ops.Relation('test', 123, True, unit, model._backend, model._cache) + rel234 = ops.Relation('test', 234, True, unit, model._backend, model._cache) secret.revoke(rel123) - unit = ops.Unit('app/0', ops.CharmMeta(), self.model._backend, self.model._cache) + unit = ops.Unit('app/0', ops.CharmMeta(), model._backend, model._cache) secret.revoke(rel234, unit=unit) # If secret doesn't have an ID, we'll run secret-info-get to fetch it - secret = self.make_secret(label='y') + secret = self.make_secret(model, label='y') assert secret.id is None - rel345 = ops.Relation('test', 345, True, unit, self.model._backend, self.model._cache) + rel345 = ops.Relation('test', 345, True, unit, model._backend, model._cache) secret.revoke(rel345) assert secret.id == 'secret:z' - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['relation-list', '-r', '123', '--format=json'], ['relation-list', '-r', '234', '--format=json'], ['secret-revoke', 'secret:x', '--relation', '123'], @@ -3526,102 +3598,103 @@ def test_revoke(self): ['secret-revoke', 'secret:z', '--relation', '345'], ] - def test_remove_revision(self): - fake_script(self, 'secret-remove', """exit 0""") - fake_script(self, 'secret-info-get', """echo '{"z": {"label": "y", "revision": 7}}'""") + def test_remove_revision(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('secret-remove', """exit 0""") + fake_script.write('secret-info-get', """echo '{"z": {"label": "y", "revision": 7}}'""") - secret = self.make_secret(id='x') + secret = self.make_secret(model, id='x') secret.remove_revision(123) # If secret doesn't have an ID, we'll run secret-info-get to fetch it - secret = self.make_secret(label='y') + secret = self.make_secret(model, label='y') assert secret.id is None secret.remove_revision(234) assert secret.id == 'secret:z' - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['secret-remove', 'secret:x', '--revision', '123'], ['secret-info-get', '--label', 'y', '--format=json'], ['secret-remove', 'secret:z', '--revision', '234'], ] - def test_remove_all_revisions(self): - fake_script(self, 'secret-remove', """exit 0""") - fake_script(self, 'secret-info-get', """echo '{"z": {"label": "y", "revision": 7}}'""") + def test_remove_all_revisions(self, model: ops.Model, fake_script: FakeScript): + fake_script.write('secret-remove', """exit 0""") + fake_script.write('secret-info-get', """echo '{"z": {"label": "y", "revision": 7}}'""") - secret = self.make_secret(id='x') + secret = self.make_secret(model, id='x') secret.remove_all_revisions() # If secret doesn't have an ID, we'll run secret-info-get to fetch it - secret = self.make_secret(label='y') + secret = self.make_secret(model, label='y') assert secret.id is None secret.remove_all_revisions() assert secret.id == 'secret:z' - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['secret-remove', 'secret:x'], ['secret-info-get', '--label', 'y', '--format=json'], ['secret-remove', 'secret:z'], ] -class TestPorts(unittest.TestCase): - def setUp(self): - self.model = ops.model.Model(ops.charm.CharmMeta(), ops.model._ModelBackend('myapp/0')) - self.unit = self.model.unit +class TestPorts: + @pytest.fixture + def unit(self): + model = ops.Model(ops.charm.CharmMeta(), ops.model._ModelBackend('myapp/0')) + return model.unit - def test_open_port(self): - fake_script(self, 'open-port', 'exit 0') + def test_open_port(self, fake_script: FakeScript, unit: ops.Unit): + fake_script.write('open-port', 'exit 0') - self.unit.open_port('tcp', 8080) - self.unit.open_port('UDP', 4000) # type: ignore - self.unit.open_port('icmp') + unit.open_port('tcp', 8080) + unit.open_port('UDP', 4000) # type: ignore + unit.open_port('icmp') - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['open-port', '8080/tcp'], ['open-port', '4000/udp'], ['open-port', 'icmp'], ] - def test_open_port_error(self): - fake_script(self, 'open-port', "echo 'ERROR bad protocol' >&2; exit 1") + def test_open_port_error(self, fake_script: FakeScript, unit: ops.Unit): + fake_script.write('open-port', "echo 'ERROR bad protocol' >&2; exit 1") with pytest.raises(ops.ModelError) as excinfo: - self.unit.open_port('ftp', 8080) # type: ignore + unit.open_port('ftp', 8080) # type: ignore assert str(excinfo.value) == 'ERROR bad protocol\n' - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['open-port', '8080/ftp'], ] - def test_close_port(self): - fake_script(self, 'close-port', 'exit 0') + def test_close_port(self, fake_script: FakeScript, unit: ops.Unit): + fake_script.write('close-port', 'exit 0') - self.unit.close_port('tcp', 8080) - self.unit.close_port('UDP', 4000) # type: ignore - self.unit.close_port('icmp') + unit.close_port('tcp', 8080) + unit.close_port('UDP', 4000) # type: ignore + unit.close_port('icmp') - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['close-port', '8080/tcp'], ['close-port', '4000/udp'], ['close-port', 'icmp'], ] - def test_close_port_error(self): - fake_script(self, 'close-port', "echo 'ERROR bad protocol' >&2; exit 1") + def test_close_port_error(self, fake_script: FakeScript, unit: ops.Unit): + fake_script.write('close-port', "echo 'ERROR bad protocol' >&2; exit 1") with pytest.raises(ops.ModelError) as excinfo: - self.unit.close_port('ftp', 8080) # type: ignore + unit.close_port('ftp', 8080) # type: ignore assert str(excinfo.value) == 'ERROR bad protocol\n' - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['close-port', '8080/ftp'], ] - def test_opened_ports(self): - fake_script(self, 'opened-ports', """echo 8080/tcp; echo icmp""") + def test_opened_ports(self, fake_script: FakeScript, unit: ops.Unit): + fake_script.write('opened-ports', """echo 8080/tcp; echo icmp""") - ports_set = self.unit.opened_ports() + ports_set = unit.opened_ports() assert isinstance(ports_set, set) ports = sorted(ports_set, key=lambda p: (p.protocol, p.port)) assert len(ports) == 2 @@ -3632,18 +3705,22 @@ def test_opened_ports(self): assert ports[1].protocol == 'tcp' assert ports[1].port == 8080 - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['opened-ports', ''], ] - def test_opened_ports_warnings(self): - fake_script(self, 'opened-ports', """echo 8080/tcp; echo 1234/ftp; echo 1000-2000/udp""") + def test_opened_ports_warnings( + self, + caplog: pytest.LogCaptureFixture, + fake_script: FakeScript, + unit: ops.Unit): + fake_script.write('opened-ports', """echo 8080/tcp; echo 1234/ftp; echo 1000-2000/udp""") - with self.assertLogs('ops.model', level='WARNING') as cm: - ports_set = self.unit.opened_ports() - assert len(cm.output) == 2 - assert re.search(r'WARNING:ops.model:.*protocol.*', cm.output[0]) - assert re.search(r'WARNING:ops.model:.*range.*', cm.output[1]) + with caplog.at_level(level='WARNING', logger='ops.model'): + ports_set = unit.opened_ports() + assert len(caplog.records) == 2 + assert re.search(r'.*protocol.*', caplog.records[0].message) + assert re.search(r'.*range.*', caplog.records[1].message) assert isinstance(ports_set, set) ports = sorted(ports_set, key=lambda p: (p.protocol, p.port)) @@ -3655,16 +3732,16 @@ def test_opened_ports_warnings(self): assert ports[1].protocol == 'udp' assert ports[1].port == 1000 - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['opened-ports', ''], ] - def test_set_ports_all_open(self): - fake_script(self, 'open-port', 'exit 0') - fake_script(self, 'close-port', 'exit 0') - fake_script(self, 'opened-ports', 'exit 0') - self.unit.set_ports(8000, 8025) - calls = fake_script_calls(self, clear=True) + def test_set_ports_all_open(self, fake_script: FakeScript, unit: ops.Unit): + fake_script.write('open-port', 'exit 0') + fake_script.write('close-port', 'exit 0') + fake_script.write('opened-ports', 'exit 0') + unit.set_ports(8000, 8025) + calls = fake_script.calls(clear=True) assert calls.pop(0) == ['opened-ports', ''] calls.sort() # We make no guarantee on the order the ports are opened. assert calls == [ @@ -3672,24 +3749,24 @@ def test_set_ports_all_open(self): ['open-port', '8025/tcp'], ] - def test_set_ports_mixed(self): + def test_set_ports_mixed(self, fake_script: FakeScript, unit: ops.Unit): # Two open ports, leave one alone and open another one. - fake_script(self, 'open-port', 'exit 0') - fake_script(self, 'close-port', 'exit 0') - fake_script(self, 'opened-ports', 'echo 8025/tcp; echo 8028/tcp') - self.unit.set_ports(ops.Port('udp', 8022), 8028) - assert fake_script_calls(self, clear=True) == [ + fake_script.write('open-port', 'exit 0') + fake_script.write('close-port', 'exit 0') + fake_script.write('opened-ports', 'echo 8025/tcp; echo 8028/tcp') + unit.set_ports(ops.Port('udp', 8022), 8028) + assert fake_script.calls(clear=True) == [ ['opened-ports', ''], ['close-port', '8025/tcp'], ['open-port', '8022/udp'], ] - def test_set_ports_replace(self): - fake_script(self, 'open-port', 'exit 0') - fake_script(self, 'close-port', 'exit 0') - fake_script(self, 'opened-ports', 'echo 8025/tcp; echo 8028/tcp') - self.unit.set_ports(8001, 8002) - calls = fake_script_calls(self, clear=True) + def test_set_ports_replace(self, fake_script: FakeScript, unit: ops.Unit): + fake_script.write('open-port', 'exit 0') + fake_script.write('close-port', 'exit 0') + fake_script.write('opened-ports', 'echo 8025/tcp; echo 8028/tcp') + unit.set_ports(8001, 8002) + calls = fake_script.calls(clear=True) assert calls.pop(0) == ['opened-ports', ''] calls.sort() assert calls == [ @@ -3699,50 +3776,48 @@ def test_set_ports_replace(self): ['open-port', '8002/tcp'], ] - def test_set_ports_close_all(self): - fake_script(self, 'open-port', 'exit 0') - fake_script(self, 'close-port', 'exit 0') - fake_script(self, 'opened-ports', 'echo 8022/udp') - self.unit.set_ports() - assert fake_script_calls(self, clear=True) == [ + def test_set_ports_close_all(self, fake_script: FakeScript, unit: ops.Unit): + fake_script.write('open-port', 'exit 0') + fake_script.write('close-port', 'exit 0') + fake_script.write('opened-ports', 'echo 8022/udp') + unit.set_ports() + assert fake_script.calls(clear=True) == [ ['opened-ports', ''], ['close-port', '8022/udp'], ] - def test_set_ports_noop(self): - fake_script(self, 'open-port', 'exit 0') - fake_script(self, 'close-port', 'exit 0') - fake_script(self, 'opened-ports', 'echo 8000/tcp') - self.unit.set_ports(ops.Port('tcp', 8000)) - assert fake_script_calls(self, clear=True) == [ + def test_set_ports_noop(self, fake_script: FakeScript, unit: ops.Unit): + fake_script.write('open-port', 'exit 0') + fake_script.write('close-port', 'exit 0') + fake_script.write('opened-ports', 'echo 8000/tcp') + unit.set_ports(ops.Port('tcp', 8000)) + assert fake_script.calls(clear=True) == [ ['opened-ports', ''], ] -class TestUnit(unittest.TestCase): - def setUp(self): - self.model = ops.model.Model(ops.charm.CharmMeta(), ops.model._ModelBackend('myapp/0')) - self.unit = self.model.unit - - def test_reboot(self): - fake_script(self, 'juju-reboot', 'exit 0') - self.unit.reboot() - assert fake_script_calls(self, clear=True) == [ +class TestUnit: + def test_reboot(self, fake_script: FakeScript): + model = ops.model.Model(ops.charm.CharmMeta(), ops.model._ModelBackend('myapp/0')) + unit = model.unit + fake_script.write('juju-reboot', 'exit 0') + unit.reboot() + assert fake_script.calls(clear=True) == [ ['juju-reboot', ''], ] with pytest.raises(SystemExit): - self.unit.reboot(now=True) - assert fake_script_calls(self, clear=True) == [ + unit.reboot(now=True) + assert fake_script.calls(clear=True) == [ ['juju-reboot', '--now'], ] with pytest.raises(RuntimeError): - self.model.get_unit('other').reboot() + model.get_unit('other').reboot() with pytest.raises(RuntimeError): - self.model.get_unit('other').reboot(now=True) + model.get_unit('other').reboot(now=True) -class TestLazyNotice(unittest.TestCase): +class TestLazyNotice: def test_lazy_notice(self): calls = 0 timestamp = datetime.datetime.now() @@ -3791,7 +3866,7 @@ def test_repr(self): "LazyNotice(id='123', type='foobar', key='example.com/a')" -class TestCloudCredential(unittest.TestCase): +class TestCloudCredential: def test_from_dict(self): d = { 'auth-type': 'certificate', @@ -3817,7 +3892,7 @@ def test_from_dict_full(self): assert cloud_cred.redacted == d['redacted'] -class TestCloudSpec(unittest.TestCase): +class TestCloudSpec: def test_from_dict(self): cloud_spec = ops.CloudSpec.from_dict( { @@ -3895,25 +3970,24 @@ def test_from_dict_no_credential(self): assert cloud_spec.is_controller_cloud -class TestGetCloudSpec(unittest.TestCase): - def setUp(self): - self.model = ops.Model(ops.CharmMeta(), _ModelBackend('myapp/0')) +class TestGetCloudSpec: + @pytest.fixture + def model(self): + return ops.Model(ops.CharmMeta(), _ModelBackend('myapp/0')) - def test_success(self): - fake_script(self, 'credential-get', """echo '{"type": "lxd", "name": "localhost"}'""") - cloud_spec = self.model.get_cloud_spec() + def test_success(self, fake_script: FakeScript, model: ops.Model): + fake_script.write('credential-get', """echo '{"type": "lxd", "name": "localhost"}'""") + cloud_spec = model.get_cloud_spec() assert cloud_spec.type == 'lxd' assert cloud_spec.name == 'localhost' - assert fake_script_calls(self, clear=True) == \ - [['credential-get', '--format=json']] + assert fake_script.calls(clear=True) == [['credential-get', '--format=json']] - def test_error(self): - fake_script( - self, + def test_error(self, fake_script: FakeScript, model: ops.Model): + fake_script.write( 'credential-get', """echo 'ERROR cannot access cloud credentials' >&2; exit 1""") with pytest.raises(ops.ModelError) as excinfo: - self.model.get_cloud_spec() + model.get_cloud_spec() assert str(excinfo.value) == 'ERROR cannot access cloud credentials\n' diff --git a/test/test_storage.py b/test/test_storage.py index 9d954176c..eab42c6c9 100644 --- a/test/test_storage.py +++ b/test/test_storage.py @@ -23,7 +23,7 @@ import typing import unittest import unittest.mock -from test.test_helpers import BaseTestCase, fake_script, fake_script_calls +from test.test_helpers import FakeScript from textwrap import dedent import pytest @@ -33,27 +33,49 @@ import ops.storage +@pytest.fixture +def fake_script(request: pytest.FixtureRequest): + return FakeScript(request) + + class StoragePermutations(abc.ABC): assertEqual = unittest.TestCase.assertEqual # noqa assertRaises = unittest.TestCase.assertRaises # noqa - def create_framework(self) -> ops.Framework: + def create_framework( + self, + request: pytest.FixtureRequest, + fake_script: FakeScript, + ) -> ops.Framework: """Create a Framework that we can use to test the backend storage.""" - return ops.Framework(self.create_storage(), None, None, None) # type: ignore + storage = self.create_storage(request, fake_script) + return ops.Framework(storage, None, None, None) # type: ignore @abc.abstractmethod - def create_storage(self) -> ops.storage.SQLiteStorage: + def create_storage( + self, + request: pytest.FixtureRequest, + fake_script: FakeScript, + ) -> ops.storage.SQLiteStorage: """Create a Storage backend that we can interact with.""" return NotImplemented - def test_save_and_load_snapshot(self): - f = self.create_framework() + def test_save_and_load_snapshot( + self, + request: pytest.FixtureRequest, + fake_script: FakeScript, + ): + f = self.create_framework(request, fake_script) class Sample(ops.StoredStateData): - def __init__(self, parent: ops.Object, key: str, - content: typing.Dict[str, typing.Any]): + def __init__( + self, + parent: ops.Object, + key: str, + content: typing.Dict[str, typing.Any], + ): super().__init__(parent, key) self.content = content @@ -81,8 +103,8 @@ def restore(self, snapshot: typing.Dict[str, typing.Any]): res = f.load_snapshot(handle) assert data == res.content # type: ignore - def test_emit_event(self): - f = self.create_framework() + def test_emit_event(self, request: pytest.FixtureRequest, fake_script: FakeScript): + f = self.create_framework(request, fake_script) class Evt(ops.EventBase): def __init__(self, handle: ops.Handle, content: typing.Any): @@ -125,23 +147,31 @@ def restore(self, snapshot: typing.Dict[str, typing.Any]) -> None: s.on.event.emit(None) assert s.observed_content is None - def test_save_and_overwrite_snapshot(self): - store = self.create_storage() + def test_save_and_overwrite_snapshot( + self, + request: pytest.FixtureRequest, + fake_script: FakeScript, + ): + store = self.create_storage(request, fake_script) store.save_snapshot('foo', {1: 2}) assert store.load_snapshot('foo') == {1: 2} store.save_snapshot('foo', {'three': 4}) assert store.load_snapshot('foo') == {'three': 4} - def test_drop_snapshot(self): - store = self.create_storage() + def test_drop_snapshot(self, request: pytest.FixtureRequest, fake_script: FakeScript): + store = self.create_storage(request, fake_script) store.save_snapshot('foo', {1: 2}) assert store.load_snapshot('foo') == {1: 2} store.drop_snapshot('foo') with pytest.raises(ops.storage.NoSnapshotError): store.load_snapshot('foo') - def test_save_snapshot_empty_string(self): - store = self.create_storage() + def test_save_snapshot_empty_string( + self, + request: pytest.FixtureRequest, + fake_script: FakeScript, + ): + store = self.create_storage(request, fake_script) with pytest.raises(ops.storage.NoSnapshotError): store.load_snapshot('foo') store.save_snapshot('foo', '') @@ -150,8 +180,12 @@ def test_save_snapshot_empty_string(self): with pytest.raises(ops.storage.NoSnapshotError): store.load_snapshot('foo') - def test_save_snapshot_none(self): - store = self.create_storage() + def test_save_snapshot_none( + self, + request: pytest.FixtureRequest, + fake_script: FakeScript, + ): + store = self.create_storage(request, fake_script) with pytest.raises(ops.storage.NoSnapshotError): store.load_snapshot('bar') store.save_snapshot('bar', None) @@ -160,8 +194,12 @@ def test_save_snapshot_none(self): with pytest.raises(ops.storage.NoSnapshotError): store.load_snapshot('bar') - def test_save_snapshot_zero(self): - store = self.create_storage() + def test_save_snapshot_zero( + self, + request: pytest.FixtureRequest, + fake_script: FakeScript, + ): + store = self.create_storage(request, fake_script) with pytest.raises(ops.storage.NoSnapshotError): store.load_snapshot('zero') store.save_snapshot('zero', 0) @@ -170,15 +208,14 @@ def test_save_snapshot_zero(self): with pytest.raises(ops.storage.NoSnapshotError): store.load_snapshot('zero') - def test_save_notice(self): - store = self.create_storage() + def test_save_notice(self, request: pytest.FixtureRequest, fake_script: FakeScript): + store = self.create_storage(request, fake_script) store.save_notice('event', 'observer', 'method') - assert list(store.notices('event')) == \ - [('event', 'observer', 'method')] + assert list(store.notices('event')) == [('event', 'observer', 'method')] - def test_all_notices(self): + def test_all_notices(self, request: pytest.FixtureRequest, fake_script: FakeScript): notices = [('e1', 'o1', 'm1'), ('e1', 'o2', 'm2'), ('e2', 'o3', 'm3')] - store = self.create_storage() + store = self.create_storage(request, fake_script) for notice in notices: store.save_notice(*notice) @@ -195,28 +232,36 @@ def test_all_notices(self): assert list(store.notices(None)) == notices assert list(store.notices('')) == notices - def test_load_notices(self): - store = self.create_storage() + def test_load_notices(self, request: pytest.FixtureRequest, fake_script: FakeScript): + store = self.create_storage(request, fake_script) assert list(store.notices('path')) == [] - def test_save_one_load_another_notice(self): - store = self.create_storage() + def test_save_one_load_another_notice( + self, + request: pytest.FixtureRequest, + fake_script: FakeScript, + ): + store = self.create_storage(request, fake_script) store.save_notice('event', 'observer', 'method') assert list(store.notices('other')) == [] - def test_save_load_drop_load_notices(self): - store = self.create_storage() + def test_save_load_drop_load_notices( + self, + request: pytest.FixtureRequest, + fake_script: FakeScript, + ): + store = self.create_storage(request, fake_script) store.save_notice('event', 'observer', 'method') store.save_notice('event', 'observer', 'method2') - assert list(store.notices('event')) == \ - [('event', 'observer', 'method'), - ('event', 'observer', 'method2'), - ] + assert list(store.notices('event')) == [ + ('event', 'observer', 'method'), + ('event', 'observer', 'method2') + ] -class TestSQLiteStorage(StoragePermutations, BaseTestCase): +class TestSQLiteStorage(StoragePermutations): - def create_storage(self): + def create_storage(self, request: pytest.FixtureRequest, fake_script: FakeScript): return ops.storage.SQLiteStorage(':memory:') def test_permissions_new(self): @@ -255,7 +300,7 @@ def test_permissions_failure(self, chmod: unittest.mock.MagicMock): pytest.raises(RuntimeError, ops.storage.SQLiteStorage, filename) -def setup_juju_backend(test_case: unittest.TestCase, state_file: pathlib.Path): +def setup_juju_backend(fake_script: FakeScript, state_file: pathlib.Path): """Create fake scripts for pretending to be state-set and state-get.""" template_args = { 'executable': str(pathlib.Path(sys.executable).as_posix()), @@ -263,7 +308,7 @@ def setup_juju_backend(test_case: unittest.TestCase, state_file: pathlib.Path): 'state_file': str(state_file.as_posix()), } - fake_script(test_case, 'state-set', dedent('''\ + fake_script.write('state-set', dedent('''\ {executable} -c ' import sys if "{pthpth}" not in sys.path: @@ -284,7 +329,7 @@ def setup_juju_backend(test_case: unittest.TestCase, state_file: pathlib.Path): ' "$@" ''').format(**template_args)) - fake_script(test_case, 'state-get', dedent('''\ + fake_script.write('state-get', dedent('''\ {executable} -Sc ' import sys if "{pthpth}" not in sys.path: @@ -302,7 +347,7 @@ def setup_juju_backend(test_case: unittest.TestCase, state_file: pathlib.Path): ' "$@" ''').format(**template_args)) - fake_script(test_case, 'state-delete', dedent('''\ + fake_script.write('state-delete', dedent('''\ {executable} -Sc ' import sys if "{pthpth}" not in sys.path: @@ -322,18 +367,18 @@ def setup_juju_backend(test_case: unittest.TestCase, state_file: pathlib.Path): ''').format(**template_args)) -class TestJujuStorage(StoragePermutations, BaseTestCase): +class TestJujuStorage(StoragePermutations): - def create_storage(self): + def create_storage(self, request: pytest.FixtureRequest, fake_script: FakeScript): fd, fn = tempfile.mkstemp(prefix='tmp-ops-test-state-') os.close(fd) state_file = pathlib.Path(fn) - self.addCleanup(state_file.unlink) - setup_juju_backend(self, state_file) + request.addfinalizer(state_file.unlink) + setup_juju_backend(fake_script, state_file) return ops.storage.JujuStorage() -class TestSimpleLoader(BaseTestCase): +class TestSimpleLoader: def test_is_c_loader(self): loader = ops.storage._SimpleLoader(io.StringIO('')) @@ -376,25 +421,25 @@ class Foo: self.assertRefused(f) -class TestJujuStateBackend(BaseTestCase): +class TestJujuStateBackend: def test_is_not_available(self): assert not ops.storage.juju_backend_available() - def test_is_available(self): - fake_script(self, 'state-get', 'echo ""') + def test_is_available(self, fake_script: FakeScript): + fake_script.write('state-get', 'echo ""') assert ops.storage.juju_backend_available() - assert fake_script_calls(self, clear=True) == [] + assert fake_script.calls(clear=True) == [] - def test_set_encodes_args(self): + def test_set_encodes_args(self, fake_script: FakeScript): t = tempfile.NamedTemporaryFile() try: - fake_script(self, 'state-set', dedent(""" + fake_script.write('state-set', dedent(""" cat >> {} """).format(pathlib.Path(t.name).as_posix())) backend = ops.storage._JujuStorageBackend() backend.set('key', {'foo': 2}) - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['state-set', '--file', '-'], ] t.seek(0) @@ -406,21 +451,21 @@ def test_set_encodes_args(self): {foo: 2} """) - def test_get(self): - fake_script(self, 'state-get', dedent(""" + def test_get(self, fake_script: FakeScript): + fake_script.write('state-get', dedent(""" echo 'foo: "bar"' """)) backend = ops.storage._JujuStorageBackend() value = backend.get('key') assert value == {'foo': 'bar'} - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['state-get', 'key'], ] - def test_set_and_get_complex_value(self): + def test_set_and_get_complex_value(self, fake_script: FakeScript): t = tempfile.NamedTemporaryFile() try: - fake_script(self, 'state-set', dedent(""" + fake_script.write('state-set', dedent(""" cat >> {} """).format(pathlib.Path(t.name).as_posix())) backend = ops.storage._JujuStorageBackend() @@ -433,7 +478,7 @@ def test_set_and_get_complex_value(self): 'seven': b'1234', } backend.set('Class[foo]/_stored', complex_val) - assert fake_script_calls(self, clear=True) == [ + assert fake_script.calls(clear=True) == [ ['state-set', '--file', '-'], ] t.seek(0) @@ -457,7 +502,7 @@ def test_set_and_get_complex_value(self): """) # Note that the content is yaml in a string, embedded inside YAML to declare the Key: # Value of where to store the entry. - fake_script(self, 'state-get', dedent(""" + fake_script.write('state-get', dedent(""" echo "foo: 2 3: [1, 2, '3'] four: !!set {2: null, 3: null} diff --git a/test/test_testing.py b/test/test_testing.py index 96694b58a..0488d60e8 100644 --- a/test/test_testing.py +++ b/test/test_testing.py @@ -5584,7 +5584,10 @@ def _on_simple_action(self, event: ops.ActionEvent): def _on_fail_action(self, event: ops.ActionEvent): event.fail("this will be ignored") event.log("some progress") - event.fail("something went wrong") + if event.params.get('empty-failure-message'): + event.fail() + else: + event.fail("something went wrong") event.log("more progress") event.set_results(action_results) @@ -5645,11 +5648,18 @@ def test_run_action(self): assert out.results == {} assert self.harness.charm.simple_was_called + def test_fail_action_no_message(self): + with pytest.raises(ops.testing.ActionFailed) as excinfo: + self.harness.run_action('fail', {'empty-failure-message': True}) + assert 'called `fail()`' in str(excinfo.value) + assert excinfo.value.message == '' + def test_fail_action(self): self._action_results.clear() self._action_results["partial"] = "foo" with pytest.raises(ops.testing.ActionFailed) as excinfo: self.harness.run_action("fail") + assert "something went wrong" in str(excinfo.value) assert excinfo.value.message == "something went wrong" assert excinfo.value.output.logs == ["some progress", "more progress"] assert excinfo.value.output.results == {"partial": "foo"}