From 16391a38d75c338ab69337b8166f51e00f01f940 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 1 Nov 2024 15:03:14 +0100 Subject: [PATCH 1/3] eval hook base impl --- ops/_main.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ops/_main.py b/ops/_main.py index 07adc041c..faa06e3ee 100644 --- a/ops/_main.py +++ b/ops/_main.py @@ -20,6 +20,7 @@ import subprocess import sys import warnings +from optparse import Option from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Type, Union, cast @@ -523,15 +524,34 @@ def _commit(self): """Commit the framework and gracefully teardown.""" self.framework.commit() + def _eval(self, expr: str): + """Eval an expression in the context of the charm and print the result to stdout.""" + import json + try: + print(eval(expr, __globals={ + "self": self.charm, + "ops": ops, + "json": json + })) + except Exception: + logger.exception("failure evaluating expression") + return + def run(self): """Emit and then commit the framework.""" try: + if eval_expr := _is_eval_set(): + self._eval(eval_expr) self._emit() self._commit() finally: self.framework.close() +def _is_eval_set() -> Optional[str]: + return os.getenv("CHARM_EVAL_EXPR") + + def main(charm_class: Type[ops.charm.CharmBase], use_juju_for_storage: Optional[bool] = None): """Set up the charm and dispatch the observed event. From a73b3afe057cd49b191f3fd4a36a42fa257ca6aa Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 1 Nov 2024 20:37:11 +0100 Subject: [PATCH 2/3] eval hook test --- ops/_main.py | 26 ++++++++++++++------- test/test_main.py | 58 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/ops/_main.py b/ops/_main.py index faa06e3ee..38c9eb220 100644 --- a/ops/_main.py +++ b/ops/_main.py @@ -33,6 +33,7 @@ from ops.log import setup_root_logging CHARM_STATE_FILE = '.unit-state.db' +CHARM_EVAL_EXPR_ENVVAR = 'CHARM_EVAL_EXPR' logger = logging.getLogger() @@ -527,21 +528,24 @@ def _commit(self): def _eval(self, expr: str): """Eval an expression in the context of the charm and print the result to stdout.""" import json + + globs = {'self': self.charm, 'ops': ops, 'json': json} try: - print(eval(expr, __globals={ - "self": self.charm, - "ops": ops, - "json": json - })) + globs = {'self': self.charm, 'ops': ops, 'json': json} + out = eval(expr, globs) except Exception: - logger.exception("failure evaluating expression") + logger.exception( + f'failure evaluating expression {expr!r} ' f'given available globs ({globs})' + ) return + logger.debug(f'expression {expr!r} evaluated to {out!r}') + print(out) def run(self): """Emit and then commit the framework.""" try: if eval_expr := _is_eval_set(): - self._eval(eval_expr) + return self._eval(eval_expr) self._emit() self._commit() finally: @@ -549,7 +553,13 @@ def run(self): def _is_eval_set() -> Optional[str]: - return os.getenv("CHARM_EVAL_EXPR") + """Return the value of the `CHARM_EVAL_EXPR_ENVVAR` envvar, if set. + + This allows the admin to run, for example, + `CHARM_EVAL_EXPR="self.foo()" juju exec mycharm/0` + and see the result printed to stdout. + """ + return os.getenv(CHARM_EVAL_EXPR_ENVVAR) def main(charm_class: Type[ops.charm.CharmBase], use_juju_for_storage: Optional[bool] = None): diff --git a/test/test_main.py b/test/test_main.py index 2ce616268..1c2c02fe4 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -15,6 +15,7 @@ import abc import importlib.util import io +import json import logging import os import re @@ -30,10 +31,9 @@ import pytest import ops -from ops._main import _should_use_controller_storage +from ops._main import _should_use_controller_storage, CHARM_EVAL_EXPR_ENVVAR from ops.jujucontext import _JujuContext from ops.storage import SQLiteStorage - from .charms.test_main.src.charm import MyCharmEvents from .test_helpers import FakeScript @@ -1491,3 +1491,57 @@ def test_not_if_already_local(self): with patch.dict(os.environ, {'JUJU_VERSION': '2.8'}), tempfile.NamedTemporaryFile() as fd: juju_context = _JujuContext.from_dict(os.environ) assert not _should_use_controller_storage(Path(fd.name), meta, juju_context) + + +class TestCharmEval: + def _check( + 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 = { + 'JUJU_UNIT_NAME': 'test_main/0', + 'JUJU_MODEL_NAME': 'mymodel', + 'JUJU_VERSION': '2.7.0', + } + if extra_environ is not None: + fake_environ.update(extra_environ) + + with tempfile.TemporaryDirectory() as tmpdirname: + fake_environ.update({'JUJU_CHARM_DIR': tmpdirname}) + with patch.dict(os.environ, fake_environ): + tmpdirname = Path(tmpdirname) + fake_metadata = tmpdirname / 'metadata.yaml' + with fake_metadata.open('wb') as fh: + fh.write(b'name: test') + + ops.main(charm_class, **kwargs) + + def test_eval_charm_stmt(self, fake_script: FakeScript): + fake_script.write('juju-log', 'exit 0') + + for expr, expected_result in ( + ('type(self).__name__', 'CharmBase'), + ('ops.StatusBase.__name__', 'StatusBase'), + ('json.dumps({1:2})', json.dumps({1:2})), + ('type(self.framework).__name__', 'Framework'), + ('2 + 2', 4), + ): + with patch.dict(os.environ, {CHARM_EVAL_EXPR_ENVVAR: expr}): + self._check(ops.CharmBase) + + expected = [ + 'juju-log', + '--log-level', + 'DEBUG', + '--', + f"expression {expr!r} evaluated to {expected_result!r}", + ] + + + calls = fake_script.calls() + assert expected in calls From d383bbee176b3a5b1c035251f8d06b1cdd1b39af Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 1 Nov 2024 20:38:18 +0100 Subject: [PATCH 3/3] fmt --- test/test_main.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/test/test_main.py b/test/test_main.py index 1c2c02fe4..f2abf2fe7 100644 --- a/test/test_main.py +++ b/test/test_main.py @@ -1527,7 +1527,7 @@ def test_eval_charm_stmt(self, fake_script: FakeScript): for expr, expected_result in ( ('type(self).__name__', 'CharmBase'), ('ops.StatusBase.__name__', 'StatusBase'), - ('json.dumps({1:2})', json.dumps({1:2})), + ('json.dumps({1:2})', json.dumps({1: 2})), ('type(self.framework).__name__', 'Framework'), ('2 + 2', 4), ): @@ -1535,13 +1535,12 @@ def test_eval_charm_stmt(self, fake_script: FakeScript): self._check(ops.CharmBase) expected = [ - 'juju-log', - '--log-level', - 'DEBUG', - '--', - f"expression {expr!r} evaluated to {expected_result!r}", - ] - + 'juju-log', + '--log-level', + 'DEBUG', + '--', + f'expression {expr!r} evaluated to {expected_result!r}', + ] calls = fake_script.calls() assert expected in calls