diff --git a/ops/_main.py b/ops/_main.py index 07adc041c..38c9eb220 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 @@ -32,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() @@ -523,15 +525,43 @@ 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 + + globs = {'self': self.charm, 'ops': ops, 'json': json} + try: + globs = {'self': self.charm, 'ops': ops, 'json': json} + out = eval(expr, globs) + except Exception: + 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(): + return self._eval(eval_expr) self._emit() self._commit() finally: self.framework.close() +def _is_eval_set() -> Optional[str]: + """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): """Set up the charm and dispatch the observed event. diff --git a/test/test_main.py b/test/test_main.py index 2ce616268..f2abf2fe7 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,56 @@ 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