Skip to content

Commit 4d846b2

Browse files
PietroPasottibenhoyttonyandrewmeyer
authored
chore: Refactor main.py to add a new _Manager class (canonical#1085)
Contributes to ops some of the abstractions developed for Scenario. This is not a functional change, that is, ops functions in exactly the same way. Improvements: * tidier, more object-oriented. `main.py` was hard to understand and extend - (therefore) easier to play with in e.g. testing frameworks or experimental modules wishing to modify or extend its functionality - easier to maintain The core contributions are: - a `_Manager` class encapsulating the management of the ops lifecycle, from instantiation of Framework and charm instances to storage creation, event emission, and storage commit. - adding the `CharmMeta.from_charm_root` method to load a `CharmMeta` instance from the relevant `*.yaml` files --------- Co-authored-by: Ben Hoyt <[email protected]> Co-authored-by: Tony Meyer <[email protected]> Co-authored-by: Tony Meyer <[email protected]> Co-authored-by: Ben Hoyt <[email protected]>
1 parent 9ebc3f9 commit 4d846b2

File tree

5 files changed

+226
-81
lines changed

5 files changed

+226
-81
lines changed

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
* Updated Pebble Notices `get_notices` parameter name to `users=all` (previously `select=all`).
44
* Added `Model.get_cloud_spec` which uses the `credential-get` hook tool to get details of the cloud where the model is deployed.
5+
* Refactored main.py, creating a new `_Manager` class.
56

67
# 2.11.0
78

ops/charm.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1373,6 +1373,24 @@ def __init__(self, raw: Optional[Dict[str, Any]] = None,
13731373
self.containers = {name: ContainerMeta(name, container)
13741374
for name, container in raw_.get('containers', {}).items()}
13751375

1376+
@staticmethod
1377+
def from_charm_root(charm_root: Union[pathlib.Path, str]):
1378+
"""Initialise CharmMeta from the path to a charm repository root folder."""
1379+
_charm_root = pathlib.Path(charm_root)
1380+
metadata_path = _charm_root / "metadata.yaml"
1381+
1382+
with metadata_path.open() as f:
1383+
meta = yaml.safe_load(f.read())
1384+
1385+
actions = None
1386+
1387+
actions_path = _charm_root / "actions.yaml"
1388+
if actions_path.exists():
1389+
with actions_path.open() as f:
1390+
actions = yaml.safe_load(f.read())
1391+
1392+
return CharmMeta(meta, actions)
1393+
13761394
def _load_links(self, raw: Dict[str, Any]):
13771395
websites = raw.get('website', [])
13781396
if not websites and 'links' in raw:

ops/main.py

Lines changed: 160 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
This is so that :code:`import ops` followed by :code:`ops.main(MyCharm)` works
1919
as expected.
2020
"""
21-
2221
import logging
2322
import os
2423
import shutil
@@ -294,10 +293,10 @@ def run_any_legacy_hook(self):
294293
subprocess.run(argv, check=True)
295294
except subprocess.CalledProcessError as e:
296295
logger.warning("Legacy %s exited with status %d.", self._dispatch_path, e.returncode)
297-
sys.exit(e.returncode)
296+
raise _Abort(e.returncode) from e
298297
except OSError as e:
299298
logger.warning("Unable to run legacy %s: %s", self._dispatch_path, e)
300-
sys.exit(1)
299+
raise _Abort(1) from e
301300
else:
302301
logger.debug("Legacy %s exited with status 0.", self._dispatch_path)
303302

@@ -330,7 +329,7 @@ def _init_dispatch(self):
330329

331330
if 'OPERATOR_DISPATCH' in os.environ:
332331
logger.debug("Charm called itself via %s.", self._dispatch_path)
333-
sys.exit(0)
332+
raise _Abort(0)
334333
os.environ['OPERATOR_DISPATCH'] = '1'
335334

336335
self.is_dispatch_aware = True
@@ -369,97 +368,182 @@ def _should_use_controller_storage(db_path: Path, meta: CharmMeta) -> bool:
369368
return False
370369

371370

372-
def main(charm_class: Type[ops.charm.CharmBase],
373-
use_juju_for_storage: Optional[bool] = None):
374-
"""Setup the charm and dispatch the observed event.
371+
class _Abort(Exception): # noqa: N818
372+
"""Raised when something happens that should interrupt ops execution."""
375373

376-
The event name is based on the way this executable was called (argv[0]).
374+
def __init__(self, exit_code: int):
375+
super().__init__()
376+
self.exit_code = exit_code
377377

378-
Args:
379-
charm_class: the charm class to instantiate and receive the event.
380-
use_juju_for_storage: whether to use controller-side storage. If not specified
381-
then kubernetes charms that haven't previously used local storage and that
382-
are running on a new enough Juju default to controller-side storage,
383-
otherwise local storage is used.
378+
379+
class _Manager:
380+
"""Initialises the Framework and manages the lifecycle of a charm.
381+
382+
Running _Manager consists of three main steps:
383+
- setup: initialise the following from JUJU_* environment variables:
384+
- the Framework (hook tool wrappers)
385+
- the storage backend
386+
- the event that Juju is emitting on us
387+
- the charm instance (user-facing)
388+
- emit: core user-facing lifecycle step. Consists of:
389+
- re-emit any deferred events found in the storage
390+
- emit the Juju event to the charm
391+
- emit any custom events emitted by the charm during this phase
392+
- emit the ``collect-status`` events
393+
- commit: responsible for:
394+
- store any events deferred throughout this execution
395+
- graceful teardown of the storage
384396
"""
385-
charm_dir = _get_charm_dir()
386-
387-
model_backend = ops.model._ModelBackend()
388-
debug = ('JUJU_DEBUG' in os.environ)
389-
# For actions, there is a communication channel with the user running the
390-
# action, so we want to send exception details through stderr, rather than
391-
# only to juju-log as normal.
392-
handling_action = ('JUJU_ACTION_NAME' in os.environ)
393-
setup_root_logging(model_backend, debug=debug, exc_stderr=handling_action)
394-
logger.debug("ops %s up and running.", ops.__version__) # type:ignore
395-
396-
dispatcher = _Dispatcher(charm_dir)
397-
dispatcher.run_any_legacy_hook()
398-
399-
metadata = (charm_dir / 'metadata.yaml').read_text()
400-
actions_meta = charm_dir / 'actions.yaml'
401-
actions_metadata = actions_meta.read_text() if actions_meta.exists() else None
402-
403-
# If we are in a RelationBroken event, we want to know which relation is
404-
# broken within the model, not only in the event's `.relation` attribute.
405-
if os.environ.get('JUJU_DISPATCH_PATH', '').endswith('-relation-broken'):
406-
broken_relation_id = _get_juju_relation_id()
407-
else:
408-
broken_relation_id = None
409-
meta = CharmMeta.from_yaml(metadata, actions_metadata)
410-
model = ops.model.Model(meta, model_backend, broken_relation_id=broken_relation_id)
411-
412-
charm_state_path = charm_dir / CHARM_STATE_FILE
413-
414-
if use_juju_for_storage and not ops.storage.juju_backend_available():
415-
# raise an exception; the charm is broken and needs fixing.
416-
msg = 'charm set use_juju_for_storage=True, but Juju version {} does not support it'
417-
raise RuntimeError(msg.format(JujuVersion.from_environ()))
418-
419-
if use_juju_for_storage is None:
420-
use_juju_for_storage = _should_use_controller_storage(charm_state_path, meta)
421-
elif use_juju_for_storage:
422-
warnings.warn("Controller storage is deprecated; it's intended for "
423-
"podspec charms and will be removed in a future release.",
424-
category=DeprecationWarning)
425-
426-
if use_juju_for_storage:
427-
if dispatcher.is_restricted_context():
397+
398+
def __init__(
399+
self,
400+
charm_class: Type["ops.charm.CharmBase"],
401+
model_backend: Optional[ops.model._ModelBackend] = None,
402+
use_juju_for_storage: Optional[bool] = None,
403+
charm_state_path: str = CHARM_STATE_FILE
404+
):
405+
406+
self._charm_state_path = charm_state_path
407+
self._charm_class = charm_class
408+
if model_backend is None:
409+
model_backend = ops.model._ModelBackend()
410+
self._model_backend = model_backend
411+
412+
# Do this as early as possible to be sure to catch the most logs.
413+
self._setup_root_logging()
414+
415+
self._charm_root = _get_charm_dir()
416+
self._charm_meta = CharmMeta.from_charm_root(self._charm_root)
417+
self._use_juju_for_storage = use_juju_for_storage
418+
419+
# Set up dispatcher, framework and charm objects.
420+
self.dispatcher = _Dispatcher(self._charm_root)
421+
self.dispatcher.run_any_legacy_hook()
422+
423+
self.framework = self._make_framework(self.dispatcher)
424+
self.charm = self._make_charm(self.framework, self.dispatcher)
425+
426+
def _make_charm(self, framework: "ops.framework.Framework", dispatcher: _Dispatcher):
427+
charm = self._charm_class(framework)
428+
dispatcher.ensure_event_links(charm)
429+
return charm
430+
431+
def _setup_root_logging(self):
432+
debug = "JUJU_DEBUG" in os.environ
433+
# For actions, there is a communication channel with the user running the
434+
# action, so we want to send exception details through stderr, rather than
435+
# only to juju-log as normal.
436+
handling_action = 'JUJU_ACTION_NAME' in os.environ
437+
setup_root_logging(self._model_backend, debug=debug, exc_stderr=handling_action)
438+
439+
logger.debug("ops %s up and running.", ops.__version__) # type:ignore
440+
441+
def _make_storage(self, dispatcher: _Dispatcher):
442+
charm_state_path = self._charm_root / self._charm_state_path
443+
444+
use_juju_for_storage = self._use_juju_for_storage
445+
if use_juju_for_storage and not ops.storage.juju_backend_available():
446+
# raise an exception; the charm is broken and needs fixing.
447+
msg = 'charm set use_juju_for_storage=True, but Juju version {} does not support it'
448+
raise RuntimeError(msg.format(JujuVersion.from_environ()))
449+
450+
if use_juju_for_storage is None:
451+
use_juju_for_storage = _should_use_controller_storage(
452+
charm_state_path,
453+
self._charm_meta
454+
)
455+
elif use_juju_for_storage:
456+
warnings.warn("Controller storage is deprecated; it's intended for "
457+
"podspec charms and will be removed in a future release.",
458+
category=DeprecationWarning)
459+
460+
if use_juju_for_storage and dispatcher.is_restricted_context():
428461
# TODO: jam 2020-06-30 This unconditionally avoids running a collect metrics event
429462
# Though we eventually expect that Juju will run collect-metrics in a
430-
# non-restricted context. Once we can determine that we are running collect-metrics
431-
# in a non-restricted context, we should fire the event as normal.
463+
# non-restricted context. Once we can determine that we are running
464+
# collect-metrics in a non-restricted context, we should fire the event as normal.
432465
logger.debug('"%s" is not supported when using Juju for storage\n'
433466
'see: https://github.com/canonical/operator/issues/348',
434467
dispatcher.event_name)
435468
# Note that we don't exit nonzero, because that would cause Juju to rerun the hook
436-
return
437-
store = ops.storage.JujuStorage()
438-
else:
439-
store = ops.storage.SQLiteStorage(charm_state_path)
440-
framework = ops.framework.Framework(store, charm_dir, meta, model,
441-
event_name=dispatcher.event_name)
442-
framework.set_breakpointhook()
443-
try:
444-
charm = charm_class(framework)
445-
dispatcher.ensure_event_links(charm)
469+
raise _Abort(0)
446470

471+
if self._use_juju_for_storage:
472+
store = ops.storage.JujuStorage()
473+
else:
474+
store = ops.storage.SQLiteStorage(charm_state_path)
475+
return store
476+
477+
def _make_framework(
478+
self,
479+
dispatcher: _Dispatcher
480+
):
481+
# If we are in a RelationBroken event, we want to know which relation is
482+
# broken within the model, not only in the event's `.relation` attribute.
483+
if os.environ.get('JUJU_DISPATCH_PATH', '').endswith('-relation-broken'):
484+
broken_relation_id = _get_juju_relation_id()
485+
else:
486+
broken_relation_id = None
487+
488+
model = ops.model.Model(self._charm_meta, self._model_backend,
489+
broken_relation_id=broken_relation_id)
490+
store = self._make_storage(dispatcher)
491+
framework = ops.framework.Framework(store, self._charm_root, self._charm_meta, model,
492+
event_name=dispatcher.event_name)
493+
framework.set_breakpointhook()
494+
return framework
495+
496+
def _emit(self):
497+
"""Emit the event on the charm."""
447498
# TODO: Remove the collect_metrics check below as soon as the relevant
448499
# Juju changes are made. Also adjust the docstring on
449500
# EventBase.defer().
450501
#
451502
# Skip reemission of deferred events for collect-metrics events because
452503
# they do not have the full access to all hook tools.
453-
if not dispatcher.is_restricted_context():
454-
framework.reemit()
504+
if not self.dispatcher.is_restricted_context():
505+
# Re-emit any deferred events from the previous run.
506+
self.framework.reemit()
507+
508+
# Emit the Juju event.
509+
_emit_charm_event(self.charm, self.dispatcher.event_name)
510+
# Emit collect-status events.
511+
ops.charm._evaluate_status(self.charm)
455512

456-
_emit_charm_event(charm, dispatcher.event_name)
513+
def _commit(self):
514+
"""Commit the framework and gracefully teardown."""
515+
self.framework.commit()
457516

458-
ops.charm._evaluate_status(charm)
517+
def run(self):
518+
"""Emit and then commit the framework."""
519+
try:
520+
self._emit()
521+
self._commit()
522+
finally:
523+
self.framework.close()
524+
525+
526+
def main(charm_class: Type[ops.charm.CharmBase],
527+
use_juju_for_storage: Optional[bool] = None):
528+
"""Set up the charm and dispatch the observed event.
529+
530+
The event name is based on the way this executable was called (argv[0]).
531+
532+
Args:
533+
charm_class: the charm class to instantiate and receive the event.
534+
use_juju_for_storage: whether to use controller-side storage. If not specified
535+
then Kubernetes charms that haven't previously used local storage and that
536+
are running on a new enough Juju default to controller-side storage,
537+
otherwise local storage is used.
538+
"""
539+
try:
540+
manager = _Manager(
541+
charm_class,
542+
use_juju_for_storage=use_juju_for_storage)
459543

460-
framework.commit()
461-
finally:
462-
framework.close()
544+
manager.run()
545+
except _Abort as e:
546+
sys.exit(e.exit_code)
463547

464548

465549
# Make this module callable and call main(), so that "import ops" and then

test/test_charm.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@
1313
# limitations under the License.
1414
import functools
1515
import os
16+
import pathlib
1617
import shutil
1718
import tempfile
1819
import typing
1920
import unittest
2021
from pathlib import Path
2122

23+
import yaml
24+
2225
import ops
2326
import ops.charm
2427
from ops.model import ModelError, _ModelBackend
@@ -473,6 +476,45 @@ def _get_action_test_meta(cls):
473476
additionalProperties: false
474477
''')
475478

479+
def test_meta_from_charm_root(self):
480+
with tempfile.TemporaryDirectory() as d:
481+
td = pathlib.Path(d)
482+
(td / 'metadata.yaml').write_text(
483+
yaml.safe_dump(
484+
{"name": "bob",
485+
"requires": {
486+
"foo":
487+
{"interface": "bar"}
488+
}}))
489+
meta = ops.CharmMeta.from_charm_root(td)
490+
self.assertEqual(meta.name, "bob")
491+
self.assertEqual(meta.requires['foo'].interface_name, "bar")
492+
493+
def test_actions_from_charm_root(self):
494+
with tempfile.TemporaryDirectory() as d:
495+
td = pathlib.Path(d)
496+
(td / 'actions.yaml').write_text(
497+
yaml.safe_dump(
498+
{"foo": {
499+
"description": "foos the bar",
500+
"additionalProperties": False
501+
}}
502+
)
503+
)
504+
(td / 'metadata.yaml').write_text(
505+
yaml.safe_dump(
506+
{"name": "bob",
507+
"requires": {
508+
"foo":
509+
{"interface": "bar"}
510+
}}))
511+
512+
meta = ops.CharmMeta.from_charm_root(td)
513+
self.assertEqual(meta.name, "bob")
514+
self.assertEqual(meta.requires['foo'].interface_name, "bar")
515+
self.assertFalse(meta.actions['foo'].additional_properties)
516+
self.assertEqual(meta.actions['foo'].description, "foos the bar")
517+
476518
def _setup_test_action(self):
477519
fake_script(self, 'action-get', """echo '{"foo-name": "name", "silent": true}'""")
478520
fake_script(self, 'action-set', "")

0 commit comments

Comments
 (0)