Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/update-published-charms-tests-workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ def get_packages_from_charmhub():
"""Get the list of published charms from Charmhub."""
logger.info('Fetching the list of published charms')
url = 'https://charmhub.io/packages.json'
with urllib.request.urlopen(url, timeout=120) as response: # noqa: S310 (unsafe URL)
with urllib.request.urlopen(url, timeout=120) as response:
data = response.read().decode()
packages = json.loads(data)['packages']
return packages
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,12 @@ import ops
import subprocess
from typing import Generator # typing is an exception


Comment thread
tonyandrewmeyer marked this conversation as resolved.
class MyCharm(ops.CharmBase):
def handler(self, event: ops.PebbleReadyEvent):
subprocess.run(['echo', 'hello'])


# DON'T: Import objects directly
from ops import CharmBase, PebbleReadyEvent # Avoid this
```
Expand Down
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Things to note:
```python
import ops


class OpsExampleCharm(ops.CharmBase):
"""Charm the service."""

Expand All @@ -78,7 +79,7 @@ class OpsExampleCharm(ops.CharmBase):
# Get a reference the container attribute on the PebbleReadyEvent
container = event.workload
# Add initial Pebble config layer using the Pebble API
container.add_layer("httpbin", self._pebble_layer, combine=True)
container.add_layer('httpbin', self._pebble_layer, combine=True)
# Make Pebble reevaluate its plan, ensuring any services are started if enabled.
container.replan()
# Learn more about statuses at
Expand All @@ -100,7 +101,7 @@ from charm import OpsExampleCharm
def test_httpbin_pebble_ready():
# Arrange:
ctx = testing.Context(OpsExampleCharm)
container = testing.Container("httpbin", can_connect=True)
container = testing.Container('httpbin', can_connect=True)
state_in = testing.State(containers={container})

# Act:
Expand All @@ -109,19 +110,19 @@ def test_httpbin_pebble_ready():
# Assert:
updated_plan = state_out.get_container(container.name).plan
expected_plan = {
"services": {
"httpbin": {
"override": "replace",
"summary": "httpbin",
"command": "gunicorn -b 0.0.0.0:80 httpbin:app -k gevent",
"startup": "enabled",
"environment": {"GUNICORN_CMD_ARGS": "--log-level info"},
'services': {
'httpbin': {
'override': 'replace',
'summary': 'httpbin',
'command': 'gunicorn -b 0.0.0.0:80 httpbin:app -k gevent',
'startup': 'enabled',
'environment': {'GUNICORN_CMD_ARGS': '--log-level info'},
}
},
}
assert expected_plan == updated_plan
assert (
state_out.get_container(container.name).service_statuses["httpbin"]
state_out.get_container(container.name).service_statuses['httpbin']
== ops.pebble.ServiceStatus.ACTIVE
)
assert state_out.unit_status == testing.ActiveStatus()
Expand Down
11 changes: 9 additions & 2 deletions STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ An exception is names from `typing` -- type annotations get too verbose if these
from ops import CharmBase, PebbleReadyEvent
from subprocess import run


class MyCharm(CharmBase):
def _pebble_ready(self, event: PebbleReadyEvent):
run(['echo', 'foo'])
Expand All @@ -42,12 +43,15 @@ class MyCharm(CharmBase):
import ops
import subprocess


class MyCharm(ops.CharmBase):
def _pebble_ready(self, event: ops.PebbleReadyEvent):
subprocess.run(['echo', 'foo'])


# However, "from typing import Foo" is okay to avoid verbosity
from typing import Optional, Tuple

counts: Optional[Tuple[str, int]]
```

Expand Down Expand Up @@ -136,15 +140,18 @@ Function and method signatures should include a type annotation for the returned

```python
def method1(arg1: type1, arg2: type2) -> None:
...
return


def method2() -> str:
...
return 'Hello world!'


class C:
def __init__(self, x: type1):
...
pass


def test_method1():
assert ...
Expand Down
11 changes: 8 additions & 3 deletions docs/explanation/holistic-vs-delta-charms.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,13 @@ def __init__(self, framework: ops.Framework):
self.on.config_changed,
self.on['foo-relation'].relation_changed,
self.on['bar-relation'].relation_changed,
...
...,
]

for event in events:
framework.observe(event, self._reconcile)


def _reconcile(self, _: ops.EventBase):
# Early checks
workload_ready = self.workload.is_ready
Expand Down Expand Up @@ -158,31 +159,35 @@ def __init__(self, framework: ops.Framework):
self.framework.observe(self.on.start, self._on_start)

hostname = socket.getfqdn()
self.foo_requirer = FooRequirer(self, "foo-relation", address=hostname)
self.foo_requirer = FooRequirer(self, 'foo-relation', address=hostname)
self.framework.observe(
self.foo_requirer.on.data_available,
self._on_data_available,
)

self.bar_provider = BarProvider(self, "bar-relation")
self.bar_provider = BarProvider(self, 'bar-relation')
self.framework.observe(
self.bar_provider.on.create_bar,
self._on_create_bar,
)


def _on_install(self, event: ops.InstallEvent):
self.workload.install_binaries()


def _on_start(self, event: ops.StartEvent):
self.workload.start_service()
# Peer relation is now usable
if self.unit.is_leader():
...


def _on_data_available(self, event: DataAvailableEvent):
# Update the workload with event's data
self.workload.reconfigure(some_key=event.some_value)


def _on_create_bar(self, event: CreateBarEvent):
# Provision a `Bar` resource in the workload
self.workload.create_bar(event.some_field)
Expand Down
10 changes: 7 additions & 3 deletions docs/explanation/state-transition-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ class MyCharm(ops.CharmBase):
def test_status_leader(leader):
ctx = testing.Context(MyCharm, meta={'name': 'foo'})
state_out = ctx.run(ctx.on.start(), testing.State(leader=leader))
assert state_out.unit_status == testing.ActiveStatus('I rule' if leader else 'I am ruled')
assert state_out.unit_status == testing.ActiveStatus(
'I rule' if leader else 'I am ruled'
)
```

By defining the right state we can programmatically define what answers will the
Expand Down Expand Up @@ -266,7 +268,7 @@ td = tempfile.TemporaryDirectory()
ctx = testing.Context(
charm_type=MyCharmType,
meta={'name': 'my-charm-name'},
charm_root=td.name
charm_root=td.name,
)
state = ctx.run(ctx.on.start(), testing.State())
```
Expand All @@ -289,7 +291,9 @@ to manually specify all the metadata the extension adds:

ctx = testing.Context(MyFlaskCharm)
# The 'ingress' relation is provided by the flask-framework extension.
state = ctx.run(ctx.on.start(), testing.State(relations={testing.Relation('ingress')}))
state = ctx.run(
ctx.on.start(), testing.State(relations={testing.Relation('ingress')})
)
```

If your `charmcraft.yaml` defines keys that overlap with what the extension
Expand Down
3 changes: 2 additions & 1 deletion docs/howto/debug-your-charm.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,8 @@ In your charm code, call [](ops.Framework.breakpoint) to define breakpoints that
```python
class MyCharm(ops.CharmBase):
def _on_config_changed(self, event: ops.ConfigChangedEvent):
self.framework.breakpoint('config-start') # 'config-start' is an arbitrary string you use with `--at`
# 'config-start' is an arbitrary string you use with `--at`
self.framework.breakpoint('config-start')
new_val = self.config['setting']
# ... process the new value ...
self.framework.breakpoint('config-end')
Expand Down
6 changes: 4 additions & 2 deletions docs/howto/log-from-your-charm.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,19 @@ example:

```python
import logging

...
logger = logging.getLogger(__name__)


class HelloOperatorCharm(ops.CharmBase):
...

def _on_config_changed(self, _):
current = self.config["thing"]
current = self.config['thing']
if current not in self._stored.things:
# Note the use of the logger here:
logger.info("Found a new thing: %r", current)
logger.info('Found a new thing: %r', current)
self._stored.things.append(current)
```

Expand Down
48 changes: 34 additions & 14 deletions docs/howto/manage-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,23 @@ class CompressionKind(enum.Enum):
BZIP = 'bzip2'
XZ = 'xz'


class Compression(pydantic.BaseModel):
kind: CompressionKind = pydantic.Field(CompressionKind.BZIP)

quality: int = pydantic.Field(5, description='Compression quality.', ge=0, le=9)
quality: int = pydantic.Field(
5, description='Compression quality.', ge=0, le=9
)


class SnapshotAction(pydantic.BaseModel):
"""Take a snapshot of the database."""

filename: str = pydantic.Field(description="The name of the snapshot file.")
filename: str = pydantic.Field(description='The name of the snapshot file.')

compression: Compression = pydantic.Field(
default_factory=Compression,
description="The type of compression to use.",
description='The type of compression to use.',
)
```

Expand All @@ -83,11 +87,11 @@ def _on_snapshot_action(self, event: ops.ActionEvent):
"""Handle the snapshot action."""
# Fetch the parameters. If the user passes something invalid, this will
# fail the action with an appropriate message.
params = event.load_params(SnapshotAction, errors="fail")
params = event.load_params(SnapshotAction, errors='fail')
# This might take a while, so let the user know we're working on it.
# This is sent back to the Juju user in real-time, and appears in the output
# of the `juju run` command.
event.log(f"Generating snapshot into {params.filename}")
event.log(f'Generating snapshot into {params.filename}')
# Do the snapshot.
success = self.do_snapshot(
filename=params.filename,
Expand All @@ -96,13 +100,15 @@ def _on_snapshot_action(self, event: ops.ActionEvent):
)
if not success:
# Report to the user that the action has failed.
event.fail("Failed to generate snapshot.") # Ideally, include more details than this!
event.fail(
'Failed to generate snapshot.'
) # Ideally, include more details than this!
# Note that `fail()` doesn't interrupt code, so is typically followed by a `return`.
return
# Set the results of the action.
msg = f"Stored snapshot in {params.filename}."
msg = f'Stored snapshot in {params.filename}.'
# These will be displayed in the `juju run` output.
event.set_results({"result": msg})
event.set_results({'result': msg})
```

> See more: [](ops.ActionEvent.load_params), [](ops.ActionEvent.params), [](ops.ActionEvent.fail), [](ops.ActionEvent.set_results), [](ops.ActionEvent.log)
Expand All @@ -114,7 +120,11 @@ When a unique ID is needed for the action task - for example, for logging or cre
```python
def _on_snapshot(self, event: ops.ActionEvent):
temp_filename = f'backup-{event.id}.tar.gz'
logger.info("Using %s as the temporary backup filename in task %s", filename, event.id)
logger.info(
'Using %s as the temporary backup filename in task %s',
filename,
event.id,
)
self.create_backup(temp_filename)
...
```
Expand All @@ -131,10 +141,18 @@ For example:
```python
from ops import testing


def test_backup_action():
ctx = testing.Context(MyCharm)
ctx.run(ctx.on.action('snapshot', params={'filename': 'db-snapshot.tar.gz'}), testing.State())
assert ctx.action_logs == ['Starting snapshot', 'Table1 complete', 'Table2 complete']
ctx.run(
ctx.on.action('snapshot', params={'filename': 'db-snapshot.tar.gz'}),
testing.State(),
)
assert ctx.action_logs == [
'Starting snapshot',
'Table1 complete',
'Table2 complete',
]
assert 'snapshot-size' in ctx.action_results
```

Expand Down Expand Up @@ -167,7 +185,9 @@ To verify that an action works correctly against a real Juju instance, write an

```python
def test_logger(juju: jubilant.Juju):
action = juju.run("your-app/0", "snapshot", {"filename": "db-snapshot.tar.gz"})
assert action.status == "completed"
assert action.results["snapshot-size"].isdigit()
action = juju.run(
'your-app/0', 'snapshot', {'filename': 'db-snapshot.tar.gz'}
)
assert action.status == 'completed'
assert action.results['snapshot-size'].isdigit()
```
7 changes: 5 additions & 2 deletions docs/howto/manage-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class WikiConfig(pydantic.BaseModel):
def validate_name(cls, value):
if len(value) < 4:
raise ValueError('Name must be at least 4 characters long')
if " " in value:
if ' ' in value:
raise ValueError('Name must not contain spaces')
return value
```
Expand Down Expand Up @@ -99,10 +99,13 @@ To verify that the `config-changed` event validates the port, pass the new confi
```python
from ops import testing


def test_short_wiki_name():
ctx = testing.Context(MyCharm)

state_out = ctx.run(ctx.on.config_changed(), testing.State(config={'name': 'ww'}))
state_out = ctx.run(
ctx.on.config_changed(), testing.State(config={'name': 'ww'})
)

assert isinstance(state_out.unit_status, testing.BlockedStatus)
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ class MyCharm(ops.CharmBase):
def __init__(self, framework: ops.Framework):
super().__init__(framework)
self.container = self.unit.get_container('myapp')
self.myapp_root = pathops.ContainerPath('/etc/myapp', container=self.container)
self.myapp_root = pathops.ContainerPath(
'/etc/myapp', container=self.container
)
# ...
```

Expand Down
Loading
Loading